@gethmy/mcp 3.3.0 → 3.4.0

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.
@@ -0,0 +1,679 @@
1
+ /**
2
+ * Publishing an MCP session so a hook outside the process can find it (#874).
3
+ *
4
+ * ## The problem this solves
5
+ *
6
+ * A daemon run streams its whole tool log to `agent_run_events`, so its
7
+ * timeline reads turn by turn. An MCP session — `/hmy` in a terminal, Cursor,
8
+ * Claude Desktop — writes only what its own MCP calls carry, so a two-hour run
9
+ * that pinged progress three times renders three rows. The tool calls the agent
10
+ * actually made (Read, Edit, Bash, Grep) never reach the MCP server at all;
11
+ * they belong to the harness, which exposes them as `PostToolUse` hooks.
12
+ *
13
+ * A hook is a separate, short-lived process. It has no handle on the MCP
14
+ * server's memory, so the session identity has to travel **through the
15
+ * filesystem**. That is what this module is.
16
+ *
17
+ * ## Why not `~/.harmony-mcp/`
18
+ *
19
+ * `getConfigDir()` is the FIRST entry of `credentialDirectories()`
20
+ * (`packages/harmony-harness/src/run-containment.ts`) and is read-denied to
21
+ * every contained spawn, because it holds the API key. Publishing a session
22
+ * pointer into it would put a file that is meant to be READ into the one
23
+ * directory the containment design just finished fencing off — and would make
24
+ * the fence look negotiable to the next reader.
25
+ *
26
+ * So the pointer lives under `~/.harmony/`, which already hosts the memory
27
+ * vault (`getMemoryDir()`, `config.ts`), holds no credential, and appears
28
+ * nowhere in `credentialDirectories()`. The ruling is narrow and worth stating:
29
+ * **a session id is not a credential** — it is the id of a row the board
30
+ * renders publicly to the workspace — but it does not follow that it belongs
31
+ * beside one.
32
+ *
33
+ * ## Concurrency: two `/hmy` sessions in one repo
34
+ *
35
+ * The pointer file is named for the PUBLISHER'S PID, so two sessions can never
36
+ * overwrite each other's state — that is structural, not a convention.
37
+ *
38
+ * Routing a hook to the RIGHT pointer is the harder half, and `cwd` cannot do
39
+ * it: two sessions in one repo share a working directory. What separates them
40
+ * is process ancestry — each `claude` process spawned both its own MCP server
41
+ * and its own hooks, so the hook and its MCP server share an ancestor that the
42
+ * neighbouring session's pair does not.
43
+ *
44
+ * Naive set intersection is not enough, because two `claude` processes started
45
+ * from ONE terminal share that shell as an ancestor too. So the rule is
46
+ * *nearest* common ancestor: walk the hook's ancestor chain outward and take
47
+ * the first pointer that claims any pid on it. For the right session that match
48
+ * lands on `claude` itself; for the neighbour it lands one step further out, at
49
+ * the shared shell. Nearest wins, and the tie is broken by recency.
50
+ */
51
+
52
+ import { execFileSync } from "node:child_process";
53
+ import {
54
+ existsSync,
55
+ mkdirSync,
56
+ readdirSync,
57
+ readFileSync,
58
+ renameSync,
59
+ rmSync,
60
+ statSync,
61
+ unlinkSync,
62
+ writeFileSync,
63
+ } from "node:fs";
64
+ import { homedir } from "node:os";
65
+ import { join } from "node:path";
66
+
67
+ /**
68
+ * Override for the state root. Exists for tests and for an operator whose home
69
+ * is not writable; both the publisher and the hook read it, so they agree.
70
+ */
71
+ export const RUN_STATE_DIR_ENV = "HARMONY_RUN_STATE_DIR";
72
+
73
+ /**
74
+ * A pointer older than this is ignored even if its publisher pid looks alive.
75
+ *
76
+ * Pids are reused. Without an age bound, a pointer left by a crashed MCP server
77
+ * whose pid the OS later handed to an unrelated process would keep routing a
78
+ * hook at a session that ended hours ago. The publisher refreshes its pointer
79
+ * while it drains (see `run-event-forwarder.ts`), so a live session never ages
80
+ * out and a dead one always does.
81
+ */
82
+ export const MAX_POINTER_AGE_MS = 10 * 60_000;
83
+
84
+ /** How far up the process tree to walk. Deeper than any real hook nesting. */
85
+ const MAX_ANCESTOR_DEPTH = 12;
86
+
87
+ export interface PublishedRunSession {
88
+ /** `cards.id` the session is held on. */
89
+ cardId: string;
90
+ /** `card_agent_context.id` — the row the run's events attach to. */
91
+ agentSessionId: string;
92
+ /** Pid of the MCP server process that published this. */
93
+ publisherPid: number;
94
+ /** The publisher's ancestors, nearest first. Used to route a hook. */
95
+ ancestorPids: number[];
96
+ /** The publisher's working directory. A weak fallback signal only. */
97
+ cwd: string;
98
+ /** ISO timestamp, refreshed while the session is live. */
99
+ updatedAt: string;
100
+ }
101
+
102
+ /** Root of the on-disk run state. */
103
+ export function runStateDir(
104
+ env: Record<string, string | undefined> = process.env,
105
+ ): string {
106
+ const override = env[RUN_STATE_DIR_ENV]?.trim();
107
+ if (override) return override;
108
+ return join(homedir(), ".harmony", "runs");
109
+ }
110
+
111
+ /** Where session pointers live. */
112
+ export function sessionsDir(stateDir: string): string {
113
+ return join(stateDir, "sessions");
114
+ }
115
+
116
+ /** Where a session's un-posted tool events queue up. */
117
+ export function spoolDir(stateDir: string, agentSessionId: string): string {
118
+ return join(stateDir, "spool", sanitizeIdForPath(agentSessionId));
119
+ }
120
+
121
+ /**
122
+ * A session id is a UUID from the server, but this builds a path from it, so it
123
+ * is sanitized rather than trusted. A hostile id would otherwise escape the
124
+ * spool root with `../`.
125
+ */
126
+ function sanitizeIdForPath(id: string): string {
127
+ return id.replace(/[^A-Za-z0-9_-]/g, "_").slice(0, 100);
128
+ }
129
+
130
+ function pointerFileName(publisherPid: number, cardId: string): string {
131
+ return `${publisherPid}.${sanitizeIdForPath(cardId)}.json`;
132
+ }
133
+
134
+ /**
135
+ * Is this pid still running?
136
+ *
137
+ * `EPERM` means the process exists but belongs to another user, which is still
138
+ * "alive" for our purposes. Only `ESRCH` is a definite no.
139
+ */
140
+ export function pidIsAlive(pid: number): boolean {
141
+ if (!Number.isInteger(pid) || pid <= 0) return false;
142
+ try {
143
+ process.kill(pid, 0);
144
+ return true;
145
+ } catch (err) {
146
+ return (err as NodeJS.ErrnoException)?.code === "EPERM";
147
+ }
148
+ }
149
+
150
+ /**
151
+ * The parent pid of `pid`, or null.
152
+ *
153
+ * Linux is served from `/proc` with no spawn at all. Everything else falls back
154
+ * to one `ps` call for the WHOLE table — one spawn for the entire walk rather
155
+ * than one per level, because this runs on the critical path of every tool call
156
+ * and a per-level spawn would be a visible tax on every `Read`.
157
+ */
158
+ function readProcParent(pid: number): number | null {
159
+ try {
160
+ const stat = readFileSync(`/proc/${pid}/stat`, "utf-8");
161
+ // `comm` is parenthesized and may itself contain spaces or parens, so the
162
+ // fields are read from after the LAST ')'.
163
+ const tail = stat
164
+ .slice(stat.lastIndexOf(")") + 1)
165
+ .trim()
166
+ .split(/\s+/);
167
+ // After comm: state, ppid, …
168
+ const ppid = Number.parseInt(tail[1] ?? "", 10);
169
+ return Number.isInteger(ppid) && ppid > 0 ? ppid : null;
170
+ } catch {
171
+ return null;
172
+ }
173
+ }
174
+
175
+ let psTableCache: Map<number, number> | null = null;
176
+
177
+ function psParentTable(): Map<number, number> {
178
+ if (psTableCache) return psTableCache;
179
+ const table = new Map<number, number>();
180
+ try {
181
+ const out = execFileSync("ps", ["-Ao", "pid=,ppid="], {
182
+ encoding: "utf-8",
183
+ timeout: 2_000,
184
+ stdio: ["ignore", "pipe", "ignore"],
185
+ });
186
+ for (const line of out.split("\n")) {
187
+ const match = line.trim().match(/^(\d+)\s+(\d+)$/);
188
+ if (!match) continue;
189
+ table.set(Number(match[1]), Number(match[2]));
190
+ }
191
+ } catch {
192
+ // No `ps` (Windows, or a stripped container). Routing degrades to the cwd
193
+ // fallback in `chooseRunSessionForHook`, which is why that fallback exists.
194
+ }
195
+ psTableCache = table;
196
+ return table;
197
+ }
198
+
199
+ /**
200
+ * The ancestors of `pid`, nearest first, excluding `pid` itself.
201
+ *
202
+ * ## The first hop is free, and that is what usually decides it
203
+ *
204
+ * `process.ppid` needs no `/proc` and no `ps`, so when `pid` is this process
205
+ * the chain is SEEDED with it before any lookup runs. That matters more than it
206
+ * looks: `ps` is not always available — a hardened sandbox refuses to spawn it,
207
+ * and macOS has no `/proc` to fall back on — and the immediate parent is
208
+ * precisely the hop that separates two `/hmy` sessions started from one shell.
209
+ * So the case this routing exists for resolves at zero cost, and the spawn is
210
+ * only reached when a deeper hop is genuinely needed.
211
+ *
212
+ * `readParent` is injectable so the routing tests can build a synthetic process
213
+ * tree instead of trying to arrange a real one.
214
+ */
215
+ export function ancestorPids(
216
+ pid: number,
217
+ readParent?: (pid: number) => number | null,
218
+ ): number[] {
219
+ const parentOf =
220
+ readParent ??
221
+ ((child: number) => {
222
+ const viaProc = readProcParent(child);
223
+ if (viaProc !== null) return viaProc;
224
+ return psParentTable().get(child) ?? null;
225
+ });
226
+
227
+ const chain: number[] = [];
228
+ const seen = new Set<number>([pid]);
229
+ let current = pid;
230
+
231
+ if (!readParent && pid === process.pid) {
232
+ const ppid = process.ppid;
233
+ if (Number.isInteger(ppid) && ppid > 1) {
234
+ chain.push(ppid);
235
+ seen.add(ppid);
236
+ current = ppid;
237
+ }
238
+ }
239
+
240
+ for (let depth = chain.length; depth < MAX_ANCESTOR_DEPTH; depth++) {
241
+ const parent = parentOf(current);
242
+ // pid 1 is the end of every chain; a cycle means a bad table.
243
+ if (parent === null || parent <= 1 || seen.has(parent)) break;
244
+ chain.push(parent);
245
+ seen.add(parent);
246
+ current = parent;
247
+ }
248
+ return chain;
249
+ }
250
+
251
+ /**
252
+ * Publish (or refresh) the pointer for a live session.
253
+ *
254
+ * Written to a temp name and renamed, so a hook reading concurrently sees
255
+ * either the old file or the new one and never a half-written JSON document.
256
+ *
257
+ * Never throws: a home directory that is read-only must degrade to "no tool
258
+ * rows on the timeline", never to a failed `harmony_start_agent_session`.
259
+ */
260
+ export function publishRunSession(
261
+ session: Pick<PublishedRunSession, "cardId" | "agentSessionId">,
262
+ options?: {
263
+ stateDir?: string;
264
+ pid?: number;
265
+ ancestors?: number[];
266
+ cwd?: string;
267
+ },
268
+ ): PublishedRunSession | null {
269
+ const stateDir = options?.stateDir ?? runStateDir();
270
+ const pid = options?.pid ?? process.pid;
271
+ const record: PublishedRunSession = {
272
+ cardId: session.cardId,
273
+ agentSessionId: session.agentSessionId,
274
+ publisherPid: pid,
275
+ ancestorPids: options?.ancestors ?? ancestorPids(pid),
276
+ cwd: options?.cwd ?? process.cwd(),
277
+ updatedAt: new Date().toISOString(),
278
+ };
279
+ try {
280
+ const dir = sessionsDir(stateDir);
281
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
282
+ const target = join(dir, pointerFileName(pid, session.cardId));
283
+ const temp = `${target}.${process.pid}.tmp`;
284
+ writeFileSync(temp, JSON.stringify(record), { mode: 0o600 });
285
+ renameSync(temp, target);
286
+ return record;
287
+ } catch {
288
+ return null;
289
+ }
290
+ }
291
+
292
+ /** Remove a session's pointer and drop whatever is left in its spool. */
293
+ export function clearRunSession(
294
+ cardId: string,
295
+ options?: {
296
+ stateDir?: string;
297
+ pid?: number;
298
+ agentSessionId?: string;
299
+ /**
300
+ * Leave the pointer file in place.
301
+ *
302
+ * A pointer is named for `(pid, cardId)` and NOT for the session, so a
303
+ * second session on the same card in the same process publishes over the
304
+ * same filename. The forwarder that second session replaces then reaches
305
+ * this function and, without this flag, unlinks the pointer its successor
306
+ * just wrote — leaving the live session invisible to every hook until the
307
+ * next `POINTER_REFRESH_MS`, and silently dropping every tool call in
308
+ * that window. A handover therefore keeps the pointer and clears only the
309
+ * spool that belongs to the session going away.
310
+ */
311
+ keepPointer?: boolean;
312
+ },
313
+ ): void {
314
+ const stateDir = options?.stateDir ?? runStateDir();
315
+ const pid = options?.pid ?? process.pid;
316
+ if (!options?.keepPointer) {
317
+ try {
318
+ unlinkSync(join(sessionsDir(stateDir), pointerFileName(pid, cardId)));
319
+ } catch {
320
+ // Already gone. Clearing is idempotent by design — `harmony_end_agent_session`
321
+ // and process shutdown both call it.
322
+ }
323
+ }
324
+ if (options?.agentSessionId) {
325
+ try {
326
+ rmSync(spoolDir(stateDir, options.agentSessionId), {
327
+ recursive: true,
328
+ force: true,
329
+ });
330
+ } catch {
331
+ // Best effort; a leftover spool directory is drained-and-empty anyway.
332
+ }
333
+ }
334
+ }
335
+
336
+ /**
337
+ * Every pointer worth considering: parseable, publisher alive, not stale.
338
+ *
339
+ * Dead and stale pointers are unlinked as they are found. A hook is the most
340
+ * frequent reader in the system, so it is also the cheapest place to garbage
341
+ * collect — there is no separate sweep to forget to run.
342
+ */
343
+ export function readPublishedSessions(options?: {
344
+ stateDir?: string;
345
+ now?: number;
346
+ }): PublishedRunSession[] {
347
+ const stateDir = options?.stateDir ?? runStateDir();
348
+ const now = options?.now ?? Date.now();
349
+ const dir = sessionsDir(stateDir);
350
+ let names: string[];
351
+ try {
352
+ names = readdirSync(dir);
353
+ } catch {
354
+ return [];
355
+ }
356
+
357
+ const live: PublishedRunSession[] = [];
358
+ for (const name of names) {
359
+ if (!name.endsWith(".json")) continue;
360
+ const path = join(dir, name);
361
+ let record: PublishedRunSession;
362
+ try {
363
+ record = JSON.parse(readFileSync(path, "utf-8")) as PublishedRunSession;
364
+ } catch {
365
+ safeUnlink(path);
366
+ continue;
367
+ }
368
+ if (
369
+ typeof record?.cardId !== "string" ||
370
+ typeof record?.agentSessionId !== "string" ||
371
+ !record.cardId ||
372
+ !record.agentSessionId
373
+ ) {
374
+ safeUnlink(path);
375
+ continue;
376
+ }
377
+ const age = now - Date.parse(record.updatedAt ?? "");
378
+ if (!Number.isFinite(age) || age > MAX_POINTER_AGE_MS) {
379
+ safeUnlink(path);
380
+ continue;
381
+ }
382
+ if (!pidIsAlive(record.publisherPid)) {
383
+ safeUnlink(path);
384
+ continue;
385
+ }
386
+ live.push({ ...record, ancestorPids: record.ancestorPids ?? [] });
387
+ }
388
+ return live;
389
+ }
390
+
391
+ function safeUnlink(path: string): void {
392
+ try {
393
+ unlinkSync(path);
394
+ } catch {
395
+ // Another reader got there first, or the file is not ours to remove.
396
+ }
397
+ }
398
+
399
+ /**
400
+ * Which published session a hook belongs to. Pure — the process tree and the
401
+ * pointer list are both arguments.
402
+ *
403
+ * The scoring is "distance to the nearest shared ancestor": index 0 means the
404
+ * hook's immediate parent is (or was spawned by) the publisher's own line.
405
+ * Two `/hmy` sessions launched from one shell both match that shell, but only
406
+ * the right one matches the `claude` process below it, so the right one scores
407
+ * lower and wins.
408
+ *
409
+ * **The payload's `cwd` is a NECESSARY condition, not a last-resort fallback.**
410
+ * The hook is installed in the user layer, so it fires for EVERY Claude Code
411
+ * session on the machine — including sessions that have never touched Harmony,
412
+ * in unrelated repositories. Those sessions still share an ancestor with a
413
+ * publisher (a login shell, `launchd`, pid 1), so ancestry alone answers "some
414
+ * shared ancestor exists" for practically every pair of processes on one host.
415
+ * Ranking by distance separates two publishers from each other; it cannot tell
416
+ * a hook that belongs to NO publisher from one that belongs to the only
417
+ * publisher there is. Filtering by working directory first is what makes the
418
+ * match positive evidence rather than a default.
419
+ *
420
+ * That is also why there is no "when in doubt, use the only candidate" branch.
421
+ * It read as generous and was the widest hole in the routing: with one `/hmy`
422
+ * session published, every unrelated Claude Code session on the same machine
423
+ * posted its `Bash` command lines and file reads to that session's card. The
424
+ * same branch is what would let a future contained daemon run — should
425
+ * `settingSources` ever gain `"user"` — attribute its tool calls to a
426
+ * neighbouring `/hmy` session's card, so removing it also stops this card's
427
+ * design depending on that harness knob staying where it is.
428
+ *
429
+ * With no ancestry available at all (no `/proc`, no `ps`) every score is
430
+ * `Infinity` and an unambiguous working directory decides. Genuine ambiguity
431
+ * returns null. Dropping a row is the correct failure — attributing one
432
+ * session's `Bash` command to another session's card would put the wrong work
433
+ * on the wrong board.
434
+ */
435
+ export function chooseRunSessionForHook(args: {
436
+ candidates: PublishedRunSession[];
437
+ hookAncestorPids: number[];
438
+ cwd?: string;
439
+ }): PublishedRunSession | null {
440
+ const { candidates, hookAncestorPids } = args;
441
+ if (candidates.length === 0) return null;
442
+
443
+ const hasCwd = typeof args.cwd === "string" && args.cwd.length > 0;
444
+ // A stdio MCP server is spawned by the client with the client's own working
445
+ // directory, so a publisher's `cwd` and its hook payloads' `cwd` are the same
446
+ // string by construction.
447
+ const pool = hasCwd
448
+ ? candidates.filter((candidate) => candidate.cwd === args.cwd)
449
+ : candidates;
450
+ if (pool.length === 0) return null;
451
+
452
+ let best: PublishedRunSession | null = null;
453
+ let bestScore = Number.POSITIVE_INFINITY;
454
+
455
+ for (const candidate of pool) {
456
+ const claimed = new Set<number>([
457
+ candidate.publisherPid,
458
+ ...(candidate.ancestorPids ?? []),
459
+ ]);
460
+ let score = Number.POSITIVE_INFINITY;
461
+ for (let i = 0; i < hookAncestorPids.length; i++) {
462
+ if (claimed.has(hookAncestorPids[i] as number)) {
463
+ score = i;
464
+ break;
465
+ }
466
+ }
467
+ if (score === Number.POSITIVE_INFINITY) continue;
468
+ if (
469
+ score < bestScore ||
470
+ (score === bestScore &&
471
+ best !== null &&
472
+ Date.parse(candidate.updatedAt) > Date.parse(best.updatedAt))
473
+ ) {
474
+ best = candidate;
475
+ bestScore = score;
476
+ }
477
+ }
478
+
479
+ if (best) return best;
480
+
481
+ // No shared ancestor. A working directory that names exactly one publisher is
482
+ // the best remaining evidence; anything less than that routes nowhere.
483
+ if (hasCwd && pool.length === 1) return pool[0] as PublishedRunSession;
484
+ return null;
485
+ }
486
+
487
+ /**
488
+ * Append one batch of events to a session's spool.
489
+ *
490
+ * ONE FILE PER CALL, never a shared append-only log. Several hook processes can
491
+ * be in flight at once, and a concurrent `O_APPEND` write is only atomic below
492
+ * `PIPE_BUF` (4 KB) — a redacted tool row can exceed that, so an interleaved
493
+ * write would corrupt a line. A file per batch has no such window, needs no
494
+ * lock, and lets the drainer delete exactly what it posted.
495
+ *
496
+ * The name sorts lexicographically by time, so the drainer replays in order.
497
+ */
498
+ export function writeSpoolBatch(
499
+ dir: string,
500
+ events: unknown[],
501
+ options?: { now?: number; pid?: number; nonce?: string },
502
+ ): string | null {
503
+ if (events.length === 0) return null;
504
+ const now = options?.now ?? Date.now();
505
+ const pid = options?.pid ?? process.pid;
506
+ const nonce =
507
+ options?.nonce ?? Math.random().toString(36).slice(2, 8).padEnd(6, "0");
508
+ try {
509
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
510
+ const name = `${String(now).padStart(14, "0")}-${pid}-${nonce}.json`;
511
+ const target = join(dir, name);
512
+ const temp = `${target}.tmp`;
513
+ writeFileSync(temp, JSON.stringify(events), { mode: 0o600 });
514
+ renameSync(temp, target);
515
+ return target;
516
+ } catch {
517
+ return null;
518
+ }
519
+ }
520
+
521
+ export interface SpoolBatch {
522
+ path: string;
523
+ events: unknown[];
524
+ }
525
+
526
+ /**
527
+ * Read up to `limit` spooled batches in time order, oldest first.
528
+ *
529
+ * Reading does NOT delete: the caller removes a batch only once the API has
530
+ * accepted it, so a failed flush is retried rather than lost. An unparseable
531
+ * batch is dropped on sight, since retrying it forever would wedge the queue.
532
+ */
533
+ export function readSpoolBatches(dir: string, limit = 200): SpoolBatch[] {
534
+ let names: string[];
535
+ try {
536
+ names = readdirSync(dir);
537
+ } catch {
538
+ return [];
539
+ }
540
+ const batches: SpoolBatch[] = [];
541
+ for (const name of names.filter((n) => n.endsWith(".json")).sort()) {
542
+ if (batches.length >= limit) break;
543
+ const path = join(dir, name);
544
+ try {
545
+ const parsed = JSON.parse(readFileSync(path, "utf-8"));
546
+ if (!Array.isArray(parsed)) {
547
+ safeUnlink(path);
548
+ continue;
549
+ }
550
+ batches.push({ path, events: parsed });
551
+ } catch {
552
+ safeUnlink(path);
553
+ }
554
+ }
555
+ return batches;
556
+ }
557
+
558
+ /** Remove batches the API has accepted. */
559
+ export function removeSpoolBatches(paths: string[]): void {
560
+ for (const path of paths) safeUnlink(path);
561
+ }
562
+
563
+ /**
564
+ * Drop the oldest spooled batches once a session's queue grows past `max`.
565
+ *
566
+ * The bound the daemon's in-memory buffer gets from `MAX_BUFFER` has to be
567
+ * re-established here, because a spool survives the process that filled it: an
568
+ * offline session would otherwise fill the disk. Oldest-first matches the
569
+ * daemon, which caps its re-queued buffer from the tail.
570
+ */
571
+ export function trimSpool(dir: string, max: number): number {
572
+ let names: string[];
573
+ try {
574
+ names = readdirSync(dir)
575
+ .filter((n) => n.endsWith(".json"))
576
+ .sort();
577
+ } catch {
578
+ return 0;
579
+ }
580
+ if (names.length <= max) return 0;
581
+ const excess = names.slice(0, names.length - max);
582
+ for (const name of excess) safeUnlink(join(dir, name));
583
+ return excess.length;
584
+ }
585
+
586
+ /**
587
+ * Remember which publisher a harness session routed to, so the ancestry walk
588
+ * runs once per session rather than once per tool call.
589
+ *
590
+ * The walk costs a `ps` spawn on any platform without `/proc`, and it sits on
591
+ * the critical path of every `Read` the agent performs. The answer cannot
592
+ * change for the life of a harness session — the `claude` process and its MCP
593
+ * server both outlive it — so it is computed once and looked up afterwards.
594
+ *
595
+ * A memo is a HINT, and it is only ever as good as what it can be confirmed
596
+ * against. It therefore stores the whole identity it resolved — pid, card and
597
+ * agent session — because a pid alone cannot be confirmed: one MCP process
598
+ * routinely holds two live sessions (an explicit `/hmy` session on card A
599
+ * survives the card-switch sweep while a trigger tool publishes card B), and
600
+ * on the hosted transport one process serves many. Confirming a pid alone
601
+ * would then pick whichever pointer happened to be found first and attribute
602
+ * one card's tool calls to another. A memo that no longer matches a live
603
+ * pointer costs one ancestry walk, which is the price it was always meant to
604
+ * risk.
605
+ */
606
+ function routeMemoPath(stateDir: string, harnessSessionId: string): string {
607
+ return join(
608
+ stateDir,
609
+ "routes",
610
+ `${sanitizeIdForPath(harnessSessionId)}.json`,
611
+ );
612
+ }
613
+
614
+ /** The identity a harness session resolved to, to be re-confirmed on read. */
615
+ export interface RouteMemo {
616
+ publisherPid: number;
617
+ cardId: string;
618
+ agentSessionId: string;
619
+ }
620
+
621
+ export function readRouteMemo(
622
+ stateDir: string,
623
+ harnessSessionId: string,
624
+ ): RouteMemo | null {
625
+ try {
626
+ const raw = JSON.parse(
627
+ readFileSync(routeMemoPath(stateDir, harnessSessionId), "utf-8"),
628
+ ) as { publisherPid?: unknown; cardId?: unknown; agentSessionId?: unknown };
629
+ const pid = raw?.publisherPid;
630
+ const cardId = raw?.cardId;
631
+ const agentSessionId = raw?.agentSessionId;
632
+ // All three or nothing. A memo written by an older build carries only the
633
+ // pid; returning it partially would resurrect exactly the pid-only
634
+ // confirmation this shape exists to prevent, so it reads as absent and the
635
+ // caller re-resolves and rewrites it.
636
+ if (typeof pid !== "number" || !Number.isInteger(pid)) return null;
637
+ if (typeof cardId !== "string" || cardId.length === 0) return null;
638
+ if (typeof agentSessionId !== "string" || agentSessionId.length === 0) {
639
+ return null;
640
+ }
641
+ return { publisherPid: pid, cardId, agentSessionId };
642
+ } catch {
643
+ return null;
644
+ }
645
+ }
646
+
647
+ export function writeRouteMemo(
648
+ stateDir: string,
649
+ harnessSessionId: string,
650
+ memo: RouteMemo,
651
+ ): void {
652
+ try {
653
+ const path = routeMemoPath(stateDir, harnessSessionId);
654
+ mkdirSync(join(stateDir, "routes"), { recursive: true, mode: 0o700 });
655
+ writeFileSync(
656
+ path,
657
+ JSON.stringify({
658
+ publisherPid: memo.publisherPid,
659
+ cardId: memo.cardId,
660
+ agentSessionId: memo.agentSessionId,
661
+ }),
662
+ { mode: 0o600 },
663
+ );
664
+ } catch {
665
+ // A memo is an optimization. Losing it costs one ancestry walk.
666
+ }
667
+ }
668
+
669
+ /** True when the state root exists — used to skip work in the common no-op case. */
670
+ export function runStateExists(stateDir = runStateDir()): boolean {
671
+ try {
672
+ return (
673
+ existsSync(sessionsDir(stateDir)) &&
674
+ statSync(sessionsDir(stateDir)).isDirectory()
675
+ );
676
+ } catch {
677
+ return false;
678
+ }
679
+ }