@gethmy/mcp 3.2.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,363 @@
1
+ /**
2
+ * Draining a session's tool-call spool onto the board (#874).
3
+ *
4
+ * The `PostToolUse` hook writes; this reads. The split exists because a hook is
5
+ * a short-lived process — it cannot hold a buffer between tool calls, and a
6
+ * per-call HTTP round trip would tax every `Read`. The MCP server is already
7
+ * long-running and already holds the API client, so the batching lives here.
8
+ *
9
+ * ## The retry policy, and why it is not the daemon's
10
+ *
11
+ * `cli-agent-runner.ts` buffers in memory, re-queues a failed batch at the
12
+ * front, and caps the buffer at `MAX_BUFFER`. The shape is the same here but
13
+ * the mechanism is simpler, because the spool is on disk: a batch is deleted
14
+ * ONLY after the API accepts it, so "re-queue on failure" is just "do not
15
+ * delete". Nothing has to be held in memory and nothing is lost if this process
16
+ * dies mid-flush.
17
+ *
18
+ * Two bounds keep that from becoming a leak:
19
+ *
20
+ * - **Volume** — the hook trims the spool to `MAX_SPOOL_BATCHES` as it writes,
21
+ * which is the disk-side equivalent of `MAX_BUFFER`.
22
+ * - **Time** — a failing flush backs off geometrically to `MAX_INTERVAL_MS`,
23
+ * and a batch that has failed `MAX_BATCH_ATTEMPTS` times is dropped. The
24
+ * backoff is what makes that attempt count generous in wall-clock terms
25
+ * (~10 minutes, not ~40 seconds), so a brief API outage rides through while
26
+ * a batch the server will never accept still stops blocking the queue behind
27
+ * it.
28
+ */
29
+
30
+ import type { HarmonyApiClient } from "./api-client.js";
31
+ import { readDeclaredRunSession } from "./comment-session.js";
32
+ import {
33
+ clearRunSession,
34
+ publishRunSession,
35
+ readSpoolBatches,
36
+ removeSpoolBatches,
37
+ runStateDir,
38
+ spoolDir,
39
+ } from "./run-state.js";
40
+
41
+ /** Matches `FLUSH_INTERVAL_MS` in the daemon's `cli-agent-runner.ts`. */
42
+ const FLUSH_INTERVAL_MS = 2_000;
43
+ /** Ceiling for the failure backoff. */
44
+ const MAX_INTERVAL_MS = 60_000;
45
+ /** Server-side cap is 1000 events per request; stay under it with room. */
46
+ const MAX_EVENTS_PER_REQUEST = 500;
47
+ /** Batch files read per tick. */
48
+ const MAX_BATCHES_PER_FLUSH = 200;
49
+ /** After this many failed flushes a batch is assumed poisoned and dropped. */
50
+ const MAX_BATCH_ATTEMPTS = 20;
51
+ /** How often the session pointer's `updatedAt` is refreshed. */
52
+ const POINTER_REFRESH_MS = 60_000;
53
+
54
+ export interface RunEventForwarderOptions {
55
+ cardId: string;
56
+ agentSessionId: string;
57
+ getClient: () => HarmonyApiClient;
58
+ stateDir?: string;
59
+ intervalMs?: number;
60
+ }
61
+
62
+ export class RunEventForwarder {
63
+ private readonly cardId: string;
64
+ private readonly agentSessionId: string;
65
+ private readonly getClient: () => HarmonyApiClient;
66
+ private readonly stateDir: string;
67
+ private readonly baseIntervalMs: number;
68
+ private timer: ReturnType<typeof setTimeout> | null = null;
69
+ private flushing = false;
70
+ private stopped = false;
71
+ private consecutiveFailures = 0;
72
+ private lastPointerRefresh = Date.now();
73
+ /** batch path → how many times posting it has failed. */
74
+ private readonly attempts = new Map<string, number>();
75
+
76
+ constructor(options: RunEventForwarderOptions) {
77
+ this.cardId = options.cardId;
78
+ this.agentSessionId = options.agentSessionId;
79
+ this.getClient = options.getClient;
80
+ this.stateDir = options.stateDir ?? runStateDir();
81
+ this.baseIntervalMs = options.intervalMs ?? FLUSH_INTERVAL_MS;
82
+ }
83
+
84
+ private get dir(): string {
85
+ return spoolDir(this.stateDir, this.agentSessionId);
86
+ }
87
+
88
+ /** Current delay, geometric in the consecutive-failure count. */
89
+ private nextDelay(): number {
90
+ if (this.consecutiveFailures === 0) return this.baseIntervalMs;
91
+ const scaled =
92
+ this.baseIntervalMs * 2 ** Math.min(this.consecutiveFailures, 6);
93
+ return Math.min(scaled, MAX_INTERVAL_MS);
94
+ }
95
+
96
+ start(): void {
97
+ if (this.timer || this.stopped) return;
98
+ this.schedule();
99
+ }
100
+
101
+ private schedule(): void {
102
+ if (this.stopped) return;
103
+ this.timer = setTimeout(() => {
104
+ this.timer = null;
105
+ void this.flush().finally(() => this.schedule());
106
+ }, this.nextDelay());
107
+ // Telemetry must never be the reason the process stays alive.
108
+ this.timer.unref?.();
109
+ }
110
+
111
+ /**
112
+ * Post everything currently spooled.
113
+ *
114
+ * Returns the number of events accepted, so a test can assert progress
115
+ * without reaching into private state.
116
+ */
117
+ async flush(): Promise<number> {
118
+ if (this.flushing) return 0;
119
+ this.flushing = true;
120
+ try {
121
+ this.refreshPointer();
122
+ const batches = readSpoolBatches(this.dir, MAX_BATCHES_PER_FLUSH);
123
+ if (batches.length === 0) {
124
+ this.consecutiveFailures = 0;
125
+ return 0;
126
+ }
127
+
128
+ // Fill one request up to the server's event cap. Whole batches only: a
129
+ // batch is one tool call's start/end pair, and splitting it across
130
+ // requests would put the two halves in separate `seq` runs for no gain.
131
+ const paths: string[] = [];
132
+ const events: unknown[] = [];
133
+ for (const batch of batches) {
134
+ if (
135
+ events.length > 0 &&
136
+ events.length + batch.events.length > MAX_EVENTS_PER_REQUEST
137
+ ) {
138
+ break;
139
+ }
140
+ paths.push(batch.path);
141
+ events.push(...batch.events);
142
+ }
143
+ if (events.length === 0) return 0;
144
+
145
+ try {
146
+ await this.getClient().appendAgentRunEvents(this.cardId, {
147
+ sessionId: this.agentSessionId,
148
+ events: events as Parameters<
149
+ HarmonyApiClient["appendAgentRunEvents"]
150
+ >[1]["events"],
151
+ });
152
+ removeSpoolBatches(paths);
153
+ for (const path of paths) this.attempts.delete(path);
154
+ this.consecutiveFailures = 0;
155
+ return events.length;
156
+ } catch {
157
+ // Left on disk on purpose — that IS the re-queue. Only a batch that has
158
+ // failed repeatedly is dropped, so one unacceptable payload cannot wedge
159
+ // every later tool call behind it.
160
+ this.consecutiveFailures++;
161
+ const poisoned: string[] = [];
162
+ for (const path of paths) {
163
+ const next = (this.attempts.get(path) ?? 0) + 1;
164
+ this.attempts.set(path, next);
165
+ if (next >= MAX_BATCH_ATTEMPTS) poisoned.push(path);
166
+ }
167
+ if (poisoned.length > 0) {
168
+ removeSpoolBatches(poisoned);
169
+ for (const path of poisoned) this.attempts.delete(path);
170
+ }
171
+ return 0;
172
+ }
173
+ } finally {
174
+ this.flushing = false;
175
+ }
176
+ }
177
+
178
+ /**
179
+ * Keep the on-disk pointer young.
180
+ *
181
+ * `readPublishedSessions` ignores a pointer older than `MAX_POINTER_AGE_MS`,
182
+ * which is what stops a crashed publisher's pid — later reused by an
183
+ * unrelated process — from routing hooks at a session that ended hours ago.
184
+ * A live session therefore has to say so periodically.
185
+ */
186
+ private refreshPointer(): void {
187
+ const now = Date.now();
188
+ if (now - this.lastPointerRefresh < POINTER_REFRESH_MS) return;
189
+ this.lastPointerRefresh = now;
190
+ // `stateDir` is threaded explicitly rather than left to default. The
191
+ // default resolves the same value in production, which is exactly what
192
+ // makes the omission invisible — it only diverges under the
193
+ // `HARMONY_RUN_STATE_DIR` override, where a refresh would then re-publish
194
+ // into a different directory than the one it was published in.
195
+ publishRunSession(
196
+ { cardId: this.cardId, agentSessionId: this.agentSessionId },
197
+ { stateDir: this.stateDir },
198
+ );
199
+ }
200
+
201
+ /**
202
+ * Final drain, then remove the pointer and the spool.
203
+ *
204
+ * `handoverTo` is the agent session that is TAKING OVER this card in this
205
+ * process. It matters because the pointer file is named for `(pid, cardId)`,
206
+ * so a successor on the same card publishes over the same filename before
207
+ * this teardown runs: unlinking it here would blind every hook to a session
208
+ * that is very much live. On a handover the pointer therefore stays, and the
209
+ * spool is dropped only when it belongs to a different session — when the
210
+ * server hands back the SAME session id, that spool is the one the successor
211
+ * is already draining.
212
+ */
213
+ async stop(handover?: { handoverTo?: string }): Promise<void> {
214
+ if (this.stopped) return;
215
+ this.stopped = true;
216
+ if (this.timer) {
217
+ clearTimeout(this.timer);
218
+ this.timer = null;
219
+ }
220
+ // One last attempt so the tail of a run is not silently dropped. A failure
221
+ // here is terminal by construction: the spool is removed either way,
222
+ // because the session it belongs to no longer exists to attach events to.
223
+ try {
224
+ await this.flush();
225
+ } catch {
226
+ // `flush` already swallows; this guards a getClient() that throws.
227
+ }
228
+ const handoverTo = handover?.handoverTo;
229
+ const keepPointer = handoverTo !== undefined;
230
+ const keepSpool = handoverTo === this.agentSessionId;
231
+ clearRunSession(this.cardId, {
232
+ stateDir: this.stateDir,
233
+ ...(keepSpool ? {} : { agentSessionId: this.agentSessionId }),
234
+ ...(keepPointer ? { keepPointer: true } : {}),
235
+ });
236
+ }
237
+ }
238
+
239
+ /** cardId → the forwarder draining that card's session. */
240
+ const forwarders = new Map<string, RunEventForwarder>();
241
+
242
+ /** Operator kill-switch: no tool-call rows from this machine at all. */
243
+ export const DISABLE_RUN_HOOK_ENV = "HARMONY_DISABLE_RUN_HOOK";
244
+
245
+ /**
246
+ * Should publishing be skipped entirely?
247
+ *
248
+ * Two reasons, and the second is not decoration.
249
+ *
250
+ * 1. **`HARMONY_DISABLE_RUN_HOOK`.** A real operator knob: a team may decide
251
+ * that tool inputs and outputs do not belong on their board even redacted,
252
+ * and turning the stream off should not mean uninstalling the hook.
253
+ * 2. **A test run, unless it named its own state directory.** `trackActivity`
254
+ * publishes as a side effect of an auto-session, so EVERY test that calls a
255
+ * trigger tool would otherwise write a pointer into the developer's real
256
+ * `~/.harmony/runs/` and start a polling timer against it. That went
257
+ * unnoticed here because the sandbox denies writes outside the worktree —
258
+ * it is precisely the class of defect that only appears on a machine without
259
+ * one. A test that means to exercise this passes `stateDir` and is unaffected.
260
+ */
261
+ export function hookTimelineDisabled(
262
+ env: Record<string, string | undefined>,
263
+ explicitStateDir?: string,
264
+ ): boolean {
265
+ const off = env[DISABLE_RUN_HOOK_ENV]?.trim().toLowerCase();
266
+ if (off && off !== "0" && off !== "false") return true;
267
+ if (env.NODE_ENV === "test" && !explicitStateDir) return true;
268
+ return false;
269
+ }
270
+
271
+ /**
272
+ * Begin forwarding a session's spooled tool calls.
273
+ *
274
+ * Idempotent per card: a second `harmony_start_agent_session` on the same card
275
+ * replaces the forwarder rather than running two against one spool. The
276
+ * replaced forwarder is stopped as a HANDOVER, so its teardown does not unlink
277
+ * the pointer — or delete the spool — the caller has already published for the
278
+ * session taking over. See `RunEventForwarder.stop`.
279
+ */
280
+ export function startRunEventForwarder(
281
+ options: RunEventForwarderOptions,
282
+ ): RunEventForwarder {
283
+ const existing = forwarders.get(options.cardId);
284
+ if (existing) void existing.stop({ handoverTo: options.agentSessionId });
285
+ const forwarder = new RunEventForwarder(options);
286
+ forwarders.set(options.cardId, forwarder);
287
+ forwarder.start();
288
+ return forwarder;
289
+ }
290
+
291
+ /** Drain and tear down one card's forwarder. Safe when none is running. */
292
+ export async function stopRunEventForwarder(cardId: string): Promise<void> {
293
+ const forwarder = forwarders.get(cardId);
294
+ if (!forwarder) return;
295
+ forwarders.delete(cardId);
296
+ await forwarder.stop();
297
+ }
298
+
299
+ /** Drain and tear down every forwarder — process shutdown. */
300
+ export async function stopAllRunEventForwarders(): Promise<void> {
301
+ const all = [...forwarders.values()];
302
+ forwarders.clear();
303
+ await Promise.all(all.map((f) => f.stop().catch(() => undefined)));
304
+ }
305
+
306
+ /** Test seam: is a forwarder running for this card? */
307
+ export function hasRunEventForwarder(cardId: string): boolean {
308
+ return forwarders.has(cardId);
309
+ }
310
+
311
+ /**
312
+ * Publish this session on disk and begin draining its hook spool.
313
+ *
314
+ * The single entry point the tool handlers call, so the two guards below live
315
+ * in one place instead of being re-derived at each call site.
316
+ *
317
+ * **Guard 1 — a daemon run publishes nothing.** When the daemon declared a run
318
+ * to this process (`HARMONY_AGENT_CARD_ID` / `HARMONY_AGENT_SESSION_ID`, #1035)
319
+ * the run already self-reports its full stream from `cli-agent-runner.ts`. A
320
+ * daemon run cannot load the user settings layer, so the hook never fires for
321
+ * it and the doubling cannot happen — but publishing a pointer it has no reader
322
+ * for would leave a live-looking file that a NEIGHBOURING `/hmy` session's hook
323
+ * could then route to. Not publishing is both the cheaper and the safer answer.
324
+ *
325
+ * **Guard 2 — no pointer, no forwarder.** If the pointer cannot be written
326
+ * (read-only home, full disk) no hook will ever find this session, so a
327
+ * forwarder would poll an empty directory forever. Failing closed here is what
328
+ * makes "the hook is a no-op, never an error" true on the publisher's side too.
329
+ */
330
+ export function beginHookTimeline(args: {
331
+ cardId: string;
332
+ agentSessionId: string | undefined;
333
+ getClient: () => HarmonyApiClient;
334
+ /** Injected in tests. Defaults to the real environment. */
335
+ env?: Record<string, string | undefined>;
336
+ stateDir?: string;
337
+ }): RunEventForwarder | null {
338
+ if (!args.agentSessionId) return null;
339
+ const env = args.env ?? process.env;
340
+ if (readDeclaredRunSession(env)) return null;
341
+ if (hookTimelineDisabled(env, args.stateDir)) return null;
342
+
343
+ const published = publishRunSession(
344
+ { cardId: args.cardId, agentSessionId: args.agentSessionId },
345
+ args.stateDir ? { stateDir: args.stateDir } : undefined,
346
+ );
347
+ if (!published) return null;
348
+
349
+ return startRunEventForwarder({
350
+ cardId: args.cardId,
351
+ agentSessionId: args.agentSessionId,
352
+ getClient: args.getClient,
353
+ ...(args.stateDir ? { stateDir: args.stateDir } : {}),
354
+ });
355
+ }
356
+
357
+ /**
358
+ * Final drain, then unpublish. Call BEFORE the session row is ended: events
359
+ * appended to an ended session are refused, so the tail would be lost.
360
+ */
361
+ export async function endHookTimeline(cardId: string): Promise<void> {
362
+ await stopRunEventForwarder(cardId);
363
+ }
@@ -0,0 +1,55 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * The `PostToolUse` hook binary (#874).
4
+ *
5
+ * Installed into the USER settings layer (`~/.claude/settings.json`) by
6
+ * `harmony-mcp hook install`, never the project layer — see `hook-install.ts`
7
+ * for why that distinction is load-bearing rather than a preference.
8
+ *
9
+ * ## What it does, and what it deliberately does not
10
+ *
11
+ * It resolves which published MCP session it belongs to, redacts the tool call,
12
+ * and writes it to that session's spool directory. Then it exits.
13
+ *
14
+ * **It performs no network I/O and reads no credential.** The MCP server drains
15
+ * the spool and posts the batch (`run-event-forwarder.ts`). That split is the
16
+ * answer to the card's open "process model" question, and it buys three things
17
+ * at once:
18
+ *
19
+ * - **Batching for free.** The forwarder already lives in a long-running
20
+ * process with a timer, so hundreds of tool calls become a request every few
21
+ * seconds — the acceptance criterion — without this binary holding any state
22
+ * between invocations.
23
+ * - **No credential on the hot path.** A hook that posted would need the API
24
+ * key, on every tool call, in a process spawned by the agent. It does not
25
+ * have one, so there is nothing to leak and nothing to fail on.
26
+ * - **A trivially correct failure mode.** With no session, no state directory,
27
+ * or a full disk, the worst case is a file that does not get written.
28
+ *
29
+ * ## It exits 0. Always.
30
+ *
31
+ * A non-zero exit from a `PostToolUse` hook is surfaced to the agent and can
32
+ * derail the turn. Nothing this binary is for — improving a timeline — is worth
33
+ * breaking a tool call over, so every path is wrapped and the exit code is
34
+ * fixed. The card states it as an acceptance criterion: "the hook is a no-op —
35
+ * never an error".
36
+ *
37
+ * ## This file is a shim, on purpose
38
+ *
39
+ * The work is in `run-hook-main.ts`. The invocation below is unconditional —
40
+ * no `import.meta`/`argv[1]` entry check that a bundler could make wrong — and
41
+ * this file holds nothing else, so a test can exercise the whole hook by
42
+ * importing the other module. See that file's doc for the full reasoning.
43
+ */
44
+
45
+ import { runPostToolUseHook } from "./run-hook-main.js";
46
+
47
+ // `import.meta.main` is Bun-only; this binary runs under whatever Node the
48
+ // harness has, so the entry check is a plain invocation instead.
49
+ runPostToolUseHook()
50
+ .catch(() => {
51
+ // Swallowed on purpose. See the module doc: a hook may not fail a tool call.
52
+ })
53
+ .finally(() => {
54
+ process.exit(0);
55
+ });
@@ -0,0 +1,159 @@
1
+ /**
2
+ * The body of the `PostToolUse` hook (#874).
3
+ *
4
+ * ## Why this is not in `run-hook-cli.ts`
5
+ *
6
+ * `run-hook-cli.ts` is a bin: it calls this and exits, unconditionally, at
7
+ * module load. A test cannot import that file — importing it runs the hook and
8
+ * then calls `process.exit(0)` out from under the test runner. Review found the
9
+ * consequence: the one function that ties resolution, redaction and spooling
10
+ * together had no end-to-end test, and every test was of a part.
11
+ *
12
+ * The usual fix is an `import.meta`-vs-`process.argv[1]` entry check in the bin
13
+ * itself, and it is the wrong one here. It has to be right about a path through
14
+ * a bundler, and when it is wrong the failure is silent — a hook that runs and
15
+ * does nothing, which is indistinguishable from the no-op this thing is
16
+ * designed to be. Splitting the file leaves the bin's invocation unconditional,
17
+ * so production behaviour is unchanged by construction.
18
+ *
19
+ * See `run-hook-cli.ts` for what the hook is for and why it never posts.
20
+ */
21
+
22
+ import { buildHookEvents, type PostToolUsePayload } from "./run-hook.js";
23
+ import {
24
+ ancestorPids,
25
+ chooseRunSessionForHook,
26
+ type PublishedRunSession,
27
+ readPublishedSessions,
28
+ readRouteMemo,
29
+ runStateDir,
30
+ runStateExists,
31
+ spoolDir,
32
+ trimSpool,
33
+ writeRouteMemo,
34
+ writeSpoolBatch,
35
+ } from "./run-state.js";
36
+
37
+ /**
38
+ * Cap on spooled batches per session, mirroring `MAX_BUFFER` in the daemon's
39
+ * `cli-agent-runner.ts`. The daemon bounds an in-memory buffer; a spool
40
+ * outlives the process that filled it, so the same bound has to be enforced on
41
+ * disk or an offline session would grow without limit.
42
+ */
43
+ export const MAX_SPOOL_BATCHES = 1_000;
44
+
45
+ async function readStdin(): Promise<string> {
46
+ const chunks: Buffer[] = [];
47
+ for await (const chunk of process.stdin) {
48
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
49
+ // A runaway payload is not worth buffering; the redaction caps would throw
50
+ // most of it away regardless.
51
+ if (chunks.reduce((n, c) => n + c.length, 0) > 4_000_000) break;
52
+ }
53
+ return Buffer.concat(chunks).toString("utf-8");
54
+ }
55
+
56
+ /**
57
+ * Seams for the test, each defaulting to what the bin actually does.
58
+ *
59
+ * Deliberately narrow: only the three things a test process cannot supply —
60
+ * a home directory it may write to, stdin, and a process tree it controls.
61
+ * Everything else runs exactly as it does in the bin.
62
+ */
63
+ export interface HookRuntime {
64
+ /** Root of the on-disk run state. Defaults to `runStateDir()`. */
65
+ stateDir?: string;
66
+ /** Reads the hook payload. Defaults to draining `process.stdin`. */
67
+ readInput?: () => Promise<string>;
68
+ /** The hook process's ancestry. Defaults to `ancestorPids(process.pid)`. */
69
+ hookAncestorPids?: () => number[];
70
+ }
71
+
72
+ /**
73
+ * Resolve, redact, spool. Returns nothing and throws nothing worth catching —
74
+ * the bin swallows anything that escapes and exits 0 regardless.
75
+ */
76
+ export async function runPostToolUseHook(
77
+ runtime: HookRuntime = {},
78
+ ): Promise<void> {
79
+ const stateDir = runtime.stateDir ?? runStateDir();
80
+ // The overwhelmingly common case: nobody has ever started an MCP session on
81
+ // this machine, so there is no directory. Leave before touching stdin.
82
+ if (!runStateExists(stateDir)) return;
83
+
84
+ const raw = await (runtime.readInput ?? readStdin)();
85
+ if (!raw.trim()) return;
86
+
87
+ let payload: PostToolUsePayload;
88
+ try {
89
+ payload = JSON.parse(raw) as PostToolUsePayload;
90
+ } catch {
91
+ return;
92
+ }
93
+
94
+ const candidates = readPublishedSessions({ stateDir });
95
+ if (candidates.length === 0) return;
96
+
97
+ const harnessSessionId =
98
+ typeof payload.session_id === "string" ? payload.session_id : "";
99
+
100
+ const payloadCwd = typeof payload.cwd === "string" ? payload.cwd : undefined;
101
+
102
+ // Try the memo first — it skips the ancestry walk, which is the only
103
+ // expensive thing this binary can do.
104
+ //
105
+ // The memo is confirmed against the WHOLE identity it recorded, and the `cwd`
106
+ // condition is re-applied. A pid on its own does not identify a session: one
107
+ // MCP process routinely publishes two live pointers (an explicit `/hmy`
108
+ // session on card A survives the card-switch sweep in `auto-session.ts` while
109
+ // a trigger tool opens an auto-session on card B), and on the hosted
110
+ // transport one process serves many. Confirming a pid alone picked whichever
111
+ // pointer was read first and put card B's tool calls on card A — and it
112
+ // bypassed the `cwd` filter that `chooseRunSessionForHook` treats as
113
+ // necessary, so a reused pid could route to an unrelated publisher. Anything
114
+ // the memo cannot confirm falls through to the full resolution below, which
115
+ // then rewrites it.
116
+ let chosen: PublishedRunSession | null = null;
117
+ if (harnessSessionId) {
118
+ const memo = readRouteMemo(stateDir, harnessSessionId);
119
+ if (memo !== null) {
120
+ chosen =
121
+ candidates.find(
122
+ (c) =>
123
+ c.publisherPid === memo.publisherPid &&
124
+ c.cardId === memo.cardId &&
125
+ c.agentSessionId === memo.agentSessionId &&
126
+ (payloadCwd === undefined || c.cwd === payloadCwd),
127
+ ) ?? null;
128
+ }
129
+ }
130
+
131
+ if (!chosen) {
132
+ chosen = chooseRunSessionForHook({
133
+ candidates,
134
+ hookAncestorPids: (
135
+ runtime.hookAncestorPids ?? (() => ancestorPids(process.pid))
136
+ )(),
137
+ cwd: payloadCwd,
138
+ });
139
+ if (chosen && harnessSessionId) {
140
+ writeRouteMemo(stateDir, harnessSessionId, {
141
+ publisherPid: chosen.publisherPid,
142
+ cardId: chosen.cardId,
143
+ agentSessionId: chosen.agentSessionId,
144
+ });
145
+ }
146
+ }
147
+
148
+ // Genuine ambiguity between two concurrent sessions resolves to "no row".
149
+ // Attributing one session's work to another session's card would be worse
150
+ // than the gap this card exists to close.
151
+ if (!chosen) return;
152
+
153
+ const events = buildHookEvents(payload);
154
+ if (events.length === 0) return;
155
+
156
+ const dir = spoolDir(stateDir, chosen.agentSessionId);
157
+ trimSpool(dir, MAX_SPOOL_BATCHES);
158
+ writeSpoolBatch(dir, events);
159
+ }