@os-eco/overstory-cli 0.9.1 → 0.9.2

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.
Files changed (87) hide show
  1. package/README.md +20 -6
  2. package/agents/coordinator.md +30 -6
  3. package/agents/lead.md +11 -1
  4. package/package.json +1 -1
  5. package/src/agents/hooks-deployer.test.ts +9 -1
  6. package/src/agents/hooks-deployer.ts +2 -1
  7. package/src/agents/overlay.test.ts +26 -0
  8. package/src/agents/overlay.ts +18 -4
  9. package/src/commands/agents.ts +1 -1
  10. package/src/commands/clean.test.ts +3 -0
  11. package/src/commands/clean.ts +1 -58
  12. package/src/commands/completions.test.ts +18 -6
  13. package/src/commands/completions.ts +40 -1
  14. package/src/commands/coordinator.test.ts +77 -4
  15. package/src/commands/coordinator.ts +226 -124
  16. package/src/commands/dashboard.ts +46 -9
  17. package/src/commands/doctor.ts +3 -1
  18. package/src/commands/ecosystem.test.ts +126 -1
  19. package/src/commands/ecosystem.ts +7 -53
  20. package/src/commands/feed.test.ts +117 -2
  21. package/src/commands/feed.ts +46 -30
  22. package/src/commands/group.test.ts +274 -155
  23. package/src/commands/group.ts +11 -5
  24. package/src/commands/init.ts +8 -0
  25. package/src/commands/log.test.ts +35 -0
  26. package/src/commands/log.ts +10 -6
  27. package/src/commands/logs.test.ts +423 -1
  28. package/src/commands/logs.ts +99 -104
  29. package/src/commands/orchestrator.ts +42 -0
  30. package/src/commands/prime.test.ts +177 -2
  31. package/src/commands/prime.ts +4 -2
  32. package/src/commands/sling.ts +3 -3
  33. package/src/commands/upgrade.test.ts +2 -0
  34. package/src/commands/upgrade.ts +1 -17
  35. package/src/commands/watch.test.ts +67 -1
  36. package/src/commands/watch.ts +4 -79
  37. package/src/config.test.ts +250 -0
  38. package/src/config.ts +43 -0
  39. package/src/doctor/agents.test.ts +72 -5
  40. package/src/doctor/agents.ts +10 -10
  41. package/src/doctor/consistency.test.ts +35 -0
  42. package/src/doctor/consistency.ts +7 -3
  43. package/src/doctor/dependencies.test.ts +58 -1
  44. package/src/doctor/dependencies.ts +4 -2
  45. package/src/doctor/providers.test.ts +41 -5
  46. package/src/doctor/types.ts +2 -1
  47. package/src/doctor/version.test.ts +106 -2
  48. package/src/doctor/version.ts +4 -2
  49. package/src/doctor/watchdog.test.ts +167 -0
  50. package/src/doctor/watchdog.ts +158 -0
  51. package/src/e2e/init-sling-lifecycle.test.ts +2 -1
  52. package/src/errors.test.ts +350 -0
  53. package/src/events/tailer.test.ts +25 -0
  54. package/src/events/tailer.ts +8 -1
  55. package/src/index.ts +4 -1
  56. package/src/mail/store.test.ts +110 -0
  57. package/src/runtimes/aider.test.ts +124 -0
  58. package/src/runtimes/aider.ts +147 -0
  59. package/src/runtimes/amp.test.ts +164 -0
  60. package/src/runtimes/amp.ts +154 -0
  61. package/src/runtimes/claude.test.ts +4 -2
  62. package/src/runtimes/goose.test.ts +133 -0
  63. package/src/runtimes/goose.ts +157 -0
  64. package/src/runtimes/pi-guards.ts +2 -1
  65. package/src/runtimes/pi.test.ts +9 -9
  66. package/src/runtimes/pi.ts +6 -7
  67. package/src/runtimes/registry.test.ts +1 -1
  68. package/src/runtimes/registry.ts +13 -4
  69. package/src/runtimes/sapling.ts +2 -1
  70. package/src/runtimes/types.ts +2 -2
  71. package/src/types.ts +4 -0
  72. package/src/utils/bin.test.ts +10 -0
  73. package/src/utils/bin.ts +37 -0
  74. package/src/utils/fs.test.ts +119 -0
  75. package/src/utils/fs.ts +62 -0
  76. package/src/utils/pid.test.ts +68 -0
  77. package/src/utils/pid.ts +45 -0
  78. package/src/utils/time.test.ts +43 -0
  79. package/src/utils/time.ts +37 -0
  80. package/src/utils/version.test.ts +33 -0
  81. package/src/utils/version.ts +70 -0
  82. package/src/watchdog/daemon.test.ts +255 -1
  83. package/src/watchdog/daemon.ts +46 -9
  84. package/src/watchdog/health.test.ts +15 -1
  85. package/src/watchdog/health.ts +1 -1
  86. package/src/watchdog/triage.test.ts +49 -9
  87. package/src/watchdog/triage.ts +21 -5
@@ -59,10 +59,13 @@ const PKG_VERSION: string = JSON.parse(await Bun.file(pkgPath).text()).version ?
59
59
  * These are not colors, so they stay separate from the color module.
60
60
  */
