@polygraph/opencode-plugin 0.5.1 → 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.
@@ -1,20 +1,123 @@
1
1
  // source/hooks/agent-session-link.mjs
2
- import { appendFileSync, mkdirSync, renameSync, statSync } from "node:fs";
3
- import { homedir } from "node:os";
2
+ import { appendFileSync, mkdirSync as mkdirSync2, renameSync as renameSync2, statSync as statSync2 } from "node:fs";
3
+ import { homedir as homedir2 } from "node:os";
4
+ import { basename as basename2, join as join2 } from "node:path";
5
+ import { spawnSync as spawnSync2 } from "node:child_process";
6
+
7
+ // source/hooks/capture-cli.mjs
8
+ import { spawn as spawnChild, spawnSync } from "node:child_process";
9
+ import { closeSync, mkdirSync, openSync, renameSync, statSync } from "node:fs";
10
+ import { homedir, tmpdir } from "node:os";
4
11
  import { basename, join } from "node:path";
5
- import { spawnSync } from "node:child_process";
12
+ var HOOK_WORKER_LOG_MAX_BYTES = 5 * 1024 * 1024;
13
+ function nonEmptyString(value) {
14
+ return typeof value === "string" && value.trim() ? value : void 0;
15
+ }
16
+ function isManagedChildEnvironment(env) {
17
+ return Boolean(env && Object.hasOwn(env, "POLYGRAPH_CHILD_AGENT"));
18
+ }
19
+ function captureCommandEnvironment(env = process.env) {
20
+ const commandEnv = { ...env };
21
+ delete commandEnv.POLYGRAPH_SESSION_ID;
22
+ delete commandEnv.POLYGRAPH_CAPTURE_TOKEN;
23
+ return commandEnv;
24
+ }
25
+ function resolveLaunchDirectory(preferred, env = process.env) {
26
+ const candidates = [preferred, nonEmptyString(env?.HOME) ?? homedir(), tmpdir()];
27
+ for (const candidate of candidates) {
28
+ const directory = nonEmptyString(candidate);
29
+ if (!directory) continue;
30
+ try {
31
+ if (statSync(directory).isDirectory()) return directory;
32
+ } catch {
33
+ }
34
+ }
35
+ return void 0;
36
+ }
37
+ function nodeRuntime(execPath) {
38
+ const base = basename(execPath).toLowerCase();
39
+ return base === "node" || base === "node.exe" ? execPath : "node";
40
+ }
41
+ function reportWorkerLaunchFailure(onFailure, error) {
42
+ try {
43
+ const pending = onFailure(error);
44
+ if (pending && typeof pending.catch === "function") {
45
+ pending.catch(() => {
46
+ });
47
+ }
48
+ } catch {
49
+ }
50
+ }
51
+ function openHookWorkerLog(env, logName) {
52
+ const home = nonEmptyString(env?.HOME) ?? homedir();
53
+ const logsDir = join(home, ".polygraph", "logs");
54
+ mkdirSync(logsDir, { recursive: true });
55
+ const logFile = join(logsDir, logName);
56
+ try {
57
+ if (statSync(logFile).size > HOOK_WORKER_LOG_MAX_BYTES) {
58
+ renameSync(logFile, `${logFile}.1`);
59
+ }
60
+ } catch {
61
+ }
62
+ return openSync(logFile, "a", 384);
63
+ }
64
+ function launchDetachedHookWorker({
65
+ workerPath,
66
+ claim,
67
+ logName,
68
+ spawn = spawnChild,
69
+ env = process.env,
70
+ execPath = process.execPath,
71
+ onFailure = () => {
72
+ },
73
+ openLog = openHookWorkerLog,
74
+ closeLog = closeSync
75
+ }) {
76
+ let logFd;
77
+ try {
78
+ logFd = openLog(env, logName);
79
+ } catch (error) {
80
+ reportWorkerLaunchFailure(onFailure, error);
81
+ }
82
+ const output = logFd === void 0 ? "ignore" : logFd;
83
+ const launchDirectory = resolveLaunchDirectory(claim.cwd, env);
84
+ let child;
85
+ try {
86
+ child = spawn(nodeRuntime(execPath), [workerPath, JSON.stringify(claim)], {
87
+ ...launchDirectory ? { cwd: launchDirectory } : {},
88
+ detached: true,
89
+ env: captureCommandEnvironment(env),
90
+ shell: false,
91
+ stdio: ["ignore", output, output],
92
+ windowsHide: true
93
+ });
94
+ } finally {
95
+ if (logFd !== void 0) {
96
+ try {
97
+ closeLog(logFd);
98
+ } catch (error) {
99
+ reportWorkerLaunchFailure(onFailure, error);
100
+ }
101
+ }
102
+ }
103
+ child.once("error", (error) => reportWorkerLaunchFailure(onFailure, error));
104
+ child.unref();
105
+ return true;
106
+ }
107
+
108
+ // source/hooks/agent-session-link.mjs
6
109
  var HOOK_LOG_MAX_BYTES = 5 * 1024 * 1024;
