Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
---

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
---

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
---

Expand Down
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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
3 changes: 3 additions & 0 deletions apps/app/src/timeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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."));
Expand Down
9 changes: 8 additions & 1 deletion apps/app/src/transcript.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
};
Expand Down Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion packages/channel/src/agents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
13 changes: 13 additions & 0 deletions packages/channel/src/channel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
type EntryRecord,
Harness,
InboxDoc,
type InboxState,
LiveDoc,
type LiveState,
ROOT_CONVERSATION_ID,
Expand Down Expand Up @@ -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";
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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",
Expand All @@ -919,6 +931,7 @@ function events(entry: EntryRecord): Event[] {
...(images ? { images } : {}),
...(stopped ? { stopped: true } : {}),
...(interrupted ? { interrupted: true } : {}),
...(handoff ? { handoff: true } : {}),
}];
}
return [];
Expand Down
73 changes: 73 additions & 0 deletions packages/channel/src/handoffs.ts
Original file line number Diff line number Diff line change
@@ -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<ConversationId, string>();
#running = new Map<ConversationId, Set<(author: string) => 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);
}
},
})),
],
});
}
7 changes: 6 additions & 1 deletion packages/channel/src/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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
Expand Down
4 changes: 4 additions & 0 deletions packages/channel/src/room.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
? [
"",
Expand Down