ccdeck 3.41.3 → 3.43.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 (48) hide show
  1. package/README.md +38 -18
  2. package/bin/deck.js +4 -0
  3. package/dist/web/assets/BrowserWatchModal-BPws0psH.js +1 -0
  4. package/dist/web/assets/RadioPanel-CDl7ZImm.js +1 -0
  5. package/dist/web/assets/TrafficRadar-D1hp6BgZ.js +1 -0
  6. package/dist/web/assets/UsageHistoryModal-DPcdpBpI.js +1 -0
  7. package/dist/web/assets/app-9p127oSR.js +310 -0
  8. package/dist/web/assets/app-DsHSufBt.css +1 -0
  9. package/dist/web/assets/index-Bkx27-wy.js +1 -0
  10. package/dist/web/index.html +18 -4
  11. package/package.json +1 -1
  12. package/release-notes.json +40 -0
  13. package/src/server/account-choice.mjs +198 -0
  14. package/src/server/account-notify.mjs +15 -8
  15. package/src/server/account-routes.mjs +6 -0
  16. package/src/server/activity.mjs +11 -3
  17. package/src/server/agent-activity.mjs +33 -10
  18. package/src/server/browser-watch-decks.mjs +3 -2
  19. package/src/server/browser-watch.mjs +3 -4
  20. package/src/server/claude-recovery.mjs +150 -0
  21. package/src/server/codex-enrichment.mjs +11 -3
  22. package/src/server/codex-lineage.mjs +29 -0
  23. package/src/server/codex-recovery.mjs +83 -0
  24. package/src/server/codex-translate.mjs +24 -0
  25. package/src/server/codex-watch.mjs +116 -12
  26. package/src/server/cswap-auto-choice.mjs +296 -0
  27. package/src/server/cswap-auto-loop.mjs +18 -2
  28. package/src/server/cswap-auto-readers.mjs +5 -2
  29. package/src/server/cswap-auto.mjs +7 -1
  30. package/src/server/deck-prefs.mjs +5 -1
  31. package/src/server/event-pipeline.mjs +6 -3
  32. package/src/server/evicted-enrichment.mjs +2 -0
  33. package/src/server/feedback-form.mjs +1 -0
  34. package/src/server/index.mjs +13 -0
  35. package/src/server/pinned-build.mjs +5 -0
  36. package/src/server/reports-routes.mjs +31 -12
  37. package/src/server/request-gates.mjs +2 -0
  38. package/src/server/tool-audit-operations.mjs +112 -0
  39. package/src/server/tool-audit-report.mjs +115 -0
  40. package/src/server/tool-audit-routes.mjs +38 -0
  41. package/src/server/tool-audit.mjs +126 -0
  42. package/src/server/traffic-radar-config.mjs +8 -1
  43. package/dist/web/assets/BrowserWatchModal-6TctCbc4.js +0 -1
  44. package/dist/web/assets/RadioPanel-ZDDwNYnL.js +0 -1
  45. package/dist/web/assets/TrafficRadar-lfG5ekUR.js +0 -1
  46. package/dist/web/assets/UsageHistoryModal-Be-9GVIE.js +0 -1
  47. package/dist/web/assets/index-BgSnAnP6.css +0 -1
  48. package/dist/web/assets/index-C80Hp_2s.js +0 -307
