@polygraph/codex-plugin 0.5.0 → 0.5.2
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/.codex-plugin/plugin.json +1 -1
- package/hooks/agent-session-capture.mjs +214 -0
- package/hooks/agent-session-finalize.mjs +170 -0
- 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 +70 -0
- package/hooks/hooks.json +33 -0
- 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 +14 -2
- package/skills/polygraph/reference/publish-changes.md +48 -1
- package/skills/polygraph/reference/session-description.md +1 -1
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
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
|
+
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
|
+
);
|
|
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.
|
|
36
|
+
export function buildFinalizeAgentSessionArgs({
|
|
37
|
+
agentType,
|
|
38
|
+
agentSessionId,
|
|
39
|
+
cwd,
|
|
40
|
+
transcriptPath,
|
|
41
|
+
source,
|
|
42
|
+
observedAt,
|
|
43
|
+
}) {
|
|
44
|
+
const harnessSession = nonEmptyString(agentSessionId);
|
|
45
|
+
const hookSource = nonEmptyString(source);
|
|
46
|
+
const observed = observedAtValue(observedAt);
|
|
47
|
+
if (!FINALIZE_AGENT_TYPES.has(agentType)) {
|
|
48
|
+
throw new Error(`Unsupported agent type: ${agentType}`);
|
|
49
|
+
}
|
|
50
|
+
if (!harnessSession) throw new Error('agentSessionId is required');
|
|
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
|
+
}
|
|
55
|
+
|
|
56
|
+
const args = [
|
|
57
|
+
'_finalize-agent-session',
|
|
58
|
+
'--agent-type',
|
|
59
|
+
agentType,
|
|
60
|
+
'--agent-session-id',
|
|
61
|
+
harnessSession,
|
|
62
|
+
];
|
|
63
|
+
|
|
64
|
+
const workingDirectory = nonEmptyString(cwd);
|
|
65
|
+
if (workingDirectory) args.push('--cwd', workingDirectory);
|
|
66
|
+
|
|
67
|
+
const transcript = nonEmptyString(transcriptPath);
|
|
68
|
+
if (transcript) args.push('--transcript-path', transcript);
|
|
69
|
+
|
|
70
|
+
args.push('--source', hookSource, '--observed-at', String(observed));
|
|
71
|
+
return args;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function finalizeAgentSession(
|
|
75
|
+
claim,
|
|
76
|
+
spawn = spawnSync,
|
|
77
|
+
env = process.env,
|
|
78
|
+
runnerOptions = {}
|
|
79
|
+
) {
|
|
80
|
+
if (isManagedChildEnvironment(env)) return false;
|
|
81
|
+
|
|
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
|
+
},
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
if (result?.error || result?.status !== 0) {
|
|
108
|
+
throw cliFailure('_finalize-agent-session', result);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return true;
|
|
112
|
+
}
|
|
113
|
+
|
|
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
|
+
}
|
|
141
|
+
if (isManagedChildEnvironment(env)) return undefined;
|
|
142
|
+
if (FINALIZE_EVENTS_BY_AGENT[agentType] !== payload.hook_event_name) {
|
|
143
|
+
return undefined;
|
|
144
|
+
}
|
|
145
|
+
|
|
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);
|
|
153
|
+
if (!agentSessionId) return undefined;
|
|
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.
|
|
162
|
+
return {
|
|
163
|
+
agentType,
|
|
164
|
+
agentSessionId,
|
|
165
|
+
cwd: nonEmptyString(payload.cwd) ?? workspaceRoot,
|
|
166
|
+
transcriptPath: nonEmptyString(payload.transcript_path),
|
|
167
|
+
source: 'hook',
|
|
168
|
+
observedAt: now(),
|
|
169
|
+
};
|
|
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;
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
import { spawn as spawnChild, spawnSync } from 'node:child_process';
|
|
2
|
+
import { closeSync, mkdirSync, openSync, renameSync, statSync } from 'node:fs';
|
|
3
|
+
import { homedir, tmpdir } from 'node:os';
|
|
4
|
+
import { basename, join } from 'node:path';
|
|
5
|
+
|
|
6
|
+
const JS_CLI_ENTRY = /\.[cm]?js$/i;
|
|
7
|
+
export const HOOK_WORKER_LOG_MAX_BYTES = 5 * 1024 * 1024;
|
|
8
|
+
|
|
9
|
+
export function nonEmptyString(value) {
|
|
10
|
+
return typeof value === 'string' && value.trim() ? value : undefined;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function isManagedChildEnvironment(env) {
|
|
14
|
+
return Boolean(env && Object.hasOwn(env, 'POLYGRAPH_CHILD_AGENT'));
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function captureCommandEnvironment(env = process.env) {
|
|
18
|
+
const commandEnv = { ...env };
|
|
19
|
+
delete commandEnv.POLYGRAPH_SESSION_ID;
|
|
20
|
+
delete commandEnv.POLYGRAPH_CAPTURE_TOKEN;
|
|
21
|
+
return commandEnv;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The directory a capture process is launched from. A claim carries the
|
|
26
|
+
* harness working directory, but that directory can be gone by the time a
|
|
27
|
+
* delayed hook or detached worker runs (an archived session worktree, a
|
|
28
|
+
* removed temp dir), and a spawn from a missing cwd fails with ENOENT before
|
|
29
|
+
* the CLI ever starts. Working-directory evidence reaches the CLI as an
|
|
30
|
+
* explicit `--cwd` argument where it matters, so the launch itself only
|
|
31
|
+
* needs a directory that exists: the claim's own when it does, else the home
|
|
32
|
+
* directory, else the temp directory.
|
|
33
|
+
*/
|
|
34
|
+
export function resolveLaunchDirectory(preferred, env = process.env) {
|
|
35
|
+
const candidates = [preferred, nonEmptyString(env?.HOME) ?? homedir(), tmpdir()];
|
|
36
|
+
for (const candidate of candidates) {
|
|
37
|
+
const directory = nonEmptyString(candidate);
|
|
38
|
+
if (!directory) continue;
|
|
39
|
+
try {
|
|
40
|
+
if (statSync(directory).isDirectory()) return directory;
|
|
41
|
+
} catch {
|
|
42
|
+
// Missing or unreadable: try the next candidate.
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return undefined;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The hook process's own working directory, or undefined when it no longer
|
|
50
|
+
* has one. A harness can start a hook in a directory removed moments earlier
|
|
51
|
+
* or remove it while the hook runs, and `process.cwd()` then throws
|
|
52
|
+
* `uv_cwd`. Callers use this only as the last-resort claim directory, so a
|
|
53
|
+
* missing answer degrades to the launch fallback instead of a crash.
|
|
54
|
+
*/
|
|
55
|
+
export function processWorkingDirectory() {
|
|
56
|
+
try {
|
|
57
|
+
return process.cwd();
|
|
58
|
+
} catch {
|
|
59
|
+
return undefined;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The working directory a claim may record when the payload carries none.
|
|
65
|
+
* Claude and Codex run command hooks in the session's own directory, so the
|
|
66
|
+
* hook process's cwd is genuine harness evidence there. Cursor runs plugin
|
|
67
|
+
* hooks from the plugin root, which is never the repository: a Cursor claim
|
|
68
|
+
* without workspace_roots records no directory at all, and the launch
|
|
69
|
+
* fallback (home, then temp) stays a spawn detail rather than evidence.
|
|
70
|
+
*/
|
|
71
|
+
export function fallbackClaimDirectory(agentType, hookCwd) {
|
|
72
|
+
if (agentType === 'cursor') return undefined;
|
|
73
|
+
return nonEmptyString(hookCwd) ?? processWorkingDirectory();
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* A hook-captured observation time: a positive epoch-millisecond integer, or
|
|
78
|
+
* undefined for anything else. Wakes and finalizations both carry one, and
|
|
79
|
+
* neither ever substitutes a worker's own clock for it.
|
|
80
|
+
*/
|
|
81
|
+
export function observedAtValue(value) {
|
|
82
|
+
return Number.isSafeInteger(value) && value > 0 ? value : undefined;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function nodeRuntime(execPath) {
|
|
86
|
+
const base = basename(execPath).toLowerCase();
|
|
87
|
+
return base === 'node' || base === 'node.exe' ? execPath : 'node';
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function portableReexec(env, platform) {
|
|
91
|
+
const raw = nonEmptyString(env.POLYGRAPH_CLI_REEXEC);
|
|
92
|
+
if (!raw || platform !== 'win32') return undefined;
|
|
93
|
+
|
|
94
|
+
try {
|
|
95
|
+
const parsed = JSON.parse(raw);
|
|
96
|
+
if (
|
|
97
|
+
Array.isArray(parsed) &&
|
|
98
|
+
parsed.length > 0 &&
|
|
99
|
+
parsed.every((part) => nonEmptyString(part))
|
|
100
|
+
) {
|
|
101
|
+
return parsed;
|
|
102
|
+
}
|
|
103
|
+
} catch {
|
|
104
|
+
// An invalid portability hint is ignored in favor of the normal launch.
|
|
105
|
+
}
|
|
106
|
+
return undefined;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function deadlineExceededResult() {
|
|
110
|
+
const error = new Error('Polygraph capture command timed out before launch');
|
|
111
|
+
error.code = 'ETIMEDOUT';
|
|
112
|
+
return { error, status: null, signal: 'SIGTERM' };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function withRemainingTimeout(options, deadline, now) {
|
|
116
|
+
if (deadline === undefined) return options;
|
|
117
|
+
const remaining = Math.floor(deadline - now());
|
|
118
|
+
if (remaining < 1) return undefined;
|
|
119
|
+
const configured = Number.isFinite(options.timeout) ? options.timeout : remaining;
|
|
120
|
+
return { ...options, timeout: Math.min(configured, remaining) };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function runBeforeDeadline(spawn, command, args, options, deadline, now) {
|
|
124
|
+
const boundedOptions = withRemainingTimeout(options, deadline, now);
|
|
125
|
+
if (!boundedOptions) return deadlineExceededResult();
|
|
126
|
+
// Some runtimes hosting these hooks in-process (OpenCode runs under Bun)
|
|
127
|
+
// THROW launch errors from spawnSync instead of returning them in
|
|
128
|
+
// result.error. Both shapes must land in the same error path.
|
|
129
|
+
try {
|
|
130
|
+
return spawn(command, args, boundedOptions);
|
|
131
|
+
} catch (error) {
|
|
132
|
+
return { error, status: null, signal: null };
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function runCaptureCliSync(
|
|
137
|
+
args,
|
|
138
|
+
{
|
|
139
|
+
env = process.env,
|
|
140
|
+
spawn = spawnSync,
|
|
141
|
+
options = {},
|
|
142
|
+
cwd,
|
|
143
|
+
deadline,
|
|
144
|
+
now = Date.now,
|
|
145
|
+
platform = process.platform,
|
|
146
|
+
execPath = process.execPath,
|
|
147
|
+
} = {}
|
|
148
|
+
) {
|
|
149
|
+
const command = nonEmptyString(env.POLYGRAPH_CLI) ?? 'polygraph';
|
|
150
|
+
const reexec = portableReexec(env, platform);
|
|
151
|
+
const launchDirectory = resolveLaunchDirectory(cwd, env);
|
|
152
|
+
const spawnOptions = {
|
|
153
|
+
encoding: 'utf8',
|
|
154
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
155
|
+
...options,
|
|
156
|
+
...(launchDirectory ? { cwd: launchDirectory } : {}),
|
|
157
|
+
env: captureCommandEnvironment(env),
|
|
158
|
+
shell: false,
|
|
159
|
+
windowsHide: true,
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
// A plain JavaScript CLI entry (a local package install, a dev build without
|
|
163
|
+
// the executable bit) is never executed directly: direct execution fails
|
|
164
|
+
// EACCES/ENOEXEC on most platforms and Bun surfaces that as a synchronous
|
|
165
|
+
// throw. Running it through Node up front means exactly one process ever
|
|
166
|
+
// launches per wake — there is no ambiguity about which attempt ran.
|
|
167
|
+
let executable;
|
|
168
|
+
let prefixArgs;
|
|
169
|
+
if (reexec) {
|
|
170
|
+
[executable, ...prefixArgs] = reexec;
|
|
171
|
+
} else if (JS_CLI_ENTRY.test(command)) {
|
|
172
|
+
executable = nodeRuntime(execPath);
|
|
173
|
+
prefixArgs = [command];
|
|
174
|
+
} else {
|
|
175
|
+
executable = command;
|
|
176
|
+
prefixArgs = [];
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return runBeforeDeadline(
|
|
180
|
+
spawn,
|
|
181
|
+
executable,
|
|
182
|
+
[...prefixArgs, ...args],
|
|
183
|
+
spawnOptions,
|
|
184
|
+
deadline,
|
|
185
|
+
now
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function reportWorkerLaunchFailure(onFailure, error) {
|
|
190
|
+
try {
|
|
191
|
+
const pending = onFailure(error);
|
|
192
|
+
if (pending && typeof pending.catch === 'function') {
|
|
193
|
+
pending.catch(() => {});
|
|
194
|
+
}
|
|
195
|
+
} catch {
|
|
196
|
+
// A detached handoff must never turn diagnostics into a hook failure.
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// A worker's inherited stdout/stderr land in one append-only log per worker
|
|
201
|
+
// kind, rotated to `.1` past the same bound as hooks.log so a chatty CLI can
|
|
202
|
+
// never grow it without limit.
|
|
203
|
+
export function openHookWorkerLog(env, logName) {
|
|
204
|
+
const home = nonEmptyString(env?.HOME) ?? homedir();
|
|
205
|
+
const logsDir = join(home, '.polygraph', 'logs');
|
|
206
|
+
mkdirSync(logsDir, { recursive: true });
|
|
207
|
+
const logFile = join(logsDir, logName);
|
|
208
|
+
|
|
209
|
+
try {
|
|
210
|
+
if (statSync(logFile).size > HOOK_WORKER_LOG_MAX_BYTES) {
|
|
211
|
+
renameSync(logFile, `${logFile}.1`);
|
|
212
|
+
}
|
|
213
|
+
} catch {
|
|
214
|
+
// There may be no prior log, and rotation must stay best-effort.
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
return openSync(logFile, 'a', 0o600);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Hand a serialized claim to a detached Node worker and return immediately.
|
|
222
|
+
* The worker owns the complete CLI invocation and its durable failure
|
|
223
|
+
* logging; the short-lived parent hook observes launch errors only, because
|
|
224
|
+
* it cannot outlive the harness event that spawned it.
|
|
225
|
+
*
|
|
226
|
+
* The worker is a plain JS module, so it always launches through a Node
|
|
227
|
+
* runtime: process.execPath is the host binary, and under OpenCode that is
|
|
228
|
+
* the compiled Bun executable rather than Node.
|
|
229
|
+
*/
|
|
230
|
+
export function launchDetachedHookWorker({
|
|
231
|
+
workerPath,
|
|
232
|
+
claim,
|
|
233
|
+
logName,
|
|
234
|
+
spawn = spawnChild,
|
|
235
|
+
env = process.env,
|
|
236
|
+
execPath = process.execPath,
|
|
237
|
+
onFailure = () => {},
|
|
238
|
+
openLog = openHookWorkerLog,
|
|
239
|
+
closeLog = closeSync,
|
|
240
|
+
}) {
|
|
241
|
+
// The log is diagnostic only. If it cannot be opened (unwritable home,
|
|
242
|
+
// exhausted descriptors) the worker still launches with its output
|
|
243
|
+
// discarded; its own durable hooks.log write does not depend on it.
|
|
244
|
+
let logFd;
|
|
245
|
+
try {
|
|
246
|
+
logFd = openLog(env, logName);
|
|
247
|
+
} catch (error) {
|
|
248
|
+
reportWorkerLaunchFailure(onFailure, error);
|
|
249
|
+
}
|
|
250
|
+
const output = logFd === undefined ? 'ignore' : logFd;
|
|
251
|
+
|
|
252
|
+
// The serialized claim keeps the harness cwd as evidence even when the
|
|
253
|
+
// launch has to happen elsewhere.
|
|
254
|
+
const launchDirectory = resolveLaunchDirectory(claim.cwd, env);
|
|
255
|
+
|
|
256
|
+
let child;
|
|
257
|
+
try {
|
|
258
|
+
child = spawn(nodeRuntime(execPath), [workerPath, JSON.stringify(claim)], {
|
|
259
|
+
...(launchDirectory ? { cwd: launchDirectory } : {}),
|
|
260
|
+
detached: true,
|
|
261
|
+
env: captureCommandEnvironment(env),
|
|
262
|
+
shell: false,
|
|
263
|
+
stdio: ['ignore', output, output],
|
|
264
|
+
windowsHide: true,
|
|
265
|
+
});
|
|
266
|
+
} finally {
|
|
267
|
+
if (logFd !== undefined) {
|
|
268
|
+
try {
|
|
269
|
+
closeLog(logFd);
|
|
270
|
+
} catch (error) {
|
|
271
|
+
reportWorkerLaunchFailure(onFailure, error);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
child.once('error', (error) => reportWorkerLaunchFailure(onFailure, error));
|
|
277
|
+
child.unref();
|
|
278
|
+
|
|
279
|
+
return true;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
export function cliFailure(commandName, result) {
|
|
283
|
+
if (result?.error) return result.error;
|
|
284
|
+
const stderr = nonEmptyString(result?.stderr);
|
|
285
|
+
const stdout = nonEmptyString(result?.stdout);
|
|
286
|
+
const detail = stderr ?? stdout;
|
|
287
|
+
const outcome = result?.signal
|
|
288
|
+
? `terminated by signal ${result.signal}`
|
|
289
|
+
: `exited with status ${String(result?.status)}`;
|
|
290
|
+
return new Error(
|
|
291
|
+
`polygraph ${commandName} ${outcome}` +
|
|
292
|
+
(detail ? `: ${detail}` : '')
|
|
293
|
+
);
|
|
294
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { realpathSync } from 'node:fs';
|
|
2
|
+
import { fileURLToPath } from 'node:url';
|
|
3
|
+
|
|
4
|
+
import { ensureAgentSessionCapture } from './agent-session-capture.mjs';
|
|
5
|
+
import { logHookFailure } from './agent-session-link.mjs';
|
|
6
|
+
|
|
7
|
+
function writeWorkerFailure(error, claim) {
|
|
8
|
+
try {
|
|
9
|
+
const entry = {
|
|
10
|
+
time: new Date().toISOString(),
|
|
11
|
+
hook: `${claim?.agentType ?? 'unknown'}:ensure-agent-session-capture-worker`,
|
|
12
|
+
pid: process.pid,
|
|
13
|
+
agentSessionId: claim?.agentSessionId,
|
|
14
|
+
error: error instanceof Error ? error.message : String(error),
|
|
15
|
+
...(error instanceof Error && error.stack ? { stack: error.stack } : {}),
|
|
16
|
+
};
|
|
17
|
+
process.stderr.write(JSON.stringify(entry) + '\n');
|
|
18
|
+
} catch {
|
|
19
|
+
// The inherited log stream is diagnostic only.
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function main({
|
|
24
|
+
serializedClaim = process.argv[2],
|
|
25
|
+
env = process.env,
|
|
26
|
+
spawn,
|
|
27
|
+
logFailure = logHookFailure,
|
|
28
|
+
writeFailure = writeWorkerFailure,
|
|
29
|
+
} = {}) {
|
|
30
|
+
let claim;
|
|
31
|
+
try {
|
|
32
|
+
claim = JSON.parse(serializedClaim);
|
|
33
|
+
return ensureAgentSessionCapture(claim, spawn, env);
|
|
34
|
+
} catch (error) {
|
|
35
|
+
writeFailure(error, claim);
|
|
36
|
+
try {
|
|
37
|
+
logFailure(
|
|
38
|
+
`${claim?.agentType ?? 'unknown'}:ensure-agent-session-capture-worker`,
|
|
39
|
+
error,
|
|
40
|
+
{
|
|
41
|
+
agentSessionId: claim?.agentSessionId,
|
|
42
|
+
cli: env.POLYGRAPH_CLI || 'polygraph',
|
|
43
|
+
}
|
|
44
|
+
);
|
|
45
|
+
} catch {
|
|
46
|
+
// The worker is already detached; diagnostics cannot be allowed to crash it.
|
|
47
|
+
}
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function isMainModule() {
|
|
53
|
+
if (!process.argv[1]) return false;
|
|
54
|
+
try {
|
|
55
|
+
return realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url));
|
|
56
|
+
} catch {
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (isMainModule()) {
|
|
62
|
+
process.exitCode = main() ? 0 : 1;
|
|
63
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { readFileSync, realpathSync } from 'node:fs';
|
|
2
|
+
import { fileURLToPath } from 'node:url';
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
buildCommandHookEnsureCapture,
|
|
6
|
+
ensureAgentSessionCapture,
|
|
7
|
+
launchAgentSessionCaptureWake,
|
|
8
|
+
} from './agent-session-capture.mjs';
|
|
9
|
+
import { hookPayloadSessionId, logHookFailure } from './agent-session-link.mjs';
|
|
10
|
+
import { fallbackClaimDirectory } from './capture-cli.mjs';
|
|
11
|
+
|
|
12
|
+
function readPayload() {
|
|
13
|
+
try {
|
|
14
|
+
const raw = readFileSync(0, 'utf8');
|
|
15
|
+
return raw ? JSON.parse(raw) : undefined;
|
|
16
|
+
} catch {
|
|
17
|
+
return undefined;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function main({
|
|
22
|
+
payload = readPayload(),
|
|
23
|
+
agentType = process.argv[2],
|
|
24
|
+
// Harness manifests without an async hook flag pass --detach so the hook
|
|
25
|
+
// returns immediately; a detached worker then owns the bounded wake. This
|
|
26
|
+
// matters most for cursor's blocking beforeSubmitPrompt, which would
|
|
27
|
+
// otherwise stall every prompt on a slow CLI.
|
|
28
|
+
detach = process.argv.includes('--detach'),
|
|
29
|
+
env = process.env,
|
|
30
|
+
spawn,
|
|
31
|
+
logFailure = logHookFailure,
|
|
32
|
+
// The hook's own directory is read lazily, inside the protected path, and
|
|
33
|
+
// only when the payload carries none and the harness runs hooks in the
|
|
34
|
+
// session directory: a default evaluated at entry would throw uv_cwd from
|
|
35
|
+
// an already-deleted cwd before any fallback could run, and Cursor's hook
|
|
36
|
+
// cwd is the plugin root rather than the repository.
|
|
37
|
+
cwd,
|
|
38
|
+
launcherOptions = {},
|
|
39
|
+
now = Date.now,
|
|
40
|
+
} = {}) {
|
|
41
|
+
const reportFailure = (error) =>
|
|
42
|
+
logFailure(`${agentType || 'unknown'}:ensure-agent-session-capture`, error, {
|
|
43
|
+
hookEventName: payload?.hook_event_name,
|
|
44
|
+
agentSessionId: hookPayloadSessionId(payload),
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
try {
|
|
48
|
+
const built = buildCommandHookEnsureCapture(payload, agentType, env, now);
|
|
49
|
+
if (!built) return false;
|
|
50
|
+
|
|
51
|
+
const claim = {
|
|
52
|
+
...built,
|
|
53
|
+
cwd: built.cwd ?? fallbackClaimDirectory(agentType, cwd),
|
|
54
|
+
};
|
|
55
|
+
if (detach) {
|
|
56
|
+
return launchAgentSessionCaptureWake(claim, spawn, env, {
|
|
57
|
+
...launcherOptions,
|
|
58
|
+
onFailure: reportFailure,
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
return ensureAgentSessionCapture(claim, spawn, env);
|
|
62
|
+
} catch (error) {
|
|
63
|
+
reportFailure(error);
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function isMainModule() {
|
|
69
|
+
if (!process.argv[1]) return false;
|
|
70
|
+
try {
|
|
71
|
+
return realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url));
|
|
72
|
+
} catch {
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (isMainModule()) {
|
|
78
|
+
main();
|
|
79
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { realpathSync } from 'node:fs';
|
|
2
|
+
import { fileURLToPath } from 'node:url';
|
|
3
|
+
|
|
4
|
+
import { finalizeAgentSession } from './agent-session-finalize.mjs';
|
|
5
|
+
import { logHookFailure } from './agent-session-link.mjs';
|
|
6
|
+
|
|
7
|
+
function writeWorkerFailure(error, claim) {
|
|
8
|
+
try {
|
|
9
|
+
const entry = {
|
|
10
|
+
time: new Date().toISOString(),
|
|
11
|
+
hook: `${claim?.agentType ?? 'unknown'}:finalize-agent-session-worker`,
|
|
12
|
+
pid: process.pid,
|
|
13
|
+
agentSessionId: claim?.agentSessionId,
|
|
14
|
+
error: error instanceof Error ? error.message : String(error),
|
|
15
|
+
...(error instanceof Error && error.stack ? { stack: error.stack } : {}),
|
|
16
|
+
};
|
|
17
|
+
process.stderr.write(JSON.stringify(entry) + '\n');
|
|
18
|
+
} catch {
|
|
19
|
+
// The inherited log stream is diagnostic only.
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function main({
|
|
24
|
+
serializedClaim = process.argv[2],
|
|
25
|
+
env = process.env,
|
|
26
|
+
spawn,
|
|
27
|
+
logFailure = logHookFailure,
|
|
28
|
+
writeFailure = writeWorkerFailure,
|
|
29
|
+
} = {}) {
|
|
30
|
+
let claim;
|
|
31
|
+
try {
|
|
32
|
+
claim = JSON.parse(serializedClaim);
|
|
33
|
+
return finalizeAgentSession(claim, spawn, env);
|
|
34
|
+
} catch (error) {
|
|
35
|
+
writeFailure(error, claim);
|
|
36
|
+
try {
|
|
37
|
+
logFailure(
|
|
38
|
+
`${claim?.agentType ?? 'unknown'}:finalize-agent-session-worker`,
|
|
39
|
+
error,
|
|
40
|
+
{
|
|
41
|
+
agentSessionId: claim?.agentSessionId,
|
|
42
|
+
cli: env.POLYGRAPH_CLI || 'polygraph',
|
|
43
|
+
}
|
|
44
|
+
);
|
|
45
|
+
} catch {
|
|
46
|
+
// The worker is already detached; diagnostics cannot be allowed to crash it.
|
|
47
|
+
}
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function isMainModule() {
|
|
53
|
+
if (!process.argv[1]) return false;
|
|
54
|
+
try {
|
|
55
|
+
return realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url));
|
|
56
|
+
} catch {
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (isMainModule()) {
|
|
62
|
+
process.exitCode = main() ? 0 : 1;
|
|
63
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { readFileSync, realpathSync } from 'node:fs';
|
|
2
|
+
import { fileURLToPath } from 'node:url';
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
buildCommandHookFinalize,
|
|
6
|
+
launchAgentSessionFinalize,
|
|
7
|
+
} from './agent-session-finalize.mjs';
|
|
8
|
+
import { hookPayloadSessionId, logHookFailure } from './agent-session-link.mjs';
|
|
9
|
+
import { fallbackClaimDirectory } from './capture-cli.mjs';
|
|
10
|
+
|
|
11
|
+
function readPayload() {
|
|
12
|
+
try {
|
|
13
|
+
const raw = readFileSync(0, 'utf8');
|
|
14
|
+
return raw ? JSON.parse(raw) : undefined;
|
|
15
|
+
} catch {
|
|
16
|
+
return undefined;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function main({
|
|
21
|
+
payload = readPayload(),
|
|
22
|
+
agentType = process.argv[2],
|
|
23
|
+
env = process.env,
|
|
24
|
+
spawn,
|
|
25
|
+
logFailure = logHookFailure,
|
|
26
|
+
// The hook's own directory is read lazily, inside the protected path, and
|
|
27
|
+
// only when the payload carries none and the harness runs hooks in the
|
|
28
|
+
// session directory: a default evaluated at entry would throw uv_cwd from
|
|
29
|
+
// an already-deleted cwd before any fallback could run, and Cursor's hook
|
|
30
|
+
// cwd is the plugin root rather than the repository.
|
|
31
|
+
cwd,
|
|
32
|
+
launcherOptions = {},
|
|
33
|
+
now = Date.now,
|
|
34
|
+
} = {}) {
|
|
35
|
+
const reportFailure = (error) =>
|
|
36
|
+
logFailure(`${agentType || 'unknown'}:finalize-agent-session`, error, {
|
|
37
|
+
hookEventName: payload?.hook_event_name,
|
|
38
|
+
agentSessionId: hookPayloadSessionId(payload),
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
try {
|
|
42
|
+
const finalize = buildCommandHookFinalize(payload, agentType, env, now);
|
|
43
|
+
if (!finalize) return false;
|
|
44
|
+
return launchAgentSessionFinalize(
|
|
45
|
+
{
|
|
46
|
+
...finalize,
|
|
47
|
+
cwd: finalize.cwd ?? fallbackClaimDirectory(agentType, cwd),
|
|
48
|
+
},
|
|
49
|
+
spawn,
|
|
50
|
+
env,
|
|
51
|
+
{ ...launcherOptions, onFailure: reportFailure }
|
|
52
|
+
);
|
|
53
|
+
} catch (error) {
|
|
54
|
+
reportFailure(error);
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function isMainModule() {
|
|
60
|
+
if (!process.argv[1]) return false;
|
|
61
|
+
try {
|
|
62
|
+
return realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url));
|
|
63
|
+
} catch {
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (isMainModule()) {
|
|
69
|
+
main();
|
|
70
|
+
}
|
package/hooks/hooks.json
CHANGED
|
@@ -28,6 +28,39 @@
|
|
|
28
28
|
}
|
|
29
29
|
]
|
|
30
30
|
}
|
|
31
|
+
],
|
|
32
|
+
"UserPromptSubmit": [
|
|
33
|
+
{
|
|
34
|
+
"hooks": [
|
|
35
|
+
{
|
|
36
|
+
"type": "command",
|
|
37
|
+
"command": "node ${PLUGIN_ROOT}/hooks/ensure-agent-session-capture.mjs codex --detach",
|
|
38
|
+
"statusMessage": "Ensuring Polygraph session capture"
|
|
39
|
+
}
|
|
40
|
+
]
|
|
41
|
+
}
|
|
42
|
+
],
|
|
43
|
+
"Stop": [
|
|
44
|
+
{
|
|
45
|
+
"hooks": [
|
|
46
|
+
{
|
|
47
|
+
"type": "command",
|
|
48
|
+
"command": "node ${PLUGIN_ROOT}/hooks/ensure-agent-session-capture.mjs codex --detach",
|
|
49
|
+
"statusMessage": "Ensuring Polygraph session capture"
|
|
50
|
+
}
|
|
51
|
+
]
|
|
52
|
+
}
|
|
53
|
+
],
|
|
54
|
+
"SessionEnd": [
|
|
55
|
+
{
|
|
56
|
+
"hooks": [
|
|
57
|
+
{
|
|
58
|
+
"type": "command",
|
|
59
|
+
"command": "node ${PLUGIN_ROOT}/hooks/finalize-agent-session.mjs codex",
|
|
60
|
+
"statusMessage": "Finalizing Polygraph session capture"
|
|
61
|
+
}
|
|
62
|
+
]
|
|
63
|
+
}
|
|
31
64
|
]
|
|
32
65
|
}
|
|
33
66
|
}
|
|
@@ -3,9 +3,11 @@ import { fileURLToPath } from 'node:url';
|
|
|
3
3
|
|
|
4
4
|
import {
|
|
5
5
|
buildCommandHookLink,
|
|
6
|
+
hookPayloadSessionId,
|
|
6
7
|
linkAgentSession,
|
|
7
8
|
logHookFailure,
|
|
8
9
|
} from './agent-session-link.mjs';
|
|
10
|
+
import { fallbackClaimDirectory } from './capture-cli.mjs';
|
|
9
11
|
|
|
10
12
|
function readPayload() {
|
|
11
13
|
try {
|
|
@@ -20,29 +22,29 @@ export function main({
|
|
|
20
22
|
payload = readPayload(),
|
|
21
23
|
agentType = process.argv[2],
|
|
22
24
|
env = process.env,
|
|
23
|
-
pid = process.ppid,
|
|
24
25
|
spawn,
|
|
26
|
+
logFailure = logHookFailure,
|
|
25
27
|
} = {}) {
|
|
26
28
|
try {
|
|
27
29
|
const link = buildCommandHookLink(payload, agentType, env);
|
|
28
30
|
if (!link) return false;
|
|
29
31
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
32
|
+
// Claude and Codex run this hook in the session directory, so the hook's
|
|
33
|
+
// own cwd stands in when the payload carries none; Cursor's hook cwd is
|
|
34
|
+
// the plugin root and is never recorded. Read lazily, inside the
|
|
35
|
+
// protected path, because a deleted cwd makes process.cwd() throw.
|
|
36
|
+
return linkAgentSession(
|
|
37
|
+
{
|
|
38
|
+
...link,
|
|
39
|
+
cwd: link.cwd ?? fallbackClaimDirectory(agentType),
|
|
40
|
+
},
|
|
41
|
+
spawn,
|
|
42
|
+
env
|
|
43
|
+
);
|
|
42
44
|
} catch (error) {
|
|
43
|
-
|
|
45
|
+
logFailure(`${agentType || 'unknown'}:link-agent-session`, error, {
|
|
44
46
|
hookEventName: payload?.hook_event_name,
|
|
45
|
-
agentSessionId: payload
|
|
47
|
+
agentSessionId: hookPayloadSessionId(payload),
|
|
46
48
|
});
|
|
47
49
|
return false;
|
|
48
50
|
}
|
package/package.json
CHANGED
|
@@ -42,6 +42,7 @@ Polygraph functionality is available via both MCP tools and CLI commands. Use wh
|
|
|
42
42
|
| `stop_agent` | — | Cancel an in-progress child by delegation id; its session is preserved for later read-only context restoration. |
|
|
43
43
|
| `push_branch` | — | Push a local git branch to the remote repository. For the repo you are in, this pushes from your current checkout. Requires a session description. |
|
|
44
44
|
| `create_pr` | — | Create draft PRs with session metadata linking related PRs |
|
|
45
|
+
| `update_pr` | — | Update title, user-authored body, labels, or assignees on one PR associated with a session |
|
|
45
46
|
| `show_session` | `polygraph session show <id> [--details]` | Query status of the current session. Use details when session summary, repo IDs, PR URLs, and PR descriptions are needed. |
|
|
46
47
|
| `update_session` | `polygraph session update --session <id> [--title] [--description]` | Update the session title and/or description (at least one required); metadata only, independent of PR creation or mark-ready. |
|
|
47
48
|
| `link_reference` | — | Link an external reference to a session. |
|
|
@@ -218,9 +219,9 @@ The `allow_agent` and `deny_agent` tools exist for parents whose MCP clients do
|
|
|
218
219
|
|
|
219
220
|
### Publish Changes (Push Branches, Create PRs, Mark Ready)
|
|
220
221
|
|
|
221
|
-
Publishing covers the branch-to-PR flow: `push_branch` (push local commits; must precede PR creation), `create_pr` (linked draft PRs, including fork PRs via `targetRepository`), `mark_pr_ready` (transition drafts to OPEN),
|
|
222
|
+
Publishing covers the branch-to-PR flow: `push_branch` (push local commits; must precede PR creation), `create_pr` (linked draft PRs, including fork PRs via `targetRepository`), `mark_pr_ready` (transition drafts to OPEN), `associate_pr` (link PRs created outside Polygraph), and `update_pr` (update metadata on an associated PR).
|
|
222
223
|
|
|
223
|
-
**Whenever you push a branch, create or
|
|
224
|
+
**Whenever you push a branch, create, associate, or update a PR, or mark PRs ready, read [`reference/publish-changes.md`](reference/publish-changes.md) first.** That reference file holds the full flow.
|
|
224
225
|
|
|
225
226
|
### Session Description Policy
|
|
226
227
|
|
|
@@ -302,6 +303,7 @@ If the session has a description timeline, also display:
|
|
|
302
303
|
1. **Route waiting through Codex Polygraph subagents** — Use Codex `spawn_agent` with `agent_type: "polygraph-init-subagent"` to create new sessions, and `agent_type: "polygraph-delegate-subagent"` to wait on each delegation id. The Polygraph MCP `spawn_agent` and unwaited `show_agent` reads are yours to call directly; collect poller results with `wait_agent`.
|
|
303
304
|
|
|
304
305
|
1. **Read each result once** — when a poller exits, read that child with a single unwaited `show_agent(sessionId, id)`; `result.text` is the child's final message. Only reach for an explicit `tail` if that is not enough.
|
|
306
|
+
1. **State the output in every brief** — children are told to be concise, so the instruction must say what to return: the shape, a cap where one makes sense, and the exact token for "nothing to report". See [`reference/delegation.md`](reference/delegation.md).
|
|
305
307
|
1. **Poll child status before proceeding** — Always verify child agents have reached a terminal `child.status` (`'completed'`, `'failed'`, or `'cancelled'`) before pushing branches or creating PRs
|
|
306
308
|
1. **Link PRs in descriptions** - Reference related PRs in each PR body
|
|
307
309
|
1. **Keep PRs as drafts** until all repos are ready
|
|
@@ -28,12 +28,24 @@ spawn_agent(
|
|
|
28
28
|
|
|
29
29
|
`agent` picks the child's harness and `model` overrides its default model; include either only when the user named one.
|
|
30
30
|
|
|
31
|
-
Write the instruction as if to a competent engineer who cannot see your conversation: state the goal, the constraints,
|
|
31
|
+
Write the instruction as if to a competent engineer who cannot see your conversation: state the goal, the constraints, what "done" looks like, and what to report back. The child has its own repo and its own context; it inherits nothing from yours.
|
|
32
32
|
|
|
33
33
|
Delegate to several repos in parallel by calling `spawn_agent` once per repo before waiting on any of them.
|
|
34
34
|
|
|
35
35
|
**Own-repo rule.** With the default role, `repo` must be a repository other than the one you are working in — never delegate into your own repo with the default role; work on it directly (ordinary local subagents are fine for that). Delegating into your own repo IS allowed with an explicit non-default `role`, because each (repo, role) pair is a separate agent slot and the child then runs alongside your own default-role work without colliding with it.
|
|
36
36
|
|
|
37
|
+
## The output contract
|
|
38
|
+
|
|
39
|
+
Children are told to be concise: another agent reads their final message and pays for it on every turn that carries it. Expect terse reports; brevity is not less work done.
|
|
40
|
+
|
|
41
|
+
Your half is the brief: state the output as well as the input — shape (fields, order), a cap where useful, the exact token for "nothing to report", what to omit. In communicating with child agents, maintain extremely high information density while being concise - describe everything needed in the fewest words possible.
|
|
42
|
+
|
|
43
|
+
Investigation is where it matters most: a fan-out leaves most repos with nothing to report, and without a named empty answer (`NONE`, `no matches`) each writes several thousand characters to say so.
|
|
44
|
+
|
|
45
|
+
Implementation still wants concision, but lost information is the worse failure: a missed detail costs a round trip, costlier than the prose. Cut narration, recap, hedging — never branch names, files touched, decisions taken, or anything contradicting the brief.
|
|
46
|
+
|
|
47
|
+
Prose only where necessary. Consumers are agents first, humans second: dense and structural, not narrative.
|
|
48
|
+
|
|
37
49
|
## Waiting
|
|
38
50
|
|
|
39
51
|
For each id, launch one background poller subagent whose entire job is to block until that child stops moving. Give it the `sessionId` and the `id`, and nothing else.
|
|
@@ -59,7 +71,7 @@ When a poller exits, read the child's answer yourself with a single **unwaited**
|
|
|
59
71
|
show_agent(sessionId: "<sessionId>", id: "<id>")
|
|
60
72
|
```
|
|
61
73
|
|
|
62
|
-
`result.text` is the child's final message: what it did
|
|
74
|
+
`result.text` is the child's final message: what it did and what it found, in the shape the instruction asked for. This is the payload. Read it once, in the main conversation, and act on it.
|
|
63
75
|
|
|
64
76
|
One-off unwaited reads like this are cheap and expected inline. It is the *waiting* that belongs in a subagent, not the reading.
|
|
65
77
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Publishing Changes Reference
|
|
2
2
|
|
|
3
|
-
The branch-to-PR flow: push branches, create draft PRs, mark them ready,
|
|
3
|
+
The branch-to-PR flow: push branches, create draft PRs, mark them ready, associate PRs created outside Polygraph, and update associated PRs. `push_branch`, `create_pr`, and `associate_pr` all require a `description` following the Session Description Policy — read [`session-description.md`](session-description.md) before writing one. `update_pr` does not require a session timeline description.
|
|
4
4
|
|
|
5
5
|
## Push Branches
|
|
6
6
|
|
|
@@ -151,3 +151,50 @@ associate_pr(
|
|
|
151
151
|
```
|
|
152
152
|
|
|
153
153
|
**Returns** the list of PRs now associated with the session.
|
|
154
|
+
|
|
155
|
+
## Update an Associated PR
|
|
156
|
+
|
|
157
|
+
Use the MCP `update_pr` tool to update one PR already associated with the named Polygraph session. Do not use `gh` or call Ocean HTTP directly. `mark_pr_ready` remains a separate operation.
|
|
158
|
+
|
|
159
|
+
**Parameters:**
|
|
160
|
+
|
|
161
|
+
- `sessionId` (required): The Polygraph session ID.
|
|
162
|
+
- `prUrl` (required): The URL of a PR already associated with the session.
|
|
163
|
+
- `title` (optional): Replacement PR title.
|
|
164
|
+
- `body` (optional): Replacement user-authored PR body. Pass an empty string to clear it. The managed Polygraph session footer remains server-owned.
|
|
165
|
+
- `labels` (optional): A collection update with `mode` and `values`.
|
|
166
|
+
- `assignees` (optional): A collection update with `mode` and `values`.
|
|
167
|
+
|
|
168
|
+
Omitted fields remain unchanged. For `labels` and `assignees`:
|
|
169
|
+
|
|
170
|
+
- `{ mode: "set", values: [...] }` replaces the complete collection. An empty `values` list clears it. Use `set` only when you intend to replace every value because it can remove labels or assignees applied by humans.
|
|
171
|
+
- `add` and `remove` preserve unrelated values and require a non-empty `values` list.
|
|
172
|
+
|
|
173
|
+
Set the complete label collection and clear the user-authored body:
|
|
174
|
+
|
|
175
|
+
```
|
|
176
|
+
update_pr(
|
|
177
|
+
sessionId: "<session-id>",
|
|
178
|
+
prUrl: "https://github.com/org/repo/pull/123",
|
|
179
|
+
body: "",
|
|
180
|
+
labels: {
|
|
181
|
+
mode: "set",
|
|
182
|
+
values: ["documentation", "release-note"]
|
|
183
|
+
}
|
|
184
|
+
)
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
Add an assignee while leaving the title, body, labels, and other assignees unchanged:
|
|
188
|
+
|
|
189
|
+
```
|
|
190
|
+
update_pr(
|
|
191
|
+
sessionId: "<session-id>",
|
|
192
|
+
prUrl: "https://github.com/org/repo/pull/123",
|
|
193
|
+
assignees: {
|
|
194
|
+
mode: "add",
|
|
195
|
+
values: ["octocat"]
|
|
196
|
+
}
|
|
197
|
+
)
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
Metadata updates do not require a session timeline `description`.
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
`description` is user-facing Polygraph session context.
|
|
6
6
|
|
|
7
|
-
`description` is required for `push_branch`, `create_pr`, and `associate_pr`, and is the primary input to `update_session` (which takes `title` and/or `description`).
|
|
7
|
+
`description` is required for `push_branch`, `create_pr`, and `associate_pr`, and is the primary input to `update_session` (which takes `title` and/or `description`). Metadata updates through `update_pr` do not require a session timeline description, and `mark_pr_ready` does not take one. The Polygraph web app renders the description as Markdown, so use real Markdown headings — not flat `Label:` lines. Use the canonical structured format:
|
|
8
8
|
|
|
9
9
|
```markdown
|
|
10
10
|
## Goal
|