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,281 @@
1
+ // The sessions a deck is watching, as one row each, for `ccdeck sessions`
2
+ // (#2183) — the question somebody on SSH asks first: which of my agents is
3
+ // waiting on me?
4
+ //
5
+ // The canvas answers it from src/web/reducer.ts, which folds every event into a
6
+ // graph of agents, tool calls and subagents. None of that is needed to say
7
+ // which sessions exist and which one is blocked, and none of it can be imported
8
+ // here: it is TypeScript bundled for the browser. So this is a small fold over
9
+ // the same ring, and THE WAITING RULE IS A MIRROR of src/web/waiting-block.ts —
10
+ // the same three `notification_type` kinds, the same events that leave a block
11
+ // standing, the same rule for whose traffic clears one — written twice because
12
+ // it has to be, the way block-notify.mjs mirrors sound.ts.
13
+ // session-summary-mirror.test.ts runs both over the same events and fails the
14
+ // day they disagree.
15
+ //
16
+ // Pure: envelopes in, rows out. The route that serves it is in index.mjs.
17
+
18
+ /** How long a session may be silent before it is taken to be gone — the
19
+ * canvas's own STALE_SESSION_MS (src/web/board-sweeps.ts), for its reasons:
20
+ * long enough for a permission prompt left standing through a meeting, short
21
+ * enough that this morning's closed terminals stop being listed as running. */
22
+ export const STALE_SESSION_MS = 90 * 60_000;
23
+
24
+ /** The `notification_type` values that raise a block, as the chore each one is
25
+ * — waitingKind in waiting-block.ts. */
26
+ export function waitingKind(notificationType) {
27
+ if (notificationType === "permission_prompt") return "permission";
28
+ if (notificationType === "agent_needs_input") return "asked";
29
+ if (notificationType === "idle_prompt") return "idle";
30
+ return null;
31
+ }
32
+
33
+ /** Events that are not evidence the session moved, and leave a block standing
34
+ * — WAITING_KEEPERS in waiting-block.ts, which says why each one is here. */
35
+ export const WAITING_KEEPERS = new Set([
36
+ "Notification", "ModelObserved", "ContextObserved", "UsageObserved",
37
+ "SessionNamed", "SessionRecapped", "OutputObserved", "ActivityObserved", "JobObserved",
38
+ ]);
39
+
40
+ /** The transcript scans: enrichment the server reads off a session's files,
41
+ * never the session's own traffic — the reducer's early returns, which stamp
42
+ * no provider and prove nothing about whether the session is alive. */
43
+ const SCANS = new Set([...WAITING_KEEPERS].filter(n => n !== "Notification"));
44
+
45
+ /** Two copies of one event inside this are the wire talking twice —
46
+ * HOOK_REDELIVERY_WINDOW_MS and PROMPT_REDELIVERY_WINDOW_MS in
47
+ * src/web/redelivery.ts, which are the same number on purpose. */
48
+ const REDELIVERY_MS = 2_000;
49
+
50
+ /** Whose traffic an event is: the subagent it names, or null for the root's —
51
+ * explicitSubagentKey in agent-attribution.ts. */
52
+ function subagentKeyOf(p) {
53
+ if (p.agent_id) return p.agent_id;
54
+ if (p.parent_tool_use_id) return p.parent_tool_use_id;
55
+ return null;
56
+ }
57
+
58
+ /** Is this event evidence the human dealt with `w`? clearsWaiting in
59
+ * waiting-block.ts: any traffic falsifies an idle block; a permission prompt
60
+ * or a question is answered only by the root's own traffic or by the very
61
+ * subagent that asked. */
62
+ function clearsWaiting(w, p) {
63
+ if (w.kind === "idle") return true;
64
+ const key = subagentKeyOf(p);
65
+ if (key == null) return true;
66
+ return w.subagent != null && w.subagent === key;
67
+ }
68
+
69
+ /** The newest call still in flight on a session, whose payload names who is
70
+ * asking — blockedCall in waiting-block.ts, minus the canvas's owner lookup:
71
+ * the payload's own key is what that function reads in the end. */
72
+ function newestInFlight(s) {
73
+ let newest = null;
74
+ for (const c of s.inFlight.values()) if (!newest || c.at > newest.at) newest = c;
75
+ return newest;
76
+ }
77
+
78
+ function blankSession(id, at) {
79
+ return {
80
+ id, provider: null, cwd: null, name: null, model: null,
81
+ startedAt: at, lastEventAt: at, recoveryLiveAt: null,
82
+ turn: "idle", turnSince: at, closed: false, waiting: null,
83
+ prompts: [], lastTurnEndAt: null,
84
+ inFlight: new Map(),
85
+ };
86
+ }
87
+
88
+ /** A prompt, filed in time order unless it is a copy of one already filed —
89
+ * recordPrompt in redelivery.ts. Only the newest is ever read, as evidence. */
90
+ function recordPrompt(s, at, text) {
91
+ for (let i = s.prompts.length - 1; i >= 0; i--) {
92
+ const prev = s.prompts[i];
93
+ if (prev.at < at - REDELIVERY_MS) break;
94
+ if (prev.text === text && prev.at <= at + REDELIVERY_MS) return;
95
+ }
96
+ let i = s.prompts.length;
97
+ while (i > 0 && s.prompts[i - 1].at > at) i--;
98
+ s.prompts.splice(i, 0, { at, text });
99
+ }
100
+
101
+ /**
102
+ * `Stop` or `SessionEnd`: the root's turn is over — applyTurnEnd in
103
+ * session-lifecycle.ts, the parts of it a row can show.
104
+ *
105
+ * Refused whole when it is out of order (a boundary older than the prompt that
106
+ * opened the turn now running) or a copy of the ending already recorded, for
107
+ * the reducer's reason (#1022): a replayed `Stop` landing in the next turn
108
+ * would call a working session "your turn". And the calls it settles are the
109
+ * ROOT's only, and none on Codex: a background subagent outlives the turn that
110
+ * dispatched it, and its call — and a permission prompt that call raises — is
111
+ * still running after the root's `Stop`.
112
+ */
113
+ function endTurn(s, name, at) {
114
+ const newest = s.prompts[s.prompts.length - 1];
115
+ const evidenceAt = newest && newest.at > s.startedAt ? newest.at : s.startedAt;
116
+ if (at < evidenceAt) return;
117
+ if (s.lastTurnEndAt != null && evidenceAt > s.lastTurnEndAt && at <= s.lastTurnEndAt + REDELIVERY_MS) return;
118
+ s.turn = "idle"; s.turnSince = at;
119
+ s.lastTurnEndAt = at;
120
+ if (s.provider !== "codex") {
121
+ for (const [id, call] of s.inFlight) if (call.key == null) s.inFlight.delete(id);
122
+ }
123
+ if (name === "SessionEnd") s.closed = true;
124
+ }
125
+
126
+ /**
127
+ * The deck's recovery scan, which finds a session already running when it
128
+ * starts — recoverSession in session-recovery.ts. Not the session's traffic:
129
+ * it leaves a standing block standing (after a restart the replay has put the
130
+ * session's prompt back up, and the scan arrives every second and a half),
131
+ * and a snapshot older than the session's own newest event says nothing.
132
+ */
133
+ function recover(sessions, p, at) {
134
+ const id = p.session_id;
135
+ if ((p.provider !== "codex" && p.provider !== "claude") || typeof id !== "string" || !id) return;
136
+ const s = sessions.get(id);
137
+ if (s?.provider && s.provider !== p.provider) return;
138
+ if (s && Number.isFinite(p.snapshot_observed_at) && (s.recoveryLiveAt ?? s.lastEventAt ?? 0) >= p.snapshot_observed_at) return;
139
+ if (p.recovery_active === false) {
140
+ if (s) endTurn(s, p.recovery_session_closed === true ? "SessionEnd" : "Stop", at);
141
+ return;
142
+ }
143
+ const r = s ?? blankSession(id, at);
144
+ sessions.set(id, r);
145
+ r.recoveryLiveAt ??= s?.lastEventAt ?? 0;
146
+ r.provider = p.provider;
147
+ r.closed = false;
148
+ if (r.turn !== "working") { r.turn = "working"; r.turnSince = at; }
149
+ r.lastEventAt = at;
150
+ if (!r.cwd && typeof p.cwd === "string" && p.cwd) r.cwd = p.cwd;
151
+ if (typeof p.model === "string" && p.model) r.model = p.model;
152
+ if (!s && Number.isFinite(p.turn_started_at) && p.turn_started_at <= at) r.startedAt = p.turn_started_at;
153
+ if (typeof p.prompt === "string" && p.prompt && r.prompts.at(-1)?.text !== p.prompt) r.prompts.push({ at, text: p.prompt });
154
+ }
155
+
156
+ /**
157
+ * Every session in the ring, folded.
158
+ *
159
+ * `envelopes` are the ring's own `{ seq, receivedAt, payload }`, oldest first.
160
+ * Each row says what the session is doing in one word:
161
+ *
162
+ * waiting a permission prompt or a question is standing — the alarm the
163
+ * topbar chip counts (isAlarming in ambient-counts.ts)
164
+ * working a turn is running
165
+ * idle the turn ended and the input box is waiting, which is not an
166
+ * alarm (#348) but is "your turn"
167
+ * ended SessionEnd, or nothing heard for STALE_SESSION_MS
168
+ */
169
+ export function summarizeSessions(envelopes, { now = Date.now(), staleMs = STALE_SESSION_MS } = {}) {
170
+ let sessions = new Map();
171
+ for (const env of Array.isArray(envelopes) ? envelopes : []) {
172
+ const p = env?.payload;
173
+ if (!p || typeof p !== "object") continue;
174
+ const name = p.hook_event_name ?? "Unknown";
175
+ // POST /api/clear wipes the canvas with this; it wipes the list too.
176
+ if (name === "__clear") { sessions = new Map(); continue; }
177
+ const at = Number(env.receivedAt) || now;
178
+ if (name === "ClaudeSessionRecovered" || name === "CodexSessionRecovered") { recover(sessions, p, at); continue; }
179
+ const id = typeof p.session_id === "string" && p.session_id ? p.session_id : null;
180
+ if (!id) continue;
181
+ let s = sessions.get(id);
182
+ if (!s) { s = blankSession(id, at); sessions.set(id, s); }
183
+ // A Codex child thread named by its parent: a session of its own, and
184
+ // nothing the human did — linkCodexThread, which returns before any block
185
+ // is read.
186
+ if (name === "CodexThreadLinked") { if (p.provider === "codex") s.provider ??= "codex"; continue; }
187
+
188
+ if (!WAITING_KEEPERS.has(name) && s.waiting && clearsWaiting(s.waiting, p)) s.waiting = null;
189
+ // A background job's file is not always the session talking — see the
190
+ // reducer's note at noteSessionHeard.
191
+ if (name !== "JobObserved" && at > s.lastEventAt) s.lastEventAt = at;
192
+ if (!SCANS.has(name)) {
193
+ // The provider, from the session's first own event; a scan stamps none.
194
+ s.provider ??= p.provider === "codex" ? "codex" : "claude";
195
+ if (at >= (s.recoveryLiveAt ?? 0)) s.recoveryLiveAt = at;
196
+ }
197
+ if (!s.cwd && typeof p.cwd === "string" && p.cwd) s.cwd = p.cwd;
198
+ if (typeof p.model === "string" && p.model && !subagentKeyOf(p)) s.model = p.model;
199
+ if (name === "SessionNamed") s.name = p.sessionTitle ?? p.sessionName ?? s.name;
200
+
201
+ switch (name) {
202
+ case "SessionStart":
203
+ s.closed = false;
204
+ // The earliest beginning heard of, as applySessionStart keeps it.
205
+ s.startedAt = Math.min(s.startedAt, at);
206
+ if (s.turn !== "working") { s.turn = "idle"; s.turnSince = at; }
207
+ break;
208
+ case "UserPromptSubmit": {
209
+ s.closed = false;
210
+ s.turn = "working"; s.turnSince = at;
211
+ const text = typeof p.prompt === "string" ? p.prompt : typeof p.message === "string" ? p.message : "";
212
+ if (text) recordPrompt(s, at, text);
213
+ break;
214
+ }
215
+ case "Stop":
216
+ case "SessionEnd":
217
+ endTurn(s, name, at);
218
+ break;
219
+ case "PreToolUse":
220
+ if (p.tool_use_id) s.inFlight.set(p.tool_use_id, { at, key: subagentKeyOf(p), tool: p.tool_name ?? null });
221
+ if (s.turn !== "working") { s.turn = "working"; s.turnSince = at; }
222
+ break;
223
+ case "PostToolUse":
224
+ case "PostToolUseFailure":
225
+ if (p.tool_use_id) s.inFlight.delete(p.tool_use_id);
226
+ break;
227
+ case "Notification": {
228
+ const kind = waitingKind(p.notification_type);
229
+ if (!kind) break;
230
+ const message = typeof p.message === "string" ? p.message : "";
231
+ const prev = s.waiting;
232
+ if (prev && prev.kind === kind && prev.message === message) {
233
+ prev.since = Math.min(prev.since, at);
234
+ break;
235
+ }
236
+ const block = { kind, message, since: at, subagent: null, tool: null };
237
+ if (kind === "permission") {
238
+ const call = newestInFlight(s);
239
+ if (call?.key) block.subagent = call.key;
240
+ // BLOCK_GUESS_WINDOW_MS: only a call that started moments before the
241
+ // prompt is printed as the thing being asked about.
242
+ if (call?.tool && at - call.at <= 30_000 && call.at <= at) block.tool = call.tool;
243
+ }
244
+ s.waiting = block;
245
+ break;
246
+ }
247
+ default:
248
+ break;
249
+ }
250
+ }
251
+
252
+ const rows = [];
253
+ for (const s of sessions.values()) {
254
+ const gone = s.closed || now - s.lastEventAt > staleMs;
255
+ const alarm = !gone && s.waiting && s.waiting.kind !== "idle";
256
+ const state = gone ? "ended" : alarm ? "waiting" : s.turn === "working" && !s.waiting ? "working" : "idle";
257
+ const since = state === "waiting" ? s.waiting.since
258
+ : state === "idle" && s.waiting ? Math.min(s.waiting.since, s.turnSince)
259
+ : state === "ended" ? s.lastEventAt
260
+ : s.turnSince;
261
+ rows.push({
262
+ id: s.id,
263
+ provider: s.provider ?? "claude",
264
+ cwd: s.cwd,
265
+ project: s.cwd ? s.cwd.split(/[\\/]/).filter(Boolean).pop() ?? s.cwd : null,
266
+ name: s.name,
267
+ model: s.model,
268
+ state,
269
+ since,
270
+ startedAt: s.startedAt,
271
+ lastEventAt: s.lastEventAt,
272
+ waiting: gone || !s.waiting ? null : { kind: s.waiting.kind, message: s.waiting.message, since: s.waiting.since, tool: s.waiting.tool },
273
+ });
274
+ }
275
+ // The ones waiting on a human first, longest-blocked first — the order the
276
+ // topbar's own click visits them (blockedSessions) — then running, then the
277
+ // idle and the ended, most recently heard first.
278
+ const rank = { waiting: 0, working: 1, idle: 2, ended: 3 };
279
+ return rows.sort((a, b) => rank[a.state] - rank[b.state]
280
+ || (a.state === "waiting" ? a.since - b.since : b.lastEventAt - a.lastEventAt));
281
+ }
@@ -0,0 +1,162 @@
1
+ // Whether the deck may add the UserPromptExpansion hook that Skill activity
2
+ // counts typed slash commands with (#2181) — and, until somebody asks for it,
3
+ // the answer is no.
4
+ //
5
+ // WHY THIS ENTRY IS GATED HARDER THAN STOPFAILURE. A hook event name Claude
6
+ // Code does not know is not skipped by every version. Up to 2.1.100 it made
7
+ // Claude Code ignore the WHOLE settings.json — the user's permissions, env,
8
+ // model and every hook in it, ours included — without a word on screen. 2.1.101
9
+ // changed that ("an unrecognized hook event name in settings.json no longer
10
+ // causes the entire file to be ignored", Claude Code's CHANGELOG.md, 2.1.101).
11
+ // UserPromptExpansion itself arrived later still: the first npm release whose
12
+ // CLI carries the name, and whose Agent SDK lists it in HOOK_EVENTS, is 2.1.116
13
+ // (2.1.114 has neither, and there is no 2.1.115). Checked offline against the
14
+ // published packages, with a scratch config holding the deck's own entry shape:
15
+ //
16
+ // 2.1.100 the whole file ignored — no hook fired, no env applied
17
+ // 2.1.101 the file read, the entry ignored
18
+ // 2.1.114 the file read, the entry ignored
19
+ // 2.1.116 the file read, the entry fired on a typed custom command
20
+ //
21
+ // So the gate is the later of the two: 2.1.116, which is where the entry is
22
+ // both harmless and useful. Below it the entry buys nothing, and below 2.1.101
23
+ // it costs the user every setting they have.
24
+ //
25
+ // WHAT HAS TO BE TRUE before the entry is written:
26
+ // - the deck was started with AGENTS_DECK_SKILL_HOOK=1 — never by default;
27
+ // - at least one Claude Code was found, the way the deck finds `claude`: the
28
+ // candidate list (claude-dir.mjs) and AGENTS_DECK_CLAUDE;
29
+ // - EVERY one found says which version it is, and every one is 2.1.116 or
30
+ // newer. An old CLI anywhere on the machine still reads the same file, and
31
+ // one that will not say its version is treated as an old one.
32
+ // A version is read off disk first (claude-dir.mjs, no child process). Only
33
+ // for an install whose layout does not name one — a wrapper script, an unusual
34
+ // prefix — and only once the user has opted in, is that binary asked
35
+ // `--version`, by its full path and as argv, and believed only when it answers
36
+ // the way Claude Code does: `<x.y.z> (Claude Code)`.
37
+ //
38
+ // Every boot re-decides. A machine that stops passing — the opt-in removed, a
39
+ // Claude Code rolled back, a second, older one installed — has the entry taken
40
+ // back out by the next start (installer.mjs), and the user's own
41
+ // UserPromptExpansion hooks are left where they are.
42
+ import { claudeCodeInstalls, claudeVersionAtLeast } from "./claude-dir.mjs";
43
+ import { run } from "./exec.mjs";
44
+
45
+ export const USER_PROMPT_EXPANSION = "UserPromptExpansion";
46
+
47
+ /** The first Claude Code that knows the event: 2.1.116 (npm). */
48
+ export const USER_PROMPT_EXPANSION_SINCE = "2.1.116";
49
+
50
+ /** The first Claude Code that skips an unknown hook event instead of ignoring
51
+ * the whole settings.json: 2.1.101 (CHANGELOG.md). */
52
+ export const UNKNOWN_EVENT_TOLERATED_SINCE = "2.1.101";
53
+
54
+ /** The gate: a version that is past both of the above. */
55
+ export const SKILL_HOOK_SINCE = claudeVersionAtLeast(USER_PROMPT_EXPANSION_SINCE, UNKNOWN_EVENT_TOLERATED_SINCE)
56
+ ? USER_PROMPT_EXPANSION_SINCE
57
+ : UNKNOWN_EVENT_TOLERATED_SINCE;
58
+
59
+ /** Set to "1" to ask for the entry. Anything else, or nothing, is no. */
60
+ export const SKILL_HOOK_ENV = "AGENTS_DECK_SKILL_HOOK";
61
+
62
+ /** Whether the person starting the deck asked for the entry. Only the exact
63
+ * string "1": a typo or a "true" does not turn it on. */
64
+ export function skillHookOptedIn(env = process.env) {
65
+ return env?.[SKILL_HOOK_ENV] === "1";
66
+ }
67
+
68
+ /** A `--version` that is not quick is not Claude Code's fast path. */
69
+ const PROBE_TIMEOUT_MS = 5_000;
70
+
71
+ /** What Claude Code prints for `--version`, e.g. `2.1.116 (Claude Code)`. A
72
+ * first line in any other shape — a wrapper's own version, an error, an
73
+ * update notice — is no answer. */
74
+ const VERSION_LINE_RE = /^(\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.+-]*)?) \(Claude Code\)$/;
75
+
76
+ /** The version in Claude Code's own `--version` output, or undefined. */
77
+ export function parseClaudeVersionOutput(text) {
78
+ const first = String(text ?? "").split(/\r?\n/).map(l => l.trim()).find(l => l !== "");
79
+ const m = first === undefined ? null : VERSION_LINE_RE.exec(first);
80
+ return m ? m[1] : undefined;
81
+ }
82
+
83
+ async function askVersion(runImpl, bin, timeout) {
84
+ try {
85
+ const r = await runImpl(bin, ["--version"], { timeout });
86
+ return r?.ok ? parseClaudeVersionOutput(r.stdout) : undefined;
87
+ } catch {
88
+ return undefined;
89
+ }
90
+ }
91
+
92
+ /**
93
+ * The decision, and why:
94
+ *
95
+ * { install: false, state: "off", reason: "not-enabled" }
96
+ * { install: false, state: "skipped", reason: "no-claude" | "version-unknown" | "too-old", versions }
97
+ * { install: true, state: "on", versions }
98
+ *
99
+ * `versions` is what was found, in the order found, with `null` for an install
100
+ * that did not say. `since` is the gate. Never throws: a probe that fails is a
101
+ * version that is unknown, which is a refusal.
102
+ *
103
+ * Every dependency is injected so the suite can lay out any machine: `installs`
104
+ * is claudeCodeInstalls()'s answer, `runImpl` is exec.mjs's run.
105
+ */
106
+ export async function skillHookDecision({
107
+ env = process.env,
108
+ optedIn = skillHookOptedIn(env),
109
+ installs,
110
+ runImpl = run,
111
+ timeout = PROBE_TIMEOUT_MS,
112
+ } = {}) {
113
+ const since = SKILL_HOOK_SINCE;
114
+ // Off is decided before anything is looked at: a deck that was not asked
115
+ // spends nothing on the question, and spawns nothing.
116
+ if (!optedIn) return { install: false, state: "off", reason: "not-enabled", since, versions: [] };
117
+ let found;
118
+ try {
119
+ found = Array.isArray(installs) ? [...installs] : claudeCodeInstalls();
120
+ } catch {
121
+ found = [];
122
+ }
123
+ // AGENTS_DECK_CLAUDE is the other way the deck finds `claude` — the sign-in
124
+ // and the account panel run it ahead of the candidate list
125
+ // (claude-identity.mjs) — so it is one more Claude Code that reads this
126
+ // settings.json. Its layout is not read: it is asked.
127
+ const configured = typeof env?.AGENTS_DECK_CLAUDE === "string" ? env.AGENTS_DECK_CLAUDE.trim() : "";
128
+ if (configured && !found.some(i => i.bin === configured)) found.push({ bin: configured, version: undefined });
129
+ if (found.length === 0) return { install: false, state: "skipped", reason: "no-claude", since, versions: [] };
130
+ const versions = [];
131
+ for (const { bin, version } of found) {
132
+ const v = version ?? (typeof bin === "string" && bin ? await askVersion(runImpl, bin, timeout) : undefined);
133
+ versions.push(v ?? null);
134
+ }
135
+ if (versions.some(v => v === null)) return { install: false, state: "skipped", reason: "version-unknown", since, versions };
136
+ if (!versions.every(v => claudeVersionAtLeast(v, since))) {
137
+ return { install: false, state: "skipped", reason: "too-old", since, versions };
138
+ }
139
+ return { install: true, state: "on", since, versions };
140
+ }
141
+
142
+ /**
143
+ * The boot report's row for the entry — `{ ok, detail }` — or null when there
144
+ * is nothing to say, which is the default: nobody asked for it, and a deck
145
+ * that was not asked prints exactly what it printed before. `dash` is the
146
+ * screen's own (bin/cli/screen.js), so the line carries no punctuation a
147
+ * legacy console cannot draw.
148
+ */
149
+ export function skillHookStatus(decision, dash = "-") {
150
+ if (!decision || decision.state === "off") return null;
151
+ const found = (decision.versions ?? []).map(v => v ?? "unknown").join(", ");
152
+ if (decision.state === "on") return { ok: true, detail: `on ${dash} counts typed slash commands (Claude Code ${found})` };
153
+ const why = {
154
+ "no-claude": "no Claude Code found",
155
+ "version-unknown": `a Claude Code here does not say its version (found: ${found})`,
156
+ "too-old": `needs Claude Code ${decision.since} or newer everywhere (found: ${found})`,
157
+ "settings-shape": "the hooks in settings.json are not in a shape the deck can add to",
158
+ "backup-failed": "settings.json could not be backed up first",
159
+ raced: "settings.json changed while installing; the next start tries again",
160
+ }[decision.reason] ?? "not allowed";
161
+ return { ok: false, detail: `not installed ${dash} ${why}` };
162
+ }
@@ -0,0 +1,8 @@
1
+ import type { StopFacts } from "./stop-failure.mjs";
2
+ export interface LoginEvidence { ok: boolean; at: number; room: boolean | null }
3
+ export type GateReason = "no-login" | "before-stop" | "no-room";
4
+ export interface Gate { ready: boolean; reason: GateReason | null }
5
+ export const CONTINUE_PROMPT: string;
6
+ export const REPEAT_HOLD_MS: number;
7
+ export function loginEvidence(reading: unknown): LoginEvidence | null;
8
+ export function continueGate(stop: Pick<StopFacts, "kind" | "at"> | null | undefined, login: LoginEvidence | null | undefined): Gate;
@@ -0,0 +1,77 @@
1
+ // When a turn that stopped on an expired login or a usage limit may be run
2
+ // again (#2193), read once for both halves of the deck: the page draws the
3
+ // Continue button from it, and the server refuses a press — and holds back the
4
+ // automatic continue — on the same answer. The page imports this file the way
5
+ // it imports stop-failure.mjs, so the two cannot come to disagree about one
6
+ // stop.
7
+ //
8
+ // WHAT COUNTS AS "A WORKING LOGIN IS ACTIVE AGAIN". The deck's newest reading
9
+ // of the active account's usage (quota.mjs), taken AFTER the stop and without
10
+ // an error. That reading is made with the very credentials a session uses —
11
+ // Claude Code's own token, `claude --print /usage`, or claude-swap's copy of
12
+ // the active account — so a login that is still dead cannot produce one, and
13
+ // one from before the stop says nothing about the login now. A switch of
14
+ // account invalidates the reading, so the next one is about the new account.
15
+ //
16
+ // And the account has to have room: a working login on an account at 100% of
17
+ // its 5-hour or 7-day window would stop the turn again on a usage limit at its
18
+ // first request.
19
+ //
20
+ // Pure. Nothing here reads a clock, a file or a process.
21
+
22
+ /** What the deck sends a background session to continue the turn that
23
+ * failed: Claude Code's own words for it, the default of
24
+ * CLAUDE_CODE_RESUME_PROMPT (code.claude.com/docs/en/env-vars), which is what
25
+ * Claude Code sends when it continues an interrupted turn by itself. */
26
+ export const CONTINUE_PROMPT = "Continue from where you left off.";
27
+
28
+ /**
29
+ * A session the deck continued automatically, which then stops again on a
30
+ * login or a limit inside this window, is not continued automatically again
31
+ * until somebody acts on it — a prompt typed into it, or a press of Continue.
32
+ * That is the loop the setting must never become: a login that works for one
33
+ * request and dies on the next would otherwise be continued, stop, and be
34
+ * continued again for as long as the deck runs.
35
+ */
36
+ export const REPEAT_HOLD_MS = 60 * 60_000;
37
+
38
+ /**
39
+ * The quota reading as the gate reads it: `{ ok, at, room }`, or null when
40
+ * there is no reading at all.
41
+ *
42
+ * ok the reading is a real one, not a failure to read
43
+ * at when its numbers were collected — the reading's own clock, which is
44
+ * the one that can be compared with a stop's (a reading the deck is
45
+ * only holding over is still as old as it says)
46
+ * room both of the account's windows are under 100%, or null when the
47
+ * reading names neither
48
+ */
49
+ export function loginEvidence(reading) {
50
+ if (!reading || typeof reading !== "object") return null;
51
+ const at = Number(reading.fetchedAt);
52
+ const when = Number.isFinite(at) && at > 0 ? at : 0;
53
+ if (reading.ok !== true || !when) return { ok: false, at: when, room: null };
54
+ const pcts = [reading.session5hPct, reading.week7dPct].filter(v => typeof v === "number" && Number.isFinite(v));
55
+ return { ok: true, at: when, room: pcts.length ? pcts.every(p => p < 100) : null };
56
+ }
57
+
58
+ /**
59
+ * Whether the turn `stop` ended may be continued now, and when not, why:
60
+ *
61
+ * "no-login" there is no working reading of the active account at all
62
+ * "before-stop" the newest one was taken before the stop, so it cannot say
63
+ * the login works now
64
+ * "no-room" it works, and its 5-hour or 7-day window is full
65
+ *
66
+ * An API error that is neither — an overloaded server, a refused request — is
67
+ * not about the login, so nothing here holds it back. The automatic continue
68
+ * never acts on one (stop-continue.mjs); a press may.
69
+ */
70
+ export function continueGate(stop, login) {
71
+ if (!stop || typeof stop !== "object") return { ready: false, reason: "no-login" };
72
+ if (stop.kind !== "login" && stop.kind !== "usage-limit") return { ready: true, reason: null };
73
+ if (!login || !login.ok) return { ready: false, reason: "no-login" };
74
+ if (!(login.at > stop.at)) return { ready: false, reason: "before-stop" };
75
+ if (login.room === false) return { ready: false, reason: "no-room" };
76
+ return { ready: true, reason: null };
77
+ }