ccdeck 3.43.4 → 3.45.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 (97) hide show
  1. package/README.md +79 -18
  2. package/THIRD_PARTY_NOTICES.md +70 -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 +16 -1
  9. package/dist/web/assets/BrowserWatchModal-Cbzf3TWa.js +1 -0
  10. package/dist/web/assets/IntegrationModal-BnnBr0a3.js +5 -0
  11. package/dist/web/assets/RadioPanel-BIqrMLQN.js +1 -0
  12. package/dist/web/assets/TrafficRadar-D1gZCIwX.js +8 -0
  13. package/dist/web/assets/UsageHistoryModal-DAhplVju.js +1 -0
  14. package/dist/web/assets/app-BW3NYUYB.js +318 -0
  15. package/dist/web/assets/app-C5RY_wZ4.css +1 -0
  16. package/dist/web/assets/{index-Ch4ks76J.js → index-r8Ymr47Q.js} +1 -1
  17. package/dist/web/index.html +3 -3
  18. package/package.json +2 -1
  19. package/release-notes.json +52 -0
  20. package/src/server/account-notify.mjs +268 -9
  21. package/src/server/account-pick.mjs +160 -0
  22. package/src/server/account-projects.mjs +55 -15
  23. package/src/server/account-routes.mjs +47 -4
  24. package/src/server/account-watch.mjs +107 -4
  25. package/src/server/activity.mjs +3 -2
  26. package/src/server/args.mjs +112 -2
  27. package/src/server/autoswitch-failover.mjs +138 -0
  28. package/src/server/claude-accounts.mjs +127 -16
  29. package/src/server/claude-dir.mjs +142 -5
  30. package/src/server/claude-identity.mjs +23 -0
  31. package/src/server/cli-lines.mjs +224 -0
  32. package/src/server/codex-cost-usage.mjs +67 -0
  33. package/src/server/codex-enrichment.mjs +53 -9
  34. package/src/server/codex-review-policy.d.mts +4 -0
  35. package/src/server/codex-review-policy.mjs +11 -0
  36. package/src/server/codex-skills.mjs +146 -0
  37. package/src/server/codex-watch.mjs +5 -2
  38. package/src/server/cswap-auto-choice.mjs +53 -0
  39. package/src/server/cswap-auto-loop.mjs +100 -9
  40. package/src/server/deck-client.mjs +112 -0
  41. package/src/server/deck-prefs.mjs +26 -5
  42. package/src/server/event-pipeline.mjs +19 -3
  43. package/src/server/event-routes.mjs +15 -0
  44. package/src/server/exec.mjs +6 -2
  45. package/src/server/feature-use.mjs +11 -1
  46. package/src/server/forge-http.mjs +514 -0
  47. package/src/server/forge-public-fetch.mjs +228 -0
  48. package/src/server/forge-routes.mjs +154 -0
  49. package/src/server/github-api.mjs +1369 -0
  50. package/src/server/gitlab-api.mjs +1271 -0
  51. package/src/server/index.mjs +23 -3
  52. package/src/server/installer.mjs +149 -8
  53. package/src/server/integrations/cache.mjs +42 -0
  54. package/src/server/integrations/errors.mjs +167 -0
  55. package/src/server/integrations/forge.mjs +76 -0
  56. package/src/server/integrations/jira.mjs +1026 -0
  57. package/src/server/integrations/providers.mjs +11 -0
  58. package/src/server/integrations/proxy.mjs +314 -0
  59. package/src/server/integrations/registry.mjs +142 -0
  60. package/src/server/integrations/routes.mjs +498 -0
  61. package/src/server/integrations/store.mjs +226 -0
  62. package/src/server/lan-deck.mjs +7 -0
  63. package/src/server/lan-elsewhere.d.mts +30 -0
  64. package/src/server/lan-elsewhere.mjs +137 -0
  65. package/src/server/lan-engine.mjs +2 -2
  66. package/src/server/lan-sync.mjs +3 -3
  67. package/src/server/lan-wire.mjs +3 -3
  68. package/src/server/login-guard.mjs +162 -0
  69. package/src/server/login-service.mjs +3 -0
  70. package/src/server/org-telemetry.mjs +173 -0
  71. package/src/server/pinned-build.mjs +8 -0
  72. package/src/server/prefs-routes.mjs +4 -0
  73. package/src/server/request-gates.mjs +26 -0
  74. package/src/server/session-read-gate.mjs +5 -3
  75. package/src/server/session-summary.mjs +281 -0
  76. package/src/server/skill-hook.mjs +162 -0
  77. package/src/server/stop-continue-gate.d.mts +8 -0
  78. package/src/server/stop-continue-gate.mjs +77 -0
  79. package/src/server/stop-continue.mjs +544 -0
  80. package/src/server/stop-failure.d.mts +10 -0
  81. package/src/server/stop-failure.mjs +97 -0
  82. package/src/server/swap-log.mjs +19 -10
  83. package/src/server/tool-audit-report.mjs +5 -3
  84. package/src/server/tool-audit-routes.mjs +5 -3
  85. package/src/server/tool-audit-skills.mjs +82 -0
  86. package/src/server/tool-audit.mjs +53 -11
  87. package/src/server/traffic-radar-capture.mjs +10 -1
  88. package/src/server/traffic-radar-monitor.mjs +4 -2
  89. package/src/server/traffic-radar-routes.mjs +13 -0
  90. package/src/server/traffic-radar-service.mjs +133 -0
  91. package/src/server/usage-day.mjs +3 -1
  92. package/dist/web/assets/BrowserWatchModal-nOxhGOO4.js +0 -1
  93. package/dist/web/assets/RadioPanel-BLfpQ1Iw.js +0 -1
  94. package/dist/web/assets/TrafficRadar-BFrObpjW.js +0 -1
  95. package/dist/web/assets/UsageHistoryModal-DCnFf1vi.js +0 -1
  96. package/dist/web/assets/app-BiJ8TfUl.css +0 -1
  97. package/dist/web/assets/app-DBsNB-2y.js +0 -312
