agent-dag 3.19.0 → 3.21.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.
@@ -10,14 +10,28 @@ import { extname, join, resolve, sep, dirname as pdirname } from "node:path";
10
10
  import { homedir, networkInterfaces } from "node:os";
11
11
  import { fileURLToPath, pathToFileURL } from "node:url";
12
12
  import { dirname } from "node:path";
13
- import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
13
+ import { randomBytes, timingSafeEqual } from "node:crypto";
14
+ // Moved out to a leaf so that a one-shot `ccdeck --stop`, the boot-path module
15
+ // that finds a running deck, and the tests that pin the handshake against
16
+ // hook/hook.js can all ask these questions without importing this file and
17
+ // arming everything in it. Re-exported below under the names they have always
18
+ // had — see src/server/deck-probe.mjs.
19
+ import { challengeDeck, challengeProof, isProcessAlive } from "./deck-probe.mjs";
20
+ // The three this file's own callers and tests already name. `sameProof` and the
21
+ // challenge deadline stay private to the leaf: they were private here too, and
22
+ // re-exporting them would be inventing API on the way out of a move.
23
+ export { challengeDeck, challengeProof, isProcessAlive };
14
24
  import { promisify } from "node:util";
15
25
  import { claudeConfigDir } from "./claude-dir.mjs";
16
26
  import { CODEX_HOME, CODEX_SESSIONS_DIR, STOP, walkRolloutDays } from "./codex-dir.mjs";
17
27
  import { PRODUCT } from "./brand.mjs";
18
28
  import { createBlockNotifier } from "./block-notify.mjs";
19
- import { DEFAULTS as PREF_DEFAULTS, notificationsOn, notificationsVetoed, publicPrefs, readPrefs, writePrefs } from "./deck-prefs.mjs";
29
+ import { createActivity } from "./activity.mjs";
30
+ import { AWAY_BOOT_GRACE_MS, AWAY_RECHECK_MS, AWAY_TICK_MS, awayGate, awayUpdateStep } from "./auto-update.mjs";
31
+ import { createPresence } from "./presence.mjs";
32
+ import { DEFAULTS as PREF_DEFAULTS, cleanAlias, isAliasKey, notificationsOn, notificationsVetoed, publicPrefs, readPrefs, writePrefs } from "./deck-prefs.mjs";
20
33
  import { createEngine, defaultName } from "./lan-engine.mjs";
34
+ import { aboutThisDeck } from "./lan-about.mjs";
21
35
  import { PROBE_PS, localAliases, reachability, readProbe } from "./lan-reach.mjs";
22
36
  import { run } from "./exec.mjs";
23
37
  import { notify as osNotify } from "./browser-react.mjs";
@@ -38,6 +52,13 @@ const RUNNING_VERSION = (() => {
38
52
  catch { return null; }
39
53
  })();
40
54
 
