agent-dag 1.26.0 → 1.28.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.
@@ -0,0 +1,79 @@
1
+ // Running an external command the same way on Linux, macOS and Windows.
2
+ //
3
+ // On POSIX, `spawn("cswap", …)` finds cswap on PATH. On Windows it does not:
4
+ // the thing on PATH is `cswap.exe` or a `cswap.cmd` shim, and Node only
5
+ // applies PATHEXT when it goes through a shell. So the naive call fails with
6
+ // ENOENT on Windows even though the tool is installed and on PATH — which
7
+ // looks exactly like "not installed" and is why this is worth a module.
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.
13
+ import { execFile, spawn } from "node:child_process";
14
+
15
+ // Extensions Windows will execute, most specific first. `.com` is omitted —
16
+ // nothing ships one, and every extra candidate costs a failed spawn.
17
+ const WIN_EXTS = [".exe", ".cmd", ".bat", ""];
18
+
19
+ // Which spelling worked, per command name. A failed spawn is cheap but not
20
+ // free, and these run on a poll.
21
+ const resolved = new Map();
22
+
23
+ function candidates(cmd) {
24
+ if (process.platform !== "win32") return [cmd];
25
+ // An explicit extension is respected as given.
26
+ if (/\.[a-z]+$/i.test(cmd)) return [cmd];
27
+ const known = resolved.get(cmd);
28
+ return known ? [known] : WIN_EXTS.map(ext => cmd + ext);
29
+ }
30
+
31
+ const isMissing = (err) => err && (err.code === "ENOENT" || err.code === "EACCES");
32
+
33
+ /**
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.
37
+ */
38
+ export function run(cmd, args, { timeout = 20_000, maxBuffer = 4 << 20 } = {}) {
39
+ const tries = candidates(cmd);
40
+ return new Promise((resolve) => {
41
+ 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
+ });
53
+ });
54
+ };
55
+ attempt(0);
56
+ });
57
+ }
58
+
59
+ /**
60
+ * Start a command and don't wait for it. Same resolution, no output captured.
61
+ * Used where the result lands somewhere else — a file the next poll reads, or
62
+ * a sound the user hears.
63
+ */
64
+ export function runDetached(cmd, args) {
65
+ const tries = candidates(cmd);
66
+ const attempt = (i) => {
67
+ 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]));
73
+ child.unref?.();
74
+ } catch {
75
+ if (i + 1 < tries.length) attempt(i + 1);
76
+ }
77
+ };
78
+ attempt(0);
79
+ }
@@ -1123,13 +1123,14 @@ async function handleSoundHook(req, res) {
1123
1123
  }
1124
1124
 
