agent-dag 1.33.1 → 1.33.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/agent-dag.js +112 -5
- package/bin/deck.js +22 -7
- package/dist/web/assets/{index-BKz-oUzX.js → index-VG3Xh-3i.js} +16 -16
- package/dist/web/index.html +1 -1
- package/package.json +1 -1
- package/src/server/cswap-admin.mjs +36 -12
- package/src/server/cswap-install.mjs +39 -10
- package/src/server/exec.mjs +40 -3
- package/src/server/index.mjs +31 -5
- package/src/server/self-update.mjs +77 -4
package/dist/web/index.html
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
6
|
<title>agents-deck</title>
|
|
7
7
|
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Ctext y='84' font-size='84'%3E%E2%97%89%3C/text%3E%3C/svg%3E" />
|
|
8
|
-
<script type="module" crossorigin src="/assets/index-
|
|
8
|
+
<script type="module" crossorigin src="/assets/index-VG3Xh-3i.js"></script>
|
|
9
9
|
<link rel="stylesheet" crossorigin href="/assets/index-hRjhJVfb.css">
|
|
10
10
|
</head>
|
|
11
11
|
<body>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-dag",
|
|
3
|
-
"version": "1.33.
|
|
3
|
+
"version": "1.33.3",
|
|
4
4
|
"description": "Live deck of Claude Code and Codex agents — watch parallel subagents fork, call tools, and return on one calm canvas. Also available as npx ccdeck and npx agent-dag.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -19,8 +19,9 @@
|
|
|
19
19
|
import { readFile } from "node:fs/promises";
|
|
20
20
|
import { homedir } from "node:os";
|
|
21
21
|
import { join } from "node:path";
|
|
22
|
-
import { run, runDetached, runInteractive } from "./exec.mjs";
|
|
22
|
+
import { looksMissing, run, runDetached, runInteractive } from "./exec.mjs";
|
|
23
23
|
import { invalidateClaudeAccountsCache } from "./claude-accounts.mjs";
|
|
24
|
+
import { cswapBin } from "./cswap-install.mjs";
|
|
24
25
|
|
|
25
26
|
// An OAuth code is short-lived at the source; there is no point holding a child
|
|
26
27
|
// open longer than a user would plausibly take to fetch one.
|
|
@@ -65,9 +66,13 @@ export function withStoreLock(fn) {
|
|
|
65
66
|
|
|
66
67
|
// ── shared helpers ───────────────────────────────────────────────────────────
|
|
67
68
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
69
|
+
// cswapBin comes from cswap-install.mjs, which searches the places uv and pipx
|
|
70
|
+
// actually install to. This module used to answer the bare name "cswap"
|
|
71
|
+
// instead, which is fine on a Mac where ~/.local/bin is usually on PATH and
|
|
72
|
+
// wrong on Windows where it is usually not: every mutation here — share,
|
|
73
|
+
// import, remove, rename, reorder — failed with cmd.exe's "is not recognized",
|
|
74
|
+
// while the read-only half of the panel worked, because it was already using
|
|
75
|
+
// the resolver. Reported from Windows on 2026-08-14.
|
|
71
76
|
async function claudeBin() {
|
|
72
77
|
return process.env.AGENTS_DECK_CLAUDE ?? "claude";
|
|
73
78
|
}
|
|
@@ -224,7 +229,7 @@ export async function startLogin({ email } = {}) {
|
|
|
224
229
|
if (flow !== _login) return;
|
|
225
230
|
if (flow.state === "awaiting_url" || flow.state === "awaiting_code") {
|
|
226
231
|
flow.state = "failed";
|
|
227
|
-
flow.error = r.timedOut ? "the sign-in window expired" :
|
|
232
|
+
flow.error = r.timedOut ? "the sign-in window expired" : failureText(r, "claude auth login") || "sign-in ended without a code";
|
|
228
233
|
}
|
|
229
234
|
});
|
|
230
235
|
|
|
@@ -274,7 +279,7 @@ export async function submitLoginCode(code) {
|
|
|
274
279
|
}
|
|
275
280
|
if (!r.ok) {
|
|
276
281
|
flow.state = "failed";
|
|
277
|
-
flow.error = r.timedOut ? "the sign-in window expired" :
|
|
282
|
+
flow.error = r.timedOut ? "the sign-in window expired" : failureText(r, "claude auth login") || "the code was not accepted";
|
|
278
283
|
return { ok: false, reason: "login_failed", ...loginState() };
|
|
279
284
|
}
|
|
280
285
|
|
|
@@ -372,7 +377,7 @@ export async function shareAccount(num) {
|
|
|
372
377
|
if (!Number.isInteger(n) || n < 1 || n > 999) return { ok: false, reason: "bad_account" };
|
|
373
378
|
const r = await run(await cswapBin(), ["export", "-", "--account", String(n)], { timeout: CSWAP_TIMEOUT_MS });
|
|
374
379
|
if (!r.ok || !r.stdout.trim()) {
|
|
375
|
-
return { ok: false, reason: "export_failed", detail:
|
|
380
|
+
return { ok: false, reason: "export_failed", detail: failureText(r, "cswap export") };
|
|
376
381
|
}
|
|
377
382
|
return { ok: true, blob: wrapShare(r.stdout), expiresAt: Date.now() + SHARE_TTL_MS };
|
|
378
383
|
}
|
|
@@ -390,7 +395,7 @@ export async function importAccount(blob) {
|
|
|
390
395
|
endStdin(child);
|
|
391
396
|
|
|
392
397
|
const r = await child.done;
|
|
393
|
-
if (!r.ok) return { ok: false, reason: "import_failed", detail:
|
|
398
|
+
if (!r.ok) return { ok: false, reason: "import_failed", detail: failureText(r, "cswap import") };
|
|
394
399
|
|
|
395
400
|
const after = await readStore();
|
|
396
401
|
const slot = newSlot(before, after);
|
|
@@ -429,7 +434,7 @@ export async function removeAccount(num) {
|
|
|
429
434
|
});
|
|
430
435
|
const r = await child.done;
|
|
431
436
|
invalidateClaudeAccountsCache();
|
|
432
|
-
if (!r.ok) return { ok: false, reason: "remove_failed", detail:
|
|
437
|
+
if (!r.ok) return { ok: false, reason: "remove_failed", detail: failureText(r, "cswap remove") };
|
|
433
438
|
// Exit 0 without the prompt means cswap declined for its own reason — a
|
|
434
439
|
// live session on that account, most often — and printed why.
|
|
435
440
|
if (!answered) return { ok: false, reason: "not_confirmed", detail: firstUseful(r.stdout || r.stderr) };
|
|
@@ -447,7 +452,7 @@ export async function setAlias(num, alias) {
|
|
|
447
452
|
invalidateClaudeAccountsCache();
|
|
448
453
|
return r.ok
|
|
449
454
|
? { ok: true, output: firstUseful(r.stdout) }
|
|
450
|
-
: { ok: false, reason: "alias_failed", detail:
|
|
455
|
+
: { ok: false, reason: "alias_failed", detail: failureText(r, "cswap alias") };
|
|
451
456
|
});
|
|
452
457
|
}
|
|
453
458
|
|
|
@@ -460,12 +465,31 @@ export async function moveAccount(num, slot) {
|
|
|
460
465
|
invalidateClaudeAccountsCache();
|
|
461
466
|
return r.ok
|
|
462
467
|
? { ok: true, output: firstUseful(r.stdout) }
|
|
463
|
-
: { ok: false, reason: "move_failed", detail:
|
|
468
|
+
: { ok: false, reason: "move_failed", detail: failureText(r, "cswap move") };
|
|
464
469
|
});
|
|
465
470
|
}
|
|
466
471
|
|
|
467
472
|
// ── text ─────────────────────────────────────────────────────────────────────
|
|
468
473
|
|
|
474
|
+
/**
|
|
475
|
+
* What went wrong, in a sentence the user can act on.
|
|
476
|
+
*
|
|
477
|
+
* The missing-tool case is singled out because its own output is useless: on
|
|
478
|
+
* Windows it is cmd.exe's two-line "is not recognized …/operable program or
|
|
479
|
+
* batch file.", and `firstUseful` — which takes the LAST line, correctly for
|
|
480
|
+
* every other CLI — leaves the second half on screen by itself.
|
|
481
|
+
*/
|
|
482
|
+
export function failureText(r, what = "cswap") {
|
|
483
|
+
const out = `${r?.stderr ?? ""}\n${r?.stdout ?? ""}`;
|
|
484
|
+
const tool = String(what).split(" ")[0];
|
|
485
|
+
if (r?.code === "ENOENT" || looksMissing(out)) {
|
|
486
|
+
return tool === "claude"
|
|
487
|
+
? "the claude CLI could not be run: not on PATH. Set AGENTS_DECK_CLAUDE to its full path."
|
|
488
|
+
: "cswap could not be run: not on PATH, and not in the places uv and pipx install to. Set AGENTS_DECK_CSWAP to its full path.";
|
|
489
|
+
}
|
|
490
|
+
return firstUseful(out) || `${what} exited ${r?.code}`;
|
|
491
|
+
}
|
|
492
|
+
|
|
469
493
|
/** The line worth showing a user out of a CLI's output. */
|
|
470
494
|
export function firstUseful(text) {
|
|
471
495
|
const lines = stripTerminalEscapes(text)
|
|
@@ -488,7 +512,7 @@ export function addFailureText(r) {
|
|
|
488
512
|
if (/keychain/i.test(text)) {
|
|
489
513
|
return `${text} — start agents-deck from a Terminal window rather than a background service.`;
|
|
490
514
|
}
|
|
491
|
-
return
|
|
515
|
+
return failureText(r, "cswap add");
|
|
492
516
|
}
|
|
493
517
|
|
|
494
518
|
async function waitFor(get, timeoutMs, stepMs = 100) {
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
import { run, runDetached } from "./exec.mjs";
|
|
11
11
|
import { bootstrapUv, existingBootstrappedUv } from "./uv-bootstrap.mjs";
|
|
12
12
|
import { existsSync, mkdirSync, statSync, writeFileSync } from "node:fs";
|
|
13
|
-
import { join } from "node:path";
|
|
13
|
+
import { join, posix as posixPath, win32 as winPath } from "node:path";
|
|
14
14
|
import { homedir } from "node:os";
|
|
15
15
|
|
|
16
16
|
const INSTALL_TIMEOUT_MS = 180_000; // uv resolves + builds a Python env
|
|
@@ -34,20 +34,49 @@ const MARKER = join(homedir(), ".agents-deck", ".cswap-update-check");
|
|
|
34
34
|
* Re-resolved when a lookup fails so an install during this process is picked
|
|
35
35
|
* up without a restart.
|
|
36
36
|
*/
|
|
37
|
+
/**
|
|
38
|
+
* Every place an installer is known to leave cswap. Pure, and the platform is a
|
|
39
|
+
* parameter, so the Windows list can be checked from a Mac — which is the only
|
|
40
|
+
* way this list stays right, since it exists entirely for machines the author is
|
|
41
|
+
* not sitting at.
|
|
42
|
+
*/
|
|
43
|
+
export function cswapCandidates(platform = process.platform, env = process.env, home = homedir()) {
|
|
44
|
+
// The path flavour follows the PLATFORM ARGUMENT, not the host: node's `join`
|
|
45
|
+
// would emit forward slashes when this is exercised from a Mac, which is both
|
|
46
|
+
// wrong for the caller and invisible in a test.
|
|
47
|
+
const { join } = platform === "win32" ? winPath : posixPath;
|
|
48
|
+
const exe = platform === "win32" ? "cswap.exe" : "cswap";
|
|
49
|
+
const dirs = [];
|
|
50
|
+
// Explicit configuration first: someone who set these means them.
|
|
51
|
+
if (env.UV_TOOL_BIN_DIR) dirs.push(env.UV_TOOL_BIN_DIR);
|
|
52
|
+
if (env.XDG_BIN_HOME) dirs.push(env.XDG_BIN_HOME);
|
|
53
|
+
// Where `uv tool install` and `pipx install` put executables, everywhere.
|
|
54
|
+
dirs.push(join(home, ".local", "bin"));
|
|
55
|
+
if (platform === "win32") {
|
|
56
|
+
// pipx before 1.5, and any `pip install --user`. APPDATA is respected when
|
|
57
|
+
// set because a roaming profile moves it off the home directory.
|
|
58
|
+
dirs.push(join(env.APPDATA || join(home, "AppData", "Roaming"), "Python", "Scripts"));
|
|
59
|
+
dirs.push(join(env.LOCALAPPDATA || join(home, "AppData", "Local"), "Programs", "Python", "Scripts"));
|
|
60
|
+
dirs.push(join(home, "scoop", "shims"));
|
|
61
|
+
} else {
|
|
62
|
+
dirs.push(join(home, ".pyenv", "shims"));
|
|
63
|
+
// uv keeps the tool's own venv here and only symlinks into ~/.local/bin; if
|
|
64
|
+
// that link was never made, this is still a working executable.
|
|
65
|
+
dirs.push(join(home, ".local", "share", "uv", "tools", "claude-swap", "bin"));
|
|
66
|
+
dirs.push("/opt/homebrew/bin", "/usr/local/bin");
|
|
67
|
+
}
|
|
68
|
+
return dirs.map(d => join(d, exe));
|
|
69
|
+
}
|
|
70
|
+
|
|
37
71
|
let _bin = null;
|
|
38
72
|
export async function cswapBin() {
|
|
73
|
+
// An explicit path wins over everything and is never cached away — someone
|
|
74
|
+
// debugging a bad resolution needs it to take effect immediately.
|
|
75
|
+
if (process.env.AGENTS_DECK_CSWAP) return process.env.AGENTS_DECK_CSWAP;
|
|
39
76
|
if (_bin) return _bin;
|
|
40
77
|
if ((await run("cswap", ["--version"], { timeout: 8_000 })).ok) return (_bin = "cswap");
|
|
41
78
|
|
|
42
|
-
const
|
|
43
|
-
const candidates = [
|
|
44
|
-
join(homedir(), ".local", "bin", exe),
|
|
45
|
-
// pipx before 1.5 on Windows, and any pip --user install.
|
|
46
|
-
process.platform === "win32"
|
|
47
|
-
? join(homedir(), "AppData", "Roaming", "Python", "Scripts", exe)
|
|
48
|
-
: join(homedir(), ".pyenv", "shims", exe),
|
|
49
|
-
];
|
|
50
|
-
for (const c of candidates) {
|
|
79
|
+
for (const c of cswapCandidates()) {
|
|
51
80
|
if (existsSync(c) && (await run(c, ["--version"], { timeout: 8_000 })).ok) return (_bin = c);
|
|
52
81
|
}
|
|
53
82
|
return "cswap"; // not found; leave the bare name so errors read sensibly
|
package/src/server/exec.mjs
CHANGED
|
@@ -66,6 +66,26 @@ export const tryNext = (err) =>
|
|
|
66
66
|
Boolean(err) && (err.code === "ENOENT" || err.code === "EACCES" ||
|
|
67
67
|
err.code === "EINVAL" || err.code === "UNKNOWN");
|
|
68
68
|
|
|
69
|
+
/**
|
|
70
|
+
* cmd.exe's way of saying ENOENT.
|
|
71
|
+
*
|
|
72
|
+
* A .cmd or .bat candidate is launched THROUGH cmd.exe, and cmd.exe exists — so
|
|
73
|
+
* a missing tool is not a spawn error at all. It is a healthy shell exiting 1
|
|
74
|
+
* after printing two lines:
|
|
75
|
+
*
|
|
76
|
+
* 'cswap' is not recognized as an internal or external command,
|
|
77
|
+
* operable program or batch file.
|
|
78
|
+
*
|
|
79
|
+
* Read as a real failure, that stops the candidate loop early AND puts the
|
|
80
|
+
* second line — on its own, meaningless — in front of the user. Reported from
|
|
81
|
+
* Windows on 2026-08-14: the accounts panel said only "operable program or
|
|
82
|
+
* batch file." when sharing an account.
|
|
83
|
+
*/
|
|
84
|
+
export const looksMissing = (text) =>
|
|
85
|
+
/is not recognized as an internal or external command/i.test(String(text ?? "")) ||
|
|
86
|
+
/operable program or batch file/i.test(String(text ?? "")) ||
|
|
87
|
+
/The system cannot find the (?:path|file) specified/i.test(String(text ?? ""));
|
|
88
|
+
|
|
69
89
|
/**
|
|
70
90
|
* Run a command and collect its output. Never rejects, and never throws —
|
|
71
91
|
* failures come back as `{ ok: false }`, because every caller here is a poll or
|
|
@@ -86,11 +106,16 @@ export function run(cmd, args, { timeout = 20_000, maxBuffer = 4 << 20 } = {}) {
|
|
|
86
106
|
: { file: raw, args, opts: {} };
|
|
87
107
|
|
|
88
108
|
const done = (err, stdout, stderr) => {
|
|
89
|
-
|
|
109
|
+
// cmd.exe's "is not recognized" counts as "not this spelling" too, and
|
|
110
|
+
// it arrives as a normal non-zero exit rather than a spawn error.
|
|
111
|
+
const missing = Boolean(err) && looksMissing(`${stderr ?? ""}\n${stdout ?? ""}`);
|
|
112
|
+
if (err && (tryNext(err) || missing) && i + 1 < tries.length) return attempt(i + 1);
|
|
90
113
|
if (!err) resolved.set(cmd, raw);
|
|
91
114
|
resolve({
|
|
92
115
|
ok: !err,
|
|
93
|
-
|
|
116
|
+
// A tool cmd.exe could not find is missing, not "exited 1" — callers
|
|
117
|
+
// key their message off this.
|
|
118
|
+
code: missing ? "ENOENT" : (err?.code ?? 0),
|
|
94
119
|
killed: Boolean(err?.killed),
|
|
95
120
|
stdout: String(stdout ?? ""),
|
|
96
121
|
stderr: String(stderr ?? ""),
|
|
@@ -191,7 +216,19 @@ export function runInteractive(cmd, args, { timeout = 300_000, maxOutput = 256 <
|
|
|
191
216
|
const keep = (buf, text) => (buf + text).slice(-maxOutput);
|
|
192
217
|
child.stdout?.on("data", (d) => { const t = String(d); stdout = keep(stdout, t); emitLines(t); });
|
|
193
218
|
child.stderr?.on("data", (d) => { const t = String(d); stderr = keep(stderr, t); emitLines(t); });
|
|
194
|
-
child.on("close", (code) =>
|
|
219
|
+
child.on("close", (code) => {
|
|
220
|
+
// Same cmd.exe case as in `run`: exit 1 with "is not recognized" means
|
|
221
|
+
// this spelling does not exist, not that the tool failed.
|
|
222
|
+
if (code !== 0 && looksMissing(`${stderr}\n${stdout}`)) {
|
|
223
|
+
if (i + 1 < tries.length) {
|
|
224
|
+
stdout = ""; stderr = ""; pending = "";
|
|
225
|
+
child = null;
|
|
226
|
+
return attempt(i + 1);
|
|
227
|
+
}
|
|
228
|
+
return finish(-1, { code: "ENOENT" });
|
|
229
|
+
}
|
|
230
|
+
finish(code ?? -1, null);
|
|
231
|
+
});
|
|
195
232
|
};
|
|
196
233
|
attempt(0);
|
|
197
234
|
|
package/src/server/index.mjs
CHANGED
|
@@ -938,7 +938,11 @@ async function serveStatic(req, res, url) {
|
|
|
938
938
|
// SPA fallback to index.html for client-side routes
|
|
939
939
|
try {
|
|
940
940
|
const idx = await readFile(join(WEB_DIST, "index.html"));
|
|
941
|
-
|
|
941
|
+
// Same no-cache as the normal path above, which this used to omit. It
|
|
942
|
+
// matters more since the deck upgrades itself: index.html is the file
|
|
943
|
+
// naming the hashed bundle, so a heuristically-cached copy sends the tab
|
|
944
|
+
// back to the OLD assets after an update and the reload achieves nothing.
|
|
945
|
+
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-cache" });
|
|
942
946
|
res.end(idx);
|
|
943
947
|
} catch {
|
|
944
948
|
send(res, 404, { error: "ui not built. run `pnpm build` or `npm run build`." });
|
|
@@ -1041,17 +1045,39 @@ async function handleUpgrade(_req, res) {
|
|
|
1041
1045
|
|
|
1042
1046
|
// Restart is a two-party act: this half answers before it stops listening, so
|
|
1043
1047
|
// the caller learns it was accepted rather than losing the socket mid-reply.
|
|
1044
|
-
|
|
1048
|
+
//
|
|
1049
|
+
// `{ upgrade: true }` asks for the npx variant: come back through
|
|
1050
|
+
// `npx -y <spec>@latest` instead of re-running the files already here. Granted
|
|
1051
|
+
// only where that is genuinely how this copy updates — the mode is decided from
|
|
1052
|
+
// the install on disk, never from the request.
|
|
1053
|
+
async function handleRestart(req, res) {
|
|
1045
1054
|
if (!_onRestart) return send(res, 501, { ok: false, reason: "unsupervised" });
|
|
1046
1055
|
// Enforced here and not only in the UI. Under --no-persist a restart destroys
|
|
1047
1056
|
// the whole canvas — replayLog has no file to read — and a destructive act
|
|
1048
1057
|
// must not be prevented by a hidden button alone.
|
|
1049
1058
|
if (!_canRestart) return send(res, 409, { ok: false, reason: "no_persist" });
|
|
1059
|
+
|
|
1060
|
+
let wantUpgrade = false;
|
|
1061
|
+
if (req.method === "POST") {
|
|
1062
|
+
const body = await readBody(req).catch(() => null);
|
|
1063
|
+
try { wantUpgrade = JSON.parse(body ?? "")?.upgrade === true; } catch { /* a plain restart */ }
|
|
1064
|
+
}
|
|
1065
|
+
let mode = null;
|
|
1066
|
+
if (wantUpgrade) {
|
|
1067
|
+
const { upgradeBlock, upgradeMode, npxRestartSpec } = await import(
|
|
1068
|
+
pathToFileURL(join(PKG_ROOT, "src/server/self-update.mjs")).href
|
|
1069
|
+
);
|
|
1070
|
+
if (upgradeMode(upgradeBlock(PKG_ROOT)) !== "npx" || !npxRestartSpec(PKG_ROOT)) {
|
|
1071
|
+
return send(res, 409, { ok: false, reason: "not_npx" });
|
|
1072
|
+
}
|
|
1073
|
+
mode = "npx";
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1050
1076
|
if (_restarting) return send(res, 202, { ok: true, already: true });
|
|
1051
1077
|
_restarting = true;
|
|
1052
|
-
send(res, 200, { ok: true });
|
|
1078
|
+
send(res, 200, { ok: true, mode });
|
|
1053
1079
|
// Let the response flush before the listener goes away.
|
|
1054
|
-
setTimeout(() => { try { _onRestart(); } catch { _restarting = false; } }, 120).unref();
|
|
1080
|
+
setTimeout(() => { try { _onRestart(mode); } catch { _restarting = false; } }, 120).unref();
|
|
1055
1081
|
}
|
|
1056
1082
|
|
|
1057
1083
|
async function handleQuota(req, res) {
|
|
@@ -1311,7 +1337,7 @@ export async function startServer({ port = 4317, host = "127.0.0.1", persist = n
|
|
|
1311
1337
|
if (req.method === "GET" && url.pathname === "/events") return handleSse(req, res);
|
|
1312
1338
|
if (req.method === "GET" && url.pathname === "/api/version") return guard(handleVersion(req, res), res);
|
|
1313
1339
|
if (req.method === "POST" && url.pathname === "/api/upgrade") return guard(handleUpgrade(req, res), res);
|
|
1314
|
-
if (req.method === "POST" && url.pathname === "/api/restart") return handleRestart(req, res);
|
|
1340
|
+
if (req.method === "POST" && url.pathname === "/api/restart") return guard(handleRestart(req, res), res);
|
|
1315
1341
|
if (req.method === "GET" && url.pathname === "/api/quota") return guard(handleQuota(req, res), res);
|
|
1316
1342
|
if (req.method === "GET" && url.pathname === "/api/codex-usage") return guard(handleCodexUsage(req, res), res);
|
|
1317
1343
|
if (req.method === "GET" && url.pathname === "/api/codex-quota") return guard(handleCodexQuota(req, res), res);
|
|
@@ -83,12 +83,65 @@ export function isGitCheckout(pkgRoot) {
|
|
|
83
83
|
try { return existsSync(join(pkgRoot, ".git")); } catch { return false; }
|
|
84
84
|
}
|
|
85
85
|
|
|
86
|
+
// ── npx ──────────────────────────────────────────────────────────────────────
|
|
87
|
+
//
|
|
88
|
+
// An npx run lives in ~/.npm/_npx/<hash>/node_modules/<pkg>. The hash is over
|
|
89
|
+
// the SPEC the user typed, so upgrading means fetching a different directory —
|
|
90
|
+
// there is nothing to install over. What there IS, is the spec itself: npm
|
|
91
|
+
// writes it into <hash>/package.json as `_npx.packages`, which is the only
|
|
92
|
+
// record of whether the user typed `ccdeck`, `agent-dag` or `agents-deck`.
|
|
93
|
+
// Re-running the wrong one would work but would leave them on a package they
|
|
94
|
+
// never asked for, so it is worth reading rather than guessing.
|
|
95
|
+
|
|
96
|
+
/** The `_npx/<hash>` directory this package was unpacked into, or null. Pure —
|
|
97
|
+
* path arithmetic only, so both platforms' separators can be tested. */
|
|
98
|
+
export function npxRoot(pkgRoot) {
|
|
99
|
+
if (typeof pkgRoot !== "string") return null;
|
|
100
|
+
const parts = pkgRoot.split(/[\\/]/);
|
|
101
|
+
const i = parts.lastIndexOf("_npx");
|
|
102
|
+
if (i === -1 || i + 1 >= parts.length) return null;
|
|
103
|
+
// Keep the separator the input used: a Windows path must come back as one.
|
|
104
|
+
const sep = pkgRoot.includes("\\") && !pkgRoot.includes("/") ? "\\" : "/";
|
|
105
|
+
return parts.slice(0, i + 2).join(sep);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Package name out of an npm spec, scope intact: `ccdeck@1.2.3` → `ccdeck`,
|
|
109
|
+
* `@scope/pkg` → `@scope/pkg`. Null for anything that is not a plain name —
|
|
110
|
+
* a tarball URL or a git spec is not something to re-run with `@latest`. */
|
|
111
|
+
export function bareSpecName(spec) {
|
|
112
|
+
if (typeof spec !== "string") return null;
|
|
113
|
+
const s = spec.trim();
|
|
114
|
+
if (!s) return null;
|
|
115
|
+
const at = s.lastIndexOf("@");
|
|
116
|
+
const name = at > 0 ? s.slice(0, at) : s;
|
|
117
|
+
return /^@?[a-z0-9][a-z0-9._-]*(\/[a-z0-9][a-z0-9._-]*)?$/i.test(name) ? name : null;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** What to hand `npx -y`, read from the cache directory's own metadata. */
|
|
121
|
+
export function npxSpecFromMeta(meta, fallback = "agents-deck") {
|
|
122
|
+
const list = meta && meta._npx && Array.isArray(meta._npx.packages) ? meta._npx.packages : [];
|
|
123
|
+
for (const entry of list) {
|
|
124
|
+
const name = bareSpecName(entry);
|
|
125
|
+
if (name) return `${name}@latest`;
|
|
126
|
+
}
|
|
127
|
+
return `${fallback}@latest`;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** The same, answered against the filesystem. Null when this is not an npx run. */
|
|
131
|
+
export function npxRestartSpec(pkgRoot, name = "agents-deck") {
|
|
132
|
+
const root = npxRoot(pkgRoot);
|
|
133
|
+
if (!root) return null;
|
|
134
|
+
let meta = null;
|
|
135
|
+
try { meta = JSON.parse(readFileSync(join(root, "package.json"), "utf8")); } catch { /* fall back to the name */ }
|
|
136
|
+
return npxSpecFromMeta(meta, name);
|
|
137
|
+
}
|
|
138
|
+
|
|
86
139
|
/** The exact line the user can paste, for the way THIS copy was installed. */
|
|
87
140
|
export function upgradeCommand(pkgRoot, name = "agents-deck") {
|
|
88
141
|
// A checkout is updated by pulling, and the bundle is built, not shipped —
|
|
89
142
|
// so `npm run build` is part of the answer rather than an afterthought.
|
|
90
143
|
if (isGitCheckout(pkgRoot)) return "git pull && npm run build";
|
|
91
|
-
if (isNpxInstall(pkgRoot)) return `npx -y ${name}@latest`;
|
|
144
|
+
if (isNpxInstall(pkgRoot)) return `npx -y ${npxRestartSpec(pkgRoot, name) ?? `${name}@latest`}`;
|
|
92
145
|
return `npm i -g ${name}@latest`;
|
|
93
146
|
}
|
|
94
147
|
|
|
@@ -192,6 +245,23 @@ export function upgradeBlockedReason({ git, npx, writable, optedOut }) {
|
|
|
192
245
|
return null;
|
|
193
246
|
}
|
|
194
247
|
|
|
248
|
+
/**
|
|
249
|
+
* How this copy can update itself, if it can at all.
|
|
250
|
+
*
|
|
251
|
+
* "install" — `npm i -g` here and restart into the new files.
|
|
252
|
+
* "npx" — nothing to install: the supervisor re-runs `npx -y <spec>`,
|
|
253
|
+
* which fetches a NEW cache directory and hands the port to it.
|
|
254
|
+
* null — a checkout, an unwritable prefix, or an explicit opt-out; the
|
|
255
|
+
* user gets the command and does it themselves.
|
|
256
|
+
*
|
|
257
|
+
* Pure, so the policy is one readable expression rather than three conditions
|
|
258
|
+
* spread across the server and the UI.
|
|
259
|
+
*/
|
|
260
|
+
export function upgradeMode(blockedReason) {
|
|
261
|
+
if (blockedReason === null || blockedReason === undefined) return "install";
|
|
262
|
+
return blockedReason === "npx" ? "npx" : null;
|
|
263
|
+
}
|
|
264
|
+
|
|
195
265
|
function dirWritable(p) {
|
|
196
266
|
try { accessSync(p, FS.W_OK); return true; } catch { return false; }
|
|
197
267
|
}
|
|
@@ -296,6 +366,7 @@ export async function versionReport({ running, pkgRoot, name = "agents-deck", no
|
|
|
296
366
|
process.env.AGENTS_DECK_NO_UPDATE_CHECK === "1" ||
|
|
297
367
|
process.env.AGENTS_DECK_NO_INSTALL === "1";
|
|
298
368
|
const latest = skipRegistry ? null : await latestOnNpm(name, now);
|
|
369
|
+
const blocked = upgradeBlock(pkgRoot);
|
|
299
370
|
return {
|
|
300
371
|
name,
|
|
301
372
|
running: running ?? null,
|
|
@@ -303,9 +374,11 @@ export async function versionReport({ running, pkgRoot, name = "agents-deck", no
|
|
|
303
374
|
latest,
|
|
304
375
|
notice: pickNotice({ running, installed, latest }),
|
|
305
376
|
command: upgradeCommand(pkgRoot, name),
|
|
306
|
-
// Why
|
|
307
|
-
// instead of leaving a gap the user has to guess about.
|
|
308
|
-
|
|
377
|
+
// Why an in-app `npm i -g` is refused, when it is — so the UI can say so
|
|
378
|
+
// instead of leaving a gap the user has to guess about. "npx" is a refusal
|
|
379
|
+
// of the install, not of the update: upgradeMode says so.
|
|
380
|
+
upgradeBlocked: blocked,
|
|
381
|
+
upgradeMode: upgradeMode(blocked),
|
|
309
382
|
upgrade: upgradeStatus(),
|
|
310
383
|
};
|
|
311
384
|
}
|