diff --git a/.lane/memory/packages/channel/src/channel.ts/01M4XQKND0900E94CG3BXBEJP7-a-run-s-end-is-read-from-the.md b/.lane/memory/packages/channel/src/channel.ts/01M4XQKND0900E94CG3BXBEJP7-a-run-s-end-is-read-from-the.md index 524f8381..184fc6e8 100644 --- a/.lane/memory/packages/channel/src/channel.ts/01M4XQKND0900E94CG3BXBEJP7-a-run-s-end-is-read-from-the.md +++ b/.lane/memory/packages/channel/src/channel.ts/01M4XQKND0900E94CG3BXBEJP7-a-run-s-end-is-read-from-the.md @@ -4,9 +4,9 @@ anchor: class Channel created: 2026-10-14T17:33:22Z norm: '1' sig: c905677fa4be33c1 -body_hash: 88e62019da1fc79b -raw_hash: 315da55c847da4f0 -vouched: 2026-10-15T03:39:53Z +body_hash: 09f898fbf4a38066 +raw_hash: b380906d2c768505 +vouched: 2026-10-15T10:17:59Z lines: 114-792 --- diff --git a/.lane/memory/packages/channel/src/channel.ts/01M4YFFTK7TWGKENJAD1W10HJJ-the-desktop-default-changes.md b/.lane/memory/packages/channel/src/channel.ts/01M4YFFTK7TWGKENJAD1W10HJJ-the-desktop-default-changes.md index 94f943c0..919c889a 100644 --- a/.lane/memory/packages/channel/src/channel.ts/01M4YFFTK7TWGKENJAD1W10HJJ-the-desktop-default-changes.md +++ b/.lane/memory/packages/channel/src/channel.ts/01M4YFFTK7TWGKENJAD1W10HJJ-the-desktop-default-changes.md @@ -4,9 +4,9 @@ anchor: class Channel created: 2026-10-15T00:30:42Z norm: '1' sig: c905677fa4be33c1 -body_hash: 88e62019da1fc79b -raw_hash: 315da55c847da4f0 -vouched: 2026-10-15T03:39:53Z +body_hash: 09f898fbf4a38066 +raw_hash: b380906d2c768505 +vouched: 2026-10-15T10:18:00Z lines: 114-792 --- diff --git a/.lane/memory/packages/channel/src/channel.ts/01M4YPJQB6KKEW1YTRD08YCYJY-only-gateways-admit-clients.md b/.lane/memory/packages/channel/src/channel.ts/01M4YPJQB6KKEW1YTRD08YCYJY-only-gateways-admit-clients.md index 3a0e40cc..1c2d3717 100644 --- a/.lane/memory/packages/channel/src/channel.ts/01M4YPJQB6KKEW1YTRD08YCYJY-only-gateways-admit-clients.md +++ b/.lane/memory/packages/channel/src/channel.ts/01M4YPJQB6KKEW1YTRD08YCYJY-only-gateways-admit-clients.md @@ -4,9 +4,9 @@ anchor: class Channel created: 2026-10-15T02:34:37Z norm: '1' sig: c905677fa4be33c1 -body_hash: 88e62019da1fc79b -raw_hash: 315da55c847da4f0 -vouched: 2026-10-15T03:39:53Z +body_hash: 09f898fbf4a38066 +raw_hash: b380906d2c768505 +vouched: 2026-10-15T10:18:00Z lines: 114-794 --- diff --git a/.lane/memory/packages/channel/src/channel.ts/01M4ZH31DXZK0Y57C97DP84ZKX-handoffs-learns-queued-hando.md b/.lane/memory/packages/channel/src/channel.ts/01M4ZH31DXZK0Y57C97DP84ZKX-handoffs-learns-queued-hando.md new file mode 100644 index 00000000..0e13426e --- /dev/null +++ b/.lane/memory/packages/channel/src/channel.ts/01M4ZH31DXZK0Y57C97DP84ZKX-handoffs-learns-queued-hando.md @@ -0,0 +1,12 @@ +--- +id: 01M4ZH31DXZK0Y57C97DP84ZKX +anchor: class Channel +created: 2026-10-15T10:17:55Z +norm: '1' +sig: c905677fa4be33c1 +body_hash: 09f898fbf4a38066 +raw_hash: b380906d2c768505 +lines: 121-817 +--- + +Handoffs learns queued handoffs from InboxDoc commits, because a tool cannot watch an inbox document that does not exist yet diff --git a/.lane/memory/packages/channel/src/handoffs.ts/01M4ZH31DGT06W5HNPXCG38Q8H-only-shell-commands-are-stop.md b/.lane/memory/packages/channel/src/handoffs.ts/01M4ZH31DGT06W5HNPXCG38Q8H-only-shell-commands-are-stop.md new file mode 100644 index 00000000..4084a6dc --- /dev/null +++ b/.lane/memory/packages/channel/src/handoffs.ts/01M4ZH31DGT06W5HNPXCG38Q8H-only-shell-commands-are-stop.md @@ -0,0 +1,12 @@ +--- +id: 01M4ZH31DGT06W5HNPXCG38Q8H +anchor: class Handoffs +created: 2026-10-15T10:17:55Z +norm: '1' +sig: bd2a50becd135ca9 +body_hash: 2e87e02082e0f7db +raw_hash: e6d87c007ad776dd +lines: 28-73 +--- + +only shell commands are stopped, and only for agent@ inputs queued in pi's inbox; the stopped call returns a result instead of aborting the task, so the steer joins the same run and no tool call reruns diff --git a/apps/app/src/timeline.ts b/apps/app/src/timeline.ts index ba75ee63..725c6625 100644 --- a/apps/app/src/timeline.ts +++ b/apps/app/src/timeline.ts @@ -133,6 +133,9 @@ export function toEvents( text(`The channel restarted while ${call.name} ran, so it may have partially run.`), ); } + if (call.result?.handoff) { + content.push(text(`Stopped ${call.name} for a message from another channel.`)); + } } if (item.error) content.push(text(`Run failed: ${item.error}`)); if (item.stopped) content.push(text("Stopped.")); diff --git a/apps/app/src/transcript.ts b/apps/app/src/transcript.ts index c9f6a203..ca785237 100644 --- a/apps/app/src/transcript.ts +++ b/apps/app/src/transcript.ts @@ -10,7 +10,13 @@ export type Tool = { call: string; name: string; args: unknown; - result?: { error: boolean; text: string; images?: Image[]; interrupted?: boolean }; + result?: { + error: boolean; + text: string; + images?: Image[]; + interrupted?: boolean; + handoff?: boolean; + }; /** The running call's live output tail, until its result replaces it. */ progress?: { text: string; truncated?: boolean }; }; @@ -145,6 +151,7 @@ export function apply(state: Transcript, event: Event): Transcript { text: event.text, ...(event.images ? { images: event.images } : {}), ...(event.interrupted ? { interrupted: true } : {}), + ...(event.handoff ? { handoff: true } : {}), }, } : tool diff --git a/docs/architecture.md b/docs/architecture.md index bd2f7286..1d1382be 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -87,6 +87,12 @@ is one pi conversation in that Session. committed changes, including passive human writes but excluding internal writes. The app shows these messages above the composer until pi consumes or withdraws them; reconnect reads pi again. There is no additional queue store or change to admission and steering behavior. +- **Handoffs stop shell commands.** While another channel's agent's invoked message waits in a + chat's inbox, that chat's shell commands stop, and new ones don't start, each with a result + naming the sender. Pi then places the message as a steer after the tool round, so the run + continues with it and reruns nothing. Other tools may be mid-change and are left to finish; a + person's messages never interrupt, since they have Stop. Agents wait for another channel by + ending their run, not by polling in the shell. - **Tool progress is live only.** Watches that opt in receive `progress` events: the bounded tail of each running call's output from pi's live tool slots, at most every half second per watch, each replacing the last. Attachment rebuilds them from pi's snapshot, and the call's durable diff --git a/packages/channel/src/agents.ts b/packages/channel/src/agents.ts index c692cf32..33085e74 100644 --- a/packages/channel/src/agents.ts +++ b/packages/channel/src/agents.ts @@ -115,7 +115,7 @@ export function messaging(directory: Directory): Extension { defineTool({ name: "message", description: - "Post a message to another channel's chat. Set invoke to start that chat's agent on it; otherwise people and the agent there only see it. Delivery does not wait for an answer.", + "Post a message to another channel's chat. Set invoke to start that chat's agent on it, which also stops any shell command that agent is running so it reads your message next; otherwise people and the agent there only see it. Delivery does not wait for an answer: to get one, ask that agent to message this channel with invoke set when it is ready, say in your chat what you are waiting for, and end your run.", parameters: Type.Object({ channel: Type.String({ description: "Channel id or unique name; use the id when names repeat", diff --git a/packages/channel/src/channel.ts b/packages/channel/src/channel.ts index 6b45207e..a9d3f33e 100644 --- a/packages/channel/src/channel.ts +++ b/packages/channel/src/channel.ts @@ -14,6 +14,7 @@ import { type EntryRecord, Harness, InboxDoc, + type InboxState, LiveDoc, type LiveState, ROOT_CONVERSATION_ID, @@ -44,6 +45,7 @@ import { import { instructions } from "./context"; import { changes, patch } from "./changes"; import { type Desktop, desktop } from "./desktop"; +import { HANDOFF, Handoffs } from "./handoffs"; import { type Images, images } from "./images"; import { lanes, LanesDoc } from "./lanes"; import { failure, type Log, logging, scoped } from "./log"; @@ -154,6 +156,8 @@ export class Channel { const registry = createRegistry(); registry.install(logging(log)); registry.install(CodingTools); + const handoffs = new Handoffs(); + registry.install(handoffs.extension); const execute = options.desktop; if (execute) { registry.install(desktop(async (request, context) => { @@ -224,6 +228,13 @@ export class Channel { channel.#broadcast(); continue; } + if (change.record.kind === InboxDoc.definition.kind) { + const { scope } = change.record; + if (scope.kind === "conversation") { + handoffs.inbox(scope.conversationId, change.value as InboxState | null); + } + continue; + } if (change.record.kind !== LiveDoc.definition.kind) continue; const { scope } = change.record; if (scope.kind !== "conversation") continue; @@ -909,6 +920,7 @@ function events(entry: EntryRecord): Event[] { const stopped = codes.includes("aborted"); // pi reports this only when recovery finds an unsafe call that a previous worker started. const interrupted = codes.includes("interrupted"); + const handoff = codes.includes(HANDOFF); const images = room.images(message); return [{ kind: "result", @@ -919,6 +931,7 @@ function events(entry: EntryRecord): Event[] { ...(images ? { images } : {}), ...(stopped ? { stopped: true } : {}), ...(interrupted ? { interrupted: true } : {}), + ...(handoff ? { handoff: true } : {}), }]; } return []; diff --git a/packages/channel/src/handoffs.ts b/packages/channel/src/handoffs.ts new file mode 100644 index 00000000..7a1f1874 --- /dev/null +++ b/packages/channel/src/handoffs.ts @@ -0,0 +1,73 @@ +import { withCancel } from "@earendil-works/chord/context"; +import { + type ConversationId, + defineExtension, + type Extension, + type InboxState, + type ToolExecutionResult, + wrapTool, +} from "@earendil-works/pi-durable"; +import { createBashTool } from "@earendil-works/pi-durable/tools"; + +import * as room from "./room"; + +/** Marks a shell result that a queued handoff stopped or skipped. */ +export const HANDOFF = "handoff"; + +function result(message: string): ToolExecutionResult { + return { isError: true, diagnostics: [{ severity: "error", code: HANDOFF, message }] }; +} + +/** + * Another channel's agent invokes a chat to hand it work, often while that chat's agent waits for + * it in a shell loop. Its queued input stops the chat's running shell commands, and skips new ones, + * until pi places it as a steer after the current tool round, so the run continues with the + * message and reruns nothing. Only shell commands are interrupted: other tools may be mid-change, + * and a person has Stop. + */ +export class Handoffs { + /** The author of each chat's queued handoff, from pi's inbox commits. */ + #queued = new Map(); + #running = new Map void>>(); + + inbox(chat: ConversationId, state: InboxState | null | undefined) { + const author = (state?.items || []).flatMap((item) => + item.mode === "write" ? [] : [room.input(item.content).author] + ).find(room.isAgent); + if (!author) return void this.#queued.delete(chat); + this.#queued.set(chat, author); + for (const stop of this.#running.get(chat) || []) stop(author); + } + + readonly extension: Extension = defineExtension({ + name: "ace-handoffs", + wraps: [ + wrapTool(createBashTool(), (tool) => ({ + ...tool, + execute: async (args, api, context) => { + const chat = api.conversationId; + const queued = this.#queued.get(chat); + if (queued) return result(`Not run: ${queued} sent a message to this chat.`); + const { context: command, cancel } = withCancel(context); + let by: string | undefined; + const stop = (author: string) => { + by ||= author; + cancel(); + }; + let running = this.#running.get(chat); + if (!running) this.#running.set(chat, running = new Set()); + running.add(stop); + try { + return await tool.execute(args, api, command); + } catch (error) { + if (!by || context.abortSignal?.aborted) throw error; + return result(`Stopped: ${by} sent a message to this chat.`); + } finally { + running.delete(stop); + if (!running.size) this.#running.delete(chat); + } + }, + })), + ], + }); +} diff --git a/packages/channel/src/protocol.ts b/packages/channel/src/protocol.ts index 7e4aab7e..8602acb3 100644 --- a/packages/channel/src/protocol.ts +++ b/packages/channel/src/protocol.ts @@ -252,7 +252,11 @@ export type Event = * `truncated` when earlier output is not shown. Not stored: only pi's live state backs it. */ | { kind: "progress"; chat: ChatId; call: string; text: string; truncated?: boolean } - /** `interrupted` when the channel restarted while the tool ran, so it may have partially run. */ + /** + * `interrupted` when the channel restarted while the tool ran, so it may have partially run. + * `handoff` when another channel's agent's message stopped the shell command or kept it from + * starting. + */ | { kind: "result"; chat: ChatId; @@ -262,6 +266,7 @@ export type Event = images?: Image[]; stopped?: boolean; interrupted?: boolean; + handoff?: boolean; } /** * A fast model's one-line description of a group of consecutive tool calls, sent on attachment diff --git a/packages/channel/src/room.ts b/packages/channel/src/room.ts index bb88df89..e434fa38 100644 --- a/packages/channel/src/room.ts +++ b/packages/channel/src/room.ts @@ -134,6 +134,10 @@ export function section(room: Room): PromptSection { "Each human message starts with its author's name and a colon. You see every message, but", "you act only when someone invokes you; answer the person who did. Write replies as plain", "text without a name prefix: the channel shows who wrote each message.", + "", + "To wait for another channel's agent, say in this chat what you are waiting for and end your", + "run; its message with invoke set wakes you. Never wait for another channel in a shell sleep or", + "polling loop: an invoked message stops your running shell commands anyway.", ...(summary ? [ "",