agent-dag 1.33.43 → 1.33.45

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-CkZD1gvK.js"></script>
8
+ <script type="module" crossorigin src="/assets/index-BnOcuI0t.js"></script>
9
9
  <link rel="stylesheet" crossorigin href="/assets/index-CF2yG9oG.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.43",
3
+ "version": "1.33.45",
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,8 @@
19
19
  // notably on macOS, where Claude Code keeps credentials in the Keychain
20
20
  // and that file does not exist, so this is the ONLY self-service path
21
21
  // there. It is also the most expensive: a whole Claude Code process per
22
- // poll. On Windows the binary is a .cmd wrapper, so exec() (shell-based)
23
- // is used for correct quoting + stdin.
22
+ // poll. On Windows the binary may be a .cmd wrapper, so exec()
23
+ // (shell-based) is used for correct quoting + stdin.
24
24
  //
25
25
  // 2 and 3 are rate-floored (SELF_POLL_MS) and gated behind the same 429
26
26
  // cooldown; 1 is not, because it is a local file read.
@@ -29,11 +29,10 @@ import { exec } from "node:child_process";
29
29
  import { promisify } from "node:util";
30
30
  import { existsSync } from "node:fs";
31
31
  import { readFile } from "node:fs/promises";
32
- import { join } from "node:path";
33
- import { homedir, platform } from "node:os";
32
+ import { join, posix as posixPath, win32 as winPath } from "node:path";
33
+ import { homedir } from "node:os";
34
34
 
35
35
  const execAsync = promisify(exec);
36
- const IS_WIN = platform() === "win32";
37
36
 
38
37
  const CREDS_PATH = join(homedir(), ".claude", ".credentials.json");
39
38
  const USAGE_URL = "https://api.anthropic.com/api/oauth/usage";
@@ -298,31 +297,65 @@ function parseUsageText(raw) {
298
297
  return Object.keys(result).length > 0 ? result : null;
299
298
  }
300
299
 
300
+ /**
301
+ * Every place the `claude` CLI is known to live, in the order to try them.
302
+ *
303
+ * Pure, and the platform, environment and home directory are parameters, so the
304
+ * Windows list can be checked from a Mac — which is the only way this list stays
305
+ * right, since it exists entirely for machines the author is not sitting at.
306
+ */
307
+ export function quotaClaudeCandidates(platform = process.platform, env = process.env, home = homedir()) {
308
+ // The path flavour follows the PLATFORM ARGUMENT, not the host: node's `join`
309
+ // would emit forward slashes when the Windows list is built on a Mac.
310
+ const { join } = platform === "win32" ? winPath : posixPath;
311
+ if (platform !== "win32") {
312
+ return [
313
+ "claude",
314
+ join(home, ".local", "bin", "claude"),
315
+ "/usr/local/bin/claude",
316
+ "/opt/homebrew/bin/claude",
317
+ ];
318
+ }
319
+ return [
320
+ // The native installer, which ships a bare claude.exe and NO .cmd shim. It
321
+ // was the one install this branch could not reach: the npm path below does
322
+ // not exist on such a machine, and the bare-name fallback used to be spelled
323
+ // `claude.cmd`, which cmd.exe cannot resolve to an .exe — PATHEXT supplies a
324
+ // missing extension, it never substitutes one that is already there.
325
+ join(home, ".local", "bin", "claude.exe"),
326
+ // `npm i -g @anthropic-ai/claude-code`. npm's global prefix is %APPDATA%\npm
327
+ // — Roaming rather than Local, deliberately, since it follows the user
328
+ // between machines — and APPDATA is read from the environment because a
329
+ // roaming profile puts it on a network share, not under the home directory.
330
+ join(env.APPDATA || join(home, "AppData", "Roaming"), "npm", "claude.cmd"),
331
+ // Last resort: the bare name, which cmd.exe resolves through PATH + PATHEXT
332
+ // and so finds claude.exe and claude.cmd alike.
333
+ "claude",
334
+ ];
335
+ }
336
+
301
337
  /** Build the shell command string for `claude --print /usage`.
302
338
  *
303
339
  * We use exec() (shell-based) so cmd.exe / sh processes redirects.
304
340
  * On Windows: `< nul` closes stdin immediately, preventing the 3-second
305
341
  * "no stdin data" wait the claude CLI does when it detects a pipe.
306
342
  * On Unix: `< /dev/null` has the same effect.
343
+ *
344
+ * Exported, with everything it touches injectable, so the Windows branch is
345
+ * testable from the platforms this repo is actually developed on.
307
346
  */
