@polygraph/claude-plugin 0.5.1 → 0.5.3

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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "polygraph",
3
- "version": "0.5.1",
3
+ "version": "0.5.3",
4
4
  "description": "AI agent skills and subagents for Polygraph sessions, repository context, and coordination",
5
5
  "author": {
6
6
  "name": "Narwhal Technologies Inc",
@@ -0,0 +1,178 @@
1
+ # Capture wake hook contract
2
+
3
+ The wake hooks in this directory carry capture liveness only. Transcript
4
+ records remain canonical for prompts, answers, completion, and step
5
+ boundaries. No wake event communicates a semantic boundary, and no wake may
6
+ create, close, or cancel a step or pass a status.
7
+
8
+ ## Wake events per harness
9
+
10
+ Each harness wakes capture on prompt submission and on agent-done/idle,
11
+ using the lifecycle events its plugin API actually exposes:
12
+
13
+ - Claude Code: `UserPromptSubmit` and `Stop` (manifest-async).
14
+ - Codex: `UserPromptSubmit` and `Stop` (hook detaches via `--detach`).
15
+ - OpenCode: `chat.message` (session from the hook's documented
16
+ `input.sessionID`) and the `session.idle` bus event (both deferred to a
17
+ detached worker; subagent sessions resolve to their root before waking).
18
+ - Cursor: `beforeSubmitPrompt`, `afterAgentResponse`, and `stop` (all
19
+ detach via `--detach`). `beforeSubmitPrompt` is a blocking hook, so the
20
+ parent process must return immediately and emit nothing on stdout; Cursor
21
+ treats empty output as allow. `afterAgentResponse` is the agent-done wake:
22
+ an observational hook that fires once an assistant message completes. It
23
+ carries the message text, which the wake never forwards. `stop` is
24
+ Cursor's blocking agent-loop-end hook: it may answer with a
25
+ `followup_message`, which Cursor auto-submits as the next prompt, so the
26
+ wake exits 0 with empty stdout and never writes to it — Cursor treats no
27
+ output as no follow-up. Live multi-turn runs under `cursor-agent --plugin-dir`
28
+ dispatched `beforeSubmitPrompt`, `afterAgentResponse`, `sessionStart`,
29
+ `afterAgentThought`, `postToolUse`, and `sessionEnd` to plugin-scope
30
+ hooks; `stop` was never observed, so its registration is inert unless a
31
+ Cursor build dispatches it, in which case it repeats the same idempotent
32
+ poke. All registrations live in the plugin manifest; nothing may prompt a
33
+ user-scope `~/.cursor/hooks.json` registration. None of these events
34
+ defines a step boundary — a multi-message turn wakes more than once, and
35
+ the transcript alone decides where steps begin and end.
36
+
37
+ Every wake calls `_ensure-agent-session-capture` with the agent type, the
38
+ harness session ID, and `--observed-at <ms>`: the epoch-millisecond time at
39
+ which the hook fired, read synchronously in the hook process (for OpenCode,
40
+ in the event handler) before any deferral or worker detach. Ocean uses it to
41
+ refresh the exact mapping and to compare terminal-marker freshness. It is
42
+ lifecycle/liveness metadata only — never a step boundary and never data —
43
+ and a worker's own start time is never used, so a delayed worker cannot
44
+ present its startup as evidence that the harness was still live. Mutable
45
+ working-directory and transcript-path evidence must not narrow an ensure
46
+ lookup. There is no `--source` on the ensure command: a liveness poke has no
47
+ mapping provenance. Which event fired is never forwarded — prompt-submit and
48
+ agent-done wakes are identical invocations apart from the timestamp. The
49
+ legacy `_link-agent-session` compatibility fallback keeps the working
50
+ directory, transcript path, and `--source hook` because it still records a
51
+ mapping, and never carries `--observed-at`. `_finalize-agent-session` carries
52
+ its own `--observed-at`, described under Finalization.
53
+
54
+ ## Process identity
55
+
56
+ Harness and plugin links never carry a PID. A command hook's `process.ppid`
57
+ is an implementation detail of the harness's hook launcher and may name a
58
+ short-lived shell or helper rather than the agent. OpenCode's in-process
59
+ plugin PID is host-wide rather than session-specific. Neither is trustworthy
60
+ session lifecycle evidence, so `_link-agent-session`, capture wakes, and
61
+ finalizers never emit `--pid`. Claude, Codex, and Cursor use their explicit
62
+ session-end events for graceful finalization. OpenCode exposes no trustworthy
63
+ per-session exit event; capture already uploads transcript records
64
+ incrementally, and an unobserved hard exit does not create a semantic boundary.
65
+
66
+ ## Execution rules
67
+
68
+ Command-hook wakes are bounded by one shared twenty-second deadline,
69
+ repeatable, and may overlap or arrive out of order with SessionEnd. A
70
+ timed-out or ambiguously executed command is never retried by the plugin.
71
+ An older CLI that reports `_ensure-agent-session-capture` as unsupported is
72
+ retried once through the established `_link-agent-session` mapping command.
73
+ The version-skew check accepts Ocean's explicit marker and the observed Shell
74
+ 0.1.x root-usage/validation response whose first rejected argument is the
75
+ hidden command. Other CLI failures never trigger the fallback. A wake is
76
+ successful only when the preferred command or that fallback exits
77
+ successfully, so a successful compatibility wake does not log the old CLI's
78
+ full usage on every prompt and done event.
79
+
80
+ A `POLYGRAPH_CLI` that points at a plain `.js`/`.mjs`/`.cjs` entry always
81
+ runs through a Node runtime — never executed directly — so exactly one
82
+ process launches per wake even on hosts (Bun in OpenCode) that throw spawn
83
+ launch errors synchronously instead of reporting them on the result.
84
+
85
+ Every launch — the wake or finalize CLI process and the detached worker —
86
+ starts from a directory that exists: the claim's working directory when it
87
+ still does, else the home directory, else the temp directory. A harness
88
+ working directory can vanish before a delayed hook runs (an archived
89
+ session worktree), and a spawn from a missing cwd fails with ENOENT before
90
+ the CLI starts, which would silently drop a finalize. The fallback changes
91
+ only where the process starts; the `--cwd` evidence on the finalize and
92
+ legacy link commands stays the claim's original directory. The
93
+ `_link-agent-session` process run by `record-session-mapping` (SessionStart
94
+ and PostToolUse links) launches by the same rule while keeping its
95
+ synchronous, unbounded, stdout-ignored contract. A hook's own working
96
+ directory is consulted only when the payload carries none, only inside the
97
+ protected path, and only for Claude and Codex, whose command hooks run in the
98
+ session directory: a hook already running from a deleted directory
99
+ (`process.cwd()` fails with `uv_cwd`) still reaches that fallback instead of
100
+ crashing before it. Cursor runs plugin hooks from the plugin root, which is
101
+ never the repository, so a Cursor payload without `workspace_roots` yields a
102
+ claim with no directory and no `--cwd` on the finalize or legacy link
103
+ command; the launch then starts from the home directory as a spawn detail
104
+ only.
105
+
106
+ Detached command hooks, OpenCode wakes, and Claude/Codex/Cursor finalization
107
+ hand off to a detached worker. A wake worker enforces the shared twenty-second
108
+ CLI kill deadline; the finalization worker allows at most 90 seconds. Each
109
+ worker owns the complete CLI invocation and writes failures durably to
110
+ `~/.polygraph/logs/hooks.log`; the harness event loop observes launch errors
111
+ only. Workers always launch through a Node runtime — `process.execPath` when
112
+ it is Node, otherwise `node` from PATH — because OpenCode hosts the plugin
113
+ inside a compiled Bun binary.
114
+
115
+ A worker's inherited stdout/stderr go to `~/.polygraph/logs/capture-wake.log`
116
+ or `session-finalize.log`, each rotated to `.1` once it exceeds 5 MiB, the
117
+ same bound as `hooks.log`. If that file cannot be opened, the worker still
118
+ launches with its output discarded and the failure is logged best-effort;
119
+ no hook ever writes to its own stdout, and no hook waits on a worker.
120
+
121
+ ## Finalization
122
+
123
+ Claude's and Codex's `SessionEnd` and Cursor's `sessionEnd` finalize
124
+ (`_finalize-agent-session`, which keeps `--source`): each is the one
125
+ lifecycle event of its harness that means the conversation has ended. All
126
+ three hand off to the detached finalization worker and emit nothing on stdout.
127
+ Cursor's `sessionEnd` is observational and dispatched to plugin-scope hooks
128
+ under `cursor-agent --plugin-dir`; its payload carries `session_id` and
129
+ `conversation_id`, `workspace_roots`, `transcript_path`, `reason`, and
130
+ `final_status`. The finalize claim forwards identity, working directory,
131
+ transcript path, `--source hook`, and `--observed-at <ms>` — the
132
+ epoch-millisecond time at which the session-end hook fired, read
133
+ synchronously in the hook process before the worker detaches, exactly as a
134
+ wake reads it — and never a PID (Cursor's hook parent is a transient
135
+ wrapper) or the end reason, because the transcript alone decides what the
136
+ final answer was. The observation time is what lets Ocean order a finalize
137
+ against wakes and relinks: the finalization worker may run up to 90 seconds
138
+ after the harness exit it describes, and its own start time is never used. Claude `SessionStart` accepts
139
+ `startup`, `resume`, `clear`, `compact`, and `fork`. Ordinary Stop/idle
140
+ events never finalize. Codex's graceful `SessionEnd` hook has a one-second
141
+ default budget and three-second maximum; the hook only launches the detached
142
+ worker, so final capture continues outside that budget. OpenCode exposes no
143
+ trustworthy per-session exit event and cannot finalize.
144
+
145
+ ## Environment
146
+
147
+ All wake and finalize invocations preserve the hook environment except for
148
+ `POLYGRAPH_SESSION_ID` and `POLYGRAPH_CAPTURE_TOKEN`. A
149
+ `POLYGRAPH_CHILD_AGENT` environment disables every wake and finalize. On
150
+ Windows, Ocean may provide `POLYGRAPH_CLI_REEXEC` as a JSON argv array for a
151
+ shell-free invocation of the exact CLI build.
152
+
153
+ ## Ocean's obligations
154
+
155
+ Ocean must provide the ordering guarantee the plugin cannot impose across
156
+ asynchronous hooks: ensure/link and finalize are idempotent for an agent
157
+ session, finalize dominates an in-flight or later wake, and a wake after
158
+ finalization does not resurrect capture. Concretely, Ocean keeps a terminal
159
+ marker for each finalized harness session and applies a freshness guard to
160
+ it: a delayed or reordered wake whose `--observed-at` does not postdate a
161
+ harness session's terminal marker is a no-op and never resurrects capture,
162
+ while a genuinely newer session mapping for that identity — a later
163
+ SessionStart/sessionStart link recorded after the marker — may supersede
164
+ the older terminal marker and start fresh capture. The plugin never
165
+ distinguishes the two cases; it issues the same identity-plus-timestamp
166
+ wake either way. Ocean must accept `--observed-at` on
167
+ `_ensure-agent-session-capture` and on `_finalize-agent-session`, and must
168
+ ignore a finalize whose observation predates the mapping's last-seen time
169
+ (`mapping.lastSeenAt > observedAt`) instead of writing a completion marker:
170
+ a harness exit observed at T1 whose finalize lands after a
171
+ SessionStart/sessionStart relink or a wake of the same identity observed
172
+ later than T1 describes a conversation that has since resumed, and must not
173
+ terminate it. Ocean should emit
174
+ `POLYGRAPH_ENSURE_AGENT_SESSION_CAPTURE_UNSUPPORTED` when a CLI
175
+ intentionally cannot serve the ensure command; the plugin also recognizes
176
+ the legacy Shell 0.1.x stdout response: root `Usage: polygraph`, followed by
177
+ `Validation failed for one or more options` and `Unknown argument:
178
+ _ensure-agent-session-capture`, with exit status 1 and empty stderr.
@@ -0,0 +1,214 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import { fileURLToPath } from 'node:url';
3
+
4
+ import {
5
+ cliFailure,
6
+ isManagedChildEnvironment,
7
+ launchDetachedHookWorker,
8
+ nonEmptyString,
9
+ observedAtValue,
10
+ runCaptureCliSync,
11
+ } from './capture-cli.mjs';
12
+
13
+ // Ocean may spend up to 2.5 seconds waiting for its singleton startup lock and
14
+ // 10 seconds on the sidecar handshake. Keep enough headroom for CLI startup
15
+ // while retaining one bounded deadline across ensure and its legacy fallback.
16
+ export const ENSURE_CAPTURE_TIMEOUT_MS = 20_000;
17
+ export const ENSURE_CAPTURE_UNSUPPORTED_MARKER =
18
+ 'POLYGRAPH_ENSURE_AGENT_SESSION_CAPTURE_UNSUPPORTED';
19
+
20
+ const WAKE_AGENT_TYPES = new Set(['claude', 'codex', 'opencode', 'cursor']);
21
+
22
+ // Every wake event is capture liveness only. Which harness event fired, and
23
+ // in which order, is never forwarded: the transcript alone carries step
24
+ // semantics, so both the prompt-submit and the agent-done wake of a harness
25
+ // produce the identical identity-only invocation.
26
+ const WAKE_EVENTS_BY_AGENT = {
27
+ claude: new Set(['UserPromptSubmit', 'Stop']),
28
+ codex: new Set(['UserPromptSubmit', 'Stop']),
29
+ // afterAgentResponse fires per completed assistant message, so one Cursor
30
+ // turn may wake several times; every wake is the same idempotent poke and
31
+ // the message text it carries is never forwarded.
32
+ cursor: new Set(['beforeSubmitPrompt', 'afterAgentResponse', 'stop']),
33
+ };
34
+
35
+ const ENSURE_WAKE_WORKER_PATH = fileURLToPath(
36
+ new URL('./ensure-agent-session-capture-worker.mjs', import.meta.url)
37
+ );
38
+
39
+ function boundedEnsureTimeout(timeoutMs) {
40
+ if (!Number.isFinite(timeoutMs) || timeoutMs < 1) {
41
+ return ENSURE_CAPTURE_TIMEOUT_MS;
42
+ }
43
+ return Math.min(Math.floor(timeoutMs), ENSURE_CAPTURE_TIMEOUT_MS);
44
+ }
45
+
46
+ function wakeIdentityArgs({ agentType, agentSessionId }) {
47
+ const harnessSession = nonEmptyString(agentSessionId);
48
+ if (!WAKE_AGENT_TYPES.has(agentType)) {
49
+ throw new Error(`Unsupported agent type: ${agentType}`);
50
+ }
51
+ if (!harnessSession) throw new Error('agentSessionId is required');
52
+
53
+ return ['--agent-type', agentType, '--agent-session-id', harnessSession];
54
+ }
55
+
56
+ // The ensure command carries stable harness identity plus the moment the
57
+ // hook fired. Ocean refreshes the exact mapping and compares terminal-marker
58
+ // freshness against that timestamp, so it must be the hook's own clock
59
+ // reading, never the detached worker's start time: a delayed worker cannot
60
+ // present its startup as evidence that the harness was still live.
61
+ export function buildEnsureAgentSessionCaptureArgs({
62
+ agentType,
63
+ agentSessionId,
64
+ observedAt,
65
+ }) {
66
+ const identity = wakeIdentityArgs({ agentType, agentSessionId });
67
+ const observed = observedAtValue(observedAt);
68
+ if (observed === undefined) {
69
+ throw new Error('observedAt is required: a wake must carry the hook-captured time');
70
+ }
71
+
72
+ return [
73
+ '_ensure-agent-session-capture',
74
+ ...identity,
75
+ '--observed-at',
76
+ String(observed),
77
+ ];
78
+ }
79
+
80
+ // The legacy mapping command keeps its mutable path evidence and `--source`
81
+ // provenance and never carries `--observed-at`. The preferred ensure command
82
+ // above deliberately carries only stable harness identity so a cwd or
83
+ // transcript-path change cannot narrow a liveness lookup to zero mappings.
84
+ export function buildLegacyCaptureWakeArgs(claim) {
85
+ const args = ['_link-agent-session', ...wakeIdentityArgs(claim)];
86
+
87
+ const workingDirectory = nonEmptyString(claim.cwd);
88
+ if (workingDirectory) args.push('--cwd', workingDirectory);
89
+
90
+ const transcript = nonEmptyString(claim.transcriptPath);
91
+ if (transcript) args.push('--transcript-path', transcript);
92
+
93
+ args.push('--source', 'hook');
94
+ return args;
95
+ }
96
+
97
+ function commandUnavailable(result) {
98
+ if (result?.error) return false;
99
+ const output = `${result?.stdout ?? ''}\n${result?.stderr ?? ''}`;
100
+ if (output.includes(ENSURE_CAPTURE_UNSUPPORTED_MARKER)) return true;
101
+
102
+ // Shell 0.1.x prints the root usage to stdout and reports every token as an
103
+ // unknown argument. Match that complete, observed failure shape rather than
104
+ // treating an arbitrary error that happens to mention the hidden command as
105
+ // evidence of version skew.
106
+ const stdout =
107
+ typeof result?.stdout === 'string'
108
+ ? result.stdout.replace(/\r\n/g, '\n')
109
+ : '';
110
+ return (
111
+ result?.status === 1 &&
112
+ !nonEmptyString(result?.stderr) &&
113
+ stdout.startsWith('Usage: polygraph\n') &&
114
+ stdout.includes('\nValidation failed for one or more options\n') &&
115
+ stdout.includes('\n - Unknown argument: _ensure-agent-session-capture\n')
116
+ );
117
+ }
118
+
119
+ export function ensureAgentSessionCapture(
120
+ claim,
121
+ spawn = spawnSync,
122
+ env = process.env,
123
+ {
124
+ timeoutMs = ENSURE_CAPTURE_TIMEOUT_MS,
125
+ now = Date.now,
126
+ platform = process.platform,
127
+ execPath = process.execPath,
128
+ } = {}
129
+ ) {
130
+ if (isManagedChildEnvironment(env)) return false;
131
+
132
+ const deadline = now() + boundedEnsureTimeout(timeoutMs);
133
+ const options = {
134
+ killSignal: 'SIGKILL',
135
+ maxBuffer: 256 * 1024,
136
+ };
137
+ const run = (args) =>
138
+ runCaptureCliSync(args, {
139
+ env,
140
+ spawn,
141
+ options,
142
+ cwd: claim.cwd,
143
+ deadline,
144
+ now,
145
+ platform,
146
+ execPath,
147
+ });
148
+
149
+ const result = run(buildEnsureAgentSessionCaptureArgs(claim));
150
+ if (!result?.error && result?.status === 0) return true;
151
+
152
+ if (commandUnavailable(result)) {
153
+ const fallback = run(buildLegacyCaptureWakeArgs(claim));
154
+ if (!fallback?.error && fallback?.status === 0) return true;
155
+ throw cliFailure('_link-agent-session compatibility fallback', fallback);
156
+ }
157
+
158
+ throw cliFailure('_ensure-agent-session-capture', result);
159
+ }
160
+
161
+ export function launchAgentSessionCaptureWake(
162
+ claim,
163
+ spawn,
164
+ env = process.env,
165
+ { onFailure = () => {}, ...workerOptions } = {}
166
+ ) {
167
+ if (isManagedChildEnvironment(env)) return false;
168
+
169
+ return launchDetachedHookWorker({
170
+ workerPath: ENSURE_WAKE_WORKER_PATH,
171
+ logName: 'capture-wake.log',
172
+ ...workerOptions,
173
+ claim,
174
+ ...(spawn ? { spawn } : {}),
175
+ env,
176
+ onFailure,
177
+ });
178
+ }
179
+
180
+ export function buildCommandHookEnsureCapture(
181
+ payload,
182
+ agentType,
183
+ env = process.env,
184
+ now = Date.now
185
+ ) {
186
+ if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
187
+ return undefined;
188
+ }
189
+ if (isManagedChildEnvironment(env)) return undefined;
190
+
191
+ const wakeEvents = WAKE_EVENTS_BY_AGENT[agentType];
192
+ if (!wakeEvents?.has(payload.hook_event_name)) return undefined;
193
+
194
+ // Cursor payloads carry the id in both session_id and conversation_id and
195
+ // have no top-level cwd; workspace_roots[0] is the launch directory.
196
+ const agentSessionId =
197
+ nonEmptyString(payload.session_id) ??
198
+ (agentType === 'cursor' ? nonEmptyString(payload.conversation_id) : undefined);
199
+ if (!agentSessionId) return undefined;
200
+
201
+ const workspaceRoot =
202
+ agentType === 'cursor' && Array.isArray(payload.workspace_roots)
203
+ ? nonEmptyString(payload.workspace_roots[0])
204
+ : undefined;
205
+
206
+ // Captured here, synchronously in the hook process, before any detach.
207
+ return {
208
+ agentType,
209
+ agentSessionId,
210
+ cwd: nonEmptyString(payload.cwd) ?? workspaceRoot,
211
+ transcriptPath: nonEmptyString(payload.transcript_path),
212
+ observedAt: now(),
213
+ };
214
+ }
@@ -1,25 +1,57 @@
1
1
  import { spawnSync } from 'node:child_process';
