@polygraph/codex-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,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, 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;
@@ -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
- const claim = {
31
- ...link,
32
- cwd: link.cwd ?? process.cwd(),
33
- };
34
- // Claude lifecycle hooks deliberately forward only the exact harness
35
- // identity, transcript, cwd, and hook source. PID is not identity and can
36
- // be stale by the time an asynchronous SessionStart hook runs.
37
- if (!(agentType === 'claude' && payload?.hook_event_name === 'SessionStart')) {
38
- claim.pid = pid;
39
- }
40
-
41
- return linkAgentSession(claim, spawn, env);
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
- logHookFailure(`${agentType || 'unknown'}:link-agent-session`, error, {
45
+ logFailure(`${agentType || 'unknown'}:link-agent-session`, error, {
44
46
  hookEventName: payload?.hook_event_name,
45
- agentSessionId: payload?.session_id,
47
+ agentSessionId: hookPayloadSessionId(payload),
46
48
  });
47
49
  return false;
48
50
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@polygraph/codex-plugin",
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
  "license": "UNLICENSED",
6
6
  "private": false,
@@ -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), and `associate_pr` (link PRs created outside Polygraph).
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 associate a PR, or mark PRs ready, read [`reference/publish-changes.md`](reference/publish-changes.md) first.** That reference file holds the full flow.
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
 
@@ -303,6 +304,7 @@ If the session has a description timeline, also display:
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.
305
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).
307
+ 1. **Batch the spawn and the read** — send several repos in one spawn call and collect their results in one read. Identical work rides a single instruction; differing work goes in a spec file with an entry per repo, sharing only what is genuinely shared. See [`reference/delegation.md`](reference/delegation.md).
306
308
  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
307
309
  1. **Link PRs in descriptions** - Reference related PRs in each PR body
308
310
  1. **Keep PRs as drafts** until all repos are ready
@@ -30,7 +30,29 @@ spawn_agent(
30
30
 
31
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
- Delegate to several repos in parallel by calling `spawn_agent` once per repo before waiting on any of them.
33
+ Delegate to several repos in parallel before waiting on any of them, and prefer one call over N. There are three shapes, the same three `polygraph agent spawn` takes:
34
+
35
+ - **One repo** — `repo` with an `instruction`.
36
+ - **Several repos, one task** — `repos` with one `instruction`, run verbatim in each.
37
+ - **Several repos, different tasks** — `specFile`, a JSON file you write, whose path you pass.
38
+
39
+ Batching pays because the shared text is transmitted once rather than once per repo, and every sibling starts from a byte-identical prompt prefix that can be served from cache. Each id still gets its own poller.
40
+
41
+ The spec file is the many-to-many form. Each entry names a repo and carries its own `instruction`, and optionally its own `context`, `role`, `agent`, and `model`. Whatever the entries share goes in `sharedInstruction` / `sharedContext`, which are prepended to each entry's own text with a blank line between — so the common brief stays one identical prefix and only the per-repo part varies. An entry may carry no `instruction` of its own and rely entirely on the shared one. Being a file, it also carries a brief too large to pass as an argument, and it is straightforward to generate programmatically.
42
+
43
+ ```jsonc
44
+ {
45
+ "sharedInstruction": "Brief every child gets, verbatim.",
46
+ "sharedContext": "Optional background every child gets.",
47
+ "agents": [
48
+ { "repo": "api", "instruction": "Add the expand parameter to the endpoint." },
49
+ { "repo": "people", "instruction": "Consume it in the client.", "context": "This repo still pins the old SDK." },
50
+ { "repo": "planets", "role": "reviewer", "agent": "codex", "instruction": "Review the change against the shared brief." }
51
+ ]
52
+ }
53
+ ```
54
+
55
+ One qualifier is load-bearing: share only what is genuinely shared. Flattening several different tasks into one `sharedInstruction` to look efficient produces worse work, and the round trips to repair it cost more than the batch saved. Work that differs belongs in each entry's own `instruction`, which is what the file is for.
34
56
 
35
57
  **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
58
 
@@ -75,6 +97,8 @@ show_agent(sessionId: "<sessionId>", id: "<id>")
75
97
 
76
98
  One-off unwaited reads like this are cheap and expected inline. It is the *waiting* that belongs in a subagent, not the reading.
77
99
 
100
+ When several pollers have exited, a **batch read** collects their results in one unwaited call: pass the list of ids and correlate each result by its delegation id.
101
+
78
102
  ## When the result is not enough
79
103
 
80
104
  Only if `result.text` is missing, truncated, or the child failed in a way you cannot explain from it:
@@ -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, and associate PRs created outside Polygraph. `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.
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`). (`mark_pr_ready` does not take a description.) The Polygraph web app renders the description as Markdown, so use real Markdown headings — not flat `Label:` lines. Use the canonical structured format:
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