ccdeck 3.43.3 → 3.44.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 (82) hide show
  1. package/README.md +74 -20
  2. package/THIRD_PARTY_NOTICES.md +43 -7
  3. package/bin/cli/commands.js +613 -0
  4. package/bin/cli/help.js +26 -3
  5. package/bin/cli/one-shot.js +2 -2
  6. package/bin/cli/picker.js +86 -0
  7. package/bin/cli/startup.js +13 -0
  8. package/bin/deck.js +60 -16
  9. package/dist/web/assets/{BrowserWatchModal-DZTMfoGp.js → BrowserWatchModal-DMIUygWz.js} +1 -1
  10. package/dist/web/assets/{RadioPanel-DFqW-MUV.js → RadioPanel-DNB_AHSs.js} +1 -1
  11. package/dist/web/assets/TrafficRadar-Ch0LVdSl.js +8 -0
  12. package/dist/web/assets/UsageHistoryModal-Du0ZkIJ1.js +1 -0
  13. package/dist/web/assets/app-CQIstkMF.js +318 -0
  14. package/dist/web/assets/app-Ch-0CYlK.css +1 -0
  15. package/dist/web/assets/{index-DqxnkNs2.js → index-BQbOfxbL.js} +1 -1
  16. package/dist/web/index.html +3 -3
  17. package/package.json +2 -1
  18. package/release-notes.json +40 -0
  19. package/src/server/account-notify.mjs +206 -9
  20. package/src/server/account-pick.mjs +160 -0
  21. package/src/server/account-projects.mjs +55 -15
  22. package/src/server/account-routes.mjs +47 -4
  23. package/src/server/account-watch.mjs +48 -4
  24. package/src/server/activity.mjs +3 -2
  25. package/src/server/args.mjs +112 -2
  26. package/src/server/autoswitch-failover.mjs +138 -0
  27. package/src/server/claude-accounts.mjs +115 -14
  28. package/src/server/claude-dir.mjs +142 -5
  29. package/src/server/claude-identity.mjs +23 -0
  30. package/src/server/cli-lines.mjs +224 -0
  31. package/src/server/codex-cost-usage.mjs +67 -0
  32. package/src/server/codex-enrichment.mjs +53 -9
  33. package/src/server/codex-review-policy.d.mts +4 -0
  34. package/src/server/codex-review-policy.mjs +11 -0
  35. package/src/server/codex-skills.mjs +146 -0
  36. package/src/server/codex-watch.mjs +5 -2
  37. package/src/server/cswap-auto-choice.mjs +53 -0
  38. package/src/server/cswap-auto-loop.mjs +100 -9
  39. package/src/server/deck-client.mjs +112 -0
  40. package/src/server/deck-prefs.mjs +30 -6
  41. package/src/server/event-pipeline.mjs +19 -3
  42. package/src/server/event-routes.mjs +15 -0
  43. package/src/server/exec.mjs +6 -2
  44. package/src/server/index.mjs +17 -1
  45. package/src/server/installer.mjs +149 -8
  46. package/src/server/lan-copies.mjs +43 -5
  47. package/src/server/lan-deck.mjs +3 -0
  48. package/src/server/lan-engine.mjs +7 -2
  49. package/src/server/lan-manifest.mjs +2 -2
  50. package/src/server/lan-serve.mjs +17 -2
  51. package/src/server/lan-sync.mjs +5 -5
  52. package/src/server/lan-wire.mjs +3 -3
  53. package/src/server/login-guard.mjs +162 -0
  54. package/src/server/login-service.mjs +3 -0
  55. package/src/server/pinned-build.mjs +5 -0
  56. package/src/server/prefs-routes.mjs +4 -0
  57. package/src/server/process-command.mjs +4 -1
  58. package/src/server/reports.mjs +7 -1
  59. package/src/server/request-gates.mjs +7 -0
  60. package/src/server/session-read-gate.mjs +5 -3
  61. package/src/server/session-summary.mjs +281 -0
  62. package/src/server/skill-hook.mjs +162 -0
  63. package/src/server/stop-continue-gate.d.mts +8 -0
  64. package/src/server/stop-continue-gate.mjs +77 -0
  65. package/src/server/stop-continue.mjs +544 -0
  66. package/src/server/stop-failure.d.mts +10 -0
  67. package/src/server/stop-failure.mjs +97 -0
  68. package/src/server/swap-log.mjs +19 -10
  69. package/src/server/tool-audit-report.mjs +5 -3
  70. package/src/server/tool-audit-routes.mjs +5 -3
  71. package/src/server/tool-audit-skills.mjs +82 -0
  72. package/src/server/tool-audit.mjs +53 -11
  73. package/src/server/traffic-radar-capture.mjs +10 -1
  74. package/src/server/traffic-radar-monitor.mjs +4 -2
  75. package/src/server/traffic-radar-routes.mjs +13 -0
  76. package/src/server/traffic-radar-service.mjs +133 -0
  77. package/src/server/traffic-radar-wire.mjs +4 -3
  78. package/src/server/usage-day.mjs +1 -1
  79. package/dist/web/assets/TrafficRadar-CPzsjc2O.js +0 -1
  80. package/dist/web/assets/UsageHistoryModal-CfVS-N_Z.js +0 -1
  81. package/dist/web/assets/app-DsHSufBt.css +0 -1
  82. package/dist/web/assets/app-HpFVXK_g.js +0 -310
