@ask-llm/plugin 0.17.0 → 0.19.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/.cursor-plugin/plugin.json +1 -1
- package/CHANGELOG.md +36 -0
- package/README.md +8 -8
- package/agents/brainstorm-coordinator.md +17 -16
- package/agents/codex-reviewer.md +1 -1
- package/agents/sol-reviewer.md +5 -5
- package/codex-pair-defaults.json +1 -1
- package/dist/brainstorm-panel.d.ts +1 -1
- package/dist/brainstorm-panel.d.ts.map +1 -1
- package/dist/brainstorm-panel.js +8 -8
- package/dist/brainstorm-panel.js.map +1 -1
- package/dist/brainstorm-run.js +1 -1
- package/dist/brainstorm-run.js.map +1 -1
- package/package.json +11 -10
- package/pi/extensions/codex-pair.ts +2 -1
- package/pi/extensions/provider-tools.ts +1 -1
- package/scripts/codex-pair-debounce-worker.mjs +60 -88
- package/scripts/codex-pair-prompt-drain.mjs +50 -64
- package/scripts/codex-pair-session.mjs +129 -168
- package/scripts/codex-pair-stop-gate.mjs +183 -233
- package/scripts/codex-pair-watch.mjs +1018 -1371
- package/scripts/lib/broker-lifecycle.mjs +677 -0
- package/scripts/lib/broker-rpc.mjs +173 -0
- package/scripts/lib/broker-transport.mjs +327 -0
- package/scripts/lib/broker.mjs +327 -0
- package/scripts/lib/debounce-state.mjs +206 -0
- package/scripts/lib/frontmatter.mjs +57 -0
- package/scripts/lib/parser.mjs +229 -0
- package/scripts/lib/process.mjs +56 -0
- package/scripts/lib/prompt.mjs +32 -0
- package/scripts/lib/session-registry.mjs +161 -0
- package/scripts/lib/state.mjs +720 -0
- package/scripts/lib/stop-gate.mjs +134 -0
- package/scripts/sol-review-transport.mjs +1 -1
- package/skills/brainstorm/SKILL.md +9 -9
- package/skills/codex-image/SKILL.md +2 -2
- package/skills/codex-pair/SKILL.md +5 -4
- package/skills/codex-review/SKILL.md +1 -1
- package/skills/grok-pair/SKILL.md +3 -3
- package/skills/sol-review/SKILL.md +5 -5
|
@@ -1,103 +1,75 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
// Spawned by codex-pair-watch.mjs on each edit when debounceMs > 0. Sleeps the
|
|
5
|
-
// settle window, then — only if no newer edit superseded it (trailing-edge) or
|
|
6
|
-
// the burst exceeded the max cap — re-invokes the hook in FORCED-SYNC mode to
|
|
7
|
-
// run the real review. The forced-sync hook acquires the existing per-file
|
|
8
|
-
// inflight lock itself, so concurrent workers race there and exactly one
|
|
9
|
-
// reviews (the inflight lock IS the claim — the worker holds no lock, which
|
|
10
|
-
// would otherwise deadlock against the hook).
|
|
11
|
-
//
|
|
12
|
-
// The worker has no stdout channel to Claude, so it captures the hook's emitted
|
|
13
|
-
// systemMessage and queues it in the per-file pending store; the next edit hook
|
|
14
|
-
// (or the UserPromptSubmit drain) surfaces it. MUST exit 0 on every path (ADR-077).
|
|
15
|
-
|
|
2
|
+
// Source of truth: codex-pair-debounce-worker.mts. The sibling .mjs is generated by `yarn workspace @ask-llm/plugin build:hooks`; never edit it.
|
|
3
|
+
// Workers defer edits to the forced-sync hook, which owns the per-file lock; pending verdicts surface on a later hook.
|
|
16
4
|
import { spawnSync } from "node:child_process";
|
|
17
5
|
import { dirname, join } from "node:path";
|
|
18
6
|
import { fileURLToPath } from "node:url";
|
|
19
|
-
import {
|
|
20
|
-
clearReviewing,
|
|
21
|
-
decideReview,
|
|
22
|
-
markReviewed,
|
|
23
|
-
markReviewing,
|
|
24
|
-
readEditRecord,
|
|
25
|
-
writePending,
|
|
26
|
-
} from "./lib/debounce-state.mjs";
|
|
27
|
-
|
|
7
|
+
import { clearReviewing, decideReview, markReviewed, markReviewing, readEditRecord, writePending, } from "./lib/debounce-state.mjs";
|
|
28
8
|
const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url));
|
|
29
9
|
const HOOK_PATH = join(SCRIPT_DIR, "codex-pair-watch.mjs");
|
|
30
|
-
|
|
31
10
|
function sleep(ms) {
|
|
32
|
-
|
|
11
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
33
12
|
}
|
|
34
|
-
|
|
35
|
-
// The forced-sync hook writes one `{ "continue": true, "systemMessage": "..." }`
|
|
36
|
-
// JSON line to stdout. Pull systemMessage from the last parseable line.
|
|
13
|
+
// Read the last parseable hook output line; earlier stdout may not be JSON.
|
|
37
14
|
function extractSystemMessage(stdout) {
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
15
|
+
if (!stdout)
|
|
16
|
+
return null;
|
|
17
|
+
const lines = stdout.split("\n").filter((l) => l.trim().length > 0);
|
|
18
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
19
|
+
try {
|
|
20
|
+
const obj = JSON.parse(lines[i]);
|
|
21
|
+
if (typeof obj.systemMessage === "string")
|
|
22
|
+
return obj.systemMessage;
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
// not JSON — skip
|
|
26
|
+
}
|
|
46
27
|
}
|
|
47
|
-
|
|
48
|
-
return null;
|
|
28
|
+
return null;
|
|
49
29
|
}
|
|
50
|
-
|
|
51
30
|
async function main() {
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
// concurrency claim is the inflight lock acquired by the forced-sync hook.
|
|
77
|
-
markReviewed(markerDir, file, myGeneration);
|
|
78
|
-
|
|
79
|
-
const payload = JSON.stringify({
|
|
80
|
-
hook_event_name: "PostToolUse",
|
|
81
|
-
tool_name: tool,
|
|
82
|
-
tool_input: { file_path: file },
|
|
83
|
-
session_id: process.env.CP_SESSION_ID || "",
|
|
84
|
-
});
|
|
85
|
-
const codexTimeout = Number(process.env.ASK_CODEX_TIMEOUT_MS ?? 800_000);
|
|
86
|
-
let res;
|
|
87
|
-
try {
|
|
88
|
-
res = spawnSync(process.execPath, [HOOK_PATH], {
|
|
89
|
-
input: payload,
|
|
90
|
-
cwd: markerDir,
|
|
91
|
-
encoding: "utf-8",
|
|
92
|
-
env: { ...process.env, CODEX_PAIR_FORCE_SYNC: "1" },
|
|
93
|
-
timeout: codexTimeout + 60_000,
|
|
31
|
+
const markerDir = process.env.CP_MARKER_DIR;
|
|
32
|
+
const file = process.env.CP_FILE;
|
|
33
|
+
const tool = process.env.CP_TOOL || "Edit";
|
|
34
|
+
const myGeneration = Number(process.env.CP_GENERATION);
|
|
35
|
+
const settleMs = Number(process.env.CP_SETTLE_MS);
|
|
36
|
+
const rawMaxMs = Number(process.env.CP_MAX_MS);
|
|
37
|
+
// NaN would disable the anti-starvation cap because comparisons always fail.
|
|
38
|
+
const maxMs = Number.isFinite(rawMaxMs) && rawMaxMs > 0 ? rawMaxMs : 60_000;
|
|
39
|
+
if (!markerDir || !file || !Number.isFinite(myGeneration))
|
|
40
|
+
process.exit(0);
|
|
41
|
+
await sleep(Number.isFinite(settleMs) ? settleMs : 15_000);
|
|
42
|
+
const record = readEditRecord(markerDir, file);
|
|
43
|
+
const decision = decideReview({ record, myGeneration, now: Date.now(), maxMs });
|
|
44
|
+
if (!decision.review)
|
|
45
|
+
process.exit(0);
|
|
46
|
+
// Mark reviewing before consuming the debounce record so the Stop-gate always sees in-flight work.
|
|
47
|
+
markReviewing(markerDir, file);
|
|
48
|
+
// The forced-sync hook owns the concurrency lock, not this worker.
|
|
49
|
+
markReviewed(markerDir, file, myGeneration);
|
|
50
|
+
const payload = JSON.stringify({
|
|
51
|
+
hook_event_name: "PostToolUse",
|
|
52
|
+
tool_name: tool,
|
|
53
|
+
tool_input: { file_path: file },
|
|
54
|
+
session_id: process.env.CP_SESSION_ID || "",
|
|
94
55
|
});
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
56
|
+
const codexTimeout = Number(process.env.ASK_CODEX_TIMEOUT_MS ?? 800_000);
|
|
57
|
+
let res;
|
|
58
|
+
try {
|
|
59
|
+
res = spawnSync(process.execPath, [HOOK_PATH], {
|
|
60
|
+
input: payload,
|
|
61
|
+
cwd: markerDir,
|
|
62
|
+
encoding: "utf-8",
|
|
63
|
+
env: { ...process.env, CODEX_PAIR_FORCE_SYNC: "1" },
|
|
64
|
+
timeout: codexTimeout + 60_000,
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
finally {
|
|
68
|
+
clearReviewing(markerDir, file);
|
|
69
|
+
}
|
|
70
|
+
const message = extractSystemMessage(res?.stdout);
|
|
71
|
+
if (message)
|
|
72
|
+
writePending(markerDir, file, message);
|
|
73
|
+
process.exit(0);
|
|
101
74
|
}
|
|
102
|
-
|
|
103
75
|
main().catch(() => process.exit(0));
|
|
@@ -1,81 +1,67 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
// Surfaces any verdict a debounce worker queued, at the START of the next user
|
|
5
|
-
// turn — closing the gap where a single edit (with no following edit) leaves
|
|
6
|
-
// its review in the log but never in Claude's context. Cheap no-op when nothing
|
|
7
|
-
// is pending. MUST exit 0 on every path (ADR-077).
|
|
8
|
-
//
|
|
9
|
-
// findMarkerUp is duplicated from codex-pair-watch/session.mjs by design:
|
|
10
|
-
// zero-workspace-imports (marketplace git-subdir install has no node_modules)
|
|
11
|
-
// and the helper is too small to extract (15 LOC).
|
|
12
|
-
|
|
2
|
+
// Source of truth: codex-pair-prompt-drain.mts. The sibling .mjs is generated by `yarn workspace @ask-llm/plugin build:hooks`; never edit it.
|
|
3
|
+
// Drain queued worker verdicts at the next user turn; failures must not block submission.
|
|
13
4
|
import { access } from "node:fs/promises";
|
|
14
5
|
import { homedir } from "node:os";
|
|
15
6
|
import { dirname, join, resolve } from "node:path";
|
|
16
7
|
import { drainPending, joinPendingForSurface } from "./lib/debounce-state.mjs";
|
|
17
8
|
import { collectSessionMarkers } from "./lib/session-registry.mjs";
|
|
18
9
|
import { CONTEXT_FILENAME, PAIR_ROOT_DIR } from "./lib/state.mjs";
|
|
19
|
-
|
|
20
10
|
const MARKER_FILE = join(PAIR_ROOT_DIR, CONTEXT_FILENAME);
|
|
21
|
-
|
|
22
11
|
async function findMarkerUp(startDir) {
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
12
|
+
const home = homedir();
|
|
13
|
+
let current = resolve(startDir);
|
|
14
|
+
for (let depth = 0; depth < 20; depth++) {
|
|
15
|
+
try {
|
|
16
|
+
await access(join(current, MARKER_FILE));
|
|
17
|
+
return current;
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
// not here
|
|
21
|
+
}
|
|
22
|
+
const parent = dirname(current);
|
|
23
|
+
if (parent === current || current === home)
|
|
24
|
+
return null;
|
|
25
|
+
current = parent;
|
|
31
26
|
}
|
|
32
|
-
|
|
33
|
-
if (parent === current || current === home) return null;
|
|
34
|
-
current = parent;
|
|
35
|
-
}
|
|
36
|
-
return null;
|
|
27
|
+
return null;
|
|
37
28
|
}
|
|
38
|
-
|
|
39
29
|
async function readStdin() {
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
30
|
+
return new Promise((r) => {
|
|
31
|
+
let data = "";
|
|
32
|
+
process.stdin.on("data", (c) => {
|
|
33
|
+
data += c.toString();
|
|
34
|
+
});
|
|
35
|
+
process.stdin.on("end", () => r(data));
|
|
36
|
+
process.stdin.on("error", () => r(""));
|
|
44
37
|
});
|
|
45
|
-
process.stdin.on("end", () => r(data));
|
|
46
|
-
process.stdin.on("error", () => r(""));
|
|
47
|
-
});
|
|
48
38
|
}
|
|
49
|
-
|
|
50
39
|
async function main() {
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
}
|
|
77
|
-
});
|
|
78
|
-
process.stdout.write(`${out}\n`, () => process.exit(0));
|
|
40
|
+
const raw = await readStdin();
|
|
41
|
+
let payload;
|
|
42
|
+
try {
|
|
43
|
+
payload = JSON.parse(raw);
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
process.exit(0);
|
|
47
|
+
}
|
|
48
|
+
if (payload?.hook_event_name !== "UserPromptSubmit")
|
|
49
|
+
process.exit(0);
|
|
50
|
+
// Include registered sibling repositories, not only cwd.
|
|
51
|
+
const cwdMarker = await findMarkerUp(process.cwd());
|
|
52
|
+
const markers = collectSessionMarkers(cwdMarker, payload?.session_id);
|
|
53
|
+
if (markers.length === 0)
|
|
54
|
+
process.exit(0);
|
|
55
|
+
const messages = markers.flatMap((m) => drainPending(m));
|
|
56
|
+
if (messages.length === 0)
|
|
57
|
+
process.exit(0);
|
|
58
|
+
// additionalContext reaches the model on the next user turn.
|
|
59
|
+
const out = JSON.stringify({
|
|
60
|
+
hookSpecificOutput: {
|
|
61
|
+
hookEventName: "UserPromptSubmit",
|
|
62
|
+
additionalContext: joinPendingForSurface(messages),
|
|
63
|
+
},
|
|
64
|
+
});
|
|
65
|
+
process.stdout.write(`${out}\n`, () => process.exit(0));
|
|
79
66
|
}
|
|
80
|
-
|
|
81
67
|
main().catch(() => process.exit(0));
|
|
@@ -1,194 +1,155 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
// the broker + handshake + descriptor write (Milestone 2 PR 2);
|
|
5
|
-
// SessionEnd teardown remains TODO (Milestone 2 PR 3).
|
|
6
|
-
//
|
|
7
|
-
// The hook MUST exit 0 on every path. A broker spawn failure is logged
|
|
8
|
-
// silently to broker.log but doesn't break the session — the per-edit
|
|
9
|
-
// path keeps working via per-edit codex spawns (ADR-077).
|
|
10
|
-
|
|
2
|
+
// Source of truth: codex-pair-session.mts. The sibling .mjs is generated by `yarn workspace @ask-llm/plugin build:hooks`; never edit it.
|
|
3
|
+
// Session lifecycle hook. Broker failures must not break per-edit reviews.
|
|
11
4
|
import { access } from "node:fs/promises";
|
|
12
5
|
import { homedir } from "node:os";
|
|
13
6
|
import { dirname, join, resolve } from "node:path";
|
|
14
|
-
import {
|
|
7
|
+
import { resolveBrokerPreference } from "./lib/broker.mjs";
|
|
8
|
+
import { bootstrapBroker, teardownBroker } from "./lib/broker-lifecycle.mjs";
|
|
15
9
|
import { clearAllDebounceState } from "./lib/debounce-state.mjs";
|
|
16
10
|
import { clearSession } from "./lib/session-registry.mjs";
|
|
17
|
-
import {
|
|
18
|
-
appendLog,
|
|
19
|
-
CONTEXT_FILENAME,
|
|
20
|
-
clearAutoPause,
|
|
21
|
-
PAIR_ROOT_DIR,
|
|
22
|
-
readPauseInfo,
|
|
23
|
-
readPluginVersion,
|
|
24
|
-
resolveAutoResume,
|
|
25
|
-
} from "./lib/state.mjs";
|
|
26
|
-
|
|
11
|
+
import { appendLog, CONTEXT_FILENAME, clearAutoPause, PAIR_ROOT_DIR, readPauseInfo, readPluginVersion, resolveAutoResume, } from "./lib/state.mjs";
|
|
27
12
|
const MARKER_FILE = join(PAIR_ROOT_DIR, CONTEXT_FILENAME);
|
|
28
|
-
|
|
29
|
-
// Walk up from startDir looking for `.codex-pair/context.md`. Returns
|
|
30
|
-
// the marker directory (the directory CONTAINING `.codex-pair/`) or
|
|
31
|
-
// null. Mirrors codex-pair-watch.mjs and codex-pair-log.mjs — duplicated
|
|
32
|
-
// because zero-workspace-imports + the helper is too small to extract
|
|
33
|
-
// (15 LOC × 3 callers).
|
|
13
|
+
// Keep marker discovery local so marketplace installs need no workspace dependencies.
|
|
34
14
|
async function findMarkerUp(startDir) {
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
15
|
+
const home = homedir();
|
|
16
|
+
let current = resolve(startDir);
|
|
17
|
+
for (let depth = 0; depth < 20; depth++) {
|
|
18
|
+
const candidate = join(current, MARKER_FILE);
|
|
19
|
+
try {
|
|
20
|
+
await access(candidate);
|
|
21
|
+
return current;
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
// not found here
|
|
25
|
+
}
|
|
26
|
+
const parent = dirname(current);
|
|
27
|
+
if (parent === current)
|
|
28
|
+
return null;
|
|
29
|
+
if (current === home)
|
|
30
|
+
return null;
|
|
31
|
+
current = parent;
|
|
44
32
|
}
|
|
45
|
-
|
|
46
|
-
if (parent === current) return null;
|
|
47
|
-
if (current === home) return null;
|
|
48
|
-
current = parent;
|
|
49
|
-
}
|
|
50
|
-
return null;
|
|
33
|
+
return null;
|
|
51
34
|
}
|
|
52
|
-
|
|
53
35
|
async function readStdin() {
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
36
|
+
return new Promise((resolve) => {
|
|
37
|
+
let data = "";
|
|
38
|
+
process.stdin.on("data", (c) => {
|
|
39
|
+
data += c.toString();
|
|
40
|
+
});
|
|
41
|
+
process.stdin.on("end", () => resolve(data));
|
|
42
|
+
process.stdin.on("error", () => resolve(""));
|
|
58
43
|
});
|
|
59
|
-
process.stdin.on("end", () => resolve(data));
|
|
60
|
-
process.stdin.on("error", () => resolve(""));
|
|
61
|
-
});
|
|
62
44
|
}
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
// "stale" both result in a clean slate; bootstrapBroker handles
|
|
72
|
-
// the spawn + handshake from there.
|
|
73
|
-
const state = clearStaleBrokerState(markerDir);
|
|
74
|
-
if (state === "live") return;
|
|
75
|
-
await bootstrapBroker(markerDir);
|
|
45
|
+
async function handleSessionStart(sessionId) {
|
|
46
|
+
const cwd = process.cwd();
|
|
47
|
+
const markerDir = await findMarkerUp(cwd);
|
|
48
|
+
if (!markerDir)
|
|
49
|
+
return; // no opt-in marker, nothing to do
|
|
50
|
+
if (typeof sessionId !== "string" || !sessionId)
|
|
51
|
+
return;
|
|
52
|
+
await bootstrapBroker(markerDir, { sessionId });
|
|
76
53
|
}
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
// unlinks the descriptor + socket + lock. Returns the descriptor
|
|
85
|
-
// that was torn down (or null if none existed) — we ignore it; the
|
|
86
|
-
// hook just needs to exit 0 either way per ADR-077.
|
|
87
|
-
await teardownBroker(markerDir);
|
|
54
|
+
async function handleSessionEnd(sessionId) {
|
|
55
|
+
const cwd = process.cwd();
|
|
56
|
+
const markerDir = await findMarkerUp(cwd);
|
|
57
|
+
if (!markerDir)
|
|
58
|
+
return;
|
|
59
|
+
// SessionEnd teardown is best-effort; broker failure must not block the session.
|
|
60
|
+
await teardownBroker(markerDir, { sessionId: typeof sessionId === "string" ? sessionId : undefined });
|
|
88
61
|
}
|
|
89
|
-
|
|
90
62
|
async function main() {
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
try {
|
|
94
|
-
payload = JSON.parse(raw);
|
|
95
|
-
} catch {
|
|
96
|
-
process.exit(0);
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
const event = payload?.hook_event_name;
|
|
100
|
-
if (event !== "SessionStart" && event !== "SessionEnd") {
|
|
101
|
-
process.exit(0);
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
// SessionStart pause visibility (2026-07-02 seamless-pairing design; un-gated
|
|
105
|
-
// by the broker flag). An auto-pause used to be notify-ONCE and manual-resume-
|
|
106
|
-
// only — miss that single message and pairing is silently dead forever (the
|
|
107
|
-
// dogfood repo spent 18 days that way). Now: an expired auto-pause self-heals
|
|
108
|
-
// right here; a still-active pause gets a reminder the model actually sees
|
|
109
|
-
// (SessionStart supports additionalContext; it does NOT support systemMessage).
|
|
110
|
-
if (event === "SessionStart") {
|
|
63
|
+
const raw = await readStdin();
|
|
64
|
+
let payload;
|
|
111
65
|
try {
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
66
|
+
payload = JSON.parse(raw);
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
process.exit(0);
|
|
70
|
+
}
|
|
71
|
+
const event = payload?.hook_event_name;
|
|
72
|
+
if (event !== "SessionStart" && event !== "SessionEnd") {
|
|
73
|
+
process.exit(0);
|
|
74
|
+
}
|
|
75
|
+
// SessionStart reminds the model of active pauses via additionalContext and resumes expired ones.
|
|
76
|
+
if (event === "SessionStart") {
|
|
77
|
+
try {
|
|
78
|
+
const markerDir = await findMarkerUp(process.cwd());
|
|
79
|
+
const pauseInfo = markerDir ? readPauseInfo(markerDir) : null;
|
|
80
|
+
if (markerDir && pauseInfo) {
|
|
81
|
+
const decision = resolveAutoResume(pauseInfo, {
|
|
82
|
+
now: Date.now(),
|
|
83
|
+
currentVersion: readPluginVersion(),
|
|
84
|
+
});
|
|
85
|
+
let context = null;
|
|
86
|
+
// A changed pause sentinel requires rereading current state before notifying.
|
|
87
|
+
if (decision.resume && clearAutoPause(markerDir, pauseInfo)) {
|
|
88
|
+
await appendLog(markerDir, {
|
|
89
|
+
timestamp: new Date().toISOString(),
|
|
90
|
+
verdict: "auto_resumed",
|
|
91
|
+
reason: `${decision.why} (paused ${pauseInfo.at ?? "unknown"}, kind: ${pauseInfo.kind})`,
|
|
92
|
+
});
|
|
93
|
+
context = `codex-pair auto-resumed (${decision.why}): was ${pauseInfo.kind}-paused since ${pauseInfo.at ?? "unknown"}. Reviews are live again.`;
|
|
94
|
+
}
|
|
95
|
+
else {
|
|
96
|
+
const current = decision.resume ? readPauseInfo(markerDir) : pauseInfo;
|
|
97
|
+
if (current) {
|
|
98
|
+
const since = current.manual ? "" : ` since ${current.at}`;
|
|
99
|
+
const kind = current.manual ? "manually" : `auto (${current.kind})`;
|
|
100
|
+
const reason = current.manual ? "" : ` Reason: ${current.reason}.`;
|
|
101
|
+
context = `codex-pair is paused — ${kind}${since}.${reason} Edits are NOT being reviewed. Resume with /codex-pair-resume.`;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
if (context) {
|
|
105
|
+
await new Promise((resolveWrite) => {
|
|
106
|
+
const out = JSON.stringify({
|
|
107
|
+
hookSpecificOutput: { hookEventName: "SessionStart", additionalContext: context },
|
|
108
|
+
});
|
|
109
|
+
process.stdout.write(`${out}\n`, () => resolveWrite());
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
}
|
|
139
113
|
}
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
const out = JSON.stringify({
|
|
143
|
-
hookSpecificOutput: { hookEventName: "SessionStart", additionalContext: context },
|
|
144
|
-
});
|
|
145
|
-
process.stdout.write(`${out}\n`, () => resolveWrite());
|
|
146
|
-
});
|
|
114
|
+
catch {
|
|
115
|
+
// best-effort (ADR-077) — pause visibility must never break the session
|
|
147
116
|
}
|
|
148
|
-
}
|
|
149
|
-
} catch {
|
|
150
|
-
// best-effort (ADR-077) — pause visibility must never break the session
|
|
151
117
|
}
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
118
|
+
// Clear debounce state only on SessionEnd; SessionStart could erase a queued verdict.
|
|
119
|
+
if (event === "SessionEnd") {
|
|
120
|
+
const dbMarkerDir = await findMarkerUp(process.cwd());
|
|
121
|
+
if (dbMarkerDir) {
|
|
122
|
+
try {
|
|
123
|
+
clearAllDebounceState(dbMarkerDir);
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
// best-effort (ADR-077)
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
// Clear the registry by session ID so cwd does not affect cleanup.
|
|
130
|
+
try {
|
|
131
|
+
clearSession(payload?.session_id);
|
|
132
|
+
}
|
|
133
|
+
catch {
|
|
134
|
+
// best-effort (ADR-077)
|
|
135
|
+
}
|
|
167
136
|
}
|
|
168
|
-
|
|
169
|
-
|
|
137
|
+
const brokerMarkerDir = await findMarkerUp(process.cwd());
|
|
138
|
+
if (!brokerMarkerDir)
|
|
139
|
+
process.exit(0);
|
|
170
140
|
try {
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
141
|
+
if (event === "SessionStart") {
|
|
142
|
+
if (resolveBrokerPreference(brokerMarkerDir))
|
|
143
|
+
await handleSessionStart(payload?.session_id);
|
|
144
|
+
else
|
|
145
|
+
await teardownBroker(brokerMarkerDir, { onlyIfCredentialMissing: true });
|
|
146
|
+
}
|
|
147
|
+
else if (event === "SessionEnd")
|
|
148
|
+
await handleSessionEnd(payload?.session_id);
|
|
149
|
+
}
|
|
150
|
+
catch {
|
|
151
|
+
// Bootstrap failure must not break the session.
|
|
174
152
|
}
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
// Broker is disabled until ASK_CODEX_BROKER=1. Production behavior
|
|
178
|
-
// unchanged: SessionStart/SessionEnd are silent no-ops.
|
|
179
|
-
if (process.env.ASK_CODEX_BROKER !== "1") {
|
|
180
153
|
process.exit(0);
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
try {
|
|
184
|
-
if (event === "SessionStart") await handleSessionStart();
|
|
185
|
-
else if (event === "SessionEnd") await handleSessionEnd();
|
|
186
|
-
} catch {
|
|
187
|
-
// ADR-077 silent-on-error: a failed bootstrap MUST NOT break the
|
|
188
|
-
// session. bootstrapBroker already catches internally, but defense
|
|
189
|
-
// in depth.
|
|
190
|
-
}
|
|
191
|
-
process.exit(0);
|
|
192
154
|
}
|
|
193
|
-
|
|
194
155
|
main().catch(() => process.exit(0));
|