agent-dag 1.29.1 → 1.30.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.
@@ -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-D9BvUKYs.js"></script>
8
+ <script type="module" crossorigin src="/assets/index-CgugXiGZ.js"></script>
9
9
  <link rel="stylesheet" crossorigin href="/assets/index-BLOOP_Fy.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.29.1",
3
+ "version": "1.30.0",
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": {
@@ -13,10 +13,11 @@
13
13
  // means the deck cannot 429 the user's account, cannot burn a refresh token,
14
14
  // and cannot lose a login. Switching shells out to `cswap` rather than
15
15
  // reimplementing the lock protocol its correctness depends on.
16
- import { readFile } from "node:fs/promises";
16
+ import { readFile, writeFile, mkdir } from "node:fs/promises";
17
17
  import { cswapBin, cswapVersion, installHint } from "./cswap-install.mjs";
18
18
  import { run, runDetached } from "./exec.mjs";
19
- import { join } from "node:path";
19
+ import { dirname, join } from "node:path";
20
+ import { existsSync } from "node:fs";
20
21
  import { homedir, platform } from "node:os";
21
22
 
22
23
  // claude-swap keeps its store under XDG on Linux and in the home directory
@@ -220,3 +221,79 @@ export function switchClaudeAccount(accountNum) {
220
221
  return { ok: false, reason, output: (r.stderr || r.stdout).trim().slice(0, 500) };
221
222
  });
222
223
  }
224
+
225
+
226
+ /**
227
+ * Register the account already signed in, the first time and only the first
228
+ * time.
229
+ *
230
+ * A fresh install leaves claude-swap with an empty store, so the panel comes up
231
+ * saying "no accounts added yet" and telling the user to run `cswap add`
232
+ * themselves — for the account they are already using, on the machine they are
233
+ * already on. Running it for them is the difference between the panel working
234
+ * and the panel being a to-do item.
235
+ *
236
+ * `cswap add` takes no arguments, prompts for nothing, and does not sign
237
+ * anyone in: it records the Claude Code session that already exists. Even so it
238
+ * is bounded tightly, because it writes to a credential store:
239
+ *
240
+ * - only when the store holds no accounts at all, so nothing can be
241
+ * overwritten or reordered;
242
+ * - only once ever, marked on disk, so a user who deliberately removes their
243
+ * last account does not get it added back on the next launch;
244
+ * - never when AGENTS_DECK_NO_INSTALL=1.
245
+ *
246
+ * Failure is normal and quiet: on a machine where Claude Code has never signed
247
+ * in there is nothing to record.
248
+ */
249
+ const SEED_MARKER = join(homedir(), ".agents-deck", ".cswap-seeded");
250
+
251
+ /**
252
+ * How many accounts a sequence.json holds.
253
+ *
254
+ * claude-swap writes `accounts` as an object keyed by slot number — {"2": {…},
255
+ * "3": {…}} — not as a list. An Array.isArray guard here read that as "no
256
+ * accounts" and ran `cswap add` against a populated store, which is exactly
257
+ * what the guard existed to prevent. Both shapes are accepted now, and
258
+ * anything unrecognised counts as -1: unknown is not the same as empty, and
259
+ * only a confident zero may lead to a write.
260
+ */
261
+ export function accountCount(seq) {
262
+ const a = seq?.accounts;
263
+ if (Array.isArray(a)) return a.length;
264
+ if (a && typeof a === "object") return Object.keys(a).length;
265
+ if (a == null && seq && typeof seq === "object") return 0; // store exists, no accounts yet
266
+ return -1; // unreadable — do nothing
267
+ }
268
+
269
+ export async function seedFirstAccount() {
270
+ if (process.env.AGENTS_DECK_NO_INSTALL === "1") return { state: "skipped" };
271
+ if (existsSync(SEED_MARKER)) return { state: "already-tried" };
272
+
273
+ // Empty store only. A store with accounts in it is the user's, not ours, and
274
+ // a store we cannot parse is treated the same way.
275
+ // No file at all is the fresh install this exists for; a file that will not
276
+ // parse is a store in an unknown state. readJson answers null to both, so
277
+ // they are told apart before deciding, because they call for opposite
278
+ // decisions — seed, and keep well away.
279
+ const seqPath = join(backupRoot(), "sequence.json");
280
+ const before = existsSync(seqPath) ? accountCount(await readJson(seqPath)) : 0;
281
+ if (before > 0) return { state: "has-accounts", count: before };
282
+ if (before < 0) return { state: "unreadable-store" };
283
+
284
+ // Mark before running, not after: if `cswap add` half-succeeds or the process
285
+ // dies mid-way, the retry-forever loop is the worse outcome.
286
+ try {
287
+ await mkdir(dirname(SEED_MARKER), { recursive: true });
288
+ await writeFile(SEED_MARKER, new Date().toISOString());
289
+ } catch { /* best-effort — worst case it is attempted again */ }
290
+
291
+ const r = await run(await cswapBin(), ["add"], { timeout: 60_000 });
292
+ if (!r.ok) {
293
+ return { state: "failed", detail: (r.stderr || r.stdout).trim().slice(0, 200) };
294
+ }
295
+
296
+ invalidateClaudeAccountsCache();
297
+ const count = existsSync(seqPath) ? accountCount(await readJson(seqPath)) : 0;
298
+ return count > 0 ? { state: "added", count } : { state: "nothing-to-add" };
299
+ }
@@ -6,10 +6,18 @@
6
6
  // ENOENT on Windows even though the tool is installed and on PATH — which
