@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,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));