ccdeck 3.43.4 → 3.44.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/README.md +69 -17
  2. package/THIRD_PARTY_NOTICES.md +43 -7
  3. package/bin/cli/commands.js +613 -0
  4. package/bin/cli/help.js +26 -3
  5. package/bin/cli/one-shot.js +2 -2
  6. package/bin/cli/picker.js +86 -0
  7. package/bin/cli/startup.js +13 -0
  8. package/bin/deck.js +16 -1
  9. package/dist/web/assets/{BrowserWatchModal-nOxhGOO4.js → BrowserWatchModal-DMIUygWz.js} +1 -1
  10. package/dist/web/assets/{RadioPanel-BLfpQ1Iw.js → RadioPanel-DNB_AHSs.js} +1 -1
  11. package/dist/web/assets/TrafficRadar-Ch0LVdSl.js +8 -0
  12. package/dist/web/assets/UsageHistoryModal-Du0ZkIJ1.js +1 -0
  13. package/dist/web/assets/app-CQIstkMF.js +318 -0
  14. package/dist/web/assets/app-Ch-0CYlK.css +1 -0
  15. package/dist/web/assets/{index-Ch4ks76J.js → index-BQbOfxbL.js} +1 -1
  16. package/dist/web/index.html +3 -3
  17. package/package.json +2 -1
  18. package/release-notes.json +34 -0
  19. package/src/server/account-notify.mjs +206 -9
  20. package/src/server/account-pick.mjs +160 -0
  21. package/src/server/account-projects.mjs +55 -15
  22. package/src/server/account-routes.mjs +47 -4
  23. package/src/server/account-watch.mjs +48 -4
  24. package/src/server/activity.mjs +3 -2
  25. package/src/server/args.mjs +112 -2
  26. package/src/server/autoswitch-failover.mjs +138 -0
  27. package/src/server/claude-accounts.mjs +115 -14
  28. package/src/server/claude-dir.mjs +142 -5
  29. package/src/server/claude-identity.mjs +23 -0
  30. package/src/server/cli-lines.mjs +224 -0
  31. package/src/server/codex-cost-usage.mjs +67 -0
  32. package/src/server/codex-enrichment.mjs +53 -9
  33. package/src/server/codex-review-policy.d.mts +4 -0
  34. package/src/server/codex-review-policy.mjs +11 -0
  35. package/src/server/codex-skills.mjs +146 -0
  36. package/src/server/codex-watch.mjs +5 -2
  37. package/src/server/cswap-auto-choice.mjs +53 -0
  38. package/src/server/cswap-auto-loop.mjs +100 -9
  39. package/src/server/deck-client.mjs +112 -0
  40. package/src/server/deck-prefs.mjs +16 -5
  41. package/src/server/event-pipeline.mjs +19 -3
  42. package/src/server/event-routes.mjs +15 -0
  43. package/src/server/exec.mjs +6 -2
  44. package/src/server/index.mjs +17 -1
  45. package/src/server/installer.mjs +149 -8
  46. package/src/server/lan-engine.mjs +2 -2
  47. package/src/server/lan-sync.mjs +3 -3
  48. package/src/server/lan-wire.mjs +3 -3
  49. package/src/server/login-guard.mjs +162 -0
  50. package/src/server/login-service.mjs +3 -0
  51. package/src/server/pinned-build.mjs +5 -0
  52. package/src/server/prefs-routes.mjs +4 -0
  53. package/src/server/request-gates.mjs +7 -0
  54. package/src/server/session-read-gate.mjs +5 -3
  55. package/src/server/session-summary.mjs +281 -0
  56. package/src/server/skill-hook.mjs +162 -0
  57. package/src/server/stop-continue-gate.d.mts +8 -0
  58. package/src/server/stop-continue-gate.mjs +77 -0
  59. package/src/server/stop-continue.mjs +544 -0
  60. package/src/server/stop-failure.d.mts +10 -0
  61. package/src/server/stop-failure.mjs +97 -0
  62. package/src/server/swap-log.mjs +19 -10
  63. package/src/server/tool-audit-report.mjs +5 -3
  64. package/src/server/tool-audit-routes.mjs +5 -3
  65. package/src/server/tool-audit-skills.mjs +82 -0
  66. package/src/server/tool-audit.mjs +53 -11
  67. package/src/server/traffic-radar-capture.mjs +10 -1
  68. package/src/server/traffic-radar-monitor.mjs +4 -2
  69. package/src/server/traffic-radar-routes.mjs +13 -0
  70. package/src/server/traffic-radar-service.mjs +133 -0
  71. package/src/server/usage-day.mjs +1 -1
  72. package/dist/web/assets/TrafficRadar-BFrObpjW.js +0 -1
  73. package/dist/web/assets/UsageHistoryModal-DCnFf1vi.js +0 -1
  74. package/dist/web/assets/app-BiJ8TfUl.css +0 -1
  75. package/dist/web/assets/app-DBsNB-2y.js +0 -312
