@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,244 @@
1
+ /** Session identity registration and Pi lifecycle adapter. */
2
+
3
+ import type { AgentMessage } from "@earendil-works/pi-agent-core";
4
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
5
+ import { Text } from "@earendil-works/pi-tui";
6
+ import { type Static, Type } from "typebox";
7
+
8
+ import { canCallTool, type EffectiveCapabilities } from "../core/capabilities.ts";
9
+ import {
10
+ findStoredIdentity,
11
+ IDENTITY_CONTEXT_CUSTOM_TYPE,
12
+ IDENTITY_ENTRY_CUSTOM_TYPE,
13
+ makeIdentityEntry,
14
+ provisionalIdentityName,
15
+ sanitizeIdentityName,
16
+ } from "../core/session-identity.ts";
17
+ import { compactInlineText } from "../ui/presentation.ts";
18
+
19
+ export interface IdentityHost {
20
+ capabilities(): EffectiveCapabilities | undefined;
21
+ reservedNames(): string[];
22
+ exocomActive(): boolean;
23
+ onChanged(ctx: ExtensionContext): void;
24
+ }
25
+
26
+ export interface SessionIdentity {
27
+ start(ctx: ExtensionContext): void;
28
+ rename(raw: string, ctx: ExtensionContext): string;
29
+ readonly name: string;
30
+ readonly chosen: boolean;
31
+ }
32
+
33
+ const AgentNameParams = Type.Object({
34
+ name: Type.String({ minLength: 1, maxLength: 32, description: "A short personal handle, separate from persona and role." }),
35
+ });
36
+
37
+ type SessionManagerCompat = {
38
+ getSessionId?: () => string;
39
+ getHeader?: () => { id?: string } | null;
40
+ getBranch?: () => readonly unknown[];
41
+ getEntries?: () => readonly unknown[];
42
+ };
43
+
44
+ function sessionManager(ctx: ExtensionContext): SessionManagerCompat {
45
+ return (ctx as ExtensionContext & { sessionManager?: SessionManagerCompat }).sessionManager ?? {};
46
+ }
47
+
48
+ /** Read the real session id when available. Process fallback is intentionally start-only. */
49
+ function readSessionId(ctx: ExtensionContext, allowProcessFallback: boolean): string | undefined {
50
+ const manager = sessionManager(ctx);
51
+ try {
52
+ const id = manager.getSessionId?.();
53
+ if (typeof id === "string" && id.trim()) return id;
54
+ } catch {
55
+ /* Compatibility with light-weight session-manager test doubles. */
56
+ }
57
+ try {
58
+ const id = manager.getHeader?.()?.id;
59
+ if (typeof id === "string" && id.trim()) return id;
60
+ } catch {
61
+ /* Compatibility with light-weight session-manager test doubles. */
62
+ }
63
+ return allowProcessFallback ? `process-${process.pid}` : undefined;
64
+ }
65
+
66
+ function readEntries(ctx: ExtensionContext): readonly unknown[] {
67
+ const manager = sessionManager(ctx);
68
+ try {
69
+ const branch = manager.getBranch?.();
70
+ if (Array.isArray(branch)) return branch;
71
+ } catch {
72
+ /* Fall through to getEntries when a mock or older host has no branch reader. */
73
+ }
74
+ try {
75
+ const entries = manager.getEntries?.();
76
+ if (Array.isArray(entries)) return entries;
77
+ } catch {
78
+ /* Missing persistence only means there is no stored chosen identity to restore. */
79
+ }
80
+ return [];
81
+ }
82
+
83
+ function setIdentityStatus(ctx: ExtensionContext, name: string): void {
84
+ try {
85
+ ctx.ui.setStatus("persona-identity", name);
86
+ } catch {
87
+ /* Status is cosmetic; the append-only identity entry remains authoritative. */
88
+ }
89
+ }
90
+
91
+ function activeTools(pi: ExtensionAPI): readonly string[] {
92
+ try {
93
+ const getActiveTools = (pi as unknown as { getActiveTools?: () => string[] }).getActiveTools;
94
+ const names = getActiveTools?.call(pi);
95
+ return Array.isArray(names) ? names : [];
96
+ } catch {
97
+ return [];
98
+ }
99
+ }
100
+
101
+ function toolIsCallable(pi: ExtensionAPI, host: IdentityHost, name: string): boolean {
102
+ if (!activeTools(pi).includes(name)) return false;
103
+ const caps = host.capabilities();
104
+ return caps === undefined || canCallTool(caps, name);
105
+ }
106
+
107
+ function messageCustomType(message: AgentMessage): string | undefined {
108
+ const item = message as AgentMessage & { customType?: unknown };
109
+ return typeof item.customType === "string" ? item.customType : undefined;
110
+ }
111
+
112
+ function hasInboundPeerRequest(messages: readonly AgentMessage[]): boolean {
113
+ return messages.some((message) => {
114
+ const customType = messageCustomType(message);
115
+ return customType === "exocom_received" || customType === "exocom-inbound" || customType === "pi-persona:exocom-inbound";
116
+ });
117
+ }
118
+
119
+ /** Render the one-shot naming bootstrap without copying any user or peer payload into it. */
120
+ export function buildIdentityContext(
121
+ chosen: boolean,
122
+ options: { namingTool?: "agent_name" | "exocom_name"; inboundPeer?: boolean } = {},
123
+ ): string | undefined {
124
+ if (chosen || !options.namingTool) return undefined;
125
+ const source = options.inboundPeer ? "the current task or inbound peer request" : "the current user task or an inbound peer request";
126
+ return (
127
+ `[pi-persona] FIRST action: invent a distinct short personal handle from ${source}, ` +
128
+ `then call ${options.namingTool}({ name: "<your invented handle>" }) before other work. ` +
129
+ "The handle is separate from your persona and role, persists across turns and persona changes, and must never borrow a peer's or role's name. Do not make an extra model call. " +
130
+ "After the naming tool succeeds, continue the task; no separate acknowledgement is needed."
131
+ );
132
+ }
133
+
134
+ function identityReceipt(name: string, changed: boolean): string {
135
+ return `Session handle: "${name}" (${changed ? "saved" : "unchanged"}).`;
136
+ }
137
+
138
+ export function installIdentity(pi: ExtensionAPI, host: IdentityHost): SessionIdentity {
139
+ let currentSessionId: string | undefined;
140
+ let currentName = provisionalIdentityName(`process-${process.pid}`);
141
+ let currentChosen = false;
142
+ let currentSessionIsFallback = false;
143
+ let started = false;
144
+
145
+ const identity: SessionIdentity = {
146
+ start(ctx) {
147
+ const detectedSessionId = readSessionId(ctx, false);
148
+ const sessionId = detectedSessionId ?? readSessionId(ctx, true)!;
149
+ if (started && currentSessionId === sessionId) {
150
+ setIdentityStatus(ctx, currentName);
151
+ return;
152
+ }
153
+ currentSessionId = sessionId;
154
+ currentSessionIsFallback = detectedSessionId === undefined;
155
+ const stored = findStoredIdentity(readEntries(ctx), sessionId, host.reservedNames());
156
+ currentName = stored ?? provisionalIdentityName(sessionId);
157
+ currentChosen = stored !== undefined;
158
+ started = true;
159
+ setIdentityStatus(ctx, currentName);
160
+ },
161
+
162
+ rename(raw, ctx) {
163
+ const name = sanitizeIdentityName(raw, host.reservedNames());
164
+ if (!name) throw new Error("identity name is empty, unsafe, generic, or reserved by a persona/agent");
165
+ const sessionId = readSessionId(ctx, false) ?? (currentSessionIsFallback ? undefined : currentSessionId);
166
+ if (!sessionId) throw new Error("identity session is unavailable");
167
+ if (currentChosen && currentSessionId === sessionId && currentName === name) return currentName;
168
+
169
+ // Persistence comes first. A failed append therefore cannot leave memory or telemetry
170
+ // claiming a name that is absent from the session history.
171
+ pi.appendEntry(IDENTITY_ENTRY_CUSTOM_TYPE, makeIdentityEntry(sessionId, name));
172
+ currentSessionId = sessionId;
173
+ currentName = name;
174
+ currentChosen = true;
175
+ setIdentityStatus(ctx, currentName);
176
+ try {
177
+ host.onChanged(ctx);
178
+ } catch {
179
+ /* Identity persistence is authoritative; Exocom/telemetry refresh is best effort. */
180
+ }
181
+ return currentName;
182
+ },
183
+
184
+ get name() { return currentName; },
185
+ get chosen() { return currentChosen; },
186
+ };
187
+
188
+ pi.registerTool({
189
+ name: "agent_name",
190
+ label: "Agent Name",
191
+ description: "Choose a short personal handle for this session. It is separate from the active persona and role, persists across turns and persona changes, and is unavailable while Exocom is active; use exocom_name there.",
192
+ parameters: AgentNameParams,
193
+ async execute(_toolCallId, params: Static<typeof AgentNameParams>, _signal, _onUpdate, ctx) {
194
+ if (host.exocomActive()) throw new Error("agent_name is unavailable while Exocom is active; use exocom_name");
195
+ if (!activeTools(pi).includes("agent_name")) throw new Error("agent_name is not active in this session");
196
+ const caps = host.capabilities();
197
+ if (caps !== undefined && !canCallTool(caps, "agent_name")) throw new Error("agent_name is not permitted by the active persona");
198
+ const previousName = identity.chosen ? identity.name : undefined;
199
+ const name = identity.rename(params.name, ctx);
200
+ const changed = previousName !== name;
201
+ const text = identityReceipt(name, changed);
202
+ return { content: [{ type: "text", text }], details: { name, changed } };
203
+ },
204
+ renderCall(args, theme) {
205
+ const name = compactInlineText(args.name, { maxChars: 32 }) || "?";
206
+ return new Text(`${theme.fg("toolTitle", theme.bold("Agent Name "))}${theme.fg("accent", name)}`, 0, 0);
207
+ },
208
+ renderResult(result, { expanded }, theme) {
209
+ const details = result.details as unknown as { name?: unknown; changed?: unknown } | undefined;
210
+ const name = typeof details?.name === "string" ? compactInlineText(details.name, { maxChars: 32 }) : "";
211
+ const first = result.content.find((item) => item.type === "text");
212
+ const rendered = name
213
+ ? identityReceipt(name, details?.changed !== false)
214
+ : first?.type === "text" ? compactInlineText(first.text, { maxChars: 96 }) : "Agent name failed";
215
+ return new Text(theme.fg(name ? (expanded ? "toolOutput" : "accent") : "error", rendered), 0, 0);
216
+ },
217
+ });
218
+
219
+ // Context is rebuilt for every provider request. The hidden message is actionable only until a
220
+ // name is chosen; emitting identity metadata afterwards makes it look like a fresh user turn on
221
+ // every tool-loop continuation. Always strip an older copy, then add only the naming bootstrap.
222
+ pi.on("context", (event, _ctx) => {
223
+ const messages = event.messages.filter((message) => messageCustomType(message) !== IDENTITY_CONTEXT_CUSTOM_TYPE);
224
+ const namingTool: "exocom_name" | "agent_name" | undefined = host.exocomActive()
225
+ ? toolIsCallable(pi, host, "exocom_name") ? "exocom_name" : undefined
226
+ : toolIsCallable(pi, host, "agent_name") ? "agent_name" : undefined;
227
+ const contextOptions = {
228
+ inboundPeer: hasInboundPeerRequest(event.messages),
229
+ ...(namingTool === undefined ? {} : { namingTool }),
230
+ };
231
+ const content = buildIdentityContext(currentChosen, contextOptions);
232
+ if (content === undefined) return { messages };
233
+ const contextMessage = {
234
+ role: "custom" as const,
235
+ customType: IDENTITY_CONTEXT_CUSTOM_TYPE,
236
+ content,
237
+ display: false,
238
+ timestamp: Date.now(),
239
+ } as unknown as AgentMessage;
240
+ return { messages: [...messages, contextMessage] };
241
+ });
242
+
243
+ return identity;
244
+ }
@@ -0,0 +1,175 @@
1
+ /**
2
+ * Small leaf helpers shared by the extension composition root and extracted hook/tool modules.
3
+ * This module must not import `extension.ts` or any of the registration modules that consume it.
4
+ */
5
+ import { type ExtensionAPI, keyHint } from "@earendil-works/pi-coding-agent";
6
+
7
+ import { sanitizeDisplayLabel } from "../core/display-label.ts";
8
+ import { boundCompletionSurface, MAX_COMPLETION_REPORT_CHARS } from "../engine/async.ts";
9
+ import type { InboundDecision } from "../exocom/inbound.ts";
10
+ import { EXOCOM } from "../exocom/limits.ts";
11
+ import type { DisplayPeer, ExocomInboundResult } from "../exocom/plane.ts";
12
+ import type { DelegateView } from "../tools/delegate.ts";
13
+ import type { AgentNodeStatus } from "../ui/agent-tree.ts";
14
+ import { compactInlineText } from "../ui/presentation.ts";
15
+
16
+ const PI_PERSONA_TOOL_ERROR = "__piPersonaToolError";
17
+
18
+ export function failureDetails<T extends object>(details: T): T & { __piPersonaToolError: true } {
19
+ return { ...details, [PI_PERSONA_TOOL_ERROR]: true };
20
+ }
21
+
22
+ export function piPersonaToolErrorPatch(details: unknown): { isError: true; details: Record<string, unknown> } | undefined {
23
+ if (!details || typeof details !== "object" || Array.isArray(details)) return undefined;
24
+ const record = details as Record<string, unknown>;
25
+ if (record[PI_PERSONA_TOOL_ERROR] !== true) return undefined;
26
+ const clean = { ...record };
27
+ delete clean[PI_PERSONA_TOOL_ERROR];
28
+ return { isError: true, details: clean };
29
+ }
30
+
31
+ export function canDeliverPersonaNotification(orchestrating: boolean, processingDeferred: boolean, hostIdle: boolean): boolean {
32
+ return !orchestrating && !processingDeferred && hostIdle;
33
+ }
34
+
35
+ export function sanitizePeerField(value: string, max: number): string {
36
+ return value
37
+ .normalize("NFKC")
38
+ .replace(/[^A-Za-z0-9._/@:+#-]+/g, "-")
39
+ .replace(/-{2,}/g, "-")
40
+ .replace(/^[-._/@:+#]+|[-._/@:+#]+$/g, "")
41
+ .slice(0, max);
42
+ }
43
+
44
+ export function sanitizeLabel(s: string): string {
45
+ return sanitizePeerField(s, 80) || "peer";
46
+ }
47
+
48
+ const EXOCOM_ROUTING_TOKEN = /@([a-f0-9]{24})$/i;
49
+
50
+ /** Mirror transport routing for observability: a qualified target's readable prefix may be stale
51
+ * after rename, while its session-hash suffix remains authoritative. Ambiguity emits no speculative
52
+ * edge; the transport will fail the same collision closed. */
53
+ export function canonicalExocomTelemetryTargets(
54
+ peers: ReadonlyArray<Pick<DisplayPeer, "session_id" | "displayName" | "target">>,
55
+ target: string,
56
+ ): string[] {
57
+ if (target === "*") return peers.map((peer) => peer.session_id);
58
+ const routing = EXOCOM_ROUTING_TOKEN.exec(target);
59
+ if (routing) {
60
+ const suffix = routing[1]!.toLowerCase();
61
+ const matches = peers.filter((peer) => EXOCOM_ROUTING_TOKEN.exec(peer.target)?.[1]?.toLowerCase() === suffix);
62
+ return matches.length === 1 ? [matches[0]!.session_id] : [];
63
+ }
64
+ const display = peers.find((peer) => peer.displayName === target);
65
+ return display ? [display.session_id] : [];
66
+ }
67
+
68
+ export function exocomInboundDisposition(decision: InboundDecision): ExocomInboundResult {
69
+ if ("deliver" in decision) return { accepted: true };
70
+ if ("duplicate" in decision) return { accepted: true, duplicate: true };
71
+ return { accepted: false, reason: decision.drop };
72
+ }
73
+
74
+ export function formatExocomQueuedToast(label: string, inReplyTo: string | undefined): string {
75
+ return `exocom: ${inReplyTo === undefined ? "message" : "reply"} from ${label} queued`;
76
+ }
77
+
78
+ export interface ExocomQueuedToast {
79
+ label: string;
80
+ inReplyTo: string | undefined;
81
+ }
82
+
83
+ export function formatExocomQueuedBatchToast(items: readonly ExocomQueuedToast[]): string {
84
+ if (items.length === 0) return "";
85
+ const first = items[0]!;
86
+ if (items.length === 1) return formatExocomQueuedToast(first.label, first.inReplyTo);
87
+ const counts = new Map<string, number>();
88
+ for (const item of items) counts.set(item.label, (counts.get(item.label) ?? 0) + 1);
89
+ const senders = [...counts.entries()];
90
+ const visible = senders.slice(0, 3).map(([label, count]) => count > 1 ? `${label} ×${count}` : label);
91
+ const omitted = senders.length - visible.length;
92
+ return `exocom: ${items.length} messages/replies queued from ${visible.join(", ")}${omitted > 0 ? `, +${omitted} more` : ""}`;
93
+ }
94
+
95
+ export function boundExocomInboundBatch(items: readonly string[]): string {
96
+ return boundCompletionSurface(
97
+ items.join("\n\n"),
98
+ "exocom inbound batch truncated; remaining peer messages stay queued for later delivery",
99
+ );
100
+ }
101
+
102
+ export function exocomInboundBatchSize(items: readonly string[]): number {
103
+ let chars = 0;
104
+ let count = 0;
105
+ for (const item of items) {
106
+ const withItem = chars + (count > 0 ? 2 : 0) + item.length;
107
+ if (count > 0 && withItem > MAX_COMPLETION_REPORT_CHARS) break;
108
+ chars = withItem;
109
+ count += 1;
110
+ }
111
+ return Math.max(1, count);
112
+ }
113
+
114
+ export const EXOCOM_INBOX_MAX = 3 * EXOCOM.SENDER_MAX_MSGS;
115
+
116
+ export function agentNodeStatusForDelegate(view: Pick<DelegateView, "running" | "ok"> & { failureKind?: DelegateView["failureKind"] }): AgentNodeStatus {
117
+ if (view.running) return "running";
118
+ if (view.failureKind === "abort") return "stopped";
119
+ return view.ok ? "done" : "failed";
120
+ }
121
+
122
+ export function sendPersonaFollowUp(
123
+ pi: Pick<ExtensionAPI, "sendMessage">,
124
+ content: string,
125
+ customType = "pi-persona",
126
+ ): void {
127
+ const body = customType === "pi-persona" ? content.replace(/^\[pi-persona\]\s*/, "") : content;
128
+ pi.sendMessage(
129
+ { customType, content: body, display: true },
130
+ { deliverAs: "followUp", triggerTurn: true },
131
+ );
132
+ }
133
+
134
+ export function expandDetailHint(): string {
135
+ try {
136
+ return keyHint("app.tools.expand", "to expand");
137
+ } catch {
138
+ return "expand to see full detail";
139
+ }
140
+ }
141
+
142
+ export function formatCouncilCallLabel(strategy: string, roster: string): string {
143
+ const safeStrategy = compactInlineText(strategy, { maxChars: 80 }) || "?";
144
+ const safeRoster = compactInlineText(roster, { maxChars: 80 }) || "?";
145
+ return `council ${safeStrategy}${strategy === roster ? "" : ` · ${safeRoster}`}`;
146
+ }
147
+
148
+ export interface PendingAsk {
149
+ askId: string;
150
+ text: string;
151
+ }
152
+
153
+ export function reconcileAnsweredAsk(
154
+ askId: string,
155
+ notifier: { discard: (pred: (item: PendingAsk) => boolean) => void },
156
+ inbox: { takeWhere: (handle: string, pred: (env: { id: string }) => boolean) => unknown },
157
+ handle: string,
158
+ ): void {
159
+ notifier.discard((item) => item.askId === askId);
160
+ inbox.takeWhere(handle, (env) => env.id === askId);
161
+ }
162
+
163
+ export function shouldReportHeartbeatFailure(consecutiveFailures: number): boolean {
164
+ return consecutiveFailures === 1 || consecutiveFailures % 10 === 0;
165
+ }
166
+
167
+ export function fenceIntercomOutcome(out: { text: string; details: { action: string; messages?: unknown[] } }, fence: (t: string) => string): string {
168
+ const untrusted = out.details.action === "inbox" && (out.details.messages?.length ?? 0) > 0;
169
+ return untrusted ? fence(out.text) : out.text;
170
+ }
171
+
172
+ export function coachingDisabledHint(personaName: string | undefined): string {
173
+ const who = sanitizeDisplayLabel(personaName ?? "default", "default");
174
+ return `(coaching is OFF for persona "${who}" — sub-agents get no contact_supervisor tool, so the message bus is empty. To just watch or redirect them use action "peek"/"steer"; to exchange messages, add \`coaching: true\` or switch to a coaching persona.)`;
175
+ }