agent-dag 1.33.127 → 1.33.129
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/bin/agent-dag.js +11 -1
- package/bin/deck.js +259 -183
- package/dist/web/assets/index-6fcA52jg.js +66 -0
- package/dist/web/index.html +1 -1
- package/package.json +1 -1
- package/src/server/term.mjs +450 -0
- package/dist/web/assets/index-jSeSu1R1.js +0 -66
package/bin/agent-dag.js
CHANGED
|
@@ -41,6 +41,7 @@ import {
|
|
|
41
41
|
npxRestartSpec, readRestartFailure, recordRestartFailure,
|
|
42
42
|
} from "../src/server/self-update.mjs";
|
|
43
43
|
import { dieOfSignal, upgradeAttempt, upgradeRefusalText, workerExitAction } from "../src/server/supervisor.mjs";
|
|
44
|
+
import { colorProfile, glyphs, palette, unicodeOK } from "../src/server/term.mjs";
|
|
44
45
|
|
|
45
46
|
const BIN_DIR = dirname(fileURLToPath(import.meta.url));
|
|
46
47
|
const WORKER = join(BIN_DIR, "deck.js");
|
|
@@ -48,6 +49,15 @@ const PKG_ROOT = dirname(BIN_DIR);
|
|
|
48
49
|
|
|
49
50
|
const VERSION = installedVersion(PKG_ROOT) ?? "?";
|
|
50
51
|
|
|
52
|
+
// The supervisor prints exactly one line of its own — the fetch — and it has to
|
|
53
|
+
// look like it came from the same product as the worker's rows. That is all the
|
|
54
|
+
// presentation this file gets: it is the process that is never replaced, and a
|
|
55
|
+
// colour profile plus a glyph tier is the most it can carry without becoming
|
|
56
|
+
// something that can fail. Everything else it says is an error, on stderr, in
|
|
57
|
+
// plain text, where it belongs.
|
|
58
|
+
const P = palette(colorProfile({ isTTY: Boolean(process.stdout.isTTY) }));
|
|
59
|
+
const G = glyphs(unicodeOK());
|
|
60
|
+
|
|
51
61
|
// Who the restart-failure note below belongs to. Several decks of the same
|
|
52
62
|
// package run out of one home directory — two `npx ccdeck` runs even share the
|
|
53
63
|
// _npx directory and therefore the version — so a note named after the package
|
|
@@ -228,7 +238,7 @@ async function prefetchUpgrade(worker) {
|
|
|
228
238
|
}
|
|
229
239
|
|
|
230
240
|
attempting = { pkgName, spec, target, attempt: decision.attempt };
|
|
231
|
-
process.stdout.write(`\n
|
|
241
|
+
process.stdout.write(`\n ${P.warn}${G.restart}${P.reset} ${P.muted}fetching ${spec}${G.ellipsis}${P.reset}\n`);
|
|
232
242
|
const got = await npxPrefetch(spec, { onChild: (c) => { fetching = c; } });
|
|
233
243
|
fetching = null;
|
|
234
244
|
// Ctrl+C, or a worker that died on its own while npm was working: either way
|
package/bin/deck.js
CHANGED
|
@@ -8,6 +8,11 @@ import { resolve, dirname, join } from "node:path";
|
|
|
8
8
|
import { homedir } from "node:os";
|
|
9
9
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
10
10
|
import { existsSync, readFileSync } from "node:fs";
|
|
11
|
+
import { dieOfSignal } from "../src/server/supervisor.mjs";
|
|
12
|
+
import {
|
|
13
|
+
CURSOR_HIDE, CURSOR_SHOW, colorProfile, fit, glyphs, labelColumn, link, motionOK, palette,
|
|
14
|
+
pulseText, spinnerFrames, statusLine, supportsHyperlinks, termColumns, unicodeOK, wordmark,
|
|
15
|
+
} from "../src/server/term.mjs";
|
|
11
16
|
|
|
12
17
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
13
18
|
const PKG_ROOT = resolve(__dirname, "..");
|
|
@@ -101,181 +106,244 @@ if (!existsSync(WEB_DIST)) {
|
|
|
101
106
|
process.exit(1);
|
|
102
107
|
}
|
|
103
108
|
|
|
104
|
-
// ──
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
};
|
|
109
|
+
// ── the terminal we are printing into ─────────────────────────────────────────
|
|
110
|
+
// Asked once, degraded from there — see src/server/term.mjs, which is where all
|
|
111
|
+
// of this is decided and asserted. Below this point the deck writes no escape of
|
|
112
|
+
// its own: colour comes from `P`, glyphs from `G`, layout from statusLine. That
|
|
113
|
+
// is what makes NO_COLOR, a pipe, a CI log and a legacy Windows console one
|
|
114
|
+
// question rather than thirty separate ones nobody remembers to ask.
|
|
115
|
+
const tty = Boolean(process.stdout.isTTY);
|
|
116
|
+
const PROFILE = colorProfile({ isTTY: tty });
|
|
117
|
+
const P = palette(PROFILE);
|
|
118
|
+
const UNICODE = unicodeOK();
|
|
119
|
+
const G = glyphs(UNICODE);
|
|
120
|
+
const LINKS = supportsHyperlinks({ profile: PROFILE });
|
|
121
|
+
// The terminal's prefers-reduced-motion: nothing sleeps, spins or repaints in a
|
|
122
|
+
// pipe, under CI, or with NO_COLOR set.
|
|
123
|
+
const MOTION = motionOK({ isTTY: tty, profile: PROFILE });
|
|
124
|
+
const write = (s) => process.stdout.write(s);
|
|
125
|
+
// Read per line, never cached: a terminal can be resized while the deck runs,
|
|
126
|
+
// and the pulse below is still on screen hours later.
|
|
127
|
+
const cols = () => termColumns(process.stdout);
|
|
119
128
|
const sleep = ms => new Promise(r => setTimeout(r, ms));
|
|
129
|
+
const fileLink = (path) => link(path, pathToFileURL(path).href, LINKS);
|
|
130
|
+
|
|
131
|
+
// ── the cursor ────────────────────────────────────────────────────────────────
|
|
132
|
+
// Hidden for as long as anything of ours is moving — the reveal, the spinner,
|
|
133
|
+
// the pulse — and put back on every way out of this process: the ordinary exit,
|
|
134
|
+
// all three signals, and an uncaught throw, which reaches 'exit' after Node has
|
|
135
|
+
// printed it. Half of this is worse than none: a deck that dies with the cursor
|
|
136
|
+
// hidden leaves the user's shell with no cursor and nothing to do about it but
|
|
137
|
+
// `reset`.
|
|
138
|
+
let cursorHidden = false;
|
|
139
|
+
const showCursor = () => {
|
|
140
|
+
if (!cursorHidden) return;
|
|
141
|
+
cursorHidden = false;
|
|
142
|
+
try { write(CURSOR_SHOW); } catch { /* stdout is gone; nothing left to restore */ }
|
|
143
|
+
};
|
|
144
|
+
if (MOTION) { cursorHidden = true; write(CURSOR_HIDE); }
|
|
145
|
+
process.on("exit", showCursor);
|
|
146
|
+
// SIGHUP is the one signal this process does not otherwise handle, so its
|
|
147
|
+
// default action would end us before 'exit' could run. Handled only to put the
|
|
148
|
+
// cursor back and then die of it exactly as before — the supervisor reads the
|
|
149
|
+
// signal, not an exit code.
|
|
150
|
+
process.on("SIGHUP", () => { showCursor(); dieOfSignal("SIGHUP"); });
|
|
151
|
+
|
|
152
|
+
// ── rows ──────────────────────────────────────────────────────────────────────
|
|
153
|
+
// The status column is computed from the longest label. It used to be counted
|
|
154
|
+
// into each string as trailing spaces, so any new row, or any label a character
|
|
155
|
+
// longer, silently broke the alignment of every other one.
|
|
156
|
+
const LABELS = [
|
|
157
|
+
"workspace", "Claude hooks", "Codex sessions", "claude-swap", "accounts",
|
|
158
|
+
"ccusage", "update", "server ready", "log",
|
|
159
|
+
];
|
|
160
|
+
const LABEL_W = labelColumn(LABELS);
|
|
161
|
+
|
|
162
|
+
function row({ mark = " ", tone = P.ok, label = "", detail = "", detailTone = P.muted, keep = false }) {
|
|
163
|
+
return statusLine({
|
|
164
|
+
mark, label, detail, keep, labelWidth: LABEL_W, columns: cols(), ellipsis: G.ellipsis,
|
|
165
|
+
paint: {
|
|
166
|
+
mark: (s) => `${tone}${s}${P.reset}`,
|
|
167
|
+
detail: (s) => `${detailTone}${s}${P.reset}`,
|
|
168
|
+
},
|
|
169
|
+
}) + "\n";
|
|
170
|
+
}
|
|
120
171
|
|
|
121
|
-
// ──
|
|
172
|
+
// ── the wordmark ──────────────────────────────────────────────────────────────
|
|
122
173
|
async function printBanner() {
|
|
123
|
-
|
|
124
|
-
const
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
const COLORS = [C.dim, C.blue, C.cyan, C.bCyan, C.magenta, C.dim];
|
|
133
|
-
|
|
134
|
-
process.stdout.write('\n');
|
|
135
|
-
|
|
136
|
-
if (tty) {
|
|
137
|
-
const frames = ['⠋','⠙','⠹','⠸','⠼','⠴','⠦','⠧','⠇','⠏'];
|
|
138
|
-
for (let i = 0; i < 8; i++) {
|
|
139
|
-
process.stdout.write(`\r ${C.bCyan}${frames[i % frames.length]}${C.reset} ${C.dim}loading…${C.reset}`);
|
|
140
|
-
await sleep(70);
|
|
141
|
-
}
|
|
142
|
-
process.stdout.write('\r' + ' '.repeat(28) + '\n');
|
|
143
|
-
await sleep(40);
|
|
174
|
+
const { lines } = wordmark({ columns: cols(), version: PKG_VERSION, profile: PROFILE, unicode: UNICODE, pal: P });
|
|
175
|
+
for (const line of lines) {
|
|
176
|
+
write(line + "\n");
|
|
177
|
+
// A reveal, not a wait. The once-per-session work is already running under
|
|
178
|
+
// it (see startupWork), so the art costs the boot nothing and the deck is
|
|
179
|
+
// ready about when the last row lands. What used to be here — 560ms of
|
|
180
|
+
// spinner at "loading…" before a single art line — was dead time in a tool
|
|
181
|
+
// whose documented entry point is `npx ccdeck`.
|
|
182
|
+
if (MOTION && line) await sleep(45);
|
|
144
183
|
}
|
|
145
|
-
|
|
146
|
-
for (let i = 0; i < ART.length; i++) {
|
|
147
|
-
process.stdout.write(` ${COLORS[i]}${ART[i]}${C.reset}\n`);
|
|
148
|
-
if (tty) await sleep(38);
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
process.stdout.write(`\n ${C.dim}v${PKG_VERSION} · live agent DAG · Claude Code + Codex${C.reset}\n\n`);
|
|
152
184
|
}
|
|
153
185
|
|
|
154
|
-
// ──
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
186
|
+
// ── a step, with a spinner only if it is slow enough to need one ──────────────
|
|
187
|
+
// The interval's first frame is 80ms away, so anything already settled when we
|
|
188
|
+
// get here paints nothing at all and the row below is the only trace of it.
|
|
189
|
+
async function step(label, work) {
|
|
190
|
+
if (!MOTION) return work;
|
|
191
|
+
const frames = spinnerFrames(UNICODE);
|
|
192
|
+
// Kept inside the terminal: a label that wraps is a label the \r below can
|
|
193
|
+
// only half erase, and what is left of it stays under the row that follows.
|
|
194
|
+
const text = fit(label, cols() - 6, G.ellipsis);
|
|
158
195
|
let i = 0;
|
|
159
196
|
const iv = setInterval(() => {
|
|
160
|
-
|
|
197
|
+
write(`\r ${P.accent}${frames[i++ % frames.length]}${P.reset} ${P.muted}${text}${P.reset}`);
|
|
161
198
|
}, 80);
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
199
|
+
try {
|
|
200
|
+
return await work;
|
|
201
|
+
} finally {
|
|
202
|
+
clearInterval(iv);
|
|
203
|
+
// Cleared rather than overwritten: the row that follows is a different
|
|
204
|
+
// length, and relying on it to be the longer of the two is how a spinner
|
|
205
|
+
// leaves its own tail on screen. Nothing to clear if it never painted.
|
|
206
|
+
if (i) write("\r" + " ".repeat(text.length + 5) + "\r");
|
|
207
|
+
}
|
|
169
208
|
}
|
|
170
209
|
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
sp = spinner("installing Claude hooks…");
|
|
185
|
-
let claudeInstall;
|
|
186
|
-
try {
|
|
187
|
-
claudeInstall = await installHooks({ provider: "claude" });
|
|
188
|
-
} catch (err) {
|
|
210
|
+
/**
|
|
211
|
+
* The once-per-session work, all of it started at once and none of it awaited.
|
|
212
|
+
*
|
|
213
|
+
* Hook install, the claude-swap probe and the registry lookup have nothing to
|
|
214
|
+
* do with each other and nothing to do with the wordmark, so they run underneath
|
|
215
|
+
* the reveal instead of queueing behind it — the animation then costs the boot
|
|
216
|
+
* nothing and the deck is ready about when the last art row lands. Every one of
|
|
217
|
+
* them is given its rejection handler here, at the moment it is created, since a
|
|
218
|
+
* promise that settles before anything awaits it is otherwise an unhandled
|
|
219
|
+
* rejection.
|
|
220
|
+
*/
|
|
221
|
+
function startupWork() {
|
|
189
222
|
// Settings the installer cannot parse are settings it cannot rewrite without
|
|
190
|
-
// losing them, so it refuses
|
|
191
|
-
// it
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
}
|
|
206
|
-
|
|
223
|
+
// losing them, so it refuses — and that refusal is reported rather than
|
|
224
|
+
// thrown, because it is the only thing the user can act on.
|
|
225
|
+
const hooks = installHooks({ provider: "claude" }).then(v => ({ ok: true, v }), err => ({ ok: false, err }));
|
|
226
|
+
|
|
227
|
+
// claude-swap backs the multi-account panel, and an empty store leaves that
|
|
228
|
+
// panel useless even when the tool is there — so the account already signed
|
|
229
|
+
// in is registered once. Bounded inside seedFirstAccount: empty store only,
|
|
230
|
+
// once ever, never with NO_INSTALL set.
|
|
231
|
+
const cswap = (async () => {
|
|
232
|
+
const { ensureCswap } = await import(pathToFileURL(join(PKG_ROOT, "src/server/cswap-install.mjs")).href);
|
|
233
|
+
const cs = await ensureCswap();
|
|
234
|
+
const usable = cs.state === "present" || cs.state === "installed" || cs.state === "upgrading";
|
|
235
|
+
if (!usable) return { cs, seed: null };
|
|
236
|
+
const { seedFirstAccount } = await import(pathToFileURL(join(PKG_ROOT, "src/server/claude-accounts.mjs")).href);
|
|
237
|
+
return { cs, seed: await seedFirstAccount().catch(() => ({ state: "failed" })) };
|
|
238
|
+
})().catch(() => null);
|
|
239
|
+
|
|
240
|
+
// ccusage backs the usage-history modal. Primed at boot rather than on first
|
|
241
|
+
// open so a cold machine pays the install while the deck is still starting.
|
|
242
|
+
const ccusage = (async () => {
|
|
243
|
+
if (process.env.AGENTS_DECK_NO_INSTALL === "1") return null;
|
|
244
|
+
const { primeCcusage } = await import(pathToFileURL(join(PKG_ROOT, "src/server/ccusage.mjs")).href);
|
|
245
|
+
return primeCcusage();
|
|
246
|
+
})().catch(() => null);
|
|
247
|
+
|
|
248
|
+
// A newer release on npm, said once, in the place the upgrade gets typed.
|
|
249
|
+
// Hard-capped so a slow registry cannot delay the server — the answer is
|
|
250
|
+
// usually already cached in ~/.agents-deck/.self-update-check anyway. It has
|
|
251
|
+
// to resolve BEFORE the pulse indicator starts writing over the last line.
|
|
252
|
+
const update = Promise.race([
|
|
253
|
+
import(pathToFileURL(join(PKG_ROOT, "src/server/self-update.mjs")).href)
|
|
254
|
+
.then(m => m.versionReport({ running: PKG_VERSION, pkgRoot: PKG_ROOT }))
|
|
255
|
+
.then(r => (r?.notice?.kind === "upgrade" ? r : null))
|
|
256
|
+
.catch(() => null),
|
|
257
|
+
new Promise(r => setTimeout(() => r(null), 1200)),
|
|
258
|
+
]);
|
|
259
|
+
|
|
260
|
+
return { hooks, cswap, ccusage, update };
|
|
207
261
|
}
|
|
208
262
|
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
{
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
263
|
+
/** The same work, said out loud, in a fixed order — a boot whose rows arrive in
|
|
264
|
+
* whatever order the network settled is a boot nobody can scan twice. */
|
|
265
|
+
async function reportStartup(jobs) {
|
|
266
|
+
write(row({
|
|
267
|
+
mark: G.ok, label: "workspace",
|
|
268
|
+
detail: workspace === "" ? "(all)" : workspace,
|
|
269
|
+
detailTone: workspace === "" ? P.warn : P.muted,
|
|
270
|
+
}));
|
|
271
|
+
|
|
272
|
+
const hooks = await step(`installing Claude hooks${G.ellipsis}`, jobs.hooks);
|
|
273
|
+
if (!hooks.ok) {
|
|
274
|
+
// The file it names is one only the user can repair, and every Claude Code
|
|
275
|
+
// session on this machine is reading it too.
|
|
276
|
+
write(row({ mark: G.fail, tone: P.err, label: "Claude hooks", detail: "not installed" }));
|
|
277
|
+
console.error(`\n agents-deck: ${hooks.err.message}\n`);
|
|
278
|
+
process.exit(1);
|
|
279
|
+
}
|
|
280
|
+
write(row({ mark: G.ok, label: "Claude hooks", detail: fileLink(hooks.v.hookPath) }));
|
|
281
|
+
|
|
282
|
+
// Codex CLI hooks never fire on Windows (sandbox refuses to spawn the hook
|
|
283
|
+
// command). Instead the server tails Codex's rollout JSONL files directly, so
|
|
284
|
+
// there's nothing to install and no /hooks trust step. We just confirm Codex
|
|
285
|
+
// is present and let the watcher pick up sessions.
|
|
286
|
+
if (wantCodex) {
|
|
287
|
+
const dir = join(homedir(), ".codex", "sessions");
|
|
288
|
+
write(row({ mark: G.ok, label: "Codex sessions", detail: `watching ${fileLink(dir)}` }));
|
|
289
|
+
} else {
|
|
290
|
+
write(row({ label: "Codex sessions", detail: `skipped ${G.dash} no ~/.codex/, or --no-codex` }));
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const swap = await step(`checking claude-swap${G.ellipsis}`, jobs.cswap);
|
|
294
|
+
const cs = swap?.cs;
|
|
295
|
+
if (cs?.state === "present") {
|
|
296
|
+
write(row({ mark: G.ok, label: "claude-swap", detail: `v${cs.version} (accounts panel enabled)` }));
|
|
297
|
+
} else if (cs?.state === "installed") {
|
|
298
|
+
write(row({ mark: G.ok, label: "claude-swap", detail: `installed v${cs.version} via ${cs.via}` }));
|
|
299
|
+
} else if (cs?.state === "upgrading") {
|
|
300
|
+
write(row({ mark: G.ok, label: "claude-swap", detail: `v${cs.version}, upgrading to v${cs.latest} in background` }));
|
|
301
|
+
} else if (cs?.state === "skipped") {
|
|
302
|
+
write(row({ mark: G.ok, label: "claude-swap", detail: "not installed (AGENTS_DECK_NO_INSTALL=1)" }));
|
|
223
303
|
} else {
|
|
224
|
-
const how = cs
|
|
225
|
-
?
|
|
226
|
-
: cs
|
|
227
|
-
? `installed via ${cs.via} but not on PATH
|
|
304
|
+
const how = cs?.reason === "no_installer"
|
|
305
|
+
? `not installed ${G.dash} the accounts panel needs it`
|
|
306
|
+
: cs?.reason === "not_on_path"
|
|
307
|
+
? `installed via ${cs.via} but not on PATH ${G.dash} add ${
|
|
228
308
|
process.platform === "win32" ? "%USERPROFILE%\\.local\\bin" : "~/.local/bin"
|
|
229
309
|
}`
|
|
230
|
-
: `install failed via ${cs.via}`;
|
|
231
|
-
|
|
310
|
+
: `install failed${cs?.via ? ` via ${cs.via}` : ""}`;
|
|
311
|
+
write(row({ mark: G.fail, tone: P.warn, label: "claude-swap", detail: how }));
|
|
232
312
|
// A URL is not an answer when someone just wants the panel to work. Print
|
|
233
313
|
// the command for THIS machine, picked from what is already on it.
|
|
234
|
-
if (cs
|
|
314
|
+
if (cs?.hint) write(row({ label: "", detail: cs.hint }));
|
|
235
315
|
}
|
|
236
316
|
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
const { seedFirstAccount } = await import(pathToFileURL(join(PKG_ROOT, "src/server/claude-accounts.mjs")).href);
|
|
242
|
-
const seed = await seedFirstAccount().catch(() => ({ state: "failed" }));
|
|
243
|
-
if (seed.state === "added") {
|
|
244
|
-
process.stdout.write(` ${C.green}✓${C.reset} accounts ${C.dim}registered the signed-in account (cswap add)${C.reset}\n`);
|
|
245
|
-
} else if (seed.state === "failed" || seed.state === "nothing-to-add") {
|
|
246
|
-
process.stdout.write(` ${C.dim} accounts panel empty — sign in to Claude Code, then run cswap add${C.reset}\n`);
|
|
247
|
-
}
|
|
317
|
+
if (swap?.seed?.state === "added") {
|
|
318
|
+
write(row({ mark: G.ok, label: "accounts", detail: "registered the signed-in account (cswap add)" }));
|
|
319
|
+
} else if (swap?.seed?.state === "failed" || swap?.seed?.state === "nothing-to-add") {
|
|
320
|
+
write(row({ label: "accounts", detail: `panel empty ${G.dash} sign in to Claude Code, then run cswap add` }));
|
|
248
321
|
}
|
|
249
|
-
}
|
|
250
322
|
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
if (
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
323
|
+
const cu = await jobs.ccusage;
|
|
324
|
+
if (cu?.state === "present") write(row({ mark: G.ok, label: "ccusage", detail: `v${cu.version}` }));
|
|
325
|
+
else if (cu?.state === "updating") write(row({ mark: G.ok, label: "ccusage", detail: `v${cu.version}, checking for update` }));
|
|
326
|
+
else if (cu?.state === "installing") write(row({ mark: G.ok, label: "ccusage", detail: "installing in background" }));
|
|
327
|
+
|
|
328
|
+
const upgrade = await jobs.update;
|
|
329
|
+
if (upgrade) {
|
|
330
|
+
write(row({
|
|
331
|
+
mark: G.up, tone: P.warn, label: "update",
|
|
332
|
+
detail: `v${upgrade.notice.to} available ${G.dash} ${upgrade.command}`,
|
|
333
|
+
}));
|
|
334
|
+
}
|
|
259
335
|
}
|
|
260
336
|
|
|
261
|
-
//
|
|
262
|
-
//
|
|
263
|
-
//
|
|
264
|
-
//
|
|
265
|
-
//
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
selfCheck.then(r => r?.notice?.kind === "upgrade" ? r : null),
|
|
271
|
-
new Promise(r => setTimeout(() => r(null), 1200)),
|
|
272
|
-
]);
|
|
273
|
-
if (upgrade) {
|
|
274
|
-
process.stdout.write(
|
|
275
|
-
` ${C.yellow}↑${C.reset} update ${C.dim}v${upgrade.notice.to} available — ${C.reset}${C.yellow}${upgrade.command}${C.reset}\n`,
|
|
276
|
-
);
|
|
337
|
+
// Everything above is once-per-session setup — hook install, tool probes,
|
|
338
|
+
// registry lookups, and the banner it now runs underneath. A respawn is the
|
|
339
|
+
// same session continuing, so it skips the lot and prints one line instead.
|
|
340
|
+
// This is the difference between a restart that feels instant and one that
|
|
341
|
+
// makes you wonder whether it worked.
|
|
342
|
+
if (!RESPAWN) {
|
|
343
|
+
const jobs = startupWork();
|
|
344
|
+
await printBanner();
|
|
345
|
+
await reportStartup(jobs);
|
|
277
346
|
}
|
|
278
|
-
} // end !RESPAWN
|
|
279
347
|
|
|
280
348
|
// Asking the supervisor to bring us back. It is the only party that can, and
|
|
281
349
|
// only after this process is gone — which is precisely what keeps the
|
|
@@ -310,7 +378,7 @@ const requestRestart = (mode) => {
|
|
|
310
378
|
return;
|
|
311
379
|
}
|
|
312
380
|
const to = restartTarget();
|
|
313
|
-
|
|
381
|
+
write(`\n ${P.warn}${G.restart}${P.reset} ${P.muted}restarting${to ? ` ${G.arrow} v${to}` : ""}${G.ellipsis}${P.reset}\n`);
|
|
314
382
|
shutdown(RESTART_CODE);
|
|
315
383
|
};
|
|
316
384
|
|
|
@@ -322,9 +390,9 @@ const abandonUpgrade = (why) => {
|
|
|
322
390
|
restarting = false;
|
|
323
391
|
// The server's own latch, which no longer has an exiting process to clear it.
|
|
324
392
|
releaseRestart();
|
|
325
|
-
|
|
326
|
-
`\n ${
|
|
327
|
-
(why ? ` ${
|
|
393
|
+
write(
|
|
394
|
+
`\n ${P.warn}${G.cancel}${P.reset} ${P.muted}update not applied ${G.dash} still on ${P.reset}v${PKG_VERSION}\n` +
|
|
395
|
+
(why ? ` ${P.muted}${why}${P.reset}\n` : ""),
|
|
328
396
|
);
|
|
329
397
|
};
|
|
330
398
|
|
|
@@ -337,7 +405,7 @@ process.on("message", (m) => {
|
|
|
337
405
|
clearTimeout(upgradeTimer);
|
|
338
406
|
// The replacement is on the machine now, so this is the last moment the
|
|
339
407
|
// port is worth holding: exiting hands it straight over.
|
|
340
|
-
|
|
408
|
+
write(`\n ${P.warn}${G.restart}${P.reset} ${P.muted}updating via npx${G.ellipsis}${P.reset}\n`);
|
|
341
409
|
shutdown(UPGRADE_CODE);
|
|
342
410
|
} else if (m.type === "upgrade-refused") {
|
|
343
411
|
abandonUpgrade(m.error);
|
|
@@ -351,15 +419,16 @@ function restartTarget() {
|
|
|
351
419
|
catch { return null; }
|
|
352
420
|
}
|
|
353
421
|
|
|
354
|
-
|
|
355
|
-
const server = await startServer({
|
|
422
|
+
const starting = startServer({
|
|
356
423
|
port, persist, workspace, codex: wantCodex,
|
|
357
424
|
// Withheld when nothing is supervising us: without a parent, exiting is just
|
|
358
425
|
// exiting, and /api/restart answers 501 so the UI hides the control.
|
|
359
426
|
onRestart: SUPERVISED ? requestRestart : null,
|
|
360
|
-
})
|
|
361
|
-
|
|
362
|
-
|
|
427
|
+
});
|
|
428
|
+
const server = await (RESPAWN ? starting : step(`starting server${G.ellipsis}`, starting)).catch(err => {
|
|
429
|
+
// stderr, not a row: a deck that could not bind is not a status line, and
|
|
430
|
+
// whatever launched it reads this stream.
|
|
431
|
+
console.error(`agents-deck: server failed: ${err.message}`);
|
|
363
432
|
process.exit(1);
|
|
364
433
|
});
|
|
365
434
|
const addr = server.address();
|
|
@@ -373,15 +442,21 @@ const url = `http://127.0.0.1:${realPort}`;
|
|
|
373
442
|
try { process.send?.({ type: "listening", port: realPort }); } catch { /* not supervised */ }
|
|
374
443
|
|
|
375
444
|
if (RESPAWN) {
|
|
376
|
-
|
|
445
|
+
write(` ${P.ok}${G.restart}${P.reset} ${P.muted}restarted ${G.arrow} ${P.reset}v${PKG_VERSION}${P.muted} ${G.bullet} ${link(url, url, LINKS)}${P.reset}\n`);
|
|
377
446
|
} else {
|
|
378
|
-
|
|
379
|
-
|
|
447
|
+
// The URL is the one detail an ellipsis would destroy — half an address is
|
|
448
|
+
// not a shorter address — so it keeps its own line when the terminal is too
|
|
449
|
+
// narrow to hold it beside the label. See statusLine's `keep`.
|
|
450
|
+
write(row({
|
|
451
|
+
mark: G.ok, label: "server ready",
|
|
452
|
+
detail: link(url, url, LINKS), detailTone: `${P.accent}${P.bold}`, keep: true,
|
|
453
|
+
}));
|
|
454
|
+
if (persist) write(row({ label: "log", detail: fileLink(persist) }));
|
|
380
455
|
// Only when one is actually being opened. Under --no-open — which is how an
|
|
381
456
|
// npx update relaunches, with a tab already waiting — this was announcing
|
|
382
457
|
// something that never happened.
|
|
383
|
-
if (openBrowser)
|
|
384
|
-
else
|
|
458
|
+
if (openBrowser) write(`\n ${P.ok}${P.bold}${G.play} opening browser${G.ellipsis}${P.reset}\n\n`);
|
|
459
|
+
else write("\n");
|
|
385
460
|
}
|
|
386
461
|
|
|
387
462
|
// The discovery file is the whole of how a hook finds this deck: hook.js
|
|
@@ -428,21 +503,19 @@ if (openBrowser && !RESPAWN) {
|
|
|
428
503
|
}
|
|
429
504
|
|
|
430
505
|
// ── Pulse indicator ───────────────────────────────────────────────────────────
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
// Padded on the plain text, before any colour: the line is redrawn over
|
|
439
|
-
// itself with \r, so the shorter message has to cover the longer one.
|
|
440
|
-
const width = UNREGISTERED.length + 3;
|
|
506
|
+
// The whole line is rewritten each beat rather than just the dot: anything else
|
|
507
|
+
// on this deck that has something to say writes a newline first, and after that
|
|
508
|
+
// the line under the cursor is no longer the one we drew — a partial repaint
|
|
509
|
+
// would leave the message behind and pulse into empty space. Sized to the real
|
|
510
|
+
// terminal, because at 40 columns the old fixed 61-character line wrapped, and
|
|
511
|
+
// from then on \r only ever reached its second row.
|
|
512
|
+
if (MOTION) {
|
|
441
513
|
let pi = 0;
|
|
442
514
|
setInterval(() => {
|
|
443
|
-
const text = (registered
|
|
444
|
-
const
|
|
445
|
-
|
|
515
|
+
const text = pulseText({ registered, columns: cols(), unicode: UNICODE });
|
|
516
|
+
const dot = pi++ % 2 === 0 ? (registered ? P.ok : P.warn) : P.muted;
|
|
517
|
+
const tone = registered ? P.muted : P.warn;
|
|
518
|
+
write(`\r ${dot}${G.pulse}${P.reset} ${tone}${text}${P.reset}`);
|
|
446
519
|
}, 800).unref();
|
|
447
520
|
}
|
|
448
521
|
|
|
@@ -451,8 +524,11 @@ const shutdown = async (code = 0) => {
|
|
|
451
524
|
// on its own before either timer runs, Node would otherwise exit 0 and the
|
|
452
525
|
// supervisor would take that as "done" instead of "bring me back".
|
|
453
526
|
process.exitCode = code;
|
|
527
|
+
// Before anything that can take time: a Ctrl+C the user has to watch for a
|
|
528
|
+
// second and a half is a second and a half without a cursor.
|
|
529
|
+
showCursor();
|
|
454
530
|
if (tty && code !== RESTART_CODE && code !== UPGRADE_CODE) {
|
|
455
|
-
|
|
531
|
+
write(`\n\n ${P.warn}${G.stop} shutting down${G.ellipsis}${P.reset}\n`);
|
|
456
532
|
}
|
|
457
533
|
// Stopped first, always: a tick landing after the unlink would re-register a
|
|
458
534
|
// deck that is on its way out, and leave the file behind for the hooks to
|
|
@@ -477,15 +553,15 @@ process.on("beforeExit", () => { discovery.stop(); removeDiscovery(discoveryFile
|
|
|
477
553
|
// reason — because the alternative is what this replaced: an ordinary-looking
|
|
478
554
|
// deck that simply never shows a session.
|
|
479
555
|
function reportUnregistered({ file, error }) {
|
|
480
|
-
const why = error?.message ? `
|
|
481
|
-
|
|
482
|
-
`\n ${
|
|
483
|
-
` ${
|
|
556
|
+
const why = error?.message ? ` ${G.dash} ${error.message}` : "";
|
|
557
|
+
write(
|
|
558
|
+
`\n ${P.warn}${G.warn}${P.reset} ${P.bold}not registered${P.reset}${P.muted}${why}${P.reset}\n` +
|
|
559
|
+
` ${P.muted}hooks find this deck through ${file}, so until that file exists no events arrive.${P.reset}\n`,
|
|
484
560
|
);
|
|
485
561
|
}
|
|
486
562
|
|
|
487
563
|
function reportReregistered({ file }) {
|
|
488
|
-
|
|
564
|
+
write(`\n ${P.ok}${G.ok}${P.reset} ${P.muted}registered again ${G.arrow} ${fileLink(file)}${P.reset}\n`);
|
|
489
565
|
}
|
|
490
566
|
|
|
491
567
|
function parseArgs(args) {
|