@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.
- package/AGENTS.md +249 -0
- package/CHANGELOG.md +260 -0
- package/LICENSE +21 -0
- package/README.md +172 -0
- package/agents/balthasar.md +32 -0
- package/agents/casper.md +33 -0
- package/agents/evidence-verifier.md +23 -0
- package/agents/melchior.md +32 -0
- package/agents/operator.md +63 -0
- package/agents/research.md +58 -0
- package/agents/reviewer.md +49 -0
- package/agents/scout.md +23 -0
- package/agents/verifier.md +21 -0
- package/assets/banner.png +0 -0
- package/assets/demo1.gif +0 -0
- package/assets/demo1.mp4 +0 -0
- package/assets/workflow.png +0 -0
- package/contracts/finding.contract.json +9 -0
- package/docs/ARCHITECTURE.md +727 -0
- package/docs/EXPERIENCE.md +96 -0
- package/docs/MONITORS.md +84 -0
- package/docs/README.md +53 -0
- package/docs/REFERENCE.md +408 -0
- package/docs/SPINE.md +152 -0
- package/docs/STRATEGIES.md +215 -0
- package/docs/TELEMETRY.md +9 -0
- package/flows/deep-review.flow.json +8 -0
- package/flows/gated-build.flow.json +9 -0
- package/package.json +106 -0
- package/personas/audit.md +29 -0
- package/personas/dev.md +52 -0
- package/personas/elite.md +256 -0
- package/personas/judge.md +32 -0
- package/personas/magi.md +41 -0
- package/personas/planner.md +37 -0
- package/personas/researcher.md +37 -0
- package/personas/swarm.md +38 -0
- package/personas/verify.md +31 -0
- package/presets/magi-rounds.preset.json +5 -0
- package/prompts/spine.md +11 -0
- package/prompts/spine.worker.md +9 -0
- package/src/agents/agent.ts +78 -0
- package/src/bridge.ts +209 -0
- package/src/bus/broker/client.ts +379 -0
- package/src/bus/broker/framing.ts +43 -0
- package/src/bus/broker/host.ts +407 -0
- package/src/bus/broker/messages.ts +87 -0
- package/src/bus/broker/paths.ts +31 -0
- package/src/bus/contact.ts +145 -0
- package/src/bus/inproc.ts +279 -0
- package/src/bus/peers.ts +114 -0
- package/src/core/brief.ts +356 -0
- package/src/core/capabilities.ts +114 -0
- package/src/core/config.ts +228 -0
- package/src/core/contract.ts +391 -0
- package/src/core/data-root.ts +260 -0
- package/src/core/discovery.ts +33 -0
- package/src/core/display-label.ts +47 -0
- package/src/core/fence.ts +151 -0
- package/src/core/format.ts +13 -0
- package/src/core/frontmatter.ts +202 -0
- package/src/core/models.ts +192 -0
- package/src/core/monitor.ts +415 -0
- package/src/core/naming.ts +42 -0
- package/src/core/nudge.ts +238 -0
- package/src/core/ownership.ts +102 -0
- package/src/core/permissions.ts +56 -0
- package/src/core/pi-compat.ts +50 -0
- package/src/core/seed.ts +770 -0
- package/src/core/session-identity.ts +89 -0
- package/src/core/time.ts +202 -0
- package/src/core/timer.ts +221 -0
- package/src/core/types.ts +28 -0
- package/src/engine/adapter.ts +257 -0
- package/src/engine/async.ts +898 -0
- package/src/engine/change-report.ts +143 -0
- package/src/engine/child.ts +532 -0
- package/src/engine/errors.ts +30 -0
- package/src/engine/fallback.ts +57 -0
- package/src/engine/handles.ts +21 -0
- package/src/engine/inproc.ts +857 -0
- package/src/engine/signals.ts +11 -0
- package/src/engine/spec-preflight.ts +33 -0
- package/src/engine/stream.ts +369 -0
- package/src/engine/worktree.ts +144 -0
- package/src/exocom/activation.ts +31 -0
- package/src/exocom/codes.ts +261 -0
- package/src/exocom/envelope.ts +180 -0
- package/src/exocom/gate.ts +6 -0
- package/src/exocom/guards.ts +45 -0
- package/src/exocom/inbound.ts +141 -0
- package/src/exocom/install.ts +970 -0
- package/src/exocom/ledger.ts +603 -0
- package/src/exocom/limits.ts +20 -0
- package/src/exocom/paths.ts +35 -0
- package/src/exocom/plane.ts +1116 -0
- package/src/exocom/registry.ts +307 -0
- package/src/exocom/scope.ts +42 -0
- package/src/exocom/status.ts +86 -0
- package/src/exocom/untrusted.ts +40 -0
- package/src/exocom/wait.ts +23 -0
- package/src/exocom/write-guard.ts +89 -0
- package/src/extension/broker-host.ts +181 -0
- package/src/extension/clock.ts +14 -0
- package/src/extension/engine.ts +321 -0
- package/src/extension/hooks.ts +796 -0
- package/src/extension/identity.ts +244 -0
- package/src/extension/shared.ts +175 -0
- package/src/extension.ts +2221 -0
- package/src/loader.ts +218 -0
- package/src/monitor/process.ts +116 -0
- package/src/monitor/session.ts +61 -0
- package/src/orchestration/flow-journal.ts +86 -0
- package/src/orchestration/flow-run.ts +301 -0
- package/src/orchestration/flow.ts +125 -0
- package/src/orchestration/judge.ts +78 -0
- package/src/orchestration/model-retry.ts +85 -0
- package/src/orchestration/parallel.ts +107 -0
- package/src/orchestration/params.ts +23 -0
- package/src/orchestration/reducers.ts +123 -0
- package/src/orchestration/render.ts +127 -0
- package/src/orchestration/roster.ts +133 -0
- package/src/orchestration/sdk.ts +326 -0
- package/src/orchestration/strategies/compete.ts +182 -0
- package/src/orchestration/strategies/council-rounds.ts +124 -0
- package/src/orchestration/strategies/critic-loop.ts +168 -0
- package/src/orchestration/strategies/debate.ts +126 -0
- package/src/orchestration/strategies/fanout.ts +20 -0
- package/src/orchestration/strategies/judge.ts +117 -0
- package/src/orchestration/strategies/magi.ts +239 -0
- package/src/orchestration/strategies/map.ts +327 -0
- package/src/orchestration/strategies/pair.ts +72 -0
- package/src/orchestration/strategies/pipeline.ts +91 -0
- package/src/orchestration/strategies/synthesize.ts +99 -0
- package/src/orchestration/strategy.ts +49 -0
- package/src/orchestration/types.ts +31 -0
- package/src/orchestration/voting.ts +126 -0
- package/src/persona/config-store.ts +215 -0
- package/src/persona/controller.ts +173 -0
- package/src/persona/gating.ts +82 -0
- package/src/persona/orchestrate.ts +83 -0
- package/src/persona/persona.ts +294 -0
- package/src/persona/spine.ts +221 -0
- package/src/persona/state.ts +28 -0
- package/src/telemetry/contract.ts +244 -0
- package/src/telemetry/paths.ts +14 -0
- package/src/telemetry/producer.ts +390 -0
- package/src/tools/council.ts +200 -0
- package/src/tools/delegate-tool.ts +637 -0
- package/src/tools/delegate.ts +661 -0
- package/src/tools/exocom-work.ts +328 -0
- package/src/tools/exocom.ts +476 -0
- package/src/tools/flow.ts +117 -0
- package/src/tools/intercom-tool.ts +292 -0
- package/src/tools/intercom.ts +181 -0
- package/src/tools/models.ts +39 -0
- package/src/tools/monitor.ts +66 -0
- package/src/tools/timer.ts +105 -0
- package/src/ui/agent-overlay.ts +526 -0
- package/src/ui/agent-tree.ts +307 -0
- package/src/ui/live-clock.ts +42 -0
- package/src/ui/model-picker.ts +160 -0
- package/src/ui/presentation.ts +170 -0
- package/src/ui/usage.ts +133 -0
- 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
|
+
}
|