ccdeck 3.29.10 → 3.30.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 +1 -1
- package/bin/agent-dag.js +67 -118
- package/bin/cli/help.js +67 -0
- package/bin/cli/login-item.js +182 -0
- package/bin/cli/one-shot.js +254 -0
- package/bin/cli/package.js +39 -0
- package/bin/cli/pulse.js +98 -0
- package/bin/cli/restart.js +179 -0
- package/bin/cli/screen.js +166 -0
- package/bin/cli/second-start.js +120 -0
- package/bin/cli/startup.js +459 -0
- package/bin/cli/uninstall.js +169 -0
- package/bin/deck.js +112 -1559
- package/dist/web/assets/BrowserWatchModal-DP3YMtEi.js +1 -0
- package/dist/web/assets/UsageHistoryModal-CcdPVXAw.js +1 -0
- package/dist/web/assets/index-3vS6A0vt.css +1 -0
- package/dist/web/assets/index-BVTWHDta.js +298 -0
- package/dist/web/index.html +2 -2
- package/hook/hook.js +15 -14
- package/package.json +1 -1
- package/release-notes.json +28 -0
- package/src/server/account-projects.mjs +25 -11
- package/src/server/account-routes.mjs +254 -0
- package/src/server/activity.mjs +3 -3
- package/src/server/append-budget.mjs +185 -0
- package/src/server/append-failures.mjs +147 -0
- package/src/server/args.mjs +37 -5
- package/src/server/atomic-write.mjs +300 -0
- package/src/server/block-notify.mjs +0 -5
- package/src/server/boot-lock.mjs +1 -1
- package/src/server/brand.mjs +7 -6
- package/src/server/browser-react.mjs +10 -2
- package/src/server/browser-watch-decks.mjs +146 -0
- package/src/server/browser-watch-feed.mjs +69 -0
- package/src/server/browser-watch-log.mjs +173 -0
- package/src/server/browser-watch-mtime.mjs +18 -0
- package/src/server/browser-watch-relay.mjs +112 -0
- package/src/server/browser-watch-routes.mjs +172 -0
- package/src/server/browser-watch-seen.mjs +127 -0
- package/src/server/browser-watch-store.mjs +14 -143
- package/src/server/browser-watch.mjs +278 -626
- package/src/server/canonical-path.mjs +150 -0
- package/src/server/ccusage-failure.mjs +64 -0
- package/src/server/ccusage-install.mjs +459 -0
- package/src/server/ccusage-runner.mjs +271 -0
- package/src/server/ccusage.mjs +21 -756
- package/src/server/claude-accounts.mjs +220 -464
- package/src/server/claude-collector.mjs +189 -0
- package/src/server/claude-dir.mjs +2 -2
- package/src/server/claude-identity.mjs +5 -4
- package/src/server/claude-reset-credits.mjs +130 -0
- package/src/server/claude-verdicts.mjs +174 -0
- package/src/server/clear-route.mjs +191 -0
- package/src/server/codex-auth.mjs +4 -4
- package/src/server/codex-dir.mjs +52 -8
- package/src/server/codex-enrichment.mjs +310 -0
- package/src/server/codex-quota.mjs +5 -3
- package/src/server/codex-translate.mjs +407 -0
- package/src/server/codex-usage.mjs +8 -5
- package/src/server/codex-watch.mjs +496 -0
- package/src/server/cswap-auto-loop.mjs +342 -0
- package/src/server/cswap-auto-readers.mjs +303 -0
- package/src/server/cswap-auto.mjs +51 -609
- package/src/server/cswap-install.mjs +69 -265
- package/src/server/cswap-layout.mjs +242 -0
- package/src/server/deck-home.mjs +1 -1
- package/src/server/deck-prefs.mjs +51 -184
- package/src/server/deck-scope.mjs +52 -0
- package/src/server/desktop-update-routes.mjs +75 -0
- package/src/server/detach.mjs +78 -0
- package/src/server/discovery.mjs +298 -0
- package/src/server/event-log.mjs +328 -0
- package/src/server/event-pipeline.mjs +235 -0
- package/src/server/event-ring.mjs +214 -0
- package/src/server/event-routes.mjs +352 -0
- package/src/server/event-sink.mjs +26 -0
- package/src/server/exec-children.mjs +128 -0
- package/src/server/exec-not-found.mjs +213 -0
- package/src/server/exec-output.mjs +23 -0
- package/src/server/exec-spec.mjs +341 -0
- package/src/server/exec.mjs +74 -661
- package/src/server/health-route.mjs +42 -0
- package/src/server/hook-challenge.mjs +37 -0
- package/src/server/http-io.mjs +104 -0
- package/src/server/index.mjs +172 -7620
- package/src/server/install-layout.mjs +506 -0
- package/src/server/installer.mjs +79 -608
- package/src/server/jsonl-chunks.mjs +139 -0
- package/src/server/lan-accounts.mjs +27 -0
- package/src/server/lan-beacon.mjs +457 -0
- package/src/server/lan-call.mjs +239 -0
- package/src/server/lan-copies.mjs +270 -0
- package/src/server/lan-deck.mjs +583 -0
- package/src/server/lan-dials.mjs +161 -0
- package/src/server/lan-engine.mjs +475 -1024
- package/src/server/lan-hearing.mjs +49 -0
- package/src/server/lan-inbound.mjs +100 -0
- package/src/server/lan-invite-offer.mjs +77 -0
- package/src/server/lan-invite.mjs +207 -0
- package/src/server/lan-lines.mjs +73 -0
- package/src/server/lan-manifest.mjs +74 -0
- package/src/server/lan-reach.mjs +82 -69
- package/src/server/lan-requests.mjs +249 -0
- package/src/server/lan-round-record.mjs +45 -0
- package/src/server/lan-round-timer.mjs +62 -0
- package/src/server/lan-routes.mjs +143 -0
- package/src/server/lan-serve.mjs +112 -0
- package/src/server/lan-socket.mjs +243 -925
- package/src/server/lan-sync.mjs +58 -1026
- package/src/server/lan-tailnet-poll.mjs +57 -0
- package/src/server/lan-turns.mjs +80 -0
- package/src/server/lan-wire.mjs +620 -0
- package/src/server/lifecycle.mjs +421 -0
- package/src/server/listen.mjs +110 -0
- package/src/server/live-decks.mjs +138 -0
- package/src/server/load-average.mjs +32 -0
- package/src/server/log-election.mjs +235 -0
- package/src/server/log-replay.mjs +374 -0
- package/src/server/log-writer.mjs +72 -557
- package/src/server/login-service.mjs +3 -2
- package/src/server/macmon.mjs +1 -1
- package/src/server/memory-files.mjs +163 -0
- package/src/server/memory-metrics.mjs +178 -0
- package/src/server/metrics-history.mjs +94 -0
- package/src/server/metrics-run.mjs +100 -0
- package/src/server/music-routes.mjs +134 -0
- package/src/server/network-metrics.mjs +5 -3
- package/src/server/network-sampler.mjs +256 -0
- package/src/server/npm-latest.mjs +385 -0
- package/src/server/npm-upgrade.mjs +352 -0
- package/src/server/npx.mjs +41 -7
- package/src/server/output-watch.mjs +5 -4
- package/src/server/pinned-build.mjs +108 -0
- package/src/server/prefs-path.mjs +14 -0
- package/src/server/prefs-refusal.mjs +77 -0
- package/src/server/prefs-routes.mjs +76 -0
- package/src/server/prefs-set-aside.mjs +86 -0
- package/src/server/prefs-state.mjs +51 -0
- package/src/server/process-command.mjs +104 -0
- package/src/server/process-list.mjs +547 -0
- package/src/server/pulse-line.mjs +132 -0
- package/src/server/purge-key.mjs +7 -7
- package/src/server/quota-cli.mjs +174 -0
- package/src/server/quota-oauth.mjs +190 -0
- package/src/server/quota-shape.mjs +229 -0
- package/src/server/quota-store-resets.mjs +206 -0
- package/src/server/quota.mjs +123 -509
- package/src/server/relay-guard.mjs +3 -3
- package/src/server/request-gates.mjs +441 -0
- package/src/server/restart-note.mjs +180 -0
- package/src/server/retire-sound-hook.mjs +3 -3
- package/src/server/ring-bounds.mjs +215 -0
- package/src/server/running-deck.mjs +1 -1
- package/src/server/running-version.mjs +24 -0
- package/src/server/self-update.mjs +39 -1371
- package/src/server/session-enrichment.mjs +622 -0
- package/src/server/session-read-gate.mjs +49 -0
- package/src/server/session-tracking.mjs +242 -0
- package/src/server/sse-clients.mjs +222 -0
- package/src/server/stale-discovery.mjs +30 -0
- package/src/server/static-serve.mjs +81 -0
- package/src/server/supervisor.mjs +73 -12
- package/src/server/system-metrics.mjs +169 -1935
- package/src/server/term.mjs +11 -249
- package/src/server/thermal-metrics.mjs +520 -0
- package/src/server/thermal-sampler.mjs +204 -0
- package/src/server/token-redact.mjs +123 -0
- package/src/server/transcript-gate.mjs +107 -0
- package/src/server/transcript-scan.mjs +563 -0
- package/src/server/usage-routes.mjs +93 -0
- package/src/server/uv-bootstrap.mjs +1 -1
- package/src/server/wordmark.mjs +127 -0
- package/dist/web/assets/BrowserWatchModal-CMn28FHb.js +0 -1
- package/dist/web/assets/UsageHistoryModal-C-sPhsKT.js +0 -1
- package/dist/web/assets/index-C1YFI9f4.js +0 -298
- package/dist/web/assets/index-Ck2RAla3.css +0 -1
|
@@ -0,0 +1,421 @@
|
|
|
1
|
+
// How this process is replaced or ended: the version, upgrade, restart and
|
|
2
|
+
// stop routes, the latches that keep two asks from starting two of either, and
|
|
3
|
+
// the away-update that presses Upgrade or Restart by itself while nobody is
|
|
4
|
+
// looking.
|
|
5
|
+
//
|
|
6
|
+
// These lived in src/server/index.mjs — the latches beside startServer, the
|
|
7
|
+
// routes after the SSE stream, the away-update before pushEvent. They share one
|
|
8
|
+
// piece of state, the restart latch, which a press and the away-update both
|
|
9
|
+
// take, and none of them reads the ring, the SSE fan-out or the scanners; so
|
|
10
|
+
// they moved together and the latch stayed private. index.mjs arms them once
|
|
11
|
+
// per boot (armLifecycle, startAwayUpdate), routes to the five handlers, feeds
|
|
12
|
+
// `activity` from pushEvent, and re-exports the launcher's two calls,
|
|
13
|
+
// releaseRestart and markDeckReady. The bodies are unchanged.
|
|
14
|
+
import { dirname, join, resolve } from "node:path";
|
|
15
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
16
|
+
import { createActivity } from "./activity.mjs";
|
|
17
|
+
import { AWAY_BOOT_GRACE_MS, AWAY_RECHECK_MS, AWAY_TICK_MS, awayGate, awayUpdateStep } from "./auto-update.mjs";
|
|
18
|
+
import { PRODUCT } from "./brand.mjs";
|
|
19
|
+
import { logWritableNow } from "./event-log.mjs";
|
|
20
|
+
import { readBody, send } from "./http-io.mjs";
|
|
21
|
+
import { invokedName, renameNotice } from "./invoked-as.mjs";
|
|
22
|
+
// The build this process runs, loaded before an install can replace it: the
|
|
23
|
+
// Upgrade press and the away-update wait on it, and markDeckReady starts it —
|
|
24
|
+
// see pinned-build.mjs.
|
|
25
|
+
import { pinRunningBuild } from "./pinned-build.mjs";
|
|
26
|
+
import { heldPrefs } from "./prefs-state.mjs";
|
|
27
|
+
import { createPresence } from "./presence.mjs";
|
|
28
|
+
import { presentsDeckToken } from "./request-gates.mjs";
|
|
29
|
+
// The version this process runs, read at boot by a leaf so that the LAN
|
|
30
|
+
// engine's card and /api/version give one answer — see running-version.mjs.
|
|
31
|
+
import { RUNNING_VERSION } from "./running-version.mjs";
|
|
32
|
+
|
|
33
|
+
// Resolved the way pinned-build.mjs resolves it, from a file in the same
|
|
34
|
+
// directory, so every lazy import below is the URL the pin has already evaluated.
|
|
35
|
+
const PKG_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..");
|
|
36
|
+
|
|
37
|
+
// Set from startServer's options. The server cannot restart itself — the
|
|
38
|
+
// process lifecycle belongs to the supervisor in bin/agent-dag.js, which is the
|
|
39
|
+
// only thing that can bring a replacement up on the same port without racing
|
|
40
|
+
// the dying listener. Absent (running the module directly, or under an older
|
|
41
|
+
// launcher), /api/restart answers 501 and the UI hides the control rather than
|
|
42
|
+
// offering a button that does nothing.
|
|
43
|
+
let _onRestart = null;
|
|
44
|
+
// How this process ends itself, handed down by bin/deck.js for the same reason
|
|
45
|
+
// _onRestart is: the server does not own the process lifecycle and must be told
|
|
46
|
+
// how to leave. Absent when nothing handed one down — running the module
|
|
47
|
+
// directly, or an embedder — and /api/shutdown then answers 501 rather than
|
|
48
|
+
// pretending.
|
|
49
|
+
let _onStop = null;
|
|
50
|
+
// A stop is in flight. Two sockets asking at once must not both start one, and
|
|
51
|
+
// the response's 'finish' and 'close' can both fire for the same request.
|
|
52
|
+
let _stopping = false;
|
|
53
|
+
// A restart is in flight. Several browser tabs watching the same deck will each
|
|
54
|
+
// ask; the second ask must not re-enter the shutdown.
|
|
55
|
+
let _restarting = false;
|
|
56
|
+
// Whether the launcher has finished booting — see markDeckReady. False for the
|
|
57
|
+
// whole window between this listener accepting its first connection and
|
|
58
|
+
// bin/deck.js reaching the end of its startup, which is a window /api/restart
|
|
59
|
+
// is reachable in and cannot answer for on its own.
|
|
60
|
+
let _deckReady = false;
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* What this boot's launcher handed down, and the latches reset for it.
|
|
64
|
+
*
|
|
65
|
+
* Lifted out of startServer, which calls it first, once per boot: the restart
|
|
66
|
+
* and the stop it may hand to, whether a restart would have a log to replay,
|
|
67
|
+
* and the two in-flight flags and the ready flag, all back to a fresh boot's.
|
|
68
|
+
*/
|
|
69
|
+
function armLifecycle({ onRestart, onStop, persist }) {
|
|
70
|
+
_onRestart = typeof onRestart === "function" ? onRestart : null;
|
|
71
|
+
_onStop = typeof onStop === "function" ? onStop : null;
|
|
72
|
+
_stopping = false;
|
|
73
|
+
_canRestart = _onRestart != null && persist != null;
|
|
74
|
+
// A new listener is a new boot, whatever a previous one had got as far as
|
|
75
|
+
// reporting. Nothing but bin/deck.js ever sets this, and it does so once, at
|
|
76
|
+
// the end of the startup that begins with this call.
|
|
77
|
+
_deckReady = false;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// True only when a supervisor is listening AND the event log is being written.
|
|
81
|
+
// Without persistence a restart wipes the canvas irrecoverably — replayLog has
|
|
82
|
+
// no file to read — so the deck must not offer to do it. Whether the log is
|
|
83
|
+
// being written is event-log.mjs's to answer; see logWritableNow.
|
|
84
|
+
let _canRestart = false;
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Can this deck be restarted without losing the canvas?
|
|
88
|
+
*
|
|
89
|
+
* Asked at the moment of the press rather than read off a boot-time flag,
|
|
90
|
+
* because the writable half of it changes while the deck runs, and in both
|
|
91
|
+
* directions: a drive unmounted or a volume filled mid-session is discovered by
|
|
92
|
+
* the appender, not by the probe, and so is one that comes back. See
|
|
93
|
+
* logWritableNow.
|
|
94
|
+
*/
|
|
95
|
+
function canRestartNow() {
|
|
96
|
+
return _canRestart && logWritableNow();
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function handleVersion(req, res) {
|
|
100
|
+
const { versionReport } = await import(
|
|
101
|
+
pathToFileURL(join(PKG_ROOT, "src/server/self-update.mjs")).href
|
|
102
|
+
);
|
|
103
|
+
// ?refresh=1 asks npm now rather than reusing the cached answer — what the
|
|
104
|
+
// version chip does when clicked, for the user who has just published or is
|
|
105
|
+
// wondering whether the check is working at all.
|
|
106
|
+
const force = new URL(req.url, "http://localhost").searchParams.get("refresh") === "1";
|
|
107
|
+
const report = await versionReport({ running: RUNNING_VERSION, pkgRoot: PKG_ROOT, force });
|
|
108
|
+
// Attached here rather than inside versionReport for the same reason
|
|
109
|
+
// canRestart is: the report is what this package root says about itself,
|
|
110
|
+
// while both of these are facts about the process serving it — the command
|
|
111
|
+
// the user typed lives in this process's environment and argv, not on disk.
|
|
112
|
+
//
|
|
113
|
+
// It is deliberately not folded into `name`, which is documented as the
|
|
114
|
+
// package an upgrade would install and is load-bearing for the update flow.
|
|
115
|
+
// The two answer different questions and give different answers: `name` is
|
|
116
|
+
// read off the install on disk and is always known, while this one is the
|
|
117
|
+
// command that was typed and is silent wherever it cannot be proven — every
|
|
118
|
+
// Windows global install, and every `npm i -g ccdeck`, whose stub spawns the
|
|
119
|
+
// deck by absolute path and leaves nothing in argv to read.
|
|
120
|
+
const invoked = invokedName({ pkgRoot: PKG_ROOT });
|
|
121
|
+
// The second half of that notice, computed here rather than in the browser.
|
|
122
|
+
//
|
|
123
|
+
// The banner needs one more fact than the name: whether typing `ccdeck` is
|
|
124
|
+
// enough (a global install ships all three commands, so it already is) or
|
|
125
|
+
// whether the whole answer is `npx ccdeck`. The browser used to derive that
|
|
126
|
+
// from `upgradeMode`, which reads like the same question and is not — it
|
|
127
|
+
// answers "may this copy install over itself", and `AGENTS_DECK_NO_INSTALL=1`
|
|
128
|
+
// makes it null for EVERY shape including npx, because upgradeBlockedReason
|
|
129
|
+
// tests the opt-out before it tests npx. So an npx user who opted out of
|
|
130
|
+
// installs was told the global-install line and sent to a command that does
|
|
131
|
+
// not exist on their machine, while the terminal row two feet away said
|
|
132
|
+
// `npx ccdeck` — one fact, two surfaces, opposite answers (#363).
|
|
133
|
+
//
|
|
134
|
+
// renameNotice is where that fact is decided for the terminal, so it decides
|
|
135
|
+
// it here too and the browser renders the string rather than a branch. No
|
|
136
|
+
// `dash` argument on purpose: the glyph tier is a terminal concern, and the
|
|
137
|
+
// default em dash is what a browser should get.
|
|
138
|
+
const rename = renameNotice({ invoked, pkgRoot: PKG_ROOT });
|
|
139
|
+
send(res, 200, {
|
|
140
|
+
...report, canRestart: canRestartNow(), invokedAs: invoked, renameFix: rename?.fix ?? null,
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Runs `npm i -g <this deck>@latest`, and only that: the argument vector is
|
|
145
|
+
// fixed inside npm-upgrade.mjs — including which of the three published names
|
|
146
|
+
// it installs, which comes from the layout npm built rather than from the
|
|
147
|
+
// request. Answers immediately — progress is read back from /api/version.
|
|
148
|
+
async function handleUpgrade(_req, res) {
|
|
149
|
+
const { startUpgrade } = await import(
|
|
150
|
+
pathToFileURL(join(PKG_ROOT, "src/server/self-update.mjs")).href
|
|
151
|
+
);
|
|
152
|
+
// Every module this process will ever load, loaded BEFORE npm starts
|
|
153
|
+
// rewriting the tree they come from — see pinned-build.mjs. On a deck that
|
|
154
|
+
// has finished booting this settled long ago; it only waits for a press that
|
|
155
|
+
// lands in the first moments of a boot, or on a server nothing marked ready.
|
|
156
|
+
await pinRunningBuild();
|
|
157
|
+
const out = startUpgrade({ pkgRoot: PKG_ROOT });
|
|
158
|
+
send(res, out.ok ? 200 : 409, out);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Restart is a two-party act: this half answers before it stops listening, so
|
|
162
|
+
// the caller learns it was accepted rather than losing the socket mid-reply.
|
|
163
|
+
//
|
|
164
|
+
// `{ upgrade: true }` asks for the npx variant: come back through
|
|
165
|
+
// `npx -y <spec>@latest` instead of re-running the files already here. Granted
|
|
166
|
+
// only where that is genuinely how this copy updates — the mode is decided from
|
|
167
|
+
// the install on disk, never from the request.
|
|
168
|
+
async function handleRestart(req, res) {
|
|
169
|
+
if (!_onRestart) return send(res, 501, { ok: false, reason: "unsupervised" });
|
|
170
|
+
// Enforced here and not only in the UI. Under --no-persist a restart destroys
|
|
171
|
+
// the whole canvas — replayLog has no file to read — and a destructive act
|
|
172
|
+
// must not be prevented by a hidden button alone.
|
|
173
|
+
if (!_canRestart) return send(res, 409, { ok: false, reason: "no_persist" });
|
|
174
|
+
// A configured log that cannot be written is the same destruction by another
|
|
175
|
+
// route, and it deserves its own word: "no_persist" would tell a user who
|
|
176
|
+
// passed `--history` that they had not, which is the sort of answer that
|
|
177
|
+
// sends someone looking in the wrong place. See canRestartNow.
|
|
178
|
+
if (!canRestartNow()) return send(res, 409, { ok: false, reason: "log_unwritable" });
|
|
179
|
+
|
|
180
|
+
let wantUpgrade = false;
|
|
181
|
+
if (req.method === "POST") {
|
|
182
|
+
const body = await readBody(req, res).catch(() => null);
|
|
183
|
+
try { wantUpgrade = JSON.parse(body ?? "")?.upgrade === true; } catch { /* a plain restart */ }
|
|
184
|
+
}
|
|
185
|
+
let mode = null;
|
|
186
|
+
if (wantUpgrade) {
|
|
187
|
+
const { upgradeBlock, upgradeMode, npxRestartSpec } = await import(
|
|
188
|
+
pathToFileURL(join(PKG_ROOT, "src/server/self-update.mjs")).href
|
|
189
|
+
);
|
|
190
|
+
if (upgradeMode(upgradeBlock(PKG_ROOT)) !== "npx" || !npxRestartSpec(PKG_ROOT)) {
|
|
191
|
+
return send(res, 409, { ok: false, reason: "not_npx" });
|
|
192
|
+
}
|
|
193
|
+
mode = "npx";
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (_restarting) return send(res, 202, { ok: true, already: true });
|
|
197
|
+
_restarting = true;
|
|
198
|
+
// Accepted either way — the ask is good and the launcher holds on to it — but
|
|
199
|
+
// the two are not the same event and answering 200 to both would be this
|
|
200
|
+
// window's second untruth rather than its first. Between this listener
|
|
201
|
+
// accepting its first connection and bin/deck.js finishing the rest of its
|
|
202
|
+
// startup there is a real stretch during which a restart cannot be run yet,
|
|
203
|
+
// and a caller that reads the body should be able to tell that it is waiting
|
|
204
|
+
// on a boot rather than on a supervisor. See markDeckReady, and requestRestart
|
|
205
|
+
// in bin/deck.js for the half that does the waiting.
|
|
206
|
+
if (_deckReady) send(res, 200, { ok: true, mode });
|
|
207
|
+
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" });
|
|
208
|
+
// Let the response flush before the listener goes away.
|
|
209
|
+
handOffRestart(mode);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Hand a restart the latch has already admitted to the launcher — from a press
|
|
214
|
+
* (handleRestart, once its answer is on its way) or from the away-update
|
|
215
|
+
* (awayUpdateTick). The delay is the press's: its response has to leave before
|
|
216
|
+
* the listener does.
|
|
217
|
+
*/
|
|
218
|
+
function handOffRestart(mode) {
|
|
219
|
+
setTimeout(() => {
|
|
220
|
+
try { _onRestart(mode); }
|
|
221
|
+
catch (err) {
|
|
222
|
+
// This catch is where #448 lived: it released the server's half of the
|
|
223
|
+
// latch and said nothing, while the launcher's half stayed set with
|
|
224
|
+
// nothing left to clear it, and every later restart answered "ok" and did
|
|
225
|
+
// nothing for the rest of the process's life. The launcher now owns its
|
|
226
|
+
// own failures; this stays as the outer net, and it says so — one line,
|
|
227
|
+
// message only, because the terminal underneath is repainted every 800ms
|
|
228
|
+
// by the pulse and a stack dumped into it is a stack nobody can read.
|
|
229
|
+
_restarting = false;
|
|
230
|
+
console.error(`${PRODUCT}: restart request failed: ${err?.message ?? err}`);
|
|
231
|
+
}
|
|
232
|
+
}, 120).unref();
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** The other end of the latch above, for the upgrade that never happened.
|
|
236
|
+
*
|
|
237
|
+
* An npx upgrade is now fetched before anything is torn down, so a fetch that
|
|
238
|
+
* fails leaves this process serving — with `_restarting` still set from a
|
|
239
|
+
* restart that is not coming. Nothing would ever clear it: the only path that
|
|
240
|
+
* did was the process ending. Every later click, from any tab, answered 202
|
|
241
|
+
* "already restarting" for the rest of the deck's life. */
|
|
242
|
+
export function releaseRestart() {
|
|
243
|
+
_restarting = false;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** The launcher saying its boot is over: everything a shutdown would have to
|
|
247
|
+
* tear down now exists.
|
|
248
|
+
*
|
|
249
|
+
* This server starts accepting from inside startServer, before that call has
|
|
250
|
+
* even returned to bin/deck.js — so /api/restart is answerable for the whole
|
|
251
|
+
* of the startup that follows it, which on a cold boot includes the discovery
|
|
252
|
+
* file's first fsynced write and spawning the browser. The listener cannot see
|
|
253
|
+
* any of that from here; it has to be told. Called once, from bin/deck.js.
|
|
254
|
+
*
|
|
255
|
+
* Only /api/restart reads it, and only to answer honestly. Nothing is refused
|
|
256
|
+
* on the strength of it: the restart is still handed to the launcher, which
|
|
257
|
+
* holds it until it can run it (#448).
|
|
258
|
+
*
|
|
259
|
+
* IT IS ALSO WHERE THE BUILD IS PINNED (#1042) — see pinned-build.mjs. After
|
|
260
|
+
* the boot, so the laziness still keeps that work off the path to the socket;
|
|
261
|
+
* and before anything can replace the tree, because `npm i -g` typed in a
|
|
262
|
+
* terminal rewrites it exactly as startUpgrade's does, and the drift path then
|
|
263
|
+
* leaves this process serving until an idle moment just the same. The promise
|
|
264
|
+
* is handed back for the test that has to know when the pin has landed;
|
|
265
|
+
* bin/deck.js has no reason to wait for it, and it never rejects. */
|
|
266
|
+
export function markDeckReady() {
|
|
267
|
+
_deckReady = true;
|
|
268
|
+
return pinRunningBuild();
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* POST /api/shutdown — end this deck, from `ccdeck --stop`.
|
|
273
|
+
*
|
|
274
|
+
* THE TOKEN AND NOTHING ELSE. Every other mutating route accepts either the
|
|
275
|
+
* token or the deck's own page (isAuthorizedMutation), and this one must not:
|
|
276
|
+
* there is no button for it, so a page asking to end the deck is a page doing
|
|
277
|
+
* something no part of this product asks it to do. Refusing the browser half
|
|
278
|
+
* costs nothing and removes the whole class.
|
|
279
|
+
*
|
|
280
|
+
* WHY A ROUTE AND NOT A SIGNAL. Windows has none. `process.kill(pid, "SIGTERM")`
|
|
281
|
+
* there is TerminateProcess: the deck stops mid-instruction, its discovery file
|
|
282
|
+
* is left for the next boot to sweep, and its LAN beacon never says goodbye — so
|
|
283
|
+
* every paired colleague watches it time out instead of seeing it leave. One
|
|
284
|
+
* loopback POST behaves identically on all three platforms and ends in the
|
|
285
|
+
* deck's own shutdown(), which closes the listener, unlinks the registration and
|
|
286
|
+
* stops the beacon. The pid ladder still exists in `--stop`, as the fallback for
|
|
287
|
+
* a deck too wedged to answer this.
|
|
288
|
+
*
|
|
289
|
+
* ANSWERED BEFORE ANYTHING IS TORN DOWN. shutdown() calls
|
|
290
|
+
* server.closeAllConnections(), which would cut this very socket — so the
|
|
291
|
+
* teardown is hung off the response having left rather than run beside it, and
|
|
292
|
+
* the caller gets a 200 instead of a dropped connection it has to interpret.
|
|
293
|
+
*/
|
|
294
|
+
async function handleStop(req, res) {
|
|
295
|
+
if (!presentsDeckToken(req?.headers ?? {})) return send(res, 401, { ok: false, reason: "unauthenticated" });
|
|
296
|
+
if (!_onStop) return send(res, 501, { ok: false, reason: "no_launcher" });
|
|
297
|
+
if (_stopping) return send(res, 200, { ok: true, pid: process.pid, already: true });
|
|
298
|
+
_stopping = true;
|
|
299
|
+
let left = false;
|
|
300
|
+
const leave = () => {
|
|
301
|
+
if (left) return;
|
|
302
|
+
left = true;
|
|
303
|
+
// A throw here must still end the process: the caller has already been told
|
|
304
|
+
// this deck is going, and a deck that answered "ok" and stayed up is worse
|
|
305
|
+
// than one that never answered.
|
|
306
|
+
try { _onStop(); } catch { process.exit(0); }
|
|
307
|
+
};
|
|
308
|
+
// 'finish' is the response handed to the OS; 'close' covers the caller that
|
|
309
|
+
// hung up before it got there. Either one means nothing is left to flush.
|
|
310
|
+
res.once("finish", leave);
|
|
311
|
+
res.once("close", leave);
|
|
312
|
+
send(res, 200, { ok: true, pid: process.pid });
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// The two facts the away-update waits on (awayUpdateTick): whether a turn is
|
|
316
|
+
// running, fed from pushEvent, and whether a tab is being looked at, fed from
|
|
317
|
+
// POST /api/presence. They sat at the top of index.mjs while pushEvent was
|
|
318
|
+
// that file's, because pushEvent can run before the module holding it has
|
|
319
|
+
// finished evaluating; made here, they exist before event-pipeline.mjs's own
|
|
320
|
+
// body runs at all.
|
|
321
|
+
const activity = createActivity();
|
|
322
|
+
const presence = createPresence();
|
|
323
|
+
|
|
324
|
+
// ── updating while nobody is looking ────────────────────────────────────────
|
|
325
|
+
//
|
|
326
|
+
// auto-update.mjs has the why and the rules; this is the timer and the three
|
|
327
|
+
// effects — install, restart, npx relaunch — each through the code a press
|
|
328
|
+
// uses.
|
|
329
|
+
let _awayTry = null;
|
|
330
|
+
let _awayTimer = null;
|
|
331
|
+
let _bootedAt = Date.now();
|
|
332
|
+
// Until when a report that found nothing newer stands — see AWAY_RECHECK_MS.
|
|
333
|
+
let _awayNothingUntil = 0;
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* One tick of the away-update. Returns what it did, or null.
|
|
337
|
+
*
|
|
338
|
+
* Every clock is the real one: presence and activity are stamped with it, and
|
|
339
|
+
* the boot grace is AWAY_BOOT_GRACE_MS. This used to be exported and take the
|
|
340
|
+
* grace as an argument, so a test could drive it without waiting that out; the
|
|
341
|
+
* tests drive awayGate and awayUpdateStep in auto-update.mjs instead, and the
|
|
342
|
+
* one caller left is the `_awayTimer` interval.
|
|
343
|
+
*/
|
|
344
|
+
async function awayUpdateTick() {
|
|
345
|
+
const now = Date.now();
|
|
346
|
+
if (!awayGate({
|
|
347
|
+
enabled: heldPrefs.current()?.autoUpdate !== false,
|
|
348
|
+
supervised: _onRestart != null && _canRestart,
|
|
349
|
+
restarting: _restarting,
|
|
350
|
+
sinceBootMs: now - _bootedAt,
|
|
351
|
+
looking: presence.looking(now),
|
|
352
|
+
busy: activity.busy(now),
|
|
353
|
+
quietMs: activity.quietMs(now),
|
|
354
|
+
graceMs: AWAY_BOOT_GRACE_MS,
|
|
355
|
+
})) return null;
|
|
356
|
+
// A standing "nothing newer", unless the clock has moved back past it.
|
|
357
|
+
if (now < _awayNothingUntil && _awayNothingUntil - now <= AWAY_RECHECK_MS) return null;
|
|
358
|
+
const su = await import(pathToFileURL(join(PKG_ROOT, "src/server/self-update.mjs")).href);
|
|
359
|
+
const report = await su.versionReport({ running: RUNNING_VERSION, pkgRoot: PKG_ROOT });
|
|
360
|
+
if (!report.notice) {
|
|
361
|
+
_awayNothingUntil = now + AWAY_RECHECK_MS;
|
|
362
|
+
return null;
|
|
363
|
+
}
|
|
364
|
+
const step = awayUpdateStep({
|
|
365
|
+
notice: report.notice, mode: report.upgradeMode,
|
|
366
|
+
installing: report.upgrade?.state === "running", lastTry: _awayTry, now,
|
|
367
|
+
});
|
|
368
|
+
if (!step.act) return null;
|
|
369
|
+
// Asked again, because the lookup above can take seconds on a slow line and
|
|
370
|
+
// somebody may have sat down, or started a turn, in them.
|
|
371
|
+
const again = Date.now();
|
|
372
|
+
if (presence.looking(again) || activity.busy(again) || _restarting) return null;
|
|
373
|
+
_awayTry = { target: step.target, at: again };
|
|
374
|
+
if (step.act === "install") {
|
|
375
|
+
// Pinned before npm is spawned, never after it — see pinned-build.mjs. On a
|
|
376
|
+
// deck bin/deck.js has marked ready this is a promise that settled long ago.
|
|
377
|
+
await pinRunningBuild();
|
|
378
|
+
su.startUpgrade({ pkgRoot: PKG_ROOT });
|
|
379
|
+
return step.act;
|
|
380
|
+
}
|
|
381
|
+
// handleRestart's own check for the npx path, for the same reason: the mode
|
|
382
|
+
// comes from the install on disk, and a spec it cannot name is no relaunch.
|
|
383
|
+
if (step.act === "npx" && !su.npxRestartSpec(PKG_ROOT)) return null;
|
|
384
|
+
_restarting = true;
|
|
385
|
+
handOffRestart(step.act === "npx" ? "npx" : null);
|
|
386
|
+
return step.act;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* The away-update's clock. The boot grace counts from here, and the timer is
|
|
391
|
+
* unref'd so it never keeps a process alive on its own. It does nothing until
|
|
392
|
+
* the launcher has handed down a restart — see awayGate's `supervised`.
|
|
393
|
+
*
|
|
394
|
+
* Lifted out of startServer, which calls it once per boot; a second boot in the
|
|
395
|
+
* same process replaces the timer rather than adding one.
|
|
396
|
+
*/
|
|
397
|
+
function startAwayUpdate() {
|
|
398
|
+
_bootedAt = Date.now();
|
|
399
|
+
clearInterval(_awayTimer);
|
|
400
|
+
_awayTimer = setInterval(() => { awayUpdateTick().catch(() => {}); }, AWAY_TICK_MS);
|
|
401
|
+
_awayTimer.unref?.();
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/** POST {tab, looking} — a tab saying whether it is being looked at. See
|
|
405
|
+
* presence.mjs, and src/web/presence.ts for the sender. */
|
|
406
|
+
async function handlePresence(req, res) {
|
|
407
|
+
const raw = await readBody(req, res).catch(() => null);
|
|
408
|
+
let body = null;
|
|
409
|
+
try { body = JSON.parse(raw ?? ""); } catch { /* handled below */ }
|
|
410
|
+
if (!body || typeof body !== "object") return send(res, 400, { ok: false, reason: "bad_request" });
|
|
411
|
+
const ok = presence.report(body.tab, body.looking === true, Date.now());
|
|
412
|
+
return send(res, ok ? 200 : 400, { ok });
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// What index.mjs calls besides the two exported above. Listed rather than
|
|
416
|
+
// marked at each declaration, so the declarations read as they did where they
|
|
417
|
+
// came from.
|
|
418
|
+
export {
|
|
419
|
+
activity, armLifecycle, handlePresence, handleRestart, handleStop,
|
|
420
|
+
handleUpgrade, handleVersion, startAwayUpdate,
|
|
421
|
+
};
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// Getting a port: the random fallback candidates, one listen attempt, and what
|
|
2
|
+
// to say when an attempt fails.
|
|
3
|
+
//
|
|
4
|
+
// These lived in src/server/index.mjs near startServer, whose listen loop is
|
|
5
|
+
// their only caller, and they ask nothing of the deck — a port range in, a
|
|
6
|
+
// bound server or a worded error out. The loop itself stays in startServer,
|
|
7
|
+
// because what it starts after the bind that takes is the deck's business, and
|
|
8
|
+
// the order of those starts is pinned there. portRetryable, listenHint and
|
|
9
|
+
// listenFailure were exported already, so that every platform's answer can be
|
|
10
|
+
// asserted on any of them.
|
|
11
|
+
|
|
12
|
+
export function randomPort(min, max) {
|
|
13
|
+
return Math.floor(Math.random() * (max - min + 1)) + min;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// One attempt, leaving the server with no more listeners on it than it started
|
|
17
|
+
// with. `server.listen(port, host, cb)` registers `cb` for a 'listening' event
|
|
18
|
+
// that a failed bind never emits, so the previous shape left one behind per
|
|
19
|
+
// attempt — eleven candidates printed Node's "MaxListenersExceededWarning:
|
|
20
|
+
// 11 listening listeners added to [Server]" into the middle of a boot that was
|
|
21
|
+
// already going wrong. Both sides are removed by whichever fires first.
|
|
22
|
+
export async function tryListen(server, port, host) {
|
|
23
|
+
return new Promise((res, rej) => {
|
|
24
|
+
const onListening = () => { server.removeListener("error", onError); res(); };
|
|
25
|
+
const onError = (err) => { server.removeListener("listening", onListening); rej(err); };
|
|
26
|
+
server.once("error", onError);
|
|
27
|
+
server.once("listening", onListening);
|
|
28
|
+
server.listen(port, host);
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Whether another PORT could fix this failed `listen`.
|
|
34
|
+
*
|
|
35
|
+
* startServer builds ten random fallback candidates and they exist for exactly
|
|
36
|
+
* one situation: "this port is unavailable". Until #552 only EADDRINUSE reached
|
|
37
|
+
* them, which is the POSIX spelling of that answer and not the only one.
|
|
38
|
+
*
|
|
39
|
+
* WINDOWS. `winnat` hands out contiguous TCP blocks to Hyper-V, WSL2 and Docker
|
|
40
|
+
* Desktop, and a bind INSIDE one of those reserved exclusion ranges — the ones
|
|
41
|
+
* `netsh interface ipv4 show excludedportrange protocol=tcp` prints — is refused
|
|
42
|
+
* with WSAEACCES, which libuv reports as EACCES. Any machine with containers or
|
|
43
|
+
* WSL on it can therefore have 4317 blocked without a single socket being open
|
|
44
|
+
* on it. The loop rethrew on the first candidate, bin/deck.js printed
|
|
45
|
+
* `server failed: listen EACCES: permission denied 127.0.0.1:4317`, and the deck
|
|
46
|
+
* exited 1 with the fallback range untouched.
|
|
47
|
+
*
|
|
48
|
+
* On POSIX the same code is what a bind below 1024 gets without privilege.
|
|
49
|
+
* Retrying is right there too: 4317 and the whole fallback range are above 1024,
|
|
50
|
+
* so a random candidate is a port the user can actually have — and the deck
|
|
51
|
+
* coming up on 4322 beats it refusing to come up at all, which is already how
|
|
52
|
+
* EADDRINUSE on a privileged port behaves today.
|
|
53
|
+
*
|
|
54
|
+
* The other direction is the half that keeps this honest. EADDRNOTAVAIL,
|
|
55
|
+
* ENOTFOUND, EAI_AGAIN and EAFNOSUPPORT are about the HOST, not the port: the
|
|
56
|
+
* address does not exist on this machine or does not resolve, and no candidate
|
|
57
|
+
* can help. Walking eleven of them only delays the one sentence that would have
|
|
58
|
+
* explained it, so anything not named here stops the loop.
|
|
59
|
+
*/
|
|
60
|
+
export const portRetryable = (err) =>
|
|
61
|
+
Boolean(err) && (err.code === "EADDRINUSE" || err.code === "EACCES");
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The sentence that goes beside a listen errno, or "" when the errno says it
|
|
65
|
+
* all.
|
|
66
|
+
*
|
|
67
|
+
* Pure, and the platform is a parameter, for the reason every Windows answer in
|
|
68
|
+
* this repo is written that way: the branch that matters most is the one the
|
|
69
|
+
* author cannot run. A raw `listen EACCES: permission denied 127.0.0.1:4317` is
|
|
70
|
+
* true and useless — the thing the user needs is the name of the command that
|
|
71
|
+
* lists the ranges their machine has reserved.
|
|
72
|
+
*/
|
|
73
|
+
export function listenHint(code, { host = "", port = 0, platform = process.platform } = {}) {
|
|
74
|
+
if (code === "EACCES") {
|
|
75
|
+
if (platform === "win32") {
|
|
76
|
+
return "a reserved port range is the usual cause on Windows: Hyper-V, WSL2 and Docker Desktop have winnat hold contiguous TCP blocks. Run `netsh interface ipv4 show excludedportrange protocol=tcp` to see them, then pass --port with a number outside every range";
|
|
77
|
+
}
|
|
78
|
+
if (port > 0 && port < 1024) return "ports below 1024 need root — pass --port with a number above 1024";
|
|
79
|
+
return "the OS refused the bind; a sandbox or a security policy is the usual cause";
|
|
80
|
+
}
|
|
81
|
+
if (code === "EADDRNOTAVAIL") {
|
|
82
|
+
return `no interface on this machine holds ${host || "that address"}, so no other port can help — pass --host with an address it does hold, or 127.0.0.1 for local only`;
|
|
83
|
+
}
|
|
84
|
+
if (code === "ENOTFOUND" || code === "EAI_AGAIN") {
|
|
85
|
+
return `${host || "that host"} does not resolve, so no other port can help — pass --host with an address rather than a name`;
|
|
86
|
+
}
|
|
87
|
+
return "";
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* The error startServer throws, with the hint already in it.
|
|
92
|
+
*
|
|
93
|
+
* `exhausted` is the difference between "every candidate was refused" and "this
|
|
94
|
+
* one was refused for a reason more candidates cannot fix". The exhausted
|
|
95
|
+
* message keeps its opening words — bin/deck.js prints them and
|
|
96
|
+
* boot-listen-before-report.test.ts reads them — and now names the LAST errno
|
|
97
|
+
* rather than asserting EADDRINUSE, which was a lie the moment EACCES could
|
|
98
|
+
* reach the end of the loop.
|
|
99
|
+
*/
|
|
100
|
+
export function listenFailure(err, { host = "", port = 0, platform = process.platform, exhausted = false } = {}) {
|
|
101
|
+
const code = err?.code;
|
|
102
|
+
const head = exhausted
|
|
103
|
+
? `all ports tried — none available (last: ${code ?? "unknown"} on ${host}:${port})`
|
|
104
|
+
: String(err?.message ?? err ?? "listen failed");
|
|
105
|
+
const hint = listenHint(code, { host, port, platform });
|
|
106
|
+
const out = new Error(hint ? `${head} — ${hint}` : head);
|
|
107
|
+
if (code !== undefined) out.code = code;
|
|
108
|
+
if (err) out.cause = err;
|
|
109
|
+
return out;
|
|
110
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
// Which decks are up right now, as their own discovery records spell them —
|
|
2
|
+
// and only the ones that proved, over their own port, that they are still the
|
|
3
|
+
// deck the record describes (#695).
|
|
4
|
+
//
|
|
5
|
+
// This lived in src/server/index.mjs, inside the Codex rollout watcher, and has
|
|
6
|
+
// two readers: the watcher's log election (writesCodexLog) in codex-watch.mjs,
|
|
7
|
+
// and logSharing in event-log.mjs, which tells the Clear dialog who owns the
|
|
8
|
+
// log. Neither needs anything from the server but the registry directory and
|
|
9
|
+
// the challenge, so it moved to a leaf both can import. The verdict cache moved with it and is still
|
|
10
|
+
// the only state.
|
|
11
|
+
import { readdir, readFile } from "node:fs/promises";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
import { claudeConfigDir } from "./claude-dir.mjs";
|
|
14
|
+
import { challengeDeck, isProcessAlive } from "./deck-probe.mjs";
|
|
15
|
+
|
|
16
|
+
// How long one record's challenge verdict is trusted. The rollout scan runs
|
|
17
|
+
// every 1.5s and would otherwise pay a round trip per record per tick for an
|
|
18
|
+
// answer that changes about once per deck lifetime.
|
|
19
|
+
//
|
|
20
|
+
// Both answers are cached, and neither one delays a takeover, because the pid
|
|
21
|
+
// probe runs first and is not cached at all: a deck killed with SIGTERM or
|
|
22
|
+
// SIGKILL loses its pid immediately and its record is dropped on the very next
|
|
23
|
+
// tick whatever this map says. What the TTL bounds is only the ghost case — a
|
|
24
|
+
// record whose pid was recycled by an unrelated process — where five seconds of
|
|
25
|
+
// believing a stale "yes" is at most a few unwritten lines, against a directory
|
|
26
|
+
// listing plus an HTTP round trip on every tick of every deck forever.
|
|
27
|
+
const DECK_PROOF_TTL_MS = 5000;
|
|
28
|
+
// `${pid}:${port}:${token}` -> { at, ok }. Keyed on the record's own identity so
|
|
29
|
+
// a rewritten record — a deck that restarted onto the same port with a fresh
|
|
30
|
+
// token — is a new question rather than an inherited answer.
|
|
31
|
+
const deckProofs = new Map();
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Is the process listening on this record's port the deck the record describes?
|
|
35
|
+
*
|
|
36
|
+
* WHY THE SERVER ASKS AT ALL (#695). readLiveDecks used to trust the pid and
|
|
37
|
+
* nothing else, and a pid is not evidence: a record left behind by a deck that
|
|
38
|
+
* is gone — SIGKILL, an OOM kill, a power cut, a console window closed on
|
|
39
|
+
* Windows, none of which run the shutdown that unlinks it — passes a signal-0
|
|
40
|
+
* probe forever once the OS hands that number to some other long-lived process.
|
|
41
|
+
* writesCodexLog then elected it, because it named a lower port than any real
|
|
42
|
+
* deck, and every deck tailing the rollout drew the events while none of them
|
|
43
|
+
* appended a line. The canvas looked perfectly normal and events.jsonl stopped
|
|
44
|
+
* growing, so nothing said so until a restart replayed a log that had stopped
|
|
45
|
+
* days earlier.
|
|
46
|
+
*
|
|
47
|
+
* hook.js has had the answer to this since the handshake landed and the Codex
|
|
48
|
+
* half never got it — this is that half. The record carries the deck's own token
|
|
49
|
+
* in plaintext (mode 0600, same user, and it is written there precisely so that
|
|
50
|
+
* another process can challenge with it), so we can ask its port to hash that
|
|
51
|
+
* token against a nonce it has never seen. A stranger cannot answer; the deck
|
|
52
|
+
* that wrote the file can.
|
|
53
|
+
*
|
|
54
|
+
* Two records pass without being asked:
|
|
55
|
+
*
|
|
56
|
+
* • our own. We are the process the record describes, and challenging
|
|
57
|
+
* ourselves over loopback inside our own scan is a question we already know
|
|
58
|
+
* the answer to. It also must never fail: writesCodexLog looks itself up in
|
|
59
|
+
* this list, and a deck that cannot find its own record falls back to
|
|
60
|
+
* writing — so a self-challenge that timed out under load would turn one
|
|
61
|
+
* unwritten line into a duplicated one.
|
|
62
|
+
* • a record with no token, written by a deck older than the handshake. That
|
|
63
|
+
* is exactly requiresProof's fallback in hook.js, kept in step with it on
|
|
64
|
+
* purpose: refusing those would make every pre-1.33.71 deck invisible to the
|
|
65
|
+
* election, which elects a writer for a log those decks are still appending
|
|
66
|
+
* to.
|
|
67
|
+
*
|
|
68
|
+
* A timeout counts as a failure, like a refusal and a wrong answer. That is the
|
|
69
|
+
* same verdict hook.js reaches — a target that misses the deadline is not posted
|
|
70
|
+
* to either — and it errs the direction the server has always erred: the deck
|
|
71
|
+
* writes, and "a line written twice is recoverable; a deck that quietly stops
|
|
72
|
+
* recording anything is not" (writesCodexLog).
|
|
73
|
+
*/
|
|
74
|
+
async function provesDeck(d) {
|
|
75
|
+
if (d.pid === process.pid) return true;
|
|
76
|
+
if (typeof d.token !== "string" || d.token === "") return true;
|
|
77
|
+
|
|
78
|
+
const key = `${d.pid}:${d.port}:${d.token}`;
|
|
79
|
+
const cached = deckProofs.get(key);
|
|
80
|
+
const now = Date.now();
|
|
81
|
+
if (cached && now - cached.at < DECK_PROOF_TTL_MS) return cached.ok;
|
|
82
|
+
|
|
83
|
+
const ok = await challengeDeck(d.port, d.token);
|
|
84
|
+
deckProofs.set(key, { at: now, ok });
|
|
85
|
+
// Records come and go; without this the map would grow by one entry per deck
|
|
86
|
+
// that ever registered on this machine while the process is up.
|
|
87
|
+
for (const [k, v] of deckProofs) {
|
|
88
|
+
if (now - v.at >= DECK_PROOF_TTL_MS) deckProofs.delete(k);
|
|
89
|
+
}
|
|
90
|
+
return ok;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Every deck registered right now, as its own discovery record spells it — and
|
|
96
|
+
* only the ones that proved they are still the deck their record describes.
|
|
97
|
+
*
|
|
98
|
+
* These are the files hook.js enumerates; this is the server reading them for
|
|
99
|
+
* itself, because the rollout watcher has no hook to do it and still has to know
|
|
100
|
+
* which other decks are tailing the same file into the same log. Dead pids are
|
|
101
|
+
* ignored rather than unlinked — sweepStaleDiscovery owns that, and a poll
|
|
102
|
+
* running every 1.5s is no place to be deleting other decks' registrations. A
|
|
103
|
+
* record that fails the challenge is likewise left alone HERE. The hook is the one
|
|
104
|
+
* reader that does delete a record, and only one that stayed silent through both
|
|
105
|
+
* of its challenge deadlines and has not been restamped for a minute, which a
|
|
106
|
+
* live deck does every five seconds (proveTargets in hook/hook.js, #1119). One
|
|
107
|
+
* missed answer on this poll's timer is not that evidence, so nothing here acts
|
|
108
|
+
* on it.
|
|
109
|
+
*
|
|
110
|
+
* The pid probe stays, in front of the challenge, because it is free and it is
|
|
111
|
+
* the one test that answers instantly when a deck is killed — which is what
|
|
112
|
+
* keeps the takeover immediate rather than TTL-bound. See provesDeck.
|
|
113
|
+
*/
|
|
114
|
+
async function readLiveDecks() {
|
|
115
|
+
const dir = join(claudeConfigDir(), "agent-dag");
|
|
116
|
+
let files;
|
|
117
|
+
try { files = await readdir(dir); } catch { return []; }
|
|
118
|
+
const candidates = [];
|
|
119
|
+
for (const f of files) {
|
|
120
|
+
if (!f.endsWith(".json")) continue;
|
|
121
|
+
try {
|
|
122
|
+
const d = JSON.parse(await readFile(join(dir, f), "utf8"));
|
|
123
|
+
// Without both numbers there is nothing to group or tie-break on, so the
|
|
124
|
+
// record cannot take part in an election either way.
|
|
125
|
+
if (!d || typeof d.pid !== "number" || typeof d.port !== "number") continue;
|
|
126
|
+
if (!isProcessAlive(d.pid)) continue;
|
|
127
|
+
candidates.push(d);
|
|
128
|
+
} catch { /* corrupt, or gone between listing and read */ }
|
|
129
|
+
}
|
|
130
|
+
// In parallel: with several decks up this is the difference between one 400ms
|
|
131
|
+
// deadline for the scan and one per record.
|
|
132
|
+
const proven = await Promise.all(candidates.map(provesDeck));
|
|
133
|
+
return candidates.filter((_, i) => proven[i]);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// What index.mjs calls. Listed rather than marked at the declaration, so the
|
|
137
|
+
// declaration reads as it did where it came from.
|
|
138
|
+
export { readLiveDecks };
|