@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.
Files changed (41) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/.cursor-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +36 -0
  4. package/README.md +8 -8
  5. package/agents/brainstorm-coordinator.md +17 -16
  6. package/agents/codex-reviewer.md +1 -1
  7. package/agents/sol-reviewer.md +5 -5
  8. package/codex-pair-defaults.json +1 -1
  9. package/dist/brainstorm-panel.d.ts +1 -1
  10. package/dist/brainstorm-panel.d.ts.map +1 -1
  11. package/dist/brainstorm-panel.js +8 -8
  12. package/dist/brainstorm-panel.js.map +1 -1
  13. package/dist/brainstorm-run.js +1 -1
  14. package/dist/brainstorm-run.js.map +1 -1
  15. package/package.json +11 -10
  16. package/pi/extensions/codex-pair.ts +2 -1
  17. package/pi/extensions/provider-tools.ts +1 -1
  18. package/scripts/codex-pair-debounce-worker.mjs +60 -88
  19. package/scripts/codex-pair-prompt-drain.mjs +50 -64
  20. package/scripts/codex-pair-session.mjs +129 -168
  21. package/scripts/codex-pair-stop-gate.mjs +183 -233
  22. package/scripts/codex-pair-watch.mjs +1018 -1371
  23. package/scripts/lib/broker-lifecycle.mjs +677 -0
  24. package/scripts/lib/broker-rpc.mjs +173 -0
  25. package/scripts/lib/broker-transport.mjs +327 -0
  26. package/scripts/lib/broker.mjs +327 -0
  27. package/scripts/lib/debounce-state.mjs +206 -0
  28. package/scripts/lib/frontmatter.mjs +57 -0
  29. package/scripts/lib/parser.mjs +229 -0
  30. package/scripts/lib/process.mjs +56 -0
  31. package/scripts/lib/prompt.mjs +32 -0
  32. package/scripts/lib/session-registry.mjs +161 -0
  33. package/scripts/lib/state.mjs +720 -0
  34. package/scripts/lib/stop-gate.mjs +134 -0
  35. package/scripts/sol-review-transport.mjs +1 -1
  36. package/skills/brainstorm/SKILL.md +9 -9
  37. package/skills/codex-image/SKILL.md +2 -2
  38. package/skills/codex-pair/SKILL.md +5 -4
  39. package/skills/codex-review/SKILL.md +1 -1
  40. package/skills/grok-pair/SKILL.md +3 -3
  41. package/skills/sol-review/SKILL.md +5 -5
@@ -1,103 +1,75 @@
1
1
  #!/usr/bin/env node
