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