@aeondave/pi-persona 1.17.0 → 1.17.3

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 (109) hide show
  1. package/AGENTS.md +79 -29
  2. package/CHANGELOG.md +264 -0
  3. package/README.md +27 -12
  4. package/agents/architect.md +21 -0
  5. package/agents/balthasar.md +17 -22
  6. package/agents/casper.md +17 -23
  7. package/agents/evidence-verifier.md +31 -16
  8. package/agents/melchior.md +17 -22
  9. package/agents/operator.md +15 -55
  10. package/agents/research.md +14 -48
  11. package/agents/reviewer.md +20 -39
  12. package/agents/scout.md +14 -15
  13. package/agents/verifier.md +15 -16
  14. package/docs/ARCHITECTURE.md +223 -57
  15. package/docs/EXPERIENCE.md +19 -9
  16. package/docs/MONITORS.md +62 -3
  17. package/docs/REFERENCE.md +189 -35
  18. package/docs/SPINE.md +2 -2
  19. package/docs/STRATEGIES.md +23 -8
  20. package/docs/TELEMETRY.md +1 -0
  21. package/flows/deep-review.flow.json +1 -1
  22. package/package.json +9 -8
  23. package/personas/audit.md +28 -17
  24. package/personas/dev.md +30 -32
  25. package/personas/elite.md +223 -221
  26. package/personas/judge.md +25 -19
  27. package/personas/magi.md +26 -29
  28. package/personas/planner.md +31 -24
  29. package/personas/researcher.md +25 -26
  30. package/personas/swarm.md +35 -25
  31. package/personas/verify.md +30 -19
  32. package/src/agents/agent.ts +20 -0
  33. package/src/bridge.ts +3 -2
  34. package/src/core/brief.ts +429 -356
  35. package/src/core/config.ts +16 -11
  36. package/src/core/model-catalog.ts +171 -0
  37. package/src/core/model-tier.ts +89 -0
  38. package/src/core/models.ts +13 -4
  39. package/src/core/monitor-output.ts +85 -0
  40. package/src/core/monitor.ts +34 -2
  41. package/src/core/nudge.ts +176 -23
  42. package/src/core/seed.ts +99 -6
  43. package/src/core/session-runs.ts +87 -0
  44. package/src/core/settings.ts +251 -0
  45. package/src/core/timer.ts +34 -9
  46. package/src/engine/adapter.ts +12 -8
  47. package/src/engine/async.ts +195 -136
  48. package/src/engine/child.ts +1 -1
  49. package/src/engine/host-module.ts +12 -0
  50. package/src/engine/inproc.ts +11 -6
  51. package/src/engine/stream.ts +3 -0
  52. package/src/exocom/install.ts +2 -1
  53. package/src/extension/companion-settings.ts +29 -0
  54. package/src/extension/engine.ts +43 -16
  55. package/src/extension/hooks.ts +106 -36
  56. package/src/extension/persona-model-settings.ts +336 -0
  57. package/src/extension/persona-settings.ts +459 -0
  58. package/src/extension/shared.ts +15 -7
  59. package/src/extension.ts +642 -267
  60. package/src/monitor/archive.ts +235 -0
  61. package/src/monitor/session.ts +21 -1
  62. package/src/orchestration/flow-journal.ts +26 -5
  63. package/src/orchestration/flow-run.ts +38 -13
  64. package/src/orchestration/flow.ts +9 -2
  65. package/src/orchestration/reducers.ts +9 -5
  66. package/src/orchestration/render.ts +1 -1
  67. package/src/orchestration/sdk.ts +17 -3
  68. package/src/orchestration/strategies/compete.ts +5 -1
  69. package/src/orchestration/strategies/council-rounds.ts +2 -0
  70. package/src/orchestration/strategies/critic-loop.ts +3 -0
  71. package/src/orchestration/strategies/debate.ts +2 -0
  72. package/src/orchestration/strategies/fanout.ts +3 -2
  73. package/src/orchestration/strategies/judge.ts +5 -1
  74. package/src/orchestration/strategies/magi.ts +40 -23
  75. package/src/orchestration/strategies/map.ts +13 -3
  76. package/src/orchestration/strategies/pair.ts +2 -0
  77. package/src/orchestration/strategies/pipeline.ts +2 -0
  78. package/src/orchestration/strategies/synthesize.ts +27 -7
  79. package/src/orchestration/strategy.ts +31 -0
  80. package/src/persona/config-store.ts +6 -195
  81. package/src/persona/model-participants.ts +1 -1
  82. package/src/persona/persona.ts +7 -3
  83. package/src/persona/settings-store.ts +636 -0
  84. package/src/telemetry/contract.ts +1 -1
  85. package/src/tools/background-run.ts +151 -0
  86. package/src/tools/council.ts +77 -52
  87. package/src/tools/delegate-tool.ts +315 -285
  88. package/src/tools/delegate.ts +22 -210
  89. package/src/tools/exocom-work.ts +18 -53
  90. package/src/tools/exocom.ts +15 -19
  91. package/src/tools/flow.ts +85 -28
  92. package/src/tools/intercom-tool.ts +108 -118
  93. package/src/tools/intercom.ts +1 -1
  94. package/src/tools/models.ts +19 -9
  95. package/src/tools/monitor.ts +39 -6
  96. package/src/tools/timer.ts +11 -13
  97. package/src/ui/agent-overlay.ts +55 -14
  98. package/src/ui/agent-tree.ts +84 -18
  99. package/src/ui/compact-widget.ts +25 -0
  100. package/src/ui/failure-summary.ts +55 -0
  101. package/src/ui/literal-tool-result.ts +24 -0
  102. package/src/ui/model-picker.ts +158 -81
  103. package/src/ui/persona-settings.ts +328 -0
  104. package/src/ui/presentation.ts +24 -0
  105. package/src/ui/report-body.ts +63 -0
  106. package/src/ui/session-cards.ts +189 -0
  107. package/src/ui/wake-rows.ts +262 -0
  108. package/teams.yaml +5 -1
  109. package/src/persona/state.ts +0 -28
