@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.
Files changed (3) hide show
  1. package/README.md +184 -0
  2. package/dist/cli.js +1023 -0
  3. 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
+ }