agent-dag 3.22.1 → 3.22.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -477
- package/package.json +14 -48
- package/shim.js +107 -0
- package/LICENSE +0 -661
- package/LICENSING.md +0 -82
- package/THIRD_PARTY_NOTICES.md +0 -395
- package/bin/agent-dag.js +0 -626
- package/bin/deck.js +0 -1805
- package/dist/web/assets/index-CJYsv0lr.css +0 -1
- package/dist/web/assets/index-Ifm23DDC.js +0 -270
- package/dist/web/index.html +0 -49
- package/hook/hook.js +0 -542
- package/release-notes.json +0 -398
- package/src/server/activity.mjs +0 -52
- package/src/server/agent-activity.mjs +0 -522
- package/src/server/args.mjs +0 -183
- package/src/server/auto-update.mjs +0 -79
- package/src/server/block-notify.mjs +0 -173
- package/src/server/boot-deadline.mjs +0 -127
- package/src/server/brand.mjs +0 -16
- package/src/server/browser-history.mjs +0 -497
- package/src/server/browser-presence.mjs +0 -211
- package/src/server/browser-profiles.mjs +0 -279
- package/src/server/browser-react.mjs +0 -284
- package/src/server/browser-watch-store.mjs +0 -350
- package/src/server/browser-watch.mjs +0 -905
- package/src/server/ccusage.mjs +0 -1168
- package/src/server/claude-accounts.mjs +0 -951
- package/src/server/claude-dir.mjs +0 -213
- package/src/server/codex-auth.mjs +0 -388
- package/src/server/codex-dir.mjs +0 -171
- package/src/server/codex-quota.mjs +0 -449
- package/src/server/codex-usage.mjs +0 -512
- package/src/server/cswap-admin.mjs +0 -1562
- package/src/server/cswap-auto.mjs +0 -658
- package/src/server/cswap-install.mjs +0 -641
- package/src/server/deck-home.mjs +0 -243
- package/src/server/deck-prefs.mjs +0 -301
- package/src/server/deck-probe.mjs +0 -111
- package/src/server/detach.mjs +0 -244
- package/src/server/exec.mjs +0 -996
- package/src/server/global-install.mjs +0 -67
- package/src/server/hwmonitor.mjs +0 -56
- package/src/server/index.mjs +0 -6043
- package/src/server/installer.mjs +0 -912
- package/src/server/invoked-as.mjs +0 -144
- package/src/server/lan-about.mjs +0 -119
- package/src/server/lan-engine.mjs +0 -952
- package/src/server/lan-reach.mjs +0 -256
- package/src/server/lan-socket.mjs +0 -682
- package/src/server/lan-sync.mjs +0 -941
- package/src/server/lhm-parse.mjs +0 -91
- package/src/server/log-tail.mjs +0 -139
- package/src/server/log-writer.mjs +0 -322
- package/src/server/login-service.mjs +0 -473
- package/src/server/macmon.mjs +0 -310
- package/src/server/npx.mjs +0 -264
- package/src/server/open-url.mjs +0 -242
- package/src/server/presence.mjs +0 -40
- package/src/server/quota.mjs +0 -792
- package/src/server/relay-guard.mjs +0 -507
- package/src/server/reset-label.mjs +0 -78
- package/src/server/retire-sound-hook.mjs +0 -349
- package/src/server/running-deck.mjs +0 -234
- package/src/server/self-update.mjs +0 -1380
- package/src/server/stop-deck.mjs +0 -171
- package/src/server/supervisor.mjs +0 -392
- package/src/server/system-metrics.mjs +0 -1825
- package/src/server/term.mjs +0 -686
- package/src/server/uv-bootstrap.mjs +0 -337
package/bin/deck.js
DELETED
|
@@ -1,1805 +0,0 @@
|
|
|
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, pulseDot, pulseText, spinnerFrames, statusLine, supportsHyperlinks, termColumns,
|
|
15
|
-
elapsedSuffix, sinceLabel, unicodeOK, unregisteredDetail, visibleWidth, 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
|
-
import { budget, bootDeadlineMs } from "../src/server/boot-deadline.mjs";
|
|
20
|
-
// A leaf — fs, path and claude-dir.mjs, nothing else — so it is imported here
|
|
21
|
-
// with the rest rather than fetched later. Deliberately NOT the other way
|
|
22
|
-
// round: it takes the handshake as a parameter precisely so that it never has
|
|
23
|
-
// to import the server. See the note at the top of that file.
|
|
24
|
-
import { asksForOwnDeck, runningDeck, versionNote } from "../src/server/running-deck.mjs";
|
|
25
|
-
|
|
26
|
-
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
27
|
-
const PKG_ROOT = resolve(__dirname, "..");
|
|
28
|
-
const PKG_VERSION = (() => {
|
|
29
|
-
try { return JSON.parse(readFileSync(join(PKG_ROOT, "package.json"), "utf8")).version ?? "0.0.0"; }
|
|
30
|
-
catch { return "0.0.0"; }
|
|
31
|
-
})();
|
|
32
|
-
|
|
33
|
-
// The command the user typed, handed down by the supervisor — our own argv[1]
|
|
34
|
-
// is this file under every one of the three names. Null when it cannot be
|
|
35
|
-
// proven, and null is the answer that prints nothing.
|
|
36
|
-
const INVOKED_AS = invokedName({ pkgRoot: PKG_ROOT });
|
|
37
|
-
|
|
38
|
-
const argv = process.argv.slice(2);
|
|
39
|
-
const flags = parseArgs(argv);
|
|
40
|
-
|
|
41
|
-
// Exit codes the supervisor reads as "bring me back": 75 from the files on
|
|
42
|
-
// disk, 76 through npx — which is the only way an npx run reaches a newer
|
|
43
|
-
// version, since its directory is never upgraded in place. Anything else it
|
|
44
|
-
// forwards.
|
|
45
|
-
const RESTART_CODE = 75;
|
|
46
|
-
const UPGRADE_CODE = 76;
|
|
47
|
-
const RESPAWN = process.env.AGENTS_DECK_RESPAWN === "1";
|
|
48
|
-
const SUPERVISED = typeof process.send === "function";
|
|
49
|
-
|
|
50
|
-
if (flags.help) {
|
|
51
|
-
printHelp();
|
|
52
|
-
process.exit(0);
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
// The version, on stdout, and then nothing — no hooks, no port, no browser.
|
|
56
|
-
//
|
|
57
|
-
// This is the first thing anyone types at a CLI they do not know and the second
|
|
58
|
-
// thing anyone types when filing a bug, and until now it was the one question
|
|
59
|
-
// the deck answered by starting a server and never exiting. The banner has
|
|
60
|
-
// always carried the number, but only underneath a running deck, which is not
|
|
61
|
-
// an answer: it cannot be piped, and the process it comes with has to be killed.
|
|
62
|
-
//
|
|
63
|
-
// Bare, unprefixed, one line: `ccdeck --version` is read by scripts as often as
|
|
64
|
-
// by people, and `node --version` style ornamentation is what those scripts
|
|
65
|
-
// then have to strip. PKG_VERSION is the same read the banner uses.
|
|
66
|
-
if (flags.version) {
|
|
67
|
-
console.log(PKG_VERSION);
|
|
68
|
-
process.exit(0);
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
if (flags.uninstall) {
|
|
72
|
-
const { uninstallHooks, hasCodexInstalled } = await import(pathToFileURL(join(PKG_ROOT, "src/server/installer.mjs")).href);
|
|
73
|
-
// Anything that could not be taken out. An uninstall that removed nothing
|
|
74
|
-
// because it could not read the file has not uninstalled anything, and both
|
|
75
|
-
// the wording and the exit code have to say so: a user who is told it worked
|
|
76
|
-
// and still has our hooks firing on every event is worse off than one who is
|
|
77
|
-
// told it failed, because they have stopped looking.
|
|
78
|
-
let refused = false;
|
|
79
|
-
// Files already reported as unparseable. The Claude hooks and the sound hook
|
|
80
|
-
// live in the SAME settings.json, so a stray comma refuses both, and printing
|
|
81
|
-
// the whole path-plus-parser-error twice buries the one line that differs —
|
|
82
|
-
// which of our two installations is still in there.
|
|
83
|
-
const named = new Set();
|
|
84
|
-
// Its own glyphs for the same reason the bad-port line has them: this block
|
|
85
|
-
// runs at module top level, well before `G` is declared (#797).
|
|
86
|
-
const { dash: gDash } = glyphs(unicodeOK());
|
|
87
|
-
/** Report one provider's outcome. `ok` first — see uninstallHooks. */
|
|
88
|
-
const report = (res, label) => {
|
|
89
|
-
if (res.ok === false) {
|
|
90
|
-
refused = true;
|
|
91
|
-
named.add(res.settingsPath);
|
|
92
|
-
console.error(`${PRODUCT}: ${label} hooks NOT removed ${gDash} ${res.settingsPath} could not be read as JSON (${res.why}).`);
|
|
93
|
-
console.error(`${PRODUCT}: the __agent-dag hook entries are still in that file and keep firing on every ${label} event.`);
|
|
94
|
-
return;
|
|
95
|
-
}
|
|
96
|
-
console.log(res.changed
|
|
97
|
-
? `${PRODUCT}: hooks removed from ${res.settingsPath}`
|
|
98
|
-
: `${PRODUCT}: no ${label} hooks to remove`);
|
|
99
|
-
};
|
|
100
|
-
report(await uninstallHooks({ provider: "claude" }), "Claude");
|
|
101
|
-
// The old finish sound was a second entry in the same file, marked
|
|
102
|
-
// __agent-dag-sound rather than __agent-dag, and uninstallHooks does not know
|
|
103
|
-
// that mark — so it used to be left behind, playing on every turn after the
|
|
104
|
-
// deck was supposedly gone. #704 retired the mechanism outright, but the entry
|
|
105
|
-
// is still on every machine that had it, so removing it is still this
|
|
106
|
-
// command's job. So is the other half: turning the sound on parked the user's
|
|
107
|
-
// own afplay/PowerShell Stop hooks, and once the deck is uninstalled nothing
|
|
108
|
-
// else on the machine knows where they went.
|
|
109
|
-
// The login item goes too, and this is the one place the uninstall's
|
|
110
|
-
// documented narrowness has to bend. "Hook entries only" is right for the
|
|
111
|
-
// event log and the port registry, which are data somebody may still want —
|
|
112
|
-
// but a login item left behind after an uninstall is not data, it is a
|
|
113
|
-
// machine that keeps starting a deck whose hooks were just removed.
|
|
114
|
-
{
|
|
115
|
-
const svc = await import(pathToFileURL(join(PKG_ROOT, "src/server/login-service.mjs")).href);
|
|
116
|
-
const { deckDataDir: dataDir } = await import(pathToFileURL(join(PKG_ROOT, "src/server/deck-home.mjs")).href);
|
|
117
|
-
if (svc.readServiceRecord(dataDir()) !== null) {
|
|
118
|
-
const gone = svc.uninstallService();
|
|
119
|
-
svc.writeServiceRecord(dataDir(), { removed: new Date().toISOString(), version: PKG_VERSION });
|
|
120
|
-
// Silent when there was nothing on the machine: an uninstall that reports
|
|
121
|
-
// removing a login item this deck never had is the same lie the explicit
|
|
122
|
-
// command used to tell, in the place a reader is least able to check it.
|
|
123
|
-
if (!gone.ok) {
|
|
124
|
-
// `gDash`, not an em dash: this block runs at module top level, on a
|
|
125
|
-
// console that may be the legacy Windows one, and #797 is the rule that
|
|
126
|
-
// a printed string never carries punctuation the terminal may not have.
|
|
127
|
-
console.error(`${PRODUCT}: could NOT remove the login item ${gDash} ${gone.reason} (${gone.path})`);
|
|
128
|
-
} else if (gone.existed) {
|
|
129
|
-
console.log(`${PRODUCT}: no longer starts at login`);
|
|
130
|
-
}
|
|
131
|
-
if (!gone.ok) refused = true;
|
|
132
|
-
}
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
const { retireSoundHook } = await import(pathToFileURL(join(PKG_ROOT, "src/server/retire-sound-hook.mjs")).href);
|
|
136
|
-
const sound = await retireSoundHook();
|
|
137
|
-
if (sound.removed) console.log(`${PRODUCT}: sound hook removed`);
|
|
138
|
-
if (sound.restored) console.log(`${PRODUCT}: restored ${sound.restored} of your own sound hook(s)`);
|
|
139
|
-
if (sound.ok === false) {
|
|
140
|
-
refused = true;
|
|
141
|
-
// Two different refusals, and saying the wrong one sends the user to the
|
|
142
|
-
// wrong file. `settings_unreadable` means nothing was touched at all — and
|
|
143
|
-
// when the forwarders already named that same file, the whole path and
|
|
144
|
-
// parser error would only bury the one line that differs. `parked_unreadable`
|
|
145
|
-
// is the other file: our entry IS out (the lines above said so), and what is
|
|
146
|
-
// still owed is the user's own hooks, which stay parked until they repair it.
|
|
147
|
-
if (sound.reason === "settings_unreadable") {
|
|
148
|
-
console.error(named.has(sound.settingsPath)
|
|
149
|
-
? `${PRODUCT}: the sound hook is still in that file too.`
|
|
150
|
-
: `${PRODUCT}: sound hook left in place — ${sound.message}`);
|
|
151
|
-
named.add(sound.settingsPath);
|
|
152
|
-
} else {
|
|
153
|
-
console.error(`${PRODUCT}: your own sound hooks were NOT restored ${gDash} ${sound.message}`);
|
|
154
|
-
}
|
|
155
|
-
}
|
|
156
|
-
if (hasCodexInstalled()) {
|
|
157
|
-
report(await uninstallHooks({ provider: "codex" }), "Codex");
|
|
158
|
-
}
|
|
159
|
-
// The remedy last and once, after every symptom above it, rather than once
|
|
160
|
-
// per refusal in the middle of the list.
|
|
161
|
-
if (named.size > 0) {
|
|
162
|
-
console.error(`${PRODUCT}: repair the JSON (or move the file aside), then run \`${PRODUCT} --uninstall\` again.`);
|
|
163
|
-
}
|
|
164
|
-
// Non-zero when any half of it refused, so `ccdeck --uninstall && …` and every
|
|
165
|
-
// CI step that runs this stops on the failure instead of continuing past it.
|
|
166
|
-
process.exit(refused ? 1 : 0);
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
// ── the two questions that are about another deck, not this one ──────────────
|
|
170
|
-
//
|
|
171
|
-
// One-shot commands in the shape `--uninstall` already established: do the
|
|
172
|
-
// thing, print, exit, and never start a server. They sit ABOVE the migration
|
|
173
|
-
// and well above the heavy imports, because a command that ends a deck has no
|
|
174
|
-
// business moving that deck's files on the way past, and because asking a
|
|
175
|
-
// server to stop should not require starting one.
|
|
176
|
-
//
|
|
177
|
-
// Their own glyphs and palette rather than `G`/`P`, for the reason the bad-port
|
|
178
|
-
// block below gives (#797): those are declared two hundred lines further down
|
|
179
|
-
// and this runs at module top level.
|
|
180
|
-
//
|
|
181
|
-
// WHICH DECK. The shape a bare `ccdeck` would have built — same `sameShape` the
|
|
182
|
-
// attach uses, so `ccdeck` and `ccdeck --stop` name the same deck and there is
|
|
183
|
-
// one model to hold. `--port <n>` names one directly; `--all` takes every deck
|
|
184
|
-
// on the machine. (`--all` is the legacy capture flag, a no-op since it became
|
|
185
|
-
// the default; beside `--stop` it can only mean this, and it is the word a
|
|
186
|
-
// person reaches for.)
|
|
187
|
-
if (flags.stop || flags.status || flags.logs || flags.install || flags.installService || flags.uninstallService) {
|
|
188
|
-
const { dash, ok: gOk, warn: gWarn, bullet, arrow, ellipsis: gEllipsis } = glyphs(unicodeOK());
|
|
189
|
-
const tone = palette(colorProfile({ isTTY: Boolean(process.stdout.isTTY) }));
|
|
190
|
-
const say = (line) => process.stdout.write(`${line}\n`);
|
|
191
|
-
|
|
192
|
-
const { deckDataDir, deckLogDir } = await import(pathToFileURL(join(PKG_ROOT, "src/server/deck-home.mjs")).href);
|
|
193
|
-
|
|
194
|
-
// ── --install ─────────────────────────────────────────────────────────────
|
|
195
|
-
//
|
|
196
|
-
// The one command that turns an npx run into a deck that comes back after a
|
|
197
|
-
// reboot. `npx ccdeck` cannot start at login — a login item must name a path
|
|
198
|
-
// that will still be there tomorrow, and npx runs out of a cache npm deletes
|
|
199
|
-
// whenever it likes — so this puts the package on PATH and points the service
|
|
200
|
-
// at THAT.
|
|
201
|
-
//
|
|
202
|
-
// BEHIND A FLAG, because `npx` means "run without installing" and a tool that
|
|
203
|
-
// installs itself anyway is the tool people uninstall. Somebody typed this.
|
|
204
|
-
if (flags.install) {
|
|
205
|
-
const gi = await import(pathToFileURL(join(PKG_ROOT, "src/server/global-install.mjs")).href);
|
|
206
|
-
const svc = await import(pathToFileURL(join(PKG_ROOT, "src/server/login-service.mjs")).href);
|
|
207
|
-
const { installedName } = await import(pathToFileURL(join(PKG_ROOT, "src/server/self-update.mjs")).href);
|
|
208
|
-
const { run } = await import(pathToFileURL(join(PKG_ROOT, "src/server/exec.mjs")).href);
|
|
209
|
-
// The name they typed, not ours. Three packages publish this deck, and
|
|
210
|
-
// installing `ccdeck` for somebody who ran `npx agent-dag` hands them a
|
|
211
|
-
// command they did not ask for.
|
|
212
|
-
const pkg = installedName(PKG_ROOT, PRODUCT);
|
|
213
|
-
|
|
214
|
-
say(`\n ${tone.muted}${dash} installing ${pkg} globally${gEllipsis}${tone.reset}`);
|
|
215
|
-
const got = await run("npm", ["i", "-g", pkg], { timeout: gi.INSTALL_TIMEOUT_MS });
|
|
216
|
-
if (!got?.ok) {
|
|
217
|
-
say(` ${tone.err}${gWarn} ${gi.installFailure(got, { pkg })}${tone.reset}\n`);
|
|
218
|
-
process.exit(1);
|
|
219
|
-
}
|
|
220
|
-
say(` ${tone.ok}${gOk}${tone.reset} ${pkg} is on your PATH${tone.muted} ${bullet} type \`${pkg}\` to start it${tone.reset}`);
|
|
221
|
-
|
|
222
|
-
// WHERE npm PUT IT, asked rather than assumed: a prefix the user set
|
|
223
|
-
// themselves is common and nothing here can guess it.
|
|
224
|
-
const root = gi.readGlobalRoot(await run("npm", ["root", "-g"], { timeout: 30_000 }));
|
|
225
|
-
const script = gi.globalScript(root, pkg);
|
|
226
|
-
if (!script) {
|
|
227
|
-
say(` ${tone.warn}${gWarn} installed, but npm did not say where ${dash} run \`${pkg} --install-service\` to start it at login${tone.reset}\n`);
|
|
228
|
-
process.exit(0);
|
|
229
|
-
}
|
|
230
|
-
const out = svc.installService({ script, logPath: join(deckLogDir(), "deck.log"), product: PRODUCT });
|
|
231
|
-
if (!out.ok) {
|
|
232
|
-
say(` ${tone.warn}${gWarn} installed, but it will not start at login ${dash} ${out.reason}${tone.reset}\n`);
|
|
233
|
-
process.exit(0);
|
|
234
|
-
}
|
|
235
|
-
svc.writeServiceRecord(deckDataDir(), { installed: PKG_VERSION, at: new Date().toISOString(), path: out.path });
|
|
236
|
-
say(` ${tone.ok}${gOk}${tone.reset} and starts when you log in${tone.muted} ${bullet} ${out.path}${tone.reset}`);
|
|
237
|
-
if (svc.lingerState() === "off") {
|
|
238
|
-
say(` ${tone.warn}${gWarn} systemd tears your session down at logout, so the deck goes with it.${tone.reset}`);
|
|
239
|
-
say(` ${tone.muted}\`sudo loginctl enable-linger $USER\` keeps it running when you are logged out.${tone.reset}`);
|
|
240
|
-
}
|
|
241
|
-
say(` ${tone.muted}\`${pkg} --uninstall-service\` undoes the login part${tone.reset}\n`);
|
|
242
|
-
process.exit(0);
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
// ── --install-service / --uninstall-service ───────────────────────────────
|
|
246
|
-
// Answered before the registry is read: neither one is about a deck that is
|
|
247
|
-
// running, and both are as meaningful on a machine with no deck up as on one
|
|
248
|
-
// with three.
|
|
249
|
-
if (flags.installService || flags.uninstallService) {
|
|
250
|
-
const svc = await import(pathToFileURL(join(PKG_ROOT, "src/server/login-service.mjs")).href);
|
|
251
|
-
const { isGitCheckout, isNpxInstall } = await import(pathToFileURL(join(PKG_ROOT, "src/server/self-update.mjs")).href);
|
|
252
|
-
if (flags.uninstallService) {
|
|
253
|
-
const out = svc.uninstallService();
|
|
254
|
-
// Recorded either way. The record is what stops the next ordinary start
|
|
255
|
-
// putting back what was just taken away, and a tool that argues with its
|
|
256
|
-
// user about a login item is a tool that gets uninstalled entirely.
|
|
257
|
-
svc.writeServiceRecord(deckDataDir(), { removed: new Date().toISOString(), version: PKG_VERSION });
|
|
258
|
-
// `existed` rather than the record: the record says what THIS tool last
|
|
259
|
-
// did, and the machine is what actually has a login item on it. Somebody
|
|
260
|
-
// who removed the plist by hand should be told the truth about the
|
|
261
|
-
// machine, not about our bookkeeping.
|
|
262
|
-
say(!out.ok
|
|
263
|
-
? `\n ${tone.err}${gWarn} could not remove it ${dash} ${out.reason}${tone.reset}\n`
|
|
264
|
-
: out.existed
|
|
265
|
-
? `\n ${tone.ok}${gOk}${tone.reset} no longer starts at login${tone.muted} ${bullet} ${out.path}${tone.reset}\n`
|
|
266
|
-
: `\n ${tone.muted}${dash} it was not starting at login${tone.reset}\n`);
|
|
267
|
-
process.exit(out.ok ? 0 : 1);
|
|
268
|
-
}
|
|
269
|
-
if (isGitCheckout(PKG_ROOT)) {
|
|
270
|
-
// Not refused outright — somebody running from a checkout may genuinely
|
|
271
|
-
// want this — but not done silently either: the item would name a working
|
|
272
|
-
// tree, and that is worth knowing before it is written.
|
|
273
|
-
say(`\n ${tone.warn}${gWarn} this is a checkout, so the login item would name ${PKG_ROOT}${tone.reset}`);
|
|
274
|
-
say(` ${tone.muted}a renamed, moved or deleted working tree leaves a login item pointing at nothing${tone.reset}\n`);
|
|
275
|
-
}
|
|
276
|
-
if (isNpxInstall(PKG_ROOT)) {
|
|
277
|
-
// The item would name a path inside ~/.npm/_npx/<hash>/, which npm deletes
|
|
278
|
-
// whenever it feels like it — a login item pointing at nothing, forever,
|
|
279
|
-
// on a machine where nothing was ever installed.
|
|
280
|
-
say(`\n ${tone.warn}${gWarn} an npx run cannot start at login ${dash} its files live in npm's cache and are deleted without warning.${tone.reset}`);
|
|
281
|
-
say(` ${tone.muted}install it first: \`npm i -g ${INVOKED_AS ?? PRODUCT}\`${tone.reset}\n`);
|
|
282
|
-
process.exit(1);
|
|
283
|
-
}
|
|
284
|
-
const out = svc.installService({
|
|
285
|
-
script: join(PKG_ROOT, "bin", "agent-dag.js"),
|
|
286
|
-
logPath: join(deckLogDir(), "deck.log"),
|
|
287
|
-
product: PRODUCT,
|
|
288
|
-
});
|
|
289
|
-
if (!out.ok) {
|
|
290
|
-
say(`\n ${tone.err}${gWarn} could not set it up ${dash} ${out.reason}${tone.reset}\n`);
|
|
291
|
-
process.exit(1);
|
|
292
|
-
}
|
|
293
|
-
svc.writeServiceRecord(deckDataDir(), { installed: PKG_VERSION, at: new Date().toISOString(), path: out.path });
|
|
294
|
-
say(`\n ${tone.ok}${gOk}${tone.reset} starts when you log in${tone.muted} ${bullet} ${out.path}${tone.reset}`);
|
|
295
|
-
if (out.how === "file-only") {
|
|
296
|
-
// The file is on disk and both launchd and systemd read their directories
|
|
297
|
-
// at the next login, so this works from then on. Said rather than hidden:
|
|
298
|
-
// "it will work tomorrow" is a different promise from "it works now".
|
|
299
|
-
say(` ${tone.warn}${gWarn} not started now ${dash} ${out.reason}. It will come up at your next login.${tone.reset}`);
|
|
300
|
-
}
|
|
301
|
-
// The one place the three platforms genuinely differ in what this buys you.
|
|
302
|
-
if (svc.lingerState() === "off") {
|
|
303
|
-
say(` ${tone.warn}${gWarn} systemd tears your session down at logout, so the deck goes with it.${tone.reset}`);
|
|
304
|
-
say(` ${tone.muted}\`sudo loginctl enable-linger $USER\` keeps it running when you are logged out.${tone.reset}`);
|
|
305
|
-
}
|
|
306
|
-
say(` ${tone.muted}\`${INVOKED_AS ?? PRODUCT} --uninstall-service\` undoes it${tone.reset}\n`);
|
|
307
|
-
process.exit(0);
|
|
308
|
-
}
|
|
309
|
-
const { canonicalLogPath } = await import(pathToFileURL(join(PKG_ROOT, "src/server/log-writer.mjs")).href);
|
|
310
|
-
const { hasCodexInstalled } = await import(pathToFileURL(join(PKG_ROOT, "src/server/installer.mjs")).href);
|
|
311
|
-
const { hasClaudeInstalled } = await import(pathToFileURL(join(PKG_ROOT, "src/server/claude-dir.mjs")).href);
|
|
312
|
-
const { liveDecks, sameShape } = await import(pathToFileURL(join(PKG_ROOT, "src/server/running-deck.mjs")).href);
|
|
313
|
-
|
|
314
|
-
// The default shape, spelled the same way the boot below spells it. NOT the
|
|
315
|
-
// shape of the flags on THIS command line: `--stop --no-codex` is not a
|
|
316
|
-
// request to stop a Codex-less deck, it is a flag that means nothing here,
|
|
317
|
-
// and reading it as a selector would make `--stop` miss the deck it was
|
|
318
|
-
// pointed at and say "nothing is running".
|
|
319
|
-
const mine = {
|
|
320
|
-
workspace: "",
|
|
321
|
-
persist: canonicalLogPath(join(deckLogDir(), "events.jsonl")),
|
|
322
|
-
codex: hasCodexInstalled(),
|
|
323
|
-
claude: hasClaudeInstalled(),
|
|
324
|
-
};
|
|
325
|
-
|
|
326
|
-
// ── --logs ────────────────────────────────────────────────────────────────
|
|
327
|
-
// What the deck wrote where a terminal would have shown it. Printed RAW,
|
|
328
|
-
// escapes and all: the launcher passes its own colour tier down to a detached
|
|
329
|
-
// deck, so this file is a mirror of the terminal the deck was started from
|
|
330
|
-
// and re-rendering it is the whole point. The path goes last, because that is
|
|
331
|
-
// the line somebody copies into `tail -f`.
|
|
332
|
-
if (flags.logs) {
|
|
333
|
-
const { readFileSync: readLog, statSync: statLog } = await import("node:fs");
|
|
334
|
-
const logPath = join(deckLogDir(), "deck.log");
|
|
335
|
-
let text = null;
|
|
336
|
-
try { text = readLog(logPath, "utf8"); } catch { /* never started, or swept */ }
|
|
337
|
-
if (text === null) {
|
|
338
|
-
say(`\n ${tone.muted}${dash} nothing logged yet ${dash} ${logPath}${tone.reset}\n`);
|
|
339
|
-
process.exit(0);
|
|
340
|
-
}
|
|
341
|
-
// The tail, not the file. It is truncated at the first start on an idle
|
|
342
|
-
// machine, so it is normally small — but an attach appends to a running
|
|
343
|
-
// deck's log every time, and a long-lived deck's log is somebody's week.
|
|
344
|
-
const lines = text.split("\n");
|
|
345
|
-
const shown = lines.slice(Math.max(0, lines.length - 200));
|
|
346
|
-
if (shown.length < lines.length) {
|
|
347
|
-
say(` ${tone.muted}${dash} showing the last ${shown.length} of ${lines.length} lines${tone.reset}`);
|
|
348
|
-
}
|
|
349
|
-
process.stdout.write(shown.join("\n"));
|
|
350
|
-
if (!text.endsWith("\n")) say("");
|
|
351
|
-
const size = (() => { try { return statLog(logPath).size; } catch { return 0; } })();
|
|
352
|
-
say(` ${tone.muted}${dash} ${logPath} ${bullet} ${size} bytes${tone.reset}\n`);
|
|
353
|
-
process.exit(0);
|
|
354
|
-
}
|
|
355
|
-
|
|
356
|
-
const decks = await liveDecks().catch(() => []);
|
|
357
|
-
const age = (d) => sinceLabel(Date.now() - Date.parse(d.startedAt ?? ""));
|
|
358
|
-
const where = (d) => (d.workspace ? d.workspace : "(all)");
|
|
359
|
-
const url = (d) => `http://127.0.0.1:${d.port}`;
|
|
360
|
-
|
|
361
|
-
if (flags.status) {
|
|
362
|
-
if (!decks.length) {
|
|
363
|
-
say(`\n ${tone.muted}${dash} no deck is running ${dash} \`${INVOKED_AS ?? PRODUCT}\` starts one${tone.reset}\n`);
|
|
364
|
-
process.exit(0);
|
|
365
|
-
}
|
|
366
|
-
// The one a bare `ccdeck` would open is marked, because with two decks up
|
|
367
|
-
// that is the only question this command is really being asked.
|
|
368
|
-
const opens = decks.find(d => sameShape(d, mine)) ?? null;
|
|
369
|
-
say("");
|
|
370
|
-
for (const d of decks) {
|
|
371
|
-
// The version chunk is dropped rather than printed as "v?" for a deck too
|
|
372
|
-
// old to publish one — same rule as the attach line, and for the same
|
|
373
|
-
// reason: a question mark beside two real facts reads as a fault.
|
|
374
|
-
const head = [d.version ? `v${d.version}` : "", `pid ${d.pid}`, `up ${age(d)}`]
|
|
375
|
-
.filter(Boolean).join(` ${bullet} `);
|
|
376
|
-
const mark = d === opens ? `${tone.ok}${gOk}${tone.reset}` : `${tone.muted}${bullet}${tone.reset}`;
|
|
377
|
-
const tail = d === opens ? `${tone.muted} ${arrow} \`${INVOKED_AS ?? PRODUCT}\` opens this one${tone.reset}` : "";
|
|
378
|
-
say(` ${mark} ${tone.muted}${head}${tone.reset}${tail}`);
|
|
379
|
-
say(` ${tone.accent}${tone.bold}${url(d)}${tone.reset}`);
|
|
380
|
-
say(` ${tone.muted}${where(d)} ${bullet} ${d.persist ?? "no log (--no-persist)"}${tone.reset}`);
|
|
381
|
-
}
|
|
382
|
-
say("");
|
|
383
|
-
process.exit(0);
|
|
384
|
-
}
|
|
385
|
-
|
|
386
|
-
// ── --stop ────────────────────────────────────────────────────────────────
|
|
387
|
-
const { stopDeck } = await import(pathToFileURL(join(PKG_ROOT, "src/server/stop-deck.mjs")).href);
|
|
388
|
-
// `flags.port`, not the `rawPort` computed below: that folds in
|
|
389
|
-
// AGENT_DAG_PORT, which is how somebody RUNS a deck rather than which deck
|
|
390
|
-
// they mean to stop — and a deck started on a custom port still has the
|
|
391
|
-
// default SHAPE, so the matcher finds it without help. It is also declared
|
|
392
|
-
// below this block, which would make reading it here a ReferenceError (#797).
|
|
393
|
-
const named = flags.port != null && isPortValue(flags.port) ? Number(flags.port) : null;
|
|
394
|
-
const wanted = flags.all
|
|
395
|
-
? decks
|
|
396
|
-
: named !== null
|
|
397
|
-
? decks.filter(d => d.port === named)
|
|
398
|
-
: decks.filter(d => sameShape(d, mine)).slice(0, 1);
|
|
399
|
-
|
|
400
|
-
if (!wanted.length) {
|
|
401
|
-
// Three different silences, and saying the wrong one sends the reader
|
|
402
|
-
// looking in the wrong place. A machine with no deck at all is not a
|
|
403
|
-
// machine whose deck is scoped differently.
|
|
404
|
-
const why = !decks.length
|
|
405
|
-
? `no deck is running`
|
|
406
|
-
: named !== null
|
|
407
|
-
? `no deck is listening on ${named} ${dash} \`${INVOKED_AS ?? PRODUCT} --status\` lists them`
|
|
408
|
-
: `no deck of this shape is running ${dash} \`${INVOKED_AS ?? PRODUCT} --status\` lists the ${decks.length} that ${decks.length === 1 ? "is" : "are"}`;
|
|
409
|
-
say(`\n ${tone.muted}${dash} ${why}${tone.reset}\n`);
|
|
410
|
-
process.exit(0);
|
|
411
|
-
}
|
|
412
|
-
|
|
413
|
-
say("");
|
|
414
|
-
let refused = false;
|
|
415
|
-
for (const d of wanted) {
|
|
416
|
-
const was = age(d);
|
|
417
|
-
const out = await stopDeck(d);
|
|
418
|
-
if (!out.ok) {
|
|
419
|
-
refused = true;
|
|
420
|
-
say(` ${tone.err}${gWarn} could not stop pid ${d.pid} on ${d.port} ${dash} ${out.reason}${tone.reset}`);
|
|
421
|
-
continue;
|
|
422
|
-
}
|
|
423
|
-
// HOW it went out, not just that it did. "asked" means the deck closed its
|
|
424
|
-
// listener, unlinked its registration and left the LAN cleanly; anything
|
|
425
|
-
// else means none of that happened and the next boot has litter to sweep.
|
|
426
|
-
const how = out.how === "asked"
|
|
427
|
-
? ""
|
|
428
|
-
: out.old
|
|
429
|
-
? ` ${tone.muted}(${out.how} ${dash} that deck predates \`--stop\`)${tone.reset}`
|
|
430
|
-
: ` ${tone.muted}(${out.how} ${dash} it did not answer)${tone.reset}`;
|
|
431
|
-
say(` ${tone.ok}${gOk}${tone.reset} stopped${tone.muted} ${bullet} pid ${d.pid} ${bullet} port ${d.port} ${bullet} was up ${was}${tone.reset}${how}`);
|
|
432
|
-
}
|
|
433
|
-
|
|
434
|
-
// What is still up, named. A command that ends one of three decks and says
|
|
435
|
-
// only "stopped" leaves the reader believing the machine is clear.
|
|
436
|
-
const left = decks.filter(d => !wanted.includes(d));
|
|
437
|
-
if (left.length) {
|
|
438
|
-
say("");
|
|
439
|
-
say(` ${tone.muted}${dash} ${left.length} other deck${left.length === 1 ? "" : "s"} still running:${tone.reset}`);
|
|
440
|
-
for (const d of left) say(` ${tone.muted}pid ${d.pid} ${bullet} ${d.port} ${bullet} ${where(d)}${tone.reset}`);
|
|
441
|
-
say(` ${tone.muted}\`${INVOKED_AS ?? PRODUCT} --stop --port <n>\` ends one ${bullet} \`--stop --all\` ends every deck${tone.reset}`);
|
|
442
|
-
}
|
|
443
|
-
say("");
|
|
444
|
-
process.exit(refused ? 1 : 0);
|
|
445
|
-
}
|
|
446
|
-
|
|
447
|
-
// The port, and the one piece of argv the deck really does refuse to boot over.
|
|
448
|
-
//
|
|
449
|
-
// It refused before too — `--port banana` and `--port --no-open` both became
|
|
450
|
-
// `Number(…)` → `NaN`, which survived the whole startup (hooks installed,
|
|
451
|
-
// claude-swap installed, ccusage probed) and then killed the process from inside
|
|
452
|
-
// `listen` with Node's own wording: "options.port should be >= 0 and < 65536.
|
|
453
|
-
// Received type number (NaN)." That names neither the flag nor the value the
|
|
454
|
-
// user typed, and arrives after a page of green ticks. Same outcome, said here:
|
|
455
|
-
// early, in the deck's own voice, quoting the flag and the value back.
|
|
456
|
-
//
|
|
457
|
-
// An empty `AGENT_DAG_PORT` is an unset one — a variable that did not expand is
|
|
458
|
-
// not a request for port zero. `--port ""` never reaches this, because the
|
|
459
|
-
// parser records an empty value as `incomplete` and leaves the flag unset.
|
|
460
|
-
const envPort = process.env.AGENT_DAG_PORT?.trim();
|
|
461
|
-
const rawPort = flags.port ?? (envPort ? envPort : null);
|
|
462
|
-
if (rawPort != null && !isPortValue(rawPort)) {
|
|
463
|
-
const named = flags.port != null ? "--port" : "AGENT_DAG_PORT";
|
|
464
|
-
// Its own glyphs, not `G` — that is declared a hundred lines below and this
|
|
465
|
-
// runs at module top level, so reaching for it here would be a temporal dead
|
|
466
|
-
// zone and a ReferenceError on the one path that reports a bad port. Both
|
|
467
|
-
// helpers read the environment and nothing this file has parsed yet, so
|
|
468
|
-
// asking twice on a path that exits immediately costs nothing (#797).
|
|
469
|
-
const { dash } = glyphs(unicodeOK());
|
|
470
|
-
console.error(`${PRODUCT}: ${named} ${rawPort}: not a port number ${dash} expected 0-65535.`);
|
|
471
|
-
process.exit(1);
|
|
472
|
-
}
|
|
473
|
-
const port = rawPort == null ? 4317 : Number(rawPort);
|
|
474
|
-
// Default = machine-wide (capture every CC session on this box). Pass
|
|
475
|
-
// `--workspace <path>` (or `--scope`) to restrict to a single tree. Canonicalized
|
|
476
|
-
// just below, once the module that owns that rule is loaded.
|
|
477
|
-
const rawWorkspace = flags.workspace != null
|
|
478
|
-
? flags.workspace
|
|
479
|
-
: (flags.scope ? process.cwd() : "");
|
|
480
|
-
const openBrowser = flags.noOpen !== true;
|
|
481
|
-
// The events log lives beside the discovery files, so it follows the Claude
|
|
482
|
-
// config dir rather than assuming ~/.claude — see src/server/claude-dir.mjs.
|
|
483
|
-
const { claudeConfigDir, hasClaudeInstalled } =
|
|
484
|
-
await import(pathToFileURL(join(PKG_ROOT, "src/server/claude-dir.mjs")).href);
|
|
485
|
-
|
|
486
|
-
// ── the deck's own files, brought to their own directory ────────────────────
|
|
487
|
-
//
|
|
488
|
-
// BEFORE ANYTHING READS THEM, which is why it is here and not in the server:
|
|
489
|
-
// index.mjs reads the preferences at module load, so a migration inside it
|
|
490
|
-
// would run after the file it is meant to find had already been looked for in
|
|
491
|
-
// the wrong place. deck-home.mjs owns the reasoning; the shape of it is that
|
|
492
|
-
// nothing is deleted and a destination that exists is never touched, so this is
|
|
493
|
-
// safe to run on every start and does nothing at all on all but the first.
|
|
494
|
-
//
|
|
495
|
-
// A failure here is reported and then ignored. A read-only home or a missing
|
|
496
|
-
// permission is not a reason for the deck not to start — the old paths are
|
|
497
|
-
// still there, and the worst case is a deck that keeps using them.
|
|
498
|
-
const { deckDataDir, deckLogDir, legacyDeckDir, migrateDeckFiles, sweepTempFiles } =
|
|
499
|
-
await import(pathToFileURL(join(PKG_ROOT, "src/server/deck-home.mjs")).href);
|
|
500
|
-
{
|
|
501
|
-
const fsp = await import("node:fs/promises");
|
|
502
|
-
const legacy = legacyDeckDir();
|
|
503
|
-
const data = deckDataDir();
|
|
504
|
-
const log = deckLogDir();
|
|
505
|
-
const { moved } = await migrateDeckFiles({
|
|
506
|
-
from: legacy, data, log, fs: fsp,
|
|
507
|
-
onError: (name, err) => console.error(`${PRODUCT}: could not move ${name}:`, err?.message ?? err),
|
|
508
|
-
});
|
|
509
|
-
if (moved.length) console.error(`${PRODUCT}: moved ${moved.join(", ")} to ${data}`);
|
|
510
|
-
// The litter an atomic write leaves when its process is killed between the
|
|
511
|
-
// write and the rename. Nothing has ever swept it, because the code that
|
|
512
|
-
// makes it is not running any more when it is made.
|
|
513
|
-
await sweepTempFiles({ dirs: [legacy, data, log], fs: fsp }).catch(() => 0);
|
|
514
|
-
}
|
|
515
|
-
// CANONICALISED here rather than left as typed: the discovery file publishes
|
|
516
|
-
// this path so the hook can tell which decks share one log and elect a single
|
|
517
|
-
// writer for it, and two spellings of one file would read as two files.
|
|
518
|
-
// startServer takes the value from here, so the two always name the same file.
|
|
519
|
-
//
|
|
520
|
-
// `resolve` alone was not enough and #793 is what that cost — see
|
|
521
|
-
// canonicalLogPath, which owns the rule and explains it beside the election it
|
|
522
|
-
// serves.
|
|
523
|
-
const { canonicalLogPath } = await import(pathToFileURL(join(PKG_ROOT, "src/server/log-writer.mjs")).href);
|
|
524
|
-
const persist = flags.noPersist
|
|
525
|
-
? null
|
|
526
|
-
// The log follows the deck rather than Claude Code now — a hundred megabytes
|
|
527
|
-
// of rotating events was never configuration, and it was living in another
|
|
528
|
-
// application's configuration directory. `--history` still wins, and the
|
|
529
|
-
// canonical form is still what the discovery file publishes, so decks that
|
|
530
|
-
// share a path still elect one writer for it.
|
|
531
|
-
: canonicalLogPath(flags.history ?? join(deckLogDir(), "events.jsonl"));
|
|
532
|
-
|
|
533
|
-
const { installHooks, keepDiscovery, removeDiscovery, hasCodexInstalled } =
|
|
534
|
-
await import(pathToFileURL(join(PKG_ROOT, "src/server/installer.mjs")).href);
|
|
535
|
-
// CODEX_SESSIONS_DIR comes along because the banner below names the directory
|
|
536
|
-
// the watcher tails, and the watcher lives in that module. Recomputing the path
|
|
537
|
-
// here is how the banner came to print ~/.codex/sessions on machines whose
|
|
538
|
-
// sessions are somewhere else entirely — see the row further down.
|
|
539
|
-
const { startServer, hookToken, releaseRestart, markDeckReady, CODEX_SESSIONS_DIR, canonicalWorkspace } =
|
|
540
|
-
await import(pathToFileURL(join(PKG_ROOT, "src/server/index.mjs")).href);
|
|
541
|
-
|
|
542
|
-
// Resolved here rather than left as typed, for the reason the events log above
|
|
543
|
-
// is: the discovery file publishes this path, and the hook that reads it runs in
|
|
544
|
-
// a process whose cwd is the agent's — so a relative `--workspace ./sub` meant
|
|
545
|
-
// one directory to the Codex watcher inside this process and a different one per
|
|
546
|
-
// agent to the hook. One canonical spelling, computed in the one process that
|
|
547
|
-
// knows what the user meant, is what both capture paths compare against. See
|
|
548
|
-
// canonicalWorkspace.
|
|
549
|
-
const workspace = canonicalWorkspace(rawWorkspace);
|
|
550
|
-
|
|
551
|
-
// Whether the server starts the Codex rollout watcher. Nothing is installed
|
|
552
|
-
// and no directory is created either way — Codex hooks are not used any more,
|
|
553
|
-
// so `--codex` only means "watch even though ~/.codex/ is not there yet",
|
|
554
|
-
// which is the right answer for a machine where Codex arrives later.
|
|
555
|
-
const wantCodex = flags.noCodex
|
|
556
|
-
? false
|
|
557
|
-
: (flags.codex === true || hasCodexInstalled());
|
|
558
|
-
|
|
559
|
-
// The same question for the other CLI, and the one nobody was asking. README
|
|
560
|
-
// offers "Claude Code CLI or OpenAI Codex CLI (or both)"; a Codex-only machine
|
|
561
|
-
// nonetheless got a Python account-switcher installed for a CLI it does not
|
|
562
|
-
// have, an accounts panel open on first run, and a banner line telling it to
|
|
563
|
-
// sign into that CLI (#402). Everything the deck installs or opens on the
|
|
564
|
-
// Claude side now hangs off this one answer, and it is stated in the banner so
|
|
565
|
-
// a wrong answer is visible rather than mysterious.
|
|
566
|
-
//
|
|
567
|
-
// `--claude` is the escape hatch for a false negative, which is the failure
|
|
568
|
-
// that matters: hasClaudeInstalled looks for the binary and for traces of use,
|
|
569
|
-
// and a machine that hides Claude Code from both would otherwise lose its hooks
|
|
570
|
-
// with no way to ask for them back. `--no-claude` is the opt-out the Claude side
|
|
571
|
-
// never had — the mirror of --no-codex — and it is also what a Codex-only user
|
|
572
|
-
// with a settings.json the installer refuses to rewrite needs, since that
|
|
573
|
-
// refusal is fatal at boot on a component they do not use.
|
|
574
|
-
const wantClaude = flags.noClaude
|
|
575
|
-
? false
|
|
576
|
-
: (flags.claude === true || hasClaudeInstalled());
|
|
577
|
-
|
|
578
|
-
const WEB_DIST = join(PKG_ROOT, "dist", "web", "index.html");
|
|
579
|
-
if (!existsSync(WEB_DIST)) {
|
|
580
|
-
console.error(`${PRODUCT}: ui not built. run \`npm run build\` (or \`pnpm build\`) first.`);
|
|
581
|
-
process.exit(1);
|
|
582
|
-
}
|
|
583
|
-
|
|
584
|
-
// ── the terminal we are printing into ─────────────────────────────────────────
|
|
585
|
-
// Asked once, degraded from there — see src/server/term.mjs, which is where all
|
|
586
|
-
// of this is decided and asserted. Below this point the deck writes no escape of
|
|
587
|
-
// its own: colour comes from `P`, glyphs from `G`, layout from statusLine. That
|
|
588
|
-
// is what makes NO_COLOR, a pipe, a CI log and a legacy Windows console one
|
|
589
|
-
// question rather than thirty separate ones nobody remembers to ask.
|
|
590
|
-
const tty = Boolean(process.stdout.isTTY);
|
|
591
|
-
const PROFILE = colorProfile({ isTTY: tty });
|
|
592
|
-
const P = palette(PROFILE);
|
|
593
|
-
const UNICODE = unicodeOK();
|
|
594
|
-
const G = glyphs(UNICODE);
|
|
595
|
-
const LINKS = supportsHyperlinks({ profile: PROFILE });
|
|
596
|
-
// The terminal's prefers-reduced-motion: nothing sleeps, spins or repaints in a
|
|
597
|
-
// pipe, under CI, or with NO_COLOR set.
|
|
598
|
-
const MOTION = motionOK({ isTTY: tty, profile: PROFILE });
|
|
599
|
-
const write = (s) => process.stdout.write(s);
|
|
600
|
-
// Read per line, never cached: a terminal can be resized while the deck runs,
|
|
601
|
-
// and the pulse below is still on screen hours later.
|
|
602
|
-
const cols = () => termColumns(process.stdout);
|
|
603
|
-
const sleep = ms => new Promise(r => setTimeout(r, ms));
|
|
604
|
-
const fileLink = (path) => link(path, pathToFileURL(path).href, LINKS);
|
|
605
|
-
|
|
606
|
-
/**
|
|
607
|
-
* What is still happening after the report ended, in three words or so.
|
|
608
|
-
*
|
|
609
|
-
* #742 left one job able to outlive the boot — an install of claude-swap that
|
|
610
|
-
* the report stopped waiting for — and the terminal said nothing about it once
|
|
611
|
-
* the rows were done. That is the wrong way round: the row that had finished
|
|
612
|
-
* was the one blinking, and the thing that really was working sat still. Now
|
|
613
|
-
* the pulse line carries the label while the job runs and drops it when the job
|
|
614
|
-
* settles, which is also what decides whether the line moves at all. Declared
|
|
615
|
-
* here rather than beside `registered` below, because reportStartup sets it and
|
|
616
|
-
* runs long before that line does.
|
|
617
|
-
*/
|
|
618
|
-
let pulseBusy = null;
|
|
619
|
-
|
|
620
|
-
// ── the cursor ────────────────────────────────────────────────────────────────
|
|
621
|
-
// Hidden for as long as anything of ours is moving — the reveal, the spinner,
|
|
622
|
-
// the pulse — and put back on every way out of this process: the ordinary exit,
|
|
623
|
-
// all three signals, and an uncaught throw, which reaches 'exit' after Node has
|
|
624
|
-
// printed it. Half of this is worse than none: a deck that dies with the cursor
|
|
625
|
-
// hidden leaves the user's shell with no cursor and nothing to do about it but
|
|
626
|
-
// `reset`.
|
|
627
|
-
let cursorHidden = false;
|
|
628
|
-
const showCursor = () => {
|
|
629
|
-
if (!cursorHidden) return;
|
|
630
|
-
cursorHidden = false;
|
|
631
|
-
try { write(CURSOR_SHOW); } catch { /* stdout is gone; nothing left to restore */ }
|
|
632
|
-
};
|
|
633
|
-
if (MOTION) { cursorHidden = true; write(CURSOR_HIDE); }
|
|
634
|
-
process.on("exit", showCursor);
|
|
635
|
-
// SIGHUP is the one signal this process does not otherwise handle, so its
|
|
636
|
-
// default action would end us before 'exit' could run. Handled only to put the
|
|
637
|
-
// cursor back and then die of it exactly as before — the supervisor reads the
|
|
638
|
-
// signal, not an exit code.
|
|
639
|
-
process.on("SIGHUP", () => { showCursor(); dieOfSignal("SIGHUP"); });
|
|
640
|
-
|
|
641
|
-
// ── rows ──────────────────────────────────────────────────────────────────────
|
|
642
|
-
// The status column is computed from the longest label. It used to be counted
|
|
643
|
-
// into each string as trailing spaces, so any new row, or any label a character
|
|
644
|
-
// longer, silently broke the alignment of every other one.
|
|
645
|
-
const LABELS = [
|
|
646
|
-
"workspace", "Claude hooks", "Codex sessions", "claude-swap", "accounts",
|
|
647
|
-
"ccusage", "update", "name", "server ready", "log", "unknown option",
|
|
648
|
-
"missing value",
|
|
649
|
-
];
|
|
650
|
-
const LABEL_W = labelColumn(LABELS);
|
|
651
|
-
|
|
652
|
-
function row({ mark = " ", tone = P.ok, label = "", detail = "", detailTone = P.muted, keep = false }) {
|
|
653
|
-
return statusLine({
|
|
654
|
-
mark, label, detail, keep, labelWidth: LABEL_W, columns: cols(), ellipsis: G.ellipsis,
|
|
655
|
-
paint: {
|
|
656
|
-
mark: (s) => `${tone}${s}${P.reset}`,
|
|
657
|
-
detail: (s) => `${detailTone}${s}${P.reset}`,
|
|
658
|
-
},
|
|
659
|
-
}) + "\n";
|
|
660
|
-
}
|
|
661
|
-
|
|
662
|
-
// ── the wordmark ──────────────────────────────────────────────────────────────
|
|
663
|
-
|
|
664
|
-
/**
|
|
665
|
-
* Hold anything else that wants to speak until the art is finished.
|
|
666
|
-
*
|
|
667
|
-
* #742, found while watching a first run through a pty: the startup jobs run
|
|
668
|
-
* UNDER the reveal on purpose, and one of them failing writes to console.error
|
|
669
|
-
* the moment it fails — which put
|
|
670
|
-
*
|
|
671
|
-
* ccdeck ccusage: install failed: npm install ccusage failed: spawn npm ENOENT
|
|
672
|
-
*
|
|
673
|
-
* between the second and third rows of the logo. A wordmark with a stack of
|
|
674
|
-
* someone else's bad news through the middle of it is the first thing a new
|
|
675
|
-
* user sees, and it reads as a crash rather than as a note.
|
|
676
|
-
*
|
|
677
|
-
* The window is the reveal and nothing else — about 180ms — so at worst a
|
|
678
|
-
* message arrives a fifth of a second later than it would have, on the one
|
|
679
|
-
* stretch of the boot where there is nowhere for it to go. Restored in a
|
|
680
|
-
* `finally`, so a throw inside the reveal cannot leave the process mute.
|
|
681
|
-
*/
|
|
682
|
-
function holdConsole() {
|
|
683
|
-
const held = [];
|
|
684
|
-
const real = { warn: console.warn, error: console.error, log: console.log };
|
|
685
|
-
for (const k of Object.keys(real)) console[k] = (...args) => { held.push([k, args]); };
|
|
686
|
-
return () => {
|
|
687
|
-
Object.assign(console, real);
|
|
688
|
-
for (const [k, args] of held) real[k](...args);
|
|
689
|
-
};
|
|
690
|
-
}
|
|
691
|
-
|
|
692
|
-
async function printBanner() {
|
|
693
|
-
const { lines } = wordmark({ columns: cols(), version: PKG_VERSION, profile: PROFILE, unicode: UNICODE, pal: P });
|
|
694
|
-
const release = holdConsole();
|
|
695
|
-
try {
|
|
696
|
-
for (const line of lines) {
|
|
697
|
-
write(line + "\n");
|
|
698
|
-
// A reveal, not a wait. The once-per-session work is already running under
|
|
699
|
-
// it (see startupWork), so the art costs the boot nothing and the deck is
|
|
700
|
-
// ready about when the last row lands. What used to be here — 560ms of
|
|
701
|
-
// spinner at "loading…" before a single art line — was dead time in a tool
|
|
702
|
-
// whose documented entry point is `npx ccdeck`.
|
|
703
|
-
if (MOTION && line) await sleep(45);
|
|
704
|
-
}
|
|
705
|
-
} finally {
|
|
706
|
-
release();
|
|
707
|
-
}
|
|
708
|
-
}
|
|
709
|
-
|
|
710
|
-
// ── a step, with a spinner only if it is slow enough to need one ──────────────
|
|
711
|
-
// The interval's first frame is 80ms away, so anything already settled when we
|
|
712
|
-
// get here paints nothing at all and the row below is the only trace of it.
|
|
713
|
-
|
|
714
|
-
async function step(label, work) {
|
|
715
|
-
if (!MOTION) return work;
|
|
716
|
-
const frames = spinnerFrames(UNICODE);
|
|
717
|
-
// Kept inside the terminal: a label that wraps is a label the \r below can
|
|
718
|
-
// only half erase, and what is left of it stays under the row that follows.
|
|
719
|
-
// Six columns for the indent and the spinner, four more so the elapsed
|
|
720
|
-
// seconds have somewhere to go without pushing the label off the edge.
|
|
721
|
-
const text = fit(label, cols() - 10, G.ellipsis);
|
|
722
|
-
const started = Date.now();
|
|
723
|
-
let i = 0;
|
|
724
|
-
let widest = 0;
|
|
725
|
-
const iv = setInterval(() => {
|
|
726
|
-
const line = ` ${P.accent}${frames[i++ % frames.length]}${P.reset} ${P.muted}${text}${elapsedSuffix(Date.now() - started)}${P.reset}`;
|
|
727
|
-
widest = Math.max(widest, visibleWidth(line));
|
|
728
|
-
write(`\r${line}`);
|
|
729
|
-
}, 80);
|
|
730
|
-
try {
|
|
731
|
-
return await work;
|
|
732
|
-
} finally {
|
|
733
|
-
clearInterval(iv);
|
|
734
|
-
// Cleared rather than overwritten: the row that follows is a different
|
|
735
|
-
// length, and relying on it to be the longer of the two is how a spinner
|
|
736
|
-
// leaves its own tail on screen. Measured rather than computed, because the
|
|
737
|
-
// line grows when the elapsed seconds appear and again when they reach two
|
|
738
|
-
// digits. Nothing to clear if it never painted.
|
|
739
|
-
if (i) write("\r" + " ".repeat(widest) + "\r");
|
|
740
|
-
}
|
|
741
|
-
}
|
|
742
|
-
|
|
743
|
-
/**
|
|
744
|
-
* The once-per-session work, all of it started at once and none of it awaited.
|
|
745
|
-
*
|
|
746
|
-
* Hook install, the claude-swap probe and the registry lookup have nothing to
|
|
747
|
-
* do with each other and nothing to do with the wordmark, so they run underneath
|
|
748
|
-
* the reveal instead of queueing behind it — the animation then costs the boot
|
|
749
|
-
* nothing and the deck is ready about when the last art row lands. Every one of
|
|
750
|
-
* them is given its rejection handler here, at the moment it is created, since a
|
|
751
|
-
* promise that settles before anything awaits it is otherwise an unhandled
|
|
752
|
-
* rejection.
|
|
753
|
-
*/
|
|
754
|
-
function startupWork() {
|
|
755
|
-
// Every job below this line serves Claude Code and only Claude Code: the
|
|
756
|
-
// hooks go in Claude Code's settings.json, claude-swap switches Claude
|
|
757
|
-
// accounts, and ccusage reads Claude Code's own session logs. On a machine
|
|
758
|
-
// without Claude Code all three are work done for a CLI that is not there —
|
|
759
|
-
// two of them installs the user did not ask for — so they are not started at
|
|
760
|
-
// all rather than started and then reported as failures. `null` is how each
|
|
761
|
-
// one says "not attempted", which reportStartup tells apart from "tried and
|
|
762
|
-
// could not".
|
|
763
|
-
|
|
764
|
-
// Settings the installer cannot parse are settings it cannot rewrite without
|
|
765
|
-
// losing them, so it refuses — and that refusal is reported rather than
|
|
766
|
-
// thrown, because it is the only thing the user can act on.
|
|
767
|
-
const hooks = wantClaude
|
|
768
|
-
? installHooks({ provider: "claude" }).then(v => ({ ok: true, v }), err => ({ ok: false, err }))
|
|
769
|
-
: Promise.resolve(null);
|
|
770
|
-
|
|
771
|
-
// claude-swap backs the multi-account panel, and an empty store leaves that
|
|
772
|
-
// panel useless even when the tool is there — so the account already signed
|
|
773
|
-
// in is registered once. Bounded inside seedFirstAccount: empty store only,
|
|
774
|
-
// once ever, never with NO_INSTALL set.
|
|
775
|
-
//
|
|
776
|
-
// `cswapInstalling` is the other half, and it is what keeps a first run from
|
|
777
|
-
// spending the boot's whole deadline on a question that has already been
|
|
778
|
-
// answered: ensureCswap resolves it the moment it commits to an install, so
|
|
779
|
-
// the report can stop waiting then rather than eight seconds later. It never
|
|
780
|
-
// settles on the machines where there is nothing to install, which is every
|
|
781
|
-
// machine after the first run.
|
|
782
|
-
let sayInstalling;
|
|
783
|
-
const cswapInstalling = new Promise(r => { sayInstalling = r; });
|
|
784
|
-
|
|
785
|
-
const cswap = (async () => {
|
|
786
|
-
if (!wantClaude) return null;
|
|
787
|
-
const { ensureCswap } = await import(pathToFileURL(join(PKG_ROOT, "src/server/cswap-install.mjs")).href);
|
|
788
|
-
const cs = await ensureCswap({ onInstalling: () => sayInstalling() });
|
|
789
|
-
const usable = cs.state === "present" || cs.state === "installed" || cs.state === "upgrading";
|
|
790
|
-
if (!usable) return { cs, seed: null };
|
|
791
|
-
const { seedFirstAccount } = await import(pathToFileURL(join(PKG_ROOT, "src/server/claude-accounts.mjs")).href);
|
|
792
|
-
return { cs, seed: await seedFirstAccount().catch(() => ({ state: "failed" })) };
|
|
793
|
-
})().catch(() => null);
|
|
794
|
-
|
|
795
|
-
// ccusage backs the usage-history modal. Primed at boot rather than on first
|
|
796
|
-
// open so a cold machine pays the install while the deck is still starting.
|
|
797
|
-
// Nothing is lost by skipping the prime: runCcusage falls back to npx, so the
|
|
798
|
-
// modal still answers if it is ever opened — it just pays the wait itself.
|
|
799
|
-
const ccusage = (async () => {
|
|
800
|
-
if (!wantClaude) return null;
|
|
801
|
-
if (process.env.AGENTS_DECK_NO_INSTALL === "1") return null;
|
|
802
|
-
const { primeCcusage } = await import(pathToFileURL(join(PKG_ROOT, "src/server/ccusage.mjs")).href);
|
|
803
|
-
return primeCcusage();
|
|
804
|
-
})().catch(() => null);
|
|
805
|
-
|
|
806
|
-
// A newer release on npm, said once, in the place the upgrade gets typed.
|
|
807
|
-
// Hard-capped so a slow registry cannot delay the server — the answer is
|
|
808
|
-
// usually already cached in ~/.agents-deck/.self-update-check anyway. It has
|
|
809
|
-
// to resolve BEFORE the pulse indicator starts writing over the last line.
|
|
810
|
-
const update = Promise.race([
|
|
811
|
-
import(pathToFileURL(join(PKG_ROOT, "src/server/self-update.mjs")).href)
|
|
812
|
-
.then(m => m.versionReport({ running: PKG_VERSION, pkgRoot: PKG_ROOT }))
|
|
813
|
-
.then(r => (r?.notice?.kind === "upgrade" ? r : null))
|
|
814
|
-
.catch(() => null),
|
|
815
|
-
new Promise(r => setTimeout(() => r(null), 1200)),
|
|
816
|
-
]);
|
|
817
|
-
|
|
818
|
-
return { hooks, cswap, cswapInstalling, ccusage, update };
|
|
819
|
-
}
|
|
820
|
-
|
|
821
|
-
/** The same work, said out loud, in a fixed order — a boot whose rows arrive in
|
|
822
|
-
* whatever order the network settled is a boot nobody can scan twice. */
|
|
823
|
-
async function reportStartup(jobs) {
|
|
824
|
-
// What the whole report may spend waiting, shared by every job under it
|
|
825
|
-
// rather than granted to each — four jobs at eight seconds each is a
|
|
826
|
-
// thirty-two second boot that no individual deadline would object to.
|
|
827
|
-
const left = budget(bootDeadlineMs());
|
|
828
|
-
|
|
829
|
-
write(row({
|
|
830
|
-
mark: G.ok, label: "workspace",
|
|
831
|
-
detail: workspace === "" ? "(all)" : workspace,
|
|
832
|
-
detailTone: workspace === "" ? P.warn : P.muted,
|
|
833
|
-
}));
|
|
834
|
-
|
|
835
|
-
const hooks = await step(`installing Claude hooks${G.ellipsis}`, jobs.hooks);
|
|
836
|
-
if (hooks === null) {
|
|
837
|
-
// Said in the same shape as the Codex row below, because it is the same
|
|
838
|
-
// sentence: this deck is not watching that CLI, and here is why. It also
|
|
839
|
-
// retires the one boot failure a Codex-only machine could hit — an
|
|
840
|
-
// unparseable or unwritable settings.json used to exit(1) below, killing a
|
|
841
|
-
// deck over a file belonging to a CLI the user does not run.
|
|
842
|
-
write(row({ label: "Claude hooks", detail: `skipped ${G.dash} no Claude Code found, or --no-claude` }));
|
|
843
|
-
} else if (!hooks.ok) {
|
|
844
|
-
// The file it names is one only the user can repair, and every Claude Code
|
|
845
|
-
// session on this machine is reading it too.
|
|
846
|
-
write(row({ mark: G.fail, tone: P.err, label: "Claude hooks", detail: "not installed" }));
|
|
847
|
-
// THE WAY OUT, printed where the wall is. The installer's message is good —
|
|
848
|
-
// the path, the reason, "fix the file or move it aside" — and it is a file
|
|
849
|
-
// the user may not be able to edit: root-owned, on read-only media, or
|
|
850
|
-
// simply not theirs. This deck knows the remedy and used to keep it in a
|
|
851
|
-
// comment: --no-claude runs everything else, which on a Codex-only machine
|
|
852
|
-
// is the whole deck, over a settings.json belonging to a CLI they do not
|
|
853
|
-
// use.
|
|
854
|
-
console.error(`\n ${PRODUCT}: ${hooks.err.message}`);
|
|
855
|
-
console.error(` Or start with --no-claude to run without Claude hooks.\n`);
|
|
856
|
-
process.exit(1);
|
|
857
|
-
} else {
|
|
858
|
-
write(row({ mark: G.ok, label: "Claude hooks", detail: fileLink(hooks.v.hookPath) }));
|
|
859
|
-
}
|
|
860
|
-
|
|
861
|
-
// Codex CLI hooks never fire on Windows (sandbox refuses to spawn the hook
|
|
862
|
-
// command). Instead the server tails Codex's rollout JSONL files directly, so
|
|
863
|
-
// there's nothing to install and no /hooks trust step. We just confirm Codex
|
|
864
|
-
// is present and let the watcher pick up sessions.
|
|
865
|
-
if (wantCodex) {
|
|
866
|
-
// The directory the watcher actually tails, imported from the module that
|
|
867
|
-
// owns it rather than rebuilt from homedir() here. CODEX_HOME relocates the
|
|
868
|
-
// whole tree, and this row is the only diagnostic the deck prints about
|
|
869
|
-
// Codex — so when sessions do not show up, a path computed a second way
|
|
870
|
-
// sends the user to inspect a directory the deck never opened.
|
|
871
|
-
write(row({ mark: G.ok, label: "Codex sessions", detail: `watching ${fileLink(CODEX_SESSIONS_DIR)}` }));
|
|
872
|
-
} else {
|
|
873
|
-
write(row({ label: "Codex sessions", detail: `skipped ${G.dash} no ~/.codex/, or --no-codex` }));
|
|
874
|
-
}
|
|
875
|
-
|
|
876
|
-
// Bounded, because this is the job that made a first boot look hung: on a
|
|
877
|
-
// machine with neither claude-swap nor a Python toolchain it fetches a uv
|
|
878
|
-
// binary and then builds an environment with it, and the report used to wait
|
|
879
|
-
// out both. See src/server/boot-deadline.mjs. Nothing is cancelled — the
|
|
880
|
-
// install carries on and says how it went when it knows.
|
|
881
|
-
const swapWait = await step(`checking claude-swap${G.ellipsis}`, Promise.race([
|
|
882
|
-
left.within(jobs.cswap),
|
|
883
|
-
// The install announcing itself. Not a timeout — a decision, arriving in
|
|
884
|
-
// about a second on the boot that would otherwise have paid the full
|
|
885
|
-
// deadline for news it already had.
|
|
886
|
-
jobs.cswapInstalling.then(() => ({ done: false, installing: true })),
|
|
887
|
-
]));
|
|
888
|
-
if (swapWait.done) writeSwapRows(swapWait.value);
|
|
889
|
-
else {
|
|
890
|
-
write(row({
|
|
891
|
-
label: "claude-swap",
|
|
892
|
-
// Both halves earn their place, and both have to survive an 80-column
|
|
893
|
-
// terminal: what the job is doing, and that waiting for it is not the
|
|
894
|
-
// user's problem. The row that only said the first is the row this
|
|
895
|
-
// replaces — a spinner at "checking claude-swap…" says that much.
|
|
896
|
-
detail: swapWait.installing
|
|
897
|
-
? `installing in the background ${G.dash} the deck is ready`
|
|
898
|
-
: `still setting up ${G.dash} the deck is ready`,
|
|
899
|
-
}));
|
|
900
|
-
// Handed to the pulse line, which is the only thing still on screen once
|
|
901
|
-
// the rows are done — and taken back the moment the job settles, whichever
|
|
902
|
-
// way it settled.
|
|
903
|
-
pulseBusy = swapWait.installing ? "installing claude-swap" : "setting up claude-swap";
|
|
904
|
-
jobs.cswap.then(
|
|
905
|
-
late => { pulseBusy = null; writeSwapRows(late, { late: true }); },
|
|
906
|
-
() => { pulseBusy = null; },
|
|
907
|
-
);
|
|
908
|
-
}
|
|
909
|
-
|
|
910
|
-
const cu = await left.within(jobs.ccusage);
|
|
911
|
-
writeCcusageRow(cu.done ? cu.value : null);
|
|
912
|
-
|
|
913
|
-
const upgrade = await jobs.update;
|
|
914
|
-
if (upgrade) {
|
|
915
|
-
write(row({
|
|
916
|
-
mark: G.up, tone: P.warn, label: "update",
|
|
917
|
-
detail: `v${upgrade.notice.to} available ${G.dash} ${upgrade.command}`,
|
|
918
|
-
}));
|
|
919
|
-
}
|
|
920
|
-
|
|
921
|
-
// Which name this deck was started under, when that is knowable — a notice,
|
|
922
|
-
// never a refusal. 95% of installs are on the two old names and the update
|
|
923
|
-
// path runs through this very process, so a build that declined to boot under
|
|
924
|
-
// one of them would kill the deck on the machine where the deck is what would
|
|
925
|
-
// have explained why. Nothing at all is printed wherever the typed name
|
|
926
|
-
// cannot be proven (a Windows global install, a git checkout): telling
|
|
927
|
-
// somebody who already types `ccdeck` to type `ccdeck` is the one failure
|
|
928
|
-
// that would make this row worth ignoring. See src/server/invoked-as.mjs.
|
|
929
|
-
const rename = renameNotice({ invoked: INVOKED_AS, pkgRoot: PKG_ROOT, dash: G.dash });
|
|
930
|
-
if (rename) {
|
|
931
|
-
write(row({ mark: G.warn, tone: P.warn, label: "name", detail: rename.said }));
|
|
932
|
-
// The line that carries the value: for a global install there is nothing to
|
|
933
|
-
// install and nothing to download, only six different characters to type.
|
|
934
|
-
write(row({ label: "", detail: rename.fix }));
|
|
935
|
-
}
|
|
936
|
-
}
|
|
937
|
-
|
|
938
|
-
/**
|
|
939
|
-
* The claude-swap rows, wherever in the boot they end up being printed.
|
|
940
|
-
*
|
|
941
|
-
* `late` is the one difference, and it is not cosmetic: by the time a late row
|
|
942
|
-
* arrives the pulse indicator owns the last line and repaints it with `\r`, so
|
|
943
|
-
* a row written without a newline first would be drawn over on the next beat.
|
|
944
|
-
* Every other thing that speaks after boot — reportUnregistered,
|
|
945
|
-
* reportReregistered — opens with the same newline for the same reason.
|
|
946
|
-
*/
|
|
947
|
-
function writeSwapRows(swap, { late = false } = {}) {
|
|
948
|
-
const cs = swap?.cs;
|
|
949
|
-
// Collected rather than written one at a time, because a late report opens
|
|
950
|
-
// with a newline and there is exactly one of those however many rows follow.
|
|
951
|
-
let out = "";
|
|
952
|
-
if (!wantClaude) {
|
|
953
|
-
// claude-swap is a Python tool that switches Claude Code accounts, and the
|
|
954
|
-
// deck used to fetch a uv binary to install it on machines with no Claude
|
|
955
|
-
// Code at all. Saying so is the point of the row: it is the one place a
|
|
956
|
-
// user can learn that the accounts panel is missing on purpose.
|
|
957
|
-
out += row({ label: "claude-swap", detail: `skipped ${G.dash} accounts are Claude-only` });
|
|
958
|
-
} else if (cs?.state === "present") {
|
|
959
|
-
out += row({ mark: G.ok, label: "claude-swap", detail: `v${cs.version} (accounts panel enabled)` });
|
|
960
|
-
} else if (cs?.state === "installed") {
|
|
961
|
-
out += row({ mark: G.ok, label: "claude-swap", detail: `installed v${cs.version} via ${cs.via}` });
|
|
962
|
-
} else if (cs?.state === "upgrading") {
|
|
963
|
-
out += row({ mark: G.ok, label: "claude-swap", detail: `v${cs.version}, upgrading to v${cs.latest} in background` });
|
|
964
|
-
} else if (cs?.state === "skipped") {
|
|
965
|
-
out += row({ mark: G.ok, label: "claude-swap", detail: "not installed (AGENTS_DECK_NO_INSTALL=1)" });
|
|
966
|
-
} else {
|
|
967
|
-
const how = cs?.reason === "no_installer"
|
|
968
|
-
? `not installed ${G.dash} the accounts panel needs it`
|
|
969
|
-
: cs?.reason === "not_on_path"
|
|
970
|
-
? `installed via ${cs.via} but not on PATH ${G.dash} add ${
|
|
971
|
-
process.platform === "win32" ? "%USERPROFILE%\\.local\\bin" : "~/.local/bin"
|
|
972
|
-
}`
|
|
973
|
-
: `install failed${cs?.via ? ` via ${cs.via}` : ""}`;
|
|
974
|
-
out += row({ mark: G.fail, tone: P.warn, label: "claude-swap", detail: how });
|
|
975
|
-
// A URL is not an answer when someone just wants the panel to work. Print
|
|
976
|
-
// the command for THIS machine, picked from what is already on it.
|
|
977
|
-
if (cs?.hint) out += row({ label: "", detail: cs.hint });
|
|
978
|
-
}
|
|
979
|
-
|
|
980
|
-
if (swap?.seed?.state === "added") {
|
|
981
|
-
out += row({ mark: G.ok, label: "accounts", detail: "registered the signed-in account (cswap add)" });
|
|
982
|
-
} else if (swap?.seed?.state === "failed" || swap?.seed?.state === "nothing-to-add") {
|
|
983
|
-
out += row({ label: "accounts", detail: `panel empty ${G.dash} sign in to Claude Code, then run cswap add` });
|
|
984
|
-
}
|
|
985
|
-
|
|
986
|
-
write(late ? "\n" + out : out);
|
|
987
|
-
}
|
|
988
|
-
|
|
989
|
-
/**
|
|
990
|
-
* The ccusage row.
|
|
991
|
-
*
|
|
992
|
-
* `null` covers both of the ways there is nothing to say — the job was not
|
|
993
|
-
* attempted, and the job had not answered by the time the boot's deadline ran
|
|
994
|
-
* out. Neither deserves a row: unlike claude-swap there is no install to wait
|
|
995
|
-
* for here, because primeCcusage starts one and returns without it, so a
|
|
996
|
-
* ccusage that is slow to answer is slow at resolving a path and will be
|
|
997
|
-
* resolved again the first time the usage modal is opened.
|
|
998
|
-
*/
|
|
999
|
-
function writeCcusageRow(cu) {
|
|
1000
|
-
if (cu?.state === "present") write(row({ mark: G.ok, label: "ccusage", detail: `v${cu.version}` }));
|
|
1001
|
-
else if (cu?.state === "updating") write(row({ mark: G.ok, label: "ccusage", detail: `v${cu.version}, checking for update` }));
|
|
1002
|
-
// A ccusage the user provided, named rather than versioned — reading a
|
|
1003
|
-
// version out of it means running it, and a status row is not worth a spawn.
|
|
1004
|
-
// Naming the file is the more useful half anyway: it is the answer to "which
|
|
1005
|
-
// ccusage is this deck actually going to run", which is a question a machine
|
|
1006
|
-
// with a managed install AND a PATH copy could not answer before #433.
|
|
1007
|
-
else if (cu?.state === "user") write(row({ mark: G.ok, label: "ccusage", detail: `your own copy ${G.dash} ${cu.bin}` }));
|
|
1008
|
-
else if (cu?.state === "installing") write(row({ mark: G.ok, label: "ccusage", detail: "installing in background" }));
|
|
1009
|
-
}
|
|
1010
|
-
|
|
1011
|
-
// Asking the supervisor to bring us back. It is the only party that can, and
|
|
1012
|
-
// only after this process is gone — which is precisely what keeps the
|
|
1013
|
-
// replacement from racing this listener onto a random fallback port.
|
|
1014
|
-
let restarting = false;
|
|
1015
|
-
// Outer bound on the supervisor's answer below. It cannot be reached today —
|
|
1016
|
-
// the fetch has a deadline of its own and every path through it replies — but
|
|
1017
|
-
// `restarting` is a latch, and a latch with no way out is how a deck ends up
|
|
1018
|
-
// silently refusing every restart for the rest of its life.
|
|
1019
|
-
const UPGRADE_ANSWER_MS = 150_000;
|
|
1020
|
-
let upgradeTimer = null;
|
|
1021
|
-
|
|
1022
|
-
// Whether the rest of this file has finished running.
|
|
1023
|
-
//
|
|
1024
|
-
// The server below starts accepting connections from inside startServer, before
|
|
1025
|
-
// that call has returned — so /api/restart is reachable for the whole of the
|
|
1026
|
-
// boot that follows it: the startup report, the port report to the supervisor,
|
|
1027
|
-
// the discovery file and its first fsynced write, and on a cold start the
|
|
1028
|
-
// browser spawn. A restart landing in that window used to reach `shutdown`
|
|
1029
|
-
// before the binding holding it was initialised and die of a ReferenceError,
|
|
1030
|
-
// having already set the latch above, with nothing left to clear it — after
|
|
1031
|
-
// which every restart from every tab was answered "ok" and did nothing, for the
|
|
1032
|
-
// life of the process (#448).
|
|
1033
|
-
//
|
|
1034
|
-
// So an ask that arrives too early is held rather than run: the user asked for
|
|
1035
|
-
// something this deck can genuinely give a moment later, and refusing outright
|
|
1036
|
-
// would put back the same silence in a politer form. BOOT_RESTART_MS is the
|
|
1037
|
-
// outer bound, for the reason UPGRADE_ANSWER_MS above is one. It used to be a
|
|
1038
|
-
// bound that got used — before #742 the report waited out a real `uv tool
|
|
1039
|
-
// install`, so the window it covers was minutes wide. It is now the boot
|
|
1040
|
-
// deadline plus the browser spawn, comfortably inside ten seconds, and this
|
|
1041
|
-
// stays as the thing that makes that a fact rather than a belief. Ten seconds
|
|
1042
|
-
// in, the ask is run; the respawn skips the report entirely and is up in about
|
|
1043
|
-
// a second.
|
|
1044
|
-
let booted = false;
|
|
1045
|
-
let heldRestart = null;
|
|
1046
|
-
let bootTimer = null;
|
|
1047
|
-
const BOOT_RESTART_MS = 10_000;
|
|
1048
|
-
|
|
1049
|
-
const requestRestart = (mode) => {
|
|
1050
|
-
if (restarting) return;
|
|
1051
|
-
restarting = true;
|
|
1052
|
-
if (!booted) {
|
|
1053
|
-
heldRestart = { mode };
|
|
1054
|
-
bootTimer = setTimeout(() => { bootTimer = null; runHeldRestart(); }, BOOT_RESTART_MS);
|
|
1055
|
-
bootTimer.unref?.();
|
|
1056
|
-
// Said out loud for the same reason abandonUpgrade below is: the tab has
|
|
1057
|
-
// already been told its restart was accepted, and a second of nothing
|
|
1058
|
-
// happening on this terminal is otherwise indistinguishable from the bug
|
|
1059
|
-
// this replaces.
|
|
1060
|
-
write(`\n ${P.warn}${G.restart}${P.reset} ${P.muted}restart queued ${G.dash} still starting up${P.reset}\n`);
|
|
1061
|
-
return;
|
|
1062
|
-
}
|
|
1063
|
-
beginRestart(mode);
|
|
1064
|
-
};
|
|
1065
|
-
|
|
1066
|
-
// The restart itself, once there is a booted deck to end. Split out of
|
|
1067
|
-
// requestRestart so the held ask above can re-enter it without tripping the
|
|
1068
|
-
// latch it is already holding.
|
|
1069
|
-
//
|
|
1070
|
-
// Everything here runs inside one try: the whole point of #448 is that a throw
|
|
1071
|
-
// on this path is not merely a failed restart but a permanent one, because the
|
|
1072
|
-
// latch it leaves behind outlives it. There is no line in here worth dying for.
|
|
1073
|
-
function beginRestart(mode) {
|
|
1074
|
-
try {
|
|
1075
|
-
// "npx" means the newer code is not on this disk at all, so it has to be
|
|
1076
|
-
// fetched — and this process keeps serving while that happens. Exiting first
|
|
1077
|
-
// is what made every failed upgrade an outage: the SSE stream dropped, hook
|
|
1078
|
-
// events fired into the gap were lost outright (hook/hook.js is
|
|
1079
|
-
// fire-and-forget with a 1s timeout and no retry), and the canvas came back
|
|
1080
|
-
// with whatever was in flight stuck until the stale sweeper reaped it — all
|
|
1081
|
-
// of it paid before anyone knew whether npm could even resolve the version.
|
|
1082
|
-
// Nothing is torn down here now; the supervisor answers when it knows.
|
|
1083
|
-
if (mode === "npx") {
|
|
1084
|
-
upgradeTimer = setTimeout(() => abandonUpgrade("no answer from the supervisor"), UPGRADE_ANSWER_MS);
|
|
1085
|
-
upgradeTimer.unref?.();
|
|
1086
|
-
// Armed before the ask, not after: a send that throws is a supervisor that
|
|
1087
|
-
// can no longer answer, and the deck has to come back out of the latch on
|
|
1088
|
-
// its own rather than wait out an answer that cannot arrive.
|
|
1089
|
-
try { process.send({ type: "upgrade" }); }
|
|
1090
|
-
catch (err) { abandonUpgrade(err?.message ?? "the supervisor is no longer listening"); }
|
|
1091
|
-
return;
|
|
1092
|
-
}
|
|
1093
|
-
const to = restartTarget();
|
|
1094
|
-
write(`\n ${P.warn}${G.restart}${P.reset} ${P.muted}restarting${to ? ` ${G.arrow} v${to}` : ""}${G.ellipsis}${P.reset}\n`);
|
|
1095
|
-
shutdown(RESTART_CODE);
|
|
1096
|
-
} catch (err) {
|
|
1097
|
-
abandonRestart(err);
|
|
1098
|
-
}
|
|
1099
|
-
}
|
|
1100
|
-
|
|
1101
|
-
// The ask that was waiting for the boot to finish, now that it has. Safe to
|
|
1102
|
-
// call when nothing is waiting, which is every ordinary boot.
|
|
1103
|
-
function runHeldRestart() {
|
|
1104
|
-
if (!heldRestart) return;
|
|
1105
|
-
const { mode } = heldRestart;
|
|
1106
|
-
heldRestart = null;
|
|
1107
|
-
clearTimeout(bootTimer);
|
|
1108
|
-
bootTimer = null;
|
|
1109
|
-
beginRestart(mode);
|
|
1110
|
-
}
|
|
1111
|
-
|
|
1112
|
-
// A restart that could not be started, said out loud and then let go of.
|
|
1113
|
-
//
|
|
1114
|
-
// Both halves of the latch have to come down — this file's and the server's —
|
|
1115
|
-
// because a latch nothing clears is precisely how one failed request turned
|
|
1116
|
-
// into a deck that refused every restart afterwards while answering "ok" to
|
|
1117
|
-
// each one (#448). The reason is folded onto one line by oneLine: the terminal
|
|
1118
|
-
// under this is repainted every 800ms by the pulse, and a stack written into
|
|
1119
|
-
// that is a stack nobody can read (#432).
|
|
1120
|
-
//
|
|
1121
|
-
// A declaration rather than a const, like `shutdown` below and for the same
|
|
1122
|
-
// reason: this is the handler for a binding that was not there yet, and it must
|
|
1123
|
-
// not be capable of becoming the next one.
|
|
1124
|
-
function abandonRestart(err) {
|
|
1125
|
-
clearTimeout(bootTimer);
|
|
1126
|
-
bootTimer = null;
|
|
1127
|
-
heldRestart = null;
|
|
1128
|
-
restarting = false;
|
|
1129
|
-
releaseRestart();
|
|
1130
|
-
write(
|
|
1131
|
-
`\n ${P.err}${G.fail}${P.reset} ${P.muted}restart failed ${G.dash} still on ${P.reset}v${PKG_VERSION}\n` +
|
|
1132
|
-
` ${P.muted}${oneLine(err?.stack ?? err, Math.max(20, cols() - 6), G.ellipsis)}${P.reset}\n`,
|
|
1133
|
-
);
|
|
1134
|
-
}
|
|
1135
|
-
|
|
1136
|
-
// The upgrade did not happen and this deck is still the deck. Said out loud
|
|
1137
|
-
// because the terminal has just printed that a fetch was starting, and left
|
|
1138
|
-
// unsaid it reads as a restart that hung.
|
|
1139
|
-
const abandonUpgrade = (why) => {
|
|
1140
|
-
clearTimeout(upgradeTimer);
|
|
1141
|
-
restarting = false;
|
|
1142
|
-
// The server's own latch, which no longer has an exiting process to clear it.
|
|
1143
|
-
releaseRestart();
|
|
1144
|
-
write(
|
|
1145
|
-
`\n ${P.warn}${G.cancel}${P.reset} ${P.muted}update not applied ${G.dash} still on ${P.reset}v${PKG_VERSION}\n` +
|
|
1146
|
-
(why ? ` ${P.muted}${why}${P.reset}\n` : ""),
|
|
1147
|
-
);
|
|
1148
|
-
};
|
|
1149
|
-
|
|
1150
|
-
// The supervisor's verdict on the fetch it was asked for. Only it can answer:
|
|
1151
|
-
// the fetch is its child, and it is the process that will still be here when
|
|
1152
|
-
// this one exits.
|
|
1153
|
-
process.on("message", (m) => {
|
|
1154
|
-
if (!restarting || !m || typeof m !== "object") return;
|
|
1155
|
-
if (m.type === "upgrade-ready") {
|
|
1156
|
-
clearTimeout(upgradeTimer);
|
|
1157
|
-
// The replacement is on the machine now, so this is the last moment the
|
|
1158
|
-
// port is worth holding: exiting hands it straight over.
|
|
1159
|
-
write(`\n ${P.warn}${G.restart}${P.reset} ${P.muted}updating via npx${G.ellipsis}${P.reset}\n`);
|
|
1160
|
-
shutdown(UPGRADE_CODE);
|
|
1161
|
-
} else if (m.type === "upgrade-refused") {
|
|
1162
|
-
abandonUpgrade(m.error);
|
|
1163
|
-
}
|
|
1164
|
-
});
|
|
1165
|
-
|
|
1166
|
-
// What a restart would land on. Read from disk now rather than remembered from
|
|
1167
|
-
// boot, because the whole point is that the two differ.
|
|
1168
|
-
function restartTarget() {
|
|
1169
|
-
try { return JSON.parse(readFileSync(join(PKG_ROOT, "package.json"), "utf8")).version ?? null; }
|
|
1170
|
-
catch { return null; }
|
|
1171
|
-
}
|
|
1172
|
-
|
|
1173
|
-
// The three things `shutdown` has to tear down, named before the boot that
|
|
1174
|
-
// fills them in rather than by it. From the line below onwards this process is
|
|
1175
|
-
// answering HTTP, and /api/restart can therefore reach `shutdown` at any moment
|
|
1176
|
-
// after it — including moments at which none of these exist yet. `let … = null`
|
|
1177
|
-
// is what makes that a question shutdown can ask instead of a ReferenceError it
|
|
1178
|
-
// dies of; the boot queue in requestRestart is what makes it a question it
|
|
1179
|
-
// almost never has to ask. See #448.
|
|
1180
|
-
let server = null;
|
|
1181
|
-
let discovery = null;
|
|
1182
|
-
let discoveryFile = null;
|
|
1183
|
-
|
|
1184
|
-
// This worker does not outlive the supervisor that started it (#702).
|
|
1185
|
-
//
|
|
1186
|
-
// Armed HERE rather than beside the signal handlers at the bottom of the file,
|
|
1187
|
-
// for the reason the three `let`s above are where they are: this is the first
|
|
1188
|
-
// line from which `shutdown` can be called without dying of a temporal dead
|
|
1189
|
-
// zone, and the window that matters is the boot — the supervisor can be killed
|
|
1190
|
-
// while the report is still printing, and a worker orphaned in there is a
|
|
1191
|
-
// worker that binds its port a moment later and then holds it for a day.
|
|
1192
|
-
//
|
|
1193
|
-
// A no-op with nothing supervising us, which is `node bin/deck.js` run by hand:
|
|
1194
|
-
// dieWithParent arms only when there is a channel, the same question SUPERVISED
|
|
1195
|
-
// asks above. Shutdown code 0 because there is nobody left to read it — the
|
|
1196
|
-
// point is only that discovery is unregistered and the port let go of on the
|
|
1197
|
-
// way out, rather than left for the next boot's stale sweep.
|
|
1198
|
-
dieWithParent(() => shutdown(0));
|
|
1199
|
-
|
|
1200
|
-
// The listen, begun HERE and awaited below the startup report rather than after
|
|
1201
|
-
// it. The report is a narration; the port is the product, and it was queued
|
|
1202
|
-
// behind three tool probes for no reason but the order these two statements
|
|
1203
|
-
// were written in (#483).
|
|
1204
|
-
//
|
|
1205
|
-
// What that cost: `reportStartup` awaits `ensureCswap`, which on a machine with
|
|
1206
|
-
// no Python tooling runs a real `uv tool install` under a 180-second timeout. So
|
|
1207
|
-
// the deck printed its rows and then refused every connection until that install
|
|
1208
|
-
// was over — on a first run, which is the one boot a new user judges the tool by.
|
|
1209
|
-
// #476 stopped that job blocking the event loop; the socket was shut either way,
|
|
1210
|
-
// because the bind had not been attempted yet.
|
|
1211
|
-
//
|
|
1212
|
-
// Nothing in the report has to finish before the socket opens, and the window
|
|
1213
|
-
// this opens is narrow on purpose — the discovery file and the browser are both
|
|
1214
|
-
// still written after the report, so the only callers who can arrive inside it
|
|
1215
|
-
// are a tab left open by an earlier deck and the user's own curl:
|
|
1216
|
-
//
|
|
1217
|
-
// • the event log is replayed and the sequence counter primed INSIDE
|
|
1218
|
-
// startServer, before it binds — so /events and /api/events answer from a
|
|
1219
|
-
// full buffer from the first connection, not a growing one.
|
|
1220
|
-
// • the hook install can only make hooks fire; a hook that fires early finds
|
|
1221
|
-
// no discovery file and posts nowhere, exactly as it does today.
|
|
1222
|
-
// • claude-swap: cswapBin memoizes only a lookup that WORKED, so a probe
|
|
1223
|
-
// landing mid-install caches nothing and the next one asks again — see
|
|
1224
|
-
// resetCswapBin's note. The accounts panel can report the tool missing for
|
|
1225
|
-
// the second it is missing, and answers properly the moment it is not.
|
|
1226
|
-
// • ccusage: getRunner awaits the very `_installing` promise primeCcusage
|
|
1227
|
-
// started, so a request in this window joins that install rather than
|
|
1228
|
-
// racing a second one.
|
|
1229
|
-
//
|
|
1230
|
-
// Settled into a tagged result rather than left bare, for the reason every job
|
|
1231
|
-
// in startupWork carries its own handler: this promise now lives across the
|
|
1232
|
-
// whole report, and a bind that fails in there with nothing attached to it is an
|
|
1233
|
-
// unhandledRejection — which Node answers by killing the process over a port it
|
|
1234
|
-
// could have named.
|
|
1235
|
-
// ── is one of ours already up? ────────────────────────────────────────────────
|
|
1236
|
-
// A bare `ccdeck` typed beside a deck that is already running used to build a
|
|
1237
|
-
// second everything on a random port, and neither half mentioned the other.
|
|
1238
|
-
// src/server/running-deck.mjs carries the whole argument, the registry read and
|
|
1239
|
-
// the token handshake that makes the answer trustworthy; this is only where it
|
|
1240
|
-
// is asked.
|
|
1241
|
-
//
|
|
1242
|
-
// ASKED EXACTLY HERE, and the position is the point. Everything the answer
|
|
1243
|
-
// depends on is resolved above — the workspace, the canonical log path, which
|
|
1244
|
-
// of the two CLIs this deck would serve — and nothing below has run yet: no
|
|
1245
|
-
// port bound, no hooks installed, no tool probed, no banner painted, no
|
|
1246
|
-
// discovery file written. An attach therefore leaves the machine precisely as
|
|
1247
|
-
// it found it, which is what makes it safe to do without asking.
|
|
1248
|
-
//
|
|
1249
|
-
// A respawn is excluded on its own line rather than left to the flag check.
|
|
1250
|
-
// The supervisor relaunches us with `--port <bound>`, which is a shaping flag
|
|
1251
|
-
// and would be excluded anyway — but a restart is THIS deck coming back, and
|
|
1252
|
-
// having that read as "somebody typed ccdeck twice" is an accident waiting for
|
|
1253
|
-
// the day the supervisor stops passing the port.
|
|
1254
|
-
if (!RESPAWN && !asksForOwnDeck(flags)) {
|
|
1255
|
-
const live = await runningDeck({
|
|
1256
|
-
want: { workspace, persist, codex: wantCodex, claude: wantClaude },
|
|
1257
|
-
}).catch(() => null);
|
|
1258
|
-
if (live) {
|
|
1259
|
-
const liveUrl = `http://127.0.0.1:${live.port}`;
|
|
1260
|
-
const note = versionNote(live.version, PKG_VERSION);
|
|
1261
|
-
// Not the startup report's rows. That report has a label column because it
|
|
1262
|
-
// has twelve lines to align; this has two, and borrowing the column would
|
|
1263
|
-
// indent a three-line message behind a gutter sized for "Codex sessions".
|
|
1264
|
-
//
|
|
1265
|
-
// The version chunk is dropped rather than printed as "v?" when the running
|
|
1266
|
-
// deck is too old to report one — see versionNote, which says nothing in the
|
|
1267
|
-
// same case for the same reason.
|
|
1268
|
-
const ident = [live.version ? `v${live.version}` : "", `pid ${live.pid}`]
|
|
1269
|
-
.filter(Boolean).join(` ${G.bullet} `);
|
|
1270
|
-
write(`\n ${P.ok}${G.ok}${P.reset} deck already running${P.muted} ${G.bullet} ${ident}${P.reset}\n`);
|
|
1271
|
-
// Its own line, always. The URL is the one detail an ellipsis would destroy
|
|
1272
|
-
// — half an address is not a shorter address — and this message has no
|
|
1273
|
-
// report to hand it to. Same rule as statusLine's `keep`.
|
|
1274
|
-
write(` ${P.accent}${P.bold}${link(liveUrl, liveUrl, LINKS)}${P.reset}\n`);
|
|
1275
|
-
if (note) write(` ${P.warn}${G.warn} ${note}${P.reset}\n`);
|
|
1276
|
-
// THE TYPO'S WARNING, on the path that has no startup report to carry it.
|
|
1277
|
-
//
|
|
1278
|
-
// asksForOwnDeck used to answer `true` for these two so the report would
|
|
1279
|
-
// run and print them, and that is how `ccdeck --stpo` — a misspelling of
|
|
1280
|
-
// the flag that STOPS a deck — came to build a second one. The warning was
|
|
1281
|
-
// the requirement; the extra process never was. Printed here, in the same
|
|
1282
|
-
// rows the report uses, so nothing is lost and nothing is started.
|
|
1283
|
-
reportUnknownFlags(flags.unknown);
|
|
1284
|
-
reportIncompleteFlags(flags.incomplete);
|
|
1285
|
-
// The line that says a second deck was NOT started. Without it the command
|
|
1286
|
-
// looks like it did nothing at all, which is the other way to be confusing
|
|
1287
|
-
// about this — and it names the flag for the person who really did want two.
|
|
1288
|
-
write(`\n ${P.muted}${G.dash} no second deck was started ${G.dash} \`${INVOKED_AS ?? PRODUCT} --new\` starts one${P.reset}\n`);
|
|
1289
|
-
if (openBrowser) {
|
|
1290
|
-
write(`\n ${P.ok}${P.bold}${G.play} opening browser${G.ellipsis}${P.reset}\n\n`);
|
|
1291
|
-
try {
|
|
1292
|
-
const { openUrl, LAUNCH_GRACE_MS } = await import(pathToFileURL(join(PKG_ROOT, "src/server/open-url.mjs")).href);
|
|
1293
|
-
openUrl(liveUrl);
|
|
1294
|
-
// Held for exactly as long as openUrl needs to fall through to its next
|
|
1295
|
-
// launcher. Every child it spawns is unref'd, so an immediate exit ends
|
|
1296
|
-
// this process before a missing xdg-open has been answered by gio — and
|
|
1297
|
-
// then no browser opens and nothing says why. The boot path never had
|
|
1298
|
-
// to think about this because it stays alive forever.
|
|
1299
|
-
await sleep(LAUNCH_GRACE_MS);
|
|
1300
|
-
} catch { /* the URL is on screen; it can be clicked or pasted */ }
|
|
1301
|
-
} else {
|
|
1302
|
-
write("\n");
|
|
1303
|
-
}
|
|
1304
|
-
process.exit(0);
|
|
1305
|
-
}
|
|
1306
|
-
}
|
|
1307
|
-
|
|
1308
|
-
const starting = startServer({
|
|
1309
|
-
port, persist, workspace, codex: wantCodex, claude: wantClaude,
|
|
1310
|
-
// Withheld when nothing is supervising us: without a parent, exiting is just
|
|
1311
|
-
// exiting, and /api/restart answers 501 so the UI hides the control.
|
|
1312
|
-
onRestart: SUPERVISED ? requestRestart : null,
|
|
1313
|
-
// NOT withheld, unlike the restart above. A restart needs a supervisor to
|
|
1314
|
-
// bring the replacement up on the same port; ending is something any deck can
|
|
1315
|
-
// do on its own, supervised or not, and `ccdeck --stop` must work on both.
|
|
1316
|
-
// shutdown() is a hoisted declaration precisely so it is callable from the
|
|
1317
|
-
// first instruction of this module — see the long note beside it (#448).
|
|
1318
|
-
onStop: () => shutdown(0),
|
|
1319
|
-
}).then(s => ({ ok: true, s }), err => ({ ok: false, err }));
|
|
1320
|
-
|
|
1321
|
-
// Once-per-session setup — hook install, tool probes, registry lookups, and the
|
|
1322
|
-
// banner it runs underneath. A respawn is the same session continuing, so it
|
|
1323
|
-
// skips the lot and prints one line instead. This is the difference between a
|
|
1324
|
-
// restart that feels instant and one that makes you wonder whether it worked.
|
|
1325
|
-
if (!RESPAWN) {
|
|
1326
|
-
const jobs = startupWork();
|
|
1327
|
-
await printBanner();
|
|
1328
|
-
await reportStartup(jobs);
|
|
1329
|
-
}
|
|
1330
|
-
|
|
1331
|
-
// Usually settled long ago by the time we get here, which is the point: `step`
|
|
1332
|
-
// paints nothing for a promise that has already resolved, so the spinner this
|
|
1333
|
-
// used to show is simply gone from the boots that were slow enough to need one.
|
|
1334
|
-
const bound = await (RESPAWN ? starting : step(`starting server${G.ellipsis}`, starting));
|
|
1335
|
-
if (!bound.ok) {
|
|
1336
|
-
// stderr, not a row: a deck that could not bind is not a status line, and
|
|
1337
|
-
// whatever launched it reads this stream.
|
|
1338
|
-
console.error(`${PRODUCT}: server failed: ${bound.err.message}`);
|
|
1339
|
-
process.exit(1);
|
|
1340
|
-
}
|
|
1341
|
-
server = bound.s;
|
|
1342
|
-
const addr = server.address();
|
|
1343
|
-
const realPort = typeof addr === "object" && addr ? addr.port : port;
|
|
1344
|
-
const url = `http://127.0.0.1:${realPort}`;
|
|
1345
|
-
|
|
1346
|
-
// The supervisor re-launches with this on --port. It has to be the port we
|
|
1347
|
-
// actually got, not the one we asked for: those differ whenever the first
|
|
1348
|
-
// launch found 4317 taken, and re-launching on the requested port would move
|
|
1349
|
-
// the deck out from under every open tab.
|
|
1350
|
-
try { process.send?.({ type: "listening", port: realPort }); } catch { /* not supervised */ }
|
|
1351
|
-
|
|
1352
|
-
if (RESPAWN) {
|
|
1353
|
-
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`);
|
|
1354
|
-
// A respawn skips the whole startup report, but not this: the argv is the
|
|
1355
|
-
// same argv, the typo in it is still there, and a deck that mentioned it once
|
|
1356
|
-
// and then went quiet for every restart afterwards is back to hiding it from
|
|
1357
|
-
// anyone who was not watching the first boot.
|
|
1358
|
-
reportUnknownFlags(flags.unknown);
|
|
1359
|
-
reportIncompleteFlags(flags.incomplete);
|
|
1360
|
-
} else {
|
|
1361
|
-
// The URL is the one detail an ellipsis would destroy — half an address is
|
|
1362
|
-
// not a shorter address — so it keeps its own line when the terminal is too
|
|
1363
|
-
// narrow to hold it beside the label. See statusLine's `keep`.
|
|
1364
|
-
write(row({
|
|
1365
|
-
mark: G.ok, label: "server ready",
|
|
1366
|
-
detail: link(url, url, LINKS), detailTone: `${P.accent}${P.bold}`, keep: true,
|
|
1367
|
-
}));
|
|
1368
|
-
if (persist) write(row({ label: "log", detail: fileLink(persist) }));
|
|
1369
|
-
// Last of the rows, on purpose — see reportUnknownFlags.
|
|
1370
|
-
reportUnknownFlags(flags.unknown);
|
|
1371
|
-
reportIncompleteFlags(flags.incomplete);
|
|
1372
|
-
// Only when one is actually being opened. Under --no-open — which is how an
|
|
1373
|
-
// npx update relaunches, with a tab already waiting — this was announcing
|
|
1374
|
-
// something that never happened.
|
|
1375
|
-
if (openBrowser) write(`\n ${P.ok}${P.bold}${G.play} opening browser${G.ellipsis}${P.reset}\n\n`);
|
|
1376
|
-
else write("\n");
|
|
1377
|
-
}
|
|
1378
|
-
|
|
1379
|
-
// The discovery file is the whole of how a hook finds this deck: hook.js
|
|
1380
|
-
// enumerates that directory and nothing else. Writing it once at boot meant
|
|
1381
|
-
// anything that later took it away left a deck that listened, served, and
|
|
1382
|
-
// received not one event — with nothing on screen to say so. So it is checked
|
|
1383
|
-
// on a timer, put back when it goes missing, and its absence is stated out loud
|
|
1384
|
-
// rather than left to look like an idle afternoon.
|
|
1385
|
-
//
|
|
1386
|
-
// The token goes in with the port: it is what lets a hook tell this deck from
|
|
1387
|
-
// whatever else may later be listening on the same number. See hookToken().
|
|
1388
|
-
//
|
|
1389
|
-
// The log path goes in with them, so a hook can see which decks share one events
|
|
1390
|
-
// log and elect a single writer for it. See electWriters in hook/hook.js. The
|
|
1391
|
-
// Codex setting goes in for the half of that election no hook is part of: the
|
|
1392
|
-
// rollout files this deck tails itself, which a --no-codex deck must never be
|
|
1393
|
-
// elected to record. See writesCodexLog in src/server/log-writer.mjs.
|
|
1394
|
-
let registered = null;
|
|
1395
|
-
discovery = keepDiscovery({
|
|
1396
|
-
port: realPort,
|
|
1397
|
-
workspace,
|
|
1398
|
-
token: hookToken(),
|
|
1399
|
-
persist,
|
|
1400
|
-
codex: wantCodex,
|
|
1401
|
-
// Both read by the next `ccdeck` on this machine, not by us: `claude` is half
|
|
1402
|
-
// of the shape it compares before it will attach to us, and `version` is what
|
|
1403
|
-
// it prints when the deck it found is not the one the user just launched. See
|
|
1404
|
-
// src/server/running-deck.mjs.
|
|
1405
|
-
claude: wantClaude,
|
|
1406
|
-
version: PKG_VERSION,
|
|
1407
|
-
// The supervisor above us, for `ccdeck --stop`'s fallback ladder: a worker
|
|
1408
|
-
// killed under a live supervisor is a worker the supervisor puts back, so the
|
|
1409
|
-
// parent has to go first and cannot be found without being told. Null when
|
|
1410
|
-
// nothing is supervising, which is the same question `onRestart` asks.
|
|
1411
|
-
parent: SUPERVISED ? process.ppid : null,
|
|
1412
|
-
onState: (state) => {
|
|
1413
|
-
const first = registered === null;
|
|
1414
|
-
registered = state.ok;
|
|
1415
|
-
if (!state.ok) reportUnregistered(state);
|
|
1416
|
-
else if (!first) reportReregistered(state);
|
|
1417
|
-
},
|
|
1418
|
-
});
|
|
1419
|
-
discoveryFile = discovery.file;
|
|
1420
|
-
// Now, not in five seconds: nothing should reach the pulse line below without
|
|
1421
|
-
// the deck knowing whether the hooks can see it.
|
|
1422
|
-
await discovery.check();
|
|
1423
|
-
|
|
1424
|
-
// Never on a respawn: the tab that asked for the restart is still open and
|
|
1425
|
-
// reconnecting on its own. A second one would be the deck talking over itself.
|
|
1426
|
-
if (openBrowser && !RESPAWN) {
|
|
1427
|
-
// Not awaited, and not a dependency any more. `open@10` was this package's
|
|
1428
|
-
// only runtime dependency and brought nine more with it, all of them fetched
|
|
1429
|
-
// on a cold `npx ccdeck` before the deck's own tarball is unpacked — and the
|
|
1430
|
-
// await under it held the boot behind a launcher that has nothing to report.
|
|
1431
|
-
// See src/server/open-url.mjs.
|
|
1432
|
-
try {
|
|
1433
|
-
const { openUrl } = await import(pathToFileURL(join(PKG_ROOT, "src/server/open-url.mjs")).href);
|
|
1434
|
-
openUrl(url);
|
|
1435
|
-
} catch {}
|
|
1436
|
-
}
|
|
1437
|
-
|
|
1438
|
-
// ── the deck from before this version that is still running ──────────────────
|
|
1439
|
-
//
|
|
1440
|
-
// UPGRADE DAY, and without this it is a mystery. A deck older than the attach
|
|
1441
|
-
// publishes no `claude` and no `version` in its discovery record, so sameShape
|
|
1442
|
-
// cannot match it — deliberately, because a record that cannot be compared is
|
|
1443
|
-
// not one to attach to. The consequence is that the first `ccdeck` after an
|
|
1444
|
-
// upgrade starts a SECOND deck beside the one already running, opens a tab on
|
|
1445
|
-
// it, and says nothing; and `ccdeck --stop` will not find the old one either,
|
|
1446
|
-
// because it has the same shape problem.
|
|
1447
|
-
//
|
|
1448
|
-
// So it is named, once, with the command that actually clears it. Only for
|
|
1449
|
-
// decks with no version: a deck started with `--new`, or one scoped to another
|
|
1450
|
-
// workspace, is somebody's deliberate second deck and needs no explaining.
|
|
1451
|
-
if (!RESPAWN) {
|
|
1452
|
-
try {
|
|
1453
|
-
const { liveDecks } = await import(pathToFileURL(join(PKG_ROOT, "src/server/running-deck.mjs")).href);
|
|
1454
|
-
const older = (await liveDecks()).filter(d => !d.version);
|
|
1455
|
-
if (older.length) {
|
|
1456
|
-
const where = older.map(d => d.port).join(", ");
|
|
1457
|
-
write(` ${P.warn}${G.warn}${P.reset} ${P.muted}${older.length === 1 ? "a deck" : `${older.length} decks`} from an older ${PRODUCT} ${older.length === 1 ? "is" : "are"} still running on ${where}${P.reset}\n`);
|
|
1458
|
-
write(` ${P.muted}too old to be recognised, so this one started beside ${older.length === 1 ? "it" : "them"} ${G.dash} \`${INVOKED_AS ?? PRODUCT} --stop --all\` clears the lot${P.reset}\n\n`);
|
|
1459
|
-
}
|
|
1460
|
-
} catch { /* a question about other decks is never a reason to fail a boot */ }
|
|
1461
|
-
}
|
|
1462
|
-
|
|
1463
|
-
// ── starting at login ─────────────────────────────────────────────────────────
|
|
1464
|
-
//
|
|
1465
|
-
// ONCE PER MACHINE, EVER. The record in the deck's own data directory is what
|
|
1466
|
-
// makes that true: without it, `--uninstall-service` would be undone by the next
|
|
1467
|
-
// start, which is not an uninstall — it is a tool arguing with its user.
|
|
1468
|
-
//
|
|
1469
|
-
// After the boot rather than during it, and deliberately: a deck that could not
|
|
1470
|
-
// come up has no business teaching the machine to start it at every login. By
|
|
1471
|
-
// here the port is bound, the hooks are registered and the browser is open.
|
|
1472
|
-
//
|
|
1473
|
-
// npx is excluded and AGENTS_DECK_NO_INSTALL is honoured — see
|
|
1474
|
-
// shouldOfferService, which owns both rules and says why. A failure is one line
|
|
1475
|
-
// and nothing else: the deck is already running, and the worst case is the
|
|
1476
|
-
// behaviour every version before this one had.
|
|
1477
|
-
if (!RESPAWN) {
|
|
1478
|
-
try {
|
|
1479
|
-
const svc = await import(pathToFileURL(join(PKG_ROOT, "src/server/login-service.mjs")).href);
|
|
1480
|
-
const { isGitCheckout, isNpxInstall } = await import(pathToFileURL(join(PKG_ROOT, "src/server/self-update.mjs")).href);
|
|
1481
|
-
if (svc.shouldOfferService({
|
|
1482
|
-
record: svc.readServiceRecord(deckDataDir()),
|
|
1483
|
-
npx: isNpxInstall(PKG_ROOT),
|
|
1484
|
-
checkout: isGitCheckout(PKG_ROOT),
|
|
1485
|
-
})) {
|
|
1486
|
-
const out = svc.installService({
|
|
1487
|
-
script: join(PKG_ROOT, "bin", "agent-dag.js"),
|
|
1488
|
-
logPath: join(deckLogDir(), "deck.log"),
|
|
1489
|
-
product: PRODUCT,
|
|
1490
|
-
});
|
|
1491
|
-
svc.writeServiceRecord(deckDataDir(), out.ok
|
|
1492
|
-
? { installed: PKG_VERSION, at: new Date().toISOString(), path: out.path }
|
|
1493
|
-
: { failed: out.reason ?? "unknown", at: new Date().toISOString(), version: PKG_VERSION });
|
|
1494
|
-
// Said once, on the one run that does it, and never again. A tool that
|
|
1495
|
-
// adds itself to your login items and does not mention it is a tool you
|
|
1496
|
-
// find later, in a settings pane, and stop trusting.
|
|
1497
|
-
write(out.ok
|
|
1498
|
-
? ` ${P.muted}${G.dash} ${PRODUCT} will now start when you log in ${G.dash} \`${INVOKED_AS ?? PRODUCT} --uninstall-service\` undoes it${P.reset}\n\n`
|
|
1499
|
-
: ` ${P.muted}${G.dash} could not set ${PRODUCT} to start at login (${out.reason}) ${G.dash} it still starts when you type it${P.reset}\n\n`);
|
|
1500
|
-
}
|
|
1501
|
-
} catch (err) {
|
|
1502
|
-
// Never fatal. The deck is up; this is a convenience that did not happen.
|
|
1503
|
-
console.error(`${PRODUCT}: could not check the login item:`, err?.message ?? err);
|
|
1504
|
-
}
|
|
1505
|
-
}
|
|
1506
|
-
|
|
1507
|
-
// ── Pulse indicator ───────────────────────────────────────────────────────────
|
|
1508
|
-
// The whole line is rewritten each beat rather than just the dot: anything else
|
|
1509
|
-
// on this deck that has something to say writes a newline first, and after that
|
|
1510
|
-
// the line under the cursor is no longer the one we drew — a partial repaint
|
|
1511
|
-
// would leave the message behind and pulse into empty space. Sized to the real
|
|
1512
|
-
// terminal, because at 40 columns the old fixed 61-character line wrapped, and
|
|
1513
|
-
// from then on \r only ever reached its second row.
|
|
1514
|
-
//
|
|
1515
|
-
// AND IT STOPS MOVING (#742). The dot alternated green and grey every 800ms for
|
|
1516
|
-
// as long as the deck ran, and a blinking indicator beside a status line is the
|
|
1517
|
-
// vocabulary of "working on it" — so a boot that finished in a second read as
|
|
1518
|
-
// one that never finished, which is what people reported. Motion is now spent
|
|
1519
|
-
// on the two states where something really is outstanding, and the frame is
|
|
1520
|
-
// compared against what is already on screen so a deck at rest paints once and
|
|
1521
|
-
// then leaves the terminal alone. See pulseMoves.
|
|
1522
|
-
if (MOTION) {
|
|
1523
|
-
let pi = 0;
|
|
1524
|
-
let painted = null;
|
|
1525
|
-
// The line is on screen and is the last thing written to this terminal.
|
|
1526
|
-
let ours = false;
|
|
1527
|
-
// We are the one writing right now, so the guard below leaves us alone.
|
|
1528
|
-
let writing = false;
|
|
1529
|
-
|
|
1530
|
-
// The pulse line's tenancy, enforced rather than agreed.
|
|
1531
|
-
//
|
|
1532
|
-
// The convention was that anything with something to say writes a newline
|
|
1533
|
-
// first, so the pulse's `\r` never lands on somebody else's text. bin/deck.js
|
|
1534
|
-
// keeps it everywhere. src/server/quota.mjs does not — it calls console.error
|
|
1535
|
-
// directly — and a Windows user with no Claude Code sent a screenshot of the
|
|
1536
|
-
// result: `listening — Ctrl+C to stop ccdeck quota: claude CLI failed`
|
|
1537
|
-
// on one row, three times over, the pulse and the complaint interleaved.
|
|
1538
|
-
//
|
|
1539
|
-
// An invariant every writer has to remember is one a writer will forget, and
|
|
1540
|
-
// the writers here are server modules that know nothing about a terminal. So
|
|
1541
|
-
// it is enforced at the stream instead: while our line is the last thing on
|
|
1542
|
-
// screen, anything else that speaks gets a newline first, and the memo is
|
|
1543
|
-
// dropped so the next beat repaints the line under whatever was said.
|
|
1544
|
-
//
|
|
1545
|
-
// Both streams, because console.error goes to stderr and lands on the same
|
|
1546
|
-
// screen. Only when MOTION is on — with no pulse there is no line to defend,
|
|
1547
|
-
// and a piped deck must not have its output rewritten.
|
|
1548
|
-
for (const stream of [process.stdout, process.stderr]) {
|
|
1549
|
-
const real = stream.write.bind(stream);
|
|
1550
|
-
stream.write = (chunk, ...rest) => {
|
|
1551
|
-
if (!writing && ours) {
|
|
1552
|
-
ours = false;
|
|
1553
|
-
// Dropped, not kept: the line is no longer where we left it, so the
|
|
1554
|
-
// next beat has to draw it again even though the frame is unchanged.
|
|
1555
|
-
painted = null;
|
|
1556
|
-
// Unless the speaker already did it. Every late message in this file
|
|
1557
|
-
// opens with one, and two blank lines is its own kind of mess.
|
|
1558
|
-
if (!String(chunk).startsWith("\n")) real("\n");
|
|
1559
|
-
}
|
|
1560
|
-
return real(chunk, ...rest);
|
|
1561
|
-
};
|
|
1562
|
-
}
|
|
1563
|
-
|
|
1564
|
-
setInterval(() => {
|
|
1565
|
-
// The colour follows the words. A Codex-only deck keeps saying "listening"
|
|
1566
|
-
// when it is unregistered — see pulseText — and painting that sentence in
|
|
1567
|
-
// the warning tone would restore the alarm the sentence just retired.
|
|
1568
|
-
const alarm = !registered && wantClaude;
|
|
1569
|
-
const state = { registered, claude: wantClaude, busy: pulseBusy };
|
|
1570
|
-
const text = pulseText({ ...state, columns: cols(), unicode: UNICODE });
|
|
1571
|
-
// At rest every beat is lit, which is what makes the line still: the frame
|
|
1572
|
-
// is then identical to the one already on screen and the write below is
|
|
1573
|
-
// skipped. See pulseDot.
|
|
1574
|
-
const dot = pulseDot(pi++, state) === "on" ? (alarm ? P.warn : P.ok) : P.muted;
|
|
1575
|
-
const tone = alarm ? P.warn : P.muted;
|
|
1576
|
-
const frame = `\r ${dot}${G.pulse}${P.reset} ${tone}${text}${P.reset}`;
|
|
1577
|
-
// Unchanged frames are not written at all. That is what makes "at rest"
|
|
1578
|
-
// visible: one paint, and then a still line for as long as nothing happens.
|
|
1579
|
-
// `painted` is dropped by the guard above whenever somebody else writes, so
|
|
1580
|
-
// this can only skip a beat while the line is genuinely still where we left
|
|
1581
|
-
// it.
|
|
1582
|
-
if (frame === painted) return;
|
|
1583
|
-
painted = frame;
|
|
1584
|
-
writing = true;
|
|
1585
|
-
try { write(frame); } finally { writing = false; }
|
|
1586
|
-
ours = true;
|
|
1587
|
-
}, 800).unref();
|
|
1588
|
-
}
|
|
1589
|
-
|
|
1590
|
-
// Boot is over. Everything `shutdown` tears down exists, so a restart can be
|
|
1591
|
-
// run rather than held — and the server is told, so /api/restart stops
|
|
1592
|
-
// describing a deck that is still assembling itself. This line is exactly where
|
|
1593
|
-
// the window opened at the top of this file closes; see requestRestart.
|
|
1594
|
-
booted = true;
|
|
1595
|
-
markDeckReady();
|
|
1596
|
-
// And said out loud, one link up. A launcher that put this deck in the
|
|
1597
|
-
// background has been tailing its log into the user's terminal since the spawn
|
|
1598
|
-
// and is waiting for exactly this to stop and hand the prompt back — NOT for
|
|
1599
|
-
// `listening`, which is sent before the server-ready row, the log row and the
|
|
1600
|
-
// browser line are written, and would cut the last three lines off every boot.
|
|
1601
|
-
// Inert when nothing is supervising us.
|
|
1602
|
-
try { process.send?.({ type: "booted" }); } catch { /* no channel; nothing waiting */ }
|
|
1603
|
-
runHeldRestart();
|
|
1604
|
-
|
|
1605
|
-
/**
|
|
1606
|
-
* A declaration, not the `const` arrow this was for eight months.
|
|
1607
|
-
*
|
|
1608
|
-
* The difference is the whole of #448: a const is in its temporal dead zone
|
|
1609
|
-
* until the line declaring it runs, and every line above — the startup report,
|
|
1610
|
-
* the port report, the discovery file, the browser spawn — executes with the
|
|
1611
|
-
* server already accepting connections. A restart arriving in that window
|
|
1612
|
-
* called this and got `ReferenceError: Cannot access 'shutdown' before
|
|
1613
|
-
* initialization`, and the latch it had already set is what made that
|
|
1614
|
-
* permanent. A declaration is hoisted, so from the first instruction of this
|
|
1615
|
-
* module there is a function here to call.
|
|
1616
|
-
*
|
|
1617
|
-
* Hoisting alone would only have moved the fault one line down, onto `server`,
|
|
1618
|
-
* `discovery` and `discoveryFile` — which is why those are `let … = null` above
|
|
1619
|
-
* and asked about rather than assumed here. Between them, this is callable at
|
|
1620
|
-
* any instant of this process's life and cannot end in a throw for the caller
|
|
1621
|
-
* to lose.
|
|
1622
|
-
*/
|
|
1623
|
-
async function shutdown(code = 0) {
|
|
1624
|
-
// Also set as exitCode, not only passed to exit(): if the event loop empties
|
|
1625
|
-
// on its own before either timer runs, Node would otherwise exit 0 and the
|
|
1626
|
-
// supervisor would take that as "done" instead of "bring me back".
|
|
1627
|
-
process.exitCode = code;
|
|
1628
|
-
// Nothing inside a shutdown is worth staying alive for, and this one is
|
|
1629
|
-
// called from three places that cannot handle a rejection — a signal handler,
|
|
1630
|
-
// an IPC message handler, and a restart. An unhandled one there ends the
|
|
1631
|
-
// process on Node's terms rather than ours, which is to say with the wrong
|
|
1632
|
-
// exit code and therefore, half the time, without the supervisor bringing the
|
|
1633
|
-
// deck back.
|
|
1634
|
-
try {
|
|
1635
|
-
// Before anything that can take time: a Ctrl+C the user has to watch for a
|
|
1636
|
-
// second and a half is a second and a half without a cursor.
|
|
1637
|
-
showCursor();
|
|
1638
|
-
if (tty && code !== RESTART_CODE && code !== UPGRADE_CODE) {
|
|
1639
|
-
write(`\n\n ${P.warn}${G.stop} shutting down${G.ellipsis}${P.reset}\n`);
|
|
1640
|
-
}
|
|
1641
|
-
// Stopped first, always: a tick landing after the unlink would re-register a
|
|
1642
|
-
// deck that is on its way out, and leave the file behind for the hooks to
|
|
1643
|
-
// find once nothing is listening.
|
|
1644
|
-
//
|
|
1645
|
-
// AWAITED, because clearing the interval only stops the NEXT tick. A tick
|
|
1646
|
-
// that started a moment ago is inside the atomic write, and it re-creates
|
|
1647
|
-
// the file after the unlink — the same stale registration, reached by the
|
|
1648
|
-
// one route "stop first" does not cover. stop() answers with that check, so
|
|
1649
|
-
// this waits for it and then removes what it wrote. Bounded: one check,
|
|
1650
|
-
// which never rejects.
|
|
1651
|
-
//
|
|
1652
|
-
// Guarded on its own, because a discovery file this process cannot remove is
|
|
1653
|
-
// a nuisance the next boot's stale sweep clears up — worth carrying on to
|
|
1654
|
-
// the orderly close below rather than skipping to the abrupt one.
|
|
1655
|
-
try {
|
|
1656
|
-
await discovery?.stop();
|
|
1657
|
-
if (discoveryFile) await removeDiscovery(discoveryFile);
|
|
1658
|
-
} catch { /* the sweep at the next boot gets it */ }
|
|
1659
|
-
// No server yet means nothing to drain and nothing to hand the port over to,
|
|
1660
|
-
// so the exit is the whole of the shutdown.
|
|
1661
|
-
if (!server) return process.exit(code);
|
|
1662
|
-
server.close(() => process.exit(code));
|
|
1663
|
-
// SSE connections never end by themselves, so close() alone would sit out the
|
|
1664
|
-
// full 1500ms fallback on every restart. Hanging them up is safe — the stream
|
|
1665
|
-
// sets retry: 1500 and replays from Last-Event-ID, so each tab reconnects and
|
|
1666
|
-
// catches up without being told anything.
|
|
1667
|
-
try { server.closeAllConnections?.(); } catch { /* Node < 18.2 */ }
|
|
1668
|
-
setTimeout(() => process.exit(code), 1500).unref();
|
|
1669
|
-
} catch {
|
|
1670
|
-
process.exit(code);
|
|
1671
|
-
}
|
|
1672
|
-
}
|
|
1673
|
-
process.on("SIGINT", () => shutdown(0));
|
|
1674
|
-
process.on("SIGTERM", () => shutdown(0));
|
|
1675
|
-
process.on("beforeExit", () => { discovery?.stop(); if (discoveryFile) removeDiscovery(discoveryFile); });
|
|
1676
|
-
|
|
1677
|
-
// ── helpers ───────────────────────────────────────────────────────────────────
|
|
1678
|
-
|
|
1679
|
-
// The deck is up and the discovery file could not be written. Said in full —
|
|
1680
|
-
// the path, the reason — because the alternative is what this replaced: an
|
|
1681
|
-
// ordinary-looking deck that simply never shows a session.
|
|
1682
|
-
//
|
|
1683
|
-
// What that costs depends on which CLI this deck watches, so the sentence comes
|
|
1684
|
-
// from term.mjs, where both answers are written down and tested.
|
|
1685
|
-
function reportUnregistered({ file, error }) {
|
|
1686
|
-
const why = error?.message ? ` ${G.dash} ${error.message}` : "";
|
|
1687
|
-
write(
|
|
1688
|
-
`\n ${P.warn}${G.warn}${P.reset} ${P.bold}not registered${P.reset}${P.muted}${why}${P.reset}\n` +
|
|
1689
|
-
` ${P.muted}${unregisteredDetail({ file, claude: wantClaude, dash: G.dash })}${P.reset}\n`,
|
|
1690
|
-
);
|
|
1691
|
-
}
|
|
1692
|
-
|
|
1693
|
-
function reportReregistered({ file }) {
|
|
1694
|
-
write(`\n ${P.ok}${G.ok}${P.reset} ${P.muted}registered again ${G.arrow} ${fileLink(file)}${P.reset}\n`);
|
|
1695
|
-
}
|
|
1696
|
-
|
|
1697
|
-
/**
|
|
1698
|
-
* Every token the parser did not recognise, named, one row each.
|
|
1699
|
-
*
|
|
1700
|
-
* Said rather than acted on: the deck goes on booting and still exits 0. It is
|
|
1701
|
-
* not a one-shot command that can afford the usual contract. `bin/agent-dag.js`
|
|
1702
|
-
* hands its own argv to every worker it spawns — including the npx relaunch,
|
|
1703
|
-
* which starts a NEWER version of the package on the argv the user typed
|
|
1704
|
-
* against an older one — and the README recommends running it from a wrapper.
|
|
1705
|
-
* Refusing to boot over one token would turn a typo into a dark dashboard, and
|
|
1706
|
-
* an argument the newer build no longer knows into a failed upgrade that costs
|
|
1707
|
-
* the port and the session. The file already holds that position once, in the
|
|
1708
|
-
* `--all` branch: a flag the deck stopped needing is still accepted rather than
|
|
1709
|
-
* made fatal.
|
|
1710
|
-
*
|
|
1711
|
-
* So it goes where the deck puts everything else it decided on your behalf —
|
|
1712
|
-
* the startup report — and it goes at the END of it. reportStartup writes its
|
|
1713
|
-
* rows in a fixed order and three more land underneath them (the server, the
|
|
1714
|
-
* log, the browser), so a warning printed among those rows is a warning the
|
|
1715
|
-
* rows scroll over. Here it is the last line before the pulse indicator takes
|
|
1716
|
-
* the bottom of the screen and stops repainting anything above it.
|
|
1717
|
-
*/
|
|
1718
|
-
function reportUnknownFlags(unknown) {
|
|
1719
|
-
for (const token of unknown) {
|
|
1720
|
-
write(row({
|
|
1721
|
-
mark: G.warn, tone: P.warn, label: "unknown option",
|
|
1722
|
-
detail: `${token} ${G.dash} see \`${PRODUCT} --help\``,
|
|
1723
|
-
}));
|
|
1724
|
-
}
|
|
1725
|
-
}
|
|
1726
|
-
|
|
1727
|
-
/**
|
|
1728
|
-
* Every value-taking flag that was given no value it could use, named, one row
|
|
1729
|
-
* each — and printed beside the unknown ones because it is the same failure
|
|
1730
|
-
* wearing a different hat.
|
|
1731
|
-
*
|
|
1732
|
-
* #697: `--workspace`, `--history` and `--port` used to consume the following
|
|
1733
|
-
* token whatever it was, so `ccdeck --workspace $PROJ --no-persist` with `PROJ`
|
|
1734
|
-
* unset scoped the deck to a directory called `--no-persist`, kept persisting to
|
|
1735
|
-
* the shared log, and reported neither. Nothing landed in `unknown`, because the
|
|
1736
|
-
* token that belonged there had been eaten. The parser refuses that value now
|
|
1737
|
-
* and lists the flag here instead.
|
|
1738
|
-
*
|
|
1739
|
-
* Said rather than acted on, under exactly the argument reportUnknownFlags makes
|
|
1740
|
-
* above: the flag falls back to its documented default and the deck still boots.
|
|
1741
|
-
* The row is what makes the fallback a decision the user can see, and the rows
|
|
1742
|
-
* around it show its consequence — `workspace (all)` and the `log` line are
|
|
1743
|
-
* printed by the same report.
|
|
1744
|
-
*/
|
|
1745
|
-
function reportIncompleteFlags(incomplete) {
|
|
1746
|
-
for (const { flag, expects } of incomplete ?? []) {
|
|
1747
|
-
write(row({
|
|
1748
|
-
mark: G.warn, tone: P.warn, label: "missing value",
|
|
1749
|
-
detail: `${flag} ${G.dash} expected ${expects}; using the default`,
|
|
1750
|
-
}));
|
|
1751
|
-
}
|
|
1752
|
-
}
|
|
1753
|
-
|
|
1754
|
-
function printHelp() {
|
|
1755
|
-
process.stdout.write(`${PRODUCT} — live deck of Claude Code + Codex agents
|
|
1756
|
-
|
|
1757
|
-
Usage:
|
|
1758
|
-
${PRODUCT} [options]
|
|
1759
|
-
|
|
1760
|
-
Options:
|
|
1761
|
-
-p, --port <number> Preferred port (default: 4317; falls back to random 4318–4400)
|
|
1762
|
-
--no-open Don't open the browser automatically
|
|
1763
|
-
--foreground Hold the terminal, the way every version before 3.20
|
|
1764
|
-
did. Ctrl+C stops the deck again
|
|
1765
|
-
--new Start a second deck even if one is already running.
|
|
1766
|
-
Without it, a bare \`${PRODUCT}\` beside a deck that is
|
|
1767
|
-
already up opens that deck's tab instead of building
|
|
1768
|
-
a rival on another port
|
|
1769
|
-
--stop Stop the deck a bare \`${PRODUCT}\` would open.
|
|
1770
|
-
With --port <n>, stop that one; with --all, stop every
|
|
1771
|
-
deck on this machine
|
|
1772
|
-
--status What is running on this machine, and on which ports
|
|
1773
|
-
--logs What the deck wrote where a terminal would have shown
|
|
1774
|
-
it, and where that file is
|
|
1775
|
-
--install Put the deck on your PATH and start it at login.
|
|
1776
|
-
What an \`npx\` run needs to survive a reboot
|
|
1777
|
-
--install-service Start the deck when you log in. Set up on first run;
|
|
1778
|
-
this is only for putting it back
|
|
1779
|
-
--uninstall-service Stop starting at login. \`--uninstall\` does this too
|
|
1780
|
-
--workspace <path> Only capture sessions whose cwd is inside <path>
|
|
1781
|
-
--scope Restrict to current working directory
|
|
1782
|
-
--all Capture every session (default). Beside --stop it
|
|
1783
|
-
means every deck rather than every session
|
|
1784
|
-
--history <path> Override events log file (default: this platform's log directory)
|
|
1785
|
-
--no-persist Don't write or replay events log (RAM-only)
|
|
1786
|
-
--codex Force-enable Codex capture even if ~/.codex/ missing
|
|
1787
|
-
--no-codex Skip Codex capture (Claude only)
|
|
1788
|
-
--claude Force-enable Claude capture even if Claude Code wasn't found
|
|
1789
|
-
--no-claude Skip Claude entirely: no hooks, no claude-swap, no accounts panel
|
|
1790
|
-
--uninstall Remove ${PRODUCT}'s hooks from ~/.claude/settings.json and
|
|
1791
|
-
~/.codex/hooks.json, and restore any sound hooks of yours it parked.
|
|
1792
|
-
Hook entries only: the forwarder script, ~/.claude/agent-dag/,
|
|
1793
|
-
the events log, ~/.agents-deck/ and claude-swap all stay
|
|
1794
|
-
-h, --help Show this help
|
|
1795
|
-
-v, --version Print the version and exit
|
|
1796
|
-
|
|
1797
|
-
Anything else on the command line is reported as an unknown option and then
|
|
1798
|
-
ignored: the deck still starts.
|
|
1799
|
-
|
|
1800
|
-
A flag that takes a value never swallows the next flag. If the value is missing,
|
|
1801
|
-
empty, or itself looks like a flag — \`${PRODUCT} --workspace \$UNSET --no-persist\`
|
|
1802
|
-
after the shell has dropped an unset variable — the flag is reported, left on its
|
|
1803
|
-
default, and the token it would have eaten is parsed as the flag it is.
|
|
1804
|
-
`);
|
|
1805
|
-
}
|