package/src/tools/flow.ts CHANGED
@@ -1,27 +1,43 @@
1
1
  /** `flow` tool — run a *.flow.json DAG. */
2
2
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
3
3
  import { Type } from "typebox";
4
- import { failureDetails } from "../extension/shared.ts";
4
+ import { expandDetailHint, failureDetails, isInline } from "../extension/shared.ts";
5
5
  import { fenceUntrusted } from "../core/fence.ts";
6
- import type { FlowOutcome } from "../orchestration/flow-run.ts";
6
+ import { flowDisposition, type FlowOutcome } from "../orchestration/flow-run.ts";
7
7
  import type { FlowSpec } from "../orchestration/flow.ts";
8
- import { compactInlineText, sanitizeTerminalText } from "../ui/presentation.ts";
9
- import { compactVisibleText } from "../ui/presentation.ts";
10
- import { toolUsageField, type ChildUsageLedger } from "../ui/usage.ts";
8
+ import { compactInlineText, compactVisibleText, sanitizeTerminalText } from "../ui/presentation.ts";
9
+ import { toolUsageField } from "../ui/usage.ts";
11
10
  import { sumUsage } from "../orchestration/reducers.ts";
12
- import { emptyUsage } from "../engine/stream.ts";
13
11
  import { Text } from "@earendil-works/pi-tui";
14
- import { expandDetailHint } from "../extension/shared.ts";
15
- import type { FailureKind } from "../orchestration/types.ts";
12
+ import { createReportBody } from "../ui/report-body.ts";
13
+ import type { AgentResult, FailureKind } from "../orchestration/types.ts";
14
+ import { launchReceipt, type BackgroundRuns, type TrackedRun } from "./background-run.ts";
16
15
 
17
16
  export interface FlowToolDeps {
18
17
  get lastCtx(): ExtensionContext | undefined;
19
18
  set lastCtx(value: ExtensionContext | undefined);
20
19
  loadFlow(cwd: string, name: string): { ok: true; flow: FlowSpec } | { ok: false; error: string } | undefined;
21
20
  listFlows(cwd: string): string[];
22
- runFlowVisible(ctx: ExtensionContext, spec: FlowSpec, task: string, signal?: AbortSignal): Promise<FlowOutcome>;
23
- childUsage: ChildUsageLedger;
24
- publishPersonaCost(): void;
21
+ runFlowVisible(ctx: ExtensionContext, spec: FlowSpec, task: string, tracked?: TrackedRun): Promise<FlowOutcome>;
22
+ /** Launch/join: a flow is one tracked run, like a delegate leg (see background-run.ts). */
23
+ runs: BackgroundRuns;
24
+ }
25
+
26
+ /** A finished flow as the tracker sees it. A checkpoint the operator declined is a STOP (the run settles
27
+ * "stopped", never "failed"); a cancelled run is an abort; anything else that is not ok is a failure. */
28
+ function flowResult(name: string, outcome: FlowOutcome): AgentResult {
29
+ const base: AgentResult = {
30
+ agent: `flow ${name}`,
31
+ output: outcome.output || "(flow produced no output)",
32
+ usage: sumUsage(Object.values(outcome.results).map((r) => r.usage)),
33
+ ok: outcome.ok,
34
+ };
35
+ const disposition = flowDisposition(outcome);
36
+ if (disposition === "done") return base;
37
+ if (disposition === "stopped") {
38
+ return { ...base, error: `stopped at checkpoint "${outcome.stoppedAtGate}": not approved`, failureKind: "abort" };
39
+ }
40
+ return { ...base, error: outcome.error ?? "flow failed", failureKind: outcome.cancelled ? "abort" : (outcome.failureKind ?? "agent") };
25
41
  }
26
42
 
