@aeondave/pi-persona 1.15.2 → 1.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/AGENTS.md +30 -6
  2. package/CHANGELOG.md +82 -0
  3. package/README.md +27 -8
  4. package/SECURITY.md +42 -0
  5. package/docs/ARCHITECTURE.md +52 -7
  6. package/docs/EXPERIENCE.md +21 -5
  7. package/docs/README.md +2 -0
  8. package/docs/REFERENCE.md +84 -14
  9. package/docs/STRATEGIES.md +58 -31
  10. package/docs/TELEMETRY.md +8 -0
  11. package/package.json +11 -9
  12. package/src/bus/broker/paths.ts +3 -2
  13. package/src/core/brief.ts +1 -1
  14. package/src/core/nudge.ts +3 -4
  15. package/src/core/pi-compat.ts +3 -3
  16. package/src/engine/adapter.ts +11 -6
  17. package/src/engine/async.ts +12 -6
  18. package/src/engine/inproc.ts +14 -6
  19. package/src/extension/engine.ts +25 -4
  20. package/src/extension/hooks.ts +68 -21
  21. package/src/extension.ts +209 -49
  22. package/src/orchestration/model-retry.ts +32 -61
  23. package/src/orchestration/params.ts +27 -1
  24. package/src/orchestration/roster.ts +80 -3
  25. package/src/orchestration/sdk.ts +150 -36
  26. package/src/orchestration/strategies/compete.ts +14 -8
  27. package/src/orchestration/strategies/council-rounds.ts +8 -1
  28. package/src/orchestration/strategies/critic-loop.ts +25 -11
  29. package/src/orchestration/strategies/judge.ts +18 -9
  30. package/src/orchestration/strategies/magi.ts +42 -93
  31. package/src/orchestration/strategies/map.ts +41 -19
  32. package/src/orchestration/strategies/pipeline.ts +2 -2
  33. package/src/orchestration/strategies/synthesize.ts +18 -11
  34. package/src/orchestration/types.ts +15 -0
  35. package/src/persona/model-participants.ts +240 -0
  36. package/src/persona/orchestrate.ts +44 -2
  37. package/src/persona/persona.ts +86 -14
  38. package/src/telemetry/contract.ts +1 -1
  39. package/src/telemetry/producer.ts +241 -8
  40. package/src/tools/council.ts +36 -8
  41. package/src/tools/delegate-tool.ts +162 -24
  42. package/src/tools/intercom-tool.ts +119 -32
  43. package/src/ui/agent-overlay.ts +15 -6
  44. package/src/ui/agent-tree.ts +39 -2
@@ -3,10 +3,10 @@ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-a
3
3
  import type { AgentConfig } from "../agents/agent.ts";
4
4
  import { Type } from "typebox";
5
5
  import { fenceUntrusted } from "../core/fence.ts";
6
- import { Text } from "@earendil-works/pi-tui";
6
+ import { Text, visibleWidth } from "@earendil-works/pi-tui";
7
7
  import { Container, Spacer } from "@earendil-works/pi-tui";
8
8
  import { configuredModels } from "../extension/engine.ts";
9
- import { compactInlineText, sanitizeTerminalText } from "../ui/presentation.ts";
9
+ import { compactInlineText, MAX_COLLAPSED_CARD_COLUMNS, sanitizeTerminalText } from "../ui/presentation.ts";
10
10
  import { compactVisibleText } from "../ui/presentation.ts";
11
11
  import { resolveModelRef } from "../core/models.ts";
12
12
  import { inventedLegNameHint } from "../core/naming.ts";
@@ -24,12 +24,125 @@ import type { AgentResult } from "../orchestration/types.ts";
24
24
  import { agentNodeStatusForDelegate, sanitizeLabel } from "../extension/shared.ts";
25
25
  import type { PersonaController } from "../persona/controller.ts";
26
26
  import type { AgentTree } from "../ui/agent-tree.ts";
27
- import type { AsyncRunTracker } from "../engine/async.ts";
27
+ import { runDisplayName, type AsyncRunTracker } from "../engine/async.ts";
28
28
  import { emptyUsage, type ProgressSnapshot, type ToolEvent } from "../engine/stream.ts";
29
29
  import { progressPatch } from "../ui/agent-tree.ts";
30
30
  import type { AddNodeInput } from "../ui/agent-tree.ts";
31
31
  import type { RunLimits } from "../core/capabilities.ts";
32
32
 
