aoaoe 0.58.0 → 0.60.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
@@ -259,6 +259,7 @@ options:
259
259
  --reasoner <opencode|claude-code> reasoning backend (default: opencode)
260
260
  --poll-interval <ms> poll interval in ms (default: 10000)
261
261
  --port <number> opencode server port (default: 4097)
262
+ --health-port <number> start HTTP health check server on this port
262
263
  --model <model> model to use
263
264
  --profile <name> aoe profile (default: default)
264
265
  --dry-run run full loop but only log actions (costs
@@ -336,9 +337,11 @@ Config lives at `~/.aoaoe/aoaoe.config.json` (canonical, written by `aoaoe init`
336
337
  | `sessionDirs` | Map session titles to project directories (relative to cwd or absolute). Bypasses heuristic directory search. | `{}` |
337
338
  | `contextFiles` | Extra AI instruction file paths to load from each project root | `[]` |
338
339
  | `captureLinesCount` | Number of tmux lines to capture per session (`-S` flag) | `100` |
340
+ | `healthPort` | Start HTTP health check server on this port (e.g. `4098`). GET `/health` returns JSON status. | (none) |
339
341
  | `notifications.webhookUrl` | Generic webhook URL (POST JSON) | (none) |
340
342
  | `notifications.slackWebhookUrl` | Slack incoming webhook URL (block kit format) | (none) |
341
343
  | `notifications.events` | Filter which events fire (omit to send all). Valid: `session_error`, `session_done`, `action_executed`, `action_failed`, `daemon_started`, `daemon_stopped` | (all) |
344
+ | `notifications.maxRetries` | Retry failed webhook deliveries with exponential backoff (1s, 2s, 4s, ...) | `0` (no retry) |
342
345
 
343
346
  Also reads `.aoaoe.json` as an alternative config filename.
344
347
 
@@ -497,6 +500,7 @@ src/
497
500
  input.ts # stdin readline listener with inject() for post-interrupt
498
501
  init.ts # `aoaoe init`: auto-discover tools, sessions, generate config
499
502
  notify.ts # webhook + Slack notification dispatcher for daemon events
503
+ health.ts # HTTP health check endpoint (GET /health JSON status)
500
504
  colors.ts # shared ANSI color/style constants
501
505
  context.ts # discoverContextFiles, resolveProjectDir, loadSessionContext
502
506
  activity.ts # detect human keystrokes in tmux sessions
package/dist/config.js CHANGED
@@ -83,7 +83,7 @@ export function loadConfig(overrides) {
83
83
  const KNOWN_KEYS = {
84
84
  reasoner: true, pollIntervalMs: true, captureLinesCount: true,
85
85
  verbose: true, dryRun: true, observe: true, confirm: true,
86
- contextFiles: true, sessionDirs: true, protectedSessions: true,
86
+ contextFiles: true, sessionDirs: true, protectedSessions: true, healthPort: true,
87
87
  opencode: new Set(["port", "model"]),
88
88
  claudeCode: new Set(["model", "yolo", "resume"]),
89
89
  aoe: new Set(["profile"]),
@@ -91,7 +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
+ notifications: new Set(["webhookUrl", "slackWebhookUrl", "events", "maxRetries"]),
95
95
  };
96
96
  export function warnUnknownKeys(raw, source) {
97
97
  if (!raw || typeof raw !== "object" || Array.isArray(raw))
@@ -128,6 +128,11 @@ export function validateConfig(config) {
128
128
  if (typeof config.opencode?.port !== "number" || !isFinite(config.opencode.port) || config.opencode.port < 1 || config.opencode.port > 65535) {
129
129
  errors.push(`opencode.port must be 1-65535, got ${config.opencode?.port}`);
130
130
  }
131
+ if (config.healthPort !== undefined) {
132
+ if (typeof config.healthPort !== "number" || !isFinite(config.healthPort) || config.healthPort < 1 || config.healthPort > 65535) {
133
+ errors.push(`healthPort must be 1-65535, got ${config.healthPort}`);
134
+ }
135
+ }
131
136
  if (typeof config.policies?.maxErrorsBeforeRestart !== "number" || config.policies.maxErrorsBeforeRestart < 1) {
132
137
  errors.push(`policies.maxErrorsBeforeRestart must be >= 1, got ${config.policies?.maxErrorsBeforeRestart}`);
133
138
  }
@@ -211,6 +216,13 @@ export function validateConfig(config) {
211
216
  }
212
217
  }
213
218
  }
219
+ // notifications.maxRetries must be a non-negative integer
220
+ if (config.notifications?.maxRetries !== undefined) {
221
+ const r = config.notifications.maxRetries;
222
+ if (typeof r !== "number" || !isFinite(r) || r < 0 || !Number.isInteger(r)) {
223
+ errors.push(`notifications.maxRetries must be a non-negative integer, got ${r}`);
224
+ }
225
+ }
214
226
  if (errors.length > 0) {
215
227
  throw new Error(`invalid config:\n ${errors.join("\n ")}`);
216
228
  }
@@ -241,22 +253,31 @@ async function which(cmd) {
241
253
  return false;
242
254
  }
243
255
  }
244
- // exported for testing
245
- export function deepMerge(...objects) {
246
- const result = {};
247
- for (const obj of objects) {
248
- for (const [key, val] of Object.entries(obj)) {
249
- if (val !== undefined && val !== null) {
250
- // empty objects ({}) replace rather than merge allows clearing sessionDirs etc.
251
- if (typeof val === "object" && !Array.isArray(val) && Object.keys(val).length > 0 && typeof result[key] === "object") {
252
- result[key] = deepMerge(result[key], val);
253
- }
254
- else {
255
- result[key] = val;
256
- }
256
+ // internal recursive merge on plain Record objects (no type assertions needed)
257
+ function mergeRecords(target, source) {
258
+ for (const [key, val] of Object.entries(source)) {
259
+ if (val !== undefined && val !== null) {
260
+ const existing = target[key];
261
+ // empty objects ({}) replace rather than merge allows clearing sessionDirs etc.
262
+ if (typeof val === "object" && !Array.isArray(val) &&
263
+ typeof existing === "object" && existing !== null && !Array.isArray(existing) &&
264
+ Object.keys(val).length > 0) {
265
+ target[key] = mergeRecords({ ...existing }, val);
266
+ }
267
+ else {
268
+ target[key] = val;
257
269
  }
258
270
  }
259
271
  }
272
+ return target;
273
+ }
274
+ // exported for testing — merges config objects with nested object support
275
+ export function deepMerge(...objects) {
276
+ let result = {};
277
+ for (const obj of objects) {
278
+ result = mergeRecords(result, obj);
279
+ }
280
+ // validated by caller (validateConfig) before use — safe cast
260
281
  return result;
261
282
  }
262
283
  // compute fields that differ between two config objects (flat dot-notation paths)
@@ -368,7 +389,7 @@ export function parseCliArgs(argv) {
368
389
  return argv[i + 1];
369
390
  };
370
391
  const knownFlags = new Set([
371
- "--reasoner", "--poll-interval", "--port", "--model", "--profile",
392
+ "--reasoner", "--poll-interval", "--port", "--model", "--profile", "--health-port",
372
393
  "--verbose", "-v", "--dry-run", "--observe", "--confirm", "--help", "-h", "--version",
373
394
  ]);
374
395
  for (let i = 2; i < argv.length; i++) {
@@ -394,6 +415,14 @@ export function parseCliArgs(argv) {
394
415
  i++;
395
416
  break;
396
417
  }
418
+ case "--health-port": {
419
+ const val = parseInt(nextArg(i, arg), 10);
420
+ if (isNaN(val))
421
+ throw new Error(`--health-port value '${argv[i + 1]}' is not a valid number`);
422
+ overrides.healthPort = val;
423
+ i++;
424
+ break;
425
+ }
397
426
  case "--model": {
398
427
  // applies to whichever backend is selected
399
428
  const model = nextArg(i, arg);
@@ -472,6 +501,7 @@ options:
472
501
  --reasoner <opencode|claude-code> reasoning backend (default: opencode)
473
502
  --poll-interval <ms> poll interval in ms (default: 10000)
474
503
  --port <number> opencode server port (default: 4097)
504
+ --health-port <number> start HTTP health check server on this port
475
505
  --model <model> model to use
476
506
  --profile <name> aoe profile (default: default)
477
507
  --dry-run run full loop but only log actions (costs
@@ -509,10 +539,12 @@ example config:
509
539
  "my-project": "/path/to/my-project",
510
540
  "other-repo": "/path/to/other-repo"
511
541
  },
542
+ "healthPort": 4098,
512
543
  "notifications": {
513
544
  "webhookUrl": "https://example.com/webhook",
514
545
  "slackWebhookUrl": "https://hooks.slack.com/services/T.../B.../xxx",
515
- "events": ["session_error", "session_done", "daemon_started", "daemon_stopped"]
546
+ "events": ["session_error", "session_done", "daemon_started", "daemon_stopped"],
547
+ "maxRetries": 2
516
548
  }
517
549
  }
518
550
 
@@ -522,7 +554,8 @@ example config:
522
554
 
523
555
  notifications sends webhook alerts for daemon events. Both webhookUrl
524
556
  and slackWebhookUrl are optional. events filters which events fire
525
- (omit to send all). Run 'aoaoe notify-test' to verify delivery.
557
+ (omit to send all). maxRetries enables exponential backoff retry on
558
+ failure (default: 0 = no retry). Run 'aoaoe notify-test' to verify.
526
559
 
527
560
  interactive commands (while daemon is running):
528
561
  /help show available commands
@@ -0,0 +1,26 @@
1
+ import { type Server } from "node:http";
2
+ import type { DaemonState } from "./types.js";
3
+ export declare function buildHealthResponse(state: DaemonState | null, startedAt: number, now?: number): HealthResponse;
4
+ export interface HealthResponse {
5
+ status: "ok" | "error";
6
+ version: string;
7
+ uptimeMs: number;
8
+ daemon: {
9
+ phase: string;
10
+ phaseStartedAt: number;
11
+ pollCount: number;
12
+ pollIntervalMs: number;
13
+ sessionCount: number;
14
+ changeCount: number;
15
+ paused: boolean;
16
+ sessions: Array<{
17
+ title: string;
18
+ tool: string;
19
+ status: string;
20
+ currentTask?: string;
21
+ userActive: boolean;
22
+ }>;
23
+ } | null;
24
+ }
25
+ export declare function startHealthServer(port: number, startedAt: number): Server;
26
+ //# sourceMappingURL=health.d.ts.map
package/dist/health.js ADDED
@@ -0,0 +1,79 @@
1
+ // health.ts — HTTP health check endpoint for daemon monitoring
2
+ // starts a lightweight HTTP server that responds to GET /health with JSON status.
3
+ // enabled when config.healthPort is set (opt-in). the server reads daemon state
4
+ // from the IPC state file and returns uptime, phase, session info, and version.
5
+ import { createServer } from "node:http";
6
+ import { readState } from "./daemon-state.js";
7
+ import { readFileSync } from "node:fs";
8
+ import { join, dirname } from "node:path";
9
+ import { fileURLToPath } from "node:url";
10
+ // read version from package.json at startup (cached)
11
+ let cachedVersion;
12
+ function getVersion() {
13
+ if (cachedVersion)
14
+ return cachedVersion;
15
+ try {
16
+ // resolve from compiled dist/ to project root package.json
17
+ const thisDir = dirname(fileURLToPath(import.meta.url));
18
+ const pkg = JSON.parse(readFileSync(join(thisDir, "..", "package.json"), "utf-8"));
19
+ cachedVersion = pkg.version ?? "unknown";
20
+ }
21
+ catch {
22
+ cachedVersion = "unknown";
23
+ }
24
+ return cachedVersion;
25
+ }
26
+ // pure function: build health response JSON from daemon state (exported for testing)
27
+ export function buildHealthResponse(state, startedAt, now = Date.now()) {
28
+ const uptimeMs = now - startedAt;
29
+ const version = getVersion();
30
+ if (!state) {
31
+ return {
32
+ status: "error",
33
+ version,
34
+ uptimeMs,
35
+ daemon: null,
36
+ };
37
+ }
38
+ return {
39
+ status: "ok",
40
+ version,
41
+ uptimeMs,
42
+ daemon: {
43
+ phase: state.phase,
44
+ phaseStartedAt: state.phaseStartedAt,
45
+ pollCount: state.pollCount,
46
+ pollIntervalMs: state.pollIntervalMs,
47
+ sessionCount: state.sessionCount,
48
+ changeCount: state.changeCount,
49
+ paused: state.paused,
50
+ sessions: state.sessions.map((s) => ({
51
+ title: s.title,
52
+ tool: s.tool,
53
+ status: s.status,
54
+ currentTask: s.currentTask,
55
+ userActive: s.userActive ?? false,
56
+ })),
57
+ },
58
+ };
59
+ }
60
+ // start the health HTTP server on the given port. returns the server for shutdown.
61
+ export function startHealthServer(port, startedAt) {
62
+ const server = createServer((req, res) => {
63
+ // only respond to GET /health (and GET / as convenience alias)
64
+ if (req.method === "GET" && (req.url === "/health" || req.url === "/")) {
65
+ const state = readState();
66
+ const body = JSON.stringify(buildHealthResponse(state, startedAt), null, 2);
67
+ res.writeHead(200, { "Content-Type": "application/json", "Cache-Control": "no-cache" });
68
+ res.end(body + "\n");
69
+ return;
70
+ }
71
+ res.writeHead(404, { "Content-Type": "application/json" });
72
+ res.end(JSON.stringify({ error: "not found", hint: "try GET /health" }) + "\n");
73
+ });
74
+ server.listen(port, "127.0.0.1", () => {
75
+ // listening — logged by caller
76
+ });
77
+ return server;
78
+ }
79
+ //# sourceMappingURL=health.js.map
package/dist/index.js CHANGED
@@ -18,6 +18,7 @@ import { runTaskCli, handleTaskSlashCommand } from "./task-cli.js";
18
18
  import { TUI } from "./tui.js";
19
19
  import { isDaemonRunningFromState } from "./chat.js";
20
20
  import { sendNotification, sendTestNotification } from "./notify.js";
21
+ import { startHealthServer } from "./health.js";
21
22
  import { actionSession, actionDetail, toActionLogEntry } from "./types.js";
22
23
  import { YELLOW, GREEN, DIM, BOLD, RED, RESET } from "./colors.js";
23
24
  import { readFileSync, existsSync, statSync, mkdirSync, writeFileSync, chmodSync } from "node:fs";
@@ -266,8 +267,18 @@ async function main() {
266
267
  }
267
268
  catch { }
268
269
  }
269
- // ── session stats (for shutdown summary) ──────────────────────────────────
270
+ // ── health check HTTP server (opt-in via config.healthPort) ────────────────
270
271
  const daemonStartedAt = Date.now();
272
+ let healthServer = null;
273
+ if (config.healthPort) {
274
+ healthServer = startHealthServer(config.healthPort, daemonStartedAt);
275
+ const msg = `health server listening on http://127.0.0.1:${config.healthPort}/health`;
276
+ if (tui)
277
+ tui.log("system", msg);
278
+ else
279
+ log(msg);
280
+ }
281
+ // ── session stats (for shutdown summary) ──────────────────────────────────
271
282
  let totalDecisions = 0;
272
283
  let totalActionsExecuted = 0;
273
284
  let totalActionsFailed = 0;
@@ -308,6 +319,8 @@ async function main() {
308
319
  console.error(` mode: dry-run (no execution)`);
309
320
  console.error("");
310
321
  log("shutting down...");
322
+ if (healthServer)
323
+ healthServer.close();
311
324
  // notify: daemon stopped (fire-and-forget, don't block shutdown)
312
325
  sendNotification(config, { event: "daemon_stopped", timestamp: Date.now(), detail: `polls: ${totalPolls}, actions: ${totalActionsExecuted}` });
313
326
  input.stop();
package/dist/notify.d.ts CHANGED
@@ -14,6 +14,7 @@ export declare function sendTestNotification(config: AoaoeConfig): Promise<{
14
14
  webhookError?: string;
15
15
  slackError?: string;
16
16
  }>;
17
+ export declare function fetchWithRetry(url: string, options: RequestInit, maxRetries?: number, baseDelayMs?: number): Promise<Response>;
17
18
  export declare function formatSlackPayload(payload: NotificationPayload): {
18
19
  text: string;
19
20
  blocks: object[];
package/dist/notify.js CHANGED
@@ -45,12 +45,13 @@ export async function sendNotification(config, payload) {
45
45
  if (isRateLimited(payload))
46
46
  return;
47
47
  recordSent(payload);
48
+ const retries = n.maxRetries ?? 0;
48
49
  const promises = [];
49
50
  if (n.webhookUrl) {
50
- promises.push(sendGenericWebhook(n.webhookUrl, payload));
51
+ promises.push(sendGenericWebhook(n.webhookUrl, payload, retries));
51
52
  }
52
53
  if (n.slackWebhookUrl) {
53
- promises.push(sendSlackWebhook(n.slackWebhookUrl, payload));
54
+ promises.push(sendSlackWebhook(n.slackWebhookUrl, payload, retries));
54
55
  }
55
56
  // fire-and-forget — swallow all errors so the daemon never crashes on notification failure
56
57
  await Promise.allSettled(promises);
@@ -104,10 +105,34 @@ export async function sendTestNotification(config) {
104
105
  }
105
106
  return result;
106
107
  }
108
+ // ── retry with exponential backoff ──────────────────────────────────────────
109
+ // exported for testing. retries failed fetch calls with exponential backoff.
110
+ // maxRetries=0 means no retry (single attempt). delay doubles each attempt.
111
+ export async function fetchWithRetry(url, options, maxRetries = 0, baseDelayMs = 1000) {
112
+ let lastError;
113
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
114
+ try {
115
+ const resp = await fetch(url, options);
116
+ if (resp.ok || attempt === maxRetries)
117
+ return resp;
118
+ // non-ok response: retry if we have attempts left
119
+ lastError = new Error(`HTTP ${resp.status} ${resp.statusText}`);
120
+ }
121
+ catch (err) {
122
+ lastError = err;
123
+ if (attempt === maxRetries)
124
+ break;
125
+ }
126
+ // exponential backoff: baseDelay * 2^attempt (1s, 2s, 4s, ...)
127
+ const delay = baseDelayMs * Math.pow(2, attempt);
128
+ await new Promise((r) => setTimeout(r, delay));
129
+ }
130
+ throw lastError;
131
+ }
107
132
  // POST JSON payload to a generic webhook URL
108
- async function sendGenericWebhook(url, payload) {
133
+ async function sendGenericWebhook(url, payload, maxRetries = 0) {
109
134
  try {
110
- await fetch(url, {
135
+ await fetchWithRetry(url, {
111
136
  method: "POST",
112
137
  headers: { "Content-Type": "application/json" },
113
138
  body: JSON.stringify({
@@ -117,22 +142,22 @@ async function sendGenericWebhook(url, payload) {
117
142
  detail: payload.detail,
118
143
  }),
119
144
  signal: AbortSignal.timeout(5000),
120
- });
145
+ }, maxRetries);
121
146
  }
122
147
  catch (err) {
123
148
  console.error(`[notify] generic webhook failed: ${err}`);
124
149
  }
125
150
  }
126
151
  // POST Slack block format to a Slack incoming webhook URL
127
- async function sendSlackWebhook(url, payload) {
152
+ async function sendSlackWebhook(url, payload, maxRetries = 0) {
128
153
  try {
129
154
  const body = formatSlackPayload(payload);
130
- await fetch(url, {
155
+ await fetchWithRetry(url, {
131
156
  method: "POST",
132
157
  headers: { "Content-Type": "application/json" },
133
158
  body: JSON.stringify(body),
134
159
  signal: AbortSignal.timeout(5000),
135
- });
160
+ }, maxRetries);
136
161
  }
137
162
  catch (err) {
138
163
  console.error(`[notify] slack webhook failed: ${err}`);
package/dist/types.d.ts CHANGED
@@ -119,7 +119,9 @@ export interface AoaoeConfig {
119
119
  webhookUrl?: string;
120
120
  slackWebhookUrl?: string;
121
121
  events?: NotificationEvent[];
122
+ maxRetries?: number;
122
123
  };
124
+ healthPort?: number;
123
125
  }
124
126
  export type NotificationEvent = "session_error" | "session_done" | "action_executed" | "action_failed" | "daemon_started" | "daemon_stopped";
125
127
  export type DaemonPhase = "sleeping" | "polling" | "reasoning" | "executing" | "interrupted";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "aoaoe",
3
- "version": "0.58.0",
3
+ "version": "0.60.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",