truncated: false,
durationMs: 1,
tokens: 0,
+ requests: 0,
...overrides,
};
}
@@ -178,7 +179,7 @@ describe("runEvalAgent", () => {
expect(runSpy).not.toHaveBeenCalled();
});
- it("passes the parent execution context and only sets outputSchema when schema is supplied", async () => {
+ it("passes parent execution options and only sets outputSchema when schema is supplied", async () => {
mockAgents();
const runSpy = vi.spyOn(taskExecutor, "runSubprocess").mockImplementation(async options => singleResult(options));
const abortController = new AbortController();
@@ -186,7 +187,7 @@ describe("runEvalAgent", () => {
const session = makeSession({ depth: 2, activeModel: "p/current", modelString: "p/fallback" });
await runEvalAgent(
- { prompt: " hello ", context: " context ", label: "My Agent", model: "p/override", schema },
+ { prompt: " hello ", label: "My Agent", model: "p/override", schema },
{ session, signal: abortController.signal },
);
await runEvalAgent({ prompt: "plain" }, { session });
@@ -199,7 +200,6 @@ describe("runEvalAgent", () => {
expect(firstOptions.parentActiveModelPattern).toBe("p/current");
expect(firstOptions.outputSchema).toBe(schema);
expect(firstOptions.assignment).toBe("hello");
- expect(firstOptions.context).toBe("context");
expect(firstOptions.description).toBe("My Agent");
expect(firstOptions.modelOverride).toEqual(["p/override"]);
expect(secondOptions.outputSchema).toBeUndefined();
@@ -542,6 +542,7 @@ describe("agent() through eval runtimes", () => {
recentOutput: [],
toolCount: 0,
tokens: 0,
+ requests: 0,
cost: 0,
durationMs: 0,
...overrides,
@@ -674,6 +675,7 @@ describe("agent() through eval runtimes", () => {
recentOutput: [],
toolCount: i,
tokens: 0,
+ requests: 0,
cost: 0,
durationMs: i * 10,
});
diff --git a/packages/coding-agent/src/eval/agent-bridge.ts b/packages/coding-agent/src/eval/agent-bridge.ts
index 23a4ecff0..d18bb48b4 100644
--- a/packages/coding-agent/src/eval/agent-bridge.ts
+++ b/packages/coding-agent/src/eval/agent-bridge.ts
@@ -34,7 +34,6 @@ const agentArgsSchema = z.object({
prompt: z.string().min(1, "prompt must be a non-empty string"),
agentType: z.string().min(1).optional(),
model: z.union([z.string().min(1), z.array(z.string().min(1)).min(1)]).optional(),
- context: z.string().optional(),
label: z.string().optional(),
schema: z.unknown().optional(),
});
@@ -43,7 +42,6 @@ interface EvalAgentArgs {
prompt: string;
agentType?: string;
model?: string | string[];
- context?: string;
label?: string;
schema?: unknown;
}
@@ -135,20 +133,12 @@ function getOutputManager(session: ToolSession): AgentOutputManager {
async function getArtifacts(session: ToolSession): Promise<{
sessionFile: string | null;
artifactsDir: string;
- contextFile?: string;
}> {
const sessionFile = session.getSessionFile();
const sessionArtifactsDir = sessionFile ? sessionFile.slice(0, -6) : null;
const artifactsDir = sessionArtifactsDir ?? path.join(os.tmpdir(), `omp-eval-agent-${Snowflake.next()}`);
await fs.mkdir(artifactsDir, { recursive: true });
-
- const shouldWriteConversationContext = session.settings.get("irc.enabled") !== true;
- const compactContext = shouldWriteConversationContext ? session.getCompactContext?.() : undefined;
- if (!compactContext) return { sessionFile, artifactsDir };
-
- const contextFile = path.join(artifactsDir, "context.md");
- await Bun.write(contextFile, compactContext);
- return { sessionFile, artifactsDir, contextFile };
+ return { sessionFile, artifactsDir };
}
function emitProgressStatus(emitStatus: ((event: JsStatusEvent) => void) | undefined, progress: AgentProgress): void {
@@ -246,11 +236,10 @@ export async function runEvalAgent(args: unknown, options: EvalAgentBridgeOption
};
const parentArtifactManager = options.session.getArtifactManager?.() ?? undefined;
const mcpManager = options.session.mcpManager ?? MCPManager.instance();
- const { sessionFile, artifactsDir, contextFile } = await getArtifacts(options.session);
+ const { sessionFile, artifactsDir } = await getArtifacts(options.session);
const outputManager = getOutputManager(options.session);
const id = await outputManager.allocate(outputIdBase(parsed.label, agentName));
const assignment = parsed.prompt.trim();
- const context = trimToUndefined(parsed.context);
// Suspend eval timeout accounting while the subagent owns control. The
// timeout clock restarts once the bridge returns to the cell runtime.
const result = await withBridgeTimeoutPause(options.emitStatus, () =>
@@ -259,7 +248,6 @@ export async function runEvalAgent(args: unknown, options: EvalAgentBridgeOption
agent: effectiveAgent,
task: renderSubagentPrompt(assignment),
assignment,
- context,
description: trimToUndefined(parsed.label),
index: 0,
id,
@@ -271,7 +259,6 @@ export async function runEvalAgent(args: unknown, options: EvalAgentBridgeOption
sessionFile,
persistArtifacts: Boolean(sessionFile),
artifactsDir,
- contextFile,
// Eval `agent()` subagents are short-lived programmatic helpers (data
// collection, structured output, parallel() fan-out). LSP server
// cold-start costs tens of seconds and is pure overhead here, so it is
diff --git a/packages/coding-agent/src/eval/js/shared/prelude.txt b/packages/coding-agent/src/eval/js/shared/prelude.txt
index 36b61c5ab..095b219a2 100644
--- a/packages/coding-agent/src/eval/js/shared/prelude.txt
+++ b/packages/coding-agent/src/eval/js/shared/prelude.txt
@@ -65,7 +65,7 @@ if (!globalThis.__omp_js_prelude_loaded__) {
};
const agent = async (prompt, opts, ...rest) => {
- const o = optionsArg("agent", opts, rest, "{ agentType, model, context, label, schema }");
+ const o = optionsArg("agent", opts, rest, "{ agentType, model, label, schema }");
const res = await globalThis.__omp_call_tool__("__agent__", { prompt, ...o });
const text = res && typeof res === "object" ? res.text : res;
return hasOwn(o, "schema") ? JSON.parse(text) : text;
diff --git a/packages/coding-agent/src/eval/py/prelude.py b/packages/coding-agent/src/eval/py/prelude.py
index 0eb2ec942..e2ac422d0 100644
--- a/packages/coding-agent/src/eval/py/prelude.py
+++ b/packages/coding-agent/src/eval/py/prelude.py
@@ -519,21 +519,20 @@ if "__omp_prelude_loaded__" not in globals():
text = res.get("text") if isinstance(res, dict) else res
return json.loads(text) if schema is not None else text
- def agent(prompt, *, agent_type="task", model=None, context=None, label=None, schema=None):
+ def agent(prompt, *, agent_type="task", model=None, label=None, schema=None):
"""Run a subagent and return its final output.
`agent_type` selects the subagent definition (default "task"). Pass
- `model` to override that agent's model, `context` for shared background,
- `label` for the output artifact id, and `schema` to request structured
- JSON output; when `schema` is supplied the parsed object is returned.
+ `model` to override that agent's model, `label` for the output artifact
+ id, and `schema` to request structured JSON output; when `schema` is
+ supplied the parsed object is returned. Share background by writing a
+ local:// file and referencing it in the prompt.
"""
args = {"prompt": prompt}
if agent_type is not None:
args["agentType"] = agent_type
if model is not None:
args["model"] = model
- if context is not None:
- args["context"] = context
if label is not None:
args["label"] = label
if schema is not None:
diff --git a/packages/coding-agent/src/export/html/template.generated.ts b/packages/coding-agent/src/export/html/template.generated.ts
index 10510d666..177e23630 100644
--- a/packages/coding-agent/src/export/html/template.generated.ts
+++ b/packages/coding-agent/src/export/html/template.generated.ts
@@ -1,2 +1,2 @@
// Auto-generated by scripts/generate-template.ts - DO NOT EDIT
-export const TEMPLATE = "\n\n\n \n \n Session Export\n \n \n\n\n \n \n \n \n \n
\n \n \n \n
\n

\n
\n
\n\n \n \n \n \n\n\n";
+export const TEMPLATE = "\n\n\n \n \n Session Export\n \n \n\n\n \n \n \n \n \n
\n \n \n \n
\n

\n
\n
\n\n \n \n \n \n\n\n";
diff --git a/packages/coding-agent/src/export/html/template.js b/packages/coding-agent/src/export/html/template.js
index d94777191..98ee6ef64 100644
--- a/packages/coding-agent/src/export/html/template.js
+++ b/packages/coding-agent/src/export/html/template.js
@@ -1023,18 +1023,18 @@
}
function renderTask(name, args, result, ctx) {
- const agent = str(args.agent) || '?';
- const tasks = Array.isArray(args.tasks) ? args.tasks : [];
- const badges = ['agent=' + agent, tasks.length + ' subtask' + (tasks.length === 1 ? '' : 's')];
+ const badges = [];
+ if (args.resume) badges.push('resume=' + str(args.resume));
+ else badges.push('agent=' + (str(args.agent) || '?'));
+ if (args.id) badges.push('id=' + str(args.id));
if (args.isolated) badges.push('isolated');
let html = toolHead('task', '', badges);
- if (tasks.length) {
+ const description = str(args.description);
+ const assignment = str(args.assignment);
+ if (description || assignment) {
html += '';
}
if (result) {
@@ -1479,13 +1479,38 @@
}
function renderIrc(name, args, result, ctx) {
- const op = str(args.op) || '?';
+ const details = result && result.details ? result.details : null;
+ const op = str(args.op) || (details && str(details.op)) || '?';
const badges = [op];
- if (args.to) badges.push('to=' + args.to);
- if (args.awaitReply === false) badges.push('no-reply');
+ if (args.to) badges.push('to=' + str(args.to));
+ if (op === 'wait' && args.from) badges.push('from=' + str(args.from));
+ if (args.await) badges.push('await');
+ if (args.peek) badges.push('peek');
let html = toolHead('irc', '', badges);
if (args.message) html += '';
- if (result) {
+ let renderedDetails = false;
+ if (details && Array.isArray(details.receipts) && details.receipts.length) {
+ html += '';
+ renderedDetails = true;
+ }
+ if (details && details.waited) {
+ html += '';
+ renderedDetails = true;
+ }
+ if (details && Array.isArray(details.inbox) && details.inbox.length) {
+ html += '';
+ renderedDetails = true;
+ }
+ if (!renderedDetails && result) {
const output = ctx.getResultText();
if (output) html += formatExpandableOutput(output, 8);
}
diff --git a/packages/coding-agent/src/extensibility/custom-tools/types.ts b/packages/coding-agent/src/extensibility/custom-tools/types.ts
index 3703cfaab..41c496287 100644
--- a/packages/coding-agent/src/extensibility/custom-tools/types.ts
+++ b/packages/coding-agent/src/extensibility/custom-tools/types.ts
@@ -103,11 +103,11 @@ export type CustomToolSessionEvent =
| {
reason: "auto_compaction_start";
trigger: "threshold" | "overflow" | "idle" | "incomplete";
- action: "context-full" | "handoff" | "shake";
+ action: "context-full" | "handoff" | "shake" | "snapcompact";
}
| {
reason: "auto_compaction_end";
- action: "context-full" | "handoff" | "shake";
+ action: "context-full" | "handoff" | "shake" | "snapcompact";
result: CompactionResult | undefined;
aborted: boolean;
willRetry: boolean;
diff --git a/packages/coding-agent/src/extensibility/shared-events.ts b/packages/coding-agent/src/extensibility/shared-events.ts
index cefc1ea94..713fd54bf 100644
--- a/packages/coding-agent/src/extensibility/shared-events.ts
+++ b/packages/coding-agent/src/extensibility/shared-events.ts
@@ -204,13 +204,13 @@ export interface TurnEndEvent {
export interface AutoCompactionStartEvent {
type: "auto_compaction_start";
reason: "threshold" | "overflow" | "idle" | "incomplete";
- action: "context-full" | "handoff" | "shake";
+ action: "context-full" | "handoff" | "shake" | "snapcompact";
}
/** Fired when auto-compaction ends */
export interface AutoCompactionEndEvent {
type: "auto_compaction_end";
- action: "context-full" | "handoff" | "shake";
+ action: "context-full" | "handoff" | "shake" | "snapcompact";
result: CompactionResult | undefined;
aborted: boolean;
willRetry: boolean;
diff --git a/packages/coding-agent/src/internal-urls/history-protocol.ts b/packages/coding-agent/src/internal-urls/history-protocol.ts
new file mode 100644
index 000000000..576af1ac1
--- /dev/null
+++ b/packages/coding-agent/src/internal-urls/history-protocol.ts
@@ -0,0 +1,113 @@
+/**
+ * Protocol handler for history:// URLs.
+ *
+ * Exposes agent transcripts as concise markdown. Live refs render from the
+ * in-memory message array; parked refs (session disposed, sessionFile
+ * retained) load read-only from the JSONL session file — no writer, no lock.
+ *
+ * URL forms:
+ * - history:// - Index of all registry agents (id, status, kind, last activity)
+ * - history:// - Concise markdown transcript of that agent
+ */
+import type { AgentRef } from "../registry/agent-registry";
+import { AgentRegistry } from "../registry/agent-registry";
+import { formatSessionHistoryMarkdown } from "../session/session-history-format";
+import { loadSessionMessagesReadOnly } from "../session/session-manager";
+import type { InternalResource, InternalUrl, ProtocolHandler, UrlCompletion } from "./types";
+
+/** Humanize a last-activity timestamp as `Ns/Nm/Nh/Nd ago`. */
+function formatAgo(timestamp: number): string {
+ const diffMs = Math.max(0, Date.now() - timestamp);
+ const secs = Math.floor(diffMs / 1000);
+ if (secs < 60) return `${secs}s ago`;
+ const mins = Math.floor(secs / 60);
+ if (mins < 60) return `${mins}m ago`;
+ const hours = Math.floor(mins / 60);
+ if (hours < 24) return `${hours}h ago`;
+ return `${Math.floor(hours / 24)}d ago`;
+}
+
+/**
+ * Handler for history:// URLs.
+ *
+ * Resolves agent ids against the global AgentRegistry, serving transcripts
+ * for both live and parked agents.
+ */
+export class HistoryProtocolHandler implements ProtocolHandler {
+ readonly scheme = "history";
+ readonly immutable = false;
+
+ async resolve(url: InternalUrl): Promise {
+ const agentId = url.rawHost || url.hostname;
+ const registry = AgentRegistry.global();
+
+ if (!agentId) {
+ const content = this.#renderIndex(registry.list());
+ return {
+ url: url.href,
+ content,
+ contentType: "text/markdown",
+ size: Buffer.byteLength(content, "utf-8"),
+ };
+ }
+
+ let ref = registry.get(agentId);
+ if (!ref) {
+ // Case-insensitive fallback: agent ids are human-typed (e.g. AuthLoader).
+ const lower = agentId.toLowerCase();
+ ref = registry.list().find(candidate => candidate.id.toLowerCase() === lower);
+ }
+ if (!ref) {
+ const known = registry.list().map(candidate => candidate.id);
+ const knownStr = known.length > 0 ? known.join(", ") : "none";
+ throw new Error(`Unknown agent: ${agentId}\nKnown agents: ${knownStr}\nList all with history://`);
+ }
+
+ const notes: string[] = [];
+ let messages: unknown[];
+ if (ref.session) {
+ messages = ref.session.messages;
+ notes.push("Source: live session");
+ } else if (ref.sessionFile) {
+ messages = await loadSessionMessagesReadOnly(ref.sessionFile);
+ notes.push(`Source: session file (read-only, ${ref.status})`);
+ } else {
+ throw new Error(`Agent ${ref.id} has no transcript: session is gone and no session file was retained`);
+ }
+
+ const content = formatSessionHistoryMarkdown(messages, { title: `${ref.id} (${ref.status})` });
+ return {
+ url: url.href,
+ content,
+ contentType: "text/markdown",
+ size: Buffer.byteLength(content, "utf-8"),
+ sourcePath: ref.sessionFile ?? undefined,
+ notes,
+ };
+ }
+
+ #renderIndex(refs: AgentRef[]): string {
+ const lines: string[] = ["# Agents", ""];
+ if (refs.length === 0) {
+ lines.push("No agents registered.");
+ return `${lines.join("\n")}\n`;
+ }
+ lines.push("| id | status | kind | parent | last activity |", "|---|---|---|---|---|");
+ for (const ref of refs) {
+ lines.push(
+ `| ${ref.id} | ${ref.status} | ${ref.kind} | ${ref.parentId ?? "—"} | ${formatAgo(ref.lastActivity)} |`,
+ );
+ }
+ lines.push("", "Read a transcript with `read history://`.");
+ return `${lines.join("\n")}\n`;
+ }
+
+ async complete(): Promise {
+ return AgentRegistry.global()
+ .list()
+ .map(ref => ({
+ value: ref.id,
+ description: `${ref.status} · ${ref.kind}${ref.parentId ? ` · parent ${ref.parentId}` : ""}`,
+ }));
+ }
+}
diff --git a/packages/coding-agent/src/internal-urls/index.ts b/packages/coding-agent/src/internal-urls/index.ts
index a460d8ff8..4714406e4 100644
--- a/packages/coding-agent/src/internal-urls/index.ts
+++ b/packages/coding-agent/src/internal-urls/index.ts
@@ -10,6 +10,7 @@
export * from "./agent-protocol";
export * from "./artifact-protocol";
+export * from "./history-protocol";
export * from "./issue-pr-protocol";
export * from "./json-query";
export * from "./local-protocol";
diff --git a/packages/coding-agent/src/internal-urls/router.ts b/packages/coding-agent/src/internal-urls/router.ts
index 194f9f156..608600ece 100644
--- a/packages/coding-agent/src/internal-urls/router.ts
+++ b/packages/coding-agent/src/internal-urls/router.ts
@@ -1,5 +1,5 @@
/**
- * Internal URL router for internal protocols (`agent://`, `artifact://`, `issue://`, `local://`, `mcp://`, `memory://`, `omp://`, `pr://`, `rule://`, `skill://`, and `vault://`).
+ * Internal URL router for internal protocols (`agent://`, `artifact://`, `history://`, `issue://`, `local://`, `mcp://`, `memory://`, `omp://`, `pr://`, `rule://`, `skill://`, and `vault://`).
*
* One process-global router with one handler per scheme. Access via
* `InternalUrlRouter.instance()`. Handlers are stateless; per-session and
@@ -7,6 +7,7 @@
*/
import { AgentProtocolHandler } from "./agent-protocol";
import { ArtifactProtocolHandler } from "./artifact-protocol";
+import { HistoryProtocolHandler } from "./history-protocol";
import { IssueProtocolHandler, PrProtocolHandler } from "./issue-pr-protocol";
import { LocalProtocolHandler } from "./local-protocol";
import { McpProtocolHandler } from "./mcp-protocol";
@@ -35,6 +36,7 @@ export class InternalUrlRouter {
this.register(new McpProtocolHandler());
this.register(new IssueProtocolHandler());
this.register(new PrProtocolHandler());
+ this.register(new HistoryProtocolHandler());
}
/** Process-global router instance. */
diff --git a/packages/coding-agent/src/internal-urls/types.ts b/packages/coding-agent/src/internal-urls/types.ts
index 3075b6b16..ef2260057 100644
--- a/packages/coding-agent/src/internal-urls/types.ts
+++ b/packages/coding-agent/src/internal-urls/types.ts
@@ -1,7 +1,7 @@
/**
* Types for the internal URL routing system.
*
- * Internal URLs (`agent://`, `artifact://`, `issue://`, `local://`, `mcp://`, `memory://`, `omp://`, `pr://`, `rule://`, `skill://`, and `vault://`) are resolved by tools like read,
+ * Internal URLs (`agent://`, `artifact://`, `history://`, `issue://`, `local://`, `mcp://`, `memory://`, `omp://`, `pr://`, `rule://`, `skill://`, and `vault://`) are resolved by tools like read,
* providing access to agent outputs and server resources without exposing filesystem paths.
*/
diff --git a/packages/coding-agent/src/irc/bus.ts b/packages/coding-agent/src/irc/bus.ts
new file mode 100644
index 000000000..cb0186272
--- /dev/null
+++ b/packages/coding-agent/src/irc/bus.ts
@@ -0,0 +1,275 @@
+/**
+ * IrcBus - Process-global mailbox bus for agent-to-agent messaging.
+ *
+ * Replaces the old auto-reply model: a `send` never blocks on the recipient
+ * generating anything. Delivery resolves the recipient via the global
+ * AgentRegistry — parked agents are revived through the
+ * AgentLifecycleManager, idle agents are woken with a real turn, and busy
+ * agents receive the message as a non-interrupting aside at the next step
+ * boundary (see AgentSession.deliverIrcMessage). Replies are real turns by
+ * the recipient, observed via `wait`.
+ */
+
+import { logger, Snowflake } from "@oh-my-pi/pi-utils";
+import { AgentLifecycleManager } from "../registry/agent-lifecycle";
+import { AgentRegistry, MAIN_AGENT_ID } from "../registry/agent-registry";
+import type { CustomMessage } from "../session/messages";
+
+export interface IrcMessage {
+ id: string;
+ /** Sender agent id. */
+ from: string;
+ /** Recipient agent id (resolved; "all" is expanded by the tool, not stored). */
+ to: string;
+ body: string;
+ ts: number;
+ /** Message id being answered. */
+ replyTo?: string;
+}
+
+export interface IrcDeliveryReceipt {
+ to: string;
+ outcome: "injected" | "woken" | "revived" | "failed";
+ error?: string;
+}
+
+interface IrcWaiter {
+ from?: string;
+ resolve: (msg: IrcMessage) => void;
+ cancel: () => void;
+}
+
+/** Mailbox cap per agent; oldest messages are dropped beyond it. */
+const MAILBOX_CAP = 100;
+
+export class IrcBus {
+ static #global: IrcBus | undefined;
+
+ static global(): IrcBus {
+ if (!IrcBus.#global) {
+ IrcBus.#global = new IrcBus();
+ }
+ return IrcBus.#global;
+ }
+
+ /** Reset the global bus. Test-only. */
+ static resetGlobalForTests(): void {
+ IrcBus.#global = undefined;
+ }
+
+ readonly #registry: AgentRegistry;
+ readonly #lifecycle: () => AgentLifecycleManager;
+ readonly #mailboxes = new Map();
+ readonly #waiters = new Map();
+
+ constructor(registry: AgentRegistry = AgentRegistry.global(), lifecycle?: AgentLifecycleManager) {
+ this.#registry = registry;
+ // Lazy: the lifecycle global self-constructs against the global registry,
+ // so only touch it when a parked recipient actually needs reviving.
+ this.#lifecycle = () => lifecycle ?? AgentLifecycleManager.global();
+ }
+
+ /**
+ * Fire-and-forget delivery. Never blocks on the recipient generating
+ * anything: the receipt reports how the message reached the recipient
+ * (waiter/aside = "injected", idle wake = "woken", park revival =
+ * "revived"), not what they did with it.
+ */
+ async send(msg: Omit): Promise {
+ const message: IrcMessage = { ...msg, id: Snowflake.next(), ts: Date.now() };
+ const ref = this.#registry.get(message.to);
+ if (!ref || ref.status === "aborted") {
+ return { to: message.to, outcome: "failed", error: `Unknown or terminated agent "${message.to}".` };
+ }
+
+ let revived = false;
+ if (ref.status === "parked") {
+ try {
+ await this.#lifecycle().ensureLive(message.to);
+ revived = true;
+ } catch (error) {
+ return {
+ to: message.to,
+ outcome: "failed",
+ error: error instanceof Error ? error.message : String(error),
+ };
+ }
+ }
+
+ // A pending `wait` from the recipient consumes the message directly —
+ // it is returned from their irc tool call and never hits the inbox or
+ // the session injection path.
+ const waiter = this.#takeMatchingWaiter(message.to, message.from);
+ if (waiter) {
+ waiter.resolve(message);
+ this.#relayToMainUi(message);
+ return { to: message.to, outcome: revived ? "revived" : "injected" };
+ }
+
+ const session = this.#registry.get(message.to)?.session;
+ if (!session) {
+ return { to: message.to, outcome: "failed", error: `Agent "${message.to}" has no live session.` };
+ }
+
+ this.#enqueue(message);
+ try {
+ const delivery = await session.deliverIrcMessage(message);
+ this.#relayToMainUi(message);
+ return { to: message.to, outcome: revived ? "revived" : delivery };
+ } catch (error) {
+ return {
+ to: message.to,
+ outcome: "failed",
+ error: error instanceof Error ? error.message : String(error),
+ };
+ }
+ }
+
+ /**
+ * Block until a message for `agentId` (optionally from `filter.from`)
+ * arrives; consume + return it. Null on timeout (`timeoutMs <= 0` waits
+ * forever). Rejects when `signal` aborts.
+ */
+ async wait(
+ agentId: string,
+ filter: { from?: string },
+ timeoutMs: number,
+ signal?: AbortSignal,
+ ): Promise {
+ if (signal?.aborted) {
+ throw signal.reason instanceof Error ? signal.reason : new Error("IRC wait aborted");
+ }
+
+ // Already-pending mail satisfies the wait without parking a waiter.
+ const pending = this.#takeFromMailbox(agentId, filter.from);
+ if (pending) return pending;
+
+ const { promise, resolve, reject } = Promise.withResolvers();
+ let timer: NodeJS.Timeout | undefined;
+ let onAbort: (() => void) | undefined;
+
+ const waiter: IrcWaiter = {
+ from: filter.from,
+ resolve: msg => {
+ cleanup();
+ resolve(msg);
+ },
+ cancel: () => {
+ cleanup();
+ },
+ };
+ const cleanup = (): void => {
+ this.#removeWaiter(agentId, waiter);
+ clearTimeout(timer);
+ if (signal && onAbort) signal.removeEventListener("abort", onAbort);
+ };
+
+ if (signal) {
+ onAbort = () => {
+ cleanup();
+ reject(signal.reason instanceof Error ? signal.reason : new Error("IRC wait aborted"));
+ };
+ signal.addEventListener("abort", onAbort, { once: true });
+ }
+ if (timeoutMs > 0) {
+ timer = setTimeout(() => {
+ cleanup();
+ resolve(null);
+ }, timeoutMs);
+ timer.unref?.();
+ }
+
+ let waiters = this.#waiters.get(agentId);
+ if (!waiters) {
+ waiters = [];
+ this.#waiters.set(agentId, waiters);
+ }
+ waiters.push(waiter);
+ return promise;
+ }
+
+ /** Drain (or peek) pending messages for `agentId`. */
+ inbox(agentId: string, opts?: { peek?: boolean }): IrcMessage[] {
+ const mailbox = this.#mailboxes.get(agentId);
+ if (!mailbox || mailbox.length === 0) return [];
+ if (opts?.peek) return [...mailbox];
+ this.#mailboxes.delete(agentId);
+ return mailbox;
+ }
+
+ unreadCount(agentId: string): number {
+ return this.#mailboxes.get(agentId)?.length ?? 0;
+ }
+
+ #enqueue(message: IrcMessage): void {
+ let mailbox = this.#mailboxes.get(message.to);
+ if (!mailbox) {
+ mailbox = [];
+ this.#mailboxes.set(message.to, mailbox);
+ }
+ mailbox.push(message);
+ if (mailbox.length > MAILBOX_CAP) {
+ const dropped = mailbox.shift();
+ logger.debug("IrcBus: mailbox full, dropped oldest message", {
+ agentId: message.to,
+ droppedId: dropped?.id,
+ droppedFrom: dropped?.from,
+ });
+ }
+ }
+
+ /** Resolve the OLDEST waiter for `agentId` whose from-filter accepts `from`. */
+ #takeMatchingWaiter(agentId: string, from: string): IrcWaiter | undefined {
+ const waiters = this.#waiters.get(agentId);
+ if (!waiters) return undefined;
+ const index = waiters.findIndex(waiter => !waiter.from || waiter.from === from);
+ if (index === -1) return undefined;
+ const [waiter] = waiters.splice(index, 1);
+ if (waiters.length === 0) this.#waiters.delete(agentId);
+ return waiter;
+ }
+
+ #removeWaiter(agentId: string, waiter: IrcWaiter): void {
+ const waiters = this.#waiters.get(agentId);
+ if (!waiters) return;
+ const index = waiters.indexOf(waiter);
+ if (index !== -1) waiters.splice(index, 1);
+ if (waiters.length === 0) this.#waiters.delete(agentId);
+ }
+
+ #takeFromMailbox(agentId: string, from?: string): IrcMessage | undefined {
+ const mailbox = this.#mailboxes.get(agentId);
+ if (!mailbox) return undefined;
+ const index = from ? mailbox.findIndex(msg => msg.from === from) : 0;
+ if (index === -1 || mailbox.length === 0) return undefined;
+ const [message] = mailbox.splice(index, 1);
+ if (mailbox.length === 0) this.#mailboxes.delete(agentId);
+ return message;
+ }
+
+ /**
+ * Surface agent↔agent traffic as a display-only card on the main session
+ * UI. Skipped when the main agent is the recipient — its own
+ * `deliverIrcMessage` (or `wait` tool result) already shows the message.
+ */
+ #relayToMainUi(message: IrcMessage): void {
+ if (message.to === MAIN_AGENT_ID) return;
+ const mainSession = this.#registry.get(MAIN_AGENT_ID)?.session;
+ if (!mainSession) return;
+ const record: CustomMessage = {
+ role: "custom",
+ customType: "irc:relay",
+ content: `[IRC \`${message.from}\` → \`${message.to}\`]\n\n${message.body}`,
+ display: true,
+ details: { from: message.from, to: message.to, body: message.body },
+ attribution: "agent",
+ timestamp: message.ts,
+ };
+ try {
+ mainSession.emitIrcRelayObservation(record);
+ } catch (error) {
+ // Display-only forwarding must never affect delivery semantics.
+ logger.debug("IrcBus: main UI relay failed", { to: message.to, error: String(error) });
+ }
+ }
+}
diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts
index 89f60ca4a..b49f888c3 100644
--- a/packages/coding-agent/src/main.ts
+++ b/packages/coding-agent/src/main.ts
@@ -51,10 +51,10 @@ import { ExtensionRunner } from "./extensibility/extensions/runner";
import type { ExtensionUIContext } from "./extensibility/extensions/types";
import { scheduleMarketplaceAutoUpdate } from "./extensibility/plugins/marketplace-auto-update";
import type { MCPManager } from "./mcp";
-import { WelcomeComponent } from "./modes/components/welcome";
import { InteractiveMode } from "./modes/interactive-mode";
import type { PrintModeOptions } from "./modes/print-mode";
import { CURRENT_SETUP_VERSION } from "./modes/setup-version";
+import { StartupInput } from "./modes/startup-input";
import { initTheme, stopThemeWatcher } from "./modes/theme/theme";
import type { SubmittedUserInput } from "./modes/types";
import {
@@ -97,33 +97,31 @@ function maybeShowStartupSplash(options: {
modelName?: string;
providerName?: string;
lspServers?: LspStartupServerInfo[];
-}): void {
- if (!options.isInteractive) return;
- if (options.resuming || options.quiet) return;
- if ($env.PI_TIMING) return;
- if (!process.stdin.isTTY || !process.stdout.isTTY) return;
+}): StartupInput | undefined {
+ if (!options.isInteractive) return undefined;
+ if (options.resuming || options.quiet) return undefined;
+ if ($env.PI_TIMING) return undefined;
+ if (!process.stdin.isTTY || !process.stdout.isTTY) return undefined;
// First-run launches go straight into the setup wizard, which paints its own
// splash — keep the minimal two-line notice there.
if (options.setupPending) {
process.stdout.write(`${chalk.dim(`omp ${options.version}`)}\n${chalk.dim("Initializing session…")}\n`);
- return;
+ return undefined;
}
- // Render the same welcome box the TUI paints first: recent sessions as a
- // loading placeholder (the fixed slot count keeps the box height stable) and
- // the logo held on the intro animation's first frame so the in-TUI intro
- // continues from the frame shown here. Clearing the screen first puts the
- // box at the same origin the TUI's first full paint (clearScrollback) uses,
- // so the live welcome replaces this frame in place without shifting.
- const welcome = new WelcomeComponent(
- options.version,
- options.modelName ?? "",
- options.providerName ?? "",
- null,
- options.lspServers ?? [],
- );
- welcome.holdIntroFirstFrame();
- const lines = welcome.render(process.stdout.columns || 80);
- process.stdout.write(`\x1b[2J\x1b[H\x1b[3J\n${lines.join("\n")}\n`);
+ // Paint the same welcome box the TUI paints first (recent sessions as a
+ // loading placeholder, logo held on the intro animation's first frame) plus
+ // a live editor, and start capturing raw stdin so the user can type — and
+ // even submit — while the session loads in the background. The TUI's first
+ // full paint (clearScrollback) replaces this frame in place; the editor
+ // instance itself is handed to InteractiveMode so nothing typed is lost.
+ const startupInput = new StartupInput({
+ version: options.version,
+ modelName: options.modelName ?? "",
+ providerName: options.providerName ?? "",
+ lspServers: options.lspServers ?? [],
+ });
+ startupInput.start();
+ return startupInput;
}
async function checkForNewVersion(currentVersion: string): Promise {
@@ -372,6 +370,7 @@ async function runInteractiveMode(
initialMessage?: string,
initialImages?: ImageContent[],
titleSystemPrompt?: string,
+ startupInput?: StartupInput,
): Promise {
const mode = new InteractiveMode(
session,
@@ -382,6 +381,7 @@ async function runInteractiveMode(
mcpManager,
eventBus,
titleSystemPrompt,
+ startupInput?.editor,
);
// Cold-launch gate: the full setup wizard (every scene + the overlay and
@@ -400,6 +400,12 @@ async function runInteractiveMode(
})
: [];
+ // Hand the terminal over: stop the pre-TUI capture (restoring cooked mode so
+ // ProcessTerminal records the correct prior raw state) right before the TUI
+ // grabs stdin. Keystrokes typed during init's awaits stay OS-buffered and
+ // flow into the TUI once it resumes stdin.
+ startupInput?.detach();
+
await mode.init({
suppressWelcomeIntro: resuming || setupScenes.length > 0,
clearInitialTerminalHistory: true,
@@ -425,7 +431,7 @@ async function runInteractiveMode(
// Every in-process session load also uses `clearTerminalHistory`; cold launch
// follows the same clean-cutover path instead of preserving a previous run's
// transcript above the fresh one.
- mode.renderInitialMessages(undefined, { preserveExistingChat: true, clearTerminalHistory: true });
+ mode.renderInitialMessages({ preserveExistingChat: true, clearTerminalHistory: true });
for (const notify of notifs) {
if (!notify) {
@@ -460,8 +466,19 @@ async function runInteractiveMode(
}
}
+ const startupSubmissions = [...(startupInput?.queuedSubmissions ?? [])];
while (true) {
- const input = await mode.getUserInput();
+ const inputPromise = mode.getUserInput();
+ const queuedText = startupSubmissions.shift();
+ if (queuedText !== undefined) {
+ // Replay through the real submit pipeline (slash commands, bash/python
+ // modes, title generation) exactly as if Enter were pressed now.
+ await mode.editor.onSubmit?.(queuedText);
+ // Handled inline (e.g. a slash command) without consuming the pending
+ // input wait — replay the next queued item on the next iteration.
+ if (mode.onInputCallback) continue;
+ }
+ const input = await inputPromise;
await submitInteractiveInput(mode, session, input);
}
}
@@ -1264,7 +1281,7 @@ export async function runRootCommand(
settingsInstance.get("lsp.lazy") ? "available" : "connecting",
)
: [];
- maybeShowStartupSplash({
+ const startupInput = maybeShowStartupSplash({
isInteractive,
resuming: Boolean(parsedArgs.continue || parsedArgs.resume || parsedArgs.fork),
quiet: settingsInstance.get("startup.quiet"),
@@ -1275,6 +1292,9 @@ export async function runRootCommand(
lspServers: splashLspServers,
});
+ // TEMP-SMOKE: stretch the startup gap for VHS verification. REMOVE.
+ if (process.env.PI_DEBUG_SLOW_START) await Bun.sleep(Number(process.env.PI_DEBUG_SLOW_START));
+
const { session, setToolUIContext, modelFallbackMessage, lspServers, mcpManager } = await createSession({
...sessionOptions,
eventBus,
@@ -1322,7 +1342,10 @@ export async function runRootCommand(
return `${scopedModel.model.id}${thinkingStr}`;
})
.join(", ");
- process.stdout.write(`${chalk.dim(`Model scope: ${modelList} ${chalk.gray("(Ctrl+P to cycle)")}`)}\n`);
+ // Routed through the TUI (not stdout): the startup capture owns the
+ // terminal in raw mode here, and the TUI's first clearScrollback paint
+ // would wipe a pre-TUI line anyway.
+ notifs.push({ kind: "info", message: `Model scope: ${modelList} (Ctrl+P to cycle)` });
}
if ($env.PI_TIMING) {
@@ -1350,6 +1373,7 @@ export async function runRootCommand(
initialMessage,
initialImages,
titleSystemPrompt,
+ startupInput,
);
} else {
// Branch-only single-shot runner: keep print-mode code out of normal interactive startup.
diff --git a/packages/coding-agent/src/modes/components/session-observer-overlay.ts b/packages/coding-agent/src/modes/components/agent-hub.ts
similarity index 54%
rename from packages/coding-agent/src/modes/components/session-observer-overlay.ts
rename to packages/coding-agent/src/modes/components/agent-hub.ts
index 73ab1d185..ebff0ae7f 100644
--- a/packages/coding-agent/src/modes/components/session-observer-overlay.ts
+++ b/packages/coding-agent/src/modes/components/agent-hub.ts
@@ -1,30 +1,34 @@
/**
- * Session observer overlay component.
+ * Agent Hub overlay component.
*
- * Picker mode: lists main + active subagent sessions with live status.
- * Viewer mode: renders a scrollable, interactive transcript of the selected subagent's session
- * by reading its JSONL session file — shows thinking, text, tool calls, results
- * with expand/collapse per entry and breadcrumb navigation for nested sub-agents.
+ * One overlay, two views:
+ * - Table view: every registered agent except Main (Main IS the ambient
+ * chat), live from the global AgentRegistry — status, unread irc count,
+ * current/last task, last activity. Select with j/k, Enter opens a chat,
+ * `r` revives a parked agent, `x` aborts + releases one.
+ * - Chat view: per-agent transcript (incremental session-file tail, absorbed
+ * from the old session observer overlay) plus an input line. Submitting
+ * revives a parked agent, then prompts/steers it; the message lands in the
+ * agent's persisted history via the normal prompt path.
*
- * Lifecycle:
- * - shortcut opens picker
- * - Enter on a subagent -> viewer
- * - shortcut while in viewer -> back to picker (or pop breadcrumb)
- * - Esc from viewer -> back to picker (or pop breadcrumb)
- * - Esc from picker -> close overlay
- * - Enter on main session -> close overlay (jump back)
+ * Replaces the old SessionObserverOverlayComponent (ctrl+s observer).
*/
+import * as fs from "node:fs";
import type { ToolResultMessage } from "@oh-my-pi/pi-ai";
-import { Container, Markdown, type MarkdownTheme, matchesKey, ScrollView } from "@oh-my-pi/pi-tui";
-import { formatDuration, formatNumber, logger } from "@oh-my-pi/pi-utils";
+import { Container, Editor, Markdown, type MarkdownTheme, matchesKey, ScrollView } from "@oh-my-pi/pi-tui";
+import { formatAge, formatDuration, formatNumber, logger } from "@oh-my-pi/pi-utils";
import type { KeyId } from "../../config/keybindings";
-import { isSilentAbort } from "../../session/messages";
+import { IrcBus } from "../../irc/bus";
+import { AgentLifecycleManager } from "../../registry/agent-lifecycle";
+import { type AgentRef, AgentRegistry, type AgentStatus, MAIN_AGENT_ID } from "../../registry/agent-registry";
+import type { AgentSession } from "../../session/agent-session";
+import { isSilentAbort, USER_INTERRUPT_LABEL } from "../../session/messages";
import type { SessionMessageEntry } from "../../session/session-manager";
import { parseSessionEntries } from "../../session/session-manager";
import { PREVIEW_LIMITS, replaceTabs, TRUNCATE_LENGTHS, truncateToWidth } from "../../tools/render-utils";
import { toPathList } from "../../tools/search";
import type { ObservableSession, SessionObserverRegistry } from "../session-observer-registry";
-import { getMarkdownTheme, theme } from "../theme/theme";
+import { getEditorTheme, getMarkdownTheme, theme } from "../theme/theme";
import { matchesSelectDown, matchesSelectUp } from "../utils/keybinding-matchers";
import { DynamicBorder } from "./dynamic-border";
import { formatContextUsage } from "./status-line/context-thresholds";
@@ -39,6 +43,10 @@ const MAX_TOOL_ARGS_CHARS = 500;
const PAGE_SIZE = 15;
/** Left indent for content under entry headers */
const INDENT = " ";
+/** Refresh cadence for the relative-time column */
+const AGE_TICK_MS = 5_000;
+/** Debounce for live-session transcript refreshes */
+const CHAT_REFRESH_DEBOUNCE_MS = 80;
/** Compute the max content width for the current terminal, accounting for indent and chrome. */
function contentWidth(indent = INDENT): number {
@@ -57,180 +65,391 @@ interface ViewerEntry {
kind: "thinking" | "text" | "toolCall" | "user";
}
-/** Breadcrumb item for nested session navigation */
-interface BreadcrumbItem {
- sessionId: string;
- label: string;
- sessionFile: string;
+const STATUS_ORDER: Record = { running: 0, idle: 1, parked: 2, aborted: 3 };
+
+/** Glyph + status word, colored per theme status conventions. */
+function statusBadge(status: AgentStatus): string {
+ switch (status) {
+ case "running":
+ return theme.fg("accent", `${theme.status.running} running`);
+ case "idle":
+ return theme.fg("success", `${theme.status.enabled} idle`);
+ case "parked":
+ return theme.fg("muted", `${theme.status.shadowed} parked`);
+ case "aborted":
+ return theme.fg("error", `${theme.status.aborted} aborted`);
+ }
}
-export class SessionObserverOverlayComponent extends Container {
- #registry: SessionObserverRegistry;
- #onDone: () => void;
- #selectedSessionId?: string;
- #observeKeys: KeyId[];
- #transcriptCache?: { path: string; bytesRead: number; entries: SessionMessageEntry[]; model?: string };
+export interface AgentHubDeps {
+ /** Progress/status snapshot source (task lifecycle + progress channels). */
+ observers: SessionObserverRegistry;
+ /** Keys that toggle the hub closed from inside (app.agents.hub + app.session.observe). */
+ hubKeys: KeyId[];
+ onDone: () => void;
+ requestRender: () => void;
+ /** Injectable for tests; defaults to the process-global registry. */
+ registry?: AgentRegistry;
+ /** Injectable for tests; defaults to the process-global lifecycle manager. */
+ lifecycle?: AgentLifecycleManager;
+ /** Injectable for tests; defaults to the process-global bus. */
+ irc?: IrcBus;
+}
- // Scroll state
+export class AgentHubOverlayComponent extends Container {
+ #registry: AgentRegistry;
+ #observers: SessionObserverRegistry;
+ #irc: IrcBus;
+ #lifecycle: () => AgentLifecycleManager;
+ #onDone: () => void;
+ #requestRender: () => void;
+ #hubKeys: KeyId[];
+ #unsubscribers: Array<() => void> = [];
+ #ageTimer: NodeJS.Timeout | undefined;
+
+ // Table state
+ #view: "table" | "chat" = "table";
+ #rows: AgentRef[] = [];
+ #selectedRow = 0;
+ #notice: string | undefined;
+
+ // Chat state
+ #chatAgentId: string | undefined;
+ #editor: Editor;
+ #sessionUnsubscribe: (() => void) | undefined;
+ #attachedSession: AgentSession | undefined;
+ #chatRefreshTimer: NodeJS.Timeout | undefined;
+ #transcriptCache: { path: string; bytesRead: number; entries: SessionMessageEntry[]; model?: string } | undefined;
+
+ // Transcript viewer state (absorbed from the session observer overlay)
#scrollOffset = 0;
#renderedLines: string[] = [];
#viewportHeight = 20;
#wasAtBottom = true;
-
- // Entry selection & expand/collapse
#viewerEntries: ViewerEntry[] = [];
#selectedEntryIndex = 0;
#expandedEntries = new Set();
-
- // Breadcrumb navigation
- #navigationStack: BreadcrumbItem[] = [];
-
- // Cached header/footer for viewer (rebuilt on refresh)
#viewerHeaderLines: string[] = [];
- #viewerFooterLines: string[] = [];
- // Markdown rendering
#mdTheme: MarkdownTheme = getMarkdownTheme();
- constructor(registry: SessionObserverRegistry, onDone: () => void, observeKeys: KeyId[]) {
+ constructor(deps: AgentHubDeps) {
super();
- this.#registry = registry;
- this.#onDone = onDone;
- this.#observeKeys = observeKeys;
+ this.#registry = deps.registry ?? AgentRegistry.global();
+ this.#observers = deps.observers;
+ this.#irc = deps.irc ?? IrcBus.global();
+ // Lazy: the lifecycle global self-constructs against the global
+ // registry, so only touch it when revive/kill actually needs it.
+ this.#lifecycle = () => deps.lifecycle ?? AgentLifecycleManager.global();
+ this.#onDone = deps.onDone;
+ this.#requestRender = deps.requestRender;
+ this.#hubKeys = deps.hubKeys;
- // Jump directly to the most recently active sub-agent
- const mostRecent = this.#getMostRecentSubagent();
- if (mostRecent) {
- this.#selectedSessionId = mostRecent.id;
- this.#setupViewer();
- } else {
- // No sub-agents — close immediately
- queueMicrotask(() => this.#onDone());
- }
+ this.#editor = new Editor(getEditorTheme());
+ this.#editor.setMaxHeight(4);
+ this.#editor.onSubmit = text => this.#submitChatMessage(text);
+
+ this.#unsubscribers.push(this.#registry.onChange(() => this.#onDataChange()));
+ this.#unsubscribers.push(this.#observers.onChange(() => this.#onDataChange()));
+ this.#ageTimer = setInterval(() => this.#requestRender(), AGE_TICK_MS);
+ this.#ageTimer.unref?.();
+
+ this.#refreshRows();
}
- /** Find the most recently updated sub-agent session (prefer active ones) */
- #getMostRecentSubagent(): ObservableSession | undefined {
- const sessions = this.#registry.getSessions().filter(s => s.kind === "subagent");
- if (sessions.length === 0) return undefined;
- // Prefer active sessions, then sort by lastUpdate descending
- const active = sessions.filter(s => s.status === "active");
- const pool = active.length > 0 ? active : sessions;
- return pool.sort((a, b) => b.lastUpdate - a.lastUpdate)[0];
+ /** Tear down every subscription and timer. Called by the overlay owner on close. */
+ dispose(): void {
+ for (const unsubscribe of this.#unsubscribers.splice(0)) unsubscribe();
+ if (this.#ageTimer) {
+ clearInterval(this.#ageTimer);
+ this.#ageTimer = undefined;
+ }
+ if (this.#chatRefreshTimer) {
+ clearTimeout(this.#chatRefreshTimer);
+ this.#chatRefreshTimer = undefined;
+ }
+ this.#detachLiveSession();
}
override render(width: number): readonly string[] {
- return this.#renderViewer(width);
+ return this.#view === "table" ? this.#renderTable(width) : this.#renderChat(width);
}
- #setupViewer(): void {
- this.clear();
+ handleInput(keyData: string): void {
+ // The hub/observe keys always close the overlay (toggle semantics)
+ for (const key of this.#hubKeys) {
+ if (matchesKey(keyData, key)) {
+ this.#onDone();
+ return;
+ }
+ }
+ if (this.#view === "table") {
+ this.#handleTableInput(keyData);
+ } else {
+ this.#handleChatInput(keyData);
+ }
+ }
+
+ /** Open the chat view for an agent id (public for table Enter and tests). */
+ openChat(id: string): void {
+ if (!this.#registry.get(id)) return;
+ this.#view = "chat";
+ this.#chatAgentId = id;
+ this.#notice = undefined;
+ this.#transcriptCache = undefined;
this.#scrollOffset = 0;
this.#selectedEntryIndex = 0;
this.#expandedEntries.clear();
this.#wasAtBottom = true;
- this.#rebuildViewerContent();
- // Auto-scroll to bottom and select last entry on init
+ this.#editor.setText("");
+ this.#attachLiveSession();
+ this.#rebuildChatContent();
+ // Auto-scroll to bottom and select last entry on open
if (this.#viewerEntries.length > 0) {
this.#selectedEntryIndex = this.#viewerEntries.length - 1;
- this.#wasAtBottom = true;
- this.#rebuildViewerContent();
+ this.#rebuildChatContent();
}
+ this.#requestRender();
}
- /** Rebuild content from live registry data */
- refreshFromRegistry(): void {
- if (this.#selectedSessionId) {
- // Keep auto-scrolling to bottom unless the user navigated away from the last entry
+ // ========================================================================
+ // Live data plumbing
+ // ========================================================================
+
+ #onDataChange(): void {
+ this.#refreshRows();
+ if (this.#view === "chat") {
+ // A revive/park swaps the live session out from under the chat view.
+ this.#attachLiveSession();
+ this.#scheduleChatRefresh();
+ return;
+ }
+ this.#requestRender();
+ }
+
+ #refreshRows(): void {
+ const selectedId = this.#rows[this.#selectedRow]?.id;
+ this.#rows = this.#registry
+ .list()
+ .filter(ref => ref.id !== MAIN_AGENT_ID)
+ .sort((a, b) => STATUS_ORDER[a.status] - STATUS_ORDER[b.status] || b.lastActivity - a.lastActivity);
+ const keptIndex = selectedId ? this.#rows.findIndex(ref => ref.id === selectedId) : -1;
+ this.#selectedRow = keptIndex >= 0 ? keptIndex : Math.min(this.#selectedRow, Math.max(0, this.#rows.length - 1));
+ }
+
+ /** Subscribe to the chat agent's live session (if any) for transcript refreshes. Idempotent per session. */
+ #attachLiveSession(): void {
+ const session = this.#chatAgentId ? (this.#registry.get(this.#chatAgentId)?.session ?? undefined) : undefined;
+ if (session === this.#attachedSession) return;
+ this.#detachLiveSession();
+ if (!session) return;
+ this.#attachedSession = session;
+ this.#sessionUnsubscribe = session.subscribe(event => {
+ if (event.type === "message_end" || event.type === "tool_execution_end" || event.type === "agent_end") {
+ this.#scheduleChatRefresh();
+ }
+ });
+ }
+
+ #detachLiveSession(): void {
+ this.#sessionUnsubscribe?.();
+ this.#sessionUnsubscribe = undefined;
+ this.#attachedSession = undefined;
+ }
+
+ #scheduleChatRefresh(): void {
+ if (this.#chatRefreshTimer) return;
+ this.#chatRefreshTimer = setTimeout(() => {
+ this.#chatRefreshTimer = undefined;
+ if (this.#view !== "chat") return;
+ // Keep auto-scrolling to bottom unless the user navigated away
this.#wasAtBottom = this.#selectedEntryIndex >= this.#viewerEntries.length - 1;
- this.#rebuildViewerContent();
- }
+ this.#rebuildChatContent();
+ if (this.#wasAtBottom && this.#viewerEntries.length > 0) {
+ this.#selectedEntryIndex = this.#viewerEntries.length - 1;
+ }
+ this.#requestRender();
+ }, CHAT_REFRESH_DEBOUNCE_MS);
+ this.#chatRefreshTimer.unref?.();
}
- /** Rebuild the transcript content lines (called on setup and refresh) */
- #rebuildViewerContent(): void {
- const sessions = this.#registry.getSessions();
- const session = sessions.find(s => s.id === this.#selectedSessionId);
+ #observableFor(id: string): ObservableSession | undefined {
+ return this.#observers.getSessions().find(s => s.id === id);
+ }
- // Load transcript first so model info is available for header
- let messageEntries: SessionMessageEntry[] | null = null;
- if (session?.sessionFile) {
- messageEntries = this.#loadTranscript(session.sessionFile);
- }
+ // ========================================================================
+ // Table view
+ // ========================================================================
- // Header
- this.#viewerHeaderLines = [];
- const breadcrumb = this.#buildBreadcrumb(session);
- this.#viewerHeaderLines.push(theme.fg("accent", breadcrumb));
- if (session) {
- const statusColor = session.status === "active" ? "success" : session.status === "failed" ? "error" : "dim";
- const statusText = theme.fg(statusColor, `[${session.status}]`);
- const agentTag = session.agent ? theme.fg("dim", ` ${session.agent}`) : "";
- const subagentIds = this.#getSubagentSessionIds();
- const posIdx = subagentIds.indexOf(this.#selectedSessionId ?? "");
- const posLabel =
- subagentIds.length > 1 && posIdx >= 0 ? theme.fg("dim", ` (${posIdx + 1}/${subagentIds.length})`) : "";
- const modelName = this.#transcriptCache?.model;
- const modelLabel = modelName ? theme.fg("muted", ` · ${modelName}`) : "";
- this.#viewerHeaderLines.push(`${theme.bold(session.label)} ${statusText}${agentTag}${posLabel}${modelLabel}`);
- }
+ #renderTable(width: number): string[] {
+ const lines: string[] = [];
+ lines.push(...new DynamicBorder().render(width));
+ const counts = this.#statusSummary();
+ lines.push(` ${theme.fg("accent", "Agent Hub")}${counts ? theme.fg("dim", `${theme.sep.dot}${counts}`) : ""}`);
+ lines.push(...new DynamicBorder().render(width));
- // Content
- const contentLines: string[] = [];
- this.#viewerEntries = [];
-
- if (!session) {
- contentLines.push(theme.fg("dim", "Session no longer available."));
- } else if (!session.sessionFile) {
- contentLines.push(theme.fg("dim", "No session file available yet."));
- } else if (!messageEntries) {
- contentLines.push(theme.fg("dim", "Unable to read session file."));
- } else if (messageEntries.length === 0) {
- contentLines.push(theme.fg("dim", "No messages yet."));
+ if (this.#rows.length === 0) {
+ lines.push(` ${theme.fg("dim", "no subagents yet — task spawns appear here")}`);
} else {
- this.#buildTranscriptLines(messageEntries, contentLines);
+ const termHeight = process.stdout.rows || 40;
+ // Chrome: 2 borders + title + notice? + blank + hints + border
+ const maxVisible = Math.max(3, termHeight - 7 - (this.#notice ? 1 : 0));
+ let start = 0;
+ if (this.#rows.length > maxVisible) {
+ start = Math.min(
+ Math.max(0, this.#selectedRow - Math.floor(maxVisible / 2)),
+ this.#rows.length - maxVisible,
+ );
+ }
+ const end = Math.min(start + maxVisible, this.#rows.length);
+ for (let i = start; i < end; i++) {
+ lines.push(this.#renderRow(this.#rows[i], i === this.#selectedRow, width));
+ }
+ if (end < this.#rows.length) {
+ lines.push(` ${theme.fg("dim", `… ${this.#rows.length - end} more`)}`);
+ }
}
- this.#renderedLines = contentLines;
- // Footer
- this.#viewerFooterLines = [];
- const statsLine = this.#buildStatsLine(session);
- if (statsLine) this.#viewerFooterLines.push(statsLine);
- this.#viewerFooterLines.push(
- theme.fg("dim", "j/k:scroll Enter:expand [/]/←→:cycle agents Esc/Ctrl+S:close g/G:top/bottom"),
- );
+ if (this.#notice) {
+ lines.push(` ${theme.fg("error", sanitizeLine(this.#notice, Math.max(10, width - 2)))}`);
+ }
+ lines.push("");
+ lines.push(` ${theme.fg("dim", "j/k:select Enter:chat r:revive x:kill Esc:close")}`);
+ lines.push(...new DynamicBorder().render(width));
+ return lines;
+ }
- // Auto-scroll to bottom if we were at bottom
- if (this.#wasAtBottom) {
- this.#scrollOffset = Math.max(0, contentLines.length - this.#viewportHeight);
+ #statusSummary(): string {
+ const counts: Record = { running: 0, idle: 0, parked: 0, aborted: 0 };
+ for (const ref of this.#rows) {
+ counts[ref.status]++;
+ }
+ const parts: string[] = [];
+ for (const status of ["running", "idle", "parked", "aborted"] as const) {
+ const count = counts[status];
+ if (count > 0) parts.push(`${count} ${status}`);
+ }
+ return parts.join(theme.sep.dot);
+ }
+
+ #renderRow(ref: AgentRef, selected: boolean, width: number): string {
+ const cursor = selected ? theme.fg("accent", theme.nav.cursor) : " ";
+ const parts: string[] = [statusBadge(ref.status), theme.bold(replaceTabs(ref.id))];
+ parts.push(theme.fg("dim", ref.parentId ? `${ref.kind} · of ${ref.parentId}` : ref.kind));
+ const observed = this.#observableFor(ref.id);
+ const task = observed?.description ?? observed?.progress?.task;
+ if (task) {
+ parts.push(theme.fg("muted", sanitizeLine(task, TRUNCATE_LENGTHS.TITLE)));
+ }
+ const unread = this.#irc.unreadCount(ref.id);
+ if (unread > 0) {
+ parts.push(theme.fg("warning", `⧉ ${unread}`));
+ }
+ parts.push(theme.fg("dim", formatAge(Math.max(1, Math.round((Date.now() - ref.lastActivity) / 1000)))));
+ return truncateToWidth(` ${cursor} ${parts.join(theme.sep.dot)}`, Math.max(10, width - 1));
+ }
+
+ #handleTableInput(keyData: string): void {
+ if (matchesKey(keyData, "escape")) {
+ this.#onDone();
+ return;
+ }
+ if (keyData === "j" || matchesSelectDown(keyData)) {
+ if (this.#rows.length > 0) {
+ this.#selectedRow = Math.min(this.#selectedRow + 1, this.#rows.length - 1);
+ }
+ this.#requestRender();
+ return;
+ }
+ if (keyData === "k" || matchesSelectUp(keyData)) {
+ if (this.#rows.length > 0) {
+ this.#selectedRow = Math.max(this.#selectedRow - 1, 0);
+ }
+ this.#requestRender();
+ return;
+ }
+ if (matchesKey(keyData, "enter") || keyData === "\r" || keyData === "\n") {
+ const selected = this.#rows[this.#selectedRow];
+ if (selected) this.openChat(selected.id);
+ return;
+ }
+ if (keyData === "r") {
+ this.#reviveSelected();
+ return;
+ }
+ if (keyData === "x") {
+ this.#killSelected();
+ return;
}
}
- /** Produce the final viewer output for the overlay system */
- #renderViewer(width: number): string[] {
- const termHeight = process.stdout.rows || 40;
+ #reviveSelected(): void {
+ const ref = this.#rows[this.#selectedRow];
+ if (!ref) return;
+ if (ref.status !== "parked") {
+ this.#notice = `Agent "${ref.id}" is ${ref.status} — only parked agents can be revived.`;
+ this.#requestRender();
+ return;
+ }
+ this.#notice = undefined;
+ // Fire-and-forget; failures surface as an inline notice
+ this.#lifecycle()
+ .ensureLive(ref.id)
+ .catch((error: unknown) => {
+ this.#notice = error instanceof Error ? error.message : String(error);
+ this.#requestRender();
+ });
+ this.#requestRender();
+ }
- // Compute viewport: total height minus header chrome and footer chrome
- // Header: border(1) + headerLines + border(1) = headerLines.length + 2
- // Footer: spacer(1) + scrollInfo(1) + footerLines + border(1) = footerLines.length + 2
+ #killSelected(): void {
+ const ref = this.#rows[this.#selectedRow];
+ if (!ref) return;
+ this.#notice = undefined;
+ void (async () => {
+ try {
+ if (ref.status === "running" && ref.session) {
+ await ref.session.abort({ reason: USER_INTERRUPT_LABEL });
+ }
+ await this.#lifecycle().release(ref.id);
+ } catch (error) {
+ logger.warn("Agent hub: kill failed", { id: ref.id, error: String(error) });
+ this.#notice = error instanceof Error ? error.message : String(error);
+ }
+ this.#refreshRows();
+ this.#requestRender();
+ })();
+ }
+
+ // ========================================================================
+ // Chat view
+ // ========================================================================
+
+ #renderChat(width: number): string[] {
+ const termHeight = process.stdout.rows || 40;
+ const innerWidth = Math.max(20, width - 2);
+ const editorLines = this.#editor.render(innerWidth);
+ const noticeLine = this.#notice
+ ? ` ${theme.fg("error", sanitizeLine(this.#notice, Math.max(10, width - 2)))}`
+ : undefined;
+ const footerLines = this.#buildChatFooterLines();
+
+ // Header: border + headerLines + border; footer: notice? + editor + footer + border
const headerChrome = this.#viewerHeaderLines.length + 2;
- const footerChrome = this.#viewerFooterLines.length + 2;
+ const footerChrome = editorLines.length + footerLines.length + (noticeLine ? 1 : 0) + 1;
this.#viewportHeight = Math.max(5, termHeight - headerChrome - footerChrome);
- // Clamp scroll offset
const maxScroll = Math.max(0, this.#renderedLines.length - this.#viewportHeight);
+ if (this.#wasAtBottom) this.#scrollOffset = maxScroll;
this.#scrollOffset = Math.max(0, Math.min(this.#scrollOffset, maxScroll));
const lines: string[] = [];
-
- // --- Header ---
lines.push(...new DynamicBorder().render(width));
- for (const hl of this.#viewerHeaderLines) {
- lines.push(` ${hl}`);
+ for (const headerLine of this.#viewerHeaderLines) {
+ lines.push(` ${headerLine}`);
}
lines.push(...new DynamicBorder().render(width));
- // --- Scrolled content viewport ---
- const sv = new ScrollView(
+ const scrollView = new ScrollView(
this.#renderedLines.slice(this.#scrollOffset, this.#scrollOffset + this.#viewportHeight),
{
height: this.#viewportHeight,
@@ -239,31 +458,27 @@ export class SessionObserverOverlayComponent extends Container {
theme: { track: t => theme.fg("dim", t), thumb: t => theme.fg("accent", t) },
},
);
- sv.setScrollOffset(this.#scrollOffset);
- for (const row of sv.render(Math.max(1, width - 1))) lines.push(` ${row}`);
+ scrollView.setScrollOffset(this.#scrollOffset);
+ for (const row of scrollView.render(Math.max(1, width - 1))) lines.push(` ${row}`);
- // --- Footer ---
- lines.push("");
- lines.push(` ${this.#viewerFooterLines[0] ?? ""}`);
- for (let i = 1; i < this.#viewerFooterLines.length; i++) {
- lines.push(` ${this.#viewerFooterLines[i]}`);
- }
+ if (noticeLine) lines.push(noticeLine);
+ for (const editorLine of editorLines) lines.push(` ${editorLine}`);
+ lines.push(...footerLines);
lines.push(...new DynamicBorder().render(width));
-
return lines;
}
- #buildBreadcrumb(session: ObservableSession | undefined): string {
- const parts: string[] = ["Session Observer"];
- for (const item of this.#navigationStack) {
- parts.push(item.label);
- }
- if (session) parts.push(session.label);
- return parts.join(" > ");
+ #buildChatFooterLines(): string[] {
+ const lines: string[] = [];
+ const observed = this.#chatAgentId ? this.#observableFor(this.#chatAgentId) : undefined;
+ const statsLine = this.#buildStatsLine(observed);
+ if (statsLine) lines.push(` ${statsLine}`);
+ lines.push(` ${theme.fg("dim", "Enter:send Esc:back empty input: j/k:scroll Enter:expand g/G:top/bottom")}`);
+ return lines;
}
- #buildStatsLine(session: ObservableSession | undefined): string {
- const progress = session?.progress;
+ #buildStatsLine(observed: ObservableSession | undefined): string {
+ const progress = observed?.progress;
if (!progress) return "";
const stats: string[] = [];
// Current per-turn context — match the status line's `%/` gauge (e.g. `5.1%/1M`).
@@ -290,6 +505,212 @@ export class SessionObserverOverlayComponent extends Container {
return parts.join(theme.sep.dot);
}
+ /** Rebuild the chat header + transcript content lines */
+ #rebuildChatContent(): void {
+ const id = this.#chatAgentId;
+ const ref = id ? this.#registry.get(id) : undefined;
+
+ // Load transcript first so model info is available for the header
+ let messageEntries: SessionMessageEntry[] | null = null;
+ if (ref?.sessionFile) {
+ messageEntries = this.#loadTranscript(ref.sessionFile);
+ }
+
+ this.#viewerHeaderLines = [];
+ this.#viewerHeaderLines.push(theme.fg("accent", `Agent Hub > ${id ?? "?"}`));
+ if (ref) {
+ const observed = this.#observableFor(ref.id);
+ const model = observed?.progress?.resolvedModel ?? this.#transcriptCache?.model;
+ const kindTag = theme.fg("dim", ` ${ref.parentId ? `${ref.kind} · of ${ref.parentId}` : ref.kind}`);
+ const modelLabel = model ? theme.fg("muted", `${theme.sep.dot}${model}`) : "";
+ this.#viewerHeaderLines.push(`${theme.bold(ref.id)} ${statusBadge(ref.status)}${kindTag}${modelLabel}`);
+ }
+
+ const contentLines: string[] = [];
+ this.#viewerEntries = [];
+ if (!ref) {
+ contentLines.push(theme.fg("dim", "Agent no longer registered."));
+ } else if (!ref.sessionFile) {
+ contentLines.push(theme.fg("dim", "No session file available yet."));
+ } else if (!messageEntries) {
+ contentLines.push(theme.fg("dim", "Unable to read session file."));
+ } else if (messageEntries.length === 0) {
+ contentLines.push(theme.fg("dim", "No messages yet."));
+ } else {
+ this.#buildTranscriptLines(messageEntries, contentLines);
+ }
+ this.#renderedLines = contentLines;
+ }
+
+ #handleChatInput(keyData: string): void {
+ const editorEmpty = this.#editor.getText().trim() === "";
+
+ if (matchesKey(keyData, "escape")) {
+ if (!editorEmpty) {
+ this.#editor.setText("");
+ this.#requestRender();
+ return;
+ }
+ this.#closeChat();
+ return;
+ }
+
+ // Navigation mirrors the old observer overlay while the input is empty;
+ // once the user starts typing, the editor owns every key.
+ if (editorEmpty && this.#handleViewerNavigation(keyData)) {
+ return;
+ }
+
+ this.#editor.handleInput(keyData);
+ this.#requestRender();
+ }
+
+ #closeChat(): void {
+ this.#view = "table";
+ this.#chatAgentId = undefined;
+ this.#notice = undefined;
+ this.#detachLiveSession();
+ this.#refreshRows();
+ this.#requestRender();
+ }
+
+ #submitChatMessage(text: string): void {
+ const id = this.#chatAgentId;
+ const trimmed = text.trim();
+ if (!id || !trimmed) return;
+ this.#editor.setText("");
+ this.#notice = undefined;
+ void (async () => {
+ try {
+ // Revives a parked agent; returns the live session for running/idle.
+ const session = await this.#lifecycle().ensureLive(id);
+ this.#attachLiveSession();
+ // Steers a mid-turn agent; sends a normal prompt to an idle one.
+ await session.prompt(trimmed, { streamingBehavior: "steer" });
+ } catch (error) {
+ this.#notice = error instanceof Error ? error.message : String(error);
+ }
+ this.#scheduleChatRefresh();
+ this.#requestRender();
+ })();
+ this.#requestRender();
+ }
+
+ /** Viewer navigation (selection, paging, expand) for the chat transcript. Returns true when handled. */
+ #handleViewerNavigation(keyData: string): boolean {
+ const entryCount = this.#viewerEntries.length;
+
+ if (keyData === "j" || matchesSelectDown(keyData)) {
+ if (entryCount > 0) {
+ this.#selectedEntryIndex = Math.min(this.#selectedEntryIndex + 1, entryCount - 1);
+ }
+ this.#rebuildAndScroll();
+ return true;
+ }
+ if (keyData === "k" || matchesSelectUp(keyData)) {
+ if (entryCount > 0) {
+ this.#selectedEntryIndex = Math.max(this.#selectedEntryIndex - 1, 0);
+ }
+ this.#rebuildAndScroll();
+ return true;
+ }
+ if (matchesKey(keyData, "pageDown")) {
+ if (entryCount > 0) {
+ const prevIndex = this.#selectedEntryIndex;
+ this.#selectedEntryIndex = Math.min(this.#selectedEntryIndex + 5, entryCount - 1);
+ if (this.#selectedEntryIndex === prevIndex) {
+ this.#scrollOffset = Math.min(
+ this.#scrollOffset + PAGE_SIZE,
+ Math.max(0, this.#renderedLines.length - this.#viewportHeight),
+ );
+ }
+ } else {
+ this.#scrollOffset = Math.min(
+ this.#scrollOffset + PAGE_SIZE,
+ Math.max(0, this.#renderedLines.length - this.#viewportHeight),
+ );
+ }
+ this.#rebuildAndScroll();
+ return true;
+ }
+ if (matchesKey(keyData, "pageUp")) {
+ if (entryCount > 0) {
+ const prevIndex = this.#selectedEntryIndex;
+ this.#selectedEntryIndex = Math.max(this.#selectedEntryIndex - 5, 0);
+ if (this.#selectedEntryIndex === prevIndex) {
+ this.#scrollOffset = Math.max(this.#scrollOffset - PAGE_SIZE, 0);
+ }
+ } else {
+ this.#scrollOffset = Math.max(this.#scrollOffset - PAGE_SIZE, 0);
+ }
+ this.#rebuildAndScroll();
+ return true;
+ }
+ if (matchesKey(keyData, "enter") || keyData === "\r" || keyData === "\n") {
+ if (entryCount > 0 && this.#selectedEntryIndex < entryCount) {
+ if (this.#expandedEntries.has(this.#selectedEntryIndex)) {
+ this.#expandedEntries.delete(this.#selectedEntryIndex);
+ } else {
+ this.#expandedEntries.add(this.#selectedEntryIndex);
+ }
+ this.#rebuildAndScroll();
+ }
+ return true;
+ }
+ if (keyData === "G") {
+ if (entryCount > 0) this.#selectedEntryIndex = entryCount - 1;
+ this.#scrollOffset = Math.max(0, this.#renderedLines.length - this.#viewportHeight);
+ this.#rebuildAndScroll();
+ return true;
+ }
+ if (keyData === "g") {
+ this.#selectedEntryIndex = 0;
+ this.#scrollOffset = 0;
+ this.#rebuildAndScroll();
+ return true;
+ }
+ return false;
+ }
+
+ /** Rebuild transcript lines (which depend on selectedEntryIndex/expandedEntries) and scroll to selection */
+ #rebuildAndScroll(): void {
+ // Resume auto-scrolling once selection returns to the last entry
+ this.#wasAtBottom = this.#selectedEntryIndex >= this.#viewerEntries.length - 1;
+ this.#rebuildChatContent();
+ this.#scrollToSelectedEntry();
+ this.#requestRender();
+ }
+
+ #scrollToSelectedEntry(): void {
+ if (this.#viewerEntries.length === 0) return;
+ const entry = this.#viewerEntries[this.#selectedEntryIndex];
+ if (!entry) return;
+
+ const entryTop = entry.lineStart;
+ const entryBottom = entry.lineStart + entry.lineCount;
+
+ if (entry.lineCount >= this.#viewportHeight) {
+ // Entry taller than viewport: only snap when it's completely out of view.
+ if (this.#scrollOffset + this.#viewportHeight <= entryTop) {
+ this.#scrollOffset = Math.max(0, entryTop - 1);
+ } else if (this.#scrollOffset >= entryBottom) {
+ this.#scrollOffset = Math.max(0, entryBottom - this.#viewportHeight);
+ }
+ } else {
+ // Entry fits in viewport: ensure it's fully visible
+ if (entryTop < this.#scrollOffset) {
+ this.#scrollOffset = Math.max(0, entryTop - 1);
+ }
+ if (entryBottom > this.#scrollOffset + this.#viewportHeight) {
+ this.#scrollOffset = Math.max(0, entryBottom - this.#viewportHeight + 1);
+ }
+ }
+ }
+
+ // ========================================================================
+ // Transcript rendering (absorbed from the session observer overlay)
+ // ========================================================================
+
#buildTranscriptLines(messageEntries: SessionMessageEntry[], lines: string[]): void {
// Build a tool call ID -> tool result map
const toolResults = new Map();
@@ -308,7 +729,7 @@ export class SessionObserverOverlayComponent extends Container {
if (msg.content.length === 0 && msg.errorMessage && !isSilentAbort(msg.errorMessage)) {
const startLine = lines.length;
const isSelected = entryIndex === this.#selectedEntryIndex;
- const cursor = isSelected ? theme.fg("accent", "▶") : " ";
+ const cursor = isSelected ? theme.fg("accent", theme.nav.cursor) : " ";
lines.push("");
const errorLines = msg.errorMessage.split("\n");
const maxWidth = contentWidth();
@@ -370,7 +791,7 @@ export class SessionObserverOverlayComponent extends Container {
const isSelected = entryIndex === this.#selectedEntryIndex;
const isExpanded = this.#expandedEntries.has(entryIndex);
const label = msg.role === "developer" ? "System" : "User";
- const cursor = isSelected ? theme.fg("accent", "▶") : " ";
+ const cursor = isSelected ? theme.fg("accent", theme.nav.cursor) : " ";
lines.push("");
if (isExpanded) {
lines.push(`${cursor} ${theme.fg("dim", `[${label}]`)}`);
@@ -402,7 +823,7 @@ export class SessionObserverOverlayComponent extends Container {
}
#renderThinkingLines(lines: string[], thinking: string, expanded: boolean, selected: boolean): void {
- const cursor = selected ? theme.fg("accent", "▶") : " ";
+ const cursor = selected ? theme.fg("accent", theme.nav.cursor) : " ";
const maxChars = expanded ? MAX_THINKING_CHARS_EXPANDED : MAX_THINKING_CHARS_COLLAPSED;
const truncated = thinking.length > maxChars;
const expandLabel = !expanded && truncated ? theme.fg("dim", " ↵") : "";
@@ -435,7 +856,7 @@ export class SessionObserverOverlayComponent extends Container {
}
#renderTextLines(lines: string[], text: string, expanded: boolean, selected: boolean): void {
- const cursor = selected ? theme.fg("accent", "▶") : " ";
+ const cursor = selected ? theme.fg("accent", theme.nav.cursor) : " ";
lines.push("");
lines.push(`${cursor} ${theme.fg("muted", "Response")}`);
@@ -467,7 +888,7 @@ export class SessionObserverOverlayComponent extends Container {
expanded: boolean,
selected: boolean,
): void {
- const cursor = selected ? theme.fg("accent", "▶") : " ";
+ const cursor = selected ? theme.fg("accent", theme.nav.cursor) : " ";
lines.push("");
// Tool call header
@@ -565,14 +986,21 @@ export class SessionObserverOverlayComponent extends Container {
case "ast_edit":
return args.path ? `path: ${args.path}` : "";
case "task": {
- const tasks = args.tasks;
- return Array.isArray(tasks) ? `${tasks.length} task(s)` : "";
+ const target =
+ typeof args.resume === "string" && args.resume
+ ? `resume ${args.resume}`
+ : typeof args.agent === "string"
+ ? args.agent
+ : "";
+ const id = typeof args.id === "string" && args.id ? ` ${args.id}` : "";
+ return `${target}${id}`.trim();
}
default: {
const parts: string[] = [];
let total = 0;
- for (const [key, value] of Object.entries(args)) {
+ for (const key in args) {
if (key.startsWith("_")) continue;
+ const value = args[key];
const v = typeof value === "string" ? value : JSON.stringify(value);
const entry = `${key}: ${replaceTabs(v ?? "")}`;
if (total + entry.length > MAX_TOOL_ARGS_CHARS) break;
@@ -592,7 +1020,7 @@ export class SessionObserverOverlayComponent extends Container {
const fromByte = this.#transcriptCache?.bytesRead ?? 0;
const result = readFileIncremental(sessionFile, fromByte);
if (!result) {
- logger.debug("Session observer: failed to read session file", { path: sessionFile });
+ logger.debug("Agent hub: failed to read session file", { path: sessionFile });
return this.#transcriptCache?.entries ?? null;
}
@@ -627,213 +1055,9 @@ export class SessionObserverOverlayComponent extends Container {
}
return this.#transcriptCache.entries;
}
-
- #navigateBack(): boolean {
- if (this.#navigationStack.length === 0) return false;
- const prev = this.#navigationStack.pop()!;
- this.#selectedSessionId = prev.sessionId;
- this.#transcriptCache = undefined;
- this.#scrollOffset = 0;
- this.#selectedEntryIndex = 0;
- this.#expandedEntries.clear();
- this.#rebuildViewerContent();
- return true;
- }
-
- handleInput(keyData: string): void {
- // Ctrl+S (observe key) always closes the overlay
- for (const key of this.#observeKeys) {
- if (matchesKey(keyData, key)) {
- this.#onDone();
- return;
- }
- }
-
- this.#handleViewerInput(keyData);
- }
-
- #handleViewerInput(keyData: string): void {
- const entryCount = this.#viewerEntries.length;
-
- // Escape — pop breadcrumb navigation or close overlay
- if (matchesKey(keyData, "escape")) {
- if (!this.#navigateBack()) {
- this.#onDone();
- }
- return;
- }
-
- // j / down — move selection down
- if (keyData === "j" || matchesSelectDown(keyData)) {
- if (entryCount > 0) {
- this.#selectedEntryIndex = Math.min(this.#selectedEntryIndex + 1, entryCount - 1);
- }
- this.#rebuildAndScroll();
- return;
- }
-
- // k / up — move selection up
- if (keyData === "k" || matchesSelectUp(keyData)) {
- if (entryCount > 0) {
- this.#selectedEntryIndex = Math.max(this.#selectedEntryIndex - 1, 0);
- }
- this.#rebuildAndScroll();
- return;
- }
-
- // Page Down
- if (matchesKey(keyData, "pageDown")) {
- if (entryCount > 0) {
- const prevIndex = this.#selectedEntryIndex;
- this.#selectedEntryIndex = Math.min(this.#selectedEntryIndex + 5, entryCount - 1);
- // If selection didn't move (bottom of list or single oversized entry), fall back to line scroll
- if (this.#selectedEntryIndex === prevIndex) {
- this.#scrollOffset = Math.min(
- this.#scrollOffset + PAGE_SIZE,
- Math.max(0, this.#renderedLines.length - this.#viewportHeight),
- );
- }
- } else {
- this.#scrollOffset = Math.min(
- this.#scrollOffset + PAGE_SIZE,
- Math.max(0, this.#renderedLines.length - this.#viewportHeight),
- );
- }
- this.#rebuildAndScroll();
- return;
- }
-
- // Page Up
- if (matchesKey(keyData, "pageUp")) {
- if (entryCount > 0) {
- const prevIndex = this.#selectedEntryIndex;
- this.#selectedEntryIndex = Math.max(this.#selectedEntryIndex - 5, 0);
- // If selection didn't move (top of list or single oversized entry), fall back to line scroll
- if (this.#selectedEntryIndex === prevIndex) {
- this.#scrollOffset = Math.max(this.#scrollOffset - PAGE_SIZE, 0);
- }
- } else {
- this.#scrollOffset = Math.max(this.#scrollOffset - PAGE_SIZE, 0);
- }
- this.#rebuildAndScroll();
- return;
- }
-
- // Enter — toggle expand/collapse, or dive into nested session
- if (matchesKey(keyData, "enter") || keyData === "\r" || keyData === "\n") {
- if (entryCount > 0 && this.#selectedEntryIndex < entryCount) {
- // Toggle expand/collapse
- if (this.#expandedEntries.has(this.#selectedEntryIndex)) {
- this.#expandedEntries.delete(this.#selectedEntryIndex);
- } else {
- this.#expandedEntries.add(this.#selectedEntryIndex);
- }
- this.#rebuildAndScroll();
- }
- return;
- }
-
- // G — jump to bottom
- if (keyData === "G") {
- if (entryCount > 0) this.#selectedEntryIndex = entryCount - 1;
- this.#scrollOffset = Math.max(0, this.#renderedLines.length - this.#viewportHeight);
- this.#rebuildAndScroll();
- return;
- }
-
- // g — jump to top
- if (keyData === "g") {
- this.#selectedEntryIndex = 0;
- this.#scrollOffset = 0;
- this.#rebuildAndScroll();
- return;
- }
-
- // ] / → / Tab — next sub-agent session
- if (keyData === "]" || matchesKey(keyData, "tab") || matchesKey(keyData, "right")) {
- this.#cycleSession(1);
- return;
- }
-
- // [ / ← / Shift+Tab — previous sub-agent session
- if (keyData === "[" || matchesKey(keyData, "shift+tab") || matchesKey(keyData, "left")) {
- this.#cycleSession(-1);
- return;
- }
- }
-
- /** Get the ordered list of sub-agent session IDs (excludes main) */
- #getSubagentSessionIds(): string[] {
- return this.#registry
- .getSessions()
- .filter(s => s.kind === "subagent")
- .map(s => s.id);
- }
-
- /** Cycle to next (+1) or previous (-1) sub-agent session */
- #cycleSession(direction: 1 | -1): void {
- const ids = this.#getSubagentSessionIds();
- if (ids.length <= 1) return;
- const currentIdx = ids.indexOf(this.#selectedSessionId ?? "");
- if (currentIdx < 0) return;
- const nextIdx = (currentIdx + direction + ids.length) % ids.length;
- this.#selectedSessionId = ids[nextIdx];
- this.#transcriptCache = undefined;
- this.#scrollOffset = 0;
- this.#selectedEntryIndex = 0;
- this.#expandedEntries.clear();
- this.#wasAtBottom = true;
- this.#rebuildViewerContent();
- // Auto-scroll to bottom: select last entry
- if (this.#viewerEntries.length > 0) {
- this.#selectedEntryIndex = this.#viewerEntries.length - 1;
- this.#wasAtBottom = true;
- this.#rebuildViewerContent();
- }
- }
-
- /** Rebuild transcript lines (which depend on selectedEntryIndex/expandedEntries) and scroll to selection */
- #rebuildAndScroll(): void {
- // Resume auto-scrolling once selection returns to the last entry
- this.#wasAtBottom = this.#selectedEntryIndex >= this.#viewerEntries.length - 1;
- this.#rebuildViewerContent();
- this.#scrollToSelectedEntry();
- }
-
- #scrollToSelectedEntry(): void {
- if (this.#viewerEntries.length === 0) return;
- const entry = this.#viewerEntries[this.#selectedEntryIndex];
- if (!entry) return;
-
- const entryTop = entry.lineStart;
- const entryBottom = entry.lineStart + entry.lineCount;
-
- if (entry.lineCount >= this.#viewportHeight) {
- // Entry taller than viewport: only snap when it's completely out of view.
- // If the viewport overlaps the entry at all, the user may be paging within it.
- if (this.#scrollOffset + this.#viewportHeight <= entryTop) {
- // Viewport is entirely above the entry — snap to entry top
- this.#scrollOffset = Math.max(0, entryTop - 1);
- } else if (this.#scrollOffset >= entryBottom) {
- // Viewport is entirely below the entry — snap to show entry bottom
- this.#scrollOffset = Math.max(0, entryBottom - this.#viewportHeight);
- }
- // Otherwise: viewport overlaps the entry — don't override manual scroll
- } else {
- // Entry fits in viewport: ensure it's fully visible
- if (entryTop < this.#scrollOffset) {
- this.#scrollOffset = Math.max(0, entryTop - 1);
- }
- if (entryBottom > this.#scrollOffset + this.#viewportHeight) {
- this.#scrollOffset = Math.max(0, entryBottom - this.#viewportHeight + 1);
- }
- }
- }
}
-// Sync helpers for render path
-import * as fs from "node:fs";
-
+// Sync helper for the render path
function readFileIncremental(filePath: string, fromByte: number): { text: string; newSize: number } | null {
try {
const stat = fs.statSync(filePath);
diff --git a/packages/coding-agent/src/modes/components/compaction-summary-message.ts b/packages/coding-agent/src/modes/components/compaction-summary-message.ts
index 55363a7c2..d2ebffb10 100644
--- a/packages/coding-agent/src/modes/components/compaction-summary-message.ts
+++ b/packages/coding-agent/src/modes/components/compaction-summary-message.ts
@@ -1,51 +1,87 @@
-import { Box, Markdown, Spacer, Text } from "@oh-my-pi/pi-tui";
+import { Box, type Component, Markdown } from "@oh-my-pi/pi-tui";
import { getMarkdownTheme, theme } from "../../modes/theme/theme";
import type { CompactionSummaryMessage } from "../../session/messages";
/**
- * Component that renders a compaction message with collapsed/expanded state.
- * Uses same background color as hook messages for visual consistency.
+ * Compaction point in the transcript, rendered as a slim horizontal divider:
+ *
+ * ──────── 📷 compacted · ctrl+o ────────
+ *
+ * The conversation above the divider stays visible (display transcript keeps
+ * full history); only the LLM context was reset. Expanding (ctrl+o) reveals
+ * the compaction summary below the divider.
*/
-export class CompactionSummaryMessageComponent extends Box {
+export class CompactionSummaryMessageComponent implements Component {
#expanded = false;
+ #cache?: { width: number; lines: string[] };
+ #detail?: Box;
- constructor(private readonly message: CompactionSummaryMessage) {
- super(1, 1, t => theme.bg("customMessageBg", t));
- this.#updateDisplay();
- }
+ constructor(private readonly message: CompactionSummaryMessage) {}
setExpanded(expanded: boolean): void {
+ if (this.#expanded === expanded) return;
this.#expanded = expanded;
- this.#updateDisplay();
+ this.#cache = undefined;
}
- override invalidate(): void {
- super.invalidate();
- this.#updateDisplay();
+ invalidate(): void {
+ this.#cache = undefined;
+ // Theme may have changed — rebuild the detail box lazily on next render.
+ this.#detail = undefined;
}
- #updateDisplay(): void {
- this.clear();
-
- const tokenStr = this.message.tokensBefore.toLocaleString();
- const label = theme.fg("customMessageLabel", theme.bold("[compaction]"));
- this.addChild(new Text(label, 0, 0));
- this.addChild(new Spacer(1));
-
- if (this.#expanded) {
- const header = `**Compacted from ${tokenStr} tokens**\n\n`;
- this.addChild(
- new Markdown(header + this.message.summary, 0, 0, getMarkdownTheme(), {
- color: (text: string) => theme.fg("customMessageText", text),
- }),
- );
- } else {
- this.addChild(
- new Text(theme.fg("customMessageText", `Compacted from ${tokenStr} tokens (ctrl+o to expand)`), 0, 0),
- );
- if (this.message.shortSummary) {
- this.addChild(new Text(theme.fg("customMessageText", this.message.shortSummary), 0, 1));
- }
+ render(width: number): readonly string[] {
+ width = Math.max(1, width);
+ if (this.#cache?.width === width) {
+ return this.#cache.lines;
}
+ const lines = this.#expanded
+ ? ["", this.#divider(width), "", ...this.#detailBox().render(width)]
+ : ["", this.#divider(width), ""];
+ this.#cache = { width, lines };
+ return lines;
+ }
+
+ #divider(width: number): string {
+ const rule = theme.tree.horizontal;
+ const label = `${theme.icon.camera} compacted`;
+ // sep.dot ships pre-padded (" · "); trim so the hint joins with single spaces.
+ const hint = `${theme.sep.dot.trim()} ctrl+o`;
+ const plainWidth = Bun.stringWidth(`${label} ${hint}`, { countAnsiEscapeCodes: false });
+ // ` label hint ` framed by rules on both sides.
+ const remaining = width - plainWidth - 2;
+ if (remaining < 4) {
+ // Too narrow for a framed rule — emit the bare label.
+ return theme.fg("muted", label);
+ }
+ const left = Math.floor(remaining / 2);
+ const right = remaining - left;
+ return (
+ theme.fg("dim", rule.repeat(left)) +
+ ` ${theme.fg("muted", label)} ${theme.fg("dim", hint)} ` +
+ theme.fg("dim", rule.repeat(right))
+ );
+ }
+
+ #detailBox(): Box {
+ if (this.#detail) return this.#detail;
+ const box = new Box(1, 1, t => theme.bg("customMessageBg", t));
+ const tokenStr = this.message.tokensBefore.toLocaleString();
+ const frameCount = this.message.images?.length ?? 0;
+ const frameNote =
+ frameCount > 0 ? `\n\n_${frameCount} snapcompact frame${frameCount === 1 ? "" : "s"} attached_` : "";
+ box.addChild(
+ new Markdown(
+ `**Compacted from ${tokenStr} tokens**\n\n${this.message.summary}${frameNote}`,
+ 0,
+ 0,
+ getMarkdownTheme(),
+ {
+ color: (text: string) => theme.fg("customMessageText", text),
+ },
+ ),
+ );
+ this.#detail = box;
+ return box;
}
}
diff --git a/packages/coding-agent/src/modes/components/custom-editor.ts b/packages/coding-agent/src/modes/components/custom-editor.ts
index e6f04e6ff..d5ffa1e04 100644
--- a/packages/coding-agent/src/modes/components/custom-editor.ts
+++ b/packages/coding-agent/src/modes/components/custom-editor.ts
@@ -175,6 +175,8 @@ export class CustomEditor extends Editor {
onDequeue?: () => void;
/** Called when Caps Lock is pressed. */
onCapsLock?: () => void;
+ /** Called when left-arrow is pressed while the editor is empty (cursor necessarily at start). */
+ onLeftAtStart?: () => void;
/** Custom key handlers from extensions and non-built-in app actions. */
#customKeyHandlers = new Map void>();
@@ -257,6 +259,14 @@ export class CustomEditor extends Editor {
const parsedKey = parseKey(data);
const canonical = parsedKey !== undefined ? canonicalKeyId(parsedKey) : undefined;
+ // Left-arrow on an empty editor: surface for the agent-hub double-tap
+ // gesture. Plain "left" only — modified arrows and any in-text cursor
+ // movement fall through to normal handling.
+ if (canonical === "left" && this.onLeftAtStart && this.getText().trim() === "") {
+ this.onLeftAtStart();
+ return;
+ }
+
if (canonical !== undefined) {
// Intercept configured image paste (async - fires and handles result)
if (this.#matchesAction(canonical, "app.clipboard.pasteImage") && this.onPasteImage) {
diff --git a/packages/coding-agent/src/modes/components/tool-execution.ts b/packages/coding-agent/src/modes/components/tool-execution.ts
index e63785818..284f86e6d 100644
--- a/packages/coding-agent/src/modes/components/tool-execution.ts
+++ b/packages/coding-agent/src/modes/components/tool-execution.ts
@@ -19,6 +19,7 @@ import type { Theme } from "../../modes/theme/theme";
import { theme } from "../../modes/theme/theme";
import { BASH_DEFAULT_PREVIEW_LINES } from "../../tools/bash";
import { EVAL_DEFAULT_PREVIEW_LINES } from "../../tools/eval";
+import { isWaitingPollDetails } from "../../tools/job";
import {
formatArgsInline,
JSON_TREE_MAX_DEPTH_COLLAPSED,
@@ -194,6 +195,11 @@ export class ToolExecutionComponent extends Container {
// sealed the block stays in the transcript's repaintable live region so a
// late result still repaints instead of stranding the streaming preview.
#sealed = false;
+ // A `job` poll result whose watched jobs are all still running. Such a
+ // block never finalizes (stays in the transcript live region) so a
+ // follow-up `job` call can displace it instead of stacking another
+ // "waiting on N jobs" frame. Cleared by `seal()`.
+ #displaceable = false;
#renderState: {
spinnerFrame?: number;
expanded: boolean;
@@ -359,6 +365,11 @@ export class ToolExecutionComponent extends Container {
): void {
this.#result = result;
this.#isPartial = isPartial;
+ // A `job` poll that found every watched job still running is transient
+ // "still waiting" chrome; keep the block displaceable so the next `job`
+ // call replaces it instead of stacking another waiting frame (see the
+ // event controller's displaceable-poll bookkeeping).
+ this.#displaceable = this.#toolName === "job" && result.isError !== true && isWaitingPollDetails(result.details);
// When tool is complete, ensure args are marked complete so spinner stops
if (!isPartial) {
this.#argsComplete = true;
@@ -425,7 +436,11 @@ export class ToolExecutionComponent extends Container {
(this.#result?.details as { async?: { state?: string } } | undefined)?.async?.state === "running";
const isBackgroundAsyncTask = this.#toolName === "task" && isBackgroundAsyncRunning;
const isPartialTask = this.#isPartial && this.#toolName === "task" && !isBackgroundAsyncTask;
- const needsSpinner = isStreamingArgs || isPartialTask;
+ // A displaceable waiting poll keeps its spinner ticking: it reads as one
+ // persistent live poll, and the changing leading glyph keeps the
+ // transcript's stable-prefix ratchet from committing rows of a block
+ // that a follow-up `job` call may remove.
+ const needsSpinner = isStreamingArgs || isPartialTask || this.isDisplaceableBlock();
if (needsSpinner && !this.#spinnerInterval) {
const now = performance.now();
const frameCount = theme.spinnerFrames.length;
@@ -513,6 +528,9 @@ export class ToolExecutionComponent extends Container {
isTranscriptBlockFinalized(): boolean {
if (this.#sealed) return true;
if (this.#result === undefined) return false;
+ // A displaceable waiting poll stays live: its rows are kept out of
+ // native scrollback so a follow-up `job` call can remove the block.
+ if (this.#displaceable) return false;
if (!this.#isPartial) return true;
// Partial result: a background async tool is accepted to freeze (the agent
// continues while it runs and would otherwise pin an unbounded live region);
@@ -528,11 +546,23 @@ export class ToolExecutionComponent extends Container {
seal(): void {
if (this.#sealed) return;
this.#sealed = true;
+ this.#displaceable = false;
this.stopAnimation();
this.#updateDisplay();
this.#ui.requestRender();
}
+ /**
+ * Whether this block is a waiting `job` poll (every watched job still
+ * running) that has not been sealed. Such a block never finalized, so none
+ * of its rows entered native scrollback (the ticking spinner keeps the
+ * stable-prefix ratchet at zero) and the whole block can be removed when a
+ * follow-up `job` call supersedes it.
+ */
+ isDisplaceableBlock(): boolean {
+ return this.#displaceable && !this.#sealed;
+ }
+
/**
* Stop spinner animation and cleanup resources.
*/
diff --git a/packages/coding-agent/src/modes/controllers/event-controller.ts b/packages/coding-agent/src/modes/controllers/event-controller.ts
index d9a48f3fb..2c12e1d45 100644
--- a/packages/coding-agent/src/modes/controllers/event-controller.ts
+++ b/packages/coding-agent/src/modes/controllers/event-controller.ts
@@ -77,6 +77,11 @@ export class EventController {
// Insertion-ordered IRC cards not yet retired; values are the transcript
// components each card contributed (see #retireIrcCard for the guard).
#liveIrcCards = new Map();
+ // Most recent `job` tool block whose result still had every watched job
+ // running. Kept un-finalized (live) so the next `job` call displaces it —
+ // one persistent poll instead of a stack of "waiting on N jobs" frames —
+ // and sealed in place the moment anything else lands below it.
+ #displaceablePollComponent: ToolExecutionComponent | undefined = undefined;
#streamingReveal: StreamingRevealController;
#handlers: AgentSessionEventHandlers;
@@ -282,6 +287,7 @@ export class EventController {
const signature = `${textContent}\u0000${imageCount}`;
this.#resetReadGroup();
+ this.#resolveDisplaceablePoll();
const wasOptimistic = this.ctx.optimisticUserMessageSignature === signature;
const wasLocallySubmitted = this.ctx.locallySubmittedUserSignatures.delete(signature) || wasOptimistic;
if (!wasOptimistic) {
@@ -389,6 +395,28 @@ export class EventController {
}
}
+ /**
+ * Resolve the pending displaceable poll block before the next block lands.
+ * A follow-up `job` call displaces it — the stale "waiting on N jobs" frame
+ * is removed so repeated polls read as one persistent poll — while anything
+ * else seals it in place as final history. Removal is safe only because a
+ * displaceable block never finalizes: commits stop at the first live block,
+ * so none of its rows have entered native scrollback (see
+ * ToolExecutionComponent.isDisplaceableBlock).
+ */
+ #resolveDisplaceablePoll(nextToolName?: string): void {
+ const previous = this.#displaceablePollComponent;
+ if (!previous) return;
+ this.#displaceablePollComponent = undefined;
+ if (nextToolName === "job" && previous.isDisplaceableBlock()) {
+ this.ctx.chatContainer.removeChild(previous);
+ }
+ // Sealing stops the waiting-poll spinner and freezes the block (for a
+ // just-removed component it only clears the animation timer).
+ previous.seal();
+ this.ctx.ui.requestRender();
+ }
+
async #handleNotice(event: Extract): Promise {
const message = event.source ? `${event.source}: ${event.message}` : event.message;
if (event.level === "error") {
@@ -444,6 +472,7 @@ export class EventController {
continue;
}
if (!readArgsTargetInternalUrl(content.arguments)) {
+ if (!this.ctx.pendingTools.has(content.id)) this.#resolveDisplaceablePoll(content.name);
this.#trackReadToolCall(content.id, content.arguments);
const component = this.ctx.pendingTools.get(content.id);
if (component) {
@@ -465,6 +494,7 @@ export class EventController {
? { ...content.arguments, __partialJson: content.partialJson }
: content.arguments;
if (!this.ctx.pendingTools.has(content.id)) {
+ this.#resolveDisplaceablePoll(content.name);
this.#resetReadGroup();
const tool = this.ctx.session.getToolByName(content.name);
const component = new ToolExecutionComponent(
@@ -561,6 +591,9 @@ export class EventController {
component.seal();
}
}
+ // These calls will never produce a result either, so the tracked
+ // waiting poll cannot be displaced anymore — freeze it in place.
+ this.#resolveDisplaceablePoll();
}
this.#lastAssistantComponent = this.ctx.streamingComponent;
this.#lastAssistantComponent.setUsageInfo(event.message.usage);
@@ -589,6 +622,7 @@ export class EventController {
async #handleToolExecutionStart(event: Extract): Promise {
this.#updateWorkingMessageFromIntent(event.intent);
if (!this.ctx.pendingTools.has(event.toolCallId)) {
+ this.#resolveDisplaceablePoll(event.toolName);
if (event.toolName === "read" && readArgsHaveTarget(event.args) && !readArgsTargetInternalUrl(event.args)) {
this.#trackReadToolCall(event.toolCallId, event.args);
const component = this.ctx.pendingTools.get(event.toolCallId);
@@ -697,6 +731,14 @@ export class EventController {
this.ctx.pendingTools.delete(event.toolCallId);
this.#backgroundToolCallIds.delete(event.toolCallId);
}
+ if (
+ event.toolName === "job" &&
+ component instanceof ToolExecutionComponent &&
+ component.isDisplaceableBlock()
+ ) {
+ // Remember the waiting poll so the next `job` call can displace it.
+ this.#displaceablePollComponent = component;
+ }
this.ctx.ui.requestRender();
}
}
@@ -759,6 +801,9 @@ export class EventController {
this.#readToolCallArgs.clear();
this.#readToolCallAssistantComponents.clear();
this.#resetReadGroup();
+ // The turn is over: nothing else lands this turn, so the waiting poll is
+ // final history — seal it instead of letting its spinner tick while idle.
+ this.#resolveDisplaceablePoll();
this.#lastAssistantComponent = undefined;
this.ctx.ui.requestRender();
this.#scheduleIdleCompaction();
diff --git a/packages/coding-agent/src/modes/controllers/extension-ui-controller.ts b/packages/coding-agent/src/modes/controllers/extension-ui-controller.ts
index 3b6abe82e..f0f2027f3 100644
--- a/packages/coding-agent/src/modes/controllers/extension-ui-controller.ts
+++ b/packages/coding-agent/src/modes/controllers/extension-ui-controller.ts
@@ -140,7 +140,7 @@ export class ExtensionUiController {
reload: async () => {
await this.ctx.session.reload();
this.ctx.chatContainer.clear();
- this.ctx.renderInitialMessages(undefined, { clearTerminalHistory: true });
+ this.ctx.renderInitialMessages({ clearTerminalHistory: true });
await this.ctx.reloadTodos();
this.ctx.showStatus("Reloaded session");
},
@@ -197,7 +197,7 @@ export class ExtensionUiController {
// Update UI
this.ctx.chatContainer.clear();
- this.ctx.renderInitialMessages(undefined, { clearTerminalHistory: true });
+ this.ctx.renderInitialMessages({ clearTerminalHistory: true });
await this.ctx.reloadTodos();
this.ctx.editor.setText(result.selectedText);
this.ctx.showStatus("Branched to new session");
@@ -212,7 +212,7 @@ export class ExtensionUiController {
// Update UI
this.ctx.chatContainer.clear();
- this.ctx.renderInitialMessages(undefined, { clearTerminalHistory: true });
+ this.ctx.renderInitialMessages({ clearTerminalHistory: true });
await this.ctx.reloadTodos();
if (result.editorText && !this.ctx.editor.getText().trim()) {
this.ctx.editor.setText(result.editorText);
@@ -230,7 +230,7 @@ export class ExtensionUiController {
}
setSessionTerminalTitle(this.ctx.sessionManager.getSessionName(), this.ctx.sessionManager.getCwd());
this.ctx.chatContainer.clear();
- this.ctx.renderInitialMessages(undefined, { clearTerminalHistory: true });
+ this.ctx.renderInitialMessages({ clearTerminalHistory: true });
await this.ctx.reloadTodos();
return { cancelled: false };
},
@@ -376,7 +376,7 @@ export class ExtensionUiController {
reload: async () => {
await this.ctx.session.reload();
this.ctx.chatContainer.clear();
- this.ctx.renderInitialMessages(undefined, { clearTerminalHistory: true });
+ this.ctx.renderInitialMessages({ clearTerminalHistory: true });
await this.ctx.reloadTodos();
this.ctx.showStatus("Reloaded session");
},
@@ -426,7 +426,7 @@ export class ExtensionUiController {
// Update UI
this.ctx.chatContainer.clear();
- this.ctx.renderInitialMessages(undefined, { clearTerminalHistory: true });
+ this.ctx.renderInitialMessages({ clearTerminalHistory: true });
await this.ctx.reloadTodos();
this.ctx.editor.setText(result.selectedText);
this.ctx.showStatus("Branched to new session");
@@ -441,7 +441,7 @@ export class ExtensionUiController {
// Update UI
this.ctx.chatContainer.clear();
- this.ctx.renderInitialMessages(undefined, { clearTerminalHistory: true });
+ this.ctx.renderInitialMessages({ clearTerminalHistory: true });
await this.ctx.reloadTodos();
if (result.editorText && !this.ctx.editor.getText().trim()) {
this.ctx.editor.setText(result.editorText);
@@ -458,7 +458,7 @@ export class ExtensionUiController {
return { cancelled: true };
}
this.ctx.chatContainer.clear();
- this.ctx.renderInitialMessages(undefined, { clearTerminalHistory: true });
+ this.ctx.renderInitialMessages({ clearTerminalHistory: true });
await this.ctx.reloadTodos();
return { cancelled: false };
},
diff --git a/packages/coding-agent/src/modes/controllers/input-controller.ts b/packages/coding-agent/src/modes/controllers/input-controller.ts
index de55b97ad..39a8dca25 100644
--- a/packages/coding-agent/src/modes/controllers/input-controller.ts
+++ b/packages/coding-agent/src/modes/controllers/input-controller.ts
@@ -235,10 +235,26 @@ export class InputController {
for (const key of this.ctx.keybindings.getKeys("app.clipboard.copyLine")) {
this.ctx.editor.setCustomKeyHandler(key, () => this.handleCopyCurrentLine());
}
- for (const key of this.ctx.keybindings.getKeys("app.session.observe")) {
- this.ctx.editor.setCustomKeyHandler(key, () => this.ctx.showSessionObserver());
+ const hubKeys = new Set([
+ ...this.ctx.keybindings.getKeys("app.agents.hub"),
+ ...this.ctx.keybindings.getKeys("app.session.observe"),
+ ]);
+ for (const key of hubKeys) {
+ this.ctx.editor.setCustomKeyHandler(key, () => this.ctx.showAgentHub());
}
+ // Double-tap left arrow on an empty editor opens the agent hub — same
+ // 500ms window as the double-escape state machine above.
+ this.ctx.editor.onLeftAtStart = () => {
+ const now = Date.now();
+ if (now - this.ctx.lastLeftTapTime < 500) {
+ this.ctx.lastLeftTapTime = 0;
+ this.ctx.showAgentHub();
+ } else {
+ this.ctx.lastLeftTapTime = now;
+ }
+ };
+
this.#setupEnhancedPaste();
this.ctx.editor.onChange = (text: string) => {
diff --git a/packages/coding-agent/src/modes/controllers/selector-controller.ts b/packages/coding-agent/src/modes/controllers/selector-controller.ts
index d8dd97a5b..54143cc7a 100644
--- a/packages/coding-agent/src/modes/controllers/selector-controller.ts
+++ b/packages/coding-agent/src/modes/controllers/selector-controller.ts
@@ -40,6 +40,7 @@ import { shortenPath } from "../../tools/render-utils";
import { copyToClipboard } from "../../utils/clipboard";
import { setSessionTerminalTitle } from "../../utils/title-generator";
import { AgentDashboard } from "../components/agent-dashboard";
+import { AgentHubOverlayComponent } from "../components/agent-hub";
import { AssistantMessageComponent } from "../components/assistant-message";
import { CopySelectorComponent } from "../components/copy-selector";
import { ExtensionDashboard } from "../components/extensions";
@@ -47,7 +48,6 @@ import { HistorySearchComponent } from "../components/history-search";
import { ModelSelectorComponent } from "../components/model-selector";
import { OAuthSelectorComponent } from "../components/oauth-selector";
import { PluginSelectorComponent } from "../components/plugin-selector";
-import { SessionObserverOverlayComponent } from "../components/session-observer-overlay";
import { SessionSelectorComponent } from "../components/session-selector";
import { SettingsSelectorComponent } from "../components/settings-selector";
import { ToolExecutionComponent } from "../components/tool-execution";
@@ -578,7 +578,7 @@ export class SelectorController {
}
this.ctx.chatContainer.clear();
- this.ctx.renderInitialMessages(undefined, { clearTerminalHistory: true });
+ this.ctx.renderInitialMessages({ clearTerminalHistory: true });
this.ctx.editor.setText(result.selectedText);
done();
this.ctx.showStatus("Branched to new session");
@@ -719,9 +719,10 @@ export class SelectorController {
return;
}
- // Update UI — pass the context built by navigateTree to skip a second O(N) walk.
+ // Update UI — rebuild the display transcript for the new leaf (the
+ // context from navigateTree is the LLM context, not the transcript).
this.ctx.chatContainer.clear();
- this.ctx.renderInitialMessages(result.sessionContext, { clearTerminalHistory: true });
+ this.ctx.renderInitialMessages({ clearTerminalHistory: true });
await this.ctx.reloadTodos();
if (result.editorText && !this.ctx.editor.getText().trim()) {
this.ctx.editor.setText(result.editorText);
@@ -846,7 +847,7 @@ export class SelectorController {
this.ctx.statusLine.setSessionStartTime(Date.now());
this.ctx.updateEditorTopBorder();
this.ctx.updateEditorBorderColor();
- this.ctx.renderInitialMessages(undefined, { clearTerminalHistory: true });
+ this.ctx.renderInitialMessages({ clearTerminalHistory: true });
await this.ctx.reloadTodos();
this.ctx.ui.requestRender(true, { clearScrollback: true });
return true;
@@ -871,7 +872,7 @@ export class SelectorController {
// Clear and re-render the chat
this.ctx.chatContainer.clear();
- this.ctx.renderInitialMessages(undefined, { clearTerminalHistory: true });
+ this.ctx.renderInitialMessages({ clearTerminalHistory: true });
await this.ctx.reloadTodos();
this.ctx.showStatus(movedProject ? `Resumed session in ${shortenPath(newCwd)}` : "Resumed session");
}
@@ -1074,31 +1075,34 @@ export class SelectorController {
});
}
- showSessionObserver(registry: SessionObserverRegistry): void {
- const observeKeys = this.ctx.keybindings.getKeys("app.session.observe");
- let cleanup: (() => void) | undefined;
+ showAgentHub(observers: SessionObserverRegistry): void {
+ const hubKeys = [
+ ...this.ctx.keybindings.getKeys("app.agents.hub"),
+ ...this.ctx.keybindings.getKeys("app.session.observe"),
+ ];
+ let hub: AgentHubOverlayComponent | undefined;
let overlayHandle: OverlayHandle | undefined;
const done = () => {
- cleanup?.();
+ hub?.dispose();
overlayHandle?.hide();
this.ctx.ui.requestRender();
};
- const selector = new SessionObserverOverlayComponent(registry, done, observeKeys);
-
- cleanup = registry.onChange(() => {
- selector.refreshFromRegistry();
- this.ctx.ui.requestRender();
+ hub = new AgentHubOverlayComponent({
+ observers,
+ hubKeys,
+ onDone: done,
+ requestRender: () => this.ctx.ui.requestRender(),
});
- overlayHandle = this.ctx.ui.showOverlay(selector, {
+ overlayHandle = this.ctx.ui.showOverlay(hub, {
anchor: "bottom-center",
width: "100%",
maxHeight: "100%",
margin: 0,
});
- this.ctx.ui.setFocus(selector);
+ this.ctx.ui.setFocus(hub);
this.ctx.ui.requestRender();
}
}
diff --git a/packages/coding-agent/src/modes/interactive-mode.ts b/packages/coding-agent/src/modes/interactive-mode.ts
index b1cfec78d..d26d385d4 100644
--- a/packages/coding-agent/src/modes/interactive-mode.ts
+++ b/packages/coding-agent/src/modes/interactive-mode.ts
@@ -327,6 +327,7 @@ export class InteractiveMode implements InteractiveModeContext {
#pendingSubmissionDispose: (() => void) | undefined;
lastSigintTime = 0;
lastEscapeTime = 0;
+ lastLeftTapTime = 0;
shutdownRequested = false;
#isShuttingDown = false;
hookSelector: HookSelectorComponent | undefined = undefined;
@@ -388,6 +389,7 @@ export class InteractiveMode implements InteractiveModeContext {
mcpManager?: MCPManager,
eventBus?: EventBus,
titleSystemPrompt?: string,
+ startupEditor?: CustomEditor,
) {
this.session = session;
this.sessionManager = session.sessionManager;
@@ -422,7 +424,9 @@ export class InteractiveMode implements InteractiveModeContext {
this.btwContainer = new Container();
this.omfgContainer = new Container();
this.errorBannerContainer = new Container();
- this.editor = new CustomEditor(getEditorTheme());
+ // Adopt the pre-TUI startup editor when provided: typed text, cursor,
+ // paste buffers, and undo history carry over from the splash phase.
+ this.editor = startupEditor ?? new CustomEditor(getEditorTheme());
this.editor.setUseTerminalCursor(this.ui.getShowHardwareCursor());
this.editor.setAutocompleteMaxVisible(settings.get("autocompleteMaxVisible"));
this.editor.onAutocompleteCancel = () => {
@@ -1088,7 +1092,9 @@ export class InteractiveMode implements InteractiveModeContext {
rebuildChatFromMessages(): void {
this.chatContainer.clear();
- const context = this.session.buildDisplaySessionContext();
+ // Full-history transcript: compactions render as inline dividers instead
+ // of restarting the visible conversation (the LLM context still resets).
+ const context = this.session.buildTranscriptSessionContext();
this.renderSessionContext(context);
}
@@ -2880,11 +2886,8 @@ export class InteractiveMode implements InteractiveModeContext {
this.#uiHelpers.renderSessionContext(sessionContext, options);
}
- renderInitialMessages(
- prebuiltContext?: SessionContext,
- options?: { preserveExistingChat?: boolean; clearTerminalHistory?: boolean },
- ): void {
- this.#uiHelpers.renderInitialMessages(prebuiltContext, options);
+ renderInitialMessages(options?: { preserveExistingChat?: boolean; clearTerminalHistory?: boolean }): void {
+ this.#uiHelpers.renderInitialMessages(options);
}
getUserMessageText(message: Message): string {
@@ -3068,13 +3071,8 @@ export class InteractiveMode implements InteractiveModeContext {
await this.#selectorController.showDebugSelector();
}
- showSessionObserver(): void {
- const sessions = this.#observerRegistry.getSessions();
- if (sessions.length <= 1) {
- this.showStatus("No active subagent sessions");
- return;
- }
- this.#selectorController.showSessionObserver(this.#observerRegistry);
+ showAgentHub(): void {
+ this.#selectorController.showAgentHub(this.#observerRegistry);
}
resetObserverRegistry(): void {
diff --git a/packages/coding-agent/src/modes/startup-input.ts b/packages/coding-agent/src/modes/startup-input.ts
new file mode 100644
index 000000000..1bba19721
--- /dev/null
+++ b/packages/coding-agent/src/modes/startup-input.ts
@@ -0,0 +1,192 @@
+import { StdinBuffer, truncateToWidth } from "@oh-my-pi/pi-tui";
+import { postmortem } from "@oh-my-pi/pi-utils";
+import { CustomEditor } from "./components/custom-editor";
+import { type LspServerInfo, WelcomeComponent } from "./components/welcome";
+import { getEditorTheme, theme } from "./theme/theme";
+
+/** Synchronized-output guards (DEC 2026); unsupported terminals ignore them. */
+const SYNC_BEGIN = "\x1b[?2026h";
+const SYNC_END = "\x1b[?2026l";
+
+export interface StartupInputOptions {
+ version: string;
+ modelName: string;
+ providerName: string;
+ lspServers: LspServerInfo[];
+}
+
+/**
+ * Pre-TUI live input phase. Paints the same frame the TUI's first full paint
+ * will produce — welcome box held on the intro's first frame, blank chat area,
+ * editor box — and runs a real {@link CustomEditor} against raw stdin while
+ * session creation continues in the background.
+ *
+ * The editor instance is handed to InteractiveMode at construction, so typed
+ * text, cursor position, paste buffers, and undo history carry seamlessly into
+ * the live UI. Enter submissions made before the session is ready are queued
+ * (rendered dimmed in the chat area, where the real transcript will appear)
+ * and replayed through the real submit pipeline once the input loop starts.
+ *
+ * Handoff contract: {@link detach} must run before `ProcessTerminal.start()`
+ * grabs stdin — it restores cooked mode (so the terminal records the correct
+ * prior raw state) and pauses stdin, leaving OS-buffered keystrokes to flow
+ * into the TUI once it resumes.
+ */
+export class StartupInput {
+ readonly editor: CustomEditor;
+ readonly #welcome: WelcomeComponent;
+ #queued: string[] = [];
+ #stdinBuffer: StdinBuffer | undefined;
+ #dataListener: ((chunk: string) => void) | undefined;
+ #resizeListener: (() => void) | undefined;
+ #unregisterCleanup: (() => void) | undefined;
+ #started = false;
+ #detached = false;
+ #wasRaw = false;
+
+ constructor(options: StartupInputOptions) {
+ this.#welcome = new WelcomeComponent(
+ options.version,
+ options.modelName,
+ options.providerName,
+ null,
+ options.lspServers,
+ );
+ // Freeze the logo on the intro's first frame so the in-TUI intro picks up
+ // exactly where this frame leaves off.
+ this.#welcome.holdIntroFirstFrame();
+
+ this.editor = new CustomEditor(getEditorTheme());
+ // `Editor.#submitValue` expands paste markers, trims, and clears the
+ // buffer before invoking onSubmit, so `text` is final plain text.
+ this.editor.onSubmit = text => {
+ if (text) this.#queued.push(text);
+ this.#paintLiveRegion();
+ };
+ // Ctrl+C: clear typed text; on an empty editor abort startup (pre-TUI raw
+ // mode swallows SIGINT, so this is the muscle-memory escape hatch).
+ this.editor.onClear = () => {
+ if (this.editor.getText()) {
+ this.editor.setText("");
+ this.#paintLiveRegion();
+ } else {
+ this.#exit(130);
+ }
+ };
+ // Ctrl+D: same exit semantics as the live UI on an idle session.
+ this.editor.onExit = () => this.#exit(0);
+ }
+
+ /** Enter submissions captured before the session was ready, in order. */
+ get queuedSubmissions(): readonly string[] {
+ return this.#queued;
+ }
+
+ /** Grab stdin (raw mode), paint the initial frame, and start echoing input. */
+ start(): void {
+ if (this.#started) return;
+ this.#started = true;
+ this.#wasRaw = process.stdin.isRaw === true;
+ process.stdin.setRawMode?.(true);
+ process.stdin.setEncoding("utf8");
+ process.stdin.resume();
+
+ // Same sequence-splitting pipeline ProcessTerminal uses, so the editor
+ // receives single key events and bracketed pastes arrive re-wrapped.
+ const buffer = new StdinBuffer({ timeout: 50 });
+ buffer.on("data", sequence => this.feedInput(sequence));
+ buffer.on("paste", content => this.feedInput(`\x1b[200~${content}\x1b[201~`));
+ this.#stdinBuffer = buffer;
+ this.#dataListener = chunk => buffer.process(chunk);
+ process.stdin.on("data", this.#dataListener);
+ this.#resizeListener = () => this.#paintFull();
+ process.stdout.on("resize", this.#resizeListener);
+ // Crash safety: a fatal error before handoff must not leave the user's
+ // terminal in raw mode with a hidden cursor.
+ this.#unregisterCleanup = postmortem.register("startup-input-restore", () => this.#restoreTerminal());
+
+ // Bracketed paste on; hardware cursor off (the editor draws its own).
+ process.stdout.write("\x1b[?2004h\x1b[?25l");
+ this.#paintFull();
+ }
+
+ /** Route one complete input sequence into the editor and refresh the frame. */
+ feedInput(sequence: string): void {
+ this.editor.handleInput(sequence);
+ this.#paintLiveRegion();
+ }
+
+ /**
+ * Stop capturing and hand the terminal to the TUI. Restores cooked mode so
+ * `ProcessTerminal.start()` records the correct prior state, and pauses
+ * stdin so keystrokes typed during the remaining init await flow into the
+ * TUI once it resumes. The painted frame is left in place — the TUI's first
+ * full paint replaces it at the same origin.
+ */
+ detach(): void {
+ this.#unregisterCleanup?.();
+ this.#unregisterCleanup = undefined;
+ this.editor.onSubmit = undefined;
+ this.editor.onClear = undefined;
+ this.editor.onExit = undefined;
+ this.#restoreTerminal();
+ }
+
+ #restoreTerminal(): void {
+ if (!this.#started || this.#detached) return;
+ this.#detached = true;
+ if (this.#dataListener) process.stdin.off("data", this.#dataListener);
+ if (this.#resizeListener) process.stdout.off("resize", this.#resizeListener);
+ this.#stdinBuffer?.removeAllListeners();
+ process.stdin.pause();
+ process.stdin.setRawMode?.(this.#wasRaw);
+ process.stdout.write("\x1b[?2004l\x1b[?25h");
+ }
+
+ #exit(code: number): void {
+ this.detach();
+ process.stdout.write("\r\n");
+ void postmortem.quit(code);
+ }
+
+ /**
+ * Compose the full frame, mirroring the TUI's first-paint layout: Spacer,
+ * welcome box, Spacer, chat area (queued submissions), hook Spacer, editor.
+ * `liveRegionIndex` marks the first row that changes with input; everything
+ * above it is the stable welcome prefix.
+ */
+ #frameRows(width: number): { rows: string[]; liveRegionIndex: number } {
+ const rows: string[] = ["", ...this.#welcome.render(width), ""];
+ const liveRegionIndex = rows.length;
+ for (const text of this.#queued) {
+ rows.push(truncateToWidth(theme.fg("dim", ` › ${text.replace(/\s+/g, " ")}`), Math.max(0, width - 1)));
+ }
+ rows.push("");
+ const terminalRows = process.stdout.rows || 24;
+ this.editor.setMaxHeight(Math.max(3, Math.min(10, terminalRows - rows.length - 2)));
+ rows.push(...this.editor.render(width));
+ return { rows, liveRegionIndex };
+ }
+
+ #paintFull(): void {
+ if (!this.#started || this.#detached) return;
+ const width = process.stdout.columns || 80;
+ const { rows } = this.#frameRows(width);
+ // Raw mode disables ONLCR; emit explicit CR+LF between rows.
+ process.stdout.write(`${SYNC_BEGIN}\x1b[2J\x1b[H\x1b[3J${rows.join("\r\n")}${SYNC_END}`);
+ }
+
+ #paintLiveRegion(): void {
+ if (!this.#started || this.#detached) return;
+ const width = process.stdout.columns || 80;
+ const { rows, liveRegionIndex } = this.#frameRows(width);
+ if (rows.length >= (process.stdout.rows || 24)) {
+ // Frame taller than the viewport: the initial write scrolled, so
+ // absolute row addressing no longer maps to the frame. Repaint all.
+ this.#paintFull();
+ return;
+ }
+ const region = rows.slice(liveRegionIndex).join("\r\n");
+ process.stdout.write(`${SYNC_BEGIN}\x1b[${liveRegionIndex + 1};1H\x1b[0J${region}${SYNC_END}`);
+ }
+}
diff --git a/packages/coding-agent/src/modes/theme/theme.ts b/packages/coding-agent/src/modes/theme/theme.ts
index f9ebba809..c0222bba9 100644
--- a/packages/coding-agent/src/modes/theme/theme.ts
+++ b/packages/coding-agent/src/modes/theme/theme.ts
@@ -129,6 +129,8 @@ export type SymbolKey =
| "icon.extensionInstruction"
// STT
| "icon.mic"
+ // Compaction divider
+ | "icon.camera"
// Thinking Levels
| "thinking.minimal"
| "thinking.low"
@@ -220,7 +222,8 @@ export type SymbolKey =
| "tool.resolve"
| "tool.review"
| "tool.inspectImage"
- | "tool.goal";
+ | "tool.goal"
+ | "tool.irc";
type SymbolMap = Record;
@@ -322,13 +325,15 @@ const UNICODE_SYMBOLS: SymbolMap = {
"icon.extensionInstruction": "📘",
// STT
"icon.mic": "🎤",
+ // Compaction divider
+ "icon.camera": "📷",
// Thinking levels
"thinking.minimal": "◔ min",
"thinking.low": "◑ low",
"thinking.medium": "◒ med",
"thinking.high": "◕ high",
"thinking.xhigh": "◉ xhigh",
- "thinking.autoPending": "▣?",
+ "thinking.autoPending": "⟳",
// Checkboxes
"checkbox.checked": "☑",
"checkbox.unchecked": "☐",
@@ -414,6 +419,7 @@ const UNICODE_SYMBOLS: SymbolMap = {
"tool.review": "◉",
"tool.inspectImage": "🖼",
"tool.goal": "◎",
+ "tool.irc": "✉",
};
const NERD_SYMBOLS: SymbolMap = {
@@ -599,6 +605,8 @@ const NERD_SYMBOLS: SymbolMap = {
"icon.extensionInstruction": "\uf02d",
// STT - fa-microphone
"icon.mic": "\uf130",
+ // Compaction divider - fa-camera-retro
+ "icon.camera": "\uf083",
// Thinking Levels - emoji labels
// pick: 🤨 min | alt: min min
"thinking.minimal": "\u{F0E7} min",
@@ -610,8 +618,8 @@ const NERD_SYMBOLS: SymbolMap = {
"thinking.high": "\u{F111} high",
// pick: 🧠 xhi | alt: xhi xhi
"thinking.xhigh": "\u{F06D} xhi",
- // pick: (nf-md-help_box) | alt: [?]
- "thinking.autoPending": "\u{f078b}",
+ // pick: (fa-circle-o-notch) | alt: (nf-md-cached) ⟳
+ "thinking.autoPending": "\uf1ce",
// Checkboxes
// pick: | alt:
"checkbox.checked": "\uf14a",
@@ -708,6 +716,7 @@ const NERD_SYMBOLS: SymbolMap = {
"tool.review": "\uEA70",
"tool.inspectImage": "\uEAEA",
"tool.goal": "\uEBF8",
+ "tool.irc": "\uF086",
};
const ASCII_SYMBOLS: SymbolMap = {
@@ -808,13 +817,15 @@ const ASCII_SYMBOLS: SymbolMap = {
"icon.extensionInstruction": "IN",
// STT
"icon.mic": "MIC",
+ // Compaction divider
+ "icon.camera": "[o]",
// Thinking Levels
"thinking.minimal": "[min]",
"thinking.low": "[low]",
"thinking.medium": "[med]",
"thinking.high": "[high]",
"thinking.xhigh": "[xhi]",
- "thinking.autoPending": "[?]",
+ "thinking.autoPending": "[~]",
// Checkboxes
"checkbox.checked": "[x]",
"checkbox.unchecked": "[ ]",
@@ -898,6 +909,7 @@ const ASCII_SYMBOLS: SymbolMap = {
"tool.review": "rev",
"tool.inspectImage": "[i]",
"tool.goal": "(o)",
+ "tool.irc": "irc",
};
const SYMBOL_PRESETS: Record = {
@@ -1686,6 +1698,7 @@ export class Theme {
extensionContextFile: this.#symbols["icon.extensionContextFile"],
extensionInstruction: this.#symbols["icon.extensionInstruction"],
mic: this.#symbols["icon.mic"],
+ camera: this.#symbols["icon.camera"],
};
}
diff --git a/packages/coding-agent/src/modes/types.ts b/packages/coding-agent/src/modes/types.ts
index f9ffd072a..9df19c9b3 100644
--- a/packages/coding-agent/src/modes/types.ts
+++ b/packages/coding-agent/src/modes/types.ts
@@ -136,6 +136,7 @@ export interface InteractiveModeContext {
locallySubmittedUserSignatures: Set;
lastSigintTime: number;
lastEscapeTime: number;
+ lastLeftTapTime: number;
shutdownRequested: boolean;
hookSelector: HookSelectorComponent | undefined;
hookInput: HookInputComponent | undefined;
@@ -225,10 +226,7 @@ export interface InteractiveModeContext {
sessionContext: SessionContext,
options?: { updateFooter?: boolean; populateHistory?: boolean },
): void;
- renderInitialMessages(
- prebuiltContext?: SessionContext,
- options?: { preserveExistingChat?: boolean; clearTerminalHistory?: boolean },
- ): void;
+ renderInitialMessages(options?: { preserveExistingChat?: boolean; clearTerminalHistory?: boolean }): void;
getUserMessageText(message: Message): string;
findLastAssistantMessage(): AssistantMessage | undefined;
extractAssistantText(message: AssistantMessage): string;
@@ -292,7 +290,7 @@ export interface InteractiveModeContext {
showProviderSetup(): Promise;
showHookConfirm(title: string, message: string): Promise;
showDebugSelector(): Promise;
- showSessionObserver(): void;
+ showAgentHub(): void;
resetObserverRegistry(): void;
// Input handling
diff --git a/packages/coding-agent/src/modes/utils/hotkeys-markdown.ts b/packages/coding-agent/src/modes/utils/hotkeys-markdown.ts
index 97a179e66..b0d86e8f2 100644
--- a/packages/coding-agent/src/modes/utils/hotkeys-markdown.ts
+++ b/packages/coding-agent/src/modes/utils/hotkeys-markdown.ts
@@ -50,6 +50,7 @@ export function buildHotkeysMarkdown(bindings: HotkeysMarkdownBindings): string
`| \`${appKey(bindings, "app.editor.external")}\` | Edit message in external editor |`,
`| \`${appKey(bindings, "app.clipboard.pasteImage")}\` | Paste image from clipboard |`,
`| \`${appKey(bindings, "app.stt.toggle")}\` | Toggle speech-to-text recording |`,
+ `| \`${appKey(bindings, "app.agents.hub")}\` / \`${appKey(bindings, "app.session.observe")}\` / double-tap \`←\` (empty editor) | Open the agent hub |`,
"| `#` | Open prompt actions |",
"| `/` | Slash commands |",
"| `!` | Run bash command |",
diff --git a/packages/coding-agent/src/modes/utils/ui-helpers.ts b/packages/coding-agent/src/modes/utils/ui-helpers.ts
index 3136296f8..e0e805b09 100644
--- a/packages/coding-agent/src/modes/utils/ui-helpers.ts
+++ b/packages/coding-agent/src/modes/utils/ui-helpers.ts
@@ -190,19 +190,13 @@ export class UiHelpers {
this.ctx.chatContainer.addChild(component);
break;
}
- if (
- message.customType === "irc:incoming" ||
- message.customType === "irc:autoreply" ||
- message.customType === "irc:relay"
- ) {
+ if (message.customType === "irc:incoming" || message.customType === "irc:relay") {
const details = (
message as CustomMessage<{
from?: string;
to?: string;
message?: string;
- reply?: string;
body?: string;
- kind?: "message" | "reply";
}>
).details;
let arrow: string;
@@ -211,10 +205,6 @@ export class UiHelpers {
const peer = details?.from ?? "?";
body = details?.message ?? "";
arrow = `⇦ ${peer}`;
- } else if (message.customType === "irc:autoreply") {
- const peer = details?.to ?? "?";
- body = details?.reply ?? "";
- arrow = `⇨ ${peer}`;
} else {
const from = details?.from ?? "?";
const to = details?.to ?? "?";
@@ -337,13 +327,23 @@ export class UiHelpers {
let readGroup: ReadToolGroupComponent | null = null;
const readToolCallArgs = new Map>();
const readToolCallAssistantComponents = new Map();
- const deferredMessages: AgentMessage[] = [];
- for (const message of sessionContext.messages) {
- // Defer compaction summaries so they render at the bottom (visible after scroll)
- if (message.role === "compactionSummary") {
- deferredMessages.push(message);
- continue;
+ // Rebuild-time mirror of the event controller's displaceable-poll
+ // bookkeeping: a `job` poll that found every watched job still running is
+ // superseded by the next `job` call, so a rebuilt transcript collapses a
+ // repeated-poll run to its final snapshot instead of replaying the spam.
+ let waitingPoll: ToolExecutionComponent | null = null;
+ const resolveWaitingPoll = (nextToolName?: string) => {
+ const previous = waitingPoll;
+ if (!previous) return;
+ waitingPoll = null;
+ if (nextToolName === "job" && previous.isDisplaceableBlock()) {
+ this.ctx.chatContainer.removeChild(previous);
}
+ // Sealing freezes the block and stops the waiting-poll spinner that
+ // updateResult armed.
+ previous.seal();
+ };
+ for (const message of sessionContext.messages) {
// Assistant messages need special handling for tool calls
if (message.role === "assistant") {
this.ctx.addMessageToChat(message);
@@ -379,6 +379,7 @@ export class UiHelpers {
if (content.type !== "toolCall") {
continue;
}
+ resolveWaitingPoll(content.name);
if (
content.name === "read" &&
@@ -493,8 +494,17 @@ export class UiHelpers {
if (component) {
component.updateResult(message, false, message.toolCallId);
this.ctx.pendingTools.delete(message.toolCallId);
+ if (
+ message.toolName === "job" &&
+ component instanceof ToolExecutionComponent &&
+ component.isDisplaceableBlock()
+ ) {
+ waitingPoll = component;
+ }
}
} else {
+ // A user prompt closes the displacement window, same as the live path.
+ if (message.role === "user") resolveWaitingPoll();
// All other messages use standard rendering
this.ctx.addMessageToChat(message, options);
}
@@ -504,17 +514,15 @@ export class UiHelpers {
// rebuilt group freezes (even with a never-persisted result) and commits to
// native scrollback like every other historical block.
readGroup?.seal();
-
- // Render deferred messages (compaction summaries) at the bottom so they're visible
- for (const message of deferredMessages) {
- this.ctx.addMessageToChat(message, options);
- }
+ // A trailing waiting poll is final history on rebuild; seal it so it
+ // freezes (and its spinner timer stops) like every other block.
+ resolveWaitingPoll();
this.ctx.pendingTools.clear();
this.ctx.ui.requestRender();
}
- renderInitialMessages(prebuiltContext?: SessionContext, options: RenderInitialMessagesOptions = {}): void {
+ renderInitialMessages(options: RenderInitialMessagesOptions = {}): void {
// This path is used to rebuild the visible chat transcript (e.g. after custom/debug UI).
// Clear existing rendered chat first to avoid duplicating the full session in the container.
// On a non-preserving rebuild the existing blocks are discarded for good, so
@@ -530,8 +538,9 @@ export class UiHelpers {
this.ctx.pendingBashComponents = [];
this.ctx.pendingPythonComponents = [];
- // Reuse a pre-built context when available (e.g. from navigateTree) to avoid a second O(N) walk.
- const context = prebuiltContext ?? this.ctx.sessionManager.buildSessionContext();
+ // Display always uses the full-history transcript: compactions show as
+ // inline dividers instead of restarting the visible conversation.
+ const context = this.ctx.session.buildTranscriptSessionContext();
this.ctx.renderSessionContext(context, {
updateFooter: true,
populateHistory: true,
diff --git a/packages/coding-agent/src/prompts/system/irc-incoming.md b/packages/coding-agent/src/prompts/system/irc-incoming.md
index 7f5b8f139..6175b2216 100644
--- a/packages/coding-agent/src/prompts/system/irc-incoming.md
+++ b/packages/coding-agent/src/prompts/system/irc-incoming.md
@@ -1,8 +1,7 @@
-You received an IRC message from agent `{{from}}`.
+Incoming IRC message from agent `{{from}}`{{#if replyTo}} (replying to {{replyTo}}){{/if}}:
-Reply briefly and directly using the conversation context already available to you. NEVER call tools. The reply you write is delivered back to `{{from}}` as your answer.
-
-Message:
{{message}}
+
+If a response is expected, reply with the `irc` tool (`op: "send"`, `to: "{{from}}"`) — you may finish your current step first. Nobody replies on your behalf.
diff --git a/packages/coding-agent/src/prompts/system/orchestrate-notice.md b/packages/coding-agent/src/prompts/system/orchestrate-notice.md
index c8086fbb4..cd2754909 100644
--- a/packages/coding-agent/src/prompts/system/orchestrate-notice.md
+++ b/packages/coding-agent/src/prompts/system/orchestrate-notice.md
@@ -8,7 +8,7 @@ You decompose, dispatch, verify, and iterate. Substantial and parallelizable wor
1. **NEVER yield until everything is closed.** A phase finishing is *not* a yield point — launch the next phase in the same turn. Stop only when every requested item is verifiably done, or you hit a concrete [blocked] state that genuinely requires the user.
2. **Enumerate the full surface before dispatching.** If the request references audits, plans, checklists, phase lists, or file lists, expand them into a flat set of items in `todo`. "Most of them" or "the important ones" is failure. Re-read the source documents — NEVER work from memory.
-3. **Parallelize maximally; NEVER launch a one-off task.** Every set of edits with disjoint file scope MUST ship as one `task` batch — fan the work as wide as it decomposes. A single-task batch for divisible work is a failure: split it. If you are about to dispatch exactly one subagent, stop — either there is more to run alongside it (find it and batch them) or the change is small enough to make inline yourself (do it). Serialize only when one subagent produces a contract (types, schema, shared module) the next consumes — and state the dependency when you do.
+3. **Parallelize maximally; NEVER launch a one-off task.** Every set of edits with disjoint file scope MUST ship as parallel `task` calls in one message — fan the work as wide as it decomposes. Dispatching divisible work one call at a time, serially, is a failure: split it and dispatch together. If you are about to dispatch exactly one subagent, stop — either there is more to run alongside it (find it and dispatch them together) or the change is small enough to make inline yourself (do it). Serialize only when one subagent produces a contract (types, schema, shared module) the next consumes — and state the dependency when you do.
4. **Each `task` assignment is self-contained.** Subagents have no shared context. Spell out: target files (≤3–5 explicit paths, no globs), the change with APIs and patterns, edge cases, and observable acceptance criteria. NEVER assume they read the same plan you did.
5. **Verify after every phase before launching the next.** Run the appropriate gate: `bun check` for types, package-scoped `bun test` for behavior, `lsp diagnostics` for changed files. If a phase introduced breakage, dispatch fix-up subagents *before* moving on. NEVER declare a phase done on a red tree.
6. **Commit policy.** If the request asks for commits or the repo workflow expects them, commit after each green phase with a focused message. NEVER commit a red tree. NEVER commit work the user did not ask to commit.
@@ -21,7 +21,7 @@ You decompose, dispatch, verify, and iterate. Substantial and parallelizable wor
1. **Ingest.** Read every referenced file (audits, plans, prior agent output, current branch state). Run `git status` to see uncommitted changes.
2. **Plan.** Materialize the full work surface in `todo` as ordered phases. Within each phase, list the parallelizable units.
-3. **Dispatch phase.** Launch all parallel `task` subagents in one call. Wait for the batch.
+3. **Dispatch phase.** Launch all parallel `task` subagents in one message, then collect every result (async results / `job poll`) before moving on.
4. **Verify phase.** Run the gates. On failure, dispatch fix-up subagents and re-verify. Do not advance with a red gate.
5. **Commit phase** (if applicable). Focused message naming the phase.
6. **Advance.** Mark the phase done in `todo`, immediately start the next phase. No summary message between phases — keep going.
diff --git a/packages/coding-agent/src/prompts/system/subagent-system-prompt.md b/packages/coding-agent/src/prompts/system/subagent-system-prompt.md
index a7a25dad0..d6f4345cd 100644
--- a/packages/coding-agent/src/prompts/system/subagent-system-prompt.md
+++ b/packages/coding-agent/src/prompts/system/subagent-system-prompt.md
@@ -3,13 +3,6 @@ ROLE
{{agent}}
-{{#if context}}
-CONTEXT
-===================================
-
-{{context}}
-{{/if}}
-
{{#if planReference}}
PLAN
===================================
@@ -32,11 +25,6 @@ You are working in an isolated working tree at `{{worktree}}` for this sub-task.
You NEVER modify files outside this tree or in the original repository.
{{/if}}
-{{#if contextFile}}
-# Conversation Context
-If you need additional information, your conversation with the user is in {{contextFile}} — `read` its tail or `search` it for relevant terms.
-{{/if}}
-
{{#if ircPeers}}
# IRC Peers
You can reach other live agents via the `irc` tool. Your id is `{{ircSelfId}}`. Currently visible peers:
diff --git a/packages/coding-agent/src/prompts/system/system-prompt.md b/packages/coding-agent/src/prompts/system/system-prompt.md
index db0bd2a07..89218c062 100644
--- a/packages/coding-agent/src/prompts/system/system-prompt.md
+++ b/packages/coding-agent/src/prompts/system/system-prompt.md
@@ -149,6 +149,7 @@ With most FS/bash-like tools, static references to them will automatically resol
- `agent://`: full agent output artifact
- `/`: JSON field extraction
- `artifact://`: Artifact content
+- `history://`: agent transcript as concise markdown; bare `history://` lists agents
- `local://.md`: Plan artifacts and shared content with subagents
{{#if hasObsidian}}
- `vault:///`: Obsidian vault content (read/edit). `vault://` lists vaults; `vault://_/…` targets the active vault. File-scoped `?op=outline|backlinks|links|tags|properties|tasks|base|…`; vault-scoped `?op=search&q=…|daily|tasks|orphans|unresolved|bases|…`.
diff --git a/packages/coding-agent/src/prompts/system/workflow-notice.md b/packages/coding-agent/src/prompts/system/workflow-notice.md
index 73085ec6e..a8e1c6f55 100644
--- a/packages/coding-agent/src/prompts/system/workflow-notice.md
+++ b/packages/coding-agent/src/prompts/system/workflow-notice.md
@@ -13,8 +13,8 @@ Worth it when the task benefits from decomposition + parallel coverage, or from
State persists across cells, so scout in one cell and fan out in the next. Every cell has:
-- `agent(prompt, *, agent_type="task", model=None, context=None, label=None, schema=None)` — run ONE subagent; returns its final text, or the validated object when `schema` (a JSON Schema dict) is given. With `schema` the subagent is forced to emit structured output that is validated for you — branch on the object, not on parsed prose. `agent_type` picks a discovered agent ("explore", "reviewer", "oracle", …); `context` is shared background; `label` names the artifact. Subagents are told their final text IS the return value, so they hand back raw data. `agent()` blocks until the subagent finishes; eval-spawned agents nest at most 3 deep.
-- `parallel(thunks)` — run zero-arg callables concurrently through a bounded pool, preserving input order; returns once all finish. The pool runs as wide as a `task` tool batch — don't hand-tune it; fan out as wide as the work divides. A thunk that raises propagates — wrap risky work in `try/except` inside the thunk to keep partial results. In a loop, bind each closure's value with a default arg (`lambda d=d: …`) or every thunk captures the last one.
+- `agent(prompt, *, agent_type="task", model=None, label=None, schema=None)` — run ONE subagent; returns its final text, or the validated object when `schema` (a JSON Schema dict) is given. With `schema` the subagent is forced to emit structured output that is validated for you — branch on the object, not on parsed prose. `agent_type` picks a discovered agent ("explore", "reviewer", "oracle", …); `label` names the artifact. Shared background goes in a `local://` file referenced from each prompt, not a parameter. Subagents are told their final text IS the return value, so they hand back raw data. `agent()` blocks until the subagent finishes; eval-spawned agents nest at most 3 deep.
+- `parallel(thunks)` — run zero-arg callables concurrently through a bounded pool, preserving input order; returns once all finish. The pool is bounded by the session's `task` concurrency — don't hand-tune it; fan out as wide as the work divides. A thunk that raises propagates — wrap risky work in `try/except` inside the thunk to keep partial results. In a loop, bind each closure's value with a default arg (`lambda d=d: …`) or every thunk captures the last one.
- `pipeline(items, *stages)` — map items through `stages` left-to-right. There is a BARRIER between stages: ALL items clear stage N before stage N+1 begins. Each stage is a one-arg callable; stage 1 gets the original item, later stages get the previous result. Same pool width as `parallel()`.
- `completion(prompt, *, model="default", system=None, schema=None)` — oneshot, stateless model call (no tools, no history). Tiers: "smol", "default", "slow". Cheap classification/scoring inside a fan-out.
- `log(message)` — emit a progress line above the status tree. `phase(title)` — start a phase; the status lines that follow group under it.
diff --git a/packages/coding-agent/src/prompts/tools/eval.md b/packages/coding-agent/src/prompts/tools/eval.md
index 8e99ffc0f..52f25f101 100644
--- a/packages/coding-agent/src/prompts/tools/eval.md
+++ b/packages/coding-agent/src/prompts/tools/eval.md
@@ -46,9 +46,9 @@ tool.(args) → unknown
Invoke any session tool by name. `args` is the tool's parameter object.
completion(prompt, model?="default", system?=None, schema?=None) → str | dict
Oneshot, stateless completion (no history, no tools). `model` picks a tier: "smol" (fast), "default" (this session's model), "slow" (most capable). Pass `system` for a system prompt. Pass a JSON-Schema `schema` to force structured output and get the parsed object back; otherwise returns the completion text.
-{{#if spawns}}agent(prompt, agent_type?="task", model?=None, context?=None, label?=None, schema?=None) → str | dict
- Run a subagent and return its final output. Defaults to the bundled "task" agent; pass `agent_type`/`agentType` for another discovered agent. Pass a JSON-Schema `schema` to force structured output and get the parsed object back.
-{{#if js}} In JS, pass options as one trailing object — never positional: agent(prompt, { agentType, context, schema }).
+{{#if spawns}}agent(prompt, agent_type?="task", model?=None, label?=None, schema?=None) → str | dict
+ Run a subagent and return its final output. Defaults to the bundled "task" agent; pass `agent_type`/`agentType` for another discovered agent. Pass a JSON-Schema `schema` to force structured output and get the parsed object back. Share background by writing a `local://` file and referencing it in the prompt.
+{{#if js}} In JS, pass options as one trailing object — never positional: agent(prompt, { agentType, schema }).
{{/if}}
{{/if}}
parallel(thunks) → list
diff --git a/packages/coding-agent/src/prompts/tools/irc.md b/packages/coding-agent/src/prompts/tools/irc.md
index edb10b560..0aee47c57 100644
--- a/packages/coding-agent/src/prompts/tools/irc.md
+++ b/packages/coding-agent/src/prompts/tools/irc.md
@@ -1,11 +1,15 @@
-Sends short text messages to other live agents in this process and receives their prose replies.
+Sends short text messages to other agents in this process and receives theirs.
- The main agent is addressable as `Main`. Subagents reuse their task id (e.g. `AuthLoader`, or `AuthLoader-2` when the name repeats).
-- `op: "list"` returns the current set of visible peers. Use it before sending if you are not sure who is live.
-- `op: "send"` delivers `message` to `to`. `to` may be a specific id or `"all"` to broadcast.
-- Replies are generated on a side channel that does not wait for the recipient's main loop, so it is safe to IRC an agent that is mid tool call.
-- The exchange (question + auto-reply) is injected into the recipient's history; they see it on their next turn and can follow up.
+- `op: "list"` — every addressable peer with status (`running` | `idle` | `parked`), unread count, parent, and last activity. Use it before sending if you are not sure who exists.
+- `op: "send"` — fire-and-forget delivery of `message` to `to` (a peer id, or `"all"` to broadcast to live peers). Returns per-recipient receipts immediately; it NEVER waits for the recipient to act. Receipt outcomes: `injected` (recipient was mid-turn; message folded in at their next step boundary), `woken` (idle recipient started a turn), `revived` (parked recipient was brought back and woken), `failed`.
+- Messaging an `idle` or `parked` peer is how you wake it — there is no separate revive call.
+- `send` with `await: true` — convenience round-trip: send, then block until the next message from that peer arrives (or the timeout passes). Invalid with `to: "all"`.
+- `op: "wait"` — block until a message arrives (optionally only `from` a specific peer); consumes and returns it. A timeout is a clean "no message" result, not an error.
+- `op: "inbox"` — drain pending messages without blocking (`peek: true` to leave them unread).
+- `replyTo` — set it to the id of the message you are answering so the sender can correlate.
+- Nobody answers on a peer's behalf anymore: a reply only arrives when the recipient actually sends one. For background on what a peer has been doing, `read` `history://` instead of interrogating them.
@@ -21,29 +25,35 @@ NEVER use `irc` for: routine progress updates, things a tool call can verify, or
These rules apply to both sending and replying.
- **Plain prose only.** NEVER send structured JSON status payloads (e.g. `{"type":"task_completed",…}`). Write a normal sentence: "Done with the auth refactor — left a TODO in `src/server/auth.ts` for the rate limiter."
-- **NEVER quote the message you are replying to.** Lead with the answer.
-- **Use IRC, not terminal tools, to learn about peers.** NEVER `grep` artifacts, read other sessions' JSONL files, or shell-poke to figure out what another agent is doing. DM them.
-- **One round-trip is enough.** Replies arrive synchronously when the recipient is reachable. NEVER follow up with "did you get my message?". If `delivered` is empty or the result was `failed`, the peer is unavailable — move on or report the blocker; NEVER retry in a loop.
+- **NEVER quote the message you are replying to.** Lead with the answer; set `replyTo` instead.
+- **Use IRC, not terminal tools, to learn about peers.** NEVER `grep` artifacts, read other sessions' JSONL files, or shell-poke to figure out what another agent is doing. DM them, or `read` `history://`.
+- **Send, then keep working.** `send` returns immediately — only `wait` (or `await: true`) when you genuinely cannot proceed without the answer. NEVER follow up with "did you get my message?"; a `failed` receipt means the peer is unreachable — move on or report the blocker; NEVER retry in a loop.
+- **Answer when a response is expected.** When an incoming message asks something, reply with `irc send` to the sender (you may finish your current step first).
- **Stay terse.** A DM is a chat message, not a memo. One question per send. Share file paths and artifacts via `local://` / `memory://` / `artifact://` URLs instead of pasting blobs.
- **Address peers by id.** Use the exact id from `op: "list"` (e.g. `AuthLoader`, `Main`). NEVER invent friendly names.
- **NEVER IRC for things a tool would answer.** If a `read`, `grep`, or build command resolves the question, do that first.
-- **Answer incoming IRC messages before continuing.** Address the question directly; do not repeat it back to the user.
# List peers
`{"op": "list"}`
-# Direct message to the main agent (waits for prose reply)
-`{"op": "send", "to": "Main", "message": "Should I prefer JWT or session cookies for the auth flow?"}`
-# Unexpected state — ask the originator
-`{"op": "send", "to": "Main", "message": "Assignment says edit src/auth/jwt.ts but the file does not exist. Is the new path src/server/auth/jwt.ts?"}`
-# Blocked by a peer — ask them directly
-`{"op": "send", "to": "AuthLoader", "message": "Are you still touching src/server/auth.ts? I need to add a 401 path; OK to proceed or should I wait?"}`
-# Broadcast to discover who owns something (no replies, just informs them)
-`{"op": "send", "to": "all", "message": "About to refactor src/server/middleware/*. Anyone already in there?", "awaitReply": false}`
+# Fire-and-forget DM — keep working, check inbox later
+`{"op": "send", "to": "AuthLoader", "message": "Are you still touching src/server/auth.ts? I need to add a 401 path."}`
+# Round-trip when you cannot proceed without the answer
+`{"op": "send", "to": "Main", "message": "Should I prefer JWT or session cookies for the auth flow?", "await": true}`
+# Wake a parked agent (same send — the bus revives it)
+`{"op": "send", "to": "SchemaMigrator", "message": "The users table changed again; please re-check your migration."}`
+# Block until a specific peer answers
+`{"op": "wait", "from": "AuthLoader", "timeoutMs": 60000}`
+# Drain pending messages
+`{"op": "inbox"}`
+# Broadcast to live peers (no replies expected)
+`{"op": "send", "to": "all", "message": "About to refactor src/server/middleware/*. Anyone already in there?"}`
diff --git a/packages/coding-agent/src/prompts/tools/read.md b/packages/coding-agent/src/prompts/tools/read.md
index 4a0004298..b95a1a36c 100644
--- a/packages/coding-agent/src/prompts/tools/read.md
+++ b/packages/coding-agent/src/prompts/tools/read.md
@@ -8,7 +8,7 @@ Read files, directories, archives, SQLite databases, images, documents, internal
## Parameters
-- `path` — required. Local path, internal URI (`skill://`, `agent://`, `artifact://`, `memory://`, `rule://`, `local://`, `vault://`, `mcp://`, `omp://`, `issue://`, `pr://`), or URL. Append `:` for line ranges, raw mode, or special modes (e.g. `src/foo.ts:50-200`, `src/foo.ts:raw`, `db.sqlite:users:42`).
+- `path` — required. Local path, internal URI (`skill://`, `agent://`, `artifact://`, `history://`, `memory://`, `rule://`, `local://`, `vault://`, `mcp://`, `omp://`, `issue://`, `pr://`), or URL. Append `:` for line ranges, raw mode, or special modes (e.g. `src/foo.ts:50-200`, `src/foo.ts:raw`, `db.sqlite:users:42`).
## Selectors
@@ -74,7 +74,7 @@ For `.sqlite`, `.sqlite3`, `.db`, `.db3`:
# Internal URIs
-`skill://`, `agent://`, `artifact://`, `memory://root`, `rule://`, `local://.md`, `vault:///`, `mcp://`, `omp://.md`, `issue://`, and `pr://` resolve transparently and accept the same line selectors as filesystem paths. Use `artifact://` to recover full output that a previous bash/eval/tool result spilled or truncated.
+`skill://`, `agent://`, `artifact://`, `history://`, `memory://root`, `rule://`, `local://.md`, `vault:///`, `mcp://`, `omp://.md`, `issue://`, and `pr://` resolve transparently and accept the same line selectors as filesystem paths. Use `artifact://` to recover full output that a previous bash/eval/tool result spilled or truncated. `history://` is an agent's transcript as concise markdown; bare `history://` lists agents.
- You MUST use `read` for every file, directory, archive, and URL inspection. `cat`, `head`, `tail`, `less`, `more`, `ls`, `tar`, `unzip`, `curl`, `wget` are FORBIDDEN — any such bash call is a bug, regardless of how short or convenient it looks.
diff --git a/packages/coding-agent/src/prompts/tools/task-summary.md b/packages/coding-agent/src/prompts/tools/task-summary.md
index b6a945351..21f21d9b6 100644
--- a/packages/coding-agent/src/prompts/tools/task-summary.md
+++ b/packages/coding-agent/src/prompts/tools/task-summary.md
@@ -1,28 +1,17 @@
-
-{{successCount}}/{{totalCount}} succeeded{{#if hasCancelledNote}} ({{cancelledCount}} cancelled){{/if}} [{{duration}}]
-
-{{#each summaries}}
-
-{{status}}
+
{{#if meta}}{{/if}}
{{#if truncated}}
-
+
{{preview}}
{{else}}
-
+
+
{{/if}}
-
-{{#unless @last}}
----
-{{/unless}}
-{{/each}}
-
{{#if mergeSummary}}
{{mergeSummary}}
{{/if}}
-
+
diff --git a/packages/coding-agent/src/prompts/tools/task.md b/packages/coding-agent/src/prompts/tools/task.md
index eb2e8cd83..d567568c2 100644
--- a/packages/coding-agent/src/prompts/tools/task.md
+++ b/packages/coding-agent/src/prompts/tools/task.md
@@ -1,43 +1,37 @@
-Launches subagents to parallelize workflows.
+Spawns ONE subagent per call to work in the background, or resumes an existing one.
-{{#if asyncEnabled}}
-- Results are delivered automatically when complete.
-- The tool result lists the assigned task ids (e.g. `AuthLoader`) — those are the live agent ids.
+- Spawning is non-blocking: the call returns immediately with the agent id and a job id; the result is delivered automatically when the agent yields.
+- Parallelism = multiple `task` calls in one assistant message. Concurrency is bounded at {{MAX_CONCURRENCY}} running subagents per session.
+- If genuinely blocked on a result, wait with `job poll`; otherwise keep working. `job cancel` terminates a task and **cannot carry a message** — only for stalled/abandoned work.
{{#if ircEnabled}}
-- Coordinate with running tasks via `irc` using those ids. `job cancel` terminates a task and **cannot carry a message** — only use it for stalled/abandoned work.
-- If genuinely blocked on completion, wait with `job poll`; otherwise keep working.
-{{else}}
-- If genuinely blocked on completion, wait with `job poll`; otherwise keep working.
-- Use `job list` to snapshot manager state; `cancel: [id]` only to actually stop a stuck task.
-{{/if}}
+- Coordinate with running agents via `irc` using their ids. Agents reach you and their siblings live the same way.
{{/if}}
-{{#if ircEnabled}}
-Subagents have no conversation history, but they can reach you and their siblings live via the `irc` tool. Front-load every fact, file path, and direction they need in {{#if contextEnabled}}`context` or `assignment`{{else}}each `assignment`{{/if}}.
-{{else}}
-Subagents have no conversation history. Every fact, file path, and direction they need MUST be explicit in {{#if contextEnabled}}`context` or `assignment`{{else}}each `assignment`{{/if}}.
-{{/if}}
+
+- Finished agents stay alive: `idle` first, then `parked` after a TTL — both remain addressable and revivable.
+- `resume: ""` revives an idle/parked agent and runs a follow-up assignment in its existing session. **Prefer resuming an agent that already holds the relevant context over spawning fresh**{{#if ircEnabled}} — check `irc` op:"list" for candidates{{/if}}.
+- `history://` is the agent's transcript; `agent://` its latest output artifact.
+
-- `agent`: agent type for all tasks
-- `tasks`: tasks to execute in parallel
- - `.id`: CamelCase, ≤32 chars
- - `.description`: UI label only — subagent never sees it
- - `.assignment`: complete self-contained instructions; one-liners and missing acceptance criteria are PROHIBITED
-{{#if contextEnabled}}- `context`: shared background prepended to every assignment; session-specific only{{/if}}
+- `agent`: agent type to spawn; omit when `resume` is set
+- `resume`: existing agent id — continue that agent instead of spawning (cannot combine with `agent` or `isolated`)
+- `id`: stable agent id, CamelCase, ≤32 chars; generated when omitted
+- `description`: UI label only — subagent never sees it
+- `assignment`: complete self-contained instructions; one-liners and missing acceptance criteria are PROHIBITED
{{#if customSchemaEnabled}}- `schema`: JTD schema for expected structured output (do not put format rules in assignments){{/if}}
-{{#if isolationEnabled}}- `isolated`: run in isolated env; use when tasks edit overlapping files{{/if}}
+{{#if isolationEnabled}}- `isolated`: run in isolated env; returns patches. Isolated agents are NOT resumable{{/if}}
-- **Maximize batch width.** Spawn the widest parallel set the work decomposes into. NEVER spawn a single-task batch for divisible work, or defer work that could have been concurrent.
-- **Subagents do not verify, lint, or format.** Every assignment MUST instruct the subagent to skip all gates, formatters, and project-wide build/test/lint. You run them once at the end across the union of changed files — avoids redundant runs and racing formatter passes.
+- **Maximize fan-out.** Issue the widest set of parallel `task` calls the work decomposes into. NEVER serialize work that could run concurrently.
+- **Subagents do not verify, lint, or format.** Every assignment MUST instruct the subagent to skip all gates, formatters, and project-wide build/test/lint. You run them once at the end across the union of changed files.
- No globs, no "update all", no package-wide scope. Fan out.
- NEVER slow down or serialize because tasks might overlap on some files. Agents resolve collisions among themselves in real time.
-- Pass large payloads via `local://` URIs, not inline. {{#if contextEnabled}} (other than the context){{/if}}
-{{#if contextEnabled}}- Put shared constraints in `context` once; do not duplicate across assignments.{{/if}}
+- Subagents have no conversation history. Every fact, file path, and direction they need MUST be explicit in the `assignment`.
+- **Shared background**: write it ONCE to a `local://` file (e.g. `local://ctx.md`) and reference that path in each assignment. Pass large payloads via `local://` URIs, not inline.
- Prefer agents that investigate **and** edit in one pass; only spin a read-only discovery step when affected files are genuinely unknown.
-- **Read-only agents**: Agents tagged READ-ONLY (e.g. `explore`) have no edit/write/command tools. NEVER hand them an assignment that requires changing files or running commands — they cannot do it and the turn is wasted. Use them to investigate and report back; do the edits yourself or delegate to a writing agent (`task`, `oracle`, `designer`).
+- **Read-only agents**: Agents tagged READ-ONLY (e.g. `explore`) have no edit/write/command tools. NEVER hand them an assignment that requires changing files or running commands. Use them to investigate and report back; do the edits yourself or delegate to a writing agent (`task`, `oracle`, `designer`).
- **No reasoning offload**: NEVER offload reasoning, analysis, design, or decision-making to `quick_task` or `explore` — they run minimal-effort / small models for mechanical lookups and data collection only. Keep judgment and synthesis in your own context; delegate hard thinking to `task`, `plan`, or `oracle`.
@@ -51,16 +45,9 @@ Test: can task B run correctly without seeing A's output? If no, sequence A →
Sequential when one task produces a contract (types, API, schema, core module) the other consumes.
Parallel when tasks touch disjoint files or are independent refactors/tests.
{{/if}}
+Sequenced follow-ups SHOULD `resume` the agent that produced the prerequisite — it already holds the context.
-{{#if contextEnabled}}
-
-# Goal ← one sentence: what the batch accomplishes
-# Constraints ← MUST/NEVER rules and session decisions
-# Contract ← exact types/signatures if tasks share an interface
-
-{{/if}}
-
# Target ← exact files and symbols; explicit non-goals
# Change ← step-by-step add/remove/rename; APIs and patterns
diff --git a/packages/coding-agent/src/registry/agent-lifecycle.ts b/packages/coding-agent/src/registry/agent-lifecycle.ts
new file mode 100644
index 000000000..ad2ef4f16
--- /dev/null
+++ b/packages/coding-agent/src/registry/agent-lifecycle.ts
@@ -0,0 +1,218 @@
+/**
+ * AgentLifecycleManager - Owns the idle → parked → revived lifecycle of
+ * adopted subagents.
+ *
+ * The task executor hands a finished agent over via {@link AgentLifecycleManager.adopt};
+ * from then on the manager arms a TTL timer whenever the agent goes `idle`,
+ * parks it on expiry (disposes the live session, keeps the AgentRef +
+ * sessionFile), and revives it on demand through
+ * {@link AgentLifecycleManager.ensureLive}. Only this manager flips
+ * `parked` ↔ `idle`.
+ */
+
+import { logger } from "@oh-my-pi/pi-utils";
+import type { AgentSession } from "../session/agent-session";
+import { AgentRegistry, MAIN_AGENT_ID, type RegistryEvent } from "./agent-registry";
+
+export type AgentReviver = () => Promise;
+
+export interface AdoptOptions {
+ /** TTL before an idle agent is parked. <= 0 disables parking. */
+ idleTtlMs: number;
+ /** Recreates a live AgentSession from the ref's sessionFile. Absent => not resumable after park (e.g. isolated runs). */
+ revive?: AgentReviver;
+}
+
+interface AdoptedAgent {
+ idleTtlMs: number;
+ revive?: AgentReviver;
+ timer?: NodeJS.Timeout;
+}
+
+export class AgentLifecycleManager {
+ static #global: AgentLifecycleManager | undefined;
+
+ static global(): AgentLifecycleManager {
+ if (!AgentLifecycleManager.#global) {
+ AgentLifecycleManager.#global = new AgentLifecycleManager();
+ }
+ return AgentLifecycleManager.#global;
+ }
+
+ /** Reset the global manager. Test-only. */
+ static resetGlobalForTests(): void {
+ const current = AgentLifecycleManager.#global;
+ if (current) {
+ current.#unsubscribe?.();
+ current.#unsubscribe = undefined;
+ for (const adopted of current.#adopted.values()) {
+ clearTimeout(adopted.timer);
+ }
+ current.#adopted.clear();
+ current.#revivals.clear();
+ current.#parking.clear();
+ }
+ AgentLifecycleManager.#global = undefined;
+ }
+
+ readonly #registry: AgentRegistry;
+ readonly #adopted = new Map();
+ /** Ids whose session is being disposed by {@link park} right now. */
+ readonly #parking = new Set();
+ /** In-flight revives, so concurrent {@link ensureLive} calls coalesce. */
+ readonly #revivals = new Map>();
+ #unsubscribe: (() => void) | undefined;
+
+ constructor(registry: AgentRegistry = AgentRegistry.global()) {
+ this.#registry = registry;
+ this.#unsubscribe = registry.onChange(event => this.#onRegistryEvent(event));
+ }
+
+ /**
+ * Take ownership of a finished subagent. Caller has already set registry
+ * status to "idle". Arms the TTL timer (idleTtlMs <= 0 adopts without one).
+ */
+ adopt(id: string, opts: AdoptOptions): void {
+ if (id === MAIN_AGENT_ID) return;
+ if (!this.#registry.get(id)) {
+ logger.warn("AgentLifecycleManager.adopt: unknown agent id", { id });
+ return;
+ }
+ const existing = this.#adopted.get(id);
+ clearTimeout(existing?.timer);
+ const adopted: AdoptedAgent = { idleTtlMs: opts.idleTtlMs, revive: opts.revive };
+ this.#adopted.set(id, adopted);
+ this.#armTimer(id, adopted);
+ }
+
+ /** True if the id is adopted (parked or live). */
+ has(id: string): boolean {
+ return this.#adopted.has(id);
+ }
+
+ /** True while {@link park} is disposing this agent's session (lets dispose hooks distinguish park from teardown). */
+ isParking(id: string): boolean {
+ return this.#parking.has(id);
+ }
+
+ /**
+ * Dispose the live session, detach it from the registry, and mark the
+ * agent `parked`. No-op unless the id is adopted and live.
+ */
+ async park(id: string): Promise {
+ const adopted = this.#adopted.get(id);
+ if (!adopted) return;
+ const ref = this.#registry.get(id);
+ if (!ref?.session) return;
+ if (adopted.timer) {
+ clearTimeout(adopted.timer);
+ adopted.timer = undefined;
+ }
+ this.#parking.add(id);
+ try {
+ try {
+ await ref.session.dispose();
+ } catch (error) {
+ logger.warn("AgentLifecycleManager.park: session dispose failed", { id, error: String(error) });
+ }
+ this.#registry.detachSession(id);
+ this.#registry.setStatus(id, "parked");
+ } finally {
+ this.#parking.delete(id);
+ }
+ }
+
+ /**
+ * Return the live session, reviving from the sessionFile if parked.
+ * Throws a plain Error if the id is unknown or parked without a reviver.
+ * Concurrent calls share one in-flight revive.
+ */
+ async ensureLive(id: string): Promise {
+ const ref = this.#registry.get(id);
+ if (!ref) {
+ throw new Error(
+ `Unknown agent "${id}" — it was never registered or has been released. If a transcript exists, read history://${id}.`,
+ );
+ }
+ if (ref.session) return ref.session;
+ const inflight = this.#revivals.get(id);
+ if (inflight) return inflight;
+ const adopted = this.#adopted.get(id);
+ if (ref.status !== "parked" || !adopted?.revive) {
+ throw new Error(
+ `Agent "${id}" is ${ref.status} and cannot be revived${adopted?.revive ? "" : " (no reviver registered)"}. Its transcript remains readable at history://${id}.`,
+ );
+ }
+ const revival = this.#revive(id, adopted.revive, ref.sessionFile);
+ this.#revivals.set(id, revival);
+ try {
+ return await revival;
+ } finally {
+ this.#revivals.delete(id);
+ }
+ }
+
+ /** Hard removal: dispose if live, unregister from registry, drop timers. */
+ async release(id: string): Promise {
+ const adopted = this.#adopted.get(id);
+ clearTimeout(adopted?.timer);
+ this.#adopted.delete(id);
+ const ref = this.#registry.get(id);
+ if (ref?.session) {
+ try {
+ await ref.session.dispose();
+ } catch (error) {
+ logger.warn("AgentLifecycleManager.release: session dispose failed", { id, error: String(error) });
+ }
+ }
+ this.#registry.unregister(id);
+ }
+
+ /** Teardown everything (process exit / main session dispose). */
+ async dispose(): Promise {
+ this.#unsubscribe?.();
+ this.#unsubscribe = undefined;
+ const ids = [...this.#adopted.keys()];
+ await Promise.all(ids.map(id => this.release(id)));
+ this.#revivals.clear();
+ this.#parking.clear();
+ }
+
+ async #revive(id: string, revive: AgentReviver, sessionFile: string | null): Promise {
+ const session = await revive();
+ this.#registry.attachSession(id, session, sessionFile);
+ // Emits status_changed → "idle", which re-arms the TTL timer below.
+ this.#registry.setStatus(id, "idle");
+ return session;
+ }
+
+ #armTimer(id: string, adopted: AdoptedAgent): void {
+ if (adopted.idleTtlMs <= 0) return;
+ clearTimeout(adopted.timer);
+ const timer = setTimeout(() => {
+ adopted.timer = undefined;
+ void this.park(id);
+ }, adopted.idleTtlMs);
+ timer.unref?.();
+ adopted.timer = timer;
+ }
+
+ #onRegistryEvent(event: RegistryEvent): void {
+ const adopted = this.#adopted.get(event.ref.id);
+ if (!adopted) return;
+ if (event.type === "removed") {
+ clearTimeout(adopted.timer);
+ this.#adopted.delete(event.ref.id);
+ return;
+ }
+ if (event.type !== "status_changed") return;
+ if (event.ref.status === "running") {
+ if (adopted.timer) {
+ clearTimeout(adopted.timer);
+ adopted.timer = undefined;
+ }
+ } else if (event.ref.status === "idle") {
+ this.#armTimer(event.ref.id, adopted);
+ }
+ }
+}
diff --git a/packages/coding-agent/src/registry/agent-registry.ts b/packages/coding-agent/src/registry/agent-registry.ts
index 271b9d90b..f791fa225 100644
--- a/packages/coding-agent/src/registry/agent-registry.ts
+++ b/packages/coding-agent/src/registry/agent-registry.ts
@@ -1,16 +1,26 @@
/**
- * AgentRegistry - Process-global registry of live AgentSession instances.
+ * AgentRegistry - Process-global registry of agents (the main session plus
+ * every subagent), keyed by stable id.
*
- * Tracks every alive agent (the main session plus every subagent) so the
- * `irc` tool can address peers by id. Sessions are registered explicitly at
- * creation and removed when the owner releases them.
+ * Tracks each agent's status and (when live) its AgentSession so peers can be
+ * addressed by id (`irc`, `task resume`, `history://`). Sessions are
+ * registered explicitly at creation; finished agents stay registered as
+ * `idle` (live) or `parked` (session disposed, ref + sessionFile retained for
+ * revival) and are only removed on explicit release/teardown.
*/
import type { AgentSession } from "../session/agent-session";
export const MAIN_AGENT_ID = "Main";
-export type AgentStatus = "running" | "idle" | "completed" | "aborted";
+/**
+ * - `running`: a turn is in flight.
+ * - `idle`: live AgentSession in memory, awaiting work. Finished agents are
+ * `idle`, not removed.
+ * - `parked`: session disposed; AgentRef + sessionFile retained, revivable.
+ * - `aborted`: hard-killed, terminal.
+ */
+export type AgentStatus = "running" | "idle" | "parked" | "aborted";
export type AgentKind = "main" | "sub";
export interface AgentRef {
@@ -19,6 +29,7 @@ export interface AgentRef {
kind: AgentKind;
parentId?: string;
status: AgentStatus;
+ /** Null exactly when parked/aborted. */
session: AgentSession | null;
sessionFile: string | null;
createdAt: number;
diff --git a/packages/coding-agent/src/sdk.ts b/packages/coding-agent/src/sdk.ts
index d128cce56..05e6664bd 100644
--- a/packages/coding-agent/src/sdk.ts
+++ b/packages/coding-agent/src/sdk.ts
@@ -34,7 +34,7 @@ import {
Snowflake,
} from "@oh-my-pi/pi-utils";
import chalk from "chalk";
-import { type AsyncJob, AsyncJobManager, isBackgroundJobSupportEnabled } from "./async";
+import { type AsyncJob, AsyncJobManager } from "./async";
import { loadCapability } from "./capability";
import { type Rule, ruleCapability, setActiveRules } from "./capability/rule";
import { bucketRules } from "./capability/rule-buckets";
@@ -93,6 +93,7 @@ import { createSessionMemoryRuntimeContext, resolveMemoryBackend } from "./memor
import type { MnemopiSessionState } from "./mnemopi/state";
import asyncResultTemplate from "./prompts/tools/async-result.md" with { type: "text" };
import lateDiagnosticTemplate from "./prompts/tools/lsp-late-diagnostic.md" with { type: "text" };
+import { AgentLifecycleManager } from "./registry/agent-lifecycle";
import { AgentRegistry, MAIN_AGENT_ID } from "./registry/agent-registry";
import {
collectEnvSecrets,
@@ -1293,7 +1294,6 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
let hasSession = false;
let hasRegistered = false;
const enableLsp = options.enableLsp ?? true;
- const backgroundJobsEnabled = isBackgroundJobSupportEnabled(settings);
const asyncMaxJobs = Math.min(100, Math.max(1, settings.get("async.maxJobs") ?? 100));
const ASYNC_INLINE_RESULT_MAX_CHARS = 12_000;
const ASYNC_PREVIEW_MAX_CHARS = 4_000;
@@ -1326,7 +1326,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
// (issue #1923). The `instance()` guard means later sessions also skip
// constructing an orphaned manager that nothing would ever route to.
const asyncJobManager =
- backgroundJobsEnabled && !options.parentTaskPrefix && !AsyncJobManager.instance()
+ !options.parentTaskPrefix && !AsyncJobManager.instance()
? new AsyncJobManager({
maxRunningJobs: asyncMaxJobs,
onJobComplete: async (jobId, result, job) => {
@@ -1351,6 +1351,17 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
const resolvedAgentId = options.agentId ?? options.parentTaskPrefix ?? MAIN_AGENT_ID;
const resolvedAgentDisplayName =
options.agentDisplayName ?? ((options.taskDepth ?? 0) > 0 || options.parentTaskPrefix ? "sub" : "main");
+ const agentKind = (options.taskDepth ?? 0) > 0 || options.parentTaskPrefix ? ("sub" as const) : ("main" as const);
+ /**
+ * Forget the agent ref on teardown — unless the agent is being parked (or is
+ * already parked). Parking disposes the session but keeps the ref addressable
+ * (history://, revive); only process teardown / explicit kill unregisters.
+ */
+ const unregisterUnlessParked = (): void => {
+ if (agentRegistry.get(resolvedAgentId)?.status === "parked") return;
+ if (AgentLifecycleManager.global().isParking(resolvedAgentId)) return;
+ agentRegistry.unregister(resolvedAgentId);
+ };
const evalKernelOwnerId = `agent-session:${Snowflake.next()}`;
try {
@@ -1409,7 +1420,6 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
getTurnBudget: () => sessionManager.getTurnBudget(),
recordEvalSubagentUsage: output => sessionManager.recordEvalSubagentOutput(output),
getClientBridge: () => session?.clientBridge,
- getCompactContext: () => session.formatCompactContext(),
queueDeferredDiagnostics: entry => session?.yieldQueue.enqueue(LSP_LATE_DIAGNOSTIC_MESSAGE_TYPE, entry),
bumpFileMutationVersion: path => {
const next = (fileMutationVersions.get(path) ?? 0) + 1;
@@ -2083,7 +2093,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
agentRegistry.register({
id: resolvedAgentId,
displayName: resolvedAgentDisplayName,
- kind: (options.taskDepth ?? 0) > 0 || options.parentTaskPrefix ? "sub" : "main",
+ kind: agentKind,
parentId: options.parentTaskPrefix,
session: null,
sessionFile: sessionManager.getSessionFile() ?? null,
@@ -2320,7 +2330,6 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
ttsrManager,
obfuscator,
agentId: resolvedAgentId,
- agentRegistry,
providerSessionId: options.providerSessionId,
parentEvalSessionId: options.parentEvalSessionId,
});
@@ -2341,15 +2350,26 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
// Attach the live session to the pre-registered ref so peers can route IRC
// messages here. Refresh sessionFile in case it was unavailable at pre-register
- // time. The dispose wrapper below unregisters on teardown.
+ // time. The dispose wrapper below unregisters on teardown (unless parked).
agentRegistry.attachSession(resolvedAgentId, session, sessionManager.getSessionFile() ?? null);
{
const originalDispose = session.dispose.bind(session);
session.dispose = async () => {
try {
+ // Reject new session work (Python/eval starts) the moment disposal
+ // begins — the lifecycle await below opens an async gap before
+ // AgentSession.dispose() would otherwise set its guards.
+ session.beginDispose();
+ if (agentKind === "main") {
+ // Top-level teardown owns the global agent lifecycle: park timers,
+ // adopted subagent sessions, revivers. Tear it down while shared
+ // resources (kernels, MCP, LSP) are still live. Subagent disposal
+ // must NOT touch the global lifecycle.
+ await AgentLifecycleManager.global().dispose();
+ }
await originalDispose();
} finally {
- agentRegistry.unregister(resolvedAgentId);
+ unregisterUnlessParked();
unsubscribeCredentialDisabled?.();
}
};
@@ -2502,7 +2522,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
if (hasSession) {
await session.dispose();
} else {
- if (hasRegistered) agentRegistry.unregister(resolvedAgentId);
+ if (hasRegistered) unregisterUnlessParked();
if (asyncJobManager) {
if (AsyncJobManager.instance() === asyncJobManager) {
AsyncJobManager.setInstance(undefined);
diff --git a/packages/coding-agent/src/session/agent-session.ts b/packages/coding-agent/src/session/agent-session.ts
index d677a9d72..51f82b5b5 100644
--- a/packages/coding-agent/src/session/agent-session.ts
+++ b/packages/coding-agent/src/session/agent-session.ts
@@ -29,6 +29,7 @@ import {
type AgentState,
type AgentTool,
AppendOnlyContextManager,
+ type AsideMessage,
resolveTelemetry,
ThinkingLevel,
} from "@oh-my-pi/pi-agent-core";
@@ -55,7 +56,12 @@ import {
type SummaryOptions,
shouldCompact,
} from "@oh-my-pi/pi-agent-core/compaction";
-import { DEFAULT_PRUNE_CONFIG, pruneToolOutputs } from "@oh-my-pi/pi-agent-core/compaction/pruning";
+import {
+ DEFAULT_PRUNE_CONFIG,
+ pruneSupersededToolResults,
+ pruneToolOutputs,
+ readToolSupersedeKey,
+} from "@oh-my-pi/pi-agent-core/compaction/pruning";
import type { ProtectedToolMatcher } from "@oh-my-pi/pi-agent-core/compaction/tool-protection";
import type {
AssistantMessage,
@@ -100,6 +106,7 @@ import {
relativePathWithinRoot,
Snowflake,
} from "@oh-my-pi/pi-utils";
+import { snapcompactCompact } from "@oh-my-pi/snapcompact";
import { type AsyncJob, type AsyncJobDeliveryState, AsyncJobManager } from "../async";
import { classifyDifficulty } from "../auto-thinking/classifier";
import { reset as resetCapabilities } from "../capability";
@@ -163,6 +170,7 @@ import { GoalRuntime } from "../goals/runtime";
import type { Goal, GoalModeState } from "../goals/state";
import type { HindsightSessionState } from "../hindsight/state";
import { type LocalProtocolOptions, resolveLocalUrlToPath } from "../internal-urls";
+import type { IrcMessage } from "../irc/bus";
import { resolveMemoryBackend } from "../memory-backend";
import { getMnemopiSessionState, type MnemopiSessionState, setMnemopiSessionState } from "../mnemopi/state";
import { containsOrchestrate, ORCHESTRATE_NOTICE } from "../modes/orchestrate";
@@ -184,7 +192,6 @@ import planModeToolDecisionReminderPrompt from "../prompts/system/plan-mode-tool
};
import ttsrInterruptTemplate from "../prompts/system/ttsr-interrupt.md" with { type: "text" };
import ttsrToolReminderTemplate from "../prompts/system/ttsr-tool-reminder.md" with { type: "text" };
-import { type AgentRegistry, MAIN_AGENT_ID } from "../registry/agent-registry";
import {
deobfuscateSessionContext,
obfuscateProviderContext,
@@ -230,10 +237,8 @@ import type { AuthStorage } from "./auth-storage";
import type { ClientBridge, ClientBridgePermissionOption, ClientBridgePermissionOutcome } from "./client-bridge";
import {
type BashExecutionMessage,
- type CompactionSummaryMessage,
type CustomMessage,
convertToLlm,
- type FileMentionMessage,
type PythonExecutionMessage,
readPendingDisplayTag,
SILENT_ABORT_MARKER,
@@ -259,11 +264,11 @@ export type AgentSessionEvent =
| {
type: "auto_compaction_start";
reason: "threshold" | "overflow" | "idle" | "incomplete";
- action: "context-full" | "handoff" | "shake";
+ action: "context-full" | "handoff" | "shake" | "snapcompact";
}
| {
type: "auto_compaction_end";
- action: "context-full" | "handoff" | "shake";
+ action: "context-full" | "handoff" | "shake" | "snapcompact";
result: CompactionResult | undefined;
aborted: boolean;
willRetry: boolean;
@@ -413,8 +418,6 @@ export interface AgentSessionConfig {
asyncJobManager?: AsyncJobManager;
/** Agent identity (registry id like "Main" or "Alice") used for IRC routing. */
agentId?: string;
- /** Shared agent registry (for forwarding IRC observations to the main session UI). */
- agentRegistry?: AgentRegistry;
/**
* Override the provider-facing session ID for all API requests from this session.
* When absent, `sessionManager.getSessionId()` is used. Needed when benchmark or
@@ -557,15 +560,15 @@ function formatRetryFallbackBaseSelector(selector: RetryFallbackSelector): strin
return `${selector.provider}/${selector.id}`;
}
-const IRC_REPLY_MAX_BYTES = 4096;
+const EPHEMERAL_REPLY_MAX_BYTES = 4096;
/**
- * Collapse degenerate IRC ephemeral replies before they hit the relay.
+ * Collapse degenerate ephemeral replies (/btw, /omfg side-channel turns).
* Models occasionally loop on a single line (~16 reports of N-times-repeated
* replies); compress runs longer than 3 down to one instance + `[…N×]`, then
* cap at 4 KiB so a runaway reply can't flood the channel.
*/
-function dedupeIrcReply(text: string): string {
+function dedupeEphemeralReply(text: string): string {
if (!text) return text;
const lines = text.split("\n");
const out: string[] = [];
@@ -582,11 +585,11 @@ function dedupeIrcReply(text: string): string {
i = j;
}
let result = out.join("\n");
- if (Buffer.byteLength(result, "utf8") > IRC_REPLY_MAX_BYTES) {
+ if (Buffer.byteLength(result, "utf8") > EPHEMERAL_REPLY_MAX_BYTES) {
// Trim by characters until we're under the byte budget — handles multi-byte
// glyphs at the boundary without splitting them.
const suffix = "\n[…truncated]";
- const budget = IRC_REPLY_MAX_BYTES - Buffer.byteLength(suffix, "utf8");
+ const budget = EPHEMERAL_REPLY_MAX_BYTES - Buffer.byteLength(suffix, "utf8");
while (Buffer.byteLength(result, "utf8") > budget) {
result = result.slice(0, -1);
}
@@ -941,13 +944,11 @@ export class AgentSession {
#activeEvalExecutions = new Set>();
#evalExecutionDisposing = false;
- // Background-channel IRC exchanges queued while the recipient was streaming.
- // Drained into history (via emitExternalEvent) once the recipient becomes idle.
- #pendingBackgroundExchanges: CustomMessage[][] = [];
- #scheduledBackgroundExchangeFlush = false;
- // Agent identity + registry for IRC relay forwarding to the main session UI.
+ // Incoming IRC messages received while a turn was streaming; drained as
+ // non-interrupting asides at the next step boundary (see the aside provider).
+ #pendingIrcAsides: CustomMessage[] = [];
+ // Agent identity (registry id) used for IRC routing and job ownership.
#agentId: string | undefined;
- #agentRegistry: AgentRegistry | undefined;
#providerSessionId: string | undefined;
#freshProviderSessionId: string | undefined;
#isDisposed = false;
@@ -1204,7 +1205,13 @@ export class AgentSession {
// Background-job completions / late diagnostics are pulled into the run at
// each step boundary as non-interrupting asides (see Agent.getAsideMessages),
// so they reach the model between requests without waiting for a yield.
- this.agent.setAsideMessageProvider(() => this.yieldQueue.drainLazy());
+ this.agent.setAsideMessageProvider(() => {
+ const pendingIrc = this.#pendingIrcAsides;
+ this.#pendingIrcAsides = [];
+ const thunks: AsideMessage[] = pendingIrc.map(record => () => record);
+ thunks.push(...this.yieldQueue.drainLazy());
+ return thunks;
+ });
this.#convertToLlm = config.convertToLlm ?? convertToLlm;
this.#rebuildSystemPrompt = config.rebuildSystemPrompt;
this.#getMcpServerInstructions = config.getMcpServerInstructions;
@@ -1235,7 +1242,6 @@ export class AgentSession {
this.#ttsrManager = config.ttsrManager;
this.#obfuscator = config.obfuscator;
this.#agentId = config.agentId;
- this.#agentRegistry = config.agentRegistry;
this.#providerSessionId = config.providerSessionId;
this.agent.setAssistantMessageEventInterceptor((message, assistantMessageEvent) => {
const event: AgentEvent = {
@@ -3090,16 +3096,29 @@ export class AgentSession {
state.resetConversationTracking();
}
+ /**
+ * Synchronously mark the session as disposing so new work is rejected
+ * immediately: Python/eval starts throw, queued asides are dropped, and the
+ * aside provider is detached. Idempotent; `dispose()` runs it first.
+ *
+ * Wrappers that await other teardown before delegating to `dispose()` MUST
+ * call this before their first await — otherwise work started in that async
+ * gap slips past the disposal guards.
+ */
+ beginDispose(): void {
+ this.#isDisposed = true;
+ this.#pendingIrcAsides = [];
+ this.yieldQueue.clear();
+ this.agent.setAsideMessageProvider(undefined);
+ this.#evalExecutionDisposing = true;
+ }
+
/**
* Remove all listeners, flush pending writes, and disconnect from agent.
* Call this when completely done with the session.
*/
async dispose(): Promise {
- this.#isDisposed = true;
- this.#pendingBackgroundExchanges = [];
- this.yieldQueue.clear();
- this.agent.setAsideMessageProvider(undefined);
- this.#evalExecutionDisposing = true;
+ this.beginDispose();
try {
if (this.#extensionRunner?.hasHandlers("session_shutdown")) {
await this.#extensionRunner.emit({ type: "session_shutdown" });
@@ -4032,6 +4051,16 @@ export class AgentSession {
return deobfuscateSessionContext(this.sessionManager.buildSessionContext(), this.#obfuscator);
}
+ /**
+ * Full-history transcript for TUI display: every path entry in
+ * chronological order with compactions rendered inline at the point they
+ * fired (instead of replacing prior history). Display-only — NEVER feed
+ * the result to `agent.replaceMessages` or a provider.
+ */
+ buildTranscriptSessionContext(): SessionContext {
+ return deobfuscateSessionContext(this.sessionManager.buildSessionContext({ transcript: true }), this.#obfuscator);
+ }
+
#obfuscateForProvider(value: T): T {
if (!this.#obfuscator?.hasSecrets()) return value;
return this.#obfuscator.obfuscateObject(value);
@@ -4634,7 +4663,7 @@ export class AgentSession {
// Flush any pending bash messages before the new prompt
this.#flushPendingBashMessages();
this.#flushPendingPythonMessages();
- this.#flushPendingBackgroundExchanges();
+ this.#flushPendingIrcAsides();
// Reset todo reminder count on new user prompt
this.#todoReminderCount = 0;
@@ -6048,6 +6077,35 @@ export class AgentSession {
return result;
}
+ /**
+ * Per-turn supersede pass: prune older `read` results that a newer read of
+ * the same file has made stale. Cache-aware (only fires when the suffix
+ * after a candidate is small or the session has been idle long enough that
+ * the provider prompt cache is cold), so it is cheap to run every turn.
+ * Gated on the `compaction.supersedeReads` setting.
+ */
+ async #pruneSupersededReads(): Promise<{ prunedCount: number; tokensSaved: number } | undefined> {
+ if (!this.settings.getGroup("compaction").supersedeReads) return undefined;
+ const branchEntries = this.sessionManager.getBranch();
+ const result = pruneSupersededToolResults(
+ branchEntries,
+ this.#withPlanProtection({
+ supersedeKey: readToolSupersedeKey,
+ protectedTools: [...DEFAULT_PRUNE_CONFIG.protectedTools],
+ }),
+ );
+ if (result.prunedCount === 0) {
+ return undefined;
+ }
+
+ await this.sessionManager.rewriteEntries();
+ const sessionContext = this.buildDisplaySessionContext();
+ this.agent.replaceMessages(sessionContext.messages);
+ this.#syncTodoPhasesFromBranch();
+ this.#closeCodexProviderSessionsForHistoryRewrite();
+ return result;
+ }
+
/**
* Strip image content blocks from every message on the current branch and
* persist the rewrite. Walks `SessionManager.getBranch()` in place — both
@@ -6237,6 +6295,20 @@ export class AgentSession {
const compactionPrep = await this.#prepareCompactionFromHooks(preparation, hookCompaction);
+ // Strategy honored on manual /compact too. Custom instructions imply a
+ // directed LLM summary; a text-only model cannot read the frames back —
+ // both take the summarizer path (the latter loudly).
+ const wantsSnapcompact =
+ compactionPrep.kind !== "fromHook" && compactionSettings.strategy === "snapcompact" && !customInstructions;
+ const snapcompactReady = wantsSnapcompact && this.model.input.includes("image");
+ if (wantsSnapcompact && !snapcompactReady) {
+ this.emitNotice(
+ "warning",
+ `snapcompact needs a vision-capable model (${this.model.id} is text-only) — using an LLM summary instead`,
+ "compaction",
+ );
+ }
+
let summary: string;
let shortSummary: string | undefined;
let firstKeptEntryId: string;
@@ -6250,6 +6322,14 @@ export class AgentSession {
tokensBefore = compactionPrep.tokensBefore;
details = compactionPrep.details;
preserveData = compactionPrep.preserveData;
+ } else if (snapcompactReady) {
+ const snapcompactResult = await snapcompactCompact(preparation, { convertToLlm, model: this.model });
+ summary = snapcompactResult.summary;
+ shortSummary = snapcompactResult.shortSummary;
+ firstKeptEntryId = snapcompactResult.firstKeptEntryId;
+ tokensBefore = snapcompactResult.tokensBefore;
+ details = snapcompactResult.details;
+ preserveData = { ...(compactionPrep.preserveData ?? {}), ...(snapcompactResult.preserveData ?? {}) };
} else {
// Generate compaction result. Only convert known abort-shaped
// rejections (AbortError raised while the abort signal is set,
@@ -6680,6 +6760,10 @@ export class AgentSession {
return false;
}
+ // Supersede pass runs every turn, before any threshold gating: it is cheap
+ // (bails when no candidate) and independent of the compaction setting.
+ const supersedeResult = await this.#pruneSupersededReads();
+
const compactionSettings = this.settings.getGroup("compaction");
if (!compactionSettings.enabled || compactionSettings.strategy === "off") return false;
@@ -6688,6 +6772,9 @@ export class AgentSession {
if (assistantMessage.stopReason === "error") return false;
const pruneResult = await this.#pruneToolOutputs();
let contextTokens = calculateContextTokens(assistantMessage.usage);
+ if (supersedeResult) {
+ contextTokens = Math.max(0, contextTokens - supersedeResult.tokensSaved);
+ }
if (pruneResult) {
contextTokens = Math.max(0, contextTokens - pruneResult.tokensSaved);
}
@@ -7578,9 +7665,25 @@ export class AgentSession {
// "overflow" forces context-full because the input itself is broken — a handoff
// LLM call would hit the same overflow. "incomplete" is an output-side problem,
- // so a handoff request on the existing context is still viable.
- let action: "context-full" | "handoff" =
+ // so a handoff request on the existing context is still viable. Snapcompact is
+ // safe for every reason (it makes no LLM call at all) but requires a vision
+ // model to be worth anything — fall back to context-full otherwise.
+ let action: "context-full" | "handoff" | "snapcompact" =
compactionSettings.strategy === "handoff" && reason !== "overflow" ? "handoff" : "context-full";
+ if (compactionSettings.strategy === "snapcompact") {
+ if (this.model?.input.includes("image")) {
+ action = "snapcompact";
+ } else {
+ logger.warn("Snapcompact compaction requires a vision-capable model; falling back to context-full", {
+ model: this.model?.id,
+ });
+ this.emitNotice(
+ "warning",
+ `snapcompact needs a vision-capable model (${this.model?.id ?? "unknown"} is text-only) — using an LLM summary instead`,
+ "compaction",
+ );
+ }
+ }
await this.#emitSessionEvent({ type: "auto_compaction_start", reason, action });
// Abort any older auto-compaction before installing this run's controller.
this.#autoCompactionAbortController?.abort();
@@ -7719,6 +7822,16 @@ export class AgentSession {
tokensBefore = compactionPrep.tokensBefore;
details = compactionPrep.details;
preserveData = compactionPrep.preserveData;
+ } else if (action === "snapcompact") {
+ // Local, deterministic: render discarded history onto PNG frames.
+ // No model candidates, no API key, no retry loop.
+ const snapcompactResult = await snapcompactCompact(preparation, { convertToLlm, model: this.model });
+ summary = snapcompactResult.summary;
+ shortSummary = snapcompactResult.shortSummary;
+ firstKeptEntryId = snapcompactResult.firstKeptEntryId;
+ tokensBefore = snapcompactResult.tokensBefore;
+ details = snapcompactResult.details;
+ preserveData = { ...(compactionPrep.preserveData ?? {}), ...(snapcompactResult.preserveData ?? {}) };
} else {
const candidates = this.#getCompactionModelCandidates(availableModels);
const retrySettings = this.settings.getGroup("retry");
@@ -8926,118 +9039,56 @@ export class AgentSession {
}
// =========================================================================
- // Background-Channel IRC Exchanges
+ // IRC Delivery
// =========================================================================
/**
- * Generate an ephemeral reply to a background message (e.g. an IRC ping from
- * another agent) using this session's current model + system prompt + history.
+ * Deliver an IRC message into this session (recipient side; called by the
+ * IrcBus). Emits the `irc_message` session event for UI cards and injects
+ * the rendered message into the model's context as an `irc:incoming`
+ * custom message:
*
- * The incoming message is queued for injection into the recipient's persisted
- * history immediately so timeouts/abort still preserve delivery. The reply is
- * computed via a side-channel `streamSimple` call (analogous to `/btw`) so it
- * never blocks on the recipient's in-flight tool calls. When a reply is
- * generated, it is queued separately. Injection happens immediately when the
- * session is idle, otherwise it is deferred until streaming ends.
+ * - mid-turn → queued on the aside channel and folded in at the next step
+ * boundary (non-interrupting, like async-result deliveries) → "injected";
+ * - idle → starts a real turn with the message so the recipient wakes
+ * → "woken".
+ *
+ * Never blocks on the recipient's turn: the wake turn is fire-and-forget.
*/
- async respondAsBackground(args: {
- from: string;
- message: string;
- awaitReply?: boolean;
- signal?: AbortSignal;
- }): Promise<{ replyText: string | null }> {
- const awaitReply = args.awaitReply !== false;
- const incomingTimestamp = Date.now();
- const incomingRecord: CustomMessage = {
+ async deliverIrcMessage(msg: IrcMessage): Promise<"injected" | "woken"> {
+ if (this.#isDisposed) {
+ throw new Error("Recipient session is disposed.");
+ }
+ const record: CustomMessage = {
role: "custom",
customType: "irc:incoming",
- content: `[IRC \`${args.from}\` → you]\n\n${args.message}`,
+ content: prompt.render(ircIncomingTemplate, {
+ from: msg.from,
+ message: msg.body,
+ replyTo: msg.replyTo ?? "",
+ }),
display: true,
- details: { from: args.from, message: args.message },
+ details: { id: msg.id, from: msg.from, message: msg.body, ...(msg.replyTo ? { replyTo: msg.replyTo } : {}) },
attribution: "agent",
- timestamp: incomingTimestamp,
+ timestamp: msg.ts,
};
- void this.#emitSessionEvent({ type: "irc_message", message: incomingRecord });
- this.#forwardIrcRelayToMain({
- from: args.from,
- to: this.#agentId ?? "?",
- body: args.message,
- kind: "message",
- timestamp: incomingTimestamp,
- });
-
- this.#queueBackgroundExchangeInjection([incomingRecord]);
- if (!awaitReply) {
- return { replyText: null };
+ void this.#emitSessionEvent({ type: "irc_message", message: record });
+ if (this.isStreaming) {
+ this.#pendingIrcAsides.push(record);
+ return "injected";
}
-
- const incomingPrompt = prompt.render(ircIncomingTemplate, {
- from: args.from,
- message: args.message,
+ // Idle: same wake primitive the yield queue uses for async-result
+ // delivery — prompt the agent directly so a real turn runs.
+ this.agent.prompt(record).catch(error => {
+ logger.warn("IRC wake turn failed", { from: msg.from, to: msg.to, error: String(error) });
});
- const { replyText } = await this.runEphemeralTurn({
- promptText: incomingPrompt,
- signal: args.signal,
- });
-
- const replyRecord: CustomMessage = {
- role: "custom",
- customType: "irc:autoreply",
- content: `[IRC you → \`${args.from}\` (auto)]\n\n${replyText}`,
- display: true,
- details: { to: args.from, reply: replyText },
- attribution: "agent",
- timestamp: Date.now(),
- };
- void this.#emitSessionEvent({ type: "irc_message", message: replyRecord });
- this.#forwardIrcRelayToMain({
- from: this.#agentId ?? "?",
- to: args.from,
- body: replyText,
- kind: "reply",
- timestamp: replyRecord.timestamp,
- });
- this.#queueBackgroundExchangeInjection([replyRecord]);
-
- return { replyText };
- }
-
- /**
- * Forward an IRC exchange observation to the main agent's session UI so the
- * user can see every IRC conversation in the main transcript, even when the
- * main agent is not a direct participant. The relay record is display-only:
- * it is NOT injected into the main agent's persisted history.
- */
- #forwardIrcRelayToMain(args: {
- from: string;
- to: string;
- body: string;
- kind: "message" | "reply";
- timestamp: number;
- }): void {
- const registry = this.#agentRegistry;
- if (!registry) return;
- // If this session is the main agent, the local emit already reached the main UI.
- if (this.#agentId === MAIN_AGENT_ID) return;
- const mainRef = registry.get(MAIN_AGENT_ID);
- const mainSession = mainRef?.session;
- if (!mainSession || mainSession === this) return;
- const arrow = args.kind === "reply" ? "→ (auto)" : "→";
- const relayRecord: CustomMessage = {
- role: "custom",
- customType: "irc:relay",
- content: `[IRC \`${args.from}\` ${arrow} \`${args.to}\`]\n\n${args.body}`,
- display: true,
- details: { from: args.from, to: args.to, body: args.body, kind: args.kind },
- attribution: "agent",
- timestamp: args.timestamp,
- };
- mainSession.emitIrcRelayObservation(relayRecord);
+ return "woken";
}
/**
* Emit an IRC relay observation event on this session for UI rendering only.
- * Does not persist the record to history. Public so other sessions can forward.
+ * Does not persist the record to history. Called by the IrcBus to surface
+ * agent↔agent traffic on the main session.
*/
emitIrcRelayObservation(record: CustomMessage): void {
void this.#emitSessionEvent({ type: "irc_message", message: record });
@@ -9049,7 +9100,7 @@ export class AgentSession {
* does not block on, or interfere with, any in-flight main turn. The
* session's history and persisted state are NOT modified by this call.
*
- * Used by `respondAsBackground` (IRC) and `BtwController` (`/btw`) to share
+ * Used by `BtwController` (`/btw`) and `OmfgController` (`/omfg`) to share
* the snapshot + stream pipeline. The snapshot includes any in-flight
* streaming assistant text so the model sees the half-finished response
* rather than missing context.
@@ -9137,7 +9188,7 @@ export class AgentSession {
args.onTextDelta(replyText.slice(emittedReplyText.length));
}
return {
- replyText: args.dedupeReply === false ? replyText.trim() : dedupeIrcReply(replyText.trim()),
+ replyText: args.dedupeReply === false ? replyText.trim() : dedupeEphemeralReply(replyText.trim()),
assistantMessage,
};
}
@@ -9188,46 +9239,21 @@ export class AgentSession {
return messages;
}
- #queueBackgroundExchangeInjection(messages: CustomMessage[]): void {
- this.#pendingBackgroundExchanges.push(messages);
- if (!this.isStreaming) {
- this.#flushPendingBackgroundExchanges();
- return;
- }
- this.#scheduleBackgroundExchangeFlush();
- }
-
- #scheduleBackgroundExchangeFlush(): void {
- if (this.#scheduledBackgroundExchangeFlush) return;
- this.#scheduledBackgroundExchangeFlush = true;
- const attempt = (): void => {
- if (this.#pendingBackgroundExchanges.length === 0 || this.#isDisposed) {
- this.#pendingBackgroundExchanges = [];
- this.#scheduledBackgroundExchangeFlush = false;
- return;
- }
- if (this.isStreaming) {
- setTimeout(attempt, 50);
- return;
- }
- this.#scheduledBackgroundExchangeFlush = false;
- this.#flushPendingBackgroundExchanges();
- };
- setTimeout(attempt, 0);
- }
-
- #flushPendingBackgroundExchanges(): void {
- if (this.#pendingBackgroundExchanges.length === 0) return;
- const batches = this.#pendingBackgroundExchanges;
- this.#pendingBackgroundExchanges = [];
- for (const batch of batches) {
- for (const msg of batch) {
- // emitExternalEvent on message_end appends to agent state and dispatches
- // to all session listeners, which in turn handle TUI rendering and
- // sessionManager persistence via #handleAgentEvent.
- this.agent.emitExternalEvent({ type: "message_start", message: msg });
- this.agent.emitExternalEvent({ type: "message_end", message: msg });
- }
+ /**
+ * Persist any IRC asides that missed their step-boundary injection (the
+ * message landed after the turn's last aside drain). Called at the start
+ * of the next prompt so the model still sees them.
+ */
+ #flushPendingIrcAsides(): void {
+ if (this.#pendingIrcAsides.length === 0) return;
+ const records = this.#pendingIrcAsides;
+ this.#pendingIrcAsides = [];
+ for (const record of records) {
+ // emitExternalEvent on message_end appends to agent state and dispatches
+ // to all session listeners, which in turn handle TUI rendering and
+ // sessionManager persistence via #handleAgentEvent.
+ this.agent.emitExternalEvent({ type: "message_start", message: record });
+ this.agent.emitExternalEvent({ type: "message_end", message: record });
}
}
@@ -10032,69 +10058,6 @@ export class AgentSession {
});
}
- /**
- * Format the conversation as compact context for subagents.
- * Includes only user messages and assistant text responses.
- * Excludes: system prompt, tool definitions, tool calls/results, thinking blocks.
- */
- formatCompactContext(): string {
- const lines: string[] = [];
- lines.push("# Conversation Context");
- lines.push("");
- lines.push(
- "This is a summary of the parent conversation. Read this if you need additional context about what was discussed or decided.",
- );
- lines.push("");
-
- for (const msg of this.messages) {
- if (msg.role === "user" || msg.role === "developer") {
- lines.push(msg.role === "developer" ? "## Developer" : "## User");
- lines.push("");
- if (typeof msg.content === "string") {
- lines.push(msg.content);
- } else {
- for (const c of msg.content) {
- if (c.type === "text") {
- lines.push(c.text);
- } else if (c.type === "image") {
- lines.push("[Image attached]");
- }
- }
- }
- lines.push("");
- } else if (msg.role === "assistant") {
- const assistantMsg = msg as AssistantMessage;
- // Only include text content, skip tool calls and thinking
- const textParts: string[] = [];
- for (const c of assistantMsg.content) {
- if (c.type === "text" && c.text.trim()) {
- textParts.push(c.text);
- }
- }
- if (textParts.length > 0) {
- lines.push("## Assistant");
- lines.push("");
- lines.push(textParts.join("\n\n"));
- lines.push("");
- }
- } else if (msg.role === "fileMention") {
- const fileMsg = msg as FileMentionMessage;
- const paths = fileMsg.files.map(f => f.path).join(", ");
- lines.push(`[Files referenced: ${paths}]`);
- lines.push("");
- } else if (msg.role === "compactionSummary") {
- const compactMsg = msg as CompactionSummaryMessage;
- lines.push("## Earlier Context (Summarized)");
- lines.push("");
- lines.push(compactMsg.summary);
- lines.push("");
- }
- // Skip: toolResult, bashExecution, pythonExecution, branchSummary, custom, hookMessage
- }
-
- return lines.join("\n").trim();
- }
-
// =========================================================================
// Extension System
// =========================================================================
diff --git a/packages/coding-agent/src/session/messages.ts b/packages/coding-agent/src/session/messages.ts
index 838d861b0..c19089cfa 100644
--- a/packages/coding-agent/src/session/messages.ts
+++ b/packages/coding-agent/src/session/messages.ts
@@ -8,8 +8,7 @@ import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
import {
type BranchSummaryMessage,
type CompactionSummaryMessage,
- renderBranchSummaryContext,
- renderCompactionSummaryContext,
+ convertMessageToLlm,
} from "@oh-my-pi/pi-agent-core/compaction/messages";
import type {
AssistantMessage,
@@ -17,7 +16,6 @@ import type {
Message,
MessageAttribution,
TextContent,
- ToolResultMessage,
UserMessage,
} from "@oh-my-pi/pi-ai";
import { prompt } from "@oh-my-pi/pi-utils";
@@ -28,6 +26,7 @@ export {
type CompactionSummaryMessage,
createBranchSummaryMessage,
createCompactionSummaryMessage,
+ createCustomMessage,
} from "@oh-my-pi/pi-agent-core/compaction/messages";
import type { OutputMeta } from "../tools/output-meta";
@@ -59,7 +58,7 @@ export interface SkillPromptDetails {
*
* Consumers: `AgentSession.#handleAgentEvent` (stamper) writes this value;
* `EventController.#handleMessageEnd`, `AssistantMessageComponent`,
- * `ui-helpers.addMessageToChat` (renderers), `SessionObserverOverlay
+ * `ui-helpers.addMessageToChat` (renderers), `AgentHubOverlayComponent
* #buildTranscriptLines`, `runPrintMode`, and `AcpAgent#replayAssistantMessage`
* (fallback error emission) read it via `isSilentAbort`. */
export const SILENT_ABORT_MARKER = "__omp.silent_abort__";
@@ -220,15 +219,6 @@ export function wrapSteeringForModel(messages: AgentMessage[]): AgentMessage[] {
return wrappedMessages ?? messages;
}
-function getPrunedToolResultContent(message: ToolResultMessage): (TextContent | ImageContent)[] {
- if (message.prunedAt === undefined) {
- return message.content;
- }
- const textBlocks = message.content.filter((content): content is TextContent => content.type === "text");
- const text = textBlocks.map(block => block.text).join("") || "[Output truncated]";
- return [{ type: "text", text }];
-}
-
/** Result of filtering image blocks out of a `(TextContent | ImageContent)[]` array. */
interface StripContentResult {
content: (TextContent | ImageContent)[];
@@ -478,26 +468,6 @@ export function sanitizeRehydratedOpenAIResponsesAssistantMessage(message: Assis
};
}
-/** Convert CustomMessageEntry to AgentMessage format */
-export function createCustomMessage(
- customType: string,
- content: string | (TextContent | ImageContent)[],
- display: boolean,
- details: unknown | undefined,
- timestamp: string,
- attribution?: MessageAttribution,
-): CustomMessage {
- return {
- role: "custom",
- customType,
- content,
- display,
- details,
- attribution,
- timestamp: new Date(timestamp).getTime(),
- };
-}
-
/**
* Transform AgentMessages (including custom types) to LLM-compatible Messages.
*
@@ -530,43 +500,6 @@ export function convertToLlm(messages: AgentMessage[]): Message[] {
attribution: "user",
timestamp: m.timestamp,
};
- case "custom":
- case "hookMessage": {
- const content = typeof m.content === "string" ? [{ type: "text" as const, text: m.content }] : m.content;
- const role = "developer";
- const attribution = m.attribution;
- return {
- role,
- content,
- attribution,
- timestamp: m.timestamp,
- };
- }
- case "branchSummary":
- return {
- role: "user",
- content: [
- {
- type: "text" as const,
- text: renderBranchSummaryContext(m.summary),
- },
- ],
- attribution: "agent",
- timestamp: m.timestamp,
- };
- case "compactionSummary":
- return {
- role: "user",
- content: [
- {
- type: "text" as const,
- text: renderCompactionSummaryContext(m.summary),
- },
- ],
- attribution: "agent",
- providerPayload: m.providerPayload,
- timestamp: m.timestamp,
- };
case "fileMention": {
const fileContents = m.files
.map(file => {
@@ -587,18 +520,18 @@ export function convertToLlm(messages: AgentMessage[]): Message[] {
timestamp: m.timestamp,
};
}
+ case "custom":
+ case "hookMessage":
+ case "branchSummary":
+ case "compactionSummary":
case "user":
- return { ...m, attribution: m.attribution ?? "user" };
case "developer":
- return { ...m, attribution: m.attribution ?? "agent" };
case "assistant":
- return m;
case "toolResult":
- return {
- ...m,
- content: getPrunedToolResultContent(m as ToolResultMessage),
- attribution: m.attribution ?? "agent",
- };
+ // Core roles share one transformer with agent-core —
+ // duplicating them here is how snapcompact frames once
+ // silently fell off the provider request.
+ return convertMessageToLlm(m);
default:
m satisfies never;
return undefined;
diff --git a/packages/coding-agent/src/session/session-history-format.ts b/packages/coding-agent/src/session/session-history-format.ts
new file mode 100644
index 000000000..9588ed1fc
--- /dev/null
+++ b/packages/coding-agent/src/session/session-history-format.ts
@@ -0,0 +1,246 @@
+/**
+ * Concise markdown transcript serializer for `history://` URLs.
+ *
+ * Unlike `session-dump-format.ts` (verbose `/dump` export), this emits a
+ * compressed transcript: full user/assistant/developer text, tool call +
+ * result pairs collapsed to single lines, thinking elided, custom messages
+ * as one-liners. No system prompt, no tool catalog, no config sections.
+ */
+import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
+import { INTENT_FIELD } from "@oh-my-pi/pi-agent-core";
+import type { AssistantMessage, ImageContent, TextContent, ToolResultMessage } from "@oh-my-pi/pi-ai";
+import type {
+ BashExecutionMessage,
+ BranchSummaryMessage,
+ CompactionSummaryMessage,
+ CustomMessage,
+ FileMentionMessage,
+ HookMessage,
+ PythonExecutionMessage,
+} from "./messages";
+
+export interface HistoryFormatOptions {
+ /** Optional H1 prepended to the transcript. */
+ title?: string;
+}
+
+/** Max length of the primary-arg summary inside `→ tool(...)` lines. */
+const PRIMARY_ARG_MAX = 120;
+
+/** Per-tool preference order for the most informative scalar argument. */
+const PRIMARY_ARG_KEYS = [
+ "path",
+ "file_path",
+ "filePath",
+ "command",
+ "cmd",
+ "pattern",
+ "url",
+ "query",
+ "prompt",
+ "assignment",
+ "message",
+ "op",
+ "name",
+ "id",
+] as const;
+
+/** Collapse whitespace runs and truncate to `max` chars with an ellipsis. */
+function oneLine(text: string, max = PRIMARY_ARG_MAX): string {
+ const flat = text.replace(/\s+/g, " ").trim();
+ return flat.length > max ? `${flat.slice(0, max - 1)}…` : flat;
+}
+
+/** Join the text blocks of a string-or-blocks content field. Images become `[image]`. */
+function contentToText(content: string | readonly (TextContent | ImageContent)[]): string {
+ if (typeof content === "string") return content;
+ const parts: string[] = [];
+ for (const block of content) {
+ if (block.type === "text") parts.push(block.text);
+ else parts.push("[image]");
+ }
+ return parts.join("\n");
+}
+
+function lineCount(text: string): number {
+ if (!text) return 0;
+ return text.split("\n").length;
+}
+
+/** Pick the most informative scalar argument of a tool call. */
+function primaryArg(args: Record | undefined): string {
+ if (!args || typeof args !== "object") return "";
+ for (const key of PRIMARY_ARG_KEYS) {
+ const value = args[key];
+ if (typeof value === "string" && value.length > 0) return oneLine(value);
+ if (Array.isArray(value) && value.length > 0 && value.every(v => typeof v === "string")) {
+ return oneLine(value.join(", "));
+ }
+ }
+ // Fallback: first non-intent string arg, then a compact JSON of the args.
+ const rest: Record = {};
+ let restCount = 0;
+ for (const key in args) {
+ if (key === INTENT_FIELD) continue;
+ const value = args[key];
+ if (typeof value === "string" && value.length > 0) return oneLine(value);
+ rest[key] = value;
+ restCount++;
+ }
+ if (restCount === 0) return "";
+ try {
+ return oneLine(JSON.stringify(rest));
+ } catch {
+ return "";
+ }
+}
+
+/** One line per tool call: `→ read(src/foo.ts:50-80) ⇒ ok · 31 lines`. */
+function toolCallLine(
+ name: string,
+ args: Record | undefined,
+ result: ToolResultMessage | undefined,
+): string {
+ const head = `→ ${name}(${primaryArg(args)})`;
+ if (!result) return `${head} ⇒ pending`;
+ const text = contentToText(result.content);
+ const lines = lineCount(text);
+ const count = `${lines} ${lines === 1 ? "line" : "lines"}`;
+ if (result.isError) {
+ const firstLine = oneLine(text.split("\n", 1)[0] ?? "");
+ return firstLine ? `${head} ⇒ error · ${count} — ${firstLine}` : `${head} ⇒ error · ${count}`;
+ }
+ return `${head} ⇒ ok · ${count}`;
+}
+
+/** One line for a user-initiated `!`/`$` execution. */
+function executionLine(
+ kind: "bash" | "python",
+ source: string,
+ msg: BashExecutionMessage | PythonExecutionMessage,
+): string {
+ const status = msg.cancelled
+ ? "cancelled"
+ : msg.exitCode !== undefined && msg.exitCode !== 0
+ ? `error · exit ${msg.exitCode}`
+ : "ok";
+ const lines = lineCount(msg.output);
+ return `→ ${kind}! ${oneLine(source)} ⇒ ${status} · ${lines} ${lines === 1 ? "line" : "lines"}`;
+}
+
+/** One-liner for custom/hook messages: `[irc] A → B: body…`. */
+function customOneLiner(msg: CustomMessage | HookMessage): string {
+ const details = (msg.details ?? {}) as Record;
+ const str = (key: string): string => (typeof details[key] === "string" ? (details[key] as string) : "");
+ switch (msg.customType) {
+ case "irc:incoming":
+ return `[irc] ${str("from") || "?"} → me: ${oneLine(str("message"))}`;
+ case "irc:relay":
+ return `[irc] ${str("from") || "?"} → ${str("to") || "?"}: ${oneLine(str("body"))}`;
+ case "async-result": {
+ const jobs = Array.isArray(details.jobs) && details.jobs.length > 0 ? details.jobs : [details];
+ const labels = jobs
+ .map(job => {
+ const j = (job ?? {}) as Record;
+ return typeof j.label === "string" && j.label ? j.label : typeof j.jobId === "string" ? j.jobId : "job";
+ })
+ .join(", ");
+ return `[async-result] ${oneLine(labels)}`;
+ }
+ default:
+ return `[${msg.customType}] ${oneLine(contentToText(msg.content))}`;
+ }
+}
+
+/**
+ * Format a session's message array as a concise markdown transcript.
+ *
+ * `messages` is the session's in-memory message array (or the read-only
+ * equivalent loaded from a session file) — the same shapes
+ * `session-dump-format.ts` consumes.
+ */
+export function formatSessionHistoryMarkdown(messages: unknown[], opts?: HistoryFormatOptions): string {
+ const typed = messages as AgentMessage[];
+ const lines: string[] = [];
+ if (opts?.title) {
+ lines.push(`# ${opts.title}`, "");
+ }
+
+ // Index tool results by call id so each toolCall collapses to one line.
+ const resultsByCallId = new Map();
+ for (const msg of typed) {
+ if (msg.role === "toolResult") {
+ resultsByCallId.set(msg.toolCallId, msg);
+ }
+ }
+ const consumed = new Set();
+
+ for (const msg of typed) {
+ switch (msg.role) {
+ case "user":
+ case "developer": {
+ const text = contentToText(msg.content);
+ if (!text.trim()) break;
+ lines.push(`## ${msg.role}`, "", text, "");
+ break;
+ }
+ case "assistant": {
+ const assistantMsg = msg as AssistantMessage;
+ const body: string[] = [];
+ for (const block of assistantMsg.content) {
+ if (block.type === "text") {
+ if (block.text.trim()) body.push(block.text);
+ } else if (block.type === "toolCall") {
+ const result = resultsByCallId.get(block.id);
+ if (result) consumed.add(block.id);
+ body.push(toolCallLine(block.name, block.arguments, result));
+ }
+ // thinking / redactedThinking elided entirely
+ }
+ if (body.length === 0) break;
+ lines.push("## assistant", "", ...body, "");
+ break;
+ }
+ case "toolResult": {
+ // Normally consumed by its toolCall; orphans (e.g. truncated history) get their own line.
+ if (consumed.has(msg.toolCallId)) break;
+ lines.push(toolCallLine(msg.toolName, undefined, msg), "");
+ break;
+ }
+ case "bashExecution": {
+ const bashMsg = msg as BashExecutionMessage;
+ if (bashMsg.excludeFromContext) break;
+ lines.push(executionLine("bash", bashMsg.command, bashMsg), "");
+ break;
+ }
+ case "pythonExecution": {
+ const pythonMsg = msg as PythonExecutionMessage;
+ if (pythonMsg.excludeFromContext) break;
+ lines.push(executionLine("python", pythonMsg.code, pythonMsg), "");
+ break;
+ }
+ case "custom":
+ case "hookMessage": {
+ lines.push(customOneLiner(msg as CustomMessage | HookMessage), "");
+ break;
+ }
+ case "branchSummary": {
+ const branchMsg = msg as BranchSummaryMessage;
+ lines.push(`[branch] from ${branchMsg.fromId}: ${oneLine(branchMsg.summary)}`, "");
+ break;
+ }
+ case "compactionSummary": {
+ const compactMsg = msg as CompactionSummaryMessage;
+ lines.push(`[compaction] ${oneLine(compactMsg.summary)}`, "");
+ break;
+ }
+ case "fileMention": {
+ const fileMsg = msg as FileMentionMessage;
+ lines.push(`[file-mention] ${oneLine(fileMsg.files.map(f => f.path).join(", "))}`, "");
+ break;
+ }
+ }
+ }
+
+ return `${lines.join("\n").trim()}\n`;
+}
diff --git a/packages/coding-agent/src/session/session-manager.ts b/packages/coding-agent/src/session/session-manager.ts
index 5252730a7..2c8f49c86 100644
--- a/packages/coding-agent/src/session/session-manager.ts
+++ b/packages/coding-agent/src/session/session-manager.ts
@@ -27,6 +27,7 @@ import {
Snowflake,
toError,
} from "@oh-my-pi/pi-utils";
+import { getPreservedSnapcompactArchive, snapcompactImages } from "@oh-my-pi/snapcompact";
import { ArtifactManager } from "./artifacts";
import {
type BlobPutOptions,
@@ -544,6 +545,17 @@ export function getLatestCompactionEntry(entries: SessionEntry[]): CompactionEnt
return null;
}
+export interface BuildSessionContextOptions {
+ /**
+ * Build the full-history display transcript instead of the LLM context:
+ * every path entry in chronological order, with each compaction emitted
+ * inline as a `compactionSummary` message at the position it fired rather
+ * than replacing the history before it. Display-only — never send the
+ * result to a provider.
+ */
+ transcript?: boolean;
+}
+
/**
* Build the session context from entries using tree traversal.
* If leafId is provided, walks from that entry to root.
@@ -553,6 +565,7 @@ export function buildSessionContext(
entries: SessionEntry[],
leafId?: string | null,
byId?: Map,
+ options?: BuildSessionContextOptions,
): SessionContext {
// Build uuid index if not available
if (!byId) {
@@ -692,7 +705,29 @@ export function buildSessionContext(
}
};
- if (compaction) {
+ if (options?.transcript) {
+ // Display transcript: every entry in chronological order. Compactions do
+ // not erase prior history here — each renders inline (as a divider in the
+ // TUI) at the point it fired, with any snapcompact frames re-attached so
+ // the component can report them.
+ for (const entry of path) {
+ if (entry.type === "compaction") {
+ const snapcompactArchive = getPreservedSnapcompactArchive(entry.preserveData);
+ messages.push(
+ createCompactionSummaryMessage(
+ entry.summary,
+ entry.tokensBefore,
+ entry.timestamp,
+ entry.shortSummary,
+ undefined,
+ snapcompactArchive ? snapcompactImages(snapcompactArchive) : undefined,
+ ),
+ );
+ } else {
+ appendMessage(entry);
+ }
+ }
+ } else if (compaction) {
const providerPayload: ProviderPayload | undefined = (() => {
const candidate = compaction.preserveData?.openaiRemoteCompaction;
if (!candidate || typeof candidate !== "object") return undefined;
@@ -707,7 +742,9 @@ export function buildSessionContext(
})();
const remoteReplacementHistory = providerPayload?.items;
- // Emit summary first
+ // Emit summary first; re-attach any archived snapcompact frames so the
+ // model can keep reading the archived history after every context rebuild.
+ const snapcompactArchive = getPreservedSnapcompactArchive(compaction.preserveData);
messages.push(
createCompactionSummaryMessage(
compaction.summary,
@@ -715,6 +752,7 @@ export function buildSessionContext(
compaction.timestamp,
compaction.shortSummary,
providerPayload,
+ snapcompactArchive ? snapcompactImages(snapcompactArchive) : undefined,
),
);
@@ -957,6 +995,21 @@ async function resolveBlobRefsInEntries(entries: FileEntry[], blobStore: BlobSto
await Promise.all(promises);
}
+/**
+ * Read-only message view of a session file: load entries, migrate to the
+ * current version, resolve blob refs, and build the context along the
+ * persisted leaf path (last entry). Does NOT create a writer or take the
+ * session lock — safe to call against a file another session is writing.
+ */
+export async function loadSessionMessagesReadOnly(filePath: string): Promise {
+ const entries = await loadEntriesFromFile(filePath);
+ if (entries.length === 0) return [];
+ migrateToCurrentVersion(entries);
+ await resolveBlobRefsInEntries(entries, new BlobStore(getBlobsDir()));
+ const sessionEntries = entries.filter((e): e is SessionEntry => e.type !== "session");
+ return buildSessionContext(sessionEntries).messages;
+}
+
/**
* Lightweight metadata for a session file, used in session picker UI.
* Uses lazy getters to defer string formatting until actually displayed.
@@ -3205,11 +3258,12 @@ export class SessionManager {
}
/**
- * Build the session context (what gets sent to the LLM).
+ * Build the session context (what gets sent to the LLM), or — with
+ * `{ transcript: true }` — the full-history display transcript.
* Uses tree traversal from current leaf.
*/
- buildSessionContext(): SessionContext {
- return buildSessionContext(this.getEntries(), this.#leafId, this.#byId);
+ buildSessionContext(options?: BuildSessionContextOptions): SessionContext {
+ return buildSessionContext(this.getEntries(), this.#leafId, this.#byId, options);
}
/** Strip stale OpenAI Responses assistant replay metadata from loaded in-memory entries. */
diff --git a/packages/coding-agent/src/session/streaming-output.ts b/packages/coding-agent/src/session/streaming-output.ts
index 2eee2dd25..b4aa18ab4 100644
--- a/packages/coding-agent/src/session/streaming-output.ts
+++ b/packages/coding-agent/src/session/streaming-output.ts
@@ -570,6 +570,66 @@ export function truncateMiddle(content: string, options: TruncationOptions = {})
};
}
+// =============================================================================
+// Inline byte cap — final defense at the tool-result boundary
+// =============================================================================
+
+/** Options for {@link enforceInlineByteCap}. */
+export interface InlineByteCapOptions {
+ /** Inline byte budget. Defaults to {@link DEFAULT_MAX_BYTES}. */
+ maxBytes?: number;
+ /** What the text is, for the elision marker (e.g. "bash output"). */
+ label: string;
+ /**
+ * Persist the full text as a session artifact. When an artifact id is
+ * returned, a `[raw output: artifact://]` footer is appended so the
+ * elided bytes stay recoverable.
+ */
+ saveArtifact?: (full: string) => string | undefined | Promise;
+}
+
+/** Drop the partial last line of a head window (keep it if there is no newline at all). */
+function trimHeadToLineBoundary(text: string): string {
+ const idx = text.lastIndexOf(NL);
+ return idx > 0 ? text.substring(0, idx) : text;
+}
+
+/** Drop the partial first line of a tail window (keep it if there is no newline at all). */
+function trimTailToLineBoundary(text: string): string {
+ const idx = text.indexOf(NL);
+ if (idx < 0 || idx === text.length - 1) return text;
+ return text.substring(idx + 1);
+}
+
+/**
+ * Final-defense inline size guard for tool results.
+ *
+ * No-op when `text` fits within `maxBytes` (the common path). Otherwise keeps
+ * ~60% of the budget from the head and ~25% from the tail — cut on line
+ * boundaries, never splitting a multi-byte UTF-8 sequence — with an elision
+ * marker between. The remaining ~15% is slack for the marker and the optional
+ * `[raw output: artifact://]` footer, so the result stays under `maxBytes`.
+ */
+export async function enforceInlineByteCap(text: string, options: InlineByteCapOptions): Promise {
+ const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES;
+ if (maxBytes <= 0) return text;
+ const totalBytes = Buffer.byteLength(text, "utf-8");
+ if (totalBytes <= maxBytes) return text;
+
+ const head = trimHeadToLineBoundary(truncateHeadBytes(text, Math.floor(maxBytes * 0.6)).text);
+ const tail = trimTailToLineBoundary(truncateTailBytes(text, Math.floor(maxBytes * 0.25)).text);
+ const elidedBytes = Math.max(0, totalBytes - Buffer.byteLength(head, "utf-8") - Buffer.byteLength(tail, "utf-8"));
+ const marker = `[… elided ${elidedBytes} bytes of ${options.label} …]`;
+ let composed = `${head}\n${marker}\n${tail}`;
+
+ const artifactId = await options.saveArtifact?.(text);
+ if (artifactId) {
+ const sep = composed.endsWith(NL) ? "" : NL;
+ composed += `${sep}[raw output: artifact://${artifactId}]`;
+ }
+ return composed;
+}
+
// =============================================================================
// TailBuffer — ring-style tail buffer with lazy joining
// =============================================================================
diff --git a/packages/coding-agent/src/task/executor.ts b/packages/coding-agent/src/task/executor.ts
index a0ebd42c1..dda69eeef 100644
--- a/packages/coding-agent/src/task/executor.ts
+++ b/packages/coding-agent/src/task/executor.ts
@@ -7,6 +7,7 @@
import path from "node:path";
import type { AgentEvent, AgentIdentity, AgentTelemetryConfig, ThinkingLevel } from "@oh-my-pi/pi-agent-core";
import { recordHandoff, resolveTelemetry } from "@oh-my-pi/pi-agent-core";
+import type { Usage } from "@oh-my-pi/pi-ai";
import { logger, prompt, untilAborted } from "@oh-my-pi/pi-utils";
import type { Rule } from "../capability/rule";
import { ModelRegistry } from "../config/model-registry";
@@ -26,8 +27,9 @@ import type { MCPManager } from "../mcp/manager";
import type { MnemopiSessionState } from "../mnemopi/state";
import subagentSystemPromptTemplate from "../prompts/system/subagent-system-prompt.md" with { type: "text" };
import submitReminderTemplate from "../prompts/system/subagent-yield-reminder.md" with { type: "text" };
+import { AgentLifecycleManager } from "../registry/agent-lifecycle";
import { AgentRegistry } from "../registry/agent-registry";
-import { createAgentSession, discoverAuthStorage } from "../sdk";
+import { type CreateAgentSessionOptions, createAgentSession, discoverAuthStorage } from "../sdk";
import type { AgentSession, AgentSessionEvent } from "../session/agent-session";
import type { ArtifactManager } from "../session/artifacts";
import type { AuthStorage } from "../session/auth-storage";
@@ -63,6 +65,30 @@ import {
const MCP_CALL_TIMEOUT_MS = 60_000;
+/**
+ * Soft per-agent request budgets (assistant requests per run). When a subagent
+ * crosses its budget it receives ONE steering notice asking it to wrap up; at
+ * 1.5x the budget the run is aborted gracefully so partial output is salvaged.
+ * The `default` key applies to agents without an explicit entry and can be
+ * overridden via the `task.softRequestBudget` setting (0 disables the guard).
+ */
+export const SOFT_REQUEST_BUDGET: Record = {
+ explore: 40,
+ quick_task: 40,
+ default: 90,
+};
+
+/** Steering notice injected once when a subagent crosses its soft request budget. */
+export function buildBudgetNotice(requests: number): string {
+ return `[budget notice] You have used ${requests} requests in this run. Wrap up now: finish the current step and yield your final report.`;
+}
+
+/** Flatten whitespace and clip salvage text for the cancelled-child summary line. */
+function formatSalvageSnippet(text: string, maxLength = 500): string {
+ const flattened = text.replace(/\s+/g, " ").trim();
+ return flattened.length > maxLength ? `${flattened.slice(0, maxLength - 1)}…` : flattened;
+}
+
/** Agent event types to forward for progress tracking. */
const agentEventTypes = new Set([
"agent_start",
@@ -94,9 +120,13 @@ function normalizeModelPatterns(value: string | string[] | undefined): string[]
function renderIrcPeerRoster(selfId: string): string {
const peers = AgentRegistry.global()
.list()
- .filter(ref => ref.id !== selfId && (ref.status === "running" || ref.status === "idle"));
- if (peers.length === 0) return "- (no other live agents)";
- return peers.map(peer => `- \`${peer.id}\` — ${peer.displayName} (${peer.kind}, ${peer.status})`).join("\n");
+ .filter(ref => ref.id !== selfId && ref.status !== "aborted");
+ if (peers.length === 0) return "- (no other agents)";
+ const lines = peers.map(peer => `- \`${peer.id}\` — ${peer.displayName} (${peer.kind}, ${peer.status})`);
+ if (peers.some(peer => peer.status === "idle" || peer.status === "parked")) {
+ lines.push("Idle/parked peers are not gone: messaging them wakes (or revives) them.");
+ }
+ return lines.join("\n");
}
function withAbortTimeout(promise: Promise, timeoutMs: number, signal?: AbortSignal): Promise {
@@ -152,7 +182,6 @@ export interface ExecutorOptions {
agent: AgentDefinition;
task: string;
assignment?: string;
- context?: string;
/**
* The session's active overall plan, handed off so subagents spawned during
* plan execution share the same plan context as the main agent. Omitted when
@@ -186,8 +215,6 @@ export interface ExecutorOptions {
sessionFile?: string | null;
persistArtifacts?: boolean;
artifactsDir?: string;
- /** Path to parent conversation context file */
- contextFile?: string;
eventBus?: EventBus;
contextFiles?: ContextFileEntry[];
skills?: Skill[];
@@ -611,28 +638,67 @@ export function createSubagentSettings(
});
}
+type AbortReason = "signal" | "terminate" | "timeout" | "budget";
+
+/** Inputs for the shared run monitor used by both fresh spawns and resumes. */
+interface RunMonitorArgs {
+ index: number;
+ id: string;
+ agent: AgentDefinition;
+ task: string;
+ assignment?: string;
+ description?: string;
+ modelOverride?: string | string[];
+ signal?: AbortSignal;
+ onProgress?: (progress: AgentProgress) => void;
+ eventBus?: EventBus;
+ parentToolCallId?: string;
+ sessionFile?: string;
+ /** Soft assistant-request budget; 0 disables the guard. */
+ softRequestBudget: number;
+ /** Wall-clock cap in ms; 0 disables the timer. */
+ maxRuntimeMs: number;
+}
+
/**
- * Run a single agent in-process.
+ * The run-monitoring core shared by {@link runSubprocess} and
+ * {@link resumeSubprocess}: progress tracking, event processing, abort/budget
+ * machinery, usage accumulation, and output capture for one assignment run.
*/
-export async function runSubprocess(options: ExecutorOptions): Promise {
- const {
- cwd,
- agent,
- task,
- assignment,
- index,
- id,
- worktree,
- modelOverride,
- thinkingLevel,
- outputSchema,
- enableLsp,
- signal,
- onProgress,
- } = options;
+interface SubagentRunMonitor {
+ readonly progress: AgentProgress;
+ /** Fires when the run was asked to stop (caller signal, timeout, budget, terminate). */
+ readonly abortSignal: AbortSignal;
+ readonly accumulatedUsage: Usage;
+ hasUsage(): boolean;
+ yieldCalled(): boolean;
+ runtimeLimitExceeded(): boolean;
+ /** True when the abort carries a precise external reason (signal / wall-clock / budget). */
+ hasExplicitAbortReason(): boolean;
+ /** Whether the (attempted) abort counts as a cancelled run rather than an internal failure. */
+ isAbortedRun(): boolean;
+ requestAbort(reason: AbortReason): void;
+ resolveSignalAbortReason(): string;
+ resolveAbortReasonText(): string;
+ setActiveSession(session: AgentSession | null): void;
+ /** Return and clear the active session reference. */
+ takeActiveSession(): AgentSession | null;
+ /** Subscribe the monitor to a session's events. Returns the unsubscribe function. */
+ attach(session: AgentSession): () => void;
+ /** Best-effort capture of the last assistant text for cancelled-run salvage. */
+ captureSalvage(session: AgentSession): void;
+ lastAssistantSalvageText(): string | undefined;
+ /** Final raw output: end-of-run assistant text when available, else accumulated chunks. */
+ rawOutput(): string;
+ scheduleProgress(flush?: boolean): void;
+ /** Stop processing events and clear listeners/timers. Call once the run settled. */
+ finish(): void;
+}
+
+function createSubagentRunMonitor(args: RunMonitorArgs): SubagentRunMonitor {
+ const { index, id, agent, task, assignment, signal, onProgress, softRequestBudget, maxRuntimeMs } = args;
const startTime = Date.now();
- // Initialize progress
const progress: AgentProgress = {
index,
id,
@@ -641,109 +707,23 @@ export async function runSubprocess(options: ExecutorOptions): Promise= 0 && childDepth >= maxRecursionDepth;
-
- // Add tools if specified
- let toolNames: string[] | undefined;
- if (agent.tools && agent.tools.length > 0) {
- toolNames = agent.tools;
- // Auto-include task tool if spawns defined but task not in tools
- if (agent.spawns !== undefined && !toolNames.includes("task") && !atMaxDepth) {
- toolNames = [...toolNames, "task"];
- }
- }
-
- if (atMaxDepth && toolNames?.includes("task")) {
- toolNames = toolNames.filter(name => name !== "task");
- }
- // IRC is always available; the COOP prompt section advertises it, so a restricted
- // whitelist must still carry `irc` for the subagent to actually use it.
- if (toolNames && !toolNames.includes("irc")) {
- toolNames = [...toolNames, "irc"];
- }
- if (toolNames?.includes("exec")) {
- const allowEvalPy = settings.get("eval.py") ?? true;
- const allowEvalJs = settings.get("eval.js") ?? true;
- const expanded = toolNames.filter(name => name !== "exec");
- if (allowEvalPy || allowEvalJs) expanded.push("eval");
- expanded.push("bash");
- toolNames = Array.from(new Set(expanded));
- }
-
- const modelPatterns = normalizeModelPatterns(modelOverride ?? agent.model);
- const sessionFile = subtaskSessionFile ?? null;
- const spawnsEnv = atMaxDepth
- ? ""
- : agent.spawns === undefined
- ? ""
- : agent.spawns === "*"
- ? "*"
- : agent.spawns.join(",");
-
- const lspEnabled = enableLsp ?? true;
- const ircEnabled = subagentSettings.get("irc.enabled") === true;
- const contextFileForPrompt = ircEnabled ? undefined : options.contextFile;
- const skipPythonPreflight = Array.isArray(toolNames) && !toolNames.includes("eval");
-
const outputChunks: string[] = [];
const finalOutputChunks: string[] = [];
const RECENT_OUTPUT_TAIL_BYTES = 8 * 1024;
let recentOutputTail = "";
- let stderr = "";
let resolved = false;
- type AbortReason = "signal" | "terminate" | "timeout";
let abortSent = false;
let abortReason: AbortReason | undefined;
let runtimeLimitExceeded = false;
@@ -752,11 +732,10 @@ export async function runSubprocess(options: ExecutorOptions): Promise void) | null = null;
let yieldCalled = false;
// Accumulate usage incrementally from message_end events (no memory for streaming events)
- const accumulatedUsage = {
+ const accumulatedUsage: Usage = {
input: 0,
output: 0,
cacheRead: 0,
@@ -765,11 +744,17 @@ export async function runSubprocess(options: ExecutorOptions): Promise {
if (reason === "timeout") {
runtimeLimitExceeded = true;
}
+ if (reason === "budget") {
+ budgetLimitExceeded = true;
+ }
if (abortSent) {
if (reason === "signal" && abortReason !== "signal" && abortReason !== "timeout") {
abortReason = "signal";
@@ -786,11 +771,14 @@ export async function runSubprocess(options: ExecutorOptions): Promise {
- if (!resolved) requestAbort("signal");
- };
if (signal) {
- signal.addEventListener("abort", onAbort, { once: true, signal: listenerSignal });
+ signal.addEventListener(
+ "abort",
+ () => {
+ if (!resolved) requestAbort("signal");
+ },
+ { once: true, signal: listenerSignal },
+ );
}
// Wall-clock hard limit. Defense-in-depth for the case where a provider stream
@@ -826,6 +814,9 @@ export async function runSubprocess(options: ExecutorOptions): Promise {
progress.durationMs = Date.now() - startTime;
onProgress?.({ ...progress });
- if (options.eventBus) {
- options.eventBus.emit(TASK_SUBAGENT_PROGRESS_CHANNEL, {
+ if (args.eventBus) {
+ args.eventBus.emit(TASK_SUBAGENT_PROGRESS_CHANNEL, {
index,
agent: agent.name,
agentSource: agent.source,
task,
- parentToolCallId: options.parentToolCallId,
+ parentToolCallId: args.parentToolCallId,
assignment,
progress: { ...progress },
- sessionFile: subtaskSessionFile,
+ sessionFile: args.sessionFile,
});
}
lastProgressEmitMs = Date.now();
@@ -925,8 +916,8 @@ export async function runSubprocess(options: ExecutorOptions): Promise {
- if (!options.eventBus) return;
- options.eventBus.emit(TASK_SUBAGENT_EVENT_CHANNEL, {
+ if (!args.eventBus) return;
+ args.eventBus.emit(TASK_SUBAGENT_EVENT_CHANNEL, {
id,
event,
});
@@ -1078,6 +1069,26 @@ export async function runSubprocess(options: ExecutorOptions): Promise 0 && !abortSent) {
+ if (progress.requests >= softRequestBudget * 1.5) {
+ requestAbort("budget");
+ } else if (!budgetSteerSent && progress.requests >= softRequestBudget) {
+ budgetSteerSent = true;
+ const steerSession = activeSession;
+ if (steerSession) {
+ void steerSession
+ .sendUserMessage(buildBudgetNotice(progress.requests), { deliverAs: "steer" })
+ .catch(err => {
+ logger.warn("Subagent budget steer failed", {
+ error: err instanceof Error ? err.message : String(err),
+ });
+ });
+ }
+ }
+ }
+ }
if (role === "assistant") {
const messageContent =
getMessageContent(event.message) || (event as AgentEvent & { content?: unknown }).content;
@@ -1147,6 +1158,543 @@ export async function runSubprocess(options: ExecutorOptions): Promise void) =>
+ session.subscribe(event => {
+ emitSubagentEvent(event);
+ if (event.type === "auto_retry_start") {
+ progress.retryState = {
+ attempt: event.attempt,
+ maxAttempts: event.maxAttempts,
+ delayMs: event.delayMs,
+ errorMessage: event.errorMessage,
+ startedAtMs: Date.now(),
+ };
+ progress.retryFailure = undefined;
+ scheduleProgress(true);
+ return;
+ }
+ if (event.type === "auto_retry_end") {
+ const attempt = progress.retryState?.attempt ?? event.attempt;
+ progress.retryState = undefined;
+ if (!event.success) {
+ progress.retryFailure = {
+ attempt,
+ errorMessage: event.finalError ?? "Auto-retry failed",
+ };
+ }
+ scheduleProgress(true);
+ return;
+ }
+ if (isAgentEvent(event)) {
+ try {
+ processEvent(event);
+ } catch (err) {
+ logger.error("Subagent event processing failed", {
+ error: err instanceof Error ? err.message : String(err),
+ });
+ requestAbort("terminate");
+ }
+ }
+ });
+
+ const captureSalvage = (session: AgentSession): void => {
+ // Best-effort salvage: capture the last assistant text so
+ // cancelled/aborted children can surface "last activity" instead of
+ // "(no output)".
+ try {
+ const lastContent = session.getLastAssistantMessage()?.content;
+ if (Array.isArray(lastContent)) {
+ const text = lastContent
+ .map(block => (block.type === "text" && typeof block.text === "string" ? block.text : ""))
+ .filter(Boolean)
+ .join("\n");
+ if (text.trim()) {
+ lastAssistantSalvageText = text;
+ }
+ }
+ } catch {
+ // Salvage is best-effort; partial sessions may not implement it
+ }
+ };
+
+ return {
+ progress,
+ abortSignal,
+ accumulatedUsage,
+ hasUsage: () => hasUsage,
+ yieldCalled: () => yieldCalled,
+ runtimeLimitExceeded: () => runtimeLimitExceeded,
+ hasExplicitAbortReason: () => abortReason === "signal" || runtimeLimitExceeded || budgetLimitExceeded,
+ isAbortedRun: () =>
+ abortReason === "signal" || runtimeLimitExceeded || budgetLimitExceeded || abortReason === undefined,
+ requestAbort,
+ resolveSignalAbortReason,
+ resolveAbortReasonText,
+ setActiveSession: session => {
+ activeSession = session;
+ },
+ takeActiveSession: () => {
+ const session = activeSession;
+ activeSession = null;
+ return session;
+ },
+ attach,
+ captureSalvage,
+ lastAssistantSalvageText: () => lastAssistantSalvageText,
+ rawOutput: () => (finalOutputChunks.length > 0 ? finalOutputChunks.join("") : outputChunks.join("")),
+ scheduleProgress,
+ finish: () => {
+ resolved = true;
+ listenerController.abort();
+ if (runtimeTimeoutId !== undefined) {
+ clearTimeout(runtimeTimeoutId);
+ runtimeTimeoutId = undefined;
+ }
+ if (progressTimeoutId) {
+ clearTimeout(progressTimeoutId);
+ progressTimeoutId = null;
+ }
+ },
+ };
+}
+
+interface DriveOutcome {
+ exitCode: number;
+ error?: string;
+ aborted: boolean;
+ abortReasonText?: string;
+}
+
+const MAX_YIELD_RETRIES = 3;
+
+/**
+ * Drive one assignment through a live session: send the prompt, wait for idle,
+ * remind the agent to `yield` (up to {@link MAX_YIELD_RETRIES} times), then
+ * classify the terminal assistant state. Shared by spawn and resume paths.
+ */
+async function driveSessionToYield(
+ session: AgentSession,
+ monitor: SubagentRunMonitor,
+ task: string,
+): Promise {
+ const abortSignal = monitor.abortSignal;
+ let exitCode = 0;
+ let error: string | undefined;
+ let aborted = false;
+ let abortReasonText: string | undefined;
+ const checkAbort = () => {
+ if (abortSignal.aborted) {
+ aborted = monitor.isAbortedRun();
+ if (aborted) {
+ abortReasonText ??= monitor.resolveAbortReasonText();
+ }
+ exitCode = 1;
+ throw new ToolAbortError();
+ }
+ };
+ const awaitAbortable = async (promise: Promise): Promise => {
+ checkAbort();
+ const { promise: abortPromise, reject } = Promise.withResolvers();
+ const onAbort = () => {
+ try {
+ checkAbort();
+ } catch (err) {
+ reject(err);
+ }
+ };
+ abortSignal.addEventListener("abort", onAbort, { once: true });
+ try {
+ return await Promise.race([promise, abortPromise]);
+ } finally {
+ abortSignal.removeEventListener("abort", onAbort);
+ }
+ };
+
+ try {
+ await awaitAbortable(session.prompt(task, { attribution: "agent" }));
+ await awaitAbortable(session.waitForIdle());
+
+ const reminderToolChoice = buildNamedToolChoice("yield", session.model);
+
+ let retryCount = 0;
+ while (!monitor.yieldCalled() && retryCount < MAX_YIELD_RETRIES && !abortSignal.aborted) {
+ // Skip reminders when the model returned a terminal error (e.g.
+ // rate-limit cap hit, auth failure). Re-prompting would just
+ // hit the same wall, multiplying the failure noise without
+ // any chance of producing a yield.
+ const lastBeforeReminder = session.getLastAssistantMessage();
+ if (lastBeforeReminder?.stopReason === "error") break;
+ try {
+ retryCount++;
+ const reminder = prompt.render(submitReminderTemplate, {
+ retryCount,
+ maxRetries: MAX_YIELD_RETRIES,
+ });
+
+ const isFinalRetry = retryCount >= MAX_YIELD_RETRIES;
+ await awaitAbortable(
+ session.prompt(reminder, {
+ attribution: "agent",
+ synthetic: true,
+ ...(isFinalRetry && reminderToolChoice ? { toolChoice: reminderToolChoice } : {}),
+ }),
+ );
+ await awaitAbortable(session.waitForIdle());
+ } catch (err) {
+ if (abortSignal.aborted || err instanceof ToolAbortError) {
+ // Benign control-flow exit — user cancel (^C) or compaction aborting
+ // pending operations both surface here as ToolAbortError. The outer
+ // catch and finally already mark the run aborted; logging at ERROR
+ // would spam operator dashboards with non-failures.
+ logger.debug("Subagent prompt aborted");
+ } else {
+ logger.error("Subagent prompt failed", {
+ error: err instanceof Error ? err.message : String(err),
+ });
+ }
+ }
+ }
+
+ await awaitAbortable(session.waitForIdle());
+
+ const lastAssistant = session.getLastAssistantMessage();
+ if (lastAssistant) {
+ if (lastAssistant.stopReason === "aborted") {
+ aborted = monitor.isAbortedRun();
+ if (aborted) {
+ // A real caller signal or the wall-clock timer carries a precise
+ // reason (signal.reason / "runtime limit exceeded"). An internal
+ // turn abort does NOT — prefer the assistant message's own
+ // errorMessage ("Request was aborted" or a specific stream error)
+ // over the misleading "Cancelled by caller".
+ abortReasonText ??= monitor.hasExplicitAbortReason()
+ ? monitor.resolveAbortReasonText()
+ : lastAssistant.errorMessage?.trim() || monitor.resolveAbortReasonText();
+ }
+ exitCode = 1;
+ } else if (lastAssistant.stopReason === "error") {
+ exitCode = 1;
+ error ??= lastAssistant.errorMessage || "Subagent failed";
+ }
+ }
+ } catch (err) {
+ exitCode = 1;
+ if (!abortSignal.aborted) {
+ error = err instanceof Error ? err.stack || err.message : String(err);
+ }
+ } finally {
+ if (abortSignal.aborted) {
+ aborted = monitor.isAbortedRun();
+ if (aborted) {
+ abortReasonText ??= monitor.resolveAbortReasonText();
+ }
+ if (exitCode === 0) exitCode = 1;
+ }
+ }
+
+ return { exitCode, error, aborted, abortReasonText };
+}
+
+interface FinalizeRunArgs {
+ monitor: SubagentRunMonitor;
+ done: { exitCode: number; error?: string; aborted?: boolean; abortReason?: string; durationMs: number };
+ index: number;
+ id: string;
+ agent: AgentDefinition;
+ task: string;
+ assignment?: string;
+ description?: string;
+ modelOverride?: string | string[];
+ outputSchema?: unknown;
+ signal?: AbortSignal;
+ artifactsDir?: string;
+ eventBus?: EventBus;
+ parentToolCallId?: string;
+ sessionFile?: string;
+ startTime: number;
+}
+
+/**
+ * Turn a settled run into a {@link SingleResult}: resolve the yield payload via
+ * {@link finalizeSubprocessOutput}, salvage cancelled-run output, write the
+ * `.md` output artifact, flush final progress, and emit the lifecycle end
+ * event. Shared by spawn and resume paths.
+ */
+async function finalizeRunResult(args: FinalizeRunArgs): Promise {
+ const { monitor, done, index, id, agent, task, assignment, signal, modelOverride } = args;
+ const progress = monitor.progress;
+ let exitCode = done.exitCode;
+ let stderr = done.error ?? "";
+
+ // Use final output if available, otherwise accumulated output
+ let rawOutput = monitor.rawOutput();
+ const yieldItems = progress.extractedToolData?.yield as YieldItem[] | undefined;
+ const reportFindingDetails = progress.extractedToolData?.report_finding as ReportFindingDetails[] | undefined;
+ const reportFindings: ReviewFinding[] | undefined = reportFindingDetails?.map(toReviewFinding);
+ const finalized = finalizeSubprocessOutput({
+ rawOutput,
+ exitCode,
+ stderr,
+ doneAborted: Boolean(done.aborted),
+ signalAborted: Boolean(signal?.aborted),
+ yieldItems,
+ reportFindings,
+ outputSchema: args.outputSchema,
+ });
+ rawOutput = finalized.rawOutput;
+ exitCode = finalized.exitCode;
+ stderr = finalized.stderr;
+ // Salvage for cancelled/aborted children that produced no completed output:
+ // surface the last assistant text + stats instead of "(no output)" so the
+ // parent doesn't redo work the child already finished.
+ const salvageText = monitor.lastAssistantSalvageText();
+ if (
+ (done.aborted || signal?.aborted || monitor.runtimeLimitExceeded()) &&
+ !rawOutput.trim() &&
+ salvageText !== undefined
+ ) {
+ rawOutput = `[cancelled after ${progress.requests} req, ${progress.tokens} tok — last activity: "${formatSalvageSnippet(salvageText)}"]`;
+ }
+ const lastYield = yieldItems?.[yieldItems.length - 1];
+ const yieldAbortReason = lastYield?.status === "aborted" ? lastYield.error || "Subagent aborted task" : undefined;
+ const { abortedViaYield, hasYield } = finalized;
+ const { content: truncatedOutput, truncated } = truncateTail(rawOutput, {
+ maxBytes: MAX_OUTPUT_BYTES,
+ maxLines: MAX_OUTPUT_LINES,
+ });
+
+ // Write output artifact (input and jsonl already written in real-time)
+ // Compute output metadata for agent:// URL integration
+ let outputMeta: { lineCount: number; charCount: number } | undefined;
+ let outputPath: string | undefined;
+ if (args.artifactsDir) {
+ outputPath = path.join(args.artifactsDir, `${id}.md`);
+ try {
+ await Bun.write(outputPath, rawOutput);
+ outputMeta = {
+ lineCount: rawOutput.split("\n").length,
+ charCount: rawOutput.length,
+ };
+ } catch {
+ // Non-fatal
+ }
+ }
+
+ // Update final progress. A wall-clock timeout always wins: if the runtime
+ // limit fired we report aborted/failed regardless of whether a yield landed
+ // while we were tearing the session down. The yield data is still surfaced
+ // to the caller via `progress.extractedToolData`, but the exit status must
+ // reflect the timeout so on-call doesn't mistake a stuck run for success.
+ const runtimeLimitExceeded = monitor.runtimeLimitExceeded();
+ if (runtimeLimitExceeded && exitCode === 0) {
+ exitCode = 1;
+ }
+ const wasAborted =
+ runtimeLimitExceeded || abortedViaYield || (!hasYield && (done.aborted || signal?.aborted || false));
+ const finalAbortReason = wasAborted
+ ? runtimeLimitExceeded
+ ? monitor.resolveAbortReasonText()
+ : abortedViaYield
+ ? yieldAbortReason
+ : (done.abortReason ??
+ (signal?.aborted ? monitor.resolveSignalAbortReason() : monitor.resolveAbortReasonText()))
+ : undefined;
+ progress.status = wasAborted ? "aborted" : exitCode === 0 ? "completed" : "failed";
+ monitor.scheduleProgress(true);
+
+ // Emit lifecycle end event after finalization so yield status is reflected
+ if (args.eventBus) {
+ args.eventBus.emit(TASK_SUBAGENT_LIFECYCLE_CHANNEL, {
+ id,
+ agent: agent.name,
+ parentToolCallId: args.parentToolCallId,
+ agentSource: agent.source,
+ description: args.description,
+ status: progress.status as "completed" | "failed" | "aborted",
+ sessionFile: args.sessionFile,
+ index,
+ });
+ }
+
+ return {
+ index,
+ id,
+ agent: agent.name,
+ agentSource: agent.source,
+ task,
+ assignment,
+ description: args.description,
+ lastIntent: progress.lastIntent,
+ exitCode,
+ output: truncatedOutput,
+ stderr,
+ truncated: Boolean(truncated),
+ durationMs: Date.now() - args.startTime,
+ tokens: progress.tokens,
+ requests: progress.requests,
+ contextTokens: progress.contextTokens,
+ contextWindow: progress.contextWindow,
+ modelOverride,
+ resolvedModel: progress.resolvedModel,
+ error: exitCode !== 0 && stderr ? stderr : undefined,
+ aborted: wasAborted,
+ abortReason: finalAbortReason,
+ usage: monitor.hasUsage() ? monitor.accumulatedUsage : undefined,
+ outputPath,
+ extractedToolData: progress.extractedToolData,
+ retryFailure: progress.retryFailure,
+ outputMeta,
+ };
+}
+
+/**
+ * Run a single agent in-process.
+ */
+export async function runSubprocess(options: ExecutorOptions): Promise {
+ const {
+ cwd,
+ agent,
+ task,
+ assignment,
+ index,
+ id,
+ worktree,
+ modelOverride,
+ thinkingLevel,
+ outputSchema,
+ enableLsp,
+ signal,
+ onProgress,
+ } = options;
+ const startTime = Date.now();
+
+ // Check if already aborted
+ if (signal?.aborted) {
+ return {
+ index,
+ id,
+ agent: agent.name,
+ agentSource: agent.source,
+ task,
+ assignment,
+ description: options.description,
+ exitCode: 1,
+ output: "",
+ stderr: "Cancelled before start",
+ truncated: false,
+ durationMs: 0,
+ tokens: 0,
+ requests: 0,
+ modelOverride,
+ error: "Cancelled before start",
+ aborted: true,
+ abortReason: "Cancelled before start",
+ };
+ }
+
+ // Set up artifact paths and write input file upfront if artifacts dir provided
+ let subtaskSessionFile: string | undefined;
+ if (options.artifactsDir) {
+ subtaskSessionFile = path.join(options.artifactsDir, `${id}.jsonl`);
+ }
+
+ const settings = options.settings ?? Settings.isolated();
+ const subagentSettings = createSubagentSettings(
+ settings,
+ agent.readSummarize === false ? { "read.summarize.enabled": false } : undefined,
+ );
+ const maxRecursionDepth = settings.get("task.maxRecursionDepth") ?? 2;
+ const maxRuntimeMs = Math.max(
+ 0,
+ Math.trunc(Number(options.maxRuntimeMs ?? settings.get("task.maxRuntimeMs") ?? 0) || 0),
+ );
+ // TTL before an adopted idle subagent is parked by the lifecycle manager.
+ // <= 0 disables parking (the session stays live until process teardown).
+ const agentIdleTtlMs = Math.trunc(Number(settings.get("task.agentIdleTtlMs") ?? 420_000) || 0);
+ const configuredDefaultBudget = Math.max(
+ 0,
+ Math.trunc(Number(settings.get("task.softRequestBudget") ?? SOFT_REQUEST_BUDGET.default) || 0),
+ );
+ const softRequestBudget =
+ configuredDefaultBudget === 0 ? 0 : (SOFT_REQUEST_BUDGET[agent.name] ?? configuredDefaultBudget);
+ const parentDepth = options.taskDepth ?? 0;
+ const childDepth = parentDepth + 1;
+ const atMaxDepth = maxRecursionDepth >= 0 && childDepth >= maxRecursionDepth;
+
+ // Add tools if specified
+ let toolNames: string[] | undefined;
+ if (agent.tools && agent.tools.length > 0) {
+ toolNames = agent.tools;
+ // Auto-include task tool if spawns defined but task not in tools
+ if (agent.spawns !== undefined && !toolNames.includes("task") && !atMaxDepth) {
+ toolNames = [...toolNames, "task"];
+ }
+ }
+
+ if (atMaxDepth && toolNames?.includes("task")) {
+ toolNames = toolNames.filter(name => name !== "task");
+ }
+ // IRC is always available; the COOP prompt section advertises it, so a restricted
+ // whitelist must still carry `irc` for the subagent to actually use it.
+ if (toolNames && !toolNames.includes("irc")) {
+ toolNames = [...toolNames, "irc"];
+ }
+ if (toolNames?.includes("exec")) {
+ const allowEvalPy = settings.get("eval.py") ?? true;
+ const allowEvalJs = settings.get("eval.js") ?? true;
+ const expanded = toolNames.filter(name => name !== "exec");
+ if (allowEvalPy || allowEvalJs) expanded.push("eval");
+ expanded.push("bash");
+ toolNames = Array.from(new Set(expanded));
+ }
+
+ const modelPatterns = normalizeModelPatterns(modelOverride ?? agent.model);
+ const sessionFile = subtaskSessionFile ?? null;
+ const spawnsEnv = atMaxDepth
+ ? ""
+ : agent.spawns === undefined
+ ? ""
+ : agent.spawns === "*"
+ ? "*"
+ : agent.spawns.join(",");
+
+ const lspEnabled = enableLsp ?? true;
+ const ircEnabled = subagentSettings.get("irc.enabled") === true;
+ const skipPythonPreflight = Array.isArray(toolNames) && !toolNames.includes("eval");
+
+ const monitor = createSubagentRunMonitor({
+ index,
+ id,
+ agent,
+ task,
+ assignment,
+ description: options.description,
+ modelOverride,
+ signal,
+ onProgress,
+ eventBus: options.eventBus,
+ parentToolCallId: options.parentToolCallId,
+ sessionFile: subtaskSessionFile,
+ softRequestBudget,
+ maxRuntimeMs,
+ });
+ const progress = monitor.progress;
+ let unsubscribe: (() => void) | null = null;
+ let reviveSession: (() => Promise) | null = null;
+ // Adopted (kept-alive) subagents flip registry status from session events on
+ // later turns: revive/wake → running, turn drained → idle. The subscription
+ // intentionally survives this run; a disposed session emits nothing, so it
+ // needs no teardown.
+ const installRegistryStatusSync = (target: AgentSession): void => {
+ target.subscribe(event => {
+ if (event.type === "agent_start") {
+ AgentRegistry.global().setStatus(id, "running");
+ } else if (event.type === "agent_end") {
+ AgentRegistry.global().setStatus(id, "idle");
+ }
+ });
+ };
+
const runSubagent = async (): Promise<{
exitCode: number;
error?: string;
@@ -1155,17 +1703,13 @@ export async function runSubprocess(options: ExecutorOptions): Promise => {
const sessionAbortController = new AbortController();
+ const abortSignal = monitor.abortSignal;
let exitCode = 0;
let error: string | undefined;
let aborted = false;
let abortReasonText: string | undefined;
const checkAbort = () => {
if (abortSignal.aborted) {
- aborted = abortReason === "signal" || runtimeLimitExceeded || abortReason === undefined;
- if (aborted) {
- abortReasonText ??= resolveAbortReasonText();
- }
- exitCode = 1;
throw new ToolAbortError();
}
};
@@ -1283,7 +1827,11 @@ export async function runSubprocess(options: ExecutorOptions): Promise ({
cwd: worktree ?? cwd,
authStorage,
modelRegistry,
@@ -1303,12 +1851,10 @@ export async function runSubprocess(options: ExecutorOptions): Promise {
const subagentPrompt = prompt.render(subagentSystemPromptTemplate, {
agent: agent.systemPrompt,
- context: options.context?.trim() ?? "",
planReference: options.planReference?.content ?? "",
planReferencePath: options.planReference?.path ?? "",
worktree: worktree ?? "",
outputSchema: normalizedOutputSchema,
- contextFile: contextFileForPrompt,
ircPeers: ircEnabled ? renderIrcPeerRoster(id) : "",
ircSelfId: ircEnabled ? id : "",
});
@@ -1316,7 +1862,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise {
+ const reopened = await SessionManager.open(sessionFile);
+ if (options.parentArtifactManager) {
+ reopened.adoptArtifactManager(options.parentArtifactManager);
+ }
+ const { session: revived } = await createAgentSession(buildSubagentSessionOptions(reopened));
+ installRegistryStatusSync(revived);
+ return revived;
+ };
+ }
// Emit lifecycle start event
if (options.eventBus) {
@@ -1449,44 +2013,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise {
- emitSubagentEvent(event);
- if (event.type === "auto_retry_start") {
- progress.retryState = {
- attempt: event.attempt,
- maxAttempts: event.maxAttempts,
- delayMs: event.delayMs,
- errorMessage: event.errorMessage,
- startedAtMs: Date.now(),
- };
- progress.retryFailure = undefined;
- scheduleProgress(true);
- return;
- }
- if (event.type === "auto_retry_end") {
- const attempt = progress.retryState?.attempt ?? event.attempt;
- progress.retryState = undefined;
- if (!event.success) {
- progress.retryFailure = {
- attempt,
- errorMessage: event.finalError ?? "Auto-retry failed",
- };
- }
- scheduleProgress(true);
- return;
- }
- if (isAgentEvent(event)) {
- try {
- processEvent(event);
- } catch (err) {
- logger.error("Subagent event processing failed", {
- error: err instanceof Error ? err.message : String(err),
- });
- requestAbort("terminate");
- }
- }
- });
+ unsubscribe = monitor.attach(session);
checkAbort();
// Autoload skills via sendCustomMessage (same mechanic as /skill:)
@@ -1504,78 +2031,12 @@ export async function runSubprocess(options: ExecutorOptions): Promise= MAX_YIELD_RETRIES;
- await awaitAbortable(
- session.prompt(reminder, {
- attribution: "agent",
- synthetic: true,
- ...(isFinalRetry && reminderToolChoice ? { toolChoice: reminderToolChoice } : {}),
- }),
- );
- await awaitAbortable(session.waitForIdle());
- } catch (err) {
- if (abortSignal.aborted || err instanceof ToolAbortError) {
- // Benign control-flow exit — user cancel (^C) or compaction aborting
- // pending operations both surface here as ToolAbortError. The outer
- // catch and finally already mark the run aborted; logging at ERROR
- // would spam operator dashboards with non-failures.
- logger.debug("Subagent prompt aborted", {
- reason: abortReason ?? "signal",
- });
- } else {
- logger.error("Subagent prompt failed", {
- error: err instanceof Error ? err.message : String(err),
- });
- }
- }
- }
-
- await awaitAbortable(session.waitForIdle());
- if (!yieldCalled && !abortSignal.aborted) {
- exitCode = 0;
- }
-
- const lastAssistant = session.getLastAssistantMessage();
- if (lastAssistant) {
- if (lastAssistant.stopReason === "aborted") {
- aborted = abortReason === "signal" || runtimeLimitExceeded || abortReason === undefined;
- if (aborted) {
- // A real caller signal or the wall-clock timer carries a precise
- // reason (signal.reason / "runtime limit exceeded"). An internal
- // turn abort (abortReason === undefined) does NOT — prefer the
- // assistant message's own errorMessage ("Request was aborted" or a
- // specific stream error) over the misleading "Cancelled by caller".
- abortReasonText ??=
- abortReason === "signal" || runtimeLimitExceeded
- ? resolveAbortReasonText()
- : lastAssistant.errorMessage?.trim() || resolveAbortReasonText();
- }
- exitCode = 1;
- } else if (lastAssistant.stopReason === "error") {
- exitCode = 1;
- error ??= lastAssistant.errorMessage || "Subagent failed";
- }
- }
+ const outcome = await driveSessionToYield(session, monitor, task);
+ exitCode = outcome.exitCode;
+ error = outcome.error;
+ aborted = outcome.aborted;
+ abortReasonText = outcome.abortReasonText;
} catch (err) {
exitCode = 1;
if (!abortSignal.aborted) {
@@ -1583,9 +2044,9 @@ export async function runSubprocess(options: ExecutorOptions): Promise session.dispose());
- } catch {
- // Ignore cleanup errors
+ const session = monitor.takeActiveSession();
+ if (session) {
+ monitor.captureSalvage(session);
+ const registry = AgentRegistry.global();
+ if (aborted) {
+ // Hard abort (caller signal / wall-clock / budget): terminal teardown.
+ registry.setStatus(id, "aborted");
+ try {
+ await untilAborted(AbortSignal.timeout(5000), () => session.dispose());
+ } catch {
+ // Ignore cleanup errors
+ }
+ } else if (worktree !== undefined) {
+ // Isolated run: the worktree is merged + cleaned after the run, so
+ // the session is not resumable. Park the ref WITHOUT adopting — the
+ // transcript stays reachable (history://), but ensureLive will throw.
+ // Status must flip to "parked" before dispose so the sdk dispose
+ // wrapper skips unregister.
+ registry.setStatus(id, "parked");
+ try {
+ await untilAborted(AbortSignal.timeout(5000), () => session.dispose());
+ } catch {
+ // Ignore cleanup errors
+ }
+ registry.detachSession(id);
+ } else {
+ // Keep-alive: finished and failed subagents both stay interrogable.
+ // The lifecycle manager owns idle-TTL parking + revival from here on.
+ registry.setStatus(id, "idle");
+ AgentLifecycleManager.global().adopt(id, {
+ idleTtlMs: agentIdleTtlMs,
+ revive: reviveSession ?? undefined,
+ });
}
}
}
@@ -1619,87 +2106,115 @@ export async function runSubprocess(options: ExecutorOptions): Promise 0 ? finalOutputChunks.join("") : outputChunks.join("");
- const yieldItems = progress.extractedToolData?.yield as YieldItem[] | undefined;
- const reportFindingDetails = progress.extractedToolData?.report_finding as ReportFindingDetails[] | undefined;
- const reportFindings: ReviewFinding[] | undefined = reportFindingDetails?.map(toReviewFinding);
- const finalized = finalizeSubprocessOutput({
- rawOutput,
- exitCode,
- stderr,
- doneAborted: Boolean(done.aborted),
- signalAborted: Boolean(signal?.aborted),
- yieldItems,
- reportFindings,
+ return finalizeRunResult({
+ monitor,
+ done,
+ index,
+ id,
+ agent,
+ task,
+ assignment,
+ description: options.description,
+ modelOverride,
outputSchema,
+ signal,
+ artifactsDir: options.artifactsDir,
+ eventBus: options.eventBus,
+ parentToolCallId: options.parentToolCallId,
+ sessionFile: subtaskSessionFile,
+ startTime,
});
- rawOutput = finalized.rawOutput;
- exitCode = finalized.exitCode;
- stderr = finalized.stderr;
- const lastYield = yieldItems?.[yieldItems.length - 1];
- const yieldAbortReason = lastYield?.status === "aborted" ? lastYield.error || "Subagent aborted task" : undefined;
- const { abortedViaYield, hasYield } = finalized;
- const { content: truncatedOutput, truncated } = truncateTail(rawOutput, {
- maxBytes: MAX_OUTPUT_BYTES,
- maxLines: MAX_OUTPUT_LINES,
- });
+}
- // Write output artifact (input and jsonl already written in real-time)
- // Compute output metadata for agent:// URL integration
- let outputMeta: { lineCount: number; charCount: number } | undefined;
- let outputPath: string | undefined;
- if (options.artifactsDir) {
- outputPath = path.join(options.artifactsDir, `${id}.md`);
- try {
- await Bun.write(outputPath, rawOutput);
- outputMeta = {
- lineCount: rawOutput.split("\n").length,
- charCount: rawOutput.length,
- };
- } catch {
- // Non-fatal
- }
+/** Options for resuming an existing live subagent session with a follow-up assignment. */
+export interface ResumeExecutorOptions {
+ /** Live session, e.g. from `AgentLifecycleManager.global().ensureLive(id)`. */
+ session: AgentSession;
+ /** Registry agent id being resumed. */
+ id: string;
+ /** Agent definition for progress labels and soft budgets; a minimal stub is acceptable. */
+ agent: AgentDefinition;
+ /** Rendered follow-up prompt, injected via the session's normal prompt path. */
+ task: string;
+ assignment?: string;
+ description?: string;
+ index: number;
+ parentToolCallId?: string;
+ /** Optional schema validating this follow-up's yield payload. */
+ outputSchema?: unknown;
+ signal?: AbortSignal;
+ onProgress?: (progress: AgentProgress) => void;
+ eventBus?: EventBus;
+ settings?: Settings;
+ /** Where the `.md` output artifact is (over)written for this assignment. */
+ artifactsDir?: string;
+}
+
+/**
+ * Run a follow-up assignment on an EXISTING live agent session through the same
+ * monitoring/finalize pipeline as a fresh spawn. The session is never created
+ * or disposed here: it stays alive (and adopted by the lifecycle manager from
+ * its original spawn) afterwards — registry status flips via the session's
+ * registry status sync, and the idle TTL re-arms via the lifecycle manager's
+ * registry subscription. Each resume overwrites the `agent://` output
+ * artifact; the transcript accretes in the session JSONL.
+ */
+export async function resumeSubprocess(options: ResumeExecutorOptions): Promise {
+ const { session, id, agent, task, assignment, index, signal } = options;
+ const startTime = Date.now();
+
+ if (signal?.aborted) {
+ return {
+ index,
+ id,
+ agent: agent.name,
+ agentSource: agent.source,
+ task,
+ assignment,
+ description: options.description,
+ exitCode: 1,
+ output: "",
+ stderr: "Cancelled before start",
+ truncated: false,
+ durationMs: 0,
+ tokens: 0,
+ requests: 0,
+ error: "Cancelled before start",
+ aborted: true,
+ abortReason: "Cancelled before start",
+ };
}
- // Update final progress. A wall-clock timeout always wins: if the runtime
- // limit fired we report aborted/failed regardless of whether a yield landed
- // while we were tearing the session down. The yield data is still surfaced
- // to the caller via `progress.extractedToolData`, but the exit status must
- // reflect the timeout so on-call doesn't mistake a stuck run for success.
- if (runtimeLimitExceeded && exitCode === 0) {
- exitCode = 1;
- }
- const wasAborted =
- runtimeLimitExceeded || abortedViaYield || (!hasYield && (done.aborted || signal?.aborted || false));
- const finalAbortReason = wasAborted
- ? runtimeLimitExceeded
- ? resolveAbortReasonText()
- : abortedViaYield
- ? yieldAbortReason
- : (done.abortReason ?? (signal?.aborted ? resolveSignalAbortReason() : resolveAbortReasonText()))
- : undefined;
- progress.status = wasAborted ? "aborted" : exitCode === 0 ? "completed" : "failed";
- scheduleProgress(true);
+ const settings = options.settings ?? Settings.isolated();
+ const maxRuntimeMs = Math.max(0, Math.trunc(Number(settings.get("task.maxRuntimeMs") ?? 0) || 0));
+ const configuredDefaultBudget = Math.max(
+ 0,
+ Math.trunc(Number(settings.get("task.softRequestBudget") ?? SOFT_REQUEST_BUDGET.default) || 0),
+ );
+ const softRequestBudget =
+ configuredDefaultBudget === 0 ? 0 : (SOFT_REQUEST_BUDGET[agent.name] ?? configuredDefaultBudget);
+ const sessionFile = AgentRegistry.global().get(id)?.sessionFile ?? undefined;
+
+ const monitor = createSubagentRunMonitor({
+ index,
+ id,
+ agent,
+ task,
+ assignment,
+ description: options.description,
+ signal,
+ onProgress: options.onProgress,
+ eventBus: options.eventBus,
+ parentToolCallId: options.parentToolCallId,
+ sessionFile,
+ softRequestBudget,
+ maxRuntimeMs,
+ });
+ monitor.setActiveSession(session);
+ const unsubscribe = monitor.attach(session);
- // Emit lifecycle end event after finalization so yield status is reflected
if (options.eventBus) {
options.eventBus.emit(TASK_SUBAGENT_LIFECYCLE_CHANNEL, {
id,
@@ -1707,38 +2222,56 @@ export async function runSubprocess(options: ExecutorOptions): Promise): void {
- const input = usage.input ?? 0;
- const output = usage.output ?? 0;
- const cacheRead = usage.cacheRead ?? 0;
- const cacheWrite = usage.cacheWrite ?? 0;
- const totalTokens = usage.totalTokens ?? input + output + cacheRead + cacheWrite;
- const cost =
- usage.cost ??
- ({
- input: 0,
- output: 0,
- cacheRead: 0,
- cacheWrite: 0,
- total: 0,
- } satisfies Usage["cost"]);
-
- target.input += input;
- target.output += output;
- target.cacheRead += cacheRead;
- target.cacheWrite += cacheWrite;
- target.totalTokens += totalTokens;
- target.cost.input += cost.input;
- target.cost.output += cost.output;
- target.cost.cacheRead += cost.cacheRead;
- target.cost.cacheWrite += cost.cacheWrite;
- target.cost.total += cost.total;
-}
// Re-export types and utilities
export { loadBundledAgents as BUNDLED_AGENTS } from "./agents";
@@ -165,6 +132,17 @@ export function isReadOnlyAgent(agent: AgentDefinition): boolean {
return !!agent.tools?.length && agent.tools.every(tool => READ_ONLY_TOOL_NAMES.has(tool));
}
+/**
+ * Preview text for a child result. Falls back to "(no output)" — annotated
+ * with the request count when the child actually did work, so the parent can
+ * tell a no-op child from one that burned requests before being cancelled.
+ */
+export function formatResultOutputFallback(result: Pick): string {
+ const base = result.output.trim() || result.stderr.trim();
+ if (base) return base;
+ return result.requests > 0 ? `(no output) after ${result.requests} req` : "(no output)";
+}
+
/**
* Render the tool description from a cached agent list and current settings.
*/
@@ -172,7 +150,6 @@ function renderDescription(
agents: AgentDefinition[],
maxConcurrency: number,
isolationEnabled: boolean,
- asyncEnabled: boolean,
disabledAgents: string[],
simpleMode: TaskSimpleMode,
ircEnabled: boolean,
@@ -196,14 +173,12 @@ function renderDescription(
description: agent.description,
readOnly: isReadOnlyAgent(agent),
}));
- const { contextEnabled, customSchemaEnabled } = getTaskSimpleModeCapabilities(simpleMode);
+ const { customSchemaEnabled } = getTaskSimpleModeCapabilities(simpleMode);
return prompt.render(taskDescriptionTemplate, {
agents: renderedAgents,
spawningDisabled,
MAX_CONCURRENCY: maxConcurrency,
isolationEnabled,
- asyncEnabled,
- contextEnabled,
customSchemaEnabled,
ircEnabled,
defaultMode: simpleMode === "default",
@@ -220,87 +195,46 @@ function createTaskModeError(text: string): AgentToolResult