@ask-llm/plugin 0.18.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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ask-llm",
3
- "version": "0.18.0",
3
+ "version": "0.19.0",
4
4
  "description": "AI-to-AI collaboration — review code and debate plans across Codex, Grok, Antigravity, Ollama, and Gemini",
5
5
  "author": {
6
6
  "name": "Anton Lykhoyda",
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "ask-llm",
3
3
  "displayName": "Ask LLM",
4
- "version": "0.18.0",
4
+ "version": "0.19.0",
5
5
  "description": "Portable AI-to-AI review and pairing workflows for Cursor Agent with explicit provider transports.",
6
6
  "author": {
7
7
  "name": "Anton Lykhoyda"
package/CHANGELOG.md CHANGED
@@ -1,5 +1,21 @@
1
1
  # @ask-llm/plugin
2
2
 
3
+ ## 0.19.0
4
+
5
+ ### Minor Changes
6
+
7
+ - [#340](https://github.com/Lykhoyda/ask-llm/pull/340) [`d9c4ff8`](https://github.com/Lykhoyda/ask-llm/commit/d9c4ff804fe3349dffa16be451b58ac39bd6d5da) Thanks [@Lykhoyda](https://github.com/Lykhoyda)! - Keep the codex-pair broker opt-in with project and environment controls, stale-broker cleanup, and direct-review fallback on broker protocol failures.
8
+
9
+ - [#340](https://github.com/Lykhoyda/ask-llm/pull/340) [`d9c4ff8`](https://github.com/Lykhoyda/ask-llm/commit/d9c4ff804fe3349dffa16be451b58ac39bd6d5da) Thanks [@Lykhoyda](https://github.com/Lykhoyda)! - Author the codex-pair hooks and broker in TypeScript, ship them as generated JavaScript, and require Node 24 or newer.
10
+
11
+ ### Patch Changes
12
+
13
+ - [#340](https://github.com/Lykhoyda/ask-llm/pull/340) [`d9c4ff8`](https://github.com/Lykhoyda/ask-llm/commit/d9c4ff804fe3349dffa16be451b58ac39bd6d5da) Thanks [@Lykhoyda](https://github.com/Lykhoyda)! - Make the opt-in codex-pair broker safe to tear down and recover, keep its socket usable on deep project paths, and give broker reviews the same quota fallback and transient retry as direct reviews.
14
+
15
+ - [#341](https://github.com/Lykhoyda/ask-llm/pull/341) [`97030be`](https://github.com/Lykhoyda/ask-llm/commit/97030bec4dd923ed64b8f5bf3378493da3e2c316) Thanks [@Lykhoyda](https://github.com/Lykhoyda)! - Keep a codex-pair broker running and tracked when `ps` cannot verify it, and give a stalled broker review half the review timeout so its direct fallback still finishes within one timeout.
16
+
17
+ - [#338](https://github.com/Lykhoyda/ask-llm/pull/338) [`52f5230`](https://github.com/Lykhoyda/ask-llm/commit/52f5230a1e67ea499d18a3250801780ac39bdd4e) Thanks [@Lykhoyda](https://github.com/Lykhoyda)! - Insert codex-pair file and context text literally to prevent phantom injected-prompt findings.
18
+
3
19
  ## 0.18.0
4
20
 
5
21
  ### Minor Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ask-llm/plugin",
3
- "version": "0.18.0",
3
+ "version": "0.19.0",
4
4
  "description": "Ask LLM review, comparison, brainstorming, image, verification, and pairing workflows for Claude Code, Cursor Agent, and Pi",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -14,8 +14,9 @@
14
14
  },
15
15
  "scripts": {
16
16
  "build": "tsc",
17
+ "build:hooks": "tsc -p scripts/tsconfig.json && chmod 755 scripts/codex-pair-debounce-worker.mjs scripts/codex-pair-prompt-drain.mjs scripts/codex-pair-session.mjs scripts/codex-pair-stop-gate.mjs scripts/codex-pair-watch.mjs",
17
18
  "test": "vitest run",
18
- "lint": "biome check src/ pi/ && biome lint scripts/ && tsc --noEmit && tsc --noEmit -p pi/tsconfig.json"
19
+ "lint": "biome check src/ pi/ && biome lint scripts/ && tsc --noEmit && tsc --noEmit -p pi/tsconfig.json && tsc -p scripts/tsconfig.json --noEmit"
19
20
  },
20
21
  "keywords": [
21
22
  "pi-package",
@@ -56,7 +57,7 @@
56
57
  "prompts/",
57
58
  "README.md",
58
59
  "scripts/*.mjs",
59
- "scripts/lib/",
60
+ "scripts/lib/*.mjs",
60
61
  "skills/"
61
62
  ],
62
63
  "pi": {
@@ -96,7 +97,7 @@
96
97
  "typebox": "*"
97
98
  },
98
99
  "engines": {
99
- "node": ">=20.0.0"
100
+ "node": ">=24.0.0"
100
101
  },
101
102
  "devDependencies": {
102
103
  "@biomejs/biome": "^2.5.10",
@@ -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));