agent-dag 1.33.20 → 1.33.22

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-DLJMh8ec.js"></script>
8
+ <script type="module" crossorigin src="/assets/index-BC3e-3-4.js"></script>
9
9
  <link rel="stylesheet" crossorigin href="/assets/index-Cpi89XJ8.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.20",
3
+ "version": "1.33.22",
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": {
@@ -37,6 +37,16 @@ const NPM_SHELL = process.platform === "win32";
37
37
  let _installing = null; // Promise guard so concurrent calls share one install
38
38
  let _checkedThisRun = false; // only kick the daily check once per process boot
39
39
 
40
+ // AGENTS_DECK_NO_INSTALL=1 is documented as "never install or update
41
+ // claude-swap / ccusage, and never ask npm about releases", so it has to hold
42
+ // on the lazy path too — opening the usage-history modal must not be a way to
43
+ // pull ccusage off the registry behind the user's back. Read per call rather
44
+ // than once at import, because the module is imported lazily and a test (or an
45
+ // embedder) may set it after load.
46
+ function installsDisabled() {
47
+ return process.env.AGENTS_DECK_NO_INSTALL === "1";
48
+ }
49
+
40
50
  // ── managed install ─────────────────────────────────────────────────────────
41
51
 
42
52
  // Absolute path to ccusage's CLI entry inside our managed install, or null if
