agent-dag 3.22.0 → 3.22.3

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.
Files changed (70) hide show
  1. package/README.md +6 -477
  2. package/package.json +14 -48
  3. package/shim.js +107 -0
  4. package/LICENSE +0 -661
  5. package/LICENSING.md +0 -82
  6. package/THIRD_PARTY_NOTICES.md +0 -395
  7. package/bin/agent-dag.js +0 -626
  8. package/bin/deck.js +0 -1805
  9. package/dist/web/assets/index-3FWd7g_W.css +0 -1
  10. package/dist/web/assets/index-BOwtoP02.js +0 -266
  11. package/dist/web/index.html +0 -49
  12. package/hook/hook.js +0 -542
  13. package/release-notes.json +0 -392
  14. package/src/server/activity.mjs +0 -52
  15. package/src/server/agent-activity.mjs +0 -522
  16. package/src/server/args.mjs +0 -183
  17. package/src/server/auto-update.mjs +0 -79
  18. package/src/server/block-notify.mjs +0 -173
  19. package/src/server/boot-deadline.mjs +0 -127
  20. package/src/server/brand.mjs +0 -16
  21. package/src/server/browser-history.mjs +0 -497
  22. package/src/server/browser-presence.mjs +0 -211
  23. package/src/server/browser-profiles.mjs +0 -279
  24. package/src/server/browser-react.mjs +0 -284
  25. package/src/server/browser-watch-store.mjs +0 -350
  26. package/src/server/browser-watch.mjs +0 -905
  27. package/src/server/ccusage.mjs +0 -1168
  28. package/src/server/claude-accounts.mjs +0 -951
  29. package/src/server/claude-dir.mjs +0 -213
  30. package/src/server/codex-auth.mjs +0 -388
  31. package/src/server/codex-dir.mjs +0 -171
  32. package/src/server/codex-quota.mjs +0 -449
  33. package/src/server/codex-usage.mjs +0 -512
  34. package/src/server/cswap-admin.mjs +0 -1562
  35. package/src/server/cswap-auto.mjs +0 -658
  36. package/src/server/cswap-install.mjs +0 -641
  37. package/src/server/deck-home.mjs +0 -243
  38. package/src/server/deck-prefs.mjs +0 -301
  39. package/src/server/deck-probe.mjs +0 -111
  40. package/src/server/detach.mjs +0 -244
  41. package/src/server/exec.mjs +0 -996
  42. package/src/server/global-install.mjs +0 -67
  43. package/src/server/hwmonitor.mjs +0 -56
  44. package/src/server/index.mjs +0 -6043
  45. package/src/server/installer.mjs +0 -912
  46. package/src/server/invoked-as.mjs +0 -144
  47. package/src/server/lan-about.mjs +0 -119
  48. package/src/server/lan-engine.mjs +0 -952
  49. package/src/server/lan-reach.mjs +0 -256
  50. package/src/server/lan-socket.mjs +0 -682
  51. package/src/server/lan-sync.mjs +0 -941
  52. package/src/server/lhm-parse.mjs +0 -91
  53. package/src/server/log-tail.mjs +0 -139
  54. package/src/server/log-writer.mjs +0 -322
  55. package/src/server/login-service.mjs +0 -473
  56. package/src/server/macmon.mjs +0 -310
  57. package/src/server/npx.mjs +0 -264
  58. package/src/server/open-url.mjs +0 -242
  59. package/src/server/presence.mjs +0 -40
  60. package/src/server/quota.mjs +0 -792
  61. package/src/server/relay-guard.mjs +0 -507
  62. package/src/server/reset-label.mjs +0 -78
  63. package/src/server/retire-sound-hook.mjs +0 -349
  64. package/src/server/running-deck.mjs +0 -234
  65. package/src/server/self-update.mjs +0 -1380
  66. package/src/server/stop-deck.mjs +0 -171
  67. package/src/server/supervisor.mjs +0 -392
  68. package/src/server/system-metrics.mjs +0 -1825
  69. package/src/server/term.mjs +0 -686
  70. package/src/server/uv-bootstrap.mjs +0 -337
