@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.
- package/README.md +41 -2
- package/dist/cli.js +1188 -157
- package/dist/index.js +805 -42
- package/dist/lib/api-client.js +3 -1
- package/dist/run-hook-cli.js +742 -0
- package/package.json +4 -3
- package/src/api-client.ts +57 -1
- package/src/auto-session.ts +33 -0
- package/src/cli.ts +104 -0
- package/src/comment-session.ts +149 -0
- package/src/hook-install.ts +388 -0
- package/src/plan-task-link.ts +130 -0
- package/src/run-event-forwarder.ts +363 -0
- package/src/run-hook-cli.ts +55 -0
- package/src/run-hook-main.ts +159 -0
- package/src/run-hook.ts +203 -0
- package/src/run-redaction.ts +461 -0
- package/src/run-state.ts +679 -0
- package/src/server.ts +342 -34
- package/src/tui/setup.ts +3 -0
|
@@ -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
|
+
}
|