7
110
  var AGENT_TYPES = /* @__PURE__ */ new Set(["claude", "codex", "opencode", "cursor"]);
8
111
  var COMMAND_HOOK_TOOL = /^mcp__(?:plugin_polygraph_)?polygraph[-_]mcp__/;
9
112
  var OPENCODE_TOOL = /^polygraph(?:(?:-|_)mcp)?_/;
10
- function nonEmptyString(value) {
113
+ function nonEmptyString2(value) {
11
114
  return typeof value === "string" && value.trim() ? value : void 0;
12
115
  }
13
- function isManagedChildEnvironment(env) {
116
+ function isManagedChildEnvironment2(env) {
14
117
  return Boolean(env && Object.hasOwn(env, "POLYGRAPH_CHILD_AGENT"));
15
118
  }
16
119
  function isPolygraphMcpToolName(toolName) {
17
- const name = nonEmptyString(toolName);
120
+ const name = nonEmptyString2(toolName);
18
121
  return Boolean(name && (COMMAND_HOOK_TOOL.test(name) || OPENCODE_TOOL.test(name)));
19
122
  }
20
123
  function buildLinkAgentSessionArgs({
@@ -23,26 +126,22 @@ function buildLinkAgentSessionArgs({
23
126
  agentSessionId,
24
127
  cwd,
25
128
  transcriptPath,
26
- pid,
27
129
  source,
28
130
  hookOperation
29
131
  }) {
30
- const session = nonEmptyString(polygraphSessionId);
31
- const harnessSession = nonEmptyString(agentSessionId);
32
- const claimSource = nonEmptyString(source);
132
+ const session = nonEmptyString2(polygraphSessionId);
133
+ const harnessSession = nonEmptyString2(agentSessionId);
134
+ const claimSource = nonEmptyString2(source);
33
135
  if (!AGENT_TYPES.has(agentType)) throw new Error(`Unsupported agent type: ${agentType}`);
34
136
  if (!harnessSession) throw new Error("agentSessionId is required");
35
137
  if (!claimSource) throw new Error("source is required");
36
138
  const args = ["_link-agent-session"];
37
139
  if (session) args.push("--session", session);
38
140
  args.push("--agent-type", agentType, "--agent-session-id", harnessSession);
39
- const workingDirectory = nonEmptyString(cwd);
141
+ const workingDirectory = nonEmptyString2(cwd);
40
142
  if (workingDirectory) args.push("--cwd", workingDirectory);
41
- const transcript = nonEmptyString(transcriptPath);
143
+ const transcript = nonEmptyString2(transcriptPath);
42
144
  if (transcript) args.push("--transcript-path", transcript);
43
- if (Number.isSafeInteger(pid) && pid > 0) {
44
- args.push("--pid", String(pid));
45
- }
46
145
  let input;
47
146
  if (hookOperation && typeof hookOperation === "object") {
48
147
  args.push("--hook-operation-stdin");
@@ -51,46 +150,51 @@ function buildLinkAgentSessionArgs({
51
150
  args.push("--source", claimSource);
52
151
  return { args, input };
53
152
  }
54
- function nodeRuntime() {
55
- const base = basename(process.execPath).toLowerCase();
153
+ function nodeRuntime2() {
154
+ const base = basename2(process.execPath).toLowerCase();
56
155
  return base === "node" || base === "node.exe" ? process.execPath : "node";
57
156
  }
58
- function linkAgentSession(claim, spawn = spawnSync, env = process.env) {
59
- if (isManagedChildEnvironment(env)) return false;
157
+ function linkAgentSession(claim, spawn = spawnSync2, env = process.env) {
158
+ if (isManagedChildEnvironment2(env)) return false;
60
159
  const { args, input } = buildLinkAgentSessionArgs(claim);
61
- const command = nonEmptyString(env?.POLYGRAPH_CLI) ?? "polygraph";
62
- const commandEnv = nonEmptyString(claim.polygraphSessionId) ? env : { ...env };
160
+ const command = nonEmptyString2(env?.POLYGRAPH_CLI) ?? "polygraph";
161
+ const commandEnv = nonEmptyString2(claim.polygraphSessionId) ? env : { ...env };
63
162
  if (commandEnv !== env) {
64
163
  delete commandEnv.POLYGRAPH_SESSION_ID;
65
164
  delete commandEnv.POLYGRAPH_CAPTURE_TOKEN;
66
165
  }
166
+ const launchDirectory = resolveLaunchDirectory(claim.cwd, env);
67
167
  const spawnOptions = {
68
168
  encoding: "utf8",
69
169
  env: commandEnv,
70
170
  stdio: [input === void 0 ? "ignore" : "pipe", "ignore", "pipe"],
71
- ...input === void 0 ? {} : { input }
171
+ ...input === void 0 ? {} : { input },
172
+ ...launchDirectory ? { cwd: launchDirectory } : {}
72
173
  };
73
- let result = spawn(command, args, spawnOptions);
74
- if (result?.error && /\.[cm]?js$/i.test(command)) {
75
- result = spawn(nodeRuntime(), [command, ...args], spawnOptions);
174
+ const jsEntry = /\.[cm]?js$/i.test(command);
175
+ let result;
176
+ try {
177
+ result = jsEntry ? spawn(nodeRuntime2(), [command, ...args], spawnOptions) : spawn(command, args, spawnOptions);
178
+ } catch (error) {
179
+ throw error instanceof Error ? error : new Error(String(error));
76
180
  }
77
181
  if (result?.error) throw result.error;
78
182
  if (result?.status !== 0) {
79
- const detail = nonEmptyString(result?.stderr);
183
+ const detail = nonEmptyString2(result?.stderr);
80
184
  throw new Error(
81
185
  `polygraph _link-agent-session exited with status ${String(result?.status)}` + (detail ? `: ${detail}` : "")
82
186
  );
83
187
  }
84
188
  return true;
85
189
  }
86
- function logHookFailure(hook, error, meta = {}, home = process.env.HOME?.trim() || homedir()) {
190
+ function logHookFailure(hook, error, meta = {}, home = process.env.HOME?.trim() || homedir2()) {
87
191
  try {
88
- const logsDir = join(home, ".polygraph", "logs");
89
- mkdirSync(logsDir, { recursive: true });
90
- const logFile = join(logsDir, "hooks.log");
192
+ const logsDir = join2(home, ".polygraph", "logs");
193
+ mkdirSync2(logsDir, { recursive: true });
194
+ const logFile = join2(logsDir, "hooks.log");
91
195
  try {
92
- if (statSync(logFile).size > HOOK_LOG_MAX_BYTES) {
93
- renameSync(logFile, `${logFile}.1`);
196
+ if (statSync2(logFile).size > HOOK_LOG_MAX_BYTES) {
197
+ renameSync2(logFile, `${logFile}.1`);
94
198
  }
95
199
  } catch {
96
200
  }
@@ -107,6 +211,25 @@ function logHookFailure(hook, error, meta = {}, home = process.env.HOME?.trim()
107
211
  }
108
212
  }
109
213
 
214
+ // source/hooks/agent-session-capture.mjs
215
+ import { fileURLToPath } from "node:url";
216
+ var ENSURE_WAKE_WORKER_PATH = fileURLToPath(
217
+ new URL("./ensure-agent-session-capture-worker.mjs", import.meta.url)
218
+ );
219
+ function launchAgentSessionCaptureWake(claim, spawn, env = process.env, { onFailure = () => {
220
+ }, ...workerOptions } = {}) {
221
+ if (isManagedChildEnvironment(env)) return false;
222
+ return launchDetachedHookWorker({
223
+ workerPath: ENSURE_WAKE_WORKER_PATH,
224
+ logName: "capture-wake.log",
225
+ ...workerOptions,
226
+ claim,
227
+ ...spawn ? { spawn } : {},
228
+ env,
229
+ onFailure
230
+ });
231
+ }
232
+
110
233
  // source/opencode/agent-session-link.mjs
111
234
  function sessionRecord(result) {
112
235
  if (result?.error) {
@@ -149,22 +272,48 @@ async function linkOpenCodeSessionCreatedEvent(input, sessionLinker) {
149
272
  if (event?.type !== "session.created") return false;
150
273
  return sessionLinker.fromSessionCreated(event.properties?.info);
151
274
  }
275
+ function openCodeChatMessageSessionId(input, output) {
276
+ return sessionIdValue(input?.sessionID) ?? sessionIdValue(output?.message?.sessionID);
277
+ }
278
+ function sessionIdValue(value) {
279
+ return typeof value === "string" && value.trim() ? value : void 0;
280
+ }
152
281
  function deferOpenCodeToolActivity(input, sessionLinker, onError, schedule = setTimeout) {
153
282
  schedule(() => {
154
- Promise.resolve(sessionLinker.fromToolActivity(input)).catch(onError);
283
+ return Promise.resolve().then(() => sessionLinker.fromToolActivity(input)).catch((error) => {
284
+ try {
285
+ return onError(error);
286
+ } catch {
287
+ }
288
+ }).catch(() => {
289
+ });
290
+ }, 0);
291
+ }
292
+ function deferOpenCodeCaptureWake(sessionId, sessionLinker, onError, schedule = setTimeout, now = Date.now) {
293
+ const observedAt = now();
294
+ schedule(() => {
295
+ return Promise.resolve().then(() => sessionLinker.wakeCapture(sessionId, { observedAt })).catch((error) => {
296
+ try {
297
+ return onError(error);
298
+ } catch {
299
+ }
300
+ }).catch(() => {
301
+ });
155
302
  }, 0);
156
303
  }
157
304
  function createOpenCodeSessionLinker({
158
305
  client,
159
306
  directory,
160
307
  env = process.env,
161
- pid = process.pid,
162
308
  link,
163
- spawn
309
+ ensure,
310
+ spawn,
311
+ now = Date.now
164
312
  } = {}) {
165
313
  const roots = /* @__PURE__ */ new Map();
166
314
  const linkedLifecycleSessions = /* @__PURE__ */ new Set();
167
315
  const submitLink = link ?? ((claim) => linkAgentSession(claim, spawn, env));
316
+ const submitEnsure = ensure ?? ((claim) => launchAgentSessionCaptureWake(claim, spawn, env));
168
317
  async function rootSessionId(sessionId) {
169
318
  if (!sessionId) return void 0;
170
319
  if (roots.has(sessionId)) return roots.get(sessionId);
@@ -185,7 +334,6 @@ function createOpenCodeSessionLinker({
185
334
  agentType: "opencode",
186
335
  agentSessionId,
187
336
  cwd: cwd || directory || process.cwd(),
188
- pid,
189
337
  source: "hook"
190
338
  });
191
339
  if (linked && lifecycleKey) linkedLifecycleSessions.add(lifecycleKey);
@@ -208,13 +356,31 @@ function createOpenCodeSessionLinker({
208
356
  async fromToolActivity(input) {
209
357
  if (!isPolygraphMcpToolName(input?.tool)) return false;
210
358
  return submit(input?.sessionID);
359
+ },
360
+ // Identity-only capture liveness. Resolves the root session (subagent
361
+ // activity wakes the parent's capture) and pokes the sidecar; it records
362
+ // no mapping, no provenance, and no step boundary.
363
+ async wakeCapture(openCodeSessionId, { cwd, observedAt = now() } = {}) {
364
+ if (!openCodeSessionId || env && Object.hasOwn(env, "POLYGRAPH_CHILD_AGENT")) {
365
+ return false;
366
+ }
367
+ const agentSessionId = await rootSessionId(openCodeSessionId);
368
+ if (!agentSessionId) return false;
369
+ return submitEnsure({
370
+ agentType: "opencode",
371
+ agentSessionId,
372
+ cwd: cwd || directory || process.cwd(),
373
+ observedAt
374
+ });
211
375
  }
212
376
  };
213
377
  }
214
378
  export {
215
379
  createOpenCodeSessionLinker,
380
+ deferOpenCodeCaptureWake,
216
381
  deferOpenCodeToolActivity,
217
382
  linkOpenCodeSessionCreatedEvent,
218
383
  logHookFailure,
384
+ openCodeChatMessageSessionId,
219
385
  resolveOpenCodeRootSessionId
220
386
  };
@@ -0,0 +1,304 @@
1
+ // source/hooks/ensure-agent-session-capture-worker.mjs
2
+ import { realpathSync } from "node:fs";
3
+ import { fileURLToPath as fileURLToPath2 } from "node:url";
4
+
5
+ // source/hooks/agent-session-capture.mjs
6
+ import { spawnSync as spawnSync2 } from "node:child_process";
7
+ import { fileURLToPath } from "node:url";
8
+
9
+ // source/hooks/capture-cli.mjs
10
+ import { spawn as spawnChild, spawnSync } from "node:child_process";
11
+ import { closeSync, mkdirSync, openSync, renameSync, statSync } from "node:fs";
12
+ import { homedir, tmpdir } from "node:os";
13
+ import { basename, join } from "node:path";
14
+ var JS_CLI_ENTRY = /\.[cm]?js$/i;
15
+ var HOOK_WORKER_LOG_MAX_BYTES = 5 * 1024 * 1024;
16
+ function nonEmptyString(value) {
17
+ return typeof value === "string" && value.trim() ? value : void 0;
18
+ }
19
+ function isManagedChildEnvironment(env) {
20
+ return Boolean(env && Object.hasOwn(env, "POLYGRAPH_CHILD_AGENT"));
21
+ }
22
+ function captureCommandEnvironment(env = process.env) {
23
+ const commandEnv = { ...env };
24
+ delete commandEnv.POLYGRAPH_SESSION_ID;
25
+ delete commandEnv.POLYGRAPH_CAPTURE_TOKEN;
26
+ return commandEnv;
27
+ }
28
+ function resolveLaunchDirectory(preferred, env = process.env) {
29
+ const candidates = [preferred, nonEmptyString(env?.HOME) ?? homedir(), tmpdir()];
30
+ for (const candidate of candidates) {
31
+ const directory = nonEmptyString(candidate);
32
+ if (!directory) continue;
33
+ try {
34
+ if (statSync(directory).isDirectory()) return directory;
35
+ } catch {
36
+ }
37
+ }
38
+ return void 0;
39
+ }
40
+ function observedAtValue(value) {
41
+ return Number.isSafeInteger(value) && value > 0 ? value : void 0;
42
+ }
43
+ function nodeRuntime(execPath) {
44
+ const base = basename(execPath).toLowerCase();
45
+ return base === "node" || base === "node.exe" ? execPath : "node";
46
+ }
47
+ function portableReexec(env, platform) {
48
+ const raw = nonEmptyString(env.POLYGRAPH_CLI_REEXEC);
49
+ if (!raw || platform !== "win32") return void 0;
50
+ try {
51
+ const parsed = JSON.parse(raw);
52
+ if (Array.isArray(parsed) && parsed.length > 0 && parsed.every((part) => nonEmptyString(part))) {
53
+ return parsed;
54
+ }
55
+ } catch {
56
+ }
57
+ return void 0;
58
+ }
59
+ function deadlineExceededResult() {
60
+ const error = new Error("Polygraph capture command timed out before launch");
61
+ error.code = "ETIMEDOUT";
62
+ return { error, status: null, signal: "SIGTERM" };
63
+ }
64
+ function withRemainingTimeout(options, deadline, now) {
65
+ if (deadline === void 0) return options;
66
+ const remaining = Math.floor(deadline - now());
67
+ if (remaining < 1) return void 0;
68
+ const configured = Number.isFinite(options.timeout) ? options.timeout : remaining;
69
+ return { ...options, timeout: Math.min(configured, remaining) };
70
+ }
71
+ function runBeforeDeadline(spawn, command, args, options, deadline, now) {
72
+ const boundedOptions = withRemainingTimeout(options, deadline, now);
73
+ if (!boundedOptions) return deadlineExceededResult();
74
+ try {
75
+ return spawn(command, args, boundedOptions);
76
+ } catch (error) {
77
+ return { error, status: null, signal: null };
78
+ }
79
+ }
80
+ function runCaptureCliSync(args, {
81
+ env = process.env,
82
+ spawn = spawnSync,
83
+ options = {},
84
+ cwd,
85
+ deadline,
86
+ now = Date.now,
87
+ platform = process.platform,
88
+ execPath = process.execPath
89
+ } = {}) {
90
+ const command = nonEmptyString(env.POLYGRAPH_CLI) ?? "polygraph";
91
+ const reexec = portableReexec(env, platform);
92
+ const launchDirectory = resolveLaunchDirectory(cwd, env);
93
+ const spawnOptions = {
94
+ encoding: "utf8",
95
+ stdio: ["ignore", "pipe", "pipe"],
96
+ ...options,
97
+ ...launchDirectory ? { cwd: launchDirectory } : {},
98
+ env: captureCommandEnvironment(env),
99
+ shell: false,
100
+ windowsHide: true
101
+ };
102
+ let executable;
103
+ let prefixArgs;
104
+ if (reexec) {
105
+ [executable, ...prefixArgs] = reexec;
106
+ } else if (JS_CLI_ENTRY.test(command)) {
107
+ executable = nodeRuntime(execPath);
108
+ prefixArgs = [command];
109
+ } else {
110
+ executable = command;
111
+ prefixArgs = [];
112
+ }
113
+ return runBeforeDeadline(
114
+ spawn,
115
+ executable,
116
+ [...prefixArgs, ...args],
117
+ spawnOptions,
118
+ deadline,
119
+ now
120
+ );
121
+ }
122
+ function cliFailure(commandName, result) {
123
+ if (result?.error) return result.error;
124
+ const stderr = nonEmptyString(result?.stderr);
125
+ const stdout = nonEmptyString(result?.stdout);
126
+ const detail = stderr ?? stdout;
127
+ const outcome = result?.signal ? `terminated by signal ${result.signal}` : `exited with status ${String(result?.status)}`;
128
+ return new Error(
129
+ `polygraph ${commandName} ${outcome}` + (detail ? `: ${detail}` : "")
130
+ );
131
+ }
132
+
133
+ // source/hooks/agent-session-capture.mjs
134
+ var ENSURE_CAPTURE_TIMEOUT_MS = 2e4;
135
+ var ENSURE_CAPTURE_UNSUPPORTED_MARKER = "POLYGRAPH_ENSURE_AGENT_SESSION_CAPTURE_UNSUPPORTED";
136
+ var WAKE_AGENT_TYPES = /* @__PURE__ */ new Set(["claude", "codex", "opencode", "cursor"]);
137
+ var ENSURE_WAKE_WORKER_PATH = fileURLToPath(
138
+ new URL("./ensure-agent-session-capture-worker.mjs", import.meta.url)
139
+ );
140
+ function boundedEnsureTimeout(timeoutMs) {
141
+ if (!Number.isFinite(timeoutMs) || timeoutMs < 1) {
142
+ return ENSURE_CAPTURE_TIMEOUT_MS;
143
+ }
144
+ return Math.min(Math.floor(timeoutMs), ENSURE_CAPTURE_TIMEOUT_MS);
145
+ }
146
+ function wakeIdentityArgs({ agentType, agentSessionId }) {
147
+ const harnessSession = nonEmptyString(agentSessionId);
148
+ if (!WAKE_AGENT_TYPES.has(agentType)) {
149
+ throw new Error(`Unsupported agent type: ${agentType}`);
150
+ }
151
+ if (!harnessSession) throw new Error("agentSessionId is required");
152
+ return ["--agent-type", agentType, "--agent-session-id", harnessSession];
153
+ }
154
+ function buildEnsureAgentSessionCaptureArgs({
155
+ agentType,
156
+ agentSessionId,
157
+ observedAt
158
+ }) {
159
+ const identity = wakeIdentityArgs({ agentType, agentSessionId });
160
+ const observed = observedAtValue(observedAt);
161
+ if (observed === void 0) {
162
+ throw new Error("observedAt is required: a wake must carry the hook-captured time");
163
+ }
164
+ return [
165
+ "_ensure-agent-session-capture",
166
+ ...identity,
167
+ "--observed-at",
168
+ String(observed)
169
+ ];
170
+ }
171
+ function buildLegacyCaptureWakeArgs(claim) {
172
+ const args = ["_link-agent-session", ...wakeIdentityArgs(claim)];
173
+ const workingDirectory = nonEmptyString(claim.cwd);
174
+ if (workingDirectory) args.push("--cwd", workingDirectory);
175
+ const transcript = nonEmptyString(claim.transcriptPath);
176
+ if (transcript) args.push("--transcript-path", transcript);
177
+ args.push("--source", "hook");
178
+ return args;
179
+ }
180
+ function commandUnavailable(result) {
181
+ if (result?.error) return false;
182
+ const output = `${result?.stdout ?? ""}
183
+ ${result?.stderr ?? ""}`;
184
+ if (output.includes(ENSURE_CAPTURE_UNSUPPORTED_MARKER)) return true;
185
+ const stdout = typeof result?.stdout === "string" ? result.stdout.replace(/\r\n/g, "\n") : "";
186
+ return result?.status === 1 && !nonEmptyString(result?.stderr) && stdout.startsWith("Usage: polygraph\n") && stdout.includes("\nValidation failed for one or more options\n") && stdout.includes("\n - Unknown argument: _ensure-agent-session-capture\n");
187
+ }
188
+ function ensureAgentSessionCapture(claim, spawn = spawnSync2, env = process.env, {
189
+ timeoutMs = ENSURE_CAPTURE_TIMEOUT_MS,
190
+ now = Date.now,
191
+ platform = process.platform,
192
+ execPath = process.execPath
193
+ } = {}) {
194
+ if (isManagedChildEnvironment(env)) return false;
195
+ const deadline = now() + boundedEnsureTimeout(timeoutMs);
196
+ const options = {
197
+ killSignal: "SIGKILL",
198
+ maxBuffer: 256 * 1024
199
+ };
200
+ const run = (args) => runCaptureCliSync(args, {
201
+ env,
202
+ spawn,
203
+ options,
204
+ cwd: claim.cwd,
205
+ deadline,
206
+ now,
207
+ platform,
208
+ execPath
209
+ });
210
+ const result = run(buildEnsureAgentSessionCaptureArgs(claim));
211
+ if (!result?.error && result?.status === 0) return true;
212
+ if (commandUnavailable(result)) {
213
+ const fallback = run(buildLegacyCaptureWakeArgs(claim));
214
+ if (!fallback?.error && fallback?.status === 0) return true;
215
+ throw cliFailure("_link-agent-session compatibility fallback", fallback);
216
+ }
217
+ throw cliFailure("_ensure-agent-session-capture", result);
218
+ }
219
+
220
+ // source/hooks/agent-session-link.mjs
221
+ import { appendFileSync, mkdirSync as mkdirSync2, renameSync as renameSync2, statSync as statSync2 } from "node:fs";
222
+ import { homedir as homedir2 } from "node:os";
223
+ import { basename as basename2, join as join2 } from "node:path";
224
+ var HOOK_LOG_MAX_BYTES = 5 * 1024 * 1024;
225
+ function logHookFailure(hook, error, meta = {}, home = process.env.HOME?.trim() || homedir2()) {
226
+ try {
227
+ const logsDir = join2(home, ".polygraph", "logs");
228
+ mkdirSync2(logsDir, { recursive: true });
229
+ const logFile = join2(logsDir, "hooks.log");
230
+ try {
231
+ if (statSync2(logFile).size > HOOK_LOG_MAX_BYTES) {
232
+ renameSync2(logFile, `${logFile}.1`);
233
+ }
234
+ } catch {
235
+ }
236
+ const entry = {
237
+ time: (/* @__PURE__ */ new Date()).toISOString(),
238
+ hook,
239
+ pid: process.pid,
240
+ ...meta,
241
+ error: error instanceof Error ? error.message : String(error),
242
+ ...error instanceof Error && error.stack ? { stack: error.stack } : {}
243
+ };
244
+ appendFileSync(logFile, JSON.stringify(entry) + "\n");
245
+ } catch {
246
+ }
247
+ }
248
+
249
+ // source/hooks/ensure-agent-session-capture-worker.mjs
250
+ function writeWorkerFailure(error, claim) {
251
+ try {
252
+ const entry = {
253
+ time: (/* @__PURE__ */ new Date()).toISOString(),
254
+ hook: `${claim?.agentType ?? "unknown"}:ensure-agent-session-capture-worker`,
255
+ pid: process.pid,
256
+ agentSessionId: claim?.agentSessionId,
257
+ error: error instanceof Error ? error.message : String(error),
258
+ ...error instanceof Error && error.stack ? { stack: error.stack } : {}
259
+ };
260
+ process.stderr.write(JSON.stringify(entry) + "\n");
261
+ } catch {
262
+ }
263
+ }
264
+ function main({
265
+ serializedClaim = process.argv[2],
266
+ env = process.env,
267
+ spawn,
268
+ logFailure = logHookFailure,
269
+ writeFailure = writeWorkerFailure
270
+ } = {}) {
271
+ let claim;
272
+ try {
273
+ claim = JSON.parse(serializedClaim);
274
+ return ensureAgentSessionCapture(claim, spawn, env);
275
+ } catch (error) {
276
+ writeFailure(error, claim);
277
+ try {
278
+ logFailure(
279
+ `${claim?.agentType ?? "unknown"}:ensure-agent-session-capture-worker`,
280
+ error,
281
+ {
282
+ agentSessionId: claim?.agentSessionId,
283
+ cli: env.POLYGRAPH_CLI || "polygraph"
284
+ }
285
+ );
286
+ } catch {
287
+ }
288
+ return false;
289
+ }
290
+ }
291
+ function isMainModule() {
292
+ if (!process.argv[1]) return false;
293
+ try {
294
+ return realpathSync(process.argv[1]) === realpathSync(fileURLToPath2(import.meta.url));
295
+ } catch {
296
+ return false;
297
+ }
298
+ }
299
+ if (isMainModule()) {
300
+ process.exitCode = main() ? 0 : 1;
301
+ }
302
+ export {
303
+ main
304
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@polygraph/opencode-plugin",
3
- "version": "0.5.1",
3
+ "version": "0.5.2",
4
4
  "description": "AI agent skills and subagents for Polygraph sessions, repository context, and coordination",
5
5
  "license": "UNLICENSED",
6
6
  "private": false,
@@ -25,6 +25,7 @@
25
25
  "files": [
26
26
  "server.js",
27
27
  "agent-session-link.mjs",
28
+ "ensure-agent-session-capture-worker.mjs",
28
29
  "frontmatter.mjs",
29
30
  "skills/",
30
31
  "agents/",
package/server.js CHANGED
@@ -15,9 +15,11 @@ import { fileURLToPath } from 'node:url';
15
15
 
16
16
  import {
17
17
  createOpenCodeSessionLinker,
18
+ deferOpenCodeCaptureWake,
18
19
  deferOpenCodeToolActivity,
19
20
  linkOpenCodeSessionCreatedEvent,
20
21
  logHookFailure,
22
+ openCodeChatMessageSessionId,
21
23
  } from './agent-session-link.mjs';
22
24
  import { parseFrontmatter } from './frontmatter.mjs';
23
25
 
@@ -40,6 +42,24 @@ export const PolygraphPlugin = async ({ client, directory } = {}) => {
40
42
  sessionID: input?.event?.properties?.info?.id,
41
43
  });
42
44
  }
45
+
46
+ // session.idle is OpenCode's agent-done signal. It is a capture
47
+ // liveness wake only — the transcript decides step boundaries.
48
+ if (input?.event?.type === 'session.idle') {
49
+ const sessionID = input.event.properties?.sessionID;
50
+ deferOpenCodeCaptureWake(sessionID, sessionLinker, (error) => {
51
+ logHookFailure('opencode:session.idle', error, { sessionID });
52
+ });
53
+ }
54
+ },
55
+
56
+ // Fires when the user submits a prompt; wakes capture so the prompt is
57
+ // available promptly even if the sidecar died. Liveness only.
58
+ 'chat.message': async (input, output) => {
59
+ const sessionID = openCodeChatMessageSessionId(input, output);
60
+ deferOpenCodeCaptureWake(sessionID, sessionLinker, (error) => {
61
+ logHookFailure('opencode:chat.message', error, { sessionID });
62
+ });
43
63
  },
44
64
 
45
65
  config: async (cfg) => {
@@ -26,6 +26,7 @@ Polygraph functionality is available via both MCP tools and CLI commands. Use wh
26
26
  | `stop_agent` | — | Cancel an in-progress child by delegation id; its session is preserved for later read-only context restoration. |
27
27
  | `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. |
28
28
  | `create_pr` | — | Create draft PRs with session metadata linking related PRs |
29
+ | `update_pr` | — | Update title, user-authored body, labels, or assignees on one PR associated with a session |
29
30
  | `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. |
30
31
  | `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. |
31
32
  | `link_reference` | — | Link an external reference to a session. |
@@ -223,9 +224,9 @@ When polling `show_agent`, treat `permission-required` like `input-required`:
223
224
 
224
225
  ### Publish Changes (Push Branches, Create PRs, Mark Ready)
225
226
 
226
- 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).
227
+ 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).
227
228
 
228
- **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.
229
+ **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.
229
230
 
230
231
  ### Session Description Policy
231
232
 
@@ -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