aoaoe 0.52.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 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
@@ -20,6 +20,7 @@ export declare function parseCliArgs(argv: string[]): {
20
20
  showHistory: boolean;
21
21
  showStatus: boolean;
22
22
  showConfig: boolean;
23
+ notifyTest: boolean;
23
24
  runInit: boolean;
24
25
  initForce: boolean;
25
26
  runTaskCli: boolean;
package/dist/config.js CHANGED
@@ -274,7 +274,7 @@ export function parseCliArgs(argv) {
274
274
  let initForce = false;
275
275
  let runTaskCli = false;
276
276
  let registerTitle;
277
- 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 };
278
278
  // check for subcommand as first non-flag arg
279
279
  if (argv[2] === "test-context") {
280
280
  return { ...defaults, testContext: true };
@@ -297,6 +297,9 @@ export function parseCliArgs(argv) {
297
297
  if (argv[2] === "config") {
298
298
  return { ...defaults, showConfig: true };
299
299
  }
300
+ if (argv[2] === "notify-test") {
301
+ return { ...defaults, notifyTest: true };
302
+ }
300
303
  if (argv[2] === "init") {
301
304
  const force = argv.includes("--force") || argv.includes("-f");
302
305
  return { ...defaults, runInit: true, initForce: force };
@@ -386,7 +389,7 @@ export function parseCliArgs(argv) {
386
389
  break;
387
390
  }
388
391
  }
389
- 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 };
390
393
  }
391
394
  export function printHelp() {
392
395
  console.log(`aoaoe - autonomous supervisor for agent-of-empires sessions
@@ -404,6 +407,7 @@ commands:
404
407
  (none) start the supervisor daemon (interactive TUI)
405
408
  status quick daemon health check (is it running? what's it doing?)
406
409
  config show the effective resolved config (defaults + file)
410
+ notify-test send a test notification to configured webhooks
407
411
  task manage tasks and sessions (list, start, stop, new, rm, edit)
408
412
  tasks show task progress (from aoaoe.tasks.json)
409
413
  history review recent actions (from ~/.aoaoe/actions.log)
@@ -446,6 +450,11 @@ example config:
446
450
  "sessionDirs": {
447
451
  "my-project": "/path/to/my-project",
448
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"]
449
458
  }
450
459
  }
451
460
 
@@ -453,6 +462,10 @@ example config:
453
462
  aoaoe loads AGENTS.md, claude.md, and other AI instruction files
454
463
  from each project directory to give the reasoner per-session context.
455
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
+
456
469
  interactive commands (while daemon is running):
457
470
  /help show available commands
458
471
  /explain ask the AI to explain what's happening in plain English
package/dist/index.js CHANGED
@@ -17,7 +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 } from "./notify.js";
20
+ import { sendNotification, sendTestNotification } from "./notify.js";
21
21
  import { actionSession, actionDetail } from "./types.js";
22
22
  import { YELLOW, GREEN, DIM, BOLD, RED, RESET } from "./colors.js";
23
23
  import { readFileSync, existsSync, statSync, mkdirSync, writeFileSync, chmodSync } from "node:fs";
@@ -28,7 +28,7 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
28
28
  const AOAOE_DIR = join(homedir(), ".aoaoe"); // watch dir for wakeable sleep
29
29
  const INPUT_FILE = join(AOAOE_DIR, "pending-input.txt"); // file IPC from chat.ts
30
30
  async function main() {
31
- 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);
32
32
  if (help) {
33
33
  printHelp();
34
34
  process.exit(0);
@@ -78,6 +78,11 @@ async function main() {
78
78
  showEffectiveConfig();
79
79
  return;
80
80
  }
81
+ // `aoaoe notify-test` -- send a test notification to configured webhooks
82
+ if (notifyTest) {
83
+ await runNotifyTest();
84
+ return;
85
+ }
81
86
  // `aoaoe task` -- task management CLI
82
87
  if (isTaskCli) {
83
88
  await runTaskCli(process.argv);
@@ -1041,6 +1046,43 @@ async function registerAsAoeSession(title) {
1041
1046
  console.log();
1042
1047
  console.log(`or start + enter immediately: aoe session start ${sessionTitle} && aoe`);
1043
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
+ }
1044
1086
  function readPkgVersion() {
1045
1087
  try {
1046
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 CHANGED
@@ -5,7 +5,15 @@ export interface NotificationPayload {
5
5
  session?: string;
6
6
  detail?: string;
7
7
  }
8
+ export declare function isRateLimited(payload: NotificationPayload, now?: number): boolean;
9
+ export declare function resetRateLimiter(): void;
8
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
+ }>;
9
17
  export declare function formatSlackPayload(payload: NotificationPayload): {
10
18
  text: string;
11
19
  blocks: object[];
package/dist/notify.js CHANGED
@@ -1,5 +1,39 @@
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
+ }
1
34
  // send a notification to all configured webhooks.
2
35
  // fire-and-forget: never throws, never blocks the daemon.
36
+ // rate-limited: suppresses duplicate event+session combos within 60s.
3
37
  export async function sendNotification(config, payload) {
4
38
  const n = config.notifications;
5
39
  if (!n)
@@ -7,6 +41,10 @@ export async function sendNotification(config, payload) {
7
41
  // filter: only send if event is in the configured list (or no filter = send all)
8
42
  if (n.events && n.events.length > 0 && !n.events.includes(payload.event))
9
43
  return;
44
+ // rate limit: skip if we sent the same event+session recently
45
+ if (isRateLimited(payload))
46
+ return;
47
+ recordSent(payload);
10
48
  const promises = [];
11
49
  if (n.webhookUrl) {
12
50
  promises.push(sendGenericWebhook(n.webhookUrl, payload));
@@ -17,6 +55,55 @@ export async function sendNotification(config, payload) {
17
55
  // fire-and-forget — swallow all errors so the daemon never crashes on notification failure
18
56
  await Promise.allSettled(promises);
19
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
+ }
20
107
  // POST JSON payload to a generic webhook URL
21
108
  async function sendGenericWebhook(url, payload) {
22
109
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "aoaoe",
3
- "version": "0.52.0",
3
+ "version": "0.53.0",
4
4
  "description": "Autonomous supervisor for agent-of-empires sessions using OpenCode or Claude Code",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",