@ask-llm/plugin 0.13.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 +20 -0
- package/.mcp.json +3 -0
- package/LICENSE +21 -0
- package/README.md +135 -0
- package/agents/antigravity-reviewer.md +139 -0
- package/agents/brainstorm-coordinator.md +305 -0
- package/agents/codex-reviewer.md +194 -0
- package/agents/codex-verifier.md +149 -0
- package/agents/fable-reviewer.md +44 -0
- package/agents/gemini-reviewer.md +130 -0
- package/agents/ollama-reviewer.md +131 -0
- package/agents/sol-reviewer.md +60 -0
- package/codex-pair-defaults.json +4 -0
- package/dist/antigravity-run.d.ts +3 -0
- package/dist/antigravity-run.d.ts.map +1 -0
- package/dist/antigravity-run.js +32 -0
- package/dist/antigravity-run.js.map +1 -0
- package/dist/codex-run.d.ts +3 -0
- package/dist/codex-run.d.ts.map +1 -0
- package/dist/codex-run.js +32 -0
- package/dist/codex-run.js.map +1 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +39 -0
- package/dist/index.js.map +1 -0
- package/dist/ollama-run.d.ts +3 -0
- package/dist/ollama-run.d.ts.map +1 -0
- package/dist/ollama-run.js +32 -0
- package/dist/ollama-run.js.map +1 -0
- package/dist/run.d.ts +3 -0
- package/dist/run.d.ts.map +1 -0
- package/dist/run.js +32 -0
- package/dist/run.js.map +1 -0
- package/hooks/hooks.json +55 -0
- package/package.json +104 -0
- package/pi/extensions/codex-pair.ts +870 -0
- package/pi/extensions/index.ts +13 -0
- package/pi/extensions/provider-tools.ts +241 -0
- package/pi/tsconfig.json +10 -0
- package/prompts/review.txt +75 -0
- package/scripts/codex-pair-debounce-worker.mjs +103 -0
- package/scripts/codex-pair-log.mjs +271 -0
- package/scripts/codex-pair-prompt-drain.mjs +81 -0
- package/scripts/codex-pair-session.mjs +194 -0
- package/scripts/codex-pair-stop-gate.mjs +271 -0
- package/scripts/codex-pair-watch.mjs +1525 -0
- package/scripts/lib/broker-lifecycle.mjs +575 -0
- package/scripts/lib/broker-rpc.mjs +203 -0
- package/scripts/lib/broker-transport.mjs +407 -0
- package/scripts/lib/broker.mjs +537 -0
- package/scripts/lib/debounce-state.mjs +208 -0
- package/scripts/lib/parser.d.mts +12 -0
- package/scripts/lib/parser.mjs +229 -0
- package/scripts/lib/process.mjs +39 -0
- package/scripts/lib/prompt.d.mts +8 -0
- package/scripts/lib/prompt.mjs +41 -0
- package/scripts/lib/session-registry.mjs +162 -0
- package/scripts/lib/state.d.mts +58 -0
- package/scripts/lib/state.mjs +733 -0
- package/scripts/lib/stop-gate.mjs +134 -0
- package/skills/antigravity-review/SKILL.md +49 -0
- package/skills/brainstorm/SKILL.md +105 -0
- package/skills/brainstorm-all/SKILL.md +43 -0
- package/skills/codex-image/SKILL.md +120 -0
- package/skills/codex-pair/SKILL.md +315 -0
- package/skills/codex-pair-ack/SKILL.md +64 -0
- package/skills/codex-pair-pause/SKILL.md +62 -0
- package/skills/codex-pair-resume/SKILL.md +52 -0
- package/skills/codex-review/SKILL.md +52 -0
- package/skills/codex-verify/SKILL.md +110 -0
- package/skills/compare/SKILL.md +151 -0
- package/skills/fable-review/SKILL.md +42 -0
- package/skills/gemini-review/SKILL.md +40 -0
- package/skills/multi-review/SKILL.md +182 -0
- package/skills/ollama-review/SKILL.md +40 -0
- package/skills/sol-review/SKILL.md +41 -0
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// codex-pair-log — standalone CLI viewer for .codex-pair/log.jsonl written
|
|
3
|
+
// by the codex-pair PostToolUse hook. Walks up from cwd to find the
|
|
4
|
+
// .codex-pair/context.md marker (same gate as the hook), then reads and
|
|
5
|
+
// renders the sibling log file.
|
|
6
|
+
//
|
|
7
|
+
// Subcommands:
|
|
8
|
+
// --latest [N] Show last N entries (default 10). DEFAULT subcommand.
|
|
9
|
+
// --summary Aggregate stats: verdict breakdown, top files, durations.
|
|
10
|
+
// --file <path> Filter to one file's history.
|
|
11
|
+
// --since <dur> Filter to entries within the last <dur>. Examples: 24h, 7d, 30m.
|
|
12
|
+
//
|
|
13
|
+
// Zero workspace imports — distributed via marketplace `git-subdir` install,
|
|
14
|
+
// no node_modules at runtime. Same constraint as codex-pair-watch.mjs (ADR-078).
|
|
15
|
+
// Path constants come from the sibling lib/state.mjs so the layout stays
|
|
16
|
+
// consistent with the hook (ADR-092).
|
|
17
|
+
|
|
18
|
+
import { readFileSync } from "node:fs";
|
|
19
|
+
import { access } from "node:fs/promises";
|
|
20
|
+
import { homedir } from "node:os";
|
|
21
|
+
import { dirname, join, resolve } from "node:path";
|
|
22
|
+
import { CONTEXT_FILENAME, logPath as resolveLogPath, PAIR_ROOT_DIR } from "./lib/state.mjs";
|
|
23
|
+
|
|
24
|
+
const MARKER_FILE = join(PAIR_ROOT_DIR, CONTEXT_FILENAME);
|
|
25
|
+
|
|
26
|
+
// Returns the project ROOT (the directory holding `.codex-pair/`) or null.
|
|
27
|
+
async function findMarkerUp(startDir) {
|
|
28
|
+
const home = homedir();
|
|
29
|
+
let current = resolve(startDir);
|
|
30
|
+
for (let depth = 0; depth < 20; depth++) {
|
|
31
|
+
const candidate = join(current, MARKER_FILE);
|
|
32
|
+
try {
|
|
33
|
+
await access(candidate);
|
|
34
|
+
return current;
|
|
35
|
+
} catch {
|
|
36
|
+
// not found here
|
|
37
|
+
}
|
|
38
|
+
const parent = dirname(current);
|
|
39
|
+
if (parent === current) return null;
|
|
40
|
+
if (current === home) return null;
|
|
41
|
+
current = parent;
|
|
42
|
+
}
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function parseDuration(s) {
|
|
47
|
+
const m = s.match(/^(\d+)([smhd])$/);
|
|
48
|
+
if (!m) return null;
|
|
49
|
+
const n = Number(m[1]);
|
|
50
|
+
const unit = m[2];
|
|
51
|
+
if (unit === "s") return n * 1000;
|
|
52
|
+
if (unit === "m") return n * 60 * 1000;
|
|
53
|
+
if (unit === "h") return n * 60 * 60 * 1000;
|
|
54
|
+
if (unit === "d") return n * 24 * 60 * 60 * 1000;
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function parseArgs(argv) {
|
|
59
|
+
const args = { cmd: "latest", n: 10, file: null, sinceMs: null };
|
|
60
|
+
for (let i = 0; i < argv.length; i++) {
|
|
61
|
+
const a = argv[i];
|
|
62
|
+
if (a === "--latest") {
|
|
63
|
+
args.cmd = "latest";
|
|
64
|
+
const next = argv[i + 1];
|
|
65
|
+
if (next && /^\d+$/.test(next)) {
|
|
66
|
+
args.n = Number(next);
|
|
67
|
+
i++;
|
|
68
|
+
}
|
|
69
|
+
} else if (a === "--summary") {
|
|
70
|
+
args.cmd = "summary";
|
|
71
|
+
} else if (a === "--file") {
|
|
72
|
+
args.cmd = "file";
|
|
73
|
+
args.file = argv[i + 1] ?? null;
|
|
74
|
+
i++;
|
|
75
|
+
} else if (a === "--since") {
|
|
76
|
+
const next = argv[i + 1];
|
|
77
|
+
const ms = next ? parseDuration(next) : null;
|
|
78
|
+
if (ms === null) {
|
|
79
|
+
process.stderr.write(`codex-pair-log: invalid --since duration "${next ?? ""}"\n`);
|
|
80
|
+
process.exit(2);
|
|
81
|
+
}
|
|
82
|
+
args.sinceMs = ms;
|
|
83
|
+
i++;
|
|
84
|
+
} else if (a === "--help" || a === "-h") {
|
|
85
|
+
args.cmd = "help";
|
|
86
|
+
} else {
|
|
87
|
+
process.stderr.write(`codex-pair-log: unknown arg "${a}"\n`);
|
|
88
|
+
process.exit(2);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return args;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function loadEntries(logPath) {
|
|
95
|
+
let content;
|
|
96
|
+
try {
|
|
97
|
+
content = readFileSync(logPath, "utf8");
|
|
98
|
+
} catch {
|
|
99
|
+
return [];
|
|
100
|
+
}
|
|
101
|
+
const entries = [];
|
|
102
|
+
for (const line of content.split("\n")) {
|
|
103
|
+
if (line.length === 0) continue;
|
|
104
|
+
try {
|
|
105
|
+
entries.push(JSON.parse(line));
|
|
106
|
+
} catch {
|
|
107
|
+
// skip malformed lines silently
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return entries;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function applySinceFilter(entries, sinceMs) {
|
|
114
|
+
if (sinceMs == null) return entries;
|
|
115
|
+
const cutoff = Date.now() - sinceMs;
|
|
116
|
+
return entries.filter((e) => {
|
|
117
|
+
if (!e.timestamp) return false;
|
|
118
|
+
const t = new Date(e.timestamp).getTime();
|
|
119
|
+
return Number.isFinite(t) && t >= cutoff;
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function formatCounts(entry) {
|
|
124
|
+
if (entry.counts) {
|
|
125
|
+
return `${entry.counts.high}H/${entry.counts.med}M/${entry.counts.low}L`;
|
|
126
|
+
}
|
|
127
|
+
return "—";
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function formatDuration(ms) {
|
|
131
|
+
if (ms == null) return "—";
|
|
132
|
+
if (ms < 1000) return `${ms}ms`;
|
|
133
|
+
return `${(ms / 1000).toFixed(1)}s`;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function showLatest(entries, n) {
|
|
137
|
+
const slice = entries.slice(-n);
|
|
138
|
+
for (const e of slice) {
|
|
139
|
+
const verdict = (e.verdict || (e.level ? `[${e.level}]` : "?")).padEnd(13);
|
|
140
|
+
const file = (e.file || "—").padEnd(50);
|
|
141
|
+
const counts = formatCounts(e).padEnd(11);
|
|
142
|
+
const dur = formatDuration(e.durationMs);
|
|
143
|
+
const ts = e.timestamp || "—";
|
|
144
|
+
process.stdout.write(`${ts} ${verdict} ${file} ${counts} ${dur}\n`);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function showFile(entries, filePath) {
|
|
149
|
+
const filtered = entries.filter((e) => e.file === filePath);
|
|
150
|
+
if (filtered.length === 0) {
|
|
151
|
+
process.stdout.write(`codex-pair-log: no entries for file ${filePath}\n`);
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
showLatest(filtered, filtered.length);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function showSummary(entries) {
|
|
158
|
+
const verdictCounts = {};
|
|
159
|
+
const fileCounts = {};
|
|
160
|
+
let durationSum = 0;
|
|
161
|
+
let durationCount = 0;
|
|
162
|
+
let fallbackCount = 0;
|
|
163
|
+
let cachedCount = 0;
|
|
164
|
+
let runCount = 0;
|
|
165
|
+
for (const e of entries) {
|
|
166
|
+
if (e.verdict) {
|
|
167
|
+
verdictCounts[e.verdict] = (verdictCounts[e.verdict] || 0) + 1;
|
|
168
|
+
}
|
|
169
|
+
if (e.file) {
|
|
170
|
+
fileCounts[e.file] = (fileCounts[e.file] || 0) + 1;
|
|
171
|
+
}
|
|
172
|
+
if (e.fellBack) fallbackCount++;
|
|
173
|
+
if (e.verdict === "cached") cachedCount++;
|
|
174
|
+
if (
|
|
175
|
+
e.verdict === "none" ||
|
|
176
|
+
e.verdict === "concerns" ||
|
|
177
|
+
e.verdict === "cached"
|
|
178
|
+
) {
|
|
179
|
+
runCount++;
|
|
180
|
+
}
|
|
181
|
+
if (
|
|
182
|
+
e.durationMs != null &&
|
|
183
|
+
(e.verdict === "none" || e.verdict === "concerns")
|
|
184
|
+
) {
|
|
185
|
+
durationSum += e.durationMs;
|
|
186
|
+
durationCount++;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
const topFiles = Object.entries(fileCounts)
|
|
190
|
+
.sort((a, b) => b[1] - a[1])
|
|
191
|
+
.slice(0, 5);
|
|
192
|
+
process.stdout.write(`codex-pair log summary\n`);
|
|
193
|
+
process.stdout.write(`Total entries: ${entries.length}\n`);
|
|
194
|
+
process.stdout.write(`Verdict breakdown:\n`);
|
|
195
|
+
for (const [v, c] of Object.entries(verdictCounts).sort((a, b) => b[1] - a[1])) {
|
|
196
|
+
process.stdout.write(` ${v.padEnd(15)} ${c}\n`);
|
|
197
|
+
}
|
|
198
|
+
process.stdout.write(`Top 5 files by entry count:\n`);
|
|
199
|
+
for (const [f, c] of topFiles) {
|
|
200
|
+
process.stdout.write(` ${String(c).padStart(4)} ${f}\n`);
|
|
201
|
+
}
|
|
202
|
+
if (durationCount > 0) {
|
|
203
|
+
const avg = durationSum / durationCount;
|
|
204
|
+
process.stdout.write(
|
|
205
|
+
`Average codex duration (uncached runs only): ${formatDuration(avg)} over ${durationCount} runs\n`,
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
if (runCount > 0) {
|
|
209
|
+
const cacheRate = ((cachedCount / runCount) * 100).toFixed(1);
|
|
210
|
+
process.stdout.write(`Cache hit rate: ${cachedCount} / ${runCount} (${cacheRate}%)\n`);
|
|
211
|
+
}
|
|
212
|
+
if (fallbackCount > 0) {
|
|
213
|
+
process.stdout.write(`Fallback model used: ${fallbackCount} time(s)\n`);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function showHelp() {
|
|
218
|
+
process.stdout.write(
|
|
219
|
+
[
|
|
220
|
+
"codex-pair-log — view .codex-pair/log.jsonl written by the codex-pair hook",
|
|
221
|
+
"",
|
|
222
|
+
"Usage: codex-pair-log [SUBCOMMAND] [--since DURATION]",
|
|
223
|
+
"",
|
|
224
|
+
"Subcommands:",
|
|
225
|
+
" --latest [N] Show last N entries (default 10). Default subcommand.",
|
|
226
|
+
" --summary Aggregate stats over the log.",
|
|
227
|
+
" --file <path> Filter to one file's history.",
|
|
228
|
+
" --since <dur> Filter to entries within the last <dur>. Examples: 30m, 24h, 7d.",
|
|
229
|
+
" --help, -h Show this help.",
|
|
230
|
+
"",
|
|
231
|
+
].join("\n"),
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
async function main() {
|
|
236
|
+
const args = parseArgs(process.argv.slice(2));
|
|
237
|
+
if (args.cmd === "help") {
|
|
238
|
+
showHelp();
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
const markerDir = await findMarkerUp(process.cwd());
|
|
242
|
+
if (!markerDir) {
|
|
243
|
+
process.stderr.write(
|
|
244
|
+
`codex-pair-log: no .codex-pair/context.md marker found in cwd or parents\n`,
|
|
245
|
+
);
|
|
246
|
+
process.exit(1);
|
|
247
|
+
}
|
|
248
|
+
const logPath = resolveLogPath(markerDir);
|
|
249
|
+
let entries = loadEntries(logPath);
|
|
250
|
+
if (entries.length === 0) {
|
|
251
|
+
process.stdout.write(`codex-pair-log: no entries in ${logPath}\n`);
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
entries = applySinceFilter(entries, args.sinceMs);
|
|
255
|
+
if (args.cmd === "latest") {
|
|
256
|
+
showLatest(entries, args.n);
|
|
257
|
+
} else if (args.cmd === "summary") {
|
|
258
|
+
showSummary(entries);
|
|
259
|
+
} else if (args.cmd === "file") {
|
|
260
|
+
if (!args.file) {
|
|
261
|
+
process.stderr.write(`codex-pair-log: --file requires a path argument\n`);
|
|
262
|
+
process.exit(2);
|
|
263
|
+
}
|
|
264
|
+
showFile(entries, args.file);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
main().catch((err) => {
|
|
269
|
+
process.stderr.write(`codex-pair-log: ${err?.message ?? String(err)}\n`);
|
|
270
|
+
process.exit(1);
|
|
271
|
+
});
|
|
@@ -0,0 +1,81 @@
|
|
|
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
|
+
|
|
13
|
+
import { access } from "node:fs/promises";
|
|
14
|
+
import { homedir } from "node:os";
|
|
15
|
+
import { dirname, join, resolve } from "node:path";
|
|
16
|
+
import { CONTEXT_FILENAME, PAIR_ROOT_DIR } from "./lib/state.mjs";
|
|
17
|
+
import { drainPending, joinPendingForSurface } from "./lib/debounce-state.mjs";
|
|
18
|
+
import { collectSessionMarkers } from "./lib/session-registry.mjs";
|
|
19
|
+
|
|
20
|
+
const MARKER_FILE = join(PAIR_ROOT_DIR, CONTEXT_FILENAME);
|
|
21
|
+
|
|
22
|
+
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
|
|
31
|
+
}
|
|
32
|
+
const parent = dirname(current);
|
|
33
|
+
if (parent === current || current === home) return null;
|
|
34
|
+
current = parent;
|
|
35
|
+
}
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function readStdin() {
|
|
40
|
+
return new Promise((r) => {
|
|
41
|
+
let data = "";
|
|
42
|
+
process.stdin.on("data", (c) => {
|
|
43
|
+
data += c.toString();
|
|
44
|
+
});
|
|
45
|
+
process.stdin.on("end", () => r(data));
|
|
46
|
+
process.stdin.on("error", () => r(""));
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
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));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
main().catch(() => process.exit(0));
|
|
@@ -0,0 +1,194 @@
|
|
|
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
|
+
|
|
11
|
+
import { access } from "node:fs/promises";
|
|
12
|
+
import { homedir } from "node:os";
|
|
13
|
+
import { dirname, join, resolve } from "node:path";
|
|
14
|
+
import { bootstrapBroker, clearStaleBrokerState, teardownBroker } from "./lib/broker-lifecycle.mjs";
|
|
15
|
+
import { clearAllDebounceState } from "./lib/debounce-state.mjs";
|
|
16
|
+
import {
|
|
17
|
+
appendLog,
|
|
18
|
+
clearAutoPause,
|
|
19
|
+
CONTEXT_FILENAME,
|
|
20
|
+
PAIR_ROOT_DIR,
|
|
21
|
+
readPauseInfo,
|
|
22
|
+
readPluginVersion,
|
|
23
|
+
resolveAutoResume,
|
|
24
|
+
} from "./lib/state.mjs";
|
|
25
|
+
import { clearSession } from "./lib/session-registry.mjs";
|
|
26
|
+
|
|
27
|
+
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).
|
|
34
|
+
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
|
|
44
|
+
}
|
|
45
|
+
const parent = dirname(current);
|
|
46
|
+
if (parent === current) return null;
|
|
47
|
+
if (current === home) return null;
|
|
48
|
+
current = parent;
|
|
49
|
+
}
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function readStdin() {
|
|
54
|
+
return new Promise((resolve) => {
|
|
55
|
+
let data = "";
|
|
56
|
+
process.stdin.on("data", (c) => {
|
|
57
|
+
data += c.toString();
|
|
58
|
+
});
|
|
59
|
+
process.stdin.on("end", () => resolve(data));
|
|
60
|
+
process.stdin.on("error", () => resolve(""));
|
|
61
|
+
});
|
|
62
|
+
}
|
|
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);
|
|
76
|
+
}
|
|
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);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
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") {
|
|
111
|
+
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
|
+
}
|
|
139
|
+
}
|
|
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
|
+
});
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
} catch {
|
|
150
|
+
// best-effort (ADR-077) — pause visibility must never break the session
|
|
151
|
+
}
|
|
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
|
+
}
|
|
167
|
+
}
|
|
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.
|
|
170
|
+
try {
|
|
171
|
+
clearSession(payload?.session_id);
|
|
172
|
+
} catch {
|
|
173
|
+
// best-effort (ADR-077)
|
|
174
|
+
}
|
|
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
|
+
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
|
+
}
|
|
193
|
+
|
|
194
|
+
main().catch(() => process.exit(0));
|