@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,476 @@
1
+ /**
2
+ * `exocom_list` + `exocom_send` — the supervisor-facing tools over the exocom plane
3
+ * (Task 7's {@link ExocomPlane}): independent top-level Pi instances in one selected scope
4
+ * messaging each other peer-to-peer, one-way and non-blocking.
5
+ *
6
+ * - `exocom_list` — who else is reachable right now (live, pruned peers).
7
+ * - `exocom_send` — fire a message at a named peer, or `target: "*"` to broadcast to
8
+ * all of them. `plane.send` only resolves a SINGLE named target (Task 7), so the
9
+ * broadcast fan-out is this tool's job: best-effort, one unreachable peer does not
10
+ * abort the rest.
11
+ *
12
+ * Pure over `getPlane` (a stub accessor is enough to unit-test), registered directly with
13
+ * `pi.registerTool` — the same shape as the other `pi.registerTool` calls in extension.ts.
14
+ * `getPlane` is a live accessor rather than a captured plane so a downgrade can fail these
15
+ * tools closed without an `unregisterTool` API — see the doc comment on `registerExocomTools`.
16
+ */
17
+
18
+ import { type Static, Type } from "typebox";
19
+
20
+ import { keyHint, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
21
+ import { Text } from "@earendil-works/pi-tui";
22
+
23
+ import { fencePeer } from "../core/fence.ts";
24
+ import { inventedExocomNameHint } from "../core/naming.ts";
25
+ import { MAX_IDENTITY_NAME_CHARS } from "../core/session-identity.ts";
26
+ import { ExocomPeerRejection, type DisplayPeer, type ExocomPlane } from "../exocom/plane.ts";
27
+ import { normalizeMetadataText, normalizePeerName } from "../exocom/registry.ts";
28
+ import type { ExocomScope } from "../exocom/scope.ts";
29
+ import { compactInlineText } from "../ui/presentation.ts";
30
+
31
+ const COLLAPSED_PEER_LIMIT = 8;
32
+ /** Model-facing presence is a sample, not a dump. Keep the count and a refresh action below. */
33
+ export const MAX_MODEL_PEER_ROWS = 24;
34
+ /** Structured broadcast details retain enough IDs/errors to act while exact totals cover the rest. */
35
+ export const MAX_BROADCAST_DETAIL_ITEMS = 64;
36
+ const MODEL_BROADCAST_ID_SAMPLE = 12;
37
+ const MODEL_BROADCAST_FAILURE_SAMPLE = 8;
38
+
39
+ const ExocomListParams = Type.Object({
40
+ offset: Type.Optional(Type.Integer({ minimum: 0, maximum: 1_000_000, description: "Zero-based page offset into the live peer list." })),
41
+ limit: Type.Optional(Type.Integer({ minimum: 1, maximum: MAX_MODEL_PEER_ROWS, description: `Maximum peers to show (default ${MAX_MODEL_PEER_ROWS}).` })),
42
+ });
43
+
44
+ const ExocomSendParams = Type.Object({
45
+ target: Type.String({ minLength: 1, maxLength: 80, description: 'The peer\'s qualified `target` from `exocom_list`, 1–80 characters; its session suffix stays routable if that peer later renames. A display name alone still works but can be reassigned. Use "*" to broadcast to every reachable peer.' }),
46
+ message: Type.String({ minLength: 1, maxLength: 1_000_000, description: "The message body." }),
47
+ in_reply_to: Type.Optional(Type.String({ minLength: 1, maxLength: 128, pattern: "^[A-Za-z0-9._:-]+$", description: 'The original `msg_id` token (1–128 characters matching [A-Za-z0-9._:-]) when replying; omit for a new message. Broadcast target "*" cannot carry a reply id.' })),
48
+ });
49
+
50
+ const ExocomNameParams = Type.Object({
51
+ name: Type.String({
52
+ minLength: 1,
53
+ maxLength: 96,
54
+ description: `${inventedExocomNameHint()} Keep it to ${MAX_IDENTITY_NAME_CHARS} characters; longer legacy inputs are accepted for compatibility and clipped by the shared identity validator. Whitespace-only names are rejected.`,
55
+ }),
56
+ });
57
+
58
+ /** Single-target send vs. `target: "*"` broadcast — two distinct result shapes, so `execute`'s
59
+ * return type is spelled out explicitly instead of leaving it to per-branch inference (which
60
+ * otherwise unifies the two into an ill-typed merged shape). */
61
+ type ExocomSendDetails =
62
+ | { msg_id: string; target: string }
63
+ | {
64
+ target: "*";
65
+ /** The bounded samples below are accompanied by exact totals so a large fan-out never
66
+ * turns the tool result into an unbounded model/context payload. */
67
+ peerCount: number;
68
+ queuedCount: number;
69
+ failedCount: number;
70
+ msg_ids: string[];
71
+ failed: Array<{ target: string; error: string }>;
72
+ omittedMsgIds: number;
73
+ omittedFailures: number;
74
+ };
75
+
76
+ type ExocomPeerSummary = Pick<DisplayPeer, "displayName" | "persona" | "model" | "context_pct" | "target"> & {
77
+ workspace_id?: string;
78
+ workspace_code?: string;
79
+ workspace_label?: string;
80
+ sameWorkspace?: boolean;
81
+ };
82
+
83
+ function peerWord(count: number): string {
84
+ return `${count} peer${count === 1 ? "" : "s"}`;
85
+ }
86
+
87
+ /** Human-facing rows are bounded in terminal columns. Expanded mode spends a second line on the
88
+ * model so it can retain useful detail without turning one peer into a 300-column wall. */
89
+ function peerRows(peer: ExocomPeerSummary, expanded: boolean): string[] {
90
+ const context = `${Math.max(0, Math.min(100, Number(peer.context_pct) || 0))}%`;
91
+ const workspace = peer.workspace_code && peer.workspace_label
92
+ ? ` workspace: ${compactInlineText(peer.workspace_label, { maxChars: expanded ? 64 : 40 }) || "workspace"} [${peer.workspace_code}] · ${peer.sameWorkspace === false ? "external" : "same"}`
93
+ : undefined;
94
+ if (expanded) {
95
+ const name = compactInlineText(peer.displayName, { maxChars: 48 }) || "peer";
96
+ const persona = compactInlineText(peer.persona, { maxChars: 32 }) || "unknown";
97
+ const model = compactInlineText(peer.model, { maxChars: 88 }) || "unknown";
98
+ return [`- ${name} · ${persona} · ctx ${context}`, ` model: ${model}`, ...(workspace ? [workspace] : [])];
99
+ }
100
+ const name = compactInlineText(peer.displayName, { maxChars: 24 }) || "peer";
101
+ const persona = compactInlineText(peer.persona, { maxChars: 18 }) || "unknown";
102
+ const model = compactInlineText(peer.model, { maxChars: 36 }) || "unknown";
103
+ return [`- ${name} · ${persona} · ${model} · ctx ${context}`, ...(workspace ? [workspace] : [])];
104
+ }
105
+
106
+ /** The model-facing row carries the ROUTE as well as the human label: a display name is
107
+ * recomputed from the live set on every call, so the label a roster showed can belong to a
108
+ * different session by the time the model sends. The TUI row above stays human-sized. */
109
+ function modelPeerRow(peer: ExocomPeerSummary): string {
110
+ const workspace = peer.workspace_code && peer.workspace_label
111
+ ? ` · workspace: ${normalizeMetadataText(peer.workspace_label, 80, "workspace")} [${peer.workspace_code}] · ${peer.sameWorkspace === false ? "external workspace" : "same workspace"}`
112
+ : "";
113
+ return `- ${normalizeMetadataText(peer.displayName, 80, "peer")} · ${normalizeMetadataText(peer.persona, 64, "unknown")} · ${normalizeMetadataText(peer.model, 160, "unknown")} · ctx ${Math.max(0, Math.min(100, Number(peer.context_pct) || 0))}% · target: ${normalizeMetadataText(peer.target, 80, "peer")}${workspace}`;
114
+ }
115
+
116
+ function summarizePeer(peer: DisplayPeer, scope: ExocomScope | undefined): ExocomPeerSummary {
117
+ if (!scope) {
118
+ return {
119
+ displayName: normalizeMetadataText(peer.displayName, 80, "peer"),
120
+ target: normalizeMetadataText(peer.target, 80, "peer"),
121
+ persona: normalizeMetadataText(peer.persona, 64, "unknown"),
122
+ model: normalizeMetadataText(peer.model, 160, "unknown"),
123
+ context_pct: Math.max(0, Math.min(100, Number(peer.context_pct) || 0)),
124
+ };
125
+ }
126
+ // Legacy peers predate workspace metadata. Their registry location still proves they belong
127
+ // to the selected scope; infer that identity, but never invent an absolute path or label.
128
+ const workspaceId = peer.workspace_id ?? scope.scopeWorkspaceId;
129
+ const workspaceCode = peer.workspace_code ?? (workspaceId === scope.scopeWorkspaceId ? scope.scopeCode : undefined);
130
+ const workspaceLabel = peer.workspace_label
131
+ ?? (workspaceId === scope.homeWorkspaceId ? scope.homeWorkspaceLabel : "workspace");
132
+ return {
133
+ displayName: normalizeMetadataText(peer.displayName, 80, "peer"),
134
+ target: normalizeMetadataText(peer.target, 80, "peer"),
135
+ persona: normalizeMetadataText(peer.persona, 64, "unknown"),
136
+ model: normalizeMetadataText(peer.model, 160, "unknown"),
137
+ context_pct: Math.max(0, Math.min(100, Number(peer.context_pct) || 0)),
138
+ workspace_id: workspaceId,
139
+ ...(workspaceCode ? { workspace_code: workspaceCode } : {}),
140
+ workspace_label: normalizeMetadataText(workspaceLabel, 80, "workspace"),
141
+ sameWorkspace: workspaceId === scope.homeWorkspaceId,
142
+ };
143
+ }
144
+
145
+ /** One readable line per live peer: `displayName` (not the possibly-shared `.name`) disambiguates
146
+ * two peers registered under the same persona name — see plane.ts's `dedupeDisplayNames` — and
147
+ * `target` is what the model must actually address with. */
148
+ function formatPeers(peers: DisplayPeer[], offset = 0, limit = MAX_MODEL_PEER_ROWS, scope?: ExocomScope): string {
149
+ const scopeHead = scope
150
+ ? scope.joined
151
+ ? `Exocom joined scope [${scope.scopeCode}] from workspace ${scope.homeWorkspaceLabel} [${scope.homeWorkspaceCode}]`
152
+ : `Exocom workspace ${scope.homeWorkspaceLabel} [${scope.scopeCode}]`
153
+ : "Exocom";
154
+ const head = `${scopeHead} presence only (${peerWord(peers.length)}; ${peers.length === 0 ? "no reachable peers" : "not a message inbox"})`;
155
+ if (peers.length === 0) return head;
156
+ const shown = peers.slice(offset, offset + limit).map((peer) => summarizePeer(peer, scope));
157
+ const paged = offset > 0 || peers.length > limit;
158
+ const pageHead = paged
159
+ ? `${scopeHead} presence only (${peerWord(peers.length)}; showing ${shown.length === 0 ? "none" : `${offset + 1}–${offset + shown.length}`} of ${peers.length}; not a message inbox)`
160
+ : head;
161
+ const omitted = peers.length - shown.length;
162
+ const lines = [pageHead, shown.map(modelPeerRow).join("\n")];
163
+ if (omitted > 0) {
164
+ const next = offset + shown.length < peers.length ? `call exocom_list with offset: ${offset + shown.length} to continue` : "call exocom_list with offset: 0 to return to the first page";
165
+ lines.push(`…and ${omitted} peers omitted from this bounded page; ${next}.`);
166
+ }
167
+ lines.push("Use each peer's qualified target as shown. Its session suffix stays routable if the peer later renames; display names alone can be reassigned.");
168
+ return lines.join("\n");
169
+ }
170
+
171
+ function renderListResult(
172
+ peers: ExocomPeerSummary[],
173
+ expanded: boolean,
174
+ total = peers.length,
175
+ offset = 0,
176
+ nextOffset?: number,
177
+ scope?: { scopeCode: string; homeWorkspace: { label: string; code: string; sameAsScope: boolean } },
178
+ ): string {
179
+ const scopeLabel = scope
180
+ ? scope.homeWorkspace.sameAsScope
181
+ ? ` · workspace ${compactInlineText(scope.homeWorkspace.label, { maxChars: 48 })} [${scope.scopeCode}]`
182
+ : ` · joined [${scope.scopeCode}] · home ${compactInlineText(scope.homeWorkspace.label, { maxChars: 48 })} [${scope.homeWorkspace.code}]`
183
+ : "";
184
+ if (total === 0) return `Exocom${scopeLabel} · 0 peers · no reachable peers`;
185
+ const shown = expanded ? peers : peers.slice(0, COLLAPSED_PEER_LIMIT);
186
+ const pageEnd = Math.min(total, offset + shown.length);
187
+ const paged = offset > 0 || pageEnd < total;
188
+ const page = paged
189
+ ? shown.length === 0 ? `; showing none of ${total}` : `; showing ${offset + 1}–${pageEnd} of ${total}`
190
+ : "";
191
+ const lines = [`Exocom${scopeLabel} · ${peerWord(total)} (presence only${page})`];
192
+ for (const peer of shown) lines.push(...peerRows(peer, expanded));
193
+ if (offset > 0) lines.push(`…and ${offset} peers before this page`);
194
+ if (pageEnd < total) lines.push(`…and ${total - pageEnd} more peers after this page`);
195
+ if (!expanded && total > shown.length) lines.push(`(${keyHint("app.tools.expand", "to expand")})`);
196
+ if (nextOffset !== undefined && nextOffset < total) lines.push(`next offset: ${nextOffset}`);
197
+ return lines.join("\n");
198
+ }
199
+
200
+ /** A refusal in OUR voice: the peer's own prose is stripped out of it and carried separately, so it
201
+ * can travel to the model behind the peer fence instead of inside this agent's tool output. */
202
+ function refusalLine(target: string, err: unknown): string {
203
+ if (err instanceof ExocomPeerRejection) return `exocom: peer "${normalizeMetadataText(target, 80, "peer")}" rejected the message; its stated reason is quoted below`;
204
+ return normalizeMetadataText(err instanceof Error ? err.message : String(err), 240, "exocom: send failed");
205
+ }
206
+
207
+ /** The peer half of one or more refusals, quoted into a single fence. Each reason is flattened to
208
+ * one bounded line first (`normalizeMetadataText`), so a peer can neither fake a fence terminator
209
+ * nor spend the receiving turn's context, and the fence keeps it marked as what it is. */
210
+ function fencedPeerReasons(reasons: Array<{ target: string; reason: string }>): string {
211
+ if (reasons.length === 0) return "";
212
+ return fencePeer(reasons
213
+ .map((item) => `${normalizeMetadataText(item.target, 80, "peer")}: ${normalizeMetadataText(item.reason, 240, "no reason given")}`)
214
+ .join("\n"));
215
+ }
216
+
217
+ function isSendDetails(details: unknown): details is ExocomSendDetails {
218
+ if (!details || typeof details !== "object") return false;
219
+ const shape = details as { msg_id?: unknown; failed?: unknown };
220
+ return Array.isArray(shape.failed) || typeof shape.msg_id === "string";
221
+ }
222
+
223
+ function renderSendResult(details: ExocomSendDetails, expanded: boolean): string {
224
+ if ("failed" in details) {
225
+ const failed = details.failedCount ?? details.failed.length;
226
+ const queued = details.queuedCount ?? details.msg_ids.length;
227
+ const summary = `${queued > 0 ? "✓" : "✗"} queued ${queued} peer${queued === 1 ? "" : "s"}${failed > 0 ? ` · ${failed} failed` : ""}`;
228
+ if (!expanded) return `${summary} (${keyHint("app.tools.expand", "to expand")})`;
229
+ const lines = [summary];
230
+ if (details.msg_ids.length > 0) lines.push(`msg_ids: ${details.msg_ids.join(", ")}`);
231
+ for (const failure of details.failed) {
232
+ lines.push(`✗ ${normalizeMetadataText(failure.target, 80, "peer")}: ${normalizeMetadataText(failure.error, 240, "send failed")}`);
233
+ }
234
+ if ((details.omittedMsgIds ?? 0) > 0) lines.push(`…and ${details.omittedMsgIds} more msg_ids omitted; use the returned count to track the remaining queued peers.`);
235
+ if ((details.omittedFailures ?? 0) > 0) lines.push(`…and ${details.omittedFailures} more failures omitted; retry only the failed targets shown above.`);
236
+ return lines.join("\n");
237
+ }
238
+ const summary = `✓ queued to ${details.target} · msg_id=${details.msg_id}`;
239
+ return expanded ? summary : `${summary} (${keyHint("app.tools.expand", "to expand")})`;
240
+ }
241
+
242
+ /** `getPlane` is a LIVE accessor, not a captured value: a `canUseBus` downgrade (`stopExocom`,
243
+ * see extension.ts) nulls the plane out from under these tool closures without needing an
244
+ * `unregisterTool` API (pi's `ExtensionAPI` has none) — each call re-reads the current plane and
245
+ * fails closed if it's gone, so the tools genuinely stop working the instant exocom is torn down
246
+ * (I3: previously the tools captured the plane BY VALUE, so they stayed callable — and `send`
247
+ * stayed able to connect out — even after a downgrade revoked the persona's bus capability). */
248
+ export function registerExocomTools(
249
+ pi: ExtensionAPI,
250
+ getPlane: () => ExocomPlane | undefined,
251
+ onRename?: (name: string) => string,
252
+ getScope?: () => ExocomScope | undefined,
253
+ ): void {
254
+ pi.registerTool({
255
+ name: "exocom_list",
256
+ label: "Exocom List",
257
+ description: "List current exocom peer presence only; this is not a message inbox and replies arrive automatically.",
258
+ parameters: ExocomListParams,
259
+ async execute(_toolCallId, params: Static<typeof ExocomListParams>) {
260
+ const plane = getPlane();
261
+ if (!plane) throw new Error("exocom is not active for this persona");
262
+ const peers = plane.listPeers();
263
+ const scope = getScope?.();
264
+ const offset = Math.min(peers.length, Math.max(0, Math.floor(params?.offset ?? 0)));
265
+ const limit = Math.max(1, Math.min(MAX_MODEL_PEER_ROWS, Math.floor(params?.limit ?? MAX_MODEL_PEER_ROWS)));
266
+ const shown = peers.slice(offset, offset + limit).map((peer) => summarizePeer(peer, scope));
267
+ return {
268
+ content: [{ type: "text", text: formatPeers(peers, offset, limit, scope) }],
269
+ details: {
270
+ peers: shown,
271
+ total: peers.length,
272
+ offset,
273
+ limit,
274
+ omitted: peers.length - shown.length,
275
+ ...(scope ? {
276
+ scopeCode: scope.scopeCode,
277
+ homeWorkspace: {
278
+ label: scope.homeWorkspaceLabel,
279
+ code: scope.homeWorkspaceCode,
280
+ sameAsScope: !scope.joined,
281
+ },
282
+ } : {}),
283
+ ...(offset + shown.length < peers.length ? { nextOffset: offset + shown.length } : {}),
284
+ },
285
+ };
286
+ },
287
+ renderCall(_args, theme) {
288
+ return new Text(theme.fg("toolTitle", theme.bold("Exocom List")), 0, 0);
289
+ },
290
+ renderResult(result, { expanded }, theme) {
291
+ const details = result.details as unknown as {
292
+ peers?: ExocomPeerSummary[];
293
+ total?: number;
294
+ offset?: number;
295
+ nextOffset?: number;
296
+ scopeCode?: string;
297
+ homeWorkspace?: { label?: string; code?: string; sameAsScope?: boolean };
298
+ } | undefined;
299
+ if (!details || !Array.isArray(details.peers)) {
300
+ const first = result.content.find((item) => item.type === "text");
301
+ return new Text(theme.fg("error", first?.type === "text" ? first.text : "Exocom list failed"), 0, 0);
302
+ }
303
+ const peers = details.peers;
304
+ const total = typeof details.total === "number" ? details.total : peers.length;
305
+ const offset = typeof details.offset === "number" ? Math.max(0, details.offset) : 0;
306
+ const nextOffset = typeof details.nextOffset === "number" ? details.nextOffset : undefined;
307
+ const scope = typeof details.scopeCode === "string"
308
+ && typeof details.homeWorkspace?.label === "string"
309
+ && typeof details.homeWorkspace.code === "string"
310
+ && typeof details.homeWorkspace.sameAsScope === "boolean"
311
+ ? {
312
+ scopeCode: details.scopeCode,
313
+ homeWorkspace: {
314
+ label: details.homeWorkspace.label,
315
+ code: details.homeWorkspace.code,
316
+ sameAsScope: details.homeWorkspace.sameAsScope,
317
+ },
318
+ }
319
+ : undefined;
320
+ return new Text(theme.fg(expanded ? "toolOutput" : "accent", renderListResult(peers, expanded, total, offset, nextOffset, scope)), 0, 0);
321
+ },
322
+ });
323
+
324
+ pi.registerTool({
325
+ name: "exocom_send",
326
+ label: "Exocom Send",
327
+ description: [
328
+ "Send a ONE-WAY, non-blocking message to another top-level pi instance in this Exocom scope",
329
+ "(see `exocom_list`) — it returns a `msg_id` immediately and does not wait for the peer to",
330
+ "act on it. If you're replying to something a peer sent you, set `in_reply_to` to its",
331
+ '`msg_id`. `target: "*"` broadcasts the message to every reachable peer.',
332
+ ].join(" "),
333
+ parameters: ExocomSendParams,
334
+ async execute(
335
+ _toolCallId,
336
+ params: Static<typeof ExocomSendParams>,
337
+ ): Promise<{ content: [{ type: "text"; text: string }]; details: ExocomSendDetails }> {
338
+ const plane = getPlane();
339
+ if (!plane) throw new Error("exocom is not active for this persona");
340
+ if (params.target === "*" && params.in_reply_to) {
341
+ throw new Error('exocom_send: target "*" cannot carry in_reply_to — a broadcast cannot be a reply (N−1 peers would reject it).');
342
+ }
343
+ if (params.target === "*") {
344
+ const peers = plane.listPeers();
345
+ const sent: Array<{ target: string; msg_id: string }> = [];
346
+ const failed: Array<{ target: string; error: string }> = [];
347
+ // Peer-authored refusal prose, kept apart from our own failure lines so the whole
348
+ // fan-out's worth of it can be quoted into ONE peer fence below.
349
+ const rejections: Array<{ target: string; reason: string }> = [];
350
+ const outcomes = await Promise.all(
351
+ // By the session-pinned target, not the possibly-shared `.name` nor the display
352
+ // label: two LIVE peers can share a raw name (session_id-keyed registry, PartA),
353
+ // and a label is recomputed from the live set on every call — a peer that joins or
354
+ // leaves mid-fan-out can inherit another's label and receive its message.
355
+ peers.map(async (p) => {
356
+ try {
357
+ const { msg_id } = await plane.send(p.target, params.message, params.in_reply_to);
358
+ return { ok: true as const, target: p.target, msg_id };
359
+ } catch (err) {
360
+ return {
361
+ ok: false as const, target: p.target,
362
+ error: err instanceof Error ? err.message : String(err),
363
+ reason: err instanceof ExocomPeerRejection ? err.reason : undefined,
364
+ };
365
+ }
366
+ }),
367
+ );
368
+ // Promise completion order is not a presentation contract. Fold after all sends settle so
369
+ // sampled IDs/errors retain the stable registry order even when peers answer at different speeds.
370
+ for (const outcome of outcomes) {
371
+ if (outcome.ok) { sent.push({ target: outcome.target, msg_id: outcome.msg_id }); continue; }
372
+ failed.push({ target: outcome.target, error: outcome.error });
373
+ if (outcome.reason !== undefined) rejections.push({ target: outcome.target, reason: outcome.reason });
374
+ }
375
+ const failNote = failed.length > 0 ? `; ${failed.length} failed` : "";
376
+ const marker = sent.length > 0 ? "✓" : "✗";
377
+ const shownIds = sent.slice(0, MODEL_BROADCAST_ID_SAMPLE).map((item) =>
378
+ `${normalizeMetadataText(item.target, 80, "peer")}=${normalizeMetadataText(item.msg_id, 128, "msg")}`,
379
+ );
380
+ const omittedIds = sent.length - shownIds.length;
381
+ // The model-facing sample describes each failure in OUR words; a peer's own explanation
382
+ // travels only inside the fence appended below (`details` keeps it verbatim for the
383
+ // operator's card, which is rendering, not context).
384
+ const rejectedTargets = new Set(rejections.map((item) => item.target));
385
+ const shownFailures = failed.slice(0, MODEL_BROADCAST_FAILURE_SAMPLE).map((item) =>
386
+ `${normalizeMetadataText(item.target, 80, "peer")}: ${rejectedTargets.has(item.target) ? "rejected by the peer; reason quoted below" : normalizeMetadataText(item.error, 160, "send failed")}`,
387
+ );
388
+ const omittedFailures = failed.length - shownFailures.length;
389
+ const action = omittedIds > 0 || omittedFailures > 0
390
+ ? `; ${[
391
+ shownIds.length > 0 ? `msg_ids: ${shownIds.join(", ")}${omittedIds > 0 ? `; +${omittedIds} more msg_ids omitted` : ""}` : "",
392
+ shownFailures.length > 0 ? `failures: ${shownFailures.join("; ")}${omittedFailures > 0 ? `; +${omittedFailures} more failures omitted` : ""}` : "",
393
+ ].filter(Boolean).join("; ")}`
394
+ : shownIds.length > 0 ? `; msg_ids: ${shownIds.join(", ")}` : "";
395
+ const quoted = fencedPeerReasons(rejections.slice(0, MODEL_BROADCAST_FAILURE_SAMPLE));
396
+ const summary = `${marker} queued ${sent.length}/${peers.length} ${peers.length === 1 ? "peer" : "peers"}${failNote}${action}`;
397
+ return {
398
+ content: [{ type: "text", text: peers.length === 0 ? "⚠ no reachable peers; nothing queued" : [summary, quoted].filter(Boolean).join("\n") }],
399
+ details: {
400
+ target: "*",
401
+ peerCount: peers.length,
402
+ queuedCount: sent.length,
403
+ failedCount: failed.length,
404
+ msg_ids: sent.slice(0, MAX_BROADCAST_DETAIL_ITEMS).map((item) => normalizeMetadataText(item.msg_id, 128, "msg")),
405
+ failed: failed.slice(0, MAX_BROADCAST_DETAIL_ITEMS).map((failure) => ({
406
+ target: normalizeMetadataText(failure.target, 80, "peer"),
407
+ error: normalizeMetadataText(failure.error, 240, "send failed"),
408
+ })),
409
+ omittedMsgIds: Math.max(0, sent.length - MAX_BROADCAST_DETAIL_ITEMS),
410
+ omittedFailures: Math.max(0, failed.length - MAX_BROADCAST_DETAIL_ITEMS),
411
+ },
412
+ };
413
+ }
414
+
415
+ let msg_id: string;
416
+ try {
417
+ ({ msg_id } = await plane.send(params.target, params.message, params.in_reply_to));
418
+ } catch (err) {
419
+ // pi turns a thrown execute() into model-facing tool-result text, so a refusal carrying
420
+ // the PEER's own NACK prose would land in the supervisor's context as this agent's own
421
+ // words. Our half stays ours; the peer's half is flattened and fenced, exactly as an
422
+ // inbound peer message is (src/core/fence.ts).
423
+ const quoted = err instanceof ExocomPeerRejection ? fencedPeerReasons([{ target: params.target, reason: err.reason }]) : "";
424
+ throw new Error([refusalLine(params.target, err), quoted].filter(Boolean).join("\n"));
425
+ }
426
+ return {
427
+ content: [{ type: "text", text: `✓ queued to ${params.target} · msg_id=${msg_id}` }],
428
+ details: { msg_id, target: params.target },
429
+ };
430
+ },
431
+ renderCall(args, theme) {
432
+ const target = normalizeMetadataText(args.target, 80, "*");
433
+ const message = String(args.message ?? "").replace(/\s+/g, " ").trim();
434
+ const preview = normalizeMetadataText(message, 60);
435
+ const suffix = preview.length < message.length ? "…" : "";
436
+ return new Text(`${theme.fg("toolTitle", theme.bold("Exocom Send "))}${theme.fg("accent", target)}${preview ? theme.fg("dim", ` ${preview}${suffix}`) : ""}`, 0, 0);
437
+ },
438
+ renderResult(result, { expanded }, theme) {
439
+ const details = result.details as unknown as ExocomSendDetails | undefined;
440
+ // pi renders this for a FAILED call too, handing us the error's empty details object — an
441
+ // unrecognised shape is a failure to show, not a send to congratulate the user on.
442
+ if (!isSendDetails(details)) {
443
+ const text = result.content.find((item) => item.type === "text");
444
+ return new Text(theme.fg("error", text?.type === "text" ? text.text : "(no result)"), 0, 0);
445
+ }
446
+ return new Text(theme.fg(expanded ? "toolOutput" : "accent", renderSendResult(details, expanded)), 0, 0);
447
+ },
448
+ });
449
+
450
+ pi.registerTool({
451
+ name: "exocom_name",
452
+ label: "Exocom Name",
453
+ description: inventedExocomNameHint(),
454
+ parameters: ExocomNameParams,
455
+ async execute(_toolCallId, params: Static<typeof ExocomNameParams>) {
456
+ const plane = getPlane();
457
+ if (!plane || !onRename) throw new Error("exocom is not active for this persona");
458
+ if (!params.name.trim()) throw new Error("exocom_name: name must contain visible characters");
459
+ const applied = normalizePeerName(onRename(normalizePeerName(params.name)));
460
+ return { content: [{ type: "text", text: `exocom: you are now "${applied}"` }], details: { name: applied } };
461
+ },
462
+ renderCall(args, theme) {
463
+ return new Text(`${theme.fg("toolTitle", theme.bold("Exocom Name "))}${theme.fg("accent", normalizeMetadataText(args.name, 96, "peer"))}`, 0, 0);
464
+ },
465
+ renderResult(result, { expanded }, theme) {
466
+ const details = result.details as unknown as { name?: string } | undefined;
467
+ if (!details?.name) {
468
+ const first = result.content.find((item) => item.type === "text");
469
+ return new Text(theme.fg("error", first?.type === "text" ? first.text : "Exocom name failed"), 0, 0);
470
+ }
471
+ const name = normalizeMetadataText(details.name, 96, "(unchanged)");
472
+ const text = `exocom: you are now "${name}"`;
473
+ return new Text(theme.fg(expanded ? "toolOutput" : "accent", expanded ? text : `${text} (${keyHint("app.tools.expand", "to expand")})`), 0, 0);
474
+ },
475
+ });
476
+ }
@@ -0,0 +1,117 @@
1
+ /** `flow` tool — run a *.flow.json DAG. */
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 { fenceUntrusted } from "../core/fence.ts";
6
+ import type { FlowOutcome } from "../orchestration/flow-run.ts";
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";
11
+ import { sumUsage } from "../orchestration/reducers.ts";
12
+ import { emptyUsage } from "../engine/stream.ts";
13
+ import { Text } from "@earendil-works/pi-tui";
14
+ import { expandDetailHint } from "../extension/shared.ts";
15
+ import type { FailureKind } from "../orchestration/types.ts";
16
+
17
+ export interface FlowToolDeps {
18
+ get lastCtx(): ExtensionContext | undefined;
19
+ set lastCtx(value: ExtensionContext | undefined);
20
+ loadFlow(cwd: string, name: string): { ok: true; flow: FlowSpec } | { ok: false; error: string } | undefined;
21
+ listFlows(cwd: string): string[];
22
+ runFlowVisible(ctx: ExtensionContext, spec: FlowSpec, task: string, signal?: AbortSignal): Promise<FlowOutcome>;
23
+ childUsage: ChildUsageLedger;
24
+ publishPersonaCost(): void;
25
+ }
26
+
27
+ export function registerFlowTool(pi: ExtensionAPI, d: FlowToolDeps): void {
28
+ // ── flow tool (run a *.flow.json DAG over strategies; the supervisor self-launches) ──
29
+ const FlowToolParams = Type.Object({
30
+ name: Type.String({ description: "The flow to run — a *.flow.json by name (installed flows are listed in your sub-agents brief; the user can run /flow to list them)" }),
31
+ task: Type.String({ description: "The objective to run the flow on" }),
32
+ });
33
+ pi.registerTool({
34
+ name: "flow",
35
+ label: "Flow",
36
+ description: [
37
+ "Run a named flow — a declarative DAG over strategies (`*.flow.json`): phases each run a",
38
+ "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",
40
+ "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.",
42
+ ].join(" "),
43
+ parameters: FlowToolParams,
44
+ async execute(_id, params, signal, _onUpdate, ctx) {
45
+ d.lastCtx = ctx;
46
+ const parsed = d.loadFlow(ctx.cwd, params.name);
47
+ if (!parsed) {
48
+ const installed = d.listFlows(ctx.cwd);
49
+ const hint = installed.length > 0 ? `Installed flows: ${installed.join(", ")}.` : "No flows are installed — add a *.flow.json under .pi/flows/.";
50
+ return {
51
+ content: [{ type: "text", text: `no flow named "${params.name}". ${hint}` }],
52
+ details: failureDetails({ ok: false, error: `no flow named "${params.name}"` }),
53
+ isError: true,
54
+ };
55
+ }
56
+ if (!parsed.ok) {
57
+ return {
58
+ content: [{ type: "text", text: `flow "${params.name}" is invalid: ${parsed.error}` }],
59
+ details: failureDetails({ ok: false, failureKind: "contract", error: parsed.error }),
60
+ isError: true,
61
+ };
62
+ }
63
+ try {
64
+ const outcome = await d.runFlowVisible(ctx, parsed.flow, params.task, signal);
65
+ const details = {
66
+ ok: outcome.ok,
67
+ failedPhase: outcome.failedPhase,
68
+ failureKind: outcome.failureKind,
69
+ error: outcome.error,
70
+ };
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();
76
+ return {
77
+ content: [{ type: "text", text: fenceUntrusted(outcome.output || "(flow produced no output)") }],
78
+ details: outcome.ok ? details : failureDetails(details),
79
+ isError: !outcome.ok,
80
+ ...toolUsageField(usage),
81
+ };
82
+ } catch (err) {
83
+ const message = err instanceof Error ? err.message : String(err);
84
+ return { content: [{ type: "text", text: `flow failed: ${message}` }], details: failureDetails({ ok: false, error: message }), isError: true };
85
+ }
86
+ },
87
+ renderCall(args, theme) {
88
+ return new Text(`${theme.fg("toolTitle", theme.bold("flow "))}${theme.fg("accent", compactInlineText(args.name ?? "?", { maxChars: 96 }) || "?")}`, 0, 0);
89
+ },
90
+ renderResult(result, { expanded }, theme) {
91
+ const details = (result.details ?? {}) as {
92
+ ok?: boolean;
93
+ failedPhase?: string;
94
+ failureKind?: FailureKind;
95
+ error?: string;
96
+ };
97
+ const first = result.content[0];
98
+ const full = sanitizeTerminalText(first?.type === "text" ? first.text : "(no output)");
99
+ const failed = details.ok === false;
100
+ const failureBits = failed
101
+ ? compactInlineText(
102
+ [details.failedPhase ? `phase ${details.failedPhase}` : undefined, details.failureKind, details.error]
103
+ .filter((part): part is string => !!part)
104
+ .join(" · "),
105
+ { maxChars: 160 },
106
+ )
107
+ : "";
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);
112
+ const preview = compactVisibleText(full, { maxLines: 3, maxLineChars: 100 });
113
+ const hint = preview.truncated ? `\n${theme.fg("dim", expandDetailHint())}` : "";
114
+ return new Text(`${title}\n${theme.fg("toolOutput", preview.text)}${hint}`, 0, 0);
115
+ },
116
+ });
117
+ }