phaedo-mcp 0.2.2 → 0.3.1
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/NOTICE +17 -0
- package/README.md +182 -19
- package/action-binding.js +82 -0
- package/agreement.js +86 -2
- package/ap2.js +214 -0
- package/autonomy-promotion.js +280 -0
- package/build-package.mjs +25 -10
- package/capture-a11y.js +116 -0
- package/capture-local.js +85 -0
- package/capture-sync.js +99 -0
- package/consult-core.js +126 -8
- package/consult-policy.js +427 -33
- package/consult-runtime.js +60 -0
- package/consult.js +29 -1
- package/delegation-sync.js +95 -0
- package/escalation-policy.sample.json +13 -0
- package/escalation.js +383 -0
- package/fingerprint-source.js +112 -13
- package/health.js +54 -0
- package/hooks/pre-ship.mjs +409 -0
- package/hooks/session-end.mjs +244 -0
- package/hooks/stop-capture.mjs +140 -0
- package/install.mjs +226 -36
- package/lib/capture-home.js +46 -0
- package/lib/domain.js +101 -0
- package/lib/is-main.js +40 -0
- package/lib/phaedo-crypto.js +17 -1
- package/package.json +29 -5
- package/phone-source.js +85 -12
- package/projection.js +4 -1
- package/pull.js +19 -3
- package/receipts-sync.js +197 -0
- package/receipts.js +34 -3
- package/server.js +323 -54
- package/server.json +3 -3
- package/ship-actions.js +164 -0
- package/ship-readout.js +106 -0
- package/signing-audit.js +111 -0
- package/signing-request.js +217 -0
- package/signing.js +176 -0
- package/vendor/authored-source.js +250 -0
- package/vendor/capabilities.js +203 -0
- package/vendor/capture/capture-core.js +67 -0
- package/vendor/capture/detectors.js +287 -0
- package/vendor/capture/exchanges.js +77 -0
- package/vendor/capture/model-detect.js +195 -0
- package/vendor/capture/owner.js +35 -0
- package/vendor/capture/package.json +4 -0
- package/vendor/capture/source-id.js +56 -0
- package/vendor/capture/state.js +148 -0
- package/vendor/capture/transcript.js +184 -0
- package/vendor/commitments.js +169 -0
- package/vendor/context-block.js +251 -18
- package/vendor/envelope.js +50 -4
- package/vendor/extraction/capture-combine.js +160 -0
- package/vendor/extraction/catalog.js +232 -0
- package/vendor/extraction/delta-lift.js +109 -0
- package/vendor/extraction/fingerprint-update.js +227 -0
- package/vendor/extraction/package.json +4 -0
- package/vendor/extraction/persist.js +45 -0
- package/vendor/extraction/reconcile.js +310 -0
- package/vendor/extraction/resolve.js +178 -0
- package/vendor/extraction/rule-promotion.js +103 -0
- package/vendor/kdf.js +20 -1
- package/vendor/protocol.js +7 -0
- package/vendor/signal-ledger.js +402 -0
- package/vendor/trait-phrases.js +656 -0
- package/vendored-files.mjs +86 -0
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Phaedo — Claude Code SessionEnd capture hook (Track A, option (a)).
|
|
3
|
+
//
|
|
4
|
+
// Claude Code runs this when a session ends, passing hook input on stdin
|
|
5
|
+
// ({ session_id, transcript_path, ... }). The hook:
|
|
6
|
+
// 1. reads the session transcript JSONL (local file, never leaves the machine),
|
|
7
|
+
// 2. parses it to an exchange_batch (capture/transcript.js),
|
|
8
|
+
// 3. runs the deterministic Path-A detectors over the user messages
|
|
9
|
+
// (capture/detectors.js — the parity-tested mirror of the extension's tables),
|
|
10
|
+
// optionally enriched by a LOCAL model classifying the regex-miss messages
|
|
11
|
+
// against the same taxonomy (capture/model-detect.js, option (b) — opt-in
|
|
12
|
+
// via PHAEDO_CAPTURE_MODEL, e.g. `llama3.1:8b` on Ollama; fail-safe),
|
|
13
|
+
// 4. folds this session's counts into the durable per-surface state, REPLACING
|
|
14
|
+
// any prior contribution from the same session (capture/state.js — resume-safe),
|
|
15
|
+
// 5. lifts the CUMULATIVE counters into source='delta' §5.1 signals
|
|
16
|
+
// (extraction/delta-lift.js — idempotent snapshot semantics), and
|
|
17
|
+
// 6. deposits the signal delta to the relay's capture_to_owner lane
|
|
18
|
+
// (mcp/capture-sync.js — ciphertext under fp_sync_key, best-effort).
|
|
19
|
+
//
|
|
20
|
+
// The raw transcript is read, counted, and DISCARDED — nothing derived from it
|
|
21
|
+
// but signal names + integers is ever persisted or transmitted (the discard-raw
|
|
22
|
+
// contract). Wire it up with `npm run setup:code-hook` (installs SessionStart
|
|
23
|
+
// injection + this SessionEnd capture + the throttled Stop capture in
|
|
24
|
+
// stop-capture.mjs, which re-runs this file for sessions that stay open), or by hand in
|
|
25
|
+
// ~/.claude/settings.json:
|
|
26
|
+
// { "hooks": { "SessionEnd": [ { "hooks": [
|
|
27
|
+
// { "type": "command", "command": "<node> <abs path to this file>" }
|
|
28
|
+
// ] } ] } }
|
|
29
|
+
//
|
|
30
|
+
// FAIL-SAFE: any error (no transcript, unpaired, relay down, corrupt state)
|
|
31
|
+
// exits 0 with NO output — capture must never break or slow a session's end.
|
|
32
|
+
// Unpaired machines still accumulate local state; deposits begin once paired.
|
|
33
|
+
//
|
|
34
|
+
// Packaging note: the capture/extraction modules load via lib/capture-home.js —
|
|
35
|
+
// repo root in the dev clone, mcp/vendor/ in the published npm tarball
|
|
36
|
+
// (build-package.mjs vendors the whole closure; test-vendored-capture.mjs proves
|
|
37
|
+
// the rootless layout works).
|
|
38
|
+
|
|
39
|
+
import { readFileSync, existsSync, statSync, openSync, readSync, closeSync } from 'node:fs';
|
|
40
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
41
|
+
import { dirname, resolve } from 'node:path';
|
|
42
|
+
import { loadCaptureModule } from '../lib/capture-home.js';
|
|
43
|
+
import { isMainModule } from '../lib/is-main.js';
|
|
44
|
+
import { loadPairingRecord } from '../phone-source.js';
|
|
45
|
+
import { buildCaptureDelta, depositCaptureDelta } from '../capture-sync.js';
|
|
46
|
+
|
|
47
|
+
// The capture stack loads through lib/capture-home.js so this hook runs BOTH
|
|
48
|
+
// from the dev clone (repo-root sources) and from the published npm tarball
|
|
49
|
+
// (build-package.mjs vendors them under mcp/vendor/). Top-level await: the
|
|
50
|
+
// install gate (install.mjs captureHookAvailable) guarantees these resolve
|
|
51
|
+
// wherever the hook is actually wired.
|
|
52
|
+
const [
|
|
53
|
+
{ transcriptToBatch },
|
|
54
|
+
{ accumulateBatchCounts },
|
|
55
|
+
{ captureModelOpts, enrichCountsWithModel },
|
|
56
|
+
{ deriveSourceDeviceId },
|
|
57
|
+
{ defaultStatePath, loadState, saveState, applySession, cumulativeCounts },
|
|
58
|
+
{ liftDeltaSignals },
|
|
59
|
+
] = await Promise.all([
|
|
60
|
+
'capture/transcript.js',
|
|
61
|
+
'capture/detectors.js',
|
|
62
|
+
'capture/model-detect.js',
|
|
63
|
+
'capture/source-id.js',
|
|
64
|
+
'capture/state.js',
|
|
65
|
+
'extraction/delta-lift.js',
|
|
66
|
+
].map(loadCaptureModule));
|
|
67
|
+
|
|
68
|
+
const MCP_DIR = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
69
|
+
const SURFACE = 'claude-code';
|
|
70
|
+
// Option (b): with a local capture model configured — env var OR the
|
|
71
|
+
// capture-model.json the installer writes — allow headroom for one
|
|
72
|
+
// classification call (its own 15s timeout) on top of the deterministic pass.
|
|
73
|
+
// Without one, the original tight budget stands.
|
|
74
|
+
const BUDGET_MS = captureModelOpts(process.env).model ? 30000 : 10000;
|
|
75
|
+
// Cap the transcript bytes we read+parse. The budget is a setTimeout race, which a
|
|
76
|
+
// setTimeout CANNOT preempt while the synchronous read/parse runs — so a huge transcript
|
|
77
|
+
// (long agentic sessions accumulate tool_result blobs) would block session-end past
|
|
78
|
+
// BUDGET_MS regardless. Recent behavior lives at the END of an append-only log, so on an
|
|
79
|
+
// oversized file we read only the last MAX_READ_BYTES and start parsing at the first
|
|
80
|
+
// whole line. 24 MB ≈ well under 100ms to parse — comfortably inside the budget.
|
|
81
|
+
const MAX_READ_BYTES = 24 * 1024 * 1024;
|
|
82
|
+
|
|
83
|
+
// Read the whole file, or just its tail when it exceeds MAX_READ_BYTES (dropping the
|
|
84
|
+
// leading partial line). Returns '' on any IO error (caller treats as unreadable).
|
|
85
|
+
function readTranscriptCapped(path) {
|
|
86
|
+
let size;
|
|
87
|
+
try { size = statSync(path).size; } catch { return readFileSync(path, 'utf8'); }
|
|
88
|
+
if (size <= MAX_READ_BYTES) return readFileSync(path, 'utf8');
|
|
89
|
+
const fd = openSync(path, 'r');
|
|
90
|
+
try {
|
|
91
|
+
const buf = Buffer.allocUnsafe(MAX_READ_BYTES);
|
|
92
|
+
readSync(fd, buf, 0, MAX_READ_BYTES, size - MAX_READ_BYTES);
|
|
93
|
+
const text = buf.toString('utf8');
|
|
94
|
+
const nl = text.indexOf('\n');
|
|
95
|
+
return nl >= 0 ? text.slice(nl + 1) : text; // drop the partial first line
|
|
96
|
+
} finally { closeSync(fd); }
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ── Capture pairing (whose key encrypts the deposit) ─────────────────────────
|
|
100
|
+
// The capture_to_owner envelope must be decryptable by the DRAINING owner. The
|
|
101
|
+
// PHONE is the tier-1 owner and its drain is BUILT (fpSync.drainCapture): it
|
|
102
|
+
// holds every client's pair_key and tries each until one decrypts, so the plain
|
|
103
|
+
// <mcp>/pairing.json — whatever pairing this machine rides — is the right
|
|
104
|
+
// default. The pairing.capture.json export (the extension's phaedo_local_pairing,
|
|
105
|
+
// copied by hand so the EXTENSION could decrypt) is RETIRED as a requirement:
|
|
106
|
+
// it remains a supported override for a phone app that predates the drain, or
|
|
107
|
+
// to pin the key explicitly. Resolution order:
|
|
108
|
+
// 1. PHAEDO_CAPTURE_PAIRING (explicit path)
|
|
109
|
+
// 2. <mcp>/pairing.capture.json — legacy/override (gitignored); delete it once
|
|
110
|
+
// the phone build with drainCapture is installed
|
|
111
|
+
// 3. <mcp>/pairing.json — the default
|
|
112
|
+
// Needs pair_key + relay_device_id (+ relay_device_secret for relay auth).
|
|
113
|
+
export class CapturePairingError extends Error {}
|
|
114
|
+
|
|
115
|
+
// Returns a valid capture pairing record, or null when NO capture pairing is
|
|
116
|
+
// configured (caller may fall back to mcp/pairing.json). THROWS CapturePairingError
|
|
117
|
+
// when a capture-pairing file is PRESENT but broken (unparseable, or missing
|
|
118
|
+
// pair_key/relay_device_id): silently falling back to a DIFFERENT pairing would
|
|
119
|
+
// encrypt every deposit under the wrong key to the wrong relay slot — the exact
|
|
120
|
+
// live-test failure the override exists to prevent, reinstated by a typo. The caller
|
|
121
|
+
// must skip the deposit instead. A record lacking relay_device_secret is returned but
|
|
122
|
+
// warned about (its deposits will 401 until the user re-pairs).
|
|
123
|
+
export function loadCapturePairing(mcpDir, env = process.env, warn = (m) => process.stderr.write(`[phaedo capture] ${m}\n`)) {
|
|
124
|
+
const candidates = [
|
|
125
|
+
{ path: env.PHAEDO_CAPTURE_PAIRING && resolve(process.cwd(), env.PHAEDO_CAPTURE_PAIRING), explicit: true },
|
|
126
|
+
{ path: resolve(mcpDir, 'pairing.capture.json'), explicit: false },
|
|
127
|
+
].filter((c) => c.path);
|
|
128
|
+
for (const { path, explicit } of candidates) {
|
|
129
|
+
if (!existsSync(path)) {
|
|
130
|
+
if (explicit) throw new CapturePairingError(`PHAEDO_CAPTURE_PAIRING points at a missing file: ${path} — not depositing (would use the wrong key).`);
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
let rec;
|
|
134
|
+
try { rec = JSON.parse(readFileSync(path, 'utf8')); }
|
|
135
|
+
catch (e) { throw new CapturePairingError(`capture pairing ${path} is not valid JSON (${e.message}) — not depositing (would use the wrong key). Re-export the extension's pairing.`); }
|
|
136
|
+
if (!rec || !rec.pair_key || !rec.relay_device_id) {
|
|
137
|
+
throw new CapturePairingError(`capture pairing ${path} is missing pair_key/relay_device_id — not depositing (would use the wrong key). Re-export the extension's pairing.`);
|
|
138
|
+
}
|
|
139
|
+
if (!rec.relay_device_secret) warn(`capture pairing ${path} has no relay_device_secret — deposits will 401 until you re-pair the extension.`);
|
|
140
|
+
return rec;
|
|
141
|
+
}
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// The full capture pass, dependency-injectable for tests.
|
|
146
|
+
// input — the hook's stdin JSON ({ session_id, transcript_path, ... }).
|
|
147
|
+
// deps — { readFile, statePath, pairing, fetch, relayBase, now, mcpDir }.
|
|
148
|
+
// Returns a result record (never throws).
|
|
149
|
+
export async function runCaptureHook(input, deps = {}) {
|
|
150
|
+
const read = deps.readFile || readTranscriptCapped;
|
|
151
|
+
const statePath = deps.statePath || defaultStatePath();
|
|
152
|
+
const nowIso = deps.now || new Date().toISOString();
|
|
153
|
+
|
|
154
|
+
const transcriptPath = input && input.transcript_path;
|
|
155
|
+
if (!transcriptPath) return { deposited: false, reason: 'no-transcript-path' };
|
|
156
|
+
let text;
|
|
157
|
+
try { text = read(transcriptPath); } catch { return { deposited: false, reason: 'transcript-unreadable' }; }
|
|
158
|
+
|
|
159
|
+
const batch = transcriptToBatch(text, { sessionId: input.session_id });
|
|
160
|
+
if (!batch.exchanges.length) return { deposited: false, reason: 'no-exchanges' };
|
|
161
|
+
let counts = accumulateBatchCounts(batch);
|
|
162
|
+
|
|
163
|
+
// Option (b) — model-backed detector enrichment (capture/model-detect.js):
|
|
164
|
+
// classify the regex-MISS messages against the same correction taxonomy and
|
|
165
|
+
// fold the hits into this session's counts BEFORE the per-session-replace
|
|
166
|
+
// state apply, so re-fires stay resume-safe and the deposit stays pure delta.
|
|
167
|
+
// Opt-in (PHAEDO_CAPTURE_MODEL env, or the installer-written
|
|
168
|
+
// capture-model.json); any failure leaves the regex counts intact.
|
|
169
|
+
let modelAdded = 0;
|
|
170
|
+
const modelOpts = deps.captureModel !== undefined ? deps.captureModel : captureModelOpts(deps.env);
|
|
171
|
+
if (modelOpts && modelOpts.model) {
|
|
172
|
+
const enriched = await enrichCountsWithModel(batch, counts, modelOpts);
|
|
173
|
+
counts = enriched.counts;
|
|
174
|
+
modelAdded = enriched.added;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Pairing is optional: without one we still accumulate state locally (the
|
|
178
|
+
// mcp-local owner tier can read it); the deposit just no-ops as 'unpaired'.
|
|
179
|
+
// A capture-specific pairing (the extension's, exported) wins over the MCP's
|
|
180
|
+
// own pairing.json so the draining owner can actually decrypt the deposit.
|
|
181
|
+
let pairing = deps.pairing;
|
|
182
|
+
if (pairing === undefined) {
|
|
183
|
+
try {
|
|
184
|
+
pairing = loadCapturePairing(deps.mcpDir || MCP_DIR, deps.env);
|
|
185
|
+
} catch (e) {
|
|
186
|
+
// A capture-pairing file exists but is broken: do NOT fall back to a different
|
|
187
|
+
// key (that silently deposits to the wrong slot). Accumulate state, skip deposit.
|
|
188
|
+
if (!(e instanceof CapturePairingError)) throw e;
|
|
189
|
+
process.stderr.write(`[phaedo capture] ${e.message}\n`);
|
|
190
|
+
const sourceId0 = deriveSourceDeviceId(null, SURFACE);
|
|
191
|
+
const st = applySession(loadState(statePath), sourceId0, batch.session_local_id || input.session_id, counts, { now: nowIso });
|
|
192
|
+
try { saveState(statePath, st); } catch { /* non-fatal */ }
|
|
193
|
+
return { deposited: false, reason: 'capture-pairing-invalid', counted: counts.message_count };
|
|
194
|
+
}
|
|
195
|
+
if (!pairing) {
|
|
196
|
+
try { pairing = await loadPairingRecord(deps.mcpDir || MCP_DIR); } catch { pairing = null; }
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
const sourceId = deriveSourceDeviceId(pairing, SURFACE);
|
|
200
|
+
|
|
201
|
+
const state = applySession(loadState(statePath), sourceId, batch.session_local_id || input.session_id, counts, { now: nowIso });
|
|
202
|
+
try { saveState(statePath, state); } catch { /* state loss just restarts counters — never fatal */ }
|
|
203
|
+
|
|
204
|
+
const signals = liftDeltaSignals(cumulativeCounts(state, sourceId).behavioral_signals, nowIso);
|
|
205
|
+
if (!signals.length) return { deposited: false, reason: 'no-signal', counted: counts.message_count };
|
|
206
|
+
|
|
207
|
+
const delta = buildCaptureDelta(signals, { sourceSurface: SURFACE, sourceDeviceId: sourceId, capturedAt: nowIso });
|
|
208
|
+
// capture-sync's `now` dep is a FUNCTION (envelope created_at); ours is an ISO string.
|
|
209
|
+
const res = await depositCaptureDelta(pairing, delta, { ...deps, now: () => nowIso });
|
|
210
|
+
// Surface a deposit that FAILED for a fixable reason (a 401 = the pairing lost its
|
|
211
|
+
// relay_device_secret; re-pair). 'unpaired'/'no-relay-device-id' are expected
|
|
212
|
+
// local-only states, not worth a warning. Goes to stderr — Claude Code parses only
|
|
213
|
+
// stdout, so this never disturbs the session, but it appears in the hook's error log.
|
|
214
|
+
if (res && res.deposited === false && (res.status === 401 || (res.reason && /^http-/.test(String(res.reason))))) {
|
|
215
|
+
process.stderr.write(`[phaedo capture] deposit rejected (${res.status || res.reason}) — re-pair the extension so capture can sync.\n`);
|
|
216
|
+
}
|
|
217
|
+
return { ...res, counted: counts.message_count, signals: signals.length, ...(modelAdded ? { model_added: modelAdded } : {}) };
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// ── script entry (Claude Code invokes this file directly) ────────────────────
|
|
221
|
+
// Symlink-safe (lib/is-main.js): the naive URL comparison silently returns false
|
|
222
|
+
// whenever the hook is reached through a link, turning this hook into a no-op that
|
|
223
|
+
// exits 0 and loses the session.
|
|
224
|
+
const isMain = isMainModule(import.meta.url);
|
|
225
|
+
if (isMain) {
|
|
226
|
+
const readStdin = () => new Promise((res) => {
|
|
227
|
+
let data = '';
|
|
228
|
+
process.stdin.on('data', (c) => { data += c; });
|
|
229
|
+
process.stdin.on('end', () => res(data));
|
|
230
|
+
process.stdin.on('error', () => res(''));
|
|
231
|
+
});
|
|
232
|
+
// Input comes on stdin from Claude Code (SessionEnd), or in PHAEDO_CAPTURE_INPUT when
|
|
233
|
+
// the Stop hook (stop-capture.mjs) launches this file detached for an open session.
|
|
234
|
+
const run = async () => {
|
|
235
|
+
let input = {};
|
|
236
|
+
const handed = process.env.PHAEDO_CAPTURE_INPUT;
|
|
237
|
+
try { input = JSON.parse(handed !== undefined ? handed : await readStdin()); } catch { /* no/bad input → no-op */ }
|
|
238
|
+
await runCaptureHook(input);
|
|
239
|
+
};
|
|
240
|
+
const timeout = new Promise((res) => setTimeout(res, BUDGET_MS));
|
|
241
|
+
Promise.race([run().catch(() => {}), timeout])
|
|
242
|
+
.then(() => process.exit(0))
|
|
243
|
+
.catch(() => process.exit(0)); // a hook must never break the session
|
|
244
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Phaedo — Claude Code Stop hook: capture a session that is still OPEN.
|
|
3
|
+
//
|
|
4
|
+
// The SessionEnd hook (session-end.mjs) only runs when a session ends, and the
|
|
5
|
+
// Claude desktop app keeps sessions open for days — measured 2026-10-20, no Claude
|
|
6
|
+
// Code session had been captured since 2026-09-20 although ~39 had happened. Stop
|
|
7
|
+
// fires at the end of every assistant turn, so this hook re-runs the SAME capture on
|
|
8
|
+
// the live transcript, at most once per session per CAPTURE_INTERVAL_MIN.
|
|
9
|
+
//
|
|
10
|
+
// That is safe to repeat: capture/state.js applySession REPLACES a session's counts
|
|
11
|
+
// (never adds), so capturing a session at turn 10, turn 40 and again at SessionEnd
|
|
12
|
+
// leaves exactly one contribution — the latest. The relay deposit is a cumulative
|
|
13
|
+
// snapshot (delta-lift), so a later deposit supersedes an earlier one.
|
|
14
|
+
//
|
|
15
|
+
// Cost on the common path (inside the window): read one small JSON file, exit. No
|
|
16
|
+
// transcript read, no capture modules loaded. When a capture IS due, the window is
|
|
17
|
+
// claimed first and the capture runs in a DETACHED child (session-end.mjs, input via
|
|
18
|
+
// PHAEDO_CAPTURE_INPUT) — the turn never waits for the transcript parse or the relay.
|
|
19
|
+
//
|
|
20
|
+
// FAIL-SAFE: any error exits 0 with NO output. Stop hooks can block a turn from
|
|
21
|
+
// ending by printing a decision — this one never prints anything.
|
|
22
|
+
//
|
|
23
|
+
// Wired by `npm run setup:code-hook` beside SessionStart/SessionEnd, or by hand in
|
|
24
|
+
// ~/.claude/settings.json:
|
|
25
|
+
// { "hooks": { "Stop": [ { "hooks": [
|
|
26
|
+
// { "type": "command", "command": "<node> <abs path to this file>" }
|
|
27
|
+
// ] } ] } }
|
|
28
|
+
|
|
29
|
+
import { readFileSync, writeFileSync, renameSync, mkdirSync } from 'node:fs';
|
|
30
|
+
import { spawn as nodeSpawn } from 'node:child_process';
|
|
31
|
+
import { dirname, join, resolve } from 'node:path';
|
|
32
|
+
import { homedir } from 'node:os';
|
|
33
|
+
import { fileURLToPath } from 'node:url';
|
|
34
|
+
import { isMainModule } from '../lib/is-main.js';
|
|
35
|
+
|
|
36
|
+
const CAPTURE_HOOK_PATH = resolve(dirname(fileURLToPath(import.meta.url)), 'session-end.mjs');
|
|
37
|
+
export const DEFAULT_INTERVAL_MIN = 15;
|
|
38
|
+
// Throttle entries older than this are dropped on write, so the file stays a few lines.
|
|
39
|
+
const KEEP_MS = 7 * 86400 * 1000;
|
|
40
|
+
// The whole hook is a file read + maybe a spawn; never hold a turn longer than this.
|
|
41
|
+
const BUDGET_MS = 2000;
|
|
42
|
+
|
|
43
|
+
// Same directory capture/state.js uses (defaultStatePath) — inlined so the common
|
|
44
|
+
// path loads nothing from the capture stack.
|
|
45
|
+
export function defaultThrottlePath(env = process.env) {
|
|
46
|
+
return join(env.PHAEDO_MCP_STATE_DIR || join(homedir(), '.phaedo-mcp'), 'capture-throttle.json');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function intervalMs(env = process.env) {
|
|
50
|
+
const min = Number(env.PHAEDO_CAPTURE_INTERVAL_MIN);
|
|
51
|
+
return (Number.isFinite(min) && min >= 1 ? min : DEFAULT_INTERVAL_MIN) * 60 * 1000;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Missing or corrupt → {} (a broken throttle file means "capture now", never "never").
|
|
55
|
+
function loadThrottle(path) {
|
|
56
|
+
try {
|
|
57
|
+
const t = JSON.parse(readFileSync(path, 'utf8'));
|
|
58
|
+
return t && typeof t === 'object' && !Array.isArray(t) ? t : {};
|
|
59
|
+
} catch { return {}; }
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Due unless this session was captured within the window. A stamp in the FUTURE
|
|
63
|
+
// (clock moved back) counts as due — a bad clock must not silence capture.
|
|
64
|
+
export function isDue(throttle, sessionId, nowMs, windowMs) {
|
|
65
|
+
const last = Number(throttle[sessionId]);
|
|
66
|
+
if (!Number.isFinite(last)) return true;
|
|
67
|
+
return !(last <= nowMs && nowMs - last < windowMs);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Two sessions stopping at the same instant can each overwrite the other's stamp;
|
|
71
|
+
// the loser just captures again on its next Stop (harmless — capture is idempotent).
|
|
72
|
+
function saveThrottle(path, throttle, sessionId, nowMs) {
|
|
73
|
+
const next = {};
|
|
74
|
+
for (const [id, at] of Object.entries(throttle)) {
|
|
75
|
+
if (Number.isFinite(Number(at)) && nowMs - Number(at) < KEEP_MS && Number(at) <= nowMs) next[id] = Number(at);
|
|
76
|
+
}
|
|
77
|
+
next[sessionId] = nowMs;
|
|
78
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
79
|
+
const tmp = `${path}.${process.pid}.tmp`;
|
|
80
|
+
writeFileSync(tmp, JSON.stringify(next) + '\n', 'utf8');
|
|
81
|
+
renameSync(tmp, path);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Launch session-end.mjs detached, so it outlives this hook and the turn ends now.
|
|
85
|
+
function spawnCapture(input, { spawn = nodeSpawn, hookPath = CAPTURE_HOOK_PATH, env = process.env } = {}) {
|
|
86
|
+
const child = spawn(process.execPath, [hookPath], {
|
|
87
|
+
detached: true,
|
|
88
|
+
stdio: 'ignore',
|
|
89
|
+
windowsHide: true,
|
|
90
|
+
env: { ...env, PHAEDO_CAPTURE_INPUT: JSON.stringify(input) },
|
|
91
|
+
});
|
|
92
|
+
if (child && typeof child.on === 'function') child.on('error', () => {});
|
|
93
|
+
if (child && typeof child.unref === 'function') child.unref();
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// The Stop pass, dependency-injectable for tests.
|
|
97
|
+
// input — the hook's stdin JSON ({ session_id, transcript_path, ... }).
|
|
98
|
+
// deps — { now (ms), throttlePath, windowMs, spawn, hookPath, env }.
|
|
99
|
+
// Returns { spawned, reason } (never throws).
|
|
100
|
+
export function runStopHook(input, deps = {}) {
|
|
101
|
+
try {
|
|
102
|
+
const env = deps.env || process.env;
|
|
103
|
+
const sessionId = input && input.session_id;
|
|
104
|
+
const transcriptPath = input && input.transcript_path;
|
|
105
|
+
if (!sessionId || !transcriptPath) return { spawned: false, reason: 'no-session' };
|
|
106
|
+
const nowMs = deps.now !== undefined ? deps.now : Date.now();
|
|
107
|
+
const path = deps.throttlePath || defaultThrottlePath(env);
|
|
108
|
+
const windowMs = deps.windowMs || intervalMs(env);
|
|
109
|
+
const throttle = loadThrottle(path);
|
|
110
|
+
if (!isDue(throttle, sessionId, nowMs, windowMs)) return { spawned: false, reason: 'throttled' };
|
|
111
|
+
// Claim the window BEFORE spawning: a second Stop a moment later must not start a
|
|
112
|
+
// second capture. If the claim can't be written, don't capture either — otherwise
|
|
113
|
+
// an unwritable state dir would mean a full capture on every turn.
|
|
114
|
+
saveThrottle(path, throttle, sessionId, nowMs);
|
|
115
|
+
spawnCapture({ session_id: sessionId, transcript_path: transcriptPath, hook_event_name: 'Stop' },
|
|
116
|
+
{ spawn: deps.spawn, hookPath: deps.hookPath, env });
|
|
117
|
+
return { spawned: true, reason: 'due' };
|
|
118
|
+
} catch {
|
|
119
|
+
return { spawned: false, reason: 'error' };
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// ── script entry (Claude Code invokes this file directly) ────────────────────
|
|
124
|
+
if (isMainModule(import.meta.url)) {
|
|
125
|
+
const readStdin = () => new Promise((res) => {
|
|
126
|
+
let data = '';
|
|
127
|
+
process.stdin.on('data', (c) => { data += c; });
|
|
128
|
+
process.stdin.on('end', () => res(data));
|
|
129
|
+
process.stdin.on('error', () => res(''));
|
|
130
|
+
});
|
|
131
|
+
const run = async () => {
|
|
132
|
+
let input = {};
|
|
133
|
+
try { input = JSON.parse(await readStdin()); } catch { /* no/bad input → no-op */ }
|
|
134
|
+
runStopHook(input);
|
|
135
|
+
};
|
|
136
|
+
const timeout = new Promise((res) => setTimeout(res, BUDGET_MS));
|
|
137
|
+
Promise.race([run().catch(() => {}), timeout])
|
|
138
|
+
.then(() => process.exit(0))
|
|
139
|
+
.catch(() => process.exit(0)); // a hook must never break the session
|
|
140
|
+
}
|