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