@@ -103,6 +113,7 @@ function touchMarker() {
103
113
 
104
114
  // Non-blocking: compare installed version to npm `latest`; install if newer.
105
115
  function maybeBackgroundUpdate(installedVersion) {
116
+ if (installsDisabled()) return; // no `npm view`, no upgrade install
106
117
  if (_checkedThisRun || !updateCheckDue()) return;
107
118
  _checkedThisRun = true;
108
119
  touchMarker();
@@ -127,6 +138,13 @@ async function getRunner() {
127
138
  maybeBackgroundUpdate(resolved.version);
128
139
  return { kind: "node", entry: resolved.entry };
129
140
  }
141
+ // Nothing installed and installs are forbidden. The npx fallback is not an
142
+ // escape hatch — `npx -y ccusage@latest` downloads and runs the same package
143
+ // — so there is no runner to hand back. Fail with the reason, which the
144
+ // usage-history modal shows, instead of quietly installing.
145
+ if (installsDisabled()) {
146
+ throw new Error("ccusage is not installed, and installs are off (AGENTS_DECK_NO_INSTALL=1)");
147
+ }
130
148
  // Cold: install once (deduped across concurrent callers).
131
149
  if (!_installing) {
132
150
  _installing = (async () => { installSync("latest"); })()
@@ -232,6 +250,13 @@ export async function fetchCcusageDaily({ since, until, force = false } = {}) {
232
250
  * not hold up the server.
233
251
  */
234
252
  export function primeCcusage() {
253
+ // The CLI already skips this call under AGENTS_DECK_NO_INSTALL=1; repeating
254
+ // the check here keeps the promise a property of the module rather than of
255
+ // one caller.
256
+ if (installsDisabled()) {
257
+ const have = resolveEntry();
258
+ return have ? { state: "present", version: have.version } : { state: "unavailable" };
259
+ }
235
260
  const resolved = resolveEntry();
236
261
  if (resolved) {
237
262
  // Already installed: the daily check may still queue a background upgrade.
@@ -4,10 +4,11 @@
4
4
  // Both providers share the discovery dir at ~/.claude/agent-dag/ so a single
5
5
  // running server can receive events from either CLI. Re-runs are safe; entries
6
6
  // are tagged with __agent-dag and de-duped.
7
- import { readFile, writeFile, mkdir, copyFile, unlink } from "node:fs/promises";
7
+ import { readFile, writeFile, mkdir, copyFile, unlink, rename, open, stat, chmod } from "node:fs/promises";
8
8
  import { existsSync } from "node:fs";
9
9
  import { homedir } from "node:os";
10
10
  import { join, resolve, dirname } from "node:path";
11
+ import { setTimeout as delay } from "node:timers/promises";
11
12
  import { fileURLToPath } from "node:url";
12
13
 
13
14
  const __dirname = dirname(fileURLToPath(import.meta.url));
@@ -128,7 +129,7 @@ async function readSettingsForWrite(p) {
128
129
  try {
129
130
  raw = await readFile(p, "utf8");
130
131
  } catch (err) {
131
- if (err?.code === "ENOENT") return {};
132
+ if (err?.code === "ENOENT") return { settings: {}, raw: null };
132
133
  throw unreadableSettings(p, err?.message ?? String(err));
133
134
  }
134
135
  let parsed;
@@ -140,7 +141,61 @@ async function readSettingsForWrite(p) {
140
141
  if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
141
142
  throw unreadableSettings(p, "top level is not a JSON object");
142
143
  }
143
- return parsed;
144
+ return { settings: parsed, raw };
145
+ }
146
+
147
+ // Rename over an open file is the one thing Windows does differently here. The
148
+ // call itself is atomic (MoveFileEx with MOVEFILE_REPLACE_EXISTING), but it
149
+ // fails outright while another process holds the target open — and a virus
150
+ // scanner or the search indexer opens files the instant they are written, for a
151
+ // few milliseconds at a time. Retrying a sharing violation a handful of times
152
+ // turns that into a successful install. POSIX never hits this path, and a real
153
+ // permission error just costs an extra fraction of a second before it surfaces.
154
+ const RENAME_RETRY_CODES = new Set(["EPERM", "EACCES", "EBUSY"]);
155
+
156
+ async function renameWithRetry(from, to, attempts = 5) {
157
+ for (let attempt = 1; ; attempt++) {
158
+ try {
159
+ await rename(from, to);
160
+ return;
161
+ } catch (err) {
162
+ if (attempt >= attempts || !RENAME_RETRY_CODES.has(err?.code)) throw err;
163
+ await delay(20 * attempt);
164
+ }
165
+ }
166
+ }
167
+
168
+ /**
169
+ * Replace a file in a single step readers cannot land inside.
170
+ *
171
+ * The temp file is created beside the target rather than in $TMPDIR, because
172
+ * rename is only atomic within one filesystem and the two are routinely on
173
+ * different ones. It is fsync'd before the rename so that a crash or power loss
174
+ * just after a successful install cannot leave the new directory entry pointing
175
+ * at blocks that were never flushed — the classic file-of-zero-bytes. The pid in
176
+ * the name keeps two decks starting at once off each other's temp file.
177
+ */
178
+ async function writeFileAtomic(target, text) {
179
+ const tmp = `${target}.agent-dag-${process.pid}.tmp`;
180
+ let handle;
181
+ try {
182
+ handle = await open(tmp, "w");
183
+ await handle.writeFile(text, "utf8");
184
+ await handle.sync();
185
+ } finally {
186
+ await handle?.close();
187
+ }
188
+ try {
189
+ // A rename creates a fresh directory entry, so the old file's mode does not
190
+ // come with it. Carry it over — a settings.json the user chmod'ed to 600 has
191
+ // to stay 600. No-op on Windows, where chmod only toggles the read-only bit.
192
+ const mode = await stat(target).then(s => s.mode, () => null);
193
+ if (mode !== null) await chmod(tmp, mode).catch(() => {});
194
+ await renameWithRetry(tmp, target);
195
+ } catch (err) {
196
+ await unlink(tmp).catch(() => {});
197
+ throw err;
198
+ }
144
199
  }
145
200
 
146
201
  async function installHookScript(installDir) {
@@ -163,14 +218,14 @@ function dedupeOurEntries(group) {
163
218
  return group.filter(g => !isOurEntry(g));
164
219
  }
165
220
 
166
- /** Install hooks for a single provider. Returns {settingsPath, hookPath, events}. */
221
+ /** Install hooks for a single provider. Returns {settingsPath, hookPath, events, changed}. */
167
222
  export async function installHooks({ provider = "claude" } = {}) {
168
223
  const cfg = PROVIDERS[provider];
169
224
  if (!cfg) throw new Error(`unknown provider: ${provider}`);
170
225
 
171
226
  // Read before writing anything, so a settings file we cannot parse aborts
172
227
  // the install without leaving half of it behind.
173
- const current = await readSettingsForWrite(cfg.settingsPath);
228
+ const { settings: current, raw: before } = await readSettingsForWrite(cfg.settingsPath);
174
229
 
175
230
  const hookPath = await installHookScript(cfg.hookInstallDir);
176
231
  const command = hookCommand(hookPath, provider);
@@ -186,8 +241,15 @@ export async function installHooks({ provider = "claude" } = {}) {
186
241
  current.hooks[evt] = cleaned;
187
242
  }
188
243
 
189
- await writeFile(cfg.settingsPath, JSON.stringify(current, null, 2) + "\n", "utf8");
190
- return { settingsPath: cfg.settingsPath, hookPath, events: cfg.events, provider };
244
+ // Every launch reinstalls, and on all but the first the entries are already
245
+ // there and identical. Writing anyway is pure downside: it is one more chance
246
+ // to be interrupted mid-write, and one more window in which a change Claude
247
+ // Code made to the file between our read and our write gets discarded. So
248
+ // compare against the exact bytes we read and, when they match, do nothing.
249
+ const next = JSON.stringify(current, null, 2) + "\n";
250
+ const changed = next !== before;
251
+ if (changed) await writeFileAtomic(cfg.settingsPath, next);
252
+ return { settingsPath: cfg.settingsPath, hookPath, events: cfg.events, provider, changed };
191
253
  }
192
254
 
193
255
  export async function uninstallHooks({ provider = "claude" } = {}) {
@@ -202,7 +264,7 @@ export async function uninstallHooks({ provider = "claude" } = {}) {
202
264
  if (cleaned.length === 0) delete current.hooks[evt];
203
265
  else current.hooks[evt] = cleaned;
204
266
  }
205
- if (changed) await writeFile(cfg.settingsPath, JSON.stringify(current, null, 2) + "\n", "utf8");
267
+ if (changed) await writeFileAtomic(cfg.settingsPath, JSON.stringify(current, null, 2) + "\n");
206
268
  return { changed, provider, settingsPath: cfg.settingsPath };
207
269
  }
208
270