@@ -181,6 +181,23 @@ async function accountsInCswapRun(accounts) {
181
181
  return live;
182
182
  }
183
183
 
184
+ /**
185
+ * The slots no move may land on, whatever the rule: the ones claude-swap's
186
+ * engine quarantined and the ones a local `cswap run` session is using — the
187
+ * two `landable` skips by key. For the failover away from an expired login
188
+ * (autoswitch-failover.mjs), which moves without asking the rules, so it
189
+ * never lands on another dead login or on a login a session already holds.
190
+ * Roster rows in, slot numbers out.
191
+ */
192
+ export async function slotsHeldBack(roster) {
193
+ const accounts = (Array.isArray(roster) ? roster : [])
194
+ .filter(a => a && a.num != null)
195
+ .map(a => ({ ...a, key: accountKey(a.email, a.orgUuid) }));
196
+ const engine = await engineState(accounts);
197
+ const busy = await accountsInCswapRun(accounts);
198
+ return new Set(accounts.filter(a => engine.quarantined.has(a.key) || busy.has(a.key)).map(a => a.num));
199
+ }
200
+
184
201
  const numberOr = (v, fallback) => {
185
202
  const n = Number(v);
186
203
  return v != null && v !== "" && Number.isFinite(n) ? n : fallback;
@@ -294,3 +311,39 @@ export async function noteEngineTick(result, accounts) {
294
311
  const to = accounts.find(a => String(a.num) === String(result.to?.number));
295
312
  await updateChoice(c => ({ ...c, manual: false, lastActive: to?.key ?? null, lastMoveAt: Date.now() }));
296
313
  }
314
+
315
+ /**
316
+ * The two rules as `ccdeck switch --best` asks them (#2183): what
317
+ * account-pick.mjs bestAccount hands chooseAccount, read the way
318
+ * chooseBeforeEngine reads it — the threshold, the counted models and the
319
+ * engine's strategy from claude-swap's config, the accounts its engine
320
+ * quarantined and the ones a local `cswap run` session holds. Null when
321
+ * neither rule is set, before anything else is read: `--best` is then the
322
+ * engine's own choice, as every tick is.
323
+ *
324
+ * `choice` and `settings` are the deck's (GET /api/cswap-auto) when a deck
325
+ * answered, so the rules are the ones its panel shows; with no deck they are
326
+ * read here. Only reads: nothing in auto-choice.json or claude-swap's store
327
+ * changes, so it needs no lock and takes none.
328
+ */
329
+ export async function bestRules(rows, { choice = null, settings = null, now = Date.now() } = {}) {
330
+ const c = choice ?? await choiceStatus();
331
+ const next = c?.next === "weekly" ? "weekly" : "room";
332
+ const home = text(c?.home?.email) ? accountKey(c.home.email, c.home.orgUuid) : null;
333
+ if (next === "room" && !home) return null;
334
+ const cfg = (settings ?? await readCswapConfig()) ?? {};
335
+ const accounts = (Array.isArray(rows) ? rows : [])
336
+ .filter(a => a && a.num != null)
337
+ .map(a => ({ ...a, key: accountKey(a.email, a.orgUuid) }));
338
+ const engine = await engineState(accounts);
339
+ return {
340
+ next,
341
+ home,
342
+ threshold: numberOr(cfg["autoswitch.threshold"]?.value, 90),
343
+ models: countedModels(cfg["autoswitch.model"]?.value),
344
+ busy: await accountsInCswapRun(accounts),
345
+ quarantined: engine.quarantined,
346
+ optional: (cfg["autoswitch.strategy"]?.value ?? "best") === "best",
347
+ now,
348
+ };
349
+ }
@@ -18,11 +18,18 @@ import { invalidateQuotaCache } from "./quota.mjs";
18
18
  import { withStoreLock } from "./store-lock.mjs";
19
19
  // Told about every finished tick and about the switch itself, so a tick that
20
20
  // moved the account is said once on the desktop (account-notify.mjs). Both
21
- // doors return at once and never throw.
22
- import { noteAutoSwitch, noteAutoTick } from "./account-watch.mjs";
21
+ // doors return at once and never throw. The third is the other way round: how a
22
+ // session that stopped on a dead login asks for a tick now (tickSoon).
23
+ import { connectEarlyTick, noteAutoSwitch, noteAutoTick } from "./account-watch.mjs";
23
24
  // The owner's two rules — a home account, and the soonest weekly reset — asked
24
25
  // before the engine, inside the same lock. With neither set, nothing.
25
- import { chooseBeforeEngine, forgetPicksByHand, noteEngineTick } from "./cswap-auto-choice.mjs";
26
+ import { chooseBeforeEngine, forgetPicksByHand, noteEngineTick, slotsHeldBack } from "./cswap-auto-choice.mjs";
27
+ // The one decision the engine cannot reach under `--once`: leaving an active
28
+ // account whose login has died. See its header.
29
+ import { engineLostActive, failOver } from "./autoswitch-failover.mjs";
30
+ // And the automatic continue of stopped background sessions (#2193), which a
31
+ // tick that moved the account is the likeliest moment for. Returns at once.
32
+ import { noteContinueTick } from "./stop-continue.mjs";
26
33
  import { readFile, writeFile, mkdir } from "node:fs/promises";
27
34
  import { join } from "node:path";
28
35
  import { homedir } from "node:os";
@@ -55,7 +62,8 @@ function summarise(stdout) {
55
62
  // here throws away readings that cost a subprocess each.
56
63
  switched: events.some(e => e.event === "switch" && e.dryRun !== true),
57
64
  // Why the engine moved — "proactive", "at-limit", "failover" — beside the
58
- // deck's own "home" and "weekly-reset" (cswap-auto-choice.mjs).
65
+ // deck's own "home" and "weekly-reset" (cswap-auto-choice.mjs), and
66
+ // "login-expired", which the deck's failover writes here.
59
67
  trigger: action?.trigger ?? null,
60
68
  reason: action?.reason ?? null,
61
69
  detail: action?.detail ?? null,
@@ -121,9 +129,13 @@ async function runAutoTick() {
121
129
  // A deck-managed `cswap switch` does not advance the engine's own
122
130
  // lastSwitchAt. Its fallback must keep our cooldown too, or a manual
123
131
  // return to the full account can immediately escape the weekly rule
124
- // through the engine's most-room choice. Still allow emergency exits.
132
+ // through the engine's most-room choice. Still allow emergency exits:
133
+ // the engine's own at-limit and failover, and an active account it could
134
+ // not read, which may be a login that has died — the deck's failover
135
+ // judges that one after the tick (autoswitch-failover.mjs).
125
136
  const decision = summarise(preview.stdout);
126
- if (Date.now() < chose.cooldownUntil && decision.trigger !== "at-limit" && decision.trigger !== "failover") {
137
+ if (Date.now() < chose.cooldownUntil && decision.trigger !== "at-limit" && decision.trigger !== "failover"
138
+ && !engineLostActive({ ok: true, ...decision })) {
127
139
  return { chose: {
128
140
  ok: true, ...decision, event: "no-switch", switched: false,
129
141
  trigger: null, reason: "cooldown", from: null, to: null,
@@ -134,6 +146,7 @@ async function runAutoTick() {
134
146
  const out = await run(await cswapBin(), ["auto", "--once", "--json"], { timeout: TICK_TIMEOUT_MS });
135
147
  return { ...out, accounts: chose.accounts };
136
148
  });
149
+ _ruleAccounts = r.accounts ?? null;
137
150
  if (r.skipped) return { event: "skipped", reason: r.skipped };
138
151
  if (r.chose) return r.chose;
139
152
  // A KILLED RUN IS NOT A QUIET ONE. `run`'s timeout path deliberately keeps an
@@ -159,6 +172,11 @@ async function runAutoTick() {
159
172
  let _timer = null;
160
173
  let _lastTick = null;
161
174
  let _enabled = false;
175
+ // When the deck last switched away from a dead login, for failOver's cooldown.
176
+ let _lastFailoverAt = null;
177
+ // The roster the home/weekly rules decided on in this tick, or null when none
178
+ // is set: the failover's move is a tick's, and noteEngineTick records it so.
179
+ let _ruleAccounts = null;
162
180
  // Set the instant startLoop is entered and cleared when it settles, because
163
181
  // `_timer` cannot do that job: it is assigned AFTER an await, and the window in
164
182
  // between is what #537 was. See startLoop.
@@ -169,6 +187,9 @@ let _starting = false;
169
187
  let _restartWanted = false;
170
188
  // The tick in flight, so the interval can skip rather than stack. See tick.
171
189
  let _ticking = null;
190
+ // When the tick in flight began, so an early tick can tell whether the one
191
+ // running already started after it was asked for. See tickSoon.
192
+ let _tickStartedAt = 0;
172
193
  // What every tick waits for before it runs anything: the launcher's word that
173
194
  // the claude-swap it drives is not being installed or upgraded underneath it.
174
195
  // Settled by default, so a caller with nothing to wait for — a respawn, a test,
@@ -244,7 +265,25 @@ async function runTick() {
244
265
  _lastTick = { at: Date.now(), event: "skipped", reason: "disabled" };
245
266
  return;
246
267
  }
247
- const result = await runAutoTick();
268
+ let result = await runAutoTick();
269
+ // A tick that could not read the active account may be a login that has
270
+ // died, which the engine cannot leave under --once (autoswitch-failover.mjs).
271
+ // The switch asks both of this function's questions again inside the lock,
272
+ // for the same reason runAutoTick does (#1797).
273
+ const failover = await failOver(result, {
274
+ lastAt: _lastFailoverAt,
275
+ before: async () => (!_enabled ? "disabled" : (await externalAutoRunning()) ? "external-engine" : null),
276
+ heldBack: slotsHeldBack,
277
+ }).catch(() => null);
278
+ if (failover) {
279
+ result = { ...result, ...failover };
280
+ if (failover.switched) {
281
+ _lastFailoverAt = Date.now();
282
+ // Where the failover left the user is a tick's pick, not one made by
283
+ // hand, so the home/weekly rules and their cooldown go on from it.
284
+ await noteEngineTick(result, _ruleAccounts).catch(() => {});
285
+ }
286
+ }
248
287
  // Before `_lastTick`, not after. This is the only path in the deck that moves
249
288
  // the live account without a click behind it, so nothing else is in a position
250
289
  // to make the call — and `_lastTick` is what /api/cswap-auto reports, so
@@ -286,12 +325,63 @@ async function runTick() {
286
325
  function tick() {
287
326
  if (_ticking) return _ticking;
288
327
  // The notifier hears what every tick did, the ones that switched nothing
289
- // included: a threshold notice it is holding waits for exactly that answer.
328
+ // included: a threshold notice it is holding waits for exactly that answer,
329
+ // and a stopped notice for one that began after the stop.
290
330
  // Not awaited, and caught inside: a notification never delays a tick.
291
- _ticking = runTick().then(() => { noteAutoTick(_lastTick); }).finally(() => { _ticking = null; });
331
+ const startedAt = Date.now();
332
+ _tickStartedAt = startedAt;
333
+ _ticking = runTick().then(() => { noteAutoTick(_lastTick, { startedAt }); noteContinueTick(_lastTick); }).finally(() => { _ticking = null; });
292
334
  return _ticking;
293
335
  }
294
336
 
337
+ /** How long an early tick waits for the rest of a burst: sessions sharing a
338
+ * login stop within a second or two of each other. */
339
+ export const SOON_MS = 1_500;
340
+
341
+ /** The least time between two early ticks. The stops arrive on `/api/event`,
342
+ * which takes a post without the deck's token, so this is what keeps a loop
343
+ * of fake ones from running `cswap auto --once` back to back — the interval's
344
+ * own floor is fifteen seconds, and this is twice that. */
345
+ export const SOON_GAP_MS = 30_000;
346
+
347
+ let _soon = null;
348
+ let _soonAskedAt = -Infinity;
349
+
350
+ /**
351
+ * Run one tick now rather than at the next interval, because a session has
352
+ * just stopped on an expired login or a usage limit (account-watch.mjs
353
+ * `noteHookStop`). The same tick the interval runs, through the same guards,
354
+ * so nothing about what the engine decides changes — only when it is asked.
355
+ *
356
+ * COALESCED: the first ask arms one tick SOON_MS later and every ask until it
357
+ * runs joins it, so ten sessions stopping in the same second cause one tick;
358
+ * after it, asks inside SOON_GAP_MS are dropped, and the interval carries on.
359
+ * NEVER BESIDE A RUNNING TICK: one in flight is waited for, and is enough by
360
+ * itself when it began after the ask. Off, this does nothing.
361
+ *
362
+ * Returns whether a tick was armed, for the suite.
363
+ */
364
+ export function tickSoon() {
365
+ if (!_enabled || _soon) return false;
366
+ const askedAt = Date.now();
367
+ if (askedAt - _soonAskedAt < SOON_GAP_MS) return false;
368
+ _soonAskedAt = askedAt;
369
+ _soon = setTimeout(async () => {
370
+ _soon = null;
371
+ try {
372
+ if (_ticking) {
373
+ const covered = _tickStartedAt >= askedAt;
374
+ await _ticking;
375
+ if (covered) return;
376
+ }
377
+ if (_enabled) await tick();
378
+ } catch { /* a tick reports its own failures in _lastTick */ }
379
+ }, SOON_MS);
380
+ _soon.unref?.();
381
+ return true;
382
+ }
383
+ connectEarlyTick(tickSoon);
384
+
295
385
  /**
296
386
  * Start the deck-managed loop, at most once.
297
387
  *
@@ -354,6 +444,7 @@ async function startLoop() {
354
444
 
355
445
  function stopLoop() {
356
446
  if (_timer) { clearInterval(_timer); _timer = null; }
447
+ if (_soon) { clearTimeout(_soon); _soon = null; }
357
448
  }
358
449
 
359
450
  /** Put a new interval into effect: the timer goes, and a fresh read of the
@@ -0,0 +1,112 @@
1
+ // How a command typed in a terminal talks to the deck already running (#2183).
2
+ //
3
+ // `ccdeck accounts` and `ccdeck switch` are asked over SSH as often as at the
4
+ // desk, by somebody who has no tab open and no wish to forward one. The deck
5
+ // already answers everything they need — the roster, the switch, the sessions
6
+ // it is watching — so the command is a client of it rather than a second
7
+ // implementation beside it.
8
+ //
9
+ // AND IT HAS TO BE. The account store's mutex is in the deck's own process
10
+ // (store-lock.mjs, "not defence against another process"), so a terminal that
11
+ // ran `cswap switch` itself while a deck was up would race the Accounts panel,
12
+ // the auto-switch tick and a sign-in in flight — the race #1039 closed inside
13
+ // the deck. Through the deck, the switch takes that lock, invalidates the
14
+ // roster and the quota the panel is showing, and lands in the swap log, exactly
15
+ // as a click does.
16
+ //
17
+ // THE SAME DOOR `--stop` USES. The registry record (0600, this user's) holds
18
+ // the deck's port and token; a request on 127.0.0.1 with `x-ccdeck-token` and
19
+ // no browser headers is what request-gates.mjs admits for a guarded read and a
20
+ // mutation alike. The token is read again on every call, because a deck that
21
+ // restarted has a new one.
22
+ //
23
+ // A leaf, like stop-deck.mjs: node:http and running-deck.mjs, and nothing that
24
+ // starts a server or arms a timer.
25
+ import { request as httpRequest } from "node:http";
26
+ import { liveDecks } from "./running-deck.mjs";
27
+
28
+ /** Long enough for a roster read, which can wait on claude-swap's store; short
29
+ * enough that a wedged deck does not hold the terminal. A switch passes its
30
+ * own, since `cswap switch` is given thirty seconds inside the deck. */
31
+ export const ASK_MS = 10_000;
32
+
33
+ /**
34
+ * The deck a command talks to, or null when none is running.
35
+ *
36
+ * `--port` names one. Otherwise there is meant to be exactly one — a start
37
+ * keeps at most one per Claude config directory (running-deck.mjs) — and when a
38
+ * leftover from before that rule is still up, the newest version wins, then the
39
+ * lowest port, so the answer is the same every time it is asked.
40
+ */
41
+ export async function findDeck({ port = null, live = liveDecks } = {}) {
42
+ const decks = await live().catch(() => []);
43
+ if (port != null) return decks.find(d => d.port === Number(port)) ?? null;
44
+ if (!decks.length) return null;
45
+ return [...decks].sort((a, b) => newer(b.version, a.version) || a.port - b.port)[0];
46
+ }
47
+
48
+ /** Positive when `a` is a newer x.y.z than `b`. A missing version is oldest. */
49
+ function newer(a, b) {
50
+ const parts = (v) => String(v ?? "").split(".").map(n => Number.parseInt(n, 10) || 0);
51
+ const [x, y] = [parts(a), parts(b)];
52
+ for (let i = 0; i < 3; i++) if ((x[i] ?? 0) !== (y[i] ?? 0)) return (x[i] ?? 0) - (y[i] ?? 0);
53
+ return 0;
54
+ }
55
+
56
+ /**
57
+ * One request to a deck, answered as `{ ok, status, body }`.
58
+ *
59
+ * Never rejects, for the reason askDeckToStop gives: every failure is a verdict
60
+ * the caller has something to say about. `ok` is the HTTP status, and `body` the
61
+ * parsed JSON or null — the deck's own `{ ok: false, reason }` arrives with a
62
+ * 400 and is worth more to the reader than the status alone, so it is kept.
63
+ */
64
+ export function askDeck(rec, { method = "GET", path, body = undefined, timeoutMs = ASK_MS, request = httpRequest } = {}) {
65
+ return new Promise(resolve => {
66
+ let settled = false;
67
+ const finish = (v) => { if (!settled) { settled = true; resolve(v); } };
68
+ const payload = body === undefined ? null : JSON.stringify(body);
69
+ let req;
70
+ try {
71
+ req = request({
72
+ hostname: "127.0.0.1",
73
+ port: rec.port,
74
+ path,
75
+ method,
76
+ timeout: timeoutMs,
77
+ headers: {
78
+ // The token, and nothing a browser would send: a request that carried
79
+ // an Origin or Sec-Fetch-Site would be judged as a page's, and a page
80
+ // is not who this is.
81
+ "x-ccdeck-token": String(rec.token ?? ""),
82
+ ...(payload === null
83
+ ? { "content-length": "0" }
84
+ : { "content-type": "application/json", "content-length": String(Buffer.byteLength(payload)) }),
85
+ },
86
+ }, res => {
87
+ let text = "";
88
+ res.setEncoding("utf8");
89
+ res.on("data", d => { text += d; });
90
+ res.on("end", () => {
91
+ let parsed = null;
92
+ try { parsed = JSON.parse(text); } catch { /* not JSON: the status speaks */ }
93
+ const status = res.statusCode ?? 0;
94
+ finish({ ok: status >= 200 && status < 300, status, body: parsed });
95
+ });
96
+ // A reply cut off half way — the deck stopped, or its socket did —
97
+ // ends with neither `end` nor a request error, and a promise left
98
+ // waiting for one is a command that never returns.
99
+ res.on("error", () => {});
100
+ res.on("close", () => {
101
+ if (!res.complete) finish({ ok: false, status: res.statusCode ?? 0, body: null, reason: "cut_off" });
102
+ });
103
+ });
104
+ } catch (err) {
105
+ return finish({ ok: false, status: 0, body: null, reason: err?.code ?? "request_failed" });
106
+ }
107
+ req.on("error", err => finish({ ok: false, status: 0, body: null, reason: err?.code ?? "unreachable" }));
108
+ req.on("timeout", () => { req.destroy(); finish({ ok: false, status: 0, body: null, reason: "timeout" }); });
109
+ if (payload !== null) req.write(payload);
110
+ req.end();
111
+ });
112
+ }
@@ -84,6 +84,19 @@ export const DEFAULTS = Object.freeze({
84
84
  notifySwap: true,
85
85
  notifyQuota: false,
86
86
  notifyReset: false,
87
+ // Sessions that stopped on an expired login or a usage limit, said once per
88
+ // incident (account-notify.mjs). ON for the swap's reason: it is rare, every
89
+ // running session is stopped when it happens, and nothing else says so.
90
+ notifyStopped: true,
91
+ // A login this deck is on, live on another deck too (#2192). ON, like the
92
+ // swap: it is rare, it is said once per incident, and nothing else on the
93
+ // desktop says it before one of the two machines is signed out.
94
+ notifyElsewhere: true,
95
+ // Continue a BACKGROUND session whose turn stopped on an expired login or a
96
+ // usage limit, once a working login with room is active again — once per
97
+ // stop (stop-continue.mjs, #2193). OFF: it stops and restarts a session's
98
+ // process on the user's behalf, and that is something a person turns on.
99
+ continueStopped: false,
87
100
  // The Electron window can open on a new localhost port after an update.
88
101
  // Its tour marker must outlive that origin; browser tabs still use their
89
102
  // own localStorage marker and never read this preference.
@@ -334,6 +347,9 @@ export function normalise(raw) {
334
347
  notifySwap: flagOr(src.notifySwap, DEFAULTS.notifySwap),
335
348
  notifyQuota: flagOr(src.notifyQuota, DEFAULTS.notifyQuota),
336
349
  notifyReset: flagOr(src.notifyReset, DEFAULTS.notifyReset),
350
+ notifyStopped: flagOr(src.notifyStopped, DEFAULTS.notifyStopped),
351
+ notifyElsewhere: flagOr(src.notifyElsewhere, DEFAULTS.notifyElsewhere),
352
+ continueStopped: flagOr(src.continueStopped, DEFAULTS.continueStopped),
337
353
  tourSeen: flagOr(src.tourSeen, DEFAULTS.tourSeen),
338
354
  autoUpdate: flagOr(src.autoUpdate, DEFAULTS.autoUpdate),
339
355
  reports: flagOr(src.reports, DEFAULTS.reports),
@@ -718,7 +734,10 @@ export function publicPrefs(prefs) {
718
734
  * here — prefs-route-fields.test.ts lists them.
719
735
  */
720
736
  export const PAGE_FIELDS = Object.freeze({
721
- top: Object.freeze(["notifications", "notifySwap", "notifyQuota", "notifyReset", "tourSeen", "autoUpdate", "recoverCodexSessions", "recoverClaudeSessions"]),
737
+ top: Object.freeze([
738
+ "notifications", "notifySwap", "notifyQuota", "notifyReset", "notifyStopped", "notifyElsewhere", "continueStopped",
739
+ "tourSeen", "autoUpdate", "recoverCodexSessions", "recoverClaudeSessions",
740
+ ]),
722
741
  lan: Object.freeze([
723
742
  "enabled", "name", "shared", "manual", "shareActive", "sendInUse", "pairingMode",
724
743
  "autoAsk", "autoAccept", "tailscale", "tailscaleAsk", "tailscaleAccept",
@@ -752,15 +771,17 @@ export function notificationsOn(prefs, env = process.env) {
752
771
  }
753
772
 
754
773
  /**
755
- * Which account notifications may go out right now: the three switches, each
756
- * on its own, and all three off when the machine vetoed notifications at
774
+ * Which account notifications may go out right now: the five switches, each
775
+ * on its own, and all five off when the machine vetoed notifications at
757
776
  * launch — AGENTS_DECK_NO_NOTIFY=1 keeps the deck off the desktop whatever
758
777
  * else it was told, and these are desktop notifications too.
759
778
  */
760
779
  export function accountNotifySettings(prefs, env = process.env) {
761
- if (notificationsVetoed(env)) return { swap: false, quota: false, reset: false };
780
+ if (notificationsVetoed(env)) return { swap: false, quota: false, reset: false, stopped: false, elsewhere: false };
762
781
  const p = normalise(prefs);
763
- return { swap: p.notifySwap, quota: p.notifyQuota, reset: p.notifyReset };
782
+ return {
783
+ swap: p.notifySwap, quota: p.notifyQuota, reset: p.notifyReset, stopped: p.notifyStopped, elsewhere: p.notifyElsewhere,
784
+ };
764
785
  }
765
786
 
766
787
  /**
@@ -14,9 +14,13 @@ import { PRODUCT } from "./brand.mjs";
14
14
  import { createBlockNotifier } from "./block-notify.mjs";
15
15
  import { notify as osNotify } from "./browser-react.mjs";
16
16
  import { accountNotifySettings, notificationsOn } from "./deck-prefs.mjs";
17
- // The account notifier — a switch, a quota threshold, a reset — connected below
18
- // to the same OS call the block notifier uses.
19
- import { connectAccountNotify } from "./account-watch.mjs";
17
+ // The account notifier — a switch, a quota threshold, a reset, sessions stopped
18
+ // on a dead login — connected below to the same OS call the block notifier
19
+ // uses, and the door a stopped turn is reported through.
20
+ import { connectAccountNotify, noteHookStop } from "./account-watch.mjs";
21
+ // The same stops, and what shows each session working again, for the Continue
22
+ // action and the automatic continue of background sessions (#2193).
23
+ import { noteStopContinue } from "./stop-continue.mjs";
20
24
  // The settings as this process holds them — see prefs-state.mjs.
21
25
  import { heldPrefs } from "./prefs-state.mjs";
22
26
  // The deck's own token, taken back out of every event before the ring, the
@@ -205,6 +209,15 @@ export function pushEvent(raw, source, opts = {}) {
205
209
  // know the other exists. block-notify.mjs holds the gates and the cooldown.
206
210
  blockNotifier.consider(raw, { clients: pageCount(), replay: !!opts.replay });
207
211
 
212
+ // A turn this machine's Claude Code ended on an expired login or a usage
213
+ // limit: one notification for the incident, and an auto-switch tick now
214
+ // rather than at the next interval (account-watch.mjs). A hook's own post
215
+ // only — a replay is history, and the scanners never send one.
216
+ if (source === "hook" && !opts.replay) {
217
+ noteHookStop(raw, evt.receivedAt);
218
+ noteStopContinue(raw, evt.receivedAt);
219
+ }
220
+
208
221
  // Persisted history is not current activity. Fresh recovery snapshots are
209
222
  // current evidence even though they bypass replayed-tool accounting.
210
223
  if (shouldTrackActivity(raw, opts)) activity.note(raw, evt.receivedAt);
@@ -241,6 +254,9 @@ export function pushEvent(raw, source, opts = {}) {
241
254
  // Provider gates the path: Claude reads transcript_path; Codex reads its
242
255
  // rollout JSONL under ~/.codex/sessions/. The Claude scanners short-circuit
243
256
  // when transcript_path is absent (always the case for Codex hooks).
257
+ if (source === "codex" && !opts.replay && raw?.hook_event_name === "UsageObserved") {
258
+ maybeResolveCodex(raw, { persist: opts.persist });
259
+ }
244
260
  if (source === "hook" && !opts.replay) {
245
261
  // A hook is somebody running the CLI, which is what the day's usage counts.
246
262
  usageDay.noteUse(raw);
@@ -17,6 +17,7 @@ import { presentsDeckToken } from "./request-gates.mjs";
17
17
  import { SEQ_EPOCH, ringHoldsNewerThan, ringSnapshot } from "./event-ring.mjs";
18
18
  // The one door every event comes through — see event-pipeline.mjs.
19
19
  import { pushEvent } from "./event-pipeline.mjs";
20
+ import { noteToolAudit } from "./tool-audit.mjs";
20
21
  import { noteLogWriter } from "./event-log.mjs";
21
22
  // Which CLIs this deck watches — see deck-scope.mjs.
22
23
  import { deckProviders } from "./deck-scope.mjs";
@@ -201,6 +202,20 @@ export function handleEventIngest(req, res, persist = true) {
201
202
  if (!deckProviders().claude && parsed?.provider === "claude") {
202
203
  return send(res, 409, { error: "this deck is not watching Claude" });
203
204
  }
205
+ // A slash command expanding into a prompt (UserPromptExpansion) is counted
206
+ // for Skill activity and goes no further. The prompt it becomes arrives on
207
+ // its own UserPromptSubmit, the canvas has nothing to draw for it, and the
208
+ // reducer would otherwise treat an event it does not know as session
209
+ // traffic. So it never reaches the ring, the pages or the log.
210
+ if (parsed?.hook_event_name === "UserPromptExpansion") {
211
+ try {
212
+ noteToolAudit(parsed, "hook", {});
213
+ send(res, 200, { ok: true });
214
+ } catch (err) {
215
+ sendInternalError(res, err);
216
+ }
217
+ return;
218
+ }
204
219
  // Everything past the parse is inside one net, because this listener is the
205
220
  // one place in the route table `guard` cannot reach. The route does wrap the
206
221
  // call — `guard(handleEventIngest(req, res, …), res)` — but this function
@@ -96,8 +96,12 @@ const TIMEOUT_TAIL = 8 << 10;
96
96
  * `{...process.env, X: "1"}` to add to it. It exists because the quota probe
97
97
  * runs a whole Claude Code and has to mark the run as the deck's own, and that
98
98
  * marker is what stops every poll drawing itself onto the canvas.
99
+ *
100
+ * `cwd` is the child's working directory, and the deck's own when absent. It
101
+ * exists for `claude --resume <id> --bg` (stop-continue.mjs): Claude Code
102
+ * finds a conversation by the project it was started in, and resumes it there.
99
103
  */
100
- export function run(cmd, args, { timeout = 20_000, maxBuffer = 4 << 20, env } = {}) {
104
+ export function run(cmd, args, { timeout = 20_000, maxBuffer = 4 << 20, env, cwd } = {}) {
101
105
  const tries = candidates(cmd);
102
106
  return new Promise((resolve) => {
103
107
  const attempt = (i) => {
@@ -166,7 +170,7 @@ export function run(cmd, args, { timeout = 20_000, maxBuffer = 4 << 20, env } =
166
170
  // process, so a shutdown that does not reap it leaves the child with
167
171
  // no deadline at all. See `live` in exec-children.mjs, and #1012.
168
172
  const cp = watchChild(execFile(file, argv,
169
- { timeout: 0, shell: false, windowsHide: true, maxBuffer, ...(env ? { env } : {}), ...opts }, done));
173
+ { timeout: 0, shell: false, windowsHide: true, maxBuffer, ...(env ? { env } : {}), ...(cwd ? { cwd } : {}), ...opts }, done));
170
174
  // Give the child EOF on stdin straight away, which is what this
171
175
  // function's contract has always claimed ("run closes stdin", says
172
176
  // runInteractive's header) and what execFile does not do: it leaves the
@@ -23,10 +23,20 @@ const ROUTE_FEATURES = new Map([
23
23
  ["POST /api/clear", "clear-board"],
24
24
  ]);
25
25
 
26
+ /** Features a whole namespace of routes is: a connected tracker's reads carry
27
+ * the connection's id in the path (#2178), so they are matched by prefix. The
28
+ * name is the provider's, never the connection, board or issue read. */
29
+ const ROUTE_PREFIX_FEATURES = [
30
+ ["GET /api/integrations/jira/", "jira"],
31
+ ["GET /api/integrations/github/", "github"],
32
+ ["GET /api/integrations/gitlab/", "gitlab"],
33
+ ];
34
+
26
35
  /** Note the feature a request is, if it is one. Called for requests that got
27
36
  * past the deck's own gates. */
28
37
  export function noteRouteFeature(method, pathname) {
29
- const name = ROUTE_FEATURES.get(`${method} ${pathname}`);
38
+ const route = `${method} ${pathname}`;
39
+ const name = ROUTE_FEATURES.get(route) ?? ROUTE_PREFIX_FEATURES.find(([prefix]) => route.startsWith(prefix))?.[1];
30
40
  if (name) usageDay.noteFeature(name);
31
41
  }
32
42