agent-dag 1.44.1 → 1.45.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -1
- package/bin/agent-dag.js +25 -1
- package/bin/deck.js +98 -15
- package/dist/web/assets/{index-XtT5NdJI.css → index-Bdl1LX0-.css} +1 -1
- package/dist/web/assets/index-jXBjwwZC.js +89 -0
- package/dist/web/index.html +2 -2
- package/hook/hook.js +120 -31
- package/package.json +1 -1
- package/src/server/args.mjs +113 -15
- package/src/server/codex-auth.mjs +9 -4
- package/src/server/codex-usage.mjs +65 -7
- package/src/server/cswap-admin.mjs +166 -34
- package/src/server/cswap-auto.mjs +156 -2
- package/src/server/exec.mjs +103 -15
- package/src/server/index.mjs +1793 -163
- package/src/server/installer.mjs +119 -30
- package/src/server/retire-sound-hook.mjs +315 -0
- package/src/server/supervisor.mjs +67 -0
- package/dist/web/assets/index-DBsxIfdM.js +0 -78
- package/hook/notify.mjs +0 -104
- package/src/server/sound-hook.mjs +0 -518
package/src/server/installer.mjs
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
// sessions reach the same server through the rollout watcher instead, so one
|
|
6
6
|
// running server still sees both CLIs. Re-runs are safe; entries are tagged
|
|
7
7
|
// with __agent-dag and de-duped.
|
|
8
|
-
import { readFile, mkdir, unlink, rename, open, stat, chmod } from "node:fs/promises";
|
|
8
|
+
import { readFile, mkdir, unlink, rename, open, stat, chmod, realpath, readlink } from "node:fs/promises";
|
|
9
9
|
import { existsSync } from "node:fs";
|
|
10
10
|
import { join, resolve, dirname } from "node:path";
|
|
11
11
|
import { setTimeout as delay } from "node:timers/promises";
|
|
@@ -265,6 +265,66 @@ async function createTemp(target, { mode = 0o666, attempts = 5 } = {}) {
|
|
|
265
265
|
}
|
|
266
266
|
}
|
|
267
267
|
|
|
268
|
+
// A chain longer than this is a loop, or something no real setup has: stow and
|
|
269
|
+
// chezmoi produce one hop, an encrypted volume two. The number is a bound on the
|
|
270
|
+
// walk below, not a promise about how deep a legitimate link goes.
|
|
271
|
+
const MAX_LINK_HOPS = 8;
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* The file a name is really asking for — the one the links under it end at.
|
|
275
|
+
*
|
|
276
|
+
* A rename replaces the DIRECTORY ENTRY it is handed. Handed a symlink, it
|
|
277
|
+
* deletes the link and leaves an ordinary file where it was, and
|
|
278
|
+
* `~/.claude/settings.json` is a symlink on a great many machines: into a
|
|
279
|
+
* dotfiles repo, a stow or chezmoi target, an encrypted volume. The content
|
|
280
|
+
* survives — we write back what we read — so nothing looks wrong and nothing
|
|
281
|
+
* says anything. The repo copy keeps what it said before, never goes dirty, and
|
|
282
|
+
* from then on the user's edits there reach nobody while every launch rewrites
|
|
283
|
+
* the detached file and widens the gap. This is a file the deck did not create
|
|
284
|
+
* and does not own; quietly cutting it loose from the thing that manages it is
|
|
285
|
+
* worse than failing to write it at all.
|
|
286
|
+
*
|
|
287
|
+
* persistAuth in codex-auth.mjs has resolved for exactly this reason since it
|
|
288
|
+
* was written, on the file that is LESS often linked. This is the same rule at
|
|
289
|
+
* the helper every settings writer in the deck goes through — and the same
|
|
290
|
+
* function, so there is one of it rather than two that can drift.
|
|
291
|
+
*
|
|
292
|
+
* Resolving also decides which filesystem the temp file is staged on, and it has
|
|
293
|
+
* to be the target's. A rename is atomic within one filesystem and fails with
|
|
294
|
+
* EXDEV across two, so staging beside the LINK — a link into a dotfiles repo on
|
|
295
|
+
* a separate volume — is a write that cannot land at all.
|
|
296
|
+
*
|
|
297
|
+
* A DANGLING link is the case realpath alone cannot answer: the target not
|
|
298
|
+
* created yet, the encrypted volume not mounted. Answering it with the raw path
|
|
299
|
+
* is the bug again, because that is precisely when the link gets replaced — so
|
|
300
|
+
* the walk falls back to readlink, which reads a link without needing its target
|
|
301
|
+
* to exist, and follows it the way opening the name for writing would.
|
|
302
|
+
* `printf x > link` creates the target; it does not replace the link. If the
|
|
303
|
+
* target's directory is gone the write then fails, which is the honest answer:
|
|
304
|
+
* the bytes did not reach the file the user's setup points at.
|
|
305
|
+
*
|
|
306
|
+
* The ordinary case — a plain file, no link anywhere — is one realpath and the
|
|
307
|
+
* first return.
|
|
308
|
+
*/
|
|
309
|
+
async function resolveWriteTarget(raw) {
|
|
310
|
+
let at = raw;
|
|
311
|
+
for (let hop = 0; hop < MAX_LINK_HOPS; hop++) {
|
|
312
|
+
// Resolves every link on the path at once, when all of them lead somewhere.
|
|
313
|
+
const real = await realpath(at).catch(() => null);
|
|
314
|
+
if (real !== null) return real;
|
|
315
|
+
const to = await readlink(at).catch(() => null);
|
|
316
|
+
// Not a link, so nothing exists at this name yet and this name is the
|
|
317
|
+
// answer: a first install, or the far end of a chain we have just followed.
|
|
318
|
+
if (to === null) return at;
|
|
319
|
+
at = resolve(dirname(at), to);
|
|
320
|
+
}
|
|
321
|
+
// Only a cycle gets here. Say so rather than pick a link out of it and
|
|
322
|
+
// destroy that one — the OS answers a write through such a name the same way.
|
|
323
|
+
const err = new Error(`too many symbolic links resolving ${raw}`);
|
|
324
|
+
err.code = "ELOOP";
|
|
325
|
+
throw err;
|
|
326
|
+
}
|
|
327
|
+
|
|
268
328
|
/**
|
|
269
329
|
* Replace a file in a single step readers cannot land inside.
|
|
270
330
|
*
|
|
@@ -273,8 +333,13 @@ async function createTemp(target, { mode = 0o666, attempts = 5 } = {}) {
|
|
|
273
333
|
* different ones. It is fsync'd before the rename so that a crash or power loss
|
|
274
334
|
* just after a successful install cannot leave the new directory entry pointing
|
|
275
335
|
* at blocks that were never flushed — the classic file-of-zero-bytes.
|
|
336
|
+
*
|
|
337
|
+
* "Beside the target" means beside the file the name resolves to, not beside the
|
|
338
|
+
* name: see resolveWriteTarget, which is what keeps a symlinked settings.json a
|
|
339
|
+
* symlink.
|
|
276
340
|
*/
|
|
277
|
-
async function writeFileAtomic(
|
|
341
|
+
async function writeFileAtomic(rawTarget, text) {
|
|
342
|
+
const target = await resolveWriteTarget(rawTarget);
|
|
278
343
|
const { tmp, handle } = await createTemp(target);
|
|
279
344
|
try {
|
|
280
345
|
try {
|
|
@@ -366,27 +431,47 @@ export async function installHooks({ provider = "claude" } = {}) {
|
|
|
366
431
|
current.hooks[evt] = cleaned;
|
|
367
432
|
}
|
|
368
433
|
|
|
369
|
-
//
|
|
370
|
-
//
|
|
371
|
-
//
|
|
372
|
-
//
|
|
373
|
-
//
|
|
374
|
-
//
|
|
375
|
-
//
|
|
376
|
-
//
|
|
377
|
-
// still what decides whether settings.json is touched at all.
|
|
434
|
+
// Retiring the finish-sound hook rides in here, on this read and this write,
|
|
435
|
+
// and this is the seam it needs rather than a convenient one. #704 moved the
|
|
436
|
+
// sound into the browser and deleted the script the old `Stop` entry ran, so
|
|
437
|
+
// an install that upgrades a machine which HAS that entry leaves a hook
|
|
438
|
+
// pointing at a file that is no longer in the package — an error at the end of
|
|
439
|
+
// every turn, on a machine that was working before the upgrade. It therefore
|
|
440
|
+
// has to happen without the user asking for it, and a normal boot is the only
|
|
441
|
+
// moment that qualifies.
|
|
378
442
|
//
|
|
379
|
-
//
|
|
380
|
-
//
|
|
381
|
-
//
|
|
382
|
-
//
|
|
383
|
-
//
|
|
384
|
-
|
|
385
|
-
|
|
443
|
+
// Riding along buys the two properties it would otherwise have to invent.
|
|
444
|
+
// There is ONE write of settings.json on the boot that retires, compared
|
|
445
|
+
// against the exact bytes read a few lines up — so a second deck doing the
|
|
446
|
+
// same work at the same time writes the same payload, and every later boot
|
|
447
|
+
// finds nothing to do and changes nothing. And the mutate-then-let-the-caller-
|
|
448
|
+
// write split is what keeps the script deletion after the write: until the new
|
|
449
|
+
// file has landed, a live Claude Code session's next turn still runs the old
|
|
450
|
+
// command.
|
|
451
|
+
//
|
|
452
|
+
// Imported here rather than at the top of the file because retire-sound-hook.mjs
|
|
453
|
+
// imports this module — writeFileAtomic and readSettingsForWrite live here —
|
|
454
|
+
// and a static import would close that into a cycle. Claude only: the entry
|
|
455
|
+
// was one line in Claude Code's settings.json and there was never a Codex one.
|
|
456
|
+
//
|
|
457
|
+
// The equality test is not ceremony. Retirement DELETES two files — the parked
|
|
458
|
+
// hooks and the installed script — at absolute paths it resolved for itself,
|
|
459
|
+
// from claudeConfigDir() and os.homedir(), at its own import. This function
|
|
460
|
+
// writes `cfg.settingsPath`. In the product those are the same settings.json
|
|
461
|
+
// and the paths belong together. When they are not the same file, the two
|
|
462
|
+
// modules are looking at different homes, and acting on that difference means
|
|
463
|
+
// deleting files belonging to a machine this install is not writing to. That
|
|
464
|
+
// is not hypothetical: it happened to the author's own ~/.agents-deck while
|
|
465
|
+
// this very change was being written, from a test whose environment teardown
|
|
466
|
+
// ran a describe too early. Disagreement is a reason to do nothing.
|
|
467
|
+
let retire = { pending: false, changed: false, removed: 0, restored: 0 };
|
|
468
|
+
let completeSoundHookRetirement = null;
|
|
386
469
|
if (provider === "claude") {
|
|
387
|
-
const
|
|
388
|
-
|
|
389
|
-
|
|
470
|
+
const retirement = await import("./retire-sound-hook.mjs");
|
|
471
|
+
if (retirement.SETTINGS_PATH === cfg.settingsPath) {
|
|
472
|
+
completeSoundHookRetirement = retirement.completeSoundHookRetirement;
|
|
473
|
+
retire = await retirement.retireSoundHookIn(current);
|
|
474
|
+
}
|
|
390
475
|
}
|
|
391
476
|
|
|
392
477
|
// Every launch reinstalls, and on all but the first the entries are already
|
|
@@ -397,11 +482,11 @@ export async function installHooks({ provider = "claude" } = {}) {
|
|
|
397
482
|
const next = JSON.stringify(current, null, 2) + "\n";
|
|
398
483
|
const changed = next !== before;
|
|
399
484
|
if (changed) await writeFileAtomic(cfg.settingsPath, next);
|
|
400
|
-
// After the write, never before it: the
|
|
401
|
-
// what a live session's cached command still names until the new entry is
|
|
402
|
-
// disk, and deleting it early turns a stale sound into a missing module.
|
|
403
|
-
if (
|
|
404
|
-
return { settingsPath: cfg.settingsPath, hookPath, events: cfg.events, provider, changed,
|
|
485
|
+
// After the write, never before it: the notify script an older deck installed
|
|
486
|
+
// is what a live session's cached command still names until the new entry is
|
|
487
|
+
// on disk, and deleting it early turns a stale sound into a missing module.
|
|
488
|
+
if (retire.pending) await completeSoundHookRetirement(retire, current);
|
|
489
|
+
return { settingsPath: cfg.settingsPath, hookPath, events: cfg.events, provider, changed, retire };
|
|
405
490
|
}
|
|
406
491
|
|
|
407
492
|
/**
|
|
@@ -420,8 +505,8 @@ export async function installHooks({ provider = "claude" } = {}) {
|
|
|
420
505
|
* it. `--uninstall` printed "no Claude hooks to remove" and exited 0 while all
|
|
421
506
|
* ten `__agent-dag` entries sat in the file, spawning node on every tool call
|
|
422
507
|
* of every session, for a deck the user had been told was gone. The other half
|
|
423
|
-
* of the same command already knew better:
|
|
424
|
-
* readSettingsForWrite and
|
|
508
|
+
* of the same command already knew better: the sound-hook half read through
|
|
509
|
+
* readSettingsForWrite and said so out loud, so one command gave two opposite
|
|
425
510
|
* verdicts about one file and the load-bearing one was the one that lied.
|
|
426
511
|
*
|
|
427
512
|
* So the read is the same read the install does, and for the same reason. A
|
|
@@ -440,7 +525,7 @@ export async function uninstallHooks({ provider = "claude" } = {}) {
|
|
|
440
525
|
({ settings: current } = await readSettingsForWrite(cfg.settingsPath));
|
|
441
526
|
} catch (err) {
|
|
442
527
|
if (err?.code !== "SETTINGS_UNREADABLE") throw err;
|
|
443
|
-
// Same shape
|
|
528
|
+
// Same shape retireSoundHook answers with, so bin/deck.js reports both
|
|
444
529
|
// halves of `--uninstall` the same way instead of one of them inventing a
|
|
445
530
|
// second vocabulary for the identical condition on the identical file.
|
|
446
531
|
return {
|
|
@@ -671,4 +756,8 @@ export { AGENT_DAG_DIR, CLAUDE_DIR, CODEX_DIR };
|
|
|
671
756
|
// the same reason one step lower: codex-auth.mjs needs the collision-free temp
|
|
672
757
|
// name but not writeFileAtomic's mode handling, which carries over the target's
|
|
673
758
|
// mode and so would leave a brand-new auth.json at whatever the umask allows.
|
|
674
|
-
|
|
759
|
+
// resolveWriteTarget goes with them, because auth.json is linked into a dotfiles
|
|
760
|
+
// repo for the same reasons settings.json is, and "never rename onto a link" is
|
|
761
|
+
// one rule: codex-auth.mjs called a realpath of its own before this existed, and
|
|
762
|
+
// two spellings of a rule are two things that can drift.
|
|
763
|
+
export { readSettingsForWrite, writeFileAtomic, installScript, renameWithRetry, createTemp, resolveWriteTarget };
|
|
@@ -0,0 +1,315 @@
|
|
|
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
|
+
/** An entry this deck put there: by its mark, or by the script it runs. */
|
|
98
|
+
function isOurs(entry) {
|
|
99
|
+
if (entry?.[MARK] === true) return true;
|
|
100
|
+
return commandsOf(entry).some(cmd => OUR_SCRIPTS.some(p => cmd.includes(p)));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Anywhere in the file — not just `Stop` — that still runs one of our scripts.
|
|
104
|
+
* The sweep below asks this before deleting them: a stale sound is survivable
|
|
105
|
+
* and a hook pointing at nothing is not. */
|
|
106
|
+
function anythingStillNamesOurScripts(settings) {
|
|
107
|
+
const groups = settings?.hooks;
|
|
108
|
+
if (!groups || typeof groups !== "object") return false;
|
|
109
|
+
for (const group of Object.values(groups)) {
|
|
110
|
+
if (!Array.isArray(group)) continue;
|
|
111
|
+
for (const entry of group) {
|
|
112
|
+
if (commandsOf(entry).some(cmd => OUR_SCRIPTS.some(p => cmd.includes(p)))) return true;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return false;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function parkedError(why) {
|
|
119
|
+
const err = new Error(
|
|
120
|
+
`${PARKED_PATH} could not be read as JSON (${why}). It holds sound hooks you wrote yourself, so it ` +
|
|
121
|
+
`is not being treated as empty and it has not been deleted — repair it or move it aside, and the ` +
|
|
122
|
+
`deck will hand them back on its next start.`,
|
|
123
|
+
);
|
|
124
|
+
err.code = "PARKED_UNREADABLE";
|
|
125
|
+
err.parkedPath = PARKED_PATH;
|
|
126
|
+
return err;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* The hooks the toggle set aside, or a refusal.
|
|
131
|
+
*
|
|
132
|
+
* Only ENOENT is genuinely empty. A truncated file — a kill mid-write, a full
|
|
133
|
+
* disk — used to read as "nothing was ever parked", and this is the only copy of
|
|
134
|
+
* hooks a user wrote by hand: answering "restored: 0" about a file with their
|
|
135
|
+
* work in it, and then deleting it, is the one unrecoverable thing in this
|
|
136
|
+
* module. A JSON object rather than an array is a file that is not ours.
|
|
137
|
+
*/
|
|
138
|
+
async function readParked() {
|
|
139
|
+
let raw;
|
|
140
|
+
try {
|
|
141
|
+
raw = await readFile(PARKED_PATH, "utf8");
|
|
142
|
+
} catch (err) {
|
|
143
|
+
if (err?.code === "ENOENT") return [];
|
|
144
|
+
throw parkedError(err?.message ?? String(err));
|
|
145
|
+
}
|
|
146
|
+
let parsed;
|
|
147
|
+
try {
|
|
148
|
+
parsed = JSON.parse(raw);
|
|
149
|
+
} catch (err) {
|
|
150
|
+
throw parkedError(err?.message ?? String(err));
|
|
151
|
+
}
|
|
152
|
+
if (!Array.isArray(parsed)) throw parkedError("top level is not a JSON array");
|
|
153
|
+
return parsed;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Is there anything of the retired mechanism left on this machine? Three
|
|
157
|
+
* `existsSync` calls and a scan of a settings object the caller already read —
|
|
158
|
+
* which is the whole cost of retirement on every boot after the first. */
|
|
159
|
+
function anythingToRetire(settings) {
|
|
160
|
+
const group = settings?.hooks?.[EVENT];
|
|
161
|
+
if (Array.isArray(group) && group.some(isOurs)) return true;
|
|
162
|
+
if (existsSync(PARKED_PATH)) return true;
|
|
163
|
+
return OUR_SCRIPTS.some(existsSync);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Nothing here, and nothing for the caller to do afterwards. */
|
|
167
|
+
const NOTHING = Object.freeze({ pending: false, changed: false, removed: 0, restored: 0, parkError: null });
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Retire the sound hook inside a settings object the caller is about to write.
|
|
171
|
+
*
|
|
172
|
+
* Mutates `settings` and returns what it did; the caller owns the write, so a
|
|
173
|
+
* boot that would otherwise change nothing still changes nothing. Call
|
|
174
|
+
* `completeSoundHookRetirement` with the result AFTER settings.json is on disk —
|
|
175
|
+
* that ordering is the point of the split. Until the new file has landed, the
|
|
176
|
+
* old command is still what a live Claude Code session will run at the end of
|
|
177
|
+
* its next turn, and deleting the script it names turns a stale sound into a
|
|
178
|
+
* "Cannot find module" in the user's session.
|
|
179
|
+
*
|
|
180
|
+
* Never throws over the parked file. A corrupt ~/.agents-deck must not stop the
|
|
181
|
+
* deck from booting, and it must not stop the urgent half either: our entry
|
|
182
|
+
* points at a script that is about to be deleted, and taking it out is worth
|
|
183
|
+
* doing whether or not the user's own hooks can be handed back in the same pass.
|
|
184
|
+
*/
|
|
185
|
+
export async function retireSoundHookIn(settings) {
|
|
186
|
+
if (!anythingToRetire(settings)) return NOTHING;
|
|
187
|
+
|
|
188
|
+
let parked = [];
|
|
189
|
+
let parkError = null;
|
|
190
|
+
try {
|
|
191
|
+
parked = await readParked();
|
|
192
|
+
} catch (err) {
|
|
193
|
+
if (err?.code !== "PARKED_UNREADABLE") throw err;
|
|
194
|
+
parkError = { reason: "parked_unreadable", parkedPath: PARKED_PATH, message: err.message };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const group = Array.isArray(settings?.hooks?.[EVENT]) ? settings.hooks[EVENT] : [];
|
|
198
|
+
const theirs = group.filter(g => !isOurs(g));
|
|
199
|
+
const removed = group.length - theirs.length;
|
|
200
|
+
|
|
201
|
+
// Identical entries are not restored twice — see the note on two decks at the
|
|
202
|
+
// top. `theirs` is what will be in the file, so a hook already back from an
|
|
203
|
+
// earlier attempt (or from the other deck, a millisecond ago) is recognised.
|
|
204
|
+
//
|
|
205
|
+
// And an entry that is OURS is never restored, wherever it was found. The park
|
|
206
|
+
// is not supposed to contain one — the old toggle set aside hooks that looked
|
|
207
|
+
// hand-written and skipped its own — but "supposed to" is doing all the work
|
|
208
|
+
// in that sentence: the file is years old on some machines, it is synced
|
|
209
|
+
// between them, and the unmarked entries naming our installed script are
|
|
210
|
+
// exactly the shape a hand-written-hook filter would have swept up. Restoring
|
|
211
|
+
// one would put back the hook this whole module exists to remove, pointing at
|
|
212
|
+
// a script this release deletes, on the boot that was supposed to repair it.
|
|
213
|
+
const seen = new Set(theirs.map(g => JSON.stringify(g)));
|
|
214
|
+
const putBack = [];
|
|
215
|
+
for (const entry of parked) {
|
|
216
|
+
if (isOurs(entry)) continue;
|
|
217
|
+
const key = JSON.stringify(entry);
|
|
218
|
+
if (seen.has(key)) continue;
|
|
219
|
+
seen.add(key);
|
|
220
|
+
putBack.push(entry);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const next = [...putBack, ...theirs];
|
|
224
|
+
let changed = false;
|
|
225
|
+
if (removed > 0 || putBack.length > 0) {
|
|
226
|
+
changed = true;
|
|
227
|
+
settings.hooks ??= {};
|
|
228
|
+
if (next.length) settings.hooks[EVENT] = next;
|
|
229
|
+
else delete settings.hooks[EVENT]; // don't leave an empty array behind
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
return {
|
|
233
|
+
pending: true,
|
|
234
|
+
changed,
|
|
235
|
+
removed,
|
|
236
|
+
restored: putBack.length,
|
|
237
|
+
// Only when the whole park was accounted for. A read that refused leaves the
|
|
238
|
+
// file for repair, and the next boot tries again.
|
|
239
|
+
clearPark: parkError === null && existsSync(PARKED_PATH),
|
|
240
|
+
parkError,
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* The half of retirement that must happen after settings.json is on disk.
|
|
246
|
+
*
|
|
247
|
+
* Deleting the parked file is safe here and only here: its contents are in the
|
|
248
|
+
* file Claude Code reads. If the delete fails — a read-only ~/.agents-deck, a
|
|
249
|
+
* Windows lock — the next boot reads the same park and restores nothing, because
|
|
250
|
+
* the hooks it names are already in the group. That is the whole reason the
|
|
251
|
+
* restore de-duplicates.
|
|
252
|
+
*
|
|
253
|
+
* The scripts go last, and only when nothing in settings.json still names them.
|
|
254
|
+
* Retirement removes every entry that does, so the guard is normally already
|
|
255
|
+
* satisfied; it exists for the file that puts one under some other event, where
|
|
256
|
+
* leaving a stale sound is right and leaving a missing module is not.
|
|
257
|
+
*/
|
|
258
|
+
export async function completeSoundHookRetirement(plan, settings) {
|
|
259
|
+
if (!plan?.pending) return { parkCleared: false, scripts: [] };
|
|
260
|
+
let parkCleared = false;
|
|
261
|
+
if (plan.clearPark) {
|
|
262
|
+
parkCleared = await rm(PARKED_PATH, { force: true }).then(() => true, () => false);
|
|
263
|
+
}
|
|
264
|
+
const scripts = [];
|
|
265
|
+
if (!anythingStillNamesOurScripts(settings)) {
|
|
266
|
+
for (const path of OUR_SCRIPTS) {
|
|
267
|
+
if (!existsSync(path)) continue;
|
|
268
|
+
if (await rm(path, { force: true }).then(() => true, () => false)) scripts.push(path);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
return { parkCleared, scripts };
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Retirement for a caller that holds no settings object: `agents-deck
|
|
276
|
+
* --uninstall`, which is taking the deck off the machine rather than upgrading
|
|
277
|
+
* it, and where there is no hook install to ride along with.
|
|
278
|
+
*
|
|
279
|
+
* Same three steps in the same order — read, mutate, write, then clean up — so
|
|
280
|
+
* there is one description of what retirement is rather than two that can drift.
|
|
281
|
+
*/
|
|
282
|
+
export async function retireSoundHook() {
|
|
283
|
+
let settings;
|
|
284
|
+
try {
|
|
285
|
+
({ settings } = await readSettingsForWrite(SETTINGS_PATH));
|
|
286
|
+
} catch (err) {
|
|
287
|
+
if (err?.code !== "SETTINGS_UNREADABLE") throw err;
|
|
288
|
+
// A file we cannot parse is a file whose contents we cannot reproduce, and
|
|
289
|
+
// this rewrites the whole of it. Left exactly as found, parked hooks still
|
|
290
|
+
// parked, and the user told which file and why.
|
|
291
|
+
return {
|
|
292
|
+
ok: false,
|
|
293
|
+
reason: "settings_unreadable",
|
|
294
|
+
settingsPath: SETTINGS_PATH,
|
|
295
|
+
why: err.why ?? err.message,
|
|
296
|
+
message: err?.message ?? String(err),
|
|
297
|
+
removed: 0,
|
|
298
|
+
restored: 0,
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const plan = await retireSoundHookIn(settings);
|
|
303
|
+
if (plan.changed) await writeFileAtomic(SETTINGS_PATH, JSON.stringify(settings, null, 2) + "\n");
|
|
304
|
+
await completeSoundHookRetirement(plan, settings);
|
|
305
|
+
|
|
306
|
+
if (plan.parkError) return { ok: false, ...plan.parkError, removed: plan.removed, restored: plan.restored };
|
|
307
|
+
return { ok: true, removed: plan.removed, restored: plan.restored };
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// Exported so a test can prove it is pointed at a sandbox before it writes
|
|
311
|
+
// anything — the real ones are the user's own settings and the user's own hooks.
|
|
312
|
+
// The script paths are also the only honest way to ask where the retired script
|
|
313
|
+
// ACTUALLY lived: rebuilding `<config dir>/agent-dag/notify.mjs` inside a test
|
|
314
|
+
// would keep passing on the day this module started looking somewhere else.
|
|
315
|
+
export { SETTINGS_PATH, PARKED_PATH, NOTIFY_PATH, LEGACY_NOTIFY_PATH };
|
|
@@ -233,3 +233,70 @@ export function upgradeRefusalText({ reason, waitMs = 0, attempt = 0 } = {}, tar
|
|
|
233
233
|
const left = waitMs >= 60_000 ? `${Math.ceil(waitMs / 60_000)}m` : `${Math.max(1, Math.ceil(waitMs / 1000))}s`;
|
|
234
234
|
return `${what} failed to fetch a moment ago — waiting ${left} before trying again`;
|
|
235
235
|
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Die when the process that started this one does — on all three operating
|
|
239
|
+
* systems (#702).
|
|
240
|
+
*
|
|
241
|
+
* The deck is two processes: bin/agent-dag.js supervises, bin/deck.js serves.
|
|
242
|
+
* The supervisor kills the worker on every path it knows about — a restart, an
|
|
243
|
+
* upgrade, a Ctrl+C, its own exit — and cannot kill it on the one path it never
|
|
244
|
+
* gets to run: being killed itself. SIGKILL cannot be handled, a crash runs no
|
|
245
|
+
* handler, and a `taskkill` without `/T` reaches only the process it names. On
|
|
246
|
+
* POSIX the worker is then re-parented to init and keeps its port, its temp
|
|
247
|
+
* directory and its 40-60 MB of RSS forever. 310 of those were alive on one
|
|
248
|
+
* development machine, the oldest a day and four hours old, every one a worker
|
|
249
|
+
* whose supervisor a test's teardown had SIGKILLed.
|
|
250
|
+
*
|
|
251
|
+
* WHY THE IPC CHANNEL, AND NOT ANY OF THE OBVIOUS ALTERNATIVES. The requirement
|
|
252
|
+
* is one signal meaning "whoever started me is gone", and the usual answers are
|
|
253
|
+
* each missing a platform:
|
|
254
|
+
*
|
|
255
|
+
* • `process.ppid === 1` — POSIX re-parents an orphan to init, so polling ppid
|
|
256
|
+
* works there and answers nothing on Windows, which does not re-parent at
|
|
257
|
+
* all: the ppid goes on naming a pid that no longer exists.
|
|
258
|
+
* • process groups and `kill(-pgid)` — a POSIX concept. Windows job objects
|
|
259
|
+
* are the nearest equivalent and Node exposes none of it.
|
|
260
|
+
* • a SIGTERM the parent sends on its way out — Windows delivers no signals to
|
|
261
|
+
* a Node process, so `process.on("SIGTERM")` there never fires; and a parent
|
|
262
|
+
* that was killed sends nothing anywhere.
|
|
263
|
+
*
|
|
264
|
+
* The channel is the one mechanism that behaves the same everywhere, because
|
|
265
|
+
* Node normalises it: a Unix socketpair on POSIX, a named pipe on Windows, both
|
|
266
|
+
* closed by the kernel when the process holding the other end stops existing,
|
|
267
|
+
* however it stopped. Node turns that close into a single `disconnect` event on
|
|
268
|
+
* the child's own `process`, and it arrives for a SIGKILL, a crash, a
|
|
269
|
+
* `taskkill /F`, a closed console window and an ordinary exit alike. That is the
|
|
270
|
+
* same reasoning the header above gives for Ctrl+C: what differs between
|
|
271
|
+
* platforms is how a process dies, not that this notices.
|
|
272
|
+
*
|
|
273
|
+
* The channel is not created for this. It already carries `{type:"listening"}`
|
|
274
|
+
* and the upgrade handshake; this only says what its closure means.
|
|
275
|
+
*
|
|
276
|
+
* ARMED ONLY WHERE THERE IS A PARENT TO DIE WITH — `process.send` is a function
|
|
277
|
+
* exactly when a channel was given. `node bin/deck.js` run by hand has none, and
|
|
278
|
+
* that is the same question `SUPERVISED` in bin/deck.js already asks before it
|
|
279
|
+
* offers /api/restart.
|
|
280
|
+
*
|
|
281
|
+
* WHAT IT DELIBERATELY DOES NOT COVER: a parent that calls `child.disconnect()`
|
|
282
|
+
* and stays alive would look identical from here. Nothing in this repo does
|
|
283
|
+
* that, and the alternative — an "are you still there" ping — is a second
|
|
284
|
+
* protocol to keep correct in exchange for a case that does not exist.
|
|
285
|
+
*
|
|
286
|
+
* Returns whether a leash was armed, so a caller can state it rather than infer
|
|
287
|
+
* it. `proc` is a parameter for the reason `workerExitAction` takes `stopping`:
|
|
288
|
+
* the behaviour has to be checkable without a second process.
|
|
289
|
+
*/
|
|
290
|
+
export function dieWithParent(stop, proc = process) {
|
|
291
|
+
if (!proc || typeof proc.once !== "function" || typeof proc.send !== "function") return false;
|
|
292
|
+
let done = false;
|
|
293
|
+
proc.once("disconnect", () => {
|
|
294
|
+
if (done) return;
|
|
295
|
+
done = true;
|
|
296
|
+
// A stop that throws must still end the process: the whole point is that
|
|
297
|
+
// nothing is left behind, and there is no parent left to notice a child
|
|
298
|
+
// that failed to leave.
|
|
299
|
+
try { stop(); } catch { proc.exit?.(0); }
|
|
300
|
+
});
|
|
301
|
+
return true;
|
|
302
|
+
}
|