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.
- package/README.md +74 -20
- package/THIRD_PARTY_NOTICES.md +43 -7
- package/bin/cli/commands.js +613 -0
- package/bin/cli/help.js +26 -3
- package/bin/cli/one-shot.js +2 -2
- package/bin/cli/picker.js +86 -0
- package/bin/cli/startup.js +13 -0
- package/bin/deck.js +60 -16
- package/dist/web/assets/{BrowserWatchModal-DZTMfoGp.js → BrowserWatchModal-DMIUygWz.js} +1 -1
- package/dist/web/assets/{RadioPanel-DFqW-MUV.js → RadioPanel-DNB_AHSs.js} +1 -1
- package/dist/web/assets/TrafficRadar-Ch0LVdSl.js +8 -0
- package/dist/web/assets/UsageHistoryModal-Du0ZkIJ1.js +1 -0
- package/dist/web/assets/app-CQIstkMF.js +318 -0
- package/dist/web/assets/app-Ch-0CYlK.css +1 -0
- package/dist/web/assets/{index-DqxnkNs2.js → index-BQbOfxbL.js} +1 -1
- package/dist/web/index.html +3 -3
- package/package.json +2 -1
- package/release-notes.json +40 -0
- package/src/server/account-notify.mjs +206 -9
- package/src/server/account-pick.mjs +160 -0
- package/src/server/account-projects.mjs +55 -15
- package/src/server/account-routes.mjs +47 -4
- package/src/server/account-watch.mjs +48 -4
- package/src/server/activity.mjs +3 -2
- package/src/server/args.mjs +112 -2
- package/src/server/autoswitch-failover.mjs +138 -0
- package/src/server/claude-accounts.mjs +115 -14
- package/src/server/claude-dir.mjs +142 -5
- package/src/server/claude-identity.mjs +23 -0
- package/src/server/cli-lines.mjs +224 -0
- package/src/server/codex-cost-usage.mjs +67 -0
- package/src/server/codex-enrichment.mjs +53 -9
- package/src/server/codex-review-policy.d.mts +4 -0
- package/src/server/codex-review-policy.mjs +11 -0
- package/src/server/codex-skills.mjs +146 -0
- package/src/server/codex-watch.mjs +5 -2
- package/src/server/cswap-auto-choice.mjs +53 -0
- package/src/server/cswap-auto-loop.mjs +100 -9
- package/src/server/deck-client.mjs +112 -0
- package/src/server/deck-prefs.mjs +30 -6
- package/src/server/event-pipeline.mjs +19 -3
- package/src/server/event-routes.mjs +15 -0
- package/src/server/exec.mjs +6 -2
- package/src/server/index.mjs +17 -1
- package/src/server/installer.mjs +149 -8
- package/src/server/lan-copies.mjs +43 -5
- package/src/server/lan-deck.mjs +3 -0
- package/src/server/lan-engine.mjs +7 -2
- package/src/server/lan-manifest.mjs +2 -2
- package/src/server/lan-serve.mjs +17 -2
- package/src/server/lan-sync.mjs +5 -5
- package/src/server/lan-wire.mjs +3 -3
- package/src/server/login-guard.mjs +162 -0
- package/src/server/login-service.mjs +3 -0
- package/src/server/pinned-build.mjs +5 -0
- package/src/server/prefs-routes.mjs +4 -0
- package/src/server/process-command.mjs +4 -1
- package/src/server/reports.mjs +7 -1
- package/src/server/request-gates.mjs +7 -0
- package/src/server/session-read-gate.mjs +5 -3
- package/src/server/session-summary.mjs +281 -0
- package/src/server/skill-hook.mjs +162 -0
- package/src/server/stop-continue-gate.d.mts +8 -0
- package/src/server/stop-continue-gate.mjs +77 -0
- package/src/server/stop-continue.mjs +544 -0
- package/src/server/stop-failure.d.mts +10 -0
- package/src/server/stop-failure.mjs +97 -0
- package/src/server/swap-log.mjs +19 -10
- package/src/server/tool-audit-report.mjs +5 -3
- package/src/server/tool-audit-routes.mjs +5 -3
- package/src/server/tool-audit-skills.mjs +82 -0
- package/src/server/tool-audit.mjs +53 -11
- package/src/server/traffic-radar-capture.mjs +10 -1
- package/src/server/traffic-radar-monitor.mjs +4 -2
- package/src/server/traffic-radar-routes.mjs +13 -0
- package/src/server/traffic-radar-service.mjs +133 -0
- package/src/server/traffic-radar-wire.mjs +4 -3
- package/src/server/usage-day.mjs +1 -1
- package/dist/web/assets/TrafficRadar-CPzsjc2O.js +0 -1
- package/dist/web/assets/UsageHistoryModal-CfVS-N_Z.js +0 -1
- package/dist/web/assets/app-DsHSufBt.css +0 -1
- 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
|
+
}
|