@@ -0,0 +1,296 @@
1
+ // The deck's own step in an auto-switch tick, taken before claude-swap's
2
+ // engine: the home account and "soonest weekly reset" rules
3
+ // (account-choice.mjs), carried out, and what they need remembered between
4
+ // ticks.
5
+ //
6
+ // NOTHING SET, NOTHING DONE. With the next account left on "most room" and no
7
+ // home, a tick never reads anything here beyond this file and goes straight to
8
+ // the engine — the behaviour every deck had before these rules existed.
9
+ //
10
+ // WHAT IS REMEMBERED, per machine, in auto-choice.json beside prefs.json:
11
+ //
12
+ // next "room" | "weekly"
13
+ // home { key, email, orgUuid } or null — an identity, never a slot:
14
+ // slots are reused and differ between machines (lan-copies.mjs)
15
+ // manual the account in use was picked by hand: the panel's Switch, the
16
+ // terminal, anything that was not a tick. The moves nobody needs
17
+ // leave it alone until it fills.
18
+ // goHome a home was just set, so the next tick may take the user there
19
+ // lastActive the account the last tick left the user on, which is how a
20
+ // switch made by hand is told from one a tick made
21
+ // lastMoveAt when a tick last moved the account, for the cooldowns
22
+ //
23
+ // The ticks run one at a time under the store mutex; the panel's two writes
24
+ // do not, so every write here goes through one chain.
25
+ import { readFile, readdir, mkdir } from "node:fs/promises";
26
+ import { dirname, join } from "node:path";
27
+ import { deckDataDir } from "./deck-home.mjs";
28
+ import { writeFileAtomic } from "./atomic-write.mjs";
29
+ import { backupRoot, fetchClaudeAccounts, slotNumber, switchClaudeAccount } from "./claude-accounts.mjs";
30
+ import { readCswapConfig } from "./cswap-auto-readers.mjs";
31
+ import { accountKey } from "./lan-copies.mjs";
32
+ import { isProcessAlive } from "./deck-probe.mjs";
33
+ import { chooseAccount, countedModels, NEXT_CHOICES } from "./account-choice.mjs";
34
+
35
+ /** Where the choice is kept: beside prefs.json, in the deck's own data. */
36
+ export function choicePath(home = deckDataDir()) {
37
+ return join(home, "auto-choice.json");
38
+ }
39
+
40
+ const text = v => (typeof v === "string" && v ? v : null);
41
+
42
+ /** Whatever is on disk, as a whole and known-shaped choice. */
43
+ export function normaliseChoice(raw) {
44
+ const src = raw && typeof raw === "object" ? raw : {};
45
+ const home = src.home && typeof src.home === "object" && text(src.home.email)
46
+ ? { key: accountKey(src.home.email, src.home.orgUuid), email: src.home.email, orgUuid: text(src.home.orgUuid) }
47
+ : null;
48
+ return {
49
+ next: NEXT_CHOICES.includes(src.next) ? src.next : "room",
50
+ home,
51
+ manual: src.manual === true,
52
+ goHome: home != null && src.goHome === true,
53
+ lastActive: text(src.lastActive),
54
+ lastMoveAt: Number.isFinite(src.lastMoveAt) ? src.lastMoveAt : 0,
55
+ };
56
+ }
57
+
58
+ // The choice as last read or written. This process is the file's only writer,
59
+ // so after the first read a tick asks memory, not the disk — which is what
60
+ // keeps a deck with nothing set paying nothing per tick.
61
+ let _held = null;
62
+
63
+ async function readChoice() {
64
+ if (_held) return _held;
65
+ try { _held = normaliseChoice(JSON.parse(await readFile(choicePath(), "utf8"))); } catch { _held = normaliseChoice(null); }
66
+ return _held;
67
+ }
68
+
69
+ let _writes = Promise.resolve();
70
+ /** Read, change and write the choice, one change at a time. A change that
71
+ * changes nothing writes nothing: most ticks only confirm where they are. */
72
+ function updateChoice(change) {
73
+ const done = _writes.then(async () => {
74
+ const before = await readChoice();
75
+ const next = normaliseChoice(change(before));
76
+ if (JSON.stringify(next) === JSON.stringify(before)) return before;
77
+ const path = choicePath();
78
+ await mkdir(dirname(path), { recursive: true });
79
+ await writeFileAtomic(path, `${JSON.stringify(next)}\n`);
80
+ _held = next;
81
+ return next;
82
+ });
83
+ _writes = done.catch(() => {});
84
+ return done;
85
+ }
86
+
87
+ /** What the panel draws: the strategy, and whose row carries the home mark. */
88
+ export async function choiceStatus() {
89
+ const c = await readChoice();
90
+ return { next: c.next, home: c.home ? { email: c.home.email, orgUuid: c.home.orgUuid } : null };
91
+ }
92
+
93
+ /** "room" or "weekly". Choosing is a decision about the account in use too,
94
+ * so a pick made by hand no longer holds the new rule back. */
95
+ export async function setNextChoice(value) {
96
+ if (!NEXT_CHOICES.includes(value)) return { ok: false, reason: "bad_value" };
97
+ await updateChoice(c => ({ ...c, next: value, manual: false, lastActive: null }));
98
+ return { ok: true };
99
+ }
100
+
101
+ /**
102
+ * Make the account in slot `accountNum` the home, or clear it with null.
103
+ * Setting one is asking to be taken there: the next tick does, once the
104
+ * account is usable, even from an account picked by hand.
105
+ */
106
+ export async function setHomeAccount(accountNum) {
107
+ if (accountNum === null) {
108
+ await updateChoice(c => ({ ...c, home: null, goHome: false }));
109
+ return { ok: true };
110
+ }
111
+ const num = slotNumber(accountNum);
112
+ if (num == null) return { ok: false, reason: "bad_account" };
113
+ const roster = await fetchClaudeAccounts();
114
+ const row = roster?.ok ? roster.accounts?.find(a => a.num === num) : null;
115
+ if (!row?.email) return { ok: false, reason: "bad_account" };
116
+ await updateChoice(c => ({ ...c, home: { email: row.email, orgUuid: row.orgUuid ?? null }, goHome: true, manual: false }));
117
+ return { ok: true };
118
+ }
119
+
120
+ /** Auto-switch was just turned on: whatever happened while it was off is not
121
+ * a pick to protect, so the next tick starts from the account in use. */
122
+ export function forgetPicksByHand() {
123
+ return updateChoice(c => ({ ...c, manual: false, lastActive: null })).catch(() => {});
124
+ }
125
+
126
+ /**
127
+ * What claude-swap's engine keeps in autoswitch_state.json that a move here
128
+ * has to respect: when it last switched, in ms or 0 — a move the deck makes
129
+ * does not start the engine's cooldown, so this one keeps both — and the
130
+ * accounts it has quarantined, by key.
131
+ *
132
+ * A quarantine is the slot's, with the email it held: the engine drops one
133
+ * whose slot now holds somebody else, so it is only this account's while the
134
+ * emails agree. One the engine would drop for a fresh login it drops on its
135
+ * next run, which every tick that does not move here is.
136
+ */
137
+ async function engineState(accounts) {
138
+ let state = null;
139
+ try { state = JSON.parse(await readFile(join(backupRoot(), "autoswitch_state.json"), "utf8")); } catch {}
140
+ const at = Number(state?.lastSwitchAt);
141
+ const held = state?.quarantine && typeof state.quarantine === "object" ? state.quarantine : {};
142
+ const sameEmail = (x, y) => typeof x === "string" && typeof y === "string" && x.trim().toLowerCase() === y.trim().toLowerCase();
143
+ return {
144
+ lastSwitchAt: Number.isFinite(at) && at > 0 ? at * 1000 : 0,
145
+ quarantined: new Set(accounts.filter(a => sameEmail(held[String(a.num)]?.email, a.email)).map(a => a.key)),
146
+ };
147
+ }
148
+
149
+ /** The accounts the decks this one is paired with say they are working on.
150
+ * One login live on two machines races for every renewal (README, "One login
151
+ * live in one place"), so a tick never moves onto one of them. */
152
+ async function accountsOnOtherDecks() {
153
+ try {
154
+ const { lanEngine } = await import("./lan-deck.mjs");
155
+ const peers = lanEngine.status()?.peers ?? [];
156
+ return new Set(peers.map(p => p?.offers?.current?.key).filter(k => typeof k === "string"));
157
+ } catch {
158
+ return new Set();
159
+ }
160
+ }
161
+
162
+ /** The name claude-swap gives an account's `cswap run` profile after its
163
+ * slot number: the email, NFC, with anything but ASCII letters, digits and
164
+ * `._-` turned into `_` (claude_swap/session.py, slugify_email). */
165
+ export function sessionSlug(email) {
166
+ return [...String(email ?? "").normalize("NFC")].map(ch => (/^[A-Za-z0-9._-]$/.test(ch) ? ch : "_")).join("");
167
+ }
168
+
169
+ /**
170
+ * The accounts a `cswap run` session is working on, by key. Such a session
171
+ * runs Claude Code on the account's own copy of the login; the engine never
172
+ * activates one of them, because two copies of one login race for every
173
+ * renewal. `cswap switch`, which is what a move here runs, only warns — so
174
+ * the deck asks the same question first.
175
+ *
176
+ * Claude Code leaves one record per running instance in the profile's
177
+ * `sessions/<pid>.json`; one counts while its process is running. A pid used
178
+ * again by something else reads as live too, which only leaves that move to
179
+ * the engine.
180
+ */
181
+ async function accountsInCswapRun(accounts) {
182
+ const live = new Set();
183
+ for (const a of accounts) {
184
+ if (a.num == null || !a.email) continue;
185
+ const dir = join(backupRoot(), "sessions", `${a.num}-${sessionSlug(a.email)}`, "sessions");
186
+ let names = [];
187
+ try { names = (await readdir(dir)).filter(n => /^\d+\.json$/.test(n)); } catch { continue; }
188
+ for (const name of names) {
189
+ let pid = null;
190
+ try { pid = Number(JSON.parse(await readFile(join(dir, name), "utf8"))?.pid); } catch {}
191
+ if (Number.isSafeInteger(pid) && pid > 0 && isProcessAlive(pid)) { live.add(a.key); break; }
192
+ }
193
+ }
194
+ return live;
195
+ }
196
+
197
+ const numberOr = (v, fallback) => {
198
+ const n = Number(v);
199
+ return v != null && v !== "" && Number.isFinite(n) ? n : fallback;
200
+ };
201
+
202
+ /** One side of a move, in the shape claude-swap's own `switch` event has. */
203
+ const refOf = a => (a ? { number: a.num, email: a.email ?? null } : null);
204
+
205
+ /**
206
+ * Ask the two rules, and move when they say so. Under the store mutex, from
207
+ * the tick (cswap-auto-loop.mjs).
208
+ *
209
+ * Returns `{ result, accounts }`: `result` is a tick in the shape summarise
210
+ * gives the engine's — so the caches, the swap log and the notification treat
211
+ * the move as any other — or null for "run the engine"; `accounts` is the
212
+ * roster it decided on, which noteEngineTick needs to name where the engine
213
+ * went, and null when nothing is set.
214
+ */
215
+ export async function chooseBeforeEngine({ now = Date.now() } = {}) {
216
+ const choice = await readChoice();
217
+ if (choice.next === "room" && !choice.home) return { result: null, accounts: null };
218
+ const roster = await fetchClaudeAccounts();
219
+ if (!roster?.ok || !Array.isArray(roster.accounts)) return { result: null, accounts: null };
220
+ const accounts = roster.accounts.map(a => ({ ...a, key: accountKey(a.email, a.orgUuid) }));
221
+ const active = accounts.find(a => a.active) ?? null;
222
+
223
+ const byHand = active != null && choice.lastActive != null && choice.lastActive !== active.key;
224
+ const manual = choice.manual || byHand;
225
+ const goHome = choice.goHome && active?.key !== choice.home?.key;
226
+ // WHAT THIS TICK LEARNED, WRITTEN OVER WHAT IS THERE NOW. The panel's writes
227
+ // do not wait for the store lock, so "Set as home" or a new rule can land
228
+ // while the awaits below run. `manual` and `goHome` were worked out from the
229
+ // choice as it was read, so they are only written back over that same
230
+ // choice — over a newer one they would undo what the person just set.
231
+ // Every write replaces the held object, which is what tells the two apart.
232
+ const keep = c => (c === choice
233
+ ? { ...c, manual, goHome, lastActive: active?.key ?? c.lastActive }
234
+ : { ...c, lastActive: active?.key ?? c.lastActive });
235
+
236
+ const cfg = (await readCswapConfig()) ?? {};
237
+ const threshold = numberOr(cfg["autoswitch.threshold"]?.value, 90);
238
+ const models = countedModels(cfg["autoswitch.model"]?.value);
239
+ const engine = await engineState(accounts);
240
+ const pick = chooseAccount({
241
+ accounts,
242
+ choice: { next: choice.next, home: choice.home?.key ?? null, manual, goHome },
243
+ threshold,
244
+ models,
245
+ busy: new Set([...await accountsOnOtherDecks(), ...await accountsInCswapRun(accounts)]),
246
+ quarantined: engine.quarantined,
247
+ sinceMove: now - Math.max(choice.lastMoveAt, engine.lastSwitchAt),
248
+ cooldownMs: numberOr(cfg["autoswitch.cooldownSeconds"]?.value, 300) * 1000,
249
+ optional: (cfg["autoswitch.strategy"]?.value ?? "best") === "best",
250
+ now,
251
+ });
252
+ if (!pick) {
253
+ await updateChoice(keep);
254
+ return { result: null, accounts };
255
+ }
256
+ const moved = await switchClaudeAccount(pick.to.num);
257
+ if (!moved.ok) {
258
+ await updateChoice(keep);
259
+ return { result: null, accounts };
260
+ }
261
+ // The move happened, so where it left the user and when are true whatever
262
+ // was set meanwhile; a home still to be gone to is the current choice's.
263
+ await updateChoice(c => ({
264
+ ...c, manual: false, goHome: (c === choice ? goHome : c.goHome) && pick.to.key !== c.home?.key,
265
+ lastActive: pick.to.key, lastMoveAt: now,
266
+ }));
267
+ return {
268
+ result: {
269
+ ok: true,
270
+ event: "switch",
271
+ switched: true,
272
+ trigger: pick.trigger,
273
+ reason: null,
274
+ detail: null,
275
+ from: refOf(active),
276
+ to: refOf(pick.to),
277
+ active: refOf(active),
278
+ threshold,
279
+ // What the notification reads, in the shape claude-swap's poll gives:
280
+ // room per account, and the windows of each by label.
281
+ headroom: Object.fromEntries(accounts.map(a => [String(a.num), a.headroom ?? null])),
282
+ windows: Object.fromEntries(accounts.map(a => [String(a.num),
283
+ Object.fromEntries((a.lanes ?? []).map(l => [l.label, l.pct]))])),
284
+ resetAt: pick.resetAt ?? null,
285
+ },
286
+ accounts,
287
+ };
288
+ }
289
+
290
+ /** The engine switched in a tick this step had looked at: the account it left
291
+ * the user on is the tick's pick, not one made by hand. */
292
+ export async function noteEngineTick(result, accounts) {
293
+ if (!accounts || !result?.switched) return;
294
+ const to = accounts.find(a => String(a.num) === String(result.to?.number));
295
+ await updateChoice(c => ({ ...c, manual: false, lastActive: to?.key ?? null, lastMoveAt: Date.now() }));
296
+ }
@@ -20,6 +20,9 @@ import { withStoreLock } from "./store-lock.mjs";
20
20
  // moved the account is said once on the desktop (account-notify.mjs). Both