308
- function buildQuotaShellCmd() {
309
- if (IS_WIN) {
310
- const npmBin = join(homedir(), "AppData", "Roaming", "npm", "claude.cmd");
311
- const bin = existsSync(npmBin) ? npmBin : "claude.cmd";
312
- // exec() on Windows uses cmd /c, so < nul redirect works fine.
313
- // Wrap path in quotes in case of spaces in username.
314
- return `"${bin}" --print /usage < nul`;
315
- }
316
- const candidates = [
317
- "claude",
318
- join(homedir(), ".local", "bin", "claude"),
319
- "/usr/local/bin/claude",
320
- "/opt/homebrew/bin/claude",
321
- ];
322
- for (const c of candidates) {
323
- if (!c.includes("/") || existsSync(c)) return `${c} --print /usage < /dev/null`;
324
- }
325
- return "claude --print /usage < /dev/null";
347
+ export function buildQuotaShellCmd(platform = process.platform, env = process.env,
348
+ home = homedir(), exists = existsSync) {
349
+ const win = platform === "win32";
350
+ const sep = win ? "\\" : "/";
351
+ // A bare name is left to the shell's own lookup; a full path is only worth
352
+ // naming when it is actually there.
353
+ const bin = quotaClaudeCandidates(platform, env, home)
354
+ .find(c => !c.includes(sep) || exists(c)) ?? "claude";
355
+ // Quote a path — a username can contain a space — but not a bare name, which
356
+ // cmd.exe resolves more predictably unquoted.
357
+ const quoted = bin.includes(sep) ? `"${bin}"` : bin;
358
+ return `${quoted} --print /usage < ${win ? "nul" : "/dev/null"}`;
326
359
  }
327
360
 
328
361
  export async function fetchClaudeQuota({ force = false } = {}) {
@@ -36,6 +36,13 @@ import { dirname, join, resolve } from "node:path";
36
36
  // is the case where it bites hardest, since npx runs are short-lived and each
37
37
  // one inherits the same stale marker. The request is ~20 bytes.
38
38
  const CHECK_MS = 3600_000;
39
+ // A lookup that failed is not an answer, so it must not spend the hour an
40
+ // answer buys. It still has to spend something: the reason to rate-limit is
41
+ // gone, but a registry that is down would otherwise be asked again on every
42
+ // single poll. Five minutes is the compromise — short enough that a network
43
+ // coming back is noticed while the user is still looking at the deck, long
44
+ // enough that a full npm outage costs a dozen ~20-byte requests an hour.
45
+ const RETRY_MS = 300_000;
39
46
  const FETCH_TIMEOUT_MS = 6_000;
40
47
  // A third marker path: ccusage owns ~/.agents-deck/ccusage/.last-update-check
41
48
  // and cswap owns ~/.agents-deck/.cswap-update-check. Sharing one would make the
@@ -158,38 +165,66 @@ export function upgradeCommand(pkgRoot, name = "agents-deck") {
158
165
 
159
166
  // The dist-tags endpoint answers with ~20 bytes ({"latest":"1.30.7"}); the full
160
167
  // packument is >2 KB and needs parsing we have no use for.
168
+ //
169
+ // Answers `{ ok, version }` rather than a bare string, because "npm says the
170
+ // latest is X" and "npm did not answer" used to arrive here as the same null.
171
+ // `ok` is true only when the registry handed back a usable version — a
172
+ // timeout, a non-200 and a 200 with no `latest` in it are all failures, and
173
+ // the caller has to be able to tell them from an up-to-date deck.
161
174
  async function fetchLatest(name) {
162
175
  try {
163
176
  const res = await fetch(`https://registry.npmjs.org/-/package/${name}/dist-tags`, {
164
177
  headers: { accept: "application/json", "user-agent": "agents-deck" },
165
178
  signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
166
179
  });
167
- if (!res.ok) return null;
180
+ if (!res.ok) return { ok: false, version: null };
168
181
  const v = (await res.json())?.latest;
169
- return typeof v === "string" ? v : null;
182
+ return typeof v === "string" ? { ok: true, version: v } : { ok: false, version: null };
170
183
  } catch {
171
- return null;
184
+ return { ok: false, version: null };
172
185
  }
173
186
  }
174
187
 
175
188
  // The marker carries the answer, not just the timestamp. The existing markers
176
189
  // store only an mtime, which means a restart inside the 24h window forgets what
177
190
  // npm said and shows nothing until the window expires.
191
+ //
192
+ // It carries the outcome too. `at` is when npm last ANSWERED and `version` is
193
+ // what it said; `failedAt` is when the last attempt failed, and is null the
194
+ // moment one succeeds. Keeping them apart is what lets the deck say "checked
195
+ // 3m ago" and "could not reach npm" as the different things they are, instead
196
+ // of reporting a timeout as a fresh, up-to-date check.
178
197
  function readMarker() {
179
198
  try {
180
199
  const m = JSON.parse(readFileSync(MARKER, "utf8"));
181
- return typeof m?.at === "number" ? m : null;
200
+ // Either half is enough to be worth keeping: a marker written by a first
201
+ // attempt that failed has no `at` yet, and markers written before
202
+ // `failedAt` existed have no `failedAt` at all.
203
+ return (typeof m?.at === "number" || typeof m?.failedAt === "number") ? m : null;
182
204
  } catch {
183
205
  return null;
184
206
  }
185
207
  }
186
- function writeMarker(at, version) {
208
+ function writeMarker(marker) {
187
209
  try {
188
210
  mkdirSync(dirname(MARKER), { recursive: true });
189
- writeFileSync(MARKER, JSON.stringify({ at, version: version ?? null }));
211
+ writeFileSync(MARKER, JSON.stringify(marker));
190
212
  } catch { /* a read-only home must not break the deck */ }
191
213
  }
192
214
 
215
+ /** What the marker should hold after an attempt. Pure, because "a failed
216
+ * lookup must not be recorded as a successful one" is precisely the rule this
217
+ * file used to get wrong, and a rule worth a bug is worth a test.
218
+ *
219
+ * A success stamps the hour and clears the failure. A failure records only
220
+ * itself, leaving the last real answer and the time it arrived untouched —
221
+ * the deck keeps showing what it knew, and stops claiming it just confirmed
222
+ * it. */
223
+ export function nextMarker({ prev, now, ok, version }) {
224
+ if (ok) return { at: now, version: version ?? null, failedAt: null };
225
+ return { at: prev?.at ?? null, version: prev?.version ?? null, failedAt: now };
226
+ }
227
+
193
228
  // The marker is shared by every deck on the machine, so one deck's check
194
229
  // silences the others for the rest of the window. Asking once per PROCESS fixes
195
230
  // that, and it lands the check exactly where the user expects the truth: a
@@ -201,8 +236,16 @@ let _askedThisProcess = false;
201
236
  * banner appear" is the question this feature gets asked, and the rule behind
202
237
  * it should be readable in one place.
203
238
  */
204
- export function checkDue({ at, now, first = false, force = false, ttlMs = CHECK_MS }) {
239
+ export function checkDue({ at, failedAt, now, first = false, force = false, ttlMs = CHECK_MS, retryMs = RETRY_MS }) {
205
240
  if (force || first) return true; // explicit ask, or this process's first
241
+ // The last attempt failed, so there is nothing to reuse and the long window
242
+ // does not apply — but the short one does, or an unreachable registry turns
243
+ // every poll into another request. Answered first: `at` here is the older,
244
+ // successful check, and letting it decide would ask again immediately.
245
+ if (typeof failedAt === "number") {
246
+ if (failedAt > now) return true; // clock moved; do not wait it out
247
+ return now - failedAt >= retryMs;
248
+ }
206
249
  if (typeof at !== "number") return true; // never checked
207
250
  if (at > now) return true; // marker from the future: a moved clock
208
251
  return now - at >= ttlMs;
@@ -217,13 +260,16 @@ async function latestOnNpm(name, now, force = false) {
217
260
  const m = readMarker();
218
261
  const first = !_askedThisProcess;
219
262
  _askedThisProcess = true;
220
- if (!checkDue({ at: m?.at, now, first, force })) return m?.version ?? null;
263
+ if (!checkDue({ at: m?.at, failedAt: m?.failedAt, now, first, force })) return m?.version ?? null;
221
264
  if (_inflight) return _inflight;
222
265
  _inflight = fetchLatest(name)
223
- // Stamp before deciding what to keep: a failed lookup must burn the day's
224
- // slot rather than retry on every poll, but it must not erase a version we
225
- // already knew.
226
- .then(v => { writeMarker(now, v ?? m?.version ?? null); return v ?? m?.version ?? null; })
266
+ // Record the outcome, not just the moment. Only an answer stamps `at`; a
267
+ // failure takes the short retry window instead of the hour, and keeps the
268
+ // version we already knew rather than erasing it.
269
+ .then(({ ok, version }) => {
270
+ writeMarker(nextMarker({ prev: m, now, ok, version }));
271
+ return (ok ? version : null) ?? m?.version ?? null;
272
+ })
227
273
  .catch(() => m?.version ?? null)
228
274
  .finally(() => { _inflight = null; });
229
275
  return _inflight;
@@ -398,15 +444,22 @@ export async function versionReport({ running, pkgRoot, name = "agents-deck", no
398
444
  process.env.AGENTS_DECK_NO_UPDATE_CHECK === "1" ||
399
445
  process.env.AGENTS_DECK_NO_INSTALL === "1";
400
446
  const latest = skipRegistry ? null : await latestOnNpm(name, now, force);
447
+ const marker = skipRegistry ? null : readMarker();
401
448
  const blocked = upgradeBlock(pkgRoot);
402
449
  return {
403
450
  name,
404
451
  running: running ?? null,
405
452
  installed,
406
453
  latest,
407
- // When npm was last asked, so the UI can say it rather than leaving the
408
- // user to wonder whether the check runs at all.
409
- checkedAt: skipRegistry ? null : (readMarker()?.at ?? null),
454
+ // When npm last ANSWERED, so the UI can say it rather than leaving the
455
+ // user to wonder whether the check runs at all. A lookup that failed does
456
+ // not move this: "checked 2 minutes ago" over an hour-old answer is the
457
+ // one thing this field must never say.
458
+ checkedAt: marker?.at ?? null,
459
+ // …and when it last failed, null once one succeeds. Without it, the single
460
+ // most common reason for a missing update button — a proxy, a flaky line,
461
+ // an offline machine — is indistinguishable from being up to date.
462
+ checkFailedAt: marker?.failedAt ?? null,
410
463
  checkDisabled: skipRegistry,
411
464
  notice: pickNotice({ running, installed, latest }),
412
465
  command: upgradeCommand(pkgRoot, name),