@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.
Files changed (76) hide show
  1. package/.claude-plugin/plugin.json +20 -0
  2. package/.mcp.json +3 -0
  3. package/LICENSE +21 -0
  4. package/README.md +135 -0
  5. package/agents/antigravity-reviewer.md +139 -0
  6. package/agents/brainstorm-coordinator.md +305 -0
  7. package/agents/codex-reviewer.md +194 -0
  8. package/agents/codex-verifier.md +149 -0
  9. package/agents/fable-reviewer.md +44 -0
  10. package/agents/gemini-reviewer.md +130 -0
  11. package/agents/ollama-reviewer.md +131 -0
  12. package/agents/sol-reviewer.md +60 -0
  13. package/codex-pair-defaults.json +4 -0
  14. package/dist/antigravity-run.d.ts +3 -0
  15. package/dist/antigravity-run.d.ts.map +1 -0
  16. package/dist/antigravity-run.js +32 -0
  17. package/dist/antigravity-run.js.map +1 -0
  18. package/dist/codex-run.d.ts +3 -0
  19. package/dist/codex-run.d.ts.map +1 -0
  20. package/dist/codex-run.js +32 -0
  21. package/dist/codex-run.js.map +1 -0
  22. package/dist/index.d.ts +7 -0
  23. package/dist/index.d.ts.map +1 -0
  24. package/dist/index.js +39 -0
  25. package/dist/index.js.map +1 -0
  26. package/dist/ollama-run.d.ts +3 -0
  27. package/dist/ollama-run.d.ts.map +1 -0
  28. package/dist/ollama-run.js +32 -0
  29. package/dist/ollama-run.js.map +1 -0
  30. package/dist/run.d.ts +3 -0
  31. package/dist/run.d.ts.map +1 -0
  32. package/dist/run.js +32 -0
  33. package/dist/run.js.map +1 -0
  34. package/hooks/hooks.json +55 -0
  35. package/package.json +104 -0
  36. package/pi/extensions/codex-pair.ts +870 -0
  37. package/pi/extensions/index.ts +13 -0
  38. package/pi/extensions/provider-tools.ts +241 -0
  39. package/pi/tsconfig.json +10 -0
  40. package/prompts/review.txt +75 -0
  41. package/scripts/codex-pair-debounce-worker.mjs +103 -0
  42. package/scripts/codex-pair-log.mjs +271 -0
  43. package/scripts/codex-pair-prompt-drain.mjs +81 -0
  44. package/scripts/codex-pair-session.mjs +194 -0
  45. package/scripts/codex-pair-stop-gate.mjs +271 -0
  46. package/scripts/codex-pair-watch.mjs +1525 -0
  47. package/scripts/lib/broker-lifecycle.mjs +575 -0
  48. package/scripts/lib/broker-rpc.mjs +203 -0
  49. package/scripts/lib/broker-transport.mjs +407 -0
  50. package/scripts/lib/broker.mjs +537 -0
  51. package/scripts/lib/debounce-state.mjs +208 -0
  52. package/scripts/lib/parser.d.mts +12 -0
  53. package/scripts/lib/parser.mjs +229 -0
  54. package/scripts/lib/process.mjs +39 -0
  55. package/scripts/lib/prompt.d.mts +8 -0
  56. package/scripts/lib/prompt.mjs +41 -0
  57. package/scripts/lib/session-registry.mjs +162 -0
  58. package/scripts/lib/state.d.mts +58 -0
  59. package/scripts/lib/state.mjs +733 -0
  60. package/scripts/lib/stop-gate.mjs +134 -0
  61. package/skills/antigravity-review/SKILL.md +49 -0
  62. package/skills/brainstorm/SKILL.md +105 -0
  63. package/skills/brainstorm-all/SKILL.md +43 -0
  64. package/skills/codex-image/SKILL.md +120 -0
  65. package/skills/codex-pair/SKILL.md +315 -0
  66. package/skills/codex-pair-ack/SKILL.md +64 -0
  67. package/skills/codex-pair-pause/SKILL.md +62 -0
  68. package/skills/codex-pair-resume/SKILL.md +52 -0
  69. package/skills/codex-review/SKILL.md +52 -0
  70. package/skills/codex-verify/SKILL.md +110 -0
  71. package/skills/compare/SKILL.md +151 -0
  72. package/skills/fable-review/SKILL.md +42 -0
  73. package/skills/gemini-review/SKILL.md +40 -0
  74. package/skills/multi-review/SKILL.md +182 -0
  75. package/skills/ollama-review/SKILL.md +40 -0
  76. package/skills/sol-review/SKILL.md +41 -0
