ccdeck 3.29.11 → 3.31.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -3
- package/bin/agent-dag.js +67 -118
- package/bin/cli/help.js +67 -0
- package/bin/cli/login-item.js +182 -0
- package/bin/cli/one-shot.js +254 -0
- package/bin/cli/package.js +39 -0
- package/bin/cli/pulse.js +98 -0
- package/bin/cli/restart.js +179 -0
- package/bin/cli/screen.js +166 -0
- package/bin/cli/second-start.js +120 -0
- package/bin/cli/startup.js +459 -0
- package/bin/cli/uninstall.js +169 -0
- package/bin/deck.js +112 -1559
- package/dist/web/assets/BrowserWatchModal-n63GjLhv.js +1 -0
- package/dist/web/assets/UsageHistoryModal-DEwBX4Fk.js +1 -0
- package/dist/web/assets/index-CZJf1Cpp.js +299 -0
- package/dist/web/assets/index-D4q1ElXU.css +1 -0
- package/dist/web/index.html +2 -2
- package/hook/hook.js +15 -14
- package/package.json +1 -1
- package/release-notes.json +40 -0
- package/src/server/account-projects.mjs +25 -11
- package/src/server/account-routes.mjs +254 -0
- package/src/server/activity.mjs +3 -3
- package/src/server/append-budget.mjs +185 -0
- package/src/server/append-failures.mjs +147 -0
- package/src/server/args.mjs +37 -5
- package/src/server/atomic-write.mjs +300 -0
- package/src/server/block-notify.mjs +0 -5
- package/src/server/boot-lock.mjs +1 -1
- package/src/server/brand.mjs +7 -6
- package/src/server/browser-react.mjs +10 -2
- package/src/server/browser-watch-decks.mjs +146 -0
- package/src/server/browser-watch-feed.mjs +69 -0
- package/src/server/browser-watch-log.mjs +173 -0
- package/src/server/browser-watch-mtime.mjs +18 -0
- package/src/server/browser-watch-relay.mjs +112 -0
- package/src/server/browser-watch-routes.mjs +172 -0
- package/src/server/browser-watch-seen.mjs +127 -0
- package/src/server/browser-watch-store.mjs +14 -143
- package/src/server/browser-watch.mjs +278 -626
- package/src/server/canonical-path.mjs +150 -0
- package/src/server/ccusage-failure.mjs +64 -0
- package/src/server/ccusage-install.mjs +459 -0
- package/src/server/ccusage-runner.mjs +271 -0
- package/src/server/ccusage.mjs +21 -756
- package/src/server/claude-accounts.mjs +220 -464
- package/src/server/claude-collector.mjs +189 -0
- package/src/server/claude-dir.mjs +7 -3
- package/src/server/claude-identity.mjs +5 -4
- package/src/server/claude-reset-credits.mjs +130 -0
- package/src/server/claude-verdicts.mjs +174 -0
- package/src/server/clear-route.mjs +191 -0
- package/src/server/codex-auth.mjs +4 -4
- package/src/server/codex-dir.mjs +52 -8
- package/src/server/codex-enrichment.mjs +310 -0
- package/src/server/codex-quota.mjs +5 -3
- package/src/server/codex-translate.mjs +407 -0
- package/src/server/codex-usage.mjs +8 -5
- package/src/server/codex-watch.mjs +496 -0
- package/src/server/cswap-auto-loop.mjs +342 -0
- package/src/server/cswap-auto-readers.mjs +303 -0
- package/src/server/cswap-auto.mjs +51 -609
- package/src/server/cswap-install.mjs +69 -265
- package/src/server/cswap-layout.mjs +242 -0
- package/src/server/deck-home.mjs +2 -2
- package/src/server/deck-prefs.mjs +58 -184
- package/src/server/deck-scope.mjs +52 -0
- package/src/server/desktop-update-routes.mjs +75 -0
- package/src/server/detach.mjs +78 -0
- package/src/server/discovery.mjs +298 -0
- package/src/server/event-log.mjs +328 -0
- package/src/server/event-pipeline.mjs +235 -0
- package/src/server/event-ring.mjs +214 -0
- package/src/server/event-routes.mjs +352 -0
- package/src/server/event-sink.mjs +26 -0
- package/src/server/exec-children.mjs +128 -0
- package/src/server/exec-not-found.mjs +213 -0
- package/src/server/exec-output.mjs +23 -0
- package/src/server/exec-spec.mjs +341 -0
- package/src/server/exec.mjs +74 -661
- package/src/server/health-route.mjs +42 -0
- package/src/server/hook-challenge.mjs +37 -0
- package/src/server/http-io.mjs +104 -0
- package/src/server/index.mjs +174 -7620
- package/src/server/install-layout.mjs +506 -0
- package/src/server/installer.mjs +79 -608
- package/src/server/jsonl-chunks.mjs +139 -0
- package/src/server/lan-accounts.mjs +27 -0
- package/src/server/lan-beacon.mjs +457 -0
- package/src/server/lan-call.mjs +239 -0
- package/src/server/lan-copies.mjs +270 -0
- package/src/server/lan-deck.mjs +583 -0
- package/src/server/lan-dials.mjs +161 -0
- package/src/server/lan-engine.mjs +475 -1024
- package/src/server/lan-hearing.mjs +49 -0
- package/src/server/lan-inbound.mjs +100 -0
- package/src/server/lan-invite-offer.mjs +77 -0
- package/src/server/lan-invite.mjs +207 -0
- package/src/server/lan-lines.mjs +73 -0
- package/src/server/lan-manifest.mjs +74 -0
- package/src/server/lan-reach.mjs +82 -69
- package/src/server/lan-requests.mjs +249 -0
- package/src/server/lan-round-record.mjs +45 -0
- package/src/server/lan-round-timer.mjs +62 -0
- package/src/server/lan-routes.mjs +143 -0
- package/src/server/lan-serve.mjs +112 -0
- package/src/server/lan-socket.mjs +243 -925
- package/src/server/lan-sync.mjs +58 -1026
- package/src/server/lan-tailnet-poll.mjs +57 -0
- package/src/server/lan-turns.mjs +80 -0
- package/src/server/lan-wire.mjs +620 -0
- package/src/server/lifecycle.mjs +421 -0
- package/src/server/listen.mjs +110 -0
- package/src/server/live-decks.mjs +138 -0
- package/src/server/load-average.mjs +32 -0
- package/src/server/log-election.mjs +235 -0
- package/src/server/log-replay.mjs +374 -0
- package/src/server/log-writer.mjs +72 -557
- package/src/server/login-service.mjs +3 -2
- package/src/server/macmon.mjs +1 -1
- package/src/server/memory-files.mjs +163 -0
- package/src/server/memory-metrics.mjs +178 -0
- package/src/server/metrics-history.mjs +94 -0
- package/src/server/metrics-run.mjs +100 -0
- package/src/server/music-routes.mjs +134 -0
- package/src/server/network-metrics.mjs +5 -3
- package/src/server/network-sampler.mjs +256 -0
- package/src/server/npm-latest.mjs +385 -0
- package/src/server/npm-upgrade.mjs +352 -0
- package/src/server/npx.mjs +41 -7
- package/src/server/output-watch.mjs +5 -4
- package/src/server/pinned-build.mjs +110 -0
- package/src/server/prefs-give-back.mjs +206 -0
- package/src/server/prefs-path.mjs +14 -0
- package/src/server/prefs-refusal.mjs +86 -0
- package/src/server/prefs-routes.mjs +99 -0
- package/src/server/prefs-set-aside.mjs +86 -0
- package/src/server/prefs-state.mjs +70 -0
- package/src/server/process-command.mjs +104 -0
- package/src/server/process-list.mjs +547 -0
- package/src/server/provider-status.mjs +307 -0
- package/src/server/pulse-line.mjs +132 -0
- package/src/server/purge-key.mjs +7 -7
- package/src/server/quota-cli.mjs +174 -0
- package/src/server/quota-oauth.mjs +190 -0
- package/src/server/quota-shape.mjs +229 -0
- package/src/server/quota-store-resets.mjs +206 -0
- package/src/server/quota.mjs +123 -509
- package/src/server/relay-guard.mjs +3 -3
- package/src/server/request-gates.mjs +441 -0
- package/src/server/restart-note.mjs +180 -0
- package/src/server/retire-sound-hook.mjs +3 -3
- package/src/server/ring-bounds.mjs +215 -0
- package/src/server/running-deck.mjs +1 -1
- package/src/server/running-version.mjs +24 -0
- package/src/server/self-update.mjs +39 -1371
- package/src/server/session-enrichment.mjs +622 -0
- package/src/server/session-read-gate.mjs +49 -0
- package/src/server/session-tracking.mjs +242 -0
- package/src/server/sse-clients.mjs +222 -0
- package/src/server/stale-discovery.mjs +30 -0
- package/src/server/static-serve.mjs +81 -0
- package/src/server/supervisor.mjs +73 -12
- package/src/server/system-metrics.mjs +169 -1935
- package/src/server/term.mjs +11 -249
- package/src/server/thermal-metrics.mjs +520 -0
- package/src/server/thermal-sampler.mjs +204 -0
- package/src/server/token-redact.mjs +123 -0
- package/src/server/transcript-gate.mjs +107 -0
- package/src/server/transcript-scan.mjs +563 -0
- package/src/server/usage-routes.mjs +108 -0
- package/src/server/uv-bootstrap.mjs +1 -1
- package/src/server/wordmark.mjs +127 -0
- package/dist/web/assets/BrowserWatchModal-CyAFTvAY.js +0 -1
- package/dist/web/assets/UsageHistoryModal-KJfli0oa.js +0 -1
- package/dist/web/assets/index-Ck2RAla3.css +0 -1
- package/dist/web/assets/index-jmrcEsCj.js +0 -298
package/bin/deck.js
CHANGED
|
@@ -4,40 +4,42 @@
|
|
|
4
4
|
// exits with RESTART_CODE. On a respawn (AGENTS_DECK_RESPAWN=1) everything that
|
|
5
5
|
// was already done once this session is skipped — that is what makes a restart
|
|
6
6
|
// take about a second instead of the better part of ten.
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
} from "../src/server/
|
|
7
|
+
//
|
|
8
|
+
// This file is the order things happen in; what each step does lives in
|
|
9
|
+
// bin/cli/ — the one-shot commands, the terminal, the startup report, the
|
|
10
|
+
// second-start rule, the restart latch and the pulse line. Its top-level
|
|
11
|
+
// `await`s run in source order, so where a line sits here is part of what it
|
|
12
|
+
// means, and most of the comments below are about exactly that.
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
import { pathToFileURL } from "node:url";
|
|
15
|
+
import { existsSync } from "node:fs";
|
|
16
|
+
import { RESTART_CODE, UPGRADE_CODE, dieWithParent } from "../src/server/supervisor.mjs";
|
|
17
|
+
import { parseArgs, startPort, wantsCli } from "../src/server/args.mjs";
|
|
18
|
+
import { unregisteredDetail } from "../src/server/pulse-line.mjs";
|
|
17
19
|
import { PRODUCT } from "../src/server/brand.mjs";
|
|
18
|
-
import { invokedName, renameNotice } from "../src/server/invoked-as.mjs";
|
|
19
|
-
import { wayBackNote } from "../src/server/way-back.mjs";
|
|
20
|
-
import { budget, bootDeadlineMs } from "../src/server/boot-deadline.mjs";
|
|
21
20
|
// A leaf — fs, path and claude-dir.mjs, nothing else — so it is imported here
|
|
22
21
|
// with the rest rather than fetched later. Deliberately NOT the other way
|
|
23
22
|
// round: it takes the handshake as a parameter precisely so that it never has
|
|
24
23
|
// to import the server. See the note at the top of that file.
|
|
25
|
-
import { deckRegistryDir
|
|
24
|
+
import { deckRegistryDir } from "../src/server/running-deck.mjs";
|
|
26
25
|
// The same kind of leaf — fs, path, crypto and deck-probe.mjs — for the same
|
|
27
26
|
// reason: it is taken before anything else in the boot has run.
|
|
28
27
|
import { takeBootLock } from "../src/server/boot-lock.mjs";
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
//
|
|
40
|
-
|
|
28
|
+
// The package this worker belongs to — see there.
|
|
29
|
+
import { PKG_ROOT, PKG_VERSION } from "./cli/package.js";
|
|
30
|
+
import { printHelp } from "./cli/help.js";
|
|
31
|
+
import { uninstall } from "./cli/uninstall.js";
|
|
32
|
+
import { offerLoginItem } from "./cli/login-item.js";
|
|
33
|
+
import { oneShot } from "./cli/one-shot.js";
|
|
34
|
+
// The terminal the boot draws in: palette, glyphs, rows, the wordmark and the spinner.
|
|
35
|
+
import {
|
|
36
|
+
G, P, fileLink, printBanner, showCursor, step, takeCursor, tty, write,
|
|
37
|
+
} from "./cli/screen.js";
|
|
38
|
+
// The once-per-session work and the rows that report it.
|
|
39
|
+
import { reportReady, reportRestarted, reportStartup, startupWork } from "./cli/startup.js";
|
|
40
|
+
import { restartLatch } from "./cli/restart.js";
|
|
41
|
+
import { startPulse } from "./cli/pulse.js";
|
|
42
|
+
import { settleSecondStart } from "./cli/second-start.js";
|
|
41
43
|
|
|
42
44
|
const argv = process.argv.slice(2);
|
|
43
45
|
const flags = parseArgs(argv);
|
|
@@ -45,9 +47,8 @@ const flags = parseArgs(argv);
|
|
|
45
47
|
// Exit codes the supervisor reads as "bring me back": 75 from the files on
|
|
46
48
|
// disk, 76 through npx — which is the only way an npx run reaches a newer
|
|
47
49
|
// version, since its directory is never upgraded in place. Anything else it
|
|
48
|
-
// forwards.
|
|
49
|
-
|
|
50
|
-
const UPGRADE_CODE = 76;
|
|
50
|
+
// forwards. Both are the supervisor's own constants, so the two ends of the
|
|
51
|
+
// agreement cannot drift apart.
|
|
51
52
|
const RESPAWN = process.env.AGENTS_DECK_RESPAWN === "1";
|
|
52
53
|
const SUPERVISED = typeof process.send === "function";
|
|
53
54
|
|
|
@@ -76,475 +77,19 @@ if (flags.version) {
|
|
|
76
77
|
// own key, and a `--purge` that started a server instead would be a flag whose
|
|
77
78
|
// entire effect was silence.
|
|
78
79
|
if (flags.uninstall || flags.purge) {
|
|
79
|
-
|
|
80
|
-
// Anything that could not be taken out. An uninstall that removed nothing
|
|
81
|
-
// because it could not read the file has not uninstalled anything, and both
|
|
82
|
-
// the wording and the exit code have to say so: a user who is told it worked
|
|
83
|
-
// and still has our hooks firing on every event is worse off than one who is
|
|
84
|
-
// told it failed, because they have stopped looking.
|
|
85
|
-
let refused = false;
|
|
86
|
-
// Files already reported as unparseable. The Claude hooks and the sound hook
|
|
87
|
-
// live in the SAME settings.json, so a stray comma refuses both, and printing
|
|
88
|
-
// the whole path-plus-parser-error twice buries the one line that differs —
|
|
89
|
-
// which of our two installations is still in there.
|
|
90
|
-
const named = new Set();
|
|
91
|
-
// Its own glyphs for the same reason the bad-port line has them: this block
|
|
92
|
-
// runs at module top level, well before `G` is declared (#797).
|
|
93
|
-
const { dash: gDash } = glyphs(unicodeOK());
|
|
94
|
-
/** Report one provider's outcome. `ok` first — see uninstallHooks. */
|
|
95
|
-
const report = (res, label) => {
|
|
96
|
-
if (res.ok === false) {
|
|
97
|
-
refused = true;
|
|
98
|
-
named.add(res.settingsPath);
|
|
99
|
-
console.error(`${PRODUCT}: ${label} hooks NOT removed ${gDash} ${res.settingsPath} could not be read as JSON (${res.why}).`);
|
|
100
|
-
console.error(`${PRODUCT}: the __agent-dag hook entries are still in that file and keep firing on every ${label} event.`);
|
|
101
|
-
return;
|
|
102
|
-
}
|
|
103
|
-
console.log(res.changed
|
|
104
|
-
? `${PRODUCT}: hooks removed from ${res.settingsPath}`
|
|
105
|
-
: `${PRODUCT}: no ${label} hooks to remove`);
|
|
106
|
-
};
|
|
107
|
-
report(await uninstallHooks({ provider: "claude" }), "Claude");
|
|
108
|
-
// The old finish sound was a second entry in the same file, marked
|
|
109
|
-
// __agent-dag-sound rather than __agent-dag, and uninstallHooks does not know
|
|
110
|
-
// that mark — so it used to be left behind, playing on every turn after the
|
|
111
|
-
// deck was supposedly gone. #704 retired the mechanism outright, but the entry
|
|
112
|
-
// is still on every machine that had it, so removing it is still this
|
|
113
|
-
// command's job. So is the other half: turning the sound on parked the user's
|
|
114
|
-
// own afplay/PowerShell Stop hooks, and once the deck is uninstalled nothing
|
|
115
|
-
// else on the machine knows where they went.
|
|
116
|
-
// The login item goes too, and this is the one place the uninstall's
|
|
117
|
-
// documented narrowness has to bend. "Hook entries only" is right for the
|
|
118
|
-
// event log and the port registry, which are data somebody may still want —
|
|
119
|
-
// but a login item left behind after an uninstall is not data, it is a
|
|
120
|
-
// machine that keeps starting a deck whose hooks were just removed.
|
|
121
|
-
{
|
|
122
|
-
const svc = await import(pathToFileURL(join(PKG_ROOT, "src/server/login-service.mjs")).href);
|
|
123
|
-
const { deckDataDir: dataDir } = await import(pathToFileURL(join(PKG_ROOT, "src/server/deck-home.mjs")).href);
|
|
124
|
-
if (svc.readServiceRecord(dataDir()) !== null) {
|
|
125
|
-
const gone = svc.uninstallService();
|
|
126
|
-
svc.writeServiceRecord(dataDir(), { removed: new Date().toISOString(), version: PKG_VERSION });
|
|
127
|
-
// Silent when there was nothing on the machine: an uninstall that reports
|
|
128
|
-
// removing a login item this deck never had is the same lie the explicit
|
|
129
|
-
// command used to tell, in the place a reader is least able to check it.
|
|
130
|
-
if (!gone.ok) {
|
|
131
|
-
// `gDash`, not an em dash: this block runs at module top level, on a
|
|
132
|
-
// console that may be the legacy Windows one, and #797 is the rule that
|
|
133
|
-
// a printed string never carries punctuation the terminal may not have.
|
|
134
|
-
console.error(`${PRODUCT}: could NOT remove the login item ${gDash} ${gone.reason} (${gone.path})`);
|
|
135
|
-
} else if (gone.existed) {
|
|
136
|
-
console.log(`${PRODUCT}: no longer starts at login`);
|
|
137
|
-
}
|
|
138
|
-
if (!gone.ok) refused = true;
|
|
139
|
-
}
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
const { retireSoundHook } = await import(pathToFileURL(join(PKG_ROOT, "src/server/retire-sound-hook.mjs")).href);
|
|
143
|
-
const sound = await retireSoundHook();
|
|
144
|
-
if (sound.removed) console.log(`${PRODUCT}: sound hook removed`);
|
|
145
|
-
if (sound.restored) console.log(`${PRODUCT}: restored ${sound.restored} of your own sound hook(s)`);
|
|
146
|
-
if (sound.ok === false) {
|
|
147
|
-
refused = true;
|
|
148
|
-
// Two different refusals, and saying the wrong one sends the user to the
|
|
149
|
-
// wrong file. `settings_unreadable` means nothing was touched at all — and
|
|
150
|
-
// when the forwarders already named that same file, the whole path and
|
|
151
|
-
// parser error would only bury the one line that differs. `parked_unreadable`
|
|
152
|
-
// is the other file: our entry IS out (the lines above said so), and what is
|
|
153
|
-
// still owed is the user's own hooks, which stay parked until they repair it.
|
|
154
|
-
if (sound.reason === "settings_unreadable") {
|
|
155
|
-
console.error(named.has(sound.settingsPath)
|
|
156
|
-
? `${PRODUCT}: the sound hook is still in that file too.`
|
|
157
|
-
: `${PRODUCT}: sound hook left in place — ${sound.message}`);
|
|
158
|
-
named.add(sound.settingsPath);
|
|
159
|
-
} else {
|
|
160
|
-
console.error(`${PRODUCT}: your own sound hooks were NOT restored ${gDash} ${sound.message}`);
|
|
161
|
-
}
|
|
162
|
-
}
|
|
163
|
-
if (hasCodexInstalled()) {
|
|
164
|
-
report(await uninstallHooks({ provider: "codex" }), "Codex");
|
|
165
|
-
}
|
|
166
|
-
// ── THE PRIVATE KEY ────────────────────────────────────────────────────────
|
|
167
|
-
//
|
|
168
|
-
// The one thing left on the disk that is a CREDENTIAL rather than data. Every
|
|
169
|
-
// deck paired with this one has pinned the key in prefs.json, and until #959
|
|
170
|
-
// nothing in this command — or in the `--help` text, or in the README's
|
|
171
|
-
// uninstall paragraph — named the file, so somebody who followed the
|
|
172
|
-
// instructions to the letter believed the machine was clean and it was not.
|
|
173
|
-
//
|
|
174
|
-
// NAMED, NOT REMOVED, unless somebody asked. prefs.json also holds the
|
|
175
|
-
// pairings, the aliases and which accounts this deck offers, and deleting a
|
|
176
|
-
// user's settings out from under a command documented as "hook entries only"
|
|
177
|
-
// is a different complaint of the same size. So the default is to say exactly
|
|
178
|
-
// where the key is — resolved for THIS machine, both copies, because
|
|
179
|
-
// `migrateDeckFiles` copies rather than moves and the old one is in a
|
|
180
|
-
// directory nothing calls the deck's state — and `--purge` is the sentence
|
|
181
|
-
// somebody types when they mean it.
|
|
182
|
-
{
|
|
183
|
-
const { keyDirs, findKeyFiles, purgeKeyFiles } =
|
|
184
|
-
await import(pathToFileURL(join(PKG_ROOT, "src/server/purge-key.mjs")).href);
|
|
185
|
-
const found = await findKeyFiles(keyDirs());
|
|
186
|
-
if (flags.purge) {
|
|
187
|
-
const { removed, failed } = await purgeKeyFiles(found);
|
|
188
|
-
for (const path of removed) console.log(`${PRODUCT}: removed ${path}`);
|
|
189
|
-
for (const f of failed) {
|
|
190
|
-
refused = true;
|
|
191
|
-
// `gDash`, not an em dash: top-level block, possibly the legacy Windows
|
|
192
|
-
// console (#797).
|
|
193
|
-
console.error(`${PRODUCT}: could NOT remove ${f.path} ${gDash} ${f.why}`);
|
|
194
|
-
console.error(`${PRODUCT}: this deck's LAN private key is still in that file.`);
|
|
195
|
-
}
|
|
196
|
-
if (removed.length === 0 && failed.length === 0) {
|
|
197
|
-
console.log(`${PRODUCT}: no ${PRODUCT} state to purge`);
|
|
198
|
-
}
|
|
199
|
-
} else {
|
|
200
|
-
// "no-key" files are not mentioned. Telling somebody their private key is
|
|
201
|
-
// on the machine when the file has none is the same defect as the silence,
|
|
202
|
-
// pointing the other way. "unknown" IS mentioned: an unparseable file is
|
|
203
|
-
// the one nobody can rule a key out of.
|
|
204
|
-
const holding = found.filter(f => f.holds !== "no-key");
|
|
205
|
-
if (holding.length > 0) {
|
|
206
|
-
console.log(`${PRODUCT}: this deck's LAN private key is still on this machine:`);
|
|
207
|
-
for (const f of holding) {
|
|
208
|
-
console.log(`${PRODUCT}: ${f.path}${f.holds === "unknown" ? ` (unreadable ${gDash} ${f.why})` : ""}`);
|
|
209
|
-
}
|
|
210
|
-
console.log(`${PRODUCT}: every deck you paired with has pinned that key. \`${INVOKED_AS ?? PRODUCT} --uninstall --purge\` removes the file(s) above.`);
|
|
211
|
-
}
|
|
212
|
-
}
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
// The remedy last and once, after every symptom above it, rather than once
|
|
216
|
-
// per refusal in the middle of the list.
|
|
217
|
-
if (named.size > 0) {
|
|
218
|
-
console.error(`${PRODUCT}: repair the JSON (or move the file aside), then run \`${PRODUCT} --uninstall\` again.`);
|
|
219
|
-
}
|
|
220
|
-
// Non-zero when any half of it refused, so `ccdeck --uninstall && …` and every
|
|
221
|
-
// CI step that runs this stops on the failure instead of continuing past it.
|
|
222
|
-
process.exit(refused ? 1 : 0);
|
|
80
|
+
process.exit(await uninstall(flags));
|
|
223
81
|
}
|
|
224
82
|
|
|
225
|
-
// ── the
|
|
83
|
+
// ── the one-shot commands ─────────────────────────────────────────────────────
|
|
226
84
|
//
|
|
227
|
-
//
|
|
228
|
-
//
|
|
85
|
+
// `--status`, `--logs`, `--stop` and the three login-item commands, answered in
|
|
86
|
+
// bin/cli/one-shot.js in the shape `--uninstall` already established: do the
|
|
87
|
+
// thing, print, exit, and never start a server. Asked HERE, above the migration
|
|
229
88
|
// and well above the heavy imports, because a command that ends a deck has no
|
|
230
89
|
// business moving that deck's files on the way past, and because asking a
|
|
231
90
|
// server to stop should not require starting one.
|
|
232
|
-
//
|
|
233
|
-
// Their own glyphs and palette rather than `G`/`P`, for the reason the bad-port
|
|
234
|
-
// block below gives (#797): those are declared two hundred lines further down
|
|
235
|
-
// and this runs at module top level.
|
|
236
|
-
//
|
|
237
|
-
// WHICH DECK. Every one, unless `--port <n>` names one. A start keeps at most
|
|
238
|
-
// one deck now, so a second is a leftover from before that rule, and an off
|
|
239
|
-
// switch that ended one of two would leave the machine running. (`--all` is the
|
|
240
|
-
// legacy capture flag, a no-op since it became the default; beside `--stop` it
|
|
241
|
-
// always meant "every deck", and that is what the bare command means now.)
|
|
242
91
|
if (flags.stop || flags.status || flags.logs || flags.install || flags.installService || flags.uninstallService) {
|
|
243
|
-
|
|
244
|
-
const tone = palette(colorProfile({ isTTY: Boolean(process.stdout.isTTY) }));
|
|
245
|
-
const say = (line) => process.stdout.write(`${line}\n`);
|
|
246
|
-
|
|
247
|
-
// WHAT THE ONE-SHOTS USED TO SWALLOW.
|
|
248
|
-
//
|
|
249
|
-
// reportUnknownFlags and reportIncompleteFlags live below this block and
|
|
250
|
-
// cannot be called from it — they reach for `G`, `P` and `write`, all
|
|
251
|
-
// declared a thousand lines down, so a call here is a temporal dead zone
|
|
252
|
-
// (the same reason the `--port` guard builds its own glyphs, #797). So the
|
|
253
|
-
// rows are written here, with this block's own tone.
|
|
254
|
-
//
|
|
255
|
-
// Without them `--help`'s closing promise — "Anything else on the command
|
|
256
|
-
// line is reported as an unknown option and then ignored" — held for a boot
|
|
257
|
-
// and for none of the nine one-shots: `ccdeck --status --prot 4317` printed
|
|
258
|
-
// the status and never mentioned the flag.
|
|
259
|
-
for (const token of flags.unknown ?? []) {
|
|
260
|
-
process.stderr.write(` ${tone.warn}${gWarn} unknown option${tone.reset} ${token} ${dash} see \`${INVOKED_AS ?? PRODUCT} --help\`\n`);
|
|
261
|
-
}
|
|
262
|
-
for (const { flag, expects } of flags.incomplete ?? []) {
|
|
263
|
-
process.stderr.write(` ${tone.warn}${gWarn} missing value${tone.reset} ${flag} ${dash} expected ${expects}\n`);
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
// AND `--stop` FAILS CLOSED ON A PORT IT CANNOT USE.
|
|
267
|
-
//
|
|
268
|
-
// The selector below reads "no usable port" and "no port asked for" as the
|
|
269
|
-
// same thing, and the second means every deck — so `ccdeck --stop --port
|
|
270
|
-
// 431x`, or `--port $UNSET`, ended every deck on the machine and exited 0.
|
|
271
|
-
// The guard that refuses a bad port sits ~270 lines below this block, which
|
|
272
|
-
// always exits before reaching it.
|
|
273
|
-
//
|
|
274
|
-
// A narrowing flag that fails open to "everything" is the wrong default for
|
|
275
|
-
// an off switch: failing closed costs a retype, failing open costs a deck
|
|
276
|
-
// somebody else was watching.
|
|
277
|
-
if (flags.stop) {
|
|
278
|
-
const askedPort = (flags.incomplete ?? []).some(x => x.flag === "--port" || x.flag === "-p");
|
|
279
|
-
const badPort = flags.port != null && !isPortValue(flags.port);
|
|
280
|
-
if (badPort || askedPort) {
|
|
281
|
-
const shown = badPort ? ` ${flags.port}` : "";
|
|
282
|
-
console.error(`${INVOKED_AS ?? PRODUCT}: --port${shown}: not a port number ${dash} expected 0-65535.`);
|
|
283
|
-
console.error(`${INVOKED_AS ?? PRODUCT}: refusing to stop every deck when you asked for one.`);
|
|
284
|
-
process.exit(1);
|
|
285
|
-
}
|
|
286
|
-
}
|
|
287
|
-
|
|
288
|
-
const { deckDataDir, deckLogDir } = await import(pathToFileURL(join(PKG_ROOT, "src/server/deck-home.mjs")).href);
|
|
289
|
-
|
|
290
|
-
// ── --install ─────────────────────────────────────────────────────────────
|
|
291
|
-
//
|
|
292
|
-
// The one command that turns an npx run into a deck that comes back after a
|
|
293
|
-
// reboot. `npx ccdeck` cannot start at login — a login item must name a path
|
|
294
|
-
// that will still be there tomorrow, and npx runs out of a cache npm deletes
|
|
295
|
-
// whenever it likes — so this puts the package on PATH and points the service
|
|
296
|
-
// at THAT.
|
|
297
|
-
//
|
|
298
|
-
// BEHIND A FLAG, because `npx` means "run without installing" and a tool that
|
|
299
|
-
// installs itself anyway is the tool people uninstall. Somebody typed this.
|
|
300
|
-
if (flags.install) {
|
|
301
|
-
const gi = await import(pathToFileURL(join(PKG_ROOT, "src/server/global-install.mjs")).href);
|
|
302
|
-
const svc = await import(pathToFileURL(join(PKG_ROOT, "src/server/login-service.mjs")).href);
|
|
303
|
-
const { installedName } = await import(pathToFileURL(join(PKG_ROOT, "src/server/self-update.mjs")).href);
|
|
304
|
-
const { run } = await import(pathToFileURL(join(PKG_ROOT, "src/server/exec.mjs")).href);
|
|
305
|
-
// The name they typed, not ours. Three packages publish this deck, and
|
|
306
|
-
// installing `ccdeck` for somebody who ran `npx agent-dag` hands them a
|
|
307
|
-
// command they did not ask for.
|
|
308
|
-
const pkg = installedName(PKG_ROOT, PRODUCT);
|
|
309
|
-
|
|
310
|
-
say(`\n ${tone.muted}${dash} installing ${pkg} globally${gEllipsis}${tone.reset}`);
|
|
311
|
-
const got = await run("npm", ["i", "-g", pkg], { timeout: gi.INSTALL_TIMEOUT_MS });
|
|
312
|
-
if (!got?.ok) {
|
|
313
|
-
say(` ${tone.err}${gWarn} ${gi.installFailure(got, { pkg })}${tone.reset}\n`);
|
|
314
|
-
process.exit(1);
|
|
315
|
-
}
|
|
316
|
-
say(` ${tone.ok}${gOk}${tone.reset} ${pkg} is on your PATH${tone.muted} ${bullet} type \`${pkg}\` to start it${tone.reset}`);
|
|
317
|
-
|
|
318
|
-
// WHERE npm PUT IT, asked rather than assumed: a prefix the user set
|
|
319
|
-
// themselves is common and nothing here can guess it.
|
|
320
|
-
const root = gi.readGlobalRoot(await run("npm", ["root", "-g"], { timeout: 30_000 }));
|
|
321
|
-
const script = gi.globalScript(root, pkg);
|
|
322
|
-
if (!script) {
|
|
323
|
-
say(` ${tone.warn}${gWarn} installed, but npm did not say where ${dash} run \`${pkg} --install-service\` to start it at login${tone.reset}\n`);
|
|
324
|
-
process.exit(0);
|
|
325
|
-
}
|
|
326
|
-
const out = svc.installService({ script, logPath: join(deckLogDir(), "deck.log"), product: PRODUCT });
|
|
327
|
-
if (!out.ok) {
|
|
328
|
-
say(` ${tone.warn}${gWarn} installed, but it will not start at login ${dash} ${out.reason}${tone.reset}\n`);
|
|
329
|
-
process.exit(0);
|
|
330
|
-
}
|
|
331
|
-
svc.writeServiceRecord(deckDataDir(), { installed: PKG_VERSION, at: new Date().toISOString(), path: out.path });
|
|
332
|
-
say(` ${tone.ok}${gOk}${tone.reset} and starts when you log in${tone.muted} ${bullet} ${out.path}${tone.reset}`);
|
|
333
|
-
if (svc.lingerState() === "off") {
|
|
334
|
-
say(` ${tone.warn}${gWarn} systemd tears your session down at logout, so the deck goes with it.${tone.reset}`);
|
|
335
|
-
say(` ${tone.muted}\`sudo loginctl enable-linger $USER\` keeps it running when you are logged out.${tone.reset}`);
|
|
336
|
-
}
|
|
337
|
-
say(` ${tone.muted}\`${pkg} --uninstall-service\` undoes the login part${tone.reset}\n`);
|
|
338
|
-
process.exit(0);
|
|
339
|
-
}
|
|
340
|
-
|
|
341
|
-
// ── --install-service / --uninstall-service ───────────────────────────────
|
|
342
|
-
// Answered before the registry is read: neither one is about a deck that is
|
|
343
|
-
// running, and both are as meaningful on a machine with no deck up as on one
|
|
344
|
-
// with three.
|
|
345
|
-
if (flags.installService || flags.uninstallService) {
|
|
346
|
-
const svc = await import(pathToFileURL(join(PKG_ROOT, "src/server/login-service.mjs")).href);
|
|
347
|
-
const { isGitCheckout, isNpxInstall } = await import(pathToFileURL(join(PKG_ROOT, "src/server/self-update.mjs")).href);
|
|
348
|
-
if (flags.uninstallService) {
|
|
349
|
-
const out = svc.uninstallService();
|
|
350
|
-
// Recorded either way. The record is what stops the next ordinary start
|
|
351
|
-
// putting back what was just taken away, and a tool that argues with its
|
|
352
|
-
// user about a login item is a tool that gets uninstalled entirely.
|
|
353
|
-
svc.writeServiceRecord(deckDataDir(), { removed: new Date().toISOString(), version: PKG_VERSION });
|
|
354
|
-
// `existed` rather than the record: the record says what THIS tool last
|
|
355
|
-
// did, and the machine is what actually has a login item on it. Somebody
|
|
356
|
-
// who removed the plist by hand should be told the truth about the
|
|
357
|
-
// machine, not about our bookkeeping.
|
|
358
|
-
say(!out.ok
|
|
359
|
-
? `\n ${tone.err}${gWarn} could not remove it ${dash} ${out.reason}${tone.reset}\n`
|
|
360
|
-
: out.existed
|
|
361
|
-
? `\n ${tone.ok}${gOk}${tone.reset} no longer starts at login${tone.muted} ${bullet} ${out.path}${tone.reset}\n`
|
|
362
|
-
: `\n ${tone.muted}${dash} it was not starting at login${tone.reset}\n`);
|
|
363
|
-
process.exit(out.ok ? 0 : 1);
|
|
364
|
-
}
|
|
365
|
-
if (isGitCheckout(PKG_ROOT)) {
|
|
366
|
-
// Not refused outright — somebody running from a checkout may genuinely
|
|
367
|
-
// want this — but not done silently either: the item would name a working
|
|
368
|
-
// tree, and that is worth knowing before it is written.
|
|
369
|
-
say(`\n ${tone.warn}${gWarn} this is a checkout, so the login item would name ${PKG_ROOT}${tone.reset}`);
|
|
370
|
-
say(` ${tone.muted}a renamed, moved or deleted working tree leaves a login item pointing at nothing${tone.reset}\n`);
|
|
371
|
-
}
|
|
372
|
-
if (isNpxInstall(PKG_ROOT)) {
|
|
373
|
-
// The item would name a path inside ~/.npm/_npx/<hash>/, which npm deletes
|
|
374
|
-
// whenever it feels like it — a login item pointing at nothing, forever,
|
|
375
|
-
// on a machine where nothing was ever installed.
|
|
376
|
-
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}`);
|
|
377
|
-
say(` ${tone.muted}install it first: \`npm i -g ${INVOKED_AS ?? PRODUCT}\`${tone.reset}\n`);
|
|
378
|
-
process.exit(1);
|
|
379
|
-
}
|
|
380
|
-
const out = svc.installService({
|
|
381
|
-
script: join(PKG_ROOT, "bin", "agent-dag.js"),
|
|
382
|
-
logPath: join(deckLogDir(), "deck.log"),
|
|
383
|
-
product: PRODUCT,
|
|
384
|
-
});
|
|
385
|
-
if (!out.ok) {
|
|
386
|
-
say(`\n ${tone.err}${gWarn} could not set it up ${dash} ${out.reason}${tone.reset}\n`);
|
|
387
|
-
process.exit(1);
|
|
388
|
-
}
|
|
389
|
-
svc.writeServiceRecord(deckDataDir(), { installed: PKG_VERSION, at: new Date().toISOString(), path: out.path });
|
|
390
|
-
say(`\n ${tone.ok}${gOk}${tone.reset} starts when you log in${tone.muted} ${bullet} ${out.path}${tone.reset}`);
|
|
391
|
-
if (out.how === "file-only") {
|
|
392
|
-
// The file is on disk and both launchd and systemd read their directories
|
|
393
|
-
// at the next login, so this works from then on. Said rather than hidden:
|
|
394
|
-
// "it will work tomorrow" is a different promise from "it works now".
|
|
395
|
-
say(` ${tone.warn}${gWarn} not started now ${dash} ${out.reason}. It will come up at your next login.${tone.reset}`);
|
|
396
|
-
}
|
|
397
|
-
// The one place the three platforms genuinely differ in what this buys you.
|
|
398
|
-
if (svc.lingerState() === "off") {
|
|
399
|
-
say(` ${tone.warn}${gWarn} systemd tears your session down at logout, so the deck goes with it.${tone.reset}`);
|
|
400
|
-
say(` ${tone.muted}\`sudo loginctl enable-linger $USER\` keeps it running when you are logged out.${tone.reset}`);
|
|
401
|
-
}
|
|
402
|
-
say(` ${tone.muted}\`${INVOKED_AS ?? PRODUCT} --uninstall-service\` undoes it${tone.reset}\n`);
|
|
403
|
-
process.exit(0);
|
|
404
|
-
}
|
|
405
|
-
const { canonicalLogPath } = await import(pathToFileURL(join(PKG_ROOT, "src/server/log-writer.mjs")).href);
|
|
406
|
-
const { hasCodexInstalled, codexHomeField } = await import(pathToFileURL(join(PKG_ROOT, "src/server/installer.mjs")).href);
|
|
407
|
-
const { hasClaudeInstalled } = await import(pathToFileURL(join(PKG_ROOT, "src/server/claude-dir.mjs")).href);
|
|
408
|
-
const { liveDecks, sameShape } = await import(pathToFileURL(join(PKG_ROOT, "src/server/running-deck.mjs")).href);
|
|
409
|
-
|
|
410
|
-
// The default shape, spelled the same way the boot below spells it. NOT the
|
|
411
|
-
// shape of the flags on THIS command line: `--stop --no-codex` is not a
|
|
412
|
-
// request to stop a Codex-less deck, it is a flag that means nothing here,
|
|
413
|
-
// and reading it as a selector would make `--stop` miss the deck it was
|
|
414
|
-
// pointed at and say "nothing is running".
|
|
415
|
-
const mine = {
|
|
416
|
-
workspace: "",
|
|
417
|
-
persist: canonicalLogPath(join(deckLogDir(), "events.jsonl")),
|
|
418
|
-
codex: hasCodexInstalled(),
|
|
419
|
-
claude: hasClaudeInstalled(),
|
|
420
|
-
};
|
|
421
|
-
// And the Codex tree, the one field of a start's shape that is not a flag. A
|
|
422
|
-
// bare start has passed it since #1123 and replaces a deck on another tree, so
|
|
423
|
-
// without it here every tree matched, and `--status` could mark as "opens this
|
|
424
|
-
// one" exactly the deck the next `ccdeck` stops (#1134). Worked out by the
|
|
425
|
-
// function the start and the discovery record use, so all three agree.
|
|
426
|
-
mine.codexHome = codexHomeField(mine.codex);
|
|
427
|
-
|
|
428
|
-
// ── --logs ────────────────────────────────────────────────────────────────
|
|
429
|
-
// What the deck wrote where a terminal would have shown it. Printed RAW,
|
|
430
|
-
// escapes and all: the launcher passes its own colour tier down to a detached
|
|
431
|
-
// deck, so this file is a mirror of the terminal the deck was started from
|
|
432
|
-
// and re-rendering it is the whole point. The path goes last, because that is
|
|
433
|
-
// the line somebody copies into `tail -f`.
|
|
434
|
-
if (flags.logs) {
|
|
435
|
-
const { readFileSync: readLog, statSync: statLog } = await import("node:fs");
|
|
436
|
-
const logPath = join(deckLogDir(), "deck.log");
|
|
437
|
-
let text = null;
|
|
438
|
-
try { text = readLog(logPath, "utf8"); } catch { /* never started, or swept */ }
|
|
439
|
-
if (text === null) {
|
|
440
|
-
say(`\n ${tone.muted}${dash} nothing logged yet ${dash} ${logPath}${tone.reset}\n`);
|
|
441
|
-
process.exit(0);
|
|
442
|
-
}
|
|
443
|
-
// The tail, not the file. It is truncated at the first start on an idle
|
|
444
|
-
// machine, so it is normally small — but an attach appends to a running
|
|
445
|
-
// deck's log every time, and a long-lived deck's log is somebody's week.
|
|
446
|
-
const lines = text.split("\n");
|
|
447
|
-
const shown = lines.slice(Math.max(0, lines.length - 200));
|
|
448
|
-
if (shown.length < lines.length) {
|
|
449
|
-
say(` ${tone.muted}${dash} showing the last ${shown.length} of ${lines.length} lines${tone.reset}`);
|
|
450
|
-
}
|
|
451
|
-
process.stdout.write(shown.join("\n"));
|
|
452
|
-
if (!text.endsWith("\n")) say("");
|
|
453
|
-
const size = (() => { try { return statLog(logPath).size; } catch { return 0; } })();
|
|
454
|
-
say(` ${tone.muted}${dash} ${logPath} ${bullet} ${size} bytes${tone.reset}\n`);
|
|
455
|
-
process.exit(0);
|
|
456
|
-
}
|
|
457
|
-
|
|
458
|
-
const decks = await liveDecks().catch(() => []);
|
|
459
|
-
const age = (d) => sinceLabel(Date.now() - Date.parse(d.startedAt ?? ""));
|
|
460
|
-
const where = (d) => (d.workspace ? d.workspace : "(all)");
|
|
461
|
-
const url = (d) => `http://127.0.0.1:${d.port}`;
|
|
462
|
-
|
|
463
|
-
if (flags.status) {
|
|
464
|
-
if (!decks.length) {
|
|
465
|
-
say(`\n ${tone.muted}${dash} no deck is running ${dash} \`${INVOKED_AS ?? PRODUCT}\` starts one${tone.reset}\n`);
|
|
466
|
-
process.exit(0);
|
|
467
|
-
}
|
|
468
|
-
// The one a bare `ccdeck` would open is marked, because with two decks up
|
|
469
|
-
// that is the only question this command is really being asked.
|
|
470
|
-
const opens = decks.find(d => sameShape(d, mine)) ?? null;
|
|
471
|
-
say("");
|
|
472
|
-
for (const d of decks) {
|
|
473
|
-
// The version chunk is dropped rather than printed as "v?" for a deck too
|
|
474
|
-
// old to publish one — same rule as the attach line, and for the same
|
|
475
|
-
// reason: a question mark beside two real facts reads as a fault.
|
|
476
|
-
const head = [d.version ? `v${d.version}` : "", `pid ${d.pid}`, `up ${age(d)}`]
|
|
477
|
-
.filter(Boolean).join(` ${bullet} `);
|
|
478
|
-
const mark = d === opens ? `${tone.ok}${gOk}${tone.reset}` : `${tone.muted}${bullet}${tone.reset}`;
|
|
479
|
-
const tail = d === opens ? `${tone.muted} ${arrow} \`${INVOKED_AS ?? PRODUCT}\` opens this one${tone.reset}` : "";
|
|
480
|
-
say(` ${mark} ${tone.muted}${head}${tone.reset}${tail}`);
|
|
481
|
-
say(` ${tone.accent}${tone.bold}${url(d)}${tone.reset}`);
|
|
482
|
-
say(` ${tone.muted}${where(d)} ${bullet} ${d.persist ?? "no log (--no-persist)"}${tone.reset}`);
|
|
483
|
-
}
|
|
484
|
-
say("");
|
|
485
|
-
process.exit(0);
|
|
486
|
-
}
|
|
487
|
-
|
|
488
|
-
// ── --stop ────────────────────────────────────────────────────────────────
|
|
489
|
-
const { stopDeck } = await import(pathToFileURL(join(PKG_ROOT, "src/server/stop-deck.mjs")).href);
|
|
490
|
-
// `flags.port`, not the `rawPort` computed below: that folds in
|
|
491
|
-
// AGENT_DAG_PORT, which is how somebody RUNS a deck rather than which deck
|
|
492
|
-
// they mean to stop — and a deck started on a custom port still has the
|
|
493
|
-
// default SHAPE, so the matcher finds it without help. It is also declared
|
|
494
|
-
// below this block, which would make reading it here a ReferenceError (#797).
|
|
495
|
-
const named = flags.port != null && isPortValue(flags.port) ? Number(flags.port) : null;
|
|
496
|
-
// EVERY DECK, unless one is named. There is meant to be one — a start keeps
|
|
497
|
-
// at most one now — so a second here is a leftover from before that rule, and
|
|
498
|
-
// an off switch that ended one of two would leave the machine running. `--all`
|
|
499
|
-
// is still accepted, and now means what the bare command means.
|
|
500
|
-
const wanted = named !== null ? decks.filter(d => d.port === named) : decks;
|
|
501
|
-
|
|
502
|
-
if (!wanted.length) {
|
|
503
|
-
// Two different silences, and saying the wrong one sends the reader looking
|
|
504
|
-
// in the wrong place.
|
|
505
|
-
const why = named !== null && decks.length
|
|
506
|
-
? `no deck is listening on ${named} ${dash} \`${INVOKED_AS ?? PRODUCT} --status\` lists them`
|
|
507
|
-
: `no deck is running`;
|
|
508
|
-
say(`\n ${tone.muted}${dash} ${why}${tone.reset}\n`);
|
|
509
|
-
process.exit(0);
|
|
510
|
-
}
|
|
511
|
-
|
|
512
|
-
say("");
|
|
513
|
-
let refused = false;
|
|
514
|
-
for (const d of wanted) {
|
|
515
|
-
const was = age(d);
|
|
516
|
-
const out = await stopDeck(d);
|
|
517
|
-
if (!out.ok) {
|
|
518
|
-
refused = true;
|
|
519
|
-
say(` ${tone.err}${gWarn} could not stop pid ${d.pid} on ${d.port} ${dash} ${out.reason}${tone.reset}`);
|
|
520
|
-
continue;
|
|
521
|
-
}
|
|
522
|
-
// HOW it went out, not just that it did. "asked" means the deck closed its
|
|
523
|
-
// listener, unlinked its registration and left the LAN cleanly; anything
|
|
524
|
-
// else means none of that happened and the next boot has litter to sweep.
|
|
525
|
-
const how = out.how === "asked"
|
|
526
|
-
? ""
|
|
527
|
-
: out.old
|
|
528
|
-
? ` ${tone.muted}(${out.how} ${dash} that deck predates \`--stop\`)${tone.reset}`
|
|
529
|
-
: ` ${tone.muted}(${out.how} ${dash} it did not answer)${tone.reset}`;
|
|
530
|
-
say(` ${tone.ok}${gOk}${tone.reset} stopped${tone.muted} ${bullet} pid ${d.pid} ${bullet} port ${d.port} ${bullet} was up ${was}${tone.reset}${how}`);
|
|
531
|
-
}
|
|
532
|
-
|
|
533
|
-
// What is still up, named. A command that ends one of three decks and says
|
|
534
|
-
// only "stopped" leaves the reader believing the machine is clear.
|
|
535
|
-
const left = decks.filter(d => !wanted.includes(d));
|
|
536
|
-
if (left.length) {
|
|
537
|
-
say("");
|
|
538
|
-
say(` ${tone.muted}${dash} ${left.length} other deck${left.length === 1 ? "" : "s"} still running:${tone.reset}`);
|
|
539
|
-
for (const d of left) say(` ${tone.muted}pid ${d.pid} ${bullet} ${d.port} ${bullet} ${where(d)}${tone.reset}`);
|
|
540
|
-
// `--stop --all` used to be named here as the way to reach every deck. It
|
|
541
|
-
// is not: `--all` is a parsed no-op, and a bare `--stop` already ends every
|
|
542
|
-
// deck — which is why this line is only reachable after `--stop --port <n>`
|
|
543
|
-
// narrowed one. Naming the narrowing form is the half that is true.
|
|
544
|
-
say(` ${tone.muted}\`${INVOKED_AS ?? PRODUCT} --stop\` ends every deck ${bullet} \`--stop --port <n>\` ends one${tone.reset}`);
|
|
545
|
-
}
|
|
546
|
-
say("");
|
|
547
|
-
process.exit(refused ? 1 : 0);
|
|
92
|
+
process.exit(await oneShot(flags));
|
|
548
93
|
}
|
|
549
94
|
|
|
550
95
|
// The port, and the one piece of argv the deck really does refuse to boot over.
|
|
@@ -555,25 +100,18 @@ if (flags.stop || flags.status || flags.logs || flags.install || flags.installSe
|
|
|
555
100
|
// `listen` with Node's own wording: "options.port should be >= 0 and < 65536.
|
|
556
101
|
// Received type number (NaN)." That names neither the flag nor the value the
|
|
557
102
|
// user typed, and arrives after a page of green ticks. Same outcome, said here:
|
|
558
|
-
// early, in the deck's own voice, quoting the flag and the value back.
|
|
559
|
-
//
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
// Its own glyphs, not `G` — that is declared a hundred lines below and this
|
|
568
|
-
// runs at module top level, so reaching for it here would be a temporal dead
|
|
569
|
-
// zone and a ReferenceError on the one path that reports a bad port. Both
|
|
570
|
-
// helpers read the environment and nothing this file has parsed yet, so
|
|
571
|
-
// asking twice on a path that exits immediately costs nothing (#797).
|
|
572
|
-
const { dash } = glyphs(unicodeOK());
|
|
573
|
-
console.error(`${PRODUCT}: ${named} ${rawPort}: not a port number ${dash} expected 0-65535.`);
|
|
103
|
+
// early, in the deck's own voice, quoting the flag and the value back. Which
|
|
104
|
+
// value counts, and which name it goes by, is startPort's.
|
|
105
|
+
const asked = startPort({ flag: flags.port, env: process.env.AGENT_DAG_PORT });
|
|
106
|
+
if (asked.refused) {
|
|
107
|
+
const { named, raw } = asked.refused;
|
|
108
|
+
// `G.dash`, not an em dash: the console may be the legacy Windows one (#797).
|
|
109
|
+
// `G` is bin/cli/screen.js's, answered when that module loads, so it is there
|
|
110
|
+
// on this path too, long before the boot draws anything with it.
|
|
111
|
+
console.error(`${PRODUCT}: ${named} ${raw}: not a port number ${G.dash} expected 0-65535.`);
|
|
574
112
|
process.exit(1);
|
|
575
113
|
}
|
|
576
|
-
const port =
|
|
114
|
+
const port = asked.port;
|
|
577
115
|
// Default = machine-wide (capture every CC session on this box). Pass
|
|
578
116
|
// `--workspace <path>` (or `--scope`) to restrict to a single tree. Canonicalized
|
|
579
117
|
// just below, once the module that owns that rule is loaded.
|
|
@@ -634,7 +172,7 @@ const { deckDataDir, deckLogDir, legacyDeckDir, migrateDeckFiles, sweepTempFiles
|
|
|
634
172
|
// `resolve` alone was not enough and #793 is what that cost — see
|
|
635
173
|
// canonicalLogPath, which owns the rule and explains it beside the election it
|
|
636
174
|
// serves.
|
|
637
|
-
const { canonicalLogPath } = await import(pathToFileURL(join(PKG_ROOT, "src/server/log-
|
|
175
|
+
const { canonicalLogPath } = await import(pathToFileURL(join(PKG_ROOT, "src/server/log-election.mjs")).href);
|
|
638
176
|
const persist = flags.noPersist
|
|
639
177
|
? null
|
|
640
178
|
// The log follows the deck rather than Claude Code now — a hundred megabytes
|
|
@@ -646,10 +184,11 @@ const persist = flags.noPersist
|
|
|
646
184
|
|
|
647
185
|
const { installHooks, keepDiscovery, removeDiscovery, hasCodexInstalled, leftoverCodexHooks, codexHomeField } =
|
|
648
186
|
await import(pathToFileURL(join(PKG_ROOT, "src/server/installer.mjs")).href);
|
|
649
|
-
// CODEX_SESSIONS_DIR comes along because the
|
|
187
|
+
// CODEX_SESSIONS_DIR comes along because the startup report names the directory
|
|
650
188
|
// the watcher tails, and the watcher lives in that module. Recomputing the path
|
|
651
189
|
// here is how the banner came to print ~/.codex/sessions on machines whose
|
|
652
|
-
// sessions are somewhere else entirely — see the row
|
|
190
|
+
// sessions are somewhere else entirely — see the Codex sessions row in
|
|
191
|
+
// bin/cli/startup.js.
|
|
653
192
|
const { startServer, hookToken, releaseRestart, markDeckReady, CODEX_SESSIONS_DIR, canonicalWorkspace } =
|
|
654
193
|
await import(pathToFileURL(join(PKG_ROOT, "src/server/index.mjs")).href);
|
|
655
194
|
|
|
@@ -666,9 +205,7 @@ const workspace = canonicalWorkspace(rawWorkspace);
|
|
|
666
205
|
// and no directory is created either way — Codex hooks are not used any more,
|
|
667
206
|
// so `--codex` only means "watch even though ~/.codex/ is not there yet",
|
|
668
207
|
// which is the right answer for a machine where Codex arrives later.
|
|
669
|
-
const wantCodex = flags.noCodex
|
|
670
|
-
? false
|
|
671
|
-
: (flags.codex === true || hasCodexInstalled());
|
|
208
|
+
const wantCodex = wantsCli({ off: flags.noCodex, on: flags.codex, installed: hasCodexInstalled });
|
|
672
209
|
|
|
673
210
|
// The same question for the other CLI, and the one nobody was asking. README
|
|
674
211
|
// offers "Claude Code CLI or OpenAI Codex CLI (or both)"; a Codex-only machine
|
|
@@ -685,9 +222,7 @@ const wantCodex = flags.noCodex
|
|
|
685
222
|
// never had — the mirror of --no-codex — and it is also what a Codex-only user
|
|
686
223
|
// with a settings.json the installer refuses to rewrite needs, since that
|
|
687
224
|
// refusal is fatal at boot on a component they do not use.
|
|
688
|
-
const wantClaude = flags.noClaude
|
|
689
|
-
? false
|
|
690
|
-
: (flags.claude === true || hasClaudeInstalled());
|
|
225
|
+
const wantClaude = wantsCli({ off: flags.noClaude, on: flags.claude, installed: hasClaudeInstalled });
|
|
691
226
|
// The Codex tree this deck would tail, spelled the way its discovery record will
|
|
692
227
|
// spell it — the one field of a start's shape that is not a flag. Resolved here,
|
|
693
228
|
// with the other three, because the second-start question below is asked of
|
|
@@ -700,648 +235,16 @@ if (!existsSync(WEB_DIST)) {
|
|
|
700
235
|
process.exit(1);
|
|
701
236
|
}
|
|
702
237
|
|
|
703
|
-
// ── the terminal we are printing into ─────────────────────────────────────────
|
|
704
|
-
// Asked once, degraded from there — see src/server/term.mjs, which is where all
|
|
705
|
-
// of this is decided and asserted. Below this point the deck writes no escape of
|
|
706
|
-
// its own: colour comes from `P`, glyphs from `G`, layout from statusLine. That
|
|
707
|
-
// is what makes NO_COLOR, a pipe, a CI log and a legacy Windows console one
|
|
708
|
-
// question rather than thirty separate ones nobody remembers to ask.
|
|
709
|
-
const tty = Boolean(process.stdout.isTTY);
|
|
710
|
-
const PROFILE = colorProfile({ isTTY: tty });
|
|
711
|
-
const P = palette(PROFILE);
|
|
712
|
-
const UNICODE = unicodeOK();
|
|
713
|
-
const G = glyphs(UNICODE);
|
|
714
|
-
const LINKS = supportsHyperlinks({ profile: PROFILE });
|
|
715
|
-
// The terminal's prefers-reduced-motion: nothing sleeps, spins or repaints in a
|
|
716
|
-
// pipe, under CI, or with NO_COLOR set.
|
|
717
|
-
const MOTION = motionOK({ isTTY: tty, profile: PROFILE });
|
|
718
|
-
const write = (s) => process.stdout.write(s);
|
|
719
|
-
// Read per line, never cached: a terminal can be resized while the deck runs,
|
|
720
|
-
// and the pulse below is still on screen hours later.
|
|
721
|
-
const cols = () => termColumns(process.stdout);
|
|
722
|
-
const sleep = ms => new Promise(r => setTimeout(r, ms));
|
|
723
|
-
const fileLink = (path) => link(path, pathToFileURL(path).href, LINKS);
|
|
724
|
-
|
|
725
|
-
/**
|
|
726
|
-
* What is still happening after the report ended, in three words or so.
|
|
727
|
-
*
|
|
728
|
-
* #742 left one job able to outlive the boot — an install of claude-swap that
|
|
729
|
-
* the report stopped waiting for — and the terminal said nothing about it once
|
|
730
|
-
* the rows were done. That is the wrong way round: the row that had finished
|
|
731
|
-
* was the one blinking, and the thing that really was working sat still. Now
|
|
732
|
-
* the pulse line carries the label while the job runs and drops it when the job
|
|
733
|
-
* settles, which is also what decides whether the line moves at all. Declared
|
|
734
|
-
* here rather than beside `registered` below, because reportStartup sets it and
|
|
735
|
-
* runs long before that line does.
|
|
736
|
-
*/
|
|
737
|
-
let pulseBusy = null;
|
|
738
|
-
|
|
739
238
|
// ── the cursor ────────────────────────────────────────────────────────────────
|
|
740
|
-
// Hidden for as long as anything of ours is moving
|
|
741
|
-
//
|
|
742
|
-
//
|
|
743
|
-
|
|
744
|
-
// hidden leaves the user's shell with no cursor and nothing to do about it but
|
|
745
|
-
// `reset`.
|
|
746
|
-
let cursorHidden = false;
|
|
747
|
-
const showCursor = () => {
|
|
748
|
-
if (!cursorHidden) return;
|
|
749
|
-
cursorHidden = false;
|
|
750
|
-
try { write(CURSOR_SHOW); } catch { /* stdout is gone; nothing left to restore */ }
|
|
751
|
-
};
|
|
752
|
-
if (MOTION) { cursorHidden = true; write(CURSOR_HIDE); }
|
|
753
|
-
process.on("exit", showCursor);
|
|
754
|
-
// SIGHUP is the one signal this process does not otherwise handle, so its
|
|
755
|
-
// default action would end us before 'exit' could run. Handled only to put the
|
|
756
|
-
// cursor back and then die of it exactly as before — the supervisor reads the
|
|
757
|
-
// signal, not an exit code.
|
|
758
|
-
process.on("SIGHUP", () => { showCursor(); dieOfSignal("SIGHUP"); });
|
|
759
|
-
|
|
760
|
-
// ── rows ──────────────────────────────────────────────────────────────────────
|
|
761
|
-
// The status column is computed from the longest label. It used to be counted
|
|
762
|
-
// into each string as trailing spaces, so any new row, or any label a character
|
|
763
|
-
// longer, silently broke the alignment of every other one.
|
|
764
|
-
const LABELS = [
|
|
765
|
-
"workspace", "Claude hooks", "Codex sessions", "Codex hooks", "claude-swap", "accounts",
|
|
766
|
-
"ccusage", "update", "name", "server ready", "log", "unknown option",
|
|
767
|
-
"missing value",
|
|
768
|
-
];
|
|
769
|
-
const LABEL_W = labelColumn(LABELS);
|
|
770
|
-
|
|
771
|
-
function row({ mark = " ", tone = P.ok, label = "", detail = "", detailTone = P.muted, keep = false }) {
|
|
772
|
-
return statusLine({
|
|
773
|
-
mark, label, detail, keep, labelWidth: LABEL_W, columns: cols(), ellipsis: G.ellipsis,
|
|
774
|
-
paint: {
|
|
775
|
-
mark: (s) => `${tone}${s}${P.reset}`,
|
|
776
|
-
detail: (s) => `${detailTone}${s}${P.reset}`,
|
|
777
|
-
},
|
|
778
|
-
}) + "\n";
|
|
779
|
-
}
|
|
780
|
-
|
|
781
|
-
// ── the wordmark ──────────────────────────────────────────────────────────────
|
|
782
|
-
|
|
783
|
-
/**
|
|
784
|
-
* Hold anything else that wants to speak until the art is finished.
|
|
785
|
-
*
|
|
786
|
-
* #742, found while watching a first run through a pty: the startup jobs run
|
|
787
|
-
* UNDER the reveal on purpose, and one of them failing writes to console.error
|
|
788
|
-
* the moment it fails — which put
|
|
789
|
-
*
|
|
790
|
-
* ccdeck ccusage: install failed: npm install ccusage failed: spawn npm ENOENT
|
|
791
|
-
*
|
|
792
|
-
* between the second and third rows of the logo. A wordmark with a stack of
|
|
793
|
-
* someone else's bad news through the middle of it is the first thing a new
|
|
794
|
-
* user sees, and it reads as a crash rather than as a note.
|
|
795
|
-
*
|
|
796
|
-
* The window is the reveal and nothing else — about 180ms — so at worst a
|
|
797
|
-
* message arrives a fifth of a second later than it would have, on the one
|
|
798
|
-
* stretch of the boot where there is nowhere for it to go. Restored in a
|
|
799
|
-
* `finally`, so a throw inside the reveal cannot leave the process mute.
|
|
800
|
-
*/
|
|
801
|
-
function holdConsole() {
|
|
802
|
-
const held = [];
|
|
803
|
-
const real = { warn: console.warn, error: console.error, log: console.log };
|
|
804
|
-
for (const k of Object.keys(real)) console[k] = (...args) => { held.push([k, args]); };
|
|
805
|
-
return () => {
|
|
806
|
-
Object.assign(console, real);
|
|
807
|
-
for (const [k, args] of held) real[k](...args);
|
|
808
|
-
};
|
|
809
|
-
}
|
|
810
|
-
|
|
811
|
-
async function printBanner() {
|
|
812
|
-
const { lines } = wordmark({ columns: cols(), version: PKG_VERSION, profile: PROFILE, unicode: UNICODE, pal: P });
|
|
813
|
-
const release = holdConsole();
|
|
814
|
-
try {
|
|
815
|
-
for (const line of lines) {
|
|
816
|
-
write(line + "\n");
|
|
817
|
-
// A reveal, not a wait. The once-per-session work is already running under
|
|
818
|
-
// it (see startupWork), so the art costs the boot nothing and the deck is
|
|
819
|
-
// ready about when the last row lands. What used to be here — 560ms of
|
|
820
|
-
// spinner at "loading…" before a single art line — was dead time in a tool
|
|
821
|
-
// whose documented entry point is `npx ccdeck`.
|
|
822
|
-
if (MOTION && line) await sleep(45);
|
|
823
|
-
}
|
|
824
|
-
} finally {
|
|
825
|
-
release();
|
|
826
|
-
}
|
|
827
|
-
}
|
|
828
|
-
|
|
829
|
-
// ── a step, with a spinner only if it is slow enough to need one ──────────────
|
|
830
|
-
// The interval's first frame is 80ms away, so anything already settled when we
|
|
831
|
-
// get here paints nothing at all and the row below is the only trace of it.
|
|
832
|
-
|
|
833
|
-
async function step(label, work) {
|
|
834
|
-
if (!MOTION) return work;
|
|
835
|
-
const frames = spinnerFrames(UNICODE);
|
|
836
|
-
// Kept inside the terminal: a label that wraps is a label the \r below can
|
|
837
|
-
// only half erase, and what is left of it stays under the row that follows.
|
|
838
|
-
// Six columns for the indent and the spinner, four more so the elapsed
|
|
839
|
-
// seconds have somewhere to go without pushing the label off the edge.
|
|
840
|
-
const text = fit(label, cols() - 10, G.ellipsis);
|
|
841
|
-
const started = Date.now();
|
|
842
|
-
let i = 0;
|
|
843
|
-
let widest = 0;
|
|
844
|
-
const iv = setInterval(() => {
|
|
845
|
-
const line = ` ${P.accent}${frames[i++ % frames.length]}${P.reset} ${P.muted}${text}${elapsedSuffix(Date.now() - started)}${P.reset}`;
|
|
846
|
-
widest = Math.max(widest, visibleWidth(line));
|
|
847
|
-
write(`\r${line}`);
|
|
848
|
-
}, 80);
|
|
849
|
-
try {
|
|
850
|
-
return await work;
|
|
851
|
-
} finally {
|
|
852
|
-
clearInterval(iv);
|
|
853
|
-
// Cleared rather than overwritten: the row that follows is a different
|
|
854
|
-
// length, and relying on it to be the longer of the two is how a spinner
|
|
855
|
-
// leaves its own tail on screen. Measured rather than computed, because the
|
|
856
|
-
// line grows when the elapsed seconds appear and again when they reach two
|
|
857
|
-
// digits. Nothing to clear if it never painted.
|
|
858
|
-
if (i) write("\r" + " ".repeat(widest) + "\r");
|
|
859
|
-
}
|
|
860
|
-
}
|
|
861
|
-
|
|
862
|
-
/**
|
|
863
|
-
* The once-per-session work, all of it started at once and none of it awaited.
|
|
864
|
-
*
|
|
865
|
-
* Hook install, the claude-swap probe and the registry lookup have nothing to
|
|
866
|
-
* do with each other and nothing to do with the wordmark, so they run underneath
|
|
867
|
-
* the reveal instead of queueing behind it — the animation then costs the boot
|
|
868
|
-
* nothing and the deck is ready about when the last art row lands. Every one of
|
|
869
|
-
* them is given its rejection handler here, at the moment it is created, since a
|
|
870
|
-
* promise that settles before anything awaits it is otherwise an unhandled
|
|
871
|
-
* rejection.
|
|
872
|
-
*/
|
|
873
|
-
function startupWork() {
|
|
874
|
-
// Every job below this line serves Claude Code and only Claude Code: the
|
|
875
|
-
// hooks go in Claude Code's settings.json, claude-swap switches Claude
|
|
876
|
-
// accounts, and ccusage reads Claude Code's own session logs. On a machine
|
|
877
|
-
// without Claude Code all three are work done for a CLI that is not there —
|
|
878
|
-
// two of them installs the user did not ask for — so they are not started at
|
|
879
|
-
// all rather than started and then reported as failures. `null` is how each
|
|
880
|
-
// one says "not attempted", which reportStartup tells apart from "tried and
|
|
881
|
-
// could not".
|
|
239
|
+
// Hidden from here for as long as anything of ours is moving, and put back on
|
|
240
|
+
// every way out — see takeCursor in bin/cli/screen.js, which draws the rest of
|
|
241
|
+
// the boot's terminal too.
|
|
242
|
+
takeCursor();
|
|
882
243
|
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
? installHooks({ provider: "claude" }).then(v => ({ ok: true, v }), err => ({ ok: false, err }))
|
|
888
|
-
: Promise.resolve(null);
|
|
889
|
-
|
|
890
|
-
// claude-swap backs the multi-account panel, and an empty store leaves that
|
|
891
|
-
// panel useless even when the tool is there — so the account already signed
|
|
892
|
-
// in is registered once. Bounded inside seedFirstAccount: empty store only,
|
|
893
|
-
// once ever, never with NO_INSTALL set.
|
|
894
|
-
//
|
|
895
|
-
// `cswapInstalling` is the other half, and it is what keeps a first run from
|
|
896
|
-
// spending the boot's whole deadline on a question that has already been
|
|
897
|
-
// answered: ensureCswap resolves it the moment it commits to an install, so
|
|
898
|
-
// the report can stop waiting then rather than eight seconds later. It never
|
|
899
|
-
// settles on the machines where there is nothing to install, which is every
|
|
900
|
-
// machine after the first run.
|
|
901
|
-
let sayInstalling;
|
|
902
|
-
const cswapInstalling = new Promise(r => { sayInstalling = r; });
|
|
903
|
-
|
|
904
|
-
const cswap = (async () => {
|
|
905
|
-
if (!wantClaude) return null;
|
|
906
|
-
const { ensureCswap } = await import(pathToFileURL(join(PKG_ROOT, "src/server/cswap-install.mjs")).href);
|
|
907
|
-
const cs = await ensureCswap({ onInstalling: () => sayInstalling() });
|
|
908
|
-
const usable = cs.state === "present" || cs.state === "installed" || cs.state === "upgrading";
|
|
909
|
-
if (!usable) return { cs, seed: null };
|
|
910
|
-
const { seedFirstAccount } = await import(pathToFileURL(join(PKG_ROOT, "src/server/claude-accounts.mjs")).href);
|
|
911
|
-
return { cs, seed: await seedFirstAccount().catch(() => ({ state: "failed" })) };
|
|
912
|
-
})().catch(() => null);
|
|
913
|
-
|
|
914
|
-
// WHEN THE TOOL IS QUIET, which is not the same as when the job above settles
|
|
915
|
-
// (#1043). The auto-switch drives this same claude-swap — every tick is
|
|
916
|
-
// `cswap auto --once`, which moves the user's live Claude credentials — and
|
|
917
|
-
// the server arms it the moment the port binds, while this job is still in
|
|
918
|
-
// ensureCswap. So the server holds every tick until this resolves, and it has
|
|
919
|
-
// two halves because the job covers only the first: ensureCswap answers
|
|
920
|
-
// "upgrading" as soon as it has FIRED an upgrade it deliberately does not
|
|
921
|
-
// await, and that upgrade is a uv or pipx environment being rewritten for
|
|
922
|
-
// tens of seconds after this job has returned. upgradeSettled() is the handle
|
|
923
|
-
// on it, and null when nothing was started.
|
|
924
|
-
//
|
|
925
|
-
// Not markDeckReady, the other place "the boot is over" gets said:
|
|
926
|
-
// reportStartup stops waiting for claude-swap the moment the install
|
|
927
|
-
// announces itself, so the deck is marked ready with the install still
|
|
928
|
-
// running. Never rejects — a job that failed has nothing left running.
|
|
929
|
-
const cswapQuiet = cswap.then(async () => {
|
|
930
|
-
if (!wantClaude) return;
|
|
931
|
-
const { upgradeSettled } = await import(pathToFileURL(join(PKG_ROOT, "src/server/cswap-install.mjs")).href);
|
|
932
|
-
await upgradeSettled();
|
|
933
|
-
}).catch(() => {});
|
|
934
|
-
|
|
935
|
-
// ccusage backs the usage-history modal. Primed at boot rather than on first
|
|
936
|
-
// open so a cold machine pays the install while the deck is still starting.
|
|
937
|
-
// Nothing is lost by skipping the prime: runCcusage falls back to npx, so the
|
|
938
|
-
// modal still answers if it is ever opened — it just pays the wait itself.
|
|
939
|
-
const ccusage = (async () => {
|
|
940
|
-
if (!wantClaude) return null;
|
|
941
|
-
if (process.env.AGENTS_DECK_NO_INSTALL === "1") return null;
|
|
942
|
-
const { primeCcusage } = await import(pathToFileURL(join(PKG_ROOT, "src/server/ccusage.mjs")).href);
|
|
943
|
-
return primeCcusage();
|
|
944
|
-
})().catch(() => null);
|
|
945
|
-
|
|
946
|
-
// A newer release on npm, said once, in the place the upgrade gets typed.
|
|
947
|
-
// Hard-capped so a slow registry cannot delay the server — the answer is
|
|
948
|
-
// usually already cached in ~/.agents-deck/.self-update-check anyway. It has
|
|
949
|
-
// to resolve BEFORE the pulse indicator starts writing over the last line.
|
|
950
|
-
const update = Promise.race([
|
|
951
|
-
import(pathToFileURL(join(PKG_ROOT, "src/server/self-update.mjs")).href)
|
|
952
|
-
.then(m => m.versionReport({ running: PKG_VERSION, pkgRoot: PKG_ROOT }))
|
|
953
|
-
.then(r => (r?.notice?.kind === "upgrade" ? r : null))
|
|
954
|
-
.catch(() => null),
|
|
955
|
-
new Promise(r => setTimeout(() => r(null), 1200)),
|
|
956
|
-
]);
|
|
957
|
-
|
|
958
|
-
// An older deck's Codex forwarders, looked for and never touched — see
|
|
959
|
-
// leftoverCodexHooks for why a boot names them rather than removing them.
|
|
960
|
-
// Asked with --no-codex too: the entries fire whether or not this deck is
|
|
961
|
-
// watching the rollouts, and one file read is the whole cost.
|
|
962
|
-
const codexHooks = leftoverCodexHooks().catch(() => null);
|
|
963
|
-
|
|
964
|
-
return { hooks, cswap, cswapInstalling, cswapQuiet, ccusage, update, codexHooks };
|
|
965
|
-
}
|
|
966
|
-
|
|
967
|
-
/** The same work, said out loud, in a fixed order — a boot whose rows arrive in
|
|
968
|
-
* whatever order the network settled is a boot nobody can scan twice. */
|
|
969
|
-
async function reportStartup(jobs) {
|
|
970
|
-
// What the whole report may spend waiting, shared by every job under it
|
|
971
|
-
// rather than granted to each — four jobs at eight seconds each is a
|
|
972
|
-
// thirty-two second boot that no individual deadline would object to.
|
|
973
|
-
const left = budget(bootDeadlineMs());
|
|
974
|
-
|
|
975
|
-
write(row({
|
|
976
|
-
mark: G.ok, label: "workspace",
|
|
977
|
-
detail: workspace === "" ? "(all)" : workspace,
|
|
978
|
-
detailTone: workspace === "" ? P.warn : P.muted,
|
|
979
|
-
}));
|
|
980
|
-
|
|
981
|
-
const hooks = await step(`installing Claude hooks${G.ellipsis}`, jobs.hooks);
|
|
982
|
-
if (hooks === null) {
|
|
983
|
-
// Said in the same shape as the Codex row below, because it is the same
|
|
984
|
-
// sentence: this deck is not watching that CLI, and here is why. It also
|
|
985
|
-
// retires the one boot failure a Codex-only machine could hit — an
|
|
986
|
-
// unparseable or unwritable settings.json used to exit(1) below, killing a
|
|
987
|
-
// deck over a file belonging to a CLI the user does not run.
|
|
988
|
-
write(row({ label: "Claude hooks", detail: `skipped ${G.dash} no Claude Code found, or --no-claude` }));
|
|
989
|
-
} else if (!hooks.ok) {
|
|
990
|
-
// The file it names is one only the user can repair, and every Claude Code
|
|
991
|
-
// session on this machine is reading it too.
|
|
992
|
-
write(row({ mark: G.fail, tone: P.err, label: "Claude hooks", detail: "not installed" }));
|
|
993
|
-
// THE WAY OUT, printed where the wall is. The installer's message is good —
|
|
994
|
-
// the path, the reason, "fix the file or move it aside" — and it is a file
|
|
995
|
-
// the user may not be able to edit: root-owned, on read-only media, or
|
|
996
|
-
// simply not theirs. This deck knows the remedy and used to keep it in a
|
|
997
|
-
// comment: --no-claude runs everything else, which on a Codex-only machine
|
|
998
|
-
// is the whole deck, over a settings.json belonging to a CLI they do not
|
|
999
|
-
// use.
|
|
1000
|
-
console.error(`\n ${PRODUCT}: ${hooks.err.message}`);
|
|
1001
|
-
console.error(` Or start with --no-claude to run without Claude hooks.\n`);
|
|
1002
|
-
process.exit(1);
|
|
1003
|
-
} else {
|
|
1004
|
-
write(row({ mark: G.ok, label: "Claude hooks", detail: fileLink(hooks.v.hookPath) }));
|
|
1005
|
-
}
|
|
1006
|
-
|
|
1007
|
-
// Codex CLI hooks never fire on Windows (sandbox refuses to spawn the hook
|
|
1008
|
-
// command). Instead the server tails Codex's rollout JSONL files directly, so
|
|
1009
|
-
// there's nothing to install and no /hooks trust step. We just confirm Codex
|
|
1010
|
-
// is present and let the watcher pick up sessions.
|
|
1011
|
-
if (wantCodex) {
|
|
1012
|
-
// The directory the watcher actually tails, imported from the module that
|
|
1013
|
-
// owns it rather than rebuilt from homedir() here. CODEX_HOME relocates the
|
|
1014
|
-
// whole tree, and this row is the only diagnostic the deck prints about
|
|
1015
|
-
// Codex — so when sessions do not show up, a path computed a second way
|
|
1016
|
-
// sends the user to inspect a directory the deck never opened.
|
|
1017
|
-
write(row({ mark: G.ok, label: "Codex sessions", detail: `watching ${fileLink(CODEX_SESSIONS_DIR)}` }));
|
|
1018
|
-
} else {
|
|
1019
|
-
write(row({ label: "Codex sessions", detail: `skipped ${G.dash} no ~/.codex/, or --no-codex` }));
|
|
1020
|
-
}
|
|
1021
|
-
|
|
1022
|
-
// #983. A machine that ran a deck from before #253 still has that deck's
|
|
1023
|
-
// forwarders in Codex's hooks.json, and nothing but `--uninstall` ever looked.
|
|
1024
|
-
// Where Codex honours the file, each one posts the session to /api/event while
|
|
1025
|
-
// the watcher above reads the same session off disk, so it arrives twice. Both
|
|
1026
|
-
// copies go into the ring and events.jsonl; the reducer's two-second
|
|
1027
|
-
// redelivery windows fold some of them on the card and not others, and a
|
|
1028
|
-
// duplicate that shows only sometimes reads as a reducer bug, which is where
|
|
1029
|
-
// it would otherwise be chased. Saying nothing was the one answer the issue
|
|
1030
|
-
// ruled out. So: one row, in the place the deck already talks about Codex,
|
|
1031
|
-
// naming the file and the command. `keep`, because a remedy cut off by an
|
|
1032
|
-
// ellipsis is no remedy. The clause about arriving twice is said only while
|
|
1033
|
-
// the watcher runs, since without it the forwarders are the only copy.
|
|
1034
|
-
const leftover = await jobs.codexHooks;
|
|
1035
|
-
if (leftover) {
|
|
1036
|
-
const twice = wantCodex ? ", so Codex sessions can arrive twice" : "";
|
|
1037
|
-
write(row({
|
|
1038
|
-
mark: G.warn, tone: P.warn, label: "Codex hooks", keep: true,
|
|
1039
|
-
detail: `left by an older deck in ${fileLink(leftover.settingsPath)}${twice} ${G.dash} \`${INVOKED_AS ?? PRODUCT} --uninstall\` takes them out`,
|
|
1040
|
-
}));
|
|
1041
|
-
}
|
|
1042
|
-
|
|
1043
|
-
// Bounded, because this is the job that made a first boot look hung: on a
|
|
1044
|
-
// machine with neither claude-swap nor a Python toolchain it fetches a uv
|
|
1045
|
-
// binary and then builds an environment with it, and the report used to wait
|
|
1046
|
-
// out both. See src/server/boot-deadline.mjs. Nothing is cancelled — the
|
|
1047
|
-
// install carries on and says how it went when it knows.
|
|
1048
|
-
const swapWait = await step(`checking claude-swap${G.ellipsis}`, Promise.race([
|
|
1049
|
-
left.within(jobs.cswap),
|
|
1050
|
-
// The install announcing itself. Not a timeout — a decision, arriving in
|
|
1051
|
-
// about a second on the boot that would otherwise have paid the full
|
|
1052
|
-
// deadline for news it already had.
|
|
1053
|
-
jobs.cswapInstalling.then(() => ({ done: false, installing: true })),
|
|
1054
|
-
]));
|
|
1055
|
-
if (swapWait.done) writeSwapRows(swapWait.value);
|
|
1056
|
-
else {
|
|
1057
|
-
write(row({
|
|
1058
|
-
label: "claude-swap",
|
|
1059
|
-
// Both halves earn their place, and both have to survive an 80-column
|
|
1060
|
-
// terminal: what the job is doing, and that waiting for it is not the
|
|
1061
|
-
// user's problem. The row that only said the first is the row this
|
|
1062
|
-
// replaces — a spinner at "checking claude-swap…" says that much.
|
|
1063
|
-
detail: swapWait.installing
|
|
1064
|
-
? `installing in the background ${G.dash} the deck is ready`
|
|
1065
|
-
: `still setting up ${G.dash} the deck is ready`,
|
|
1066
|
-
}));
|
|
1067
|
-
// Handed to the pulse line, which is the only thing still on screen once
|
|
1068
|
-
// the rows are done — and taken back the moment the job settles, whichever
|
|
1069
|
-
// way it settled.
|
|
1070
|
-
pulseBusy = swapWait.installing ? "installing claude-swap" : "setting up claude-swap";
|
|
1071
|
-
jobs.cswap.then(
|
|
1072
|
-
late => { pulseBusy = null; writeSwapRows(late, { late: true }); },
|
|
1073
|
-
() => { pulseBusy = null; },
|
|
1074
|
-
);
|
|
1075
|
-
}
|
|
1076
|
-
|
|
1077
|
-
const cu = await left.within(jobs.ccusage);
|
|
1078
|
-
writeCcusageRow(cu.done ? cu.value : null);
|
|
1079
|
-
|
|
1080
|
-
const upgrade = await jobs.update;
|
|
1081
|
-
if (upgrade) {
|
|
1082
|
-
write(row({
|
|
1083
|
-
mark: G.up, tone: P.warn, label: "update",
|
|
1084
|
-
detail: `v${upgrade.notice.to} available ${G.dash} ${upgrade.command}`,
|
|
1085
|
-
}));
|
|
1086
|
-
}
|
|
1087
|
-
|
|
1088
|
-
// Which name this deck was started under, when that is knowable — a notice,
|
|
1089
|
-
// never a refusal. 95% of installs are on the two old names and the update
|
|
1090
|
-
// path runs through this very process, so a build that declined to boot under
|
|
1091
|
-
// one of them would kill the deck on the machine where the deck is what would
|
|
1092
|
-
// have explained why. Nothing at all is printed wherever the typed name
|
|
1093
|
-
// cannot be proven (a Windows global install, a git checkout): telling
|
|
1094
|
-
// somebody who already types `ccdeck` to type `ccdeck` is the one failure
|
|
1095
|
-
// that would make this row worth ignoring. See src/server/invoked-as.mjs.
|
|
1096
|
-
const rename = renameNotice({ invoked: INVOKED_AS, pkgRoot: PKG_ROOT, dash: G.dash });
|
|
1097
|
-
if (rename) {
|
|
1098
|
-
write(row({ mark: G.warn, tone: P.warn, label: "name", detail: rename.said }));
|
|
1099
|
-
// The line that carries the value: for a global install there is nothing to
|
|
1100
|
-
// install and nothing to download, only six different characters to type.
|
|
1101
|
-
write(row({ label: "", detail: rename.fix }));
|
|
1102
|
-
}
|
|
1103
|
-
}
|
|
1104
|
-
|
|
1105
|
-
/**
|
|
1106
|
-
* The claude-swap rows, wherever in the boot they end up being printed.
|
|
1107
|
-
*
|
|
1108
|
-
* `late` is the one difference, and it is not cosmetic: by the time a late row
|
|
1109
|
-
* arrives the pulse indicator owns the last line and repaints it with `\r`, so
|
|
1110
|
-
* a row written without a newline first would be drawn over on the next beat.
|
|
1111
|
-
* Every other thing that speaks after boot — reportUnregistered,
|
|
1112
|
-
* reportReregistered — opens with the same newline for the same reason.
|
|
1113
|
-
*/
|
|
1114
|
-
function writeSwapRows(swap, { late = false } = {}) {
|
|
1115
|
-
const cs = swap?.cs;
|
|
1116
|
-
// Collected rather than written one at a time, because a late report opens
|
|
1117
|
-
// with a newline and there is exactly one of those however many rows follow.
|
|
1118
|
-
let out = "";
|
|
1119
|
-
if (!wantClaude) {
|
|
1120
|
-
// claude-swap is a Python tool that switches Claude Code accounts, and the
|
|
1121
|
-
// deck used to fetch a uv binary to install it on machines with no Claude
|
|
1122
|
-
// Code at all. Saying so is the point of the row: it is the one place a
|
|
1123
|
-
// user can learn that the accounts panel is missing on purpose.
|
|
1124
|
-
out += row({ label: "claude-swap", detail: `skipped ${G.dash} accounts are Claude-only` });
|
|
1125
|
-
} else if (cs?.state === "present") {
|
|
1126
|
-
out += row({ mark: G.ok, label: "claude-swap", detail: `v${cs.version} (accounts panel enabled)` });
|
|
1127
|
-
} else if (cs?.state === "installed") {
|
|
1128
|
-
out += row({ mark: G.ok, label: "claude-swap", detail: `installed v${cs.version} via ${cs.via}` });
|
|
1129
|
-
} else if (cs?.state === "upgrading") {
|
|
1130
|
-
out += row({ mark: G.ok, label: "claude-swap", detail: `v${cs.version}, upgrading to v${cs.latest} in background` });
|
|
1131
|
-
} else if (cs?.state === "skipped") {
|
|
1132
|
-
out += row({ mark: G.ok, label: "claude-swap", detail: "not installed (AGENTS_DECK_NO_INSTALL=1)" });
|
|
1133
|
-
} else {
|
|
1134
|
-
const how = cs?.reason === "no_installer"
|
|
1135
|
-
? `not installed ${G.dash} the accounts panel needs it`
|
|
1136
|
-
: cs?.reason === "not_on_path"
|
|
1137
|
-
? `installed via ${cs.via} but not on PATH ${G.dash} add ${
|
|
1138
|
-
process.platform === "win32" ? "%USERPROFILE%\\.local\\bin" : "~/.local/bin"
|
|
1139
|
-
}`
|
|
1140
|
-
// The deck asked for one version and a different one answered, so it
|
|
1141
|
-
// declines to drive it. Naming both is the whole content of the row:
|
|
1142
|
-
// "install failed" would be a lie about an install that succeeded, and
|
|
1143
|
-
// the number that arrived is the thing a person needs to see.
|
|
1144
|
-
: cs?.reason === "unexpected_version"
|
|
1145
|
-
? `refused v${cs.version} ${G.dash} asked for ${cs.want}, not driving it`
|
|
1146
|
-
: `install failed${cs?.via ? ` via ${cs.via}` : ""}`;
|
|
1147
|
-
out += row({ mark: G.fail, tone: P.warn, label: "claude-swap", detail: how });
|
|
1148
|
-
// A URL is not an answer when someone just wants the panel to work. Print
|
|
1149
|
-
// the command for THIS machine, picked from what is already on it.
|
|
1150
|
-
if (cs?.hint) out += row({ label: "", detail: cs.hint });
|
|
1151
|
-
}
|
|
1152
|
-
|
|
1153
|
-
if (swap?.seed?.state === "added") {
|
|
1154
|
-
out += row({ mark: G.ok, label: "accounts", detail: "registered the signed-in account (cswap add)" });
|
|
1155
|
-
} else if (swap?.seed?.state === "failed" || swap?.seed?.state === "nothing-to-add") {
|
|
1156
|
-
out += row({ label: "accounts", detail: `panel empty ${G.dash} sign in to Claude Code, then run cswap add` });
|
|
1157
|
-
}
|
|
1158
|
-
|
|
1159
|
-
write(late ? "\n" + out : out);
|
|
1160
|
-
}
|
|
1161
|
-
|
|
1162
|
-
/**
|
|
1163
|
-
* The ccusage row.
|
|
1164
|
-
*
|
|
1165
|
-
* `null` covers both of the ways there is nothing to say — the job was not
|
|
1166
|
-
* attempted, and the job had not answered by the time the boot's deadline ran
|
|
1167
|
-
* out. Neither deserves a row: unlike claude-swap there is no install to wait
|
|
1168
|
-
* for here, because primeCcusage starts one and returns without it, so a
|
|
1169
|
-
* ccusage that is slow to answer is slow at resolving a path and will be
|
|
1170
|
-
* resolved again the first time the usage modal is opened.
|
|
1171
|
-
*/
|
|
1172
|
-
function writeCcusageRow(cu) {
|
|
1173
|
-
if (cu?.state === "present") write(row({ mark: G.ok, label: "ccusage", detail: `v${cu.version}` }));
|
|
1174
|
-
else if (cu?.state === "updating") write(row({ mark: G.ok, label: "ccusage", detail: `v${cu.version}, checking for update` }));
|
|
1175
|
-
// A ccusage the user provided, named rather than versioned — reading a
|
|
1176
|
-
// version out of it means running it, and a status row is not worth a spawn.
|
|
1177
|
-
// Naming the file is the more useful half anyway: it is the answer to "which
|
|
1178
|
-
// ccusage is this deck actually going to run", which is a question a machine
|
|
1179
|
-
// with a managed install AND a PATH copy could not answer before #433.
|
|
1180
|
-
else if (cu?.state === "user") write(row({ mark: G.ok, label: "ccusage", detail: `your own copy ${G.dash} ${cu.bin}` }));
|
|
1181
|
-
else if (cu?.state === "installing") write(row({ mark: G.ok, label: "ccusage", detail: "installing in background" }));
|
|
1182
|
-
}
|
|
1183
|
-
|
|
1184
|
-
// Asking the supervisor to bring us back. It is the only party that can, and
|
|
1185
|
-
// only after this process is gone — which is precisely what keeps the
|
|
1186
|
-
// replacement from racing this listener onto a random fallback port.
|
|
1187
|
-
let restarting = false;
|
|
1188
|
-
// Outer bound on the supervisor's answer below. It cannot be reached today —
|
|
1189
|
-
// the fetch has a deadline of its own and every path through it replies — but
|
|
1190
|
-
// `restarting` is a latch, and a latch with no way out is how a deck ends up
|
|
1191
|
-
// silently refusing every restart for the rest of its life.
|
|
1192
|
-
const UPGRADE_ANSWER_MS = 150_000;
|
|
1193
|
-
let upgradeTimer = null;
|
|
1194
|
-
|
|
1195
|
-
// Whether the rest of this file has finished running.
|
|
1196
|
-
//
|
|
1197
|
-
// The server below starts accepting connections from inside startServer, before
|
|
1198
|
-
// that call has returned — so /api/restart is reachable for the whole of the
|
|
1199
|
-
// boot that follows it: the startup report, the port report to the supervisor,
|
|
1200
|
-
// the discovery file and its first fsynced write, and on a cold start the
|
|
1201
|
-
// browser spawn. A restart landing in that window used to reach `shutdown`
|
|
1202
|
-
// before the binding holding it was initialised and die of a ReferenceError,
|
|
1203
|
-
// having already set the latch above, with nothing left to clear it — after
|
|
1204
|
-
// which every restart from every tab was answered "ok" and did nothing, for the
|
|
1205
|
-
// life of the process (#448).
|
|
1206
|
-
//
|
|
1207
|
-
// So an ask that arrives too early is held rather than run: the user asked for
|
|
1208
|
-
// something this deck can genuinely give a moment later, and refusing outright
|
|
1209
|
-
// would put back the same silence in a politer form. BOOT_RESTART_MS is the
|
|
1210
|
-
// outer bound, for the reason UPGRADE_ANSWER_MS above is one. It used to be a
|
|
1211
|
-
// bound that got used — before #742 the report waited out a real `uv tool
|
|
1212
|
-
// install`, so the window it covers was minutes wide. It is now the boot
|
|
1213
|
-
// deadline plus the browser spawn, comfortably inside ten seconds, and this
|
|
1214
|
-
// stays as the thing that makes that a fact rather than a belief. Ten seconds
|
|
1215
|
-
// in, the ask is run; the respawn skips the report entirely and is up in about
|
|
1216
|
-
// a second.
|
|
1217
|
-
let booted = false;
|
|
1218
|
-
let heldRestart = null;
|
|
1219
|
-
let bootTimer = null;
|
|
1220
|
-
const BOOT_RESTART_MS = 10_000;
|
|
1221
|
-
|
|
1222
|
-
const requestRestart = (mode) => {
|
|
1223
|
-
if (restarting) return;
|
|
1224
|
-
restarting = true;
|
|
1225
|
-
if (!booted) {
|
|
1226
|
-
heldRestart = { mode };
|
|
1227
|
-
bootTimer = setTimeout(() => { bootTimer = null; runHeldRestart(); }, BOOT_RESTART_MS);
|
|
1228
|
-
bootTimer.unref?.();
|
|
1229
|
-
// Said out loud for the same reason abandonUpgrade below is: the tab has
|
|
1230
|
-
// already been told its restart was accepted, and a second of nothing
|
|
1231
|
-
// happening on this terminal is otherwise indistinguishable from the bug
|
|
1232
|
-
// this replaces.
|
|
1233
|
-
write(`\n ${P.warn}${G.restart}${P.reset} ${P.muted}restart queued ${G.dash} still starting up${P.reset}\n`);
|
|
1234
|
-
return;
|
|
1235
|
-
}
|
|
1236
|
-
beginRestart(mode);
|
|
1237
|
-
};
|
|
1238
|
-
|
|
1239
|
-
// The restart itself, once there is a booted deck to end. Split out of
|
|
1240
|
-
// requestRestart so the held ask above can re-enter it without tripping the
|
|
1241
|
-
// latch it is already holding.
|
|
1242
|
-
//
|
|
1243
|
-
// Everything here runs inside one try: the whole point of #448 is that a throw
|
|
1244
|
-
// on this path is not merely a failed restart but a permanent one, because the
|
|
1245
|
-
// latch it leaves behind outlives it. There is no line in here worth dying for.
|
|
1246
|
-
function beginRestart(mode) {
|
|
1247
|
-
try {
|
|
1248
|
-
// "npx" means the newer code is not on this disk at all, so it has to be
|
|
1249
|
-
// fetched — and this process keeps serving while that happens. Exiting first
|
|
1250
|
-
// is what made every failed upgrade an outage: the SSE stream dropped, hook
|
|
1251
|
-
// events fired into the gap were lost outright (hook/hook.js is
|
|
1252
|
-
// fire-and-forget with a 1s timeout and no retry), and the canvas came back
|
|
1253
|
-
// with whatever was in flight stuck until the stale sweeper reaped it — all
|
|
1254
|
-
// of it paid before anyone knew whether npm could even resolve the version.
|
|
1255
|
-
// Nothing is torn down here now; the supervisor answers when it knows.
|
|
1256
|
-
if (mode === "npx") {
|
|
1257
|
-
upgradeTimer = setTimeout(() => abandonUpgrade("no answer from the supervisor"), UPGRADE_ANSWER_MS);
|
|
1258
|
-
upgradeTimer.unref?.();
|
|
1259
|
-
// Armed before the ask, not after: a send that throws is a supervisor that
|
|
1260
|
-
// can no longer answer, and the deck has to come back out of the latch on
|
|
1261
|
-
// its own rather than wait out an answer that cannot arrive.
|
|
1262
|
-
try { process.send({ type: "upgrade" }); }
|
|
1263
|
-
catch (err) { abandonUpgrade(err?.message ?? "the supervisor is no longer listening"); }
|
|
1264
|
-
return;
|
|
1265
|
-
}
|
|
1266
|
-
const to = restartTarget();
|
|
1267
|
-
write(`\n ${P.warn}${G.restart}${P.reset} ${P.muted}restarting${to ? ` ${G.arrow} v${to}` : ""}${G.ellipsis}${P.reset}\n`);
|
|
1268
|
-
shutdown(RESTART_CODE);
|
|
1269
|
-
} catch (err) {
|
|
1270
|
-
abandonRestart(err);
|
|
1271
|
-
}
|
|
1272
|
-
}
|
|
1273
|
-
|
|
1274
|
-
// The ask that was waiting for the boot to finish, now that it has. Safe to
|
|
1275
|
-
// call when nothing is waiting, which is every ordinary boot.
|
|
1276
|
-
function runHeldRestart() {
|
|
1277
|
-
if (!heldRestart) return;
|
|
1278
|
-
const { mode } = heldRestart;
|
|
1279
|
-
heldRestart = null;
|
|
1280
|
-
clearTimeout(bootTimer);
|
|
1281
|
-
bootTimer = null;
|
|
1282
|
-
beginRestart(mode);
|
|
1283
|
-
}
|
|
1284
|
-
|
|
1285
|
-
// A restart that could not be started, said out loud and then let go of.
|
|
1286
|
-
//
|
|
1287
|
-
// Both halves of the latch have to come down — this file's and the server's —
|
|
1288
|
-
// because a latch nothing clears is precisely how one failed request turned
|
|
1289
|
-
// into a deck that refused every restart afterwards while answering "ok" to
|
|
1290
|
-
// each one (#448). The reason is folded onto one line by oneLine: the terminal
|
|
1291
|
-
// under this is repainted every 800ms by the pulse, and a stack written into
|
|
1292
|
-
// that is a stack nobody can read (#432).
|
|
1293
|
-
//
|
|
1294
|
-
// A declaration rather than a const, like `shutdown` below and for the same
|
|
1295
|
-
// reason: this is the handler for a binding that was not there yet, and it must
|
|
1296
|
-
// not be capable of becoming the next one.
|
|
1297
|
-
function abandonRestart(err) {
|
|
1298
|
-
clearTimeout(bootTimer);
|
|
1299
|
-
bootTimer = null;
|
|
1300
|
-
heldRestart = null;
|
|
1301
|
-
restarting = false;
|
|
1302
|
-
releaseRestart();
|
|
1303
|
-
write(
|
|
1304
|
-
`\n ${P.err}${G.fail}${P.reset} ${P.muted}restart failed ${G.dash} still on ${P.reset}v${PKG_VERSION}\n` +
|
|
1305
|
-
` ${P.muted}${oneLine(err?.stack ?? err, Math.max(20, cols() - 6), G.ellipsis)}${P.reset}\n`,
|
|
1306
|
-
);
|
|
1307
|
-
}
|
|
1308
|
-
|
|
1309
|
-
// The upgrade did not happen and this deck is still the deck. Said out loud
|
|
1310
|
-
// because the terminal has just printed that a fetch was starting, and left
|
|
1311
|
-
// unsaid it reads as a restart that hung.
|
|
1312
|
-
const abandonUpgrade = (why) => {
|
|
1313
|
-
clearTimeout(upgradeTimer);
|
|
1314
|
-
restarting = false;
|
|
1315
|
-
// The server's own latch, which no longer has an exiting process to clear it.
|
|
1316
|
-
releaseRestart();
|
|
1317
|
-
write(
|
|
1318
|
-
`\n ${P.warn}${G.cancel}${P.reset} ${P.muted}update not applied ${G.dash} still on ${P.reset}v${PKG_VERSION}\n` +
|
|
1319
|
-
(why ? ` ${P.muted}${why}${P.reset}\n` : ""),
|
|
1320
|
-
);
|
|
1321
|
-
};
|
|
1322
|
-
|
|
1323
|
-
// The supervisor's verdict on the fetch it was asked for. Only it can answer:
|
|
1324
|
-
// the fetch is its child, and it is the process that will still be here when
|
|
1325
|
-
// this one exits.
|
|
1326
|
-
process.on("message", (m) => {
|
|
1327
|
-
if (!restarting || !m || typeof m !== "object") return;
|
|
1328
|
-
if (m.type === "upgrade-ready") {
|
|
1329
|
-
clearTimeout(upgradeTimer);
|
|
1330
|
-
// The replacement is on the machine now, so this is the last moment the
|
|
1331
|
-
// port is worth holding: exiting hands it straight over.
|
|
1332
|
-
write(`\n ${P.warn}${G.restart}${P.reset} ${P.muted}updating via npx${G.ellipsis}${P.reset}\n`);
|
|
1333
|
-
shutdown(UPGRADE_CODE);
|
|
1334
|
-
} else if (m.type === "upgrade-refused") {
|
|
1335
|
-
abandonUpgrade(m.error);
|
|
1336
|
-
}
|
|
1337
|
-
});
|
|
1338
|
-
|
|
1339
|
-
// What a restart would land on. Read from disk now rather than remembered from
|
|
1340
|
-
// boot, because the whole point is that the two differ.
|
|
1341
|
-
function restartTarget() {
|
|
1342
|
-
try { return JSON.parse(readFileSync(join(PKG_ROOT, "package.json"), "utf8")).version ?? null; }
|
|
1343
|
-
catch { return null; }
|
|
1344
|
-
}
|
|
244
|
+
// The restart latch, armed before the server can take a request — see
|
|
245
|
+
// bin/cli/restart.js. `shutdown` is a declaration further down, hoisted, so it
|
|
246
|
+
// is a function here already (#448).
|
|
247
|
+
const { requestRestart, markBooted, runHeldRestart } = restartLatch({ shutdown, releaseRestart });
|
|
1345
248
|
|
|
1346
249
|
// The three things `shutdown` has to tear down, named before the boot that
|
|
1347
250
|
// fills them in rather than by it. From the line below onwards this process is
|
|
@@ -1362,11 +265,12 @@ let bootLock = null;
|
|
|
1362
265
|
//
|
|
1363
266
|
// This file is an ES module with top-level `await`, so its statements run in
|
|
1364
267
|
// source order and a handler registered at the bottom does not exist until the
|
|
1365
|
-
// boot has got there. Three
|
|
1366
|
-
//
|
|
1367
|
-
//
|
|
1368
|
-
//
|
|
1369
|
-
//
|
|
268
|
+
// boot has got there. Three ways out sit between the gate below and that point
|
|
269
|
+
// — the yield when another deck came up first, the ATTACH that every `ccdeck`
|
|
270
|
+
// typed beside a running deck takes, both of which leave through the one
|
|
271
|
+
// `process.exit()` after settleSecondStart, and the exit of a boot whose server
|
|
272
|
+
// could not bind — and the explicit release further down is on the one path
|
|
273
|
+
// none of them take. So the handler that was written to cover "every
|
|
1370
274
|
// way out" covered only the ways out it was already past.
|
|
1371
275
|
//
|
|
1372
276
|
// What the leftover file costs is a start that has to wait the lock out: the
|
|
@@ -1396,6 +300,36 @@ process.on("exit", () => { bootLock?.release(); });
|
|
|
1396
300
|
// way out, rather than left for the next boot's stale sweep.
|
|
1397
301
|
dieWithParent(() => shutdown(0));
|
|
1398
302
|
|
|
303
|
+
// ── is one of ours already up? ────────────────────────────────────────────────
|
|
304
|
+
// A second start, settled before anything else in the boot — see
|
|
305
|
+
// settleSecondStart in bin/cli/second-start.js, which carries the argument.
|
|
306
|
+
//
|
|
307
|
+
// ASKED EXACTLY HERE, and the position is the point. Everything the answer
|
|
308
|
+
// depends on is resolved above — the workspace, the canonical log path, which
|
|
309
|
+
// of the two CLIs this deck would serve — and nothing below has run yet: no
|
|
310
|
+
// port bound, no hooks installed, no tool probed, no banner painted, no
|
|
311
|
+
// discovery file written. An attach therefore leaves the machine precisely as
|
|
312
|
+
// it found it, which is what makes it safe to do without asking.
|
|
313
|
+
//
|
|
314
|
+
// UNDER THE BOOT LOCK, held until this deck's own record is on disk — see the
|
|
315
|
+
// release beside discovery.check below, and boot-lock.mjs for why a registry
|
|
316
|
+
// read alone could not close the window two starts at login fell through.
|
|
317
|
+
bootLock = await takeBootLock({ dir: deckRegistryDir() }).catch(() => null);
|
|
318
|
+
{
|
|
319
|
+
const ended = await settleSecondStart({ flags, workspace, persist, wantCodex, wantClaude, codexHome, openBrowser, RESPAWN });
|
|
320
|
+
// A yield or an attach: this start ends here, having said why.
|
|
321
|
+
if (ended !== null) process.exit(ended);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// The one fact the auto-switch has to wait for and the server cannot see for
|
|
325
|
+
// itself: that the claude-swap it drives is not being installed or upgraded
|
|
326
|
+
// underneath it (#1043). startupWork is what knows, and it has not run yet — it
|
|
327
|
+
// runs beside the server rather than before it (#483) — so the server is handed
|
|
328
|
+
// a promise here and startupWork settles it below. A respawn runs no startup
|
|
329
|
+
// work and installs nothing, so there it settles at once. See cswapQuiet.
|
|
330
|
+
let settleCswap;
|
|
331
|
+
const cswapQuiet = new Promise(r => { settleCswap = r; });
|
|
332
|
+
|
|
1399
333
|
// The listen, begun HERE and awaited below the startup report rather than after
|
|
1400
334
|
// it. The report is a narration; the port is the product, and it was queued
|
|
1401
335
|
// behind three tool probes for no reason but the order these two statements
|
|
@@ -1431,126 +365,6 @@ dieWithParent(() => shutdown(0));
|
|
|
1431
365
|
// whole report, and a bind that fails in there with nothing attached to it is an
|
|
1432
366
|
// unhandledRejection — which Node answers by killing the process over a port it
|
|
1433
367
|
// could have named.
|
|
1434
|
-
// ── is one of ours already up? ────────────────────────────────────────────────
|
|
1435
|
-
// A bare `ccdeck` typed beside a deck that is already running used to build a
|
|
1436
|
-
// second everything on a random port, and neither half mentioned the other.
|
|
1437
|
-
// Then it attached only to a deck of exactly its own shape and built a second
|
|
1438
|
-
// one beside anything else — a different flag, an older version, or a login
|
|
1439
|
-
// item whose environment decided `codex` or the log path differently from the
|
|
1440
|
-
// shell's. src/server/running-deck.mjs carries the whole argument, the registry
|
|
1441
|
-
// read, the handshake and the rule; this is only where it is asked and acted on.
|
|
1442
|
-
//
|
|
1443
|
-
// ASKED EXACTLY HERE, and the position is the point. Everything the answer
|
|
1444
|
-
// depends on is resolved above — the workspace, the canonical log path, which
|
|
1445
|
-
// of the two CLIs this deck would serve — and nothing below has run yet: no
|
|
1446
|
-
// port bound, no hooks installed, no tool probed, no banner painted, no
|
|
1447
|
-
// discovery file written. An attach therefore leaves the machine precisely as
|
|
1448
|
-
// it found it, which is what makes it safe to do without asking.
|
|
1449
|
-
//
|
|
1450
|
-
// UNDER THE BOOT LOCK, held until this deck's own record is on disk — see the
|
|
1451
|
-
// release beside discovery.check below, and boot-lock.mjs for why a registry
|
|
1452
|
-
// read alone could not close the window two starts at login fell through.
|
|
1453
|
-
//
|
|
1454
|
-
// A RESPAWN IS ASKED TOO, and answers differently. A restart is THIS deck
|
|
1455
|
-
// coming back, so there is nothing to attach to — but in the gap a crash
|
|
1456
|
-
// leaves, a `ccdeck` typed by hand finds no deck and starts one, and the
|
|
1457
|
-
// respawn that followed used to take a random port beside it. Now it finds
|
|
1458
|
-
// that deck and exits 0, and the supervisor, reading a clean exit, ends too.
|
|
1459
|
-
bootLock = await takeBootLock({ dir: deckRegistryDir() }).catch(() => null);
|
|
1460
|
-
{
|
|
1461
|
-
// `--port` alone, not AGENT_DAG_PORT: the variable is how somebody RUNS a
|
|
1462
|
-
// deck rather than which one they mean — the line `--stop` draws above.
|
|
1463
|
-
const askedPort = flags.port != null && isPortValue(flags.port) ? Number(flags.port) : null;
|
|
1464
|
-
const plan = secondStart({
|
|
1465
|
-
live: await liveDecks().catch(() => []),
|
|
1466
|
-
want: { workspace, persist, codex: wantCodex, claude: wantClaude, codexHome },
|
|
1467
|
-
port: askedPort,
|
|
1468
|
-
ours: PKG_VERSION,
|
|
1469
|
-
fresh: flags.new === true,
|
|
1470
|
-
respawn: RESPAWN,
|
|
1471
|
-
});
|
|
1472
|
-
if (plan.act === "yield") {
|
|
1473
|
-
console.error(`${PRODUCT}: a deck started on ${plan.deck.port} while this one was coming back ${G.dash} leaving it to that one.`);
|
|
1474
|
-
process.exit(0);
|
|
1475
|
-
}
|
|
1476
|
-
// THE NEWEST START WINS. What is stopped here is either the deck this start
|
|
1477
|
-
// replaces or, on an attach, a second deck left over from before this rule —
|
|
1478
|
-
// the duplicate the rule exists to end. Each one is said, with why, because a
|
|
1479
|
-
// deck that vanishes without a word is a mystery of its own.
|
|
1480
|
-
if (plan.stop.length) {
|
|
1481
|
-
const { stopDeck } = await import(pathToFileURL(join(PKG_ROOT, "src/server/stop-deck.mjs")).href);
|
|
1482
|
-
for (const d of plan.stop) {
|
|
1483
|
-
const why = plan.act === "replace" && flags.new === true
|
|
1484
|
-
? "you asked for a fresh one"
|
|
1485
|
-
: olderVersion(d.version, PKG_VERSION)
|
|
1486
|
-
? (d.version ? `it was v${d.version}` : "it was an older version")
|
|
1487
|
-
: plan.act === "attach" ? "it was a second deck" : "it was started with different settings";
|
|
1488
|
-
const out = await stopDeck(d).catch(() => ({ ok: false, reason: "unreachable" }));
|
|
1489
|
-
write(out.ok
|
|
1490
|
-
? `\n ${P.ok}${G.ok}${P.reset} stopped the deck on ${d.port}${P.muted} ${G.bullet} pid ${d.pid} ${G.bullet} ${why}${P.reset}\n`
|
|
1491
|
-
: `\n ${P.warn}${G.warn} could not stop the deck on ${d.port} (pid ${d.pid}) ${G.dash} ${out.reason ?? out.how}${P.reset}\n`);
|
|
1492
|
-
}
|
|
1493
|
-
}
|
|
1494
|
-
if (plan.act === "attach") {
|
|
1495
|
-
const live = plan.deck;
|
|
1496
|
-
const liveUrl = `http://127.0.0.1:${live.port}`;
|
|
1497
|
-
const note = versionNote(live.version, PKG_VERSION);
|
|
1498
|
-
// Not the startup report's rows. That report has a label column because it
|
|
1499
|
-
// has twelve lines to align; this has two, and borrowing the column would
|
|
1500
|
-
// indent a three-line message behind a gutter sized for "Codex sessions".
|
|
1501
|
-
//
|
|
1502
|
-
// The version chunk is dropped rather than printed as "v?" when the running
|
|
1503
|
-
// deck is too old to report one — see versionNote, which says nothing in the
|
|
1504
|
-
// same case for the same reason.
|
|
1505
|
-
const ident = [live.version ? `v${live.version}` : "", `pid ${live.pid}`]
|
|
1506
|
-
.filter(Boolean).join(` ${G.bullet} `);
|
|
1507
|
-
write(`\n ${P.ok}${G.ok}${P.reset} deck already running${P.muted} ${G.bullet} ${ident}${P.reset}\n`);
|
|
1508
|
-
// Its own line, always. The URL is the one detail an ellipsis would destroy
|
|
1509
|
-
// — half an address is not a shorter address — and this message has no
|
|
1510
|
-
// report to hand it to. Same rule as statusLine's `keep`.
|
|
1511
|
-
write(` ${P.accent}${P.bold}${link(liveUrl, liveUrl, LINKS)}${P.reset}\n`);
|
|
1512
|
-
if (note) write(` ${P.warn}${G.warn} ${note}${P.reset}\n`);
|
|
1513
|
-
// THE TYPO'S WARNING, on the path that has no startup report to carry it.
|
|
1514
|
-
//
|
|
1515
|
-
// The gate used to answer `true` for these two so the report would
|
|
1516
|
-
// run and print them, and that is how `ccdeck --stpo` — a misspelling of
|
|
1517
|
-
// the flag that STOPS a deck — came to build a second one. The warning was
|
|
1518
|
-
// the requirement; the extra process never was. Printed here, in the same
|
|
1519
|
-
// rows the report uses, so nothing is lost and nothing is started.
|
|
1520
|
-
reportUnknownFlags(flags.unknown);
|
|
1521
|
-
reportIncompleteFlags(flags.incomplete);
|
|
1522
|
-
// The line that says a second deck was NOT started. Without it the command
|
|
1523
|
-
// looks like it did nothing at all, which is the other way to be confusing
|
|
1524
|
-
// about this — and it names the flag for the person who wanted a fresh one.
|
|
1525
|
-
write(`\n ${P.muted}${G.dash} no second deck was started ${G.dash} \`${INVOKED_AS ?? PRODUCT} --new\` replaces it with a fresh one${P.reset}\n`);
|
|
1526
|
-
if (openBrowser) {
|
|
1527
|
-
write(`\n ${P.ok}${P.bold}${G.play} opening browser${G.ellipsis}${P.reset}\n\n`);
|
|
1528
|
-
try {
|
|
1529
|
-
const { openUrl, LAUNCH_GRACE_MS } = await import(pathToFileURL(join(PKG_ROOT, "src/server/open-url.mjs")).href);
|
|
1530
|
-
openUrl(liveUrl);
|
|
1531
|
-
// Held for exactly as long as openUrl needs to fall through to its next
|
|
1532
|
-
// launcher. Every child it spawns is unref'd, so an immediate exit ends
|
|
1533
|
-
// this process before a missing xdg-open has been answered by gio — and
|
|
1534
|
-
// then no browser opens and nothing says why. The boot path never had
|
|
1535
|
-
// to think about this because it stays alive forever.
|
|
1536
|
-
await sleep(LAUNCH_GRACE_MS);
|
|
1537
|
-
} catch { /* the URL is on screen; it can be clicked or pasted */ }
|
|
1538
|
-
} else {
|
|
1539
|
-
write("\n");
|
|
1540
|
-
}
|
|
1541
|
-
process.exit(0);
|
|
1542
|
-
}
|
|
1543
|
-
}
|
|
1544
|
-
|
|
1545
|
-
// The one fact the auto-switch has to wait for and the server cannot see for
|
|
1546
|
-
// itself: that the claude-swap it drives is not being installed or upgraded
|
|
1547
|
-
// underneath it (#1043). startupWork is what knows, and it has not run yet — it
|
|
1548
|
-
// runs beside the server rather than before it (#483) — so the server is handed
|
|
1549
|
-
// a promise here and startupWork settles it below. A respawn runs no startup
|
|
1550
|
-
// work and installs nothing, so there it settles at once. See cswapQuiet.
|
|
1551
|
-
let settleCswap;
|
|
1552
|
-
const cswapQuiet = new Promise(r => { settleCswap = r; });
|
|
1553
|
-
|
|
1554
368
|
const starting = startServer({
|
|
1555
369
|
port, persist, workspace, codex: wantCodex, claude: wantClaude,
|
|
1556
370
|
// Withheld when nothing is supervising us: without a parent, exiting is just
|
|
@@ -1571,10 +385,10 @@ const starting = startServer({
|
|
|
1571
385
|
// skips the lot and prints one line instead. This is the difference between a
|
|
1572
386
|
// restart that feels instant and one that makes you wonder whether it worked.
|
|
1573
387
|
if (!RESPAWN) {
|
|
1574
|
-
const jobs = startupWork();
|
|
388
|
+
const jobs = startupWork({ wantClaude, installHooks, leftoverCodexHooks });
|
|
1575
389
|
jobs.cswapQuiet.then(settleCswap);
|
|
1576
390
|
await printBanner();
|
|
1577
|
-
await reportStartup(jobs);
|
|
391
|
+
await reportStartup(jobs, { workspace, wantClaude, wantCodex, CODEX_SESSIONS_DIR });
|
|
1578
392
|
} else {
|
|
1579
393
|
settleCswap();
|
|
1580
394
|
}
|
|
@@ -1600,39 +414,8 @@ const url = `http://127.0.0.1:${realPort}`;
|
|
|
1600
414
|
// the deck out from under every open tab.
|
|
1601
415
|
try { process.send?.({ type: "listening", port: realPort }); } catch { /* not supervised */ }
|
|
1602
416
|
|
|
1603
|
-
if (RESPAWN) {
|
|
1604
|
-
|
|
1605
|
-
// A respawn skips the whole startup report, but not this: the argv is the
|
|
1606
|
-
// same argv, the typo in it is still there, and a deck that mentioned it once
|
|
1607
|
-
// and then went quiet for every restart afterwards is back to hiding it from
|
|
1608
|
-
// anyone who was not watching the first boot.
|
|
1609
|
-
reportUnknownFlags(flags.unknown);
|
|
1610
|
-
reportIncompleteFlags(flags.incomplete);
|
|
1611
|
-
} else {
|
|
1612
|
-
// The URL is the one detail an ellipsis would destroy — half an address is
|
|
1613
|
-
// not a shorter address — so it keeps its own line when the terminal is too
|
|
1614
|
-
// narrow to hold it beside the label. See statusLine's `keep`.
|
|
1615
|
-
write(row({
|
|
1616
|
-
mark: G.ok, label: "server ready",
|
|
1617
|
-
detail: link(url, url, LINKS), detailTone: `${P.accent}${P.bold}`, keep: true,
|
|
1618
|
-
}));
|
|
1619
|
-
if (persist) write(row({ label: "log", detail: fileLink(persist) }));
|
|
1620
|
-
// Last of the rows, on purpose — see reportUnknownFlags.
|
|
1621
|
-
reportUnknownFlags(flags.unknown);
|
|
1622
|
-
reportIncompleteFlags(flags.incomplete);
|
|
1623
|
-
// AFTER the warnings and outside the rows, because it is neither. It is the
|
|
1624
|
-
// one thing on this screen that is about next week rather than about this
|
|
1625
|
-
// boot — see way-back.mjs — and putting it above a typo warning would be
|
|
1626
|
-
// spending the reader's last line of attention on the calmer of the two.
|
|
1627
|
-
write(`\n ${P.muted}${G.dash} ${wayBackNote({
|
|
1628
|
-
command: INVOKED_AS ?? PRODUCT, dash: G.dash, columns: cols(),
|
|
1629
|
-
})}${P.reset}\n`);
|
|
1630
|
-
// Only when one is actually being opened. Under --no-open — which is how an
|
|
1631
|
-
// npx update relaunches, with a tab already waiting — this was announcing
|
|
1632
|
-
// something that never happened.
|
|
1633
|
-
if (openBrowser) write(`\n ${P.ok}${P.bold}${G.play} opening browser${G.ellipsis}${P.reset}\n\n`);
|
|
1634
|
-
else write("\n");
|
|
1635
|
-
}
|
|
417
|
+
if (RESPAWN) reportRestarted({ url, flags });
|
|
418
|
+
else reportReady({ url, persist, openBrowser, flags });
|
|
1636
419
|
|
|
1637
420
|
// The discovery file is the whole of how a hook finds this deck: hook.js
|
|
1638
421
|
// enumerates that directory and nothing else. Writing it once at boot meant
|
|
@@ -1648,7 +431,7 @@ if (RESPAWN) {
|
|
|
1648
431
|
// log and elect a single writer for it. See electWriters in hook/hook.js. The
|
|
1649
432
|
// Codex setting goes in for the half of that election no hook is part of: the
|
|
1650
433
|
// rollout files this deck tails itself, which a --no-codex deck must never be
|
|
1651
|
-
// elected to record. See writesCodexLog in src/server/log-
|
|
434
|
+
// elected to record. See writesCodexLog in src/server/log-election.mjs.
|
|
1652
435
|
let registered = null;
|
|
1653
436
|
discovery = keepDiscovery({
|
|
1654
437
|
port: realPort,
|
|
@@ -1699,136 +482,23 @@ if (openBrowser && !RESPAWN) {
|
|
|
1699
482
|
|
|
1700
483
|
// ── starting at login ─────────────────────────────────────────────────────────
|
|
1701
484
|
//
|
|
1702
|
-
// ONCE PER MACHINE, EVER. The record in the deck's own data directory is what
|
|
1703
|
-
// makes that true: without it, `--uninstall-service` would be undone by the next
|
|
1704
|
-
// start, which is not an uninstall — it is a tool arguing with its user.
|
|
1705
|
-
//
|
|
1706
485
|
// After the boot rather than during it, and deliberately: a deck that could not
|
|
1707
486
|
// come up has no business teaching the machine to start it at every login. By
|
|
1708
487
|
// here the port is bound, the hooks are registered and the browser is open.
|
|
1709
|
-
//
|
|
1710
|
-
|
|
1711
|
-
// shouldOfferService, which owns both rules and says why. A failure is one line
|
|
1712
|
-
// and nothing else: the deck is already running, and the worst case is the
|
|
1713
|
-
// behaviour every version before this one had.
|
|
1714
|
-
if (!RESPAWN) {
|
|
1715
|
-
try {
|
|
1716
|
-
const svc = await import(pathToFileURL(join(PKG_ROOT, "src/server/login-service.mjs")).href);
|
|
1717
|
-
const { isGitCheckout, isNpxInstall } = await import(pathToFileURL(join(PKG_ROOT, "src/server/self-update.mjs")).href);
|
|
1718
|
-
if (svc.shouldOfferService({
|
|
1719
|
-
record: svc.readServiceRecord(deckDataDir()),
|
|
1720
|
-
npx: isNpxInstall(PKG_ROOT),
|
|
1721
|
-
checkout: isGitCheckout(PKG_ROOT),
|
|
1722
|
-
})) {
|
|
1723
|
-
const out = svc.installService({
|
|
1724
|
-
script: join(PKG_ROOT, "bin", "agent-dag.js"),
|
|
1725
|
-
logPath: join(deckLogDir(), "deck.log"),
|
|
1726
|
-
product: PRODUCT,
|
|
1727
|
-
});
|
|
1728
|
-
svc.writeServiceRecord(deckDataDir(), out.ok
|
|
1729
|
-
? { installed: PKG_VERSION, at: new Date().toISOString(), path: out.path }
|
|
1730
|
-
: { failed: out.reason ?? "unknown", at: new Date().toISOString(), version: PKG_VERSION });
|
|
1731
|
-
// Said once, on the one run that does it, and never again. A tool that
|
|
1732
|
-
// adds itself to your login items and does not mention it is a tool you
|
|
1733
|
-
// find later, in a settings pane, and stop trusting.
|
|
1734
|
-
write(out.ok
|
|
1735
|
-
? ` ${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`
|
|
1736
|
-
: ` ${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`);
|
|
1737
|
-
}
|
|
1738
|
-
} catch (err) {
|
|
1739
|
-
// Never fatal. The deck is up; this is a convenience that did not happen.
|
|
1740
|
-
console.error(`${PRODUCT}: could not check the login item:`, err?.message ?? err);
|
|
1741
|
-
}
|
|
1742
|
-
}
|
|
488
|
+
// Never on a respawn, which is the same session continuing. See offerLoginItem.
|
|
489
|
+
if (!RESPAWN) await offerLoginItem({ deckDataDir, deckLogDir });
|
|
1743
490
|
|
|
1744
491
|
// ── Pulse indicator ───────────────────────────────────────────────────────────
|
|
1745
|
-
// The
|
|
1746
|
-
//
|
|
1747
|
-
//
|
|
1748
|
-
|
|
1749
|
-
// terminal, because at 40 columns the old fixed 61-character line wrapped, and
|
|
1750
|
-
// from then on \r only ever reached its second row.
|
|
1751
|
-
//
|
|
1752
|
-
// AND IT STOPS MOVING (#742). The dot alternated green and grey every 800ms for
|
|
1753
|
-
// as long as the deck ran, and a blinking indicator beside a status line is the
|
|
1754
|
-
// vocabulary of "working on it" — so a boot that finished in a second read as
|
|
1755
|
-
// one that never finished, which is what people reported. Motion is now spent
|
|
1756
|
-
// on the two states where something really is outstanding, and the frame is
|
|
1757
|
-
// compared against what is already on screen so a deck at rest paints once and
|
|
1758
|
-
// then leaves the terminal alone. See pulseMoves.
|
|
1759
|
-
if (MOTION) {
|
|
1760
|
-
let pi = 0;
|
|
1761
|
-
let painted = null;
|
|
1762
|
-
// The line is on screen and is the last thing written to this terminal.
|
|
1763
|
-
let ours = false;
|
|
1764
|
-
// We are the one writing right now, so the guard below leaves us alone.
|
|
1765
|
-
let writing = false;
|
|
1766
|
-
|
|
1767
|
-
// The pulse line's tenancy, enforced rather than agreed.
|
|
1768
|
-
//
|
|
1769
|
-
// The convention was that anything with something to say writes a newline
|
|
1770
|
-
// first, so the pulse's `\r` never lands on somebody else's text. bin/deck.js
|
|
1771
|
-
// keeps it everywhere. src/server/quota.mjs does not — it calls console.error
|
|
1772
|
-
// directly — and a Windows user with no Claude Code sent a screenshot of the
|
|
1773
|
-
// result: `listening — Ctrl+C to stop ccdeck quota: claude CLI failed`
|
|
1774
|
-
// on one row, three times over, the pulse and the complaint interleaved.
|
|
1775
|
-
//
|
|
1776
|
-
// An invariant every writer has to remember is one a writer will forget, and
|
|
1777
|
-
// the writers here are server modules that know nothing about a terminal. So
|
|
1778
|
-
// it is enforced at the stream instead: while our line is the last thing on
|
|
1779
|
-
// screen, anything else that speaks gets a newline first, and the memo is
|
|
1780
|
-
// dropped so the next beat repaints the line under whatever was said.
|
|
1781
|
-
//
|
|
1782
|
-
// Both streams, because console.error goes to stderr and lands on the same
|
|
1783
|
-
// screen. Only when MOTION is on — with no pulse there is no line to defend,
|
|
1784
|
-
// and a piped deck must not have its output rewritten.
|
|
1785
|
-
for (const stream of [process.stdout, process.stderr]) {
|
|
1786
|
-
const real = stream.write.bind(stream);
|
|
1787
|
-
stream.write = (chunk, ...rest) => {
|
|
1788
|
-
if (!writing && ours) {
|
|
1789
|
-
ours = false;
|
|
1790
|
-
// Dropped, not kept: the line is no longer where we left it, so the
|
|
1791
|
-
// next beat has to draw it again even though the frame is unchanged.
|
|
1792
|
-
painted = null;
|
|
1793
|
-
// Unless the speaker already did it. Every late message in this file
|
|
1794
|
-
// opens with one, and two blank lines is its own kind of mess.
|
|
1795
|
-
if (!String(chunk).startsWith("\n")) real("\n");
|
|
1796
|
-
}
|
|
1797
|
-
return real(chunk, ...rest);
|
|
1798
|
-
};
|
|
1799
|
-
}
|
|
1800
|
-
|
|
1801
|
-
setInterval(() => {
|
|
1802
|
-
// The colour follows the words. A Codex-only deck keeps saying "listening"
|
|
1803
|
-
// when it is unregistered — see pulseText — and painting that sentence in
|
|
1804
|
-
// the warning tone would restore the alarm the sentence just retired.
|
|
1805
|
-
const alarm = !registered && wantClaude;
|
|
1806
|
-
const state = { registered, claude: wantClaude, busy: pulseBusy };
|
|
1807
|
-
const text = pulseText({ ...state, columns: cols(), unicode: UNICODE });
|
|
1808
|
-
// At rest every beat is lit, which is what makes the line still: the frame
|
|
1809
|
-
// is then identical to the one already on screen and the write below is
|
|
1810
|
-
// skipped. See pulseDot.
|
|
1811
|
-
const dot = pulseDot(pi++, state) === "on" ? (alarm ? P.warn : P.ok) : P.muted;
|
|
1812
|
-
const tone = alarm ? P.warn : P.muted;
|
|
1813
|
-
const frame = `\r ${dot}${G.pulse}${P.reset} ${tone}${text}${P.reset}`;
|
|
1814
|
-
// Unchanged frames are not written at all. That is what makes "at rest"
|
|
1815
|
-
// visible: one paint, and then a still line for as long as nothing happens.
|
|
1816
|
-
// `painted` is dropped by the guard above whenever somebody else writes, so
|
|
1817
|
-
// this can only skip a beat while the line is genuinely still where we left
|
|
1818
|
-
// it.
|
|
1819
|
-
if (frame === painted) return;
|
|
1820
|
-
painted = frame;
|
|
1821
|
-
writing = true;
|
|
1822
|
-
try { write(frame); } finally { writing = false; }
|
|
1823
|
-
ours = true;
|
|
1824
|
-
}, 800).unref();
|
|
1825
|
-
}
|
|
492
|
+
// The last line on screen, still only while something is outstanding, and the
|
|
493
|
+
// guard that keeps every other writer off it — see startPulse in
|
|
494
|
+
// bin/cli/pulse.js. It asks for `registered` on each beat.
|
|
495
|
+
startPulse({ registered: () => registered, wantClaude });
|
|
1826
496
|
|
|
1827
497
|
// Boot is over. Everything `shutdown` tears down exists, so a restart can be
|
|
1828
498
|
// run rather than held — and the server is told, so /api/restart stops
|
|
1829
499
|
// describing a deck that is still assembling itself. This line is exactly where
|
|
1830
500
|
// the window opened at the top of this file closes; see requestRestart.
|
|
1831
|
-
|
|
501
|
+
markBooted();
|
|
1832
502
|
markDeckReady();
|
|
1833
503
|
// And said out loud, one link up. A launcher that put this deck in the
|
|
1834
504
|
// background has been tailing its log into the user's terminal since the spawn
|
|
@@ -1942,9 +612,9 @@ process.on("SIGINT", () => shutdown(0));
|
|
|
1942
612
|
process.on("SIGTERM", () => shutdown(0));
|
|
1943
613
|
process.on("beforeExit", () => { discovery?.stop(); if (discoveryFile) removeDiscovery(discoveryFile); });
|
|
1944
614
|
// The boot lock's `exit` handler is NOT here with its siblings. It is armed up
|
|
1945
|
-
// beside `let bootLock = null;`, above the start gate, because the three
|
|
1946
|
-
// this file takes before reaching this line are the ones it exists for —
|
|
1947
|
-
// the note there (#980).
|
|
615
|
+
// beside `let bootLock = null;`, above the start gate, because the three ways
|
|
616
|
+
// out this file takes before reaching this line are the ones it exists for —
|
|
617
|
+
// see the note there (#980).
|
|
1948
618
|
|
|
1949
619
|
// ── helpers ───────────────────────────────────────────────────────────────────
|
|
1950
620
|
|
|
@@ -1953,7 +623,7 @@ process.on("beforeExit", () => { discovery?.stop(); if (discoveryFile) removeDis
|
|
|
1953
623
|
// ordinary-looking deck that simply never shows a session.
|
|
1954
624
|
//
|
|
1955
625
|
// What that costs depends on which CLI this deck watches, so the sentence comes
|
|
1956
|
-
// from
|
|
626
|
+
// from pulse-line.mjs, where both answers are written down and tested.
|
|
1957
627
|
function reportUnregistered({ file, error }) {
|
|
1958
628
|
const why = error?.message ? ` ${G.dash} ${error.message}` : "";
|
|
1959
629
|
write(
|
|
@@ -1965,120 +635,3 @@ function reportUnregistered({ file, error }) {
|
|
|
1965
635
|
function reportReregistered({ file }) {
|
|
1966
636
|
write(`\n ${P.ok}${G.ok}${P.reset} ${P.muted}registered again ${G.arrow} ${fileLink(file)}${P.reset}\n`);
|
|
1967
637
|
}
|
|
1968
|
-
|
|
1969
|
-
/**
|
|
1970
|
-
* Every token the parser did not recognise, named, one row each.
|
|
1971
|
-
*
|
|
1972
|
-
* Said rather than acted on: the deck goes on booting and still exits 0. It is
|
|
1973
|
-
* not a one-shot command that can afford the usual contract. `bin/agent-dag.js`
|
|
1974
|
-
* hands its own argv to every worker it spawns — including the npx relaunch,
|
|
1975
|
-
* which starts a NEWER version of the package on the argv the user typed
|
|
1976
|
-
* against an older one — and the README recommends running it from a wrapper.
|
|
1977
|
-
* Refusing to boot over one token would turn a typo into a dark dashboard, and
|
|
1978
|
-
* an argument the newer build no longer knows into a failed upgrade that costs
|
|
1979
|
-
* the port and the session. The file already holds that position once, in the
|
|
1980
|
-
* `--all` branch: a flag the deck stopped needing is still accepted rather than
|
|
1981
|
-
* made fatal.
|
|
1982
|
-
*
|
|
1983
|
-
* So it goes where the deck puts everything else it decided on your behalf —
|
|
1984
|
-
* the startup report — and it goes at the END of it. reportStartup writes its
|
|
1985
|
-
* rows in a fixed order and three more land underneath them (the server, the
|
|
1986
|
-
* log, the browser), so a warning printed among those rows is a warning the
|
|
1987
|
-
* rows scroll over. Here it is the last line before the pulse indicator takes
|
|
1988
|
-
* the bottom of the screen and stops repainting anything above it.
|
|
1989
|
-
*/
|
|
1990
|
-
function reportUnknownFlags(unknown) {
|
|
1991
|
-
for (const token of unknown) {
|
|
1992
|
-
write(row({
|
|
1993
|
-
mark: G.warn, tone: P.warn, label: "unknown option",
|
|
1994
|
-
detail: `${token} ${G.dash} see \`${PRODUCT} --help\``,
|
|
1995
|
-
}));
|
|
1996
|
-
}
|
|
1997
|
-
}
|
|
1998
|
-
|
|
1999
|
-
/**
|
|
2000
|
-
* Every value-taking flag that was given no value it could use, named, one row
|
|
2001
|
-
* each — and printed beside the unknown ones because it is the same failure
|
|
2002
|
-
* wearing a different hat.
|
|
2003
|
-
*
|
|
2004
|
-
* #697: `--workspace`, `--history` and `--port` used to consume the following
|
|
2005
|
-
* token whatever it was, so `ccdeck --workspace $PROJ --no-persist` with `PROJ`
|
|
2006
|
-
* unset scoped the deck to a directory called `--no-persist`, kept persisting to
|
|
2007
|
-
* the shared log, and reported neither. Nothing landed in `unknown`, because the
|
|
2008
|
-
* token that belonged there had been eaten. The parser refuses that value now
|
|
2009
|
-
* and lists the flag here instead.
|
|
2010
|
-
*
|
|
2011
|
-
* Said rather than acted on, under exactly the argument reportUnknownFlags makes
|
|
2012
|
-
* above: the flag falls back to its documented default and the deck still boots.
|
|
2013
|
-
* The row is what makes the fallback a decision the user can see, and the rows
|
|
2014
|
-
* around it show its consequence — `workspace (all)` and the `log` line are
|
|
2015
|
-
* printed by the same report.
|
|
2016
|
-
*/
|
|
2017
|
-
function reportIncompleteFlags(incomplete) {
|
|
2018
|
-
for (const { flag, expects } of incomplete ?? []) {
|
|
2019
|
-
write(row({
|
|
2020
|
-
mark: G.warn, tone: P.warn, label: "missing value",
|
|
2021
|
-
detail: `${flag} ${G.dash} expected ${expects}; using the default`,
|
|
2022
|
-
}));
|
|
2023
|
-
}
|
|
2024
|
-
}
|
|
2025
|
-
|
|
2026
|
-
function printHelp() {
|
|
2027
|
-
process.stdout.write(`${PRODUCT} — live deck of Claude Code + Codex agents
|
|
2028
|
-
|
|
2029
|
-
Usage:
|
|
2030
|
-
${PRODUCT} [options]
|
|
2031
|
-
|
|
2032
|
-
Options:
|
|
2033
|
-
-p, --port <number> Preferred port (default: 4317; falls back to random 4318–4400)
|
|
2034
|
-
--no-open Don't open the browser automatically
|
|
2035
|
-
--foreground Hold the terminal, the way every version before 3.20
|
|
2036
|
-
did. Ctrl+C stops the deck again
|
|
2037
|
-
--new Replace the running deck with a fresh one.
|
|
2038
|
-
Without it, a bare \`${PRODUCT}\` beside a deck that is
|
|
2039
|
-
already up opens that deck's tab instead of building
|
|
2040
|
-
a rival on another port
|
|
2041
|
-
--stop Stop the running deck.
|
|
2042
|
-
With --port <n>, stop that one; with --all, stop every
|
|
2043
|
-
deck on this machine
|
|
2044
|
-
--status What is running on this machine, and on which ports
|
|
2045
|
-
--logs What the deck wrote where a terminal would have shown
|
|
2046
|
-
it, and where that file is
|
|
2047
|
-
--install Put the deck on your PATH and start it at login.
|
|
2048
|
-
What an \`npx\` run needs to survive a reboot
|
|
2049
|
-
--install-service Start the deck when you log in. Set up on first run;
|
|
2050
|
-
this is only for putting it back
|
|
2051
|
-
--uninstall-service Stop starting at login. \`--uninstall\` does this too
|
|
2052
|
-
--workspace <path> Only capture sessions whose cwd is inside <path>
|
|
2053
|
-
--scope Restrict to current working directory
|
|
2054
|
-
--all Capture every session (default). Accepted and
|
|
2055
|
-
ignored — it is what a bare run already does, and
|
|
2056
|
-
\`--stop --port <n>\` is the only way to narrow a stop
|
|
2057
|
-
--history <path> Override events log file (default: this platform's log directory)
|
|
2058
|
-
--no-persist Don't write or replay events log (RAM-only)
|
|
2059
|
-
--codex Force-enable Codex capture even if ~/.codex/ missing
|
|
2060
|
-
--no-codex Skip Codex capture (Claude only)
|
|
2061
|
-
--claude Force-enable Claude capture even if Claude Code wasn't found
|
|
2062
|
-
--no-claude Skip Claude entirely: no hooks, no claude-swap, no accounts panel
|
|
2063
|
-
--uninstall Remove ${PRODUCT}'s hooks from ~/.claude/settings.json and
|
|
2064
|
-
~/.codex/hooks.json, and restore any sound hooks of yours it parked.
|
|
2065
|
-
Hook entries only: the forwarder script under
|
|
2066
|
-
~/.claude/agent-dag/, the deck's own state and log
|
|
2067
|
-
directories (\`--status\` names them), ~/.agents-deck/
|
|
2068
|
-
and claude-swap all stay. It NAMES the prefs.json
|
|
2069
|
-
files that still hold this deck's LAN private key
|
|
2070
|
-
--purge With --uninstall: delete those prefs.json files too, so the
|
|
2071
|
-
private key every deck you paired with has pinned does not
|
|
2072
|
-
outlive the uninstall. Your ${PRODUCT} settings go with them
|
|
2073
|
-
-h, --help Show this help
|
|
2074
|
-
-v, --version Print the version and exit
|
|
2075
|
-
|
|
2076
|
-
Anything else on the command line is reported as an unknown option and then
|
|
2077
|
-
ignored: the deck still starts.
|
|
2078
|
-
|
|
2079
|
-
A flag that takes a value never swallows the next flag. If the value is missing,
|
|
2080
|
-
empty, or itself looks like a flag — \`${PRODUCT} --workspace \$UNSET --no-persist\`
|
|
2081
|
-
after the shell has dropped an unset variable — the flag is reported, left on its
|
|
2082
|
-
default, and the token it would have eaten is parsed as the flag it is.
|
|
2083
|
-
`);
|
|
2084
|
-
}
|