aoaoe 0.51.0 → 0.53.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 +32 -2
- package/dist/config.d.ts +1 -0
- package/dist/config.js +44 -2
- package/dist/index.js +68 -1
- package/dist/init.js +1 -0
- package/dist/notify.d.ts +21 -0
- package/dist/notify.js +191 -0
- package/dist/types.d.ts +6 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -238,6 +238,9 @@ aoaoe [command] [options]
|
|
|
238
238
|
commands:
|
|
239
239
|
(none) start the supervisor daemon (interactive TUI)
|
|
240
240
|
init detect tools + sessions, import history, generate config
|
|
241
|
+
status quick daemon health check (is it running? what's it doing?)
|
|
242
|
+
config show the effective resolved config (defaults + file)
|
|
243
|
+
notify-test send a test notification to configured webhooks
|
|
241
244
|
task manage tasks and sessions (list, start, stop, new, rm, edit)
|
|
242
245
|
tasks show task progress (from aoaoe.tasks.json)
|
|
243
246
|
history review recent actions (from ~/.aoaoe/actions.log)
|
|
@@ -297,7 +300,12 @@ Config lives at `~/.aoaoe/aoaoe.config.json` (canonical, written by `aoaoe init`
|
|
|
297
300
|
"adventure": "github/adventure",
|
|
298
301
|
"cloudchamber": "cc/cloudchamber"
|
|
299
302
|
},
|
|
300
|
-
"contextFiles": []
|
|
303
|
+
"contextFiles": [],
|
|
304
|
+
"notifications": {
|
|
305
|
+
"webhookUrl": "https://example.com/webhook",
|
|
306
|
+
"slackWebhookUrl": "https://hooks.slack.com/services/T.../B.../xxx",
|
|
307
|
+
"events": ["session_error", "session_done", "daemon_started", "daemon_stopped"]
|
|
308
|
+
}
|
|
301
309
|
}
|
|
302
310
|
```
|
|
303
311
|
|
|
@@ -321,6 +329,9 @@ Config lives at `~/.aoaoe/aoaoe.config.json` (canonical, written by `aoaoe init`
|
|
|
321
329
|
| `sessionDirs` | Map session titles to project directories (relative to cwd or absolute). Bypasses heuristic directory search. | `{}` |
|
|
322
330
|
| `contextFiles` | Extra AI instruction file paths to load from each project root | `[]` |
|
|
323
331
|
| `captureLinesCount` | Number of tmux lines to capture per session (`-S` flag) | `100` |
|
|
332
|
+
| `notifications.webhookUrl` | Generic webhook URL (POST JSON) | (none) |
|
|
333
|
+
| `notifications.slackWebhookUrl` | Slack incoming webhook URL (block kit format) | (none) |
|
|
334
|
+
| `notifications.events` | Filter which events fire (omit to send all). Valid: `session_error`, `session_done`, `action_executed`, `action_failed`, `daemon_started`, `daemon_stopped` | (all) |
|
|
324
335
|
|
|
325
336
|
Also reads `.aoaoe.json` as an alternative config filename.
|
|
326
337
|
|
|
@@ -342,7 +353,25 @@ For non-standard layouts or when the session title doesn't match the directory n
|
|
|
342
353
|
|
|
343
354
|
Paths can be relative (resolved from the directory where you run `aoaoe`) or absolute. Case-insensitive matching is used for session title lookup. If a mapped path doesn't exist on disk, aoaoe falls back to heuristic search.
|
|
344
355
|
|
|
345
|
-
Use `aoaoe test-context` to verify resolution
|
|
356
|
+
Use `aoaoe test-context` to verify resolution.
|
|
357
|
+
|
|
358
|
+
### `notifications` — webhook alerts for daemon events
|
|
359
|
+
|
|
360
|
+
aoaoe can send webhook notifications when significant events occur (session errors, task completions, daemon start/stop). Supports generic JSON webhooks and Slack incoming webhooks with block kit formatting.
|
|
361
|
+
|
|
362
|
+
```json
|
|
363
|
+
{
|
|
364
|
+
"notifications": {
|
|
365
|
+
"webhookUrl": "https://example.com/webhook",
|
|
366
|
+
"slackWebhookUrl": "https://hooks.slack.com/services/T.../B.../xxx",
|
|
367
|
+
"events": ["session_error", "session_done", "daemon_started", "daemon_stopped"]
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
```
|
|
371
|
+
|
|
372
|
+
Both webhook URLs are optional — configure one or both. The `events` array filters which event types fire (omit it to receive all events). Notifications are fire-and-forget with a 5s timeout and 60s rate limiting per event+session combo to prevent spam.
|
|
373
|
+
|
|
374
|
+
Run `aoaoe notify-test` to verify your webhook configuration.
|
|
346
375
|
|
|
347
376
|
## How It Works
|
|
348
377
|
|
|
@@ -460,6 +489,7 @@ src/
|
|
|
460
489
|
tui.ts # in-place terminal UI (alternate screen, scroll regions)
|
|
461
490
|
input.ts # stdin readline listener with inject() for post-interrupt
|
|
462
491
|
init.ts # `aoaoe init`: auto-discover tools, sessions, generate config
|
|
492
|
+
notify.ts # webhook + Slack notification dispatcher for daemon events
|
|
463
493
|
colors.ts # shared ANSI color/style constants
|
|
464
494
|
context.ts # discoverContextFiles, resolveProjectDir, loadSessionContext
|
|
465
495
|
activity.ts # detect human keystrokes in tmux sessions
|
package/dist/config.d.ts
CHANGED
package/dist/config.js
CHANGED
|
@@ -91,6 +91,7 @@ const KNOWN_KEYS = {
|
|
|
91
91
|
"maxIdleBeforeNudgeMs", "maxErrorsBeforeRestart", "autoAnswerPermissions",
|
|
92
92
|
"actionCooldownMs", "userActivityThresholdMs", "allowDestructive",
|
|
93
93
|
]),
|
|
94
|
+
notifications: new Set(["webhookUrl", "slackWebhookUrl", "events"]),
|
|
94
95
|
};
|
|
95
96
|
export function warnUnknownKeys(raw, source) {
|
|
96
97
|
if (!raw || typeof raw !== "object" || Array.isArray(raw))
|
|
@@ -182,6 +183,34 @@ export function validateConfig(config) {
|
|
|
182
183
|
if (config.policies?.allowDestructive !== undefined && typeof config.policies.allowDestructive !== "boolean") {
|
|
183
184
|
errors.push(`policies.allowDestructive must be a boolean, got ${typeof config.policies.allowDestructive}`);
|
|
184
185
|
}
|
|
186
|
+
// notifications.webhookUrl must be a string starting with http:// or https://
|
|
187
|
+
if (config.notifications?.webhookUrl !== undefined) {
|
|
188
|
+
const u = config.notifications.webhookUrl;
|
|
189
|
+
if (typeof u !== "string" || (!u.startsWith("http://") && !u.startsWith("https://"))) {
|
|
190
|
+
errors.push(`notifications.webhookUrl must be a URL starting with http:// or https://, got ${JSON.stringify(u)}`);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
// notifications.slackWebhookUrl must be a string starting with http:// or https://
|
|
194
|
+
if (config.notifications?.slackWebhookUrl !== undefined) {
|
|
195
|
+
const u = config.notifications.slackWebhookUrl;
|
|
196
|
+
if (typeof u !== "string" || (!u.startsWith("http://") && !u.startsWith("https://"))) {
|
|
197
|
+
errors.push(`notifications.slackWebhookUrl must be a URL starting with http:// or https://, got ${JSON.stringify(u)}`);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
// notifications.events must be an array of valid NotificationEvent values
|
|
201
|
+
if (config.notifications?.events !== undefined) {
|
|
202
|
+
const VALID_EVENTS = new Set(["session_error", "session_done", "action_executed", "action_failed", "daemon_started", "daemon_stopped"]);
|
|
203
|
+
if (!Array.isArray(config.notifications.events)) {
|
|
204
|
+
errors.push(`notifications.events must be an array, got ${typeof config.notifications.events}`);
|
|
205
|
+
}
|
|
206
|
+
else {
|
|
207
|
+
for (const e of config.notifications.events) {
|
|
208
|
+
if (!VALID_EVENTS.has(e)) {
|
|
209
|
+
errors.push(`notifications.events contains invalid event "${e}"`);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
185
214
|
if (errors.length > 0) {
|
|
186
215
|
throw new Error(`invalid config:\n ${errors.join("\n ")}`);
|
|
187
216
|
}
|
|
@@ -245,7 +274,7 @@ export function parseCliArgs(argv) {
|
|
|
245
274
|
let initForce = false;
|
|
246
275
|
let runTaskCli = false;
|
|
247
276
|
let registerTitle;
|
|
248
|
-
const defaults = { overrides, help: false, version: false, register: false, testContext: false, runTest: false, showTasks: false, showHistory: false, showStatus: false, showConfig: false, runInit: false, initForce: false, runTaskCli: false };
|
|
277
|
+
const defaults = { overrides, help: false, version: false, register: false, testContext: false, runTest: false, showTasks: false, showHistory: false, showStatus: false, showConfig: false, notifyTest: false, runInit: false, initForce: false, runTaskCli: false };
|
|
249
278
|
// check for subcommand as first non-flag arg
|
|
250
279
|
if (argv[2] === "test-context") {
|
|
251
280
|
return { ...defaults, testContext: true };
|
|
@@ -268,6 +297,9 @@ export function parseCliArgs(argv) {
|
|
|
268
297
|
if (argv[2] === "config") {
|
|
269
298
|
return { ...defaults, showConfig: true };
|
|
270
299
|
}
|
|
300
|
+
if (argv[2] === "notify-test") {
|
|
301
|
+
return { ...defaults, notifyTest: true };
|
|
302
|
+
}
|
|
271
303
|
if (argv[2] === "init") {
|
|
272
304
|
const force = argv.includes("--force") || argv.includes("-f");
|
|
273
305
|
return { ...defaults, runInit: true, initForce: force };
|
|
@@ -357,7 +389,7 @@ export function parseCliArgs(argv) {
|
|
|
357
389
|
break;
|
|
358
390
|
}
|
|
359
391
|
}
|
|
360
|
-
return { overrides, help, version, register: false, testContext: false, runTest: false, showTasks: false, showHistory: false, showStatus: false, showConfig: false, runInit: false, initForce: false, runTaskCli: false };
|
|
392
|
+
return { overrides, help, version, register: false, testContext: false, runTest: false, showTasks: false, showHistory: false, showStatus: false, showConfig: false, notifyTest: false, runInit: false, initForce: false, runTaskCli: false };
|
|
361
393
|
}
|
|
362
394
|
export function printHelp() {
|
|
363
395
|
console.log(`aoaoe - autonomous supervisor for agent-of-empires sessions
|
|
@@ -375,6 +407,7 @@ commands:
|
|
|
375
407
|
(none) start the supervisor daemon (interactive TUI)
|
|
376
408
|
status quick daemon health check (is it running? what's it doing?)
|
|
377
409
|
config show the effective resolved config (defaults + file)
|
|
410
|
+
notify-test send a test notification to configured webhooks
|
|
378
411
|
task manage tasks and sessions (list, start, stop, new, rm, edit)
|
|
379
412
|
tasks show task progress (from aoaoe.tasks.json)
|
|
380
413
|
history review recent actions (from ~/.aoaoe/actions.log)
|
|
@@ -417,6 +450,11 @@ example config:
|
|
|
417
450
|
"sessionDirs": {
|
|
418
451
|
"my-project": "/path/to/my-project",
|
|
419
452
|
"other-repo": "/path/to/other-repo"
|
|
453
|
+
},
|
|
454
|
+
"notifications": {
|
|
455
|
+
"webhookUrl": "https://example.com/webhook",
|
|
456
|
+
"slackWebhookUrl": "https://hooks.slack.com/services/T.../B.../xxx",
|
|
457
|
+
"events": ["session_error", "session_done", "daemon_started", "daemon_stopped"]
|
|
420
458
|
}
|
|
421
459
|
}
|
|
422
460
|
|
|
@@ -424,6 +462,10 @@ example config:
|
|
|
424
462
|
aoaoe loads AGENTS.md, claude.md, and other AI instruction files
|
|
425
463
|
from each project directory to give the reasoner per-session context.
|
|
426
464
|
|
|
465
|
+
notifications sends webhook alerts for daemon events. Both webhookUrl
|
|
466
|
+
and slackWebhookUrl are optional. events filters which events fire
|
|
467
|
+
(omit to send all). Run 'aoaoe notify-test' to verify delivery.
|
|
468
|
+
|
|
427
469
|
interactive commands (while daemon is running):
|
|
428
470
|
/help show available commands
|
|
429
471
|
/explain ask the AI to explain what's happening in plain English
|
package/dist/index.js
CHANGED
|
@@ -17,6 +17,7 @@ import { TaskManager, loadTaskDefinitions, loadTaskState, formatTaskTable } from
|
|
|
17
17
|
import { runTaskCli, handleTaskSlashCommand } from "./task-cli.js";
|
|
18
18
|
import { TUI } from "./tui.js";
|
|
19
19
|
import { isDaemonRunningFromState } from "./chat.js";
|
|
20
|
+
import { sendNotification, sendTestNotification } from "./notify.js";
|
|
20
21
|
import { actionSession, actionDetail } from "./types.js";
|
|
21
22
|
import { YELLOW, GREEN, DIM, BOLD, RED, RESET } from "./colors.js";
|
|
22
23
|
import { readFileSync, existsSync, statSync, mkdirSync, writeFileSync, chmodSync } from "node:fs";
|
|
@@ -27,7 +28,7 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
|
27
28
|
const AOAOE_DIR = join(homedir(), ".aoaoe"); // watch dir for wakeable sleep
|
|
28
29
|
const INPUT_FILE = join(AOAOE_DIR, "pending-input.txt"); // file IPC from chat.ts
|
|
29
30
|
async function main() {
|
|
30
|
-
const { overrides, help, version, register, testContext: isTestContext, runTest, showTasks, showHistory, showStatus, showConfig, runInit, initForce, runTaskCli: isTaskCli, registerTitle } = parseCliArgs(process.argv);
|
|
31
|
+
const { overrides, help, version, register, testContext: isTestContext, runTest, showTasks, showHistory, showStatus, showConfig, notifyTest, runInit, initForce, runTaskCli: isTaskCli, registerTitle } = parseCliArgs(process.argv);
|
|
31
32
|
if (help) {
|
|
32
33
|
printHelp();
|
|
33
34
|
process.exit(0);
|
|
@@ -77,6 +78,11 @@ async function main() {
|
|
|
77
78
|
showEffectiveConfig();
|
|
78
79
|
return;
|
|
79
80
|
}
|
|
81
|
+
// `aoaoe notify-test` -- send a test notification to configured webhooks
|
|
82
|
+
if (notifyTest) {
|
|
83
|
+
await runNotifyTest();
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
80
86
|
// `aoaoe task` -- task management CLI
|
|
81
87
|
if (isTaskCli) {
|
|
82
88
|
await runTaskCli(process.argv);
|
|
@@ -284,6 +290,8 @@ async function main() {
|
|
|
284
290
|
console.error(` mode: dry-run (no execution)`);
|
|
285
291
|
console.error("");
|
|
286
292
|
log("shutting down...");
|
|
293
|
+
// notify: daemon stopped (fire-and-forget, don't block shutdown)
|
|
294
|
+
sendNotification(config, { event: "daemon_stopped", timestamp: Date.now(), detail: `polls: ${totalPolls}, actions: ${totalActionsExecuted}` });
|
|
287
295
|
input.stop();
|
|
288
296
|
Promise.resolve()
|
|
289
297
|
.then(() => reasonerConsole.stop())
|
|
@@ -307,6 +315,8 @@ async function main() {
|
|
|
307
315
|
else {
|
|
308
316
|
log("entering main loop (Ctrl+C to stop)\n");
|
|
309
317
|
}
|
|
318
|
+
// notify: daemon started
|
|
319
|
+
sendNotification(config, { event: "daemon_started", timestamp: Date.now(), detail: `reasoner: ${config.reasoner}` });
|
|
310
320
|
// clear any stale interrupt from a previous run
|
|
311
321
|
clearInterrupt();
|
|
312
322
|
// auto-explain: on the very first tick with sessions, inject an explain prompt
|
|
@@ -680,6 +690,19 @@ async function daemonTick(config, poller, reasoner, executor, reasonerConsole, p
|
|
|
680
690
|
}
|
|
681
691
|
}
|
|
682
692
|
}
|
|
693
|
+
// notify: session error/done events (fires for both TUI and non-TUI modes)
|
|
694
|
+
{
|
|
695
|
+
const changedSet = new Set(observation.changes.map((c) => c.title));
|
|
696
|
+
for (const snap of observation.sessions) {
|
|
697
|
+
const s = snap.session;
|
|
698
|
+
if (s.status === "error" && changedSet.has(s.title)) {
|
|
699
|
+
sendNotification(config, { event: "session_error", timestamp: Date.now(), session: s.title, detail: `status: ${s.status}` });
|
|
700
|
+
}
|
|
701
|
+
if (s.status === "done" && changedSet.has(s.title)) {
|
|
702
|
+
sendNotification(config, { event: "session_done", timestamp: Date.now(), session: s.title });
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
}
|
|
683
706
|
if (skippedReason === "no changes") {
|
|
684
707
|
if (config.verbose) {
|
|
685
708
|
if (tui)
|
|
@@ -750,6 +773,13 @@ async function daemonTick(config, poller, reasoner, executor, reasonerConsole, p
|
|
|
750
773
|
log(`[${icon}] ${displayText}`);
|
|
751
774
|
}
|
|
752
775
|
reasonerConsole.writeAction(entry.action.action, richDetail, entry.success);
|
|
776
|
+
// notify: action executed or failed
|
|
777
|
+
sendNotification(config, {
|
|
778
|
+
event: entry.success ? "action_executed" : "action_failed",
|
|
779
|
+
timestamp: Date.now(),
|
|
780
|
+
session: sessionTitle,
|
|
781
|
+
detail: `${entry.action.action}${actionText ? `: ${actionText.slice(0, 200)}` : ""}`,
|
|
782
|
+
});
|
|
753
783
|
}
|
|
754
784
|
const actionsOk = executed.filter((e) => e.success && e.action.action !== "wait").length;
|
|
755
785
|
const actionsFail = executed.filter((e) => !e.success && e.action.action !== "wait").length;
|
|
@@ -1016,6 +1046,43 @@ async function registerAsAoeSession(title) {
|
|
|
1016
1046
|
console.log();
|
|
1017
1047
|
console.log(`or start + enter immediately: aoe session start ${sessionTitle} && aoe`);
|
|
1018
1048
|
}
|
|
1049
|
+
// `aoaoe notify-test` -- send a test notification to all configured webhooks and report results
|
|
1050
|
+
async function runNotifyTest() {
|
|
1051
|
+
const config = loadConfig();
|
|
1052
|
+
if (!config.notifications) {
|
|
1053
|
+
console.log("");
|
|
1054
|
+
console.log(" no notifications configured.");
|
|
1055
|
+
console.log("");
|
|
1056
|
+
console.log(" add to your config (~/.aoaoe/aoaoe.config.json):");
|
|
1057
|
+
console.log(' "notifications": {');
|
|
1058
|
+
console.log(' "webhookUrl": "https://example.com/webhook",');
|
|
1059
|
+
console.log(' "slackWebhookUrl": "https://hooks.slack.com/services/...",');
|
|
1060
|
+
console.log(' "events": ["session_error", "session_done", "daemon_started", "daemon_stopped"]');
|
|
1061
|
+
console.log(" }");
|
|
1062
|
+
console.log("");
|
|
1063
|
+
return;
|
|
1064
|
+
}
|
|
1065
|
+
if (!config.notifications.webhookUrl && !config.notifications.slackWebhookUrl) {
|
|
1066
|
+
console.log("");
|
|
1067
|
+
console.log(" notifications block exists but no webhook URLs configured.");
|
|
1068
|
+
console.log(" add webhookUrl and/or slackWebhookUrl to your notifications config.");
|
|
1069
|
+
console.log("");
|
|
1070
|
+
return;
|
|
1071
|
+
}
|
|
1072
|
+
console.log("");
|
|
1073
|
+
console.log(" sending test notification...");
|
|
1074
|
+
const result = await sendTestNotification(config);
|
|
1075
|
+
console.log("");
|
|
1076
|
+
if (result.webhookOk !== undefined) {
|
|
1077
|
+
const icon = result.webhookOk ? `${GREEN}✓${RESET}` : `${RED}✗${RESET}`;
|
|
1078
|
+
console.log(` ${icon} generic webhook: ${result.webhookOk ? "ok" : result.webhookError ?? "failed"}`);
|
|
1079
|
+
}
|
|
1080
|
+
if (result.slackOk !== undefined) {
|
|
1081
|
+
const icon = result.slackOk ? `${GREEN}✓${RESET}` : `${RED}✗${RESET}`;
|
|
1082
|
+
console.log(` ${icon} slack webhook: ${result.slackOk ? "ok" : result.slackError ?? "failed"}`);
|
|
1083
|
+
}
|
|
1084
|
+
console.log("");
|
|
1085
|
+
}
|
|
1019
1086
|
function readPkgVersion() {
|
|
1020
1087
|
try {
|
|
1021
1088
|
const pkg = JSON.parse(readFileSync(resolve(__dirname, "..", "package.json"), "utf-8"));
|
package/dist/init.js
CHANGED
|
@@ -291,6 +291,7 @@ export async function runInit(forceOverwrite = false) {
|
|
|
291
291
|
console.log(` ${CYAN}aoaoe${RESET}`);
|
|
292
292
|
}
|
|
293
293
|
console.log(`\n ${DIM}tip: run ${BOLD}aoaoe test-context${RESET}${DIM} to verify session discovery without starting the daemon${RESET}`);
|
|
294
|
+
console.log(` ${DIM}tip: add a "notifications" block to your config for webhook alerts (see ${BOLD}aoaoe --help${RESET}${DIM})${RESET}`);
|
|
294
295
|
console.log();
|
|
295
296
|
return { tools, sessions, reasoner, opencodePort, opencodeRunning, configPath, wrote: true };
|
|
296
297
|
}
|
package/dist/notify.d.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { AoaoeConfig, NotificationEvent } from "./types.js";
|
|
2
|
+
export interface NotificationPayload {
|
|
3
|
+
event: NotificationEvent;
|
|
4
|
+
timestamp: number;
|
|
5
|
+
session?: string;
|
|
6
|
+
detail?: string;
|
|
7
|
+
}
|
|
8
|
+
export declare function isRateLimited(payload: NotificationPayload, now?: number): boolean;
|
|
9
|
+
export declare function resetRateLimiter(): void;
|
|
10
|
+
export declare function sendNotification(config: AoaoeConfig, payload: NotificationPayload): Promise<void>;
|
|
11
|
+
export declare function sendTestNotification(config: AoaoeConfig): Promise<{
|
|
12
|
+
webhookOk?: boolean;
|
|
13
|
+
slackOk?: boolean;
|
|
14
|
+
webhookError?: string;
|
|
15
|
+
slackError?: string;
|
|
16
|
+
}>;
|
|
17
|
+
export declare function formatSlackPayload(payload: NotificationPayload): {
|
|
18
|
+
text: string;
|
|
19
|
+
blocks: object[];
|
|
20
|
+
};
|
|
21
|
+
//# sourceMappingURL=notify.d.ts.map
|
package/dist/notify.js
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
// ── rate limiting ───────────────────────────────────────────────────────────
|
|
2
|
+
// dedup key: "event:session" — prevents spam when sessions rapidly error/recover.
|
|
3
|
+
// default window: 60s per unique event+session combo.
|
|
4
|
+
const RATE_LIMIT_MS = 60_000;
|
|
5
|
+
const recentNotifications = new Map(); // key → last-sent timestamp
|
|
6
|
+
function rateLimitKey(payload) {
|
|
7
|
+
return `${payload.event}:${payload.session ?? ""}`;
|
|
8
|
+
}
|
|
9
|
+
// exported for testing
|
|
10
|
+
export function isRateLimited(payload, now) {
|
|
11
|
+
const key = rateLimitKey(payload);
|
|
12
|
+
const lastSent = recentNotifications.get(key);
|
|
13
|
+
const ts = now ?? Date.now();
|
|
14
|
+
if (lastSent !== undefined && ts - lastSent < RATE_LIMIT_MS)
|
|
15
|
+
return true;
|
|
16
|
+
return false;
|
|
17
|
+
}
|
|
18
|
+
function recordSent(payload, now) {
|
|
19
|
+
const key = rateLimitKey(payload);
|
|
20
|
+
recentNotifications.set(key, now ?? Date.now());
|
|
21
|
+
// prune old entries to prevent unbounded growth (keep last 200)
|
|
22
|
+
if (recentNotifications.size > 200) {
|
|
23
|
+
const cutoff = (now ?? Date.now()) - RATE_LIMIT_MS;
|
|
24
|
+
for (const [k, v] of recentNotifications) {
|
|
25
|
+
if (v < cutoff)
|
|
26
|
+
recentNotifications.delete(k);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
// exported for testing — reset rate limiter state between tests
|
|
31
|
+
export function resetRateLimiter() {
|
|
32
|
+
recentNotifications.clear();
|
|
33
|
+
}
|
|
34
|
+
// send a notification to all configured webhooks.
|
|
35
|
+
// fire-and-forget: never throws, never blocks the daemon.
|
|
36
|
+
// rate-limited: suppresses duplicate event+session combos within 60s.
|
|
37
|
+
export async function sendNotification(config, payload) {
|
|
38
|
+
const n = config.notifications;
|
|
39
|
+
if (!n)
|
|
40
|
+
return;
|
|
41
|
+
// filter: only send if event is in the configured list (or no filter = send all)
|
|
42
|
+
if (n.events && n.events.length > 0 && !n.events.includes(payload.event))
|
|
43
|
+
return;
|
|
44
|
+
// rate limit: skip if we sent the same event+session recently
|
|
45
|
+
if (isRateLimited(payload))
|
|
46
|
+
return;
|
|
47
|
+
recordSent(payload);
|
|
48
|
+
const promises = [];
|
|
49
|
+
if (n.webhookUrl) {
|
|
50
|
+
promises.push(sendGenericWebhook(n.webhookUrl, payload));
|
|
51
|
+
}
|
|
52
|
+
if (n.slackWebhookUrl) {
|
|
53
|
+
promises.push(sendSlackWebhook(n.slackWebhookUrl, payload));
|
|
54
|
+
}
|
|
55
|
+
// fire-and-forget — swallow all errors so the daemon never crashes on notification failure
|
|
56
|
+
await Promise.allSettled(promises);
|
|
57
|
+
}
|
|
58
|
+
// send a test notification and return whether delivery succeeded.
|
|
59
|
+
// unlike sendNotification, this is NOT fire-and-forget — it reports errors.
|
|
60
|
+
export async function sendTestNotification(config) {
|
|
61
|
+
const n = config.notifications;
|
|
62
|
+
if (!n)
|
|
63
|
+
return {};
|
|
64
|
+
const payload = {
|
|
65
|
+
event: "daemon_started",
|
|
66
|
+
timestamp: Date.now(),
|
|
67
|
+
detail: "test notification from aoaoe notify-test",
|
|
68
|
+
};
|
|
69
|
+
const result = {};
|
|
70
|
+
if (n.webhookUrl) {
|
|
71
|
+
try {
|
|
72
|
+
const resp = await fetch(n.webhookUrl, {
|
|
73
|
+
method: "POST",
|
|
74
|
+
headers: { "Content-Type": "application/json" },
|
|
75
|
+
body: JSON.stringify({ event: payload.event, timestamp: payload.timestamp, detail: payload.detail }),
|
|
76
|
+
signal: AbortSignal.timeout(10_000),
|
|
77
|
+
});
|
|
78
|
+
result.webhookOk = resp.ok;
|
|
79
|
+
if (!resp.ok)
|
|
80
|
+
result.webhookError = `HTTP ${resp.status} ${resp.statusText}`;
|
|
81
|
+
}
|
|
82
|
+
catch (err) {
|
|
83
|
+
result.webhookOk = false;
|
|
84
|
+
result.webhookError = String(err);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
if (n.slackWebhookUrl) {
|
|
88
|
+
try {
|
|
89
|
+
const body = formatSlackPayload(payload);
|
|
90
|
+
const resp = await fetch(n.slackWebhookUrl, {
|
|
91
|
+
method: "POST",
|
|
92
|
+
headers: { "Content-Type": "application/json" },
|
|
93
|
+
body: JSON.stringify(body),
|
|
94
|
+
signal: AbortSignal.timeout(10_000),
|
|
95
|
+
});
|
|
96
|
+
result.slackOk = resp.ok;
|
|
97
|
+
if (!resp.ok)
|
|
98
|
+
result.slackError = `HTTP ${resp.status} ${resp.statusText}`;
|
|
99
|
+
}
|
|
100
|
+
catch (err) {
|
|
101
|
+
result.slackOk = false;
|
|
102
|
+
result.slackError = String(err);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return result;
|
|
106
|
+
}
|
|
107
|
+
// POST JSON payload to a generic webhook URL
|
|
108
|
+
async function sendGenericWebhook(url, payload) {
|
|
109
|
+
try {
|
|
110
|
+
await fetch(url, {
|
|
111
|
+
method: "POST",
|
|
112
|
+
headers: { "Content-Type": "application/json" },
|
|
113
|
+
body: JSON.stringify({
|
|
114
|
+
event: payload.event,
|
|
115
|
+
timestamp: payload.timestamp,
|
|
116
|
+
session: payload.session,
|
|
117
|
+
detail: payload.detail,
|
|
118
|
+
}),
|
|
119
|
+
signal: AbortSignal.timeout(5000),
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
catch (err) {
|
|
123
|
+
console.error(`[notify] generic webhook failed: ${err}`);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
// POST Slack block format to a Slack incoming webhook URL
|
|
127
|
+
async function sendSlackWebhook(url, payload) {
|
|
128
|
+
try {
|
|
129
|
+
const body = formatSlackPayload(payload);
|
|
130
|
+
await fetch(url, {
|
|
131
|
+
method: "POST",
|
|
132
|
+
headers: { "Content-Type": "application/json" },
|
|
133
|
+
body: JSON.stringify(body),
|
|
134
|
+
signal: AbortSignal.timeout(5000),
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
catch (err) {
|
|
138
|
+
console.error(`[notify] slack webhook failed: ${err}`);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
// format a notification payload into Slack block kit format.
|
|
142
|
+
// exported for testing.
|
|
143
|
+
export function formatSlackPayload(payload) {
|
|
144
|
+
const icon = eventIcon(payload.event);
|
|
145
|
+
const title = eventTitle(payload.event);
|
|
146
|
+
const fallbackText = payload.session
|
|
147
|
+
? `${icon} ${title}: ${payload.session}${payload.detail ? ` — ${payload.detail}` : ""}`
|
|
148
|
+
: `${icon} ${title}${payload.detail ? ` — ${payload.detail}` : ""}`;
|
|
149
|
+
const blocks = [
|
|
150
|
+
{
|
|
151
|
+
type: "section",
|
|
152
|
+
text: {
|
|
153
|
+
type: "mrkdwn",
|
|
154
|
+
text: `*${icon} ${title}*${payload.session ? `\n*Session:* ${payload.session}` : ""}${payload.detail ? `\n${payload.detail}` : ""}`,
|
|
155
|
+
},
|
|
156
|
+
},
|
|
157
|
+
{
|
|
158
|
+
type: "context",
|
|
159
|
+
elements: [
|
|
160
|
+
{
|
|
161
|
+
type: "mrkdwn",
|
|
162
|
+
text: `aoaoe | ${new Date(payload.timestamp).toISOString()}`,
|
|
163
|
+
},
|
|
164
|
+
],
|
|
165
|
+
},
|
|
166
|
+
];
|
|
167
|
+
return { text: fallbackText, blocks };
|
|
168
|
+
}
|
|
169
|
+
// human-readable title for each event type
|
|
170
|
+
function eventTitle(event) {
|
|
171
|
+
switch (event) {
|
|
172
|
+
case "session_error": return "Session Error";
|
|
173
|
+
case "session_done": return "Session Done";
|
|
174
|
+
case "action_executed": return "Action Executed";
|
|
175
|
+
case "action_failed": return "Action Failed";
|
|
176
|
+
case "daemon_started": return "Daemon Started";
|
|
177
|
+
case "daemon_stopped": return "Daemon Stopped";
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
// emoji icon for each event type (used in Slack messages)
|
|
181
|
+
function eventIcon(event) {
|
|
182
|
+
switch (event) {
|
|
183
|
+
case "session_error": return "\u{1F6A8}"; // 🚨
|
|
184
|
+
case "session_done": return "\u2705"; // ✅
|
|
185
|
+
case "action_executed": return "\u2699\uFE0F"; // ⚙️
|
|
186
|
+
case "action_failed": return "\u274C"; // ❌
|
|
187
|
+
case "daemon_started": return "\u{1F680}"; // 🚀
|
|
188
|
+
case "daemon_stopped": return "\u{1F6D1}"; // 🛑
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
//# sourceMappingURL=notify.js.map
|
package/dist/types.d.ts
CHANGED
|
@@ -115,7 +115,13 @@ export interface AoaoeConfig {
|
|
|
115
115
|
dryRun: boolean;
|
|
116
116
|
observe: boolean;
|
|
117
117
|
confirm: boolean;
|
|
118
|
+
notifications?: {
|
|
119
|
+
webhookUrl?: string;
|
|
120
|
+
slackWebhookUrl?: string;
|
|
121
|
+
events?: NotificationEvent[];
|
|
122
|
+
};
|
|
118
123
|
}
|
|
124
|
+
export type NotificationEvent = "session_error" | "session_done" | "action_executed" | "action_failed" | "daemon_started" | "daemon_stopped";
|
|
119
125
|
export type DaemonPhase = "sleeping" | "polling" | "reasoning" | "executing" | "interrupted";
|
|
120
126
|
export interface DaemonSessionState {
|
|
121
127
|
id: string;
|