agent-dag 1.33.90 → 1.33.92

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-9QGqVovu.js"></script>
8
+ <script type="module" crossorigin src="/assets/index-CJhj8oVC.js"></script>
9
9
  <link rel="stylesheet" crossorigin href="/assets/index-dwRx-2y0.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.90",
3
+ "version": "1.33.92",
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": {
@@ -10,7 +10,7 @@
10
10
  // Only ever touches its own entry, tagged `__agent-dag-sound`. Hooks the user
11
11
  // wrote themselves are left exactly as found — including the platform-specific
12
12
  // ones this replaces, which are reported rather than deleted.
13
- import { readFile, writeFile, mkdir } from "node:fs/promises";
13
+ import { readFile, mkdir } from "node:fs/promises";
14
14
  import { existsSync } from "node:fs";
15
15
  import { join, dirname } from "node:path";
16
16
  import { homedir } from "node:os";
@@ -60,6 +60,18 @@ function refusal(err) {
60
60
  };
61
61
  }
62
62
 
63
+ const isParkFailure = (err) => err?.code === "PARKED_UNREADABLE" || err?.code === "PARKED_UNWRITABLE";
64
+
65
+ /** The same refusal, for the file the parked hooks live in rather than settings.json. */
66
+ function parkRefusal(err) {
67
+ return {
68
+ ok: false,
69
+ reason: err?.code === "PARKED_UNREADABLE" ? "parked_unreadable" : "parked_unwritable",
70
+ parkedPath: PARKED_PATH,
71
+ message: err?.message ?? String(err),
72
+ };
73
+ }
74
+
63
75
  /**
64
76
  * Write settings.json back atomically — this file holds every hook the user
65
77
  * has, and a torn write costs them all of them.
@@ -91,18 +103,63 @@ function foreignSoundHooks(settings) {
91
103
  return found;
92
104
  }
93
105
 
106
+ function parkedError(code, why) {
107
+ const err = new Error(
108
+ code === "PARKED_UNREADABLE"
109
+ ? `${PARKED_PATH} could not be read as JSON (${why}). It holds sound hooks you wrote yourself, ` +
110
+ `so it is not being treated as empty — fix the file or move it aside, then try again.`
111
+ : `${PARKED_PATH} could not be written (${why}). It is where your own sound hooks are kept while ` +
112
+ `the toggle is on, so nothing was taken out of settings.json — they would have been in neither file.`,
113
+ );
114
+ err.code = code;
115
+ err.parkedPath = PARKED_PATH;
116
+ return err;
117
+ }
118
+
119
+ /**
120
+ * Read the parked hooks, refusing to guess at a file that will not parse.
121
+ *
122
+ * Same bargain as readSettingsForWrite, for the same reason: this is the only
123
+ * copy of hooks the user wrote by hand, and every caller either overwrites the
124
+ * file or reports how much is in it. A truncated file — a kill mid-write, a full
125
+ * disk — used to read as "nothing was ever parked", and the next toggle wrote
126
+ * its own list over the remains. Only ENOENT is genuinely empty; the array is
127
+ * not optional, because a JSON object here means the file is not ours to touch.
128
+ */
94
129
  async function readParked() {
130
+ let raw;
95
131
  try {
96
- const parsed = JSON.parse(await readFile(PARKED_PATH, "utf8"));
97
- return Array.isArray(parsed) ? parsed : [];
98
- } catch { return []; }
132
+ raw = await readFile(PARKED_PATH, "utf8");
133
+ } catch (err) {
134
+ if (err?.code === "ENOENT") return [];
135
+ throw parkedError("PARKED_UNREADABLE", err?.message ?? String(err));
136
+ }
137
+ let parsed;
138
+ try {
139
+ parsed = JSON.parse(raw);
140
+ } catch (err) {
141
+ throw parkedError("PARKED_UNREADABLE", err?.message ?? String(err));
142
+ }
143
+ if (!Array.isArray(parsed)) throw parkedError("PARKED_UNREADABLE", "top level is not a JSON array");
144
+ return parsed;
99
145
  }
100
146
 
147
+ /**
148
+ * Write the parked hooks, and never quietly fail to.
149
+ *
150
+ * This used to swallow every error, which made the park a suggestion: a
151
+ * root-owned ~/.agents-deck, a full disk or a Windows lock on the file left the
152
+ * write undone while setSoundHook went on to strip the same hooks out of
153
+ * settings.json and report success. Atomic for the other half of it — a torn
154
+ * parked file is a parked file that reads as empty.
155
+ */
101
156
  async function writeParked(entries) {
102
157
  try {
103
158
  if (!existsSync(dirname(PARKED_PATH))) await mkdir(dirname(PARKED_PATH), { recursive: true });
104
- await writeFile(PARKED_PATH, JSON.stringify(entries, null, 2) + "\n", "utf8");
105
- } catch { /* best-effort */ }
159
+ await writeFileAtomic(PARKED_PATH, JSON.stringify(entries, null, 2) + "\n");
160
+ } catch (err) {
161
+ throw parkedError("PARKED_UNWRITABLE", err?.message ?? String(err));
162
+ }
106
163
  }