33
+ interface DelegateLaunchSnapshot {
34
+ id: string;
35
+ label: string;
36
+ agent: string;
37
+ model?: string;
38
+ }
39
+
40
+ interface DelegateLaunchRow {
41
+ id: string;
42
+ fullName: string;
43
+ displayName: string;
44
+ clipped: boolean;
45
+ hasAlias: boolean;
46
+ }
47
+
48
+ const COLLAPSED_LAUNCH_ROWS = 3;
49
+
50
+ /** Prefer session-persisted identity, then recover old launch cards from the live tracker. */
51
+ function launchRows(
52
+ details: { runId?: unknown; runIds?: unknown; runs?: unknown },
53
+ tracker: AsyncRunTracker,
54
+ allowTracker: boolean,
55
+ ): DelegateLaunchRow[] {
56
+ const persisted = new Map<string, DelegateLaunchSnapshot>();
57
+ if (Array.isArray(details.runs)) {
58
+ for (const item of details.runs) {
59
+ if (!item || typeof item !== "object") continue;
60
+ const run = item as Record<string, unknown>;
61
+ if (typeof run.id !== "string" || typeof run.label !== "string" || typeof run.agent !== "string") continue;
62
+ persisted.set(run.id, {
63
+ id: run.id,
64
+ label: run.label,
65
+ agent: run.agent,
66
+ ...(typeof run.model === "string" ? { model: run.model } : {}),
67
+ });
68
+ }
69
+ }
70
+ const storedIds = Array.isArray(details.runIds)
71
+ ? details.runIds.filter((id): id is string => typeof id === "string")
72
+ : typeof details.runId === "string" ? [details.runId] : [];
73
+ const ids = [...storedIds];
74
+ const seenIds = new Set(ids);
75
+ for (const id of persisted.keys()) {
76
+ if (seenIds.has(id)) continue;
77
+ seenIds.add(id);
78
+ ids.push(id);
79
+ }
80
+ const entries = ids.map((id): { run: DelegateLaunchSnapshot; hasAlias: boolean } => {
81
+ const saved = persisted.get(id);
82
+ if (saved) return { run: saved, hasAlias: true };
83
+ const tracked = allowTracker ? tracker.peek(id) : undefined;
84
+ if (tracked) {
85
+ return {
86
+ run: {
87
+ id,
88
+ label: tracked.label ?? tracked.agent,
89
+ agent: tracked.agent,
90
+ ...(tracked.model ? { model: tracked.model } : {}),
91
+ },
92
+ hasAlias: true,
93
+ };
94
+ }
95
+ // Older history may outlive both its launch snapshot and tracker entry. Retain the
96
+ // routing id as an explicitly diagnostic fallback so the batch count stays complete.
97
+ return { run: { id, label: id, agent: id }, hasAlias: false };
98
+ });
99
+ const named = entries.map(({ run, hasAlias }) => ({
100
+ id: compactInlineText(run.id, { maxChars: 64 }) || "run",
101
+ fullName: runDisplayName(run),
102
+ hasAlias,
103
+ }));
104
+ const counts = new Map<string, number>();
105
+ for (const row of named) counts.set(row.fullName, (counts.get(row.fullName) ?? 0) + 1);
106
+ return named.map((row) => {
107
+ const displayName = counts.get(row.fullName)! > 1
108
+ ? `${compactInlineText(row.fullName, { maxChars: 56 })} [${compactInlineText(row.id, { maxChars: 16 })}]`
109
+ : row.fullName;
110
+ return {
111
+ ...row,
112
+ displayName,
113
+ clipped: compactInlineText(displayName, { maxChars: 80 }) !== displayName,
114
+ };
115
+ });
116
+ }
117
+
118
+ function collapsedLaunchSummary(rows: readonly DelegateLaunchRow[], dropped: number | undefined): string {
119
+ const prefix = "delegate launched ";
120
+ const dropNote = dropped && dropped > 0 ? ` · ${dropped} task${dropped === 1 ? "" : "s"} dropped` : "";
121
+ const maxVisible = Math.min(rows.length, COLLAPSED_LAUNCH_ROWS);
122
+ for (let count = maxVisible; count >= 1; count--) {
123
+ const names = rows.slice(0, count).map((row) => row.displayName);
124
+ const omitted = rows.length - names.length;
125
+ const suffix = omitted > 0 ? `, +${omitted} more` : "";
126
+ const body = `${prefix}${names.join(", ")}${suffix}${dropNote}`;
127
+ const needsHint = omitted > 0 || rows.slice(0, count).some((row) => row.clipped) || body.length > MAX_COLLAPSED_CARD_COLUMNS || visibleWidth(body) > MAX_COLLAPSED_CARD_COLUMNS;
128
+ const candidate = `${body}${needsHint ? ` · ${expandDetailHint()}` : ""}`;
129
+ if (candidate.length <= MAX_COLLAPSED_CARD_COLUMNS && visibleWidth(candidate) <= MAX_COLLAPSED_CARD_COLUMNS) return candidate;
130
+ }
131
+ const omitted = rows.length - 1;
132
+ const suffix = omitted > 0 ? `, +${omitted} more` : "";
133
+ const hint = ` · ${expandDetailHint()}`;
134
+ const fixed = `${prefix}${suffix}${dropNote}${hint}`;
135
+ const aliasLimit = Math.max(16, Math.min(MAX_COLLAPSED_CARD_COLUMNS - visibleWidth(fixed), MAX_COLLAPSED_CARD_COLUMNS - fixed.length));
136
+ const first = compactInlineText(rows[0]!.displayName, { maxChars: aliasLimit });
137
+ return compactInlineText(`${prefix}${first}${suffix}${dropNote}${hint}`, { maxChars: MAX_COLLAPSED_CARD_COLUMNS });
138
+ }
139
+
140
+ function expandedLaunchDetails(text: string, rows: readonly DelegateLaunchRow[]): string {
141
+ if (rows.length === 0 || !rows.some((row) => row.hasAlias)) return text;
142
+ const diagnostics = rows.map((row) => `- ${row.fullName} [${row.id}]`).join("\n");
143
+ return `${text}\n\nLaunched workers:\n${diagnostics}`;
144
+ }
145
+
33
146
  export interface DelegateToolDeps {
34
147
  get lastCtx(): ExtensionContext | undefined;
35
148
  set lastCtx(value: ExtensionContext | undefined);
@@ -290,9 +403,10 @@ export function registerDelegateTool(pi: ExtensionAPI, d: DelegateToolDeps): voi
290
403
  "Pass agent and task for one worker, or a tasks array for parallel workers. Follow the active persona's delegation policy for required fields.",
291
404
  "When requireBrief is active, EVERY worker needs brief: { objective, scopeRoe, position, constraints, requiredArtifacts, stopConditions } with nonempty values, including read-only scouts. In parallel mode put it in each tasks[].brief; task prose and a top-level brief do not substitute. Read the current per-turn delegation brief for a complete example.",
292
405
  "A persona may also require a structured output contract or disjoint writeSet ownership for parallel writers. Incomplete calls are rejected before any worker starts; fill the missing fields across the batch before retrying.",
293
- "In interactive sessions it runs in the BACKGROUND by default: you get run ids at once, stay free,",
294
- "and each result returns to you automatically as a follow-up — do NOT poll (`intercom wait` only when",
295
- "you need a result before your very next step; `sync: true` to block instead; headless runs default to sync).",
406
+ "In interactive sessions it runs in the BACKGROUND by default: you get run ids at once and stay free.",
407
+ "Work independently while children run; when only children remain, end your turn and let their automatic completion follow-ups wake you.",
408
+ "Do not monitor, poll, or automatically wait for all children. Use `intercom wait` only when a result is needed before your next step; `sync: true` intentionally blocks instead; headless runs default to sync.",
409
+ "Steering received during an active tool may be acted on after that tool returns; do not assume arbitrary tool work is interrupted immediately.",
296
410
  "No fitting agent? Shape one on the fly: `operator` + `role` (extra system prompt) + `skills`.",
297
411
  "Assign each worker's name before launch so it sees the same identity from its first turn; omitted names keep the generic fallback.",
298
412
  "A `model` may be a loose name ('sonnet') — it resolves to YOUR provider's id; ambiguous names return",
@@ -449,13 +563,17 @@ export function registerDelegateTool(pi: ExtensionAPI, d: DelegateToolDeps): voi
449
563
  const batchSlots = effectiveConcurrency < tasks.length ? new Semaphore(effectiveConcurrency) : undefined;
450
564
  const nameOffset = asyncNameSequence;
451
565
  asyncNameSequence += tasks.length;
452
- const ids = tasks.map((t, i) => {
566
+ const runs = tasks.map((t, i): DelegateLaunchSnapshot => {
453
567
  // Routed through specOf() (not a hand-rolled field list) so this, the interactive
454
568
  // DEFAULT delegate path, never drifts from the sync path's mapping — NP2's per-leg
455
569
  // `timeoutMs` (and any future knob) lands here for free instead of needing a second copy.
456
570
  const spec = specOf(t, nameOffset + i);
457
- return launchAsyncRun(t.agent, t.task, spec, nameFor(t, nameOffset + i), batchSlots);
571
+ const label = nameFor(t, nameOffset + i);
572
+ const id = launchAsyncRun(t.agent, t.task, spec, label, batchSlots);
573
+ const model = shortModel(spec.model);
574
+ return { id, label, agent: t.agent, ...(model ? { model } : {}) };
458
575
  });
576
+ const ids = runs.map((run) => run.id);
459
577
  const droppedNote = dropped > 0 ? ` ${dropped} task(s) beyond the max-children limit (${d.RUN_LIMITS.maxChildren}) were dropped.` : "";
460
578
  return {
461
579
  content: [
@@ -464,7 +582,7 @@ export function registerDelegateTool(pi: ExtensionAPI, d: DelegateToolDeps): voi
464
582
  text: `Launched ${ids.length} async runs in the background (${ids.join(", ")}) — keep working; each notifies on completion. /peek to watch.${droppedNote}`,
465
583
  },
466
584
  ],
467
- details: { runIds: ids },
585
+ details: { runIds: ids, runs, ...(dropped > 0 ? { dropped } : {}) },
468
586
  isError: false,
469
587
  };
470
588
  }
@@ -476,7 +594,9 @@ export function registerDelegateTool(pi: ExtensionAPI, d: DelegateToolDeps): voi
476
594
  // survive exactly as they do in fan-out and sync mode.
477
595
  const single = { ...params, agent, task };
478
596
  const runSpec = specOf(single, nameIndex);
479
- const id = launchAsyncRun(agent, task, runSpec, nameFor(single, nameIndex));
597
+ const label = nameFor(single, nameIndex);
598
+ const id = launchAsyncRun(agent, task, runSpec, label);
599
+ const model = shortModel(runSpec.model);
480
600
  return {
481
601
  content: [
482
602
  {
@@ -484,7 +604,7 @@ export function registerDelegateTool(pi: ExtensionAPI, d: DelegateToolDeps): voi
484
604
  text: `Launched async run ${id} (${agent}) — runs in the background; you'll be notified on completion. /peek ${id} to watch.`,
485
605
  },
486
606
  ],
487
- details: { runId: id },
607
+ details: { runId: id, runs: [{ id, label, agent, ...(model ? { model } : {}) }] },
488
608
  isError: false,
489
609
  };
490
610
  }
