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.
- package/README.md +6 -477
- package/package.json +14 -48
- package/shim.js +107 -0
- package/LICENSE +0 -661
- package/LICENSING.md +0 -82
- package/THIRD_PARTY_NOTICES.md +0 -395
- package/bin/agent-dag.js +0 -626
- package/bin/deck.js +0 -1805
- package/dist/web/assets/index-3FWd7g_W.css +0 -1
- package/dist/web/assets/index-BOwtoP02.js +0 -266
- package/dist/web/index.html +0 -49
- package/hook/hook.js +0 -542
- package/release-notes.json +0 -392
- package/src/server/activity.mjs +0 -52
- package/src/server/agent-activity.mjs +0 -522
- package/src/server/args.mjs +0 -183
- package/src/server/auto-update.mjs +0 -79
- package/src/server/block-notify.mjs +0 -173
- package/src/server/boot-deadline.mjs +0 -127
- package/src/server/brand.mjs +0 -16
- package/src/server/browser-history.mjs +0 -497
- package/src/server/browser-presence.mjs +0 -211
- package/src/server/browser-profiles.mjs +0 -279
- package/src/server/browser-react.mjs +0 -284
- package/src/server/browser-watch-store.mjs +0 -350
- package/src/server/browser-watch.mjs +0 -905
- package/src/server/ccusage.mjs +0 -1168
- package/src/server/claude-accounts.mjs +0 -951
- package/src/server/claude-dir.mjs +0 -213
- package/src/server/codex-auth.mjs +0 -388
- package/src/server/codex-dir.mjs +0 -171
- package/src/server/codex-quota.mjs +0 -449
- package/src/server/codex-usage.mjs +0 -512
- package/src/server/cswap-admin.mjs +0 -1562
- package/src/server/cswap-auto.mjs +0 -658
- package/src/server/cswap-install.mjs +0 -641
- package/src/server/deck-home.mjs +0 -243
- package/src/server/deck-prefs.mjs +0 -301
- package/src/server/deck-probe.mjs +0 -111
- package/src/server/detach.mjs +0 -244
- package/src/server/exec.mjs +0 -996
- package/src/server/global-install.mjs +0 -67
- package/src/server/hwmonitor.mjs +0 -56
- package/src/server/index.mjs +0 -6043
- package/src/server/installer.mjs +0 -912
- package/src/server/invoked-as.mjs +0 -144
- package/src/server/lan-about.mjs +0 -119
- package/src/server/lan-engine.mjs +0 -952
- package/src/server/lan-reach.mjs +0 -256
- package/src/server/lan-socket.mjs +0 -682
- package/src/server/lan-sync.mjs +0 -941
- package/src/server/lhm-parse.mjs +0 -91
- package/src/server/log-tail.mjs +0 -139
- package/src/server/log-writer.mjs +0 -322
- package/src/server/login-service.mjs +0 -473
- package/src/server/macmon.mjs +0 -310
- package/src/server/npx.mjs +0 -264
- package/src/server/open-url.mjs +0 -242
- package/src/server/presence.mjs +0 -40
- package/src/server/quota.mjs +0 -792
- package/src/server/relay-guard.mjs +0 -507
- package/src/server/reset-label.mjs +0 -78
- package/src/server/retire-sound-hook.mjs +0 -349
- package/src/server/running-deck.mjs +0 -234
- package/src/server/self-update.mjs +0 -1380
- package/src/server/stop-deck.mjs +0 -171
- package/src/server/supervisor.mjs +0 -392
- package/src/server/system-metrics.mjs +0 -1825
- package/src/server/term.mjs +0 -686
- 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
|
-
}
|