107
164
 
108
165
  /**
@@ -128,13 +185,30 @@ export async function soundHookStatus() {
128
185
  // Reporting a healthy "off" here would be a lie the user acts on: the
129
186
  // toggle cannot do anything until they repair the file, so say which file
130
187
  // and why rather than offering a switch that will refuse.
131
- return { ...refusal(err), enabled: false, platform: process.platform, foreign: [], parked: (await readParked()).length };
188
+ return {
189
+ ...refusal(err),
190
+ enabled: false,
191
+ platform: process.platform,
192
+ foreign: [],
193
+ // A parked file that will not read is a second refusal, and settings.json
194
+ // is the one being reported. Count what can be counted.
195
+ parked: await readParked().then(p => p.length, () => 0),
196
+ };
132
197
  }
133
198
  const group = settings?.hooks?.[EVENT];
134
- const parked = await readParked();
199
+ const enabled = Array.isArray(group) && group.some(isOurs);
200
+ let parked;
201
+ try {
202
+ parked = await readParked();
203
+ } catch (err) {
204
+ if (!isParkFailure(err)) throw err;
205
+ // "parked: 0" on a file we cannot read is the lie that sends the user to
206
+ // click the toggle, which is the thing that would overwrite it.
207
+ return { ...parkRefusal(err), enabled, platform: process.platform, foreign: foreignSoundHooks(settings), parked: 0 };
208
+ }
135
209
  return {
136
210
  ok: true,
137
- enabled: Array.isArray(group) && group.some(isOurs),
211
+ enabled,
138
212
  platform: process.platform,
139
213
  foreign: foreignSoundHooks(settings),
140
214
  parked: parked.length,
@@ -148,7 +222,15 @@ export async function soundHookStatus() {
148
222
  * can have it back exactly as it was.
149
223
  */
150
224
  export async function restoreParkedSoundHooks() {
151
- const parked = await readParked();
225
+ let parked;
226
+ try {
227
+ parked = await readParked();
228
+ } catch (err) {
229
+ if (!isParkFailure(err)) throw err;
230
+ // The file is left exactly as it is. Answering "restored: 0" would be the
231
+ // last word on hooks that are still in there, badly written but present.
232
+ return parkRefusal(err);
233
+ }
152
234
  if (parked.length === 0) return { ok: true, restored: 0 };
153
235
  let settings;
154
236
  try {
@@ -163,7 +245,15 @@ export async function restoreParkedSoundHooks() {
163
245
  const group = Array.isArray(settings.hooks[EVENT]) ? settings.hooks[EVENT] : [];
164
246
  settings.hooks[EVENT] = [...parked, ...group];
165
247
  await writeSettings(settings);
166
- await writeParked([]);
248
+ // Emptying the park comes last and its failure is reported, not swallowed:
249
+ // the hooks are safely back in settings.json now, but a park left behind is
250
+ // one the next restore hands over a second time, duplicating them.
251
+ try {
252
+ await writeParked([]);
253
+ } catch (err) {
254
+ if (!isParkFailure(err)) throw err;
255
+ return { ...parkRefusal(err), restored: parked.length };
256
+ }
167
257
  return { ok: true, restored: parked.length };
168
258
  }
169
259
 
@@ -221,9 +311,19 @@ export async function setSoundHook(enabled) {
221
311
  // Without this the toggle is a lie in both directions: off still plays their
222
312
  // afplay/PowerShell hook, and on plays twice. They are moved, not deleted —
223
313
  // restoreParkedSoundHooks puts them back untouched.
314
+ //
315
+ // Which only holds if the move lands first. The filter below drops exactly
316
+ // these entries from the object written to settings.json, so a park that
317
+ // failed and said nothing left the user's own Stop hook in neither file, with
318
+ // ok:true on the way out. Nothing here is written until the park is on disk.
224
319
  const parking = group.filter(isSoundHook);
225
320
  if (parking.length > 0) {
226
- await writeParked([...(await readParked()), ...parking]);
321
+ try {
322
+ await writeParked([...(await readParked()), ...parking]);
323
+ } catch (err) {
324
+ if (!isParkFailure(err)) throw err;
325
+ return parkRefusal(err);
326
+ }
227
327
  }
228
328
  const others = group.filter(g => !isOurs(g) && !isSoundHook(g));
229
329