@@ -0,0 +1,162 @@
1
+ // Never put a dead login under every session on the machine (#2184).
2
+ //
3
+ // Every Claude Code session on a machine shares one login. `cswap switch N`
4
+ // writes slot N's stored copy over it, and when that copy's refresh token is
5
+ // already dead every running session fails within seconds with "Login expired ·
6
+ // Please run /login". On the owner's machine that was three of the seven login
7
+ // interruptions in the month before this was written.
8
+ //
9
+ // claude-swap's own `auto` engine refreshes a target before it activates it and
10
+ // quarantines one whose refresh is refused. A plain `cswap switch` does neither,
11
+ // and that is the command the deck runs. So the deck asks claude-swap's store
12
+ // first, and refuses only on evidence: a dead-login error on the slot's own
13
+ // usage row, newer than the last good read and the last sign-in. Anything it
14
+ // cannot tell — no row, a row for somebody else, a store it cannot read — lets
15
+ // the switch go ahead exactly as it did before.
16
+ //
17
+ // WHY NOT ASK CLAUDE-SWAP ABOUT ONE SLOT. 0.26.0 has no command that checks a
18
+ // single account. `cswap list --json` is the nearest: a collection over every
19
+ // account that is due, under claude-swap's own locks, which refreshes an
20
+ // expired stored token through its one-time-grant gate and strikes the slot
21
+ // when the refresh is refused. It never re-reads a slot it has already struck,
22
+ // and it skips one its plan says is not due. So the deck asks for one only when
23
+ // it would actually read the target (collectionWouldRead) — the 2026-10-12
24
+ // incident was exactly that case: slot 5 last read ten minutes earlier, its
25
+ // poll overdue, and the refusal that proved its login dead recorded one second
26
+ // AFTER the switch had put it live.
27
+ //
28
+ // Pure: no file, no clock, no subprocess. claude-accounts.mjs reads the store
29
+ // and asks these, and account-routes.mjs hands removeLeavingLiveLogin its three
30
+ // steps.
31
+
32
+ /** claude-swap's two words for a stored refresh token only a sign-in can
33
+ * replace — its PERMANENT_AUTH_ERRORS. The panel says "login expired". */
34
+ export const DEAD_LOGIN_ERRORS = new Set(["invalid_grant", "no_refresh_token"]);
35
+
36
+ /** claude-swap's SERVE_TTL_S: younger than this, an on-demand collection
37
+ * serves the row from the store and fetches nothing. */
38
+ const SERVE_TTL_MS = 180_000;
39
+
40
+ /** A claude-swap timestamp (epoch seconds) in ms, or null. */
41
+ function ms(seconds) {
42
+ const v = typeof seconds === "number" ? seconds * 1000 : NaN;
43
+ return Number.isFinite(v) && v > 0 ? v : null;
44
+ }
45
+
46
+ /**
47
+ * Whether a slot's stored login is KNOWN dead, from its usage row.
48
+ *
49
+ * `matches` is rowIsFor's answer — a row left by the slot's previous occupant
50
+ * says nothing about this one. `signedInAt` is when the deck last signed this
51
+ * account in, in ms, or null.
52
+ *
53
+ * Dead is claude-swap's strike (`authDeadStrikes`, set by an `invalid_grant` or
54
+ * `no_refresh_token` and lifted by a good read or by any write of a new
55
+ * credential — add, import, a sign-in) or that error on a failing row, from a
56
+ * store that predates the strike count. And only when the refusal is the newest
57
+ * thing known about the login: a good read or a sign-in after it is about a
58
+ * login that has since worked. Transient errors — a 429, a timeout, a network
59
+ * that is not there — are never evidence either way.
60
+ *
61
+ * Answers `{ error, since }`, `since` being when the refusal was recorded (ms,
62
+ * or null when the row does not say), or null.
63
+ */
64
+ export function deadLoginOf(row, { matches = false, signedInAt = null } = {}) {
65
+ if (!matches || !row || typeof row !== "object") return null;
66
+ const struck = Number(row.authDeadStrikes) >= 1;
67
+ const refused = Number(row.consecutiveFailures) > 0 && DEAD_LOGIN_ERRORS.has(row.lastError);
68
+ if (!struck && !refused) return null;
69
+ const failedAt = ms(row.lastAttemptAt);
70
+ const goodAt = ms(row.fetchedAt);
71
+ if (failedAt != null && goodAt != null && goodAt > failedAt) return null;
72
+ if (failedAt != null && Number.isFinite(signedInAt) && signedInAt > failedAt) return null;
73
+ return { error: DEAD_LOGIN_ERRORS.has(row.lastError) ? row.lastError : "invalid_grant", since: failedAt };
74
+ }
75
+
76
+ /**
77
+ * Whether a `cswap list` asked for right now would read this row — claude-swap's
78
+ * own on-demand rule (usage_store._row_eligible with respect_plans), so the deck
79
+ * spends a collection on a switch only when it can change the answer.
80
+ *
81
+ * No row, or one for somebody else, is read on the first pass. A struck row is
82
+ * never read again, a row in backoff or claimed by another collector waits, and
83
+ * otherwise it must be both older than the serve TTL and past its planned poll.
84
+ */
85
+ export function collectionWouldRead(row, { matches = false, now }) {
86
+ if (!matches || !row || typeof row !== "object") return true;
87
+ if (Number(row.authDeadStrikes) >= 1) return false;
88
+ const backoff = ms(row.backoffUntil);
89
+ if (backoff != null && backoff > now) return false;
90
+ const claim = ms(row.claimUntil);
91
+ if (claim != null && claim > now) return false;
92
+ const fetched = ms(row.fetchedAt);
93
+ const next = ms(row.nextPollAt);
94
+ return (fetched == null || now - fetched > SERVE_TTL_MS) && (next == null || now >= next);
95
+ }
96
+
97
+ // ── removing the active account ─────────────────────────────────────────────
98
+ //
99
+ // `cswap remove` deletes the slot's stored copy and its entry, and nothing else:
100
+ // it does not activate another slot, and Claude Code stays signed in as the
101
+ // account that was removed. When that login works, that is the right thing to
102
+ // leave alone — every session goes on running. When it is the expired one, the
103
+ // sessions are already failing and nothing would ever move them: auto-switch
104
+ // will not touch a login claude-swap no longer manages. So then, and only then,
105
+ // the deck moves Claude Code to a working account once the removal has landed.
106
+ // After it, the dead login belongs to no slot, so `cswap switch` activates the
107
+ // target directly instead of refusing to back up the empty login first.
108
+
109
+ /** The roster's active row when its login is one only a sign-in brings back —
110
+ * the panel's "Login expired" on the live account. Never a stale copy (#721):
111
+ * there the user IS signed in, and only claude-swap's copy is dead. */
112
+ export function activeLoginExpired(row) {
113
+ if (!row?.active || row.staleCopy) return false;
114
+ if (row.stopped) return row.collector === "relogin_required";
115
+ return DEAD_LOGIN_ERRORS.has(row.error);
116
+ }
117
+
118
+ /** The accounts Claude Code may be moved to, best first: working, not held
119
+ * out, and with nothing wrong on the row — the freshest reading first, then the
120
+ * most room. `held` is the slots no move the deck makes may land on: set aside
121
+ * by claude-swap's engine, or in use by a local `cswap run` session
122
+ * (cswap-auto-choice.mjs slotsHeldBack). */
123
+ export function liveTargets(accounts, leaving, held = new Set()) {
124
+ return (Array.isArray(accounts) ? accounts : [])
125
+ .filter(a => a && a.num !== leaving && !a.active && !a.disabled && a.alive === true
126
+ && !a.staleCopy && !a.stopped && !a.error && !held.has(a.num))
127
+ .sort((a, b) => Number(a.stale === true) - Number(b.stale === true) || (b.headroom ?? -1) - (a.headroom ?? -1));
128
+ }
129
+
130
+ /** How many accounts a removal tries before it says none would take over. Each
131
+ * try can wait on a collection, and a store full of dead logins is a sign-in's
132
+ * job, not a walk through every slot. */
133
+ const MOVE_TRIES = 3;
134
+
135
+ /**
136
+ * Remove an account, and when it was the active one with its login expired,
137
+ * move Claude Code to a working account afterwards.
138
+ *
139
+ * `roster()` is the roster as it stood before the removal, `remove(num)` is
140
+ * cswap-admin's removeAccount and `switchTo(num)` the switch route's own path,
141
+ * guard included. `heldBack(accounts)` names the slots the move may not land
142
+ * on (liveTargets); one it cannot read holds nothing back. Answers the
143
+ * removal's own answer, plus `movedTo` (the account now active) or
144
+ * `stillExpired` (nothing could take over).
145
+ */
146
+ export async function removeLeavingLiveLogin(num, { roster, remove, switchTo, heldBack = async () => new Set() }) {
147
+ const before = await Promise.resolve().then(roster).catch(() => null);
148
+ const removed = await remove(num);
149
+ if (!removed?.ok) return removed;
150
+ const accounts = before?.ok && Array.isArray(before.accounts) ? before.accounts : [];
151
+ const row = accounts.find(a => a?.num === Number(num));
152
+ if (!activeLoginExpired(row)) return removed;
153
+ const held = await Promise.resolve().then(() => heldBack(accounts)).catch(() => null);
154
+ for (const target of liveTargets(accounts, row.num, held instanceof Set ? held : new Set()).slice(0, MOVE_TRIES)) {
155
+ const moved = await Promise.resolve().then(() => switchTo(target.num)).catch(() => null);
156
+ if (moved?.ok) return { ...removed, movedTo: { num: target.num, email: target.email ?? null, alias: target.alias ?? null } };
157
+ // Only a target refused for its own login is a reason to try the next one.
158
+ // Anything else — no claude-swap, a timeout — would refuse the next as well.
159
+ if (moved?.reason !== "login_expired") break;
160
+ }
161
+ return { ...removed, stillExpired: true };
162
+ }
@@ -403,6 +403,9 @@ export const SETTING_VARS = Object.freeze([
403
403
  "AGENTS_DECK_NO_INSTALL", "AGENTS_DECK_NO_DOWNLOAD", "AGENTS_DECK_NO_UPDATE_CHECK", "AGENTS_DECK_NO_STATUS",
404
404
  "AGENTS_DECK_NO_FRESHEN", "AGENTS_DECK_NO_NOTIFY", "AGENTS_DECK_NO_LAN", "AGENTS_DECK_NO_REPORTS",
405
405
  "AGENTS_DECK_CSWAP", "AGENTS_DECK_CLAUDE", "AGENTS_DECK_CCUSAGE", "CLAUDE_SWAP_BACKUP", "AGENTS_DECK_LHM_PORT",
406
+ // The opt-in for Skill activity's typed-command hook (skill-hook.mjs). A deck
407
+ // started at login without it would take the entry back out.
408
+ "AGENTS_DECK_SKILL_HOOK",
406
409
  ]);