7
7
  // looks exactly like "not installed" and is why this is worth a module.
8
8
  //
9
- // The alternative, `shell: true`, would work but concatenates arguments into a
10
- // command line instead of passing them as a vector: an argument containing a
11
- // quote or an ampersand stops being an argument. Resolving the extension
12
- // ourselves keeps the argument vector intact.
9
+ // Resolving the extension ourselves keeps the argument vector intact, which
10
+ // blanket `shell: true` would not: it concatenates arguments into a command
11
+ // line, so an argument containing a quote or an ampersand stops being an
12
+ // argument.
13
+ //
14
+ // The exception is .cmd and .bat, which since Node 20.12 CANNOT be spawned
15
+ // without a shell at all — the fix for CVE-2024-27980 makes that throw EINVAL,
16
+ // synchronously, from inside execFile. Those are routed through cmd.exe the
17
+ // same way Node's own `shell: true` does it, with the arguments quoted here
18
+ // rather than pasted together. Getting this wrong is not a degraded feature:
19
+ // the throw escaped the retry path and took the whole process down on Windows
20
+ // before the server ever started.
13
21
  import { execFile, spawn } from "node:child_process";
14
22
 
15
23
  // Extensions Windows will execute, most specific first. `.com` is omitted —
@@ -28,29 +36,75 @@ function candidates(cmd) {
28
36
  return known ? [known] : WIN_EXTS.map(ext => cmd + ext);
29
37
  }
30
38
 
31
- const isMissing = (err) => err && (err.code === "ENOENT" || err.code === "EACCES");
39
+ /** Exported for tests: the platform is a parameter so both can be checked. */
40
+ export const isBatch = (file, platform = process.platform) =>
41
+ platform === "win32" && /\.(cmd|bat)$/i.test(file);
32
42
 
33
43
  /**
34
- * Run a command and collect its output. Never rejects — failures come back as
35
- * `{ ok: false }`, because every caller here is a poll or a UI action where a
36
- * missing tool is an expected state rather than an exception.
44
+ * Rewrite a batch-file invocation as a cmd.exe one.
45
+ *
46
+ * Mirrors what Node does internally for `shell: true` on Windows — comspec,
47
+ * /d /s /c, the whole command line as a single quoted argument, and
48
+ * windowsVerbatimArguments so Node does not quote it a second time. Each
49
+ * argument is quoted here, with embedded quotes doubled, which is the escape
50
+ * cmd.exe understands inside a quoted string.
51
+ */
52
+ export function viaCmd(file, args) {
53
+ const q = (s) => `"${String(s).replace(/"/g, '""')}"`;
54
+ const line = [file, ...args].map(q).join(" ");
55
+ return {
56
+ file: process.env.comspec || process.env.ComSpec || "cmd.exe",
57
+ args: ["/d", "/s", "/c", `"${line}"`],
58
+ opts: { windowsVerbatimArguments: true },
59
+ };
60
+ }
61
+
62
+ // Reasons to try the next candidate spelling rather than give up. EINVAL and
63
+ // UNKNOWN show up on Windows for a file that exists but cannot be executed the
64
+ // way it was asked for; both mean "not this one", not "no such tool".
65
+ export const tryNext = (err) =>
66
+ Boolean(err) && (err.code === "ENOENT" || err.code === "EACCES" ||
67
+ err.code === "EINVAL" || err.code === "UNKNOWN");
68
+
69
+ /**
70
+ * Run a command and collect its output. Never rejects, and never throws —
71
+ * failures come back as `{ ok: false }`, because every caller here is a poll or
72
+ * a UI action where a missing tool is an expected state rather than an
73
+ * exception. execFile can throw synchronously on Windows, so the call itself is
74
+ * guarded as well as its callback.
37
75
  */
