@lucas_zaia/agent-voice 1.0.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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Lucas Zaia
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,164 @@
1
+ # agent-voice
2
+
3
+ **Make your coding agent talk to you.** When Claude Code or Codex finishes a
4
+ long task — or gets stuck waiting for your permission — a speaker says so out
5
+ loud:
6
+
7
+ > *"Claude Code terminou na sessão Home assistant repo, depois de cerca de 4
8
+ > minutos. Você tinha pedido: criar o docker compose."*
9
+
10
+ > *"Codex precisa de você na sessão azul, do projeto api, para usar o Bash."*
11
+
12
+ Works on macOS, Windows and Linux. Speaks through an Echo (via Home Assistant),
13
+ your computer's own voice, or any command you like.
14
+
15
+ ## Install
16
+
17
+ Requires Node.js 20 or newer.
18
+
19
+ ```bash
20
+ npm install -g @lucas_zaia/agent-voice
21
+ agent-voice setup
22
+ ```
23
+
24
+ `setup` finds your agents, adds its hooks to them (showing you exactly what it
25
+ adds, and backing up the file first), asks how you want it to speak, and says a
26
+ test sentence so you know it works.
27
+
28
+ **Windows PowerShell** blocks npm's `.ps1` launchers by default ("execução de
29
+ scripts foi desabilitada" / "running scripts is disabled"). Either allow them
30
+ for your user once — `Set-ExecutionPolicy -Scope CurrentUser RemoteSigned` —
31
+ or call `npm.cmd` and `agent-voice.cmd` instead. The agent hooks are not
32
+ affected: they run `agent-voice.cmd`.
33
+
34
+ Running from a checkout instead? Either `npm link` in the checkout (so
35
+ `agent-voice` is on your PATH), or tell the hooks how to call it before
36
+ connecting:
37
+
38
+ ```bash
39
+ AV_HOOK_COMMAND="node /path/to/agent-voice/bin/agent-voice.js" agent-voice connect claude-code
40
+ ```
41
+
42
+ `AV_HOOK_COMMAND` is the command written into the agent's hooks (default
43
+ `agent-voice`); it is read when you run `setup` or `connect`.
44
+
45
+ ## Why
46
+
47
+ You ask an agent for something that takes a while and stop watching. Then
48
+ either the work finishes and sits there, or — worse — it stopped thirty seconds
49
+ in at a permission prompt. A popup appears in the window you stopped looking
50
+ at. A voice does not. agent-voice follows two rules:
51
+
52
+ - **Say something specific**: which session, and what you had asked for.
53
+ - **Stay quiet most of the time**: short turns are never announced.
54
+
55
+ ## When it speaks
56
+
57
+ | Situation | What happens |
58
+ |---|---|
59
+ | A turn finishes in under 30s | Silent |
60
+ | A turn finishes in over 30s | **Speaks**, with how long it took and what you asked |
61
+ | Background work finishes (a subagent, a task) | **Speaks**, at most once every 120s per session |
62
+ | Background work with nothing to report | Silent |
63
+ | The agent needs your permission or input | **Always speaks** |
64
+
65
+ Every decision, including every silence, goes to the log with its reason —
66
+ `agent-voice status` shows the latest lines.
67
+
68
+ ## Commands
69
+
70
+ ```
71
+ agent-voice setup guided setup
72
+ agent-voice connect <claude-code|codex> add hooks to an agent (--yes to skip the question)
73
+ agent-voice disconnect <claude-code|codex>
74
+ agent-voice output add <alexa|local|command> [name]
75
+ agent-voice output list | remove | enable | disable | test [name]
76
+ agent-voice wrap [--name <label>] -- <command...>
77
+ agent-voice status
78
+ agent-voice config get [key] | set <key> <value>
79
+ agent-voice test
80
+ ```
81
+
82
+ ## Speakers
83
+
84
+ | Type | How | Needs |
85
+ |---|---|---|
86
+ | `alexa` | Home Assistant's `notify.send_message` on an Echo entity | Home Assistant URL, a long-lived token, and the Alexa Media Player integration |
87
+ | `local` | `say` (macOS), SAPI (Windows), `spd-say`/`espeak-ng` (Linux) | Nothing on macOS/Windows; `espeak-ng` or speech-dispatcher on Linux |
88
+ | `command` | Runs any command; `{text}` in it is replaced by the sentence, otherwise the sentence goes to stdin | The command |
89
+
90
+ You can have several, e.g. `alexa-sala` and `alexa-escritorio`; every enabled
91
+ output speaks, each given up to 15 seconds.
92
+
93
+ A `command` never goes through a shell: arguments are passed as typed, and a
94
+ leading `~`, `$HOME` or `%USERPROFILE%` means your home directory. On Windows a
95
+ `.cmd`/`.bat` speaker is run through `cmd.exe` with every argument quoted and
96
+ escaped, so the sentence can never run anything; a batch file sees it with
97
+ `^` escapes, which disappear when it passes `%*` or `%1` on to another program.
98
+
99
+ ## Agents
100
+
101
+ - **Claude Code** — hooks in `~/.claude/settings.json`.
102
+ - **Codex CLI** — hooks in `~/.codex/hooks.json`. Codex runs a hook only after
103
+ you trust it: open Codex and run `/hooks` once after connecting.
104
+ - **Anything else** — `agent-voice wrap -- aider ...` announces when the
105
+ command finishes (no "needs you" announcements in this mode).
106
+
107
+ Adding an agent: see [`docs/adapters.md`](docs/adapters.md).
108
+
109
+ ## Tuning
110
+
111
+ Tune against your own log. If most of your turns sit below the threshold it
112
+ will feel broken — it will simply never speak.
113
+
114
+ ```bash
115
+ agent-voice config set minSeconds 20
116
+ agent-voice config set cooldownSeconds 300
117
+ ```
118
+
119
+ | Key | Default | Env override |
120
+ |---|---|---|
121
+ | `minSeconds` | 30 | `AV_MIN_SECONDS` |
122
+ | `cooldownSeconds` | 120 | `AV_COOLDOWN_SECONDS` |
123
+ | `maxSpeechChars` | 90 | `AV_MAX_SPEECH_CHARS` |
124
+
125
+ ## Where things live
126
+
127
+ | | Config | State and `events.log` |
128
+ |---|---|---|
129
+ | Linux | `~/.config/agent-voice` | `~/.local/state/agent-voice` |
130
+ | macOS | `~/Library/Application Support/agent-voice` | `…/agent-voice/state` |
131
+ | Windows | `%APPDATA%\agent-voice` | `%LOCALAPPDATA%\agent-voice` |
132
+
133
+ ## Troubleshooting
134
+
135
+ Run `agent-voice status` first — the reason is almost always in the log lines it prints.
136
+
137
+ - `silent (turn 21s < 30s)` → working as configured; lower `minSeconds`.
138
+ - `FAILED via <output> (…)` → the speaker failed; the parenthesis says why. Try `agent-voice output test <name>`.
139
+ - `no such output: <name>` → enabled but not configured; `agent-voice output add`.
140
+ - An agent shows `legacy` → it still has the old bash hooks; run `agent-voice connect <agent>`.
141
+ - Nothing in the log at all → the hooks are not firing. In Claude Code, open `/hooks` once to reload; in Codex, trust them in `/hooks`.
142
+
143
+ ## Upgrading from the bash version
144
+
145
+ 1. `npm install -g @lucas_zaia/agent-voice`
146
+ 2. `agent-voice setup` — for the same Echo as before, pick `command` and enter
147
+ `~/softwares/home-assistant/falar.sh -a {text}`, or pick `alexa` to talk to
148
+ Home Assistant directly.
149
+ 3. `agent-voice connect claude-code` replaces the old `…/bin/notify` hooks.
150
+
151
+ Existing state in `~/.local/state/agent-voice` is reused as-is.
152
+
153
+ ## Notes
154
+
155
+ - **It cannot break your agent.** The hook entry point exits 0 no matter what;
156
+ failures go to the log and nowhere else.
157
+ - **Language.** Code and docs are English; the spoken sentences are Brazilian
158
+ Portuguese and live in one file, `src/core/phrases.js`.
159
+ - **Concurrency.** State is keyed per agent and per session, so parallel
160
+ sessions never mix up their turns, durations or cooldowns.
161
+
162
+ ## License
163
+
164
+ MIT.
@@ -0,0 +1,9 @@
1
+ #!/usr/bin/env node
2
+ import { main } from '../src/cli/main.js';
3
+ import { guardNotifyProcess } from '../src/cli/notify.js';
4
+
5
+ const argv = process.argv.slice(2);
6
+ if (argv[0] === 'notify') guardNotifyProcess(argv.slice(1));
7
+ // `agent-voice status | head`: the reader went away, so there is nobody left to talk to.
8
+ else process.stdout.on('error', (e) => { if (e.code === 'EPIPE') process.exit(0); throw e; });
9
+ process.exitCode = await main(argv);
@@ -0,0 +1,56 @@
1
+ # Writing an adapter
2
+
3
+ An adapter teaches agent-voice about one agent. It is a module in
4
+ `src/adapters/<agent>.js`, registered in `src/adapters/index.js`. It **stores
5
+ nothing and decides nothing** — no thresholds, no cooldown, no sentences. The
6
+ core owns all of that, so every agent gets the same behaviour and the same fixes.
7
+
8
+ ## The module
9
+
10
+ | Export | What it is |
11
+ |---|---|
12
+ | `name` | The agent id, e.g. `"codex"`. Used in hook commands, state paths and the log |
13
+ | `hooks` | `[{ event, sub, async?, timeout }]` — which agent events call `agent-voice notify <name> <sub>`. Give hooks that can speak `timeout: 60`: each output gets up to 15s, one after another, and the FAILED line must reach the log before the agent kills the hook |
14
+ | `notes` | Strings printed after `connect` (e.g. "trust the hooks in /hooks") |
15
+ | `configFile(env, home)` | Path of the agent's JSON config that holds hooks |
16
+ | `detect(env, home)` | Whether the agent is installed here |
17
+ | `hookReply(sub)` | What to print on stdout for the agent (usually `''`) |
18
+ | `translate(sub, payload, home)` | The agent's hook payload → one canonical event, or `null` |
19
+
20
+ `connect`/`disconnect` work for any agent whose config uses the
21
+ `{"hooks": {Event: [{hooks: [{type, command}]}]}}` shape; see
22
+ `src/adapters/hooks-json.js`.
23
+
24
+ ## The canonical event
25
+
26
+ ```json
27
+ { "type": "task_done",
28
+ "agent": "your-agent",
29
+ "session_id": "stable-identity",
30
+ "session_name": "Human readable, optional",
31
+ "project": "last path segment, optional",
32
+ "text": "what was asked, or the message, optional" }
33
+ ```
34
+
35
+ | `type` | When |
36
+ |---|---|
37
+ | `turn_start` | The user submitted a request. Carry the request in `text` |
38
+ | `task_done` | The agent finished responding (`text` is ignored — the core uses the stored request) |
39
+ | `background_done` | Background work finished (a subagent, a queued task) |
40
+ | `needs_input` | The agent is blocked on a permission or a question |
41
+
42
+ `type`, `agent` and `session_id` are required; an event missing any of them is
43
+ dropped and logged as `ignored: incomplete event`. `session_id` must be stable
44
+ for the whole session. Never compute a duration — the core does.
45
+
46
+ ## Agents with no hooks
47
+
48
+ Use `agent-voice wrap -- <command>`: it marks the start, runs the command, and
49
+ marks the end.
50
+
51
+ ## Checklist
52
+
53
+ - [ ] `translate` returns `null` for unknown subcommands and never throws on garbage
54
+ - [ ] `agentName` in `src/core/phrases.js` has a spoken name for the agent
55
+ - [ ] A test file `tests/adapter-<agent>.test.js` mirroring `tests/adapter-codex.test.js`
56
+ - [ ] The adapter is listed in `src/adapters/index.js`
package/package.json ADDED
@@ -0,0 +1,14 @@
1
+ {
2
+ "name": "@lucas_zaia/agent-voice",
3
+ "version": "1.0.0",
4
+ "description": "Make your coding agent talk to you: a smart speaker says when a long task finishes or when the agent needs you.",
5
+ "type": "module",
6
+ "bin": { "agent-voice": "bin/agent-voice.js" },
7
+ "files": ["bin", "src", "docs/adapters.md", "README.md", "LICENSE"],
8
+ "engines": { "node": ">=20" },
9
+ "scripts": { "test": "node --test" },
10
+ "repository": { "type": "git", "url": "git+https://github.com/LucasZaia/agent-voice.git" },
11
+ "keywords": ["claude-code", "codex", "alexa", "home-assistant", "tts", "notifications"],
12
+ "license": "MIT",
13
+ "publishConfig": { "access": "public" }
14
+ }
@@ -0,0 +1,103 @@
1
+ // Translates Claude Code hook payloads into canonical events. Pure: it reads
2
+ // the payload, returns one event, and keeps no state. Everything it knows about
3
+ // Claude Code — hook names, field names, where the session title hides — is here.
4
+ import { openSync, readSync, fstatSync, closeSync, existsSync } from 'node:fs';
5
+ import { homedir } from 'node:os';
6
+ import { join, dirname } from 'node:path';
7
+ import { str, projectOf } from './common.js';
8
+
9
+ export const name = 'claude-code';
10
+
11
+ export const hooks = [
12
+ { event: 'UserPromptSubmit', sub: 'start', timeout: 5 },
13
+ { event: 'Stop', sub: 'stop', async: true, timeout: 60 },
14
+ { event: 'SubagentStop', sub: 'task', async: true, timeout: 60 },
15
+ { event: 'TaskCompleted', sub: 'task', async: true, timeout: 60 },
16
+ { event: 'Notification', sub: 'notification', async: true, timeout: 60 },
17
+ ];
18
+
19
+ export const notes = [];
20
+
21
+ export function configFile(env = process.env, home = homedir()) {
22
+ return join(env.CLAUDE_CONFIG_DIR || join(home, '.claude'), 'settings.json');
23
+ }
24
+
25
+ export const detect = (env = process.env, home = homedir()) => existsSync(dirname(configFile(env, home)));
26
+
27
+ export const hookReply = () => '';
28
+
29
+ // Claude Code writes an AI-generated title into the transcript as
30
+ // {"type":"ai-title","aiTitle":"..."} and rewrites it as the topic shifts.
31
+ // The newest one wins, so the file is read backwards in chunks: transcripts
32
+ // grow to many megabytes and the title is usually near the end.
33
+ function titleOf(line) {
34
+ if (!line.includes('"type":"ai-title"')) return null;
35
+ try {
36
+ return str(JSON.parse(line).aiTitle).replace(/[_-]+/g, ' ');
37
+ } catch {
38
+ return '';
39
+ }
40
+ }
41
+
42
+ export function sessionName(transcriptPath, chunkSize = 64 * 1024) {
43
+ if (!transcriptPath) return '';
44
+ let fd;
45
+ try {
46
+ fd = openSync(transcriptPath, 'r');
47
+ let pos = fstatSync(fd).size;
48
+ // Pieces of the line being assembled, oldest first: everything after the
49
+ // newest newline not yet consumed. Joined once per line, so one huge line
50
+ // (a large tool result) is not copied again for every chunk.
51
+ let parts = [];
52
+ while (pos > 0) {
53
+ const size = Math.min(chunkSize, pos);
54
+ pos -= size;
55
+ const chunk = Buffer.alloc(size);
56
+ readSync(fd, chunk, 0, size, pos);
57
+ // Splitting on the newline byte keeps multi-byte UTF-8 characters whole.
58
+ let end = size;
59
+ for (let nl = chunk.lastIndexOf(10, end - 1); nl !== -1; nl = end > 0 ? chunk.lastIndexOf(10, end - 1) : -1) {
60
+ const title = titleOf(Buffer.concat([chunk.subarray(nl + 1, end), ...parts]).toString('utf8'));
61
+ if (title !== null) return title;
62
+ parts = [];
63
+ end = nl;
64
+ }
65
+ parts.unshift(chunk.subarray(0, end));
66
+ }
67
+ return titleOf(Buffer.concat(parts).toString('utf8')) ?? '';
68
+ } catch {
69
+ return '';
70
+ } finally {
71
+ if (fd !== undefined) closeSync(fd);
72
+ }
73
+ }
74
+
75
+ // SubagentStop / TaskCompleted rarely fill agent_type, so try each field in
76
+ // turn; the core decides what to do when all are empty.
77
+ const taskText = (p) => str(p.agent_type) || str(p.subagent_type) || str(p.description) || str(p.task_description);
78
+
79
+ // Claude Code submits some turns itself — a background task finishing arrives as
80
+ // a <task-notification> "prompt". Those blocks are not what the user asked for.
81
+ const SYSTEM_BLOCKS = /<(task-notification|system-reminder)>[\s\S]*?<\/\1>/g;
82
+ const requestText = (p) => str(p.prompt).replace(SYSTEM_BLOCKS, '').trim();
83
+
84
+ const MAPPING = {
85
+ start: ['turn_start', requestText],
86
+ stop: ['task_done', () => ''],
87
+ task: ['background_done', taskText],
88
+ notification: ['needs_input', (p) => str(p.message)],
89
+ };
90
+
91
+ export function translate(sub, payload, home = homedir()) {
92
+ if (!Object.hasOwn(MAPPING, sub)) return null;
93
+ const p = payload && typeof payload === 'object' ? payload : {};
94
+ const [type, text] = MAPPING[sub];
95
+ return {
96
+ type,
97
+ agent: name,
98
+ session_id: str(p.session_id),
99
+ session_name: sessionName(str(p.transcript_path)),
100
+ project: projectOf(p.cwd, home),
101
+ text: text(p),
102
+ };
103
+ }
@@ -0,0 +1,43 @@
1
+ // Translates Codex CLI hook payloads into canonical events. Codex hooks share
2
+ // Claude Code's shape; the differences are the event names, no ai-title in the
3
+ // transcript (Codex calls its format unstable), and Stop wanting JSON back.
4
+ import { existsSync } from 'node:fs';
5
+ import { homedir } from 'node:os';
6
+ import { join, dirname } from 'node:path';
7
+ import { str, projectOf } from './common.js';
8
+
9
+ export const name = 'codex';
10
+
11
+ export const hooks = [
12
+ { event: 'UserPromptSubmit', sub: 'start', timeout: 5 },
13
+ { event: 'Stop', sub: 'stop', async: true, timeout: 60 },
14
+ { event: 'PermissionRequest', sub: 'permission', async: true, timeout: 60 },
15
+ ];
16
+
17
+ export const notes = ['Codex skips hooks it has not trusted yet: open Codex and run /hooks to review and trust them.'];
18
+
19
+ export function configFile(env = process.env, home = homedir()) {
20
+ return join(env.CODEX_HOME || join(home, '.codex'), 'hooks.json');
21
+ }
22
+
23
+ export const detect = (env = process.env, home = homedir()) => existsSync(dirname(configFile(env, home)));
24
+
25
+ // Codex treats non-JSON stdout from a Stop hook as invalid.
26
+ export const hookReply = (sub) => (sub === 'stop' ? '{}' : '');
27
+
28
+ // Worded like Claude Code's notice so the core's translation applies:
29
+ // "..., para usar o Bash."
30
+ const permissionText = (p) => (str(p.tool_name) ? `needs your permission to use ${str(p.tool_name)}` : 'needs your permission');
31
+
32
+ const MAPPING = {
33
+ start: ['turn_start', (p) => str(p.prompt)],
34
+ stop: ['task_done', () => ''],
35
+ permission: ['needs_input', permissionText],
36
+ };
37
+
38
+ export function translate(sub, payload, home = homedir()) {
39
+ if (!Object.hasOwn(MAPPING, sub)) return null;
40
+ const p = payload && typeof payload === 'object' ? payload : {};
41
+ const [type, text] = MAPPING[sub];
42
+ return { type, agent: name, session_id: str(p.session_id), session_name: '', project: projectOf(p.cwd, home), text: text(p) };
43
+ }
@@ -0,0 +1,14 @@
1
+ import { homedir } from 'node:os';
2
+
3
+ export const str = (v) => (typeof v === 'string' ? v : '');
4
+
5
+ // The last segment of cwd. The home directory is not a project — announcing
6
+ // "do projeto lucas-zaia" would be nonsense.
7
+ // Windows paths compare case-insensitively and with either separator.
8
+ export function projectOf(cwd, home = homedir(), platform = process.platform) {
9
+ const dir = str(cwd).replace(/[\\/]+$/, '');
10
+ const norm = (p) => (platform === 'win32' ? p.replace(/[\\/]+/g, '\\').replace(/\\$/, '').toLowerCase() : p.replace(/\/+$/, ''));
11
+ if (!dir || norm(dir) === norm(String(home))) return '';
12
+ const parts = dir.split(/[\\/]/);
13
+ return parts[parts.length - 1];
14
+ }
@@ -0,0 +1,158 @@
1
+ // Merges agent-voice's hooks into an agent's JSON config (Claude Code's
2
+ // settings.json, Codex's hooks.json — same shape). Rules: never touch foreign
3
+ // hooks, never duplicate ours, back up before writing, and on anything
4
+ // unexpected abort without writing a byte.
5
+ import {
6
+ readFileSync, writeFileSync, copyFileSync, mkdirSync, existsSync, renameSync, rmSync, statSync, chmodSync, realpathSync,
7
+ constants,
8
+ } from 'node:fs';
9
+ import { dirname } from 'node:path';
10
+
11
+ export const hookCommand = (env = process.env) => env.AV_HOOK_COMMAND || 'agent-voice';
12
+
13
+ const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
14
+
15
+ export const isLegacy = (command) => typeof command === 'string' && /bin[\\/]notify"?\s/.test(command);
16
+
17
+ export function isOurs(command, agent) {
18
+ if (typeof command !== 'string') return false;
19
+ if (!new RegExp(`\\bnotify"?\\s+${escapeRe(agent)}(\\s|$)`).test(command)) return false;
20
+ return /agent-voice/.test(command) || isLegacy(command);
21
+ }
22
+
23
+ function eventsOf(settings, file = 'settings') {
24
+ if (!settings || typeof settings !== 'object' || Array.isArray(settings)) {
25
+ throw new Error(`${file}: expected a JSON object — nothing was written`);
26
+ }
27
+ if (settings.hooks === undefined) return {};
28
+ const { hooks } = settings;
29
+ if (!hooks || typeof hooks !== 'object' || Array.isArray(hooks)) {
30
+ throw new Error(`${file}: "hooks" is not an object — nothing was written`);
31
+ }
32
+ for (const [event, groups] of Object.entries(hooks)) {
33
+ if (!Array.isArray(groups)) throw new Error(`${file}: hooks.${event} is not a list — nothing was written`);
34
+ }
35
+ return hooks;
36
+ }
37
+
38
+ export function removeHooks(settings, agent, file) {
39
+ const hooks = eventsOf(settings, file);
40
+ const next = {};
41
+ let removed = 0;
42
+ for (const [event, groups] of Object.entries(hooks)) {
43
+ const kept = [];
44
+ for (const group of groups) {
45
+ if (!group || !Array.isArray(group.hooks)) { kept.push(group); continue; }
46
+ const ours = group.hooks.filter((x) => isOurs(x?.command, agent));
47
+ if (ours.length === 0) { kept.push(group); continue; }
48
+ removed += ours.length;
49
+ const rest = group.hooks.filter((x) => !isOurs(x?.command, agent));
50
+ if (rest.length) kept.push({ ...group, hooks: rest });
51
+ }
52
+ if (kept.length || groups.length === 0) next[event] = kept;
53
+ }
54
+ if (removed === 0) return { settings, removed };
55
+ const out = { ...settings };
56
+ if (Object.keys(next).length) out.hooks = next;
57
+ else delete out.hooks;
58
+ return { settings: out, removed };
59
+ }
60
+
61
+ export function mergeHooks(settings, agent, defs, command, file) {
62
+ const { settings: base } = removeHooks(settings, agent, file);
63
+ const hooks = { ...(base.hooks ?? {}) };
64
+ for (const d of defs) {
65
+ const entry = { type: 'command', command: `${command} notify ${agent} ${d.sub}`, ...(d.async ? { async: true } : {}), timeout: d.timeout };
66
+ hooks[d.event] = [...(hooks[d.event] ?? []), { hooks: [entry] }];
67
+ }
68
+ return { ...base, hooks };
69
+ }
70
+
71
+ export function hookStatus(settings, agent, defs, file) {
72
+ const hooks = eventsOf(settings, file);
73
+ const ours = Object.values(hooks).flat()
74
+ .flatMap((g) => (g && Array.isArray(g.hooks) ? g.hooks : []))
75
+ .filter((x) => isOurs(x?.command, agent));
76
+ if (ours.some((x) => isLegacy(x.command))) return 'legacy';
77
+ const has = (d) => ((hooks[d.event] ?? [])
78
+ .some((g) => g && Array.isArray(g.hooks) && g.hooks.some((x) => isOurs(x?.command, agent) && x.command.trimEnd().endsWith(` ${agent} ${d.sub}`))));
79
+ const n = defs.filter(has).length;
80
+ if (n === defs.length) return 'connected';
81
+ return n === 0 ? 'not connected' : 'partially connected';
82
+ }
83
+
84
+ function readSettings(file) {
85
+ if (!existsSync(file)) return { settings: {}, existed: false };
86
+ // Windows editors like to start UTF-8 files with a BOM; JSON.parse does not.
87
+ const raw = readFileSync(file, 'utf8').replace(/^\uFEFF/, '');
88
+ if (raw.trim() === '') return { settings: {}, existed: true };
89
+ let settings;
90
+ try {
91
+ settings = JSON.parse(raw);
92
+ } catch (e) {
93
+ throw new Error(`${file}: invalid JSON (${e.message}) — nothing was written`);
94
+ }
95
+ eventsOf(settings, file);
96
+ return { settings, existed: true };
97
+ }
98
+
99
+ const pad2 = (n) => String(n).padStart(2, '0');
100
+ const stamp = (d) => `${d.getFullYear()}${pad2(d.getMonth() + 1)}${pad2(d.getDate())}-${pad2(d.getHours())}${pad2(d.getMinutes())}${pad2(d.getSeconds())}`;
101
+
102
+ // Never overwrites an earlier backup: connect and disconnect in the same
103
+ // second must both keep theirs.
104
+ function backupOf(file, now) {
105
+ const base = `${file}.bak-${stamp(now)}`;
106
+ for (let i = 1; ; i++) {
107
+ const backup = i === 1 ? base : `${base}-${i}`;
108
+ try {
109
+ copyFileSync(file, backup, constants.COPYFILE_EXCL);
110
+ return backup;
111
+ } catch (e) {
112
+ if (e.code !== 'EEXIST') throw e;
113
+ }
114
+ }
115
+ }
116
+
117
+ // Written to a temp file and renamed over the original, so a crash or a full
118
+ // disk never leaves the agent with half a settings file. A symlinked file
119
+ // (dotfile repos) is written at its target and stays a symlink.
120
+ function replaceFile(file, text) {
121
+ const target = existsSync(file) ? realpathSync(file) : file;
122
+ const tmp = `${target}.tmp-${process.pid}`;
123
+ try {
124
+ writeFileSync(tmp, text);
125
+ try { chmodSync(tmp, statSync(target).mode & 0o7777); } catch { /* new file, or no modes here */ }
126
+ renameSync(tmp, target);
127
+ } catch (e) {
128
+ rmSync(tmp, { force: true });
129
+ throw e;
130
+ }
131
+ }
132
+
133
+ function writeSettings(file, existed, before, next, now) {
134
+ if (JSON.stringify(before) === JSON.stringify(next)) return { changed: false, backup: null };
135
+ mkdirSync(dirname(file), { recursive: true });
136
+ const backup = existed ? backupOf(file, now) : null;
137
+ replaceFile(file, `${JSON.stringify(next, null, 2)}\n`);
138
+ return { changed: true, backup };
139
+ }
140
+
141
+ export function connectFile(file, agent, defs, { command, now = new Date() } = {}) {
142
+ const { settings, existed } = readSettings(file);
143
+ return writeSettings(file, existed, settings, mergeHooks(settings, agent, defs, command, file), now);
144
+ }
145
+
146
+ export function disconnectFile(file, agent, { now = new Date() } = {}) {
147
+ if (!existsSync(file)) return { changed: false, backup: null };
148
+ const { settings, existed } = readSettings(file);
149
+ return writeSettings(file, existed, settings, removeHooks(settings, agent, file).settings, now);
150
+ }
151
+
152
+ export function fileStatus(file, agent, defs) {
153
+ try {
154
+ return hookStatus(readSettings(file).settings, agent, defs, file);
155
+ } catch (e) {
156
+ return `unreadable (${e.message})`;
157
+ }
158
+ }
@@ -0,0 +1,4 @@
1
+ import * as claudeCode from './claude-code.js';
2
+ import * as codex from './codex.js';
3
+
4
+ export const ADAPTERS = { 'claude-code': claudeCode, codex };
@@ -0,0 +1,33 @@
1
+ import { loadConfig, saveConfig, NUMERIC_KEYS } from '../config.js';
2
+
3
+ const USAGE = `usage: agent-voice config get [key] | set <key> <value> (keys: ${Object.keys(NUMERIC_KEYS).join(', ')})`;
4
+
5
+ export function runConfig(args, { env, out }) {
6
+ const [sub, key, value] = args;
7
+ if (sub === 'get') {
8
+ const cfg = loadConfig(env);
9
+ if (!key) {
10
+ for (const k of Object.keys(NUMERIC_KEYS)) out(`${k}=${cfg[k]}`);
11
+ out(`outputs=${cfg.outputs.join(' ')}`);
12
+ return 0;
13
+ }
14
+ if (key === 'outputs') out(cfg.outputs.join(' '));
15
+ else if (Object.hasOwn(NUMERIC_KEYS, key)) out(String(cfg[key]));
16
+ else throw new Error(`unknown key "${key}". ${USAGE}`);
17
+ return 0;
18
+ }
19
+ if (sub === 'set') {
20
+ if (!Object.hasOwn(NUMERIC_KEYS, key ?? '')) {
21
+ throw new Error(`unknown key "${key}" (settable: ${Object.keys(NUMERIC_KEYS).join(', ')}; outputs are managed with "agent-voice output enable|disable")`);
22
+ }
23
+ if (!/^[0-9]+$/.test(value ?? '')) {
24
+ throw new Error(`${key} must be a whole number of ${key === 'maxSpeechChars' ? 'characters' : 'seconds'}`);
25
+ }
26
+ saveConfig({ [key]: Number(value) }, env);
27
+ out(`${key}=${value}`);
28
+ const envName = NUMERIC_KEYS[key];
29
+ if (env[envName] !== undefined) out(`Note: ${envName}=${env[envName]} overrides it in this environment.`);
30
+ return 0;
31
+ }
32
+ throw new Error(USAGE);
33
+ }
@@ -0,0 +1,42 @@
1
+ import { ADAPTERS } from '../adapters/index.js';
2
+ import { connectFile, disconnectFile, hookCommand } from '../adapters/hooks-json.js';
3
+ import { findOnPath } from '../platform.js';
4
+
5
+ export function adapterFor(name) {
6
+ if (!Object.hasOwn(ADAPTERS, name ?? '')) {
7
+ throw new Error(`unknown agent "${name}" (available: ${Object.keys(ADAPTERS).join(', ')})`);
8
+ }
9
+ return ADAPTERS[name];
10
+ }
11
+
12
+ export async function connectAgent(adapter, { env, out, prompt, yes = false }) {
13
+ const file = adapter.configFile(env);
14
+ const command = hookCommand(env);
15
+ out(`${adapter.name}: these hooks will be added to ${file}`);
16
+ for (const d of adapter.hooks) out(` ${d.event.padEnd(18)} → ${command} notify ${adapter.name} ${d.sub}`);
17
+ if (!yes && !(await prompt.confirm('Apply?', true))) {
18
+ out('Nothing changed.');
19
+ return false;
20
+ }
21
+ const { changed, backup } = connectFile(file, adapter.name, adapter.hooks, { command });
22
+ out(changed ? `Connected ${adapter.name}.${backup ? ` Backup: ${backup}` : ''}` : `${adapter.name} was already connected; nothing changed.`);
23
+ if (command === 'agent-voice' && !findOnPath('agent-voice', env)) {
24
+ out('Warning: "agent-voice" is not on your PATH, so these hooks will not run. Install with "npm i -g @lucas_zaia/agent-voice".');
25
+ }
26
+ for (const note of adapter.notes) out(`Note: ${note}`);
27
+ return true;
28
+ }
29
+
30
+ export async function runConnect(args, { env, out, prompt }) {
31
+ const adapter = adapterFor(args.find((a) => !a.startsWith('--')));
32
+ await connectAgent(adapter, { env, out, prompt, yes: args.includes('--yes') });
33
+ return 0;
34
+ }
35
+
36
+ export async function runDisconnect(args, { env, out }) {
37
+ const adapter = adapterFor(args[0]);
38
+ const file = adapter.configFile(env);
39
+ const { changed, backup } = disconnectFile(file, adapter.name);
40
+ out(changed ? `Removed agent-voice's hooks from ${file}. Backup: ${backup}` : `${adapter.name} had no agent-voice hooks; nothing changed.`);
41
+ return 0;
42
+ }