vibeaudio 0.4.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/LICENSE +21 -0
- package/README.md +601 -0
- package/bin/vibeaudio.js +16 -0
- package/package.json +51 -0
- package/src/cli.js +993 -0
- package/src/hooks.js +980 -0
- package/src/hud.js +84 -0
- package/src/interactive.js +338 -0
- package/src/mcp.js +292 -0
- package/src/player.js +666 -0
- package/src/synth/chime.js +137 -0
- package/src/synth/chiptune.js +132 -0
- package/src/synth/drone.js +133 -0
- package/src/synth/electronic.js +133 -0
- package/src/synth/generator.js +179 -0
- package/src/synth/jazz.js +178 -0
- package/src/synth/lofi.js +148 -0
- package/src/synth/piano.js +158 -0
- package/src/synth/synthwave.js +170 -0
- package/src/synth/zen.js +148 -0
package/src/hooks.js
ADDED
|
@@ -0,0 +1,980 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agent Hooks Integration (Claude Code, Codex, Cursor, Grok, Gemini CLI, Copilot CLI, Qwen Code)
|
|
3
|
+
*
|
|
4
|
+
* Hooks fire as short-lived processes, so playback lives in a detached daemon
|
|
5
|
+
* tracked by a pid file. The prompt-submit event starts it, the stop event
|
|
6
|
+
* tears it down and plays the chime. This tracks the agent's actual thinking
|
|
7
|
+
* window instead of guessing from a wrapped process's lifetime.
|
|
8
|
+
*
|
|
9
|
+
* The supported agents differ only in where the file lives, what the events
|
|
10
|
+
* are called and how one entry is shaped - TARGETS holds those three facts and
|
|
11
|
+
* everything else below is shared.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const fs = require("fs");
|
|
15
|
+
const os = require("os");
|
|
16
|
+
const path = require("path");
|
|
17
|
+
const { spawn, execFileSync } = require("child_process");
|
|
18
|
+
const { AudioPlayer } = require("./player");
|
|
19
|
+
|
|
20
|
+
const STATE_DIR = path.join(os.homedir(), ".vibeaudio");
|
|
21
|
+
const PID_FILE = path.join(STATE_DIR, "daemon.pid");
|
|
22
|
+
const INTENSITY_FILE = path.join(STATE_DIR, "intensity");
|
|
23
|
+
// Present while the music is paused mid-turn; holds what will resume it.
|
|
24
|
+
const WAITING_FILE = path.join(STATE_DIR, "waiting");
|
|
25
|
+
// The turn the music belongs to: its session, and where its transcript began.
|
|
26
|
+
const TURN_FILE = path.join(STATE_DIR, "turn.json");
|
|
27
|
+
const CLI_ENTRY = path.join(__dirname, "..", "bin", "vibeaudio.js");
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Reactive mode: which tier a tool call implies. Looking things up stays
|
|
31
|
+
* sparse, edits bring in the groove, shelling out and handing work to a
|
|
32
|
+
* subagent go to peak. Unknown tools sit in the middle rather than swinging
|
|
33
|
+
* the mix.
|
|
34
|
+
*
|
|
35
|
+
* The agents don't agree on tool names, so every vocabulary lives here: Claude
|
|
36
|
+
* Code's PascalCase set (which Grok shares), Codex's snake_case one, and
|
|
37
|
+
* Cursor's short names. They don't collide, so one flat map covers them all.
|
|
38
|
+
*
|
|
39
|
+
* Tiers here were checked against 30,532 real tool calls rather than guessed.
|
|
40
|
+
* Two things that measurement changed:
|
|
41
|
+
*
|
|
42
|
+
* - `Task` was the old name for the subagent tool; it is `Agent` now, so the
|
|
43
|
+
* heaviest thing an agent does was landing on the fallback tier. Both are
|
|
44
|
+
* listed, since an older Claude Code still emits the old one.
|
|
45
|
+
* - A third of all calls (33.7%) are MCP tools, which arrive as
|
|
46
|
+
* `mcp__<server>__<tool>` (Claude Code) or `MCP:<tool>` (Cursor) and cannot
|
|
47
|
+
* be enumerated - every user has different servers. They keep the fallback
|
|
48
|
+
* deliberately: an MCP call is usually real work, but rarely the heaviest
|
|
49
|
+
* thing in a turn, which is exactly what tier 2 means.
|
|
50
|
+
*
|
|
51
|
+
* Tools that mean "the agent has stopped and is waiting for the human" belong
|
|
52
|
+
* at tier 1 even though they are not lookups - nothing is being worked on.
|
|
53
|
+
*/
|
|
54
|
+
const TOOL_TIERS = {
|
|
55
|
+
// Claude Code - lookups, bookkeeping, and waiting on the user
|
|
56
|
+
Read: 1, Glob: 1, Grep: 1, WebFetch: 1, WebSearch: 1, TodoWrite: 1,
|
|
57
|
+
ToolSearch: 1, SearchSkills: 1, ListAgents: 1, ListSkills: 1,
|
|
58
|
+
TaskCreate: 1, TaskUpdate: 1, TaskOutput: 1,
|
|
59
|
+
BashOutput: 1, KillShell: 1,
|
|
60
|
+
AskUserQuestion: 1, ExitPlanMode: 1, SendUserFile: 1, SendMessage: 1,
|
|
61
|
+
// Claude Code - editing
|
|
62
|
+
Edit: 2, Write: 2, MultiEdit: 2, NotebookEdit: 2,
|
|
63
|
+
// Claude Code - shelling out, or handing the work to something else
|
|
64
|
+
Bash: 3, Agent: 3, Task: 3, Skill: 3, Workflow: 3,
|
|
65
|
+
// Codex
|
|
66
|
+
read_file: 1, list_dir: 1, grep: 1, web_search: 1, update_plan: 1,
|
|
67
|
+
apply_patch: 2,
|
|
68
|
+
shell: 3,
|
|
69
|
+
// Cursor
|
|
70
|
+
Delete: 2,
|
|
71
|
+
Shell: 3
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
function toolTier(toolName) {
|
|
75
|
+
return TOOL_TIERS[toolName] || 2;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function readIntensity() {
|
|
79
|
+
try {
|
|
80
|
+
const tier = parseInt(fs.readFileSync(INTENSITY_FILE, "utf8").trim(), 10);
|
|
81
|
+
return tier >= 1 && tier <= 3 ? tier : null;
|
|
82
|
+
} catch (e) {
|
|
83
|
+
return null; // No signal yet - the player falls back to time escalation.
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function parsePayload(raw) {
|
|
88
|
+
try {
|
|
89
|
+
const payload = JSON.parse(raw);
|
|
90
|
+
return payload !== null && typeof payload === "object" ? payload : {};
|
|
91
|
+
} catch (e) {
|
|
92
|
+
return {}; // Malformed or absent payload.
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function payloadToolName(raw) {
|
|
97
|
+
const payload = parsePayload(raw);
|
|
98
|
+
// Claude Code, Codex and Cursor send tool_name; Grok sends toolName.
|
|
99
|
+
// Reading only one of them would silently pin that agent to tier 2.
|
|
100
|
+
return String(payload.tool_name || payload.toolName || "");
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* PreToolUse hook: Claude Code delivers the tool call as JSON on stdin.
|
|
105
|
+
* Writes a tier for the running daemon to pick up at its next loop boundary.
|
|
106
|
+
*/
|
|
107
|
+
function hookTool() {
|
|
108
|
+
let raw = "";
|
|
109
|
+
const commit = () => {
|
|
110
|
+
try {
|
|
111
|
+
fs.mkdirSync(STATE_DIR, { recursive: true });
|
|
112
|
+
fs.writeFileSync(INTENSITY_FILE, String(toolTier(payloadToolName(raw))));
|
|
113
|
+
} catch (e) {
|
|
114
|
+
// Never let a hook failure disturb the agent.
|
|
115
|
+
}
|
|
116
|
+
process.exit(0);
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
process.stdin.setEncoding("utf8");
|
|
120
|
+
process.stdin.on("data", (chunk) => {
|
|
121
|
+
raw += chunk;
|
|
122
|
+
});
|
|
123
|
+
process.stdin.on("end", commit);
|
|
124
|
+
// A hook must never hang the tool call waiting on stdin.
|
|
125
|
+
setTimeout(commit, 500).unref();
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// A lost Stop hook must not leave music looping forever.
|
|
129
|
+
const MAX_DAEMON_MS = 15 * 60 * 1000;
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* The agents VibeAudio can wire itself into, and the few things that differ
|
|
133
|
+
* between them: where the file lives, what the events are called, how one
|
|
134
|
+
* entry is shaped. Each was verified against the tool itself - its installed
|
|
135
|
+
* binary and a live run where it is installed, its released source where not -
|
|
136
|
+
* never inferred from another tool's docs.
|
|
137
|
+
*
|
|
138
|
+
* `events` beyond start/stop/tool are optional, and a target only lists what
|
|
139
|
+
* that tool was shown to fire:
|
|
140
|
+
* - wait: the agent is blocked on the user (pause + attention chime)
|
|
141
|
+
* - resume: what ends a wait
|
|
142
|
+
* - failure: a turn ending in error *instead of* the stop event
|
|
143
|
+
* - end: the session closing, which can cut a turn off before stop
|
|
144
|
+
*
|
|
145
|
+
* `seed` is the root object to write when the file does not exist yet. Cursor
|
|
146
|
+
* and Copilot require a schema version; Codex rejects unknown root keys
|
|
147
|
+
* outright, so nothing may be added there beyond `hooks`.
|
|
148
|
+
*/
|
|
149
|
+
const TARGETS = {
|
|
150
|
+
claude: {
|
|
151
|
+
name: "Claude Code",
|
|
152
|
+
cmd: "claude",
|
|
153
|
+
file: () => path.join(os.homedir(), ".claude", "settings.json"),
|
|
154
|
+
// - wait: PermissionRequest fires when the dialog is shown in the terminal,
|
|
155
|
+
// the SDK (desktop app, IDEs) and print mode alike - Notification's
|
|
156
|
+
// permission_prompt is raised by the terminal UI alone, after 6s idle.
|
|
157
|
+
// - Esc reaches no hook at all; the daemon watches the transcript for it.
|
|
158
|
+
events: {
|
|
159
|
+
start: "UserPromptSubmit",
|
|
160
|
+
stop: "Stop",
|
|
161
|
+
tool: "PreToolUse",
|
|
162
|
+
wait: ["PermissionRequest", "Elicitation"],
|
|
163
|
+
resume: ["PostToolUse", "PostToolUseFailure", "ElicitationResult"],
|
|
164
|
+
failure: "StopFailure",
|
|
165
|
+
end: "SessionEnd"
|
|
166
|
+
},
|
|
167
|
+
entry: (command) => ({ hooks: [{ type: "command", command, timeout: 5 }] }),
|
|
168
|
+
commands: (entry) => (entry.hooks || []).map((h) => h.command),
|
|
169
|
+
seed: () => ({})
|
|
170
|
+
},
|
|
171
|
+
codex: {
|
|
172
|
+
name: "Codex",
|
|
173
|
+
cmd: "codex",
|
|
174
|
+
file: () => path.join(os.homedir(), ".codex", "hooks.json"),
|
|
175
|
+
// PermissionRequest runs only when Codex is about to ask for approval,
|
|
176
|
+
// with tool_name, and is present in 0.125's binary. Its docs describe
|
|
177
|
+
// Interrupt and SessionEnd too, but 0.125 has neither - and a strict parser
|
|
178
|
+
// meeting an event it does not know would take every hook down with it.
|
|
179
|
+
events: {
|
|
180
|
+
start: "UserPromptSubmit",
|
|
181
|
+
stop: "Stop",
|
|
182
|
+
tool: "PreToolUse",
|
|
183
|
+
wait: ["PermissionRequest"],
|
|
184
|
+
resume: ["PostToolUse"]
|
|
185
|
+
},
|
|
186
|
+
entry: (command) => ({ hooks: [{ type: "command", command, timeout: 5 }] }),
|
|
187
|
+
commands: (entry) => (entry.hooks || []).map((h) => h.command),
|
|
188
|
+
seed: () => ({}),
|
|
189
|
+
// Codex records a trusted_hash per hook in config.toml and asks before
|
|
190
|
+
// running one it has not seen, so the install is not live until approved.
|
|
191
|
+
note: "Codex asks you to trust each new hook the first time it fires — approve each one once."
|
|
192
|
+
},
|
|
193
|
+
cursor: {
|
|
194
|
+
name: "Cursor",
|
|
195
|
+
cmd: "cursor-agent",
|
|
196
|
+
file: () => path.join(os.homedir(), ".cursor", "hooks.json"),
|
|
197
|
+
events: { start: "beforeSubmitPrompt", stop: "stop", tool: "preToolUse" },
|
|
198
|
+
entry: (command) => ({ command, timeout: 5 }),
|
|
199
|
+
commands: (entry) => (entry.command ? [entry.command] : []),
|
|
200
|
+
seed: () => ({ version: 1 })
|
|
201
|
+
},
|
|
202
|
+
grok: {
|
|
203
|
+
name: "Grok",
|
|
204
|
+
cmd: "grok",
|
|
205
|
+
// Grok reads every *.json in this directory, so we get a file of our own
|
|
206
|
+
// rather than merging into someone else's - which also makes uninstall a
|
|
207
|
+
// delete instead of an edit. `dedicated` says so.
|
|
208
|
+
file: () => path.join(os.homedir(), ".grok", "hooks", "vibeaudio.json"),
|
|
209
|
+
dedicated: true,
|
|
210
|
+
// Directory, not file: detection can't use dirname() like the others.
|
|
211
|
+
configDir: () => path.join(os.homedir(), ".grok"),
|
|
212
|
+
events: { start: "UserPromptSubmit", stop: "Stop", tool: "PreToolUse" },
|
|
213
|
+
entry: (command) => ({ hooks: [{ type: "command", command, timeout: 5 }] }),
|
|
214
|
+
commands: (entry) => (entry.hooks || []).map((h) => h.command),
|
|
215
|
+
seed: () => ({})
|
|
216
|
+
},
|
|
217
|
+
gemini: {
|
|
218
|
+
name: "Gemini CLI",
|
|
219
|
+
cmd: "gemini",
|
|
220
|
+
file: () => path.join(os.homedir(), ".gemini", "settings.json"),
|
|
221
|
+
// Verified against gemini-cli v0.59.0's source (hooks/types.ts,
|
|
222
|
+
// settingsSchema.ts) - the Gemini CLI available to test against predated
|
|
223
|
+
// hooks, so there was no binary to run. Hooks are on by default and
|
|
224
|
+
// user-level ones need no trust step. The permission dialog is a
|
|
225
|
+
// Notification, and it names no tool - so any AfterTool resumes.
|
|
226
|
+
events: {
|
|
227
|
+
start: "BeforeAgent",
|
|
228
|
+
stop: "AfterAgent",
|
|
229
|
+
tool: "BeforeTool",
|
|
230
|
+
wait: ["Notification"],
|
|
231
|
+
resume: ["AfterTool"],
|
|
232
|
+
end: "SessionEnd"
|
|
233
|
+
},
|
|
234
|
+
entry: (command) => ({ hooks: [{ type: "command", command, name: "vibeaudio", timeout: 5000 }] }),
|
|
235
|
+
commands: (entry) => (entry.hooks || []).map((h) => h.command),
|
|
236
|
+
seed: () => ({}),
|
|
237
|
+
// Settings, not events, that may sit in the same `hooks` object.
|
|
238
|
+
configKeys: ["enabled", "disabled", "notifications"],
|
|
239
|
+
// Unlike the four above, not verified to re-read hooks mid-session.
|
|
240
|
+
liveReload: false,
|
|
241
|
+
note: "Not checked whether an open Gemini CLI session reloads hooks — start a new one to be sure."
|
|
242
|
+
},
|
|
243
|
+
copilot: {
|
|
244
|
+
name: "GitHub Copilot CLI",
|
|
245
|
+
cmd: "copilot",
|
|
246
|
+
// Copilot reads every *.json in hooks/, so, like Grok, a file of our own.
|
|
247
|
+
// PascalCase event names select its Claude-compatible payloads
|
|
248
|
+
// (snake_case, tool_name "Bash"); camelCase ones get a different dialect.
|
|
249
|
+
// Verified live on 1.0.80: its PermissionRequest fires before *every*
|
|
250
|
+
// permission check, prompt or not, so the wait signal is the
|
|
251
|
+
// permission_prompt Notification, which names no tool.
|
|
252
|
+
file: () => path.join(process.env.COPILOT_HOME || path.join(os.homedir(), ".copilot"), "hooks", "vibeaudio.json"),
|
|
253
|
+
dedicated: true,
|
|
254
|
+
configDir: () => process.env.COPILOT_HOME || path.join(os.homedir(), ".copilot"),
|
|
255
|
+
events: {
|
|
256
|
+
start: "UserPromptSubmit",
|
|
257
|
+
stop: "Stop",
|
|
258
|
+
tool: "PreToolUse",
|
|
259
|
+
wait: ["Notification"],
|
|
260
|
+
resume: ["PostToolUse", "PostToolUseFailure"],
|
|
261
|
+
end: "SessionEnd"
|
|
262
|
+
},
|
|
263
|
+
entry: (command) => ({ type: "command", command, timeoutSec: 5 }),
|
|
264
|
+
commands: (entry) => (entry.command ? [entry.command] : []),
|
|
265
|
+
seed: () => ({ version: 1 }),
|
|
266
|
+
// Unlike the four above, not verified to re-read hooks mid-session.
|
|
267
|
+
liveReload: false,
|
|
268
|
+
note: "Not checked whether an open Copilot CLI session reloads hooks — start a new one to be sure."
|
|
269
|
+
},
|
|
270
|
+
qwen: {
|
|
271
|
+
name: "Qwen Code",
|
|
272
|
+
cmd: "qwen",
|
|
273
|
+
file: () => path.join(os.homedir(), ".qwen", "settings.json"),
|
|
274
|
+
// Verified against qwen-code v0.23.3's source (hooks/types.ts): Claude
|
|
275
|
+
// Code's event set, including a PermissionRequest raised when the dialog
|
|
276
|
+
// is displayed, with tool_name. Timeouts are milliseconds.
|
|
277
|
+
events: {
|
|
278
|
+
start: "UserPromptSubmit",
|
|
279
|
+
stop: "Stop",
|
|
280
|
+
tool: "PreToolUse",
|
|
281
|
+
wait: ["PermissionRequest"],
|
|
282
|
+
resume: ["PostToolUse", "PostToolUseFailure"],
|
|
283
|
+
failure: "StopFailure",
|
|
284
|
+
end: "SessionEnd"
|
|
285
|
+
},
|
|
286
|
+
entry: (command) => ({ hooks: [{ type: "command", command, name: "vibeaudio", timeout: 5000 }] }),
|
|
287
|
+
commands: (entry) => (entry.hooks || []).map((h) => h.command),
|
|
288
|
+
seed: () => ({}),
|
|
289
|
+
// Unlike the four above, not verified to re-read hooks mid-session.
|
|
290
|
+
liveReload: false,
|
|
291
|
+
note: "Not checked whether an open Qwen Code session reloads hooks — start a new one to be sure."
|
|
292
|
+
}
|
|
293
|
+
};
|
|
294
|
+
|
|
295
|
+
function target(id) {
|
|
296
|
+
const t = TARGETS[id];
|
|
297
|
+
if (!t) throw new Error(`unknown hook target '${id}' — expected one of ${Object.keys(TARGETS).join(", ")}`);
|
|
298
|
+
return t;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function targetFile(id) {
|
|
302
|
+
return target(id).file();
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Which of them are on this machine. The config directory is the reliable
|
|
307
|
+
* signal - Cursor ships no CLI on PATH at all, and a tool that has never run
|
|
308
|
+
* has nothing for us to merge into anyway. PATH is a fallback for the case of
|
|
309
|
+
* a fresh install whose config directory does not exist yet.
|
|
310
|
+
*/
|
|
311
|
+
function detectTargets() {
|
|
312
|
+
const { isInstalled } = require("./interactive");
|
|
313
|
+
return Object.keys(TARGETS).filter((id) => {
|
|
314
|
+
const t = TARGETS[id];
|
|
315
|
+
const dir = t.configDir ? t.configDir() : path.dirname(t.file());
|
|
316
|
+
return fs.existsSync(dir) || isInstalled(t.cmd);
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function settingsPath() {
|
|
321
|
+
return TARGETS.claude.file();
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function readPid() {
|
|
325
|
+
try {
|
|
326
|
+
const pid = parseInt(fs.readFileSync(PID_FILE, "utf8").trim(), 10);
|
|
327
|
+
return Number.isInteger(pid) && pid > 0 ? pid : null;
|
|
328
|
+
} catch (e) {
|
|
329
|
+
return null;
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* A pid file outlives its daemon whenever the daemon dies without cleanup
|
|
335
|
+
* (SIGKILL, crash, reboot), and the OS recycles pids - so the number alone is
|
|
336
|
+
* not proof of what it now names. hookStart calls stopDaemon on every prompt,
|
|
337
|
+
* so an unverified kill would eventually SIGTERM an unrelated process.
|
|
338
|
+
*
|
|
339
|
+
* Failing closed is the safe direction here: a daemon we decline to kill stops
|
|
340
|
+
* itself at MAX_DAEMON_MS, while killing a stranger's process has no such
|
|
341
|
+
* ceiling.
|
|
342
|
+
*
|
|
343
|
+
* ponytail: posix only. Windows has no cheap command-line lookup, so the pid
|
|
344
|
+
* is trusted there as before; revisit if hooks see real Windows use.
|
|
345
|
+
*/
|
|
346
|
+
function isOurDaemon(pid) {
|
|
347
|
+
if (process.platform === "win32") return true;
|
|
348
|
+
try {
|
|
349
|
+
const out = execFileSync("ps", ["-p", String(pid), "-o", "args="], {
|
|
350
|
+
encoding: "utf8",
|
|
351
|
+
stdio: ["ignore", "pipe", "ignore"]
|
|
352
|
+
});
|
|
353
|
+
return out.includes("vibeaudio") && out.includes("--daemon");
|
|
354
|
+
} catch (e) {
|
|
355
|
+
return false; // No such process, or ps unavailable - either way, do not kill.
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* `pause` keeps the turn's state (what resumes it, whose it is): only the hooks
|
|
361
|
+
* pausing mid-turn pass it. Every other stop (new prompt, turn end, --stop,
|
|
362
|
+
* uninstall) ends the turn, or a later tool call would resume music nobody is
|
|
363
|
+
* waiting for.
|
|
364
|
+
*/
|
|
365
|
+
function stopDaemon({ pause = false } = {}) {
|
|
366
|
+
const pid = readPid();
|
|
367
|
+
fs.rmSync(PID_FILE, { force: true });
|
|
368
|
+
if (!pause) endTurnState();
|
|
369
|
+
if (pid === null || !isOurDaemon(pid)) return false;
|
|
370
|
+
|
|
371
|
+
try {
|
|
372
|
+
process.kill(pid, "SIGTERM");
|
|
373
|
+
return true;
|
|
374
|
+
} catch (e) {
|
|
375
|
+
return false; // Already gone
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function endTurnState() {
|
|
380
|
+
fs.rmSync(WAITING_FILE, { force: true });
|
|
381
|
+
fs.rmSync(TURN_FILE, { force: true });
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function fileSize(file) {
|
|
385
|
+
try {
|
|
386
|
+
return fs.statSync(file).size;
|
|
387
|
+
} catch (e) {
|
|
388
|
+
return 0;
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* A turn starts at a prompt. The transcript offset is taken here, not when a
|
|
394
|
+
* daemon spawns, because a resume respawns the daemon mid-turn - and an
|
|
395
|
+
* interrupt written just before that must still count.
|
|
396
|
+
*/
|
|
397
|
+
function newTurn(raw) {
|
|
398
|
+
const payload = parsePayload(raw);
|
|
399
|
+
const transcript = typeof payload.transcript_path === "string" ? payload.transcript_path : "";
|
|
400
|
+
return { session: String(payload.session_id || ""), transcript, offset: transcript ? fileSize(transcript) : 0 };
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function readTurn() {
|
|
404
|
+
try {
|
|
405
|
+
return JSON.parse(fs.readFileSync(TURN_FILE, "utf8"));
|
|
406
|
+
} catch (e) {
|
|
407
|
+
return null;
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// Claude Code's entry for Esc / the stop button: a user message whose text is
|
|
412
|
+
// "[Request interrupted by user]" or "... for tool use]".
|
|
413
|
+
const INTERRUPT_MARK = "[Request interrupted by user";
|
|
414
|
+
|
|
415
|
+
function isInterruptEntry(line) {
|
|
416
|
+
if (!line.includes(INTERRUPT_MARK)) return false; // Cheap filter before parsing.
|
|
417
|
+
try {
|
|
418
|
+
const entry = JSON.parse(line);
|
|
419
|
+
if (entry.type !== "user" || !entry.message) return false;
|
|
420
|
+
// Structural, not substring: a transcript that merely quotes the phrase -
|
|
421
|
+
// in a tool result, a file, a prompt about this very feature - is not one.
|
|
422
|
+
const content = entry.message.content;
|
|
423
|
+
const texts = typeof content === "string"
|
|
424
|
+
? [content]
|
|
425
|
+
: Array.isArray(content) ? content.filter((b) => b && b.type === "text").map((b) => b.text) : [];
|
|
426
|
+
return texts.some((t) => typeof t === "string" && t.startsWith(INTERRUPT_MARK));
|
|
427
|
+
} catch (e) {
|
|
428
|
+
return false;
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/**
|
|
433
|
+
* Interrupting Claude Code (Esc in the terminal, stop in the desktop app or an
|
|
434
|
+
* IDE) fires no hook at all - not Stop, not StopFailure, and mid-tool only a
|
|
435
|
+
* plain PostToolUse. What it always does is append an interrupt entry to the
|
|
436
|
+
* transcript. Returns a poll that reads only what was appended since `offset`
|
|
437
|
+
* and reports whether one arrived.
|
|
438
|
+
*/
|
|
439
|
+
function interruptWatcher(transcript, offset) {
|
|
440
|
+
const { StringDecoder } = require("string_decoder");
|
|
441
|
+
const decoder = new StringDecoder("utf8"); // A read can split a multibyte character.
|
|
442
|
+
let pos = offset;
|
|
443
|
+
let partial = "";
|
|
444
|
+
|
|
445
|
+
return () => {
|
|
446
|
+
try {
|
|
447
|
+
const size = fileSize(transcript);
|
|
448
|
+
if (size <= pos) return false;
|
|
449
|
+
const fd = fs.openSync(transcript, "r");
|
|
450
|
+
const buf = Buffer.alloc(size - pos);
|
|
451
|
+
try {
|
|
452
|
+
fs.readSync(fd, buf, 0, buf.length, pos);
|
|
453
|
+
} finally {
|
|
454
|
+
fs.closeSync(fd);
|
|
455
|
+
}
|
|
456
|
+
pos = size;
|
|
457
|
+
const lines = (partial + decoder.write(buf)).split("\n");
|
|
458
|
+
partial = lines.pop(); // Not a whole line yet.
|
|
459
|
+
return lines.some(isInterruptEntry);
|
|
460
|
+
} catch (e) {
|
|
461
|
+
return false; // Unreadable transcript: the MAX_DAEMON_MS ceiling still applies.
|
|
462
|
+
}
|
|
463
|
+
};
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
const INTERRUPT_POLL_MS = 500;
|
|
467
|
+
|
|
468
|
+
/**
|
|
469
|
+
* Internal mode: holds the audio open until told to stop. The player's own
|
|
470
|
+
* loop timer keeps the event loop alive.
|
|
471
|
+
*/
|
|
472
|
+
function runDaemon(genre, volume, { reactive = false } = {}) {
|
|
473
|
+
const turn = readTurn();
|
|
474
|
+
const interrupted = turn && turn.transcript ? interruptWatcher(turn.transcript, turn.offset) : null;
|
|
475
|
+
|
|
476
|
+
// Silent, like Ctrl+C in the wrapper: an interrupt is never reported as done.
|
|
477
|
+
const endInterrupted = () => {
|
|
478
|
+
if (readPid() === process.pid) {
|
|
479
|
+
fs.rmSync(PID_FILE, { force: true });
|
|
480
|
+
endTurnState();
|
|
481
|
+
fs.rmSync(INTENSITY_FILE, { force: true });
|
|
482
|
+
}
|
|
483
|
+
process.exit(0);
|
|
484
|
+
};
|
|
485
|
+
// Already interrupted before this daemon started - a resume respawned by the
|
|
486
|
+
// PostToolUse that an interrupted tool still fires.
|
|
487
|
+
if (interrupted && interrupted()) endInterrupted();
|
|
488
|
+
|
|
489
|
+
const player = new AudioPlayer();
|
|
490
|
+
const started = player.start(genre, volume, {
|
|
491
|
+
intensity: reactive ? readIntensity : null
|
|
492
|
+
});
|
|
493
|
+
if (!started) process.exit(0);
|
|
494
|
+
|
|
495
|
+
const shutdown = () => {
|
|
496
|
+
player.stop({ playChime: false });
|
|
497
|
+
process.exit(0);
|
|
498
|
+
};
|
|
499
|
+
|
|
500
|
+
process.on("SIGTERM", shutdown);
|
|
501
|
+
process.on("SIGINT", shutdown);
|
|
502
|
+
setTimeout(shutdown, MAX_DAEMON_MS);
|
|
503
|
+
|
|
504
|
+
if (interrupted) {
|
|
505
|
+
setInterval(() => {
|
|
506
|
+
if (!interrupted()) return;
|
|
507
|
+
player.stop({ playChime: false });
|
|
508
|
+
endInterrupted();
|
|
509
|
+
}, INTERRUPT_POLL_MS);
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
function hookStart(genre, volume, { reactive = false, turn = null } = {}) {
|
|
514
|
+
stopDaemon(); // Single instance: a new prompt replaces the previous run
|
|
515
|
+
fs.mkdirSync(STATE_DIR, { recursive: true });
|
|
516
|
+
fs.rmSync(INTENSITY_FILE, { force: true }); // Don't inherit the last prompt's activity
|
|
517
|
+
// Before the spawn: the daemon reads it on startup.
|
|
518
|
+
if (turn) fs.writeFileSync(TURN_FILE, JSON.stringify(turn));
|
|
519
|
+
|
|
520
|
+
const args = [CLI_ENTRY, "--daemon", "--genre", genre, "--volume", String(Math.round(volume * 100))];
|
|
521
|
+
if (reactive) args.push("--reactive");
|
|
522
|
+
|
|
523
|
+
const child = spawn(process.execPath, args, { detached: true, stdio: "ignore" });
|
|
524
|
+
child.unref();
|
|
525
|
+
|
|
526
|
+
fs.writeFileSync(PID_FILE, String(child.pid));
|
|
527
|
+
return child.pid;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
/**
|
|
531
|
+
* What the agent's stop event says about how the turn ended. Cursor reports
|
|
532
|
+
* it in `status` ("completed", "aborted" or "error"). Claude Code's Stop
|
|
533
|
+
* carries no verdict, but an API error ends the turn with StopFailure
|
|
534
|
+
* *instead* of Stop, so that event is the verdict. Codex's StopRequest has
|
|
535
|
+
* none, so it keeps the success chime - the alternative would be inventing a
|
|
536
|
+
* failure the agent never claimed.
|
|
537
|
+
*/
|
|
538
|
+
function outcomeFromPayload(raw) {
|
|
539
|
+
const payload = parsePayload(raw);
|
|
540
|
+
if (payload.hook_event_name === "StopFailure") return "failure";
|
|
541
|
+
const status = String(payload.status || "").toLowerCase();
|
|
542
|
+
return status === "error" || status === "aborted" ? "failure" : "success";
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
/**
|
|
546
|
+
* Reads the hook's stdin payload, then hands it on. A hook must never hang
|
|
547
|
+
* the agent waiting for input that isn't coming, hence the timeout.
|
|
548
|
+
*/
|
|
549
|
+
function readPayload(done, timeoutMs = 500) {
|
|
550
|
+
let raw = "";
|
|
551
|
+
let settled = false;
|
|
552
|
+
const collect = (chunk) => {
|
|
553
|
+
raw += chunk;
|
|
554
|
+
};
|
|
555
|
+
|
|
556
|
+
const finish = () => {
|
|
557
|
+
if (settled) return;
|
|
558
|
+
settled = true;
|
|
559
|
+
// Reading stdin resumes it, and a resumed stdin keeps the event loop
|
|
560
|
+
// alive on its own. Without letting go here the hook process outlives
|
|
561
|
+
// its work and sits there until the agent's own timeout kills it -
|
|
562
|
+
// on every single turn.
|
|
563
|
+
process.stdin.removeListener("data", collect);
|
|
564
|
+
process.stdin.removeListener("end", finish);
|
|
565
|
+
process.stdin.removeListener("error", finish);
|
|
566
|
+
process.stdin.pause();
|
|
567
|
+
done(raw);
|
|
568
|
+
};
|
|
569
|
+
|
|
570
|
+
process.stdin.setEncoding("utf8");
|
|
571
|
+
process.stdin.on("data", collect);
|
|
572
|
+
process.stdin.on("end", finish);
|
|
573
|
+
process.stdin.on("error", finish);
|
|
574
|
+
setTimeout(finish, timeoutMs).unref();
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
function hookStop({ outcome = "success", volume = 0.4, chimeVolume = null, noChime = false } = {}) {
|
|
578
|
+
// Read before stopDaemon clears it. A turn that ends while paused for the
|
|
579
|
+
// user - a denied tool that nothing resumed after - still finished.
|
|
580
|
+
const wasWaiting = fs.existsSync(WAITING_FILE);
|
|
581
|
+
const wasPlaying = stopDaemon();
|
|
582
|
+
fs.rmSync(INTENSITY_FILE, { force: true });
|
|
583
|
+
if (!(wasPlaying || wasWaiting) || noChime) return false;
|
|
584
|
+
|
|
585
|
+
// Chime plays in this short-lived hook process, after the daemon is gone.
|
|
586
|
+
new AudioPlayer().stop({ playChime: true, outcome, volume, chimeVolume });
|
|
587
|
+
return true;
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
function readWaiting() {
|
|
591
|
+
try {
|
|
592
|
+
return fs.readFileSync(WAITING_FILE, "utf8");
|
|
593
|
+
} catch (e) {
|
|
594
|
+
return null;
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
/**
|
|
599
|
+
* What a wait is waiting for, and what ends it: the tool's name for a
|
|
600
|
+
* permission dialog and its PostToolUse, the server's name for an MCP
|
|
601
|
+
* elicitation and its ElicitationResult. PermissionRequest carries no
|
|
602
|
+
* tool_use_id, hence names.
|
|
603
|
+
*/
|
|
604
|
+
function waitKey(raw) {
|
|
605
|
+
const server = parsePayload(raw).mcp_server_name;
|
|
606
|
+
return payloadToolName(raw) || (server ? `mcp:${server}` : "");
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
// Notification types that mean "blocked on the user". Agents whose permission
|
|
610
|
+
// dialog is a Notification send every other kind through the same event.
|
|
611
|
+
const WAIT_NOTIFICATIONS = new Set([
|
|
612
|
+
"permission_prompt", // Copilot CLI
|
|
613
|
+
"elicitation_dialog", // Copilot CLI
|
|
614
|
+
"ToolPermission" // Gemini CLI
|
|
615
|
+
]);
|
|
616
|
+
|
|
617
|
+
/**
|
|
618
|
+
* The agent is blocked on the user (a permission dialog, a question, a plan to
|
|
619
|
+
* approve, an MCP server asking for input). Music that keeps playing says
|
|
620
|
+
* "still working", which is the one thing that is not true, so it stops and
|
|
621
|
+
* the attention chime asks instead.
|
|
622
|
+
*
|
|
623
|
+
* Only while music is playing: after the turn has ended there is nobody to
|
|
624
|
+
* interrupt, and a second dialog while already waiting keeps the first wait
|
|
625
|
+
* rather than chiming twice.
|
|
626
|
+
*
|
|
627
|
+
* The chime is detached: the agent waits on this hook before showing the
|
|
628
|
+
* dialog, and a chime played to completion here held it back for its length.
|
|
629
|
+
*/
|
|
630
|
+
function hookWait(raw, { volume = 0.4, chimeVolume = null, noChime = false } = {}) {
|
|
631
|
+
const payload = parsePayload(raw);
|
|
632
|
+
const type = payload.notification_type ?? payload.notificationType;
|
|
633
|
+
if (type !== undefined && !WAIT_NOTIFICATIONS.has(String(type))) return false;
|
|
634
|
+
|
|
635
|
+
if (!stopDaemon({ pause: true })) return false;
|
|
636
|
+
fs.writeFileSync(WAITING_FILE, waitKey(raw));
|
|
637
|
+
if (!noChime) new AudioPlayer().stop({ playChime: true, outcome: "attention", volume, chimeVolume, detach: true });
|
|
638
|
+
return true;
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
/**
|
|
642
|
+
* A tool call or an elicitation has ended. Nothing fires on an approval
|
|
643
|
+
* itself, so this is the first signal that work carries on after one - resume,
|
|
644
|
+
* if it is the one being waited for.
|
|
645
|
+
*
|
|
646
|
+
* The turn is carried over rather than started afresh, so the new daemon keeps
|
|
647
|
+
* watching the transcript from the prompt. An approved tool interrupted with
|
|
648
|
+
* Esc still fires this PostToolUse, and must not bring the music back.
|
|
649
|
+
*
|
|
650
|
+
* ponytail: matching by name means two same-named tools in one parallel batch,
|
|
651
|
+
* one needing approval, can resume early - after the chime already did its
|
|
652
|
+
* job. Match on tool_input as well if that ever shows up in practice.
|
|
653
|
+
*/
|
|
654
|
+
function hookResume(raw, genre, volume, { reactive = false } = {}) {
|
|
655
|
+
const waitingFor = readWaiting();
|
|
656
|
+
if (waitingFor === null) return false; // Not waiting - the common case, on every tool call.
|
|
657
|
+
// An empty key is a wait that named nothing (a Notification): the next tool
|
|
658
|
+
// to finish is the first sign of work carrying on.
|
|
659
|
+
if (waitingFor !== "" && waitingFor !== waitKey(raw)) return false;
|
|
660
|
+
|
|
661
|
+
hookStart(genre, volume, { reactive, turn: readTurn() || newTurn(raw) });
|
|
662
|
+
return true;
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
/**
|
|
666
|
+
* The agent's session is over (quit, /clear, logout). A turn cut off by it
|
|
667
|
+
* never reaches Stop. Silent: nothing finished.
|
|
668
|
+
*
|
|
669
|
+
* Only the session that started the music may end it. SessionEnd also fires
|
|
670
|
+
* for idle sessions, and one terminal closing must not silence another that is
|
|
671
|
+
* mid-turn. Fails closed: with no recorded session there is no proof the music
|
|
672
|
+
* is this session's, and the MAX_DAEMON_MS ceiling still applies.
|
|
673
|
+
*/
|
|
674
|
+
function hookEnd(raw) {
|
|
675
|
+
const turn = readTurn();
|
|
676
|
+
const owner = turn && turn.session;
|
|
677
|
+
if (!owner || owner !== String(parsePayload(raw).session_id || "")) return false;
|
|
678
|
+
|
|
679
|
+
stopDaemon();
|
|
680
|
+
fs.rmSync(INTENSITY_FILE, { force: true });
|
|
681
|
+
return true;
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
/**
|
|
685
|
+
* The hook command is a string the agent hands to a shell, so every path in it
|
|
686
|
+
* has to survive that shell. Double quotes do not: `$` and a backtick expand
|
|
687
|
+
* inside them and a `"` ends the quote outright, so installing from a path
|
|
688
|
+
* like /tmp/dollar$dir wrote a command the shell quietly rewrote into a
|
|
689
|
+
* different one - and the install reported success. A hook that fails on every
|
|
690
|
+
* prompt while claiming to be installed is the exact failure
|
|
691
|
+
* ephemeralInstallReason() exists to prevent.
|
|
692
|
+
*
|
|
693
|
+
* POSIX gets single quotes, which expand nothing, plus the one escape a single
|
|
694
|
+
* quote itself needs. Windows keeps double quotes: cmd expands neither `$` nor
|
|
695
|
+
* a backtick, and `"` is not legal in a Windows path to begin with.
|
|
696
|
+
*/
|
|
697
|
+
function shellQuote(value) {
|
|
698
|
+
if (process.platform === "win32") return `"${value}"`;
|
|
699
|
+
return `'${String(value).replace(/'/g, "'\\''")}'`;
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
function hookCommand(flag, genre, volume, reactive = false) {
|
|
703
|
+
const base = `${shellQuote(process.execPath)} ${shellQuote(CLI_ENTRY)} ${flag}`;
|
|
704
|
+
// Resuming respawns the daemon, so it needs the same settings as starting.
|
|
705
|
+
if (flag !== "--hook-start" && flag !== "--hook-resume") return base;
|
|
706
|
+
|
|
707
|
+
const start = `${base} --genre ${genre} --volume ${Math.round(volume * 100)}`;
|
|
708
|
+
return reactive ? `${start} --reactive` : start;
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
const VIBE_HOOK_FLAG = /--hook-(start|stop|tool|wait|resume|end)\b/;
|
|
712
|
+
|
|
713
|
+
function isVibeHook(entry, id = "claude") {
|
|
714
|
+
return target(id)
|
|
715
|
+
.commands(entry)
|
|
716
|
+
.some((c) => typeof c === "string" && VIBE_HOOK_FLAG.test(c));
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
function setHook(hooks, event, command, id) {
|
|
720
|
+
// Replacing our own entries keeps repeat installs idempotent and leaves
|
|
721
|
+
// every other tool's hooks untouched. Appending rather than prepending also
|
|
722
|
+
// keeps the existing entries at their original index, which is what Codex
|
|
723
|
+
// keys its per-hook trust records by.
|
|
724
|
+
const kept = (hooks[event] || []).filter((entry) => !isVibeHook(entry, id));
|
|
725
|
+
kept.push(target(id).entry(command));
|
|
726
|
+
hooks[event] = kept;
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
/** [event, entries] for every event in a hooks object, skipping settings keys. */
|
|
730
|
+
function hookEntries(hooks, t) {
|
|
731
|
+
const configKeys = (t && t.configKeys) || [];
|
|
732
|
+
return Object.entries(hooks || {}).filter(([key]) => !configKeys.includes(key));
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
function readVibeEntryCount(file, id) {
|
|
736
|
+
try {
|
|
737
|
+
const { settings } = loadSettings(file, target(id));
|
|
738
|
+
return hookEntries(settings.hooks, target(id)).reduce(
|
|
739
|
+
(n, [, entries]) => n + entries.filter((e) => isVibeHook(e, id)).length,
|
|
740
|
+
0
|
|
741
|
+
);
|
|
742
|
+
} catch (e) {
|
|
743
|
+
return 0; // Unreadable: the delete below still cleans it up.
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
function loadSettings(file, t = null) {
|
|
748
|
+
if (!fs.existsSync(file)) return { settings: t ? t.seed() : {}, raw: null };
|
|
749
|
+
const raw = fs.readFileSync(file, "utf8");
|
|
750
|
+
|
|
751
|
+
let settings;
|
|
752
|
+
try {
|
|
753
|
+
settings = JSON.parse(raw);
|
|
754
|
+
} catch (e) {
|
|
755
|
+
throw new Error(`${file} is not valid JSON (${e.message}) — refusing to overwrite it.`);
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
// Valid JSON in the wrong shape used to reach the callers and fail there as
|
|
759
|
+
// "entries.filter is not a function", which tells the user nothing about
|
|
760
|
+
// their own file. Checked here because install, uninstall and the entry
|
|
761
|
+
// count all come through this function.
|
|
762
|
+
if (settings === null || typeof settings !== "object" || Array.isArray(settings)) {
|
|
763
|
+
throw new Error(`${file} does not contain a JSON object — refusing to overwrite it.`);
|
|
764
|
+
}
|
|
765
|
+
if (settings.hooks !== undefined) {
|
|
766
|
+
if (settings.hooks === null || typeof settings.hooks !== "object" || Array.isArray(settings.hooks)) {
|
|
767
|
+
throw new Error(`${file} has a "hooks" key that is not an object — refusing to overwrite it.`);
|
|
768
|
+
}
|
|
769
|
+
for (const [event, entries] of hookEntries(settings.hooks, t)) {
|
|
770
|
+
if (!Array.isArray(entries)) {
|
|
771
|
+
throw new Error(
|
|
772
|
+
`${file} has hooks.${event} as ${Array.isArray(entries) ? "an array" : typeof entries}, ` +
|
|
773
|
+
`not an array of entries — refusing to overwrite it.`
|
|
774
|
+
);
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
return { settings, raw };
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
/**
|
|
783
|
+
* Hooks record an absolute path to this CLI, so installing from a throwaway
|
|
784
|
+
* `npx` checkout writes a path npm will eventually evict — leaving every
|
|
785
|
+
* prompt firing a hook that silently fails. Refuse rather than plant that.
|
|
786
|
+
*/
|
|
787
|
+
function ephemeralInstallReason(entry = CLI_ENTRY) {
|
|
788
|
+
const dir = entry.split(path.sep);
|
|
789
|
+
if (dir.includes("_npx")) return "npx";
|
|
790
|
+
if (dir.includes(".npm-cache") || dir.includes("_cacache")) return "npm cache";
|
|
791
|
+
return null;
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
/**
|
|
795
|
+
* `dryRun` computes the result without touching the disk - no directory, no
|
|
796
|
+
* backup, no write - and returns it with the file's current contents, so the
|
|
797
|
+
* caller can show exactly what would change in a file that belongs to the user.
|
|
798
|
+
*/
|
|
799
|
+
function installHooks(genre = "lofi", volume = 0.4, file = null, { reactive = false, id = "claude", dryRun = false } = {}) {
|
|
800
|
+
const t = target(id);
|
|
801
|
+
file = file || t.file();
|
|
802
|
+
const ephemeral = ephemeralInstallReason();
|
|
803
|
+
if (ephemeral) {
|
|
804
|
+
throw new Error(
|
|
805
|
+
`refusing to install hooks from a temporary ${ephemeral} checkout — the path ` +
|
|
806
|
+
`(${CLI_ENTRY}) is deleted when the cache is cleared, which would leave Claude Code ` +
|
|
807
|
+
`running a broken hook on every prompt.\n\n` +
|
|
808
|
+
`Install it for real first, then re-run:\n\n` +
|
|
809
|
+
` npm i -g github:kiril6/vibeaudio\n vibe --install-hooks\n`
|
|
810
|
+
);
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
const { settings, raw } = loadSettings(file, t);
|
|
814
|
+
if (!dryRun) fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
815
|
+
let backup = null;
|
|
816
|
+
// Only worth backing up a file that holds someone else's config. A dedicated
|
|
817
|
+
// target's file is ours alone, so a backup of it would just be a copy of our
|
|
818
|
+
// own last install, left behind after the uninstall deletes the original.
|
|
819
|
+
//
|
|
820
|
+
// Written once and never overwritten: the second --install-hooks reads a
|
|
821
|
+
// file that already contains our entries, so re-backing up would replace the
|
|
822
|
+
// user's actual pre-VibeAudio config with a copy of our own last install -
|
|
823
|
+
// while uninstall goes on calling it "your pre-VibeAudio config backup".
|
|
824
|
+
// The first one is the only one that is true.
|
|
825
|
+
if (raw !== null && !t.dedicated) {
|
|
826
|
+
const backupFile = `${file}.vibeaudio.bak`;
|
|
827
|
+
if (!fs.existsSync(backupFile)) {
|
|
828
|
+
if (!dryRun) fs.writeFileSync(backupFile, raw);
|
|
829
|
+
backup = backupFile;
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
const ev = t.events;
|
|
834
|
+
settings.hooks = settings.hooks || {};
|
|
835
|
+
setHook(settings.hooks, ev.start, hookCommand("--hook-start", genre, volume, reactive), id);
|
|
836
|
+
setHook(settings.hooks, ev.stop, hookCommand("--hook-stop", genre, volume), id);
|
|
837
|
+
|
|
838
|
+
// Only reactive mode needs per-tool-call signalling.
|
|
839
|
+
if (reactive) {
|
|
840
|
+
setHook(settings.hooks, ev.tool, hookCommand("--hook-tool", genre, volume), id);
|
|
841
|
+
} else if (settings.hooks[ev.tool]) {
|
|
842
|
+
const kept = settings.hooks[ev.tool].filter((entry) => !isVibeHook(entry, id));
|
|
843
|
+
if (kept.length) settings.hooks[ev.tool] = kept;
|
|
844
|
+
else delete settings.hooks[ev.tool];
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
for (const event of ev.wait || []) {
|
|
848
|
+
setHook(settings.hooks, event, hookCommand("--hook-wait", genre, volume), id);
|
|
849
|
+
}
|
|
850
|
+
// The agent awaits a resume before the next tool's permission check, so a
|
|
851
|
+
// resume can never land after the next wait.
|
|
852
|
+
// ponytail: one ~40ms node start per tool call; a shell-side existence
|
|
853
|
+
// check on the waiting file would skip it if that ever shows.
|
|
854
|
+
for (const event of ev.resume || []) {
|
|
855
|
+
setHook(settings.hooks, event, hookCommand("--hook-resume", genre, volume, reactive), id);
|
|
856
|
+
}
|
|
857
|
+
if (ev.failure) setHook(settings.hooks, ev.failure, hookCommand("--hook-stop", genre, volume), id);
|
|
858
|
+
if (ev.end) setHook(settings.hooks, ev.end, hookCommand("--hook-end", genre, volume), id);
|
|
859
|
+
|
|
860
|
+
const after = `${JSON.stringify(settings, null, 2)}\n`;
|
|
861
|
+
if (!dryRun) fs.writeFileSync(file, after);
|
|
862
|
+
return { file, backup, reactive, id, name: t.name, note: t.note || null, events: ev, before: raw, after, dryRun };
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
/**
|
|
866
|
+
* `/vibe` inside Claude Code: a command file, not a hook - it asks the model to
|
|
867
|
+
* run the CLI, so mute, stop and genre changes need no second terminal. The
|
|
868
|
+
* marker is how install and uninstall tell our file from a user's own vibe.md,
|
|
869
|
+
* which neither may overwrite or delete.
|
|
870
|
+
*/
|
|
871
|
+
const SLASH_MARK = "<!-- vibeaudio:slash-command -->";
|
|
872
|
+
|
|
873
|
+
function slashCommandFile() {
|
|
874
|
+
return path.join(os.homedir(), ".claude", "commands", "vibe.md");
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
function slashCommandText() {
|
|
878
|
+
const cli = `${shellQuote(process.execPath)} ${shellQuote(CLI_ENTRY)}`;
|
|
879
|
+
return `---
|
|
880
|
+
description: Control VibeAudio - status, mute, unmute, stop, or change genre or volume
|
|
881
|
+
argument-hint: "[status | mute [minutes] | unmute | stop | genre <name> | volume <5-100>]"
|
|
882
|
+
---
|
|
883
|
+
${SLASH_MARK}
|
|
884
|
+
Control VibeAudio, the focus music that plays while you work, for the user.
|
|
885
|
+
Arguments: \`$ARGUMENTS\`
|
|
886
|
+
|
|
887
|
+
Run the one matching command with the Bash tool, then report the result in a
|
|
888
|
+
single short sentence. Do nothing else.
|
|
889
|
+
|
|
890
|
+
- no arguments, or \`status\`: \`${cli} --status\`
|
|
891
|
+
- \`mute\` or \`mute <minutes>\`: \`${cli} --mute <minutes>\`
|
|
892
|
+
- \`unmute\`: \`${cli} --unmute\`
|
|
893
|
+
- \`stop\`: \`${cli} --stop\`
|
|
894
|
+
- \`genre <name>\` or \`volume <5-100>\`: first run \`${cli} --status\` and read
|
|
895
|
+
Claude Code's current genre, volume, and whether it says "reactive". Then run
|
|
896
|
+
\`${cli} --install-hooks --tools claude --genre <genre> --volume <volume>\`,
|
|
897
|
+
adding \`--reactive\` if it was reactive, and changing only what was asked.
|
|
898
|
+
Genres: lofi, synthwave, 8bit, electronic, jazz, zen, piano, drone, random.
|
|
899
|
+
|
|
900
|
+
For anything else, show the user the list above instead of running a command.
|
|
901
|
+
`;
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
function installSlashCommand({ file = slashCommandFile(), dryRun = false } = {}) {
|
|
905
|
+
if (fs.existsSync(file) && !fs.readFileSync(file, "utf8").includes(SLASH_MARK)) {
|
|
906
|
+
return { file, installed: false, reason: "a vibe.md that is not ours is already there" };
|
|
907
|
+
}
|
|
908
|
+
if (!dryRun) {
|
|
909
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
910
|
+
fs.writeFileSync(file, slashCommandText());
|
|
911
|
+
}
|
|
912
|
+
return { file, installed: true };
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
function uninstallSlashCommand({ file = slashCommandFile() } = {}) {
|
|
916
|
+
if (!fs.existsSync(file) || !fs.readFileSync(file, "utf8").includes(SLASH_MARK)) return false;
|
|
917
|
+
fs.rmSync(file, { force: true });
|
|
918
|
+
return true;
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
function uninstallHooks(file = null, { id = "claude" } = {}) {
|
|
922
|
+
const t = target(id);
|
|
923
|
+
file = file || t.file();
|
|
924
|
+
if (!fs.existsSync(file)) return { file, removed: 0, id };
|
|
925
|
+
|
|
926
|
+
// Our own file has nothing of the user's in it, so removing our entries
|
|
927
|
+
// would just leave an empty husk in a directory the tool scans.
|
|
928
|
+
if (t.dedicated) {
|
|
929
|
+
const removed = readVibeEntryCount(file, id);
|
|
930
|
+
fs.rmSync(file, { force: true });
|
|
931
|
+
return { file, removed, id };
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
const { settings } = loadSettings(file, t);
|
|
935
|
+
if (!settings.hooks) return { file, removed: 0, id };
|
|
936
|
+
|
|
937
|
+
let removed = 0;
|
|
938
|
+
for (const [event, entries] of hookEntries(settings.hooks, t)) {
|
|
939
|
+
const kept = entries.filter((entry) => !isVibeHook(entry, id));
|
|
940
|
+
removed += entries.length - kept.length;
|
|
941
|
+
|
|
942
|
+
if (kept.length) settings.hooks[event] = kept;
|
|
943
|
+
else delete settings.hooks[event];
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
if (Object.keys(settings.hooks).length === 0) delete settings.hooks;
|
|
947
|
+
fs.writeFileSync(file, `${JSON.stringify(settings, null, 2)}\n`);
|
|
948
|
+
return { file, removed, id };
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
module.exports = {
|
|
952
|
+
shellQuote,
|
|
953
|
+
runDaemon,
|
|
954
|
+
hookStart,
|
|
955
|
+
hookStop,
|
|
956
|
+
hookTool,
|
|
957
|
+
hookWait,
|
|
958
|
+
hookResume,
|
|
959
|
+
hookEnd,
|
|
960
|
+
newTurn,
|
|
961
|
+
outcomeFromPayload,
|
|
962
|
+
readPayload,
|
|
963
|
+
toolTier,
|
|
964
|
+
readIntensity,
|
|
965
|
+
stopDaemon,
|
|
966
|
+
isOurDaemon,
|
|
967
|
+
installHooks,
|
|
968
|
+
ephemeralInstallReason,
|
|
969
|
+
uninstallHooks,
|
|
970
|
+
settingsPath,
|
|
971
|
+
isVibeHook,
|
|
972
|
+
hookEntries,
|
|
973
|
+
installSlashCommand,
|
|
974
|
+
uninstallSlashCommand,
|
|
975
|
+
VIBE_HOOK_FLAG,
|
|
976
|
+
TARGETS,
|
|
977
|
+
targetFile,
|
|
978
|
+
detectTargets,
|
|
979
|
+
PID_FILE
|
|
980
|
+
};
|