agent-dag 1.33.2 → 1.33.4

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.
@@ -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-D4UiP6Xm.js"></script>
8
+ <script type="module" crossorigin src="/assets/index-CUIo58CG.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.2",
3
+ "version": "1.33.4",
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
- async function cswapBin() {
69
- return process.env.AGENTS_DECK_CSWAP ?? "cswap";
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" : firstUseful(r.stderr || r.stdout) || "sign-in ended without a code";
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" : firstUseful(r.stderr || r.stdout) || "the code was not accepted";
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: firstUseful(r.stderr || r.stdout) };
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
  }
@@ -385,12 +390,18 @@ export async function importAccount(blob) {
385
390
  const before = await readStore();
386
391
  const child = runInteractive(await cswapBin(), ["import", "-"], { timeout: CSWAP_TIMEOUT_MS });
387
392
  child.write(un.payload);
388
- try { child.write(""); } catch { /* best effort */ }
389
393
  // cswap reads stdin to EOF, so the pipe has to close for it to proceed.
390
- endStdin(child);
394
+ //
395
+ // This used to write a raw EOT byte and then call `endStdin(child)` — a
396
+ // helper that was never written. EOT only means end-of-file on a TTY, so
397
+ // the byte did nothing to a pipe, and the call threw ReferenceError before
398
+ // cswap ever saw the payload: the route answered 500 and the dialog fell
399
+ // back to "the import failed", which is exactly what a genuinely refused
400
+ // import says. Reported 2026-08-14.
401
+ child.end();
391
402
 
392
403
  const r = await child.done;
393
- if (!r.ok) return { ok: false, reason: "import_failed", detail: firstUseful(r.stderr || r.stdout) };
404
+ if (!r.ok) return { ok: false, reason: "import_failed", detail: failureText(r, "cswap import") };
394
405
 
395
406
  const after = await readStore();
396
407
  const slot = newSlot(before, after);
@@ -429,7 +440,7 @@ export async function removeAccount(num) {
429
440
  });
430
441
  const r = await child.done;
431
442
  invalidateClaudeAccountsCache();
432
- if (!r.ok) return { ok: false, reason: "remove_failed", detail: firstUseful(r.stderr || r.stdout) };
443
+ if (!r.ok) return { ok: false, reason: "remove_failed", detail: failureText(r, "cswap remove") };
433
444
  // Exit 0 without the prompt means cswap declined for its own reason — a
434
445
  // live session on that account, most often — and printed why.
435
446
  if (!answered) return { ok: false, reason: "not_confirmed", detail: firstUseful(r.stdout || r.stderr) };
@@ -447,7 +458,7 @@ export async function setAlias(num, alias) {
447
458
  invalidateClaudeAccountsCache();
448
459
  return r.ok
449
460
  ? { ok: true, output: firstUseful(r.stdout) }
450
- : { ok: false, reason: "alias_failed", detail: firstUseful(r.stderr || r.stdout) };
461
+ : { ok: false, reason: "alias_failed", detail: failureText(r, "cswap alias") };
451
462
  });
452
463
  }
453
464
 
@@ -460,12 +471,31 @@ export async function moveAccount(num, slot) {
460
471
  invalidateClaudeAccountsCache();
461
472
  return r.ok
462
473
  ? { ok: true, output: firstUseful(r.stdout) }
463
- : { ok: false, reason: "move_failed", detail: firstUseful(r.stderr || r.stdout) };
474
+ : { ok: false, reason: "move_failed", detail: failureText(r, "cswap move") };
464
475
  });
465
476
  }
466
477
 
467
478
  // ── text ─────────────────────────────────────────────────────────────────────
468
479
 
480
+ /**
481
+ * What went wrong, in a sentence the user can act on.
482
+ *
483
+ * The missing-tool case is singled out because its own output is useless: on
484
+ * Windows it is cmd.exe's two-line "is not recognized …/operable program or
485
+ * batch file.", and `firstUseful` — which takes the LAST line, correctly for
486
+ * every other CLI — leaves the second half on screen by itself.
487
+ */
488
+ export function failureText(r, what = "cswap") {
489
+ const out = `${r?.stderr ?? ""}\n${r?.stdout ?? ""}`;
490
+ const tool = String(what).split(" ")[0];
491
+ if (r?.code === "ENOENT" || looksMissing(out)) {
492
+ return tool === "claude"
493
+ ? "the claude CLI could not be run: not on PATH. Set AGENTS_DECK_CLAUDE to its full path."
494
+ : "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.";
495
+ }
496
+ return firstUseful(out) || `${what} exited ${r?.code}`;
497
+ }
498
+
469
499
  /** The line worth showing a user out of a CLI's output. */
470
500
  export function firstUseful(text) {
471
501
  const lines = stripTerminalEscapes(text)
@@ -488,7 +518,7 @@ export function addFailureText(r) {
488
518
  if (/keychain/i.test(text)) {
489
519
  return `${text} — start agents-deck from a Terminal window rather than a background service.`;
490
520
  }
491
- return text || `cswap add exited ${r.code}`;
521
+ return failureText(r, "cswap add");
492
522
  }
493
523
 
494
524
  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 exe = process.platform === "win32" ? "cswap.exe" : "cswap";
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
@@ -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
- if (err && tryNext(err) && i + 1 < tries.length) return attempt(i + 1);
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
- code: err?.code ?? 0,
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) => finish(code ?? -1, null));
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
 
@@ -1325,7 +1325,13 @@ export async function startServer({ port = 4317, host = "127.0.0.1", persist = n
1325
1325
  // background quota poll hit a network error. Answer the request instead.
1326
1326
  const guard = (p, res) => Promise.resolve(p).catch(err => {
1327
1327
  console.error("agents-deck: request handler failed:", err?.message ?? err);
1328
- if (!res.headersSent) send(res, 500, { error: "internal error" });
1328
+ // The message goes to the browser too. This server binds 127.0.0.1 and its
1329
+ // only client is the user's own tab, so the usual reason to withhold it
1330
+ // does not apply — while withholding it is how a ReferenceError in the
1331
+ // import path spent a release looking like a rejected share.
1332
+ if (!res.headersSent) {
1333
+ send(res, 500, { error: "internal error", detail: String(err?.message ?? err).slice(0, 300) });
1334
+ }
1329
1335
  else res.end();
1330
1336
  });
1331
1337