27
43
  export function registerFlowTool(pi: ExtensionAPI, d: FlowToolDeps): void {
@@ -36,9 +52,11 @@ export function registerFlowTool(pi: ExtensionAPI, d: FlowToolDeps): void {
36
52
  description: [
37
53
  "Run a named flow — a declarative DAG over strategies (`*.flow.json`): phases each run a",
38
54
  "strategy over a roster, wired by `needs`, fanning out where independent and threading each",
39
- "phase's output into its dependents. Journaled, so an interrupted flow resumes. Reach for it",
55
+ "phase's output into its dependents. Journaled: re-running the identical task resumes finished phases. Reach for it",
40
56
  "when a task has a fixed multi-stage shape (e.g. gather → critique → decide) you want run",
41
- "deterministically, end to end, rather than deciding each step yourself.",
57
+ "deterministically, end to end, rather than deciding each step yourself. Interactive sessions run it in",
58
+ "the BACKGROUND: you get a run id at once and ONE follow-up when it ends (a checkpoint waits for the",
59
+ "operator, not for you), so end your turn and never poll it; headless sessions block and return inline.",
42
60
  ].join(" "),
43
61
  parameters: FlowToolParams,
44
62
  async execute(_id, params, signal, _onUpdate, ctx) {
@@ -61,22 +79,44 @@ export function registerFlowTool(pi: ExtensionAPI, d: FlowToolDeps): void {
61
79
  };
62
80
  }
63
81
  try {
64
- const outcome = await d.runFlowVisible(ctx, parsed.flow, params.task, signal);
82
+ // The same launch `delegate` uses: ONE tracked run. Interactive sessions get a receipt now and
83
+ // the outcome as ONE completion follow-up; a checkpoint is the OPERATOR's modal and never wakes
84
+ // the model. Headless sessions join the run before returning (and reject every checkpoint).
85
+ const inline = isInline(ctx);
86
+ const ran: { outcome?: FlowOutcome } = {}; // the run's full outcome, which the tracker's AgentResult cannot carry
87
+ const id = d.runs.launch({ kind: "flow", label: `flow ${parsed.flow.name}`, task: params.task, inline }, async (run) => {
88
+ const finished = await d.runFlowVisible(ctx, parsed.flow, params.task, run);
89
+ ran.outcome = finished;
90
+ return flowResult(parsed.flow.name, finished);
91
+ });
92
+ if (!inline) {
93
+ const name = compactInlineText(parsed.flow.name, { maxChars: 64 });
94
+ return {
95
+ content: [{ type: "text", text: launchReceipt("flow", id, name, "when it ends; a checkpoint waits for the operator, not for you") }],
96
+ details: { runId: id, flow: name },
97
+ isError: false,
98
+ };
99
+ }
100
+ const { runs, usage } = await d.runs.settle([id], signal);
101
+ const run = runs[0];
102
+ const outcome = ran.outcome;
103
+ if (!outcome) {
104
+ const message = run?.error ?? run?.result?.error ?? run?.status ?? "the run record is gone";
105
+ return { content: [{ type: "text", text: `flow failed: ${message}` }], details: failureDetails({ ok: false, error: message }), isError: true };
106
+ }
65
107
  const details = {
66
108
  ok: outcome.ok,
67
109
  failedPhase: outcome.failedPhase,
68
110
  failureKind: outcome.failureKind,
69
111
  error: outcome.error,
112
+ stoppedAtGate: outcome.stoppedAtGate,
70
113
  };
71
- const usage = Object.values(outcome.results).length > 0
72
- ? sumUsage(Object.values(outcome.results).map((r) => r.usage))
73
- : emptyUsage();
74
- d.childUsage.account(usage);
75
- d.publishPersonaCost();
114
+ // An unapproved checkpoint is the operator's choice: the flow stopped, it did not fail.
115
+ const failed = flowDisposition(outcome) === "failed";
76
116
  return {
77
117
  content: [{ type: "text", text: fenceUntrusted(outcome.output || "(flow produced no output)") }],
78
- details: outcome.ok ? details : failureDetails(details),
79
- isError: !outcome.ok,
118
+ details: failed ? failureDetails(details) : details,
119
+ isError: failed,
80
120
  ...toolUsageField(usage),
81
121
  };
82
122
  } catch (err) {
@@ -87,16 +127,27 @@ export function registerFlowTool(pi: ExtensionAPI, d: FlowToolDeps): void {
87
127
  renderCall(args, theme) {
88
128
  return new Text(`${theme.fg("toolTitle", theme.bold("flow "))}${theme.fg("accent", compactInlineText(args.name ?? "?", { maxChars: 96 }) || "?")}`, 0, 0);
89
129
  },
90
- renderResult(result, { expanded }, theme) {
130
+ renderResult(result, { expanded }, theme, context) {
91
131
  const details = (result.details ?? {}) as {
92
132
  ok?: boolean;
93
133
  failedPhase?: string;
94
134
  failureKind?: FailureKind;
95
135
  error?: string;
136
+ stoppedAtGate?: string;
137
+ runId?: string;
138
+ flow?: string;
96
139
  };
97
140
  const first = result.content[0];
98
141
  const full = sanitizeTerminalText(first?.type === "text" ? first.text : "(no output)");
99
- const failed = details.ok === false;
142
+ const hostError = context?.isError ?? result.isError;
143
+ if (details.runId) {
144
+ // Interactive launch receipt: the outcome arrives later as the completion follow-up.
145
+ const launched = hostError || details.ok === false ? theme.fg("error", theme.bold("flow failed"))
146
+ : `${theme.fg("toolTitle", theme.bold("flow "))}${theme.fg("accent", compactInlineText(`launched ${details.runId} · ${details.flow ?? "?"}`, { maxChars: 120 }))}`;
147
+ return new Text(expanded ? `${launched}\n${theme.fg("toolOutput", full)}` : launched, 0, 0);
148
+ }
149
+ const disposition = flowDisposition({ ok: !hostError && details.ok !== false, stoppedAtGate: details.stoppedAtGate });
150
+ const failed = disposition === "failed";
100
151
  const failureBits = failed
101
152
  ? compactInlineText(
102
153
  [details.failedPhase ? `phase ${details.failedPhase}` : undefined, details.failureKind, details.error]
@@ -105,13 +156,19 @@ export function registerFlowTool(pi: ExtensionAPI, d: FlowToolDeps): void {
105
156
  { maxChars: 160 },
106
157
  )
107
158
  : "";
108
- const title = failed
109
- ? theme.fg("error", theme.bold(`flow failed${failureBits ? ` · ${failureBits}` : ""}`))
110
- : theme.fg("success", theme.bold("flow complete"));
111
- if (expanded) return new Text(`${title}\n${theme.fg("toolOutput", full)}`, 0, 0);
159
+ const title = () => disposition === "stopped"
160
+ ? theme.fg("warning", theme.bold(`flow stopped · checkpoint ${compactInlineText(details.stoppedAtGate ?? "", { maxChars: 64 })}`))
161
+ : failed
162
+ ? theme.fg("error", theme.bold(`flow failed${failureBits ? ` · ${failureBits}` : ""}`))
163
+ : theme.fg("success", theme.bold("flow complete"));
164
+ if (expanded) {
165
+ // Real flow outcomes are fenced even after a phase fails; host/preflight errors are not.
166
+ if (failed && !full.startsWith("Sub-agent output (untrusted data):\n")) return new Text(`${title()}\n${theme.fg("toolOutput", full)}`, 0, 0);
167
+ return createReportBody(full, theme, { header: title }, context?.lastComponent);
168
+ }
112
169
  const preview = compactVisibleText(full, { maxLines: 3, maxLineChars: 100 });
113
170
  const hint = preview.truncated ? `\n${theme.fg("dim", expandDetailHint())}` : "";
114
- return new Text(`${title}\n${theme.fg("toolOutput", preview.text)}${hint}`, 0, 0);
171
+ return new Text(`${title()}\n${theme.fg("toolOutput", preview.text)}${hint}`, 0, 0);
115
172
  },
116
173
  });
117
174
  }
@@ -5,13 +5,14 @@ import { failureDetails } from "../extension/shared.ts";
5
5
  import { Text } from "@earendil-works/pi-tui";
6
6
  import { compactInlineText, sanitizeTerminalText } from "../ui/presentation.ts";
7
7
  import { compactVisibleText } from "../ui/presentation.ts";
8
+ import { createReportBody } from "../ui/report-body.ts";
8
9
  import { coachingDisabledHint, expandDetailHint, reconcileAnsweredAsk } from "../extension/shared.ts";
9
10
  import {
10
- type AsyncRun, AsyncRunTracker, boundCompletionSurface, buildPeekDigest, buildWaitTimeoutNote,
11
- MAX_ASYNC_STATUS_ROWS,
11
+ type AsyncRun, AsyncRunTracker, buildPeekDigest,
12
12
  dedupeRunsById, getFullRunOutput, IdleCoalescingNotifier,
13
- renderCompletion, runDisplayName, runDurationLabel,
13
+ isAsyncRunStalled, runDisplayName, runDurationLabel,
14
14
  } from "../engine/async.ts";
15
+ import type { PollGuard } from "../core/nudge.ts";
15
16
  import { fenceUntrusted } from "../core/fence.ts";
16
17
  import { sanitizeDisplayLabel } from "../core/display-label.ts";
17
18
  import { type IntercomOutcome, type IntercomParams, MAX_INTERCOM_MESSAGE_CHARS, MAX_INTERCOM_REF_CHARS, runIntercom } from "./intercom.ts";
@@ -32,35 +33,33 @@ export interface IntercomToolDeps {
32
33
  SUPERVISOR: string;
33
34
  STALL_FLAG_MS: number;
34
35
  waitingRunIds?: () => ReadonlySet<string>;
36
+ /** Refuses an unchanged repeat of peek/result on a running run. Built only when `config.nudge`. */
37
+ pollGuard?: PollGuard | undefined;
35
38
  missingRunMessage(id: string, display: string | undefined): string;
36
39
  stopAgent(nodeId: string): boolean;
37
40
  steerAgent(nodeId: string, text: string): boolean;
38
41
  steerRegistry: Map<string, unknown>;
39
42
  stopRequested: Set<string>;
40
- drainBusBlock(): string;
43
+ /** The premature-surrender note for a settled report (config-gated), else undefined. */
41
44
  scanForSurrender(text: string): string | undefined;
42
- get disposed(): boolean;
43
45
  childUsage: ChildUsageLedger;
44
46
  publishPersonaCost(): void;
45
47
  }
46
48
 
47
- /** Keep an aborted wait honest: its signal may end the join well before the configured window. */
48
- export function buildIntercomWaitStillNote(ids: readonly string[], timeoutMs: number, interrupted: boolean): string {
49
- if (!interrupted) return buildWaitTimeoutNote(ids, timeoutMs);
50
- const visibleIds = ids.slice(0, MAX_ASYNC_STATUS_ROWS).map((id) => sanitizeDisplayLabel(id, "run"));
51
- const omitted = ids.length - visibleIds.length;
52
- const idSummary = `${visibleIds.join(", ")}${omitted > 0 ? `, … +${omitted} more` : ""}`;
53
- return (
54
- `⏹ wait interrupted before the configured ${timeoutMs}ms window elapsed; still running: ${idSummary}. ` +
55
- "Continue useful supervisor work; completion will notify you automatically. Use intercom wait again when ready."
56
- );
57
- }
58
-
59
49
  /** Fence only child-authored payloads; missing-message diagnostics remain actionable tool prose. */
60
50
  export function fenceIntercomToolOutcome(out: IntercomOutcome, action: string, fence: (text: string) => string): string {
61
51
  return action === "message" && out.details.ok ? fence(out.text) : fenceIntercomOutcome(out, fence);
62
52
  }
63
53
 
54
+ /** `run-4 (alpha · haiku) running, run-3 (scout) done, … (+2 more)` — the one bounded run listing
55
+ * every id-to-call-sign teaching surface shares (a run miss, a bulk stop). `none` when empty. */
56
+ export function describeRuns(runs: readonly AsyncRun[], opts: { status?: boolean; max?: number } = {}): string {
57
+ if (runs.length === 0) return "none";
58
+ const max = opts.max ?? 8;
59
+ const shown = runs.slice(0, max).map((run) => `${run.id} (${runDisplayName(run)})${opts.status ? ` ${run.status}` : ""}`);
60
+ return `${shown.join(", ")}${runs.length > max ? `, … (+${runs.length - max} more)` : ""}`;
61
+ }
62
+
64
63
  /** Renderer-local state only. The persisted result, not this cache, owns display identity. */
65
64
  interface IntercomRenderState {
66
65
  target?: { id: string; label: string };
@@ -77,13 +76,61 @@ export function registerIntercomTool(pi: ExtensionAPI, d: IntercomToolDeps): voi
77
76
  const collision = d.tracker.list().some((other) => other.id !== id && runDisplayName(other) === name);
78
77
  return collision ? `${name} [${sanitizeDisplayLabel(id, "run")}]` : name;
79
78
  }
79
+ /** An id the tracker holds nothing for: teach what exists (and why a stale id dead-ends) instead of
80
+ * letting steer/stop fall through to a false "already finished" or "no live steer handle". */
81
+ function missingRun(action: "result" | "steer" | "stop", to: string, display: string | undefined, snapshot: object) {
82
+ return { content: [{ type: "text" as const, text: d.missingRunMessage(to, display) }], details: failureDetails({ action, ok: false, ...snapshot }), isError: true };
83
+ }
84
+ /** `stop { to: "all" }`: request cancellation of every running run, each with the per-run semantics of a
85
+ * single stop — except that a run already stopping is reported, never force-cleared, in bulk. */
86
+ function stopAllRuns() {
87
+ const snapshot = { runId: "all", target: "all" };
88
+ const requested: AsyncRun[] = [];
89
+ const already: AsyncRun[] = [];
90
+ const unreachable: AsyncRun[] = [];
91
+ for (const run of d.tracker.running()) {
92
+ const nodeId = `async:${run.id}`;
93
+ if (d.stopRequested.has(nodeId)) already.push(run);
94
+ else (d.stopAgent(nodeId) ? requested : unreachable).push(run);
95
+ }
96
+ const noHandle = unreachable.length > 0 ? `No live stop handle: ${describeRuns(unreachable)}.` : "";
97
+ if (requested.length === 0 && already.length === 0) {
98
+ return { content: [{ type: "text" as const, text: noHandle || "intercom stop: no running runs to stop." }], details: failureDetails({ action: "stop", ok: false, ...snapshot }), isError: true };
99
+ }
100
+ if (requested.length > 0) d.pollGuard?.reset();
101
+ const parts = [
102
+ ...(requested.length > 0 ? [`Cancellation requested for ${requested.length} run${requested.length === 1 ? "" : "s"}: ${describeRuns(requested)}. Stopped reports arrive as completion follow-ups; partial output is kept; changes already made are not undone.`] : []),
103
+ ...(already.length > 0 ? [`Already stopping: ${describeRuns(already)}.${requested.length === 0 ? " Repeat stop { to: <run id> } on one that stays running to force-clear it." : ""}`] : []),
104
+ ...(noHandle ? [noHandle] : []),
105
+ ];
106
+ return { content: [{ type: "text" as const, text: parts.join(" ") }], details: { action: "stop", ok: true, ...snapshot }, isError: false };
107
+ }
108
+ /**
109
+ * An unchanged repeat of a status check learns nothing and holds the turn the idle-gated completion
110
+ * wake needs: refuse it, and on the second repeat end the turn. The signature is every shown run's
111
+ * state (status, queued, waiting, stalled), so anything newly informative passes.
112
+ */
113
+ function pollRefusal(action: "peek" | "result", scope: string, runs: readonly AsyncRun[], waiting: ReadonlySet<string> | undefined, snapshot: object) {
114
+ const running = runs.filter((r) => r.status === "running");
115
+ if (!d.pollGuard || running.length === 0) return undefined;
116
+ const now = Date.now();
117
+ const sig = runs.map((r) => [r.id, r.status, r.queued === true, waiting?.has(r.id) === true, isAsyncRunStalled(r, now, d.STALL_FLAG_MS)].join("|")).sort().join(",");
118
+ const verdict = d.pollGuard.check(scope, sig);
119
+ if (verdict === "ok") return undefined;
120
+ const text = `intercom ${action}: no run settled, started, queued, began waiting or stalled since your last ${action} (${running.map((r) => r.id).join(", ")} still running). Checking again blocks your own completion follow-ups and the operator's steering — end your turn; a finished run or batch wakes you. Use steer or stop only to intervene.`;
121
+ return {
122
+ content: [{ type: "text" as const, text }],
123
+ details: failureDetails({ action, ok: false, ...snapshot }),
124
+ isError: true,
125
+ ...(verdict === "terminate" ? { terminate: true } : {}),
126
+ };
127
+ }
80
128
  // ── intercom tool (supervisor side of the comm plane: read/answer children) ───
81
129
  const IntercomToolParams = Type.Object({
82
130
  action: Type.Union(
83
131
  [
84
132
  Type.Literal("peek"),
85
133
  Type.Literal("result"),
86
- Type.Literal("wait"),
87
134
  Type.Literal("steer"),
88
135
  Type.Literal("stop"),
89
136
  Type.Literal("list"),
@@ -94,24 +141,20 @@ export function registerIntercomTool(pi: ExtensionAPI, d: IntercomToolDeps): voi
94
141
  ],
95
142
  {
96
143
  description:
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)",
144
+ "peek = status of async sub-agents · result = retrieve one complete settled result by run id · 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)",
98
145
  },
99
146
  ),
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`)" })),
147
+ to: Type.Optional(Type.String({ maxLength: MAX_INTERCOM_REF_CHARS, description: "result/steer/stop/peek: the async run id (e.g. 'run-1'); stop: \"all\" cancels every running run · send: the child handle (from `list`)" })),
101
148
  messageId: Type.Optional(Type.String({ maxLength: MAX_INTERCOM_REF_CHARS, description: "message: the drained bus message id shown by `inbox`" })),
102
149
  askId: Type.Optional(Type.String({ maxLength: MAX_INTERCOM_REF_CHARS, description: "reply: the message id of the child's pending question" })),
103
150
  message: Type.Optional(Type.String({ maxLength: MAX_INTERCOM_MESSAGE_CHARS, description: "steer/reply/send: the text to deliver" })),
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" })),
106
151
  });
107
152
  pi.registerTool({
108
153
  name: "intercom",
109
154
  label: "Intercom",
110
155
  description: [
111
- "See, steer, message, and JOIN your running sub-agents.",
112
- "`peek` watches what your async sub-agents are doing; `result` retrieves one complete settled",
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.",
156
+ "Steer, stop and inspect your background sub-agents; a finished run or batch wakes you with a completion follow-up.",
157
+ "Never `peek` healthy children; use it only for status you need. `result { to: runId }` retrieves one complete settled payload by run id.",
115
158
  "`steer` sends a soft course-correction to one (by run id); in-process async runs can receive it mid-run,",
116
159
  "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.",
117
160
  "`message` retrieves a drained bus message by its message id; `list`/`inbox`/`reply`/`send` exchange coaching messages (a child reaches you via `contact_supervisor`)",
@@ -124,7 +167,7 @@ export function registerIntercomTool(pi: ExtensionAPI, d: IntercomToolDeps): voi
124
167
  // interpolate it into trusted prose without reducing it to compact identifier metadata.
125
168
  const displayTarget = params.to === undefined ? undefined : sanitizeDisplayLabel(params.to, "run");
126
169
  const targetSnapshot = params.to === undefined ? {} : { runId: params.to, target: displayRunTarget(params.to) };
127
- // peek + wait + steer + stop are supervisor→child controls over the async d.tracker /
170
+ // peek + result + steer + stop are supervisor→child controls over the async tracker /
128
171
  // steer handles — available to EVERY persona (no dependency on the coaching bus).
129
172
  if (params.action === "peek") {
130
173
  // No `to` → running legs PLUS any settled-but-not-yet-delivered ones (the settle→deliver
@@ -132,25 +175,28 @@ export function registerIntercomTool(pi: ExtensionAPI, d: IntercomToolDeps): voi
132
175
  const runs = params.to
133
176
  ? [d.tracker.peek(params.to)].filter((r): r is AsyncRun => !!r)
134
177
  : dedupeRunsById([...d.tracker.running(), ...d.completionNotifier.peekPending()]);
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 };
178
+ const waiting = d.waitingRunIds?.();
179
+ const refusal = pollRefusal("peek", params.to ?? "*", runs, waiting, targetSnapshot);
180
+ if (refusal) return refusal;
181
+ return { content: [{ type: "text", text: buildPeekDigest(runs, { now: Date.now(), stallMs: d.STALL_FLAG_MS, ...(waiting ? { waitingForSupervisor: waiting } : {}) }) }], details: { action: "peek", ok: true, ...targetSnapshot }, isError: false };
136
182
  }
137
183
  if (params.action === "result") {
138
184
  if (!params.to) {
139
185
  return { content: [{ type: "text", text: "intercom result needs { to: <run id> }." }], details: failureDetails({ action: "result", ok: false }), isError: true };
140
186
  }
141
187
  const run = d.tracker.peek(params.to);
142
- if (!run) {
143
- return { content: [{ type: "text", text: d.missingRunMessage(params.to, displayTarget) }], details: failureDetails({ action: "result", ok: false, ...targetSnapshot }), isError: true };
144
- }
188
+ if (!run) return missingRun("result", params.to, displayTarget, targetSnapshot);
145
189
  if (run.status === "running") {
190
+ const refusal = pollRefusal("result", `result:${params.to}`, [run], d.waitingRunIds?.(), targetSnapshot);
191
+ if (refusal) return refusal;
146
192
  return {
147
- content: [{ type: "text", text: `${targetSnapshot.target} is still running. Use intercom peek/wait, or request result after it settles.` }],
193
+ content: [{ type: "text", text: `${targetSnapshot.target} is still running; a finished run or batch wakes you with a completion follow-up. End your turn if nothing else remains.` }],
148
194
  details: failureDetails({ action: "result", ok: false, status: run.status, ...targetSnapshot }),
149
195
  isError: true,
150
196
  };
151
197
  }
152
198
  // Explicit collection owns this delivery: remove a still-buffered passive completion so the
153
- // same result cannot appear again as a follow-up a moment later. Telling the d.tracker too
199
+ // same result cannot appear again as a follow-up a moment later. Telling the tracker too
154
200
  // makes this retained copy the first thing retention evicts — the supervisor has read it,
155
201
  // so it is the cheapest payload in the map to lose.
156
202
  d.completionNotifier.discard((pending) => pending.id === run.id);
@@ -162,93 +208,28 @@ export function registerIntercomTool(pi: ExtensionAPI, d: IntercomToolDeps): voi
162
208
  const cause = run.error ? `\nFailure detail:\n${fenceUntrusted(run.error)}` : "";
163
209
  const displayRun = sanitizeDisplayLabel(run.label ?? run.agent);
164
210
  // The wall time this leg took: an explicit collection is one of the paths a completion reaches
165
- // the supervisor through, so it carries the same reading the passive/join reports do.
211
+ // the supervisor through, so it carries the same reading the passive completion report does.
166
212
  const took = runDurationLabel(run);
213
+ // The follow-up discarded above is what would have carried the premature-surrender note, so
214
+ // a collected DONE report must carry it itself (a failed leg is already a failure report).
215
+ const note = run.status === "done" ? d.scanForSurrender(full) : undefined;
167
216
  const billed = d.childUsage.accountMany([{ key: run.id, usage: run.result?.usage ?? emptyUsage() }]);
168
217
  d.publishPersonaCost();
169
218
  return {
170
- content: [{ type: "text", text: `${run.id} (${displayRun}) · ${run.status}${took ? ` · ${took}` : ""}${cause}\n${body}` }],
219
+ content: [{ type: "text", text: `${run.id} (${displayRun}) · ${run.status}${took ? ` · ${took}` : ""}${cause}\n${body}${note ? `\n\n${note}` : ""}` }],
171
220
  details: { action: "result", ok: true, status: run.status, ...targetSnapshot },
172
221
  isError: false,
173
222
  ...toolUsageField(billed),
174
223
  };
175
224
  }
176
- if (params.action === "wait") {
177
- // No `to` → wait on running legs AND collect settled legs still queued for follow-up
178
- // delivery, so a wait in the settle→deliver gap returns their results (not "nothing").
179
- if (params.to && !d.tracker.peek(params.to)) {
180
- return { content: [{ type: "text", text: d.missingRunMessage(params.to, displayTarget) }], details: failureDetails({ action: "wait", ok: false, ...targetSnapshot }), isError: true };
181
- }
182
- const ids = params.to
183
- ? [params.to]
184
- : dedupeRunsById([...d.tracker.running(), ...d.completionNotifier.peekPending()]).map((r) => r.id);
185
- if (ids.length === 0) {
186
- return { content: [{ type: "text", text: "No async runs to wait for." }], details: { action: "wait", ok: true }, isError: false };
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
- }
213
- // Bounded join: never longer than a child's ask timeout (bus `ask` default 600s),
214
- // so a coaching child blocking on OUR reply can't deadlock us past its own timeout.
215
- // Default matches that ceiling — heavy sub-agents (30+ turns) routinely outlast a
216
- // short window, and a premature "still running" forces a needless re-wait.
217
- const timeoutMs = Math.min(Math.max(params.timeoutMs ?? 600_000, 1_000), 600_000);
218
- const runs = await d.tracker.waitFor(ids, timeoutMs, _signal);
219
- const settled = runs.filter((r) => r.status !== "running");
220
- const still = runs.filter((r) => r.status === "running");
221
- // These results are delivered HERE — drop them from the pending follow-up
222
- // notifier so they aren't reported a second time. Render through the SAME
223
- // renderCompletion the passive path uses, so a leg that came back BLOCKED still
224
- // carries the premature-surrender note when it is collected via `wait`.
225
- const settledIds = new Set(settled.map((r) => r.id));
226
- d.completionNotifier.discard((run) => settledIds.has(run.id));
227
- for (const id of settledIds) d.tracker.markCollected(id);
228
- const report = settled.length > 0 ? renderCompletion(settled, fenceUntrusted, (t) => d.scanForSurrender(t)) : "";
229
- const stillNote = still.length > 0
230
- ? buildIntercomWaitStillNote(still.map((r) => r.id), timeoutMs, _signal?.aborted === true)
231
- : "";
232
- const joined = [report, stillNote].filter(Boolean).join("\n\n") || "Nothing to report (unknown run ids?).";
233
- const text = boundCompletionSurface(joined);
234
- const billed = d.childUsage.accountMany(
235
- settled.map((r) => ({ key: r.id, usage: r.result?.usage ?? emptyUsage() })),
236
- );
237
- d.publishPersonaCost();
238
- return {
239
- content: [{ type: "text", text }],
240
- details: { action: "wait", ok: true, settled: [...settledIds], running: still.map((r) => r.id), ...targetSnapshot },
241
- isError: false,
242
- ...toolUsageField(billed),
243
- };
244
- }
245
225
  if (params.action === "steer") {
246
226
  if (!params.to || params.message === undefined) {
247
227
  return { content: [{ type: "text", text: "intercom steer needs { to: <run id>, message }." }], details: failureDetails({ action: "steer", ok: false }), isError: true };
248
228
  }
249
229
  const nodeId = `async:${params.to}`;
250
230
  const knownRun = d.tracker.peek(params.to);
251
- if (knownRun && knownRun.status !== "running") {
231
+ if (!knownRun) return missingRun("steer", params.to, displayTarget, targetSnapshot);
232
+ if (knownRun.status !== "running") {
252
233
  return {
253
234
  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
235
  details: failureDetails({ action: "steer", ok: false, status: knownRun.status, ...targetSnapshot }),
@@ -262,26 +243,31 @@ export function registerIntercomTool(pi: ExtensionAPI, d: IntercomToolDeps): voi
262
243
  isError: true,
263
244
  };
264
245
  }
265
- // Routed through the guarded d.steerAgent so a just-finished/d.disposed handle can't throw.
246
+ // Routed through the guarded steerAgent so a just-finished/disposed handle can't throw.
266
247
  const steered = d.steerAgent(nodeId, params.message);
248
+ // An intervention changes the child's state without moving any flag the guard compares.
249
+ if (steered) d.pollGuard?.reset();
267
250
  return steered
268
251
  ? { content: [{ type: "text", text: `Steering queued for ${targetSnapshot.target} (${displayTarget}).` }], details: { action: "steer", ok: true, ...targetSnapshot, message: params.message }, isError: false }
269
252
  : { 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 };
270
253
  }
271
254
  if (params.action === "stop") {
255
+ if (params.to === "all") return stopAllRuns();
272
256
  if (!params.to) {
273
257
  return { content: [{ type: "text", text: "intercom stop needs { to: <run id> }." }], details: failureDetails({ action: "stop", ok: false }), isError: true };
274
258
  }
259
+ if (!d.tracker.peek(params.to)) return missingRun("stop", params.to, displayTarget, targetSnapshot);
275
260
  // Abort the real run signal. The child backend can kill its process tree; the
276
261
  // in-process backend requests host cancellation and settles its lifecycle separately.
277
262
  const nodeId = `async:${params.to}`;
278
263
  const repeated = d.stopRequested.has(nodeId);
279
264
  const stopped = d.stopAgent(nodeId);
265
+ if (stopped) d.pollGuard?.reset();
280
266
  if (stopped && !repeated) {
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 };
267
+ return { content: [{ type: "text", text: `Cancellation requested for ${targetSnapshot.target}; its stopped report arrives with the completion follow-up (its batch's, if it has one). Changes already made are not undone.` }], details: { action: "stop", ok: true, ...targetSnapshot }, isError: false };
282
268
  }
283
269
  // A repeated stop has just invoked the REAL cancel handle again. Only now force-clear
284
- // d.tracker state if engine settlement is still lagging; the handle remains registered
270
+ // tracker state if engine settlement is still lagging; the handle remains registered
285
271
  // until onComplete so cancellation can never be replaced by UI-only bookkeeping.
286
272
  if (stopped && repeated && d.tracker.forceSettle(params.to, "force-stopped by supervisor after repeated engine cancellation")) {
287
273
  return {
@@ -304,7 +290,7 @@ export function registerIntercomTool(pi: ExtensionAPI, d: IntercomToolDeps): voi
304
290
  if (params.action === "reply" && out.details.ok && params.askId) {
305
291
  reconcileAnsweredAsk(params.askId, d.intercomNotifier, d.bus, d.SUPERVISOR);
306
292
  }
307
- // Child-authored inbox bodies are untrusted, exactly like the d.drainBusBlock/peek copies.
293
+ // Child-authored inbox bodies are untrusted, exactly like the automatic drain and peek copies.
308
294
  // A retrieved body is child-authored even though it is no longer an inbox batch; keep the
309
295
  // same trust fence around it as inbox and automatic drain surfaces.
310
296
  let text = fenceIntercomToolOutcome(out, params.action, fenceUntrusted);
@@ -320,15 +306,12 @@ export function registerIntercomTool(pi: ExtensionAPI, d: IntercomToolDeps): voi
320
306
  const runTarget = typeof args.to === "string"
321
307
  ? state?.target?.id === args.to ? state.target.label : displayRunTarget(args.to)
322
308
  : undefined;
323
- if (action === "wait" || action === "peek") target = runTarget ?? "all";
309
+ if (action === "peek") target = runTarget ?? "all";
324
310
  else if (["result", "steer", "stop"].includes(action)) target = runTarget ?? "?";
325
311
  else if (action === "send") target = compactInlineText(args.to ?? "?", { maxChars: 80 }) || "?";
326
312
  else if (action === "message") target = compactInlineText(args.messageId ?? "?", { maxChars: 80 }) || "?";
327
313
  else if (action === "reply") target = compactInlineText(args.askId ?? "?", { maxChars: 80 }) || "?";
328
- const timeout = action === "wait" && Number.isFinite(args.timeoutMs) && args.timeoutMs !== undefined
329
- ? ` · ${Math.max(0, Math.floor(args.timeoutMs))}ms`
330
- : "";
331
- const formatCall = (label: string) => `${theme.fg("toolTitle", theme.bold("intercom "))}${theme.fg("accent", action)}${label ? theme.fg("dim", ` ${compactInlineText(label, { maxChars: 80 })}${timeout}`) : ""}`;
314
+ const formatCall = (label: string) => `${theme.fg("toolTitle", theme.bold("intercom "))}${theme.fg("accent", action)}${label ? theme.fg("dim", ` ${compactInlineText(label, { maxChars: 80 })}`) : ""}`;
332
315
  const component = new Text(formatCall(target), 0, 0);
333
316
  if (state) { state.callText = component; state.formatCall = formatCall; }
334
317
  return component;
@@ -339,7 +322,7 @@ export function registerIntercomTool(pi: ExtensionAPI, d: IntercomToolDeps): voi
339
322
  const action = details.action ?? context?.args?.action;
340
323
  const savedId = details.runId ?? context?.args?.to;
341
324
  const legacyCompleted = !details.target && context !== undefined && context.isPartial !== true
342
- && ["steer", "stop", "result", "peek", "wait"].includes(action ?? "");
325
+ && ["steer", "stop", "result", "peek"].includes(action ?? "");
343
326
  if (state && typeof savedId === "string" && (details.target || legacyCompleted)) {
344
327
  // Old receipts have no display snapshot. A new session may reuse their run ID;
345
328
  // the current tracker cannot supply a historical alias for that unrelated execution.
@@ -350,9 +333,9 @@ export function registerIntercomTool(pi: ExtensionAPI, d: IntercomToolDeps): voi
350
333
  }
351
334
  const first = result.content[0];
352
335
  const full = sanitizeTerminalText(first?.type === "text" ? first.text : "(no output)");
353
- const failed = details.ok === false;
336
+ const failed = (context?.isError ?? result.isError) || details.ok === false;
354
337
  const prefix = failed ? `${theme.fg("error", theme.bold("failed"))}\n` : "";
355
- if (details.action === "steer" && details.ok === true) {
338
+ if (!failed && details.action === "steer" && details.ok === true) {
356
339
  const target = compactInlineText(details.target ?? state?.target?.label ?? (legacyCompleted && typeof savedId === "string" ? sanitizeDisplayLabel(savedId, "run") : details.runId ? displayRunTarget(details.runId) : ""), { maxChars: 80 });
357
340
  const message = typeof details.message === "string" ? details.message : context?.args?.message;
358
341
  // Old persisted receipts may predate message snapshots. Keep their original content
@@ -370,7 +353,14 @@ export function registerIntercomTool(pi: ExtensionAPI, d: IntercomToolDeps): voi
370
353
  const showTitle = !state?.callText || !preview;
371
354
  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
355
  }
373
- if (expanded) return new Text(`${prefix}${theme.fg("toolOutput", full)}`, 0, 0);
356
+ if (expanded) {
357
+ // Explicit report/message actions only; missing/running diagnostics and control
358
+ // receipts remain literal. Older history with no action is not guessed from prose.
359
+ if (!failed && ["result", "message", "inbox"].includes(action ?? "")) {
360
+ return createReportBody(full, theme, {}, context?.lastComponent);
361
+ }
362
+ return new Text(`${prefix}${theme.fg("toolOutput", full)}`, 0, 0);
363
+ }
374
364
  const preview = compactVisibleText(full, { maxLines: 4, maxLineChars: 100 });
375
365
  const hint = preview.truncated ? `\n${theme.fg("dim", expandDetailHint())}` : "";
376
366
  return new Text(`${prefix}${theme.fg("toolOutput", preview.text)}${hint}`, 0, 0);
@@ -120,7 +120,7 @@ export function runIntercom(params: IntercomParams, bus: InProcessBus, self: str
120
120
  }
121
121
  case "message": {
122
122
  if (!params.messageId) {
123
- return { text: "intercom message needs { messageId: <message id> }.", details: { action: "message", ok: false } };
123
+ return { text: "intercom message needs { messageId: <message id> }. To message a running child use steer { to: <run id>, message }.", details: { action: "message", ok: false } };
124
124
  }
125
125
  if (params.messageId.length > MAX_INTERCOM_REF_CHARS) {
126
126
  return { text: `intercom messageId exceeds the ${MAX_INTERCOM_REF_CHARS}-character limit.`, details: { action: "message", ok: false } };