61
61
  const CURSOR = {
62
- clear: "\x1b[2J\x1b[H", // Clear screen and home cursor
62
+ clear: "\x1b[H\x1b[J", // Home cursor then clear from cursor to end
63
+ home: "\x1b[H", // Home cursor only (for redraw without full clear)
63
64
  cursorTo: (row: number, col: number) => `\x1b[${row};${col}H`,
64
65
  hideCursor: "\x1b[?25l",
65
66
  showCursor: "\x1b[?25h",
67
+ enterAltScreen: "\x1b[?1049h", // Enter alternate screen buffer
68
+ leaveAltScreen: "\x1b[?1049l", // Leave alternate screen buffer
66
69
  } as const;
67
70
 
68
71
  /**
@@ -960,11 +963,13 @@ function renderMetricsPanel(
960
963
  /**
961
964
  * Render the full dashboard.
962
965
  */
963
- function renderDashboard(data: DashboardData, interval: number): void {
966
+ function renderDashboard(data: DashboardData, interval: number, isFirstRender: boolean): void {
964
967
  const width = process.stdout.columns ?? 100;
965
968
  const height = process.stdout.rows ?? 30;
966
969
 
967
- let output = CURSOR.clear;
970
+ // First render: clear entire alt screen. Subsequent: just home cursor
971
+ // and overwrite in-place (avoids Warp's block-per-clear issue).
972
+ let output = isFirstRender ? CURSOR.clear : CURSOR.home;
968
973
 
969
974
  // Header (rows 1-2)
970
975
  output += renderHeader(width, interval, data.currentRunId);
@@ -1050,20 +1055,44 @@ async function executeDashboard(opts: DashboardOpts): Promise<void> {
1050
1055
  zombieMs: config.watchdog.zombieThresholdMs,
1051
1056
  };
1052
1057
 
1053
- // Hide cursor
1058
+ // Enter alternate screen buffer (like vim/htop) + hide cursor + raw stdin
1059
+ process.stdout.write(CURSOR.enterAltScreen);
1054
1060
  process.stdout.write(CURSOR.hideCursor);
1061
+ if (process.stdin.isTTY) {
1062
+ process.stdin.setRawMode(true);
1063
+ process.stdin.resume();
1064
+ }
1055
1065
 
1056
- // Clean exit on Ctrl+C
1066
+ // Clean exit on Ctrl+C or 'q': restore original screen
1057
1067
  let running = true;
1058
- process.on("SIGINT", () => {
1068
+ const cleanup = () => {
1059
1069
  running = false;
1070
+ if (process.stdin.isTTY) {
1071
+ process.stdin.setRawMode(false);
1072
+ process.stdin.pause();
1073
+ }
1060
1074
  closeDashboardStores(stores);
1061
1075
  process.stdout.write(CURSOR.showCursor);
1062
- process.stdout.write(CURSOR.clear);
1076
+ process.stdout.write(CURSOR.leaveAltScreen);
1077
+ };
1078
+
1079
+ process.on("SIGINT", () => {
1080
+ cleanup();
1063
1081
  process.exitCode = 0;
1064
1082
  });
1065
1083
 
1084
+ // Allow 'q' to quit the dashboard
1085
+ process.stdin.on("data", (data: Buffer) => {
1086
+ const key = data.toString();
1087
+ if (key === "q" || key === "\x03") {
1088
+ // 'q' or Ctrl+C
1089
+ cleanup();
1090
+ process.exitCode = 0;
1091
+ }
1092
+ });
1093
+
1066
1094
  // Poll loop — errors are caught per-tick so transient DB failures never crash the dashboard.
1095
+ let isFirstRender = true;
1067
1096
  let lastGoodData: DashboardData | null = null;
1068
1097
  let lastErrorMsg: string | null = null;
1069
1098
  while (running) {
@@ -1077,12 +1106,20 @@ async function executeDashboard(opts: DashboardOpts): Promise<void> {
1077
1106
  config.runtime,
1078
1107
  );
1079
1108
  lastGoodData = data;
1109
+ // If recovering from an error, clear the stale error line at the bottom
1110
+ if (lastErrorMsg !== null) {
1111
+ const w = process.stdout.columns ?? 100;
1112
+ const h = process.stdout.rows ?? 30;
1113
+ process.stdout.write(`${CURSOR.cursorTo(h, 1)}${" ".repeat(w)}`);
1114
+ }
1080
1115
  lastErrorMsg = null;
1081
- renderDashboard(data, interval);
1116
+ renderDashboard(data, interval, isFirstRender);
1117
+ isFirstRender = false;
1082
1118
  } catch (err) {
1083
1119
  // Render last good frame so the TUI stays alive, then show the error inline.
1084
1120
  if (lastGoodData) {
1085
- renderDashboard(lastGoodData, interval);
1121
+ renderDashboard(lastGoodData, interval, isFirstRender);
1122
+ isFirstRender = false;
1086
1123
  }
1087
1124
  lastErrorMsg = err instanceof Error ? err.message : String(err);
1088
1125
  const w = process.stdout.columns ?? 100;
@@ -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 { createEcosystemCommand, executeEcosystem } from "./ecosystem.ts";
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
- function formatDoctorLine(summary: DoctorSummary): string {
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
- function printHumanOutput(results: ToolResult[]): void {
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
+ });
@@ -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();