ccdeck 1.43.0 → 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 +210 -12
- package/bin/agent-dag.js +505 -0
- package/bin/deck.js +1051 -0
- package/dist/web/assets/index-Bdl1LX0-.css +1 -0
- package/dist/web/assets/index-jXBjwwZC.js +89 -0
- package/dist/web/index.html +49 -0
- package/hook/hook.js +496 -0
- package/package.json +42 -9
- package/src/server/args.mjs +156 -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 +388 -0
- package/src/server/codex-dir.mjs +171 -0
- package/src/server/codex-quota.mjs +445 -0
- package/src/server/codex-usage.mjs +411 -0
- package/src/server/cswap-admin.mjs +1034 -0
- package/src/server/cswap-auto.mjs +595 -0
- package/src/server/cswap-install.mjs +583 -0
- package/src/server/exec.mjs +938 -0
- package/src/server/index.mjs +5109 -0
- package/src/server/installer.mjs +763 -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/retire-sound-hook.mjs +315 -0
- package/src/server/self-update.mjs +1331 -0
- package/src/server/supervisor.mjs +302 -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
package/bin/deck.js
ADDED
|
@@ -0,0 +1,1051 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// The deck itself: registers hooks, starts the server, opens the browser.
|
|
3
|
+
// Launched by the supervisor in bin/agent-dag.js, which restarts it when it
|
|
4
|
+
// exits with RESTART_CODE. On a respawn (AGENTS_DECK_RESPAWN=1) everything that
|
|
5
|
+
// was already done once this session is skipped — that is what makes a restart
|
|
6
|
+
// take about a second instead of the better part of ten.
|
|
7
|
+
import { resolve, dirname, join } from "node:path";
|
|
8
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
9
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
10
|
+
import { dieOfSignal, dieWithParent } from "../src/server/supervisor.mjs";
|
|
11
|
+
import { isPortValue, parseArgs } from "../src/server/args.mjs";
|
|
12
|
+
import {
|
|
13
|
+
CURSOR_HIDE, CURSOR_SHOW, colorProfile, fit, glyphs, labelColumn, link, motionOK, oneLine,
|
|
14
|
+
palette, pulseText, spinnerFrames, statusLine, supportsHyperlinks, termColumns, unicodeOK,
|
|
15
|
+
unregisteredDetail, wordmark,
|
|
16
|
+
} from "../src/server/term.mjs";
|
|
17
|
+
import { PRODUCT } from "../src/server/brand.mjs";
|
|
18
|
+
import { invokedName, renameNotice } from "../src/server/invoked-as.mjs";
|
|
19
|
+
|
|
20
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
21
|
+
const PKG_ROOT = resolve(__dirname, "..");
|
|
22
|
+
const PKG_VERSION = (() => {
|
|
23
|
+
try { return JSON.parse(readFileSync(join(PKG_ROOT, "package.json"), "utf8")).version ?? "0.0.0"; }
|
|
24
|
+
catch { return "0.0.0"; }
|
|
25
|
+
})();
|
|
26
|
+
|
|
27
|
+
// The command the user typed, handed down by the supervisor — our own argv[1]
|
|
28
|
+
// is this file under every one of the three names. Null when it cannot be
|
|
29
|
+
// proven, and null is the answer that prints nothing.
|
|
30
|
+
const INVOKED_AS = invokedName({ pkgRoot: PKG_ROOT });
|
|
31
|
+
|
|
32
|
+
const argv = process.argv.slice(2);
|
|
33
|
+
const flags = parseArgs(argv);
|
|
34
|
+
|
|
35
|
+
// Exit codes the supervisor reads as "bring me back": 75 from the files on
|
|
36
|
+
// disk, 76 through npx — which is the only way an npx run reaches a newer
|
|
37
|
+
// version, since its directory is never upgraded in place. Anything else it
|
|
38
|
+
// forwards.
|
|
39
|
+
const RESTART_CODE = 75;
|
|
40
|
+
const UPGRADE_CODE = 76;
|
|
41
|
+
const RESPAWN = process.env.AGENTS_DECK_RESPAWN === "1";
|
|
42
|
+
const SUPERVISED = typeof process.send === "function";
|
|
43
|
+
|
|
44
|
+
if (flags.help) {
|
|
45
|
+
printHelp();
|
|
46
|
+
process.exit(0);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// The version, on stdout, and then nothing — no hooks, no port, no browser.
|
|
50
|
+
//
|
|
51
|
+
// This is the first thing anyone types at a CLI they do not know and the second
|
|
52
|
+
// thing anyone types when filing a bug, and until now it was the one question
|
|
53
|
+
// the deck answered by starting a server and never exiting. The banner has
|
|
54
|
+
// always carried the number, but only underneath a running deck, which is not
|
|
55
|
+
// an answer: it cannot be piped, and the process it comes with has to be killed.
|
|
56
|
+
//
|
|
57
|
+
// Bare, unprefixed, one line: `ccdeck --version` is read by scripts as often as
|
|
58
|
+
// by people, and `node --version` style ornamentation is what those scripts
|
|
59
|
+
// then have to strip. PKG_VERSION is the same read the banner uses.
|
|
60
|
+
if (flags.version) {
|
|
61
|
+
console.log(PKG_VERSION);
|
|
62
|
+
process.exit(0);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (flags.uninstall) {
|
|
66
|
+
const { uninstallHooks, hasCodexInstalled } = await import(pathToFileURL(join(PKG_ROOT, "src/server/installer.mjs")).href);
|
|
67
|
+
// Anything that could not be taken out. An uninstall that removed nothing
|
|
68
|
+
// because it could not read the file has not uninstalled anything, and both
|
|
69
|
+
// the wording and the exit code have to say so: a user who is told it worked
|
|
70
|
+
// and still has our hooks firing on every event is worse off than one who is
|
|
71
|
+
// told it failed, because they have stopped looking.
|
|
72
|
+
let refused = false;
|
|
73
|
+
// Files already reported as unparseable. The Claude hooks and the sound hook
|
|
74
|
+
// live in the SAME settings.json, so a stray comma refuses both, and printing
|
|
75
|
+
// the whole path-plus-parser-error twice buries the one line that differs —
|
|
76
|
+
// which of our two installations is still in there.
|
|
77
|
+
const named = new Set();
|
|
78
|
+
/** Report one provider's outcome. `ok` first — see uninstallHooks. */
|
|
79
|
+
const report = (res, label) => {
|
|
80
|
+
if (res.ok === false) {
|
|
81
|
+
refused = true;
|
|
82
|
+
named.add(res.settingsPath);
|
|
83
|
+
console.error(`${PRODUCT}: ${label} hooks NOT removed — ${res.settingsPath} could not be read as JSON (${res.why}).`);
|
|
84
|
+
console.error(`${PRODUCT}: the __agent-dag hook entries are still in that file and keep firing on every ${label} event.`);
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
console.log(res.changed
|
|
88
|
+
? `${PRODUCT}: hooks removed from ${res.settingsPath}`
|
|
89
|
+
: `${PRODUCT}: no ${label} hooks to remove`);
|
|
90
|
+
};
|
|
91
|
+
report(await uninstallHooks({ provider: "claude" }), "Claude");
|
|
92
|
+
// The old finish sound was a second entry in the same file, marked
|
|
93
|
+
// __agent-dag-sound rather than __agent-dag, and uninstallHooks does not know
|
|
94
|
+
// that mark — so it used to be left behind, playing on every turn after the
|
|
95
|
+
// deck was supposedly gone. #704 retired the mechanism outright, but the entry
|
|
96
|
+
// is still on every machine that had it, so removing it is still this
|
|
97
|
+
// command's job. So is the other half: turning the sound on parked the user's
|
|
98
|
+
// own afplay/PowerShell Stop hooks, and once the deck is uninstalled nothing
|
|
99
|
+
// else on the machine knows where they went.
|
|
100
|
+
const { retireSoundHook } = await import(pathToFileURL(join(PKG_ROOT, "src/server/retire-sound-hook.mjs")).href);
|
|
101
|
+
const sound = await retireSoundHook();
|
|
102
|
+
if (sound.removed) console.log(`${PRODUCT}: sound hook removed`);
|
|
103
|
+
if (sound.restored) console.log(`${PRODUCT}: restored ${sound.restored} of your own sound hook(s)`);
|
|
104
|
+
if (sound.ok === false) {
|
|
105
|
+
refused = true;
|
|
106
|
+
// Two different refusals, and saying the wrong one sends the user to the
|
|
107
|
+
// wrong file. `settings_unreadable` means nothing was touched at all — and
|
|
108
|
+
// when the forwarders already named that same file, the whole path and
|
|
109
|
+
// parser error would only bury the one line that differs. `parked_unreadable`
|
|
110
|
+
// is the other file: our entry IS out (the lines above said so), and what is
|
|
111
|
+
// still owed is the user's own hooks, which stay parked until they repair it.
|
|
112
|
+
if (sound.reason === "settings_unreadable") {
|
|
113
|
+
console.error(named.has(sound.settingsPath)
|
|
114
|
+
? `${PRODUCT}: the sound hook is still in that file too.`
|
|
115
|
+
: `${PRODUCT}: sound hook left in place — ${sound.message}`);
|
|
116
|
+
named.add(sound.settingsPath);
|
|
117
|
+
} else {
|
|
118
|
+
console.error(`${PRODUCT}: your own sound hooks were NOT restored — ${sound.message}`);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
if (hasCodexInstalled()) {
|
|
122
|
+
report(await uninstallHooks({ provider: "codex" }), "Codex");
|
|
123
|
+
}
|
|
124
|
+
// The remedy last and once, after every symptom above it, rather than once
|
|
125
|
+
// per refusal in the middle of the list.
|
|
126
|
+
if (named.size > 0) {
|
|
127
|
+
console.error(`${PRODUCT}: repair the JSON (or move the file aside), then run \`${PRODUCT} --uninstall\` again.`);
|
|
128
|
+
}
|
|
129
|
+
// Non-zero when any half of it refused, so `ccdeck --uninstall && …` and every
|
|
130
|
+
// CI step that runs this stops on the failure instead of continuing past it.
|
|
131
|
+
process.exit(refused ? 1 : 0);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// The port, and the one piece of argv the deck really does refuse to boot over.
|
|
135
|
+
//
|
|
136
|
+
// It refused before too — `--port banana` and `--port --no-open` both became
|
|
137
|
+
// `Number(…)` → `NaN`, which survived the whole startup (hooks installed,
|
|
138
|
+
// claude-swap installed, ccusage probed) and then killed the process from inside
|
|
139
|
+
// `listen` with Node's own wording: "options.port should be >= 0 and < 65536.
|
|
140
|
+
// Received type number (NaN)." That names neither the flag nor the value the
|
|
141
|
+
// user typed, and arrives after a page of green ticks. Same outcome, said here:
|
|
142
|
+
// early, in the deck's own voice, quoting the flag and the value back.
|
|
143
|
+
//
|
|
144
|
+
// An empty `AGENT_DAG_PORT` is an unset one — a variable that did not expand is
|
|
145
|
+
// not a request for port zero. `--port ""` never reaches this, because the
|
|
146
|
+
// parser records an empty value as `incomplete` and leaves the flag unset.
|
|
147
|
+
const envPort = process.env.AGENT_DAG_PORT?.trim();
|
|
148
|
+
const rawPort = flags.port ?? (envPort ? envPort : null);
|
|
149
|
+
if (rawPort != null && !isPortValue(rawPort)) {
|
|
150
|
+
const named = flags.port != null ? "--port" : "AGENT_DAG_PORT";
|
|
151
|
+
console.error(`${PRODUCT}: ${named} ${rawPort}: not a port number — expected 0–65535.`);
|
|
152
|
+
process.exit(1);
|
|
153
|
+
}
|
|
154
|
+
const port = rawPort == null ? 4317 : Number(rawPort);
|
|
155
|
+
// Default = machine-wide (capture every CC session on this box). Pass
|
|
156
|
+
// `--workspace <path>` (or `--scope`) to restrict to a single tree. Canonicalized
|
|
157
|
+
// just below, once the module that owns that rule is loaded.
|
|
158
|
+
const rawWorkspace = flags.workspace != null
|
|
159
|
+
? flags.workspace
|
|
160
|
+
: (flags.scope ? process.cwd() : "");
|
|
161
|
+
const openBrowser = flags.noOpen !== true;
|
|
162
|
+
// The events log lives beside the discovery files, so it follows the Claude
|
|
163
|
+
// config dir rather than assuming ~/.claude — see src/server/claude-dir.mjs.
|
|
164
|
+
const { claudeConfigDir, hasClaudeInstalled } =
|
|
165
|
+
await import(pathToFileURL(join(PKG_ROOT, "src/server/claude-dir.mjs")).href);
|
|
166
|
+
// Resolved here rather than left as typed: the discovery file publishes this
|
|
167
|
+
// path so the hook can tell which decks share one log and elect a single
|
|
168
|
+
// writer for it, and two spellings of one file would read as two files.
|
|
169
|
+
// startServer resolves it the same way, from this same process, so the two
|
|
170
|
+
// always name the same file.
|
|
171
|
+
const persist = flags.noPersist
|
|
172
|
+
? null
|
|
173
|
+
: resolve(flags.history ?? join(claudeConfigDir(), "agent-dag", "events.jsonl"));
|
|
174
|
+
|
|
175
|
+
const { installHooks, keepDiscovery, removeDiscovery, hasCodexInstalled } =
|
|
176
|
+
await import(pathToFileURL(join(PKG_ROOT, "src/server/installer.mjs")).href);
|
|
177
|
+
// CODEX_SESSIONS_DIR comes along because the banner below names the directory
|
|
178
|
+
// the watcher tails, and the watcher lives in that module. Recomputing the path
|
|
179
|
+
// here is how the banner came to print ~/.codex/sessions on machines whose
|
|
180
|
+
// sessions are somewhere else entirely — see the row further down.
|
|
181
|
+
const { startServer, hookToken, releaseRestart, markDeckReady, CODEX_SESSIONS_DIR, canonicalWorkspace } =
|
|
182
|
+
await import(pathToFileURL(join(PKG_ROOT, "src/server/index.mjs")).href);
|
|
183
|
+
|
|
184
|
+
// Resolved here rather than left as typed, for the reason the events log above
|
|
185
|
+
// is: the discovery file publishes this path, and the hook that reads it runs in
|
|
186
|
+
// a process whose cwd is the agent's — so a relative `--workspace ./sub` meant
|
|
187
|
+
// one directory to the Codex watcher inside this process and a different one per
|
|
188
|
+
// agent to the hook. One canonical spelling, computed in the one process that
|
|
189
|
+
// knows what the user meant, is what both capture paths compare against. See
|
|
190
|
+
// canonicalWorkspace.
|
|
191
|
+
const workspace = canonicalWorkspace(rawWorkspace);
|
|
192
|
+
|
|
193
|
+
// Whether the server starts the Codex rollout watcher. Nothing is installed
|
|
194
|
+
// and no directory is created either way — Codex hooks are not used any more,
|
|
195
|
+
// so `--codex` only means "watch even though ~/.codex/ is not there yet",
|
|
196
|
+
// which is the right answer for a machine where Codex arrives later.
|
|
197
|
+
const wantCodex = flags.noCodex
|
|
198
|
+
? false
|
|
199
|
+
: (flags.codex === true || hasCodexInstalled());
|
|
200
|
+
|
|
201
|
+
// The same question for the other CLI, and the one nobody was asking. README
|
|
202
|
+
// offers "Claude Code CLI or OpenAI Codex CLI (or both)"; a Codex-only machine
|
|
203
|
+
// nonetheless got a Python account-switcher installed for a CLI it does not
|
|
204
|
+
// have, an accounts panel open on first run, and a banner line telling it to
|
|
205
|
+
// sign into that CLI (#402). Everything the deck installs or opens on the
|
|
206
|
+
// Claude side now hangs off this one answer, and it is stated in the banner so
|
|
207
|
+
// a wrong answer is visible rather than mysterious.
|
|
208
|
+
//
|
|
209
|
+
// `--claude` is the escape hatch for a false negative, which is the failure
|
|
210
|
+
// that matters: hasClaudeInstalled looks for the binary and for traces of use,
|
|
211
|
+
// and a machine that hides Claude Code from both would otherwise lose its hooks
|
|
212
|
+
// with no way to ask for them back. `--no-claude` is the opt-out the Claude side
|
|
213
|
+
// never had — the mirror of --no-codex — and it is also what a Codex-only user
|
|
214
|
+
// with a settings.json the installer refuses to rewrite needs, since that
|
|
215
|
+
// refusal is fatal at boot on a component they do not use.
|
|
216
|
+
const wantClaude = flags.noClaude
|
|
217
|
+
? false
|
|
218
|
+
: (flags.claude === true || hasClaudeInstalled());
|
|
219
|
+
|
|
220
|
+
const WEB_DIST = join(PKG_ROOT, "dist", "web", "index.html");
|
|
221
|
+
if (!existsSync(WEB_DIST)) {
|
|
222
|
+
console.error(`${PRODUCT}: ui not built. run \`npm run build\` (or \`pnpm build\`) first.`);
|
|
223
|
+
process.exit(1);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// ── the terminal we are printing into ─────────────────────────────────────────
|
|
227
|
+
// Asked once, degraded from there — see src/server/term.mjs, which is where all
|
|
228
|
+
// of this is decided and asserted. Below this point the deck writes no escape of
|
|
229
|
+
// its own: colour comes from `P`, glyphs from `G`, layout from statusLine. That
|
|
230
|
+
// is what makes NO_COLOR, a pipe, a CI log and a legacy Windows console one
|
|
231
|
+
// question rather than thirty separate ones nobody remembers to ask.
|
|
232
|
+
const tty = Boolean(process.stdout.isTTY);
|
|
233
|
+
const PROFILE = colorProfile({ isTTY: tty });
|
|
234
|
+
const P = palette(PROFILE);
|
|
235
|
+
const UNICODE = unicodeOK();
|
|
236
|
+
const G = glyphs(UNICODE);
|
|
237
|
+
const LINKS = supportsHyperlinks({ profile: PROFILE });
|
|
238
|
+
// The terminal's prefers-reduced-motion: nothing sleeps, spins or repaints in a
|
|
239
|
+
// pipe, under CI, or with NO_COLOR set.
|
|
240
|
+
const MOTION = motionOK({ isTTY: tty, profile: PROFILE });
|
|
241
|
+
const write = (s) => process.stdout.write(s);
|
|
242
|
+
// Read per line, never cached: a terminal can be resized while the deck runs,
|
|
243
|
+
// and the pulse below is still on screen hours later.
|
|
244
|
+
const cols = () => termColumns(process.stdout);
|
|
245
|
+
const sleep = ms => new Promise(r => setTimeout(r, ms));
|
|
246
|
+
const fileLink = (path) => link(path, pathToFileURL(path).href, LINKS);
|
|
247
|
+
|
|
248
|
+
// ── the cursor ────────────────────────────────────────────────────────────────
|
|
249
|
+
// Hidden for as long as anything of ours is moving — the reveal, the spinner,
|
|
250
|
+
// the pulse — and put back on every way out of this process: the ordinary exit,
|
|
251
|
+
// all three signals, and an uncaught throw, which reaches 'exit' after Node has
|
|
252
|
+
// printed it. Half of this is worse than none: a deck that dies with the cursor
|
|
253
|
+
// hidden leaves the user's shell with no cursor and nothing to do about it but
|
|
254
|
+
// `reset`.
|
|
255
|
+
let cursorHidden = false;
|
|
256
|
+
const showCursor = () => {
|
|
257
|
+
if (!cursorHidden) return;
|
|
258
|
+
cursorHidden = false;
|
|
259
|
+
try { write(CURSOR_SHOW); } catch { /* stdout is gone; nothing left to restore */ }
|
|
260
|
+
};
|
|
261
|
+
if (MOTION) { cursorHidden = true; write(CURSOR_HIDE); }
|
|
262
|
+
process.on("exit", showCursor);
|
|
263
|
+
// SIGHUP is the one signal this process does not otherwise handle, so its
|
|
264
|
+
// default action would end us before 'exit' could run. Handled only to put the
|
|
265
|
+
// cursor back and then die of it exactly as before — the supervisor reads the
|
|
266
|
+
// signal, not an exit code.
|
|
267
|
+
process.on("SIGHUP", () => { showCursor(); dieOfSignal("SIGHUP"); });
|
|
268
|
+
|
|
269
|
+
// ── rows ──────────────────────────────────────────────────────────────────────
|
|
270
|
+
// The status column is computed from the longest label. It used to be counted
|
|
271
|
+
// into each string as trailing spaces, so any new row, or any label a character
|
|
272
|
+
// longer, silently broke the alignment of every other one.
|
|
273
|
+
const LABELS = [
|
|
274
|
+
"workspace", "Claude hooks", "Codex sessions", "claude-swap", "accounts",
|
|
275
|
+
"ccusage", "update", "name", "server ready", "log", "unknown option",
|
|
276
|
+
"missing value",
|
|
277
|
+
];
|
|
278
|
+
const LABEL_W = labelColumn(LABELS);
|
|
279
|
+
|
|
280
|
+
function row({ mark = " ", tone = P.ok, label = "", detail = "", detailTone = P.muted, keep = false }) {
|
|
281
|
+
return statusLine({
|
|
282
|
+
mark, label, detail, keep, labelWidth: LABEL_W, columns: cols(), ellipsis: G.ellipsis,
|
|
283
|
+
paint: {
|
|
284
|
+
mark: (s) => `${tone}${s}${P.reset}`,
|
|
285
|
+
detail: (s) => `${detailTone}${s}${P.reset}`,
|
|
286
|
+
},
|
|
287
|
+
}) + "\n";
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// ── the wordmark ──────────────────────────────────────────────────────────────
|
|
291
|
+
async function printBanner() {
|
|
292
|
+
const { lines } = wordmark({ columns: cols(), version: PKG_VERSION, profile: PROFILE, unicode: UNICODE, pal: P });
|
|
293
|
+
for (const line of lines) {
|
|
294
|
+
write(line + "\n");
|
|
295
|
+
// A reveal, not a wait. The once-per-session work is already running under
|
|
296
|
+
// it (see startupWork), so the art costs the boot nothing and the deck is
|
|
297
|
+
// ready about when the last row lands. What used to be here — 560ms of
|
|
298
|
+
// spinner at "loading…" before a single art line — was dead time in a tool
|
|
299
|
+
// whose documented entry point is `npx ccdeck`.
|
|
300
|
+
if (MOTION && line) await sleep(45);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// ── a step, with a spinner only if it is slow enough to need one ──────────────
|
|
305
|
+
// The interval's first frame is 80ms away, so anything already settled when we
|
|
306
|
+
// get here paints nothing at all and the row below is the only trace of it.
|
|
307
|
+
async function step(label, work) {
|
|
308
|
+
if (!MOTION) return work;
|
|
309
|
+
const frames = spinnerFrames(UNICODE);
|
|
310
|
+
// Kept inside the terminal: a label that wraps is a label the \r below can
|
|
311
|
+
// only half erase, and what is left of it stays under the row that follows.
|
|
312
|
+
const text = fit(label, cols() - 6, G.ellipsis);
|
|
313
|
+
let i = 0;
|
|
314
|
+
const iv = setInterval(() => {
|
|
315
|
+
write(`\r ${P.accent}${frames[i++ % frames.length]}${P.reset} ${P.muted}${text}${P.reset}`);
|
|
316
|
+
}, 80);
|
|
317
|
+
try {
|
|
318
|
+
return await work;
|
|
319
|
+
} finally {
|
|
320
|
+
clearInterval(iv);
|
|
321
|
+
// Cleared rather than overwritten: the row that follows is a different
|
|
322
|
+
// length, and relying on it to be the longer of the two is how a spinner
|
|
323
|
+
// leaves its own tail on screen. Nothing to clear if it never painted.
|
|
324
|
+
if (i) write("\r" + " ".repeat(text.length + 5) + "\r");
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* The once-per-session work, all of it started at once and none of it awaited.
|
|
330
|
+
*
|
|
331
|
+
* Hook install, the claude-swap probe and the registry lookup have nothing to
|
|
332
|
+
* do with each other and nothing to do with the wordmark, so they run underneath
|
|
333
|
+
* the reveal instead of queueing behind it — the animation then costs the boot
|
|
334
|
+
* nothing and the deck is ready about when the last art row lands. Every one of
|
|
335
|
+
* them is given its rejection handler here, at the moment it is created, since a
|
|
336
|
+
* promise that settles before anything awaits it is otherwise an unhandled
|
|
337
|
+
* rejection.
|
|
338
|
+
*/
|
|
339
|
+
function startupWork() {
|
|
340
|
+
// Every job below this line serves Claude Code and only Claude Code: the
|
|
341
|
+
// hooks go in Claude Code's settings.json, claude-swap switches Claude
|
|
342
|
+
// accounts, and ccusage reads Claude Code's own session logs. On a machine
|
|
343
|
+
// without Claude Code all three are work done for a CLI that is not there —
|
|
344
|
+
// two of them installs the user did not ask for — so they are not started at
|
|
345
|
+
// all rather than started and then reported as failures. `null` is how each
|
|
346
|
+
// one says "not attempted", which reportStartup tells apart from "tried and
|
|
347
|
+
// could not".
|
|
348
|
+
|
|
349
|
+
// Settings the installer cannot parse are settings it cannot rewrite without
|
|
350
|
+
// losing them, so it refuses — and that refusal is reported rather than
|
|
351
|
+
// thrown, because it is the only thing the user can act on.
|
|
352
|
+
const hooks = wantClaude
|
|
353
|
+
? installHooks({ provider: "claude" }).then(v => ({ ok: true, v }), err => ({ ok: false, err }))
|
|
354
|
+
: Promise.resolve(null);
|
|
355
|
+
|
|
356
|
+
// claude-swap backs the multi-account panel, and an empty store leaves that
|
|
357
|
+
// panel useless even when the tool is there — so the account already signed
|
|
358
|
+
// in is registered once. Bounded inside seedFirstAccount: empty store only,
|
|
359
|
+
// once ever, never with NO_INSTALL set.
|
|
360
|
+
const cswap = (async () => {
|
|
361
|
+
if (!wantClaude) return null;
|
|
362
|
+
const { ensureCswap } = await import(pathToFileURL(join(PKG_ROOT, "src/server/cswap-install.mjs")).href);
|
|
363
|
+
const cs = await ensureCswap();
|
|
364
|
+
const usable = cs.state === "present" || cs.state === "installed" || cs.state === "upgrading";
|
|
365
|
+
if (!usable) return { cs, seed: null };
|
|
366
|
+
const { seedFirstAccount } = await import(pathToFileURL(join(PKG_ROOT, "src/server/claude-accounts.mjs")).href);
|
|
367
|
+
return { cs, seed: await seedFirstAccount().catch(() => ({ state: "failed" })) };
|
|
368
|
+
})().catch(() => null);
|
|
369
|
+
|
|
370
|
+
// ccusage backs the usage-history modal. Primed at boot rather than on first
|
|
371
|
+
// open so a cold machine pays the install while the deck is still starting.
|
|
372
|
+
// Nothing is lost by skipping the prime: runCcusage falls back to npx, so the
|
|
373
|
+
// modal still answers if it is ever opened — it just pays the wait itself.
|
|
374
|
+
const ccusage = (async () => {
|
|
375
|
+
if (!wantClaude) return null;
|
|
376
|
+
if (process.env.AGENTS_DECK_NO_INSTALL === "1") return null;
|
|
377
|
+
const { primeCcusage } = await import(pathToFileURL(join(PKG_ROOT, "src/server/ccusage.mjs")).href);
|
|
378
|
+
return primeCcusage();
|
|
379
|
+
})().catch(() => null);
|
|
380
|
+
|
|
381
|
+
// A newer release on npm, said once, in the place the upgrade gets typed.
|
|
382
|
+
// Hard-capped so a slow registry cannot delay the server — the answer is
|
|
383
|
+
// usually already cached in ~/.agents-deck/.self-update-check anyway. It has
|
|
384
|
+
// to resolve BEFORE the pulse indicator starts writing over the last line.
|
|
385
|
+
const update = Promise.race([
|
|
386
|
+
import(pathToFileURL(join(PKG_ROOT, "src/server/self-update.mjs")).href)
|
|
387
|
+
.then(m => m.versionReport({ running: PKG_VERSION, pkgRoot: PKG_ROOT }))
|
|
388
|
+
.then(r => (r?.notice?.kind === "upgrade" ? r : null))
|
|
389
|
+
.catch(() => null),
|
|
390
|
+
new Promise(r => setTimeout(() => r(null), 1200)),
|
|
391
|
+
]);
|
|
392
|
+
|
|
393
|
+
return { hooks, cswap, ccusage, update };
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/** The same work, said out loud, in a fixed order — a boot whose rows arrive in
|
|
397
|
+
* whatever order the network settled is a boot nobody can scan twice. */
|
|
398
|
+
async function reportStartup(jobs) {
|
|
399
|
+
write(row({
|
|
400
|
+
mark: G.ok, label: "workspace",
|
|
401
|
+
detail: workspace === "" ? "(all)" : workspace,
|
|
402
|
+
detailTone: workspace === "" ? P.warn : P.muted,
|
|
403
|
+
}));
|
|
404
|
+
|
|
405
|
+
const hooks = await step(`installing Claude hooks${G.ellipsis}`, jobs.hooks);
|
|
406
|
+
if (hooks === null) {
|
|
407
|
+
// Said in the same shape as the Codex row below, because it is the same
|
|
408
|
+
// sentence: this deck is not watching that CLI, and here is why. It also
|
|
409
|
+
// retires the one boot failure a Codex-only machine could hit — an
|
|
410
|
+
// unparseable or unwritable settings.json used to exit(1) below, killing a
|
|
411
|
+
// deck over a file belonging to a CLI the user does not run.
|
|
412
|
+
write(row({ label: "Claude hooks", detail: `skipped ${G.dash} no Claude Code found, or --no-claude` }));
|
|
413
|
+
} else if (!hooks.ok) {
|
|
414
|
+
// The file it names is one only the user can repair, and every Claude Code
|
|
415
|
+
// session on this machine is reading it too.
|
|
416
|
+
write(row({ mark: G.fail, tone: P.err, label: "Claude hooks", detail: "not installed" }));
|
|
417
|
+
console.error(`\n ${PRODUCT}: ${hooks.err.message}\n`);
|
|
418
|
+
process.exit(1);
|
|
419
|
+
} else {
|
|
420
|
+
write(row({ mark: G.ok, label: "Claude hooks", detail: fileLink(hooks.v.hookPath) }));
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
// Codex CLI hooks never fire on Windows (sandbox refuses to spawn the hook
|
|
424
|
+
// command). Instead the server tails Codex's rollout JSONL files directly, so
|
|
425
|
+
// there's nothing to install and no /hooks trust step. We just confirm Codex
|
|
426
|
+
// is present and let the watcher pick up sessions.
|
|
427
|
+
if (wantCodex) {
|
|
428
|
+
// The directory the watcher actually tails, imported from the module that
|
|
429
|
+
// owns it rather than rebuilt from homedir() here. CODEX_HOME relocates the
|
|
430
|
+
// whole tree, and this row is the only diagnostic the deck prints about
|
|
431
|
+
// Codex — so when sessions do not show up, a path computed a second way
|
|
432
|
+
// sends the user to inspect a directory the deck never opened.
|
|
433
|
+
write(row({ mark: G.ok, label: "Codex sessions", detail: `watching ${fileLink(CODEX_SESSIONS_DIR)}` }));
|
|
434
|
+
} else {
|
|
435
|
+
write(row({ label: "Codex sessions", detail: `skipped ${G.dash} no ~/.codex/, or --no-codex` }));
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
const swap = await step(`checking claude-swap${G.ellipsis}`, jobs.cswap);
|
|
439
|
+
const cs = swap?.cs;
|
|
440
|
+
if (!wantClaude) {
|
|
441
|
+
// claude-swap is a Python tool that switches Claude Code accounts, and the
|
|
442
|
+
// deck used to fetch a uv binary to install it on machines with no Claude
|
|
443
|
+
// Code at all. Saying so is the point of the row: it is the one place a
|
|
444
|
+
// user can learn that the accounts panel is missing on purpose.
|
|
445
|
+
write(row({ label: "claude-swap", detail: `skipped ${G.dash} accounts are Claude-only` }));
|
|
446
|
+
} else if (cs?.state === "present") {
|
|
447
|
+
write(row({ mark: G.ok, label: "claude-swap", detail: `v${cs.version} (accounts panel enabled)` }));
|
|
448
|
+
} else if (cs?.state === "installed") {
|
|
449
|
+
write(row({ mark: G.ok, label: "claude-swap", detail: `installed v${cs.version} via ${cs.via}` }));
|
|
450
|
+
} else if (cs?.state === "upgrading") {
|
|
451
|
+
write(row({ mark: G.ok, label: "claude-swap", detail: `v${cs.version}, upgrading to v${cs.latest} in background` }));
|
|
452
|
+
} else if (cs?.state === "skipped") {
|
|
453
|
+
write(row({ mark: G.ok, label: "claude-swap", detail: "not installed (AGENTS_DECK_NO_INSTALL=1)" }));
|
|
454
|
+
} else {
|
|
455
|
+
const how = cs?.reason === "no_installer"
|
|
456
|
+
? `not installed ${G.dash} the accounts panel needs it`
|
|
457
|
+
: cs?.reason === "not_on_path"
|
|
458
|
+
? `installed via ${cs.via} but not on PATH ${G.dash} add ${
|
|
459
|
+
process.platform === "win32" ? "%USERPROFILE%\\.local\\bin" : "~/.local/bin"
|
|
460
|
+
}`
|
|
461
|
+
: `install failed${cs?.via ? ` via ${cs.via}` : ""}`;
|
|
462
|
+
write(row({ mark: G.fail, tone: P.warn, label: "claude-swap", detail: how }));
|
|
463
|
+
// A URL is not an answer when someone just wants the panel to work. Print
|
|
464
|
+
// the command for THIS machine, picked from what is already on it.
|
|
465
|
+
if (cs?.hint) write(row({ label: "", detail: cs.hint }));
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
if (swap?.seed?.state === "added") {
|
|
469
|
+
write(row({ mark: G.ok, label: "accounts", detail: "registered the signed-in account (cswap add)" }));
|
|
470
|
+
} else if (swap?.seed?.state === "failed" || swap?.seed?.state === "nothing-to-add") {
|
|
471
|
+
write(row({ label: "accounts", detail: `panel empty ${G.dash} sign in to Claude Code, then run cswap add` }));
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
const cu = await jobs.ccusage;
|
|
475
|
+
if (cu?.state === "present") write(row({ mark: G.ok, label: "ccusage", detail: `v${cu.version}` }));
|
|
476
|
+
else if (cu?.state === "updating") write(row({ mark: G.ok, label: "ccusage", detail: `v${cu.version}, checking for update` }));
|
|
477
|
+
// A ccusage the user provided, named rather than versioned — reading a
|
|
478
|
+
// version out of it means running it, and a status row is not worth a spawn.
|
|
479
|
+
// Naming the file is the more useful half anyway: it is the answer to "which
|
|
480
|
+
// ccusage is this deck actually going to run", which is a question a machine
|
|
481
|
+
// with a managed install AND a PATH copy could not answer before #433.
|
|
482
|
+
else if (cu?.state === "user") write(row({ mark: G.ok, label: "ccusage", detail: `your own copy ${G.dash} ${cu.bin}` }));
|
|
483
|
+
else if (cu?.state === "installing") write(row({ mark: G.ok, label: "ccusage", detail: "installing in background" }));
|
|
484
|
+
|
|
485
|
+
const upgrade = await jobs.update;
|
|
486
|
+
if (upgrade) {
|
|
487
|
+
write(row({
|
|
488
|
+
mark: G.up, tone: P.warn, label: "update",
|
|
489
|
+
detail: `v${upgrade.notice.to} available ${G.dash} ${upgrade.command}`,
|
|
490
|
+
}));
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
// Which name this deck was started under, when that is knowable — a notice,
|
|
494
|
+
// never a refusal. 95% of installs are on the two old names and the update
|
|
495
|
+
// path runs through this very process, so a build that declined to boot under
|
|
496
|
+
// one of them would kill the deck on the machine where the deck is what would
|
|
497
|
+
// have explained why. Nothing at all is printed wherever the typed name
|
|
498
|
+
// cannot be proven (a Windows global install, a git checkout): telling
|
|
499
|
+
// somebody who already types `ccdeck` to type `ccdeck` is the one failure
|
|
500
|
+
// that would make this row worth ignoring. See src/server/invoked-as.mjs.
|
|
501
|
+
const rename = renameNotice({ invoked: INVOKED_AS, pkgRoot: PKG_ROOT, dash: G.dash });
|
|
502
|
+
if (rename) {
|
|
503
|
+
write(row({ mark: G.warn, tone: P.warn, label: "name", detail: rename.said }));
|
|
504
|
+
// The line that carries the value: for a global install there is nothing to
|
|
505
|
+
// install and nothing to download, only six different characters to type.
|
|
506
|
+
write(row({ label: "", detail: rename.fix }));
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
// Asking the supervisor to bring us back. It is the only party that can, and
|
|
511
|
+
// only after this process is gone — which is precisely what keeps the
|
|
512
|
+
// replacement from racing this listener onto a random fallback port.
|
|
513
|
+
let restarting = false;
|
|
514
|
+
// Outer bound on the supervisor's answer below. It cannot be reached today —
|
|
515
|
+
// the fetch has a deadline of its own and every path through it replies — but
|
|
516
|
+
// `restarting` is a latch, and a latch with no way out is how a deck ends up
|
|
517
|
+
// silently refusing every restart for the rest of its life.
|
|
518
|
+
const UPGRADE_ANSWER_MS = 150_000;
|
|
519
|
+
let upgradeTimer = null;
|
|
520
|
+
|
|
521
|
+
// Whether the rest of this file has finished running.
|
|
522
|
+
//
|
|
523
|
+
// The server below starts accepting connections from inside startServer, before
|
|
524
|
+
// that call has returned — so /api/restart is reachable for the whole of the
|
|
525
|
+
// boot that follows it: the startup report, the port report to the supervisor,
|
|
526
|
+
// the discovery file and its first fsynced write, and on a cold start the
|
|
527
|
+
// browser spawn. A restart landing in that window used to reach `shutdown`
|
|
528
|
+
// before the binding holding it was initialised and die of a ReferenceError,
|
|
529
|
+
// having already set the latch above, with nothing left to clear it — after
|
|
530
|
+
// which every restart from every tab was answered "ok" and did nothing, for the
|
|
531
|
+
// life of the process (#448).
|
|
532
|
+
//
|
|
533
|
+
// So an ask that arrives too early is held rather than run: the user asked for
|
|
534
|
+
// something this deck can genuinely give a moment later, and refusing outright
|
|
535
|
+
// would put back the same silence in a politer form. BOOT_RESTART_MS is the
|
|
536
|
+
// outer bound, for the reason UPGRADE_ANSWER_MS above is one — and since #483
|
|
537
|
+
// moved the listen in front of the report, it is a bound that gets used: a boot
|
|
538
|
+
// waiting out a real `uv tool install` is minutes long, and the restart is the
|
|
539
|
+
// right answer to it rather than a casualty of it. Ten seconds in, the ask is
|
|
540
|
+
// run; the respawn skips the report entirely and is up in about a second.
|
|
541
|
+
let booted = false;
|
|
542
|
+
let heldRestart = null;
|
|
543
|
+
let bootTimer = null;
|
|
544
|
+
const BOOT_RESTART_MS = 10_000;
|
|
545
|
+
|
|
546
|
+
const requestRestart = (mode) => {
|
|
547
|
+
if (restarting) return;
|
|
548
|
+
restarting = true;
|
|
549
|
+
if (!booted) {
|
|
550
|
+
heldRestart = { mode };
|
|
551
|
+
bootTimer = setTimeout(() => { bootTimer = null; runHeldRestart(); }, BOOT_RESTART_MS);
|
|
552
|
+
bootTimer.unref?.();
|
|
553
|
+
// Said out loud for the same reason abandonUpgrade below is: the tab has
|
|
554
|
+
// already been told its restart was accepted, and a second of nothing
|
|
555
|
+
// happening on this terminal is otherwise indistinguishable from the bug
|
|
556
|
+
// this replaces.
|
|
557
|
+
write(`\n ${P.warn}${G.restart}${P.reset} ${P.muted}restart queued ${G.dash} still starting up${P.reset}\n`);
|
|
558
|
+
return;
|
|
559
|
+
}
|
|
560
|
+
beginRestart(mode);
|
|
561
|
+
};
|
|
562
|
+
|
|
563
|
+
// The restart itself, once there is a booted deck to end. Split out of
|
|
564
|
+
// requestRestart so the held ask above can re-enter it without tripping the
|
|
565
|
+
// latch it is already holding.
|
|
566
|
+
//
|
|
567
|
+
// Everything here runs inside one try: the whole point of #448 is that a throw
|
|
568
|
+
// on this path is not merely a failed restart but a permanent one, because the
|
|
569
|
+
// latch it leaves behind outlives it. There is no line in here worth dying for.
|
|
570
|
+
function beginRestart(mode) {
|
|
571
|
+
try {
|
|
572
|
+
// "npx" means the newer code is not on this disk at all, so it has to be
|
|
573
|
+
// fetched — and this process keeps serving while that happens. Exiting first
|
|
574
|
+
// is what made every failed upgrade an outage: the SSE stream dropped, hook
|
|
575
|
+
// events fired into the gap were lost outright (hook/hook.js is
|
|
576
|
+
// fire-and-forget with a 1s timeout and no retry), and the canvas came back
|
|
577
|
+
// with whatever was in flight stuck until the stale sweeper reaped it — all
|
|
578
|
+
// of it paid before anyone knew whether npm could even resolve the version.
|
|
579
|
+
// Nothing is torn down here now; the supervisor answers when it knows.
|
|
580
|
+
if (mode === "npx") {
|
|
581
|
+
upgradeTimer = setTimeout(() => abandonUpgrade("no answer from the supervisor"), UPGRADE_ANSWER_MS);
|
|
582
|
+
upgradeTimer.unref?.();
|
|
583
|
+
// Armed before the ask, not after: a send that throws is a supervisor that
|
|
584
|
+
// can no longer answer, and the deck has to come back out of the latch on
|
|
585
|
+
// its own rather than wait out an answer that cannot arrive.
|
|
586
|
+
try { process.send({ type: "upgrade" }); }
|
|
587
|
+
catch (err) { abandonUpgrade(err?.message ?? "the supervisor is no longer listening"); }
|
|
588
|
+
return;
|
|
589
|
+
}
|
|
590
|
+
const to = restartTarget();
|
|
591
|
+
write(`\n ${P.warn}${G.restart}${P.reset} ${P.muted}restarting${to ? ` ${G.arrow} v${to}` : ""}${G.ellipsis}${P.reset}\n`);
|
|
592
|
+
shutdown(RESTART_CODE);
|
|
593
|
+
} catch (err) {
|
|
594
|
+
abandonRestart(err);
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
// The ask that was waiting for the boot to finish, now that it has. Safe to
|
|
599
|
+
// call when nothing is waiting, which is every ordinary boot.
|
|
600
|
+
function runHeldRestart() {
|
|
601
|
+
if (!heldRestart) return;
|
|
602
|
+
const { mode } = heldRestart;
|
|
603
|
+
heldRestart = null;
|
|
604
|
+
clearTimeout(bootTimer);
|
|
605
|
+
bootTimer = null;
|
|
606
|
+
beginRestart(mode);
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
// A restart that could not be started, said out loud and then let go of.
|
|
610
|
+
//
|
|
611
|
+
// Both halves of the latch have to come down — this file's and the server's —
|
|
612
|
+
// because a latch nothing clears is precisely how one failed request turned
|
|
613
|
+
// into a deck that refused every restart afterwards while answering "ok" to
|
|
614
|
+
// each one (#448). The reason is folded onto one line by oneLine: the terminal
|
|
615
|
+
// under this is repainted every 800ms by the pulse, and a stack written into
|
|
616
|
+
// that is a stack nobody can read (#432).
|
|
617
|
+
//
|
|
618
|
+
// A declaration rather than a const, like `shutdown` below and for the same
|
|
619
|
+
// reason: this is the handler for a binding that was not there yet, and it must
|
|
620
|
+
// not be capable of becoming the next one.
|
|
621
|
+
function abandonRestart(err) {
|
|
622
|
+
clearTimeout(bootTimer);
|
|
623
|
+
bootTimer = null;
|
|
624
|
+
heldRestart = null;
|
|
625
|
+
restarting = false;
|
|
626
|
+
releaseRestart();
|
|
627
|
+
write(
|
|
628
|
+
`\n ${P.err}${G.fail}${P.reset} ${P.muted}restart failed ${G.dash} still on ${P.reset}v${PKG_VERSION}\n` +
|
|
629
|
+
` ${P.muted}${oneLine(err?.stack ?? err, Math.max(20, cols() - 6), G.ellipsis)}${P.reset}\n`,
|
|
630
|
+
);
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
// The upgrade did not happen and this deck is still the deck. Said out loud
|
|
634
|
+
// because the terminal has just printed that a fetch was starting, and left
|
|
635
|
+
// unsaid it reads as a restart that hung.
|
|
636
|
+
const abandonUpgrade = (why) => {
|
|
637
|
+
clearTimeout(upgradeTimer);
|
|
638
|
+
restarting = false;
|
|
639
|
+
// The server's own latch, which no longer has an exiting process to clear it.
|
|
640
|
+
releaseRestart();
|
|
641
|
+
write(
|
|
642
|
+
`\n ${P.warn}${G.cancel}${P.reset} ${P.muted}update not applied ${G.dash} still on ${P.reset}v${PKG_VERSION}\n` +
|
|
643
|
+
(why ? ` ${P.muted}${why}${P.reset}\n` : ""),
|
|
644
|
+
);
|
|
645
|
+
};
|
|
646
|
+
|
|
647
|
+
// The supervisor's verdict on the fetch it was asked for. Only it can answer:
|
|
648
|
+
// the fetch is its child, and it is the process that will still be here when
|
|
649
|
+
// this one exits.
|
|
650
|
+
process.on("message", (m) => {
|
|
651
|
+
if (!restarting || !m || typeof m !== "object") return;
|
|
652
|
+
if (m.type === "upgrade-ready") {
|
|
653
|
+
clearTimeout(upgradeTimer);
|
|
654
|
+
// The replacement is on the machine now, so this is the last moment the
|
|
655
|
+
// port is worth holding: exiting hands it straight over.
|
|
656
|
+
write(`\n ${P.warn}${G.restart}${P.reset} ${P.muted}updating via npx${G.ellipsis}${P.reset}\n`);
|
|
657
|
+
shutdown(UPGRADE_CODE);
|
|
658
|
+
} else if (m.type === "upgrade-refused") {
|
|
659
|
+
abandonUpgrade(m.error);
|
|
660
|
+
}
|
|
661
|
+
});
|
|
662
|
+
|
|
663
|
+
// What a restart would land on. Read from disk now rather than remembered from
|
|
664
|
+
// boot, because the whole point is that the two differ.
|
|
665
|
+
function restartTarget() {
|
|
666
|
+
try { return JSON.parse(readFileSync(join(PKG_ROOT, "package.json"), "utf8")).version ?? null; }
|
|
667
|
+
catch { return null; }
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
// The three things `shutdown` has to tear down, named before the boot that
|
|
671
|
+
// fills them in rather than by it. From the line below onwards this process is
|
|
672
|
+
// answering HTTP, and /api/restart can therefore reach `shutdown` at any moment
|
|
673
|
+
// after it — including moments at which none of these exist yet. `let … = null`
|
|
674
|
+
// is what makes that a question shutdown can ask instead of a ReferenceError it
|
|
675
|
+
// dies of; the boot queue in requestRestart is what makes it a question it
|
|
676
|
+
// almost never has to ask. See #448.
|
|
677
|
+
let server = null;
|
|
678
|
+
let discovery = null;
|
|
679
|
+
let discoveryFile = null;
|
|
680
|
+
|
|
681
|
+
// This worker does not outlive the supervisor that started it (#702).
|
|
682
|
+
//
|
|
683
|
+
// Armed HERE rather than beside the signal handlers at the bottom of the file,
|
|
684
|
+
// for the reason the three `let`s above are where they are: this is the first
|
|
685
|
+
// line from which `shutdown` can be called without dying of a temporal dead
|
|
686
|
+
// zone, and the window that matters is the boot — the supervisor can be killed
|
|
687
|
+
// while the report is still printing, and a worker orphaned in there is a
|
|
688
|
+
// worker that binds its port a moment later and then holds it for a day.
|
|
689
|
+
//
|
|
690
|
+
// A no-op with nothing supervising us, which is `node bin/deck.js` run by hand:
|
|
691
|
+
// dieWithParent arms only when there is a channel, the same question SUPERVISED
|
|
692
|
+
// asks above. Shutdown code 0 because there is nobody left to read it — the
|
|
693
|
+
// point is only that discovery is unregistered and the port let go of on the
|
|
694
|
+
// way out, rather than left for the next boot's stale sweep.
|
|
695
|
+
dieWithParent(() => shutdown(0));
|
|
696
|
+
|
|
697
|
+
// The listen, begun HERE and awaited below the startup report rather than after
|
|
698
|
+
// it. The report is a narration; the port is the product, and it was queued
|
|
699
|
+
// behind three tool probes for no reason but the order these two statements
|
|
700
|
+
// were written in (#483).
|
|
701
|
+
//
|
|
702
|
+
// What that cost: `reportStartup` awaits `ensureCswap`, which on a machine with
|
|
703
|
+
// no Python tooling runs a real `uv tool install` under a 180-second timeout. So
|
|
704
|
+
// the deck printed its rows and then refused every connection until that install
|
|
705
|
+
// was over — on a first run, which is the one boot a new user judges the tool by.
|
|
706
|
+
// #476 stopped that job blocking the event loop; the socket was shut either way,
|
|
707
|
+
// because the bind had not been attempted yet.
|
|
708
|
+
//
|
|
709
|
+
// Nothing in the report has to finish before the socket opens, and the window
|
|
710
|
+
// this opens is narrow on purpose — the discovery file and the browser are both
|
|
711
|
+
// still written after the report, so the only callers who can arrive inside it
|
|
712
|
+
// are a tab left open by an earlier deck and the user's own curl:
|
|
713
|
+
//
|
|
714
|
+
// • the event log is replayed and the sequence counter primed INSIDE
|
|
715
|
+
// startServer, before it binds — so /events and /api/events answer from a
|
|
716
|
+
// full buffer from the first connection, not a growing one.
|
|
717
|
+
// • the hook install can only make hooks fire; a hook that fires early finds
|
|
718
|
+
// no discovery file and posts nowhere, exactly as it does today.
|
|
719
|
+
// • claude-swap: cswapBin memoizes only a lookup that WORKED, so a probe
|
|
720
|
+
// landing mid-install caches nothing and the next one asks again — see
|
|
721
|
+
// resetCswapBin's note. The accounts panel can report the tool missing for
|
|
722
|
+
// the second it is missing, and answers properly the moment it is not.
|
|
723
|
+
// • ccusage: getRunner awaits the very `_installing` promise primeCcusage
|
|
724
|
+
// started, so a request in this window joins that install rather than
|
|
725
|
+
// racing a second one.
|
|
726
|
+
//
|
|
727
|
+
// Settled into a tagged result rather than left bare, for the reason every job
|
|
728
|
+
// in startupWork carries its own handler: this promise now lives across the
|
|
729
|
+
// whole report, and a bind that fails in there with nothing attached to it is an
|
|
730
|
+
// unhandledRejection — which Node answers by killing the process over a port it
|
|
731
|
+
// could have named.
|
|
732
|
+
const starting = startServer({
|
|
733
|
+
port, persist, workspace, codex: wantCodex, claude: wantClaude,
|
|
734
|
+
// Withheld when nothing is supervising us: without a parent, exiting is just
|
|
735
|
+
// exiting, and /api/restart answers 501 so the UI hides the control.
|
|
736
|
+
onRestart: SUPERVISED ? requestRestart : null,
|
|
737
|
+
}).then(s => ({ ok: true, s }), err => ({ ok: false, err }));
|
|
738
|
+
|
|
739
|
+
// Once-per-session setup — hook install, tool probes, registry lookups, and the
|
|
740
|
+
// banner it runs underneath. A respawn is the same session continuing, so it
|
|
741
|
+
// skips the lot and prints one line instead. This is the difference between a
|
|
742
|
+
// restart that feels instant and one that makes you wonder whether it worked.
|
|
743
|
+
if (!RESPAWN) {
|
|
744
|
+
const jobs = startupWork();
|
|
745
|
+
await printBanner();
|
|
746
|
+
await reportStartup(jobs);
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
// Usually settled long ago by the time we get here, which is the point: `step`
|
|
750
|
+
// paints nothing for a promise that has already resolved, so the spinner this
|
|
751
|
+
// used to show is simply gone from the boots that were slow enough to need one.
|
|
752
|
+
const bound = await (RESPAWN ? starting : step(`starting server${G.ellipsis}`, starting));
|
|
753
|
+
if (!bound.ok) {
|
|
754
|
+
// stderr, not a row: a deck that could not bind is not a status line, and
|
|
755
|
+
// whatever launched it reads this stream.
|
|
756
|
+
console.error(`${PRODUCT}: server failed: ${bound.err.message}`);
|
|
757
|
+
process.exit(1);
|
|
758
|
+
}
|
|
759
|
+
server = bound.s;
|
|
760
|
+
const addr = server.address();
|
|
761
|
+
const realPort = typeof addr === "object" && addr ? addr.port : port;
|
|
762
|
+
const url = `http://127.0.0.1:${realPort}`;
|
|
763
|
+
|
|
764
|
+
// The supervisor re-launches with this on --port. It has to be the port we
|
|
765
|
+
// actually got, not the one we asked for: those differ whenever the first
|
|
766
|
+
// launch found 4317 taken, and re-launching on the requested port would move
|
|
767
|
+
// the deck out from under every open tab.
|
|
768
|
+
try { process.send?.({ type: "listening", port: realPort }); } catch { /* not supervised */ }
|
|
769
|
+
|
|
770
|
+
if (RESPAWN) {
|
|
771
|
+
write(` ${P.ok}${G.restart}${P.reset} ${P.muted}restarted ${G.arrow} ${P.reset}v${PKG_VERSION}${P.muted} ${G.bullet} ${link(url, url, LINKS)}${P.reset}\n`);
|
|
772
|
+
// A respawn skips the whole startup report, but not this: the argv is the
|
|
773
|
+
// same argv, the typo in it is still there, and a deck that mentioned it once
|
|
774
|
+
// and then went quiet for every restart afterwards is back to hiding it from
|
|
775
|
+
// anyone who was not watching the first boot.
|
|
776
|
+
reportUnknownFlags(flags.unknown);
|
|
777
|
+
reportIncompleteFlags(flags.incomplete);
|
|
778
|
+
} else {
|
|
779
|
+
// The URL is the one detail an ellipsis would destroy — half an address is
|
|
780
|
+
// not a shorter address — so it keeps its own line when the terminal is too
|
|
781
|
+
// narrow to hold it beside the label. See statusLine's `keep`.
|
|
782
|
+
write(row({
|
|
783
|
+
mark: G.ok, label: "server ready",
|
|
784
|
+
detail: link(url, url, LINKS), detailTone: `${P.accent}${P.bold}`, keep: true,
|
|
785
|
+
}));
|
|
786
|
+
if (persist) write(row({ label: "log", detail: fileLink(persist) }));
|
|
787
|
+
// Last of the rows, on purpose — see reportUnknownFlags.
|
|
788
|
+
reportUnknownFlags(flags.unknown);
|
|
789
|
+
reportIncompleteFlags(flags.incomplete);
|
|
790
|
+
// Only when one is actually being opened. Under --no-open — which is how an
|
|
791
|
+
// npx update relaunches, with a tab already waiting — this was announcing
|
|
792
|
+
// something that never happened.
|
|
793
|
+
if (openBrowser) write(`\n ${P.ok}${P.bold}${G.play} opening browser${G.ellipsis}${P.reset}\n\n`);
|
|
794
|
+
else write("\n");
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
// The discovery file is the whole of how a hook finds this deck: hook.js
|
|
798
|
+
// enumerates that directory and nothing else. Writing it once at boot meant
|
|
799
|
+
// anything that later took it away left a deck that listened, served, and
|
|
800
|
+
// received not one event — with nothing on screen to say so. So it is checked
|
|
801
|
+
// on a timer, put back when it goes missing, and its absence is stated out loud
|
|
802
|
+
// rather than left to look like an idle afternoon.
|
|
803
|
+
//
|
|
804
|
+
// The token goes in with the port: it is what lets a hook tell this deck from
|
|
805
|
+
// whatever else may later be listening on the same number. See hookToken().
|
|
806
|
+
//
|
|
807
|
+
// The log path goes in with them, so a hook can see which decks share one events
|
|
808
|
+
// log and elect a single writer for it. See electWriters in hook/hook.js. The
|
|
809
|
+
// Codex setting goes in for the half of that election no hook is part of: the
|
|
810
|
+
// rollout files this deck tails itself, which a --no-codex deck must never be
|
|
811
|
+
// elected to record. See writesCodexLog in src/server/log-writer.mjs.
|
|
812
|
+
let registered = null;
|
|
813
|
+
discovery = keepDiscovery({
|
|
814
|
+
port: realPort,
|
|
815
|
+
workspace,
|
|
816
|
+
token: hookToken(),
|
|
817
|
+
persist,
|
|
818
|
+
codex: wantCodex,
|
|
819
|
+
onState: (state) => {
|
|
820
|
+
const first = registered === null;
|
|
821
|
+
registered = state.ok;
|
|
822
|
+
if (!state.ok) reportUnregistered(state);
|
|
823
|
+
else if (!first) reportReregistered(state);
|
|
824
|
+
},
|
|
825
|
+
});
|
|
826
|
+
discoveryFile = discovery.file;
|
|
827
|
+
// Now, not in five seconds: nothing should reach the pulse line below without
|
|
828
|
+
// the deck knowing whether the hooks can see it.
|
|
829
|
+
await discovery.check();
|
|
830
|
+
|
|
831
|
+
// Never on a respawn: the tab that asked for the restart is still open and
|
|
832
|
+
// reconnecting on its own. A second one would be the deck talking over itself.
|
|
833
|
+
if (openBrowser && !RESPAWN) {
|
|
834
|
+
try {
|
|
835
|
+
const { default: open } = await import("open");
|
|
836
|
+
await open(url);
|
|
837
|
+
} catch {}
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
// ── Pulse indicator ───────────────────────────────────────────────────────────
|
|
841
|
+
// The whole line is rewritten each beat rather than just the dot: anything else
|
|
842
|
+
// on this deck that has something to say writes a newline first, and after that
|
|
843
|
+
// the line under the cursor is no longer the one we drew — a partial repaint
|
|
844
|
+
// would leave the message behind and pulse into empty space. Sized to the real
|
|
845
|
+
// terminal, because at 40 columns the old fixed 61-character line wrapped, and
|
|
846
|
+
// from then on \r only ever reached its second row.
|
|
847
|
+
if (MOTION) {
|
|
848
|
+
let pi = 0;
|
|
849
|
+
setInterval(() => {
|
|
850
|
+
// The colour follows the words. A Codex-only deck keeps saying "listening"
|
|
851
|
+
// when it is unregistered — see pulseText — and painting that sentence in
|
|
852
|
+
// the warning tone would restore the alarm the sentence just retired.
|
|
853
|
+
const alarm = !registered && wantClaude;
|
|
854
|
+
const text = pulseText({ registered, claude: wantClaude, columns: cols(), unicode: UNICODE });
|
|
855
|
+
const dot = pi++ % 2 === 0 ? (alarm ? P.warn : P.ok) : P.muted;
|
|
856
|
+
const tone = alarm ? P.warn : P.muted;
|
|
857
|
+
write(`\r ${dot}${G.pulse}${P.reset} ${tone}${text}${P.reset}`);
|
|
858
|
+
}, 800).unref();
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
// Boot is over. Everything `shutdown` tears down exists, so a restart can be
|
|
862
|
+
// run rather than held — and the server is told, so /api/restart stops
|
|
863
|
+
// describing a deck that is still assembling itself. This line is exactly where
|
|
864
|
+
// the window opened at the top of this file closes; see requestRestart.
|
|
865
|
+
booted = true;
|
|
866
|
+
markDeckReady();
|
|
867
|
+
runHeldRestart();
|
|
868
|
+
|
|
869
|
+
/**
|
|
870
|
+
* A declaration, not the `const` arrow this was for eight months.
|
|
871
|
+
*
|
|
872
|
+
* The difference is the whole of #448: a const is in its temporal dead zone
|
|
873
|
+
* until the line declaring it runs, and every line above — the startup report,
|
|
874
|
+
* the port report, the discovery file, the browser spawn — executes with the
|
|
875
|
+
* server already accepting connections. A restart arriving in that window
|
|
876
|
+
* called this and got `ReferenceError: Cannot access 'shutdown' before
|
|
877
|
+
* initialization`, and the latch it had already set is what made that
|
|
878
|
+
* permanent. A declaration is hoisted, so from the first instruction of this
|
|
879
|
+
* module there is a function here to call.
|
|
880
|
+
*
|
|
881
|
+
* Hoisting alone would only have moved the fault one line down, onto `server`,
|
|
882
|
+
* `discovery` and `discoveryFile` — which is why those are `let … = null` above
|
|
883
|
+
* and asked about rather than assumed here. Between them, this is callable at
|
|
884
|
+
* any instant of this process's life and cannot end in a throw for the caller
|
|
885
|
+
* to lose.
|
|
886
|
+
*/
|
|
887
|
+
async function shutdown(code = 0) {
|
|
888
|
+
// Also set as exitCode, not only passed to exit(): if the event loop empties
|
|
889
|
+
// on its own before either timer runs, Node would otherwise exit 0 and the
|
|
890
|
+
// supervisor would take that as "done" instead of "bring me back".
|
|
891
|
+
process.exitCode = code;
|
|
892
|
+
// Nothing inside a shutdown is worth staying alive for, and this one is
|
|
893
|
+
// called from three places that cannot handle a rejection — a signal handler,
|
|
894
|
+
// an IPC message handler, and a restart. An unhandled one there ends the
|
|
895
|
+
// process on Node's terms rather than ours, which is to say with the wrong
|
|
896
|
+
// exit code and therefore, half the time, without the supervisor bringing the
|
|
897
|
+
// deck back.
|
|
898
|
+
try {
|
|
899
|
+
// Before anything that can take time: a Ctrl+C the user has to watch for a
|
|
900
|
+
// second and a half is a second and a half without a cursor.
|
|
901
|
+
showCursor();
|
|
902
|
+
if (tty && code !== RESTART_CODE && code !== UPGRADE_CODE) {
|
|
903
|
+
write(`\n\n ${P.warn}${G.stop} shutting down${G.ellipsis}${P.reset}\n`);
|
|
904
|
+
}
|
|
905
|
+
// Stopped first, always: a tick landing after the unlink would re-register a
|
|
906
|
+
// deck that is on its way out, and leave the file behind for the hooks to
|
|
907
|
+
// find once nothing is listening.
|
|
908
|
+
//
|
|
909
|
+
// AWAITED, because clearing the interval only stops the NEXT tick. A tick
|
|
910
|
+
// that started a moment ago is inside the atomic write, and it re-creates
|
|
911
|
+
// the file after the unlink — the same stale registration, reached by the
|
|
912
|
+
// one route "stop first" does not cover. stop() answers with that check, so
|
|
913
|
+
// this waits for it and then removes what it wrote. Bounded: one check,
|
|
914
|
+
// which never rejects.
|
|
915
|
+
//
|
|
916
|
+
// Guarded on its own, because a discovery file this process cannot remove is
|
|
917
|
+
// a nuisance the next boot's stale sweep clears up — worth carrying on to
|
|
918
|
+
// the orderly close below rather than skipping to the abrupt one.
|
|
919
|
+
try {
|
|
920
|
+
await discovery?.stop();
|
|
921
|
+
if (discoveryFile) await removeDiscovery(discoveryFile);
|
|
922
|
+
} catch { /* the sweep at the next boot gets it */ }
|
|
923
|
+
// No server yet means nothing to drain and nothing to hand the port over to,
|
|
924
|
+
// so the exit is the whole of the shutdown.
|
|
925
|
+
if (!server) return process.exit(code);
|
|
926
|
+
server.close(() => process.exit(code));
|
|
927
|
+
// SSE connections never end by themselves, so close() alone would sit out the
|
|
928
|
+
// full 1500ms fallback on every restart. Hanging them up is safe — the stream
|
|
929
|
+
// sets retry: 1500 and replays from Last-Event-ID, so each tab reconnects and
|
|
930
|
+
// catches up without being told anything.
|
|
931
|
+
try { server.closeAllConnections?.(); } catch { /* Node < 18.2 */ }
|
|
932
|
+
setTimeout(() => process.exit(code), 1500).unref();
|
|
933
|
+
} catch {
|
|
934
|
+
process.exit(code);
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
process.on("SIGINT", () => shutdown(0));
|
|
938
|
+
process.on("SIGTERM", () => shutdown(0));
|
|
939
|
+
process.on("beforeExit", () => { discovery?.stop(); if (discoveryFile) removeDiscovery(discoveryFile); });
|
|
940
|
+
|
|
941
|
+
// ── helpers ───────────────────────────────────────────────────────────────────
|
|
942
|
+
|
|
943
|
+
// The deck is up and the discovery file could not be written. Said in full —
|
|
944
|
+
// the path, the reason — because the alternative is what this replaced: an
|
|
945
|
+
// ordinary-looking deck that simply never shows a session.
|
|
946
|
+
//
|
|
947
|
+
// What that costs depends on which CLI this deck watches, so the sentence comes
|
|
948
|
+
// from term.mjs, where both answers are written down and tested.
|
|
949
|
+
function reportUnregistered({ file, error }) {
|
|
950
|
+
const why = error?.message ? ` ${G.dash} ${error.message}` : "";
|
|
951
|
+
write(
|
|
952
|
+
`\n ${P.warn}${G.warn}${P.reset} ${P.bold}not registered${P.reset}${P.muted}${why}${P.reset}\n` +
|
|
953
|
+
` ${P.muted}${unregisteredDetail({ file, claude: wantClaude })}${P.reset}\n`,
|
|
954
|
+
);
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
function reportReregistered({ file }) {
|
|
958
|
+
write(`\n ${P.ok}${G.ok}${P.reset} ${P.muted}registered again ${G.arrow} ${fileLink(file)}${P.reset}\n`);
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
/**
|
|
962
|
+
* Every token the parser did not recognise, named, one row each.
|
|
963
|
+
*
|
|
964
|
+
* Said rather than acted on: the deck goes on booting and still exits 0. It is
|
|
965
|
+
* not a one-shot command that can afford the usual contract. `bin/agent-dag.js`
|
|
966
|
+
* hands its own argv to every worker it spawns — including the npx relaunch,
|
|
967
|
+
* which starts a NEWER version of the package on the argv the user typed
|
|
968
|
+
* against an older one — and the README recommends running it from a wrapper.
|
|
969
|
+
* Refusing to boot over one token would turn a typo into a dark dashboard, and
|
|
970
|
+
* an argument the newer build no longer knows into a failed upgrade that costs
|
|
971
|
+
* the port and the session. The file already holds that position once, in the
|
|
972
|
+
* `--all` branch: a flag the deck stopped needing is still accepted rather than
|
|
973
|
+
* made fatal.
|
|
974
|
+
*
|
|
975
|
+
* So it goes where the deck puts everything else it decided on your behalf —
|
|
976
|
+
* the startup report — and it goes at the END of it. reportStartup writes its
|
|
977
|
+
* rows in a fixed order and three more land underneath them (the server, the
|
|
978
|
+
* log, the browser), so a warning printed among those rows is a warning the
|
|
979
|
+
* rows scroll over. Here it is the last line before the pulse indicator takes
|
|
980
|
+
* the bottom of the screen and stops repainting anything above it.
|
|
981
|
+
*/
|
|
982
|
+
function reportUnknownFlags(unknown) {
|
|
983
|
+
for (const token of unknown) {
|
|
984
|
+
write(row({
|
|
985
|
+
mark: G.warn, tone: P.warn, label: "unknown option",
|
|
986
|
+
detail: `${token} ${G.dash} see \`${PRODUCT} --help\``,
|
|
987
|
+
}));
|
|
988
|
+
}
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
/**
|
|
992
|
+
* Every value-taking flag that was given no value it could use, named, one row
|
|
993
|
+
* each — and printed beside the unknown ones because it is the same failure
|
|
994
|
+
* wearing a different hat.
|
|
995
|
+
*
|
|
996
|
+
* #697: `--workspace`, `--history` and `--port` used to consume the following
|
|
997
|
+
* token whatever it was, so `ccdeck --workspace $PROJ --no-persist` with `PROJ`
|
|
998
|
+
* unset scoped the deck to a directory called `--no-persist`, kept persisting to
|
|
999
|
+
* the shared log, and reported neither. Nothing landed in `unknown`, because the
|
|
1000
|
+
* token that belonged there had been eaten. The parser refuses that value now
|
|
1001
|
+
* and lists the flag here instead.
|
|
1002
|
+
*
|
|
1003
|
+
* Said rather than acted on, under exactly the argument reportUnknownFlags makes
|
|
1004
|
+
* above: the flag falls back to its documented default and the deck still boots.
|
|
1005
|
+
* The row is what makes the fallback a decision the user can see, and the rows
|
|
1006
|
+
* around it show its consequence — `workspace (all)` and the `log` line are
|
|
1007
|
+
* printed by the same report.
|
|
1008
|
+
*/
|
|
1009
|
+
function reportIncompleteFlags(incomplete) {
|
|
1010
|
+
for (const { flag, expects } of incomplete ?? []) {
|
|
1011
|
+
write(row({
|
|
1012
|
+
mark: G.warn, tone: P.warn, label: "missing value",
|
|
1013
|
+
detail: `${flag} ${G.dash} expected ${expects}; using the default`,
|
|
1014
|
+
}));
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
function printHelp() {
|
|
1019
|
+
process.stdout.write(`${PRODUCT} — live deck of Claude Code + Codex agents
|
|
1020
|
+
|
|
1021
|
+
Usage:
|
|
1022
|
+
${PRODUCT} [options]
|
|
1023
|
+
|
|
1024
|
+
Options:
|
|
1025
|
+
-p, --port <number> Preferred port (default: 4317; falls back to random 4318–4400)
|
|
1026
|
+
--no-open Don't open the browser automatically
|
|
1027
|
+
--workspace <path> Only capture sessions whose cwd is inside <path>
|
|
1028
|
+
--scope Restrict to current working directory
|
|
1029
|
+
--all Capture every session (default)
|
|
1030
|
+
--history <path> Override events log file (default: ~/.claude/agent-dag/events.jsonl)
|
|
1031
|
+
--no-persist Don't write or replay events log (RAM-only)
|
|
1032
|
+
--codex Force-enable Codex capture even if ~/.codex/ missing
|
|
1033
|
+
--no-codex Skip Codex capture (Claude only)
|
|
1034
|
+
--claude Force-enable Claude capture even if Claude Code wasn't found
|
|
1035
|
+
--no-claude Skip Claude entirely: no hooks, no claude-swap, no accounts panel
|
|
1036
|
+
--uninstall Remove ${PRODUCT}'s hooks from ~/.claude/settings.json and
|
|
1037
|
+
~/.codex/hooks.json, and restore any sound hooks of yours it parked.
|
|
1038
|
+
Hook entries only: the forwarder script, ~/.claude/agent-dag/,
|
|
1039
|
+
the events log, ~/.agents-deck/ and claude-swap all stay
|
|
1040
|
+
-h, --help Show this help
|
|
1041
|
+
-v, --version Print the version and exit
|
|
1042
|
+
|
|
1043
|
+
Anything else on the command line is reported as an unknown option and then
|
|
1044
|
+
ignored: the deck still starts.
|
|
1045
|
+
|
|
1046
|
+
A flag that takes a value never swallows the next flag. If the value is missing,
|
|
1047
|
+
empty, or itself looks like a flag — \`${PRODUCT} --workspace \$UNSET --no-persist\`
|
|
1048
|
+
after the shell has dropped an unset variable — the flag is reported, left on its
|
|
1049
|
+
default, and the token it would have eaten is parsed as the flag it is.
|
|
1050
|
+
`);
|
|
1051
|
+
}
|