55
+ // The two facts the away-update waits on (awayUpdateTick): whether a turn is
56
+ // running, fed from pushEvent, and whether a tab is being looked at, fed from
57
+ // POST /api/presence. Up here rather than beside the tick because pushEvent
58
+ // can run before the module below it has finished evaluating.
59
+ const activity = createActivity();
60
+ const presence = createPresence();
61
+
41
62
  const MIME = {
42
63
  ".html": "text/html; charset=utf-8",
43
64
  ".js": "application/javascript; charset=utf-8",
@@ -2036,10 +2057,6 @@ let codexWorkspace = "";
2036
2057
  // believing a stale "yes" is at most a few unwritten lines, against a directory
2037
2058
  // listing plus an HTTP round trip on every tick of every deck forever.
2038
2059
  const DECK_PROOF_TTL_MS = 5000;
2039
- // The same deadline hook.js gives a challenge, and for the same reason: a
2040
- // bodyless GET to a loopback port is sub-millisecond when a deck is there and an
2041
- // instant ECONNREFUSED when nothing is.
2042
- const DECK_CHALLENGE_TIMEOUT_MS = 400;
2043
2060
  // `${pid}:${port}:${token}` -> { at, ok }. Keyed on the record's own identity so
2044
2061
  // a rewritten record — a deck that restarted onto the same port with a fresh
2045
2062
  // token — is a new question rather than an inherited answer.
@@ -2105,57 +2122,6 @@ async function provesDeck(d) {
2105
2122
  return ok;
2106
2123
  }
2107
2124
 
2108
- /**
2109
- * One challenge round trip, resolving true only on a correct proof.
2110
- *
2111
- * The compare is constant-time for the reason hook.js's sameProof is: whatever
2112
- * is on that port may not be a deck, and it must not be able to walk the
2113
- * expected proof out of us one byte at a time by timing how long we take to hang
2114
- * up. The nonce is fresh per call, so an answer overheard earlier is worth
2115
- * nothing, and the token itself never leaves this process.
2116
- */
2117
- function challengeDeck(port, token) {
2118
- return new Promise(resolve => {
2119
- let settled = false;
2120
- const finish = ok => { if (settled) return; settled = true; resolve(ok); };
2121
- const nonce = randomBytes(16).toString("hex");
2122
- const want = challengeProof(token, nonce);
2123
- const req = httpRequest({
2124
- hostname: "127.0.0.1",
2125
- port,
2126
- path: `/api/hook-challenge?nonce=${nonce}`,
2127
- method: "GET",
2128
- timeout: DECK_CHALLENGE_TIMEOUT_MS,
2129
- }, res => {
2130
- if (res.statusCode !== 200) { res.resume(); return res.on("end", () => finish(false)); }
2131
- let answer = "";
2132
- res.setEncoding("utf8");
2133
- res.on("data", c => {
2134
- answer += c;
2135
- // A deck answers in ~100 bytes. Anything pouring data at us is not one,
2136
- // and must not be allowed to grow this buffer without bound.
2137
- if (answer.length > 4096) { req.destroy(); finish(false); }
2138
- });
2139
- res.on("end", () => {
2140
- if (settled) return;
2141
- let proof;
2142
- try { proof = JSON.parse(answer).proof; } catch { return finish(false); }
2143
- finish(sameProof(proof, want));
2144
- });
2145
- });
2146
- req.on("error", () => finish(false));
2147
- req.on("timeout", () => req.destroy());
2148
- req.end();
2149
- });
2150
- }
2151
-
2152
- /** hook.js's sameProof, for the same reason it is constant-time there. */
2153
- function sameProof(got, want) {
2154
- if (typeof got !== "string") return false;
2155
- const a = Buffer.from(got, "utf8");
2156
- const b = Buffer.from(want, "utf8");
2157
- return a.length === b.length && timingSafeEqual(a, b);
2158
- }
2159
2125
 
2160
2126
  /**
2161
2127
  * Every deck registered right now, as its own discovery record spells it — and
@@ -3231,12 +3197,87 @@ async function handlePrefsWrite(req, res) {
3231
3197
  return send(res, 200, prefsPayload());
3232
3198
  }
3233
3199
 
3200
+ // ── updating while nobody is looking ────────────────────────────────────────
3201
+ //
3202
+ // auto-update.mjs has the why and the rules; this is the timer and the three
3203
+ // effects — install, restart, npx relaunch — each through the code a press
3204
+ // uses.
3205
+ let _awayTry = null;
3206
+ let _awayTimer = null;
3207
+ let _bootedAt = Date.now();
3208
+ // Until when a report that found nothing newer stands — see AWAY_RECHECK_MS.
3209
+ let _awayNothingUntil = 0;
3210
+
3211
+ /**
3212
+ * One tick of the away-update. Returns what it did, or null.
3213
+ *
3214
+ * Exported so a test can drive it without waiting out the boot grace — the one
3215
+ * input that is a policy rather than a fact, and so the only one it may pass.
3216
+ * Every clock is the real one: presence and activity are stamped with it.
3217
+ */
3218
+ export async function awayUpdateTick({ graceMs = AWAY_BOOT_GRACE_MS } = {}) {
3219
+ const now = Date.now();
3220
+ if (!awayGate({
3221
+ enabled: _prefs?.autoUpdate !== false,
3222
+ supervised: _onRestart != null && _canRestart,
3223
+ restarting: _restarting,
3224
+ sinceBootMs: now - _bootedAt,
3225
+ looking: presence.looking(now),
3226
+ busy: activity.busy(now),
3227
+ quietMs: activity.quietMs(now),
3228
+ graceMs,
3229
+ })) return null;
3230
+ // A standing "nothing newer", unless the clock has moved back past it.
3231
+ if (now < _awayNothingUntil && _awayNothingUntil - now <= AWAY_RECHECK_MS) return null;
3232
+ const su = await import(pathToFileURL(join(PKG_ROOT, "src/server/self-update.mjs")).href);
3233
+ const report = await su.versionReport({ running: RUNNING_VERSION, pkgRoot: PKG_ROOT });
3234
+ if (!report.notice) {
3235
+ _awayNothingUntil = now + AWAY_RECHECK_MS;
3236
+ return null;
3237
+ }
3238
+ const step = awayUpdateStep({
3239
+ notice: report.notice, mode: report.upgradeMode,
3240
+ installing: report.upgrade?.state === "running", lastTry: _awayTry, now,
3241
+ });
3242
+ if (!step.act) return null;
3243
+ // Asked again, because the lookup above can take seconds on a slow line and
3244
+ // somebody may have sat down, or started a turn, in them.
3245
+ const again = Date.now();
3246
+ if (presence.looking(again) || activity.busy(again) || _restarting) return null;
3247
+ _awayTry = { target: step.target, at: again };
3248
+ if (step.act === "install") {
3249
+ su.startUpgrade({ pkgRoot: PKG_ROOT });
3250
+ return step.act;
3251
+ }
3252
+ // handleRestart's own check for the npx path, for the same reason: the mode
3253
+ // comes from the install on disk, and a spec it cannot name is no relaunch.
3254
+ if (step.act === "npx" && !su.npxRestartSpec(PKG_ROOT)) return null;
3255
+ _restarting = true;
3256
+ handOffRestart(step.act === "npx" ? "npx" : null);
3257
+ return step.act;
3258
+ }
3259
+
3260
+ /** POST {tab, looking} — a tab saying whether it is being looked at. See
3261
+ * presence.mjs, and src/web/presence.ts for the sender. */
3262
+ async function handlePresence(req, res) {
3263
+ const raw = await readBody(req).catch(() => null);
3264
+ let body = null;
3265
+ try { body = JSON.parse(raw ?? ""); } catch { /* handled below */ }
3266
+ if (!body || typeof body !== "object") return send(res, 400, { ok: false, reason: "bad_request" });
3267
+ const ok = presence.report(body.tab, body.looking === true, Date.now());
3268
+ return send(res, ok ? 200 : 400, { ok });
3269
+ }
3270
+
3234
3271
  // ── LAN sync ────────────────────────────────────────────────────────────────
3235
3272
  //
3236
3273
  // The engine is built once and told the settings; it opens and closes its own
3237
3274
  // sockets as those change. Nothing here touches a credential — see
3238
3275
  // lan-engine.mjs, which passes an opaque blob between two claude-swap commands.
3239
3276
  const lanEngine = createEngine({
3277
+ // This deck's version and machine, for the decks it is paired with and
3278
+ // nobody else. RUNNING_VERSION rather than a fresh read, for the reason it
3279
+ // is read at import: it is what this process actually runs.
3280
+ about: aboutThisDeck({ version: RUNNING_VERSION }),
3240
3281
  readAccounts: async () => {
3241
3282
  const { fetchClaudeAccounts } = await import("./claude-accounts.mjs");
3242
3283
  return fetchClaudeAccounts();
@@ -3246,15 +3287,67 @@ const lanEngine = createEngine({
3246
3287
  const out = await shareAccounts([String(num)]);
3247
3288
  return out?.ok ? out.blob : null;
3248
3289
  },
3249
- importAccount: async blob => {
3250
- const { importAccount } = await import("./cswap-admin.mjs");
3290
+ importAccount: async (blob, step) => {
3291
+ const { importAccount, landed } = await import("./cswap-admin.mjs");
3251
3292
  // NO `force`, ever, and this is the line where that promise is kept. A
3252
3293
  // plain import adds an account that is missing and replaces exactly one
3253
3294
  // claude-swap has quarantined; it SKIPS one that is present and healthy.
3254
3295
  // So a peer cannot overwrite a working credential of this deck's even by
3255
3296
  // lying about its own, because the flag that would allow it is not passed.
3256
3297
  const out = await importAccount(blob);
3257
- return !!out?.ok;
3298
+ if (!out?.ok) return { ok: false, why: out?.reason ?? "import refused" };
3299
+ // A SKIP IS NOT A HEAL, and reading `ok` alone said it was. `cswap import`
3300
+ // exits ZERO when it declines an account it already holds — importAccount's
3301
+ // own comment says so and reports it as `added: false` — so a round that
3302
+ // changed nothing was counted as a successful repair and the panel said the
3303
+ // account had been fixed. Whoever read that then waited for numbers that
3304
+ // were never going to move.
3305
+ //
3306
+ // The decline is narrow and documented on claude-swap's side: a plain
3307
+ // import replaces a slot only when its usage row is quarantined as
3308
+ // refresh-token-dead, and is "never triggered by the live store's
3309
+ // `no credentials` state". So an account whose login expired heals over the
3310
+ // network, and one that has NO stored login does not — which is a true
3311
+ // sentence the panel can now print instead of a false one.
3312
+ if (out.added === true) return { ok: true };
3313
+
3314
+ // THE DECLINE, AND WHY IT MATTERS WHICH ONE IT IS.
3315
+ //
3316
+ // `cswap import` without --force declines for two opposite reasons and says
3317
+ // the same thing about both: the slot is present and HEALTHY, which is the
3318
+ // promise above working exactly as intended — or the slot is EMPTY, which
3319
+ // is the one case pairing exists for and the one a plain import will never
3320
+ // touch. claude-swap is explicit about the second: a plain import replaces
3321
+ // a slot "iff its usage row is quarantined as refresh-token-dead", and is
3322
+ // "never triggered by the live store's `no credentials` state".
3323
+ //
3324
+ // So `login expired` healed over the network and `no stored login` did not,
3325
+ // and the account somebody most needed repaired was the one the feature
3326
+ // could not repair.
3327
+ //
3328
+ // ASKED NOW, NOT READ FROM THE CACHE. The cached verdicts are up to ten
3329
+ // minutes old, which is right for a label and wrong for a decision that
3330
+ // writes a credential: somebody who signed in two minutes ago still reads
3331
+ // as `no_credentials` there, and acting on that would replace the login
3332
+ // they had just created.
3333
+ const { verdictNow } = await import("./claude-accounts.mjs");
3334
+ const [email, org] = String(step?.key ?? "").split("@@");
3335
+ const now = await verdictNow(email, org ?? "");
3336
+ if (now !== "no_credentials") return { ok: false, why: "claude-swap kept the slot it already has" };
3337
+
3338
+ // FILLING AN EMPTY SLOT, WHICH IS NOT OVERWRITING A WORKING ONE — and the
3339
+ // promise above survives word for word. A peer cannot reach this: the
3340
+ // verdict comes from THIS machine's claude-swap, about THIS machine's
3341
+ // store, and nothing a peer sends can make a slot report that it holds
3342
+ // nothing. Narrowed to the one account with `only`, so a bundle carrying
3343
+ // several cannot ride in behind it.
3344
+ const forced = await importAccount(blob, { force: true, only: { email, org: org ?? "" } });
3345
+ if (!forced?.ok) return { ok: false, why: forced?.reason ?? "import refused" };
3346
+ // `added` counts `imported` alone, and a forced replace is `healed` — see
3347
+ // landed, which is the difference between a repair and a repair reported as
3348
+ // a failure.
3349
+ if (!landed(forced.results)) return { ok: false, why: "claude-swap kept the slot it already has" };
3350
+ return { ok: true, filled: true };
3258
3351
  },
3259
3352
  // The deck's own long-term key, kept so a restart is the same deck rather
3260
3353
  // than a stranger to everybody who has paired with it. Written once, on the
@@ -3315,6 +3408,11 @@ async function applyLanPrefs() {
3315
3408
  shared: Array.isArray(lan.shared) ? lan.shared : [],
3316
3409
  trusted: Array.isArray(lan.trusted) ? lan.trusted : [],
3317
3410
  port: lan.port || 0,
3411
+ autoAsk: lan.autoAsk !== false,
3412
+ autoAccept: lan.autoAccept !== false,
3413
+ // Names somebody here gave other decks. The engine only hands them to
3414
+ // the page, so a change never restarts anything.
3415
+ aliases: lan.aliases && typeof lan.aliases === "object" ? lan.aliases : {},
3318
3416
  });
3319
3417
  // Wholesale, so removing an address in the panel really stops it being
3320
3418
  // dialled rather than only taking the row away.
@@ -3452,6 +3550,21 @@ async function handleLanPeer(req, res) {
3452
3550
  return send(res, 200, { ok: lanEngine.allow(fp), ...lanEngine.status() });
3453
3551
  case "unpair":
3454
3552
  return send(res, 200, { ok: lanEngine.unpair(fp), ...lanEngine.status() });
3553
+ // WHAT THIS DECK CALLS THAT ONE, and nobody else sees it. Keyed by the
3554
+ // fingerprint, so it follows the machine across a new address or a new
3555
+ // name of its own choosing. An empty name takes the alias away.
3556
+ //
3557
+ // The whole map is rebuilt from the one on disk rather than sent by the
3558
+ // page, so two tabs renaming two decks cannot undo each other.
3559
+ case "alias": {
3560
+ if (!isAliasKey(fp)) return send(res, 400, { ok: false, reason: "bad_request" });
3561
+ const name = cleanAlias(body.name);
3562
+ const next = { ...(_prefs?.lan?.aliases ?? {}) };
3563
+ if (name) next[fp] = name; else delete next[fp];
3564
+ _prefs = await writePrefs({ lan: { aliases: next } });
3565
+ await lanEngine.apply({ aliases: _prefs.lan.aliases });
3566
+ return send(res, 200, { ok: true, ...lanEngine.status() });
3567
+ }
3455
3568
  default:
3456
3569
  return send(res, 400, { ok: false, reason: "unknown_action" });
3457
3570
  }
@@ -3459,8 +3572,21 @@ async function handleLanPeer(req, res) {
3459
3572
 
3460
3573
  /** Ask every peer now rather than at the next tick — the button beside the
3461
3574
  * list, for somebody who has just fixed a login on the other machine and does
3462
- * not want to wait a minute to see it arrive. */
3575
+ * not want to wait a minute to see it arrive.
3576
+ *
3577
+ * Or ONE peer, when the body names it: the `check now` in a deck's own
3578
+ * dialog. A deck that only calls in has no address here, and says so rather
3579
+ * than reporting a round that asked nobody. */
3463
3580
  async function handleLanSync(req, res) {
3581
+ const raw = await readBody(req).catch(() => null);
3582
+ let body = null;
3583
+ try { body = JSON.parse(raw ?? ""); } catch { /* the whole-list press sends nothing to read */ }
3584
+ const fp = body && typeof body.fp === "string" ? body.fp : null;
3585
+ if (fp) {
3586
+ const done = await lanEngine.roundOne(fp);
3587
+ if (done == null) return send(res, 409, { ok: false, reason: "no_address" });
3588
+ return send(res, 200, { ok: true, done, ...lanEngine.status() });
3589
+ }
3464
3590
  const done = await lanEngine.round();
3465
3591
  return send(res, 200, { ok: true, done, ...lanEngine.status() });
3466
3592
  }
@@ -3615,6 +3741,10 @@ function pushEvent(raw, source, opts = {}) {
3615
3741
  // know the other exists. block-notify.mjs holds the gates and the cooldown.
3616
3742
  blockNotifier.consider(raw, { clients: sseClients.size, replay: !!opts.replay });
3617
3743
 
3744
+ // Whether a turn is running, for the away-update. Not from a replay: the log
3745
+ // is history, and a turn it shows open is one that ended in another process.
3746
+ if (!opts.replay) activity.note(raw, evt.receivedAt);
3747
+
3618
3748
  if (persisting) {
3619
3749
  // Fire-and-forget append. JSONL = newline-delimited JSON, so the whole line
3620
3750
  // has to reach the file as one write — this used to be `appendFile`, which
@@ -4386,6 +4516,16 @@ async function handleRestart(req, res) {
4386
4516
  if (_deckReady) send(res, 200, { ok: true, mode });
4387
4517
  else send(res, 202, { ok: true, mode, booting: true, detail: "the deck is still starting up; the restart runs as soon as it has finished booting" });
4388
4518
  // Let the response flush before the listener goes away.
4519
+ handOffRestart(mode);
4520
+ }
4521
+
4522
+ /**
4523
+ * Hand a restart the latch has already admitted to the launcher — from a press
4524
+ * (handleRestart, once its answer is on its way) or from the away-update
4525
+ * (awayUpdateTick). The delay is the press's: its response has to leave before
4526
+ * the listener does.
4527
+ */
4528
+ function handOffRestart(mode) {
4389
4529
  setTimeout(() => {
4390
4530
  try { _onRestart(mode); }
4391
4531
  catch (err) {
@@ -4429,6 +4569,50 @@ export function markDeckReady() {
4429
4569
  _deckReady = true;
4430
4570
  }
4431
4571
 
4572
+ /**
4573
+ * POST /api/shutdown — end this deck, from `ccdeck --stop`.
4574
+ *
4575
+ * THE TOKEN AND NOTHING ELSE. Every other mutating route accepts either the
4576
+ * token or the deck's own page (isAuthorizedMutation), and this one must not:
4577
+ * there is no button for it, so a page asking to end the deck is a page doing
4578
+ * something no part of this product asks it to do. Refusing the browser half
4579
+ * costs nothing and removes the whole class.
4580
+ *
4581
+ * WHY A ROUTE AND NOT A SIGNAL. Windows has none. `process.kill(pid, "SIGTERM")`
4582
+ * there is TerminateProcess: the deck stops mid-instruction, its discovery file
4583
+ * is left for the next boot to sweep, and its LAN beacon never says goodbye — so
4584
+ * every paired colleague watches it time out instead of seeing it leave. One
4585
+ * loopback POST behaves identically on all three platforms and ends in the
4586
+ * deck's own shutdown(), which closes the listener, unlinks the registration and
4587
+ * stops the beacon. The pid ladder still exists in `--stop`, as the fallback for
4588
+ * a deck too wedged to answer this.
4589
+ *
4590
+ * ANSWERED BEFORE ANYTHING IS TORN DOWN. shutdown() calls
4591
+ * server.closeAllConnections(), which would cut this very socket — so the
4592
+ * teardown is hung off the response having left rather than run beside it, and
4593
+ * the caller gets a 200 instead of a dropped connection it has to interpret.
4594
+ */
4595
+ async function handleStop(req, res) {
4596
+ if (!presentsDeckToken(req?.headers ?? {})) return send(res, 401, { ok: false, reason: "unauthenticated" });
4597
+ if (!_onStop) return send(res, 501, { ok: false, reason: "no_launcher" });
4598
+ if (_stopping) return send(res, 200, { ok: true, pid: process.pid, already: true });
4599
+ _stopping = true;
4600
+ let left = false;
4601
+ const leave = () => {
4602
+ if (left) return;
4603
+ left = true;
4604
+ // A throw here must still end the process: the caller has already been told
4605
+ // this deck is going, and a deck that answered "ok" and stayed up is worse
4606
+ // than one that never answered.
4607
+ try { _onStop(); } catch { process.exit(0); }
4608
+ };
4609
+ // 'finish' is the response handed to the OS; 'close' covers the caller that
4610
+ // hung up before it got there. Either one means nothing is left to flush.
4611
+ res.once("finish", leave);
4612
+ res.once("close", leave);
4613
+ send(res, 200, { ok: true, pid: process.pid });
4614
+ }
4615
+
4432
4616
  async function handleQuota(req, res) {
4433
4617
  const { fetchClaudeQuota } = await import(
4434
4618
  pathToFileURL(join(PKG_ROOT, "src/server/quota.mjs")).href
@@ -5035,17 +5219,6 @@ const HOOK_TOKEN = randomBytes(32).toString("hex");
5035
5219
  /** The token this deck expects to be challenged on. Written by writeDiscovery. */
5036
5220
  export function hookToken() { return HOOK_TOKEN; }
5037
5221
 
5038
- /**
5039
- * The proof of knowing `token`, for a nonce the challenger chose.
5040
- *
5041
- * hook/hook.js spells this out a second time — it is installed outside the
5042
- * package and cannot import from here — and a test pins the two against each
5043
- * other. Changing one without the other silently blinds the deck.
5044
- */
5045
- export function challengeProof(token, nonce) {
5046
- return createHash("sha256").update(`${token}:${nonce}`).digest("hex");
5047
- }
5048
-
5049
5222
  // GET /api/hook-challenge?nonce=… — answer a hook's challenge.
5050
5223
  //
5051
5224
  // The nonce is the caller's, so the answer proves knowledge of the token
@@ -5070,21 +5243,6 @@ function handleHookChallenge(_req, res, url) {
5070
5243
  send(res, 200, { proof: challengeProof(HOOK_TOKEN, nonce) });
5071
5244
  }
5072
5245
 
5073
- // Signal 0 delivers nothing; it asks whether the pid could be signalled.
5074
- //
5075
- // BOTH ERRNOS, and the second one is the Windows spelling. POSIX `kill(2)`
5076
- // answers EPERM for a process this account may not signal. On Windows
5077
- // `uv_kill` calls `OpenProcess`, a denial is ERROR_ACCESS_DENIED, and libuv
5078
- // maps that to EACCES — so a deck started from an elevated terminal, or under
5079
- // another account, read as DEAD to every probe in this repo. What followed was
5080
- // silent: the live deck's discovery file was unlinked on the next hook fire,
5081
- // rewritten five seconds later by keepDiscovery, and its banner went on
5082
- // claiming it was receiving events it had stopped receiving.
5083
- function isProcessAlive(pid) {
5084
- try { process.kill(pid, 0); return true; }
5085
- catch (e) { return !!e && (e.code === "EPERM" || e.code === "EACCES"); }
5086
- }
5087
-
5088
5246
  async function sweepStaleDiscovery() {
5089
5247
  // Same directory the installer writes and the hooks read — see claude-dir.mjs.
5090
5248
  const dir = join(claudeConfigDir(), "agent-dag");
@@ -5590,6 +5748,15 @@ export function listenFailure(err, { host = "", port = 0, platform = process.pla
5590
5748
  // launcher), /api/restart answers 501 and the UI hides the control rather than
5591
5749
  // offering a button that does nothing.
5592
5750
  let _onRestart = null;
5751
+ // How this process ends itself, handed down by bin/deck.js for the same reason
5752
+ // _onRestart is: the server does not own the process lifecycle and must be told
5753
+ // how to leave. Absent when nothing handed one down — running the module
5754
+ // directly, or an embedder — and /api/shutdown then answers 501 rather than
5755
+ // pretending.
5756
+ let _onStop = null;
5757
+ // A stop is in flight. Two sockets asking at once must not both start one, and
5758
+ // the response's 'finish' and 'close' can both fire for the same request.
5759
+ let _stopping = false;
5593
5760
  // A restart is in flight. Several browser tabs watching the same deck will each
5594
5761
  // ask; the second ask must not re-enter the shutdown.
5595
5762
  let _restarting = false;
@@ -5599,8 +5766,10 @@ let _restarting = false;
5599
5766
  // is reachable in and cannot answer for on its own.
5600
5767
  let _deckReady = false;
5601
5768
 
5602
- export async function startServer({ port = 4317, host = "127.0.0.1", persist = null, portRange = [4318, 4400], workspace = "", codex = true, claude = true, onRestart = null } = {}) {
5769
+ export async function startServer({ port = 4317, host = "127.0.0.1", persist = null, portRange = [4318, 4400], workspace = "", codex = true, claude = true, onRestart = null, onStop = null } = {}) {
5603
5770
  _onRestart = typeof onRestart === "function" ? onRestart : null;
5771
+ _onStop = typeof onStop === "function" ? onStop : null;
5772
+ _stopping = false;
5604
5773
  _canRestart = _onRestart != null && persist != null;
5605
5774
  // A new listener is a new boot, whatever a previous one had got as far as
5606
5775
  // reporting. Nothing but bin/deck.js ever sets this, and it does so once, at
@@ -5610,6 +5779,22 @@ export async function startServer({ port = 4317, host = "127.0.0.1", persist = n
5610
5779
  // `!== false` rather than a cast: a caller that omits the field means "yes",
5611
5780
  // which is how every embedder that predates this option keeps working.
5612
5781
  _providers = { claude: claude !== false, codex: codex !== false };
5782
+ // The away-update's clock. The boot grace counts from here, and the timer is
5783
+ // unref'd so it never keeps a process alive on its own. It does nothing until
5784
+ // the launcher has handed down a restart — see awayGate's `supervised`.
5785
+ _bootedAt = Date.now();
5786
+ clearInterval(_awayTimer);
5787
+ _awayTimer = setInterval(() => { awayUpdateTick().catch(() => {}); }, AWAY_TICK_MS);
5788
+ _awayTimer.unref?.();
5789
+ // The repair a paused Claude account used to wait on a `resume` press for:
5790
+ // handed to the roster read here, by the server that is actually running,
5791
+ // rather than wired at import — see repairStaleCopyWith.
5792
+ if (_providers.claude) {
5793
+ void Promise.all([
5794
+ import(pathToFileURL(join(PKG_ROOT, "src/server/claude-accounts.mjs")).href),
5795
+ cswapAdminModule(),
5796
+ ]).then(([accounts, admin]) => accounts.repairStaleCopyWith(admin.autoRecapture), () => {});
5797
+ }
5613
5798
  const removed = await sweepStaleDiscovery();
5614
5799
  if (removed > 0) console.log(` swept ${removed} stale discovery file(s)`);
5615
5800
  if (persist) {
@@ -5687,6 +5872,8 @@ export async function startServer({ port = 4317, host = "127.0.0.1", persist = n
5687
5872
  if (req.method === "GET" && url.pathname === "/api/version") return guard(handleVersion(req, res), res);
5688
5873
  if (req.method === "POST" && url.pathname === "/api/upgrade") return guard(handleUpgrade(req, res), res);
5689
5874
  if (req.method === "POST" && url.pathname === "/api/restart") return guard(handleRestart(req, res), res);
5875
+ if (req.method === "POST" && url.pathname === "/api/presence") return guard(handlePresence(req, res), res);
5876
+ if (req.method === "POST" && url.pathname === "/api/shutdown") return guard(handleStop(req, res), res);
5690
5877
  if (req.method === "GET" && url.pathname === "/api/quota") return guard(handleQuota(req, res), res);
5691
5878
  if (req.method === "GET" && url.pathname === "/api/codex-usage") return guard(handleCodexUsage(req, res), res);
5692
5879
  // Machine state, not session state: sampled on the server's own timer and
@@ -709,7 +709,7 @@ async function ensureDiscoveryDir() {
709
709
  * the target already had — a first registration, or one left by an earlier run
710
710
  * under a recycled pid, would otherwise keep whatever the umask handed it.
711
711
  */
712
- export async function writeDiscovery({ port, workspace, token, persist = null, codex = true }) {
712
+ export async function writeDiscovery({ port, workspace, token, persist = null, codex = true, claude = true, version = "", parent = null }) {
713
713
  await ensureDiscoveryDir();
714
714
  const file = discoveryPath();
715
715
  const data = {
@@ -744,6 +744,27 @@ export async function writeDiscovery({ port, workspace, token, persist = null, c
744
744
  // doing the work must be left out of the election rather than win it. An
745
745
  // older deck has no such field, so it is excluded by construction.
746
746
  watch: true,
747
+ // Is the Claude side of this deck switched on? Same shape as `codex` above
748
+ // and there for a second reader: a bare `ccdeck` that finds this deck
749
+ // running attaches to it instead of starting a rival, and it may only do
750
+ // that when the deck on the port is the deck it would itself have built. A
751
+ // `--no-claude` deck has no hooks, no accounts panel and no switcher, and
752
+ // until this field existed it was indistinguishable from one that has all
753
+ // three. An older deck has no such field, so it is excluded by
754
+ // construction — see runningDeck.
755
+ claude: claude !== false,
756
+ // What this deck IS, so a launcher that attaches can say whether the deck
757
+ // it found is the version the user just asked for. Never a decision: a
758
+ // rival deck on a random port is worse than an older deck said out loud.
759
+ version: typeof version === "string" ? version : "",
760
+ // The supervisor above this worker, or null when nothing is supervising.
761
+ //
762
+ // Only `ccdeck --stop` reads it, and only on the path where the polite
763
+ // request failed. A worker killed on its own leaves its supervisor alive,
764
+ // and a supervisor that puts crashed workers back would answer that kill by
765
+ // starting the deck again — so the ladder has to end the parent first and
766
+ // the child second, which it cannot do without being told who the parent is.
767
+ parent: Number.isInteger(parent) ? parent : null,
747
768
  startedAt: new Date().toISOString(),
748
769
  };
749
770
  await writeFileAtomic(file, JSON.stringify(data, null, 2) + "\n");
@@ -771,13 +792,14 @@ export function discoveryPath() {
771
792
  * is cheap (one small read), so the deck checks rather than assumes.
772
793
  *
773
794
  * A file this process wrote is left alone, mode included. Anything else — no
774
- * file, unreadable, another pid, a stale port, token, events log or Codex
775
- * setting — is replaced. Every field another deck decides by is compared, the
795
+ * file, unreadable, another pid, a stale port, token, events log, Codex or
796
+ * Claude setting, or a version left by the deck this process replaced — is
797
+ * replaced. Every field another deck decides by is compared, the
776
798
  * log path included: leave one out and a record missing it would pass as ours
777
799
  * forever, which for the log path means no deck can tell which of them share a
778
800
  * file and they all write their own copy of every event again.
779
801
  */
780
- export async function ensureDiscovery({ port, workspace, token, persist = null, codex = true }) {
802
+ export async function ensureDiscovery({ port, workspace, token, persist = null, codex = true, claude = true, version = "", parent = null }) {
781
803
  const file = discoveryPath();
782
804
  try {
783
805
  const d = JSON.parse(stripBom(await readFile(file, "utf8")));
@@ -787,11 +809,14 @@ export async function ensureDiscovery({ port, workspace, token, persist = null,
787
809
  && (d.workspace ?? "") === (workspace ?? "")
788
810
  && (d.token ?? "") === (token ?? "")
789
811
  && (d.persist ?? null) === persistField(persist)
790
- && d.codex === (codex !== false)) {
812
+ && d.codex === (codex !== false)
813
+ && d.claude === (claude !== false)
814
+ && (d.version ?? "") === (typeof version === "string" ? version : "")
815
+ && (d.parent ?? null) === (Number.isInteger(parent) ? parent : null)) {
791
816
  return { file, rewritten: false };
792
817
  }
793
818
  } catch { /* missing, unreadable or corrupt — rewritten below */ }
794
- await writeDiscovery({ port, workspace, token, persist, codex });
819
+ await writeDiscovery({ port, workspace, token, persist, codex, claude, version, parent });
795
820
  return { file, rewritten: true };
796
821
  }
797
822
 
@@ -822,7 +847,7 @@ export async function ensureDiscovery({ port, workspace, token, persist = null,
822
847
  * `run()` never rejects (every failure is a state), so awaiting this cannot
823
848
  * throw and cannot outlast one bounded check.
824
849
  */
825
- export function keepDiscovery({ port, workspace, token, persist = null, codex = true, intervalMs = 5000, onState = null } = {}) {
850
+ export function keepDiscovery({ port, workspace, token, persist = null, codex = true, claude = true, version = "", parent = null, intervalMs = 5000, onState = null } = {}) {
826
851
  // null until the first outcome, which therefore always differs and is always
827
852
  // reported — the caller learns where it stands before anything else happens.
828
853
  let healthy = null;
@@ -830,7 +855,7 @@ export function keepDiscovery({ port, workspace, token, persist = null, codex =
830
855
  const run = async () => {
831
856
  let state;
832
857
  try {
833
- const { rewritten } = await ensureDiscovery({ port, workspace, token, persist, codex });
858
+ const { rewritten } = await ensureDiscovery({ port, workspace, token, persist, codex, claude, version, parent });
834
859
  state = { ok: true, rewritten, file: discoveryPath(), error: null };
835
860
  } catch (err) {
836
861
  state = { ok: false, rewritten: false, file: discoveryPath(), error: err };