@@ -0,0 +1,544 @@
1
+ // Continuing a background session whose turn stopped on an expired login or a
2
+ // usage limit (#2193), by a press of Continue or, when the user has switched
3
+ // it on, by itself once a working login is active again.
4
+ //
5
+ // WHAT IS RUN, AND WHY IT IS THAT. Claude Code has no command that hands a
6
+ // message to a background session from a script. Agent view's reply and
7
+ // `claude attach` both need a terminal, and `claude --resume <id> "prompt"`
8
+ // sends the prompt to a running background session only from one: with piped
9
+ // output it "sends nothing and exits with status 1" (code.claude.com/docs/en/
10
+ // sessions, "Resume a running background session"). What it does document,
11
+ // for exactly this case, is the pair: "To continue the conversation itself in
12
+ // a session of your own … run `claude stop <id>` and then repeat the command",
13
+ // and `claude --resume <session-id> --bg "<prompt>"`, which "continues that
14
+ // session under the same ID, or starts a copy under a new ID and prints a
15
+ // `note:` line" (code.claude.com/docs/en/agent-view, "Dispatch an agent from
16
+ // your shell"; v2.1.257 and later). So a continue is, in order:
17
+ //
18
+ // claude agents --json --all which session this is, whether its process
19
+ // is alive, and what it is doing — the
20
+ // documented reader of that state
21
+ // claude stop <id> only when its process is alive and idle
22
+ // claude agents --json --all until Claude Code no longer lists a live
23
+ // process for it; nothing is sent otherwise
24
+ // claude --resume <session> --bg "Continue from where you left off."
25
+ // in the session's own directory
26
+ //
27
+ // NEVER A SECOND PROCESS ON ONE SESSION. The resume is run only once Claude
28
+ // Code itself reports no live process for the session, and Claude Code's own
29
+ // rule for a session that is still running is to start a copy rather than a
30
+ // second process beside it — which the `note:` line says, and the deck reports.
31
+ // A session that is busy, waiting on a permission prompt or a dialog, or
32
+ // between steps of work it drives itself (`working`) is left alone.
33
+ //
34
+ // An interactive session in a terminal is never touched: the deck cannot type
35
+ // into it, and the page says to type "continue" there instead.
36
+ //
37
+ // NOT AS A CHILD OF ANOTHER SESSION. A deck started from a Claude Code
38
+ // session's Bash tool carries that session's markers in its environment
39
+ // (CLAUDE_CODE_CHILD_SESSION, CLAUDE_JOB_DIR, its messaging socket …), and a
40
+ // `claude` that inherits them is that session's nested child: Claude Code
41
+ // leaves such a session out of `claude agents` and `--resume`, and its job
42
+ // folder is somebody else's (code.claude.com/docs/en/env-vars). So the three
43
+ // commands run with those markers taken out, and with everything else — the
44
+ // config dir, the provider, the PATH — as the deck has it.
45
+ //
46
+ // THE AUTOMATIC HALF is off until the user turns it on (prefs `continueStopped`).
47
+ // With it on, each stop that a hook posted to this process live is continued
48
+ // at most once, only once the gate in stop-continue-gate.mjs says a working
49
+ // login with room is active, only for a session Claude Code itself lists as
50
+ // background and blocked or failed, and never again for a session that stops
51
+ // again within REPEAT_HOLD_MS of being continued, until somebody acts on it.
52
+ // Across all sessions there is a cap per window and a pause after failures in
53
+ // a row. `/api/event` takes a post without the deck's token, so a stop is
54
+ // never enough on its own: what Claude Code lists is what decides.
55
+ import { statSync } from "node:fs";
56
+ import { dirname, isAbsolute, join, resolve } from "node:path";
57
+ import { fileURLToPath, pathToFileURL } from "node:url";
58
+ import { run } from "./exec.mjs";
59
+ import { adminClaudeBin } from "./claude-identity.mjs";
60
+ import { stopFailureOf, switchCanCure } from "./stop-failure.mjs";
61
+ import { CONTINUE_PROMPT, REPEAT_HOLD_MS, continueGate, loginEvidence } from "./stop-continue-gate.mjs";
62
+ import { heldPrefs } from "./prefs-state.mjs";
63
+ import { readBody, send } from "./http-io.mjs";
64
+
65
+ const PKG_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..");
66
+
67
+ /** A Claude Code session id: a UUID, and so never shaped like a flag. */
68
+ export const SESSION_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
69
+ /** A background session's short id, as `claude agents --json` prints it.
70
+ * Never with a leading dash, so it can only be read as the id. */
71
+ export const JOB_ID_RE = /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/;
72
+
73
+ /** What a Claude Code session sets in the environment of what it spawns,
74
+ * saying "a child of THIS session": the four the env-vars reference names,
75
+ * and the per-session ones beside them on 2.1.303 (its messaging socket and
76
+ * token, its pid, its entry point, its binary and its effort). */
77
+ export const SESSION_MARKERS = Object.freeze([
78
+ "CLAUDECODE", "CLAUDE_CODE_CHILD_SESSION", "CLAUDE_CODE_SESSION_ID", "CLAUDE_JOB_DIR",
79
+ "CLAUDE_CODE_MESSAGING_SOCKET", "CLAUDE_CODE_MESSAGING_TOKEN", "CLAUDE_PID",
80
+ "CLAUDE_CODE_ENTRYPOINT", "CLAUDE_CODE_EXECPATH", "CLAUDE_CODE_SESSION_ATTENDED", "CLAUDE_EFFORT",
81
+ ]);
82
+
83
+ /** The environment the continue's commands run in: the deck's own, without
84
+ * another session's markers — see the header. Matched without regard to
85
+ * case, the way Windows reads its environment. */
86
+ export function continueEnv(env = process.env) {
87
+ const drop = new Set(SESSION_MARKERS);
88
+ const out = {};
89
+ for (const [k, v] of Object.entries(env)) if (!drop.has(k.toUpperCase()) && v !== undefined) out[k] = v;
90
+ return out;
91
+ }
92
+
93
+ /** The documented reader of background-session state, completed ones too. */
94
+ export function agentsArgv() {
95
+ return ["agents", "--json", "--all"];
96
+ }
97
+
98
+ /** `claude stop <id>`, or null for an id that is not one. */
99
+ export function stopArgv(jobId) {
100
+ return typeof jobId === "string" && JOB_ID_RE.test(jobId) ? ["stop", jobId] : null;
101
+ }
102
+
103
+ /** `claude --resume <session> --bg "Continue from where you left off."`, or
104
+ * null for a session id that is not one. The prompt is the positional
105
+ * argument: `--bg` refuses `-p`. */
106
+ export function resumeArgv(sessionId) {
107
+ return typeof sessionId === "string" && SESSION_ID_RE.test(sessionId)
108
+ ? ["--resume", sessionId, "--bg", CONTINUE_PROMPT]
109
+ : null;
110
+ }
111
+
112
+ /** `claude agents --json` prints a JSON array; take it out of anything printed
113
+ * around it, such as `Starting background service…`. */
114
+ function jsonArray(text) {
115
+ const s = String(text ?? "");
116
+ try { const v = JSON.parse(s); if (Array.isArray(v)) return v; } catch { /* below */ }
117
+ const a = s.indexOf("["), b = s.lastIndexOf("]");
118
+ if (a < 0 || b <= a) return null;
119
+ try { const v = JSON.parse(s.slice(a, b + 1)); return Array.isArray(v) ? v : null; } catch { return null; }
120
+ }
121
+
122
+ const STATUSES = new Set(["busy", "waiting", "idle"]);
123
+
124
+ /**
125
+ * What `claude agents --json --all` says about one session: `{ entry }` with
126
+ * the fields the plan reads, `{ entry: null }` when it lists nothing for it,
127
+ * or `{ entry: undefined }` when the output was not a list at all. Every field
128
+ * is checked: these are Claude Code's words, and the plan acts on them.
129
+ */
130
+ export function agentEntryOf(stdout, sessionId) {
131
+ const list = jsonArray(stdout);
132
+ if (!list) return { entry: undefined };
133
+ const mine = list.filter(e => e && typeof e === "object" && e.sessionId === sessionId);
134
+ if (!mine.length) return { entry: null };
135
+ const e = mine.find(x => x.kind === "background") ?? mine[0];
136
+ return {
137
+ entry: {
138
+ kind: e.kind === "background" ? "background" : e.kind === "interactive" ? "interactive" : "unknown",
139
+ id: typeof e.id === "string" && JOB_ID_RE.test(e.id) ? e.id : null,
140
+ state: typeof e.state === "string" ? e.state : null,
141
+ // `pid` and `status` are there while the process is alive (agent view's
142
+ // "List sessions as JSON").
143
+ alive: Number.isInteger(e.pid) && e.pid > 0,
144
+ status: STATUSES.has(e.status) ? e.status : null,
145
+ cwd: typeof e.cwd === "string" ? e.cwd : null,
146
+ },
147
+ };
148
+ }
149
+
150
+ /**
151
+ * What a continue would do with that entry: `{ id, stopFirst }`, or
152
+ * `{ refuse }` with why not. `auto` asks the stricter question the automatic
153
+ * continue asks: Claude Code must itself say the session is held up, which a
154
+ * session that finished its turn (`done`) is not.
155
+ */
156
+ export function continuePlan(entry, { auto = false } = {}) {
157
+ if (entry === undefined) return { refuse: "unreadable" };
158
+ if (!entry) return { refuse: "not_listed" };
159
+ if (entry.kind !== "background") return { refuse: "interactive" };
160
+ if (!entry.id) return { refuse: "not_listed" };
161
+ if (entry.state === "working" || (entry.alive && entry.status === "busy")) return { refuse: "working" };
162
+ // A live process that is not idle is waiting on something only a person can
163
+ // answer — a permission prompt, a dialog — and stopping it would drop that.
164
+ if (entry.alive && entry.status !== "idle") return { refuse: "waiting" };
165
+ if (auto && entry.state !== "blocked" && entry.state !== "failed") return { refuse: "not_held" };
166
+ return { id: entry.id, stopFirst: entry.alive };
167
+ }
168
+
169
+ /** Claude Code's `note:` line, when the resume started a copy instead. */
170
+ export function copyNote(text) {
171
+ const m = /^\s*note:\s*(.+)$/im.exec(stripAnsi(text));
172
+ return m ? oneLine(m[1]) : null;
173
+ }
174
+
175
+ const DETAIL_MAX = 240;
176
+
177
+ function stripAnsi(text) {
178
+ return String(text ?? "").replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "").replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)?/g, "");
179
+ }
180
+
181
+ /** One line of what a command said, short, with anything shaped like a
182
+ * credential masked: it is drawn on a card. */
183
+ function oneLine(text) {
184
+ const t = stripAnsi(text)
185
+ .replace(/[\p{Cc}\p{Cf}]/gu, " ")
186
+ .replace(/\bsk-ant-[A-Za-z0-9_-]+/g, "sk-ant-…")
187
+ .replace(/\b(Bearer)\s+[A-Za-z0-9._~+/=-]+/gi, "$1 …")
188
+ .replace(/\s+/g, " ")
189
+ .trim();
190
+ return t.length > DETAIL_MAX ? `${t.slice(0, DETAIL_MAX - 1).trimEnd()}…` : t;
191
+ }
192
+
193
+ /** What a failed run said: stderr when it wrote any, else stdout, else how it
194
+ * ended. */
195
+ function said(r) {
196
+ const text = oneLine(r?.stderr || r?.stdout || "");
197
+ if (text) return text;
198
+ if (r?.timedOut) return "timed out";
199
+ if (r?.code === "ENOENT") return "the claude CLI could not be run";
200
+ return `exited ${r?.code ?? "?"}`;
201
+ }
202
+
203
+ /** A directory that is there, or null. */
204
+ function usableDir(p) {
205
+ if (typeof p !== "string" || !p || !isAbsolute(p)) return null;
206
+ try { return statSync(p).isDirectory() ? p : null; } catch { return null; }
207
+ }
208
+
209
+ /** The automatic continue's limits. A window's cap, so a whole machine of
210
+ * sessions stopped at once cannot become a burst of Claude Code processes,
211
+ * and a pause after failures in a row, so a CLI that cannot do this is not
212
+ * asked again every minute. */
213
+ export const AUTO_CAP = 6;
214
+ export const AUTO_WINDOW_MS = 10 * 60_000;
215
+ export const FAIL_STREAK = 3;
216
+ export const FAIL_PAUSE_MS = 30 * 60_000;
217
+ /** How often the automatic continue looks again while a stop is waiting for a
218
+ * working login. The reading it asks for is cached and floored (quota.mjs),
219
+ * so this costs a file read or nothing on most passes. */
220
+ export const AUTO_EVERY_MS = 60_000;
221
+ /** How long a continue's outcome stays on its card. */
222
+ export const LAST_KEEP_MS = 6 * 60 * 60_000;
223
+ /** How many sessions this keeps anything for. `/api/event` takes a post
224
+ * without the deck's token, so this is bounded like every set it feeds. */
225
+ export const MAX_RECORDS = 256;
226
+ /** How many times, a second apart, a stopped session's process is looked for
227
+ * before the resume is given up. */
228
+ export const STOP_POLLS = 10;
229
+ /** How long a stop is worth continuing at all: Claude Code's own bound for
230
+ * continuing a turn that ended on an API error by itself (code.claude.com/
231
+ * docs/en/env-vars, CLAUDE_CODE_RESUME_INTERRUPTED_TURN_MAX_AGE_MS). Older,
232
+ * the automatic continue leaves it, and nothing keeps looking for it. */
233
+ export const STOP_KEEP_MS = 6 * 60 * 60_000;
234
+ /** How many passes a stop Claude Code does not yet call held up — its state
235
+ * not written yet, a prompt open in it — is looked at again before the
236
+ * automatic continue leaves it. */
237
+ export const NOT_NOW_LOOKS = 5;
238
+ /** The answers that mean "not now" rather than "no": nothing was run. */
239
+ const NOT_NOW = new Set(["not_held", "working", "waiting"]);
240
+
241
+ const STOP_KINDS = new Set(["login", "usage-limit", "api-error"]);
242
+
243
+ /** A stop the page says it is continuing, when this process never saw it — a
244
+ * deck restarted since. Only its kind and time are taken. */
245
+ function claimedStop(raw) {
246
+ if (!raw || typeof raw !== "object" || !STOP_KINDS.has(raw.kind)) return null;
247
+ const at = Number(raw.at);
248
+ return Number.isFinite(at) && at > 0 ? { kind: raw.kind, at } : null;
249
+ }
250
+
251
+ const TOOL_EVENTS = new Set(["PreToolUse", "PostToolUse", "PostToolUseFailure"]);
252
+
253
+ /**
254
+ * The continuer, with everything that reaches outside injected:
255
+ *
256
+ * run(bin, args, opts) exec.mjs's `run`
257
+ * bin() which `claude` to run (claude-identity.mjs)
258
+ * login({ force }) the gate's evidence (stop-continue-gate.mjs
259
+ * loginEvidence of the newest quota reading)
260
+ * autoOn() the setting, asked per pass
261
+ * environ() the deck's environment, which continueEnv cleans
262
+ * now, sleep, every the clock, a pause between polls, and the interval
263
+ *
264
+ * Every method answers rather than throws.
265
+ */
266
+ export function createStopContinuer({
267
+ run: exec, bin, login, autoOn = () => false, environ = () => process.env,
268
+ now = Date.now, sleep = ms => new Promise(r => setTimeout(r, ms)),
269
+ every = (fn, ms) => { const t = setInterval(fn, ms); t.unref?.(); return t; },
270
+ cancel = t => clearInterval(t),
271
+ } = {}) {
272
+ /** sid -> { stop, cwd, held, tried, autoAt, actedAt, last, touched } */
273
+ const records = new Map();
274
+ const inFlight = new Map();
275
+ const autoRuns = [];
276
+ let failStreak = 0;
277
+ let pausedUntil = 0;
278
+ let passing = false;
279
+ let forceNext = false;
280
+ let timer = null;
281
+
282
+ const record = sid => {
283
+ let r = records.get(sid);
284
+ if (!r) {
285
+ r = { stop: null, cwd: null, held: false, tried: false, looks: 0, autoAt: 0, actedAt: 0, last: null, touched: 0 };
286
+ records.set(sid, r);
287
+ if (records.size > MAX_RECORDS) {
288
+ const oldest = [...records].sort((a, b) => a[1].touched - b[1].touched)[0];
289
+ if (oldest && oldest[0] !== sid) records.delete(oldest[0]);
290
+ }
291
+ }
292
+ r.touched = now();
293
+ return r;
294
+ };
295
+
296
+ /** A stop the automatic continue may still act on. */
297
+ const pending = (r, t) => r.stop && switchCanCure(r.stop.kind) && !r.tried && !r.held && t - r.stop.at < STOP_KEEP_MS;
298
+ const waiting = () => { const t = now(); return [...records.values()].some(r => pending(r, t)); };
299
+
300
+ /** The interval runs while a stop is waiting for a login, and only then. */
301
+ const arm = () => {
302
+ if (waiting()) { if (!timer) timer = every(() => { void consider(); }, AUTO_EVERY_MS); }
303
+ else if (timer) { cancel(timer); timer = null; }
304
+ };
305
+
306
+ /**
307
+ * A hook event, as pushEvent takes it in from a live hook post. A
308
+ * StopFailure records the stop; anything that shows the session working
309
+ * again — its next prompt, a tool call of the root's own, a Stop, a new
310
+ * start — takes the stop down, the rule the page applies to its card
311
+ * (session-lifecycle.ts). A typed prompt that is not the deck's own continue
312
+ * is somebody acting on the session, which is what lifts the repeat hold.
313
+ */
314
+ function noteEvent(raw, at = now()) {
315
+ try {
316
+ const sid = raw?.session_id;
317
+ if (typeof sid !== "string" || !SESSION_ID_RE.test(sid)) return;
318
+ const name = raw.hook_event_name;
319
+ const stop = stopFailureOf(raw, at);
320
+ if (stop) {
321
+ const r = record(sid);
322
+ r.stop = { kind: stop.kind, error: stop.error, at };
323
+ r.cwd = typeof raw.cwd === "string" ? raw.cwd : r.cwd;
324
+ r.tried = false;
325
+ r.looks = 0;
326
+ r.held = r.autoAt > 0 && r.actedAt <= r.autoAt && at - r.autoAt < REPEAT_HOLD_MS;
327
+ arm();
328
+ return;
329
+ }
330
+ const r = records.get(sid);
331
+ if (!r) return;
332
+ if (name === "UserPromptSubmit" && typeof raw.prompt === "string" && raw.prompt.trim() !== CONTINUE_PROMPT) {
333
+ r.actedAt = Math.max(r.actedAt, at);
334
+ }
335
+ const resumes = name === "UserPromptSubmit" || name === "Stop" || name === "SessionStart"
336
+ || (TOOL_EVENTS.has(name) && !raw.agent_id);
337
+ if (resumes && r.stop && at > r.stop.at) { r.stop = null; arm(); }
338
+ } catch { /* never the ingest path's problem */ }
339
+ }
340
+
341
+ /** What the page reads: the setting, the gate's evidence when asked for,
342
+ * and each session's stop and the last continue's outcome. */
343
+ async function status({ withLogin = false } = {}) {
344
+ const t = now();
345
+ const sessions = {};
346
+ for (const [sid, r] of records) {
347
+ const last = r.last && t - r.last.at < LAST_KEEP_MS ? r.last : null;
348
+ if (!r.stop && !last) continue;
349
+ sessions[sid] = { stop: r.stop ? { kind: r.stop.kind, at: r.stop.at } : null, held: r.held, tried: r.tried, last };
350
+ }
351
+ let evidence = null;
352
+ if (withLogin) { try { evidence = await login({}); } catch { evidence = null; } }
353
+ return { ok: true, auto: autoOn() === true, login: evidence, sessions };
354
+ }
355
+
356
+ /** The continue itself, for one session — see the header. */
357
+ async function continueBackground(sid, cwdHint, { auto }) {
358
+ const claude = bin();
359
+ const env = continueEnv(environ());
360
+ const listed = await exec(claude, agentsArgv(), { timeout: 20_000, env });
361
+ if (!listed.ok) return { ok: false, reason: "cli_failed", detail: said(listed) };
362
+ const { entry } = agentEntryOf(listed.stdout, sid);
363
+ const plan = continuePlan(entry, { auto });
364
+ if (plan.refuse) return { ok: false, reason: plan.refuse };
365
+ const cwd = usableDir(entry.cwd) ?? usableDir(cwdHint);
366
+ if (!cwd) return { ok: false, reason: "no_dir" };
367
+ if (plan.stopFirst) {
368
+ const stopped = await exec(claude, stopArgv(plan.id), { timeout: 30_000, env });
369
+ if (!stopped.ok) return { ok: false, reason: "stop_failed", detail: said(stopped) };
370
+ let gone = false;
371
+ for (let i = 0; i < STOP_POLLS && !gone; i++) {
372
+ if (i > 0) await sleep(1_000);
373
+ const again = await exec(claude, agentsArgv(), { timeout: 20_000, env });
374
+ const e = again.ok ? agentEntryOf(again.stdout, sid).entry : undefined;
375
+ gone = e === null || (e !== undefined && !e.alive);
376
+ }
377
+ if (!gone) return { ok: false, reason: "still_running" };
378
+ }
379
+ const resumed = await exec(claude, resumeArgv(sid), { timeout: 90_000, cwd, env });
380
+ // Said with whether the deck stopped the session first: if it did, the
381
+ // session is now stopped, its conversation kept, and agent view or
382
+ // `claude attach` starts it again.
383
+ if (!resumed.ok) return { ok: false, reason: "resume_failed", detail: said(resumed), stopped: plan.stopFirst };
384
+ const note = copyNote(`${resumed.stdout}\n${resumed.stderr}`);
385
+ return note ? { ok: true, outcome: "copied", detail: note } : { ok: true, outcome: "continued" };
386
+ }
387
+
388
+ /** One continue, never two at once for one session. */
389
+ function runOnce(sid, cwdHint, opts) {
390
+ if (inFlight.has(sid)) return Promise.resolve({ ok: false, reason: "busy" });
391
+ const p = continueBackground(sid, cwdHint, opts)
392
+ .catch(err => ({ ok: false, reason: "cli_failed", detail: oneLine(err?.message ?? "") }))
393
+ .finally(() => inFlight.delete(sid));
394
+ inFlight.set(sid, p);
395
+ return p;
396
+ }
397
+
398
+ const keep = (r, out, auto) => {
399
+ r.last = {
400
+ at: now(), auto, ok: out.ok === true, outcome: out.outcome ?? null, reason: out.reason ?? null,
401
+ detail: out.detail ?? "", stopped: out.stopped === true,
402
+ };
403
+ };
404
+
405
+ /**
406
+ * A press of Continue: `claimed` is the stop the page drew, which stands in
407
+ * for one this process never saw (a deck restarted since). The gate is asked
408
+ * here too — the page drew the button from the same rule, and this is the
409
+ * one that decides.
410
+ */
411
+ async function press(sid, claimed) {
412
+ try {
413
+ if (typeof sid !== "string" || !SESSION_ID_RE.test(sid)) return { ok: false, reason: "bad_request" };
414
+ const known = records.get(sid)?.stop ?? null;
415
+ const stop = known ?? claimedStop(claimed);
416
+ if (!stop) return { ok: false, reason: "bad_request" };
417
+ const gate = continueGate(stop, await login({}).catch(() => null));
418
+ if (!gate.ready) return { ok: false, reason: "not_ready", gate: gate.reason };
419
+ const r = record(sid);
420
+ // Pressing is somebody acting on the session: a hold the automatic
421
+ // continue left on it is lifted.
422
+ r.actedAt = now();
423
+ r.held = false;
424
+ const out = await runOnce(sid, r.cwd, { auto: false });
425
+ // A press that ran is this stop's go, whatever came of it: one that
426
+ // failed is the user's to look at, not the automatic continue's to repeat.
427
+ if (out.reason !== "busy") { keep(r, out, false); r.tried = true; }
428
+ arm();
429
+ return out;
430
+ } catch {
431
+ return { ok: false, reason: "cli_failed" };
432
+ }
433
+ }
434
+
435
+ const capAllows = t => {
436
+ while (autoRuns.length && t - autoRuns[0] >= AUTO_WINDOW_MS) autoRuns.shift();
437
+ return autoRuns.length < AUTO_CAP;
438
+ };
439
+
440
+ /**
441
+ * One pass of the automatic continue. Off, it does nothing; on, it reads the
442
+ * gate's evidence once and continues every stop it clears, each once, one at
443
+ * a time, inside the cap. A pass that starts while one runs does nothing.
444
+ */
445
+ async function consider() {
446
+ if (passing) return;
447
+ passing = true;
448
+ try {
449
+ if (autoOn() !== true || !waiting() || now() < pausedUntil) return;
450
+ const force = forceNext;
451
+ forceNext = false;
452
+ const evidence = await login({ force }).catch(() => null);
453
+ for (const [sid, r] of records) {
454
+ if (!pending(r, now()) || !continueGate(r.stop, evidence).ready) continue;
455
+ if (autoOn() !== true || !capAllows(now())) break;
456
+ r.tried = true;
457
+ const out = await runOnce(sid, r.cwd, { auto: true });
458
+ if (out.reason === "busy") { r.tried = false; continue; }
459
+ // Not now: Claude Code does not call the session held up yet, or it is
460
+ // busy or waiting on a person. Looked at again a few passes, then left.
461
+ if (NOT_NOW.has(out.reason)) { r.tried = ++r.looks >= NOT_NOW_LOOKS; continue; }
462
+ // Not a background session at all: nothing was done, nothing goes on
463
+ // the card, and nothing is counted.
464
+ if (out.reason === "interactive" || out.reason === "not_listed") continue;
465
+ autoRuns.push(now());
466
+ r.autoAt = now();
467
+ keep(r, out, true);
468
+ if (out.ok) failStreak = 0;
469
+ else if (++failStreak >= FAIL_STREAK) { pausedUntil = now() + FAIL_PAUSE_MS; failStreak = 0; break; }
470
+ }
471
+ } catch { /* a pass reports through each session's outcome */ } finally {
472
+ passing = false;
473
+ arm();
474
+ }
475
+ }
476
+
477
+ /** An auto-switch tick finished. One that moved the account is the moment a
478
+ * working login most likely came back, so the next pass asks for a fresh
479
+ * reading rather than the cached one, and runs now. */
480
+ function noteTick(result) {
481
+ try {
482
+ if (!result || !waiting()) return;
483
+ if (result.switched) forceNext = true;
484
+ void consider();
485
+ } catch { /* never the tick's problem */ }
486
+ }
487
+
488
+ return {
489
+ noteEvent, noteTick, status, press, consider,
490
+ /** For the suite. */
491
+ records: () => records,
492
+ pausedUntil: () => pausedUntil,
493
+ armed: () => timer != null,
494
+ };
495
+ }
496
+
497
+ /** The gate's evidence on this machine: the newest quota reading, asked for
498
+ * through the panel's own cache and floors, and never waited on longer than
499
+ * the route waits (quota.mjs QUOTA_DEADLINE_MS). */
500
+ async function readLogin({ force = false } = {}) {
501
+ const { fetchClaudeQuota, QUOTA_DEADLINE_MS } = await import(
502
+ pathToFileURL(join(PKG_ROOT, "src/server/quota.mjs")).href
503
+ );
504
+ return loginEvidence(await fetchClaudeQuota({ force, deadlineMs: QUOTA_DEADLINE_MS }));
505
+ }
506
+
507
+ const continuer = createStopContinuer({
508
+ run,
509
+ bin: () => adminClaudeBin(),
510
+ login: readLogin,
511
+ autoOn: () => heldPrefs.current().continueStopped === true,
512
+ });
513
+
514
+ /** pushEvent's door, for a live hook post. Returns at once; never throws. */
515
+ export function noteStopContinue(raw, at) {
516
+ continuer.noteEvent(raw, at);
517
+ }
518
+
519
+ /** The auto-switch loop's door, after every tick. Never throws. */
520
+ export function noteContinueTick(result) {
521
+ continuer.noteTick(result);
522
+ }
523
+
524
+ /** The settings route's door: the switch was just turned on, so a stop
525
+ * already waiting is looked at now rather than at the next pass. */
526
+ export function noteContinueSwitchedOn() {
527
+ void continuer.consider();
528
+ }
529
+
530
+ /** GET /api/stop-continue — `?login=1` adds the gate's evidence. */
531
+ export async function handleStopContinueRead(req, res) {
532
+ const url = new URL(req.url, "http://localhost");
533
+ send(res, 200, await continuer.status({ withLogin: url.searchParams.get("login") === "1" }));
534
+ }
535
+
536
+ /** POST /api/stop-continue — `{ sessionId, stop: { kind, at } }`. */
537
+ export async function handleStopContinue(req, res) {
538
+ const body = await readBody(req, res).catch(() => null);
539
+ let parsed = null;
540
+ try { parsed = JSON.parse(body ?? ""); } catch { /* handled below */ }
541
+ if (!parsed || typeof parsed !== "object") return send(res, 400, { ok: false, reason: "bad_request" });
542
+ const out = await continuer.press(parsed.sessionId, parsed.stop);
543
+ send(res, out.ok ? 200 : out.reason === "bad_request" ? 400 : 409, out);
544
+ }
@@ -0,0 +1,10 @@
1
+ export type StopKind = "login" | "usage-limit" | "api-error";
2
+ export interface StopFacts { kind: StopKind; error: string; message: string; at: number }
3
+ export const STOP_FAILURE: "StopFailure";
4
+ export const STOP_FAILURE_SINCE: string;
5
+ export const STOP_MESSAGE_MAX: number;
6
+ export function stopKind(error: unknown): StopKind;
7
+ export function switchCanCure(kind: StopKind): boolean;
8
+ export function stopError(raw: unknown): string;
9
+ export function stopMessage(raw: unknown): string;
10
+ export function stopFailureOf(raw: unknown, at: number): StopFacts | null;
@@ -0,0 +1,97 @@
1
+ // Why a Claude Code turn stopped, when it did not stop on a finished reply.
2
+ //
3
+ // Claude Code fires `StopFailure` INSTEAD of `Stop` when a turn ends on an API
4
+ // error (2.1.78 and later). Its payload names the error with one of a fixed set
5
+ // of values — `authentication_failed`, `rate_limit`, `overloaded`,
6
+ // `server_error` and eight more — and carries the line the terminal printed
7
+ // ("Login expired · Please run /login", "API Error: Rate limit reached").
8
+ //
9
+ // Every session on a machine shares one login, so when it dies every running
10
+ // session stops on that line within seconds of the others. Before this event
11
+ // the deck saw nothing at all: no `Stop` came, and the cards went on reading
12
+ // live until the stale sweep gave up on them.
13
+ //
14
+ // Read here, once, for both halves of the deck. The page draws the note on the
15
+ // card from it (src/web/stop-failure-copy.ts), and the server decides from it
16
+ // whether a stop is worth one notification per incident and an early
17
+ // auto-switch tick (account-watch.mjs). The page imports this file the way
18
+ // pricing.ts imports codex-review-policy.mjs, so the two cannot come to read
19
+ // one payload two ways.
20
+ //
21
+ // EVERY VALUE OFF THE PAYLOAD IS TREATED AS HOSTILE. `/api/event` takes a post
22
+ // without the deck's token, and what this returns is drawn on a card, put in a
23
+ // tooltip and handed to an OS helper's argv.
24
+
25
+ export const STOP_FAILURE = "StopFailure";
26
+
27
+ /**
28
+ * The first Claude Code that knows the event, and why that is a gate rather
29
+ * than a detail. An older one does not skip a hook event it does not know:
30
+ * until 2.1.101 an unrecognised event name in settings.json made Claude Code
31
+ * ignore the whole FILE — every permission, env var and hook in it, ours
32
+ * included (Claude Code's changelog, 2.1.101). installer.mjs adds the entry
33
+ * only where every Claude Code it can find is at least this one.
34
+ */
35
+ export const STOP_FAILURE_SINCE = "2.1.78";
36
+
37
+ /** The two errors a working login cures, and what the deck calls each. Every
38
+ * other value, a future one included, is an API error. */
39
+ const KINDS = Object.freeze({ authentication_failed: "login", rate_limit: "usage-limit" });
40
+
41
+ /** "login", "usage-limit" or "api-error" for one of Claude Code's error values. */
42
+ export function stopKind(error) {
43
+ return typeof error === "string" && Object.hasOwn(KINDS, error) ? KINDS[error] : "api-error";
44
+ }
45
+
46
+ /** Whether another account can cure a stop of this kind — what makes it an
47
+ * incident to tell the user about and a reason to run auto-switch now. */
48
+ export function switchCanCure(kind) {
49
+ return kind === "login" || kind === "usage-limit";
50
+ }
51
+
52
+ /** Claude Code's values are short snake_case words. Anything else is not one
53
+ * and is not repeated anywhere. */
54
+ const ERROR_RE = /^[a-z][a-z0-9_]{0,39}$/;
55
+
56
+ /** The payload's `error`, when it is shaped like one of Claude Code's values. */
57
+ export function stopError(raw) {
58
+ const e = raw?.error;
59
+ return typeof e === "string" && ERROR_RE.test(e) ? e : "unknown";
60
+ }
61
+
62
+ /** Longest error line kept: two lines of a tooltip, and what a tray shows. */
63
+ export const STOP_MESSAGE_MAX = 160;
64
+
65
+ /** One line of plain text: terminal escapes and control characters out (a
66
+ * bidi override included), anything shaped like a credential masked, the
67
+ * whitespace folded, and cut on a whole character. */
68
+ function oneLine(value) {
69
+ if (typeof value !== "string") return "";
70
+ const text = value
71
+ .replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "")
72
+ .replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)?/g, "")
73
+ .replace(/[\p{Cc}\p{Cf}]/gu, " ")
74
+ .replace(/\bsk-ant-[A-Za-z0-9_-]+/g, "sk-ant-…")
75
+ .replace(/\b(Bearer)\s+[A-Za-z0-9._~+/=-]+/gi, "$1 …")
76
+ .replace(/\s+/g, " ")
77
+ .trim();
78
+ const chars = [...text];
79
+ return chars.length > STOP_MESSAGE_MAX ? `${chars.slice(0, STOP_MESSAGE_MAX - 1).join("").trimEnd()}…` : text;
80
+ }
81
+
82
+ /** What the terminal said: `last_assistant_message`, which on this event is
83
+ * the rendered error line, else `error_details`. Empty when neither is text. */
84
+ export function stopMessage(raw) {
85
+ return oneLine(raw?.last_assistant_message) || oneLine(raw?.error_details);
86
+ }
87
+
88
+ /**
89
+ * The facts of one StopFailure — `{ kind, error, message, at }` — or null for
90
+ * any other event. `at` is the caller's clock: the envelope's `receivedAt` on
91
+ * the page, the server's own on the way in.
92
+ */
93
+ export function stopFailureOf(raw, at) {
94
+ if (!raw || typeof raw !== "object" || raw.hook_event_name !== STOP_FAILURE) return null;
95
+ const error = stopError(raw);
96
+ return { kind: stopKind(error), error, message: stopMessage(raw), at };
97
+ }