@aeondave/pi-persona 1.15.2

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 (165) hide show
  1. package/AGENTS.md +249 -0
  2. package/CHANGELOG.md +260 -0
  3. package/LICENSE +21 -0
  4. package/README.md +172 -0
  5. package/agents/balthasar.md +32 -0
  6. package/agents/casper.md +33 -0
  7. package/agents/evidence-verifier.md +23 -0
  8. package/agents/melchior.md +32 -0
  9. package/agents/operator.md +63 -0
  10. package/agents/research.md +58 -0
  11. package/agents/reviewer.md +49 -0
  12. package/agents/scout.md +23 -0
  13. package/agents/verifier.md +21 -0
  14. package/assets/banner.png +0 -0
  15. package/assets/demo1.gif +0 -0
  16. package/assets/demo1.mp4 +0 -0
  17. package/assets/workflow.png +0 -0
  18. package/contracts/finding.contract.json +9 -0
  19. package/docs/ARCHITECTURE.md +727 -0
  20. package/docs/EXPERIENCE.md +96 -0
  21. package/docs/MONITORS.md +84 -0
  22. package/docs/README.md +53 -0
  23. package/docs/REFERENCE.md +408 -0
  24. package/docs/SPINE.md +152 -0
  25. package/docs/STRATEGIES.md +215 -0
  26. package/docs/TELEMETRY.md +9 -0
  27. package/flows/deep-review.flow.json +8 -0
  28. package/flows/gated-build.flow.json +9 -0
  29. package/package.json +106 -0
  30. package/personas/audit.md +29 -0
  31. package/personas/dev.md +52 -0
  32. package/personas/elite.md +256 -0
  33. package/personas/judge.md +32 -0
  34. package/personas/magi.md +41 -0
  35. package/personas/planner.md +37 -0
  36. package/personas/researcher.md +37 -0
  37. package/personas/swarm.md +38 -0
  38. package/personas/verify.md +31 -0
  39. package/presets/magi-rounds.preset.json +5 -0
  40. package/prompts/spine.md +11 -0
  41. package/prompts/spine.worker.md +9 -0
  42. package/src/agents/agent.ts +78 -0
  43. package/src/bridge.ts +209 -0
  44. package/src/bus/broker/client.ts +379 -0
  45. package/src/bus/broker/framing.ts +43 -0
  46. package/src/bus/broker/host.ts +407 -0
  47. package/src/bus/broker/messages.ts +87 -0
  48. package/src/bus/broker/paths.ts +31 -0
  49. package/src/bus/contact.ts +145 -0
  50. package/src/bus/inproc.ts +279 -0
  51. package/src/bus/peers.ts +114 -0
  52. package/src/core/brief.ts +356 -0
  53. package/src/core/capabilities.ts +114 -0
  54. package/src/core/config.ts +228 -0
  55. package/src/core/contract.ts +391 -0
  56. package/src/core/data-root.ts +260 -0
  57. package/src/core/discovery.ts +33 -0
  58. package/src/core/display-label.ts +47 -0
  59. package/src/core/fence.ts +151 -0
  60. package/src/core/format.ts +13 -0
  61. package/src/core/frontmatter.ts +202 -0
  62. package/src/core/models.ts +192 -0
  63. package/src/core/monitor.ts +415 -0
  64. package/src/core/naming.ts +42 -0
  65. package/src/core/nudge.ts +238 -0
  66. package/src/core/ownership.ts +102 -0
  67. package/src/core/permissions.ts +56 -0
  68. package/src/core/pi-compat.ts +50 -0
  69. package/src/core/seed.ts +770 -0
  70. package/src/core/session-identity.ts +89 -0
  71. package/src/core/time.ts +202 -0
  72. package/src/core/timer.ts +221 -0
  73. package/src/core/types.ts +28 -0
  74. package/src/engine/adapter.ts +257 -0
  75. package/src/engine/async.ts +898 -0
  76. package/src/engine/change-report.ts +143 -0
  77. package/src/engine/child.ts +532 -0
  78. package/src/engine/errors.ts +30 -0
  79. package/src/engine/fallback.ts +57 -0
  80. package/src/engine/handles.ts +21 -0
  81. package/src/engine/inproc.ts +857 -0
  82. package/src/engine/signals.ts +11 -0
  83. package/src/engine/spec-preflight.ts +33 -0
  84. package/src/engine/stream.ts +369 -0
  85. package/src/engine/worktree.ts +144 -0
  86. package/src/exocom/activation.ts +31 -0
  87. package/src/exocom/codes.ts +261 -0
  88. package/src/exocom/envelope.ts +180 -0
  89. package/src/exocom/gate.ts +6 -0
  90. package/src/exocom/guards.ts +45 -0
  91. package/src/exocom/inbound.ts +141 -0
  92. package/src/exocom/install.ts +970 -0
  93. package/src/exocom/ledger.ts +603 -0
  94. package/src/exocom/limits.ts +20 -0
  95. package/src/exocom/paths.ts +35 -0
  96. package/src/exocom/plane.ts +1116 -0
  97. package/src/exocom/registry.ts +307 -0
  98. package/src/exocom/scope.ts +42 -0
  99. package/src/exocom/status.ts +86 -0
  100. package/src/exocom/untrusted.ts +40 -0
  101. package/src/exocom/wait.ts +23 -0
  102. package/src/exocom/write-guard.ts +89 -0
  103. package/src/extension/broker-host.ts +181 -0
  104. package/src/extension/clock.ts +14 -0
  105. package/src/extension/engine.ts +321 -0
  106. package/src/extension/hooks.ts +796 -0
  107. package/src/extension/identity.ts +244 -0
  108. package/src/extension/shared.ts +175 -0
  109. package/src/extension.ts +2221 -0
  110. package/src/loader.ts +218 -0
  111. package/src/monitor/process.ts +116 -0
  112. package/src/monitor/session.ts +61 -0
  113. package/src/orchestration/flow-journal.ts +86 -0
  114. package/src/orchestration/flow-run.ts +301 -0
  115. package/src/orchestration/flow.ts +125 -0
  116. package/src/orchestration/judge.ts +78 -0
  117. package/src/orchestration/model-retry.ts +85 -0
  118. package/src/orchestration/parallel.ts +107 -0
  119. package/src/orchestration/params.ts +23 -0
  120. package/src/orchestration/reducers.ts +123 -0
  121. package/src/orchestration/render.ts +127 -0
  122. package/src/orchestration/roster.ts +133 -0
  123. package/src/orchestration/sdk.ts +326 -0
  124. package/src/orchestration/strategies/compete.ts +182 -0
  125. package/src/orchestration/strategies/council-rounds.ts +124 -0
  126. package/src/orchestration/strategies/critic-loop.ts +168 -0
  127. package/src/orchestration/strategies/debate.ts +126 -0
  128. package/src/orchestration/strategies/fanout.ts +20 -0
  129. package/src/orchestration/strategies/judge.ts +117 -0
  130. package/src/orchestration/strategies/magi.ts +239 -0
  131. package/src/orchestration/strategies/map.ts +327 -0
  132. package/src/orchestration/strategies/pair.ts +72 -0
  133. package/src/orchestration/strategies/pipeline.ts +91 -0
  134. package/src/orchestration/strategies/synthesize.ts +99 -0
  135. package/src/orchestration/strategy.ts +49 -0
  136. package/src/orchestration/types.ts +31 -0
  137. package/src/orchestration/voting.ts +126 -0
  138. package/src/persona/config-store.ts +215 -0
  139. package/src/persona/controller.ts +173 -0
  140. package/src/persona/gating.ts +82 -0
  141. package/src/persona/orchestrate.ts +83 -0
  142. package/src/persona/persona.ts +294 -0
  143. package/src/persona/spine.ts +221 -0
  144. package/src/persona/state.ts +28 -0
  145. package/src/telemetry/contract.ts +244 -0
  146. package/src/telemetry/paths.ts +14 -0
  147. package/src/telemetry/producer.ts +390 -0
  148. package/src/tools/council.ts +200 -0
  149. package/src/tools/delegate-tool.ts +637 -0
  150. package/src/tools/delegate.ts +661 -0
  151. package/src/tools/exocom-work.ts +328 -0
  152. package/src/tools/exocom.ts +476 -0
  153. package/src/tools/flow.ts +117 -0
  154. package/src/tools/intercom-tool.ts +292 -0
  155. package/src/tools/intercom.ts +181 -0
  156. package/src/tools/models.ts +39 -0
  157. package/src/tools/monitor.ts +66 -0
  158. package/src/tools/timer.ts +105 -0
  159. package/src/ui/agent-overlay.ts +526 -0
  160. package/src/ui/agent-tree.ts +307 -0
  161. package/src/ui/live-clock.ts +42 -0
  162. package/src/ui/model-picker.ts +160 -0
  163. package/src/ui/presentation.ts +170 -0
  164. package/src/ui/usage.ts +133 -0
  165. package/teams.yaml +27 -0