@@ -1,349 +0,0 @@
1
- // Takes the deck's old finish-sound hook back off a machine that already has it.
2
- //
3
- // Until #704 the deck played its "turn finished" sound by writing a `Stop` entry
4
- // into the user's settings.json whose command ran `notify.mjs` out of the deck's
5
- // own install directory. The deck plays that sound itself now, from the `Stop`
6
- // and `Notification` envelopes it already receives, and the script is gone from
7
- // the package — so an entry naming it is a hook pointing at a file that does not
8
- // exist, and Claude Code runs it at the end of every turn. On a machine that was
9
- // working yesterday. That is what this module exists to prevent, and it is the
10
- // whole of what is left here: there is no installer, no toggle, no status
11
- // reporter and no re-assert. Only the removal, and the promise the removal owes.
12
- //
13
- // THE PROMISE. Turning the sound on used to PARK any sound hook the user had
14
- // written themselves — an `afplay` line, a PowerShell one — in
15
- // ~/.agents-deck/parked-sound-hooks.json, so that "off" produced actual silence
16
- // and "on" did not play twice. Those hooks are the user's, the deck is the only
17
- // thing that knows where they went, and this is the last code that will ever be
18
- // in a position to hand them back. So retirement is not "delete our entry": it
19
- // is "delete our entry and put theirs back where they wrote it".
20
- //
21
- // WHAT COUNTS AS OURS. Two rules, and they cover different machines.
22
- //
23
- // The `__agent-dag-sound` mark is what this deck wrote, and it survives a
24
- // settings.json synced from another computer — where every path in the command
25
- // belongs to that computer and matches nothing here.
26
- //
27
- // An entry whose command names one of our installed scripts is ours too, mark
28
- // or no mark. <claude config dir>/agent-dag/ is a directory this deck creates
29
- // and fills; nobody hand-writes a Stop hook that runs `node
30
- // ~/.claude/agent-dag/notify.mjs`. The author's own machine is the case: two
31
- // Stop entries naming the installed notify.js with the mark missing from both,
32
- // which the mark rule alone would have left behind — playing a sound with no
33
- // switch anywhere that could stop it, or crashing once the script was swept.
34
- //
35
- // Everything else in the file is the user's and is not touched. `afplay …` stays
36
- // exactly where they put it, and this module has no idea what it does.
37
- //
38
- // EXACTLY ONCE, without a stamp. Retirement is triggered by the state it
39
- // removes: our entry in settings.json, a parked file, or one of our scripts on
40
- // disk. When it has run there is none of that left, so the next boot asks three
41
- // `existsSync` questions, gets three noes, and writes nothing — the same answer
42
- // every boot after it, forever. A "retirement done" marker file would have been
43
- // the other spelling and is the wrong one: a marker can say done about a machine
44
- // whose settings.json was later restored from a backup carrying the old entry,
45
- // and then the broken hook lives there permanently. State that describes itself
46
- // cannot drift from itself.
47
- //
48
- // A DECK THAT CANNOT WRITE. Nothing here is done speculatively and nothing is
49
- // recorded as done that was not. A settings.json that will not parse stops
50
- // retirement with the file byte for byte as it was found (see
51
- // readSettingsForWrite: this rewrites the whole file, so treating a damaged one
52
- // as `{}` would replace every permission, env var and hook in it with nothing).
53
- // An unwritable one throws out of the write and the boot reports it. A parked
54
- // file that will not read leaves the park alone and still removes our entry,
55
- // because those are two independent repairs and only one of them is urgent.
56
- // In every one of those cases the trigger state is still on disk, so the next
57
- // boot tries again. There is nothing to reset.
58
- //
59
- // TWO DECKS BOOTING AT ONCE. Both read the same settings, both compute the same
60
- // result, and both write it through writeFileAtomic — a rename, so the file is
61
- // one whole payload whichever lands second. The park is the part that could
62
- // have gone wrong: deck B reading the park before A deleted it and settings
63
- // after A wrote it would put the user's hooks back a second time, on top of the
64
- // copy A had just restored. So a parked entry is spliced back only when an
65
- // identical one is not already in the group. Restoring is idempotent, and the
66
- // race stops being one.
67
- import { readFile, rm } from "node:fs/promises";
68
- import { existsSync } from "node:fs";
69
- import { join } from "node:path";
70
- import { homedir } from "node:os";
71
- import { claudeConfigDir } from "./claude-dir.mjs";
72
- import { readSettingsForWrite, writeFileAtomic } from "./installer.mjs";
73
-
74
- const CLAUDE_DIR = claudeConfigDir();
75
- const SETTINGS_PATH = join(CLAUDE_DIR, "settings.json");
76
- const INSTALL_DIR = join(CLAUDE_DIR, "agent-dag");
77
-
78
- // Both names the sound script ever had. `.js` is the pre-#577 spelling, which
79
- // was CommonJS-by-default in a directory with no package.json above it and so a
80
- // `SyntaxError: Cannot use import statement` at the end of every turn on the
81
- // older half of this package's `engines` range; `.mjs` is what replaced it.
82
- // Retirement has to know both, because a machine that never upgraded past #577
83
- // is exactly the kind of machine this runs on.
84
- const NOTIFY_PATH = join(INSTALL_DIR, "notify.mjs");
85
- const LEGACY_NOTIFY_PATH = join(INSTALL_DIR, "notify.js");
86
- const OUR_SCRIPTS = [NOTIFY_PATH, LEGACY_NOTIFY_PATH];
87
-
88
- const MARK = "__agent-dag-sound";
89
- const EVENT = "Stop";
90
- // Where the user's own sound hooks were put while the toggle was on. Nothing
91
- // writes this any more; retirement reads it once and deletes it.
92
- const PARKED_PATH = join(homedir(), ".agents-deck", "parked-sound-hooks.json");
93
-
94
- const commandsOf = (entry) =>
95
- (entry?.hooks ?? []).map(h => (typeof h?.command === "string" ? h.command : ""));
96
-
97
- /**
98
- * THE TAIL, NOT THE WHOLE PATH — and this is a data-loss fix, not a tidy-up.
99
- *
100
- * The stored command was written by `shellQuoteArg`, whose POSIX branch
101
- * single-quotes the argument and rewrites every `'` as `'\''`. So on a config
102
- * dir like `/mnt/Bob's SSD/claude` the command contains `Bob'\''s` and a
103
- * `cmd.includes(NOTIFY_PATH)` against the raw path is false — on macOS and
104
- * Linux, for a perfectly ordinary directory name.
105
- *
106
- * What follows is unrecoverable. A mark-less `Stop` entry — the case this
107
- * module exists for — is then not recognised as ours, so it is kept; and
108
- * `anythingStillNamesOurScripts`, which has no mark to fall back on, also says
109
- * no, so the sweep deletes `notify.mjs`. Claude Code throws
110
- * `Cannot find module` at the end of every turn afterwards, forever, with the
111
- * deck already uninstalled.
112
- *
113
- * The last two segments are fixed whatever the prefix is — the install
114
- * directory is always `<config dir>/agent-dag` — and they contain no character
115
- * any quoting rewrites. Case is folded where the filesystem folds it, which is
116
- * the other half: `exec.mjs`'s own `sameCommand` lowercases "because Windows
117
- * paths are", and this comparison did not.
118
- */
119
- const SCRIPT_TAILS = ["agent-dag/notify.mjs", "agent-dag/notify.js"];
120
-
121
- export function namesOurScript(cmd, platform = process.platform) {
122
- if (typeof cmd !== "string" || cmd === "") return false;
123
- // Backslashes become separators only where they ARE separators. On POSIX a
124
- // backslash is an ordinary filename character, and rewriting it there could
125
- // invent a match that the filesystem does not have.
126
- let hay = platform === "win32" ? cmd.replace(/\\/g, "/") : cmd;
127
- if (platform === "win32" || platform === "darwin") hay = hay.toLowerCase();
128
- return SCRIPT_TAILS.some(tail => hay.includes(tail));
129
- }
130
-
131
- /** An entry this deck put there: by its mark, or by the script it runs. */
132
- function isOurs(entry) {
133
- if (entry?.[MARK] === true) return true;
134
- return commandsOf(entry).some(cmd => namesOurScript(cmd));
135
- }
136
-
137
- /** Anywhere in the file — not just `Stop` — that still runs one of our scripts.
138
- * The sweep below asks this before deleting them: a stale sound is survivable
139
- * and a hook pointing at nothing is not. */
140
- function anythingStillNamesOurScripts(settings) {
141
- const groups = settings?.hooks;
142
- if (!groups || typeof groups !== "object") return false;
143
- for (const group of Object.values(groups)) {
144
- if (!Array.isArray(group)) continue;
145
- for (const entry of group) {
146
- if (commandsOf(entry).some(cmd => namesOurScript(cmd))) return true;
147
- }
148
- }
149
- return false;
150
- }
151
-
152
- function parkedError(why) {
153
- const err = new Error(
154
- `${PARKED_PATH} could not be read as JSON (${why}). It holds sound hooks you wrote yourself, so it ` +
155
- `is not being treated as empty and it has not been deleted — repair it or move it aside, and the ` +
156
- `deck will hand them back on its next start.`,
157
- );
158
- err.code = "PARKED_UNREADABLE";
159
- err.parkedPath = PARKED_PATH;
160
- return err;
161
- }
162
-
163
- /**
164
- * The hooks the toggle set aside, or a refusal.
165
- *
166
- * Only ENOENT is genuinely empty. A truncated file — a kill mid-write, a full
167
- * disk — used to read as "nothing was ever parked", and this is the only copy of
168
- * hooks a user wrote by hand: answering "restored: 0" about a file with their
169
- * work in it, and then deleting it, is the one unrecoverable thing in this
170
- * module. A JSON object rather than an array is a file that is not ours.
171
- */
172
- async function readParked() {
173
- let raw;
174
- try {
175
- raw = await readFile(PARKED_PATH, "utf8");
176
- } catch (err) {
177
- if (err?.code === "ENOENT") return [];
178
- throw parkedError(err?.message ?? String(err));
179
- }
180
- let parsed;
181
- try {
182
- parsed = JSON.parse(raw);
183
- } catch (err) {
184
- throw parkedError(err?.message ?? String(err));
185
- }
186
- if (!Array.isArray(parsed)) throw parkedError("top level is not a JSON array");
187
- return parsed;
188
- }
189
-
190
- /** Is there anything of the retired mechanism left on this machine? Three
191
- * `existsSync` calls and a scan of a settings object the caller already read —
192
- * which is the whole cost of retirement on every boot after the first. */
193
- function anythingToRetire(settings) {
194
- const group = settings?.hooks?.[EVENT];
195
- if (Array.isArray(group) && group.some(isOurs)) return true;
196
- if (existsSync(PARKED_PATH)) return true;
197
- return OUR_SCRIPTS.some(existsSync);
198
- }
199
-
200
- /** Nothing here, and nothing for the caller to do afterwards. */
201
- const NOTHING = Object.freeze({ pending: false, changed: false, removed: 0, restored: 0, parkError: null });
202
-
203
- /**
204
- * Retire the sound hook inside a settings object the caller is about to write.
205
- *
206
- * Mutates `settings` and returns what it did; the caller owns the write, so a
207
- * boot that would otherwise change nothing still changes nothing. Call
208
- * `completeSoundHookRetirement` with the result AFTER settings.json is on disk —
209
- * that ordering is the point of the split. Until the new file has landed, the
210
- * old command is still what a live Claude Code session will run at the end of
211
- * its next turn, and deleting the script it names turns a stale sound into a
212
- * "Cannot find module" in the user's session.
213
- *
214
- * Never throws over the parked file. A corrupt ~/.agents-deck must not stop the
215
- * deck from booting, and it must not stop the urgent half either: our entry
216
- * points at a script that is about to be deleted, and taking it out is worth
217
- * doing whether or not the user's own hooks can be handed back in the same pass.
218
- */
219
- export async function retireSoundHookIn(settings) {
220
- if (!anythingToRetire(settings)) return NOTHING;
221
-
222
- let parked = [];
223
- let parkError = null;
224
- try {
225
- parked = await readParked();
226
- } catch (err) {
227
- if (err?.code !== "PARKED_UNREADABLE") throw err;
228
- parkError = { reason: "parked_unreadable", parkedPath: PARKED_PATH, message: err.message };
229
- }
230
-
231
- const group = Array.isArray(settings?.hooks?.[EVENT]) ? settings.hooks[EVENT] : [];
232
- const theirs = group.filter(g => !isOurs(g));
233
- const removed = group.length - theirs.length;
234
-
235
- // Identical entries are not restored twice — see the note on two decks at the
236
- // top. `theirs` is what will be in the file, so a hook already back from an
237
- // earlier attempt (or from the other deck, a millisecond ago) is recognised.
238
- //
239
- // And an entry that is OURS is never restored, wherever it was found. The park
240
- // is not supposed to contain one — the old toggle set aside hooks that looked
241
- // hand-written and skipped its own — but "supposed to" is doing all the work
242
- // in that sentence: the file is years old on some machines, it is synced
243
- // between them, and the unmarked entries naming our installed script are
244
- // exactly the shape a hand-written-hook filter would have swept up. Restoring
245
- // one would put back the hook this whole module exists to remove, pointing at
246
- // a script this release deletes, on the boot that was supposed to repair it.
247
- const seen = new Set(theirs.map(g => JSON.stringify(g)));
248
- const putBack = [];
249
- for (const entry of parked) {
250
- if (isOurs(entry)) continue;
251
- const key = JSON.stringify(entry);
252
- if (seen.has(key)) continue;
253
- seen.add(key);
254
- putBack.push(entry);
255
- }
256
-
257
- const next = [...putBack, ...theirs];
258
- let changed = false;
259
- if (removed > 0 || putBack.length > 0) {
260
- changed = true;
261
- settings.hooks ??= {};
262
- if (next.length) settings.hooks[EVENT] = next;
263
- else delete settings.hooks[EVENT]; // don't leave an empty array behind
264
- }
265
-
266
- return {
267
- pending: true,
268
- changed,
269
- removed,
270
- restored: putBack.length,
271
- // Only when the whole park was accounted for. A read that refused leaves the
272
- // file for repair, and the next boot tries again.
273
- clearPark: parkError === null && existsSync(PARKED_PATH),
274
- parkError,
275
- };
276
- }
277
-
278
- /**
279
- * The half of retirement that must happen after settings.json is on disk.
280
- *
281
- * Deleting the parked file is safe here and only here: its contents are in the
282
- * file Claude Code reads. If the delete fails — a read-only ~/.agents-deck, a
283
- * Windows lock — the next boot reads the same park and restores nothing, because
284
- * the hooks it names are already in the group. That is the whole reason the
285
- * restore de-duplicates.
286
- *
287
- * The scripts go last, and only when nothing in settings.json still names them.
288
- * Retirement removes every entry that does, so the guard is normally already
289
- * satisfied; it exists for the file that puts one under some other event, where
290
- * leaving a stale sound is right and leaving a missing module is not.
291
- */
292
- export async function completeSoundHookRetirement(plan, settings) {
293
- if (!plan?.pending) return { parkCleared: false, scripts: [] };
294
- let parkCleared = false;
295
- if (plan.clearPark) {
296
- parkCleared = await rm(PARKED_PATH, { force: true }).then(() => true, () => false);
297
- }
298
- const scripts = [];
299
- if (!anythingStillNamesOurScripts(settings)) {
300
- for (const path of OUR_SCRIPTS) {
301
- if (!existsSync(path)) continue;
302
- if (await rm(path, { force: true }).then(() => true, () => false)) scripts.push(path);
303
- }
304
- }
305
- return { parkCleared, scripts };
306
- }
307
-
308
- /**
309
- * Retirement for a caller that holds no settings object: `agents-deck
310
- * --uninstall`, which is taking the deck off the machine rather than upgrading
311
- * it, and where there is no hook install to ride along with.
312
- *
313
- * Same three steps in the same order — read, mutate, write, then clean up — so
314
- * there is one description of what retirement is rather than two that can drift.
315
- */
316
- export async function retireSoundHook() {
317
- let settings;
318
- try {
319
- ({ settings } = await readSettingsForWrite(SETTINGS_PATH));
320
- } catch (err) {
321
- if (err?.code !== "SETTINGS_UNREADABLE") throw err;
322
- // A file we cannot parse is a file whose contents we cannot reproduce, and
323
- // this rewrites the whole of it. Left exactly as found, parked hooks still
324
- // parked, and the user told which file and why.
325
- return {
326
- ok: false,
327
- reason: "settings_unreadable",
328
- settingsPath: SETTINGS_PATH,
329
- why: err.why ?? err.message,
330
- message: err?.message ?? String(err),
331
- removed: 0,
332
- restored: 0,
333
- };
334
- }
335
-
336
- const plan = await retireSoundHookIn(settings);
337
- if (plan.changed) await writeFileAtomic(SETTINGS_PATH, JSON.stringify(settings, null, 2) + "\n");
338
- await completeSoundHookRetirement(plan, settings);
339
-
340
- if (plan.parkError) return { ok: false, ...plan.parkError, removed: plan.removed, restored: plan.restored };
341
- return { ok: true, removed: plan.removed, restored: plan.restored };
342
- }
343
-
344
- // Exported so a test can prove it is pointed at a sandbox before it writes
345
- // anything — the real ones are the user's own settings and the user's own hooks.
346
- // The script paths are also the only honest way to ask where the retired script
347
- // ACTUALLY lived: rebuilding `<config dir>/agent-dag/notify.mjs` inside a test
348
- // would keep passing on the day this module started looking somewhere else.
349
- export { SETTINGS_PATH, PARKED_PATH, NOTIFY_PATH, LEGACY_NOTIFY_PATH };
@@ -1,234 +0,0 @@
1
- // Why a second `ccdeck` must not quietly become a second deck.
2
- //
3
- // THE DEFECT. `startServer` is handed `portRange: [4318, 4400]` and answers a
4
- // refused bind on 4317 by taking a random port out of it. That fallback is
5
- // right and stays: 4317 is also the standard OTLP collector port, and on
6
- // Windows `winnat` reserves contiguous TCP blocks for Hyper-V, WSL2 and Docker
7
- // Desktop, so 4317 can be unavailable on a machine with nothing listening on it
8
- // at all. A deck that comes up on 4322 beats a deck that refuses to come up.
9
- //
10
- // What was wrong is that the SAME fallback ran when the thing holding 4317 was
11
- // another ccdeck. Typing `ccdeck` a second time therefore built a second
12
- // everything — supervisor, worker, server, hook registration, LAN identity,
13
- // browser tab — beside a perfectly healthy first one, and neither said a word
14
- // about the other. Twenty of them were counted on one machine.
15
- //
16
- // THE QUESTION IS NOT "IS THE PORT FREE". It is "is one of MY decks already
17
- // running", and the registry answers that without touching a port: every deck
18
- // writes ~/.claude/agent-dag/<pid>.json with its pid, port, token and shape.
19
- //
20
- // A PID IS NOT EVIDENCE, and that is #695's whole lesson. A record left behind
21
- // by a deck that is gone — SIGKILL, an OOM kill, a power cut, a console window
22
- // closed on Windows, none of which run the shutdown that unlinks it — passes a
23
- // signal-0 probe forever once the OS recycles that number, and the port it
24
- // names may by then belong to anything. So the port has to prove itself: the
25
- // record carries the deck's own token in plaintext (mode 0600, same user,
26
- // written there precisely so another process can challenge with it), and
27
- // challengeDeck asks that port to hash the token against a nonce it has never
28
- // seen. A collector, a container, a stranger on a recycled port cannot answer.
29
- // The deck that wrote the file can.
30
- //
31
- // THE DECK FOUND MUST ALSO BE THE DECK WE WOULD HAVE BUILT. Attaching a plain
32
- // `ccdeck` to a deck started with `--workspace ~/proj` would open a canvas
33
- // filtered to a directory the user never mentioned, and attaching to a
34
- // `--no-claude` deck would open one with no accounts panel and no hooks. So the
35
- // shape is compared field for field, and anything that does not match starts
36
- // its own deck exactly as before.
37
- //
38
- // WHAT THIS FILE DOES NOT IMPORT: src/server/index.mjs. That module is the whole
39
- // server, and it arms its timers the moment it is loaded — but this one is read
40
- // on the boot path, and by `ccdeck --stop`, which is a command that talks to a
41
- // deck and exits. Starting a server to ask a server to stop is absurd on its
42
- // face and, on a cold start, slower than the thing it is asking for. The
43
- // handshake and the liveness probe therefore live in deck-probe.mjs, a leaf
44
- // that imports two node builtins; index.mjs takes them from the same place and
45
- // re-exports them, so there is still exactly one spelling in the package.
46
- import { readdir, readFile } from "node:fs/promises";
47
- import { join } from "node:path";
48
- import { claudeConfigDir } from "./claude-dir.mjs";
49
- import { challengeDeck, isProcessAlive } from "./deck-probe.mjs";
50
-
51
- /**
52
- * Where every deck on this machine registers itself.
53
- *
54
- * Still under ~/.claude, and deliberately: deck-home.mjs moved the deck's own
55
- * state out of Claude Code's directory and left exactly two things behind, of
56
- * which this is one. A deck of any version has to be able to list the others,
57
- * and an older deck reads this directory by name — move it and two versions
58
- * running side by side stop seeing each other, which is precisely the blindness
59
- * this module exists to end.
60
- */
61
- export function deckRegistryDir(env = process.env, home = undefined) {
62
- return join(home === undefined ? claudeConfigDir(env) : claudeConfigDir(env, home), "agent-dag");
63
- }
64
-
65
- /**
66
- * The flags that mean "I want a deck of my own", as opposed to "show me the
67
- * deck".
68
- *
69
- * Every one of them changes what the deck IS — which sessions it captures,
70
- * which log it appends to, which of the two CLIs it serves, which port it
71
- * binds. A command line carrying any of them is a request that an existing deck
72
- * cannot satisfy, so it is never answered by attaching to one.
73
- *
74
- * `--no-open` is deliberately absent: it changes what the LAUNCHER does once it
75
- * has a URL, not what the deck is, and it is honoured on the attach path too.
76
- * `--all` is absent because it has been a no-op since it became the default.
77
- */
78
- export const SHAPING_FLAGS = Object.freeze([
79
- "port", "workspace", "scope", "history", "noPersist",
80
- "codex", "noCodex", "claude", "noClaude",
81
- ]);
82
-
83
- /**
84
- * Is this command line one that must build its own deck?
85
- *
86
- * Only a shaping flag, and `--new` — the deliberate escape hatch for the person
87
- * who really does want two.
88
- *
89
- * A TYPO IS NOT ONE, and the first version of this had it the other way round.
90
- * `unknown` and `incomplete` both forced a new deck, on the argument that an
91
- * attach prints no startup report and would swallow the warning that names the
92
- * bad token. The consequence of that argument was `ccdeck --stpo` — a typo in
93
- * the flag that STOPS a deck — building a second one, which is the exact
94
- * outcome this whole module exists to prevent, reached through the guard meant
95
- * to protect it.
96
- *
97
- * The warning was the real requirement, not the new process, so the attach path
98
- * prints it instead: see the call to reportUnknownFlags beside the attach in
99
- * bin/deck.js. Nothing is swallowed, and no misspelling can leave a rival deck
100
- * on a random port behind it.
101
- */
102
- export function asksForOwnDeck(flags = {}) {
103
- if (flags.new === true) return true;
104
- return SHAPING_FLAGS.some((k) => flags[k] !== undefined);
105
- }
106
-
107
- /**
108
- * Would this record's deck serve what we were about to build?
109
- *
110
- * Compared field for field rather than by a version or a heuristic. `claude`
111
- * and `codex` are strict identity against a boolean, so a record written before
112
- * either field existed carries `undefined`, fails, and its deck is left alone —
113
- * which is the old behaviour, reached by construction rather than by a version
114
- * check nobody would remember to update.
115
- */
116
- export function sameShape(record, want = {}) {
117
- if (!record) return false;
118
- return (record.workspace ?? "") === (want.workspace ?? "")
119
- && (record.persist ?? null) === (want.persist ?? null)
120
- && record.codex === (want.codex !== false)
121
- && record.claude === (want.claude !== false);
122
- }
123
-
124
- /** A record complete enough to be worth a challenge. A missing token is a deck
125
- * older than the handshake: it cannot prove anything, so it cannot be attached
126
- * to, and it keeps the behaviour it has always had. */
127
- function usable(d) {
128
- return Boolean(d)
129
- && Number.isInteger(d.pid)
130
- && Number.isInteger(d.port) && d.port > 0 && d.port < 65536
131
- && typeof d.token === "string" && d.token !== "";
132
- }
133
-
134
- /**
135
- * Every registered deck whose pid is still there, ordered.
136
- *
137
- * NOT PROVED — this is the cheap half, a directory listing and a signal-0 each,
138
- * and its three callers want different things from it. `detach.mjs` wants only
139
- * the count, to decide whether deck.log belongs to a deck that is still running
140
- * or to nobody; a round trip per record to answer that would be absurd. Ordered by port with pid breaking the tie, which is the rule
141
- * electWriters uses for the log and is here for the same reason: several decks
142
- * can qualify, and the answer has to be the same one every time it is asked
143
- * rather than whatever `readdir` happened to return first.
144
- *
145
- * A failure to read the directory is "no decks", not an error. This runs on the
146
- * boot path of a program whose job is to start, and there is no reading of that
147
- * directory whose failure is worth refusing to start over.
148
- */
149
- export async function registeredDecks({
150
- dir = deckRegistryDir(),
151
- fs = { readdir, readFile },
152
- self = process.pid,
153
- alive = isProcessAlive,
154
- } = {}) {
155
- let names;
156
- try { names = await fs.readdir(dir); } catch { return []; }
157
- const out = [];
158
- for (const name of names) {
159
- if (!name.endsWith(".json")) continue;
160
- let d;
161
- try { d = JSON.parse(await fs.readFile(join(dir, name), "utf8")); } catch { continue; }
162
- if (!usable(d)) continue;
163
- if (d.pid === self) continue;
164
- if (!alive(d.pid)) continue;
165
- out.push(d);
166
- }
167
- return out.sort((a, b) => a.port - b.port || a.pid - b.pid);
168
- }
169
-
170
- /**
171
- * The deck already serving what this process was about to serve, or null.
172
- *
173
- * Only records that already match the shape are challenged, and the walk stops
174
- * at the first that proves itself. On the ordinary machine that is one loopback
175
- * round trip; on a machine with no deck running it is a directory listing and
176
- * nothing else. That is why this is not `liveDecks().find(…)`: the boot path
177
- * must not pay a round trip per deck to answer a question the first one settles.
178
- */
179
- export async function runningDeck({
180
- want = {},
181
- dir = deckRegistryDir(),
182
- fs = { readdir, readFile },
183
- self = process.pid,
184
- alive = isProcessAlive,
185
- prove = challengeDeck,
186
- } = {}) {
187
- for (const d of await registeredDecks({ dir, fs, self, alive })) {
188
- if (!sameShape(d, want)) continue;
189
- if (await prove(d.port, d.token)) return d;
190
- }
191
- return null;
192
- }
193
-
194
- /**
195
- * Every deck on this machine that answered a challenge, in port order.
196
- *
197
- * The list `--status` prints and the list `--stop` chooses from. Everything is
198
- * challenged here, unlike runningDeck: a list that quietly omitted a deck it
199
- * could not be bothered to ask about would be worse than no list, because the
200
- * whole reason to run `--status` is to find the process you did not know was
201
- * there. The round trips go out together — one deadline for the lot, not one
202
- * after another — since they are independent and each is bounded at 400ms.
203
- */
204
- export async function liveDecks({
205
- dir = deckRegistryDir(),
206
- fs = { readdir, readFile },
207
- self = process.pid,
208
- alive = isProcessAlive,
209
- prove = challengeDeck,
210
- } = {}) {
211
- const all = await registeredDecks({ dir, fs, self, alive });
212
- const proved = await Promise.all(all.map(d => prove(d.port, d.token).then(ok => (ok ? d : null))));
213
- return proved.filter(Boolean);
214
- }
215
-
216
- /**
217
- * What to say about the version of the deck we attached to.
218
- *
219
- * A mismatch does NOT stop the attach, and that is the deliberate half. Someone
220
- * who runs `npx ccdeck@latest` while an older deck is up has asked for the new
221
- * one, but the way to give it to them is not to stand a rival deck on a random
222
- * port beside the old one — that is the failure this whole module removes. So
223
- * they are attached and TOLD, with the one sentence that gets them the version
224
- * they asked for.
225
- *
226
- * Empty when the versions agree, and empty when the running deck is too old to
227
- * report one: "unknown" beside a number is noise, and there is nothing useful
228
- * to do about it either way.
229
- */
230
- export function versionNote(running, ours) {
231
- if (typeof running !== "string" || running === "" || typeof ours !== "string" || ours === "") return "";
232
- if (running === ours) return "";
233
- return `running v${running}, you launched v${ours} — restart it from the deck to upgrade`;
234
- }