407
410
 
408
411
  export function settingsEnv(env = process.env) {
@@ -98,6 +98,11 @@ const PINNED_MODULES = [
98
98
  // (cswap-auto-choice.mjs). Already in memory through prefs-routes.mjs; named
99
99
  // here because the tick reaches it through import().
100
100
  "lan-deck.mjs",
101
+ // The accounts Auto-switch's own moves never land on (slotsHeldBack), which
102
+ // the move after removing an expired active login skips too. Already in
103
+ // memory through cswap-auto.mjs; named here because account-routes.mjs
104
+ // reaches it through import().
105
+ "cswap-auto-choice.mjs",
101
106
  ];
102
107
 
103
108
  let _pinned = null;
@@ -13,6 +13,7 @@ import { heldPrefs } from "./prefs-state.mjs";
13
13
  import { applyLanPrefs, forgetReach, resetLanLoaded } from "./lan-deck.mjs";
14
14
  import { giveBackDeckFolders } from "./prefs-give-back.mjs";
15
15
  import { readBody, send } from "./http-io.mjs";
16
+ import { noteContinueSwitchedOn } from "./stop-continue.mjs";
16
17
 
17
18
  /**
18
19
  * GET the deck's settings, and what the machine is allowing.
@@ -78,6 +79,9 @@ export async function handlePrefsWrite(req, res) {
78
79
  // to one question: is anything going to turn up. Whatever the probe said
79
80
  // before is about a deck whose sockets were down — see forgetReach.
80
81
  if (body.lan?.enabled === true) forgetReach();
82
+ // A stopped background session already waiting for a working login is
83
+ // looked at now, not at the next pass (stop-continue.mjs).
84
+ if (patch.continueStopped === true) noteContinueSwitchedOn();
81
85
  return send(res, 200, prefsPayload());
82
86
  }
83
87
 
@@ -433,10 +433,14 @@ export const GUARDED_READS = new Set([
433
433
  "/api/tool-audit",
434
434
  "/api/tool-audit/storage",
435
435
  "/api/system/traffic-radar",
436
+ "/api/system/traffic-radar/settings",
436
437
  "/api/system/traffic-radar/capture",
437
438
  "/api/system/traffic-radar/capture/event",
438
439
  "/events",
439
440
  "/api/events",
441
+ // The ring folded to one row per session (#2183): each project's path and a
442
+ // standing question's own words, which is the ring's secret in fewer bytes.
443
+ "/api/sessions",
440
444
  "/api/claude-accounts",
441
445
  "/api/claude-accounts/login",
442
446
  "/api/browser-watch",
@@ -461,6 +465,9 @@ export const GUARDED_READS = new Set([
461
465
  "/api/ccusage/session",
462
466
  "/api/codex-usage",
463
467
  "/api/cswap-auto",
468
+ // Which sessions stopped, and the active account's quota reading the
469
+ // Continue button is gated on (stop-continue.mjs) — the quota route's class.
470
+ "/api/stop-continue",
464
471
  // Not a secret, and here for the other reason a read can be dangerous: it is
465
472
  // the one route where the caller names what the deck goes and fetches
466
473
  // (#1208). fm-station.mjs holds that to YouTube, but a page on another site
@@ -13,7 +13,8 @@
13
13
  * A per-session read gate with a window of `ms`.
14
14
  *
15
15
  * `run(sid, read)` calls `read()` unless a read for `sid` is still in flight or
16
- * the last one began less than `ms` ago. The stamp is taken as the read starts,
16
+ * the last one began less than `ms` ago. Returns whether a read started, so
17
+ * a caller that needs a final snapshot can keep refused work pending. The stamp is taken as the read starts,
17
18
  * so a read that fails or finds nothing still holds the next one back for the
18
19
  * window — that is the throttle. `read` is called before `run` returns, as
19
20
  * every copy called it, and returns a promise. Whatever that rejects with is
@@ -29,15 +30,16 @@ export function sessionReadGate(ms) {
29
30
  const pending = new Set(); // sid currently being read
30
31
  return {
31
32
  run(sid, read) {
32
- if (pending.has(sid)) return;
33
+ if (pending.has(sid)) return false;
33
34
  const now = Date.now();
34
35
  const last = lastReadAt.get(sid) ?? 0;
35
- if (now - last < ms) return;
36
+ if (now - last < ms) return false;
36
37
  lastReadAt.set(sid, now);
37
38
  pending.add(sid);
38
39
  read()
39
40
  .catch(() => {})
40
41
  .finally(() => pending.delete(sid));
42
+ return true;
41
43
  },
42
44
  forget(sid) {
43
45
  lastReadAt.delete(sid);
@@ -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
+ }