@os-eco/overstory-cli 0.9.1 → 0.9.3
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 +21 -6
- package/agents/coordinator.md +34 -10
- package/agents/lead.md +11 -1
- package/package.json +1 -1
- package/src/agents/copilot-hooks-deployer.test.ts +162 -0
- package/src/agents/copilot-hooks-deployer.ts +93 -0
- package/src/agents/hooks-deployer.test.ts +9 -1
- package/src/agents/hooks-deployer.ts +2 -1
- package/src/agents/overlay.test.ts +26 -0
- package/src/agents/overlay.ts +18 -4
- package/src/beads/client.ts +31 -3
- package/src/commands/agents.ts +1 -1
- package/src/commands/clean.test.ts +3 -0
- package/src/commands/clean.ts +1 -58
- package/src/commands/completions.test.ts +18 -6
- package/src/commands/completions.ts +40 -1
- package/src/commands/coordinator.test.ts +77 -4
- package/src/commands/coordinator.ts +228 -125
- package/src/commands/dashboard.ts +50 -10
- package/src/commands/doctor.ts +3 -1
- package/src/commands/ecosystem.test.ts +126 -1
- package/src/commands/ecosystem.ts +7 -53
- package/src/commands/feed.test.ts +117 -2
- package/src/commands/feed.ts +46 -30
- package/src/commands/group.test.ts +274 -155
- package/src/commands/group.ts +11 -5
- package/src/commands/init.ts +50 -0
- package/src/commands/inspect.ts +8 -4
- package/src/commands/log.test.ts +35 -0
- package/src/commands/log.ts +10 -6
- package/src/commands/logs.test.ts +423 -1
- package/src/commands/logs.ts +99 -104
- package/src/commands/monitor.ts +8 -2
- package/src/commands/orchestrator.ts +42 -0
- package/src/commands/prime.test.ts +177 -2
- package/src/commands/prime.ts +4 -2
- package/src/commands/sling.ts +8 -3
- package/src/commands/upgrade.test.ts +2 -0
- package/src/commands/upgrade.ts +1 -17
- package/src/commands/watch.test.ts +67 -1
- package/src/commands/watch.ts +4 -79
- package/src/config.test.ts +250 -0
- package/src/config.ts +43 -0
- package/src/doctor/agents.test.ts +72 -5
- package/src/doctor/agents.ts +10 -10
- package/src/doctor/consistency.test.ts +35 -0
- package/src/doctor/consistency.ts +7 -3
- package/src/doctor/dependencies.test.ts +58 -1
- package/src/doctor/dependencies.ts +4 -2
- package/src/doctor/providers.test.ts +41 -5
- package/src/doctor/types.ts +2 -1
- package/src/doctor/version.test.ts +106 -2
- package/src/doctor/version.ts +4 -2
- package/src/doctor/watchdog.test.ts +167 -0
- package/src/doctor/watchdog.ts +158 -0
- package/src/e2e/init-sling-lifecycle.test.ts +2 -1
- package/src/errors.test.ts +350 -0
- package/src/events/tailer.test.ts +25 -0
- package/src/events/tailer.ts +8 -1
- package/src/index.ts +4 -1
- package/src/mail/store.test.ts +110 -0
- package/src/runtimes/aider.test.ts +124 -0
- package/src/runtimes/aider.ts +147 -0
- package/src/runtimes/amp.test.ts +164 -0
- package/src/runtimes/amp.ts +154 -0
- package/src/runtimes/claude.test.ts +4 -2
- package/src/runtimes/codex.test.ts +38 -1
- package/src/runtimes/codex.ts +22 -3
- package/src/runtimes/copilot.test.ts +213 -13
- package/src/runtimes/copilot.ts +93 -11
- package/src/runtimes/goose.test.ts +133 -0
- package/src/runtimes/goose.ts +157 -0
- package/src/runtimes/pi-guards.ts +2 -1
- package/src/runtimes/pi.test.ts +33 -9
- package/src/runtimes/pi.ts +10 -10
- package/src/runtimes/registry.test.ts +1 -1
- package/src/runtimes/registry.ts +13 -4
- package/src/runtimes/sapling.ts +2 -1
- package/src/runtimes/types.ts +9 -2
- package/src/tracker/factory.test.ts +10 -0
- package/src/tracker/factory.ts +3 -2
- package/src/types.ts +4 -0
- package/src/utils/bin.test.ts +10 -0
- package/src/utils/bin.ts +37 -0
- package/src/utils/fs.test.ts +119 -0
- package/src/utils/fs.ts +62 -0
- package/src/utils/pid.test.ts +68 -0
- package/src/utils/pid.ts +45 -0
- package/src/utils/time.test.ts +43 -0
- package/src/utils/time.ts +37 -0
- package/src/utils/version.test.ts +33 -0
- package/src/utils/version.ts +70 -0
- package/src/watchdog/daemon.test.ts +255 -1
- package/src/watchdog/daemon.ts +46 -9
- package/src/watchdog/health.test.ts +15 -1
- package/src/watchdog/health.ts +1 -1
- package/src/watchdog/triage.test.ts +49 -9
- package/src/watchdog/triage.ts +21 -5
- package/src/worktree/tmux.test.ts +166 -49
- package/src/worktree/tmux.ts +36 -37
- package/templates/copilot-hooks.json.tmpl +13 -0
|
@@ -46,6 +46,7 @@ import type {
|
|
|
46
46
|
MailMessage,
|
|
47
47
|
OverstoryConfig,
|
|
48
48
|
StoredEvent,
|
|
49
|
+
TaskTrackerBackend,
|
|
49
50
|
} from "../types.ts";
|
|
50
51
|
import { evaluateHealth } from "../watchdog/health.ts";
|
|
51
52
|
import { isProcessAlive } from "../worktree/tmux.ts";
|
|
@@ -59,10 +60,13 @@ const PKG_VERSION: string = JSON.parse(await Bun.file(pkgPath).text()).version ?
|
|
|
59
60
|
* These are not colors, so they stay separate from the color module.
|
|
60
61
|
*/
|
|
61
62
|
const CURSOR = {
|
|
62
|
-
clear: "\x1b[
|
|
63
|
+
clear: "\x1b[H\x1b[J", // Home cursor then clear from cursor to end
|
|
64
|
+
home: "\x1b[H", // Home cursor only (for redraw without full clear)
|
|
63
65
|
cursorTo: (row: number, col: number) => `\x1b[${row};${col}H`,
|
|
64
66
|
hideCursor: "\x1b[?25l",
|
|
65
67
|
showCursor: "\x1b[?25h",
|
|
68
|
+
enterAltScreen: "\x1b[?1049h", // Enter alternate screen buffer
|
|
69
|
+
leaveAltScreen: "\x1b[?1049l", // Leave alternate screen buffer
|
|
66
70
|
} as const;
|
|
67
71
|
|
|
68
72
|
/**
|
|
@@ -353,6 +357,7 @@ async function loadDashboardData(
|
|
|
353
357
|
thresholds?: { staleMs: number; zombieMs: number },
|
|
354
358
|
eventBuffer?: EventBuffer,
|
|
355
359
|
runtimeConfig?: OverstoryConfig["runtime"],
|
|
360
|
+
taskTrackerBackend?: TaskTrackerBackend,
|
|
356
361
|
): Promise<DashboardData> {
|
|
357
362
|
// Get all sessions from the pre-opened session store — fall back to cache on SQLite errors.
|
|
358
363
|
let allSessions: AgentSession[];
|
|
@@ -513,7 +518,7 @@ async function loadDashboardData(
|
|
|
513
518
|
const now2 = Date.now();
|
|
514
519
|
if (!trackerCache || now2 - trackerCache.fetchedAt > TRACKER_CACHE_TTL_MS) {
|
|
515
520
|
try {
|
|
516
|
-
const backend = await resolveBackend("auto", root);
|
|
521
|
+
const backend = await resolveBackend(taskTrackerBackend ?? "auto", root);
|
|
517
522
|
const tracker = createTrackerClient(backend, root);
|
|
518
523
|
tasks = await tracker.list({ limit: 10 });
|
|
519
524
|
trackerCache = { tasks, fetchedAt: now2 };
|
|
@@ -960,11 +965,13 @@ function renderMetricsPanel(
|
|
|
960
965
|
/**
|
|
961
966
|
* Render the full dashboard.
|
|
962
967
|
*/
|
|
963
|
-
function renderDashboard(data: DashboardData, interval: number): void {
|
|
968
|
+
function renderDashboard(data: DashboardData, interval: number, isFirstRender: boolean): void {
|
|
964
969
|
const width = process.stdout.columns ?? 100;
|
|
965
970
|
const height = process.stdout.rows ?? 30;
|
|
966
971
|
|
|
967
|
-
|
|
972
|
+
// First render: clear entire alt screen. Subsequent: just home cursor
|
|
973
|
+
// and overwrite in-place (avoids Warp's block-per-clear issue).
|
|
974
|
+
let output = isFirstRender ? CURSOR.clear : CURSOR.home;
|
|
968
975
|
|
|
969
976
|
// Header (rows 1-2)
|
|
970
977
|
output += renderHeader(width, interval, data.currentRunId);
|
|
@@ -1050,20 +1057,44 @@ async function executeDashboard(opts: DashboardOpts): Promise<void> {
|
|
|
1050
1057
|
zombieMs: config.watchdog.zombieThresholdMs,
|
|
1051
1058
|
};
|
|
1052
1059
|
|
|
1053
|
-
//
|
|
1060
|
+
// Enter alternate screen buffer (like vim/htop) + hide cursor + raw stdin
|
|
1061
|
+
process.stdout.write(CURSOR.enterAltScreen);
|
|
1054
1062
|
process.stdout.write(CURSOR.hideCursor);
|
|
1063
|
+
if (process.stdin.isTTY) {
|
|
1064
|
+
process.stdin.setRawMode(true);
|
|
1065
|
+
process.stdin.resume();
|
|
1066
|
+
}
|
|
1055
1067
|
|
|
1056
|
-
// Clean exit on Ctrl+C
|
|
1068
|
+
// Clean exit on Ctrl+C or 'q': restore original screen
|
|
1057
1069
|
let running = true;
|
|
1058
|
-
|
|
1070
|
+
const cleanup = () => {
|
|
1059
1071
|
running = false;
|
|
1072
|
+
if (process.stdin.isTTY) {
|
|
1073
|
+
process.stdin.setRawMode(false);
|
|
1074
|
+
process.stdin.pause();
|
|
1075
|
+
}
|
|
1060
1076
|
closeDashboardStores(stores);
|
|
1061
1077
|
process.stdout.write(CURSOR.showCursor);
|
|
1062
|
-
process.stdout.write(CURSOR.
|
|
1078
|
+
process.stdout.write(CURSOR.leaveAltScreen);
|
|
1079
|
+
};
|
|
1080
|
+
|
|
1081
|
+
process.on("SIGINT", () => {
|
|
1082
|
+
cleanup();
|
|
1063
1083
|
process.exitCode = 0;
|
|
1064
1084
|
});
|
|
1065
1085
|
|
|
1086
|
+
// Allow 'q' to quit the dashboard
|
|
1087
|
+
process.stdin.on("data", (data: Buffer) => {
|
|
1088
|
+
const key = data.toString();
|
|
1089
|
+
if (key === "q" || key === "\x03") {
|
|
1090
|
+
// 'q' or Ctrl+C
|
|
1091
|
+
cleanup();
|
|
1092
|
+
process.exitCode = 0;
|
|
1093
|
+
}
|
|
1094
|
+
});
|
|
1095
|
+
|
|
1066
1096
|
// Poll loop — errors are caught per-tick so transient DB failures never crash the dashboard.
|
|
1097
|
+
let isFirstRender = true;
|
|
1067
1098
|
let lastGoodData: DashboardData | null = null;
|
|
1068
1099
|
let lastErrorMsg: string | null = null;
|
|
1069
1100
|
while (running) {
|
|
@@ -1075,14 +1106,23 @@ async function executeDashboard(opts: DashboardOpts): Promise<void> {
|
|
|
1075
1106
|
thresholds,
|
|
1076
1107
|
eventBuffer,
|
|
1077
1108
|
config.runtime,
|
|
1109
|
+
config.taskTracker.backend,
|
|
1078
1110
|
);
|
|
1079
1111
|
lastGoodData = data;
|
|
1112
|
+
// If recovering from an error, clear the stale error line at the bottom
|
|
1113
|
+
if (lastErrorMsg !== null) {
|
|
1114
|
+
const w = process.stdout.columns ?? 100;
|
|
1115
|
+
const h = process.stdout.rows ?? 30;
|
|
1116
|
+
process.stdout.write(`${CURSOR.cursorTo(h, 1)}${" ".repeat(w)}`);
|
|
1117
|
+
}
|
|
1080
1118
|
lastErrorMsg = null;
|
|
1081
|
-
renderDashboard(data, interval);
|
|
1119
|
+
renderDashboard(data, interval, isFirstRender);
|
|
1120
|
+
isFirstRender = false;
|
|
1082
1121
|
} catch (err) {
|
|
1083
1122
|
// Render last good frame so the TUI stays alive, then show the error inline.
|
|
1084
1123
|
if (lastGoodData) {
|
|
1085
|
-
renderDashboard(lastGoodData, interval);
|
|
1124
|
+
renderDashboard(lastGoodData, interval, isFirstRender);
|
|
1125
|
+
isFirstRender = false;
|
|
1086
1126
|
}
|
|
1087
1127
|
lastErrorMsg = err instanceof Error ? err.message : String(err);
|
|
1088
1128
|
const w = process.stdout.columns ?? 100;
|
package/src/commands/doctor.ts
CHANGED
|
@@ -19,6 +19,7 @@ import { checkProviders } from "../doctor/providers.ts";
|
|
|
19
19
|
import { checkStructure } from "../doctor/structure.ts";
|
|
20
20
|
import type { DoctorCategory, DoctorCheck, DoctorCheckFn } from "../doctor/types.ts";
|
|
21
21
|
import { checkVersion } from "../doctor/version.ts";
|
|
22
|
+
import { checkWatchdog } from "../doctor/watchdog.ts";
|
|
22
23
|
import { ValidationError } from "../errors.ts";
|
|
23
24
|
import { jsonOutput } from "../json.ts";
|
|
24
25
|
import { color } from "../logging/color.ts";
|
|
@@ -37,6 +38,7 @@ const ALL_CHECKS: Array<{ category: DoctorCategory; fn: DoctorCheckFn }> = [
|
|
|
37
38
|
{ category: "version", fn: checkVersion },
|
|
38
39
|
{ category: "ecosystem", fn: checkEcosystem },
|
|
39
40
|
{ category: "providers", fn: checkProviders },
|
|
41
|
+
{ category: "watchdog", fn: checkWatchdog },
|
|
40
42
|
];
|
|
41
43
|
|
|
42
44
|
/**
|
|
@@ -168,7 +170,7 @@ export function createDoctorCommand(options?: DoctorCommandOptions): Command {
|
|
|
168
170
|
.option("--fix", "Attempt to auto-fix issues")
|
|
169
171
|
.addHelpText(
|
|
170
172
|
"after",
|
|
171
|
-
"\nCategories: dependencies, structure, config, databases, consistency, agents, merge, logs, version, ecosystem, providers",
|
|
173
|
+
"\nCategories: dependencies, structure, config, databases, consistency, agents, merge, logs, version, ecosystem, providers, watchdog",
|
|
172
174
|
)
|
|
173
175
|
.action(
|
|
174
176
|
async (opts: { json?: boolean; verbose?: boolean; category?: string; fix?: boolean }) => {
|
|
@@ -7,7 +7,13 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import { describe, expect, test } from "bun:test";
|
|
10
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
createEcosystemCommand,
|
|
12
|
+
executeEcosystem,
|
|
13
|
+
formatDoctorLine,
|
|
14
|
+
printHumanOutput,
|
|
15
|
+
type ToolResult,
|
|
16
|
+
} from "./ecosystem.ts";
|
|
11
17
|
|
|
12
18
|
describe("createEcosystemCommand — CLI structure", () => {
|
|
13
19
|
test("command has correct name", () => {
|
|
@@ -99,3 +105,122 @@ describe("executeEcosystem — JSON output shape", () => {
|
|
|
99
105
|
}
|
|
100
106
|
}, 30_000);
|
|
101
107
|
});
|
|
108
|
+
|
|
109
|
+
describe("formatDoctorLine", () => {
|
|
110
|
+
test("pass-only shows green passed count", () => {
|
|
111
|
+
const result = formatDoctorLine({ pass: 5, warn: 0, fail: 0 });
|
|
112
|
+
expect(result).toContain("5 passed");
|
|
113
|
+
expect(result).not.toContain("warn");
|
|
114
|
+
expect(result).not.toContain("fail");
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("warn and fail without pass", () => {
|
|
118
|
+
const result = formatDoctorLine({ pass: 0, warn: 2, fail: 3 });
|
|
119
|
+
expect(result).toContain("2 warn");
|
|
120
|
+
expect(result).toContain("3 fail");
|
|
121
|
+
expect(result).not.toContain("passed");
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test("all three counts present", () => {
|
|
125
|
+
const result = formatDoctorLine({ pass: 10, warn: 1, fail: 2 });
|
|
126
|
+
expect(result).toContain("10 passed");
|
|
127
|
+
expect(result).toContain("1 warn");
|
|
128
|
+
expect(result).toContain("2 fail");
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test("all-zero returns 'no checks'", () => {
|
|
132
|
+
const result = formatDoctorLine({ pass: 0, warn: 0, fail: 0 });
|
|
133
|
+
expect(result).toBe("no checks");
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
// getInstalledVersion tests moved to src/utils/version.test.ts
|
|
138
|
+
|
|
139
|
+
describe("printHumanOutput", () => {
|
|
140
|
+
function capturePrintOutput(results: ToolResult[]): string {
|
|
141
|
+
const chunks: string[] = [];
|
|
142
|
+
const original = process.stdout.write;
|
|
143
|
+
process.stdout.write = (chunk: string | Uint8Array) => {
|
|
144
|
+
chunks.push(typeof chunk === "string" ? chunk : new TextDecoder().decode(chunk));
|
|
145
|
+
return true;
|
|
146
|
+
};
|
|
147
|
+
try {
|
|
148
|
+
printHumanOutput(results);
|
|
149
|
+
} finally {
|
|
150
|
+
process.stdout.write = original;
|
|
151
|
+
}
|
|
152
|
+
return chunks.join("");
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
test("shows installed tool with version", () => {
|
|
156
|
+
const results: ToolResult[] = [
|
|
157
|
+
{
|
|
158
|
+
name: "test-tool",
|
|
159
|
+
cli: "tt",
|
|
160
|
+
npm: "@test/tool",
|
|
161
|
+
installed: true,
|
|
162
|
+
version: "1.2.3",
|
|
163
|
+
latest: "1.2.3",
|
|
164
|
+
upToDate: true,
|
|
165
|
+
},
|
|
166
|
+
];
|
|
167
|
+
const output = capturePrintOutput(results);
|
|
168
|
+
expect(output).toContain("test-tool");
|
|
169
|
+
expect(output).toContain("1.2.3");
|
|
170
|
+
expect(output).toContain("up to date");
|
|
171
|
+
expect(output).toContain("1/1 installed");
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test("shows not-installed tool with install hint", () => {
|
|
175
|
+
const results: ToolResult[] = [
|
|
176
|
+
{
|
|
177
|
+
name: "missing-tool",
|
|
178
|
+
cli: "mt",
|
|
179
|
+
npm: "@test/missing",
|
|
180
|
+
installed: false,
|
|
181
|
+
},
|
|
182
|
+
];
|
|
183
|
+
const output = capturePrintOutput(results);
|
|
184
|
+
expect(output).toContain("missing-tool");
|
|
185
|
+
expect(output).toContain("not installed");
|
|
186
|
+
expect(output).toContain("npm i -g @test/missing");
|
|
187
|
+
expect(output).toContain("1 missing");
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test("shows outdated tool with latest version", () => {
|
|
191
|
+
const results: ToolResult[] = [
|
|
192
|
+
{
|
|
193
|
+
name: "old-tool",
|
|
194
|
+
cli: "ot",
|
|
195
|
+
npm: "@test/old",
|
|
196
|
+
installed: true,
|
|
197
|
+
version: "1.0.0",
|
|
198
|
+
latest: "2.0.0",
|
|
199
|
+
upToDate: false,
|
|
200
|
+
},
|
|
201
|
+
];
|
|
202
|
+
const output = capturePrintOutput(results);
|
|
203
|
+
expect(output).toContain("old-tool");
|
|
204
|
+
expect(output).toContain("1.0.0");
|
|
205
|
+
expect(output).toContain("outdated");
|
|
206
|
+
expect(output).toContain("2.0.0");
|
|
207
|
+
expect(output).toContain("1 outdated");
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
test("shows latestError gracefully", () => {
|
|
211
|
+
const results: ToolResult[] = [
|
|
212
|
+
{
|
|
213
|
+
name: "err-tool",
|
|
214
|
+
cli: "et",
|
|
215
|
+
npm: "@test/err",
|
|
216
|
+
installed: true,
|
|
217
|
+
version: "1.0.0",
|
|
218
|
+
latestError: "network timeout",
|
|
219
|
+
},
|
|
220
|
+
];
|
|
221
|
+
const output = capturePrintOutput(results);
|
|
222
|
+
expect(output).toContain("err-tool");
|
|
223
|
+
expect(output).toContain("1.0.0");
|
|
224
|
+
expect(output).toContain("version check failed");
|
|
225
|
+
});
|
|
226
|
+
});
|
|
@@ -9,6 +9,7 @@ import { Command } from "commander";
|
|
|
9
9
|
import { jsonError, jsonOutput } from "../json.ts";
|
|
10
10
|
import { accent, brand, color, muted } from "../logging/color.ts";
|
|
11
11
|
import { thickSeparator } from "../logging/theme.ts";
|
|
12
|
+
import { fetchLatestVersion, getInstalledVersion } from "../utils/version.ts";
|
|
12
13
|
|
|
13
14
|
const TOOLS = [
|
|
14
15
|
{ name: "overstory", cli: "ov", npm: "@os-eco/overstory-cli" },
|
|
@@ -21,13 +22,13 @@ export interface EcosystemOptions {
|
|
|
21
22
|
json?: boolean;
|
|
22
23
|
}
|
|
23
24
|
|
|
24
|
-
interface DoctorSummary {
|
|
25
|
+
export interface DoctorSummary {
|
|
25
26
|
pass: number;
|
|
26
27
|
warn: number;
|
|
27
28
|
fail: number;
|
|
28
29
|
}
|
|
29
30
|
|
|
30
|
-
interface ToolResult {
|
|
31
|
+
export interface ToolResult {
|
|
31
32
|
name: string;
|
|
32
33
|
cli: string;
|
|
33
34
|
npm: string;
|
|
@@ -39,55 +40,6 @@ interface ToolResult {
|
|
|
39
40
|
latestError?: string;
|
|
40
41
|
}
|
|
41
42
|
|
|
42
|
-
async function getInstalledVersion(cli: string): Promise<string | null> {
|
|
43
|
-
// Try --version --json first
|
|
44
|
-
try {
|
|
45
|
-
const proc = Bun.spawn([cli, "--version", "--json"], {
|
|
46
|
-
stdout: "pipe",
|
|
47
|
-
stderr: "pipe",
|
|
48
|
-
});
|
|
49
|
-
const exitCode = await proc.exited;
|
|
50
|
-
if (exitCode === 0) {
|
|
51
|
-
const stdout = await new Response(proc.stdout).text();
|
|
52
|
-
try {
|
|
53
|
-
const data = JSON.parse(stdout.trim()) as { version?: string };
|
|
54
|
-
if (data.version) return data.version;
|
|
55
|
-
} catch {
|
|
56
|
-
// Not valid JSON, fall through to plain text
|
|
57
|
-
}
|
|
58
|
-
}
|
|
59
|
-
} catch {
|
|
60
|
-
// CLI not found — fall through to plain text fallback
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
// Fallback: --version plain text
|
|
64
|
-
try {
|
|
65
|
-
const proc = Bun.spawn([cli, "--version"], {
|
|
66
|
-
stdout: "pipe",
|
|
67
|
-
stderr: "pipe",
|
|
68
|
-
});
|
|
69
|
-
const exitCode = await proc.exited;
|
|
70
|
-
if (exitCode === 0) {
|
|
71
|
-
const stdout = await new Response(proc.stdout).text();
|
|
72
|
-
const match = stdout.match(/(\d+\.\d+\.\d+)/);
|
|
73
|
-
if (match?.[1]) return match[1];
|
|
74
|
-
}
|
|
75
|
-
} catch {
|
|
76
|
-
// CLI not found
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
return null;
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
async function fetchLatestVersion(packageName: string): Promise<string> {
|
|
83
|
-
const res = await fetch(`https://registry.npmjs.org/${packageName}/latest`);
|
|
84
|
-
if (!res.ok) {
|
|
85
|
-
throw new Error(`npm registry error: ${res.status} ${res.statusText}`);
|
|
86
|
-
}
|
|
87
|
-
const data = (await res.json()) as { version: string };
|
|
88
|
-
return data.version;
|
|
89
|
-
}
|
|
90
|
-
|
|
91
43
|
async function getDoctorSummary(): Promise<DoctorSummary | undefined> {
|
|
92
44
|
try {
|
|
93
45
|
const proc = Bun.spawn(["ov", "doctor", "--json"], {
|
|
@@ -158,7 +110,8 @@ async function checkTool(tool: { name: string; cli: string; npm: string }): Prom
|
|
|
158
110
|
};
|
|
159
111
|
}
|
|
160
112
|
|
|
161
|
-
|
|
113
|
+
/** @internal Exported for testing. */
|
|
114
|
+
export function formatDoctorLine(summary: DoctorSummary): string {
|
|
162
115
|
const parts: string[] = [];
|
|
163
116
|
if (summary.pass > 0) parts.push(color.green(`${summary.pass} passed`));
|
|
164
117
|
if (summary.warn > 0) parts.push(color.yellow(`${summary.warn} warn`));
|
|
@@ -166,7 +119,8 @@ function formatDoctorLine(summary: DoctorSummary): string {
|
|
|
166
119
|
return parts.length > 0 ? parts.join(", ") : "no checks";
|
|
167
120
|
}
|
|
168
121
|
|
|
169
|
-
|
|
122
|
+
/** @internal Exported for testing. */
|
|
123
|
+
export function printHumanOutput(results: ToolResult[]): void {
|
|
170
124
|
process.stdout.write(`${brand.bold("os-eco Ecosystem")}\n`);
|
|
171
125
|
process.stdout.write(`${thickSeparator()}\n`);
|
|
172
126
|
process.stdout.write("\n");
|
|
@@ -14,9 +14,10 @@ import { tmpdir } from "node:os";
|
|
|
14
14
|
import { join } from "node:path";
|
|
15
15
|
import { ValidationError } from "../errors.ts";
|
|
16
16
|
import { createEventStore } from "../events/store.ts";
|
|
17
|
+
import type { ColorFn } from "../logging/color.ts";
|
|
17
18
|
import { cleanupTempDir } from "../test-helpers.ts";
|
|
18
|
-
import type { InsertEvent } from "../types.ts";
|
|
19
|
-
import { feedCommand } from "./feed.ts";
|
|
19
|
+
import type { InsertEvent, StoredEvent } from "../types.ts";
|
|
20
|
+
import { feedCommand, pollFeedTick } from "./feed.ts";
|
|
20
21
|
|
|
21
22
|
/** Helper to create an InsertEvent with sensible defaults. */
|
|
22
23
|
function makeEvent(overrides: Partial<InsertEvent> = {}): InsertEvent {
|
|
@@ -592,3 +593,117 @@ describe("feedCommand", () => {
|
|
|
592
593
|
});
|
|
593
594
|
});
|
|
594
595
|
});
|
|
596
|
+
|
|
597
|
+
describe("pollFeedTick", () => {
|
|
598
|
+
test("returns same lastSeenId when no new events", () => {
|
|
599
|
+
const queryFn = (): StoredEvent[] => [];
|
|
600
|
+
const colorMap = new Map<string, ColorFn>();
|
|
601
|
+
|
|
602
|
+
const result = pollFeedTick(42, queryFn, colorMap, true);
|
|
603
|
+
expect(result).toBe(42);
|
|
604
|
+
});
|
|
605
|
+
|
|
606
|
+
test("returns max id when new events are found", () => {
|
|
607
|
+
const events: StoredEvent[] = [
|
|
608
|
+
{
|
|
609
|
+
id: 50,
|
|
610
|
+
runId: "run-1",
|
|
611
|
+
agentName: "builder-1",
|
|
612
|
+
sessionId: "s1",
|
|
613
|
+
eventType: "tool_start",
|
|
614
|
+
toolName: "Bash",
|
|
615
|
+
toolArgs: null,
|
|
616
|
+
toolDurationMs: null,
|
|
617
|
+
level: "info",
|
|
618
|
+
data: null,
|
|
619
|
+
createdAt: new Date().toISOString(),
|
|
620
|
+
},
|
|
621
|
+
{
|
|
622
|
+
id: 51,
|
|
623
|
+
runId: "run-1",
|
|
624
|
+
agentName: "builder-1",
|
|
625
|
+
sessionId: "s1",
|
|
626
|
+
eventType: "tool_end",
|
|
627
|
+
toolName: "Bash",
|
|
628
|
+
toolArgs: null,
|
|
629
|
+
toolDurationMs: 100,
|
|
630
|
+
level: "info",
|
|
631
|
+
data: null,
|
|
632
|
+
createdAt: new Date().toISOString(),
|
|
633
|
+
},
|
|
634
|
+
];
|
|
635
|
+
|
|
636
|
+
const queryFn = (): StoredEvent[] => events;
|
|
637
|
+
const colorMap = new Map<string, ColorFn>();
|
|
638
|
+
|
|
639
|
+
// Capture stdout to avoid test noise
|
|
640
|
+
const origWrite = process.stdout.write;
|
|
641
|
+
const captured: string[] = [];
|
|
642
|
+
process.stdout.write = ((chunk: string) => {
|
|
643
|
+
captured.push(chunk);
|
|
644
|
+
return true;
|
|
645
|
+
}) as typeof process.stdout.write;
|
|
646
|
+
|
|
647
|
+
try {
|
|
648
|
+
const result = pollFeedTick(40, queryFn, colorMap, true);
|
|
649
|
+
expect(result).toBe(51);
|
|
650
|
+
// Should have produced JSON output
|
|
651
|
+
expect(captured.length).toBeGreaterThan(0);
|
|
652
|
+
} finally {
|
|
653
|
+
process.stdout.write = origWrite;
|
|
654
|
+
}
|
|
655
|
+
});
|
|
656
|
+
|
|
657
|
+
test("filters events to those with id > lastSeenId", () => {
|
|
658
|
+
const events: StoredEvent[] = [
|
|
659
|
+
{
|
|
660
|
+
id: 5,
|
|
661
|
+
runId: "run-1",
|
|
662
|
+
agentName: "builder-1",
|
|
663
|
+
sessionId: "s1",
|
|
664
|
+
eventType: "tool_start",
|
|
665
|
+
toolName: "Read",
|
|
666
|
+
toolArgs: null,
|
|
667
|
+
toolDurationMs: null,
|
|
668
|
+
level: "info",
|
|
669
|
+
data: null,
|
|
670
|
+
createdAt: new Date().toISOString(),
|
|
671
|
+
},
|
|
672
|
+
{
|
|
673
|
+
id: 10,
|
|
674
|
+
runId: "run-1",
|
|
675
|
+
agentName: "builder-1",
|
|
676
|
+
sessionId: "s1",
|
|
677
|
+
eventType: "tool_end",
|
|
678
|
+
toolName: "Read",
|
|
679
|
+
toolArgs: null,
|
|
680
|
+
toolDurationMs: 50,
|
|
681
|
+
level: "info",
|
|
682
|
+
data: null,
|
|
683
|
+
createdAt: new Date().toISOString(),
|
|
684
|
+
},
|
|
685
|
+
];
|
|
686
|
+
|
|
687
|
+
const queryFn = (): StoredEvent[] => events;
|
|
688
|
+
const colorMap = new Map<string, ColorFn>();
|
|
689
|
+
|
|
690
|
+
// With lastSeenId = 5, only event with id=10 should pass
|
|
691
|
+
const origWrite = process.stdout.write;
|
|
692
|
+
const captured: string[] = [];
|
|
693
|
+
process.stdout.write = ((chunk: string) => {
|
|
694
|
+
captured.push(chunk);
|
|
695
|
+
return true;
|
|
696
|
+
}) as typeof process.stdout.write;
|
|
697
|
+
|
|
698
|
+
try {
|
|
699
|
+
const result = pollFeedTick(5, queryFn, colorMap, true);
|
|
700
|
+
expect(result).toBe(10);
|
|
701
|
+
// Only 1 event should be emitted (the one with id > 5)
|
|
702
|
+
// Each JSON event is output on its own line
|
|
703
|
+
const jsonOutputs = captured.filter((c) => c.includes("tool_end"));
|
|
704
|
+
expect(jsonOutputs).toHaveLength(1);
|
|
705
|
+
} finally {
|
|
706
|
+
process.stdout.write = origWrite;
|
|
707
|
+
}
|
|
708
|
+
});
|
|
709
|
+
});
|
package/src/commands/feed.ts
CHANGED
|
@@ -24,6 +24,51 @@ function printEvent(event: StoredEvent, colorMap: Map<string, ColorFn>): void {
|
|
|
24
24
|
process.stdout.write(`${formatEventLine(event, colorMap)}\n`);
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
+
/**
|
|
28
|
+
* Process one poll tick of the feed follow loop: query recent events,
|
|
29
|
+
* filter to those newer than lastSeenId, print them, and return the
|
|
30
|
+
* updated lastSeenId.
|
|
31
|
+
* @internal Exported for testing.
|
|
32
|
+
*/
|
|
33
|
+
export function pollFeedTick(
|
|
34
|
+
lastSeenId: number,
|
|
35
|
+
queryFn: (opts: { since: string; limit: number }) => StoredEvent[],
|
|
36
|
+
colorMap: Map<string, ColorFn>,
|
|
37
|
+
json: boolean,
|
|
38
|
+
): number {
|
|
39
|
+
// Query events from 60s ago, then filter client-side for id > lastSeenId
|
|
40
|
+
const pollSince = new Date(Date.now() - 60 * 1000).toISOString();
|
|
41
|
+
const recentEvents = queryFn({ since: pollSince, limit: 1000 });
|
|
42
|
+
|
|
43
|
+
// Filter to new events only
|
|
44
|
+
const newEvents = recentEvents.filter((e) => e.id > lastSeenId);
|
|
45
|
+
|
|
46
|
+
if (newEvents.length > 0) {
|
|
47
|
+
if (!json) {
|
|
48
|
+
// Update color map for any new agents
|
|
49
|
+
extendAgentColorMap(colorMap, newEvents);
|
|
50
|
+
|
|
51
|
+
// Print new events
|
|
52
|
+
for (const event of newEvents) {
|
|
53
|
+
printEvent(event, colorMap);
|
|
54
|
+
}
|
|
55
|
+
} else {
|
|
56
|
+
// JSON mode: print each event as a line
|
|
57
|
+
for (const event of newEvents) {
|
|
58
|
+
jsonOutput("feed", { event });
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Update lastSeenId
|
|
63
|
+
const lastNew = newEvents[newEvents.length - 1];
|
|
64
|
+
if (lastNew) {
|
|
65
|
+
return lastNew.id;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return lastSeenId;
|
|
70
|
+
}
|
|
71
|
+
|
|
27
72
|
interface FeedOpts {
|
|
28
73
|
follow?: boolean;
|
|
29
74
|
interval?: string;
|
|
@@ -167,36 +212,7 @@ async function executeFeed(opts: FeedOpts): Promise<void> {
|
|
|
167
212
|
// Poll for new events
|
|
168
213
|
while (true) {
|
|
169
214
|
await Bun.sleep(interval);
|
|
170
|
-
|
|
171
|
-
// Query events from 60s ago, then filter client-side for id > lastSeenId
|
|
172
|
-
const pollSince = new Date(Date.now() - 60 * 1000).toISOString();
|
|
173
|
-
const recentEvents = queryEvents({ since: pollSince, limit: 1000 });
|
|
174
|
-
|
|
175
|
-
// Filter to new events only
|
|
176
|
-
const newEvents = recentEvents.filter((e) => e.id > lastSeenId);
|
|
177
|
-
|
|
178
|
-
if (newEvents.length > 0) {
|
|
179
|
-
if (!json) {
|
|
180
|
-
// Update color map for any new agents
|
|
181
|
-
extendAgentColorMap(globalColorMap, newEvents);
|
|
182
|
-
|
|
183
|
-
// Print new events
|
|
184
|
-
for (const event of newEvents) {
|
|
185
|
-
printEvent(event, globalColorMap);
|
|
186
|
-
}
|
|
187
|
-
} else {
|
|
188
|
-
// JSON mode: print each event as a line
|
|
189
|
-
for (const event of newEvents) {
|
|
190
|
-
jsonOutput("feed", { event });
|
|
191
|
-
}
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
// Update lastSeenId
|
|
195
|
-
const lastNew = newEvents[newEvents.length - 1];
|
|
196
|
-
if (lastNew) {
|
|
197
|
-
lastSeenId = lastNew.id;
|
|
198
|
-
}
|
|
199
|
-
}
|
|
215
|
+
lastSeenId = pollFeedTick(lastSeenId, queryEvents, globalColorMap, json);
|
|
200
216
|
}
|
|
201
217
|
} finally {
|
|
202
218
|
eventStore.close();
|