2
+ import { fileURLToPath } from 'node:url';
2
3
 
3
- function nonEmptyString(value) {
4
- return typeof value === 'string' && value.trim() ? value : undefined;
5
- }
4
+ import {
5
+ cliFailure,
6
+ isManagedChildEnvironment,
7
+ launchDetachedHookWorker,
8
+ nonEmptyString,
9
+ observedAtValue,
10
+ runCaptureCliSync,
11
+ } from './capture-cli.mjs';
6
12
 
7
- function isManagedChildEnvironment(env) {
8
- return Boolean(env && Object.hasOwn(env, 'POLYGRAPH_CHILD_AGENT'));
9
- }
13
+ export const FINALIZE_TIMEOUT_MS = 90_000;
14
+
15
+ const FINALIZE_AGENT_TYPES = new Set(['claude', 'codex', 'cursor']);
16
+
17
+ // The one lifecycle event per harness that means the conversation ended.
18
+ // Claude and Codex send PascalCase; Cursor sends camelCase. OpenCode exposes
19
+ // no trustworthy per-session exit event.
20
+ const FINALIZE_EVENTS_BY_AGENT = {
21
+ claude: 'SessionEnd',
22
+ codex: 'SessionEnd',
23
+ cursor: 'sessionEnd',
24
+ };
25
+
26
+ const FINALIZE_WORKER_PATH = fileURLToPath(
27
+ new URL('./finalize-agent-session-worker.mjs', import.meta.url)
28
+ );
10
29
 
