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.
- package/dist/web/assets/index-D3CYh4Dp.js +66 -0
- package/dist/web/assets/{index-uyBGpPqo.css → index-WLMEm0bm.css} +1 -1
- package/dist/web/index.html +2 -2
- package/hook/hook.js +7 -0
- package/package.json +1 -1
- package/src/server/claude-accounts.mjs +8 -19
- package/src/server/cswap-auto.mjs +22 -21
- package/src/server/cswap-install.mjs +5 -17
- package/src/server/exec.mjs +79 -0
- package/src/server/index.mjs +2 -1
- package/src/server/quota.mjs +6 -1
- package/src/server/sound-hook.mjs +61 -1
- package/dist/web/assets/index-CufegSZ-.js +0 -64
|
@@ -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
|
+
}
|
package/src/server/index.mjs
CHANGED
|
@@ -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
|
|
package/src/server/quota.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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 });
|