2
- // Detached edit-debounce worker (design 2026-06-03, closes #96 Bug 2 / Idea 1).
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
- return new Promise((r) => setTimeout(r, ms));
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
- if (!stdout) return null;
39
- const lines = stdout.split("\n").filter((l) => l.trim().length > 0);
40
- for (let i = lines.length - 1; i >= 0; i--) {
41
- try {
42
- const obj = JSON.parse(lines[i]);
43
- if (typeof obj.systemMessage === "string") return obj.systemMessage;
44
- } catch {
45
- // not JSON — skip
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
- const markerDir = process.env.CP_MARKER_DIR;
53
- const file = process.env.CP_FILE;
54
- const tool = process.env.CP_TOOL || "Edit";
55
- const myGeneration = Number(process.env.CP_GENERATION);
56
- const settleMs = Number(process.env.CP_SETTLE_MS);
57
- const rawMaxMs = Number(process.env.CP_MAX_MS);
58
- // Guard maxMs like settleMs: a missing/NaN CP_MAX_MS must not silently
59
- // disable the anti-starvation cap (every `>= NaN` comparison is false).
60
- const maxMs = Number.isFinite(rawMaxMs) && rawMaxMs > 0 ? rawMaxMs : 60_000;
61
- if (!markerDir || !file || !Number.isFinite(myGeneration)) process.exit(0);
62
-
63
- await sleep(Number.isFinite(settleMs) ? settleMs : 15_000);
64
-
65
- const record = readEditRecord(markerDir, file);
66
- const decision = decideReview({ record, myGeneration, now: Date.now(), maxMs });
67
- if (!decision.review) process.exit(0);
68
-
69
- // Hold a `reviewing` marker across the whole handoff: markReviewed consumes
70
- // the debounce record BEFORE the forced-sync hook acquires the inflight
71
- // lock, and the Stop-gate's in-flight check would otherwise see neither
72
- // signal in that gap (dogfood review finding, 2026-07-02). Marker first,
73
- // then record advance — no instant where both are absent.
74
- markReviewing(markerDir, file);
75
- // Advance the burst marker so the next edit starts a fresh burst. The actual
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
- } finally {
96
- clearReviewing(markerDir, file);
97
- }
98
- const message = extractSystemMessage(res.stdout);
99
- if (message) writePending(markerDir, file, message);
100
- process.exit(0);
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
- // UserPromptSubmit drain hook (design 2026-06-03 + plan red-team 2026-06-04).
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
- const home = homedir();
24
- let current = resolve(startDir);
25
- for (let depth = 0; depth < 20; depth++) {
26
- try {
27
- await access(join(current, MARKER_FILE));
28
- return current;
29
- } catch {
30
- // not here
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
- const parent = dirname(current);
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
- return new Promise((r) => {
41
- let data = "";
42
- process.stdin.on("data", (c) => {
43
- data += c.toString();
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
- const raw = await readStdin();
52
- let payload;
53
- try {
54
- payload = JSON.parse(raw);
55
- } catch {
56
- process.exit(0);
57
- }
58
- if (payload?.hook_event_name !== "UserPromptSubmit") process.exit(0);
59
-
60
- // ADR-131 (#209): drain EVERY repo active this session, not just cwd's. The
61
- // watch hook registers each edited repo under session_id; union it with the
62
- // cwd marker so single-repo behavior is unchanged when no session_id is present.
63
- const cwdMarker = await findMarkerUp(process.cwd());
64
- const markers = collectSessionMarkers(cwdMarker, payload?.session_id);
65
- if (markers.length === 0) process.exit(0);
66
-
67
- const messages = markers.flatMap((m) => drainPending(m));
68
- if (messages.length === 0) process.exit(0);
69
-
70
- // UserPromptSubmit context-injection contract: additionalContext is added to
71
- // the model's context for the upcoming turn.
72
- const out = JSON.stringify({
73
- hookSpecificOutput: {
74
- hookEventName: "UserPromptSubmit",
75
- additionalContext: joinPendingForSurface(messages),
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
- // SessionStart / SessionEnd hook for the codex-pair app-server broker
3
- // (ADR-090, milestones implemented per ADR-093). SessionStart spawns
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 { bootstrapBroker, clearStaleBrokerState, teardownBroker } from "./lib/broker-lifecycle.mjs";
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
- const home = homedir();
36
- let current = resolve(startDir);
37
- for (let depth = 0; depth < 20; depth++) {
38
- const candidate = join(current, MARKER_FILE);
39
- try {
40
- await access(candidate);
41
- return current;
42
- } catch {
43
- // not found here
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
- const parent = dirname(current);
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
- return new Promise((resolve) => {
55
- let data = "";
56
- process.stdin.on("data", (c) => {
57
- data += c.toString();
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
- async function handleSessionStart() {
65
- const cwd = process.cwd();
66
- const markerDir = await findMarkerUp(cwd);
67
- if (!markerDir) return; // no opt-in marker, nothing to do
68
- // Recover from a prior-session crash before launching fresh.
69
- // clearStaleBrokerState returns "live" if a still-usable broker
70
- // exists — in that case we skip spawning a new one. "absent" or
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
- async function handleSessionEnd() {
79
- const cwd = process.cwd();
80
- const markerDir = await findMarkerUp(cwd);
81
- if (!markerDir) return;
82
- // teardownBroker reads the descriptor, SIGTERMs the pid with a grace
83
- // window, escalates to SIGKILL via terminateProcessTree if needed,
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
- const raw = await readStdin();
92
- let payload;
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
- const markerDir = await findMarkerUp(process.cwd());
113
- const pauseInfo = markerDir ? readPauseInfo(markerDir) : null;
114
- if (pauseInfo) {
115
- const decision = resolveAutoResume(pauseInfo, {
116
- now: Date.now(),
117
- currentVersion: readPluginVersion(),
118
- });
119
- let context = null;
120
- // clearAutoPause aborts (false) when the sentinel changed since we
121
- // read it — either a concurrent SessionStart already resumed (sentinel
122
- // gone → say nothing; reviews are live) or a new pause raced in
123
- // (render the reminder from CURRENT state, not the stale pauseInfo).
124
- if (decision.resume && clearAutoPause(markerDir, pauseInfo)) {
125
- await appendLog(markerDir, {
126
- timestamp: new Date().toISOString(),
127
- verdict: "auto_resumed",
128
- reason: `${decision.why} (paused ${pauseInfo.at ?? "unknown"}, kind: ${pauseInfo.kind})`,
129
- });
130
- context = `codex-pair auto-resumed (${decision.why}): was ${pauseInfo.kind}-paused since ${pauseInfo.at ?? "unknown"}. Reviews are live again.`;
131
- } else {
132
- const current = decision.resume ? readPauseInfo(markerDir) : pauseInfo;
133
- if (current) {
134
- const since = current.manual ? "" : ` since ${current.at}`;
135
- const kind = current.manual ? "manually" : `auto (${current.kind})`;
136
- const reason = current.manual ? "" : ` Reason: ${current.reason}.`;
137
- context = `codex-pair is paused — ${kind}${since}.${reason} Edits are NOT being reviewed. Resume with /codex-pair-resume.`;
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
- if (context) {
141
- await new Promise((resolveWrite) => {
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
- // Edit-debounce cleanup runs on SessionEnd only (un-gated by the broker flag,
155
- // since debounce is not broker-gated): a sleeping worker wakes to a missing
156
- // record and self-cancels. NOT on SessionStart — that would wipe a verdict
157
- // queued just before a new session begins; crash-orphaned state is reclaimed
158
- // by the TTL sweep (sweepStaleDebounce) instead.
159
- if (event === "SessionEnd") {
160
- const dbMarkerDir = await findMarkerUp(process.cwd());
161
- if (dbMarkerDir) {
162
- try {
163
- clearAllDebounceState(dbMarkerDir);
164
- } catch {
165
- // best-effort (ADR-077)
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
- // ADR-131 (#209): drop this session's cross-repo marker registry. Keyed by
169
- // session_id, not cwd — so it cleans up regardless of which repo cwd is.
137
+ const brokerMarkerDir = await findMarkerUp(process.cwd());
138
+ if (!brokerMarkerDir)
139
+ process.exit(0);
170
140
  try {
171
- clearSession(payload?.session_id);
172
- } catch {
173
- // best-effort (ADR-077)
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));