@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.
- package/.claude-plugin/plugin.json +1 -1
- package/hooks/CAPTURE_CONTRACT.md +178 -0
- package/hooks/agent-session-capture.mjs +214 -0
- package/hooks/agent-session-finalize.mjs +115 -30
- package/hooks/agent-session-link.mjs +35 -13
- package/hooks/capture-cli.mjs +294 -0
- package/hooks/ensure-agent-session-capture-worker.mjs +63 -0
- package/hooks/ensure-agent-session-capture.mjs +79 -0
- package/hooks/finalize-agent-session-worker.mjs +63 -0
- package/hooks/finalize-agent-session.mjs +24 -10
- package/hooks/hooks.json +23 -1
- package/hooks/record-session-mapping.mjs +17 -15
- package/package.json +1 -1
- package/skills/polygraph/SKILL.md +4 -2
- package/skills/polygraph/reference/delegation.md +25 -1
- package/skills/polygraph/reference/publish-changes.md +48 -1
- package/skills/polygraph/reference/session-description.md +1 -1
|
@@ -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
|
-
|
|
4
|
-
|
|
5
|
-
|
|
4
|
+
import {
|
|
5
|
+
cliFailure,
|
|
6
|
+
isManagedChildEnvironment,
|
|
7
|
+
launchDetachedHookWorker,
|
|
8
|
+
nonEmptyString,
|
|
9
|
+
observedAtValue,
|
|
10
|
+
runCaptureCliSync,
|
|
11
|
+
} from './capture-cli.mjs';
|
|
6
12
|
|
|
7
|
-
|
|
8
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
|
57
|
-
|
|
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
|
|
69
|
-
|
|
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 !==
|
|
142
|
+
if (FINALIZE_EVENTS_BY_AGENT[agentType] !== payload.hook_event_name) {
|
|
72
143
|
return undefined;
|
|
73
144
|
}
|
|
74
145
|
|
|
75
|
-
|
|
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,
|
|
113
|
-
//
|
|
114
|
-
//
|
|
115
|
-
//
|
|
116
|
-
|
|
117
|
-
|
|
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;
|