@polygraph/opencode-plugin 0.4.45 → 0.4.46
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.
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
// source/hooks/agent-session-link.mjs
|
|
2
|
+
import { appendFileSync, mkdirSync, renameSync, statSync } from "node:fs";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { spawnSync } from "node:child_process";
|
|
6
|
+
var HOOK_LOG_MAX_BYTES = 5 * 1024 * 1024;
|
|
7
|
+
var AGENT_TYPES = /* @__PURE__ */ new Set(["claude", "codex", "opencode"]);
|
|
8
|
+
var COMMAND_HOOK_TOOL = /^mcp__(?:plugin_polygraph_)?polygraph[-_]mcp__/;
|
|
9
|
+
var OPENCODE_TOOL = /^polygraph(?:(?:-|_)mcp)?_/;
|
|
10
|
+
function nonEmptyString(value) {
|
|
11
|
+
return typeof value === "string" && value.trim() ? value : void 0;
|
|
12
|
+
}
|
|
13
|
+
function isManagedChildEnvironment(env) {
|
|
14
|
+
return Boolean(env && Object.hasOwn(env, "POLYGRAPH_CHILD_AGENT"));
|
|
15
|
+
}
|
|
16
|
+
function isPolygraphMcpToolName(toolName) {
|
|
17
|
+
const name = nonEmptyString(toolName);
|
|
18
|
+
return Boolean(name && (COMMAND_HOOK_TOOL.test(name) || OPENCODE_TOOL.test(name)));
|
|
19
|
+
}
|
|
20
|
+
function buildLinkAgentSessionArgs({
|
|
21
|
+
polygraphSessionId,
|
|
22
|
+
agentType,
|
|
23
|
+
agentSessionId,
|
|
24
|
+
cwd,
|
|
25
|
+
transcriptPath,
|
|
26
|
+
pid,
|
|
27
|
+
source
|
|
28
|
+
}) {
|
|
29
|
+
const session = nonEmptyString(polygraphSessionId);
|
|
30
|
+
const harnessSession = nonEmptyString(agentSessionId);
|
|
31
|
+
const claimSource = nonEmptyString(source);
|
|
32
|
+
if (!AGENT_TYPES.has(agentType)) throw new Error(`Unsupported agent type: ${agentType}`);
|
|
33
|
+
if (!harnessSession) throw new Error("agentSessionId is required");
|
|
34
|
+
if (!claimSource) throw new Error("source is required");
|
|
35
|
+
const args = ["_link-agent-session"];
|
|
36
|
+
if (session) args.push("--session", session);
|
|
37
|
+
args.push("--agent-type", agentType, "--agent-session-id", harnessSession);
|
|
38
|
+
const workingDirectory = nonEmptyString(cwd);
|
|
39
|
+
if (workingDirectory) args.push("--cwd", workingDirectory);
|
|
40
|
+
const transcript = nonEmptyString(transcriptPath);
|
|
41
|
+
if (transcript) args.push("--transcript-path", transcript);
|
|
42
|
+
if (Number.isSafeInteger(pid) && pid > 0) {
|
|
43
|
+
args.push("--pid", String(pid));
|
|
44
|
+
}
|
|
45
|
+
args.push("--source", claimSource);
|
|
46
|
+
return args;
|
|
47
|
+
}
|
|
48
|
+
function linkAgentSession(claim, spawn = spawnSync, env = process.env) {
|
|
49
|
+
if (isManagedChildEnvironment(env)) return false;
|
|
50
|
+
const args = buildLinkAgentSessionArgs(claim);
|
|
51
|
+
const command = nonEmptyString(env?.POLYGRAPH_CLI) ?? "polygraph";
|
|
52
|
+
const commandEnv = nonEmptyString(claim.polygraphSessionId) ? env : { ...env };
|
|
53
|
+
if (commandEnv !== env) {
|
|
54
|
+
delete commandEnv.POLYGRAPH_SESSION_ID;
|
|
55
|
+
delete commandEnv.POLYGRAPH_CAPTURE_TOKEN;
|
|
56
|
+
}
|
|
57
|
+
const result = spawn(command, args, {
|
|
58
|
+
encoding: "utf8",
|
|
59
|
+
env: commandEnv,
|
|
60
|
+
stdio: ["ignore", "ignore", "pipe"]
|
|
61
|
+
});
|
|
62
|
+
if (result?.error) throw result.error;
|
|
63
|
+
if (result?.status !== 0) {
|
|
64
|
+
const detail = nonEmptyString(result?.stderr);
|
|
65
|
+
throw new Error(
|
|
66
|
+
`polygraph _link-agent-session exited with status ${String(result?.status)}` + (detail ? `: ${detail}` : "")
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
return true;
|
|
70
|
+
}
|
|
71
|
+
function logHookFailure(hook, error, meta = {}, home = process.env.HOME?.trim() || homedir()) {
|
|
72
|
+
try {
|
|
73
|
+
const logsDir = join(home, ".polygraph", "logs");
|
|
74
|
+
mkdirSync(logsDir, { recursive: true });
|
|
75
|
+
const logFile = join(logsDir, "hooks.log");
|
|
76
|
+
try {
|
|
77
|
+
if (statSync(logFile).size > HOOK_LOG_MAX_BYTES) {
|
|
78
|
+
renameSync(logFile, `${logFile}.1`);
|
|
79
|
+
}
|
|
80
|
+
} catch {
|
|
81
|
+
}
|
|
82
|
+
const entry = {
|
|
83
|
+
time: (/* @__PURE__ */ new Date()).toISOString(),
|
|
84
|
+
hook,
|
|
85
|
+
pid: process.pid,
|
|
86
|
+
...meta,
|
|
87
|
+
error: error instanceof Error ? error.message : String(error),
|
|
88
|
+
...error instanceof Error && error.stack ? { stack: error.stack } : {}
|
|
89
|
+
};
|
|
90
|
+
appendFileSync(logFile, JSON.stringify(entry) + "\n");
|
|
91
|
+
} catch {
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// source/opencode/agent-session-link.mjs
|
|
96
|
+
function sessionRecord(result) {
|
|
97
|
+
if (result?.error) {
|
|
98
|
+
throw new Error(`OpenCode session lookup failed: ${JSON.stringify(result.error)}`);
|
|
99
|
+
}
|
|
100
|
+
const record = result && typeof result === "object" && Object.hasOwn(result, "data") ? result.data : result;
|
|
101
|
+
return record && typeof record === "object" && !Array.isArray(record) ? record : void 0;
|
|
102
|
+
}
|
|
103
|
+
async function resolveOpenCodeRootSessionId(client, sessionId) {
|
|
104
|
+
if (!sessionId) return void 0;
|
|
105
|
+
if (typeof client?.session?.get !== "function") {
|
|
106
|
+
throw new Error("OpenCode client.session.get is unavailable");
|
|
107
|
+
}
|
|
108
|
+
const seen = /* @__PURE__ */ new Set();
|
|
109
|
+
let current = sessionId;
|
|
110
|
+
while (current) {
|
|
111
|
+
if (seen.has(current)) {
|
|
112
|
+
throw new Error(`OpenCode session parent cycle detected at ${current}`);
|
|
113
|
+
}
|
|
114
|
+
seen.add(current);
|
|
115
|
+
const record = sessionRecord(
|
|
116
|
+
await client.session.get({ path: { id: current } })
|
|
117
|
+
);
|
|
118
|
+
if (!record) {
|
|
119
|
+
throw new Error(`OpenCode session ${current} was not found`);
|
|
120
|
+
}
|
|
121
|
+
if (record.id !== current) {
|
|
122
|
+
throw new Error(`OpenCode session lookup returned ${String(record.id)} for ${current}`);
|
|
123
|
+
}
|
|
124
|
+
if (!record.parentID) return current;
|
|
125
|
+
if (typeof record.parentID !== "string") {
|
|
126
|
+
throw new Error(`OpenCode session ${current} has an invalid parentID`);
|
|
127
|
+
}
|
|
128
|
+
current = record.parentID;
|
|
129
|
+
}
|
|
130
|
+
return void 0;
|
|
131
|
+
}
|
|
132
|
+
async function linkOpenCodeSessionCreatedEvent(input, sessionLinker) {
|
|
133
|
+
const event = input?.event;
|
|
134
|
+
if (event?.type !== "session.created") return false;
|
|
135
|
+
return sessionLinker.fromSessionCreated(event.properties?.info);
|
|
136
|
+
}
|
|
137
|
+
function deferOpenCodeToolActivity(input, sessionLinker, onError, schedule = setTimeout) {
|
|
138
|
+
schedule(() => {
|
|
139
|
+
Promise.resolve(sessionLinker.fromToolActivity(input)).catch(onError);
|
|
140
|
+
}, 0);
|
|
141
|
+
}
|
|
142
|
+
function createOpenCodeSessionLinker({
|
|
143
|
+
client,
|
|
144
|
+
directory,
|
|
145
|
+
env = process.env,
|
|
146
|
+
pid = process.pid,
|
|
147
|
+
link,
|
|
148
|
+
spawn
|
|
149
|
+
} = {}) {
|
|
150
|
+
const roots = /* @__PURE__ */ new Map();
|
|
151
|
+
const linkedLifecycleSessions = /* @__PURE__ */ new Set();
|
|
152
|
+
const submitLink = link ?? ((claim) => linkAgentSession(claim, spawn, env));
|
|
153
|
+
async function rootSessionId(sessionId) {
|
|
154
|
+
if (!sessionId) return void 0;
|
|
155
|
+
if (roots.has(sessionId)) return roots.get(sessionId);
|
|
156
|
+
const root = await resolveOpenCodeRootSessionId(client, sessionId);
|
|
157
|
+
if (root) roots.set(sessionId, root);
|
|
158
|
+
return root;
|
|
159
|
+
}
|
|
160
|
+
async function submit(openCodeSessionId, cwd, polygraphSessionId) {
|
|
161
|
+
if (!openCodeSessionId || env && Object.hasOwn(env, "POLYGRAPH_CHILD_AGENT")) {
|
|
162
|
+
return false;
|
|
163
|
+
}
|
|
164
|
+
const agentSessionId = await rootSessionId(openCodeSessionId);
|
|
165
|
+
if (!agentSessionId) return false;
|
|
166
|
+
const lifecycleKey = polygraphSessionId ? `${polygraphSessionId}\0${agentSessionId}` : void 0;
|
|
167
|
+
if (lifecycleKey && linkedLifecycleSessions.has(lifecycleKey)) return false;
|
|
168
|
+
const linked = await submitLink({
|
|
169
|
+
...polygraphSessionId ? { polygraphSessionId } : {},
|
|
170
|
+
agentType: "opencode",
|
|
171
|
+
agentSessionId,
|
|
172
|
+
cwd: cwd || directory || process.cwd(),
|
|
173
|
+
pid,
|
|
174
|
+
source: "hook"
|
|
175
|
+
});
|
|
176
|
+
if (linked && lifecycleKey) linkedLifecycleSessions.add(lifecycleKey);
|
|
177
|
+
return linked;
|
|
178
|
+
}
|
|
179
|
+
async function fromEnvironment(sessionId, cwd) {
|
|
180
|
+
if (!env.POLYGRAPH_SESSION_ID) return false;
|
|
181
|
+
return submit(sessionId, cwd, env.POLYGRAPH_SESSION_ID);
|
|
182
|
+
}
|
|
183
|
+
return {
|
|
184
|
+
async fromSessionCreated(info) {
|
|
185
|
+
if (!info?.id || info.parentID) return false;
|
|
186
|
+
roots.set(info.id, info.id);
|
|
187
|
+
return fromEnvironment(info.id, info.directory);
|
|
188
|
+
},
|
|
189
|
+
fromEnvironment,
|
|
190
|
+
async fromToolActivity(input) {
|
|
191
|
+
if (!isPolygraphMcpToolName(input?.tool)) return false;
|
|
192
|
+
return submit(input?.sessionID);
|
|
193
|
+
}
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
export {
|
|
197
|
+
createOpenCodeSessionLinker,
|
|
198
|
+
deferOpenCodeToolActivity,
|
|
199
|
+
linkOpenCodeSessionCreatedEvent,
|
|
200
|
+
logHookFailure,
|
|
201
|
+
resolveOpenCodeRootSessionId
|
|
202
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@polygraph/opencode-plugin",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.46",
|
|
4
4
|
"description": "AI agent skills and subagents for Polygraph sessions, repository context, and coordination",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"private": false,
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
},
|
|
25
25
|
"files": [
|
|
26
26
|
"server.js",
|
|
27
|
-
"agent-
|
|
27
|
+
"agent-session-link.mjs",
|
|
28
28
|
"skills/",
|
|
29
29
|
"agents/",
|
|
30
30
|
"README.md"
|
package/server.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
// plugin loading ENTIRELY for every user who has the plugin installed.
|
|
6
6
|
//
|
|
7
7
|
// DO NOT add new exports to this file. Put shared or testable logic in sibling
|
|
8
|
-
// modules under source/opencode/ (e.g. agent-
|
|
8
|
+
// modules under source/opencode/ (e.g. agent-session-link.mjs) and import
|
|
9
9
|
// it here instead.
|
|
10
10
|
|
|
11
11
|
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
|
@@ -14,17 +14,34 @@ import path from 'node:path';
|
|
|
14
14
|
import { fileURLToPath } from 'node:url';
|
|
15
15
|
import yaml from 'js-yaml';
|
|
16
16
|
|
|
17
|
-
import {
|
|
17
|
+
import {
|
|
18
|
+
createOpenCodeSessionLinker,
|
|
19
|
+
deferOpenCodeToolActivity,
|
|
20
|
+
linkOpenCodeSessionCreatedEvent,
|
|
21
|
+
logHookFailure,
|
|
22
|
+
} from './agent-session-link.mjs';
|
|
18
23
|
|
|
19
24
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
20
25
|
const packageRoot = __dirname;
|
|
21
26
|
const skillsDir = path.join(packageRoot, 'skills');
|
|
22
27
|
const agentsDir = path.join(packageRoot, 'agents');
|
|
23
28
|
|
|
24
|
-
export const PolygraphPlugin = async () => {
|
|
29
|
+
export const PolygraphPlugin = async ({ client, directory } = {}) => {
|
|
25
30
|
const agents = loadAgents();
|
|
31
|
+
const sessionLinker = createOpenCodeSessionLinker({ client, directory });
|
|
26
32
|
|
|
27
33
|
return {
|
|
34
|
+
event: async (input) => {
|
|
35
|
+
try {
|
|
36
|
+
await linkOpenCodeSessionCreatedEvent(input, sessionLinker);
|
|
37
|
+
} catch (error) {
|
|
38
|
+
logHookFailure('opencode:event', error, {
|
|
39
|
+
eventType: input?.event?.type,
|
|
40
|
+
sessionID: input?.event?.properties?.info?.id,
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
},
|
|
44
|
+
|
|
28
45
|
config: async (cfg) => {
|
|
29
46
|
cfg.skills ??= {};
|
|
30
47
|
cfg.skills.paths ??= [];
|
|
@@ -42,15 +59,22 @@ export const PolygraphPlugin = async () => {
|
|
|
42
59
|
try {
|
|
43
60
|
output.env.POLYGRAPH_AGENT_SESSION_ID = input.sessionID;
|
|
44
61
|
output.env.POLYGRAPH_AGENT_TYPE = 'opencode';
|
|
45
|
-
|
|
46
|
-
// parent-log capture deterministically for this session.
|
|
47
|
-
writeAgentCaptureMapping(input.sessionID);
|
|
62
|
+
await sessionLinker.fromEnvironment(input.sessionID, input.cwd);
|
|
48
63
|
} catch (error) {
|
|
49
64
|
// Never let a hook failure break the OpenCode session; just record it.
|
|
50
65
|
logHookFailure('opencode:shell.env', error, { sessionID: input?.sessionID });
|
|
51
66
|
}
|
|
52
67
|
},
|
|
53
68
|
|
|
69
|
+
'tool.execute.after': async (input) => {
|
|
70
|
+
deferOpenCodeToolActivity(input, sessionLinker, (error) => {
|
|
71
|
+
logHookFailure('opencode:tool.execute.after', error, {
|
|
72
|
+
sessionID: input?.sessionID,
|
|
73
|
+
tool: input?.tool,
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
},
|
|
77
|
+
|
|
54
78
|
// OpenCode has no SessionStart hook, but the Polygraph CLI already seeds the
|
|
55
79
|
// session id into context at launch. The one thing compaction can drop is
|
|
56
80
|
// that identity, so we steer the summary prompt to retain it. This fires
|
|
@@ -62,9 +86,7 @@ export const PolygraphPlugin = async () => {
|
|
|
62
86
|
if (note) {
|
|
63
87
|
output.context.push(note);
|
|
64
88
|
}
|
|
65
|
-
|
|
66
|
-
// SessionStart hooks firing on 'compact').
|
|
67
|
-
writeAgentCaptureMapping(input.sessionID);
|
|
89
|
+
await sessionLinker.fromEnvironment(input.sessionID);
|
|
68
90
|
} catch (error) {
|
|
69
91
|
// Never let a hook failure break the OpenCode session; just record it.
|
|
70
92
|
logHookFailure('opencode:session.compacting', error, {
|
|
@@ -8,9 +8,9 @@ description: Guidance for working with Polygraph sessions, shared/resumable agen
|
|
|
8
8
|
|
|
9
9
|
**IMPORTANT:** Polygraph keeps local clones only for *other* repositories in the session. NEVER `cd` into those clones or access their files directly — work in other repositories ALWAYS happens through the Polygraph MCP `spawn_agent` tool, invoked via `@polygraph-delegate-subagent`.
|
|
10
10
|
|
|
11
|
-
Polygraph connects
|
|
11
|
+
Polygraph connects repos and the agent work happening across them. Its central artifact is the session, which groups the repos, branches, PRs, and CI status for one piece of work and can be shared and resumed: use it to coordinate changes across multiple repos or in a single repoto share the session URL with collaborators, hand off progress via the session description, resume prior work, and watch CI across the session's PRs.
|
|
12
12
|
|
|
13
|
-
**Polygraph operates on the current repo in place.** Starting or joining a session never clones or modifies the repository you are in — you keep working in your real working directory, and `push_branch` pushes your local commits from that checkout. Only *other*
|
|
13
|
+
**Polygraph operates on the current repo in place.** Starting or joining a session never clones or modifies the repository you are in — you keep working in your real working directory, and `push_branch` pushes your local commits from that checkout. Only *other* repos are worked on in separate Polygraph-managed clones via `spawn_agent`.
|
|
14
14
|
|
|
15
15
|
## Available Tools
|
|
16
16
|
|
|
@@ -59,29 +59,28 @@ Use `polygraph whoami` (or the `whoami` MCP tool) before session work to check i
|
|
|
59
59
|
- If the user **is logged in** and an org is selected → proceed to the workflow.
|
|
60
60
|
- If auth is **missing, expired, or no org is selected** → stop session work. Do not keep trying session creation, repository discovery, delegation, or CI checks.
|
|
61
61
|
- Facilitate user reauth through the browser-based login flow, such as `polygraph auth login` (or the `login` MCP tool). In interactive desktop clients, browser reauth is usually user-driven; surface the need clearly and wait for the user to complete it.
|
|
62
|
-
- After login, an organization must be selected. Use `polygraph account select` (or
|
|
62
|
+
- After login, an organization must be selected. Use `polygraph account select` (or MCP equivalent) when needed.
|
|
63
63
|
- Re-run `polygraph whoami` (or `whoami`) after reauth and org selection. Continue only after it confirms a valid login and selected organization.
|
|
64
64
|
|
|
65
65
|
### Select Organization
|
|
66
66
|
|
|
67
|
-
After logging in (or if logged in but no org is selected), use `polygraph account select` (or
|
|
67
|
+
After logging in (or if logged in but no org is selected), use `polygraph account select` (or MCP equivalent) to choose the organization that future commands will run against.
|
|
68
68
|
|
|
69
69
|
## Workflow Overview
|
|
70
70
|
|
|
71
71
|
The delegate/monitor/stop steps apply only when working across repos. A single-repo session skips them and still benefits from shared progress, resume, and CI visibility.
|
|
72
72
|
|
|
73
|
-
0. **Initialize or join Polygraph session** - If you were spawned inside an existing session (the startup banner names a session ID), reuse it. Call `show_session` first; if it already has repos and the user did not ask to add more, you're done. If the user asks to add exact repo refs, call `add_repo` directly
|
|
74
|
-
1. **Delegate work to each repo** - Use the `polygraph-delegate-subagent` to start child agents
|
|
73
|
+
0. **Initialize or join Polygraph session** - If you were spawned inside an existing session (the startup banner names a session ID), reuse it. Call `show_session` first; if it already has repos and the user did not ask to add more, you're done. If the user asks to add exact repo refs, call `add_repo` directly and skip candidate discovery. If the session has no repos and no exact refs were provided, launch the `polygraph-init-subagent` with that `sessionId` so it discovers candidates and uses `add_repo` (NOT `start_session`). Only when there is no session ID at all should the init subagent create a new session.
|
|
74
|
+
1. **Delegate work to each repo** - Use the `polygraph-delegate-subagent` to start child agents. Delegate only to *other* repos — never to the repo you are in; work on it directly (your regular subagents are fine for local work — only Polygraph delegation is reserved for other repos). Parallel delegation across repos is encouraged. Choose the Simple (fire-and-forget) or Multi-turn (interactive) pattern described below based on whether the child may need clarification.
|
|
75
75
|
|
|
76
76
|
4. **Monitor child agents** - Use `show_agent` to poll one repo's children (`repo` is required; pass `role` to narrow to one agent) and read each entry's `status` and `lastOutputLines` from the `children[]` array.
|
|
77
77
|
5. **Stop child agents** (if needed) - Use `stop_agent` (with `role` when targeting a non-default agent) to cancel an in-progress child agent. The agent's session is preserved for later read-only context restoration; after a resume, wait for explicit user instructions before making changes.
|
|
78
78
|
6. **Push branches** - Use `push_branch` after making commits. A required `description` must follow the Session Description Policy.
|
|
79
|
-
7. **
|
|
80
|
-
8. **
|
|
81
|
-
9. **
|
|
82
|
-
10. **
|
|
83
|
-
11. **
|
|
84
|
-
12. **Archive session** - Use `archive_session` to archive the session when the user requests it.
|
|
79
|
+
7. **Create draft PRs** - Use `create_pr` to create linked draft PRs. Always pass `description` following the Session Description Policy.
|
|
80
|
+
8. **Associate existing PRs** (optional) - Use `associate_pr` to link PRs created outside Polygraph.
|
|
81
|
+
9. **Query PR status** - Use `show_session` to check progress.
|
|
82
|
+
10. **Mark PRs ready** - Use `mark_pr_ready` when work is complete.
|
|
83
|
+
11. **Archive session** - Use `archive_session` to archive the session when the user requests it.
|
|
85
84
|
|
|
86
85
|
## Step-by-Step Guide
|
|
87
86
|
|
|
@@ -89,7 +88,7 @@ The delegate/monitor/stop steps apply only when working across repos. A single-r
|
|
|
89
88
|
|
|
90
89
|
There are three cases. Pick exactly one before calling any tool. The case labels are internal routing shorthand — never mention them in anything you show the user.
|
|
91
90
|
|
|
92
|
-
**Hard rule: if a session ID is already in scope (e.g., the startup banner says "You're in Polygraph session …", or the user passed one), that session ID is authoritative for this entire conversation. NEVER call `start_session` — doing so creates a brand-new session and orphans the one the parent harness is pointed at. Reuse the existing session via `show_session` and, if needed, `add_repo`.**
|
|
91
|
+
**Hard rule: if a session ID is already in scope (e.g., the startup banner says "You're in Polygraph session …", or the user passed one or you are provided one by a reminder hook), that session ID is authoritative for this entire conversation. NEVER call `start_session` — doing so creates a brand-new session and orphans the one the parent harness is pointed at. Reuse the existing session via `show_session` and, if needed, `add_repo`.**
|
|
93
92
|
|
|
94
93
|
**Case A — Existing session, already has repos.** Call `show_session` directly with the known session ID. Skip the init subagent entirely, show the session details (format below), and proceed.
|
|
95
94
|
|
|
@@ -102,14 +101,10 @@ In case B, call `add_repo` yourself when exact repo refs were provided; otherwis
|
|
|
102
101
|
**Session ID handling:**
|
|
103
102
|
|
|
104
103
|
- For a new session (case C), `start_session` auto-generates a unique session ID. You do NOT need to pass one.
|
|
105
|
-
- For cases A and B, the session ID already exists; reuse it everywhere
|
|
104
|
+
- For cases A and B, the session ID already exists; reuse it everywhere
|
|
106
105
|
- The parent conversation is responsible for detecting an existing session ID from current context, the startup banner, or a user-provided session URL/ID, then passing it explicitly to `polygraph-init-subagent`. The init subagent cannot infer parent session context by itself.
|
|
107
106
|
- For a fresh Codex Desktop conversation started with `/polygraph:session-start`, no `sessionId` is expected; launch `polygraph-init-subagent` without `sessionId` so it creates a new session.
|
|
108
107
|
|
|
109
|
-
**Launch the init subagent** using `@polygraph-init-subagent` (cases B and C — skip in case A):
|
|
110
|
-
|
|
111
|
-
Invoke the `polygraph-init-subagent` agent when discovery is needed. Always pass `userContext`. If you are in case B (existing session with no repos) and no exact repo refs were provided, also pass the existing `sessionId` and instruct the subagent to use `add_repo` rather than `start_session`. The subagent returns a structured summary.
|
|
112
|
-
|
|
113
108
|
The subagent will:
|
|
114
109
|
|
|
115
110
|
1. Use exact repo refs directly when provided for an existing session; otherwise call `list_repos` to discover available repositories
|
|
@@ -118,7 +113,7 @@ The subagent will:
|
|
|
118
113
|
4. Call `show_session` to retrieve session details
|
|
119
114
|
5. Return a summary with session URL and repo info
|
|
120
115
|
|
|
121
|
-
**When the init subagent has just created a brand-new session,** render the session welcome card instead of the session-details block below. Prefer the `session_intro` MCP tool
|
|
116
|
+
**When the init subagent has just created a brand-new session,** render the session welcome card instead of the session-details block below. Prefer the `session_intro` MCP tool (or the hidden `polygraph session intro -s <sessionId>` via the CLI) — call it with the session ID; it returns the card as markdown. Print the result to the user verbatim as markdown — do NOT wrap it in a code block or reformat it (the logo is pre-fenced; the rest is live markdown). It needs no other input, and you do not need to call `show_session` first. Then continue.
|
|
122
117
|
|
|
123
118
|
**For an existing session — after `show_session` returns or the init subagent's summary arrives — show the session details:**
|
|
124
119
|
|
|
@@ -138,7 +133,7 @@ Use this workflow when the user gives a Polygraph session ID and asks to underst
|
|
|
138
133
|
**Resume is not a work command.** If the user's intent is to resume, reconnect, or reconstruct a prior Polygraph session, fetch and summarize the restored context, then stop. Do not edit files, push branches, add repos, delegate new work, or continue previous changes until the user explicitly asks for changes. Treat "resume" as context restoration followed by waiting for user instructions.
|
|
139
134
|
|
|
140
135
|
1. Fetch detailed session context:
|
|
141
|
-
- Prefer `show_session` with `details: true`
|
|
136
|
+
- Prefer `show_session` with `details: true`
|
|
142
137
|
- Otherwise run `polygraph session show --details <session-id>`.
|
|
143
138
|
2. Treat the detailed output as authoritative context. It should include:
|
|
144
139
|
- `<summary>` — the session summary.
|
|
@@ -178,29 +173,8 @@ Inspect the PR commits/diff and investigate the requested behavior. Report findi
|
|
|
178
173
|
|
|
179
174
|
### Finding the Session Behind a Commit or Line
|
|
180
175
|
|
|
181
|
-
Use this workflow when the user asks which Polygraph session produced, is behind, or changed a particular commit — or a particular line of code.
|
|
182
|
-
|
|
183
|
-
**Given a commit sha.** When the user names a sha, or asks what session is behind a commit, resolve it with `search_sessions` using the `sha` parameter (CLI: `polygraph session search --sha <sha>`):
|
|
184
|
-
|
|
185
|
-
- Pass **exactly one** of `query` or `sha` — they are mutually exclusive.
|
|
186
|
-
- `sha` accepts a full or partial sha, 7-40 hex chars.
|
|
187
|
-
- The lookup is exact and one-shot: it returns the session(s) linked to that commit, newest first, scoped to the current org, and only explicit sessions.
|
|
188
|
-
|
|
189
|
-
```
|
|
190
|
-
search_sessions(sha: "a1b2c3d")
|
|
191
|
-
# CLI equivalent:
|
|
192
|
-
polygraph session search --sha a1b2c3d
|
|
193
|
-
```
|
|
194
|
-
|
|
195
|
-
**Given a line number.** There is no line-number lookup — a line MUST first be resolved to a commit sha with `git blame`, then that sha is fed into the sha lookup:
|
|
196
|
-
|
|
197
|
-
1. `git blame -L <line>,<line> -- <file>` to get the commit that last touched the line.
|
|
198
|
-
2. Pass that sha to `search_sessions(sha: ...)` (or `polygraph session search --sha <sha>`).
|
|
199
|
-
|
|
200
|
-
**Reading the results.**
|
|
201
|
-
|
|
202
|
-
- Multiple sessions may match a sha. They come back newest first — pick the most relevant one and report the others if they matter.
|
|
203
|
-
- **A "no match" result does NOT prove the commit had no work behind it.** Not every commit is linked to an explicit session: commits pushed directly (rather than via an ingested PR) and gaps in ingestion metadata mean the sha may simply not be recorded, and implicit sessions are never returned. Report "no linked session found for that sha" — never assert that no work exists behind the commit.
|
|
176
|
+
Use this workflow when the user asks which Polygraph session produced, is behind, or changed a particular commit — or a particular line of code.
|
|
177
|
+
**Read [`reference/session-by-commit.md`](reference/session-by-commit.md) before running any lookup.** That reference file holds clear, reliable steps for answering questions related to this.
|
|
204
178
|
|
|
205
179
|
## Agent roles
|
|
206
180
|
|
|
@@ -222,6 +196,8 @@ Use this pattern when the task is well-defined and the child is not expected to
|
|
|
222
196
|
3. The subagent watches `child.status` on its delegation's `children[]` entry — the one matching its repo and role — and exits when it sees a terminal status — typically `'completed'` or `'failed'` (and `'cancelled'` if it was stopped).
|
|
223
197
|
4. Once all subagents report a terminal status, continue to `push_branch` + `create_pr`.
|
|
224
198
|
|
|
199
|
+
To debug a stuck subagent you can call `show_agent` as a one-off, but routine polling belongs in the background subagents.
|
|
200
|
+
|
|
225
201
|
Use Simple when the task is well-defined and the child will not need clarification.
|
|
226
202
|
|
|
227
203
|
## Multi-turn tasks (interactive)
|
|
@@ -276,44 +252,25 @@ Child agents running in other repositories may pause and ask the parent agent wh
|
|
|
276
252
|
|
|
277
253
|
**Native path (MCP permission dialog):** If your MCP client supports the permission dialog UI, the user picks directly in that dialog — you (the agent) won't see `permission-required` tasks in that flow.
|
|
278
254
|
|
|
279
|
-
**Structured fallback path:** When `cloud_polygraph_child_status` reports a task in `permission-required` state, read the `pendingPermission` object on that task
|
|
255
|
+
**Structured fallback path:** When `cloud_polygraph_child_status` reports a task in `permission-required` state, read the `pendingPermission` object on that task, then call `allow_agent` (to grant the requested action) or `deny_agent` (to refuse it) with the `{sessionId, repo, role?}` of the child agent.
|
|
280
256
|
|
|
281
257
|
### Answering a permission request
|
|
282
258
|
|
|
283
|
-
```jsonc
|
|
284
|
-
// To grant the requested action:
|
|
285
|
-
{
|
|
286
|
-
"sessionId": "...",
|
|
287
|
-
"repo": "org/repo-name",
|
|
288
|
-
"role": "reviewer", // optional
|
|
289
|
-
"scope": "session", // or "one-time"
|
|
290
|
-
"reason": "Trusted local repo" // optional
|
|
291
|
-
}
|
|
292
|
-
// — call `allow_agent` with this payload.
|
|
293
|
-
|
|
294
|
-
// To refuse the requested action:
|
|
295
|
-
{
|
|
296
|
-
"sessionId": "...",
|
|
297
|
-
"repo": "org/repo-name",
|
|
298
|
-
"role": "reviewer", // optional
|
|
299
|
-
"reason": "Action looks risky" // optional
|
|
300
|
-
}
|
|
301
|
-
// — call `deny_agent` with this payload.
|
|
302
|
-
```
|
|
303
|
-
|
|
304
259
|
The three decisions:
|
|
305
260
|
|
|
306
261
|
- `allow_agent` with `scope: 'one-time'` — permits the single action only; the child must ask again for the next action of the same type.
|
|
307
262
|
- `allow_agent` with `scope: 'session'` — permits the action and remembers that grant for the rest of the child's session; the child will not ask again.
|
|
308
263
|
- `deny_agent` — rejects the request; child continues without performing the action.
|
|
309
264
|
|
|
265
|
+
You always pass the sessionId, repo, optional role, optional reason to the allow/deny tool.
|
|
266
|
+
|
|
310
267
|
**Fail-closed default:** When you see a task in `permission-required` state, you MUST call either `allow_agent` or `deny_agent`. Failing to call one leaves the gate held open until the child's idle timer fires; the child cannot make progress until you decide.
|
|
311
268
|
|
|
312
269
|
> **OpenCode caveat:** *OpenCode children sometimes request permissions without specific command/path (target is empty). Dialog says 'session' grant covers ALL `${action}` calls this session — read carefully before granting session scope.*
|
|
313
270
|
|
|
314
271
|
### Polling for permission-required in the fallback path
|
|
315
272
|
|
|
316
|
-
When polling `
|
|
273
|
+
When polling `show_agent`, treat `permission-required` like `input-required`:
|
|
317
274
|
|
|
318
275
|
1. Read `child.pendingPermission` — inspect `harness`, `action`, `target`, `repoFullName`, and `scope`.
|
|
319
276
|
2. Surface the request to the user: "Child agent in `{repoFullName}` requests `{scope}` permission to run `{action}` on `{target}`."
|
|
@@ -327,30 +284,16 @@ When polling `cloud_polygraph_child_status` (or `show_agent`), treat `permission
|
|
|
327
284
|
|
|
328
285
|
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).
|
|
329
286
|
|
|
330
|
-
**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
|
|
287
|
+
**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.
|
|
331
288
|
|
|
332
289
|
### Session Description Policy
|
|
333
290
|
|
|
334
291
|
`description` is user-facing Polygraph session context. It is required for `push_branch`, `create_pr`, and `associate_pr`, and is the primary input to `update_session` (`mark_pr_ready` does not take a description).
|
|
335
292
|
|
|
336
|
-
**Whenever you write or update a session description, read [`reference/session-description.md`](reference/session-description.md) first.** That reference file holds the full policy
|
|
293
|
+
**Whenever you write or update a session description, read [`reference/session-description.md`](reference/session-description.md) first.** That reference file holds the full policy.
|
|
337
294
|
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
Check the details of a session using `show_session` or `polygraph session show --details <session-id>`. Returns the full session state — basic metadata like id, url & description timeline, plus the connected repositories, `pullRequests[]`, per-PR `ciStatus`, and `session.linkedReferences`.
|
|
341
|
-
|
|
342
|
-
**Parameters:**
|
|
343
|
-
|
|
344
|
-
- `sessionId` (required): The Polygraph session ID
|
|
345
|
-
|
|
346
|
-
**CI status rules:**
|
|
347
|
-
|
|
348
|
-
- `ciStatus[prId].cipeUrl` (null if no CIPE) is a human-facing Nx Cloud web link — display it to the user, but never fetch, curl, or poll it directly; CIPE data is only accessible programmatically via the Nx MCP `ci_information` tool.
|
|
349
|
-
- When no CIPE exists, external CI data (e.g., GitHub Actions) appears in `ciStatus[prId].externalCIRuns[]` as runs with nested `jobs[]`; each job's `jobId` is the input for `get_ci_logs`.
|
|
350
|
-
|
|
351
|
-
```
|
|
352
|
-
show_session(sessionId: "<session-id>")
|
|
353
|
-
```
|
|
295
|
+
Use `update_session` directly when the user asks to summarize progress, update the session description, or capture the current state.
|
|
296
|
+
Be liberal about updating the session description when you make changes that affect the scope of the session, how logic flows between repos, or anything else important for posterity. Avoid updating it for small implementation details that are not relevant outside of this session. An up-to-date session description matters for maintainability.
|
|
354
297
|
|
|
355
298
|
### Linked References
|
|
356
299
|
|
|
@@ -364,55 +307,16 @@ Use `link_reference` to link an external reference to the current Polygraph sess
|
|
|
364
307
|
|
|
365
308
|
When an external resource is mentioned during a Polygraph session and appears relevant to the current work, the parent agent should record it with `link_reference({ sessionId, reference })`. This applies to relevant external resources such as pull requests, GitHub issues, other Polygraph sessions, and Linear issues.
|
|
366
309
|
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
```
|
|
370
|
-
link_reference({
|
|
371
|
-
sessionId: "<current-session-id>",
|
|
372
|
-
reference: {
|
|
373
|
-
type: "github_pr",
|
|
374
|
-
url: "https://github.com/nrwl/polygraph-skills/pull/123",
|
|
375
|
-
label: "Implementation PR"
|
|
376
|
-
}
|
|
377
|
-
})
|
|
378
|
-
```
|
|
379
|
-
|
|
380
|
-
To record a relevant Polygraph session, use the same invocation shape and include `reference.sessionId`:
|
|
381
|
-
|
|
382
|
-
```
|
|
383
|
-
link_reference({
|
|
384
|
-
sessionId: "<current-session-id>",
|
|
385
|
-
reference: {
|
|
386
|
-
type: "session",
|
|
387
|
-
url: "https://polygraph.example/s/<inspected-session-id>",
|
|
388
|
-
label: "Inspected Polygraph session",
|
|
389
|
-
sessionId: "<inspected-session-id>"
|
|
390
|
-
}
|
|
391
|
-
})
|
|
392
|
-
```
|
|
393
|
-
|
|
394
|
-
The canonical MCP parameters are `{ sessionId, reference }`. There is no unlink command.
|
|
310
|
+
The canonical MCP parameters are `{ sessionId, reference }`. There is no unlink command; `show_session` returns a session's existing links as `session.linkedReferences`.
|
|
395
311
|
|
|
396
312
|
### Add Repositories to a Session
|
|
397
313
|
|
|
398
314
|
Use `add_repo` to add repositories to an existing Polygraph session after it has already started.
|
|
399
315
|
|
|
400
|
-
**Direct-add rule:** When the user provides exact repo refs by ID, short name, full name, GitHub `owner/repo` slug, or URL-like slug, pass those refs directly to `add_repo` and do not call `list_repos` first. Candidate discovery
|
|
316
|
+
**Direct-add rule:** When the user provides exact repo refs by ID, short name, full name, GitHub `owner/repo` slug, or URL-like slug, pass those refs directly to `add_repo` and do not call `list_repos` first. Candidate discovery is only for cases where the user does not know the exact repo.
|
|
401
317
|
|
|
402
318
|
**Not limited to your organization:** repos outside the org — including public open-source repos — can be added by GitHub `owner/repo` slug or URL. Only `list_repos` discovery is org-scoped, so a repo missing from `list_repos` can still be added directly.
|
|
403
319
|
|
|
404
|
-
**Parameters:**
|
|
405
|
-
|
|
406
|
-
- `sessionId` (required): The Polygraph session ID
|
|
407
|
-
- `repoIds` (required): Repository IDs or exact repository refs to add. Accepts IDs, short names, full names, GitHub `owner/repo` slugs, and URL-like slugs.
|
|
408
|
-
|
|
409
|
-
```
|
|
410
|
-
add_repo(
|
|
411
|
-
sessionId: "<session-id>",
|
|
412
|
-
repoIds: ["org/repo-name", "facebook/react"]
|
|
413
|
-
)
|
|
414
|
-
```
|
|
415
|
-
|
|
416
320
|
### Archive Session
|
|
417
321
|
|
|
418
322
|
**IMPORTANT: Only call this tool when the user explicitly asks to archive or close the session.** Do not archive sessions automatically as part of the workflow.
|
|
@@ -433,14 +337,6 @@ When you need to fetch and read a failed job's log, read [`reference/ci-job-logs
|
|
|
433
337
|
|
|
434
338
|
Session repos are shallow (`--depth 1`) clones. When git fails on missing history (`bad object` from `git revert`, `git log`, `git blame`, etc.), call `git_fetch({ sessionId, repo })` and retry. Read [`reference/shallow-clone-history.md`](reference/shallow-clone-history.md) for the CLI form, the `depth`/`refs` options, and the redundant-call behavior.
|
|
435
339
|
|
|
436
|
-
### Update Session Description
|
|
437
|
-
|
|
438
|
-
Use this when the user asks to summarize progress, update the session description, or capture the current state.
|
|
439
|
-
|
|
440
|
-
Read [`reference/session-description.md`](reference/session-description.md) for the full update procedure (what to read before writing, how to append vs. replace) and the canonical Markdown-heading format. Then call `update_session` with the resulting summary as `description`.
|
|
441
|
-
|
|
442
|
-
Be liberal about updating the session description when you make changes that affect the scope of the session, how logic flows between repos, or anything else important for posterity. Avoid updating it for small implementation details that are not relevant outside of this session. An up-to-date session description matters for maintainability.
|
|
443
|
-
|
|
444
340
|
### Print Polygraph Session Details
|
|
445
341
|
|
|
446
342
|
When asked to print polygraph session details, use `show_session` or `polygraph session show --details <session-id>` and display in the following format.
|
|
@@ -461,7 +357,7 @@ If the session has a description timeline, also display:
|
|
|
461
357
|
- PR_URL, PR_TITLE, PR_STATUS: from `pullRequests[]`
|
|
462
358
|
- CI_STATUS: from `ciStatus[prId].status`
|
|
463
359
|
- SELF_HEALING_STATUS: from `ciStatus[prId].selfHealingStatus` (omit or show `-` if null)
|
|
464
|
-
- CIPE_URL: from `ciStatus[prId].cipeUrl`
|
|
360
|
+
- CIPE_URL: from `ciStatus[prId].cipeUrl` (null if no CIPE — omit the CI Link cell) — a human-facing Nx Cloud link: render it for the user, never fetch, curl, or poll it. CIPE data is only reachable via the Nx MCP `ci_information` tool.
|
|
465
361
|
- POLYGRAPH_SESSION_URL: from `polygraphSessionUrl`
|
|
466
362
|
- SESSION_DESCRIPTION: from the latest/current item in `description`
|
|
467
363
|
|
|
@@ -479,5 +375,3 @@ If the session has a description timeline, also display:
|
|
|
479
375
|
1. **NEVER call `spawn_agent` or `show_agent` directly**. These MUST ALWAYS go through `@polygraph-delegate-subagent`.
|
|
480
376
|
|
|
481
377
|
1. **Use `stop_agent` to clean up** — Stop child agents that are stuck or no longer needed (pass `role` to target a non-default agent). The child's session is preserved (`sessionPreserved: true`) so the context can be restored later, but after resuming you must wait for explicit user instructions before making changes.
|
|
482
|
-
1. **Only archive sessions when asked** — Only call `archive_session` when the user explicitly requests it. Archiving hides the session from active lists; it can still be resumed later.
|
|
483
|
-
|
|
@@ -6,6 +6,8 @@ The branch-to-PR flow: push branches, create draft PRs, mark them ready, and ass
|
|
|
6
6
|
|
|
7
7
|
Once work is complete in a repository, push the branch using `push_branch`. This must be done before creating a PR.
|
|
8
8
|
|
|
9
|
+
**If `push_branch` fails, don't guess at the cause.** Report the tool's actual error to the user first. Only once the real cause is clear, offer to fall back to a manual `git push` — let the user decide, rather than falling back unprompted or asserting what a credential/token can or cannot do.
|
|
10
|
+
|
|
9
11
|
`push_branch` pushes from the local checkout: for the repo you are in, that is your current working directory with your commits; for delegated repos, it is the Polygraph-managed clone the child agent worked in. There is no separate session copy of the current repo.
|
|
10
12
|
|
|
11
13
|
**Parameters:**
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# Finding the Session Behind a Commit or Line
|
|
2
|
+
|
|
3
|
+
Use this workflow when the user asks which Polygraph session produced, is behind, or changed a particular commit — or a particular line of code.
|
|
4
|
+
|
|
5
|
+
**Given a commit sha.** When the user names a sha, or asks what session is behind a commit, resolve it with `search_sessions` using the `sha` parameter (CLI: `polygraph session search --sha <sha>`):
|
|
6
|
+
|
|
7
|
+
- Pass **exactly one** of `query` or `sha` — they are mutually exclusive.
|
|
8
|
+
- `sha` accepts a full or partial sha, 7-40 hex chars.
|
|
9
|
+
- The lookup is exact and one-shot: it returns the session(s) linked to that commit, newest first, scoped to the current org, and only explicit sessions.
|
|
10
|
+
|
|
11
|
+
```
|
|
12
|
+
search_sessions(sha: "a1b2c3d")
|
|
13
|
+
# CLI equivalent:
|
|
14
|
+
polygraph session search --sha a1b2c3d
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
**Given a line number.** There is no line-number lookup — a line MUST first be resolved to a commit sha with `git blame`, then that sha is fed into the sha lookup:
|
|
18
|
+
|
|
19
|
+
1. `git blame -L <line>,<line> -- <file>` to get the commit that last touched the line.
|
|
20
|
+
2. Pass that sha to `search_sessions(sha: ...)` (or `polygraph session search --sha <sha>`).
|
|
21
|
+
|
|
22
|
+
**Reading the results.**
|
|
23
|
+
|
|
24
|
+
- Multiple sessions may match a sha. They come back newest first — pick the most relevant one and report the others if they matter.
|
|
25
|
+
- **A "no match" result does NOT prove the commit had no work behind it.** Not every commit is linked to an explicit session: commits pushed directly (rather than via an ingested PR) and gaps in ingestion metadata mean the sha may simply not be recorded, and implicit sessions are never returned. Report "no linked session found for that sha" — never assert that no work exists behind the commit.
|
|
@@ -1,202 +0,0 @@
|
|
|
1
|
-
// Agent-capture mapping writer for the OpenCode plugin.
|
|
2
|
-
// Exported from this sibling module (NOT from server.js) so it can be tested
|
|
3
|
-
// independently. See server.js for the constraint on why server.js itself must
|
|
4
|
-
// stay export-free beyond its plugin entry.
|
|
5
|
-
//
|
|
6
|
-
// Records the (agentSessionId ↔ polygraphSessionId) mapping file so the
|
|
7
|
-
// Polygraph CLI can bind parent-log capture deterministically. Written on
|
|
8
|
-
// every session start and compaction; the CLI reader looks for mapping-*.json
|
|
9
|
-
// files in the per-session sidecars directory.
|
|
10
|
-
//
|
|
11
|
-
// File contract (must match the Polygraph CLI reader exactly):
|
|
12
|
-
// <sessionsRoot>/<POLYGRAPH_SESSION_ID>/sidecars/mapping-opencode-<sessionId>.json
|
|
13
|
-
// where sessionsRoot = $POLYGRAPH_ROOT, else `globalRoot` from
|
|
14
|
-
// ~/.polygraph/config.json, else ~/.polygraph/sessions
|
|
15
|
-
// Legacy fallback, used ONLY when <sessionsRoot>/<POLYGRAPH_SESSION_ID>
|
|
16
|
-
// does not exist (for real sessions nothing new is written here):
|
|
17
|
-
// ~/.polygraph/sidecars/<POLYGRAPH_SESSION_ID>/mapping-opencode-<sessionId>.json
|
|
18
|
-
//
|
|
19
|
-
// The session folder is a trustworthy location for this parent-transcript
|
|
20
|
-
// binding because the Polygraph CLI's child-agent sandboxes exclude the
|
|
21
|
-
// session root — children cannot write there. The CLI reads mappings from
|
|
22
|
-
// the session folder first, with the flat dir as a read-only fallback.
|
|
23
|
-
//
|
|
24
|
-
// Behaviour:
|
|
25
|
-
// - Silent no-op when POLYGRAPH_SESSION_ID is unset or POLYGRAPH_CHILD_AGENT is set.
|
|
26
|
-
// - Atomic write via tmp-file rename.
|
|
27
|
-
// - Refresh: preserves firstSeenAt when a valid prior mapping exists
|
|
28
|
-
// (checked in the new location first, then the legacy flat dir — keeps
|
|
29
|
-
// firstSeenAt continuity when migrating a mapping from the legacy dir).
|
|
30
|
-
// - All failures are silently swallowed.
|
|
31
|
-
|
|
32
|
-
import {
|
|
33
|
-
appendFileSync,
|
|
34
|
-
existsSync,
|
|
35
|
-
mkdirSync,
|
|
36
|
-
readFileSync,
|
|
37
|
-
renameSync,
|
|
38
|
-
statSync,
|
|
39
|
-
writeFileSync,
|
|
40
|
-
} from 'node:fs';
|
|
41
|
-
import { homedir } from 'node:os';
|
|
42
|
-
import path from 'node:path';
|
|
43
|
-
|
|
44
|
-
function sanitizeMappingFilename(str) {
|
|
45
|
-
return str.replace(/[^A-Za-z0-9._-]/g, '_');
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
const HOOK_LOG_MAX_BYTES = 5 * 1024 * 1024;
|
|
49
|
-
|
|
50
|
-
/**
|
|
51
|
-
* Append a one-line JSON record of a hook failure to ~/.polygraph/logs/hooks.log.
|
|
52
|
-
*
|
|
53
|
-
* Hooks otherwise swallow their errors silently (a broken hook must never break
|
|
54
|
-
* the agent session) and must never write to stdout — so this on-disk log is the
|
|
55
|
-
* only record that something went wrong. The logger is itself failure-proof:
|
|
56
|
-
* any error here is swallowed so a logging bug can never break a hook.
|
|
57
|
-
*
|
|
58
|
-
* @param {string} hook Identifier for the failing hook.
|
|
59
|
-
* @param {unknown} error The thrown value.
|
|
60
|
-
* @param {object} [meta] Extra context to record (sessionID, etc.).
|
|
61
|
-
* @param {string} [home] Override HOME for testing.
|
|
62
|
-
*/
|
|
63
|
-
export function logHookFailure(
|
|
64
|
-
hook,
|
|
65
|
-
error,
|
|
66
|
-
meta = {},
|
|
67
|
-
home = process.env.HOME?.trim() || homedir()
|
|
68
|
-
) {
|
|
69
|
-
try {
|
|
70
|
-
const logsDir = path.join(home, '.polygraph', 'logs');
|
|
71
|
-
mkdirSync(logsDir, { recursive: true });
|
|
72
|
-
const logFile = path.join(logsDir, 'hooks.log');
|
|
73
|
-
|
|
74
|
-
// Best-effort rotation so the log can't grow unbounded.
|
|
75
|
-
try {
|
|
76
|
-
if (statSync(logFile).size > HOOK_LOG_MAX_BYTES) {
|
|
77
|
-
renameSync(logFile, `${logFile}.1`);
|
|
78
|
-
}
|
|
79
|
-
} catch {
|
|
80
|
-
// no prior log, or rotation failed — ignore
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
const entry = {
|
|
84
|
-
time: new Date().toISOString(),
|
|
85
|
-
hook,
|
|
86
|
-
pid: process.pid,
|
|
87
|
-
...meta,
|
|
88
|
-
error: error instanceof Error ? error.message : String(error),
|
|
89
|
-
...(error instanceof Error && error.stack ? { stack: error.stack } : {}),
|
|
90
|
-
};
|
|
91
|
-
appendFileSync(logFile, JSON.stringify(entry) + '\n');
|
|
92
|
-
} catch {
|
|
93
|
-
// Logging must never throw — a failing logger must not break the hook.
|
|
94
|
-
}
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
// Resolve the root directory that holds per-session folders:
|
|
98
|
-
// $POLYGRAPH_ROOT, else `globalRoot` from ~/.polygraph/config.json, else
|
|
99
|
-
// ~/.polygraph/sessions. Must match the Polygraph CLI's own resolution.
|
|
100
|
-
function sessionsRoot(home) {
|
|
101
|
-
const fromEnv = process.env.POLYGRAPH_ROOT?.trim();
|
|
102
|
-
if (fromEnv) return fromEnv;
|
|
103
|
-
|
|
104
|
-
try {
|
|
105
|
-
const config = JSON.parse(
|
|
106
|
-
readFileSync(path.join(home, '.polygraph', 'config.json'), 'utf8')
|
|
107
|
-
);
|
|
108
|
-
if (typeof config?.globalRoot === 'string' && config.globalRoot.trim()) {
|
|
109
|
-
return config.globalRoot.trim();
|
|
110
|
-
}
|
|
111
|
-
} catch {
|
|
112
|
-
// no config — use the default
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
return path.join(home, '.polygraph', 'sessions');
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
/**
|
|
119
|
-
* Write (or refresh) the agent-capture mapping for an OpenCode session.
|
|
120
|
-
*
|
|
121
|
-
* Written into the session folder (`<sessionsRoot>/<sessionId>/sidecars/`)
|
|
122
|
-
* when the session directory exists; only when it does not exist does the
|
|
123
|
-
* write fall back to the legacy flat `~/.polygraph/sidecars/<sessionId>/`
|
|
124
|
-
* dir. Reads POLYGRAPH_SESSION_ID and POLYGRAPH_CHILD_AGENT from process.env.
|
|
125
|
-
*
|
|
126
|
-
* @param {string} agentSessionId The OpenCode session id (input.sessionID).
|
|
127
|
-
* @param {string} [home] Override HOME for testing.
|
|
128
|
-
*/
|
|
129
|
-
export function writeAgentCaptureMapping(
|
|
130
|
-
agentSessionId,
|
|
131
|
-
home = process.env.HOME?.trim() || homedir()
|
|
132
|
-
) {
|
|
133
|
-
try {
|
|
134
|
-
const polygraphSessionId = process.env.POLYGRAPH_SESSION_ID;
|
|
135
|
-
if (!polygraphSessionId) return;
|
|
136
|
-
if (process.env.POLYGRAPH_CHILD_AGENT) return;
|
|
137
|
-
if (!agentSessionId) return;
|
|
138
|
-
|
|
139
|
-
const filenamePart = sanitizeMappingFilename(`opencode-${agentSessionId}`);
|
|
140
|
-
const fileName = `mapping-${filenamePart}.json`;
|
|
141
|
-
|
|
142
|
-
const sessionDir = path.join(sessionsRoot(home), polygraphSessionId);
|
|
143
|
-
const sessionSidecarDir = path.join(sessionDir, 'sidecars');
|
|
144
|
-
const legacyDir = path.join(home, '.polygraph', 'sidecars', polygraphSessionId);
|
|
145
|
-
|
|
146
|
-
// New location when the session directory exists; legacy flat dir only
|
|
147
|
-
// when it does not.
|
|
148
|
-
const targetDir = existsSync(sessionDir) ? sessionSidecarDir : legacyDir;
|
|
149
|
-
mkdirSync(targetDir, { recursive: true });
|
|
150
|
-
|
|
151
|
-
const finalPath = path.join(targetDir, fileName);
|
|
152
|
-
const tmpPath = `${finalPath}.tmp-${process.pid}`;
|
|
153
|
-
|
|
154
|
-
const now = Date.now();
|
|
155
|
-
|
|
156
|
-
// Refresh semantics: preserve firstSeenAt from a valid prior mapping.
|
|
157
|
-
// Check the new location first, then the legacy flat dir — this keeps
|
|
158
|
-
// firstSeenAt continuity when migrating a mapping from the legacy dir.
|
|
159
|
-
let firstSeenAt = now;
|
|
160
|
-
for (const candidate of [
|
|
161
|
-
path.join(sessionSidecarDir, fileName),
|
|
162
|
-
path.join(legacyDir, fileName),
|
|
163
|
-
]) {
|
|
164
|
-
if (!existsSync(candidate)) continue;
|
|
165
|
-
try {
|
|
166
|
-
const existing = JSON.parse(readFileSync(candidate, 'utf8'));
|
|
167
|
-
if (
|
|
168
|
-
existing.version === 1 &&
|
|
169
|
-
existing.polygraphSessionId === polygraphSessionId &&
|
|
170
|
-
existing.agentSessionId === agentSessionId &&
|
|
171
|
-
Number.isFinite(existing.firstSeenAt)
|
|
172
|
-
) {
|
|
173
|
-
firstSeenAt = existing.firstSeenAt;
|
|
174
|
-
break;
|
|
175
|
-
}
|
|
176
|
-
} catch {
|
|
177
|
-
// ignore — treat as missing
|
|
178
|
-
}
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
const mapping = {
|
|
182
|
-
version: 1,
|
|
183
|
-
polygraphSessionId,
|
|
184
|
-
agentType: 'opencode',
|
|
185
|
-
agentSessionId,
|
|
186
|
-
cwd: process.cwd(),
|
|
187
|
-
// OpenCode transcripts are resolved by the CLI from its own storage by
|
|
188
|
-
// session id, so we omit transcriptPath here.
|
|
189
|
-
pid: process.pid,
|
|
190
|
-
source: 'hook',
|
|
191
|
-
firstSeenAt,
|
|
192
|
-
lastSeenAt: now,
|
|
193
|
-
};
|
|
194
|
-
|
|
195
|
-
writeFileSync(tmpPath, JSON.stringify(mapping, null, 2) + '\n');
|
|
196
|
-
renameSync(tmpPath, finalPath);
|
|
197
|
-
} catch (error) {
|
|
198
|
-
// Silent toward the agent — a broken plugin hook must never break the
|
|
199
|
-
// session — but record it so failures are not invisible.
|
|
200
|
-
logHookFailure('opencode:writeAgentCaptureMapping', error, { agentSessionId }, home);
|
|
201
|
-
}
|
|
202
|
-
}
|