21
21
  // doors return at once and never throw.
22
22
  import { noteAutoSwitch, noteAutoTick } from "./account-watch.mjs";
23
+ // The owner's two rules — a home account, and the soonest weekly reset — asked
24
+ // before the engine, inside the same lock. With neither set, nothing.
25
+ import { chooseBeforeEngine, forgetPicksByHand, noteEngineTick } from "./cswap-auto-choice.mjs";
23
26
  import { readFile, writeFile, mkdir } from "node:fs/promises";
24
27
  import { join } from "node:path";
25
28
  import { homedir } from "node:os";
@@ -51,6 +54,9 @@ function summarise(stdout) {
51
54
  // `switch` event for a decision it did not carry out, and a false positive
52
55
  // here throws away readings that cost a subprocess each.
53
56
  switched: events.some(e => e.event === "switch" && e.dryRun !== true),
57
+ // Why the engine moved — "proactive", "at-limit", "failover" — beside the
58
+ // deck's own "home" and "weekly-reset" (cswap-auto-choice.mjs).
59
+ trigger: action?.trigger ?? null,
54
60
  reason: action?.reason ?? null,
55
61
  detail: action?.detail ?? null,
56
62
  from: action?.from ?? null,
@@ -94,9 +100,15 @@ async function runAutoTick() {
94
100
  const r = await withStoreLock(async () => {
95
101
  if (!_enabled) return { skipped: "disabled" };
96
102
  if (await externalAutoRunning()) return { skipped: "external-engine" };
97
- return run(await cswapBin(), ["auto", "--once", "--json"], { timeout: TICK_TIMEOUT_MS });
103
+ // The deck's own rules first. A move they make is the tick; when they have
104
+ // nothing to say — or cannot say it — the engine decides, as it always did.
105
+ const chose = await chooseBeforeEngine().catch(() => ({ result: null, accounts: null }));
106
+ if (chose.result) return { chose: chose.result };
107
+ const out = await run(await cswapBin(), ["auto", "--once", "--json"], { timeout: TICK_TIMEOUT_MS });
108
+ return { ...out, accounts: chose.accounts };
98
109
  });
99
110
  if (r.skipped) return { event: "skipped", reason: r.skipped };
111
+ if (r.chose) return r.chose;
100
112
  // A KILLED RUN IS NOT A QUIET ONE. `run`'s timeout path deliberately keeps an
101
113
  // 8 KB tail of whatever the child managed to print, so `!r.ok && !r.stdout`
102
114
  // is false for a tick that emitted its `{"event":"poll"}` line and then
@@ -109,7 +121,10 @@ async function runAutoTick() {
109
121
  if (!r.ok && !r.stdout) {
110
122
  return { ok: false, reason: "tick_failed", detail: failureDetail(r, 300) };
111
123
  }
112
- return { ok: true, ...summarise(r.stdout) };
124
+ const result = { ok: true, ...summarise(r.stdout) };
125
+ // Where the engine left the user is a tick's pick, not one made by hand.
126
+ if (result.switched) await noteEngineTick(result, r.accounts).catch(() => {});
127
+ return result;
113
128
  }
114
129
 
115
130
  // ── deck-managed loop ──────────────────────────────────────────────────────
@@ -340,6 +355,7 @@ export function lastTick() {
340
355
  export async function setAutoEnabled(enabled) {
341
356
  _enabled = Boolean(enabled);
342
357
  noteAutoSwitch(_enabled);
358
+ if (_enabled) await forgetPicksByHand();
343
359
  await saveState({ ...(await loadState()), enabled: _enabled });
344
360
  if (_enabled) await startLoop(); else stopLoop();
345
361
  return { ok: true, enabled: _enabled };
@@ -9,6 +9,7 @@
9
9
  // it re-exports all five names, which is where the suite reaches them.
10
10
  import { run } from "./exec.mjs";
11
11
  import { cswapBin, cswapRefused } from "./cswap-install.mjs";
12
+ import { stripAnsi } from "./term.mjs";
12
13
 
13
14
  // ── one reading at a time ──────────────────────────────────────────────────
14
15
 
@@ -157,7 +158,9 @@ export function invalidateCswapAutoCache() {
157
158
  * whatever shape it is given, and the interval collapses four fields to one
158
159
  * number that is clamped anyway. The parse itself is a regex over
159
160
  * human-formatted output from a separate Python tool, on both line-ending
160
- * conventions. See cswap-auto-readers.test.ts.
161
+ * conventions, and with or without colour: the tool dims its `(default)` marker
162
+ * whenever the environment asks for colour, and a deck launched from a terminal
163
+ * inherits that terminal's FORCE_COLOR. See cswap-auto-readers.test.ts.
161
164
  *
162
165
  * One reading at a time and one every CONFIG_MIN_GAP_MS at most; the parse below
163
166
  * is what a reading is, and admission control is the wrapper. See throttled.
@@ -174,7 +177,7 @@ async function readCswapConfigNow() {
174
177
  const r = await run(bin, ["config"]);
175
178
  if (!r.ok) return null;
176
179
  const out = {};
177
- for (const line of r.stdout.split("\n")) {
180
+ for (const line of stripAnsi(r.stdout).split("\n")) {
178
181
  const m = line.match(/^(\S+)\s+(.*?)\s*(\(default\))?\s*$/);
179
182
  if (!m || !m[1].includes(".")) continue;
180
183
  const raw = m[2].trim();
@@ -20,6 +20,7 @@ import {
20
20
  commandTokens, externalAutoRunning, invalidateCswapAutoCache, looksLikeAutoLoop, readCswapConfig,
21
21
  } from "./cswap-auto-readers.mjs";
22
22
  import { initCswapAuto, lastTick, loopEnabled, restartLoop, setAutoEnabled } from "./cswap-auto-loop.mjs";
23
+ import { choiceStatus, setHomeAccount, setNextChoice } from "./cswap-auto-choice.mjs";
23
24
  import { invalidateClaudeAccountsCache, slotNumber } from "./claude-accounts.mjs";
24
25
  // The one store mutex, which the rotation flag below holds like every other
25
26
  // writer of claude-swap's store.
@@ -135,14 +136,19 @@ export { commandTokens, externalAutoRunning, looksLikeAutoLoop };
135
136
  // cswap-auto-loop.mjs.
136
137
  export { initCswapAuto, setAutoEnabled };
137
138
 
139
+ // Which account a tick moves to — the home it comes back to and the strategy
140
+ // for the next one — kept by the deck, not by claude-swap: cswap-auto-choice.mjs.
141
+ export { setHomeAccount, setNextChoice };
142
+
138
143
  export async function autoStatus() {
139
- const [config, external] = await Promise.all([readCswapConfig(), externalAutoRunning()]);
144
+ const [config, external, choice] = await Promise.all([readCswapConfig(), externalAutoRunning(), choiceStatus()]);
140
145
  return {
141
146
  ok: config != null,
142
147
  enabled: loopEnabled(),
143
148
  external, // user is running their own `cswap auto`
144
149
  lastTick: lastTick(),
145
150
  settings: config ?? {},
151
+ choice, // { next: "room"|"weekly", home: {email, orgUuid}|null }
146
152
  };
147
153
  }
148
154
 
@@ -72,6 +72,8 @@ export { prefsPath };
72
72
  */
73
73
  export const DEFAULTS = Object.freeze({
74
74
  notifications: false,
75
+ recoverCodexSessions: true,
76
+ recoverClaudeSessions: true,
75
77
  // ACCOUNT NOTIFICATIONS, one switch each (account-notify.mjs). Not part of
76
78
  // `notifications` above, which is about a deck nobody is looking at: these
77
79
  // are said whether a page is open or not, because no page says them. The
@@ -314,6 +316,8 @@ export function normalise(raw) {
314
316
  const src = raw && typeof raw === "object" ? raw : {};
315
317
  return {
316
318
  notifications: flagOr(src.notifications, DEFAULTS.notifications),
319
+ recoverClaudeSessions: flagOr(src.recoverClaudeSessions, DEFAULTS.recoverClaudeSessions),
320
+ recoverCodexSessions: flagOr(src.recoverCodexSessions, DEFAULTS.recoverCodexSessions),
317
321
  notifySwap: flagOr(src.notifySwap, DEFAULTS.notifySwap),
318
322
  notifyQuota: flagOr(src.notifyQuota, DEFAULTS.notifyQuota),
319
323
  notifyReset: flagOr(src.notifyReset, DEFAULTS.notifyReset),
@@ -701,7 +705,7 @@ export function publicPrefs(prefs) {
701
705
  * here — prefs-route-fields.test.ts lists them.
702
706
  */
703
707
  export const PAGE_FIELDS = Object.freeze({
704
- top: Object.freeze(["notifications", "notifySwap", "notifyQuota", "notifyReset", "tourSeen", "autoUpdate"]),
708
+ top: Object.freeze(["notifications", "notifySwap", "notifyQuota", "notifyReset", "tourSeen", "autoUpdate", "recoverCodexSessions", "recoverClaudeSessions"]),
705
709
  lan: Object.freeze([
706
710
  "enabled", "name", "shared", "manual", "shareActive", "pairingMode",
707
711
  "autoAsk", "autoAccept", "tailscale", "tailscaleAsk", "tailscaleAccept",
@@ -9,6 +9,7 @@
9
9
  // imports reach pushEvent through event-sink.mjs, connected below as this
10
10
  // module loads; index.mjs's routes import it from here. The bodies are
11
11
  // unchanged.
12
+ import { noteToolAudit } from "./tool-audit.mjs";
12
13
  import { PRODUCT } from "./brand.mjs";
13
14
  import { createBlockNotifier } from "./block-notify.mjs";
14
15
  import { notify as osNotify } from "./browser-react.mjs";
@@ -30,6 +31,7 @@ import { notifyTrays, pageCount, sseClients, trayClients, writeSse } from "./sse
30
31
  import { eventLogPath, maybeRotatePersistFile, writesLogFor } from "./event-log.mjs";
31
32
  import { appendLogLine } from "./log-writer.mjs";
32
33
  // The away-update's word on whether a turn is running — see lifecycle.mjs.
34
+ import { shouldTrackActivity } from "./activity.mjs";
33
35
  import { activity } from "./lifecycle.mjs";
34
36
  // The session LRU and the transcript watch — see session-tracking.mjs.
35
37
  import { outputWatch, touchSession } from "./session-tracking.mjs";
@@ -122,6 +124,7 @@ export function pushEvent(raw, source, opts = {}) {
122
124
  // Numbered, charged, held, and the ring evicted behind it — see admitEvent.
123
125
  const evt = admitEvent(raw, source, opts.receivedAt ?? Date.now());
124
126
  const seq = evt.seq;
127
+ noteToolAudit(raw, source, opts);
125
128
 
126
129
  // Does this event reach the log at all? Not on a replay (it came from
127
130
  // there), not when the hook told us another deck owns this session's log,
@@ -202,9 +205,9 @@ export function pushEvent(raw, source, opts = {}) {
202
205
  // know the other exists. block-notify.mjs holds the gates and the cooldown.
203
206
  blockNotifier.consider(raw, { clients: pageCount(), replay: !!opts.replay });
204
207
 
205
- // Whether a turn is running, for the away-update. Not from a replay: the log
206
- // is history, and a turn it shows open is one that ended in another process.
207
- if (!opts.replay) activity.note(raw, evt.receivedAt);
208
+ // Persisted history is not current activity. Fresh recovery snapshots are
209
+ // current evidence even though they bypass replayed-tool accounting.
210
+ if (shouldTrackActivity(raw, opts)) activity.note(raw, evt.receivedAt);
208
211
 
209
212
  if (persisting) {
210
213
  // Fire-and-forget append. JSONL = newline-delimited JSON, so the whole line
@@ -64,6 +64,8 @@ const nonEmpty = v => typeof v === "string" && v.trim() !== "";
64
64
  const STAMPED = [
65
65
  ["model_context_window", v => typeof v === "number" && v > 0],
66
66
  ["approval_policy", v => typeof v === "string" && v !== ""],
67
+ // Null is a value here: a turn that names no effort clears the chip (#2155).
68
+ ["reasoning_effort", v => (typeof v === "string" && v !== "") || v === null],
67
69
  ["context_tokens", v => typeof v === "number" && v >= 0],
68
70
  ];
69
71
 
@@ -139,6 +139,7 @@ export function upstreamForm({ fields, images }, facts) {
139
139
  if (contact) text("contact", contact);
140
140
  text("appVersion", facts.appVersion);
141
141
  text("platform", facts.platform);
142
+ if (facts.deviceId) text("deviceId", facts.deviceId);
142
143
  for (const [index, { data, format }] of images.entries()) {
143
144
  const { type, extension } = FORMAT[format];
144
145
  parts.push(`${head(IMAGES_FIELD, `; filename="image-${index + 1}.${extension}"`)}Content-Type: ${type}\r\n\r\n`, data, "\r\n");
@@ -83,6 +83,7 @@ import { ccProjectSlug } from "./claude-dir.mjs";
83
83
  export { ccProjectSlug };
84
84
  // The Codex half of capture: the rollouts directory tailed for its events —
85
85
  // see codex-watch.mjs.
86
+ import { createClaudeRecovery } from "./claude-recovery.mjs";
86
87
  import { startCodexWatcher } from "./codex-watch.mjs";
87
88
  // Exported from this file before they moved, and still. scanCodexNow is new,
88
89
  // and is here for the suites that import this file to drive the watcher.
@@ -164,6 +165,8 @@ import { handleBrowserWatch, handleBrowserWatchDismiss, handleBrowserWatchSettin
164
165
  // The music routes, every one behind AGENTS_DECK_NO_MUSIC — see music-routes.mjs.
165
166
  import { handleBestOfNostalgia, handleCafeMusicBgm, handleClaudeFm, handleFmStation, handleGoodLifeRadio, handleLiveRadioMix, handleLofiGirl, handleRadioStations, handleRadioClick, handleMusicConfig } from "./music-routes.mjs";
166
167
  // The usage panel's quota and history reads — see usage-routes.mjs.
168
+ import { startToolAudit } from "./tool-audit.mjs";
169
+ import { handleToolAudit } from "./tool-audit-routes.mjs";
167
170
  import { startQuotaHistory } from "./quota-history.mjs";
168
171
  import { handleCcusage, handleCcusageSession, handleCodexQuota, handleCodexUsage, handleProviderStatus, handleQuota, handleQuotaHistory } from "./usage-routes.mjs";
169
172
  // The port fallback, one listen attempt, and the words for a failed one — see
@@ -192,6 +195,7 @@ export function requestUrl(rawUrl) {
192
195
 
193
196
  export async function startServer({ port = 4317, host = "127.0.0.1", persist = null, portRange = [4318, 4400], workspace = "", codex = true, claude = true, onRestart = null, onStop = null, cswapQuiet = null } = {}) {
194
197
  startQuotaHistory();
198
+ await startToolAudit();
195
199
  armLifecycle({ onRestart, onStop, persist, onSelfUpdate: () => reporter.noteSelfUpdate() });
196
200
  // Which tree and which CLIs, normalised once — see setDeckScope.
197
201
  setDeckScope({ workspace, claude, codex });
@@ -318,6 +322,7 @@ export async function startServer({ port = 4317, host = "127.0.0.1", persist = n
318
322
  if (req.method === "POST" && url.pathname === "/api/restart") return guard(handleRestart(req, res), res);
319
323
  if (req.method === "POST" && url.pathname === "/api/presence") return guard(handlePresence(req, res), res);
320
324
  if (req.method === "POST" && url.pathname === "/api/shutdown") return guard(handleStop(req, res), res);
325
+ if (["GET", "POST"].includes(req.method) && ["/api/tool-audit", "/api/tool-audit/storage"].includes(url.pathname)) return guard(handleToolAudit(req, res), res);
321
326
  if (req.method === "GET" && url.pathname === "/api/quota/history") return guard(handleQuotaHistory(req, res), res);
322
327
  if (req.method === "GET" && url.pathname === "/api/quota") return guard(handleQuota(req, res), res);
323
328
  if (req.method === "GET" && url.pathname === "/api/codex-usage") return guard(handleCodexUsage(req, res), res);
@@ -510,6 +515,14 @@ export async function startServer({ port = 4317, host = "127.0.0.1", persist = n
510
515
  server.once("close", () => trafficCapture.dispose());
511
516
  // Codex has no working hooks on Windows — tail its rollout files instead.
512
517
  if (codex) startCodexWatcher(workspace);
518
+ if (claude) {
519
+ const recovery = createClaudeRecovery({ workspace });
520
+ const recover = () => { void recovery.scan().catch(() => {}); };
521
+ recover();
522
+ const timer = setInterval(recover, 1500);
523
+ timer.unref?.();
524
+ server.once("close", () => { clearInterval(timer); recovery.stop(); });
525
+ }
513
526
  // What a session is producing between its tool calls. Claude only — it
514
527
  // reads `transcript_path`, which Codex hooks never send — and unref'd
515
528
  // like its neighbours.
@@ -53,6 +53,7 @@ const PKG_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..");
53
53
  */
54
54
  const PINNED_MODULES = [
55
55
  "self-update.mjs",
56
+ "tool-audit.mjs",
56
57
  "claude-accounts.mjs",
57
58
  "account-health.mjs",
58
59
  "cswap-admin.mjs",
@@ -93,6 +94,10 @@ const PINNED_MODULES = [
93
94
  // The plans the daily report says, read through import() by depth-facts.mjs.
94
95
  "quota-oauth.mjs",
95
96
  "codex-auth.mjs",
97
+ // The paired decks' current accounts, which Auto-switch's own rules skip
98
+ // (cswap-auto-choice.mjs). Already in memory through prefs-routes.mjs; named
99
+ // here because the tick reaches it through import().
100
+ "lan-deck.mjs",
96
101
  ];
97
102
 
98
103
  let _pinned = null;
@@ -7,7 +7,7 @@ import { reportsVetoed } from "./deck-prefs.mjs";
7
7
  import { MAX_REQUEST_BYTES, isMultipart, readFeedbackForm, upstreamForm } from "./feedback-form.mjs";
8
8
  import { readBody, readBytes, send } from "./http-io.mjs";
9
9
  import { heldPrefs } from "./prefs-state.mjs";
10
- import { REPORTS_API, installFacts, reporter } from "./reports.mjs";
10
+ import { REPORTS_API, deviceIdToken, installFacts, reporter, reportsOn } from "./reports.mjs";
11
11
 
12
12
  async function readJson(req, res, limit) {
13
13
  const raw = await readBody(req, res, limit).catch(() => null);
@@ -72,35 +72,54 @@ export async function handleClientError(req, res, { report = reporter } = {}) {
72
72
  * one message. AGENTS_DECK_NO_INSTALL=1 still wins, because the README promises
73
73
  * that it "turns off everything but the quota reads", and a launch script's
74
74
  * word about the machine outranks a button on the page.
75
+ *
76
+ * `device` is the fingerprint feedbackDeviceId picks; a test passes its own,
77
+ * or null for none.
75
78
  */
76
- export async function handleFeedback(req, res, { fetchImpl = globalThis.fetch, env = process.env } = {}) {
79
+ export async function handleFeedback(req, res, { fetchImpl = globalThis.fetch, env = process.env, device = feedbackDeviceId({ env }) } = {}) {
77
80
  if (env.AGENTS_DECK_NO_INSTALL === "1") return send(res, 403, { ok: false, reason: "vetoed" });
81
+ // Left out of the post entirely when there is none: JSON drops an undefined.
82
+ const deviceId = device || undefined;
78
83
  const contentType = req.headers?.["content-type"];
79
- if (isMultipart(contentType)) return forwardFeedbackForm(req, res, contentType, { fetchImpl, env });
84
+ if (isMultipart(contentType)) return forwardFeedbackForm(req, res, contentType, { fetchImpl, env, deviceId });
80
85
  const body = await readJson(req, res, 32_000);
81
86
  if (!body) return send(res, 400, { ok: false, reason: "bad_request" });
82
87
  const facts = feedbackFacts({ env });
83
88
  const contact = typeof body.contact === "string" && body.contact.trim() ? body.contact.trim() : undefined;
84
89
  return forwardFeedback(res, fetchImpl, {
85
90
  headers: { "content-type": "application/json", "user-agent": `ccdeck/${facts.appVersion}` },
86
- body: JSON.stringify({ kind: body.kind, title: body.title, body: body.body, contact, ...facts }),
91
+ body: JSON.stringify({ kind: body.kind, title: body.title, body: body.body, contact, ...facts, deviceId }),
87
92
  timeoutMs: JSON_TIMEOUT_MS,
88
93
  });
89
94
  }
90
95
 
91
96
  /**
92
- * What this deck adds to a piece of feedback, and all it adds: the ccdeck
93
- * version, and the system as `os-arch` — `darwin-arm64`. installFacts knows
94
- * more than that (the channel, the runtime, a locale, a device fingerprint)
95
- * and none of it goes with feedback. One function for both posts and for the
96
- * line the dialog draws before Send, so what the page says is sent and what
97
- * the API is sent are one answer and cannot drift apart.
97
+ * What the dialog says, before Send, that this deck adds to a piece of
98
+ * feedback: the ccdeck version, and the system as `os-arch` — `darwin-arm64`.
99
+ * Both posts carry these two, from this one function, so what the line shows
100
+ * and what the API gets for them cannot drift apart.
101
+ *
102
+ * The posts also carry the device fingerprint (feedbackDeviceId), and the line
103
+ * does not show it: the owner's decision of 2026-10-14, so a report can be
104
+ * tied to the install that sent it. installFacts' other fields (the channel,
105
+ * the runtime, a locale) never go with feedback.
98
106
  */
99
107
  export function feedbackFacts({ env = process.env } = {}) {
100
108
  const facts = installFacts({ env });
101
109
  return { appVersion: facts.version, platform: `${facts.os}-${facts.arch}` };
102
110
  }
103
111
 
112
+ /**
113
+ * The device fingerprint a piece of feedback carries: the same one-way hash
114
+ * every usage report sends (deviceIdToken), and under the same rule — only
115
+ * while reports are on. Switched off, or vetoed with AGENTS_DECK_NO_REPORTS=1,
116
+ * the fingerprint stays home here as it does everywhere else, and the feedback
117
+ * still goes. It is read here, never taken from the page.
118
+ */
119
+ export function feedbackDeviceId({ env = process.env, prefs = heldPrefs.current() } = {}) {
120
+ return reportsOn(prefs, env) ? deviceIdToken() : undefined;
121
+ }
122
+
104
123
  /** `GET /api/feedback`: what a report sent from here would carry besides its
105
124
  * words, for the dialog to say before Send. Nothing leaves the machine. */
106
125
  export function handleFeedbackFacts(_req, res, { env = process.env } = {}) {
@@ -113,7 +132,7 @@ const JSON_TIMEOUT_MS = 20_000;
113
132
  * seconds of upload before the API has read a byte of it. */
114
133
  const FORM_TIMEOUT_MS = 120_000;
115
134
 
116
- async function forwardFeedbackForm(req, res, contentType, { fetchImpl, env }) {
135
+ async function forwardFeedbackForm(req, res, contentType, { fetchImpl, env, deviceId }) {
117
136
  // Over the cap, readBytes has already answered 413 too_large and the 400
118
137
  // below is a no-op; this is the answer to a read that failed some other way.
119
138
  const bytes = await readBytes(req, res, MAX_REQUEST_BYTES, { ok: false, reason: "too_large" }).catch(() => null);
@@ -125,7 +144,7 @@ async function forwardFeedbackForm(req, res, contentType, { fetchImpl, env }) {
125
144
  return forwardFeedback(res, fetchImpl, {
126
145
  // No content type: fetch writes the form's own, boundary and all.
127
146
  headers: { "user-agent": `ccdeck/${facts.appVersion}` },
128
- body: upstreamForm(form, facts),
147
+ body: upstreamForm(form, { ...facts, deviceId }),
129
148
  timeoutMs: FORM_TIMEOUT_MS,
130
149
  });
131
150
  }