/** * The handoff: what `team assign` tells the peer, and how it tells it. * * The peer is a session that is already running, so this is a message and not a * process: `session send` writes it into the recipient's inbox, and its armed watch is * what raises the turn. Nothing here starts anything, and a send that fails leaves the * card exactly as it was — the order is deliberate, because a card assigned without a * message is a card nobody knows about. * * The transport is a seam (`Dispatch`), so the tool's ordering is testable without a * bus, and the one production implementation is `lib/session-tool.ts`'s. */ import type { ExtensionToolContext } from "@earendil-works/pi-coding-agent"; import type { Card } from "../../lib/board/card.ts"; import { cardRef, type CardOwner } from "../../lib/board/card.ts"; import { listPeers, MAX_MESSAGE_TEXT, type Peer, send as sendTo } from "../../lib/session-tool.ts"; /** How a handoff reaches a peer, and what the bus can say about the peers there are. */ export interface Dispatch { /** Send the text as `kind`, and report the labels a message file was written for. Throws on failure. */ send(input: { kind: "handoff" | "event"; to: string; text: string }): Promise<{ delivered: string[] }>; /** * Every session the bus lists, when it can say. * * Its record is what makes an assignment survive a rename — `holds` compares ids * before labels — and what makes the report able to name a peer that cannot be * woken. It never throws: a resolve that fails must not undo a handoff that landed. */ peers(): Promise; } /** * What the peer reads. * * It names the card, its outcome, and the paths, and says what to do next: record the * plan, work, note, finish. It does **not** ask the peer to claim the card — `assign` * has already recorded the owner, and a claim by the holder is a no-op. The claim is * only for the failure path where the send landed and the assignment did not. */ export function handoffText(card: Card, to: string, note?: string): string { const lines = [ `Handoff: ${cardRef(card.id)} "${card.title}" is yours, ${to}.`, card.paths.length > 0 ? `Paths: ${card.paths.join(", ")}` : undefined, card.plan === undefined ? undefined : `Plan: ${card.plan}`, note === undefined ? undefined : `Note: ${note}`, `It is already assigned to you — record the plan with board action "plan" if it has none, then note each phase and move it to done or blocked.`, ].filter((line): line is string => line !== undefined); return withinLimit(lines.join("\n")); } /** * What every live session reads when the lead offers a card instead of assigning it. * * An offer names no owner: the first peer to claim the card takes it, so the text says * what the card is and that claiming is the next step, and it never implies the work is * already somebody's. */ export function offerText(card: Card, note?: string): string { const lines = [ `Offer: ${cardRef(card.id)} "${card.title}" is unclaimed.`, card.paths.length > 0 ? `Paths: ${card.paths.join(", ")}` : undefined, card.plan === undefined ? undefined : `Plan: ${card.plan}`, note === undefined ? undefined : `Note: ${note}`, `First to claim it takes it: run board action "claim" with card "${card.id}", then note each phase and move it to done or blocked.`, ].filter((line): line is string => line !== undefined); return withinLimit(lines.join("\n")); } /** * Trim to the bus's text limit, which is also this message's limit. * * Not `lib/tui/columns.ts`'s `clip`: that one measures terminal columns, and this one * counts characters, because the bus's limit is characters. The number arrives from * `lib/session-tool.ts` — one copy for the two extensions that send through it. */ function withinLimit(text: string): string { return text.length > MAX_MESSAGE_TEXT ? `${text.slice(0, MAX_MESSAGE_TEXT - 1)}…` : text; } /** The production dispatch: `lib/session-tool.ts`, with a failure turned into a throw. */ export function sessionDispatch(ctx: ExtensionToolContext): Dispatch { return { async send(input) { const result = await sendTo(ctx, input); // `assign` and `offer` branch on a failed send, so this seam reports it the way // they read it: the bus's sentence, as the reason the card was not handed over. if (!result.ok) throw new Error(result.error); return { delivered: result.delivered }; }, peers: () => listPeers(ctx), }; } /** * The peer `to` names: an id exactly, else a label ignoring case. * * The send has already proved exactly one session answers to it, so the first match is * the recipient; a miss means the bus could not say, which is why the caller falls back * to the label alone. */ export function peerFor(peers: readonly Peer[] | undefined, to: string): Peer | undefined { if (peers === undefined) return undefined; const target = to.trim(); const byId = peers.find((peer) => peer.id === target); if (byId) return byId; const wanted = target.toLowerCase(); return peers.find((peer) => peer.label.toLowerCase() === wanted); } /** * The labels a message reached that cannot be woken: their record says they are not * listening. * * A label the bus could not answer for is left out — "the record does not say" is not * "nobody will read it" — and so is a peer the bus lists as listening. */ export function notListening(peers: readonly Peer[] | undefined, labels: readonly string[]): string[] { if (peers === undefined) return []; return labels.filter((label) => { const peer = peerFor(peers, label); return peer !== undefined && peer.listening === false; }); } /** * The peer a handoff names, as the card records it when the bus cannot say who that * is: the label a message file was written for, else the one that was asked for. */ export function ownerFor(to: string, delivered: readonly string[]): CardOwner { const label = delivered[0] ?? to; return { label }; }