@@ -0,0 +1,292 @@
1
+ /** `intercom` tool registration. */
2
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
3
+ import { Type } from "typebox";
4
+ import { failureDetails } from "../extension/shared.ts";
5
+ import { Text } from "@earendil-works/pi-tui";
6
+ import { compactInlineText, sanitizeTerminalText } from "../ui/presentation.ts";
7
+ import { compactVisibleText } from "../ui/presentation.ts";
8
+ import { coachingDisabledHint, expandDetailHint, reconcileAnsweredAsk } from "../extension/shared.ts";
9
+ import {
10
+ type AsyncRun, AsyncRunTracker, boundCompletionSurface, buildPeekDigest, buildWaitTimeoutNote,
11
+ MAX_ASYNC_STATUS_ROWS,
12
+ dedupeRunsById, getFullRunOutput, IdleCoalescingNotifier,
13
+ renderCompletion, runDurationLabel,
14
+ } from "../engine/async.ts";
15
+ import { fenceUntrusted } from "../core/fence.ts";
16
+ import { sanitizeDisplayLabel } from "../core/display-label.ts";
17
+ import { type IntercomOutcome, type IntercomParams, MAX_INTERCOM_MESSAGE_CHARS, MAX_INTERCOM_REF_CHARS, runIntercom } from "./intercom.ts";
18
+ import { fenceIntercomOutcome, type PendingAsk } from "../extension/shared.ts";
19
+ import type { InProcessBus } from "../bus/inproc.ts";
20
+ import type { PersonaController } from "../persona/controller.ts";
21
+ import { emptyUsage } from "../engine/stream.ts";
22
+ import { toolUsageField, type ChildUsageLedger } from "../ui/usage.ts";
23
+
24
+ export interface IntercomToolDeps {
25
+ get lastCtx(): ExtensionContext | undefined;
26
+ set lastCtx(value: ExtensionContext | undefined);
27
+ tracker: AsyncRunTracker;
28
+ completionNotifier: IdleCoalescingNotifier<AsyncRun>;
29
+ intercomNotifier: IdleCoalescingNotifier<PendingAsk>;
30
+ controller: PersonaController;
31
+ bus: InProcessBus;
32
+ SUPERVISOR: string;
33
+ STALL_FLAG_MS: number;
34
+ missingRunMessage(id: string, display: string | undefined): string;
35
+ stopAgent(nodeId: string): boolean;
36
+ steerAgent(nodeId: string, text: string): boolean;
37
+ steerRegistry: Map<string, unknown>;
38
+ stopRequested: Set<string>;
39
+ drainBusBlock(): string;
40
+ scanForSurrender(text: string): string | undefined;
41
+ get disposed(): boolean;
42
+ childUsage: ChildUsageLedger;
43
+ publishPersonaCost(): void;
44
+ }
45
+
46
+ /** Keep an aborted wait honest: its signal may end the join well before the configured window. */
47
+ export function buildIntercomWaitStillNote(ids: readonly string[], timeoutMs: number, interrupted: boolean): string {
48
+ if (!interrupted) return buildWaitTimeoutNote(ids, timeoutMs);
49
+ const visibleIds = ids.slice(0, MAX_ASYNC_STATUS_ROWS).map((id) => sanitizeDisplayLabel(id, "run"));
50
+ const omitted = ids.length - visibleIds.length;
51
+ const idSummary = `${visibleIds.join(", ")}${omitted > 0 ? `, … +${omitted} more` : ""}`;
52
+ return (
53
+ `⏹ wait interrupted before the configured ${timeoutMs}ms window elapsed; still running: ${idSummary}. ` +
54
+ "Continue useful supervisor work; completion will notify you automatically. Use intercom wait again when ready."
55
+ );
56
+ }
57
+
58
+ /** Fence only child-authored payloads; missing-message diagnostics remain actionable tool prose. */
59
+ export function fenceIntercomToolOutcome(out: IntercomOutcome, action: string, fence: (text: string) => string): string {
60
+ return action === "message" && out.details.ok ? fence(out.text) : fenceIntercomOutcome(out, fence);
61
+ }
62
+
63
+ export function registerIntercomTool(pi: ExtensionAPI, d: IntercomToolDeps): void {
64
+ // ── intercom tool (supervisor side of the comm plane: read/answer children) ───
65
+ const IntercomToolParams = Type.Object({
66
+ action: Type.Union(
67
+ [
68
+ Type.Literal("peek"),
69
+ Type.Literal("result"),
70
+ Type.Literal("wait"),
71
+ Type.Literal("steer"),
72
+ Type.Literal("stop"),
73
+ Type.Literal("list"),
74
+ Type.Literal("inbox"),
75
+ Type.Literal("message"),
76
+ Type.Literal("reply"),
77
+ Type.Literal("send"),
78
+ ],
79
+ {
80
+ 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)",
82
+ },
83
+ ),
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`)" })),
85
+ messageId: Type.Optional(Type.String({ maxLength: MAX_INTERCOM_REF_CHARS, description: "message: the drained bus message id shown by `inbox`" })),
86
+ askId: Type.Optional(Type.String({ maxLength: MAX_INTERCOM_REF_CHARS, description: "reply: the message id of the child's pending question" })),
87
+ 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" })),
89
+ });
90
+ pi.registerTool({
91
+ name: "intercom",
92
+ label: "Intercom",
93
+ description: [
94
+ "See, steer, message, and JOIN your running sub-agents.",
95
+ "`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;",
97
+ "`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.",
99
+ "`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
+ "and need a `coaching: on` persona.",
101
+ ].join(" "),
102
+ parameters: IntercomToolParams,
103
+ async execute(_id, params, _signal, _onUpdate, ctx) {
104
+ d.lastCtx = ctx;
105
+ // `to` comes from model-authored tool arguments. Use the exact value for routing, but never
106
+ // interpolate it into trusted prose without reducing it to compact identifier metadata.
107
+ const displayTarget = params.to === undefined ? undefined : sanitizeDisplayLabel(params.to, "run");
108
+ // peek + wait + steer + stop are supervisor→child controls over the async d.tracker /
109
+ // steer handles — available to EVERY persona (no dependency on the coaching bus).
110
+ if (params.action === "peek") {
111
+ // No `to` → running legs PLUS any settled-but-not-yet-delivered ones (the settle→deliver
112
+ // gap), so a peek right after a leg finishes shows its result instead of "No async runs".
113
+ const runs = params.to
114
+ ? [d.tracker.peek(params.to)].filter((r): r is AsyncRun => !!r)
115
+ : 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 };
117
+ }
118
+ if (params.action === "result") {
119
+ if (!params.to) {
120
+ return { content: [{ type: "text", text: "intercom result needs { to: <run id> }." }], details: failureDetails({ action: "result", ok: false }), isError: true };
121
+ }
122
+ const run = d.tracker.peek(params.to);
123
+ if (!run) {
124
+ return { content: [{ type: "text", text: d.missingRunMessage(params.to, displayTarget) }], details: failureDetails({ action: "result", ok: false }), isError: true };
125
+ }
126
+ if (run.status === "running") {
127
+ 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 }),
130
+ isError: true,
131
+ };
132
+ }
133
+ // Explicit collection owns this delivery: remove a still-buffered passive completion so the
134
+ // same result cannot appear again as a follow-up a moment later. Telling the d.tracker too
135
+ // makes this retained copy the first thing retention evicts — the supervisor has read it,
136
+ // so it is the cheapest payload in the map to lose.
137
+ d.completionNotifier.discard((pending) => pending.id === run.id);
138
+ d.tracker.markCollected(run.id);
139
+ const full = getFullRunOutput(run);
140
+ const body = full === "(no output)" ? full : fenceUntrusted(full);
141
+ // The cause is engine/child-authored text too. Keep the run id/status as trusted compact
142
+ // metadata, but put the diagnostic inside the same untrusted fence as the payload.
143
+ const cause = run.error ? `\nFailure detail:\n${fenceUntrusted(run.error)}` : "";
144
+ const displayRun = sanitizeDisplayLabel(run.label ?? run.agent);
145
+ // The wall time this leg took: an explicit collection is one of the paths a completion reaches
146
+ // the supervisor through, so it carries the same reading the passive/join reports do.
147
+ const took = runDurationLabel(run);
148
+ const billed = d.childUsage.accountMany([{ key: run.id, usage: run.result?.usage ?? emptyUsage() }]);
149
+ d.publishPersonaCost();
150
+ return {
151
+ 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 },
153
+ isError: false,
154
+ ...toolUsageField(billed),
155
+ };
156
+ }
157
+ if (params.action === "wait") {
158
+ // No `to` → wait on running legs AND collect settled legs still queued for follow-up
159
+ // delivery, so a wait in the settle→deliver gap returns their results (not "nothing").
160
+ 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 };
162
+ }
163
+ const ids = params.to
164
+ ? [params.to]
165
+ : dedupeRunsById([...d.tracker.running(), ...d.completionNotifier.peekPending()]).map((r) => r.id);
166
+ if (ids.length === 0) {
167
+ return { content: [{ type: "text", text: "No async runs to wait for." }], details: { action: "wait", ok: true }, isError: false };
168
+ }
169
+ // Bounded join: never longer than a child's ask timeout (bus `ask` default 600s),
170
+ // so a coaching child blocking on OUR reply can't deadlock us past its own timeout.
171
+ // Default matches that ceiling — heavy sub-agents (30+ turns) routinely outlast a
172
+ // short window, and a premature "still running" forces a needless re-wait.
173
+ const timeoutMs = Math.min(Math.max(params.timeoutMs ?? 600_000, 1_000), 600_000);
174
+ const runs = await d.tracker.waitFor(ids, timeoutMs, _signal);
175
+ const settled = runs.filter((r) => r.status !== "running");
176
+ const still = runs.filter((r) => r.status === "running");
177
+ // These results are delivered HERE — drop them from the pending follow-up
178
+ // notifier so they aren't reported a second time. Render through the SAME
179
+ // renderCompletion the passive path uses, so a leg that came back BLOCKED still
180
+ // carries the premature-surrender note when it is collected via `wait`.
181
+ const settledIds = new Set(settled.map((r) => r.id));
182
+ d.completionNotifier.discard((run) => settledIds.has(run.id));
183
+ for (const id of settledIds) d.tracker.markCollected(id);
184
+ const report = settled.length > 0 ? renderCompletion(settled, fenceUntrusted, (t) => d.scanForSurrender(t)) : "";
185
+ const stillNote = still.length > 0
186
+ ? buildIntercomWaitStillNote(still.map((r) => r.id), timeoutMs, _signal?.aborted === true)
187
+ : "";
188
+ const joined = [report, stillNote].filter(Boolean).join("\n\n") || "Nothing to report (unknown run ids?).";
189
+ const text = boundCompletionSurface(joined);
190
+ const billed = d.childUsage.accountMany(
191
+ settled.map((r) => ({ key: r.id, usage: r.result?.usage ?? emptyUsage() })),
192
+ );
193
+ d.publishPersonaCost();
194
+ return {
195
+ content: [{ type: "text", text }],
196
+ details: { action: "wait", ok: true, settled: [...settledIds], running: still.map((r) => r.id) },
197
+ isError: false,
198
+ ...toolUsageField(billed),
199
+ };
200
+ }
201
+ if (params.action === "steer") {
202
+ if (!params.to || params.message === undefined) {
203
+ return { content: [{ type: "text", text: "intercom steer needs { to: <run id>, message }." }], details: failureDetails({ action: "steer", ok: false }), isError: true };
204
+ }
205
+ const nodeId = `async:${params.to}`;
206
+ if (!d.steerRegistry.has(nodeId)) {
207
+ 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 }),
210
+ isError: true,
211
+ };
212
+ }
213
+ // Routed through the guarded d.steerAgent so a just-finished/d.disposed handle can't throw.
214
+ const steered = d.steerAgent(nodeId, params.message);
215
+ 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 };
218
+ }
219
+ if (params.action === "stop") {
220
+ if (!params.to) {
221
+ return { content: [{ type: "text", text: "intercom stop needs { to: <run id> }." }], details: failureDetails({ action: "stop", ok: false }), isError: true };
222
+ }
223
+ // Abort the real run signal. The child backend can kill its process tree; the
224
+ // in-process backend requests host cancellation and settles its lifecycle separately.
225
+ const nodeId = `async:${params.to}`;
226
+ const repeated = d.stopRequested.has(nodeId);
227
+ const stopped = d.stopAgent(nodeId);
228
+ 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 };
230
+ }
231
+ // A repeated stop has just invoked the REAL cancel handle again. Only now force-clear
232
+ // d.tracker state if engine settlement is still lagging; the handle remains registered
233
+ // until onComplete so cancellation can never be replaced by UI-only bookkeeping.
234
+ if (stopped && repeated && d.tracker.forceSettle(params.to, "force-stopped by supervisor after repeated engine cancellation")) {
235
+ 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 },
238
+ isError: false,
239
+ };
240
+ }
241
+ return {
242
+ content: [{ type: "text", text: `Cannot stop "${displayTarget}" — no such running run (it already finished).` }],
243
+ details: failureDetails({ action: "stop", ok: false }),
244
+ isError: true,
245
+ };
246
+ }
247
+
248
+ // The message bus (coaching): list / inbox / message / reply / send.
249
+ const out = runIntercom(params as IntercomParams, d.bus, d.SUPERVISOR);
250
+ // An answered ask is settled on every surface it reached — never woken again, never
251
+ // re-listed (the ask envelope is NOT drained by the peek path, which skips expectsReply).
252
+ if (params.action === "reply" && out.details.ok && params.askId) {
253
+ reconcileAnsweredAsk(params.askId, d.intercomNotifier, d.bus, d.SUPERVISOR);
254
+ }
255
+ // Child-authored inbox bodies are untrusted, exactly like the d.drainBusBlock/peek copies.
256
+ // A retrieved body is child-authored even though it is no longer an inbox batch; keep the
257
+ // same trust fence around it as inbox and automatic drain surfaces.
258
+ let text = fenceIntercomToolOutcome(out, params.action, fenceUntrusted);
259
+ if ((params.action === "list" || params.action === "inbox") && !d.controller.activePersona?.coaching) {
260
+ text += `\n\n${coachingDisabledHint(d.controller.activePersona?.name)}`;
261
+ }
262
+ return { content: [{ type: "text", text }], details: out.details.ok ? out.details : failureDetails(out.details), isError: !out.details.ok };
263
+ },
264
+ renderCall(args, theme) {
265
+ const action = compactInlineText(args.action ?? "?", { maxChars: 24 }) || "?";
266
+ 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 }) || "?";
269
+ else if (action === "message") target = compactInlineText(args.messageId ?? "?", { maxChars: 80 }) || "?";
270
+ else if (action === "reply") target = compactInlineText(args.askId ?? "?", { maxChars: 80 }) || "?";
271
+ const timeout = action === "wait" && Number.isFinite(args.timeoutMs) && args.timeoutMs !== undefined
272
+ ? ` · ${Math.max(0, Math.floor(args.timeoutMs))}ms`
273
+ : "";
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
+ );
279
+ },
280
+ renderResult(result, { expanded }, theme) {
281
+ const details = (result.details ?? {}) as { action?: string; ok?: boolean };
282
+ const first = result.content[0];
283
+ const full = sanitizeTerminalText(first?.type === "text" ? first.text : "(no output)");
284
+ const failed = details.ok === false;
285
+ const prefix = failed ? `${theme.fg("error", theme.bold("failed"))}\n` : "";
286
+ if (expanded) return new Text(`${prefix}${theme.fg("toolOutput", full)}`, 0, 0);
287
+ const preview = compactVisibleText(full, { maxLines: 4, maxLineChars: 100 });
288
+ const hint = preview.truncated ? `\n${theme.fg("dim", expandDetailHint())}` : "";
289
+ return new Text(`${prefix}${theme.fg("toolOutput", preview.text)}${hint}`, 0, 0);
290
+ },
291
+ });
292
+ }
@@ -0,0 +1,181 @@
1
+ /**
2
+ * `intercom` — the SUPERVISOR-side tool of the communication plane (the mirror of the
3
+ * child's `contact_supervisor`). Pure core: maps an action over the {@link InProcessBus}
4
+ * and formats a readable result; the extension wraps it in a `pi.registerTool` so the
5
+ * supervisor can, while async children run:
6
+ * - `list` — who is reachable (running children + peers)
7
+ * - `inbox` — read (drain) what children have reported / asked
8
+ * - `reply` — answer a child's blocking `need_decision`/`interview_request` by id
9
+ * - `send` — push a one-way note to a running child
10
+ *
11
+ * Pure (takes the bus + self handle), so it is unit-tested without a live model.
12
+ */
13
+
14
+ import type { Envelope, InProcessBus } from "../bus/inproc.ts";
15
+ import { sanitizeDisplayLabel } from "../core/display-label.ts";
16
+
17
+ export interface IntercomParams {
18
+ action: "list" | "inbox" | "message" | "reply" | "send";
19
+ /** `send` target child handle. */
20
+ to?: string;
21
+ /** `message` target — the id shown in an inbox message line. */
22
+ messageId?: string;
23
+ /** `reply` target — the message id of the child's pending ask. */
24
+ askId?: string;
25
+ /** `send`/`reply` body. */
26
+ message?: string;
27
+ }
28
+
29
+ export interface IntercomDetails {
30
+ action: string;
31
+ peers?: string[];
32
+ messages?: Envelope[];
33
+ message?: Envelope;
34
+ messageId?: string;
35
+ totalPeers?: number;
36
+ omittedPeers?: number;
37
+ ok: boolean;
38
+ }
39
+
40
+ export interface IntercomOutcome {
41
+ text: string;
42
+ details: IntercomDetails;
43
+ }
44
+
45
+ /** Automatic intercom payload limits. Explicit `message` retrieval is the escape hatch for detail. */
46
+ export const MAX_INBOX_MESSAGE_CHARS = 2_000;
47
+ export const MAX_INBOX_BATCH_CHARS = 16_000;
48
+ export const MAX_INTERCOM_MESSAGE_CHARS = 8_000;
49
+ export const MAX_INTERCOM_REF_CHARS = 160;
50
+ export const MAX_INTERCOM_LIST_PEERS = 64;
51
+
52
+ export interface InboxFormatOptions {
53
+ maxMessageChars?: number;
54
+ maxBatchChars?: number;
55
+ }
56
+
57
+ function clipBody(value: string, maxChars: number): { text: string; omitted: number } {
58
+ if (value.length <= maxChars) return { text: value, omitted: 0 };
59
+ const marker = `[… ${value.length - maxChars} characters omitted; request the full message by id …]`;
60
+ if (maxChars <= marker.length) return { text: marker.slice(0, Math.max(0, maxChars)), omitted: value.length };
61
+ const content = Math.max(0, maxChars - marker.length);
62
+ const head = Math.ceil(content / 2);
63
+ const tail = Math.max(0, content - head);
64
+ return { text: `${value.slice(0, head)}${marker}${tail > 0 ? value.slice(-tail) : ""}`, omitted: value.length - maxChars };
65
+ }
66
+
67
+ /** One bounded readable line per inbox message; decision/interview lines always show sender + id. */
68
+ export function formatInbox(messages: Envelope[], opts?: InboxFormatOptions): string {
69
+ if (messages.length === 0) return "(no new messages from sub-agents)";
70
+ const maxMessageChars = Math.max(128, opts?.maxMessageChars ?? MAX_INBOX_MESSAGE_CHARS);
71
+ const maxBatchChars = Math.max(512, opts?.maxBatchChars ?? MAX_INBOX_BATCH_CHARS);
72
+ // Reserve a small footer so the batch can state how much was omitted without pushing the
73
+ // rendered response over its hard bound. Every message gets an equal slot: one verbose child
74
+ // cannot consume the whole inbox and hide later reply ids.
75
+ const slot = Math.max(128, Math.floor((maxBatchChars - 96) / messages.length));
76
+ let omittedChars = 0;
77
+ const lines = messages.map((m) => {
78
+ const from = sanitizeDisplayLabel(m.from, "peer", 40);
79
+ const kind = sanitizeDisplayLabel(m.kind, "message", 24);
80
+ const id = sanitizeDisplayLabel(m.id, "message", 40);
81
+ const tag = m.expectsReply ? `${kind} · reply with id ${id}` : `${kind} · message id ${id}`;
82
+ const prefix = `• [${from}] (${tag}): `;
83
+ const body = clipBody(m.text, Math.min(maxMessageChars, Math.max(0, slot - prefix.length)));
84
+ omittedChars += body.omitted;
85
+ return `${prefix}${body.text}`;
86
+ });
87
+ const footer = omittedChars > 0 ? `\n… ${omittedChars} message characters omitted from this inbox view; use intercom { action: "message", messageId: "<id>" } for full detail.` : "";
88
+ const rendered = `${lines.join("\n")}${footer}`;
89
+ if (rendered.length <= maxBatchChars) return rendered;
90
+ // This is only reachable with unusually long metadata; preserve the first and last lines while
91
+ // keeping the sender/id and omission signal visible at a predictable size.
92
+ const marker = `\n… inbox batch truncated at ${maxBatchChars} characters; ${messages.length} message(s) present, use ids for detail.`;
93
+ const content = Math.max(0, maxBatchChars - marker.length);
94
+ return `${rendered.slice(0, Math.ceil(content * 0.65))}${marker}${rendered.slice(-Math.floor(content * 0.35))}`.slice(0, maxBatchChars);
95
+ }
96
+
97
+ export function runIntercom(params: IntercomParams, bus: InProcessBus, self: string): IntercomOutcome {
98
+ switch (params.action) {
99
+ case "list": {
100
+ const peers = bus.participants().filter((p) => p !== self);
101
+ const listed = peers.slice(0, MAX_INTERCOM_LIST_PEERS);
102
+ const omittedPeers = Math.max(0, peers.length - listed.length);
103
+ const omission = omittedPeers > 0 ? `\n… ${omittedPeers} more reachable sub-agents omitted from this bounded view.` : "";
104
+ return {
105
+ text: peers.length > 0
106
+ ? `Reachable sub-agents (${peers.length}):\n${listed.map((p) => `• ${sanitizeDisplayLabel(p, "peer")}`).join("\n")}${omission}`
107
+ : "(no sub-agents are currently reachable)",
108
+ details: {
109
+ action: "list",
110
+ peers: listed,
111
+ totalPeers: peers.length,
112
+ ...(omittedPeers > 0 ? { omittedPeers } : {}),
113
+ ok: true,
114
+ },
115
+ };
116
+ }
117
+ case "inbox": {
118
+ const messages = bus.take(self);
119
+ return { text: formatInbox(messages), details: { action: "inbox", messages, ok: true } };
120
+ }
121
+ case "message": {
122
+ if (!params.messageId) {
123
+ return { text: "intercom message needs { messageId: <message id> }.", details: { action: "message", ok: false } };
124
+ }
125
+ if (params.messageId.length > MAX_INTERCOM_REF_CHARS) {
126
+ return { text: `intercom messageId exceeds the ${MAX_INTERCOM_REF_CHARS}-character limit.`, details: { action: "message", ok: false } };
127
+ }
128
+ const message = bus.retrieve(params.messageId, self);
129
+ const displayMessageId = sanitizeDisplayLabel(params.messageId, "message");
130
+ if (!message) {
131
+ return {
132
+ text: `No retained message with id "${displayMessageId}" — it may still be pending, expired, exceeded the retention limit, or never have been delivered (check "inbox").`,
133
+ details: { action: "message", ok: false },
134
+ };
135
+ }
136
+ const from = sanitizeDisplayLabel(message.from, "peer", 40);
137
+ const kind = sanitizeDisplayLabel(message.kind, "message", 24);
138
+ const id = sanitizeDisplayLabel(message.id, "message", 40);
139
+ const replyHint = message.expectsReply ? ` · reply with id ${id}` : "";
140
+ return {
141
+ text: `[${from}] (${kind}${replyHint}) message id ${id}:\n${message.text}`,
142
+ details: { action: "message", messageId: message.id, message, ok: true },
143
+ };
144
+ }
145
+ case "reply": {
146
+ if (!params.askId || params.message === undefined) {
147
+ return { text: "intercom reply needs { askId, message }.", details: { action: "reply", ok: false } };
148
+ }
149
+ if (params.askId.length > MAX_INTERCOM_REF_CHARS) {
150
+ return { text: `intercom reply askId exceeds the ${MAX_INTERCOM_REF_CHARS}-character limit.`, details: { action: "reply", ok: false } };
151
+ }
152
+ if (params.message.length > MAX_INTERCOM_MESSAGE_CHARS) {
153
+ return { text: `intercom reply message exceeds the ${MAX_INTERCOM_MESSAGE_CHARS}-character limit.`, details: { action: "reply", ok: false } };
154
+ }
155
+ const replied = bus.reply(params.askId, params.message);
156
+ const displayAskId = sanitizeDisplayLabel(params.askId, "ask");
157
+ return replied
158
+ ? { text: `Replied to ${displayAskId}.`, details: { action: "reply", ok: true } }
159
+ : {
160
+ text: `No pending ask with id "${displayAskId}" — it may have timed out or already been answered (check "inbox").`,
161
+ details: { action: "reply", ok: false },
162
+ };
163
+ }
164
+ case "send": {
165
+ if (!params.to || params.message === undefined) {
166
+ return { text: "intercom send needs { to, message }.", details: { action: "send", ok: false } };
167
+ }
168
+ if (params.to.length > MAX_INTERCOM_REF_CHARS) {
169
+ return { text: `intercom send target exceeds the ${MAX_INTERCOM_REF_CHARS}-character limit.`, details: { action: "send", ok: false } };
170
+ }
171
+ if (params.message.length > MAX_INTERCOM_MESSAGE_CHARS) {
172
+ return { text: `intercom send message exceeds the ${MAX_INTERCOM_MESSAGE_CHARS}-character limit.`, details: { action: "send", ok: false } };
173
+ }
174
+ const delivered = bus.send(self, params.to, params.message, "progress");
175
+ const displayPeer = sanitizeDisplayLabel(params.to, "peer");
176
+ return delivered
177
+ ? { text: `Sent to ${displayPeer}.`, details: { action: "send", ok: true } }
178
+ : { text: `Unknown peer "${displayPeer}" — nobody by that handle is registered.`, details: { action: "send", ok: false } };
179
+ }
180
+ }
181
+ }
@@ -0,0 +1,39 @@
1
+ /** `models` tool — discover/search model ids. */
2
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
3
+ import { Type } from "typebox";
4
+ import { Text } from "@earendil-works/pi-tui";
5
+ import { compactInlineText } from "../ui/presentation.ts";
6
+
7
+ export interface ModelsToolDeps {
8
+ get lastCtx(): ExtensionContext | undefined;
9
+ set lastCtx(value: ExtensionContext | undefined);
10
+ searchModels(ctx: ExtensionContext, query?: string): { lines: string[]; total: number; capped: boolean };
11
+ }
12
+
13
+ export function registerModelsTool(pi: ExtensionAPI, d: ModelsToolDeps): void {
14
+ // ── /models + a `models` tool — discover/search model ids ─────────────────────
15
+ pi.registerTool({
16
+ name: "models",
17
+ label: "Models",
18
+ description: [
19
+ "List or search the available model ids (provider/id). The same name exists under several",
20
+ "providers — use an EXACT id from here as a delegate task's `model`. ★ marks your session",
21
+ "provider; prefer it (it's the authenticated one).",
22
+ ].join(" "),
23
+ parameters: Type.Object({
24
+ query: Type.Optional(Type.String({ description: "Filter by substring (provider or id), e.g. 'sonnet'" })),
25
+ }),
26
+ async execute(_id, params, _signal, _onUpdate, ctx) {
27
+ d.lastCtx = ctx;
28
+ const { lines, total, capped } = d.searchModels(ctx, params.query);
29
+ const text = lines.length
30
+ ? `${total} model(s)${capped ? " (showing 40 — refine with a query)" : ""}; ★ = session provider:\n${lines.join("\n")}`
31
+ : `no models match "${params.query ?? ""}"`;
32
+ return { content: [{ type: "text", text }], details: { total }, isError: false };
33
+ },
34
+ renderCall(args, theme) {
35
+ const query = compactInlineText(args.query ?? "(all)", { maxChars: 96 }) || "(all)";
36
+ return new Text(`${theme.fg("toolTitle", theme.bold("models "))}${theme.fg("dim", query)}`, 0, 0);
37
+ },
38
+ });
39
+ }
@@ -0,0 +1,66 @@
1
+ /** Background event sources: one executable, explicit argv, bounded session lifetime. */
2
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
3
+ import { Type } from "typebox";
4
+ import { Text } from "@earendil-works/pi-tui";
5
+ import type { MonitorManager } from "../core/monitor.ts";
6
+ import { failureDetails } from "../extension/shared.ts";
7
+ import { compactInlineText } from "../ui/presentation.ts";
8
+
9
+ export interface MonitorToolDeps {
10
+ get lastCtx(): ExtensionContext | undefined;
11
+ set lastCtx(value: ExtensionContext | undefined);
12
+ canRun(): boolean;
13
+ monitors: Pick<MonitorManager, "arm" | "list" | "cancel">;
14
+ }
15
+
16
+ export function registerMonitorTool(pi: ExtensionAPI, d: MonitorToolDeps): void {
17
+ pi.registerTool({
18
+ name: "monitor",
19
+ label: "Monitor",
20
+ description: "Watch a job, file, log or system event using a background program. arm starts command (an executable) with args in the current workspace; no implicit shell. The argument list allows up to 64 literal arguments, each up to 8192 characters and 24,000 characters in total. mode=output wakes on nonempty stdout lines; write an adapter that prints only relevant changes. mode=exit wakes once when the launched job finishes. Returns immediately; continue independent work, then end your turn to receive events automatically. Requires monitor AND bash permission. Default: one event, ten-minute deadline; max eight monitors, twenty events each, twenty-four hours. list shows active watches; cancel stops the owned process tree and queued wakes. Keep this Pi session open; watches stop on session shutdown/reload. Do not wrap delegate or Exocom in a monitor: their notifications are already automatic.",
21
+ parameters: Type.Object({
22
+ action: Type.Union([Type.Literal("arm"), Type.Literal("list"), Type.Literal("cancel")]),
23
+ command: Type.Optional(Type.String({ minLength: 1, maxLength: 4096, description: "arm: executable, e.g. node, python or powershell.exe. Use args for script paths and arguments." })),
24
+ args: Type.Optional(Type.Array(Type.String({ maxLength: 8192 }), { maxItems: 64, description: "arm: literal argv (up to 64 arguments, each up to 8192 characters; combined argument strings maximum 24000 characters). For shell syntax, explicitly launch the appropriate shell and pass a script file." })),
25
+ label: Type.Optional(Type.String({ minLength: 1, maxLength: 120, description: "arm: short purpose, e.g. build finished or new application error." })),
26
+ mode: Type.Optional(Type.Union([Type.Literal("output"), Type.Literal("exit")], { description: "arm: output for event-producing watchers; exit for job completion. Required." })),
27
+ timeoutSeconds: Type.Optional(Type.Number({ minimum: 1, maximum: 86400, description: "arm: lifetime deadline, default 600 seconds. Expiry stops the process and reports a timeout." })),
28
+ maxEvents: Type.Optional(Type.Integer({ minimum: 1, maximum: 20, description: "arm/output: stop after this many distinct event lines, default 1." })),
29
+ id: Type.Optional(Type.String({ minLength: 1, description: "cancel: monitor id returned by arm." })),
30
+ }),
31
+ async execute(_id, params, signal, _onUpdate, ctx) {
32
+ d.lastCtx = ctx;
33
+ const fail = (text: string) => ({ content: [{ type: "text" as const, text }], details: failureDetails({ ok: false }), isError: true });
34
+ if (params.action === "list") {
35
+ const entries = d.monitors.list();
36
+ return { content: [{ type: "text" as const, text: entries.length ? entries.map((e) => `${e.id} (${compactInlineText(e.label, { maxChars: 120 })}) — ${e.mode}; deadline ${new Date(e.expiresAtEpochMs).toISOString()}`).join("\n") : "No active monitors." }], details: { ok: true, count: entries.length } };
37
+ }
38
+ if (params.action === "cancel") {
39
+ if (typeof params.id !== "string" || !params.id.trim()) return fail("monitor cancel needs { id } from arm or list.");
40
+ const cancelled = d.monitors.cancel(params.id);
41
+ return { content: [{ type: "text" as const, text: cancelled ? `Cancelled ${params.id}; its queued events were removed.` : `No active monitor or queued event for ${params.id}.` }], details: { ok: true, cancelled } };
42
+ }
43
+ if (params.action !== "arm") return fail("monitor action must be arm, list or cancel.");
44
+ if (!d.canRun()) return fail("monitor arm requires both monitor and bash permission for the active persona.");
45
+ if (signal?.aborted) return fail("monitor arm cancelled before launch.");
46
+ if (typeof params.command !== "string" || !params.command.trim() || typeof params.label !== "string" || !params.label.trim()) return fail("monitor arm needs { command, label, mode }.");
47
+ if (params.mode !== "output" && params.mode !== "exit") return fail("monitor arm mode must be output or exit.");
48
+ if (params.args !== undefined && (!Array.isArray(params.args) || !params.args.every((v) => typeof v === "string"))) return fail("monitor args must be an array of literal strings.");
49
+ if (params.timeoutSeconds !== undefined && (!Number.isFinite(params.timeoutSeconds) || params.timeoutSeconds < 1 || params.timeoutSeconds > 86400)) return fail("monitor timeoutSeconds must be between 1 and 86400.");
50
+ if (params.maxEvents !== undefined && (!Number.isInteger(params.maxEvents) || params.maxEvents < 1 || params.maxEvents > 20)) return fail("monitor maxEvents must be an integer between 1 and 20.");
51
+ const result = d.monitors.arm({
52
+ command: params.command, label: params.label, mode: params.mode, cwd: ctx.cwd,
53
+ ...(params.args !== undefined ? { args: params.args } : {}),
54
+ ...(params.timeoutSeconds !== undefined ? { timeoutMs: Math.round(params.timeoutSeconds * 1000) } : {}),
55
+ ...(params.maxEvents !== undefined ? { maxEvents: params.maxEvents } : {}),
56
+ });
57
+ if (!result.ok) return fail(result.error);
58
+ return { content: [{ type: "text" as const, text: `Armed ${result.entry.id} (${compactInlineText(result.entry.label, { maxChars: 120 })}); ${params.mode === "exit" ? "wakes when this job exits" : "wakes on relevant stdout lines"}. Deadline ${new Date(result.entry.expiresAtEpochMs).toISOString()}. Keep this session open. Continue independent work, or end your turn and wait for the automatic notice.` }], details: { ok: true, id: result.entry.id, expiresAtEpochMs: result.entry.expiresAtEpochMs } };
59
+ },
60
+ renderCall(args, theme) {
61
+ const action = compactInlineText(args.action ?? "", { maxChars: 12 });
62
+ const label = compactInlineText(args.label ?? args.id ?? "", { maxChars: 96 });
63
+ return new Text(`${theme.fg("toolTitle", theme.bold(`monitor ${action}`))}${label ? ` ${theme.fg("dim", label)}` : ""}`, 0, 0);
64
+ },
65
+ });
66
+ }