1125
1125
  async function handleSoundHookSet(req, res) {
1126
- const { setSoundHook } = await import(
1126
+ const { setSoundHook, restoreParkedSoundHooks } = await import(
1127
1127
  pathToFileURL(join(PKG_ROOT, "src/server/sound-hook.mjs")).href
1128
1128
  );
1129
1129
  const body = await readBody(req).catch(() => null);
1130
1130
  let parsed = null;
1131
1131
  try { parsed = JSON.parse(body ?? ""); } catch { /* handled below */ }
1132
1132
  if (!parsed || typeof parsed !== "object") return send(res, 400, { ok: false, reason: "bad_request" });
1133
+ if (parsed.action === "restore") return send(res, 200, await restoreParkedSoundHooks());
1133
1134
  send(res, 200, await setSoundHook(parsed.enabled === true));
1134
1135
  }
1135
1136
 
@@ -264,7 +264,12 @@ async function _execOnce(shellCmd) {
264
264
  try {
265
265
  const { stdout, stderr } = await execAsync(shellCmd, {
266
266
  timeout: 15_000,
267
- env: { ...process.env, NO_COLOR: "1", TERM: "dumb" },
267
+ // Marks this Claude Code run as the deck's own. `claude --print /usage`
268
+ // is a full invocation, so it fires the hooks we installed, and every
269
+ // quota poll was drawing itself onto the canvas as a fresh session with
270
+ // no prompt and no tools. Hooks inherit the environment, so hook.js
271
+ // sees this and stays quiet.
272
+ env: { ...process.env, NO_COLOR: "1", TERM: "dumb", AGENTS_DECK_INTERNAL: "1" },
268
273
  maxBuffer: 1024 * 1024,
269
274
  });
270
275
  const combined = stdout + "\n" + stderr;
@@ -24,6 +24,9 @@ const NOTIFY_PATH = join(INSTALL_DIR, "notify.js");
24
24
 
25
25
  const MARK = "__agent-dag-sound";
26
26
  const EVENT = "Stop";
27
+ // Where a user's own sound hooks are kept while the toggle is off, so turning
28
+ // the feature off actually produces silence and nothing is destroyed.
29
+ const PARKED_PATH = join(homedir(), ".agents-deck", "parked-sound-hooks.json");
27
30
 
28
31
  // Commands that look like a hand-rolled sound hook. Used only to tell the user
29
32
  // what is already there — never to modify or remove it.
@@ -73,22 +76,79 @@ function foreignSoundHooks(settings) {
73
76
  return found;
74
77
  }
75
78
 
79
+ async function readParked() {
80
+ try {
81
+ const parsed = JSON.parse(await readFile(PARKED_PATH, "utf8"));
82
+ return Array.isArray(parsed) ? parsed : [];
83
+ } catch { return []; }
84
+ }
85
+
86
+ async function writeParked(entries) {
87
+ try {
88
+ if (!existsSync(dirname(PARKED_PATH))) await mkdir(dirname(PARKED_PATH), { recursive: true });
89
+ await writeFile(PARKED_PATH, JSON.stringify(entries, null, 2) + "\n", "utf8");
90
+ } catch { /* best-effort */ }
91
+ }
92
+
93
+ /**
94
+ * True when this Stop entry plays a sound, on any platform.
95
+ *
96
+ * Deliberately not limited to the current one. settings.json is commonly
97
+ * synced between machines — this user's own file carries Windows paths
98
+ * alongside macOS ones — so parking only the hook that fires here leaves the
99
+ * other in place, and the switch looks broken again on the other machine.
100
+ */
101
+ function isSoundHook(entry) {
102
+ if (isOurs(entry)) return false;
103
+ return (entry.hooks ?? []).some(h =>
104
+ SOUND_HINTS.some(re => re.test(typeof h?.command === "string" ? h.command : "")));
105
+ }
106
+
76
107
  export async function soundHookStatus() {
77
108
  const settings = await readSettings();
78
109
  const group = settings?.hooks?.[EVENT];
110
+ const parked = await readParked();
79
111
  return {
80
112
  ok: true,
81
113
  enabled: Array.isArray(group) && group.some(isOurs),
82
114
  platform: process.platform,
83
115
  foreign: foreignSoundHooks(settings),
116
+ parked: parked.length,
84
117
  };
85
118
  }
86
119
 
120
+ /**
121
+ * Put back the hooks the toggle set aside.
122
+ *
123
+ * Nothing is deleted, only moved, so a user who preferred their own command
124
+ * can have it back exactly as it was.
125
+ */
126
+ export async function restoreParkedSoundHooks() {
127
+ const parked = await readParked();
128
+ if (parked.length === 0) return { ok: true, restored: 0 };
129
+ const settings = await readSettings();
130
+ settings.hooks ??= {};
131
+ const group = Array.isArray(settings.hooks[EVENT]) ? settings.hooks[EVENT] : [];
132
+ settings.hooks[EVENT] = [...parked, ...group];
133
+ await writeSettings(settings);
134
+ await writeParked([]);
135
+ return { ok: true, restored: parked.length };
136
+ }
137
+
87
138
  export async function setSoundHook(enabled) {
88
139
  const settings = await readSettings();
89
140
  settings.hooks ??= {};
90
141
  const group = Array.isArray(settings.hooks[EVENT]) ? settings.hooks[EVENT] : [];
91
- const others = group.filter(g => !isOurs(g));
142
+
143
+ // Set aside any of the user's own hooks that play a sound on this machine.
144
+ // Without this the toggle is a lie in both directions: off still plays their
145
+ // afplay/PowerShell hook, and on plays twice. They are moved, not deleted —
146
+ // restoreParkedSoundHooks puts them back untouched.
147
+ const parking = group.filter(isSoundHook);
148
+ if (parking.length > 0) {
149
+ await writeParked([...(await readParked()), ...parking]);
150
+ }
151
+ const others = group.filter(g => !isOurs(g) && !isSoundHook(g));
92
152
 
93
153
  if (enabled) {
94
154
  if (!existsSync(INSTALL_DIR)) await mkdir(INSTALL_DIR, { recursive: true });