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.
- package/README.md +176 -6
- package/bin/agent-dag.js +125 -4
- package/bin/deck.js +532 -4
- package/dist/web/assets/index-BYMVIG6f.css +1 -0
- package/dist/web/assets/index-G0ntHhBn.js +266 -0
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/release-notes.json +12 -0
- package/src/server/activity.mjs +52 -0
- package/src/server/args.mjs +27 -0
- package/src/server/auto-update.mjs +79 -0
- package/src/server/claude-accounts.mjs +205 -6
- package/src/server/cswap-admin.mjs +104 -11
- package/src/server/deck-home.mjs +243 -0
- package/src/server/deck-prefs.mjs +88 -8
- package/src/server/deck-probe.mjs +111 -0
- package/src/server/detach.mjs +244 -0
- package/src/server/global-install.mjs +67 -0
- package/src/server/index.mjs +275 -88
- package/src/server/installer.mjs +33 -8
- package/src/server/lan-about.mjs +119 -0
- package/src/server/lan-engine.mjs +142 -9
- package/src/server/lan-socket.mjs +94 -11
- package/src/server/lan-sync.mjs +5 -1
- package/src/server/login-service.mjs +473 -0
- package/src/server/presence.mjs +40 -0
- package/src/server/running-deck.mjs +234 -0
- package/src/server/self-update.mjs +5 -2
- package/src/server/stop-deck.mjs +171 -0
- package/src/server/supervisor.mjs +90 -0
- package/src/server/term.mjs +35 -2
- package/dist/web/assets/index-CE-8EKi6.css +0 -1
- package/dist/web/assets/index-DTbVC1d9.js +0 -266
package/src/server/index.mjs
CHANGED
|
@@ -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 {
|
|
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 {
|
|
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
|
|
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
|
package/src/server/installer.mjs
CHANGED
|
@@ -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
|
|
775
|
-
* setting
|
|
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 };
|