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.
- package/dist/config-watcher.d.ts +44 -0
- package/dist/config-watcher.js +159 -0
- package/dist/executor.d.ts +2 -0
- package/dist/executor.js +4 -0
- package/dist/index.js +33 -1
- package/package.json +1 -1
|
@@ -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
|
package/dist/executor.d.ts
CHANGED
|
@@ -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
|
-
|
|
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
|