aoaoe 0.67.0 → 0.68.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.
@@ -0,0 +1,44 @@
1
+ import type { AoaoeConfig } from "./types.js";
2
+ /** Describes a single config field change */
3
+ export interface ConfigChange {
4
+ field: string;
5
+ oldValue: unknown;
6
+ newValue: unknown;
7
+ applied: boolean;
8
+ }
9
+ /**
10
+ * Merge hot-reloadable fields from fresh config into the current config.
11
+ * Returns: { config: updated config, changes: list of what changed }
12
+ * Non-reloadable fields are left unchanged but reported as changes.
13
+ */
14
+ export declare function mergeHotReload(current: AoaoeConfig, fresh: AoaoeConfig): {
15
+ config: AoaoeConfig;
16
+ changes: ConfigChange[];
17
+ };
18
+ /**
19
+ * Format a config change for display in the TUI.
20
+ */
21
+ export declare function formatConfigChange(change: ConfigChange): string;
22
+ export type ConfigChangeCallback = (changes: ConfigChange[], newConfig: AoaoeConfig) => void;
23
+ /**
24
+ * ConfigWatcher — watches the config file and calls back when it changes.
25
+ * Debounces rapid changes (editors do save-rename-write sequences).
26
+ */
27
+ export declare class ConfigWatcher {
28
+ private watcher;
29
+ private debounceTimer;
30
+ private configPath;
31
+ private callback;
32
+ private currentConfig;
33
+ private debounceMs;
34
+ constructor(config: AoaoeConfig, debounceMs?: number);
35
+ /** Start watching the config file. Returns the watched path or null. */
36
+ start(callback: ConfigChangeCallback): string | null;
37
+ /** Stop watching */
38
+ stop(): void;
39
+ /** Get the current config (may have been hot-reloaded) */
40
+ getConfig(): AoaoeConfig;
41
+ private scheduleReload;
42
+ private doReload;
43
+ }
44
+ //# sourceMappingURL=config-watcher.d.ts.map
@@ -0,0 +1,159 @@
1
+ // config-watcher.ts — watch config file for changes and hot-reload safe fields.
2
+ // pure mergeHotReload function + ConfigWatcher class using fs.watch.
3
+ import { watch } from "node:fs";
4
+ import { loadConfig, findConfigFile, validateConfig } from "./config.js";
5
+ /**
6
+ * Fields that are safe to hot-reload without restarting the daemon.
7
+ * Fields NOT in this list require a daemon restart to take effect.
8
+ *
9
+ * Unsafe (require restart): reasoner, opencode.port, healthPort, dryRun, observe, confirm
10
+ */
11
+ const HOT_RELOAD_FIELDS = new Set([
12
+ "pollIntervalMs",
13
+ "sessionDirs",
14
+ "protectedSessions",
15
+ "contextFiles",
16
+ "verbose",
17
+ "captureLinesCount",
18
+ "tuiHistoryRetentionDays",
19
+ ]);
20
+ /** Nested objects where all sub-fields are safe to hot-reload */
21
+ const HOT_RELOAD_OBJECTS = new Set([
22
+ "policies",
23
+ "notifications",
24
+ ]);
25
+ /**
26
+ * Merge hot-reloadable fields from fresh config into the current config.
27
+ * Returns: { config: updated config, changes: list of what changed }
28
+ * Non-reloadable fields are left unchanged but reported as changes.
29
+ */
30
+ export function mergeHotReload(current, fresh) {
31
+ const changes = [];
32
+ const merged = { ...current };
33
+ // check top-level scalar/array fields
34
+ for (const field of HOT_RELOAD_FIELDS) {
35
+ const key = field;
36
+ const curVal = JSON.stringify(current[key]);
37
+ const freshVal = JSON.stringify(fresh[key]);
38
+ if (curVal !== freshVal) {
39
+ changes.push({ field, oldValue: current[key], newValue: fresh[key], applied: true });
40
+ merged[key] = fresh[key];
41
+ }
42
+ }
43
+ // check nested objects (policies, notifications)
44
+ for (const objField of HOT_RELOAD_OBJECTS) {
45
+ const key = objField;
46
+ const curVal = JSON.stringify(current[key]);
47
+ const freshVal = JSON.stringify(fresh[key]);
48
+ if (curVal !== freshVal) {
49
+ changes.push({ field: objField, oldValue: current[key], newValue: fresh[key], applied: true });
50
+ merged[key] = fresh[key];
51
+ }
52
+ }
53
+ // detect non-reloadable changes (warn user)
54
+ const unsafeFields = [
55
+ "reasoner", "dryRun", "observe", "confirm", "healthPort",
56
+ ];
57
+ for (const key of unsafeFields) {
58
+ const curVal = JSON.stringify(current[key]);
59
+ const freshVal = JSON.stringify(fresh[key]);
60
+ if (curVal !== freshVal) {
61
+ changes.push({ field: key, oldValue: current[key], newValue: fresh[key], applied: false });
62
+ }
63
+ }
64
+ // check nested non-reloadable (opencode.port)
65
+ if (current.opencode?.port !== fresh.opencode?.port) {
66
+ changes.push({ field: "opencode.port", oldValue: current.opencode?.port, newValue: fresh.opencode?.port, applied: false });
67
+ }
68
+ return { config: merged, changes };
69
+ }
70
+ /**
71
+ * Format a config change for display in the TUI.
72
+ */
73
+ export function formatConfigChange(change) {
74
+ const status = change.applied ? "applied" : "requires restart";
75
+ const newVal = typeof change.newValue === "object"
76
+ ? JSON.stringify(change.newValue)
77
+ : String(change.newValue);
78
+ const truncated = newVal.length > 60 ? newVal.slice(0, 57) + "..." : newVal;
79
+ return `${change.field}: ${truncated} (${status})`;
80
+ }
81
+ /**
82
+ * ConfigWatcher — watches the config file and calls back when it changes.
83
+ * Debounces rapid changes (editors do save-rename-write sequences).
84
+ */
85
+ export class ConfigWatcher {
86
+ watcher = null;
87
+ debounceTimer = null;
88
+ configPath = null;
89
+ callback = null;
90
+ currentConfig;
91
+ debounceMs;
92
+ constructor(config, debounceMs = 500) {
93
+ this.currentConfig = config;
94
+ this.debounceMs = debounceMs;
95
+ }
96
+ /** Start watching the config file. Returns the watched path or null. */
97
+ start(callback) {
98
+ this.callback = callback;
99
+ this.configPath = findConfigFile();
100
+ if (!this.configPath)
101
+ return null;
102
+ try {
103
+ this.watcher = watch(this.configPath, { persistent: false }, () => {
104
+ this.scheduleReload();
105
+ });
106
+ this.watcher.on("error", () => {
107
+ // config file may be renamed during save — try to re-watch
108
+ this.stop();
109
+ });
110
+ }
111
+ catch {
112
+ // watch failed — degrade gracefully
113
+ return null;
114
+ }
115
+ return this.configPath;
116
+ }
117
+ /** Stop watching */
118
+ stop() {
119
+ if (this.debounceTimer) {
120
+ clearTimeout(this.debounceTimer);
121
+ this.debounceTimer = null;
122
+ }
123
+ if (this.watcher) {
124
+ try {
125
+ this.watcher.close();
126
+ }
127
+ catch { }
128
+ this.watcher = null;
129
+ }
130
+ }
131
+ /** Get the current config (may have been hot-reloaded) */
132
+ getConfig() {
133
+ return this.currentConfig;
134
+ }
135
+ scheduleReload() {
136
+ if (this.debounceTimer)
137
+ clearTimeout(this.debounceTimer);
138
+ this.debounceTimer = setTimeout(() => {
139
+ this.debounceTimer = null;
140
+ this.doReload();
141
+ }, this.debounceMs);
142
+ }
143
+ doReload() {
144
+ try {
145
+ const fresh = loadConfig();
146
+ validateConfig(fresh);
147
+ const { config, changes } = mergeHotReload(this.currentConfig, fresh);
148
+ if (changes.length > 0) {
149
+ this.currentConfig = config;
150
+ this.callback?.(changes, config);
151
+ }
152
+ }
153
+ catch (err) {
154
+ // invalid config — ignore silently (keep running with current config)
155
+ console.error(`[config-watcher] reload failed: ${err}`);
156
+ }
157
+ }
158
+ }
159
+ //# sourceMappingURL=config-watcher.js.map
@@ -10,6 +10,8 @@ export declare class Executor {
10
10
  private taskManager?;
11
11
  constructor(config: AoaoeConfig);
12
12
  setTaskManager(tm: TaskManager): void;
13
+ /** Hot-reload: update the config reference (picks up new protectedSessions, policies, etc.) */
14
+ updateConfig(newConfig: AoaoeConfig): void;
13
15
  execute(actions: Action[], snapshots: SessionSnapshot[]): Promise<ActionLogEntry[]>;
14
16
  private executeOne;
15
17
  private sendInput;
package/dist/executor.js CHANGED
@@ -32,6 +32,10 @@ export class Executor {
32
32
  setTaskManager(tm) {
33
33
  this.taskManager = tm;
34
34
  }
35
+ /** Hot-reload: update the config reference (picks up new protectedSessions, policies, etc.) */
36
+ updateConfig(newConfig) {
37
+ this.config = newConfig;
38
+ }
35
39
  async execute(actions, snapshots) {
36
40
  const results = [];
37
41
  for (const action of actions) {
package/dist/index.js CHANGED
@@ -20,6 +20,7 @@ import { isDaemonRunningFromState } from "./chat.js";
20
20
  import { sendNotification, sendTestNotification } from "./notify.js";
21
21
  import { startHealthServer } from "./health.js";
22
22
  import { loadTuiHistory } from "./tui-history.js";
23
+ import { ConfigWatcher, formatConfigChange } from "./config-watcher.js";
23
24
  import { parseActionLogEntries, parseActivityEntries, mergeTimeline, filterByAge, parseDuration, formatTimelineJson, formatTimelineMarkdown } from "./export.js";
24
25
  import { actionSession, actionDetail, toActionLogEntry } from "./types.js";
25
26
  import { YELLOW, GREEN, DIM, BOLD, RED, RESET } from "./colors.js";
@@ -137,7 +138,7 @@ async function main() {
137
138
  }
138
139
  const configResult = loadConfig(overrides);
139
140
  const configPath = configResult._configPath;
140
- const config = configResult; // strip _configPath from type for downstream
141
+ let config = configResult; // strip _configPath from type for downstream (let: hot-reloaded)
141
142
  // acquire daemon lock — prevent two daemons from running simultaneously
142
143
  const lock = acquireLock();
143
144
  if (!lock.acquired) {
@@ -373,6 +374,7 @@ async function main() {
373
374
  console.error(` mode: dry-run (no execution)`);
374
375
  console.error("");
375
376
  log("shutting down...");
377
+ configWatcher.stop();
376
378
  if (healthServer)
377
379
  healthServer.close();
378
380
  // notify: daemon stopped (fire-and-forget, don't block shutdown)
@@ -400,6 +402,36 @@ async function main() {
400
402
  else {
401
403
  log("entering main loop (Ctrl+C to stop)\n");
402
404
  }
405
+ // ── config hot-reload watcher ──────────────────────────────────────────────
406
+ const configWatcher = new ConfigWatcher(config);
407
+ const watchedPath = configWatcher.start((changes, newConfig) => {
408
+ config = newConfig;
409
+ if (executor)
410
+ executor.updateConfig(newConfig);
411
+ const applied = changes.filter((c) => c.applied);
412
+ const needsRestart = changes.filter((c) => !c.applied);
413
+ for (const c of applied) {
414
+ const msg = `config: ${formatConfigChange(c)}`;
415
+ if (tui)
416
+ tui.log("system", msg);
417
+ else
418
+ log(msg);
419
+ }
420
+ for (const c of needsRestart) {
421
+ const msg = `config: ${c.field} changed but requires restart`;
422
+ if (tui)
423
+ tui.log("system", msg);
424
+ else
425
+ log(msg);
426
+ }
427
+ });
428
+ if (watchedPath) {
429
+ const msg = `watching config: ${watchedPath}`;
430
+ if (tui)
431
+ tui.log("system", msg);
432
+ else
433
+ log(msg);
434
+ }
403
435
  // notify: daemon started
404
436
  sendNotification(config, { event: "daemon_started", timestamp: Date.now(), detail: `reasoner: ${config.reasoner}` });
405
437
  // clear any stale interrupt from a previous run
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "aoaoe",
3
- "version": "0.67.0",
3
+ "version": "0.68.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",