@@ -561,27 +681,25 @@ export function registerDelegateTool(pi: ExtensionAPI, d: DelegateToolDeps): voi
561
681
  const title = theme.fg("toolTitle", theme.bold("delegate "));
562
682
  const coerced = coerceDelegateParams(args);
563
683
  const view = coerced.ok ? coerced.params : undefined;
684
+ const mode = wantsAsyncRun(view ?? args, true) ? theme.fg("warning", " async") : theme.fg("dim", " sync");
564
685
  if (view?.tasks && view.tasks.length > 0) {
565
686
  // Names live in the tree / final card — keep the call line itself minimal.
566
- return new Text(`${title}${theme.fg("accent", `parallel (${view.tasks.length})`)}`, 0, 0);
687
+ return new Text(`${title}${theme.fg("accent", `parallel (${view.tasks.length})`)}${mode}`, 0, 0);
567
688
  }
568
689
  if (typeof args.tasks === "string" && args.tasks.trim()) {
569
- return new Text(`${title}${theme.fg("accent", "parallel")}`, 0, 0);
690
+ return new Text(`${title}${theme.fg("accent", "parallel")}${mode}`, 0, 0);
570
691
  }
571
- const agent = compactInlineText(view?.agent ?? args.agent ?? "?", { maxChars: 80 }) || "?";
572
- const preview = compactInlineText(view?.task ?? args.task ?? "", { maxChars: 60 });
573
- // renderCall only fires in an interactive UI, where delegate runs in the BACKGROUND by
574
- // default — pass hasUI:true to the same wantsAsyncRun the execute path uses, so the common
575
- // (defaulted) background run still shows the tag; `sync: true` drops it.
576
- const asyncTag = wantsAsyncRun(view ?? args, true) ? theme.fg("warning", " async") : "";
577
- return new Text(`${title}${theme.fg("accent", agent)}${asyncTag}${theme.fg("dim", ` ${preview}`)}`, 0, 0);
692
+ const identity = [view?.name ?? args.name, view?.agent ?? args.agent ?? "?"].filter(Boolean).join(" · ");
693
+ const modelValue = view?.model ?? args.model;
694
+ const model = typeof modelValue === "string" && modelValue.trim() ? theme.fg("dim", ` · ${compactInlineText(shortModel(modelValue), { maxChars: 48 })}`) : "";
695
+ return new Text(`${title}${theme.fg("accent", compactInlineText(identity, { maxChars: 96 }) || "?")}${model}${mode}`, 0, 0);
578
696
  },
