agent-dag 3.0.0 → 3.2.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 +33 -0
- package/bin/deck.js +266 -61
- package/dist/web/assets/{index-6cAJDiBs.js → index-7UsJp5Ht.js} +40 -22
- package/dist/web/index.html +1 -1
- package/package.json +1 -4
- package/release-notes.json +28 -0
- package/src/server/boot-deadline.mjs +127 -0
- package/src/server/cswap-install.mjs +61 -8
- package/src/server/hwmonitor.mjs +56 -0
- package/src/server/index.mjs +102 -23
- package/src/server/lhm-parse.mjs +91 -0
- package/src/server/log-tail.mjs +139 -0
- package/src/server/macmon.mjs +299 -0
- package/src/server/open-url.mjs +226 -0
- package/src/server/quota.mjs +48 -2
- package/src/server/system-metrics.mjs +211 -18
- package/src/server/term.mjs +79 -3
package/README.md
CHANGED
|
@@ -68,6 +68,39 @@ What the deck does write, and the short list of what does leave the machine, is
|
|
|
68
68
|
- Node.js ≥ 18 — macOS, Linux and Windows
|
|
69
69
|
- Claude Code CLI or OpenAI Codex CLI (or both)
|
|
70
70
|
- Optional: [claude-swap](https://pypi.org/project/claude-swap/) for the Accounts panel; the deck can install it for you
|
|
71
|
+
- Nothing else. On Apple Silicon the deck fetches [`macmon`](https://github.com/vladkens/macmon) itself for the temperature rows; see below.
|
|
72
|
+
|
|
73
|
+
### Temperature, per machine
|
|
74
|
+
|
|
75
|
+
The machine panel shows a **Thermal** section only where the machine actually answers, and it never invents a reading — no sensor means no row.
|
|
76
|
+
|
|
77
|
+
| | reads | needs |
|
|
78
|
+
| --- | --- | --- |
|
|
79
|
+
| Linux | `/sys/class/hwmon` | nothing |
|
|
80
|
+
| Windows | the `Thermal Zone Information` performance counter | nothing — where the machine has an ACPI thermal zone. Many do not; see below |
|
|
81
|
+
| macOS, Intel | `ioreg` for the GPU, `pmset -g therm` for throttling | nothing |
|
|
82
|
+
| macOS, Apple Silicon | `macmon`, which the deck fetches for you | nothing |
|
|
83
|
+
|
|
84
|
+
Apple Silicon is the one that needs a tool, and it is not an oversight. No command that ships with macOS prints a temperature on an M-series Mac: `powermetrics` needs root, `pmset -g therm` records nothing there, and the sensors sit behind a private API that only native code can call. [`macmon`](https://github.com/vladkens/macmon) reads them without `sudo` and covers M1 through M5.
|
|
85
|
+
|
|
86
|
+
You do not have to install it. The deck downloads the published binary into `~/.agents-deck/tools/macmon` — the same place it already keeps `uv` — verifies it against the SHA-256 the GitHub release publishes, checks that it runs, and only then uses it. Not through Homebrew, because a machine without Homebrew would need Homebrew installed first, and that is a large thing to do to somebody who asked for a dashboard. It happens in the background, after the deck is already up, and never on the first run's critical path.
|
|
87
|
+
|
|
88
|
+
It is skipped entirely on a machine that already answers — an Intel Mac never downloads anything — and on one where you have `macmon` yourself, which is found on either Homebrew prefix. `AGENTS_DECK_NO_DOWNLOAD=1` turns it off on its own; `AGENTS_DECK_NO_INSTALL=1` turns it off along with everything else.
|
|
89
|
+
|
|
90
|
+
#### Windows, and why it is often blank
|
|
91
|
+
|
|
92
|
+
Windows is the platform where this most often shows nothing, and that is not a defect in the deck. Measured on a physical Windows 11 laptop — a Lenovo IdeaPad L340, Intel i5-9300H, English install, checked both as an ordinary user and as an administrator:
|
|
93
|
+
|
|
94
|
+
- its firmware declares **zero** ACPI thermal zones, so the performance counter above is registered but has no instances
|
|
95
|
+
- `MSAcpi_ThermalZoneTemperature` answers `Not supported` **even to an administrator**
|
|
96
|
+
- `Win32_TemperatureProbe` exists but every field reads `32768`, which is WMI's value for "unknown"
|
|
97
|
+
- the sensors are real and actively managed — Intel Dynamic Tuning is running — but it publishes them in `root\WMI EsifDeviceInformation`, which is **Access denied** without administrator
|
|
98
|
+
|
|
99
|
+
That is a class of machine, not a fault: modern Intel laptops moved thermal management into Intel DTT and stopped declaring the ACPI zones that Windows exposes to ordinary programs. There is no standard user-mode Windows API for CPU temperature — which is why HWiNFO, Core Temp and LibreHardwareMonitor all install a kernel driver, and why this deck does not.
|
|
100
|
+
|
|
101
|
+
Where the counter does have instances — many desktop boards, servers, and older laptops — it is read without any privileges at all. Its path is currently English-only; see [#747](https://github.com/BarganConstantin/ccdeck/issues/747).
|
|
102
|
+
|
|
103
|
+
One thing does work on the machines above, and it costs you nothing to have: if **LibreHardwareMonitor** happens to be running with its web server on, the deck reads its numbers over plain HTTP on localhost, which needs no privileges. That is a read, not a request — the deck does not install it, will not ask you to, and shows no section if it is not there. It is mentioned only so nobody is surprised to see degrees appear on a machine that had none.
|
|
71
104
|
|
|
72
105
|
## How it works
|
|
73
106
|
|
package/bin/deck.js
CHANGED
|
@@ -11,11 +11,12 @@ import { dieOfSignal, dieWithParent } from "../src/server/supervisor.mjs";
|
|
|
11
11
|
import { isPortValue, parseArgs } from "../src/server/args.mjs";
|
|
12
12
|
import {
|
|
13
13
|
CURSOR_HIDE, CURSOR_SHOW, colorProfile, fit, glyphs, labelColumn, link, motionOK, oneLine,
|
|
14
|
-
palette, pulseText, spinnerFrames, statusLine, supportsHyperlinks, termColumns,
|
|
15
|
-
unregisteredDetail, wordmark,
|
|
14
|
+
palette, pulseDot, pulseText, spinnerFrames, statusLine, supportsHyperlinks, termColumns,
|
|
15
|
+
elapsedSuffix, unicodeOK, unregisteredDetail, visibleWidth, wordmark,
|
|
16
16
|
} from "../src/server/term.mjs";
|
|
17
17
|
import { PRODUCT } from "../src/server/brand.mjs";
|
|
18
18
|
import { invokedName, renameNotice } from "../src/server/invoked-as.mjs";
|
|
19
|
+
import { budget, bootDeadlineMs } from "../src/server/boot-deadline.mjs";
|
|
19
20
|
|
|
20
21
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
21
22
|
const PKG_ROOT = resolve(__dirname, "..");
|
|
@@ -245,6 +246,20 @@ const cols = () => termColumns(process.stdout);
|
|
|
245
246
|
const sleep = ms => new Promise(r => setTimeout(r, ms));
|
|
246
247
|
const fileLink = (path) => link(path, pathToFileURL(path).href, LINKS);
|
|
247
248
|
|
|
249
|
+
/**
|
|
250
|
+
* What is still happening after the report ended, in three words or so.
|
|
251
|
+
*
|
|
252
|
+
* #742 left one job able to outlive the boot — an install of claude-swap that
|
|
253
|
+
* the report stopped waiting for — and the terminal said nothing about it once
|
|
254
|
+
* the rows were done. That is the wrong way round: the row that had finished
|
|
255
|
+
* was the one blinking, and the thing that really was working sat still. Now
|
|
256
|
+
* the pulse line carries the label while the job runs and drops it when the job
|
|
257
|
+
* settles, which is also what decides whether the line moves at all. Declared
|
|
258
|
+
* here rather than beside `registered` below, because reportStartup sets it and
|
|
259
|
+
* runs long before that line does.
|
|
260
|
+
*/
|
|
261
|
+
let pulseBusy = null;
|
|
262
|
+
|
|
248
263
|
// ── the cursor ────────────────────────────────────────────────────────────────
|
|
249
264
|
// Hidden for as long as anything of ours is moving — the reveal, the spinner,
|
|
250
265
|
// the pulse — and put back on every way out of this process: the ordinary exit,
|
|
@@ -288,31 +303,72 @@ function row({ mark = " ", tone = P.ok, label = "", detail = "", detailTone = P.
|
|
|
288
303
|
}
|
|
289
304
|
|
|
290
305
|
// ── the wordmark ──────────────────────────────────────────────────────────────
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Hold anything else that wants to speak until the art is finished.
|
|
309
|
+
*
|
|
310
|
+
* #742, found while watching a first run through a pty: the startup jobs run
|
|
311
|
+
* UNDER the reveal on purpose, and one of them failing writes to console.error
|
|
312
|
+
* the moment it fails — which put
|
|
313
|
+
*
|
|
314
|
+
* ccdeck ccusage: install failed: npm install ccusage failed: spawn npm ENOENT
|
|
315
|
+
*
|
|
316
|
+
* between the second and third rows of the logo. A wordmark with a stack of
|
|
317
|
+
* someone else's bad news through the middle of it is the first thing a new
|
|
318
|
+
* user sees, and it reads as a crash rather than as a note.
|
|
319
|
+
*
|
|
320
|
+
* The window is the reveal and nothing else — about 180ms — so at worst a
|
|
321
|
+
* message arrives a fifth of a second later than it would have, on the one
|
|
322
|
+
* stretch of the boot where there is nowhere for it to go. Restored in a
|
|
323
|
+
* `finally`, so a throw inside the reveal cannot leave the process mute.
|
|
324
|
+
*/
|
|
325
|
+
function holdConsole() {
|
|
326
|
+
const held = [];
|
|
327
|
+
const real = { warn: console.warn, error: console.error, log: console.log };
|
|
328
|
+
for (const k of Object.keys(real)) console[k] = (...args) => { held.push([k, args]); };
|
|
329
|
+
return () => {
|
|
330
|
+
Object.assign(console, real);
|
|
331
|
+
for (const [k, args] of held) real[k](...args);
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
|
|
291
335
|
async function printBanner() {
|
|
292
336
|
const { lines } = wordmark({ columns: cols(), version: PKG_VERSION, profile: PROFILE, unicode: UNICODE, pal: P });
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
337
|
+
const release = holdConsole();
|
|
338
|
+
try {
|
|
339
|
+
for (const line of lines) {
|
|
340
|
+
write(line + "\n");
|
|
341
|
+
// A reveal, not a wait. The once-per-session work is already running under
|
|
342
|
+
// it (see startupWork), so the art costs the boot nothing and the deck is
|
|
343
|
+
// ready about when the last row lands. What used to be here — 560ms of
|
|
344
|
+
// spinner at "loading…" before a single art line — was dead time in a tool
|
|
345
|
+
// whose documented entry point is `npx ccdeck`.
|
|
346
|
+
if (MOTION && line) await sleep(45);
|
|
347
|
+
}
|
|
348
|
+
} finally {
|
|
349
|
+
release();
|
|
301
350
|
}
|
|
302
351
|
}
|
|
303
352
|
|
|
304
353
|
// ── a step, with a spinner only if it is slow enough to need one ──────────────
|
|
305
354
|
// The interval's first frame is 80ms away, so anything already settled when we
|
|
306
355
|
// get here paints nothing at all and the row below is the only trace of it.
|
|
356
|
+
|
|
307
357
|
async function step(label, work) {
|
|
308
358
|
if (!MOTION) return work;
|
|
309
359
|
const frames = spinnerFrames(UNICODE);
|
|
310
360
|
// Kept inside the terminal: a label that wraps is a label the \r below can
|
|
311
361
|
// only half erase, and what is left of it stays under the row that follows.
|
|
312
|
-
|
|
362
|
+
// Six columns for the indent and the spinner, four more so the elapsed
|
|
363
|
+
// seconds have somewhere to go without pushing the label off the edge.
|
|
364
|
+
const text = fit(label, cols() - 10, G.ellipsis);
|
|
365
|
+
const started = Date.now();
|
|
313
366
|
let i = 0;
|
|
367
|
+
let widest = 0;
|
|
314
368
|
const iv = setInterval(() => {
|
|
315
|
-
|
|
369
|
+
const line = ` ${P.accent}${frames[i++ % frames.length]}${P.reset} ${P.muted}${text}${elapsedSuffix(Date.now() - started)}${P.reset}`;
|
|
370
|
+
widest = Math.max(widest, visibleWidth(line));
|
|
371
|
+
write(`\r${line}`);
|
|
316
372
|
}, 80);
|
|
317
373
|
try {
|
|
318
374
|
return await work;
|
|
@@ -320,8 +376,10 @@ async function step(label, work) {
|
|
|
320
376
|
clearInterval(iv);
|
|
321
377
|
// Cleared rather than overwritten: the row that follows is a different
|
|
322
378
|
// length, and relying on it to be the longer of the two is how a spinner
|
|
323
|
-
// leaves its own tail on screen.
|
|
324
|
-
|
|
379
|
+
// leaves its own tail on screen. Measured rather than computed, because the
|
|
380
|
+
// line grows when the elapsed seconds appear and again when they reach two
|
|
381
|
+
// digits. Nothing to clear if it never painted.
|
|
382
|
+
if (i) write("\r" + " ".repeat(widest) + "\r");
|
|
325
383
|
}
|
|
326
384
|
}
|
|
327
385
|
|
|
@@ -357,10 +415,20 @@ function startupWork() {
|
|
|
357
415
|
// panel useless even when the tool is there — so the account already signed
|
|
358
416
|
// in is registered once. Bounded inside seedFirstAccount: empty store only,
|
|
359
417
|
// once ever, never with NO_INSTALL set.
|
|
418
|
+
//
|
|
419
|
+
// `cswapInstalling` is the other half, and it is what keeps a first run from
|
|
420
|
+
// spending the boot's whole deadline on a question that has already been
|
|
421
|
+
// answered: ensureCswap resolves it the moment it commits to an install, so
|
|
422
|
+
// the report can stop waiting then rather than eight seconds later. It never
|
|
423
|
+
// settles on the machines where there is nothing to install, which is every
|
|
424
|
+
// machine after the first run.
|
|
425
|
+
let sayInstalling;
|
|
426
|
+
const cswapInstalling = new Promise(r => { sayInstalling = r; });
|
|
427
|
+
|
|
360
428
|
const cswap = (async () => {
|
|
361
429
|
if (!wantClaude) return null;
|
|
362
430
|
const { ensureCswap } = await import(pathToFileURL(join(PKG_ROOT, "src/server/cswap-install.mjs")).href);
|
|
363
|
-
const cs = await ensureCswap();
|
|
431
|
+
const cs = await ensureCswap({ onInstalling: () => sayInstalling() });
|
|
364
432
|
const usable = cs.state === "present" || cs.state === "installed" || cs.state === "upgrading";
|
|
365
433
|
if (!usable) return { cs, seed: null };
|
|
366
434
|
const { seedFirstAccount } = await import(pathToFileURL(join(PKG_ROOT, "src/server/claude-accounts.mjs")).href);
|
|
@@ -390,12 +458,17 @@ function startupWork() {
|
|
|
390
458
|
new Promise(r => setTimeout(() => r(null), 1200)),
|
|
391
459
|
]);
|
|
392
460
|
|
|
393
|
-
return { hooks, cswap, ccusage, update };
|
|
461
|
+
return { hooks, cswap, cswapInstalling, ccusage, update };
|
|
394
462
|
}
|
|
395
463
|
|
|
396
464
|
/** The same work, said out loud, in a fixed order — a boot whose rows arrive in
|
|
397
465
|
* whatever order the network settled is a boot nobody can scan twice. */
|
|
398
466
|
async function reportStartup(jobs) {
|
|
467
|
+
// What the whole report may spend waiting, shared by every job under it
|
|
468
|
+
// rather than granted to each — four jobs at eight seconds each is a
|
|
469
|
+
// thirty-two second boot that no individual deadline would object to.
|
|
470
|
+
const left = budget(bootDeadlineMs());
|
|
471
|
+
|
|
399
472
|
write(row({
|
|
400
473
|
mark: G.ok, label: "workspace",
|
|
401
474
|
detail: workspace === "" ? "(all)" : workspace,
|
|
@@ -435,22 +508,96 @@ async function reportStartup(jobs) {
|
|
|
435
508
|
write(row({ label: "Codex sessions", detail: `skipped ${G.dash} no ~/.codex/, or --no-codex` }));
|
|
436
509
|
}
|
|
437
510
|
|
|
438
|
-
|
|
511
|
+
// Bounded, because this is the job that made a first boot look hung: on a
|
|
512
|
+
// machine with neither claude-swap nor a Python toolchain it fetches a uv
|
|
513
|
+
// binary and then builds an environment with it, and the report used to wait
|
|
514
|
+
// out both. See src/server/boot-deadline.mjs. Nothing is cancelled — the
|
|
515
|
+
// install carries on and says how it went when it knows.
|
|
516
|
+
const swapWait = await step(`checking claude-swap${G.ellipsis}`, Promise.race([
|
|
517
|
+
left.within(jobs.cswap),
|
|
518
|
+
// The install announcing itself. Not a timeout — a decision, arriving in
|
|
519
|
+
// about a second on the boot that would otherwise have paid the full
|
|
520
|
+
// deadline for news it already had.
|
|
521
|
+
jobs.cswapInstalling.then(() => ({ done: false, installing: true })),
|
|
522
|
+
]));
|
|
523
|
+
if (swapWait.done) writeSwapRows(swapWait.value);
|
|
524
|
+
else {
|
|
525
|
+
write(row({
|
|
526
|
+
label: "claude-swap",
|
|
527
|
+
// Both halves earn their place, and both have to survive an 80-column
|
|
528
|
+
// terminal: what the job is doing, and that waiting for it is not the
|
|
529
|
+
// user's problem. The row that only said the first is the row this
|
|
530
|
+
// replaces — a spinner at "checking claude-swap…" says that much.
|
|
531
|
+
detail: swapWait.installing
|
|
532
|
+
? `installing in the background ${G.dash} the deck is ready`
|
|
533
|
+
: `still setting up ${G.dash} the deck is ready`,
|
|
534
|
+
}));
|
|
535
|
+
// Handed to the pulse line, which is the only thing still on screen once
|
|
536
|
+
// the rows are done — and taken back the moment the job settles, whichever
|
|
537
|
+
// way it settled.
|
|
538
|
+
pulseBusy = swapWait.installing ? "installing claude-swap" : "setting up claude-swap";
|
|
539
|
+
jobs.cswap.then(
|
|
540
|
+
late => { pulseBusy = null; writeSwapRows(late, { late: true }); },
|
|
541
|
+
() => { pulseBusy = null; },
|
|
542
|
+
);
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
const cu = await left.within(jobs.ccusage);
|
|
546
|
+
writeCcusageRow(cu.done ? cu.value : null);
|
|
547
|
+
|
|
548
|
+
const upgrade = await jobs.update;
|
|
549
|
+
if (upgrade) {
|
|
550
|
+
write(row({
|
|
551
|
+
mark: G.up, tone: P.warn, label: "update",
|
|
552
|
+
detail: `v${upgrade.notice.to} available ${G.dash} ${upgrade.command}`,
|
|
553
|
+
}));
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
// Which name this deck was started under, when that is knowable — a notice,
|
|
557
|
+
// never a refusal. 95% of installs are on the two old names and the update
|
|
558
|
+
// path runs through this very process, so a build that declined to boot under
|
|
559
|
+
// one of them would kill the deck on the machine where the deck is what would
|
|
560
|
+
// have explained why. Nothing at all is printed wherever the typed name
|
|
561
|
+
// cannot be proven (a Windows global install, a git checkout): telling
|
|
562
|
+
// somebody who already types `ccdeck` to type `ccdeck` is the one failure
|
|
563
|
+
// that would make this row worth ignoring. See src/server/invoked-as.mjs.
|
|
564
|
+
const rename = renameNotice({ invoked: INVOKED_AS, pkgRoot: PKG_ROOT, dash: G.dash });
|
|
565
|
+
if (rename) {
|
|
566
|
+
write(row({ mark: G.warn, tone: P.warn, label: "name", detail: rename.said }));
|
|
567
|
+
// The line that carries the value: for a global install there is nothing to
|
|
568
|
+
// install and nothing to download, only six different characters to type.
|
|
569
|
+
write(row({ label: "", detail: rename.fix }));
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
/**
|
|
574
|
+
* The claude-swap rows, wherever in the boot they end up being printed.
|
|
575
|
+
*
|
|
576
|
+
* `late` is the one difference, and it is not cosmetic: by the time a late row
|
|
577
|
+
* arrives the pulse indicator owns the last line and repaints it with `\r`, so
|
|
578
|
+
* a row written without a newline first would be drawn over on the next beat.
|
|
579
|
+
* Every other thing that speaks after boot — reportUnregistered,
|
|
580
|
+
* reportReregistered — opens with the same newline for the same reason.
|
|
581
|
+
*/
|
|
582
|
+
function writeSwapRows(swap, { late = false } = {}) {
|
|
439
583
|
const cs = swap?.cs;
|
|
584
|
+
// Collected rather than written one at a time, because a late report opens
|
|
585
|
+
// with a newline and there is exactly one of those however many rows follow.
|
|
586
|
+
let out = "";
|
|
440
587
|
if (!wantClaude) {
|
|
441
588
|
// claude-swap is a Python tool that switches Claude Code accounts, and the
|
|
442
589
|
// deck used to fetch a uv binary to install it on machines with no Claude
|
|
443
590
|
// Code at all. Saying so is the point of the row: it is the one place a
|
|
444
591
|
// user can learn that the accounts panel is missing on purpose.
|
|
445
|
-
|
|
592
|
+
out += row({ label: "claude-swap", detail: `skipped ${G.dash} accounts are Claude-only` });
|
|
446
593
|
} else if (cs?.state === "present") {
|
|
447
|
-
|
|
594
|
+
out += row({ mark: G.ok, label: "claude-swap", detail: `v${cs.version} (accounts panel enabled)` });
|
|
448
595
|
} else if (cs?.state === "installed") {
|
|
449
|
-
|
|
596
|
+
out += row({ mark: G.ok, label: "claude-swap", detail: `installed v${cs.version} via ${cs.via}` });
|
|
450
597
|
} else if (cs?.state === "upgrading") {
|
|
451
|
-
|
|
598
|
+
out += row({ mark: G.ok, label: "claude-swap", detail: `v${cs.version}, upgrading to v${cs.latest} in background` });
|
|
452
599
|
} else if (cs?.state === "skipped") {
|
|
453
|
-
|
|
600
|
+
out += row({ mark: G.ok, label: "claude-swap", detail: "not installed (AGENTS_DECK_NO_INSTALL=1)" });
|
|
454
601
|
} else {
|
|
455
602
|
const how = cs?.reason === "no_installer"
|
|
456
603
|
? `not installed ${G.dash} the accounts panel needs it`
|
|
@@ -459,19 +606,32 @@ async function reportStartup(jobs) {
|
|
|
459
606
|
process.platform === "win32" ? "%USERPROFILE%\\.local\\bin" : "~/.local/bin"
|
|
460
607
|
}`
|
|
461
608
|
: `install failed${cs?.via ? ` via ${cs.via}` : ""}`;
|
|
462
|
-
|
|
609
|
+
out += row({ mark: G.fail, tone: P.warn, label: "claude-swap", detail: how });
|
|
463
610
|
// A URL is not an answer when someone just wants the panel to work. Print
|
|
464
611
|
// the command for THIS machine, picked from what is already on it.
|
|
465
|
-
if (cs?.hint)
|
|
612
|
+
if (cs?.hint) out += row({ label: "", detail: cs.hint });
|
|
466
613
|
}
|
|
467
614
|
|
|
468
615
|
if (swap?.seed?.state === "added") {
|
|
469
|
-
|
|
616
|
+
out += row({ mark: G.ok, label: "accounts", detail: "registered the signed-in account (cswap add)" });
|
|
470
617
|
} else if (swap?.seed?.state === "failed" || swap?.seed?.state === "nothing-to-add") {
|
|
471
|
-
|
|
618
|
+
out += row({ label: "accounts", detail: `panel empty ${G.dash} sign in to Claude Code, then run cswap add` });
|
|
472
619
|
}
|
|
473
620
|
|
|
474
|
-
|
|
621
|
+
write(late ? "\n" + out : out);
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
/**
|
|
625
|
+
* The ccusage row.
|
|
626
|
+
*
|
|
627
|
+
* `null` covers both of the ways there is nothing to say — the job was not
|
|
628
|
+
* attempted, and the job had not answered by the time the boot's deadline ran
|
|
629
|
+
* out. Neither deserves a row: unlike claude-swap there is no install to wait
|
|
630
|
+
* for here, because primeCcusage starts one and returns without it, so a
|
|
631
|
+
* ccusage that is slow to answer is slow at resolving a path and will be
|
|
632
|
+
* resolved again the first time the usage modal is opened.
|
|
633
|
+
*/
|
|
634
|
+
function writeCcusageRow(cu) {
|
|
475
635
|
if (cu?.state === "present") write(row({ mark: G.ok, label: "ccusage", detail: `v${cu.version}` }));
|
|
476
636
|
else if (cu?.state === "updating") write(row({ mark: G.ok, label: "ccusage", detail: `v${cu.version}, checking for update` }));
|
|
477
637
|
// A ccusage the user provided, named rather than versioned — reading a
|
|
@@ -481,30 +641,6 @@ async function reportStartup(jobs) {
|
|
|
481
641
|
// with a managed install AND a PATH copy could not answer before #433.
|
|
482
642
|
else if (cu?.state === "user") write(row({ mark: G.ok, label: "ccusage", detail: `your own copy ${G.dash} ${cu.bin}` }));
|
|
483
643
|
else if (cu?.state === "installing") write(row({ mark: G.ok, label: "ccusage", detail: "installing in background" }));
|
|
484
|
-
|
|
485
|
-
const upgrade = await jobs.update;
|
|
486
|
-
if (upgrade) {
|
|
487
|
-
write(row({
|
|
488
|
-
mark: G.up, tone: P.warn, label: "update",
|
|
489
|
-
detail: `v${upgrade.notice.to} available ${G.dash} ${upgrade.command}`,
|
|
490
|
-
}));
|
|
491
|
-
}
|
|
492
|
-
|
|
493
|
-
// Which name this deck was started under, when that is knowable — a notice,
|
|
494
|
-
// never a refusal. 95% of installs are on the two old names and the update
|
|
495
|
-
// path runs through this very process, so a build that declined to boot under
|
|
496
|
-
// one of them would kill the deck on the machine where the deck is what would
|
|
497
|
-
// have explained why. Nothing at all is printed wherever the typed name
|
|
498
|
-
// cannot be proven (a Windows global install, a git checkout): telling
|
|
499
|
-
// somebody who already types `ccdeck` to type `ccdeck` is the one failure
|
|
500
|
-
// that would make this row worth ignoring. See src/server/invoked-as.mjs.
|
|
501
|
-
const rename = renameNotice({ invoked: INVOKED_AS, pkgRoot: PKG_ROOT, dash: G.dash });
|
|
502
|
-
if (rename) {
|
|
503
|
-
write(row({ mark: G.warn, tone: P.warn, label: "name", detail: rename.said }));
|
|
504
|
-
// The line that carries the value: for a global install there is nothing to
|
|
505
|
-
// install and nothing to download, only six different characters to type.
|
|
506
|
-
write(row({ label: "", detail: rename.fix }));
|
|
507
|
-
}
|
|
508
644
|
}
|
|
509
645
|
|
|
510
646
|
// Asking the supervisor to bring us back. It is the only party that can, and
|
|
@@ -533,11 +669,13 @@ let upgradeTimer = null;
|
|
|
533
669
|
// So an ask that arrives too early is held rather than run: the user asked for
|
|
534
670
|
// something this deck can genuinely give a moment later, and refusing outright
|
|
535
671
|
// would put back the same silence in a politer form. BOOT_RESTART_MS is the
|
|
536
|
-
// outer bound, for the reason UPGRADE_ANSWER_MS above is one
|
|
537
|
-
//
|
|
538
|
-
//
|
|
539
|
-
//
|
|
540
|
-
//
|
|
672
|
+
// outer bound, for the reason UPGRADE_ANSWER_MS above is one. It used to be a
|
|
673
|
+
// bound that got used — before #742 the report waited out a real `uv tool
|
|
674
|
+
// install`, so the window it covers was minutes wide. It is now the boot
|
|
675
|
+
// deadline plus the browser spawn, comfortably inside ten seconds, and this
|
|
676
|
+
// stays as the thing that makes that a fact rather than a belief. Ten seconds
|
|
677
|
+
// in, the ask is run; the respawn skips the report entirely and is up in about
|
|
678
|
+
// a second.
|
|
541
679
|
let booted = false;
|
|
542
680
|
let heldRestart = null;
|
|
543
681
|
let bootTimer = null;
|
|
@@ -831,9 +969,14 @@ await discovery.check();
|
|
|
831
969
|
// Never on a respawn: the tab that asked for the restart is still open and
|
|
832
970
|
// reconnecting on its own. A second one would be the deck talking over itself.
|
|
833
971
|
if (openBrowser && !RESPAWN) {
|
|
972
|
+
// Not awaited, and not a dependency any more. `open@10` was this package's
|
|
973
|
+
// only runtime dependency and brought nine more with it, all of them fetched
|
|
974
|
+
// on a cold `npx ccdeck` before the deck's own tarball is unpacked — and the
|
|
975
|
+
// await under it held the boot behind a launcher that has nothing to report.
|
|
976
|
+
// See src/server/open-url.mjs.
|
|
834
977
|
try {
|
|
835
|
-
const {
|
|
836
|
-
|
|
978
|
+
const { openUrl } = await import(pathToFileURL(join(PKG_ROOT, "src/server/open-url.mjs")).href);
|
|
979
|
+
openUrl(url);
|
|
837
980
|
} catch {}
|
|
838
981
|
}
|
|
839
982
|
|
|
@@ -844,17 +987,79 @@ if (openBrowser && !RESPAWN) {
|
|
|
844
987
|
// would leave the message behind and pulse into empty space. Sized to the real
|
|
845
988
|
// terminal, because at 40 columns the old fixed 61-character line wrapped, and
|
|
846
989
|
// from then on \r only ever reached its second row.
|
|
990
|
+
//
|
|
991
|
+
// AND IT STOPS MOVING (#742). The dot alternated green and grey every 800ms for
|
|
992
|
+
// as long as the deck ran, and a blinking indicator beside a status line is the
|
|
993
|
+
// vocabulary of "working on it" — so a boot that finished in a second read as
|
|
994
|
+
// one that never finished, which is what people reported. Motion is now spent
|
|
995
|
+
// on the two states where something really is outstanding, and the frame is
|
|
996
|
+
// compared against what is already on screen so a deck at rest paints once and
|
|
997
|
+
// then leaves the terminal alone. See pulseMoves.
|
|
847
998
|
if (MOTION) {
|
|
848
999
|
let pi = 0;
|
|
1000
|
+
let painted = null;
|
|
1001
|
+
// The line is on screen and is the last thing written to this terminal.
|
|
1002
|
+
let ours = false;
|
|
1003
|
+
// We are the one writing right now, so the guard below leaves us alone.
|
|
1004
|
+
let writing = false;
|
|
1005
|
+
|
|
1006
|
+
// The pulse line's tenancy, enforced rather than agreed.
|
|
1007
|
+
//
|
|
1008
|
+
// The convention was that anything with something to say writes a newline
|
|
1009
|
+
// first, so the pulse's `\r` never lands on somebody else's text. bin/deck.js
|
|
1010
|
+
// keeps it everywhere. src/server/quota.mjs does not — it calls console.error
|
|
1011
|
+
// directly — and a Windows user with no Claude Code sent a screenshot of the
|
|
1012
|
+
// result: `listening — Ctrl+C to stop ccdeck quota: claude CLI failed`
|
|
1013
|
+
// on one row, three times over, the pulse and the complaint interleaved.
|
|
1014
|
+
//
|
|
1015
|
+
// An invariant every writer has to remember is one a writer will forget, and
|
|
1016
|
+
// the writers here are server modules that know nothing about a terminal. So
|
|
1017
|
+
// it is enforced at the stream instead: while our line is the last thing on
|
|
1018
|
+
// screen, anything else that speaks gets a newline first, and the memo is
|
|
1019
|
+
// dropped so the next beat repaints the line under whatever was said.
|
|
1020
|
+
//
|
|
1021
|
+
// Both streams, because console.error goes to stderr and lands on the same
|
|
1022
|
+
// screen. Only when MOTION is on — with no pulse there is no line to defend,
|
|
1023
|
+
// and a piped deck must not have its output rewritten.
|
|
1024
|
+
for (const stream of [process.stdout, process.stderr]) {
|
|
1025
|
+
const real = stream.write.bind(stream);
|
|
1026
|
+
stream.write = (chunk, ...rest) => {
|
|
1027
|
+
if (!writing && ours) {
|
|
1028
|
+
ours = false;
|
|
1029
|
+
// Dropped, not kept: the line is no longer where we left it, so the
|
|
1030
|
+
// next beat has to draw it again even though the frame is unchanged.
|
|
1031
|
+
painted = null;
|
|
1032
|
+
// Unless the speaker already did it. Every late message in this file
|
|
1033
|
+
// opens with one, and two blank lines is its own kind of mess.
|
|
1034
|
+
if (!String(chunk).startsWith("\n")) real("\n");
|
|
1035
|
+
}
|
|
1036
|
+
return real(chunk, ...rest);
|
|
1037
|
+
};
|
|
1038
|
+
}
|
|
1039
|
+
|
|
849
1040
|
setInterval(() => {
|
|
850
1041
|
// The colour follows the words. A Codex-only deck keeps saying "listening"
|
|
851
1042
|
// when it is unregistered — see pulseText — and painting that sentence in
|
|
852
1043
|
// the warning tone would restore the alarm the sentence just retired.
|
|
853
1044
|
const alarm = !registered && wantClaude;
|
|
854
|
-
const
|
|
855
|
-
const
|
|
1045
|
+
const state = { registered, claude: wantClaude, busy: pulseBusy };
|
|
1046
|
+
const text = pulseText({ ...state, columns: cols(), unicode: UNICODE });
|
|
1047
|
+
// At rest every beat is lit, which is what makes the line still: the frame
|
|
1048
|
+
// is then identical to the one already on screen and the write below is
|
|
1049
|
+
// skipped. See pulseDot.
|
|
1050
|
+
const dot = pulseDot(pi++, state) === "on" ? (alarm ? P.warn : P.ok) : P.muted;
|
|
856
1051
|
const tone = alarm ? P.warn : P.muted;
|
|
857
|
-
|
|
1052
|
+
const frame = `\r ${dot}${G.pulse}${P.reset} ${tone}${text}${P.reset}`;
|
|
1053
|
+
// Unchanged frames are not written at all. That is what makes "at rest"
|
|
1054
|
+
// visible: one paint, and then a still line for as long as nothing happens.
|
|
1055
|
+
// `painted` is dropped by the guard above whenever somebody else writes, so
|
|
1056
|
+
// this can only skip a beat while the line is genuinely still where we left
|
|
1057
|
+
// it.
|
|
1058
|
+
if (frame === painted) return;
|
|
1059
|
+
painted = frame;
|
|
1060
|
+
writing = true;
|
|
1061
|
+
try { write(frame); } finally { writing = false; }
|
|
1062
|
+
ours = true;
|
|
858
1063
|
}, 800).unref();
|
|
859
1064
|
}
|
|
860
1065
|
|