@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,279 @@
1
+ /**
2
+ * In-process coordination bus — the semantic communication plane (Bus messages),
3
+ * kept DISTINCT from engine runtime events and the derived progress view
4
+ * (guardrails §4.2: EngineEvent / Bus Msg / ProgressView are three disjoint planes).
5
+ *
6
+ * Handle-based mailbox: participants register by name; `send` is one-way (a child's
7
+ * `progress_update`), `ask` blocks for a reply to that message id (a child's
8
+ * `need_decision`/`interview_request` — the supervisor must be free to answer, i.e.
9
+ * async coaching, §4.9). No IPC, no naming contract → cross-OS for free; a future
10
+ * cross-process broker would implement the same surface for separate processes.
11
+ *
12
+ * Pure module: no Pi imports, fully unit-tested. This is the seam the in-process
13
+ * engine wires a child's `contact_supervisor` tool onto.
14
+ */
15
+
16
+ /** Why a child reached the supervisor — shapes how the UI/supervisor treats it. */
17
+ export type MsgKind = "progress" | "decision" | "interview";
18
+
19
+ export interface Envelope {
20
+ id: string;
21
+ from: string;
22
+ to: string;
23
+ kind: MsgKind;
24
+ text: string;
25
+ /** A `decision`/`interview` blocks the sender until `reply(id, …)`; `progress` never does. */
26
+ expectsReply: boolean;
27
+ }
28
+
29
+ /** Retention is a bounded escape hatch for drained messages, not a second inbox. */
30
+ export const MAX_RETAINED_MESSAGES = 256;
31
+ export const MAX_RETAINED_MESSAGE_CHARS = 256_000;
32
+ export const MAX_INBOX_MESSAGES = 200;
33
+
34
+ export interface InProcessBusOptions {
35
+ /** Override the unread-envelope bound for focused tests or a future host policy. */
36
+ maxInbox?: number;
37
+ }
38
+
39
+ export type AskSettlementReason = "replied" | "aborted" | "timeout" | "unregistered" | "inbox-full" | "delivery-error";
40
+
41
+ export interface AskSettlement {
42
+ id: string;
43
+ from: string;
44
+ to: string;
45
+ reason: AskSettlementReason;
46
+ }
47
+
48
+ interface PendingAsk {
49
+ envelope: Envelope;
50
+ resolve: (reply: string) => void;
51
+ reject: (error: Error) => void;
52
+ timer: ReturnType<typeof setTimeout> | undefined;
53
+ signal: AbortSignal | undefined;
54
+ onAbort: () => void;
55
+ }
56
+
57
+ let seq = 0;
58
+ function nextId(): string {
59
+ seq += 1;
60
+ return `m${seq.toString(36)}`;
61
+ }
62
+
63
+ export class InProcessBus {
64
+ private readonly inboxes = new Map<string, Envelope[]>();
65
+ private readonly retainedMessages = new Map<string, Envelope>();
66
+ private retainedMessageChars = 0;
67
+ private readonly pendingAsks = new Map<string, PendingAsk>();
68
+ private readonly observers = new Set<(env: Envelope) => void>();
69
+ private readonly askSettledListeners = new Set<(event: AskSettlement) => void>();
70
+ private readonly maxInbox: number;
71
+
72
+ constructor(opts: InProcessBusOptions = {}) {
73
+ this.maxInbox = opts.maxInbox !== undefined && Number.isFinite(opts.maxInbox)
74
+ ? Math.max(1, Math.floor(opts.maxInbox))
75
+ : MAX_INBOX_MESSAGES;
76
+ }
77
+
78
+ /** Observe every delivered message (for event-wake follow-ups). Returns an unsubscribe. */
79
+ onMessage(listener: (env: Envelope) => void): () => void {
80
+ this.observers.add(listener);
81
+ return () => this.observers.delete(listener);
82
+ }
83
+
84
+ /** Observe ask settlement so notification surfaces can discard stale cached prompts. */
85
+ onAskSettled(listener: (event: AskSettlement) => void): () => void {
86
+ this.askSettledListeners.add(listener);
87
+ return () => this.askSettledListeners.delete(listener);
88
+ }
89
+
90
+ /** Cap on unread messages per inbox — a chatty child must not grow supervisor
91
+ * memory without bound when nobody drains. */
92
+ private deliver(box: Envelope[], env: Envelope): boolean {
93
+ if (box.length >= this.maxInbox) {
94
+ // Evict the oldest ONE-WAY note first. If every unread envelope is a live ask,
95
+ // reject this delivery instead of silently stranding an existing sender.
96
+ const idx = box.findIndex((e) => !e.expectsReply);
97
+ if (idx < 0) return false;
98
+ box.splice(idx, 1);
99
+ }
100
+ box.push(env);
101
+ for (const fn of this.observers) fn(env);
102
+ return true;
103
+ }
104
+
105
+ private retireAskEnvelope(pending: PendingAsk): void {
106
+ pending.envelope.expectsReply = false;
107
+ const { id, to: recipient } = pending.envelope;
108
+ const box = this.inboxes.get(recipient);
109
+ if (box) {
110
+ const index = box.findIndex((env) => env.id === id);
111
+ if (index >= 0) box.splice(index, 1);
112
+ }
113
+ const retained = this.retainedMessages.get(id);
114
+ if (retained?.to === recipient) retained.expectsReply = false;
115
+ }
116
+
117
+ private settleAsk(id: string, reason: AskSettlementReason, reply?: string, failure?: Error): boolean {
118
+ const pending = this.pendingAsks.get(id);
119
+ if (!pending) return false;
120
+ this.pendingAsks.delete(id);
121
+ if (pending.timer !== undefined) clearTimeout(pending.timer);
122
+ pending.signal?.removeEventListener("abort", pending.onAbort);
123
+ this.retireAskEnvelope(pending);
124
+ if (reason === "replied") pending.resolve(reply ?? "");
125
+ else pending.reject(failure ?? new Error(reason === "inbox-full" ? "inbox full: ask was not delivered" : `ask ${reason}`));
126
+ const event: AskSettlement = { id, from: pending.envelope.from, to: pending.envelope.to, reason };
127
+ for (const listener of this.askSettledListeners) {
128
+ try {
129
+ listener(event);
130
+ } catch {
131
+ // Settlement observers are cleanup hooks; one faulty observer must not alter the ask result.
132
+ }
133
+ }
134
+ return true;
135
+ }
136
+
137
+ private retain(env: Envelope): void {
138
+ if (env.text.length > MAX_RETAINED_MESSAGE_CHARS || this.retainedMessages.has(env.id)) return;
139
+ this.retainedMessages.set(env.id, env);
140
+ this.retainedMessageChars += env.text.length;
141
+ while (this.retainedMessages.size > MAX_RETAINED_MESSAGES || this.retainedMessageChars > MAX_RETAINED_MESSAGE_CHARS) {
142
+ const oldest = this.retainedMessages.keys().next().value;
143
+ if (oldest === undefined) break;
144
+ const removed = this.retainedMessages.get(oldest);
145
+ this.retainedMessages.delete(oldest);
146
+ if (removed) this.retainedMessageChars -= removed.text.length;
147
+ }
148
+ }
149
+
150
+ private retainAll(messages: readonly Envelope[]): void {
151
+ for (const message of messages) this.retain(message);
152
+ }
153
+
154
+ register(name: string): void {
155
+ if (!this.inboxes.has(name)) this.inboxes.set(name, []);
156
+ }
157
+
158
+ unregister(name: string): void {
159
+ this.inboxes.delete(name);
160
+ for (const [id, pending] of this.pendingAsks) {
161
+ if (pending.envelope.from === name || pending.envelope.to === name) this.settleAsk(id, "unregistered");
162
+ }
163
+ for (const [id, message] of this.retainedMessages) {
164
+ if (message.to !== name) continue;
165
+ this.retainedMessages.delete(id);
166
+ this.retainedMessageChars -= message.text.length;
167
+ }
168
+ }
169
+
170
+ participants(): string[] {
171
+ return [...this.inboxes.keys()];
172
+ }
173
+
174
+ /** One-way message (default `progress`). Returns false if the recipient is not registered. */
175
+ send(from: string, to: string, text: string, kind: MsgKind = "progress"): boolean {
176
+ const box = this.inboxes.get(to);
177
+ if (!box) return false;
178
+ return this.deliver(box, { id: nextId(), from, to, kind, text, expectsReply: false });
179
+ }
180
+
181
+ /** Blocking request: resolves with the reply to this message, or rejects on timeout. */
182
+ ask(
183
+ from: string,
184
+ to: string,
185
+ text: string,
186
+ opts: { kind?: MsgKind; timeoutMs?: number; signal?: AbortSignal } = {},
187
+ ): Promise<string> {
188
+ const box = this.inboxes.get(to);
189
+ if (!box) throw new Error(`unknown peer: ${to}`);
190
+ const id = nextId();
191
+ const timeoutMs = opts.timeoutMs ?? 600_000;
192
+ return new Promise<string>((resolve, reject) => {
193
+ const envelope: Envelope = { id, from, to, kind: opts.kind ?? "decision", text, expectsReply: true };
194
+ const onAbort = (): void => {
195
+ this.settleAsk(id, "aborted");
196
+ };
197
+ const pending: PendingAsk = {
198
+ envelope,
199
+ resolve,
200
+ reject,
201
+ timer: undefined,
202
+ signal: opts.signal,
203
+ onAbort,
204
+ };
205
+ this.pendingAsks.set(id, pending);
206
+ if (opts.signal?.aborted) {
207
+ this.settleAsk(id, "aborted");
208
+ return;
209
+ }
210
+ pending.timer = setTimeout(() => {
211
+ this.settleAsk(id, "timeout", undefined, new Error(`ask timeout after ${timeoutMs}ms`));
212
+ }, timeoutMs);
213
+ pending.timer.unref?.();
214
+ if (opts.signal) opts.signal.addEventListener("abort", onAbort, { once: true });
215
+ // Deliver only once the resolver is registered: `deliver` notifies observers
216
+ // synchronously, and one that answers inline would otherwise hit `reply`'s
217
+ // unknown-id path and strand this ask until its timeout.
218
+ try {
219
+ if (!this.deliver(box, envelope)) this.settleAsk(id, "inbox-full");
220
+ } catch (error) {
221
+ this.settleAsk(id, "delivery-error", undefined, error instanceof Error ? error : new Error(String(error)));
222
+ }
223
+ });
224
+ }
225
+
226
+ /** Answer a pending ask by its message id. Returns false (a harmless no-op) for an
227
+ * unknown/expired id — so the caller can tell the child actually got the answer. */
228
+ reply(askId: string, text: string): boolean {
229
+ return this.settleAsk(askId, "replied", text);
230
+ }
231
+
232
+ /** Drain and return a participant's inbox. */
233
+ take(name: string): Envelope[] {
234
+ const box = this.inboxes.get(name) ?? [];
235
+ this.inboxes.set(name, []);
236
+ this.retainAll(box);
237
+ return box;
238
+ }
239
+
240
+ /** Drain and return only the messages matching `pred`, leaving the rest in the inbox. */
241
+ takeWhere(name: string, pred: (env: Envelope) => boolean): Envelope[] {
242
+ const box = this.inboxes.get(name);
243
+ if (!box || box.length === 0) return [];
244
+ const taken: Envelope[] = [];
245
+ const kept: Envelope[] = [];
246
+ for (const env of box) (pred(env) ? taken : kept).push(env);
247
+ this.inboxes.set(name, kept);
248
+ this.retainAll(taken);
249
+ return taken;
250
+ }
251
+
252
+ /** Retrieve a drained message by id while it remains inside the bounded retention window. */
253
+ retrieve(messageId: string, recipient?: string): Envelope | undefined {
254
+ const message = this.retainedMessages.get(messageId);
255
+ return message && (recipient === undefined || message.to === recipient) ? message : undefined;
256
+ }
257
+
258
+ /** Peek at a participant's inbox without draining it. */
259
+ pending(name: string): Envelope[] {
260
+ return [...(this.inboxes.get(name) ?? [])];
261
+ }
262
+
263
+ /** Whether a participant has any undrained messages. */
264
+ hasPending(name: string): boolean {
265
+ return (this.inboxes.get(name)?.length ?? 0) > 0;
266
+ }
267
+
268
+ /** Whether `handle` currently has a live ask (`decision`/`interview`) awaiting a reply.
269
+ * The one question an idle/startup watchdog needs to tell real silence from a leg
270
+ * legitimately blocked on the supervisor: re-arm while this is true, kill once it goes
271
+ * false (settled by reply, abort, or timeout — {@link settleAsk} removes it from
272
+ * `pendingAsks` on every settlement path). */
273
+ hasPendingAskFrom(handle: string): boolean {
274
+ for (const pending of this.pendingAsks.values()) {
275
+ if (pending.envelope.from === handle) return true;
276
+ }
277
+ return false;
278
+ }
279
+ }
@@ -0,0 +1,114 @@
1
+ /**
2
+ * `contact_peer` — the CHILD-side sibling-messaging tool of the communication plane
3
+ * (design: docs/ARCHITECTURE.md § "The comm plane in practice"). Bound by the
4
+ * in-process engine ONLY when the strategy opted the run into peer comm (`spec.peers`,
5
+ * gated by the persona's `canUseBus`).
6
+ *
7
+ * ONE-WAY by design (decision P1): a "conversation" is reciprocal sends — the receiving
8
+ * agent replies, if it chooses, with a send of its own. Blocking asks remain a
9
+ * supervisor-channel privilege, so two peers can never deadlock on each other.
10
+ *
11
+ * The peer list comes from an engine-provided `listPeers` closure scoped to the SAME
12
+ * strategy run — the tool never scans the whole bus, so concurrent runs stay invisible
13
+ * to each other. A per-member send budget stops ping-pong loops (the idle watchdog
14
+ * cannot: mutual steers re-arm each other's clocks).
15
+ *
16
+ * Pure factory (bus + self handle + deps), unit-tested without a live model, mirroring
17
+ * `bus/contact.ts`.
18
+ */
19
+
20
+ import { type Static, Type } from "typebox";
21
+
22
+ import { defineTool } from "@earendil-works/pi-coding-agent";
23
+
24
+ import type { InProcessBus } from "./inproc.ts";
25
+
26
+ /** Anti ping-pong budget: after this many sends the tool tells the member to finalize. */
27
+ export const MAX_PEER_SENDS = 20;
28
+ export const MAX_PEER_MESSAGE_CHARS = 8_000;
29
+
30
+ export interface PeerInfo {
31
+ handle: string;
32
+ label: string;
33
+ }
34
+
35
+ export interface ContactPeerDeps {
36
+ /** The live peers of THIS run (excluding self) — scoped by the engine, never the whole bus. */
37
+ listPeers: () => PeerInfo[] | Promise<PeerInfo[]>;
38
+ /** Override the send budget (tests). Default {@link MAX_PEER_SENDS}. */
39
+ maxSends?: number;
40
+ }
41
+
42
+ const PeerParams = Type.Object({
43
+ action: Type.Union([Type.Literal("list"), Type.Literal("send")], {
44
+ description:
45
+ "list = see which peers are still working · send = push a ONE-WAY note to one peer (you do NOT wait; a peer answers, if at all, with a send of its own).",
46
+ }),
47
+ to: Type.Optional(Type.String({ description: "send: the peer handle (from `list`, e.g. reviewer#2)." })),
48
+ message: Type.Optional(Type.String({ maxLength: MAX_PEER_MESSAGE_CHARS, description: "send: what to tell the peer — specific and self-contained." })),
49
+ });
50
+
51
+ type PeerDetails = { action: string; delivered?: boolean; peers?: string[] };
52
+
53
+ function result(t: string, details: PeerDetails): { content: [{ type: "text"; text: string }]; details: PeerDetails } {
54
+ return { content: [{ type: "text", text: t }], details };
55
+ }
56
+
57
+ /** Build the child's `contact_peer` tool. Same `defineTool` variance shim as
58
+ * `makeContactSupervisorTool`, so it slots into `customTools: ToolDefinition[]`. */
59
+ export function makeContactPeerTool(bus: InProcessBus, selfHandle: string, deps: ContactPeerDeps) {
60
+ const maxSends = deps.maxSends ?? MAX_PEER_SENDS;
61
+ let sent = 0;
62
+ return defineTool({
63
+ name: "contact_peer",
64
+ label: "Contact peer",
65
+ description: [
66
+ "Message the OTHER members working this same task with you (your strategy peers).",
67
+ "`list` shows who is still reachable; `send` pushes a one-way note to one of them.",
68
+ "You never wait for a reply — a peer answers (if it chooses) with its own send, which",
69
+ 'reaches you as a "[message from peer …]" turn. Keep notes short and substantive;',
70
+ `finalize your own answer once your position is stable. This run allows at most ${maxSends} sends.`,
71
+ ].join(" "),
72
+ promptSnippet: "contact_peer — list your run's sibling agents or send one a one-way note",
73
+ parameters: PeerParams,
74
+ async execute(_toolCallId, params: Static<typeof PeerParams>, _signal, _onUpdate, _ctx) {
75
+ if (params.action === "list") {
76
+ const peers = await deps.listPeers();
77
+ return result(
78
+ peers.length > 0
79
+ ? `Reachable peers:\n${peers.map((p) => `• ${p.label}`).join("\n")}`
80
+ : "(no peers are currently reachable — they may all have finalized; proceed solo)",
81
+ { action: "list", peers: peers.map((p) => p.handle) },
82
+ );
83
+ }
84
+ if (!params.to || params.message === undefined) {
85
+ return result("contact_peer send needs { to, message } — use `list` for peer handles.", { action: "send", delivered: false });
86
+ }
87
+ if (params.message.length > MAX_PEER_MESSAGE_CHARS) {
88
+ return result(
89
+ `contact_peer message exceeds the ${MAX_PEER_MESSAGE_CHARS}-character limit; send a smaller note.`,
90
+ { action: "send", delivered: false },
91
+ );
92
+ }
93
+ if (sent >= maxSends) {
94
+ return result(
95
+ `(peer-send budget exhausted — ${maxSends} notes already sent; stop debating and finalize your answer)`,
96
+ { action: "send", delivered: false },
97
+ );
98
+ }
99
+ sent += 1;
100
+ // Enforce the run-isolation boundary (previously only convention): a handle that is
101
+ // not one of THIS run's peers (e.g. "supervisor", or another run's child) never gets
102
+ // bus.send called at all — same "gone" wording as an actually-finished peer, so the
103
+ // tool result gives no signal either way about what exists outside this run's scope.
104
+ const inScope = (await deps.listPeers()).some((p) => p.handle === params.to);
105
+ const delivered = inScope && bus.send(selfHandle, params.to, params.message, "progress");
106
+ return result(
107
+ delivered
108
+ ? `Sent to ${params.to}.`
109
+ : `Peer "${params.to}" is gone — it may have already finalized. Proceed with your own judgement.`,
110
+ { action: "send", delivered },
111
+ );
112
+ },
113
+ });
114
+ }