579
697
 
580
- renderResult(result, { expanded }, theme) {
698
+ renderResult(result, { expanded }, theme, context) {
581
699
  // Safe: the delegate `execute` above always stores `{ views: DelegateView[] }` (sync,
582
700
  // single/parallel) or `{ runId | runIds }` (async) in `details`; the double cast just narrows
583
701
  // Pi's opaque `details` type to that known shape for rendering.
584
- const details = result.details as unknown as { views?: DelegateView[]; runId?: string; runIds?: string[] } | undefined;
702
+ const details = result.details as unknown as { views?: DelegateView[]; runId?: string; runIds?: string[]; runs?: unknown; dropped?: number } | undefined;
585
703
  const views = details?.views ?? [];
586
704
  if (views.length === 0) {
587
705
  const first = result.content[0];
@@ -590,8 +708,28 @@ export function registerDelegateTool(pi: ExtensionAPI, d: DelegateToolDeps): voi
590
708
  : details?.runId
591
709
  ? `async run ${details.runId}`
592
710
  : "(no output)";
593
- const text = first?.type === "text" ? first.text : fallback;
594
- return new Text(sanitizeTerminalText(text), 0, 0);
711
+ const text = sanitizeTerminalText(first?.type === "text" ? first.text : fallback);
712
+ const allowTracker = context === undefined || context.isPartial === true;
713
+ const rows = details ? launchRows(details, d.tracker, allowTracker) : [];
714
+ if (expanded) return new Text(expandedLaunchDetails(text, rows), 0, 0);
715
+ if (details?.runId || details?.runIds?.length) {
716
+ if (rows.length > 0 && !result.isError) {
717
+ return new Text(theme.fg("accent", collapsedLaunchSummary(rows, details.dropped)), 0, 0);
718
+ }
719
+ if (result.isError) {
720
+ const preview = compactVisibleText(text, { maxLines: 4, maxLineChars: 100 });
721
+ return new Text(`${theme.fg("error", theme.bold("failed"))}\n${theme.fg("toolOutput", preview.text)}${preview.truncated ? `\n${theme.fg("dim", expandDetailHint())}` : ""}`, 0, 0);
722
+ }
723
+ const ids = details.runIds?.length ? details.runIds.slice(0, 3) : [details.runId as string];
724
+ const omitted = (details.runIds?.length ?? 0) - ids.length;
725
+ const dropped = details.dropped ? ` · ${details.dropped} task${details.dropped === 1 ? "" : "s"} dropped (max children)` : "";
726
+ return new Text(`${theme.fg("toolTitle", theme.bold("delegate "))}${theme.fg("accent", `launched ${ids.join(", ")}${omitted > 0 ? `, +${omitted} more` : ""}`)}${theme.fg("dim", dropped)}`, 0, 0);
727
+ }
728
+ if (result.isError) {
729
+ const preview = compactVisibleText(text, { maxLines: 4, maxLineChars: 100 });
730
+ return new Text(`${theme.fg("error", theme.bold("delegate failed"))}\n${theme.fg("toolOutput", preview.text)}${preview.truncated ? `\n${theme.fg("dim", expandDetailHint())}` : ""}`, 0, 0);
731
+ }
732
+ return new Text(theme.fg("toolOutput", compactVisibleText(text, { maxLines: 4, maxLineChars: 100 }).text), 0, 0);
595
733
  }
596
734
  const title = theme.fg("toolTitle", theme.bold("delegate "));
597
735
  const running = views.filter((v) => v.running).length;
@@ -10,7 +10,7 @@ import {
10
10
  type AsyncRun, AsyncRunTracker, boundCompletionSurface, buildPeekDigest, buildWaitTimeoutNote,
11
11
  MAX_ASYNC_STATUS_ROWS,
12
12
  dedupeRunsById, getFullRunOutput, IdleCoalescingNotifier,
13
- renderCompletion, runDurationLabel,
13
+ renderCompletion, runDisplayName, runDurationLabel,
14
14
  } from "../engine/async.ts";
15
15
  import { fenceUntrusted } from "../core/fence.ts";
16
16
  import { sanitizeDisplayLabel } from "../core/display-label.ts";
@@ -31,6 +31,7 @@ export interface IntercomToolDeps {
31
31
  bus: InProcessBus;
32
32
  SUPERVISOR: string;
33
33
  STALL_FLAG_MS: number;
34
+ waitingRunIds?: () => ReadonlySet<string>;
34
35
  missingRunMessage(id: string, display: string | undefined): string;
35
36
  stopAgent(nodeId: string): boolean;
36
37
  steerAgent(nodeId: string, text: string): boolean;
@@ -60,7 +61,22 @@ export function fenceIntercomToolOutcome(out: IntercomOutcome, action: string, f
60
61
  return action === "message" && out.details.ok ? fence(out.text) : fenceIntercomOutcome(out, fence);
61
62
  }
62
63
 
64
+ /** Renderer-local state only. The persisted result, not this cache, owns display identity. */
65
+ interface IntercomRenderState {
66
+ target?: { id: string; label: string };
67
+ callText?: Text;
68
+ formatCall?: (target: string) => string;
69
+ }
70
+
63
71
  export function registerIntercomTool(pi: ExtensionAPI, d: IntercomToolDeps): void {
72
+ function displayRunTarget(id: string): string {
73
+ const run = d.tracker?.peek(id);
74
+ if (!run) return sanitizeDisplayLabel(id, "run");
75
+ const name = runDisplayName(run);
76
+ // An alias is presentation, never a unique address. Disambiguate collisions without rerouting.
77
+ const collision = d.tracker.list().some((other) => other.id !== id && runDisplayName(other) === name);
78
+ return collision ? `${name} [${sanitizeDisplayLabel(id, "run")}]` : name;
79
+ }
64
80
  // ── intercom tool (supervisor side of the comm plane: read/answer children) ───
65
81
  const IntercomToolParams = Type.Object({
66
82
  action: Type.Union(
@@ -78,14 +94,15 @@ export function registerIntercomTool(pi: ExtensionAPI, d: IntercomToolDeps): voi
78
94
  ],
79
95
  {
80
96
  description:
81
- "peek = watch async sub-agents · result = retrieve one complete settled result by run id · wait = BLOCK until async run(s) settle and collect their bounded reports (a join) · steer = soft redirect into one by run id (it may ignore it) · stop = request engine cancellation by run id · message = retrieve one drained bus message by message id · list/inbox/reply/send = coaching messages (needs a coaching persona)",
97
+ "peek = watch async sub-agents · result = retrieve one complete settled result by run id · wait = collect current reports; sync:true joins (default: nonblocking with UI, blocking headless) · steer = soft redirect into one by run id (it may ignore it) · stop = request engine cancellation by run id · message = retrieve one drained bus message by message id · list/inbox/reply/send = coaching messages (needs a coaching persona)",
82
98
  },
83
99
  ),
84
- to: Type.Optional(Type.String({ maxLength: MAX_INTERCOM_REF_CHARS, description: "result/steer/stop/peek: the async run id (e.g. 'run-1') · wait: an async run id; omit it to join all running and pending completions · send: the child handle (from `list`)" })),
100
+ to: Type.Optional(Type.String({ maxLength: MAX_INTERCOM_REF_CHARS, description: "result/steer/stop/peek: the async run id (e.g. 'run-1') · wait: an async run id; omit it to collect all current/pending completions · send: the child handle (from `list`)" })),
85
101
  messageId: Type.Optional(Type.String({ maxLength: MAX_INTERCOM_REF_CHARS, description: "message: the drained bus message id shown by `inbox`" })),
86
102
  askId: Type.Optional(Type.String({ maxLength: MAX_INTERCOM_REF_CHARS, description: "reply: the message id of the child's pending question" })),
87
103
  message: Type.Optional(Type.String({ maxLength: MAX_INTERCOM_MESSAGE_CHARS, description: "steer/reply/send: the text to deliver" })),
88
- timeoutMs: Type.Optional(Type.Number({ description: "wait: max ms to hold your turn (default 600000; values below 1000 clamp to 1000, values above 600000 clamp to 600000) — on timeout you get what settled + what's still running" })),
104
+ sync: Type.Optional(Type.Boolean({ description: "wait: block for runs to settle; defaults to false in interactive/RPC sessions and true in headless sessions" })),
105
+ timeoutMs: Type.Optional(Type.Number({ description: "wait with sync:true: max ms to hold your turn (default 600000; values below 1000 clamp to 1000, values above 600000 clamp to 600000) — on timeout you get what settled + what's still running" })),
89
106
  });
90
107
  pi.registerTool({
91
108
  name: "intercom",
@@ -93,9 +110,10 @@ export function registerIntercomTool(pi: ExtensionAPI, d: IntercomToolDeps): voi
93
110
  description: [
94
111
  "See, steer, message, and JOIN your running sub-agents.",
95
112
  "`peek` watches what your async sub-agents are doing; `result` retrieves one complete settled",
96
- "payload by run id; `wait` blocks until runs settle and returns a bounded join report;",
113
+ "payload by run id; `wait` collects current reports and is nonblocking by default with UI (interactive/RPC); set `sync: true` for an intentional bounded join. Headless defaults to joining; set `sync: false` for a snapshot. Do not automatically wait for all children.",
114
+ "Work independently while children run; when only children remain, end your turn and let completion follow-ups wake you; do not monitor or poll healthy children.",
97
115
  "`steer` sends a soft course-correction to one (by run id); in-process async runs can receive it mid-run,",
98
- "while child-engine (MCP/worktree) async runs receive it as a brokered follow-up.",
116
+ "while child-engine (MCP/worktree) async runs receive it as a brokered follow-up. After remaining tools return, Pi can consume steering; arbitrary active tool work is not promised to stop immediately.",
99
117
  "`message` retrieves a drained bus message by its message id; `list`/`inbox`/`reply`/`send` exchange coaching messages (a child reaches you via `contact_supervisor`)",
100
118
  "and need a `coaching: on` persona.",
101
119
  ].join(" "),
@@ -105,6 +123,7 @@ export function registerIntercomTool(pi: ExtensionAPI, d: IntercomToolDeps): voi
105
123
  // `to` comes from model-authored tool arguments. Use the exact value for routing, but never
106
124
  // interpolate it into trusted prose without reducing it to compact identifier metadata.
107
125
  const displayTarget = params.to === undefined ? undefined : sanitizeDisplayLabel(params.to, "run");
126
+ const targetSnapshot = params.to === undefined ? {} : { runId: params.to, target: displayRunTarget(params.to) };
108
127
  // peek + wait + steer + stop are supervisor→child controls over the async d.tracker /
109
128
  // steer handles — available to EVERY persona (no dependency on the coaching bus).
110
129
  if (params.action === "peek") {
@@ -113,7 +132,7 @@ export function registerIntercomTool(pi: ExtensionAPI, d: IntercomToolDeps): voi
113
132
  const runs = params.to
114
133
  ? [d.tracker.peek(params.to)].filter((r): r is AsyncRun => !!r)
115
134
  : dedupeRunsById([...d.tracker.running(), ...d.completionNotifier.peekPending()]);
116
- return { content: [{ type: "text", text: buildPeekDigest(runs, { now: Date.now(), stallMs: d.STALL_FLAG_MS }) }], details: { action: "peek", ok: true }, isError: false };
135
+ return { content: [{ type: "text", text: buildPeekDigest(runs, { now: Date.now(), stallMs: d.STALL_FLAG_MS, ...(d.waitingRunIds ? { waitingForSupervisor: d.waitingRunIds() } : {}) }) }], details: { action: "peek", ok: true, ...targetSnapshot }, isError: false };
117
136
  }
118
137
  if (params.action === "result") {
119
138
  if (!params.to) {
@@ -121,12 +140,12 @@ export function registerIntercomTool(pi: ExtensionAPI, d: IntercomToolDeps): voi
121
140
  }
122
141
  const run = d.tracker.peek(params.to);
123
142
  if (!run) {
124
- return { content: [{ type: "text", text: d.missingRunMessage(params.to, displayTarget) }], details: failureDetails({ action: "result", ok: false }), isError: true };
143
+ return { content: [{ type: "text", text: d.missingRunMessage(params.to, displayTarget) }], details: failureDetails({ action: "result", ok: false, ...targetSnapshot }), isError: true };
125
144
  }
126
145
  if (run.status === "running") {
127
146
  return {
128
- content: [{ type: "text", text: `${displayTarget} is still running. Use intercom peek/wait, or request result after it settles.` }],
129
- details: failureDetails({ action: "result", ok: false, status: run.status }),
147
+ content: [{ type: "text", text: `${targetSnapshot.target} is still running. Use intercom peek/wait, or request result after it settles.` }],
148
+ details: failureDetails({ action: "result", ok: false, status: run.status, ...targetSnapshot }),
130
149
  isError: true,
131
150
  };
132
151
  }
@@ -149,7 +168,7 @@ export function registerIntercomTool(pi: ExtensionAPI, d: IntercomToolDeps): voi
149
168
  d.publishPersonaCost();
150
169
  return {
151
170
  content: [{ type: "text", text: `${run.id} (${displayRun}) · ${run.status}${took ? ` · ${took}` : ""}${cause}\n${body}` }],
152
- details: { action: "result", ok: true, status: run.status, runId: run.id },
171
+ details: { action: "result", ok: true, status: run.status, ...targetSnapshot },
153
172
  isError: false,
154
173
  ...toolUsageField(billed),
155
174
  };
@@ -158,7 +177,7 @@ export function registerIntercomTool(pi: ExtensionAPI, d: IntercomToolDeps): voi
158
177
  // No `to` → wait on running legs AND collect settled legs still queued for follow-up
159
178
  // delivery, so a wait in the settle→deliver gap returns their results (not "nothing").
160
179
  if (params.to && !d.tracker.peek(params.to)) {
161
- return { content: [{ type: "text", text: d.missingRunMessage(params.to, displayTarget) }], details: failureDetails({ action: "wait", ok: false }), isError: true };
180
+ return { content: [{ type: "text", text: d.missingRunMessage(params.to, displayTarget) }], details: failureDetails({ action: "wait", ok: false, ...targetSnapshot }), isError: true };
162
181
  }
163
182
  const ids = params.to
164
183
  ? [params.to]
@@ -166,6 +185,31 @@ export function registerIntercomTool(pi: ExtensionAPI, d: IntercomToolDeps): voi
166
185
  if (ids.length === 0) {
167
186
  return { content: [{ type: "text", text: "No async runs to wait for." }], details: { action: "wait", ok: true }, isError: false };
168
187
  }
188
+ // Interactive/RPC defaults to a snapshot: waiting inside a tool stalls queued user steering.
189
+ // Headless retains its historical join; callers can override either default with sync.
190
+ if (!(params.sync ?? ctx.hasUI !== true)) {
191
+ const current = ids.map((id) => d.tracker.peek(id)).filter((run): run is AsyncRun => !!run);
192
+ const settled = current.filter((run) => run.status !== "running");
193
+ const still = current.filter((run) => run.status === "running");
194
+ const settledIds = new Set(settled.map((run) => run.id));
195
+ d.completionNotifier.discard((run) => settledIds.has(run.id));
196
+ for (const id of settledIds) d.tracker.markCollected(id);
197
+ const report = settled.length > 0 ? renderCompletion(settled, fenceUntrusted, (t) => d.scanForSurrender(t)) : "";
198
+ const stillIds = still.map((run) => sanitizeDisplayLabel(run.id, "run"));
199
+ const omitted = stillIds.length > MAX_ASYNC_STATUS_ROWS;
200
+ const runningNote = still.length > 0
201
+ ? `Background continues; automatic completion will notify you. End your turn when this is the only remaining work. Still running: ${stillIds.slice(0, MAX_ASYNC_STATUS_ROWS).join(", ")}${omitted ? `, … +${stillIds.length - MAX_ASYNC_STATUS_ROWS} more` : ""}.`
202
+ : "";
203
+ const text = boundCompletionSurface([report, runningNote].filter(Boolean).join("\n\n") || "Nothing to report (unknown run ids?).");
204
+ const billed = d.childUsage.accountMany(settled.map((run) => ({ key: run.id, usage: run.result?.usage ?? emptyUsage() })));
205
+ d.publishPersonaCost();
206
+ return {
207
+ content: [{ type: "text", text }],
208
+ details: { action: "wait", ok: true, settled: [...settledIds], running: still.map((run) => run.id), ...targetSnapshot },
209
+ isError: false,
210
+ ...toolUsageField(billed),
211
+ };
212
+ }
169
213
  // Bounded join: never longer than a child's ask timeout (bus `ask` default 600s),
170
214
  // so a coaching child blocking on OUR reply can't deadlock us past its own timeout.
171
215
  // Default matches that ceiling — heavy sub-agents (30+ turns) routinely outlast a
@@ -193,7 +237,7 @@ export function registerIntercomTool(pi: ExtensionAPI, d: IntercomToolDeps): voi
193
237
  d.publishPersonaCost();
194
238
  return {
195
239
  content: [{ type: "text", text }],
196
- details: { action: "wait", ok: true, settled: [...settledIds], running: still.map((r) => r.id) },
240
+ details: { action: "wait", ok: true, settled: [...settledIds], running: still.map((r) => r.id), ...targetSnapshot },
197
241
  isError: false,
198
242
  ...toolUsageField(billed),
199
243
  };
@@ -203,18 +247,26 @@ export function registerIntercomTool(pi: ExtensionAPI, d: IntercomToolDeps): voi
203
247
  return { content: [{ type: "text", text: "intercom steer needs { to: <run id>, message }." }], details: failureDetails({ action: "steer", ok: false }), isError: true };
204
248
  }
205
249
  const nodeId = `async:${params.to}`;
250
+ const knownRun = d.tracker.peek(params.to);
251
+ if (knownRun && knownRun.status !== "running") {
252
+ return {
253
+ content: [{ type: "text", text: `Cannot steer "${targetSnapshot.target}" — this run is already settled (${knownRun.status}). Use intercom result with to="${displayTarget}" to inspect its retained result.` }],
254
+ details: failureDetails({ action: "steer", ok: false, status: knownRun.status, ...targetSnapshot }),
255
+ isError: true,
256
+ };
257
+ }
206
258
  if (!d.steerRegistry.has(nodeId)) {
207
259
  return {
208
- content: [{ type: "text", text: `Cannot steer "${displayTarget}" — no live steer handle is available (the run may have finished or not started yet, or its engine/broker does not expose steering).` }],
209
- details: failureDetails({ action: "steer", ok: false }),
260
+ content: [{ type: "text", text: `Cannot steer "${targetSnapshot.target}" — no live steer handle is available (the run may not have started yet, or its engine/broker does not expose steering).` }],
261
+ details: failureDetails({ action: "steer", ok: false, ...targetSnapshot }),
210
262
  isError: true,
211
263
  };
212
264
  }
213
265
  // Routed through the guarded d.steerAgent so a just-finished/d.disposed handle can't throw.
214
266
  const steered = d.steerAgent(nodeId, params.message);
215
267
  return steered
216
- ? { content: [{ type: "text", text: `Steering sent to ${displayTarget} (soft request; use action "stop" to request cancellation).` }], details: { action: "steer", ok: true }, isError: false }
217
- : { content: [{ type: "text", text: `Could not steer "${displayTarget}" — it may have just finished, or the message was empty.` }], details: failureDetails({ action: "steer", ok: false }), isError: true };
268
+ ? { content: [{ type: "text", text: `Steering queued for ${targetSnapshot.target} (${displayTarget}).` }], details: { action: "steer", ok: true, ...targetSnapshot, message: params.message }, isError: false }
269
+ : { content: [{ type: "text", text: `Could not steer "${targetSnapshot.target}" — it may have just finished, or the message was empty.` }], details: failureDetails({ action: "steer", ok: false, ...targetSnapshot }), isError: true };
218
270
  }
219
271
  if (params.action === "stop") {
220
272
  if (!params.to) {
@@ -226,21 +278,21 @@ export function registerIntercomTool(pi: ExtensionAPI, d: IntercomToolDeps): voi
226
278
  const repeated = d.stopRequested.has(nodeId);
227
279
  const stopped = d.stopAgent(nodeId);
228
280
  if (stopped && !repeated) {
229
- return { content: [{ type: "text", text: `Cancellation requested for ${displayTarget}. Check its terminal result; changes already made are not undone.` }], details: { action: "stop", ok: true }, isError: false };
281
+ return { content: [{ type: "text", text: `Cancellation requested for ${targetSnapshot.target}. Check its terminal result; changes already made are not undone.` }], details: { action: "stop", ok: true, ...targetSnapshot }, isError: false };
230
282
  }
231
283
  // A repeated stop has just invoked the REAL cancel handle again. Only now force-clear
232
284
  // d.tracker state if engine settlement is still lagging; the handle remains registered
233
285
  // until onComplete so cancellation can never be replaced by UI-only bookkeeping.
234
286
  if (stopped && repeated && d.tracker.forceSettle(params.to, "force-stopped by supervisor after repeated engine cancellation")) {
235
287
  return {
236
- content: [{ type: "text", text: `Force-cleared ${displayTarget} after repeating the engine cancellation; it will no longer be tracked as running.` }],
237
- details: { action: "stop", ok: true },
288
+ content: [{ type: "text", text: `Force-cleared ${targetSnapshot.target} after repeating the engine cancellation; it will no longer be tracked as running.` }],
289
+ details: { action: "stop", ok: true, ...targetSnapshot },
238
290
  isError: false,
239
291
  };
240
292
  }
241
293
  return {
242
- content: [{ type: "text", text: `Cannot stop "${displayTarget}" — no such running run (it already finished).` }],
243
- details: failureDetails({ action: "stop", ok: false }),
294
+ content: [{ type: "text", text: `Cannot stop "${targetSnapshot.target}" — no such running run (it already finished).` }],
295
+ details: failureDetails({ action: "stop", ok: false, ...targetSnapshot }),
244
296
  isError: true,
245
297
  };
246
298
  }
@@ -261,28 +313,63 @@ export function registerIntercomTool(pi: ExtensionAPI, d: IntercomToolDeps): voi
261
313
  }
262
314
  return { content: [{ type: "text", text }], details: out.details.ok ? out.details : failureDetails(out.details), isError: !out.details.ok };
263
315
  },
264
- renderCall(args, theme) {
316
+ renderCall(args, theme, context) {
317
+ const state = context?.state as IntercomRenderState | undefined;
265
318
  const action = compactInlineText(args.action ?? "?", { maxChars: 24 }) || "?";
266
319
  let target = "";
267
- if (action === "wait" || action === "peek") target = compactInlineText(args.to ?? "all", { maxChars: 80 }) || "all";
268
- else if (["result", "steer", "stop", "send"].includes(action)) target = compactInlineText(args.to ?? "?", { maxChars: 80 }) || "?";
320
+ const runTarget = typeof args.to === "string"
321
+ ? state?.target?.id === args.to ? state.target.label : displayRunTarget(args.to)
322
+ : undefined;
323
+ if (action === "wait" || action === "peek") target = runTarget ?? "all";
324
+ else if (["result", "steer", "stop"].includes(action)) target = runTarget ?? "?";
325
+ else if (action === "send") target = compactInlineText(args.to ?? "?", { maxChars: 80 }) || "?";
269
326
  else if (action === "message") target = compactInlineText(args.messageId ?? "?", { maxChars: 80 }) || "?";
270
327
  else if (action === "reply") target = compactInlineText(args.askId ?? "?", { maxChars: 80 }) || "?";
271
328
  const timeout = action === "wait" && Number.isFinite(args.timeoutMs) && args.timeoutMs !== undefined
272
329
  ? ` · ${Math.max(0, Math.floor(args.timeoutMs))}ms`
273
330
  : "";
274
- return new Text(
275
- `${theme.fg("toolTitle", theme.bold("intercom "))}${theme.fg("accent", action)}${target ? theme.fg("dim", ` ${target}${timeout}`) : ""}`,
276
- 0,
277
- 0,
278
- );
331
+ const formatCall = (label: string) => `${theme.fg("toolTitle", theme.bold("intercom "))}${theme.fg("accent", action)}${label ? theme.fg("dim", ` ${compactInlineText(label, { maxChars: 80 })}${timeout}`) : ""}`;
332
+ const component = new Text(formatCall(target), 0, 0);
333
+ if (state) { state.callText = component; state.formatCall = formatCall; }
334
+ return component;
279
335
  },
280
- renderResult(result, { expanded }, theme) {
281
- const details = (result.details ?? {}) as { action?: string; ok?: boolean };
336
+ renderResult(result, { expanded }, theme, context) {
337
+ const details = (result.details ?? {}) as { action?: string; ok?: boolean; runId?: string; target?: string; message?: string };
338
+ const state = context?.state as IntercomRenderState | undefined;
339
+ const action = details.action ?? context?.args?.action;
340
+ const savedId = details.runId ?? context?.args?.to;
341
+ const legacyCompleted = !details.target && context !== undefined && context.isPartial !== true
342
+ && ["steer", "stop", "result", "peek", "wait"].includes(action ?? "");
343
+ if (state && typeof savedId === "string" && (details.target || legacyCompleted)) {
344
+ // Old receipts have no display snapshot. A new session may reuse their run ID;
345
+ // the current tracker cannot supply a historical alias for that unrelated execution.
346
+ state.target = { id: savedId, label: details.target ? compactInlineText(details.target, { maxChars: 80 }) : sanitizeDisplayLabel(savedId, "run") };
347
+ // Pi renders the call before the result. Update that same Text rather than causing a
348
+ // recursive invalidation, so restored history uses its saved alias on its first frame.
349
+ if (state.callText && state.formatCall) state.callText.setText(state.formatCall(state.target.label));
350
+ }
282
351
  const first = result.content[0];
283
352
  const full = sanitizeTerminalText(first?.type === "text" ? first.text : "(no output)");
284
353
  const failed = details.ok === false;
285
354
  const prefix = failed ? `${theme.fg("error", theme.bold("failed"))}\n` : "";
355
+ if (details.action === "steer" && details.ok === true) {
356
+ const target = compactInlineText(details.target ?? state?.target?.label ?? (legacyCompleted && typeof savedId === "string" ? sanitizeDisplayLabel(savedId, "run") : details.runId ? displayRunTarget(details.runId) : ""), { maxChars: 80 });
357
+ const message = typeof details.message === "string" ? details.message : context?.args?.message;
358
+ // Old persisted receipts may predate message snapshots. Keep their original content
359
+ // available in expansion rather than fabricating a message that was never recorded.
360
+ if (expanded) {
361
+ const text = typeof message === "string"
362
+ ? `Steering queued${target ? ` for ${target}` : ""}.${details.runId ? `\nRun: ${sanitizeDisplayLabel(details.runId, "run")}` : ""}\n\n${sanitizeTerminalText(message)}`
363
+ : full;
364
+ return new Text(theme.fg("toolOutput", text), 0, 0);
365
+ }
366
+ const title = compactInlineText(`steering queued${target ? ` · ${target}` : ""}`, { maxChars: 100 });
367
+ const preview = typeof message === "string" ? compactVisibleText(message, { maxLines: 3, maxLineChars: 100 }) : undefined;
368
+ // Native Pi already rendered `intercom steer <alias>` above this result. Show the
369
+ // instruction directly there; only standalone/older renderers need another identity row.
370
+ const showTitle = !state?.callText || !preview;
371
+ return new Text(`${showTitle ? theme.fg("success", title) : ""}${preview ? `${showTitle ? "\n" : ""}${theme.fg("toolOutput", preview.text)}${preview.truncated ? `\n${theme.fg("dim", expandDetailHint())}` : ""}` : ""}`, 0, 0);
372
+ }
286
373
  if (expanded) return new Text(`${prefix}${theme.fg("toolOutput", full)}`, 0, 0);
287
374
  const preview = compactVisibleText(full, { maxLines: 4, maxLineChars: 100 });
288
375
  const hint = preview.truncated ? `\n${theme.fg("dim", expandDetailHint())}` : "";