aoaoe 0.67.0 → 0.69.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/config.d.ts +3 -0
- package/dist/config.js +24 -2
- package/dist/executor.d.ts +2 -0
- package/dist/executor.js +4 -0
- package/dist/index.js +40 -2
- package/dist/tail.d.ts +41 -0
- package/dist/tail.js +211 -0
- 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/config.d.ts
CHANGED
|
@@ -41,6 +41,9 @@ export declare function parseCliArgs(argv: string[]): {
|
|
|
41
41
|
runInit: boolean;
|
|
42
42
|
initForce: boolean;
|
|
43
43
|
runTaskCli: boolean;
|
|
44
|
+
runTail: boolean;
|
|
45
|
+
tailFollow: boolean;
|
|
46
|
+
tailCount?: number;
|
|
44
47
|
registerTitle?: string;
|
|
45
48
|
};
|
|
46
49
|
export declare function printHelp(): void;
|
package/dist/config.js
CHANGED
|
@@ -327,7 +327,7 @@ export function parseCliArgs(argv) {
|
|
|
327
327
|
let initForce = false;
|
|
328
328
|
let runTaskCli = false;
|
|
329
329
|
let registerTitle;
|
|
330
|
-
const defaults = { overrides, help: false, version: false, register: false, testContext: false, runTest: false, showTasks: false, showHistory: false, showStatus: false, showConfig: false, configValidate: false, configDiff: false, notifyTest: false, runDoctor: false, runLogs: false, logsActions: false, logsGrep: undefined, logsCount: undefined, runExport: false, exportFormat: undefined, exportOutput: undefined, exportLast: undefined, runInit: false, initForce: false, runTaskCli: false };
|
|
330
|
+
const defaults = { overrides, help: false, version: false, register: false, testContext: false, runTest: false, showTasks: false, showHistory: false, showStatus: false, showConfig: false, configValidate: false, configDiff: false, notifyTest: false, runDoctor: false, runLogs: false, logsActions: false, logsGrep: undefined, logsCount: undefined, runExport: false, exportFormat: undefined, exportOutput: undefined, exportLast: undefined, runInit: false, initForce: false, runTaskCli: false, runTail: false, tailFollow: false, tailCount: undefined };
|
|
331
331
|
// check for subcommand as first non-flag arg
|
|
332
332
|
if (argv[2] === "test-context") {
|
|
333
333
|
return { ...defaults, testContext: true };
|
|
@@ -395,6 +395,21 @@ export function parseCliArgs(argv) {
|
|
|
395
395
|
const force = argv.includes("--force") || argv.includes("-f");
|
|
396
396
|
return { ...defaults, runInit: true, initForce: force };
|
|
397
397
|
}
|
|
398
|
+
if (argv[2] === "tail") {
|
|
399
|
+
let follow = false;
|
|
400
|
+
let count;
|
|
401
|
+
for (let i = 3; i < argv.length; i++) {
|
|
402
|
+
if (argv[i] === "-f" || argv[i] === "--follow") {
|
|
403
|
+
follow = true;
|
|
404
|
+
}
|
|
405
|
+
else if ((argv[i] === "-n" || argv[i] === "--count") && argv[i + 1]) {
|
|
406
|
+
const val = parseInt(argv[++i], 10);
|
|
407
|
+
if (!isNaN(val) && val > 0)
|
|
408
|
+
count = val;
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
return { ...defaults, runTail: true, tailFollow: follow, tailCount: count };
|
|
412
|
+
}
|
|
398
413
|
if (argv[2] === "register") {
|
|
399
414
|
register = true;
|
|
400
415
|
// parse --title from remaining args
|
|
@@ -488,7 +503,7 @@ export function parseCliArgs(argv) {
|
|
|
488
503
|
break;
|
|
489
504
|
}
|
|
490
505
|
}
|
|
491
|
-
return { overrides, help, version, register: false, testContext: false, runTest: false, showTasks: false, showHistory: false, showStatus: false, showConfig: false, configValidate: false, configDiff: false, notifyTest: false, runDoctor: false, runLogs: false, logsActions: false, logsGrep: undefined, logsCount: undefined, runExport: false, exportFormat: undefined, exportOutput: undefined, exportLast: undefined, runInit: false, initForce: false, runTaskCli: false };
|
|
506
|
+
return { overrides, help, version, register: false, testContext: false, runTest: false, showTasks: false, showHistory: false, showStatus: false, showConfig: false, configValidate: false, configDiff: false, notifyTest: false, runDoctor: false, runLogs: false, logsActions: false, logsGrep: undefined, logsCount: undefined, runExport: false, exportFormat: undefined, exportOutput: undefined, exportLast: undefined, runInit: false, initForce: false, runTaskCli: false, runTail: false, tailFollow: false, tailCount: undefined };
|
|
492
507
|
}
|
|
493
508
|
export function printHelp() {
|
|
494
509
|
console.log(`aoaoe - autonomous supervisor for agent-of-empires sessions
|
|
@@ -518,6 +533,9 @@ commands:
|
|
|
518
533
|
export --format <json|markdown> output format (default: json)
|
|
519
534
|
export --output <file> write to file (default: stdout)
|
|
520
535
|
export --last <duration> time window: 1h, 6h, 24h, 7d (default: 24h)
|
|
536
|
+
tail live-stream daemon activity to a separate terminal
|
|
537
|
+
tail -f follow mode — keep watching for new entries (Ctrl+C to stop)
|
|
538
|
+
tail -n <N> number of entries to show (default: 50)
|
|
521
539
|
task manage tasks and sessions (list, start, stop, new, rm, edit)
|
|
522
540
|
tasks show task progress (from aoaoe.tasks.json)
|
|
523
541
|
history review recent actions (from ~/.aoaoe/actions.log)
|
|
@@ -550,6 +568,10 @@ logs options:
|
|
|
550
568
|
--grep, -g <pattern> filter entries by substring or regex
|
|
551
569
|
-n, --count <number> number of entries to show (default: 50)
|
|
552
570
|
|
|
571
|
+
tail options:
|
|
572
|
+
-f, --follow keep watching for new entries (Ctrl+C to stop)
|
|
573
|
+
-n, --count <number> number of entries to show (default: 50)
|
|
574
|
+
|
|
553
575
|
register options:
|
|
554
576
|
--title, -t <name> session title in AoE (default: aoaoe)
|
|
555
577
|
|
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";
|
|
@@ -31,7 +32,7 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
|
31
32
|
const AOAOE_DIR = join(homedir(), ".aoaoe"); // watch dir for wakeable sleep
|
|
32
33
|
const INPUT_FILE = join(AOAOE_DIR, "pending-input.txt"); // file IPC from chat.ts
|
|
33
34
|
async function main() {
|
|
34
|
-
const { overrides, help, version, register, testContext: isTestContext, runTest, showTasks, showHistory, showStatus, showConfig, configValidate, configDiff, notifyTest, runDoctor, runLogs, logsActions, logsGrep, logsCount, runExport, exportFormat, exportOutput, exportLast, runInit, initForce, runTaskCli: isTaskCli, registerTitle } = parseCliArgs(process.argv);
|
|
35
|
+
const { overrides, help, version, register, testContext: isTestContext, runTest, showTasks, showHistory, showStatus, showConfig, configValidate, configDiff, notifyTest, runDoctor, runLogs, logsActions, logsGrep, logsCount, runExport, exportFormat, exportOutput, exportLast, runInit, initForce, runTaskCli: isTaskCli, runTail: isTail, tailFollow, tailCount, registerTitle } = parseCliArgs(process.argv);
|
|
35
36
|
if (help) {
|
|
36
37
|
printHelp();
|
|
37
38
|
process.exit(0);
|
|
@@ -120,6 +121,12 @@ async function main() {
|
|
|
120
121
|
await doInit(initForce);
|
|
121
122
|
return;
|
|
122
123
|
}
|
|
124
|
+
// `aoaoe tail` -- live-stream daemon activity to a separate terminal
|
|
125
|
+
if (isTail) {
|
|
126
|
+
const { runTail: doTail } = await import("./tail.js");
|
|
127
|
+
await doTail({ count: tailCount ?? 50, follow: tailFollow });
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
123
130
|
// auto-init: if no config file exists, run init automatically
|
|
124
131
|
if (!configFileExists()) {
|
|
125
132
|
console.error("");
|
|
@@ -137,7 +144,7 @@ async function main() {
|
|
|
137
144
|
}
|
|
138
145
|
const configResult = loadConfig(overrides);
|
|
139
146
|
const configPath = configResult._configPath;
|
|
140
|
-
|
|
147
|
+
let config = configResult; // strip _configPath from type for downstream (let: hot-reloaded)
|
|
141
148
|
// acquire daemon lock — prevent two daemons from running simultaneously
|
|
142
149
|
const lock = acquireLock();
|
|
143
150
|
if (!lock.acquired) {
|
|
@@ -373,6 +380,7 @@ async function main() {
|
|
|
373
380
|
console.error(` mode: dry-run (no execution)`);
|
|
374
381
|
console.error("");
|
|
375
382
|
log("shutting down...");
|
|
383
|
+
configWatcher.stop();
|
|
376
384
|
if (healthServer)
|
|
377
385
|
healthServer.close();
|
|
378
386
|
// notify: daemon stopped (fire-and-forget, don't block shutdown)
|
|
@@ -400,6 +408,36 @@ async function main() {
|
|
|
400
408
|
else {
|
|
401
409
|
log("entering main loop (Ctrl+C to stop)\n");
|
|
402
410
|
}
|
|
411
|
+
// ── config hot-reload watcher ──────────────────────────────────────────────
|
|
412
|
+
const configWatcher = new ConfigWatcher(config);
|
|
413
|
+
const watchedPath = configWatcher.start((changes, newConfig) => {
|
|
414
|
+
config = newConfig;
|
|
415
|
+
if (executor)
|
|
416
|
+
executor.updateConfig(newConfig);
|
|
417
|
+
const applied = changes.filter((c) => c.applied);
|
|
418
|
+
const needsRestart = changes.filter((c) => !c.applied);
|
|
419
|
+
for (const c of applied) {
|
|
420
|
+
const msg = `config: ${formatConfigChange(c)}`;
|
|
421
|
+
if (tui)
|
|
422
|
+
tui.log("system", msg);
|
|
423
|
+
else
|
|
424
|
+
log(msg);
|
|
425
|
+
}
|
|
426
|
+
for (const c of needsRestart) {
|
|
427
|
+
const msg = `config: ${c.field} changed but requires restart`;
|
|
428
|
+
if (tui)
|
|
429
|
+
tui.log("system", msg);
|
|
430
|
+
else
|
|
431
|
+
log(msg);
|
|
432
|
+
}
|
|
433
|
+
});
|
|
434
|
+
if (watchedPath) {
|
|
435
|
+
const msg = `watching config: ${watchedPath}`;
|
|
436
|
+
if (tui)
|
|
437
|
+
tui.log("system", msg);
|
|
438
|
+
else
|
|
439
|
+
log(msg);
|
|
440
|
+
}
|
|
403
441
|
// notify: daemon started
|
|
404
442
|
sendNotification(config, { event: "daemon_started", timestamp: Date.now(), detail: `reasoner: ${config.reasoner}` });
|
|
405
443
|
// clear any stale interrupt from a previous run
|
package/dist/tail.d.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { type HistoryEntry } from "./tui-history.js";
|
|
2
|
+
/**
|
|
3
|
+
* Format a HistoryEntry as a colorized terminal line.
|
|
4
|
+
* Matches TUI formatActivity style but for plain scrolling output.
|
|
5
|
+
*/
|
|
6
|
+
export declare function formatTailEntry(entry: HistoryEntry): string;
|
|
7
|
+
/**
|
|
8
|
+
* Format a date string for the tail header.
|
|
9
|
+
*/
|
|
10
|
+
export declare function formatTailDate(ts: number): string;
|
|
11
|
+
/**
|
|
12
|
+
* Load the last N entries from the history file.
|
|
13
|
+
* Returns entries newest-last (chronological order).
|
|
14
|
+
*/
|
|
15
|
+
export declare function loadTailEntries(count: number, filePath?: string): HistoryEntry[];
|
|
16
|
+
/**
|
|
17
|
+
* Get the current file size (for follow mode to detect appends).
|
|
18
|
+
*/
|
|
19
|
+
export declare function getFileSize(filePath: string): number;
|
|
20
|
+
/**
|
|
21
|
+
* Read new bytes appended since a given offset, parse into entries.
|
|
22
|
+
*/
|
|
23
|
+
export declare function readNewEntries(filePath: string, fromByte: number): {
|
|
24
|
+
entries: HistoryEntry[];
|
|
25
|
+
newSize: number;
|
|
26
|
+
};
|
|
27
|
+
/**
|
|
28
|
+
* Print entries to stderr (colorized).
|
|
29
|
+
*/
|
|
30
|
+
export declare function printEntries(entries: HistoryEntry[]): void;
|
|
31
|
+
/**
|
|
32
|
+
* Run the tail command.
|
|
33
|
+
* Without follow: print last N entries, exit.
|
|
34
|
+
* With follow: print last N, then watch for new entries (blocks until Ctrl+C).
|
|
35
|
+
*/
|
|
36
|
+
export declare function runTail(opts: {
|
|
37
|
+
count: number;
|
|
38
|
+
follow: boolean;
|
|
39
|
+
filePath?: string;
|
|
40
|
+
}): Promise<void>;
|
|
41
|
+
//# sourceMappingURL=tail.d.ts.map
|
package/dist/tail.js
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
// tail.ts — live-stream daemon activity to a separate terminal.
|
|
2
|
+
// reads from tui-history.jsonl, optionally follows for new entries.
|
|
3
|
+
// pure exported functions for testability.
|
|
4
|
+
import { readFileSync, statSync, existsSync, openSync, readSync, closeSync } from "node:fs";
|
|
5
|
+
import { watch } from "node:fs";
|
|
6
|
+
import { TUI_HISTORY_FILE } from "./tui-history.js";
|
|
7
|
+
import { SLATE, RESET, DIM, BOLD, CYAN, AMBER, ROSE, LIME, SKY, } from "./colors.js";
|
|
8
|
+
/**
|
|
9
|
+
* Format a HistoryEntry as a colorized terminal line.
|
|
10
|
+
* Matches TUI formatActivity style but for plain scrolling output.
|
|
11
|
+
*/
|
|
12
|
+
export function formatTailEntry(entry) {
|
|
13
|
+
let color = SLATE;
|
|
14
|
+
let prefix = entry.tag;
|
|
15
|
+
switch (entry.tag) {
|
|
16
|
+
case "observation":
|
|
17
|
+
color = SLATE;
|
|
18
|
+
prefix = "obs";
|
|
19
|
+
break;
|
|
20
|
+
case "reasoner":
|
|
21
|
+
color = SKY;
|
|
22
|
+
break;
|
|
23
|
+
case "explain":
|
|
24
|
+
color = `${BOLD}${CYAN}`;
|
|
25
|
+
prefix = "AI";
|
|
26
|
+
break;
|
|
27
|
+
case "+ action":
|
|
28
|
+
case "action":
|
|
29
|
+
color = AMBER;
|
|
30
|
+
prefix = "→ action";
|
|
31
|
+
break;
|
|
32
|
+
case "! action":
|
|
33
|
+
case "error":
|
|
34
|
+
color = ROSE;
|
|
35
|
+
prefix = "✗ error";
|
|
36
|
+
break;
|
|
37
|
+
case "you":
|
|
38
|
+
color = LIME;
|
|
39
|
+
break;
|
|
40
|
+
case "system":
|
|
41
|
+
color = SLATE;
|
|
42
|
+
break;
|
|
43
|
+
case "status":
|
|
44
|
+
color = SLATE;
|
|
45
|
+
break;
|
|
46
|
+
default:
|
|
47
|
+
color = SLATE;
|
|
48
|
+
break;
|
|
49
|
+
}
|
|
50
|
+
return `${SLATE}${entry.time}${RESET} ${color}${prefix}${RESET} ${DIM}│${RESET} ${entry.text}`;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Format a date string for the tail header.
|
|
54
|
+
*/
|
|
55
|
+
export function formatTailDate(ts) {
|
|
56
|
+
const d = new Date(ts);
|
|
57
|
+
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Load the last N entries from the history file.
|
|
61
|
+
* Returns entries newest-last (chronological order).
|
|
62
|
+
*/
|
|
63
|
+
export function loadTailEntries(count, filePath = TUI_HISTORY_FILE) {
|
|
64
|
+
try {
|
|
65
|
+
if (!existsSync(filePath))
|
|
66
|
+
return [];
|
|
67
|
+
const content = readFileSync(filePath, "utf-8");
|
|
68
|
+
const lines = content.split("\n").filter((l) => l.trim());
|
|
69
|
+
const entries = [];
|
|
70
|
+
// read from the end to minimize parsing
|
|
71
|
+
const start = Math.max(0, lines.length - count);
|
|
72
|
+
for (let i = start; i < lines.length; i++) {
|
|
73
|
+
try {
|
|
74
|
+
const parsed = JSON.parse(lines[i]);
|
|
75
|
+
if (isValidEntry(parsed))
|
|
76
|
+
entries.push(parsed);
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
// skip malformed
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return entries;
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
return [];
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Get the current file size (for follow mode to detect appends).
|
|
90
|
+
*/
|
|
91
|
+
export function getFileSize(filePath) {
|
|
92
|
+
try {
|
|
93
|
+
return statSync(filePath).size;
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
return 0;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Read new bytes appended since a given offset, parse into entries.
|
|
101
|
+
*/
|
|
102
|
+
export function readNewEntries(filePath, fromByte) {
|
|
103
|
+
try {
|
|
104
|
+
const size = statSync(filePath).size;
|
|
105
|
+
if (size === 0)
|
|
106
|
+
return { entries: [], newSize: 0 };
|
|
107
|
+
// file was truncated/rotated — read from start
|
|
108
|
+
const start = size < fromByte ? 0 : fromByte;
|
|
109
|
+
if (start === size)
|
|
110
|
+
return { entries: [], newSize: size };
|
|
111
|
+
const buf = Buffer.alloc(size - start);
|
|
112
|
+
const fd = openSync(filePath, "r");
|
|
113
|
+
readSync(fd, buf, 0, buf.length, start);
|
|
114
|
+
closeSync(fd);
|
|
115
|
+
const text = buf.toString("utf-8");
|
|
116
|
+
const lines = text.split("\n").filter((l) => l.trim());
|
|
117
|
+
const entries = [];
|
|
118
|
+
for (const line of lines) {
|
|
119
|
+
try {
|
|
120
|
+
const parsed = JSON.parse(line);
|
|
121
|
+
if (isValidEntry(parsed))
|
|
122
|
+
entries.push(parsed);
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
// skip
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return { entries, newSize: size };
|
|
129
|
+
}
|
|
130
|
+
catch {
|
|
131
|
+
return { entries: [], newSize: fromByte };
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Print entries to stderr (colorized).
|
|
136
|
+
*/
|
|
137
|
+
export function printEntries(entries) {
|
|
138
|
+
for (const e of entries) {
|
|
139
|
+
process.stderr.write(formatTailEntry(e) + "\n");
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* Run the tail command.
|
|
144
|
+
* Without follow: print last N entries, exit.
|
|
145
|
+
* With follow: print last N, then watch for new entries (blocks until Ctrl+C).
|
|
146
|
+
*/
|
|
147
|
+
export async function runTail(opts) {
|
|
148
|
+
const filePath = opts.filePath ?? TUI_HISTORY_FILE;
|
|
149
|
+
// print initial entries
|
|
150
|
+
const entries = loadTailEntries(opts.count, filePath);
|
|
151
|
+
if (entries.length === 0) {
|
|
152
|
+
process.stderr.write(`${DIM}no history entries found${RESET}\n`);
|
|
153
|
+
if (!opts.follow)
|
|
154
|
+
return;
|
|
155
|
+
process.stderr.write(`${DIM}waiting for new entries... (Ctrl+C to stop)${RESET}\n`);
|
|
156
|
+
}
|
|
157
|
+
else {
|
|
158
|
+
// date header
|
|
159
|
+
const firstDate = formatTailDate(entries[0].ts);
|
|
160
|
+
const lastDate = formatTailDate(entries[entries.length - 1].ts);
|
|
161
|
+
const dateRange = firstDate === lastDate ? firstDate : `${firstDate} → ${lastDate}`;
|
|
162
|
+
process.stderr.write(`${DIM}── ${entries.length} entries (${dateRange}) ──${RESET}\n`);
|
|
163
|
+
printEntries(entries);
|
|
164
|
+
}
|
|
165
|
+
if (!opts.follow)
|
|
166
|
+
return;
|
|
167
|
+
// follow mode: watch file for changes
|
|
168
|
+
process.stderr.write(`${DIM}── following (Ctrl+C to stop) ──${RESET}\n`);
|
|
169
|
+
let lastSize = getFileSize(filePath);
|
|
170
|
+
return new Promise((_resolve) => {
|
|
171
|
+
let watcher = null;
|
|
172
|
+
try {
|
|
173
|
+
watcher = watch(filePath, { persistent: true }, () => {
|
|
174
|
+
const { entries: newEntries, newSize } = readNewEntries(filePath, lastSize);
|
|
175
|
+
if (newEntries.length > 0) {
|
|
176
|
+
printEntries(newEntries);
|
|
177
|
+
}
|
|
178
|
+
lastSize = newSize;
|
|
179
|
+
});
|
|
180
|
+
watcher.on("error", () => {
|
|
181
|
+
// file may be rotated — try to recover
|
|
182
|
+
lastSize = 0;
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
catch {
|
|
186
|
+
process.stderr.write(`${ROSE}failed to watch ${filePath}${RESET}\n`);
|
|
187
|
+
}
|
|
188
|
+
// Ctrl+C cleanup
|
|
189
|
+
const cleanup = () => {
|
|
190
|
+
if (watcher) {
|
|
191
|
+
try {
|
|
192
|
+
watcher.close();
|
|
193
|
+
}
|
|
194
|
+
catch { }
|
|
195
|
+
}
|
|
196
|
+
process.exit(0);
|
|
197
|
+
};
|
|
198
|
+
process.on("SIGINT", cleanup);
|
|
199
|
+
process.on("SIGTERM", cleanup);
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
function isValidEntry(val) {
|
|
203
|
+
if (typeof val !== "object" || val === null)
|
|
204
|
+
return false;
|
|
205
|
+
const obj = val;
|
|
206
|
+
return (typeof obj.ts === "number" &&
|
|
207
|
+
typeof obj.time === "string" &&
|
|
208
|
+
typeof obj.tag === "string" &&
|
|
209
|
+
typeof obj.text === "string");
|
|
210
|
+
}
|
|
211
|
+
//# sourceMappingURL=tail.js.map
|