aoaoe 0.57.0 → 0.59.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,6 +337,7 @@ 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) |
@@ -497,6 +499,7 @@ src/
497
499
  input.ts # stdin readline listener with inject() for post-interrupt
498
500
  init.ts # `aoaoe init`: auto-discover tools, sessions, generate config
499
501
  notify.ts # webhook + Slack notification dispatcher for daemon events
502
+ health.ts # HTTP health check endpoint (GET /health JSON status)
500
503
  colors.ts # shared ANSI color/style constants
501
504
  context.ts # discoverContextFiles, resolveProjectDir, loadSessionContext
502
505
  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"]),
@@ -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
  }
@@ -241,22 +246,31 @@ async function which(cmd) {
241
246
  return false;
242
247
  }
243
248
  }
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
- }
249
+ // internal recursive merge on plain Record objects (no type assertions needed)
250
+ function mergeRecords(target, source) {
251
+ for (const [key, val] of Object.entries(source)) {
252
+ if (val !== undefined && val !== null) {
253
+ const existing = target[key];
254
+ // empty objects ({}) replace rather than merge allows clearing sessionDirs etc.
255
+ if (typeof val === "object" && !Array.isArray(val) &&
256
+ typeof existing === "object" && existing !== null && !Array.isArray(existing) &&
257
+ Object.keys(val).length > 0) {
258
+ target[key] = mergeRecords({ ...existing }, val);
259
+ }
260
+ else {
261
+ target[key] = val;
257
262
  }
258
263
  }
259
264
  }
265
+ return target;
266
+ }
267
+ // exported for testing — merges config objects with nested object support
268
+ export function deepMerge(...objects) {
269
+ let result = {};
270
+ for (const obj of objects) {
271
+ result = mergeRecords(result, obj);
272
+ }
273
+ // validated by caller (validateConfig) before use — safe cast
260
274
  return result;
261
275
  }
262
276
  // compute fields that differ between two config objects (flat dot-notation paths)
@@ -368,7 +382,7 @@ export function parseCliArgs(argv) {
368
382
  return argv[i + 1];
369
383
  };
370
384
  const knownFlags = new Set([
371
- "--reasoner", "--poll-interval", "--port", "--model", "--profile",
385
+ "--reasoner", "--poll-interval", "--port", "--model", "--profile", "--health-port",
372
386
  "--verbose", "-v", "--dry-run", "--observe", "--confirm", "--help", "-h", "--version",
373
387
  ]);
374
388
  for (let i = 2; i < argv.length; i++) {
@@ -394,6 +408,14 @@ export function parseCliArgs(argv) {
394
408
  i++;
395
409
  break;
396
410
  }
411
+ case "--health-port": {
412
+ const val = parseInt(nextArg(i, arg), 10);
413
+ if (isNaN(val))
414
+ throw new Error(`--health-port value '${argv[i + 1]}' is not a valid number`);
415
+ overrides.healthPort = val;
416
+ i++;
417
+ break;
418
+ }
397
419
  case "--model": {
398
420
  // applies to whichever backend is selected
399
421
  const model = nextArg(i, arg);
@@ -472,6 +494,7 @@ options:
472
494
  --reasoner <opencode|claude-code> reasoning backend (default: opencode)
473
495
  --poll-interval <ms> poll interval in ms (default: 10000)
474
496
  --port <number> opencode server port (default: 4097)
497
+ --health-port <number> start HTTP health check server on this port
475
498
  --model <model> model to use
476
499
  --profile <name> aoe profile (default: default)
477
500
  --dry-run run full loop but only log actions (costs
@@ -509,6 +532,7 @@ example config:
509
532
  "my-project": "/path/to/my-project",
510
533
  "other-repo": "/path/to/other-repo"
511
534
  },
535
+ "healthPort": 4098,
512
536
  "notifications": {
513
537
  "webhookUrl": "https://example.com/webhook",
514
538
  "slackWebhookUrl": "https://hooks.slack.com/services/T.../B.../xxx",
@@ -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/types.d.ts CHANGED
@@ -120,6 +120,7 @@ export interface AoaoeConfig {
120
120
  slackWebhookUrl?: string;
121
121
  events?: NotificationEvent[];
122
122
  };
123
+ healthPort?: number;
123
124
  }
124
125
  export type NotificationEvent = "session_error" | "session_done" | "action_executed" | "action_failed" | "daemon_started" | "daemon_stopped";
125
126
  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.57.0",
3
+ "version": "0.59.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",