@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 +21 -0
- package/README.md +164 -0
- package/bin/agent-voice.js +9 -0
- package/docs/adapters.md +56 -0
- package/package.json +14 -0
- package/src/adapters/claude-code.js +103 -0
- package/src/adapters/codex.js +43 -0
- package/src/adapters/common.js +14 -0
- package/src/adapters/hooks-json.js +158 -0
- package/src/adapters/index.js +4 -0
- package/src/cli/config.js +33 -0
- package/src/cli/connect.js +42 -0
- package/src/cli/main.js +69 -0
- package/src/cli/notify.js +72 -0
- package/src/cli/output.js +121 -0
- package/src/cli/prompt.js +75 -0
- package/src/cli/setup.js +71 -0
- package/src/cli/status.js +43 -0
- package/src/cli/wrap.js +73 -0
- package/src/config.js +98 -0
- package/src/core/context.js +14 -0
- package/src/core/handle.js +94 -0
- package/src/core/hash.js +17 -0
- package/src/core/log.js +24 -0
- package/src/core/phrases.js +54 -0
- package/src/core/speech.js +30 -0
- package/src/core/state.js +56 -0
- package/src/outputs/alexa.js +66 -0
- package/src/outputs/command.js +66 -0
- package/src/outputs/index.js +23 -0
- package/src/outputs/local.js +87 -0
- package/src/outputs/run.js +53 -0
- package/src/platform.js +54 -0
- package/src/spawn-command.js +58 -0
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);
|
package/docs/adapters.md
ADDED
|
@@ -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,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
|
+
}
|