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.
- package/README.md +79 -18
- package/THIRD_PARTY_NOTICES.md +70 -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-Cbzf3TWa.js +1 -0
- package/dist/web/assets/IntegrationModal-BnnBr0a3.js +5 -0
- package/dist/web/assets/RadioPanel-BIqrMLQN.js +1 -0
- package/dist/web/assets/TrafficRadar-D1gZCIwX.js +8 -0
- package/dist/web/assets/UsageHistoryModal-DAhplVju.js +1 -0
- package/dist/web/assets/app-BW3NYUYB.js +318 -0
- package/dist/web/assets/app-C5RY_wZ4.css +1 -0
- package/dist/web/assets/{index-Ch4ks76J.js → index-r8Ymr47Q.js} +1 -1
- package/dist/web/index.html +3 -3
- package/package.json +2 -1
- package/release-notes.json +52 -0
- package/src/server/account-notify.mjs +268 -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 +107 -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 +127 -16
- 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 +26 -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/feature-use.mjs +11 -1
- package/src/server/forge-http.mjs +514 -0
- package/src/server/forge-public-fetch.mjs +228 -0
- package/src/server/forge-routes.mjs +154 -0
- package/src/server/github-api.mjs +1369 -0
- package/src/server/gitlab-api.mjs +1271 -0
- package/src/server/index.mjs +23 -3
- package/src/server/installer.mjs +149 -8
- package/src/server/integrations/cache.mjs +42 -0
- package/src/server/integrations/errors.mjs +167 -0
- package/src/server/integrations/forge.mjs +76 -0
- package/src/server/integrations/jira.mjs +1026 -0
- package/src/server/integrations/providers.mjs +11 -0
- package/src/server/integrations/proxy.mjs +314 -0
- package/src/server/integrations/registry.mjs +142 -0
- package/src/server/integrations/routes.mjs +498 -0
- package/src/server/integrations/store.mjs +226 -0
- package/src/server/lan-deck.mjs +7 -0
- package/src/server/lan-elsewhere.d.mts +30 -0
- package/src/server/lan-elsewhere.mjs +137 -0
- 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/org-telemetry.mjs +173 -0
- package/src/server/pinned-build.mjs +8 -0
- package/src/server/prefs-routes.mjs +4 -0
- package/src/server/request-gates.mjs +26 -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 +3 -1
- package/dist/web/assets/BrowserWatchModal-nOxhGOO4.js +0 -1
- package/dist/web/assets/RadioPanel-BLfpQ1Iw.js +0 -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,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.
|
|
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
|
+
}
|