ccdeck 1.42.0 → 1.44.1
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 +208 -12
- package/bin/agent-dag.js +481 -0
- package/bin/deck.js +968 -0
- package/dist/web/assets/index-DBsxIfdM.js +78 -0
- package/dist/web/assets/index-XtT5NdJI.css +1 -0
- package/dist/web/index.html +49 -0
- package/hook/hook.js +407 -0
- package/hook/notify.mjs +104 -0
- package/package.json +42 -9
- package/src/server/args.mjs +58 -0
- package/src/server/brand.mjs +16 -0
- package/src/server/ccusage.mjs +1052 -0
- package/src/server/claude-accounts.mjs +653 -0
- package/src/server/claude-dir.mjs +213 -0
- package/src/server/codex-auth.mjs +383 -0
- package/src/server/codex-dir.mjs +171 -0
- package/src/server/codex-quota.mjs +445 -0
- package/src/server/codex-usage.mjs +353 -0
- package/src/server/cswap-admin.mjs +902 -0
- package/src/server/cswap-auto.mjs +441 -0
- package/src/server/cswap-install.mjs +583 -0
- package/src/server/exec.mjs +850 -0
- package/src/server/index.mjs +3479 -0
- package/src/server/installer.mjs +674 -0
- package/src/server/invoked-as.mjs +144 -0
- package/src/server/log-writer.mjs +283 -0
- package/src/server/npx.mjs +259 -0
- package/src/server/quota.mjs +684 -0
- package/src/server/reset-label.mjs +78 -0
- package/src/server/self-update.mjs +1331 -0
- package/src/server/sound-hook.mjs +518 -0
- package/src/server/supervisor.mjs +235 -0
- package/src/server/system-metrics.mjs +619 -0
- package/src/server/term.mjs +570 -0
- package/src/server/uv-bootstrap.mjs +301 -0
- package/bin/ccdeck.js +0 -194
|
@@ -0,0 +1,674 @@
|
|
|
1
|
+
// Idempotent hook installer. One provider installs, two uninstall:
|
|
2
|
+
// - "claude" → $CLAUDE_CONFIG_DIR/settings.json (Claude Code, ~/.claude by default)
|
|
3
|
+
// - "codex" → $CODEX_HOME/hooks.json (uninstall only — see PROVIDERS)
|
|
4
|
+
// Hooks post to the discovery dir at <claude config dir>/agent-dag/, and Codex
|
|
5
|
+
// sessions reach the same server through the rollout watcher instead, so one
|
|
6
|
+
// running server still sees both CLIs. Re-runs are safe; entries are tagged
|
|
7
|
+
// with __agent-dag and de-duped.
|
|
8
|
+
import { readFile, mkdir, unlink, rename, open, stat, chmod } from "node:fs/promises";
|
|
9
|
+
import { existsSync } from "node:fs";
|
|
10
|
+
import { join, resolve, dirname } from "node:path";
|
|
11
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
12
|
+
import { fileURLToPath } from "node:url";
|
|
13
|
+
import { claudeConfigDir } from "./claude-dir.mjs";
|
|
14
|
+
import { CODEX_HOME } from "./codex-dir.mjs";
|
|
15
|
+
import { shellQuoteArg } from "./exec.mjs";
|
|
16
|
+
import { PRODUCT } from "./brand.mjs";
|
|
17
|
+
|
|
18
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
19
|
+
const PKG_ROOT = resolve(__dirname, "..", "..");
|
|
20
|
+
|
|
21
|
+
// Honours CLAUDE_CONFIG_DIR, exactly as CODEX_DIR honours CODEX_HOME below.
|
|
22
|
+
// Without it the hooks land in a settings.json Claude Code never opens.
|
|
23
|
+
const CLAUDE_DIR = claudeConfigDir();
|
|
24
|
+
// Both directories now come from the module that owns the rule rather than from
|
|
25
|
+
// a copy of it here — claude-dir.mjs and codex-dir.mjs. The local name stays
|
|
26
|
+
// because CODEX_DIR is what the rest of this file and its tests call it, and it
|
|
27
|
+
// says what the value is FOR here: the directory hooks.json is taken out of.
|
|
28
|
+
const CODEX_DIR = CODEX_HOME;
|
|
29
|
+
|
|
30
|
+
// Single shared discovery dir — both providers' hook scripts post here so one
|
|
31
|
+
// running agent-dag server can match either ecosystem's events. It follows the
|
|
32
|
+
// Claude config dir, so hook/hook.js has to resolve that dir the same way: it
|
|
33
|
+
// reads what this writes, and a disagreement means the hooks find no server.
|
|
34
|
+
const AGENT_DAG_DIR = join(CLAUDE_DIR, "agent-dag");
|
|
35
|
+
|
|
36
|
+
const CLAUDE_EVENTS = [
|
|
37
|
+
"SessionStart",
|
|
38
|
+
"UserPromptSubmit",
|
|
39
|
+
"PreToolUse",
|
|
40
|
+
"PostToolUse",
|
|
41
|
+
"PostToolUseFailure",
|
|
42
|
+
"SubagentStart",
|
|
43
|
+
"SubagentStop",
|
|
44
|
+
"Stop",
|
|
45
|
+
"SessionEnd",
|
|
46
|
+
"Notification",
|
|
47
|
+
];
|
|
48
|
+
|
|
49
|
+
const PROVIDERS = {
|
|
50
|
+
claude: {
|
|
51
|
+
settingsPath: join(CLAUDE_DIR, "settings.json"),
|
|
52
|
+
hookInstallDir: join(CLAUDE_DIR, "agent-dag"),
|
|
53
|
+
events: CLAUDE_EVENTS,
|
|
54
|
+
ensureDir: CLAUDE_DIR,
|
|
55
|
+
},
|
|
56
|
+
// Uninstall-only. Codex hooks do not fire reliably on Windows, so the deck
|
|
57
|
+
// stopped installing them and reads Codex's rollout files instead — nothing
|
|
58
|
+
// calls installHooks with this provider any more. The entry stays because a
|
|
59
|
+
// machine that ran an older deck still has our forwarders in hooks.json, and
|
|
60
|
+
// uninstallHooks needs the path to take them back out. It reads nothing else:
|
|
61
|
+
// it walks the events already in the file rather than a list of our own.
|
|
62
|
+
codex: {
|
|
63
|
+
settingsPath: join(CODEX_DIR, "hooks.json"),
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const MARK_KEY = "__agent-dag";
|
|
68
|
+
// Legacy marks from earlier names — purged on every install/uninstall so
|
|
69
|
+
// duplicate forwarders don't pile up when the project gets renamed.
|
|
70
|
+
const LEGACY_MARKS = ["__ccgraph", "__agent-flow"];
|
|
71
|
+
const LEGACY_DIRS = ["ccgraph", "agent-flow", "agent-dag"];
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* The `command` string Claude Code stores for our forwarder, and runs THROUGH A
|
|
75
|
+
* SHELL on every tool call.
|
|
76
|
+
*
|
|
77
|
+
* The settings.json hook format is a string, not an argv, so this is one of the
|
|
78
|
+
* two places in the codebase that has to build a shell command line by hand —
|
|
79
|
+
* see shellQuoteArg, which is where the escaping rules and their one Windows
|
|
80
|
+
* residual are written down.
|
|
81
|
+
*
|
|
82
|
+
* It used to wrap both paths in double quotes, which on POSIX escapes nothing:
|
|
83
|
+
* `$(…)`, a backtick and `\` are all still live inside them. Both paths come
|
|
84
|
+
* from outside — `installedHookPath` is built from $CLAUDE_CONFIG_DIR (resolved,
|
|
85
|
+
* never validated) or homedir(), and `node` is process.execPath — so a config
|
|
86
|
+
* dir called `/tmp/a$(id)b` was shell code, written into the user's own settings
|
|
87
|
+
* file and executed on every hook fire for as long as it stayed there. The
|
|
88
|
+
* quieter half of the same bug cost nothing but the feature: an ordinary `$` in
|
|
89
|
+
* a path expanded to nothing, the hook pointed at a file that was not there, and
|
|
90
|
+
* hooks stopped firing with no error to explain it.
|
|
91
|
+
*
|
|
92
|
+
* `provider` is a key of PROVIDERS — "claude" or "codex", never anything a
|
|
93
|
+
* caller chose — and is quoted anyway, because that is not a property worth
|
|
94
|
+
* re-deriving at every reading.
|
|
95
|
+
*
|
|
96
|
+
* Exported, with the node path AND the platform injectable, so the escaping can
|
|
97
|
+
* be checked against a path the test names rather than against whatever ran the
|
|
98
|
+
* suite — and against the rule of a platform that suite is not running on. The
|
|
99
|
+
* two rules are genuinely different (POSIX single quotes, cmd.exe doubled
|
|
100
|
+
* double quotes), so without the second parameter the only assertion a test can
|
|
101
|
+
* make is the one its own OS happens to produce, which is how this went five
|
|
102
|
+
* releases with the Windows half of it never once executed.
|
|
103
|
+
*/
|
|
104
|
+
export function hookCommand(installedHookPath, provider, node = process.execPath,
|
|
105
|
+
platform = process.platform) {
|
|
106
|
+
const q = (s) => shellQuoteArg(s, platform);
|
|
107
|
+
return `${q(node)} ${q(installedHookPath)} --provider ${q(provider)}`;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function isOurEntry(g) {
|
|
111
|
+
if (!g || typeof g !== "object") return false;
|
|
112
|
+
if (g[MARK_KEY] === true) return true;
|
|
113
|
+
for (const k of LEGACY_MARKS) if (g[k] === true) return true;
|
|
114
|
+
const cmds = Array.isArray(g.hooks) ? g.hooks : [];
|
|
115
|
+
for (const h of cmds) {
|
|
116
|
+
const c = typeof h?.command === "string" ? h.command : "";
|
|
117
|
+
for (const dir of LEGACY_DIRS) {
|
|
118
|
+
if (c.includes(`.claude/${dir}/hook.js`) || c.includes(`.claude\\${dir}\\hook.js`)) return true;
|
|
119
|
+
if (c.includes(`.codex/${dir}/hook.js`) || c.includes(`.codex\\${dir}\\hook.js`)) return true;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return false;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async function ensureDir(p) {
|
|
126
|
+
if (!existsSync(p)) await mkdir(p, { recursive: true });
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Notepad and PowerShell's Set-Content write UTF-8 with a byte-order mark, and
|
|
130
|
+
// JSON.parse throws on it when the file is read as utf8. A BOM is not damage —
|
|
131
|
+
// the JSON behind it is fine — so it never gets to look like a corrupt file.
|
|
132
|
+
function stripBom(text) {
|
|
133
|
+
return text.charCodeAt(0) === 0xfeff ? text.slice(1) : text;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function unreadableSettings(p, why) {
|
|
137
|
+
const err = new Error(
|
|
138
|
+
`${p} could not be read as JSON (${why}). Refusing to overwrite it — ` +
|
|
139
|
+
`fix the file or move it aside, then run ${PRODUCT} again.`,
|
|
140
|
+
);
|
|
141
|
+
err.code = "SETTINGS_UNREADABLE";
|
|
142
|
+
err.settingsPath = p;
|
|
143
|
+
// The bare reason, without the path and without the remedy sentence, so a
|
|
144
|
+
// caller that wants to phrase its own advice — `--uninstall` does; "run
|
|
145
|
+
// ccdeck again" is the wrong instruction there — does not have to take this
|
|
146
|
+
// message apart with a regex to get at the only part it cannot re-derive.
|
|
147
|
+
err.why = why;
|
|
148
|
+
return err;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Read settings we are about to rewrite. Only ENOENT means "nothing there yet";
|
|
153
|
+
* every other failure is a file whose contents we cannot reproduce — a stray
|
|
154
|
+
* comma, a half-written file from another process, a permission error — and
|
|
155
|
+
* writing our hooks over it would destroy every setting the user has. So the
|
|
156
|
+
* install refuses instead, loudly, and leaves the file exactly as it found it.
|
|
157
|
+
*/
|
|
158
|
+
async function readSettingsForWrite(p) {
|
|
159
|
+
let raw;
|
|
160
|
+
try {
|
|
161
|
+
raw = await readFile(p, "utf8");
|
|
162
|
+
} catch (err) {
|
|
163
|
+
if (err?.code === "ENOENT") return { settings: {}, raw: null };
|
|
164
|
+
throw unreadableSettings(p, err?.message ?? String(err));
|
|
165
|
+
}
|
|
166
|
+
let parsed;
|
|
167
|
+
try {
|
|
168
|
+
parsed = JSON.parse(stripBom(raw));
|
|
169
|
+
} catch (err) {
|
|
170
|
+
throw unreadableSettings(p, err?.message ?? String(err));
|
|
171
|
+
}
|
|
172
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
173
|
+
throw unreadableSettings(p, "top level is not a JSON object");
|
|
174
|
+
}
|
|
175
|
+
return { settings: parsed, raw };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Rename over an open file is the one thing Windows does differently here.
|
|
179
|
+
// libuv's rename is one MoveFileExW with MOVEFILE_REPLACE_EXISTING and nothing
|
|
180
|
+
// else — no retry, no MOVEFILE_COPY_ALLOWED — and MoveFileEx honours share
|
|
181
|
+
// modes: it fails outright while ANY other handle is open on the source or the
|
|
182
|
+
// target without FILE_SHARE_DELETE. A virus scanner or the search indexer opens
|
|
183
|
+
// files the instant they are written, so the target is briefly untouchable on a
|
|
184
|
+
// perfectly healthy machine. Node has declined to paper over this (nodejs/node
|
|
185
|
+
// #29481, closed wontfix: "not something that's really under Node's or libuv's
|
|
186
|
+
// control"), and libuv reverted its own four-attempt ladder for the same
|
|
187
|
+
// reason. So the policy lives here, where the stakes are known.
|
|
188
|
+
//
|
|
189
|
+
// The ladder is 10 attempts over ~1.4s, and the second number is the one that
|
|
190
|
+
// matters. It used to be 5 over 200ms, which is comfortably enough for the
|
|
191
|
+
// indexer and not enough for a scanner: the argument on libuv#2098 for
|
|
192
|
+
// reverting their retry was in part that an AV hold can outlast 2s, and 200ms
|
|
193
|
+
// of patience on a hold like that is the same as none.
|
|
194
|
+
//
|
|
195
|
+
// What that thinness cost is not an install. codex-auth.mjs stages a REFRESH
|
|
196
|
+
// TOKEN through this call, the old one is spent server-side by the time it runs,
|
|
197
|
+
// and a rename that gives up too early destroys the only copy of the new
|
|
198
|
+
// credential — the deck reports refresh_rejected and the user has to run
|
|
199
|
+
// `codex login` again. Trading a second of latency against that is not close.
|
|
200
|
+
// The comparison points: steno retries a rename 10 times at 100ms, npm's
|
|
201
|
+
// bin-links 5 times at 500ms exponential, and write-file-atomic does not retry
|
|
202
|
+
// at all (npm/write-file-atomic#227).
|
|
203
|
+
//
|
|
204
|
+
// POSIX never hits this path, and a genuinely permanent permission error costs
|
|
205
|
+
// that second and a half once, on a path that was already failing.
|
|
206
|
+
const RENAME_RETRY_CODES = new Set(["EPERM", "EACCES", "EBUSY"]);
|
|
207
|
+
|
|
208
|
+
async function renameWithRetry(from, to, attempts = 10) {
|
|
209
|
+
for (let attempt = 1; ; attempt++) {
|
|
210
|
+
try {
|
|
211
|
+
await rename(from, to);
|
|
212
|
+
return;
|
|
213
|
+
} catch (err) {
|
|
214
|
+
if (attempt >= attempts || !RENAME_RETRY_CODES.has(err?.code)) throw err;
|
|
215
|
+
// Linear: 30, 60, 90 … 270ms, ~1.4s across the nine waits. Linear rather
|
|
216
|
+
// than exponential because the holds this exists for are short and
|
|
217
|
+
// frequent, and a doubling ladder spends its whole budget on the last
|
|
218
|
+
// two waits.
|
|
219
|
+
await delay(30 * attempt);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Counts writes, not processes. The pid alone gave every call in one deck the
|
|
225
|
+
// same temp path, and there is more than one writer in a deck now: the sound
|
|
226
|
+
// toggle rewrites settings.json from a request handler, so two clients toggling
|
|
227
|
+
// within the same few milliseconds both opened that one path with O_TRUNC and
|
|
228
|
+
// both wrote their own JSON at offset zero. What got renamed over settings.json
|
|
229
|
+
// was the shorter payload with the tail of the longer one still behind it —
|
|
230
|
+
// unparseable, and readSettingsForWrite turns unparseable into a permanent
|
|
231
|
+
// SETTINGS_UNREADABLE refusal for every later toggle and install. The loser then
|
|
232
|
+
// found its temp file already renamed away and threw ENOENT on top of it.
|
|
233
|
+
let tmpSeq = 0;
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Create the temp file for one write, never sharing it with another writer.
|
|
237
|
+
*
|
|
238
|
+
* "wx" is the point of it: a name that already exists is an error here rather
|
|
239
|
+
* than a silently truncated file two writers are both filling in. The only way
|
|
240
|
+
* to meet a taken name is a deck killed between the write and the rename whose
|
|
241
|
+
* pid the OS has since handed out again — that writer is gone by definition, so
|
|
242
|
+
* the leftover is deleted and the next counter tried. Which is also the whole
|
|
243
|
+
* story on litter: the names come from a small space, this pid crossed with the
|
|
244
|
+
* first few counters, because a deck writes these files a handful of times per
|
|
245
|
+
* run. Later runs walk the same names and sweep what they find instead of
|
|
246
|
+
* piling fresh ones beside it. Digits and hyphens only — a legal filename
|
|
247
|
+
* everywhere, Windows included.
|
|
248
|
+
*
|
|
249
|
+
* `mode` is the mode the file is created with, which matters when the bytes are
|
|
250
|
+
* secret: codex-auth stages a rotated Codex refresh token here, and a temp file
|
|
251
|
+
* that starts at the umask default is readable by every other account on the
|
|
252
|
+
* box for as long as the write takes, whatever chmod follows it. O_EXCL is what
|
|
253
|
+
* makes the mode binding — a create honours it only when it is the call that
|
|
254
|
+
* makes the file, so adopting a leftover would keep the leftover's permissions.
|
|
255
|
+
*/
|
|
256
|
+
async function createTemp(target, { mode = 0o666, attempts = 5 } = {}) {
|
|
257
|
+
for (let attempt = 1; ; attempt++) {
|
|
258
|
+
const tmp = `${target}.agent-dag-${process.pid}-${tmpSeq++}.tmp`;
|
|
259
|
+
try {
|
|
260
|
+
return { tmp, handle: await open(tmp, "wx", mode) };
|
|
261
|
+
} catch (err) {
|
|
262
|
+
if (attempt >= attempts || err?.code !== "EEXIST") throw err;
|
|
263
|
+
await unlink(tmp).catch(() => {});
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Replace a file in a single step readers cannot land inside.
|
|
270
|
+
*
|
|
271
|
+
* The temp file is created beside the target rather than in $TMPDIR, because
|
|
272
|
+
* rename is only atomic within one filesystem and the two are routinely on
|
|
273
|
+
* different ones. It is fsync'd before the rename so that a crash or power loss
|
|
274
|
+
* just after a successful install cannot leave the new directory entry pointing
|
|
275
|
+
* at blocks that were never flushed — the classic file-of-zero-bytes.
|
|
276
|
+
*/
|
|
277
|
+
async function writeFileAtomic(target, text) {
|
|
278
|
+
const { tmp, handle } = await createTemp(target);
|
|
279
|
+
try {
|
|
280
|
+
try {
|
|
281
|
+
await handle.writeFile(text, "utf8");
|
|
282
|
+
await handle.sync();
|
|
283
|
+
} finally {
|
|
284
|
+
await handle.close();
|
|
285
|
+
}
|
|
286
|
+
// A rename creates a fresh directory entry, so the old file's mode does not
|
|
287
|
+
// come with it. Carry it over — a settings.json the user chmod'ed to 600 has
|
|
288
|
+
// to stay 600. No-op on Windows, where chmod only toggles the read-only bit.
|
|
289
|
+
const mode = await stat(target).then(s => s.mode, () => null);
|
|
290
|
+
if (mode !== null) await chmod(tmp, mode).catch(() => {});
|
|
291
|
+
await renameWithRetry(tmp, target);
|
|
292
|
+
} catch (err) {
|
|
293
|
+
// Cleanup covers the write and the fsync as well as the rename: a full disk
|
|
294
|
+
// used to leave the half-written temp file sitting beside the target.
|
|
295
|
+
await unlink(tmp).catch(() => {});
|
|
296
|
+
throw err;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Install one of the packaged hook scripts without ever exposing a partial one.
|
|
302
|
+
*
|
|
303
|
+
* copyFile truncates the destination and then fills it, and the destination here
|
|
304
|
+
* is a script every live Claude Code session runs on each tool call. Starting the
|
|
305
|
+
* deck while sessions are open — the normal way this is used — puts a hook
|
|
306
|
+
* invocation inside that window sooner or later, and what it executes is an
|
|
307
|
+
* empty or half-written program: a dropped event at best, a SyntaxError in the
|
|
308
|
+
* user's session at worst. Renaming a finished copy over the name closes it, so
|
|
309
|
+
* a session opens either the old script or the new one and both are whole.
|
|
310
|
+
*
|
|
311
|
+
* Re-installs are the common case and almost always produce the same bytes, so
|
|
312
|
+
* identical content skips the write and the file is not replaced at all.
|
|
313
|
+
*/
|
|
314
|
+
async function installScript(src, dst) {
|
|
315
|
+
const text = await readFile(src, "utf8");
|
|
316
|
+
const current = await readFile(dst, "utf8").catch(() => null);
|
|
317
|
+
if (current === text) return false;
|
|
318
|
+
await writeFileAtomic(dst, text);
|
|
319
|
+
return true;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
async function installHookScript(installDir) {
|
|
323
|
+
await ensureDir(installDir);
|
|
324
|
+
const src = join(PKG_ROOT, "hook", "hook.js");
|
|
325
|
+
const dst = join(installDir, "hook.js");
|
|
326
|
+
await installScript(src, dst);
|
|
327
|
+
return dst;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function buildHookEntry(command) {
|
|
331
|
+
return {
|
|
332
|
+
[MARK_KEY]: true,
|
|
333
|
+
hooks: [{ type: "command", command, timeout: 2 }],
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function dedupeOurEntries(group) {
|
|
338
|
+
if (!Array.isArray(group)) return [];
|
|
339
|
+
return group.filter(g => !isOurEntry(g));
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/** Install hooks for a single provider. Returns {settingsPath, hookPath, events, changed}. */
|
|
343
|
+
export async function installHooks({ provider = "claude" } = {}) {
|
|
344
|
+
const cfg = PROVIDERS[provider];
|
|
345
|
+
if (!cfg) throw new Error(`unknown provider: ${provider}`);
|
|
346
|
+
// An uninstall-only provider has no event list. Saying so beats the
|
|
347
|
+
// TypeError that installing an undefined list would otherwise raise several
|
|
348
|
+
// frames deep, after the hook script had already been written to disk.
|
|
349
|
+
if (!cfg.events) throw new Error(`provider ${provider} is uninstall-only: hooks are not installed for it`);
|
|
350
|
+
|
|
351
|
+
// Read before writing anything, so a settings file we cannot parse aborts
|
|
352
|
+
// the install without leaving half of it behind.
|
|
353
|
+
const { settings: current, raw: before } = await readSettingsForWrite(cfg.settingsPath);
|
|
354
|
+
|
|
355
|
+
const hookPath = await installHookScript(cfg.hookInstallDir);
|
|
356
|
+
const command = hookCommand(hookPath, provider);
|
|
357
|
+
await ensureDir(cfg.ensureDir);
|
|
358
|
+
// Discovery dir is shared across providers — always make sure it exists.
|
|
359
|
+
await ensureDir(AGENT_DAG_DIR);
|
|
360
|
+
|
|
361
|
+
current.hooks = current.hooks ?? {};
|
|
362
|
+
|
|
363
|
+
for (const evt of cfg.events) {
|
|
364
|
+
const cleaned = dedupeOurEntries(current.hooks[evt]);
|
|
365
|
+
cleaned.push(buildHookEntry(command));
|
|
366
|
+
current.hooks[evt] = cleaned;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// The finish sound is the deck's second installed script, and until this line
|
|
370
|
+
// it was the only one nothing ever re-installed. dedupeOurEntries does not
|
|
371
|
+
// touch it — isOurEntry knows `__agent-dag` and the entry is marked
|
|
372
|
+
// `__agent-dag-sound` — so the loop above carried a stale entry straight
|
|
373
|
+
// through, and nothing anywhere looked at the file that entry names. See
|
|
374
|
+
// reassertSoundHook: it re-asserts the script only where our Stop entry is
|
|
375
|
+
// already present, so a user who turned the sound off does not get it back,
|
|
376
|
+
// and it mutates `current` rather than writing, so the comparison below is
|
|
377
|
+
// still what decides whether settings.json is touched at all.
|
|
378
|
+
//
|
|
379
|
+
// Imported here rather than at the top of the file because sound-hook.mjs
|
|
380
|
+
// imports this module — installScript, writeFileAtomic and readSettingsForWrite
|
|
381
|
+
// all live here — and a static import would close that into a cycle. Claude
|
|
382
|
+
// only: the sound entry is one line in Claude Code's settings.json and there
|
|
383
|
+
// is no Codex equivalent.
|
|
384
|
+
let sound = { present: false };
|
|
385
|
+
let sweepLegacySoundScript = null;
|
|
386
|
+
if (provider === "claude") {
|
|
387
|
+
const soundHook = await import("./sound-hook.mjs");
|
|
388
|
+
sweepLegacySoundScript = soundHook.sweepLegacySoundScript;
|
|
389
|
+
sound = await soundHook.reassertSoundHook(current);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// Every launch reinstalls, and on all but the first the entries are already
|
|
393
|
+
// there and identical. Writing anyway is pure downside: it is one more chance
|
|
394
|
+
// to be interrupted mid-write, and one more window in which a change Claude
|
|
395
|
+
// Code made to the file between our read and our write gets discarded. So
|
|
396
|
+
// compare against the exact bytes we read and, when they match, do nothing.
|
|
397
|
+
const next = JSON.stringify(current, null, 2) + "\n";
|
|
398
|
+
const changed = next !== before;
|
|
399
|
+
if (changed) await writeFileAtomic(cfg.settingsPath, next);
|
|
400
|
+
// After the write, never before it: the `notify.js` an older deck installed is
|
|
401
|
+
// what a live session's cached command still names until the new entry is on
|
|
402
|
+
// disk, and deleting it early turns a stale sound into a missing module.
|
|
403
|
+
if (sound.present) await sweepLegacySoundScript();
|
|
404
|
+
return { settingsPath: cfg.settingsPath, hookPath, events: cfg.events, provider, changed, sound };
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* Take our forwarders back out of one provider's settings file.
|
|
409
|
+
*
|
|
410
|
+
* Returns `{ok: true, changed}` when the file was read — `changed` says whether
|
|
411
|
+
* anything of ours was in it — and `{ok: false, reason: "settings_unreadable"}`
|
|
412
|
+
* when it was not. Callers must look at `ok` FIRST: `changed: false` on a
|
|
413
|
+
* refusal is the literal truth about the disk and a lie about the question
|
|
414
|
+
* being asked, because the hooks are still in there.
|
|
415
|
+
*
|
|
416
|
+
* That conflation is what this used to ship. The read was readJsonSafe, which
|
|
417
|
+
* turned every parse and IO failure into `null`, so a settings.json with one
|
|
418
|
+
* stray comma — the exact file readSettingsForWrite was written to protect —
|
|
419
|
+
* came back indistinguishable from a clean machine with none of our hooks in
|
|
420
|
+
* it. `--uninstall` printed "no Claude hooks to remove" and exited 0 while all
|
|
421
|
+
* ten `__agent-dag` entries sat in the file, spawning node on every tool call
|
|
422
|
+
* of every session, for a deck the user had been told was gone. The other half
|
|
423
|
+
* of the same command already knew better: uninstallSoundHook reads through
|
|
424
|
+
* readSettingsForWrite and says so out loud, so one command gave two opposite
|
|
425
|
+
* verdicts about one file and the load-bearing one was the one that lied.
|
|
426
|
+
*
|
|
427
|
+
* So the read is the same read the install does, and for the same reason. A
|
|
428
|
+
* file we cannot parse is a file whose contents we cannot reproduce, and this
|
|
429
|
+
* function rewrites the whole thing — every permission, env var, model pin and
|
|
430
|
+
* hand-written hook in it. Refusing leaves it byte for byte as it was found and
|
|
431
|
+
* hands the user something they can act on; guessing would either destroy it or
|
|
432
|
+
* quietly do nothing. Only ENOENT is genuinely empty, and readSettingsForWrite
|
|
433
|
+
* already answers that with `{}`, which falls through to `changed: false`.
|
|
434
|
+
*/
|
|
435
|
+
export async function uninstallHooks({ provider = "claude" } = {}) {
|
|
436
|
+
const cfg = PROVIDERS[provider];
|
|
437
|
+
if (!cfg) throw new Error(`unknown provider: ${provider}`);
|
|
438
|
+
let current;
|
|
439
|
+
try {
|
|
440
|
+
({ settings: current } = await readSettingsForWrite(cfg.settingsPath));
|
|
441
|
+
} catch (err) {
|
|
442
|
+
if (err?.code !== "SETTINGS_UNREADABLE") throw err;
|
|
443
|
+
// Same shape uninstallSoundHook answers with, so bin/deck.js reports both
|
|
444
|
+
// halves of `--uninstall` the same way instead of one of them inventing a
|
|
445
|
+
// second vocabulary for the identical condition on the identical file.
|
|
446
|
+
return {
|
|
447
|
+
ok: false,
|
|
448
|
+
reason: "settings_unreadable",
|
|
449
|
+
changed: false,
|
|
450
|
+
provider,
|
|
451
|
+
settingsPath: cfg.settingsPath,
|
|
452
|
+
why: err.why ?? err.message,
|
|
453
|
+
message: err.message,
|
|
454
|
+
};
|
|
455
|
+
}
|
|
456
|
+
if (!current?.hooks) return { ok: true, changed: false, provider, settingsPath: cfg.settingsPath };
|
|
457
|
+
let changed = false;
|
|
458
|
+
for (const evt of Object.keys(current.hooks)) {
|
|
459
|
+
const cleaned = dedupeOurEntries(current.hooks[evt]);
|
|
460
|
+
if (cleaned.length !== (current.hooks[evt]?.length ?? 0)) changed = true;
|
|
461
|
+
if (cleaned.length === 0) delete current.hooks[evt];
|
|
462
|
+
else current.hooks[evt] = cleaned;
|
|
463
|
+
}
|
|
464
|
+
if (changed) await writeFileAtomic(cfg.settingsPath, JSON.stringify(current, null, 2) + "\n");
|
|
465
|
+
return { ok: true, changed, provider, settingsPath: cfg.settingsPath };
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/** True when ~/.codex/ exists — the CLI's default answer to whether the Codex
|
|
469
|
+
* rollout watcher is worth starting, and whether there are hooks to remove. */
|
|
470
|
+
export function hasCodexInstalled() {
|
|
471
|
+
return existsSync(CODEX_DIR);
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/**
|
|
475
|
+
* The events log as the record spells it: an absolute path, or null when this
|
|
476
|
+
* deck writes none. Shared by the writer and by ensureDiscovery's comparison,
|
|
477
|
+
* so a file this process wrote can never read back as somebody else's.
|
|
478
|
+
*/
|
|
479
|
+
function persistField(persist) {
|
|
480
|
+
return typeof persist === "string" && persist !== "" ? persist : null;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
// Every deck's token lives in this directory, and writeFileAtomic's temp file is
|
|
484
|
+
// created beside its target with whatever the umask allows — 0644 on most
|
|
485
|
+
// machines — so for the moment before the rename the token would sit in a
|
|
486
|
+
// world-readable file. 0700 on the directory closes that window from the outside:
|
|
487
|
+
// another user cannot traverse into it whatever the mode of a file inside says.
|
|
488
|
+
// The dir usually predates this code, so the mode is re-asserted rather than only
|
|
489
|
+
// set at creation, where it would be masked by the umask anyway. Windows ignores
|
|
490
|
+
// both — NTFS ACLs inherit from the per-user profile directory.
|
|
491
|
+
async function ensureDiscoveryDir() {
|
|
492
|
+
if (!existsSync(AGENT_DAG_DIR)) await mkdir(AGENT_DAG_DIR, { recursive: true, mode: 0o700 });
|
|
493
|
+
await chmod(AGENT_DAG_DIR, 0o700).catch(() => {});
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/**
|
|
497
|
+
* Register this deck, in one step no reader can land inside.
|
|
498
|
+
*
|
|
499
|
+
* The record used to go down with a plain writeFile, which truncates the target
|
|
500
|
+
* and then fills it, so the file existed and was empty for a moment on every
|
|
501
|
+
* rewrite. Everything that reads this directory parses each record whole —
|
|
502
|
+
* electWriters in hook/hook.js, readLiveDecks and sweepStaleDiscovery in
|
|
503
|
+
* index.mjs — and a record that fails to parse is a deck missing from that
|
|
504
|
+
* cycle: the event it should have logged is either logged by nobody or logged
|
|
505
|
+
* twice by the decks that remain, which is the exact failure the single-writer
|
|
506
|
+
* election exists to prevent, reached through the file the election reads. A
|
|
507
|
+
* rename is atomic on Linux, macOS and Windows alike, so a reader now sees the
|
|
508
|
+
* previous record or the new one and never half of either.
|
|
509
|
+
*
|
|
510
|
+
* The token is the deck's proof of identity, so the file holding it is the
|
|
511
|
+
* deck's key material: readable and writable by its owner, nobody else. The mode
|
|
512
|
+
* is pinned after the write because writeFileAtomic can only carry over a mode
|
|
513
|
+
* the target already had — a first registration, or one left by an earlier run
|
|
514
|
+
* under a recycled pid, would otherwise keep whatever the umask handed it.
|
|
515
|
+
*/
|
|
516
|
+
export async function writeDiscovery({ port, workspace, token, persist = null, codex = true }) {
|
|
517
|
+
await ensureDiscoveryDir();
|
|
518
|
+
const file = discoveryPath();
|
|
519
|
+
const data = {
|
|
520
|
+
pid: process.pid,
|
|
521
|
+
port,
|
|
522
|
+
workspace: workspace ?? "",
|
|
523
|
+
// Without this the hooks refuse to post: a file naming a port it cannot
|
|
524
|
+
// authenticate is exactly the stale-file case they now decline to trust.
|
|
525
|
+
token: token ?? "",
|
|
526
|
+
// Absolute path of the events log this deck appends to, or null under
|
|
527
|
+
// --no-persist. The hook reads it to elect a single writer per file:
|
|
528
|
+
// several decks receive the same event by design, and without this they
|
|
529
|
+
// each appended their own copy to the one log they share. See
|
|
530
|
+
// electWriters in hook/hook.js.
|
|
531
|
+
persist: persistField(persist),
|
|
532
|
+
// Is this deck tailing Codex's rollout files? Those events never pass
|
|
533
|
+
// through a hook, so the decks elect a writer for them among themselves —
|
|
534
|
+
// and a deck running --no-codex must be left out of that election rather
|
|
535
|
+
// than win it and record a rollout it is not even reading. See
|
|
536
|
+
// writesCodexLog in src/server/log-writer.mjs.
|
|
537
|
+
codex: codex !== false,
|
|
538
|
+
startedAt: new Date().toISOString(),
|
|
539
|
+
};
|
|
540
|
+
await writeFileAtomic(file, JSON.stringify(data, null, 2) + "\n");
|
|
541
|
+
await chmod(file, 0o600).catch(() => {});
|
|
542
|
+
return file;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
export async function removeDiscovery(file) {
|
|
546
|
+
try { await unlink(file); } catch {}
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
/** Where this process registers itself. One file per deck, named by pid. */
|
|
550
|
+
export function discoveryPath() {
|
|
551
|
+
return join(AGENT_DAG_DIR, `${process.pid}.json`);
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
/**
|
|
555
|
+
* Make sure this deck's discovery file is on disk and says what it should.
|
|
556
|
+
*
|
|
557
|
+
* Registration was a single write at boot, so anything that took the file away
|
|
558
|
+
* afterwards — a sweep on another machine's clock, a half-finished restart, a
|
|
559
|
+
* user tidying the directory — left a deck that was listening, serving and
|
|
560
|
+
* completely invisible: hook.js enumerates this directory and nothing else, so
|
|
561
|
+
* the deck received zero events while looking perfectly healthy. Re-asserting
|
|
562
|
+
* is cheap (one small read), so the deck checks rather than assumes.
|
|
563
|
+
*
|
|
564
|
+
* A file this process wrote is left alone, mode included. Anything else — no
|
|
565
|
+
* file, unreadable, another pid, a stale port, token, events log or Codex
|
|
566
|
+
* setting — is replaced. Every field another deck decides by is compared, the
|
|
567
|
+
* log path included: leave one out and a record missing it would pass as ours
|
|
568
|
+
* forever, which for the log path means no deck can tell which of them share a
|
|
569
|
+
* file and they all write their own copy of every event again.
|
|
570
|
+
*/
|
|
571
|
+
export async function ensureDiscovery({ port, workspace, token, persist = null, codex = true }) {
|
|
572
|
+
const file = discoveryPath();
|
|
573
|
+
try {
|
|
574
|
+
const d = JSON.parse(stripBom(await readFile(file, "utf8")));
|
|
575
|
+
if (d
|
|
576
|
+
&& d.pid === process.pid
|
|
577
|
+
&& d.port === port
|
|
578
|
+
&& (d.workspace ?? "") === (workspace ?? "")
|
|
579
|
+
&& (d.token ?? "") === (token ?? "")
|
|
580
|
+
&& (d.persist ?? null) === persistField(persist)
|
|
581
|
+
&& d.codex === (codex !== false)) {
|
|
582
|
+
return { file, rewritten: false };
|
|
583
|
+
}
|
|
584
|
+
} catch { /* missing, unreadable or corrupt — rewritten below */ }
|
|
585
|
+
await writeDiscovery({ port, workspace, token, persist, codex });
|
|
586
|
+
return { file, rewritten: true };
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
/**
|
|
590
|
+
* Keep this deck registered for as long as it runs, and tell the caller when
|
|
591
|
+
* that stops being true.
|
|
592
|
+
*
|
|
593
|
+
* `onState` hears the first outcome, every change of health, and every
|
|
594
|
+
* re-registration after the first — never a steady state. A deck that cannot
|
|
595
|
+
* write the file has to say so: silently listening while no hook can find it
|
|
596
|
+
* is the failure this exists to end, not a state worth hiding.
|
|
597
|
+
*
|
|
598
|
+
* The interval is unref'd, so it never keeps a finished process alive, and
|
|
599
|
+
* `stop()` must be called before the file is removed on shutdown — otherwise
|
|
600
|
+
* the next tick would put it straight back.
|
|
601
|
+
*
|
|
602
|
+
* `stop()` ANSWERS WITH THE CHECK ALREADY IN FLIGHT, and shutdown has to await
|
|
603
|
+
* it. Clearing the interval stops the next tick; it does nothing about the one
|
|
604
|
+
* that started a moment ago and is currently inside writeFileAtomic. That tick
|
|
605
|
+
* finishes after the unlink and re-creates the file — exactly the "leave the
|
|
606
|
+
* file behind for the hooks to find once nothing is listening" that stopping
|
|
607
|
+
* first is supposed to prevent, reached by the one route stopping first does
|
|
608
|
+
* not cover. The window is a rename and an fsync on POSIX; on Windows it is
|
|
609
|
+
* that plus renameWithRetry's ladder, up to 200ms of sleeping while a scanner
|
|
610
|
+
* holds the target — the platform the retry was written for is the platform
|
|
611
|
+
* where the race is twenty times wider.
|
|
612
|
+
*
|
|
613
|
+
* `run()` never rejects (every failure is a state), so awaiting this cannot
|
|
614
|
+
* throw and cannot outlast one bounded check.
|
|
615
|
+
*/
|
|
616
|
+
export function keepDiscovery({ port, workspace, token, persist = null, codex = true, intervalMs = 5000, onState = null } = {}) {
|
|
617
|
+
// null until the first outcome, which therefore always differs and is always
|
|
618
|
+
// reported — the caller learns where it stands before anything else happens.
|
|
619
|
+
let healthy = null;
|
|
620
|
+
|
|
621
|
+
const run = async () => {
|
|
622
|
+
let state;
|
|
623
|
+
try {
|
|
624
|
+
const { rewritten } = await ensureDiscovery({ port, workspace, token, persist, codex });
|
|
625
|
+
state = { ok: true, rewritten, file: discoveryPath(), error: null };
|
|
626
|
+
} catch (err) {
|
|
627
|
+
state = { ok: false, rewritten: false, file: discoveryPath(), error: err };
|
|
628
|
+
}
|
|
629
|
+
const worthSaying = healthy !== state.ok || state.rewritten;
|
|
630
|
+
healthy = state.ok;
|
|
631
|
+
if (worthSaying && onState) { try { onState(state); } catch { /* not our problem */ } }
|
|
632
|
+
return state;
|
|
633
|
+
};
|
|
634
|
+
|
|
635
|
+
// One check at a time. Registration is a write and a write is now a rename,
|
|
636
|
+
// which costs an fsync — long enough that a tick can land inside the boot-time
|
|
637
|
+
// check bin/deck.js runs by hand. That second check reads a file the first has
|
|
638
|
+
// not renamed into place yet, concludes the deck is unregistered and writes it
|
|
639
|
+
// again, and the two writes race over one record: the deck reports itself
|
|
640
|
+
// unregistered on a machine where nothing whatsoever is wrong. A caller that
|
|
641
|
+
// asks mid-check gets the answer the check already in flight is fetching.
|
|
642
|
+
let inFlight = null;
|
|
643
|
+
const check = () => (inFlight ??= run().finally(() => { inFlight = null; }));
|
|
644
|
+
|
|
645
|
+
const timer = setInterval(() => { check(); }, intervalMs);
|
|
646
|
+
timer.unref?.();
|
|
647
|
+
|
|
648
|
+
const stop = () => {
|
|
649
|
+
clearInterval(timer);
|
|
650
|
+
return inFlight ?? Promise.resolve(null);
|
|
651
|
+
};
|
|
652
|
+
|
|
653
|
+
return { file: discoveryPath(), check, stop };
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
// CLAUDE_EVENTS used to ride along here (#383). It is the events list of the
|
|
657
|
+
// `claude` entry in PROVIDERS and has never had a reader outside this file; it
|
|
658
|
+
// was easy to miss because the three directories beside it ARE imported and
|
|
659
|
+
// because the long justification below belongs to the SECOND export, not this
|
|
660
|
+
// one. Which events the deck asks Claude Code for is answered by installHooks
|
|
661
|
+
// writing settings.json, which is what the tests read.
|
|
662
|
+
export { AGENT_DAG_DIR, CLAUDE_DIR, CODEX_DIR };
|
|
663
|
+
// Exported for the other modules that rewrite settings.json — the sound toggle
|
|
664
|
+
// today. Every one of them needs the same two guarantees: a file we cannot
|
|
665
|
+
// parse is never treated as an empty one, and the replacement is a single
|
|
666
|
+
// rename rather than a truncate a reader can land inside. installScript carries
|
|
667
|
+
// the same guarantee to the hook scripts themselves, which are the files live
|
|
668
|
+
// sessions are actually executing, and renameWithRetry goes out on its own for
|
|
669
|
+
// the files writeFileAtomic cannot write — the fetched uv binary in
|
|
670
|
+
// uv-bootstrap.mjs — which still need the Windows retry. createTemp goes out for
|
|
671
|
+
// the same reason one step lower: codex-auth.mjs needs the collision-free temp
|
|
672
|
+
// name but not writeFileAtomic's mode handling, which carries over the target's
|
|
673
|
+
// mode and so would leave a brand-new auth.json at whatever the umask allows.
|
|
674
|
+
export { readSettingsForWrite, writeFileAtomic, installScript, renameWithRetry, createTemp };
|