@@ -0,0 +1,870 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { existsSync } from "node:fs";
3
+ import {
4
+ mkdir,
5
+ readFile,
6
+ readdir,
7
+ realpath,
8
+ rename,
9
+ stat,
10
+ unlink,
11
+ writeFile,
12
+ } from "node:fs/promises";
13
+ import { homedir } from "node:os";
14
+ import { dirname, isAbsolute, join, parse, resolve } from "node:path";
15
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
16
+ import { executeCodexCLI, resolveCodexTimeoutMs } from "@ask-llm/codex-mcp/executor";
17
+ import {
18
+ AUTOPAUSE_FAILURE_THRESHOLD,
19
+ INFLIGHT_TTL_MIN_MS,
20
+ addAck,
21
+ appendLog,
22
+ clearAutoPause,
23
+ clearReviewFailures,
24
+ computeCacheKey,
25
+ getCachedConcerns,
26
+ hashConcernBody,
27
+ logPath,
28
+ pausePath,
29
+ readAcks,
30
+ readPauseInfo,
31
+ readPluginVersion,
32
+ recordReviewFailure,
33
+ releaseInflightLock,
34
+ resolveAutoResume,
35
+ setCachedConcerns,
36
+ stateRoot,
37
+ tryAcquireInflightLock,
38
+ writeAutoPause,
39
+ } from "../../scripts/lib/state.mjs";
40
+ import {
41
+ DEFAULT_SURFACE_THRESHOLD,
42
+ buildVerdictMessage,
43
+ parseConcerns,
44
+ } from "../../scripts/lib/parser.mjs";
45
+
46
+ const DEFAULT_DEBOUNCE_MS = 15_000;
47
+ const DEFAULT_DEBOUNCE_MAX_MS = 60_000;
48
+ const DEFAULT_MAX_FILE_BYTES = 20_000;
49
+ const MESSAGE_TYPE = "ask-llm-codex-pair";
50
+ const ALLOWLIST_FILE = "codex-pair-projects.json";
51
+ const ALLOWLIST_LOCK_RETRY_MS = 25;
52
+ const ALLOWLIST_LOCK_TIMEOUT_MS = 5_000;
53
+ const ALLOWLIST_LOCK_STALE_MS = 30_000;
54
+ const LOCK_CONTENTION_RETRY_MS = 100;
55
+ const PI_PENDING_DIR = "pi-pending";
56
+ const PI_REVIEWED_DIR = "pi-reviewed";
57
+ const SKIP_PARTS = [
58
+ "/.git/",
59
+ "/node_modules/",
60
+ "/dist/",
61
+ "/build/",
62
+ "/.next/",
63
+ "/coverage/",
64
+ "/.codex-pair/",
65
+ ];
66
+ const SKIP_SUFFIXES = [
67
+ ".lock",
68
+ ".snap",
69
+ ".map",
70
+ ".min.js",
71
+ ".min.css",
72
+ ".png",
73
+ ".jpg",
74
+ ".jpeg",
75
+ ".gif",
76
+ ".webp",
77
+ ".svg",
78
+ ".ico",
79
+ ".woff",
80
+ ".woff2",
81
+ ".ttf",
82
+ ".otf",
83
+ ".eot",
84
+ ".pdf",
85
+ ".zip",
86
+ ".tar",
87
+ ".gz",
88
+ ".wasm",
89
+ ];
90
+ const SKIP_FILENAMES = [
91
+ "yarn.lock",
92
+ "package-lock.json",
93
+ "pnpm-lock.yaml",
94
+ "cargo.lock",
95
+ "gemfile.lock",
96
+ "composer.lock",
97
+ "poetry.lock",
98
+ "go.sum",
99
+ ];
100
+
101
+ interface PairConfig {
102
+ debounceMs: number;
103
+ debounceMaxMs: number;
104
+ maxFileBytes: number;
105
+ surfaceThreshold: "high" | "med" | "low";
106
+ model: string;
107
+ }
108
+
109
+ interface ScheduledReview {
110
+ generation: number;
111
+ firstAt: number;
112
+ markerDir: string;
113
+ toolName: string;
114
+ timer?: ReturnType<typeof setTimeout>;
115
+ }
116
+
117
+ type RevalidationState = "current" | "closed" | "superseded" | "stale";
118
+
119
+ interface PairFinding {
120
+ id: string;
121
+ file: string;
122
+ markerDir: string;
123
+ contentHash: string;
124
+ message: string;
125
+ createdAt: string;
126
+ }
127
+
128
+ interface ReviewedRecord {
129
+ contentHash: string;
130
+ findingId: string;
131
+ deliveredAt: string;
132
+ }
133
+
134
+ interface Allowlist {
135
+ version: 1;
136
+ projects: Array<{ root: string; allowedAt: string }>;
137
+ }
138
+
139
+ interface AllowlistLock {
140
+ id: string;
141
+ pid: number;
142
+ acquiredAt: string;
143
+ }
144
+
145
+ function hash(value: string): string {
146
+ return createHash("sha256").update(value).digest("hex");
147
+ }
148
+
149
+ function configDir(): string {
150
+ return process.env.PI_CODING_AGENT_DIR || join(homedir(), ".pi", "agent");
151
+ }
152
+
153
+ function allowlistPath(): string {
154
+ return join(configDir(), "ask-llm", ALLOWLIST_FILE);
155
+ }
156
+
157
+ async function writeJsonAtomic(path: string, value: unknown): Promise<void> {
158
+ await mkdir(dirname(path), { recursive: true });
159
+ const tmp = `${path}.tmp.${process.pid}.${randomUUID()}`;
160
+ await writeFile(tmp, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
161
+ await rename(tmp, path);
162
+ }
163
+
164
+ async function readAllowlist(): Promise<Allowlist> {
165
+ try {
166
+ const parsed = JSON.parse(await readFile(allowlistPath(), "utf8")) as Partial<Allowlist>;
167
+ if (parsed.version === 1 && Array.isArray(parsed.projects)) {
168
+ return {
169
+ version: 1,
170
+ projects: parsed.projects.filter(
171
+ (entry): entry is { root: string; allowedAt: string } =>
172
+ typeof entry?.root === "string" && typeof entry.allowedAt === "string",
173
+ ),
174
+ };
175
+ }
176
+ } catch {}
177
+ return { version: 1, projects: [] };
178
+ }
179
+
180
+ async function canonicalDirectory(path: string): Promise<string> {
181
+ return realpath(path);
182
+ }
183
+
184
+ async function isAllowed(markerDir: string): Promise<boolean> {
185
+ const canonical = await canonicalDirectory(markerDir);
186
+ return (await readAllowlist()).projects.some((entry) => entry.root === canonical);
187
+ }
188
+
189
+ async function recoverAbandonedAllowlistLock(lockPath: string): Promise<boolean> {
190
+ try {
191
+ const content = await readFile(lockPath, "utf8");
192
+ const lock = JSON.parse(content) as Partial<AllowlistLock>;
193
+ const info = await stat(lockPath);
194
+ const acquiredAt = typeof lock.acquiredAt === "string" ? Date.parse(lock.acquiredAt) : Number.NaN;
195
+ const pid = typeof lock.pid === "number" ? lock.pid : Number.NaN;
196
+ if (
197
+ typeof lock.id !== "string" ||
198
+ !Number.isInteger(pid) ||
199
+ !Number.isFinite(acquiredAt) ||
200
+ Date.now() - acquiredAt <= ALLOWLIST_LOCK_STALE_MS ||
201
+ Date.now() - info.mtimeMs <= ALLOWLIST_LOCK_STALE_MS ||
202
+ processIsAlive(pid)
203
+ ) {
204
+ return false;
205
+ }
206
+ const recheckContent = await readFile(lockPath, "utf8");
207
+ const recheckInfo = await stat(lockPath);
208
+ if (recheckContent !== content || recheckInfo.mtimeMs !== info.mtimeMs) return false;
209
+ await unlink(lockPath);
210
+ return true;
211
+ } catch {
212
+ return false;
213
+ }
214
+ }
215
+
216
+ async function releaseOwnedAllowlistLock(lockPath: string, identity: string): Promise<void> {
217
+ try {
218
+ if ((await readFile(lockPath, "utf8")) === identity) await unlink(lockPath);
219
+ } catch {}
220
+ }
221
+
222
+ async function setAllowed(markerDir: string, allowed: boolean): Promise<void> {
223
+ const canonical = await canonicalDirectory(markerDir);
224
+ const lockPath = `${allowlistPath()}.lock`;
225
+ const lock = {
226
+ id: randomUUID(),
227
+ pid: process.pid,
228
+ acquiredAt: new Date().toISOString(),
229
+ } satisfies AllowlistLock;
230
+ const identity = `${JSON.stringify(lock)}\n`;
231
+ await mkdir(dirname(lockPath), { recursive: true });
232
+ const deadline = Date.now() + ALLOWLIST_LOCK_TIMEOUT_MS;
233
+ while (true) {
234
+ try {
235
+ await writeFile(lockPath, identity, { flag: "wx", mode: 0o600 });
236
+ break;
237
+ } catch (error) {
238
+ if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
239
+ if (await recoverAbandonedAllowlistLock(lockPath)) continue;
240
+ if (Date.now() >= deadline) throw new Error("Timed out updating the codex-pair project allowlist");
241
+ await new Promise((resolveWait) => setTimeout(resolveWait, ALLOWLIST_LOCK_RETRY_MS));
242
+ }
243
+ }
244
+ try {
245
+ const current = await readAllowlist();
246
+ const projects = current.projects.filter((entry) => entry.root !== canonical);
247
+ if (allowed) projects.push({ root: canonical, allowedAt: new Date().toISOString() });
248
+ await writeJsonAtomic(allowlistPath(), { version: 1, projects } satisfies Allowlist);
249
+ } finally {
250
+ await releaseOwnedAllowlistLock(lockPath, identity);
251
+ }
252
+ }
253
+
254
+ async function findMarkerUp(start: string): Promise<string | undefined> {
255
+ let current = start;
256
+ try {
257
+ const info = await stat(current);
258
+ if (!info.isDirectory()) current = dirname(current);
259
+ } catch {
260
+ current = dirname(current);
261
+ }
262
+ current = resolve(current);
263
+ const root = parse(current).root;
264
+ for (let depth = 0; depth < 40; depth++) {
265
+ if (existsSync(join(current, ".codex-pair", "context.md"))) return canonicalDirectory(current);
266
+ if (current === root) break;
267
+ current = dirname(current);
268
+ }
269
+ return undefined;
270
+ }
271
+
272
+ function parseNumberFrontmatter(content: string, key: string, fallback: number): number {
273
+ const match = content.match(new RegExp(`^${key}:\\s*(\\d+)\\s*$`, "m"));
274
+ const value = match ? Number(match[1]) : fallback;
275
+ return Number.isFinite(value) && value >= 0 ? value : fallback;
276
+ }
277
+
278
+ async function readConfig(markerDir: string): Promise<{ config: PairConfig; context: string }> {
279
+ const context = await readFile(join(markerDir, ".codex-pair", "context.md"), "utf8");
280
+ const threshold = context.match(/^surfaceThreshold:\s*(high|med|low)\s*$/m)?.[1];
281
+ const model = context.match(/^model:\s*([^\n]+)$/m)?.[1]?.trim() || "gpt-5.6-sol";
282
+ return {
283
+ context,
284
+ config: {
285
+ debounceMs: parseNumberFrontmatter(context, "debounceMs", DEFAULT_DEBOUNCE_MS),
286
+ debounceMaxMs: parseNumberFrontmatter(context, "debounceMaxMs", DEFAULT_DEBOUNCE_MAX_MS),
287
+ maxFileBytes: parseNumberFrontmatter(context, "maxFileBytes", DEFAULT_MAX_FILE_BYTES),
288
+ surfaceThreshold: (threshold as PairConfig["surfaceThreshold"] | undefined) ?? DEFAULT_SURFACE_THRESHOLD,
289
+ model,
290
+ },
291
+ };
292
+ }
293
+
294
+ function normalizeToolPath(inputPath: unknown, cwd: string): string | undefined {
295
+ if (typeof inputPath !== "string" || inputPath.trim().length === 0) return undefined;
296
+ const withoutAt = inputPath.startsWith("@") ? inputPath.slice(1) : inputPath;
297
+ return isAbsolute(withoutAt) ? resolve(withoutAt) : resolve(cwd, withoutAt);
298
+ }
299
+
300
+ async function canonicalRegularFile(path: string): Promise<string | undefined> {
301
+ try {
302
+ const canonical = await realpath(path);
303
+ return (await stat(canonical)).isFile() ? canonical : undefined;
304
+ } catch {
305
+ return undefined;
306
+ }
307
+ }
308
+
309
+ function shouldSkip(path: string): boolean {
310
+ const normalized = path.replaceAll("\\", "/").toLowerCase();
311
+ return (
312
+ SKIP_PARTS.some((part) => normalized.includes(part)) ||
313
+ SKIP_FILENAMES.some((name) => normalized === name || normalized.endsWith(`/${name}`)) ||
314
+ SKIP_SUFFIXES.some((part) => normalized.endsWith(part))
315
+ );
316
+ }
317
+
318
+ function deleteScheduledIfOwned(
319
+ scheduled: Map<string, ScheduledReview>,
320
+ file: string,
321
+ generation: number,
322
+ ): boolean {
323
+ if (scheduled.get(file)?.generation !== generation) return false;
324
+ scheduled.delete(file);
325
+ return true;
326
+ }
327
+
328
+ function shouldDiscardPending(state: RevalidationState): boolean {
329
+ return state === "superseded" || state === "stale";
330
+ }
331
+
332
+ function isBinaryContent(content: Buffer): boolean {
333
+ if (content.includes(0)) return true;
334
+ try {
335
+ new TextDecoder("utf-8", { fatal: true }).decode(content);
336
+ return false;
337
+ } catch {
338
+ return true;
339
+ }
340
+ }
341
+
342
+ function reviewedPath(markerDir: string, file: string): string {
343
+ return join(stateRoot(markerDir), PI_REVIEWED_DIR, `${hash(file).slice(0, 16)}.json`);
344
+ }
345
+
346
+ function pendingPath(markerDir: string, id: string): string {
347
+ return join(stateRoot(markerDir), PI_PENDING_DIR, `${id}.json`);
348
+ }
349
+
350
+ async function readReviewed(markerDir: string, file: string): Promise<ReviewedRecord | undefined> {
351
+ try {
352
+ return JSON.parse(await readFile(reviewedPath(markerDir, file), "utf8")) as ReviewedRecord;
353
+ } catch {
354
+ return undefined;
355
+ }
356
+ }
357
+
358
+ async function persistFinding(finding: PairFinding): Promise<void> {
359
+ await writeJsonAtomic(pendingPath(finding.markerDir, finding.id), finding);
360
+ }
361
+
362
+ async function markReviewed(finding: PairFinding): Promise<void> {
363
+ await writeJsonAtomic(reviewedPath(finding.markerDir, finding.file), {
364
+ contentHash: finding.contentHash,
365
+ findingId: finding.id,
366
+ deliveredAt: finding.createdAt,
367
+ } satisfies ReviewedRecord);
368
+ }
369
+
370
+ function sendFinding(pi: ExtensionAPI, finding: PairFinding): void {
371
+ pi.sendMessage(
372
+ {
373
+ customType: MESSAGE_TYPE,
374
+ content: `${finding.message}\n\nPi note: findings are advisory and non-blocking; Pi has no safe Claude Stop-gate equivalent.`,
375
+ display: true,
376
+ details: { file: finding.file, project: finding.markerDir, findingId: finding.id },
377
+ },
378
+ { deliverAs: "steer", triggerTurn: false },
379
+ );
380
+ }
381
+
382
+ interface ClaimedFinding {
383
+ claimPath: string;
384
+ originalPath: string;
385
+ finding: PairFinding;
386
+ }
387
+
388
+ function pendingRoot(markerDir: string): string {
389
+ return join(stateRoot(markerDir), PI_PENDING_DIR);
390
+ }
391
+
392
+ function processIsAlive(pid: number): boolean {
393
+ try {
394
+ process.kill(pid, 0);
395
+ return true;
396
+ } catch (error) {
397
+ return (error as NodeJS.ErrnoException).code === "EPERM";
398
+ }
399
+ }
400
+
401
+ async function recoverAbandonedClaims(markerDir: string): Promise<void> {
402
+ const root = pendingRoot(markerDir);
403
+ try {
404
+ const names = (await readdir(root)).filter((name) => name.includes(".json.claim-")).sort();
405
+ for (const name of names) {
406
+ const pid = Number(name.match(/\.claim-(\d+)-/)?.[1]);
407
+ if (Number.isInteger(pid) && processIsAlive(pid)) continue;
408
+ const claimPath = join(root, name);
409
+ const originalPath = join(root, name.slice(0, name.indexOf(".claim-")));
410
+ try {
411
+ await rename(claimPath, originalPath);
412
+ } catch {}
413
+ }
414
+ } catch {}
415
+ }
416
+
417
+ async function claimPending(path: string): Promise<ClaimedFinding | undefined> {
418
+ const claimPath = `${path}.claim-${process.pid}-${randomUUID()}`;
419
+ try {
420
+ await rename(path, claimPath);
421
+ } catch {
422
+ return undefined;
423
+ }
424
+ try {
425
+ return {
426
+ claimPath,
427
+ originalPath: path,
428
+ finding: JSON.parse(await readFile(claimPath, "utf8")) as PairFinding,
429
+ };
430
+ } catch {
431
+ try {
432
+ await unlink(claimPath);
433
+ } catch {}
434
+ return undefined;
435
+ }
436
+ }
437
+
438
+ async function adaptiveContent(content: string, maxBytes: number): Promise<{ content: string; partial: boolean }> {
439
+ if (Buffer.byteLength(content, "utf8") <= maxBytes) return { content, partial: false };
440
+ const chars = Math.max(1000, Math.floor(maxBytes / 2));
441
+ return {
442
+ content: `${content.slice(0, chars)}\n\n[... middle omitted by codex-pair size bound ...]\n\n${content.slice(-chars)}`,
443
+ partial: true,
444
+ };
445
+ }
446
+
447
+ function activeConcerns(
448
+ concerns: { high: string[]; med: string[]; low: string[] },
449
+ acks: Record<string, unknown>,
450
+ ): { high: string[]; med: string[]; low: string[] } {
451
+ const prepare = (body: string) => {
452
+ const concernHash = hashConcernBody(body);
453
+ return concernHash in acks ? undefined : `${body}\n[Acknowledge: /codex-pair-ack ${concernHash} <reason>]`;
454
+ };
455
+ return {
456
+ high: concerns.high.map(prepare).filter((body): body is string => body !== undefined),
457
+ med: concerns.med.map(prepare).filter((body): body is string => body !== undefined),
458
+ low: concerns.low.map(prepare).filter((body): body is string => body !== undefined),
459
+ };
460
+ }
461
+
462
+ function isQuotaError(error: unknown): boolean {
463
+ const text = (error instanceof Error ? error.message : String(error)).toLowerCase();
464
+ return ["quota", "usage limit", "rate limit", "429", "insufficient_quota"].some((part) => text.includes(part));
465
+ }
466
+
467
+ export function registerCodexPair(pi: ExtensionAPI): void {
468
+ const scheduled = new Map<string, ScheduledReview>();
469
+ const active = new Set<Promise<void>>();
470
+ const controllers = new Set<AbortController>();
471
+ let closed = false;
472
+ let epoch = 0;
473
+ const initializedSessions = new Set<string>();
474
+
475
+ const notifyRefusal = (ctx: ExtensionContext, message: string) => {
476
+ if (ctx.hasUI) ctx.ui.notify(message, "warning");
477
+ };
478
+
479
+ const sessionOwnsProject = async (ctx: ExtensionContext, markerDir: string): Promise<boolean> => {
480
+ if (!ctx.isProjectTrusted()) return false;
481
+ return (await findMarkerUp(ctx.cwd)) === markerDir;
482
+ };
483
+
484
+ const pairIsPaused = async (markerDir: string): Promise<boolean> => {
485
+ const pauseInfo = readPauseInfo(markerDir);
486
+ if (!pauseInfo) return false;
487
+ const decision = resolveAutoResume(pauseInfo, {
488
+ now: Date.now(),
489
+ currentVersion: readPluginVersion(),
490
+ });
491
+ if (!decision.resume || !clearAutoPause(markerDir, pauseInfo)) return true;
492
+ await appendLog(markerDir, {
493
+ timestamp: new Date().toISOString(),
494
+ verdict: "auto_resumed",
495
+ source: "pi",
496
+ reason: decision.why,
497
+ });
498
+ return false;
499
+ };
500
+
501
+ const deliverClaimed = async (claimed: ClaimedFinding) => {
502
+ if (closed) {
503
+ try {
504
+ await rename(claimed.claimPath, claimed.originalPath);
505
+ } catch {}
506
+ return;
507
+ }
508
+ try {
509
+ const current = await readFile(claimed.finding.file);
510
+ if (hash(current.toString("utf8")) !== claimed.finding.contentHash) {
511
+ await unlink(claimed.claimPath);
512
+ return;
513
+ }
514
+ if (closed) {
515
+ await rename(claimed.claimPath, claimed.originalPath);
516
+ return;
517
+ }
518
+ sendFinding(pi, claimed.finding);
519
+ await markReviewed(claimed.finding);
520
+ await unlink(claimed.claimPath);
521
+ } catch (error) {
522
+ try {
523
+ await rename(claimed.claimPath, claimed.originalPath);
524
+ } catch {}
525
+ throw error;
526
+ }
527
+ };
528
+
529
+ const drainPending = async (markerDir: string) => {
530
+ await recoverAbandonedClaims(markerDir);
531
+ while (!closed) {
532
+ let names: string[];
533
+ try {
534
+ names = (await readdir(pendingRoot(markerDir)))
535
+ .filter((name) => name.endsWith(".json"))
536
+ .sort()
537
+ .slice(0, 8);
538
+ } catch {
539
+ return;
540
+ }
541
+ if (names.length === 0) return;
542
+ let claimedAny = false;
543
+ for (const name of names) {
544
+ const claimed = await claimPending(join(pendingRoot(markerDir), name));
545
+ if (!claimed) continue;
546
+ claimedAny = true;
547
+ await deliverClaimed(claimed);
548
+ }
549
+ if (!claimedAny) await new Promise((resolveWait) => setTimeout(resolveWait, LOCK_CONTENTION_RETRY_MS));
550
+ }
551
+ };
552
+
553
+ const armReview = (
554
+ file: string,
555
+ work: ScheduledReview,
556
+ delay: number,
557
+ myEpoch: number,
558
+ ctx: ExtensionContext,
559
+ ) => {
560
+ if (work.timer) clearTimeout(work.timer);
561
+ work.timer = setTimeout(() => {
562
+ work.timer = undefined;
563
+ const promise = review(file, work.generation, myEpoch, ctx).catch(async (error) => {
564
+ if (closed || error?.name === "AbortError") return;
565
+ const reason = error instanceof Error ? error.message : String(error);
566
+ await appendLog(work.markerDir, {
567
+ timestamp: new Date().toISOString(),
568
+ tool: work.toolName,
569
+ file,
570
+ verdict: "error",
571
+ reason: `Pi: ${reason}`,
572
+ });
573
+ if (isQuotaError(error)) {
574
+ writeAutoPause(work.markerDir, { kind: "quota", reason });
575
+ } else {
576
+ const failures = recordReviewFailure(work.markerDir, reason);
577
+ if (failures >= AUTOPAUSE_FAILURE_THRESHOLD)
578
+ writeAutoPause(work.markerDir, { kind: "failures", reason });
579
+ }
580
+ notifyRefusal(ctx, `codex-pair review failed: ${reason}`);
581
+ });
582
+ active.add(promise);
583
+ promise.finally(() => active.delete(promise));
584
+ }, delay);
585
+ };
586
+
587
+ const schedule = async (file: string, markerDir: string, toolName: string, ctx: ExtensionContext) => {
588
+ if (closed || ctx.mode === "print") return;
589
+ const now = Date.now();
590
+ const previous = scheduled.get(file);
591
+ if (previous?.timer) clearTimeout(previous.timer);
592
+ const next: ScheduledReview = {
593
+ generation: (previous?.generation ?? 0) + 1,
594
+ firstAt: previous?.firstAt ?? now,
595
+ markerDir,
596
+ toolName,
597
+ };
598
+ scheduled.set(file, next);
599
+ const { config } = await readConfig(markerDir);
600
+ const remainingToCap = Math.max(0, config.debounceMaxMs - (now - next.firstAt));
601
+ const delay = Math.min(config.debounceMs, remainingToCap);
602
+ const myEpoch = epoch;
603
+ armReview(file, next, delay, myEpoch, ctx);
604
+ };
605
+
606
+ const review = async (file: string, generation: number, myEpoch: number, ctx: ExtensionContext): Promise<void> => {
607
+ if (closed || myEpoch !== epoch) return;
608
+ const work = scheduled.get(file);
609
+ if (!work || work.generation !== generation) return;
610
+ if (!(await sessionOwnsProject(ctx, work.markerDir)) || !(await isAllowed(work.markerDir)) || (await pairIsPaused(work.markerDir)))
611
+ return;
612
+
613
+ const { config, context } = await readConfig(work.markerDir);
614
+ const lock = tryAcquireInflightLock(
615
+ work.markerDir,
616
+ file,
617
+ Math.max(INFLIGHT_TTL_MIN_MS, resolveCodexTimeoutMs()) + 60_000,
618
+ );
619
+ if (!lock.acquired) {
620
+ work.firstAt = Date.now();
621
+ if (scheduled.get(file)?.generation === generation)
622
+ armReview(file, work, LOCK_CONTENTION_RETRY_MS, myEpoch, ctx);
623
+ return;
624
+ }
625
+
626
+ try {
627
+ const rawBuffer = await readFile(file);
628
+ if (isBinaryContent(rawBuffer)) {
629
+ deleteScheduledIfOwned(scheduled, file, generation);
630
+ return;
631
+ }
632
+ const raw = rawBuffer.toString("utf8");
633
+ const contentHash = hash(raw);
634
+ if ((await readReviewed(work.markerDir, file))?.contentHash === contentHash) {
635
+ deleteScheduledIfOwned(scheduled, file, generation);
636
+ return;
637
+ }
638
+ const view = await adaptiveContent(raw, config.maxFileBytes);
639
+ const { buildReviewPrompt } = await import("../../scripts/lib/prompt.mjs");
640
+ const prompt = buildReviewPrompt({
641
+ filePath: file,
642
+ fileContent: view.content,
643
+ toolName: `Pi ${work.toolName}`,
644
+ projectContext: context,
645
+ partialView: view.partial,
646
+ });
647
+ const cacheKey = computeCacheKey({
648
+ model: config.model,
649
+ prompt,
650
+ fileContent: view.content,
651
+ surfaceThreshold: config.surfaceThreshold,
652
+ });
653
+ const startedAt = Date.now();
654
+ let concerns = await getCachedConcerns(work.markerDir, cacheKey);
655
+ let fellBack = false;
656
+ let cached = true;
657
+ if (!concerns) {
658
+ cached = false;
659
+ const controller = new AbortController();
660
+ controllers.add(controller);
661
+ try {
662
+ const result = await executeCodexCLI({
663
+ prompt,
664
+ model: config.model,
665
+ sandbox: "read-only",
666
+ signal: controller.signal,
667
+ });
668
+ concerns = parseConcerns(result.response);
669
+ fellBack = result.usage?.fellBack ?? false;
670
+ await setCachedConcerns(work.markerDir, cacheKey, {
671
+ ...concerns,
672
+ durationMs: Date.now() - startedAt,
673
+ });
674
+ } finally {
675
+ controllers.delete(controller);
676
+ }
677
+ }
678
+ if (closed || myEpoch !== epoch) return;
679
+ const revalidate = async (): Promise<RevalidationState> => {
680
+ if (closed || myEpoch !== epoch) return "closed";
681
+ const current = scheduled.get(file);
682
+ if (!current || current.generation !== generation) return "superseded";
683
+ const latest = await readFile(file, "utf8");
684
+ if (closed || myEpoch !== epoch) return "closed";
685
+ if (scheduled.get(file)?.generation !== generation) return "superseded";
686
+ if (hash(latest) === contentHash) return "current";
687
+ if (scheduled.get(file)?.generation === generation) {
688
+ work.firstAt = Date.now();
689
+ await schedule(file, work.markerDir, work.toolName, ctx);
690
+ }
691
+ return "stale";
692
+ };
693
+ if ((await revalidate()) !== "current") return;
694
+ concerns = activeConcerns(concerns, readAcks(work.markerDir));
695
+ clearReviewFailures(work.markerDir);
696
+ const durationMs = Date.now() - startedAt;
697
+ const message = buildVerdictMessage({
698
+ filePath: file,
699
+ concerns,
700
+ fellBack,
701
+ durationMs,
702
+ surfaceThreshold: config.surfaceThreshold,
703
+ cached,
704
+ logPath: logPath(work.markerDir),
705
+ });
706
+ const finding: PairFinding = {
707
+ id: randomUUID(),
708
+ file,
709
+ markerDir: work.markerDir,
710
+ contentHash,
711
+ message,
712
+ createdAt: new Date().toISOString(),
713
+ };
714
+ await appendLog(work.markerDir, {
715
+ timestamp: finding.createdAt,
716
+ tool: work.toolName,
717
+ file,
718
+ verdict: concerns.high.length + concerns.med.length + concerns.low.length === 0 ? "none" : "concerns",
719
+ source: "pi",
720
+ fellBack,
721
+ cached,
722
+ counts: { high: concerns.high.length, med: concerns.med.length, low: concerns.low.length },
723
+ durationMs,
724
+ });
725
+ if ((await revalidate()) !== "current") return;
726
+ await persistFinding(finding);
727
+ const persistedState = await revalidate();
728
+ if (persistedState === "closed") return;
729
+ if (shouldDiscardPending(persistedState)) {
730
+ try {
731
+ await unlink(pendingPath(finding.markerDir, finding.id));
732
+ } catch {}
733
+ return;
734
+ }
735
+ deleteScheduledIfOwned(scheduled, file, generation);
736
+ const claimed = await claimPending(pendingPath(finding.markerDir, finding.id));
737
+ if (claimed) await deliverClaimed(claimed);
738
+ } finally {
739
+ releaseInflightLock(lock.lockPath);
740
+ }
741
+ };
742
+
743
+ pi.on("tool_result", async (event, ctx) => {
744
+ if (closed || event.isError || (event.toolName !== "edit" && event.toolName !== "write")) return;
745
+ const target = normalizeToolPath((event.input as { path?: unknown })?.path, ctx.cwd);
746
+ if (!target || shouldSkip(target)) return;
747
+ const file = await canonicalRegularFile(target);
748
+ if (!file) return;
749
+ const markerDir = await findMarkerUp(file);
750
+ if (!markerDir) return;
751
+ if (!(await sessionOwnsProject(ctx, markerDir))) {
752
+ notifyRefusal(ctx, "codex-pair is disabled: the edited file is outside Pi's trusted project.");
753
+ return;
754
+ }
755
+ if (!(await isAllowed(markerDir))) {
756
+ notifyRefusal(ctx, "codex-pair marker found, but this project is not in your Pi allowlist. Run /codex-pair to consent.");
757
+ return;
758
+ }
759
+ await schedule(file, markerDir, event.toolName, ctx);
760
+ });
761
+
762
+ pi.on("session_start", async (_event, ctx) => {
763
+ const sessionId = ctx.sessionManager.getSessionId();
764
+ if (initializedSessions.has(sessionId)) return;
765
+ initializedSessions.add(sessionId);
766
+ if (ctx.mode === "print") return;
767
+ const markerDir = await findMarkerUp(ctx.cwd);
768
+ if (!markerDir || !(await sessionOwnsProject(ctx, markerDir)) || !(await isAllowed(markerDir))) return;
769
+ await drainPending(markerDir);
770
+ if (await pairIsPaused(markerDir)) notifyRefusal(ctx, "codex-pair is paused for this project.");
771
+ });
772
+
773
+ pi.on("session_shutdown", async () => {
774
+ if (closed) return;
775
+ closed = true;
776
+ epoch += 1;
777
+ for (const work of scheduled.values()) if (work.timer) clearTimeout(work.timer);
778
+ scheduled.clear();
779
+ for (const controller of controllers) controller.abort(new DOMException("Pi session shut down", "AbortError"));
780
+ controllers.clear();
781
+ await Promise.race([
782
+ Promise.allSettled([...active]),
783
+ new Promise<void>((resolveWait) => setTimeout(resolveWait, 5_000)),
784
+ ]);
785
+ });
786
+
787
+ pi.registerCommand("codex-pair", {
788
+ description: "Show Pi codex-pair status, grant explicit project consent, or revoke it",
789
+ handler: async (args, ctx) => {
790
+ const markerDir = await findMarkerUp(ctx.cwd);
791
+ if (!markerDir) {
792
+ notifyRefusal(ctx, "No .codex-pair/context.md marker found. Run /skill:codex-pair for setup instructions.");
793
+ return;
794
+ }
795
+ if (args.trim() === "revoke") {
796
+ await setAllowed(markerDir, false);
797
+ ctx.ui.notify(`codex-pair consent revoked for ${markerDir}`, "info");
798
+ return;
799
+ }
800
+ if (!ctx.isProjectTrusted()) {
801
+ notifyRefusal(ctx, "Project trust is required before codex-pair can be enabled.");
802
+ return;
803
+ }
804
+ if (await isAllowed(markerDir)) {
805
+ ctx.ui.notify(`codex-pair is allowed for ${markerDir}${(await pairIsPaused(markerDir)) ? " (paused)" : ""}`, "info");
806
+ return;
807
+ }
808
+ if (!ctx.hasUI) {
809
+ notifyRefusal(ctx, "Consent requires interactive Pi. Open Pi in TUI mode and run /codex-pair.");
810
+ return;
811
+ }
812
+ const confirmed = await ctx.ui.confirm(
813
+ "Enable codex-pair for this project?",
814
+ `This sends bounded edited-file content and .codex-pair/context.md to the Codex CLI account configured on this machine and may consume subscription quota. Project: ${markerDir}`,
815
+ );
816
+ if (!confirmed) return;
817
+ await setAllowed(markerDir, true);
818
+ ctx.ui.notify(`codex-pair enabled for ${markerDir}. Revoke with /codex-pair revoke.`, "info");
819
+ },
820
+ });
821
+
822
+ pi.registerCommand("codex-pair-pause", {
823
+ description: "Pause Pi codex-pair in the current marked project",
824
+ handler: async (_args, ctx) => {
825
+ const markerDir = await findMarkerUp(ctx.cwd);
826
+ if (!markerDir) return notifyRefusal(ctx, "No codex-pair marker found.");
827
+ await mkdir(stateRoot(markerDir), { recursive: true });
828
+ await writeFile(pausePath(markerDir), "", { flag: "a", mode: 0o600 });
829
+ ctx.ui.notify("codex-pair paused", "info");
830
+ },
831
+ });
832
+
833
+ pi.registerCommand("codex-pair-resume", {
834
+ description: "Resume Pi codex-pair in the current marked project",
835
+ handler: async (_args, ctx) => {
836
+ const markerDir = await findMarkerUp(ctx.cwd);
837
+ if (!markerDir) return notifyRefusal(ctx, "No codex-pair marker found.");
838
+ try {
839
+ await unlink(pausePath(markerDir));
840
+ } catch {}
841
+ clearReviewFailures(markerDir);
842
+ ctx.ui.notify("codex-pair resumed", "info");
843
+ },
844
+ });
845
+
846
+ pi.registerCommand("codex-pair-ack", {
847
+ description: "Dismiss a Pi codex-pair finding reminder: /codex-pair-ack <16-char-hash> <reason>",
848
+ handler: async (args, ctx) => {
849
+ const match = args.trim().match(/^([a-f0-9]{16})\s+(.+)$/i);
850
+ if (!match) return notifyRefusal(ctx, "Usage: /codex-pair-ack <16-char-hash> <reason>");
851
+ const markerDir = await findMarkerUp(ctx.cwd);
852
+ if (!markerDir) return notifyRefusal(ctx, "No codex-pair marker found.");
853
+ addAck(markerDir, match[1].toLowerCase(), { reason: match[2] });
854
+ ctx.ui.notify(`Acknowledged ${match[1].toLowerCase()} (suppresses that reminder; it does not block reviews).`, "info");
855
+ },
856
+ });
857
+ }
858
+
859
+ export const __testing = {
860
+ activeConcerns,
861
+ adaptiveContent,
862
+ allowlistPath,
863
+ deleteScheduledIfOwned,
864
+ findMarkerUp,
865
+ normalizeToolPath,
866
+ readAllowlist,
867
+ setAllowed,
868
+ shouldDiscardPending,
869
+ shouldSkip,
870
+ };