38
76
  export function run(cmd, args, { timeout = 20_000, maxBuffer = 4 << 20 } = {}) {
39
77
  const tries = candidates(cmd);
40
78
  return new Promise((resolve) => {
41
79
  const attempt = (i) => {
42
- execFile(tries[i], args, { timeout, shell: false, windowsHide: true, maxBuffer },
43
- (err, stdout, stderr) => {
44
- if (err && isMissing(err) && i + 1 < tries.length) return attempt(i + 1);
45
- if (!err) resolved.set(cmd, tries[i]);
46
- resolve({
47
- ok: !err,
48
- code: err?.code ?? 0,
49
- killed: Boolean(err?.killed),
50
- stdout: String(stdout ?? ""),
51
- stderr: String(stderr ?? ""),
52
- });
80
+ if (i >= tries.length) {
81
+ return resolve({ ok: false, code: "ENOENT", killed: false, stdout: "", stderr: "" });
82
+ }
83
+ const raw = tries[i];
84
+ const { file, args: argv, opts } = isBatch(raw)
85
+ ? viaCmd(raw, args)
86
+ : { file: raw, args, opts: {} };
87
+
88
+ const done = (err, stdout, stderr) => {
89
+ if (err && tryNext(err) && i + 1 < tries.length) return attempt(i + 1);
90
+ if (!err) resolved.set(cmd, raw);
91
+ resolve({
92
+ ok: !err,
93
+ code: err?.code ?? 0,
94
+ killed: Boolean(err?.killed),
95
+ stdout: String(stdout ?? ""),
96
+ stderr: String(stderr ?? ""),
53
97
  });
98
+ };
99
+
100
+ try {
101
+ execFile(file, argv, { timeout, shell: false, windowsHide: true, maxBuffer, ...opts }, done);
102
+ } catch (err) {
103
+ // Synchronous throw — the EINVAL case. Same handling as a callback
104
+ // error; letting it propagate here is what crashed the server, because
105
+ // this runs inside the previous attempt's error handler.
106
+ done(err, "", "");
107
+ }
54
108
  };
55
109
  attempt(0);
56
110
  });
@@ -64,15 +118,18 @@ export function run(cmd, args, { timeout = 20_000, maxBuffer = 4 << 20 } = {}) {
64
118
  export function runDetached(cmd, args) {
65
119
  const tries = candidates(cmd);
66
120
  const attempt = (i) => {
121
+ if (i >= tries.length) return;
122
+ const raw = tries[i];
123
+ const { file, args: argv, opts } = isBatch(raw)
124
+ ? viaCmd(raw, args)
125
+ : { file: raw, args, opts: {} };
67
126
  try {
68
- const child = spawn(tries[i], args, { stdio: "ignore", shell: false, windowsHide: true });
69
- child.on("error", (err) => {
70
- if (isMissing(err) && i + 1 < tries.length) attempt(i + 1);
71
- });
72
- child.on("spawn", () => resolved.set(cmd, tries[i]));
127
+ const child = spawn(file, argv, { stdio: "ignore", shell: false, windowsHide: true, ...opts });
128
+ child.on("error", (err) => { if (tryNext(err)) attempt(i + 1); });
129
+ child.on("spawn", () => resolved.set(cmd, raw));
73
130
  child.unref?.();
74
131
  } catch {
75
- if (i + 1 < tries.length) attempt(i + 1);
132
+ attempt(i + 1);
76
133
  }
77
134
  };
78
135
  attempt(0);