30
+ // The finalize command carries identity, the mutable path evidence,
31
+ // `--source`, and the moment the session-end hook fired. Ocean compares that
32
+ // observation against the mapping's last-seen time and ignores a finalize
33
+ // that predates a later wake or relink, so it must be the hook's own clock
34
+ // reading, never the detached worker's start time: a finalize worker may run
35
+ // up to 90 seconds after the harness exit it describes.
11
36
  export function buildFinalizeAgentSessionArgs({
12
37
  agentType,
13
38
  agentSessionId,
14
39
  cwd,
15
40
  transcriptPath,
16
41
  source,
42
+ observedAt,
17
43
  }) {
18
44
  const harnessSession = nonEmptyString(agentSessionId);
19
45
  const hookSource = nonEmptyString(source);
20
- if (agentType !== 'claude') throw new Error(`Unsupported agent type: ${agentType}`);
46
+ const observed = observedAtValue(observedAt);
47
+ if (!FINALIZE_AGENT_TYPES.has(agentType)) {
48
+ throw new Error(`Unsupported agent type: ${agentType}`);
49
+ }
21
50
  if (!harnessSession) throw new Error('agentSessionId is required');
22
51
  if (!hookSource) throw new Error('source is required');
52
+ if (observed === undefined) {
53
+ throw new Error('observedAt is required: a finalize must carry the hook-captured time');
54
+ }
23
55
 
24
56
  const args = [
25
57
  '_finalize-agent-session',
@@ -35,51 +67,104 @@ export function buildFinalizeAgentSessionArgs({
35
67
  const transcript = nonEmptyString(transcriptPath);
36
68
  if (transcript) args.push('--transcript-path', transcript);
37
69
 
38
- args.push('--source', hookSource);
70
+ args.push('--source', hookSource, '--observed-at', String(observed));
39
71
  return args;
40
72
  }
41
73
 
42
- export function finalizeAgentSession(claim, spawn = spawnSync, env = process.env) {
74
+ export function finalizeAgentSession(
75
+ claim,
76
+ spawn = spawnSync,
77
+ env = process.env,
78
+ runnerOptions = {}
79
+ ) {
43
80
  if (isManagedChildEnvironment(env)) return false;
44
81
 
45
- const command = nonEmptyString(env?.POLYGRAPH_CLI) ?? 'polygraph';
46
- const commandEnv = { ...env };
47
- delete commandEnv.POLYGRAPH_SESSION_ID;
48
- delete commandEnv.POLYGRAPH_CAPTURE_TOKEN;
49
-
50
- const result = spawn(command, buildFinalizeAgentSessionArgs(claim), {
51
- encoding: 'utf8',
52
- env: commandEnv,
53
- stdio: ['ignore', 'ignore', 'pipe'],
82
+ const {
83
+ options = {},
84
+ timeoutMs = FINALIZE_TIMEOUT_MS,
85
+ now = Date.now,
86
+ ...runOptions
87
+ } = runnerOptions;
88
+ const boundedTimeout =
89
+ Number.isFinite(timeoutMs) && timeoutMs > 0
90
+ ? Math.min(Math.floor(timeoutMs), FINALIZE_TIMEOUT_MS)
91
+ : FINALIZE_TIMEOUT_MS;
92
+ const deadline = now() + boundedTimeout;
93
+ const result = runCaptureCliSync(buildFinalizeAgentSessionArgs(claim), {
94
+ ...runOptions,
95
+ env,
96
+ spawn,
97
+ cwd: claim.cwd,
98
+ deadline,
99
+ now,
100
+ options: {
101
+ ...options,
102
+ killSignal: 'SIGKILL',
103
+ maxBuffer: 256 * 1024,
104
+ },
54
105
  });
55
106
 
56
- if (result?.error) throw result.error;
57
- if (result?.status !== 0) {
58
- const detail = nonEmptyString(result?.stderr);
59
- throw new Error(
60
- `polygraph _finalize-agent-session exited with status ${String(result?.status)}` +
61
- (detail ? `: ${detail}` : '')
62
- );
107
+ if (result?.error || result?.status !== 0) {
108
+ throw cliFailure('_finalize-agent-session', result);
63
109
  }
64
110
 
65
111
  return true;
66
112
  }
67
113
 
68
- export function buildCommandHookFinalize(payload, agentType, env = process.env) {
69
- if (!payload || typeof payload !== 'object') return undefined;
114
+ export function launchAgentSessionFinalize(
115
+ claim,
116
+ spawn,
117
+ env = process.env,
118
+ { workerPath = FINALIZE_WORKER_PATH, ...workerOptions } = {}
119
+ ) {
120
+ if (isManagedChildEnvironment(env)) return false;
121
+
122
+ return launchDetachedHookWorker({
123
+ logName: 'session-finalize.log',
124
+ ...workerOptions,
125
+ workerPath,
126
+ claim,
127
+ ...(spawn ? { spawn } : {}),
128
+ env,
129
+ });
130
+ }
131
+
132
+ export function buildCommandHookFinalize(
133
+ payload,
134
+ agentType,
135
+ env = process.env,
136
+ now = Date.now
137
+ ) {
138
+ if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
139
+ return undefined;
140
+ }
70
141
  if (isManagedChildEnvironment(env)) return undefined;
71
- if (agentType !== 'claude' || payload.hook_event_name !== 'SessionEnd') {
142
+ if (FINALIZE_EVENTS_BY_AGENT[agentType] !== payload.hook_event_name) {
72
143
  return undefined;
73
144
  }
74
145
 
75
- const agentSessionId = nonEmptyString(payload.session_id);
146
+ // Cursor payloads carry the id in both session_id and conversation_id and
147
+ // have no top-level cwd; workspace_roots[0] is the launch directory. The
148
+ // end reason and final status stay behind: the transcript alone decides
149
+ // what the final answer was. No finalize claim ever carries a PID.
150
+ const agentSessionId =
151
+ nonEmptyString(payload.session_id) ??
152
+ (agentType === 'cursor' ? nonEmptyString(payload.conversation_id) : undefined);
76
153
  if (!agentSessionId) return undefined;
77
154
 
155
+ const workspaceRoot =
156
+ agentType === 'cursor' && Array.isArray(payload.workspace_roots)
157
+ ? nonEmptyString(payload.workspace_roots[0])
158
+ : undefined;
159
+
160
+ // The observation time is read here, synchronously in the hook process,
161
+ // before the worker detaches.
78
162
  return {
79
163
  agentType,
80
164
  agentSessionId,
81
- cwd: nonEmptyString(payload.cwd),
165
+ cwd: nonEmptyString(payload.cwd) ?? workspaceRoot,
82
166
  transcriptPath: nonEmptyString(payload.transcript_path),
83
167
  source: 'hook',
168
+ observedAt: now(),
84
169
  };
85
170
  }
@@ -3,6 +3,8 @@ import { homedir } from 'node:os';
3
3
  import { basename, join } from 'node:path';
4
4
  import { spawnSync } from 'node:child_process';
5
5
 
6
+ import { resolveLaunchDirectory } from './capture-cli.mjs';
7
+
6
8
  const HOOK_LOG_MAX_BYTES = 5 * 1024 * 1024;
7
9
 
8
10
  const AGENT_TYPES = new Set(['claude', 'codex', 'opencode', 'cursor']);
@@ -29,13 +31,24 @@ export function isPolygraphMcpToolName(toolName) {
29
31
  return Boolean(name && (COMMAND_HOOK_TOOL.test(name) || OPENCODE_TOOL.test(name)));
30
32
  }
31
33
 
34
+ /**
35
+ * The harness session a hook payload names, for failure diagnostics. Claude
36
+ * and Codex carry it as session_id; Cursor's prompt and agent-done payloads
37
+ * carry only conversation_id, and its lifecycle payloads carry both with
38
+ * the same value.
39
+ */
40
+ export function hookPayloadSessionId(payload) {
41
+ return (
42
+ nonEmptyString(payload?.session_id) ?? nonEmptyString(payload?.conversation_id)
43
+ );
44
+ }
45
+
32
46
  export function buildLinkAgentSessionArgs({
33
47
  polygraphSessionId,
34
48
  agentType,
35
49
  agentSessionId,
36
50
  cwd,
37
51
  transcriptPath,
38
- pid,
39
52
  source,
40
53
  hookOperation,
41
54
  }) {
@@ -56,10 +69,6 @@ export function buildLinkAgentSessionArgs({
56
69
  const transcript = nonEmptyString(transcriptPath);
57
70
  if (transcript) args.push('--transcript-path', transcript);
58
71
 
59
- if (Number.isSafeInteger(pid) && pid > 0) {
60
- args.push('--pid', String(pid));
61
- }
62
-
63
72
  // Cursor post-tool evidence rides the hook payload (the transcript stores
64
73
  // no tool results); forwarded verbatim, classified by the CLI. The
65
74
  // operation travels on STDIN, never argv: toolInput can carry an entire
@@ -99,22 +108,35 @@ export function linkAgentSession(claim, spawn = spawnSync, env = process.env) {
99
108
  delete commandEnv.POLYGRAPH_CAPTURE_TOKEN;
100
109
  }
101
110
 
111
+ // The link launches from a directory that exists — the claim's own when it
112
+ // still does, else home, else temp — exactly like the wake and finalize
113
+ // processes. The launch directory is a spawn detail only: the recorded
114
+ // working directory is the claim's `--cwd` above, which a Cursor claim
115
+ // without workspace roots omits rather than substituting the plugin root.
116
+ const launchDirectory = resolveLaunchDirectory(claim.cwd, env);
102
117
  const spawnOptions = {
103
118
  encoding: 'utf8',
104
119
  env: commandEnv,
105
120
  stdio: [input === undefined ? 'ignore' : 'pipe', 'ignore', 'pipe'],
106
121
  ...(input === undefined ? {} : { input }),
122
+ ...(launchDirectory ? { cwd: launchDirectory } : {}),
107
123
  };
108
124
 
109
- let result = spawn(command, args, spawnOptions);
110
-
111
125
  // POLYGRAPH_CLI may point at a plain JS entry that cannot be spawned
112
- // directly: a dev build without the executable bit, or a platform that
113
- // cannot exec scripts. A spawn that failed to LAUNCH ran nothing, so the
114
- // retry under a Node runtime is side-effect free and anything that
115
- // spawns directly today keeps its exact behavior.
116
- if (result?.error && /\.[cm]?js$/i.test(command)) {
117
- result = spawn(nodeRuntime(), [command, ...args], spawnOptions);
126
+ // directly: a dev build without the executable bit, a local package
127
+ // install, or a platform that cannot exec scripts. Such entries run
128
+ // through a Node runtime up front, so exactly one process ever launches
129
+ // per link. Bun (which hosts this module in-process for OpenCode) throws
130
+ // launch errors from spawnSync instead of returning them, so both error
131
+ // shapes must land in the same path.
132
+ const jsEntry = /\.[cm]?js$/i.test(command);
133
+ let result;
134
+ try {
135
+ result = jsEntry
136
+ ? spawn(nodeRuntime(), [command, ...args], spawnOptions)
137
+ : spawn(command, args, spawnOptions);
138
+ } catch (error) {
139
+ throw error instanceof Error ? error : new Error(String(error));
118
140
  }
119
141
 
120
142
  if (result?.error) throw result.error;