@spencer-kit/agent-notify 0.1.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/README.md +184 -0
- package/dist/cli.js +1023 -0
- package/package.json +36 -0
package/README.md
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
# agent-notify
|
|
2
|
+
|
|
3
|
+
`agent-notify` is a native TypeScript CLI for sending local completion and attention notifications from Codex and Claude Code.
|
|
4
|
+
|
|
5
|
+
Published package:
|
|
6
|
+
|
|
7
|
+
- `@spencer-kit/agent-notify`
|
|
8
|
+
- installed executable: `agent-notify`
|
|
9
|
+
|
|
10
|
+
## What It Does
|
|
11
|
+
|
|
12
|
+
- patches Codex and Claude Code config files to call `agent-notify` when work finishes or needs input
|
|
13
|
+
- normalizes Codex and Claude hook payloads into a shared event model
|
|
14
|
+
- sends local desktop notifications and sound fallbacks
|
|
15
|
+
- dedupes repeated events and keeps a local event log
|
|
16
|
+
|
|
17
|
+
## Install
|
|
18
|
+
|
|
19
|
+
Global install:
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
npm install -g @spencer-kit/agent-notify
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
One-off execution:
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
npx @spencer-kit/agent-notify install codex --dry-run
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
After install, the command name is still:
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
agent-notify
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Quick Start
|
|
38
|
+
|
|
39
|
+
Install the Codex hook:
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
agent-notify install codex
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Install the Claude Code hooks:
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
agent-notify install claude
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Preview the generated config before writing anything:
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
agent-notify install codex --dry-run
|
|
55
|
+
agent-notify install claude --dry-run
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## Commands
|
|
59
|
+
|
|
60
|
+
Install the Codex hook into `~/.codex/config.toml`:
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
agent-notify install codex
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Install the Claude Code hooks into `~/.claude/settings.json`:
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
agent-notify install claude
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Preview either install without writing files:
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
agent-notify install codex --dry-run
|
|
76
|
+
agent-notify install claude --dry-run
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Handle a Codex notify payload directly:
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
agent-notify handle codex '{"type":"agent-turn-complete","thread-id":"thread-1","turn-id":"turn-1","cwd":"/tmp/demo","input-messages":["rename foo"],"last-assistant-message":"rename complete"}'
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Handle a Claude Code hook payload from stdin:
|
|
86
|
+
|
|
87
|
+
```bash
|
|
88
|
+
printf '%s\n' '{"session_id":"session-1","cwd":"/tmp/demo","notification_type":"idle_prompt","message":"Claude is waiting"}' | agent-notify handle claude --event Notification
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
Supported Claude events:
|
|
92
|
+
|
|
93
|
+
- `Notification`
|
|
94
|
+
- `Stop`
|
|
95
|
+
- `StopFailure`
|
|
96
|
+
|
|
97
|
+
CLI forms:
|
|
98
|
+
|
|
99
|
+
```bash
|
|
100
|
+
agent-notify install codex [--config <path>] [--dry-run]
|
|
101
|
+
agent-notify install claude [--settings <path>] [--dry-run]
|
|
102
|
+
agent-notify handle codex '<json>' [--state-dir <path>]
|
|
103
|
+
agent-notify handle claude --event <Notification|Stop|StopFailure> ['<json>'] [--state-dir <path>]
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
If the JSON payload is omitted for `handle`, the CLI reads it from stdin.
|
|
107
|
+
|
|
108
|
+
## Paths
|
|
109
|
+
|
|
110
|
+
- Codex config: `~/.codex/config.toml`
|
|
111
|
+
- Claude settings: `~/.claude/settings.json`
|
|
112
|
+
- global config: `~/.config/agent-notify/config.toml`
|
|
113
|
+
- repo override: `.agent-notify.toml`
|
|
114
|
+
- state dir: `~/.local/state/agent-notify`
|
|
115
|
+
|
|
116
|
+
## Notification Behavior
|
|
117
|
+
|
|
118
|
+
- `Notification` maps to `needs_input`
|
|
119
|
+
- `Stop` maps to `completed`
|
|
120
|
+
- `StopFailure` maps to `failed`
|
|
121
|
+
- desktop delivery falls back across supported local mechanisms
|
|
122
|
+
- repeated events are suppressed for a short dedupe window
|
|
123
|
+
- provider failures are fail-open and should not block agent execution
|
|
124
|
+
|
|
125
|
+
## Development
|
|
126
|
+
|
|
127
|
+
```bash
|
|
128
|
+
npm install
|
|
129
|
+
npm test
|
|
130
|
+
npm run build
|
|
131
|
+
npm pack
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
## Release
|
|
135
|
+
|
|
136
|
+
This repository is set up for GitHub Actions based publishing to npm when a GitHub Release is published from a version tag.
|
|
137
|
+
|
|
138
|
+
### One-Time Setup
|
|
139
|
+
|
|
140
|
+
1. Create the GitHub repository at `spencerkit/agent-notify`, or update `package.json` if your actual repo slug differs.
|
|
141
|
+
2. On npm, configure `@spencer-kit/agent-notify` to use a GitHub Actions Trusted Publisher.
|
|
142
|
+
3. In the npm Trusted Publisher configuration, use this repository and this exact workflow filename:
|
|
143
|
+
|
|
144
|
+
```text
|
|
145
|
+
release.yml
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
The workflow filename must match exactly or npm trusted publishing will reject the release job.
|
|
149
|
+
|
|
150
|
+
If this is the very first publish and the package does not exist on npm yet, do one manual bootstrap publish first:
|
|
151
|
+
|
|
152
|
+
```bash
|
|
153
|
+
npm publish --access public
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
After the package exists, configure the Trusted Publisher in the package settings and use the GitHub Release flow below for subsequent releases.
|
|
157
|
+
|
|
158
|
+
### Release Steps
|
|
159
|
+
|
|
160
|
+
1. Update `package.json` to the target version.
|
|
161
|
+
2. Run the local verification steps:
|
|
162
|
+
|
|
163
|
+
```bash
|
|
164
|
+
npm test
|
|
165
|
+
npm run build
|
|
166
|
+
npm pack --dry-run
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
3. Commit the release changes.
|
|
170
|
+
4. Create and push the version tag:
|
|
171
|
+
|
|
172
|
+
```bash
|
|
173
|
+
git tag v0.1.0
|
|
174
|
+
git push origin main --tags
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
5. In GitHub, create a GitHub Release from that tag and publish it.
|
|
178
|
+
6. GitHub Actions runs [`release.yml`](./.github/workflows/release.yml), verifies the tag matches `package.json`, reruns CI, and publishes the package to npm.
|
|
179
|
+
|
|
180
|
+
### GitHub Actions
|
|
181
|
+
|
|
182
|
+
- [`ci.yml`](./.github/workflows/ci.yml) runs on pushes to `main` and on pull requests.
|
|
183
|
+
- [`release.yml`](./.github/workflows/release.yml) runs only when a GitHub Release is published.
|
|
184
|
+
- The release workflow uses npm Trusted Publisher authentication and publishes the public scoped package with provenance.
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,1023 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/cli.ts
|
|
4
|
+
import { realpathSync } from "fs";
|
|
5
|
+
import { mkdir as mkdir2, readFile as readFile2, writeFile as writeFile2 } from "fs/promises";
|
|
6
|
+
import { homedir as homedir2 } from "os";
|
|
7
|
+
import { dirname as dirname2, join as join3 } from "path";
|
|
8
|
+
import { fileURLToPath } from "url";
|
|
9
|
+
|
|
10
|
+
// src/config.ts
|
|
11
|
+
import { existsSync, readFileSync } from "fs";
|
|
12
|
+
import { homedir } from "os";
|
|
13
|
+
import { join, resolve } from "path";
|
|
14
|
+
var DEFAULT_CONFIG = {
|
|
15
|
+
desktopEnabled: true,
|
|
16
|
+
soundEnabled: true,
|
|
17
|
+
soundOnCompleted: true,
|
|
18
|
+
dedupeSeconds: 15,
|
|
19
|
+
maxLogEntries: 5e3,
|
|
20
|
+
maxLogAgeDays: 7,
|
|
21
|
+
summaryLength: 180
|
|
22
|
+
};
|
|
23
|
+
var FILE_KEY_MAP = {
|
|
24
|
+
desktop_enabled: "desktopEnabled",
|
|
25
|
+
sound_enabled: "soundEnabled",
|
|
26
|
+
sound_on_completed: "soundOnCompleted",
|
|
27
|
+
dedupe_seconds: "dedupeSeconds",
|
|
28
|
+
max_log_entries: "maxLogEntries",
|
|
29
|
+
max_log_age_days: "maxLogAgeDays",
|
|
30
|
+
summary_length: "summaryLength"
|
|
31
|
+
};
|
|
32
|
+
var ENV_KEY_MAP = {
|
|
33
|
+
AGENT_NOTIFY_DESKTOP_ENABLED: "desktopEnabled",
|
|
34
|
+
AGENT_NOTIFY_SOUND_ENABLED: "soundEnabled",
|
|
35
|
+
AGENT_NOTIFY_SOUND_ON_COMPLETED: "soundOnCompleted",
|
|
36
|
+
AGENT_NOTIFY_DEDUPE_SECONDS: "dedupeSeconds",
|
|
37
|
+
AGENT_NOTIFY_MAX_LOG_ENTRIES: "maxLogEntries",
|
|
38
|
+
AGENT_NOTIFY_MAX_LOG_AGE_DAYS: "maxLogAgeDays",
|
|
39
|
+
AGENT_NOTIFY_SUMMARY_LENGTH: "summaryLength"
|
|
40
|
+
};
|
|
41
|
+
function defaultStateDir() {
|
|
42
|
+
return process.env.XDG_STATE_HOME ? join(process.env.XDG_STATE_HOME, "agent-notify") : join(homedir(), ".local", "state", "agent-notify");
|
|
43
|
+
}
|
|
44
|
+
function defaultConfigPath() {
|
|
45
|
+
return process.env.XDG_CONFIG_HOME ? join(process.env.XDG_CONFIG_HOME, "agent-notify", "config.toml") : join(homedir(), ".config", "agent-notify", "config.toml");
|
|
46
|
+
}
|
|
47
|
+
function findRepoConfig(cwd) {
|
|
48
|
+
if (!cwd) {
|
|
49
|
+
return void 0;
|
|
50
|
+
}
|
|
51
|
+
let current = resolve(cwd);
|
|
52
|
+
while (true) {
|
|
53
|
+
const candidate = join(current, ".agent-notify.toml");
|
|
54
|
+
if (existsSync(candidate)) {
|
|
55
|
+
return candidate;
|
|
56
|
+
}
|
|
57
|
+
const parent = resolve(current, "..");
|
|
58
|
+
if (parent === current) {
|
|
59
|
+
return void 0;
|
|
60
|
+
}
|
|
61
|
+
current = parent;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
function loadConfig(cwd) {
|
|
65
|
+
const mergedRaw = {
|
|
66
|
+
...readTomlConfig(defaultConfigPath()),
|
|
67
|
+
...readTomlConfig(findRepoConfig(cwd)),
|
|
68
|
+
...readEnvConfig()
|
|
69
|
+
};
|
|
70
|
+
return {
|
|
71
|
+
desktopEnabled: coerceBoolean(
|
|
72
|
+
mergedRaw.desktopEnabled,
|
|
73
|
+
DEFAULT_CONFIG.desktopEnabled
|
|
74
|
+
),
|
|
75
|
+
soundEnabled: coerceBoolean(mergedRaw.soundEnabled, DEFAULT_CONFIG.soundEnabled),
|
|
76
|
+
soundOnCompleted: coerceBoolean(
|
|
77
|
+
mergedRaw.soundOnCompleted,
|
|
78
|
+
DEFAULT_CONFIG.soundOnCompleted
|
|
79
|
+
),
|
|
80
|
+
dedupeSeconds: coerceInteger(
|
|
81
|
+
mergedRaw.dedupeSeconds,
|
|
82
|
+
DEFAULT_CONFIG.dedupeSeconds
|
|
83
|
+
),
|
|
84
|
+
maxLogEntries: coerceInteger(
|
|
85
|
+
mergedRaw.maxLogEntries,
|
|
86
|
+
DEFAULT_CONFIG.maxLogEntries
|
|
87
|
+
),
|
|
88
|
+
maxLogAgeDays: coerceInteger(
|
|
89
|
+
mergedRaw.maxLogAgeDays,
|
|
90
|
+
DEFAULT_CONFIG.maxLogAgeDays
|
|
91
|
+
),
|
|
92
|
+
summaryLength: coerceInteger(
|
|
93
|
+
mergedRaw.summaryLength,
|
|
94
|
+
DEFAULT_CONFIG.summaryLength
|
|
95
|
+
)
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
function shouldPlaySound(config, state) {
|
|
99
|
+
if (!config.soundEnabled) {
|
|
100
|
+
return false;
|
|
101
|
+
}
|
|
102
|
+
if (state === "completed") {
|
|
103
|
+
return config.soundOnCompleted;
|
|
104
|
+
}
|
|
105
|
+
return state === "needs_input" || state === "failed";
|
|
106
|
+
}
|
|
107
|
+
function readTomlConfig(path) {
|
|
108
|
+
if (!path || !existsSync(path)) {
|
|
109
|
+
return {};
|
|
110
|
+
}
|
|
111
|
+
return parseFlatToml(readFileSync(path, "utf8"));
|
|
112
|
+
}
|
|
113
|
+
function parseFlatToml(source) {
|
|
114
|
+
const config = {};
|
|
115
|
+
for (const rawLine of source.split(/\r?\n/)) {
|
|
116
|
+
const line = rawLine.trim();
|
|
117
|
+
if (line.length === 0 || line.startsWith("#")) {
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
const commentStart = line.indexOf("#");
|
|
121
|
+
const cleaned = commentStart >= 0 ? line.slice(0, commentStart).trim() : line;
|
|
122
|
+
const separatorIndex = cleaned.indexOf("=");
|
|
123
|
+
if (separatorIndex < 0) {
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
const rawKey = cleaned.slice(0, separatorIndex).trim();
|
|
127
|
+
const rawValue = cleaned.slice(separatorIndex + 1).trim();
|
|
128
|
+
const mappedKey = FILE_KEY_MAP[rawKey];
|
|
129
|
+
if (!mappedKey) {
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
assignRawConfigValue(config, mappedKey, rawValue);
|
|
133
|
+
}
|
|
134
|
+
return config;
|
|
135
|
+
}
|
|
136
|
+
function readEnvConfig() {
|
|
137
|
+
const config = {};
|
|
138
|
+
for (const [envKey, mappedKey] of Object.entries(ENV_KEY_MAP)) {
|
|
139
|
+
const rawValue = process.env[envKey];
|
|
140
|
+
if (rawValue === void 0) {
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
assignRawConfigValue(config, mappedKey, rawValue);
|
|
144
|
+
}
|
|
145
|
+
return config;
|
|
146
|
+
}
|
|
147
|
+
function coerceBoolean(value, fallback) {
|
|
148
|
+
const normalized = unquote(value ?? "").trim().toLowerCase();
|
|
149
|
+
if (["1", "true", "yes", "on"].includes(normalized)) {
|
|
150
|
+
return true;
|
|
151
|
+
}
|
|
152
|
+
if (["0", "false", "no", "off"].includes(normalized)) {
|
|
153
|
+
return false;
|
|
154
|
+
}
|
|
155
|
+
return fallback;
|
|
156
|
+
}
|
|
157
|
+
function coerceInteger(value, fallback) {
|
|
158
|
+
const normalized = unquote(value ?? "").trim();
|
|
159
|
+
if (!/^-?\d+$/.test(normalized)) {
|
|
160
|
+
return fallback;
|
|
161
|
+
}
|
|
162
|
+
const parsed = Number.parseInt(normalized, 10);
|
|
163
|
+
return Number.isNaN(parsed) ? fallback : parsed;
|
|
164
|
+
}
|
|
165
|
+
function unquote(value) {
|
|
166
|
+
const trimmed = value.trim();
|
|
167
|
+
if (trimmed.startsWith('"') && trimmed.endsWith('"') || trimmed.startsWith("'") && trimmed.endsWith("'")) {
|
|
168
|
+
return trimmed.slice(1, -1);
|
|
169
|
+
}
|
|
170
|
+
return trimmed;
|
|
171
|
+
}
|
|
172
|
+
function assignRawConfigValue(config, key, value) {
|
|
173
|
+
Object.assign(config, { [key]: value });
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// src/events.ts
|
|
177
|
+
function parseEvent(tool, rawEvent, eventName) {
|
|
178
|
+
if (tool === "codex") {
|
|
179
|
+
return parseCodexEvent(rawEvent);
|
|
180
|
+
}
|
|
181
|
+
return parseClaudeEvent(rawEvent, eventName);
|
|
182
|
+
}
|
|
183
|
+
function parseCodexEvent(rawEvent) {
|
|
184
|
+
if (rawEvent.type !== "agent-turn-complete") {
|
|
185
|
+
throw new Error("unsupported Codex payload");
|
|
186
|
+
}
|
|
187
|
+
const cwd = getString(rawEvent.cwd, ".");
|
|
188
|
+
return {
|
|
189
|
+
tool: "codex",
|
|
190
|
+
state: "completed",
|
|
191
|
+
sessionId: getString(
|
|
192
|
+
rawEvent["thread-id"],
|
|
193
|
+
getString(
|
|
194
|
+
rawEvent["thread_id"],
|
|
195
|
+
getString(
|
|
196
|
+
rawEvent["session_id"],
|
|
197
|
+
getString(rawEvent["turn-id"], getString(rawEvent["turn_id"], "unknown"))
|
|
198
|
+
)
|
|
199
|
+
)
|
|
200
|
+
),
|
|
201
|
+
cwd,
|
|
202
|
+
project: projectFromCwd(cwd),
|
|
203
|
+
summary: normalizeSummary(
|
|
204
|
+
rawEvent["last-assistant-message"],
|
|
205
|
+
getString(rawEvent["last_assistant_message"], "Codex completed a turn")
|
|
206
|
+
),
|
|
207
|
+
rawEvent,
|
|
208
|
+
occurredAt: toOccurredAt(rawEvent)
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
function parseClaudeEvent(rawEvent, eventName) {
|
|
212
|
+
const normalizedEventName = getString(eventName, getString(rawEvent.eventName, ""));
|
|
213
|
+
const notificationType = getString(
|
|
214
|
+
rawEvent.notification_type,
|
|
215
|
+
getString(rawEvent.notificationType, "")
|
|
216
|
+
);
|
|
217
|
+
const cwd = getString(rawEvent.cwd, ".");
|
|
218
|
+
const sessionId = getString(rawEvent.session_id, getString(rawEvent.sessionId, "unknown"));
|
|
219
|
+
if (normalizedEventName === "Notification" && ["permission_prompt", "idle_prompt", "elicitation_dialog"].includes(notificationType)) {
|
|
220
|
+
return {
|
|
221
|
+
tool: "claude",
|
|
222
|
+
state: "needs_input",
|
|
223
|
+
sessionId,
|
|
224
|
+
cwd,
|
|
225
|
+
project: projectFromCwd(cwd),
|
|
226
|
+
summary: normalizeSummary(
|
|
227
|
+
rawEvent.message,
|
|
228
|
+
`Claude notification: ${notificationType || "attention required"}`
|
|
229
|
+
),
|
|
230
|
+
rawEvent,
|
|
231
|
+
occurredAt: toOccurredAt(rawEvent)
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
if (normalizedEventName === "Stop") {
|
|
235
|
+
return {
|
|
236
|
+
tool: "claude",
|
|
237
|
+
state: "completed",
|
|
238
|
+
sessionId,
|
|
239
|
+
cwd,
|
|
240
|
+
project: projectFromCwd(cwd),
|
|
241
|
+
summary: normalizeSummary(
|
|
242
|
+
rawEvent.last_assistant_message,
|
|
243
|
+
getString(rawEvent.message, "Claude completed a task")
|
|
244
|
+
),
|
|
245
|
+
rawEvent,
|
|
246
|
+
occurredAt: toOccurredAt(rawEvent)
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
if (normalizedEventName === "StopFailure") {
|
|
250
|
+
const errorType = getString(rawEvent.error_type, getString(rawEvent.errorType, "unknown"));
|
|
251
|
+
return {
|
|
252
|
+
tool: "claude",
|
|
253
|
+
state: "failed",
|
|
254
|
+
sessionId,
|
|
255
|
+
cwd,
|
|
256
|
+
project: projectFromCwd(cwd),
|
|
257
|
+
summary: normalizeSummary(rawEvent.message, `Stop failure: ${errorType}`),
|
|
258
|
+
rawEvent,
|
|
259
|
+
occurredAt: toOccurredAt(rawEvent)
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
throw new Error(`unsupported Claude payload: ${normalizedEventName || "unknown"}`);
|
|
263
|
+
}
|
|
264
|
+
function getString(value, fallback) {
|
|
265
|
+
return typeof value === "string" && value.length > 0 ? value : fallback;
|
|
266
|
+
}
|
|
267
|
+
function normalizeSummary(value, fallback) {
|
|
268
|
+
const candidate = typeof value === "string" ? value : fallback;
|
|
269
|
+
const normalized = candidate.trim().replace(/\s+/g, " ");
|
|
270
|
+
return normalized.length > 0 ? normalized : fallback;
|
|
271
|
+
}
|
|
272
|
+
function projectFromCwd(cwd) {
|
|
273
|
+
const parts = cwd.split("/").filter(Boolean);
|
|
274
|
+
return parts.at(-1) ?? cwd;
|
|
275
|
+
}
|
|
276
|
+
function toOccurredAt(rawEvent) {
|
|
277
|
+
const value = typeof rawEvent.occurredAt === "string" && rawEvent.occurredAt.length > 0 ? rawEvent.occurredAt : rawEvent.occurred_at;
|
|
278
|
+
return typeof value === "string" && value.length > 0 ? value : (/* @__PURE__ */ new Date()).toISOString();
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// src/installers.ts
|
|
282
|
+
function renderTomlArray(values) {
|
|
283
|
+
return `[${values.map((value) => JSON.stringify(value)).join(", ")}]`;
|
|
284
|
+
}
|
|
285
|
+
function firstTomlTableIndex(text) {
|
|
286
|
+
const match = text.match(/^[ \t]*\[/m);
|
|
287
|
+
return match?.index ?? -1;
|
|
288
|
+
}
|
|
289
|
+
function hasNonTopLevelAssignment(text, key) {
|
|
290
|
+
const searchLimit = firstTomlTableIndex(text);
|
|
291
|
+
if (searchLimit === -1) {
|
|
292
|
+
return false;
|
|
293
|
+
}
|
|
294
|
+
const tableText = text.slice(searchLimit);
|
|
295
|
+
const pattern = new RegExp(`^[ \\t]*${key}[ \\t]*=`, "m");
|
|
296
|
+
return pattern.test(tableText);
|
|
297
|
+
}
|
|
298
|
+
function findTopLevelAssignmentSpan(text, key) {
|
|
299
|
+
const searchLimit = firstTomlTableIndex(text);
|
|
300
|
+
const searchText = searchLimit === -1 ? text : text.slice(0, searchLimit);
|
|
301
|
+
const marker = key;
|
|
302
|
+
let start = searchText.indexOf(marker);
|
|
303
|
+
while (start !== -1) {
|
|
304
|
+
const lineStart = searchText.lastIndexOf("\n", start) + 1;
|
|
305
|
+
const prefix = searchText.slice(lineStart, start).trim();
|
|
306
|
+
if (prefix.length > 0) {
|
|
307
|
+
start = searchText.indexOf(marker, start + marker.length);
|
|
308
|
+
continue;
|
|
309
|
+
}
|
|
310
|
+
let cursor = start + marker.length;
|
|
311
|
+
while (cursor < searchText.length && /\s/.test(searchText[cursor])) {
|
|
312
|
+
cursor += 1;
|
|
313
|
+
}
|
|
314
|
+
if (cursor >= searchText.length || searchText[cursor] !== "=") {
|
|
315
|
+
start = searchText.indexOf(marker, start + marker.length);
|
|
316
|
+
continue;
|
|
317
|
+
}
|
|
318
|
+
cursor += 1;
|
|
319
|
+
while (cursor < searchText.length && /\s/.test(searchText[cursor])) {
|
|
320
|
+
cursor += 1;
|
|
321
|
+
}
|
|
322
|
+
if (cursor < searchText.length && searchText[cursor] === "[") {
|
|
323
|
+
let depth = 0;
|
|
324
|
+
let inDoubleQuotedString = false;
|
|
325
|
+
let inSingleQuotedString = false;
|
|
326
|
+
let inComment = false;
|
|
327
|
+
let escaped = false;
|
|
328
|
+
for (let index = cursor; index < searchText.length; index += 1) {
|
|
329
|
+
const char = searchText[index];
|
|
330
|
+
if (inComment) {
|
|
331
|
+
if (char === "\n") {
|
|
332
|
+
inComment = false;
|
|
333
|
+
}
|
|
334
|
+
continue;
|
|
335
|
+
}
|
|
336
|
+
if (inDoubleQuotedString) {
|
|
337
|
+
if (escaped) {
|
|
338
|
+
escaped = false;
|
|
339
|
+
} else if (char === "\\") {
|
|
340
|
+
escaped = true;
|
|
341
|
+
} else if (char === '"') {
|
|
342
|
+
inDoubleQuotedString = false;
|
|
343
|
+
}
|
|
344
|
+
continue;
|
|
345
|
+
}
|
|
346
|
+
if (inSingleQuotedString) {
|
|
347
|
+
if (char === "'") {
|
|
348
|
+
inSingleQuotedString = false;
|
|
349
|
+
}
|
|
350
|
+
continue;
|
|
351
|
+
}
|
|
352
|
+
if (char === '"') {
|
|
353
|
+
inDoubleQuotedString = true;
|
|
354
|
+
continue;
|
|
355
|
+
}
|
|
356
|
+
if (char === "'") {
|
|
357
|
+
inSingleQuotedString = true;
|
|
358
|
+
continue;
|
|
359
|
+
}
|
|
360
|
+
if (char === "#") {
|
|
361
|
+
inComment = true;
|
|
362
|
+
continue;
|
|
363
|
+
}
|
|
364
|
+
if (char === "[") {
|
|
365
|
+
depth += 1;
|
|
366
|
+
continue;
|
|
367
|
+
}
|
|
368
|
+
if (char === "]") {
|
|
369
|
+
depth -= 1;
|
|
370
|
+
if (depth === 0) {
|
|
371
|
+
let end2 = index + 1;
|
|
372
|
+
while (end2 < searchText.length && /[ \t]/.test(searchText[end2])) {
|
|
373
|
+
end2 += 1;
|
|
374
|
+
}
|
|
375
|
+
if (end2 < searchText.length && searchText[end2] === "#") {
|
|
376
|
+
while (end2 < searchText.length && searchText[end2] !== "\n") {
|
|
377
|
+
end2 += 1;
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
if (end2 < searchText.length && searchText[end2] === "\n") {
|
|
381
|
+
end2 += 1;
|
|
382
|
+
}
|
|
383
|
+
return [start, end2];
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
throw new TypeError(`Unterminated top-level ${key} array assignment in Codex config.`);
|
|
388
|
+
}
|
|
389
|
+
const lineEnd = searchText.indexOf("\n", cursor);
|
|
390
|
+
const end = lineEnd === -1 ? searchText.length : lineEnd + 1;
|
|
391
|
+
return [start, end];
|
|
392
|
+
}
|
|
393
|
+
return null;
|
|
394
|
+
}
|
|
395
|
+
function shellJoin(argv) {
|
|
396
|
+
return argv.map((part) => {
|
|
397
|
+
if (/^[A-Za-z0-9_./:-]+$/.test(part)) {
|
|
398
|
+
return part;
|
|
399
|
+
}
|
|
400
|
+
return `'${part.replace(/'/g, `'\\''`)}'`;
|
|
401
|
+
}).join(" ");
|
|
402
|
+
}
|
|
403
|
+
function buildCodexNotifyCommand(command = "agent-notify") {
|
|
404
|
+
return [command, "handle", "codex"];
|
|
405
|
+
}
|
|
406
|
+
function buildClaudeCommandPrefix(command = "agent-notify") {
|
|
407
|
+
return [command];
|
|
408
|
+
}
|
|
409
|
+
function patchCodexConfig(currentText, commandArgv) {
|
|
410
|
+
const replacement = `notify = ${renderTomlArray(commandArgv)}
|
|
411
|
+
`;
|
|
412
|
+
if (hasNonTopLevelAssignment(currentText, "notify")) {
|
|
413
|
+
throw new TypeError("Found non-top-level notify assignment in Codex config.");
|
|
414
|
+
}
|
|
415
|
+
const existingSpan = findTopLevelAssignmentSpan(currentText, "notify");
|
|
416
|
+
if (existingSpan) {
|
|
417
|
+
const [start, end] = existingSpan;
|
|
418
|
+
return currentText.slice(0, start) + replacement + currentText.slice(end);
|
|
419
|
+
}
|
|
420
|
+
const tableIndex = firstTomlTableIndex(currentText);
|
|
421
|
+
if (tableIndex !== -1) {
|
|
422
|
+
const prefix = currentText.slice(0, tableIndex).replace(/\n+$/, "");
|
|
423
|
+
const suffix = currentText.slice(tableIndex).replace(/^\n+/, "");
|
|
424
|
+
const parts = [prefix, replacement.trimEnd(), suffix].filter((part) => part.length > 0);
|
|
425
|
+
return `${parts.join("\n\n").replace(/\n+$/, "")}
|
|
426
|
+
`;
|
|
427
|
+
}
|
|
428
|
+
const base = currentText.replace(/\n+$/, "");
|
|
429
|
+
return base.length > 0 ? `${base}
|
|
430
|
+
${replacement}` : replacement;
|
|
431
|
+
}
|
|
432
|
+
function patchClaudeSettings(currentText, commandPrefix) {
|
|
433
|
+
const normalizedCurrentText = currentText.trim().length > 0 ? currentText : "{}";
|
|
434
|
+
const parsed = JSON.parse(normalizedCurrentText);
|
|
435
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
436
|
+
throw new TypeError("Claude settings must be a JSON object.");
|
|
437
|
+
}
|
|
438
|
+
const hooksValue = parsed.hooks;
|
|
439
|
+
const hooks = hooksValue === void 0 ? {} : hooksValue && typeof hooksValue === "object" && !Array.isArray(hooksValue) ? hooksValue : null;
|
|
440
|
+
if (hooks === null) {
|
|
441
|
+
throw new TypeError("Claude settings hooks must be a JSON object when present.");
|
|
442
|
+
}
|
|
443
|
+
const commandFor = (eventName) => shellJoin([...commandPrefix, "handle", "claude", "--event", eventName]);
|
|
444
|
+
hooks.Notification = [
|
|
445
|
+
{
|
|
446
|
+
matcher: "permission_prompt|idle_prompt|elicitation_dialog",
|
|
447
|
+
hooks: [
|
|
448
|
+
{
|
|
449
|
+
type: "command",
|
|
450
|
+
command: commandFor("Notification")
|
|
451
|
+
}
|
|
452
|
+
]
|
|
453
|
+
}
|
|
454
|
+
];
|
|
455
|
+
hooks.Stop = [
|
|
456
|
+
{
|
|
457
|
+
hooks: [
|
|
458
|
+
{
|
|
459
|
+
type: "command",
|
|
460
|
+
command: commandFor("Stop")
|
|
461
|
+
}
|
|
462
|
+
]
|
|
463
|
+
}
|
|
464
|
+
];
|
|
465
|
+
hooks.StopFailure = [
|
|
466
|
+
{
|
|
467
|
+
hooks: [
|
|
468
|
+
{
|
|
469
|
+
type: "command",
|
|
470
|
+
command: commandFor("StopFailure")
|
|
471
|
+
}
|
|
472
|
+
]
|
|
473
|
+
}
|
|
474
|
+
];
|
|
475
|
+
parsed.hooks = hooks;
|
|
476
|
+
return `${JSON.stringify(parsed, null, 2)}
|
|
477
|
+
`;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
// src/providers.ts
|
|
481
|
+
import { access } from "fs/promises";
|
|
482
|
+
import { constants as fsConstants } from "fs";
|
|
483
|
+
import { execFile } from "child_process";
|
|
484
|
+
var DesktopProvider = class {
|
|
485
|
+
commandExists;
|
|
486
|
+
run;
|
|
487
|
+
constructor(options = {}) {
|
|
488
|
+
this.commandExists = options.commandExists ?? commandAvailable;
|
|
489
|
+
this.run = options.run ?? runCommand;
|
|
490
|
+
}
|
|
491
|
+
async send(event) {
|
|
492
|
+
const title = formatNotificationTitle(event);
|
|
493
|
+
if (await this.commandExists("osascript")) {
|
|
494
|
+
const sent = await this.execute([
|
|
495
|
+
"osascript",
|
|
496
|
+
"-e",
|
|
497
|
+
`display notification "${escapeAppleScriptString(event.summary)}" with title "${escapeAppleScriptString(title)}"`
|
|
498
|
+
]);
|
|
499
|
+
if (sent) {
|
|
500
|
+
return true;
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
if (await this.commandExists("notify-send")) {
|
|
504
|
+
return this.execute(["notify-send", title, event.summary]);
|
|
505
|
+
}
|
|
506
|
+
return false;
|
|
507
|
+
}
|
|
508
|
+
async execute(command) {
|
|
509
|
+
try {
|
|
510
|
+
const result = await this.run(command);
|
|
511
|
+
return result.ok;
|
|
512
|
+
} catch {
|
|
513
|
+
return false;
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
};
|
|
517
|
+
var SoundProvider = class {
|
|
518
|
+
commandExists;
|
|
519
|
+
run;
|
|
520
|
+
writeTerminalBell;
|
|
521
|
+
writeStdoutBell;
|
|
522
|
+
constructor(options = {}) {
|
|
523
|
+
this.commandExists = options.commandExists ?? commandAvailable;
|
|
524
|
+
this.run = options.run ?? runCommand;
|
|
525
|
+
this.writeTerminalBell = options.writeTerminalBell ?? defaultWriteTerminalBell;
|
|
526
|
+
this.writeStdoutBell = options.writeStdoutBell ?? defaultWriteStdoutBell;
|
|
527
|
+
}
|
|
528
|
+
async send(_event) {
|
|
529
|
+
for (const command of SOUND_COMMANDS) {
|
|
530
|
+
if (!await this.commandExists(command.name)) {
|
|
531
|
+
continue;
|
|
532
|
+
}
|
|
533
|
+
try {
|
|
534
|
+
const result = await this.run(command.args);
|
|
535
|
+
if (result.ok) {
|
|
536
|
+
return true;
|
|
537
|
+
}
|
|
538
|
+
} catch {
|
|
539
|
+
continue;
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
try {
|
|
543
|
+
await this.writeTerminalBell();
|
|
544
|
+
return true;
|
|
545
|
+
} catch {
|
|
546
|
+
try {
|
|
547
|
+
await this.writeStdoutBell();
|
|
548
|
+
return true;
|
|
549
|
+
} catch {
|
|
550
|
+
return false;
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
};
|
|
555
|
+
var SOUND_COMMANDS = [
|
|
556
|
+
{ name: "osascript", args: ["osascript", "-e", "beep 1"] },
|
|
557
|
+
{ name: "paplay", args: ["paplay", "/usr/share/sounds/freedesktop/stereo/complete.oga"] },
|
|
558
|
+
{ name: "aplay", args: ["aplay", "/usr/share/sounds/alsa/Front_Center.wav"] },
|
|
559
|
+
{ name: "afplay", args: ["afplay", "/System/Library/Sounds/Glass.aiff"] }
|
|
560
|
+
];
|
|
561
|
+
function formatNotificationTitle(event) {
|
|
562
|
+
return `[${event.tool}] ${event.project} \xB7 ${event.state}`;
|
|
563
|
+
}
|
|
564
|
+
async function commandAvailable(command) {
|
|
565
|
+
const pathValue = process.env.PATH;
|
|
566
|
+
if (!pathValue) {
|
|
567
|
+
return false;
|
|
568
|
+
}
|
|
569
|
+
for (const directory of pathValue.split(":")) {
|
|
570
|
+
if (!directory) {
|
|
571
|
+
continue;
|
|
572
|
+
}
|
|
573
|
+
try {
|
|
574
|
+
await access(`${directory}/${command}`, fsConstants.X_OK);
|
|
575
|
+
return true;
|
|
576
|
+
} catch {
|
|
577
|
+
continue;
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
return false;
|
|
581
|
+
}
|
|
582
|
+
function runCommand(command) {
|
|
583
|
+
return new Promise((resolve2, reject) => {
|
|
584
|
+
execFile(command[0], command.slice(1), { timeout: 2e3 }, (error) => {
|
|
585
|
+
if (error) {
|
|
586
|
+
reject(error);
|
|
587
|
+
return;
|
|
588
|
+
}
|
|
589
|
+
resolve2({ ok: true });
|
|
590
|
+
});
|
|
591
|
+
});
|
|
592
|
+
}
|
|
593
|
+
async function defaultWriteTerminalBell() {
|
|
594
|
+
const { open } = await import("fs/promises");
|
|
595
|
+
const handle = await open("/dev/tty", "w");
|
|
596
|
+
try {
|
|
597
|
+
await handle.writeFile("\x07", "utf8");
|
|
598
|
+
} finally {
|
|
599
|
+
await handle.close();
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
async function defaultWriteStdoutBell() {
|
|
603
|
+
process.stdout.write("\x07");
|
|
604
|
+
}
|
|
605
|
+
function escapeAppleScriptString(value) {
|
|
606
|
+
return value.replaceAll("\\", "\\\\").replaceAll('"', '\\"').replaceAll("\n", "\\n");
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
// src/notifier.ts
|
|
610
|
+
var Notifier = class {
|
|
611
|
+
config;
|
|
612
|
+
store;
|
|
613
|
+
desktopProvider;
|
|
614
|
+
soundProvider;
|
|
615
|
+
constructor(options) {
|
|
616
|
+
this.config = options.config;
|
|
617
|
+
this.store = options.store;
|
|
618
|
+
this.desktopProvider = options.desktopProvider ?? new DesktopProvider();
|
|
619
|
+
this.soundProvider = options.soundProvider ?? new SoundProvider();
|
|
620
|
+
}
|
|
621
|
+
async notify(event) {
|
|
622
|
+
if (!await this.store.shouldEmit(event)) {
|
|
623
|
+
return {
|
|
624
|
+
emitted: false,
|
|
625
|
+
desktopSent: false,
|
|
626
|
+
soundSent: false
|
|
627
|
+
};
|
|
628
|
+
}
|
|
629
|
+
const desktopSent = this.config.desktopEnabled ? await safeSend(this.desktopProvider, event) : false;
|
|
630
|
+
const soundSent = shouldPlaySound(this.config, event.state) ? await safeSend(this.soundProvider, event) : false;
|
|
631
|
+
await this.store.record(event);
|
|
632
|
+
return {
|
|
633
|
+
emitted: true,
|
|
634
|
+
desktopSent,
|
|
635
|
+
soundSent
|
|
636
|
+
};
|
|
637
|
+
}
|
|
638
|
+
};
|
|
639
|
+
async function safeSend(provider, event) {
|
|
640
|
+
try {
|
|
641
|
+
return Boolean(await provider.send(event));
|
|
642
|
+
} catch {
|
|
643
|
+
return false;
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
// src/store.ts
|
|
648
|
+
import { createHash } from "crypto";
|
|
649
|
+
import { mkdir, readFile, rename, writeFile } from "fs/promises";
|
|
650
|
+
import { dirname, join as join2 } from "path";
|
|
651
|
+
var EventStore = class {
|
|
652
|
+
logPath;
|
|
653
|
+
dedupePath;
|
|
654
|
+
dedupeSeconds;
|
|
655
|
+
maxEntries;
|
|
656
|
+
maxAgeDays;
|
|
657
|
+
now;
|
|
658
|
+
constructor(stateDir, options) {
|
|
659
|
+
this.logPath = join2(stateDir, "events.jsonl");
|
|
660
|
+
this.dedupePath = join2(stateDir, "dedupe.json");
|
|
661
|
+
this.dedupeSeconds = options.dedupeSeconds;
|
|
662
|
+
this.maxEntries = options.maxEntries ?? 5e3;
|
|
663
|
+
this.maxAgeDays = options.maxAgeDays ?? 7;
|
|
664
|
+
this.now = options.now ?? (() => /* @__PURE__ */ new Date());
|
|
665
|
+
}
|
|
666
|
+
async shouldEmit(event) {
|
|
667
|
+
const dedupe = this.pruneDedupe(await this.loadDedupe());
|
|
668
|
+
await this.writeDedupe(dedupe);
|
|
669
|
+
return !(this.eventKey(event) in dedupe);
|
|
670
|
+
}
|
|
671
|
+
async record(event) {
|
|
672
|
+
const dedupe = this.pruneDedupe(await this.loadDedupe());
|
|
673
|
+
dedupe[this.eventKey(event)] = this.now().toISOString();
|
|
674
|
+
await this.writeDedupe(dedupe);
|
|
675
|
+
const entries = await this.readLog();
|
|
676
|
+
entries.push(this.toRecord(event));
|
|
677
|
+
await this.writeLog(this.pruneLogEntries(entries));
|
|
678
|
+
}
|
|
679
|
+
async readLog() {
|
|
680
|
+
await this.ensureStateDir();
|
|
681
|
+
let content;
|
|
682
|
+
try {
|
|
683
|
+
content = await readFile(this.logPath, "utf8");
|
|
684
|
+
} catch (error) {
|
|
685
|
+
if (isMissingFileError(error)) {
|
|
686
|
+
return [];
|
|
687
|
+
}
|
|
688
|
+
throw error;
|
|
689
|
+
}
|
|
690
|
+
const entries = [];
|
|
691
|
+
for (const rawLine of content.split("\n")) {
|
|
692
|
+
const line = rawLine.trim();
|
|
693
|
+
if (!line) {
|
|
694
|
+
continue;
|
|
695
|
+
}
|
|
696
|
+
try {
|
|
697
|
+
const parsed = JSON.parse(line);
|
|
698
|
+
if (isEventRecord(parsed)) {
|
|
699
|
+
entries.push(parsed);
|
|
700
|
+
}
|
|
701
|
+
} catch (error) {
|
|
702
|
+
if (error instanceof SyntaxError) {
|
|
703
|
+
continue;
|
|
704
|
+
}
|
|
705
|
+
throw error;
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
return entries;
|
|
709
|
+
}
|
|
710
|
+
eventKey(event) {
|
|
711
|
+
const summaryHash = createHash("sha256").update(event.summary, "utf8").digest("hex");
|
|
712
|
+
return `${event.tool}|${event.sessionId}|${event.state}|${summaryHash}`;
|
|
713
|
+
}
|
|
714
|
+
async ensureStateDir() {
|
|
715
|
+
await mkdir(dirname(this.logPath), { recursive: true });
|
|
716
|
+
}
|
|
717
|
+
async loadDedupe() {
|
|
718
|
+
await this.ensureStateDir();
|
|
719
|
+
try {
|
|
720
|
+
const content = await readFile(this.dedupePath, "utf8");
|
|
721
|
+
return normalizeDedupeState(JSON.parse(content));
|
|
722
|
+
} catch (error) {
|
|
723
|
+
if (isMissingFileError(error) || error instanceof SyntaxError) {
|
|
724
|
+
return {};
|
|
725
|
+
}
|
|
726
|
+
throw error;
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
async writeDedupe(data) {
|
|
730
|
+
await this.ensureStateDir();
|
|
731
|
+
await this.atomicWriteFile(this.dedupePath, `${JSON.stringify(data, null, 2)}
|
|
732
|
+
`);
|
|
733
|
+
}
|
|
734
|
+
pruneDedupe(data) {
|
|
735
|
+
const cutoff = this.now().getTime() - this.dedupeSeconds * 1e3;
|
|
736
|
+
const pruned = {};
|
|
737
|
+
for (const [key, timestamp] of Object.entries(data)) {
|
|
738
|
+
const value = Date.parse(timestamp);
|
|
739
|
+
if (Number.isNaN(value)) {
|
|
740
|
+
continue;
|
|
741
|
+
}
|
|
742
|
+
if (value >= cutoff) {
|
|
743
|
+
pruned[key] = new Date(value).toISOString();
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
return pruned;
|
|
747
|
+
}
|
|
748
|
+
pruneLogEntries(entries) {
|
|
749
|
+
const cutoff = this.now().getTime() - this.maxAgeDays * 24 * 60 * 60 * 1e3;
|
|
750
|
+
const kept = entries.filter((entry) => {
|
|
751
|
+
const occurredAt = Date.parse(entry.occurred_at);
|
|
752
|
+
return !Number.isNaN(occurredAt) && occurredAt >= cutoff;
|
|
753
|
+
});
|
|
754
|
+
return kept.slice(-this.maxEntries);
|
|
755
|
+
}
|
|
756
|
+
async writeLog(entries) {
|
|
757
|
+
await this.ensureStateDir();
|
|
758
|
+
const content = entries.map((entry) => JSON.stringify(entry)).join("\n") + (entries.length > 0 ? "\n" : "");
|
|
759
|
+
await this.atomicWriteFile(this.logPath, content);
|
|
760
|
+
}
|
|
761
|
+
toRecord(event) {
|
|
762
|
+
return {
|
|
763
|
+
tool: event.tool,
|
|
764
|
+
state: event.state,
|
|
765
|
+
session_id: event.sessionId,
|
|
766
|
+
cwd: event.cwd,
|
|
767
|
+
project: event.project,
|
|
768
|
+
summary: event.summary,
|
|
769
|
+
occurred_at: event.occurredAt,
|
|
770
|
+
raw_event: event.rawEvent
|
|
771
|
+
};
|
|
772
|
+
}
|
|
773
|
+
async atomicWriteFile(path, content) {
|
|
774
|
+
const tempPath = `${path}.${process.pid}.${Date.now()}.tmp`;
|
|
775
|
+
await writeFile(tempPath, content, "utf8");
|
|
776
|
+
await rename(tempPath, path);
|
|
777
|
+
}
|
|
778
|
+
};
|
|
779
|
+
function isMissingFileError(error) {
|
|
780
|
+
return error instanceof Error && "code" in error && error.code === "ENOENT";
|
|
781
|
+
}
|
|
782
|
+
function normalizeDedupeState(value) {
|
|
783
|
+
if (!isPlainObject(value)) {
|
|
784
|
+
return {};
|
|
785
|
+
}
|
|
786
|
+
const normalized = {};
|
|
787
|
+
for (const [key, timestamp] of Object.entries(value)) {
|
|
788
|
+
if (typeof timestamp === "string") {
|
|
789
|
+
normalized[key] = timestamp;
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
return normalized;
|
|
793
|
+
}
|
|
794
|
+
function isEventRecord(value) {
|
|
795
|
+
if (!isPlainObject(value)) {
|
|
796
|
+
return false;
|
|
797
|
+
}
|
|
798
|
+
return isString(value.tool) && isString(value.state) && isString(value.session_id) && isString(value.cwd) && isString(value.project) && isString(value.summary) && isString(value.occurred_at) && isPlainObject(value.raw_event);
|
|
799
|
+
}
|
|
800
|
+
function isPlainObject(value) {
|
|
801
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
802
|
+
}
|
|
803
|
+
function isString(value) {
|
|
804
|
+
return typeof value === "string";
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
// src/cli.ts
|
|
808
|
+
var DEFAULT_CODEX_CONFIG_PATH = join3(homedir2(), ".codex", "config.toml");
|
|
809
|
+
var DEFAULT_CLAUDE_SETTINGS_PATH = join3(homedir2(), ".claude", "settings.json");
|
|
810
|
+
async function main(argv = process.argv.slice(2), dependencies = {}) {
|
|
811
|
+
const io = createRuntime(dependencies);
|
|
812
|
+
if (process.env.AGENT_NOTIFY_SMOKE_SIGNAL === "1" && argv.length === 0) {
|
|
813
|
+
io.stdout.write("agent-notify:main-ran\n");
|
|
814
|
+
}
|
|
815
|
+
if (argv.length === 0) {
|
|
816
|
+
return 0;
|
|
817
|
+
}
|
|
818
|
+
try {
|
|
819
|
+
const [command, ...rest] = argv;
|
|
820
|
+
if (command === "handle") {
|
|
821
|
+
return await handleCommand(rest, io);
|
|
822
|
+
}
|
|
823
|
+
if (command === "install") {
|
|
824
|
+
return await installCommand(rest, io);
|
|
825
|
+
}
|
|
826
|
+
throw new Error(`unknown command: ${command}`);
|
|
827
|
+
} catch (error) {
|
|
828
|
+
io.stderr.write(`${toMessage(error)}
|
|
829
|
+
`);
|
|
830
|
+
return 1;
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
async function handleCommand(argv, runtime) {
|
|
834
|
+
const source = argv[0];
|
|
835
|
+
if (source !== "codex" && source !== "claude") {
|
|
836
|
+
throw new Error("usage: agent-notify handle <codex|claude> ...");
|
|
837
|
+
}
|
|
838
|
+
const options = parseHandleOptions(source, argv.slice(1));
|
|
839
|
+
const payloadText = (await readPayloadText(options.payload, runtime.stdin)).trim();
|
|
840
|
+
if (!payloadText) {
|
|
841
|
+
throw new Error("missing JSON payload");
|
|
842
|
+
}
|
|
843
|
+
const payloadData = parseJsonObject(payloadText);
|
|
844
|
+
const event = parseEvent(source, payloadData, options.eventName);
|
|
845
|
+
const stateDir = options.stateDir ?? defaultStateDir();
|
|
846
|
+
const notifier = runtime.createNotifier?.(source, event.cwd, stateDir) ?? new Notifier({
|
|
847
|
+
config: loadConfig(event.cwd),
|
|
848
|
+
store: runtime.createStore?.(stateDir, event.cwd) ?? new EventStore(stateDir, configToStoreOptions(loadConfig(event.cwd)))
|
|
849
|
+
});
|
|
850
|
+
await notifier.notify(event);
|
|
851
|
+
return 0;
|
|
852
|
+
}
|
|
853
|
+
function isDirectExecution() {
|
|
854
|
+
const entryArg = process.argv[1];
|
|
855
|
+
if (!entryArg) {
|
|
856
|
+
return false;
|
|
857
|
+
}
|
|
858
|
+
try {
|
|
859
|
+
return realpathSync(entryArg) === realpathSync(fileURLToPath(import.meta.url));
|
|
860
|
+
} catch {
|
|
861
|
+
return false;
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
async function installCommand(argv, runtime) {
|
|
865
|
+
const target = argv[0];
|
|
866
|
+
if (target !== "codex" && target !== "claude") {
|
|
867
|
+
throw new Error("usage: agent-notify install <codex|claude> ...");
|
|
868
|
+
}
|
|
869
|
+
const options = parseInstallOptions(target, argv.slice(1));
|
|
870
|
+
const path = target === "codex" ? options.path ?? DEFAULT_CODEX_CONFIG_PATH : options.path ?? DEFAULT_CLAUDE_SETTINGS_PATH;
|
|
871
|
+
const currentText = await readExistingText(path, runtime.readFile);
|
|
872
|
+
const updatedText = target === "codex" ? patchCodexConfig(currentText, buildCodexNotifyCommand("agent-notify")) : patchClaudeSettings(currentText, buildClaudeCommandPrefix("agent-notify"));
|
|
873
|
+
if (options.dryRun) {
|
|
874
|
+
runtime.stdout.write(updatedText);
|
|
875
|
+
return 0;
|
|
876
|
+
}
|
|
877
|
+
await runtime.mkdir(dirname2(path));
|
|
878
|
+
await runtime.writeFile(path, updatedText, "utf8");
|
|
879
|
+
runtime.stdout.write(updatedText);
|
|
880
|
+
return 0;
|
|
881
|
+
}
|
|
882
|
+
function parseHandleOptions(source, argv) {
|
|
883
|
+
let eventName;
|
|
884
|
+
let payload;
|
|
885
|
+
let stateDir;
|
|
886
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
887
|
+
const arg = argv[index];
|
|
888
|
+
if (arg === "--event") {
|
|
889
|
+
eventName = requireValue(arg, argv[index + 1]);
|
|
890
|
+
index += 1;
|
|
891
|
+
continue;
|
|
892
|
+
}
|
|
893
|
+
if (arg === "--state-dir") {
|
|
894
|
+
stateDir = requireValue(arg, argv[index + 1]);
|
|
895
|
+
index += 1;
|
|
896
|
+
continue;
|
|
897
|
+
}
|
|
898
|
+
if (arg.startsWith("--")) {
|
|
899
|
+
throw new Error(`unknown option: ${arg}`);
|
|
900
|
+
}
|
|
901
|
+
if (payload !== void 0) {
|
|
902
|
+
throw new Error("unexpected extra argument");
|
|
903
|
+
}
|
|
904
|
+
payload = arg;
|
|
905
|
+
}
|
|
906
|
+
if (source === "claude" && !eventName) {
|
|
907
|
+
throw new Error("missing required option: --event");
|
|
908
|
+
}
|
|
909
|
+
return { eventName, payload, stateDir };
|
|
910
|
+
}
|
|
911
|
+
function parseInstallOptions(target, argv) {
|
|
912
|
+
let dryRun = false;
|
|
913
|
+
let path;
|
|
914
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
915
|
+
const arg = argv[index];
|
|
916
|
+
if (arg === "--dry-run") {
|
|
917
|
+
dryRun = true;
|
|
918
|
+
continue;
|
|
919
|
+
}
|
|
920
|
+
if (target === "codex" && arg === "--config") {
|
|
921
|
+
path = requireValue(arg, argv[index + 1]);
|
|
922
|
+
index += 1;
|
|
923
|
+
continue;
|
|
924
|
+
}
|
|
925
|
+
if (target === "claude" && arg === "--settings") {
|
|
926
|
+
path = requireValue(arg, argv[index + 1]);
|
|
927
|
+
index += 1;
|
|
928
|
+
continue;
|
|
929
|
+
}
|
|
930
|
+
throw new Error(`unknown option: ${arg}`);
|
|
931
|
+
}
|
|
932
|
+
return { dryRun, path };
|
|
933
|
+
}
|
|
934
|
+
function requireValue(flag, value) {
|
|
935
|
+
if (!value) {
|
|
936
|
+
throw new Error(`missing value for ${flag}`);
|
|
937
|
+
}
|
|
938
|
+
return value;
|
|
939
|
+
}
|
|
940
|
+
async function readPayloadText(explicitPayload, stdin) {
|
|
941
|
+
if (explicitPayload !== void 0) {
|
|
942
|
+
return explicitPayload;
|
|
943
|
+
}
|
|
944
|
+
return await stdin.read() ?? "";
|
|
945
|
+
}
|
|
946
|
+
async function readExistingText(path, readText) {
|
|
947
|
+
try {
|
|
948
|
+
return await readText(path, "utf8");
|
|
949
|
+
} catch (error) {
|
|
950
|
+
if (isMissingFileError2(error)) {
|
|
951
|
+
return "";
|
|
952
|
+
}
|
|
953
|
+
throw error;
|
|
954
|
+
}
|
|
955
|
+
}
|
|
956
|
+
function parseJsonObject(payloadText) {
|
|
957
|
+
const parsed = JSON.parse(payloadText);
|
|
958
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
959
|
+
throw new Error("JSON payload must be an object");
|
|
960
|
+
}
|
|
961
|
+
return parsed;
|
|
962
|
+
}
|
|
963
|
+
function configToStoreOptions(config) {
|
|
964
|
+
return {
|
|
965
|
+
dedupeSeconds: config.dedupeSeconds,
|
|
966
|
+
maxEntries: config.maxLogEntries,
|
|
967
|
+
maxAgeDays: config.maxLogAgeDays
|
|
968
|
+
};
|
|
969
|
+
}
|
|
970
|
+
function createRuntime(dependencies) {
|
|
971
|
+
return {
|
|
972
|
+
stdin: dependencies.stdin ?? {
|
|
973
|
+
read: async () => {
|
|
974
|
+
let payload = "";
|
|
975
|
+
process.stdin.setEncoding("utf8");
|
|
976
|
+
for await (const chunk of process.stdin) {
|
|
977
|
+
payload += typeof chunk === "string" ? chunk : String(chunk);
|
|
978
|
+
}
|
|
979
|
+
return payload;
|
|
980
|
+
}
|
|
981
|
+
},
|
|
982
|
+
stdout: dependencies.stdout ?? process.stdout,
|
|
983
|
+
stderr: dependencies.stderr ?? process.stderr,
|
|
984
|
+
readFile: dependencies.readFile ?? readFile2,
|
|
985
|
+
writeFile: dependencies.writeFile ?? writeFile2,
|
|
986
|
+
mkdir: dependencies.mkdir ?? (async (path) => {
|
|
987
|
+
await mkdir2(path, { recursive: true });
|
|
988
|
+
}),
|
|
989
|
+
createStore: dependencies.createStore ?? ((stateDir, cwd) => {
|
|
990
|
+
const config = loadConfig(cwd);
|
|
991
|
+
return new EventStore(stateDir, configToStoreOptions(config));
|
|
992
|
+
}),
|
|
993
|
+
createNotifier: dependencies.createNotifier ?? ((tool, cwd, stateDir) => {
|
|
994
|
+
const config = loadConfig(cwd);
|
|
995
|
+
const store = dependencies.createStore?.(stateDir, cwd) ?? new EventStore(stateDir, configToStoreOptions(config));
|
|
996
|
+
return new Notifier({
|
|
997
|
+
config,
|
|
998
|
+
store
|
|
999
|
+
});
|
|
1000
|
+
})
|
|
1001
|
+
};
|
|
1002
|
+
}
|
|
1003
|
+
function isMissingFileError2(error) {
|
|
1004
|
+
return error instanceof Error && "code" in error && error.code === "ENOENT";
|
|
1005
|
+
}
|
|
1006
|
+
function toMessage(error) {
|
|
1007
|
+
return error instanceof Error ? error.message : String(error);
|
|
1008
|
+
}
|
|
1009
|
+
if (isDirectExecution()) {
|
|
1010
|
+
void main().then(
|
|
1011
|
+
(exitCode) => {
|
|
1012
|
+
process.exitCode = exitCode;
|
|
1013
|
+
},
|
|
1014
|
+
(error) => {
|
|
1015
|
+
process.stderr.write(`${toMessage(error)}
|
|
1016
|
+
`);
|
|
1017
|
+
process.exitCode = 1;
|
|
1018
|
+
}
|
|
1019
|
+
);
|
|
1020
|
+
}
|
|
1021
|
+
export {
|
|
1022
|
+
main
|
|
1023
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@spencer-kit/agent-notify",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "CLI notifications for Codex and Claude Code task completion and attention events",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"homepage": "https://github.com/spencerkit/agent-notify#readme",
|
|
8
|
+
"bugs": {
|
|
9
|
+
"url": "https://github.com/spencerkit/agent-notify/issues"
|
|
10
|
+
},
|
|
11
|
+
"repository": {
|
|
12
|
+
"type": "git",
|
|
13
|
+
"url": "https://github.com/spencerkit/agent-notify.git"
|
|
14
|
+
},
|
|
15
|
+
"publishConfig": {
|
|
16
|
+
"access": "public"
|
|
17
|
+
},
|
|
18
|
+
"files": [
|
|
19
|
+
"dist",
|
|
20
|
+
"README.md"
|
|
21
|
+
],
|
|
22
|
+
"bin": {
|
|
23
|
+
"agent-notify": "dist/cli.js"
|
|
24
|
+
},
|
|
25
|
+
"scripts": {
|
|
26
|
+
"build": "tsup src/cli.ts --format esm --out-dir dist --clean",
|
|
27
|
+
"prepack": "npm run build",
|
|
28
|
+
"test": "vitest run"
|
|
29
|
+
},
|
|
30
|
+
"devDependencies": {
|
|
31
|
+
"@types/node": "^24.5.2",
|
|
32
|
+
"tsup": "^8.5.0",
|
|
33
|
+
"typescript": "^5.8.2",
|
|
34
|
+
"vitest": "^3.1.1"
|
|
35
|
+
}
|
|
36
|
+
}
|