agent-dag 1.33.20 → 1.33.21

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-DLOmF1C9.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.21",
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": {
@@ -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