@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,307 @@
1
+ /** Exocom scope registry — one JSON file per live instance. */
2
+ import { createHash, randomUUID } from "node:crypto";
3
+ import { existsSync, lstatSync, linkSync, mkdirSync, readdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
4
+ import { agentsDir, registryPath } from "./paths.ts";
5
+
6
+ export interface RegistryEntry {
7
+ /** `name` is display-only — NOT unique, NOT a lookup key. The registry FILE is keyed by
8
+ * `session_id` (via `sessionKey`), so two instances launched under the SAME persona name
9
+ * persist as two distinct files; a shared name is disambiguated only at display time
10
+ * (see plane.ts's `listPeers()` → "orion"/"orion#2"). */
11
+ session_id: string; name: string; persona: string; purpose: string; color: string; model: string;
12
+ pid: number; endpoint: string; cwd: string; context_pct: number; inbox: number;
13
+ heartbeat_at: string;
14
+ public_key?: string;
15
+ /** New peers publish a safe workspace identity as one all-or-none tuple. Legacy entries omit it. */
16
+ workspace_id?: string;
17
+ workspace_code?: string;
18
+ workspace_label?: string;
19
+ }
20
+
21
+ /** The fields that prove which process owns a registry slot. `session_id` is the
22
+ * filename key, while endpoint + public_key bind the slot to the live transport
23
+ * and signing identity that registered it. */
24
+ export type RegistryOwnership = Pick<RegistryEntry, "session_id" | "endpoint"> & {
25
+ public_key?: string;
26
+ };
27
+
28
+ const CONTROL_OR_MARKUP = /[\u0000-\u001f\u007f-\u009f\u2028\u2029<>]/g;
29
+ const HAS_CONTROL_OR_MARKUP = /[\u0000-\u001f\u007f-\u009f\u2028\u2029<>]/;
30
+ /** Skip a registry file that is not a regular file or is larger than this — a hostile peer
31
+ * could otherwise force this process to parse an unbounded JSON blob on every heartbeat. */
32
+ export const MAX_REGISTRY_FILE_BYTES = 64 * 1024;
33
+
34
+ export function normalizeMetadataText(value: unknown, max: number, fallback = ""): string {
35
+ if (typeof value !== "string") return fallback;
36
+ const clean = value.normalize("NFKC").replace(CONTROL_OR_MARKUP, " ").replace(/\s+/g, " ").trim();
37
+ return Array.from(clean).slice(0, max).join("") || fallback;
38
+ }
39
+
40
+ export function normalizePeerName(value: unknown): string {
41
+ return normalizeMetadataText(value, 48, "peer").replace(/[*#\[\]{}]/g, "-");
42
+ }
43
+
44
+ function opaqueString(value: unknown, max: number): string | undefined {
45
+ if (typeof value !== "string" || value.length === 0 || value.length > max || HAS_CONTROL_OR_MARKUP.test(value)) return undefined;
46
+ return value;
47
+ }
48
+
49
+ export function normalizeRegistryEntry(value: unknown): RegistryEntry | undefined {
50
+ if (!value || typeof value !== "object") return undefined;
51
+ const e = value as Record<string, unknown>;
52
+ const sessionId = opaqueString(e.session_id, 128);
53
+ const endpoint = opaqueString(e.endpoint, 1_024);
54
+ const heartbeatAt = opaqueString(e.heartbeat_at, 128);
55
+ if (!sessionId || !/^[A-Za-z0-9._:-]+$/.test(sessionId) || !endpoint || !heartbeatAt || !Number.isFinite(Date.parse(heartbeatAt))) return undefined;
56
+ if (!Number.isInteger(e.pid) || (e.pid as number) <= 0) return undefined;
57
+ const contextPct = typeof e.context_pct === "number" && Number.isFinite(e.context_pct) ? Math.max(0, Math.min(100, e.context_pct)) : 0;
58
+ const inbox = typeof e.inbox === "number" && Number.isFinite(e.inbox) ? Math.max(0, Math.min(1_000_000, Math.floor(e.inbox))) : 0;
59
+ const publicKey = opaqueString(e.public_key, 256);
60
+ const hasWorkspaceMetadata = e.workspace_id !== undefined || e.workspace_code !== undefined || e.workspace_label !== undefined;
61
+ let workspace: Pick<RegistryEntry, "workspace_id" | "workspace_code" | "workspace_label"> = {};
62
+ if (hasWorkspaceMetadata) {
63
+ if (typeof e.workspace_id !== "string" || !/^[0-9a-f]{24}$/.test(e.workspace_id)
64
+ || typeof e.workspace_code !== "string" || !/^[0-9A-Za-z]{4}$/.test(e.workspace_code)
65
+ || typeof e.workspace_label !== "string") return undefined;
66
+ workspace = {
67
+ workspace_id: e.workspace_id,
68
+ workspace_code: e.workspace_code,
69
+ workspace_label: normalizeMetadataText(e.workspace_label, 80, "workspace"),
70
+ };
71
+ }
72
+ return {
73
+ session_id: sessionId,
74
+ name: normalizePeerName(e.name),
75
+ persona: normalizeMetadataText(e.persona, 64),
76
+ purpose: normalizeMetadataText(e.purpose, 240),
77
+ color: typeof e.color === "string" && /^#[0-9A-Fa-f]{3,8}$/.test(e.color) ? e.color : "#36F9F6",
78
+ model: normalizeMetadataText(e.model, 160, "unknown"),
79
+ pid: e.pid as number,
80
+ endpoint,
81
+ cwd: normalizeMetadataText(e.cwd, 1_024),
82
+ context_pct: contextPct,
83
+ inbox,
84
+ heartbeat_at: new Date(Date.parse(heartbeatAt)).toISOString(),
85
+ ...(publicKey && /^[A-Za-z0-9+/]+={0,2}$/.test(publicKey) ? { public_key: publicKey } : {}),
86
+ ...workspace,
87
+ };
88
+ }
89
+
90
+ export function registryEntryFixture(over: Partial<RegistryEntry>): RegistryEntry {
91
+ return { session_id: "s", name: "n", persona: "", purpose: "", color: "#36F9F6", model: "m",
92
+ pid: 0, endpoint: "/e", cwd: "/", context_pct: 0, inbox: 0,
93
+ heartbeat_at: new Date(0).toISOString(), ...over };
94
+ }
95
+
96
+ /** Short, fs-safe registry key for a session_id (mirrors paths.ts's `workspaceHash`). Keying the
97
+ * registry FILE by this — rather than by the display `name` — makes two instances launched
98
+ * under the SAME persona name collision-proof BY CONSTRUCTION: distinct session_ids always land
99
+ * in distinct files, so there is no name-collision window to atomically claim/suffix (the old
100
+ * `claimEntry` existed only because the name itself was the file key). */
101
+ export function sessionKey(sessionId: string): string {
102
+ return createHash("sha256").update(sessionId).digest("hex").slice(0, 16);
103
+ }
104
+
105
+ /** Public keys THIS process has registered, keyed by session_id — the second half of the
106
+ * key-preservation guarantee. The preserve-from-disk step in `writeEntry` only covers the
107
+ * file-still-exists case; an entry can be removed while its instance is alive (a peer's transient
108
+ * send error calls `removeEntry`, or a >STALE_AFTER_MS stall lets every peer prune it), and the
109
+ * next heartbeat then RE-CREATES the file with nothing on disk to preserve from. A plane's
110
+ * ed25519 key exists nowhere but its own process, so a re-registration that dropped it would
111
+ * leave the live instance permanently unverifiable. Keyed per session_id, so one instance's key
112
+ * is never lent to another; bounded so a long-lived process can't accumulate without limit. */
113
+ const registeredKeys = new Map<string, string>();
114
+ const MAX_REMEMBERED_KEYS = 256;
115
+
116
+ function rememberKey(sessionId: string, publicKey: string): void {
117
+ registeredKeys.delete(sessionId); // re-insert so the eviction order stays least-recently-written
118
+ registeredKeys.set(sessionId, publicKey);
119
+ while (registeredKeys.size > MAX_REMEMBERED_KEYS) {
120
+ const oldest = registeredKeys.keys().next().value;
121
+ if (oldest === undefined) break;
122
+ registeredKeys.delete(oldest);
123
+ }
124
+ }
125
+
126
+ /** Injection seams for the rename retry below (tests drive it on any OS) — mirrors config-store. */
127
+ export interface RegistryWriteIO {
128
+ rename?: (from: string, to: string) => void;
129
+ sleep?: (ms: number) => void;
130
+ }
131
+
132
+ /** Test seam for the compare-and-delete claim. Production uses the atomic rename
133
+ * claim and leaves this unset. */
134
+ export interface RegistryRemoveIO {
135
+ afterCompare?: () => void;
136
+ }
137
+
138
+ // A handle held by a virus scanner, the search indexer or a peer mid-read is transient, so a few
139
+ // short waits usually win the race — 25+50+75 ms of backoff at worst, on a 30s heartbeat.
140
+ const RENAME_ATTEMPTS = 4;
141
+ const RENAME_BACKOFF_MS = 25;
142
+
143
+ function sleepSync(ms: number): void {
144
+ // The registry API is synchronous all the way up to the heartbeat tick, so the backoff has to
145
+ // block; Atomics.wait is the standard synchronous sleep.
146
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
147
+ }
148
+
149
+ export function writeEntry(agentDir: string, hash: string, entry: RegistryEntry, io: RegistryWriteIO = {}): void {
150
+ let safe = normalizeRegistryEntry(entry);
151
+ if (!safe) throw new Error("exocom registry: invalid entry");
152
+ const dir = agentsDir(agentDir, hash);
153
+ mkdirSync(dir, { recursive: true });
154
+ const final = registryPath(agentDir, hash, sessionKey(safe.session_id));
155
+ // A key THIS process registered for THIS session_id outranks whatever is on disk: it is the
156
+ // only value guaranteed to match the private half still signing our frames, and it survives
157
+ // the entry file being deleted underneath us. Disk is the fallback for a key we never wrote.
158
+ if (!safe.public_key) {
159
+ const remembered = registeredKeys.get(safe.session_id);
160
+ if (remembered) safe = { ...safe, public_key: remembered };
161
+ }
162
+ if (!safe.public_key && existsSync(final)) {
163
+ try {
164
+ const existing = normalizeRegistryEntry(JSON.parse(readFileSync(final, "utf8")));
165
+ if (existing?.session_id === safe.session_id && existing.public_key) {
166
+ safe = { ...safe, public_key: existing.public_key };
167
+ }
168
+ } catch { /* a malformed previous entry contributes no authentication state */ }
169
+ }
170
+ if (safe.public_key) rememberKey(safe.session_id, safe.public_key);
171
+ const payload = `${JSON.stringify(safe, null, 2)}\n`;
172
+ // Temp file + same-volume rename, so a peer reading mid-write never parses a half-written entry.
173
+ // That is atomic on POSIX; Windows is NOT the same — rename fails with EPERM/EBUSY while ANY
174
+ // other handle holds the target open (Defender, the search indexer, a peer mid-read), and the
175
+ // registry lives in an ordinary user directory where that is routine. So a contended rename is
176
+ // retried and then written in place: a torn entry is skipped by `readAll` and repaired by the
177
+ // next heartbeat, whereas a lost write drops this instance out of every peer's pool.
178
+ const tmp = `${final}.tmp-${process.pid}-${randomUUID()}`;
179
+ const rename = io.rename ?? renameSync;
180
+ const sleep = io.sleep ?? sleepSync;
181
+ try {
182
+ writeFileSync(tmp, payload, { encoding: "utf8", mode: 0o600 });
183
+ } catch (err) {
184
+ try { unlinkSync(tmp); } catch { /* best-effort */ }
185
+ throw err;
186
+ }
187
+ for (let attempt = 1; ; attempt++) {
188
+ try {
189
+ rename(tmp, final);
190
+ return;
191
+ } catch (err) {
192
+ const code = (err as NodeJS.ErrnoException).code;
193
+ if (attempt < RENAME_ATTEMPTS && (code === "EPERM" || code === "EBUSY" || code === "EACCES")) {
194
+ sleep(RENAME_BACKOFF_MS * attempt);
195
+ continue;
196
+ }
197
+ try {
198
+ // The scratch file must not survive for every pid that ever tried, whether the
199
+ // in-place write lands or throws (a real failure still reaches the caller, which
200
+ // counts it as a heartbeat failure).
201
+ writeFileSync(final, payload, { encoding: "utf8", mode: 0o600 });
202
+ } finally {
203
+ try { unlinkSync(tmp); } catch { /* best-effort */ }
204
+ }
205
+ return;
206
+ }
207
+ }
208
+ }
209
+
210
+ export function readAll(agentDir: string, hash: string): RegistryEntry[] {
211
+ const dir = agentsDir(agentDir, hash);
212
+ if (!existsSync(dir)) return [];
213
+ const out: RegistryEntry[] = [];
214
+ for (const f of readdirSync(dir)) {
215
+ if (!f.endsWith(".json")) continue;
216
+ try {
217
+ const path = registryPath(agentDir, hash, f.slice(0, -5));
218
+ const st = lstatSync(path);
219
+ if (!st.isFile() || st.size > MAX_REGISTRY_FILE_BYTES) continue;
220
+ const e = normalizeRegistryEntry(JSON.parse(readFileSync(path, "utf8")));
221
+ if (e && f.slice(0, -5) === sessionKey(e.session_id)) out.push(e);
222
+ } catch { /* skip malformed */ }
223
+ }
224
+ return out;
225
+ }
226
+
227
+ export function isAlive(pid: number): boolean {
228
+ if (!Number.isInteger(pid) || pid <= 0) return false;
229
+ try { process.kill(pid, 0); return true; } catch (err) { return (err as NodeJS.ErrnoException).code === "EPERM"; }
230
+ }
231
+
232
+ /** Remove dead-pid AND stale-heartbeat entries; return the live set (R7). */
233
+ export function prune(
234
+ agentDir: string, hash: string,
235
+ opts: { now: number; staleMs: number; isAlive?: (pid: number) => boolean },
236
+ ): RegistryEntry[] {
237
+ const alive = opts.isAlive ?? isAlive;
238
+ const live: RegistryEntry[] = [];
239
+ for (const e of readAll(agentDir, hash)) {
240
+ const heartbeat = Date.parse(e.heartbeat_at);
241
+ const stale = !Number.isFinite(heartbeat) || opts.now - heartbeat > opts.staleMs;
242
+ if (alive(e.pid) && !stale) { live.push(e); continue; }
243
+ removeEntryIfMatches(agentDir, hash, e);
244
+ }
245
+ return live;
246
+ }
247
+
248
+ export function removeEntry(agentDir: string, hash: string, sessionId: string): void {
249
+ try { unlinkSync(registryPath(agentDir, hash, sessionKey(sessionId))); } catch { /* best-effort */ }
250
+ }
251
+
252
+ function restoreClaim(agentPath: string, claimedPath: string): void {
253
+ // An exclusive hard-link restore cannot overwrite a replacement that a writer
254
+ // installed while the slot was claimed. In that case the claimed old entry is
255
+ // discarded and the writer's replacement remains authoritative.
256
+ for (let attempt = 1; ; attempt++) {
257
+ try {
258
+ linkSync(claimedPath, agentPath);
259
+ try { unlinkSync(claimedPath); } catch { /* link is now the restored entry */ }
260
+ return;
261
+ } catch (err) {
262
+ const code = (err as NodeJS.ErrnoException).code;
263
+ if (code === "EEXIST" || existsSync(agentPath)) {
264
+ try { unlinkSync(claimedPath); } catch { /* best-effort */ }
265
+ return;
266
+ }
267
+ if (attempt >= RENAME_ATTEMPTS || !["EPERM", "EBUSY", "EACCES", "ENOENT"].includes(code ?? "")) break;
268
+ try { sleepSync(RENAME_BACKOFF_MS * attempt); } catch { break; }
269
+ }
270
+ }
271
+ // Do not fall back to rename: on POSIX it would overwrite a writer that wins
272
+ // immediately after the existsSync check. Leaving the quarantine file is safer
273
+ // than deleting or overwriting a replacement; the next heartbeat writes the
274
+ // live slot back at its canonical path.
275
+ }
276
+
277
+ /** Remove a registry entry only while it still belongs to the caller that observed it.
278
+ *
279
+ * Registry updates use a same-volume rename, so a peer can replace the session file
280
+ * between `readAll()` and a cleanup action. The old session-only unlink would then
281
+ * delete the replacement. First atomically claim the file, then compare session
282
+ * key, endpoint and signing key while no writer can address that path. A mismatch
283
+ * is restored without overwriting a replacement that appeared meanwhile.
284
+ */
285
+ export function removeEntryIfMatches(agentDir: string, hash: string, expected: RegistryOwnership, io: RegistryRemoveIO = {}): boolean {
286
+ const path = registryPath(agentDir, hash, sessionKey(expected.session_id));
287
+ const claimed = `${path}.claim-${process.pid}-${randomUUID()}`;
288
+ let claimedByUs = false;
289
+ try {
290
+ renameSync(path, claimed);
291
+ claimedByUs = true;
292
+ const current = normalizeRegistryEntry(JSON.parse(readFileSync(claimed, "utf8")));
293
+ const matches = current !== undefined && current.session_id === expected.session_id && current.endpoint === expected.endpoint
294
+ && (current.public_key ?? undefined) === (expected.public_key ?? undefined);
295
+ if (matches) {
296
+ io.afterCompare?.();
297
+ unlinkSync(claimed);
298
+ claimedByUs = false;
299
+ return true;
300
+ }
301
+ return false;
302
+ } catch {
303
+ return false;
304
+ } finally {
305
+ if (claimedByUs) restoreClaim(path, claimed);
306
+ }
307
+ }
@@ -0,0 +1,42 @@
1
+ /** Resolve one session-pinned Exocom scope without confusing it with the Pi's real workspace. */
2
+ import { basename, resolve } from "node:path";
3
+
4
+ import { allocateJoinCode, resolveJoinCode, validateJoinCode } from "./codes.ts";
5
+ import { workspaceHash } from "./paths.ts";
6
+ import { normalizeMetadataText } from "./registry.ts";
7
+
8
+ export interface ExocomScope {
9
+ /** Full 24-hex identity used by registry, transport, ledger, and artifacts. */
10
+ scopeWorkspaceId: string;
11
+ /** Short, case-sensitive presentation alias for `scopeWorkspaceId`. */
12
+ scopeCode: string;
13
+ /** The workspace this Pi can actually inspect, regardless of the scope it joined. */
14
+ homeWorkspaceId: string;
15
+ homeWorkspaceCode: string;
16
+ homeWorkspaceLabel: string;
17
+ /** True only when this Pi is attached to another workspace's Exocom plane. */
18
+ joined: boolean;
19
+ }
20
+
21
+ export function selectExocomScope(agentDir: string, cwd: string, joinCode?: string): ExocomScope {
22
+ const homeWorkspaceId = workspaceHash(cwd);
23
+ let scopeWorkspaceId = homeWorkspaceId;
24
+ let scopeCode: string | undefined;
25
+ if (joinCode !== undefined) {
26
+ const validCode = validateJoinCode(joinCode);
27
+ const resolvedScope = resolveJoinCode(agentDir, validCode);
28
+ if (!resolvedScope) throw new Error(`unknown Exocom join code "${validCode}"`);
29
+ scopeWorkspaceId = resolvedScope;
30
+ scopeCode = validCode;
31
+ }
32
+ const homeWorkspaceCode = allocateJoinCode(agentDir, homeWorkspaceId);
33
+ const label = normalizeMetadataText(basename(resolve(cwd)), 80, "workspace");
34
+ return {
35
+ scopeWorkspaceId,
36
+ scopeCode: scopeCode ?? homeWorkspaceCode,
37
+ homeWorkspaceId,
38
+ homeWorkspaceCode,
39
+ homeWorkspaceLabel: label,
40
+ joined: scopeWorkspaceId !== homeWorkspaceId,
41
+ };
42
+ }
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Ownership READ over the work ledger — pure formatting, no I/O.
3
+ *
4
+ * A peer today learns who owns what only when its own claim or ask collides with someone else's.
5
+ * This renders the ledger as one bounded status view — the same read `exocom_status` and `/exocom`
6
+ * both hand back — so a session can check before it claims or writes, not just after a refusal.
7
+ *
8
+ * `slice`, `write_set`, `question`, and every peer display name are PEER-AUTHORED and only
9
+ * length-bounded at the wire boundary (envelope.ts) — a hostile peer can put terminal escapes or
10
+ * instruction-shaped text in any of them. Every such field is stripped and collapsed
11
+ * ({@link untrusted}, shared by every exocom surface that renders these fields — see
12
+ * `untrusted.ts`) before it is rendered, own rows included (defense in depth: this session's
13
+ * own prior tool calls are not a different trust boundary from the ledger file itself). The two
14
+ * sections that can ONLY contain a peer's own claims or a peer's own question — "peer claims" and
15
+ * "asks waiting for you" — are additionally wrapped in one `fencePeer` per section, so the model
16
+ * reads them as quoted data rather than instructions, the same discipline `pendingAskBlock`
17
+ * already applies to a single pending ask.
18
+ */
19
+ import type { LedgerAsk, LedgerClaim, LedgerState } from "./ledger.ts";
20
+ import { formatDuration } from "../core/time.ts";
21
+ import { fencePeer } from "../core/fence.ts";
22
+ import { renderWriteSet, untrusted, UNTRUSTED_MAX } from "./untrusted.ts";
23
+
24
+ /** No section may hand the model an unbounded ledger — a long-lived scope with many claims stays
25
+ * one bounded read, same discipline as the pending-ask block and the peer roster. */
26
+ const MAX_ROWS = 20;
27
+
28
+ function ageFor(ts: string, now: number): string {
29
+ return formatDuration(now - Date.parse(ts));
30
+ }
31
+
32
+ function byTsAscending(a: { ts: string }, b: { ts: string }): number {
33
+ return Date.parse(a.ts) - Date.parse(b.ts);
34
+ }
35
+
36
+ function resolveLabel(sessionId: string, labelFor: (sessionId: string) => string | undefined): string {
37
+ return untrusted(labelFor(sessionId) ?? sessionId, UNTRUSTED_MAX.label);
38
+ }
39
+
40
+ function claimRow(claim: LedgerClaim, self: string, labelFor: (sessionId: string) => string | undefined, now: number): string {
41
+ // Own rows omit the label — a session does not need to be told its own name.
42
+ const prefix = claim.from_session === self ? "" : `${resolveLabel(claim.from_session, labelFor)} · `;
43
+ return `• ${prefix}${untrusted(claim.slice, UNTRUSTED_MAX.slice)} · ${renderWriteSet(claim.write_set)} · ${ageFor(claim.ts, now)}`;
44
+ }
45
+
46
+ function askRow(ask: LedgerAsk, otherSession: string, labelFor: (sessionId: string) => string | undefined, now: number): string {
47
+ const label = resolveLabel(otherSession, labelFor);
48
+ return `• ${label} · ${ask.work_key} · ${ask.ask_id} · "${untrusted(ask.question, UNTRUSTED_MAX.question)}" · ${ageFor(ask.ts, now)}`;
49
+ }
50
+
51
+ /** `fence: true` wraps the WHOLE section body in one `fencePeer` call — the title stays outside
52
+ * it, matching `attributePeer`'s anti-spoofing shape (a payload cannot fake the section it's in). */
53
+ function section(title: string, rows: string[], options: { fence?: boolean } = {}): string {
54
+ if (rows.length === 0) return `${title}:\n(none)`;
55
+ const bounded = rows.length <= MAX_ROWS ? rows : [...rows.slice(0, MAX_ROWS), `… +${rows.length - MAX_ROWS} more`];
56
+ const body = bounded.join("\n");
57
+ return `${title}:\n${options.fence ? fencePeer(body) : body}`;
58
+ }
59
+
60
+ /**
61
+ * Render the whole ledger from `self`'s point of view: what it owns, what peers own, who is
62
+ * waiting on it, and what it is waiting on — the read that lets a session check ownership before
63
+ * it claims or writes, instead of learning it only from a refused claim.
64
+ */
65
+ export function formatLedgerStatus(
66
+ state: LedgerState,
67
+ self: string,
68
+ labelFor: (sessionId: string) => string | undefined,
69
+ now: number,
70
+ ): string {
71
+ const ownClaims = state.claims.filter((claim) => claim.from_session === self).sort(byTsAscending);
72
+ const peerClaims = state.claims.filter((claim) => claim.from_session !== self).sort(byTsAscending);
73
+ const asksForYou = state.asks.filter((ask) => ask.to_session === self).sort(byTsAscending);
74
+ const yourAsks = state.asks.filter((ask) => ask.from_session === self).sort(byTsAscending);
75
+
76
+ if (ownClaims.length === 0 && peerClaims.length === 0 && asksForYou.length === 0 && yourAsks.length === 0) {
77
+ return "nothing claimed or pending";
78
+ }
79
+
80
+ return [
81
+ section("your claims", ownClaims.map((claim) => claimRow(claim, self, labelFor, now))),
82
+ section("peer claims", peerClaims.map((claim) => claimRow(claim, self, labelFor, now)), { fence: true }),
83
+ section("asks waiting for you", asksForYou.map((ask) => askRow(ask, ask.from_session, labelFor, now)), { fence: true }),
84
+ section("your open asks", yourAsks.map((ask) => askRow(ask, ask.to_session, labelFor, now))),
85
+ ].join("\n\n");
86
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Sanitizer for PEER-AUTHORED text — shared by every exocom surface that renders a peer-authored
3
+ * field into a status report, a UI notification, or a tool result the model reads: `slice`,
4
+ * `write_set` entries, `question`, and every peer display name/label. Every such field is only
5
+ * length-bounded at the wire boundary (envelope.ts) — a hostile peer can put terminal escapes or
6
+ * instruction-shaped text in any of them. `untrusted()` strips terminal controls, collapses the
7
+ * field to one line, and bounds its length before the value is ever interpolated downstream. One
8
+ * shared helper (rather than a local copy per surface) so a fix here reaches every surface, and
9
+ * `UNTRUSTED_MAX` is the single set of per-field caps every surface renders against.
10
+ */
11
+ import { stripTerminalControls } from "../core/fence.ts";
12
+
13
+ /** Strip terminal controls, collapse the field to one line, and bound its length. Every
14
+ * peer-authored field goes through this before it is ever interpolated into a report, a
15
+ * notification, or a tool result. */
16
+ export function untrusted(value: string, max: number): string {
17
+ return stripTerminalControls(value).replace(/\s+/g, " ").trim().slice(0, max);
18
+ }
19
+
20
+ /** Per-field length caps shared across every surface that renders these ledger fields, so the
21
+ * bounds cannot drift between (e.g.) the status view and a write-guard warning. */
22
+ export const UNTRUSTED_MAX = {
23
+ slice: 80,
24
+ writePath: 120,
25
+ question: 200,
26
+ label: 48,
27
+ } as const;
28
+
29
+ /** A single claim's write_set is peer-controlled in length up to 64 paths (envelope.ts); the
30
+ * rendered row stays one bounded line rather than growing with the claim — shared by the ledger
31
+ * status view (status.ts) and the write-guard warning (write-guard.ts) so the shape can't drift. */
32
+ const MAX_WRITE_SET_ENTRIES = 8;
33
+
34
+ /** Sanitize and bound-format a peer's `write_set` for display: `"a, b, c"`, or the first
35
+ * {@link MAX_WRITE_SET_ENTRIES} entries plus `"... +N more"` once it exceeds that count. */
36
+ export function renderWriteSet(writeSet: readonly string[]): string {
37
+ const paths = writeSet.map((path) => untrusted(path, UNTRUSTED_MAX.writePath));
38
+ if (paths.length <= MAX_WRITE_SET_ENTRIES) return paths.join(", ");
39
+ return `${paths.slice(0, MAX_WRITE_SET_ENTRIES).join(", ")}, +${paths.length - MAX_WRITE_SET_ENTRIES} more`;
40
+ }
@@ -0,0 +1,23 @@
1
+ /** Non-blocking exocom join — arm a wake, never hold the MCP tool call. Pure kernel. */
2
+ export const EXOCOM_WAIT_MAX_MS = 600_000;
3
+
4
+ /** Clamp an optional timeout to (0, EXOCOM_WAIT_MAX_MS]; default is the cap. */
5
+ export function waitTimeoutMs(requested: number | undefined, cap = EXOCOM_WAIT_MAX_MS): number {
6
+ const ceiling = Number.isFinite(cap) && cap > 0 ? Math.floor(cap) : EXOCOM_WAIT_MAX_MS;
7
+ if (requested === undefined || !Number.isFinite(requested)) return ceiling;
8
+ return Math.min(ceiling, Math.max(1, Math.floor(requested)));
9
+ }
10
+
11
+ /** Match cleanup notices only through extension-owned metadata. A substring search confuses `a`
12
+ * with `a1` and, worse, lets untrusted postcard prose containing `ask_id=...` discard an unrelated
13
+ * queued delivery. Notifier items are still strings, so recognize only their trusted header lines. */
14
+ export function waitNoticeMatchesAskId(notice: string, askId: string): boolean {
15
+ if (!askId) return false;
16
+ const [first = "", second = ""] = notice.split(/\r?\n/, 2);
17
+ const metadata = first === "[exocom-pending-ask]"
18
+ ? second
19
+ : first.startsWith("[pi-persona] exocom answer ·") || first.startsWith("[pi-persona] exocom wait ")
20
+ ? first
21
+ : "";
22
+ return metadata.split(/[\t ·]+/u).includes(`ask_id=${askId}`);
23
+ }
@@ -0,0 +1,89 @@
1
+ /** Advisory write-vs-claim overlap guard — warn-then-allow (Task 7). Pure except WriteWarnings'
2
+ * memory. Imports only `../core/ownership.ts` (path overlap), `../core/fence.ts` (`fencePeer`,
3
+ * for the peer-authored slice/write_set — the same trust boundary `status.ts` fences), and
4
+ * `./untrusted.ts` (the sanitizer shared by every exocom surface that renders peer-authored
5
+ * ledger fields), per layering. */
6
+ import { posix, win32 } from "node:path";
7
+ import { fencePeer } from "../core/fence.ts";
8
+ import { normalizeWritePath, pathsOverlap } from "../core/ownership.ts";
9
+ import { renderWriteSet, untrusted, UNTRUSTED_MAX } from "./untrusted.ts";
10
+ import type { LedgerClaim } from "./ledger.ts";
11
+
12
+ /** Built-in tools whose `input.path` can land a write; the only ones this guard inspects. */
13
+ export const WRITE_TOOLS = new Set(["write", "edit"]);
14
+
15
+ /** Pi's write/edit tools accept a path relative to `cwd` OR an absolute one; the ledger's
16
+ * write_set is always repo-relative (`writeSetPathError`). An absolute path must be relativized
17
+ * against `cwd` before the overlap check, or it silently never matches a repo-relative claim.
18
+ * Windows-vs-POSIX syntax is picked from the PATH ITSELF (same rule as `normalizeWritePath`),
19
+ * never from the host platform, so the same claim overlaps whichever spelling this host's
20
+ * filesystem actually produces. A path whose relative form escapes `cwd` (starts with "..") is
21
+ * compared as-is: no repo-relative claim can ever own it. */
22
+ function relativizeAbsolute(path: string, cwd: string): string {
23
+ const windows = normalizeWritePath(path).windows || normalizeWritePath(cwd).windows;
24
+ const mod = windows ? win32 : posix;
25
+ const trimmed = path.trim();
26
+ if (!mod.isAbsolute(trimmed)) return path;
27
+ const rel = mod.relative(cwd.trim(), trimmed).split(/[\\/]+/).join("/");
28
+ return rel.startsWith("..") ? path : rel;
29
+ }
30
+
31
+ /** The first live claim (never the caller's own) whose write_set overlaps `path`. `cwd`
32
+ * relativizes an absolute `path` first (see {@link relativizeAbsolute}); omit it to compare
33
+ * `path` as-is, for callers that only ever see repo-relative paths. */
34
+ export function peerClaimFor(
35
+ path: string,
36
+ claims: readonly LedgerClaim[],
37
+ self: string,
38
+ cwd?: string,
39
+ ): LedgerClaim | undefined {
40
+ const target = normalizeWritePath(cwd ? relativizeAbsolute(path, cwd) : path);
41
+ return claims.find((claim) =>
42
+ claim.from_session !== self && claim.write_set.some((ws) => pathsOverlap(target, normalizeWritePath(ws))),
43
+ );
44
+ }
45
+
46
+ /** One warning per (claim, normalized path) — the retry that follows a block is allowed through. */
47
+ export function writeWarningKey(claim: LedgerClaim, path: string): string {
48
+ return `${claim.msg_id}|${normalizeWritePath(path).value}`;
49
+ }
50
+
51
+ /** Build the write-guard's tool-result reason. `claim.slice`, `claim.write_set`, and `label` are
52
+ * all PEER-AUTHORED and only length-bounded at the wire (envelope.ts); this reason reaches both
53
+ * a terminal notification and the model's tool result, so every peer field is sanitized
54
+ * ({@link untrusted}) before it is interpolated. The claim's own slice/write_set additionally go
55
+ * through {@link fencePeer} — quoted as data on their own line, OUTSIDE the authoritative
56
+ * instruction sentence — so the model reads them as untrusted content, not part of the
57
+ * instruction, the same standard `status.ts` applies to peer-claims text. `path` is the caller's
58
+ * own tool argument, not peer-authored, and is passed through unsanitized. Exported (rather than
59
+ * built inline in hooks.ts) so the sanitization is directly testable and the hook stays thin. */
60
+ export function writeWarningReason(path: string, claim: LedgerClaim, label: string): string {
61
+ const who = untrusted(label, UNTRUSTED_MAX.label);
62
+ const slice = untrusted(claim.slice, UNTRUSTED_MAX.slice);
63
+ const writeSet = renderWriteSet(claim.write_set);
64
+ return `exocom: ${path} is inside ${who}'s open claim — coordinate with exocom_ask or claim it; call the tool again to proceed (this warning shows once per path)\n${fencePeer(`${slice}: ${writeSet}`)}`;
65
+ }
66
+
67
+ /** Warn-once memory for the write guard. A bare unbounded set would grow for the life of the
68
+ * install on a long session with many distinct claim+path pairs; caps at `maxEntries` (like the
69
+ * sibling `SeenMessages` in guards.ts) and drops the oldest insertion first once exceeded. */
70
+ export class WriteWarnings {
71
+ private readonly maxEntries: number;
72
+ private readonly keys = new Map<string, number>();
73
+ constructor(maxEntries = 4096) {
74
+ this.maxEntries = maxEntries;
75
+ }
76
+ seen(key: string): boolean {
77
+ return this.keys.has(key);
78
+ }
79
+ remember(key: string): void {
80
+ this.keys.set(key, this.keys.size);
81
+ if (this.keys.size > this.maxEntries) {
82
+ const oldest = this.keys.keys().next().value;
83
+ if (oldest !== undefined) this.keys.delete(oldest);
84
+ }
85
+ }
86
+ clear(): void {
87
+ this.keys.clear();
88
+ }
89
+ }