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
@@ -0,0 +1,173 @@
1
+ // What each Claude organization's settings send, remembered per organization.
2
+ //
3
+ // A Team or Enterprise organization can turn Claude Code's OpenTelemetry export
4
+ // on for every member: the admin sets it in claude.ai, and Claude Code fetches
5
+ // it at startup and hourly into <config dir>/remote-settings.json. There is ONE
6
+ // such file per machine, and it describes whichever organization's login last
7
+ // fetched it. claude-swap does not touch it on a switch, so for a while after a
8
+ // switch it still describes the account the user just left, until a Claude
9
+ // session on the new account fetches its own.
10
+ //
11
+ // So the deck cannot read an account's telemetry off the file whenever it
12
+ // likes. It reads it when the file can be attributed, and remembers the answer
13
+ // under the organization's uuid, for the accounts panel to show on every row of
14
+ // that organization — the inactive ones included, which is the point: the
15
+ // panel can say before a switch what the switch will turn on.
16
+ //
17
+ // WHEN THE FILE IS AN ORGANIZATION'S. Both of these, or nothing is recorded:
18
+ // - it was written after claude-swap last changed its store (sequence.json's
19
+ // `lastUpdated`), so it was fetched by the account active now;
20
+ // - the live login (`oauthAccount` in Claude Code's own .claude.json) belongs
21
+ // to the same organization as claude-swap's active account, so a /login
22
+ // made outside claude-swap is not mistaken for the store's account.
23
+ // Anything else is left unknown rather than guessed: telling a user an account
24
+ // is unmonitored because the file was another organization's is the one
25
+ // mistake this must not make. `lastUpdated` also moves on an alias or an add,
26
+ // which can only make an answer wait for the next hourly fetch.
27
+ //
28
+ // Read only when the file has changed since it was last looked at. The roster
29
+ // is polled every fifteen seconds, the file changes at most hourly, and
30
+ // .claude.json can be large; one stat per poll is the whole steady-state cost.
31
+ import { mkdir, readFile, stat } from "node:fs/promises";
32
+ import { homedir } from "node:os";
33
+ import { posix, win32 } from "node:path";
34
+ import { claudeConfigDir } from "./claude-dir.mjs";
35
+ import { deckDataDir } from "./deck-home.mjs";
36
+ import { writeFileAtomic } from "./atomic-write.mjs";
37
+ import { radarVariables } from "./traffic-radar-config.mjs";
38
+
39
+ /** The flags that put content, not only counts, into the export. */
40
+ export const CONTENT_KEYS = Object.freeze([
41
+ "OTEL_LOG_USER_PROMPTS", "OTEL_LOG_ASSISTANT_RESPONSES", "OTEL_LOG_TOOL_DETAILS",
42
+ "OTEL_LOG_TOOL_CONTENT", "OTEL_LOG_RAW_API_BODIES",
43
+ ]);
44
+
45
+ const RECORDS_FILE = "org-telemetry.json";
46
+
47
+ /**
48
+ * What one settings file turns on, from Radar's own reading of it (so endpoint
49
+ * paths and credentials are already stripped): `level` is "content" when a
50
+ * content flag is on beside telemetry, "metrics" for telemetry alone, and "off"
51
+ * when the file does not enable it. Content flags without telemetry send
52
+ * nothing, so they do not count.
53
+ */
54
+ export function telemetrySummary(variables) {
55
+ const on = key => variables.some(v => v.key === key && String(v.value).startsWith("Enabled"));
56
+ const telemetry = on("CLAUDE_CODE_ENABLE_TELEMETRY");
57
+ const content = telemetry ? CONTENT_KEYS.filter(on) : [];
58
+ const destinations = [...new Set(variables
59
+ .filter(v => v.key.endsWith("ENDPOINT") && /^https?:\/\//.test(v.value))
60
+ .map(v => v.value))];
61
+ return { level: !telemetry ? "off" : content.length ? "content" : "metrics", content, destinations };
62
+ }
63
+
64
+ /** Claude Code's own state file: beside the home directory, or inside a
65
+ * moved config directory. */
66
+ function claudeStatePath(env, home, platform) {
67
+ const { join } = platform === "win32" ? win32 : posix;
68
+ return env.CLAUDE_CONFIG_DIR?.trim() ? join(claudeConfigDir(env, home, platform), ".claude.json") : join(home, ".claude.json");
69
+ }
70
+
71
+ async function readJson(path, read) {
72
+ try { return JSON.parse(String(await read(path, "utf8")).replace(/^/, "")); } catch { return null; }
73
+ }
74
+
75
+ let _records = null;
76
+ let _examined = null; // mtime of the remote-settings.json last looked at
77
+
78
+ /** The content flags by the words a person would use for them. The page has
79
+ * its own copy in org-telemetry.ts, for the row. */
80
+ export const CONTENT_WORDS = Object.freeze({
81
+ OTEL_LOG_USER_PROMPTS: "prompts",
82
+ OTEL_LOG_ASSISTANT_RESPONSES: "responses",
83
+ OTEL_LOG_TOOL_DETAILS: "tool details",
84
+ OTEL_LOG_TOOL_CONTENT: "tool output",
85
+ OTEL_LOG_RAW_API_BODIES: "full API bodies",
86
+ });
87
+
88
+ /** "a, b and c". */
89
+ function listed(words) {
90
+ return words.length <= 1 ? words.join("") : `${words.slice(0, -1).join(", ")} and ${words[words.length - 1]}`;
91
+ }
92
+
93
+ /**
94
+ * "Acme's telemetry is on for new sessions: it sends your prompts and tool
95
+ * details to 192.0.2.16:4317" — what a switch to an account of this
96
+ * organization turns on, for the swap notice to carry. Null when nothing is
97
+ * known to be on: settings seen off, or never seen.
98
+ */
99
+ export function telemetryClause(record) {
100
+ if (!record || (record.level !== "metrics" && record.level !== "content")) return null;
101
+ const words = record.level === "content" ? (record.content ?? []).map(k => CONTENT_WORDS[k] ?? k) : [];
102
+ const what = words.length ? `your ${listed(words)}` : "usage data";
103
+ const hosts = (record.destinations ?? []).map(d => String(d).replace(/^https?:\/\//, ""));
104
+ const who = record.orgName ? `${record.orgName}'s telemetry` : "Its organization's telemetry";
105
+ return `${who} is on for new sessions: it sends ${what}${hosts.length ? ` to ${listed(hosts)}` : ""}`;
106
+ }
107
+
108
+ /** Every organization's record as last saved, without looking at the settings
109
+ * file: for a reader that has no store to pin a new reading on. */
110
+ export async function knownOrgTelemetry({
111
+ env = process.env, home = homedir(), platform = process.platform,
112
+ dataDir = deckDataDir(platform, env, home), read = readFile,
113
+ } = {}) {
114
+ if (_records == null) {
115
+ const { join } = platform === "win32" ? win32 : posix;
116
+ const saved = await readJson(join(dataDir, RECORDS_FILE), read);
117
+ _records = saved && typeof saved === "object" && !Array.isArray(saved) ? saved : {};
118
+ }
119
+ return _records;
120
+ }
121
+
122
+ /** Forget everything held in memory. For the suite. */
123
+ export function resetOrgTelemetry() { _records = null; _examined = null; }
124
+
125
+ /**
126
+ * Look at remote-settings.json if it changed, record it under its organization
127
+ * when it can be attributed (see the header), and answer every organization's
128
+ * record: uuid → `{ orgName, level, content, destinations, seenAt, writtenAt }`.
129
+ *
130
+ * `seq` is claude-swap's sequence.json as the roster already read it. Never
131
+ * throws: a record that could not be made is a record not made.
132
+ */
133
+ export async function observeOrgTelemetry({
134
+ seq,
135
+ env = process.env, home = homedir(), platform = process.platform, now = Date.now(),
136
+ dataDir = deckDataDir(platform, env, home),
137
+ statFile = stat, read = readFile, write = writeFileAtomic, makeDir = mkdir,
138
+ } = {}) {
139
+ const { join } = platform === "win32" ? win32 : posix;
140
+ const recordsPath = join(dataDir, RECORDS_FILE);
141
+ if (_records == null) {
142
+ const saved = await readJson(recordsPath, read);
143
+ _records = saved && typeof saved === "object" && !Array.isArray(saved) ? saved : {};
144
+ }
145
+ try {
146
+ const remote = join(claudeConfigDir(env, home, platform), "remote-settings.json");
147
+ const mtime = (await statFile(remote)).mtimeMs;
148
+ if (mtime === _examined) return _records;
149
+ _examined = mtime;
150
+
151
+ const active = seq?.accounts?.[String(seq?.activeAccountNumber)];
152
+ const switchedAt = Date.parse(seq?.lastUpdated ?? "");
153
+ if (!active?.organizationUuid || !Number.isFinite(switchedAt) || mtime <= switchedAt) return _records;
154
+ const live = (await readJson(claudeStatePath(env, home, platform), read))?.oauthAccount;
155
+ if (!live || live.organizationUuid !== active.organizationUuid) return _records;
156
+
157
+ const settings = await readJson(remote, read);
158
+ if (!settings) return _records;
159
+ _records = {
160
+ ..._records,
161
+ [active.organizationUuid]: {
162
+ orgName: live.organizationName ?? active.organizationName ?? null,
163
+ ...telemetrySummary(radarVariables(settings, "Cached organization settings")),
164
+ seenAt: now,
165
+ writtenAt: Math.round(mtime),
166
+ },
167
+ };
168
+ await makeDir(dataDir, { recursive: true })
169
+ .then(() => write(recordsPath, `${JSON.stringify(_records, null, 2)}\n`))
170
+ .catch(() => {});
171
+ } catch { /* no file, or one that could not be read: nothing new to say */ }
172
+ return _records;
173
+ }
@@ -83,6 +83,9 @@ const PINNED_MODULES = [
83
83
  // import() from the report's route.
84
84
  "account-projects.mjs",
85
85
  "swap-log.mjs",
86
+ // account-watch.mjs reads what each organization's telemetry was last seen
87
+ // to turn on, for the swap notice, only once a tick has switched.
88
+ "org-telemetry.mjs",
86
89
  "lan-sync.mjs",
87
90
  // The providers' status pages (#1311), reached only from their route.
88
91
  "provider-status.mjs",
@@ -98,6 +101,11 @@ const PINNED_MODULES = [
98
101
  // (cswap-auto-choice.mjs). Already in memory through prefs-routes.mjs; named
99
102
  // here because the tick reaches it through import().
100
103
  "lan-deck.mjs",
104
+ // The accounts Auto-switch's own moves never land on (slotsHeldBack), which
105
+ // the move after removing an expired active login skips too. Already in
106
+ // memory through cswap-auto.mjs; named here because account-routes.mjs
107
+ // reaches it through import().
108
+ "cswap-auto-choice.mjs",
101
109
  ];
102
110
 
103
111
  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
@@ -470,8 +477,27 @@ export const GUARDED_READS = new Set([
470
477
  "/api/fm-station",
471
478
  "/api/radio-stations",
472
479
  "/api/radio-click",
480
+ // The trackers this deck is connected to (#2178): the list of connections
481
+ // with their accounts, and below it every read a provider adds.
482
+ "/api/integrations",
473
483
  ]);
474
484
 
485
+ /** Whole namespaces of guarded reads, for routes whose path carries an id —
486
+ * /api/integrations/<kind>/<connId>/… (#2178). A connected tracker's boards,
487
+ * issues, comments and the images the asset route signs for are the user's
488
+ * work, read with the user's own token; every one of them is fetched by the
489
+ * deck's own page and nothing else. A prefix rather than a list because a
490
+ * provider adds its routes without touching this file, and a read it adds
491
+ * must be covered from the moment it exists. */
492
+ export const GUARDED_READ_PREFIXES = Object.freeze(["/api/integrations/"]);
493
+
494
+ /** Whether a GET to `pathname` must come from the deck's page or carry the
495
+ * token: listed in GUARDED_READS, or under one of GUARDED_READ_PREFIXES. */
496
+ export function isGuardedRead(pathname) {
497
+ const path = String(pathname ?? "");
498
+ return GUARDED_READS.has(path) || GUARDED_READ_PREFIXES.some(prefix => path.startsWith(prefix));
499
+ }
500
+
475
501
  export function isAuthorizedMutation(req) {
476
502
  const headers = req?.headers ?? {};
477
503
  if (presentsDeckToken(headers)) return true;
@@ -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
+ }