@os-eco/overstory-cli 0.6.11 → 0.7.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.
Files changed (46) hide show
  1. package/README.md +7 -9
  2. package/agents/lead.md +20 -19
  3. package/package.json +5 -3
  4. package/src/agents/overlay.test.ts +23 -0
  5. package/src/agents/overlay.ts +5 -4
  6. package/src/commands/coordinator.ts +21 -9
  7. package/src/commands/costs.test.ts +1 -1
  8. package/src/commands/costs.ts +13 -20
  9. package/src/commands/dashboard.ts +38 -138
  10. package/src/commands/doctor.test.ts +1 -1
  11. package/src/commands/doctor.ts +2 -2
  12. package/src/commands/ecosystem.ts +2 -1
  13. package/src/commands/errors.test.ts +4 -5
  14. package/src/commands/errors.ts +4 -62
  15. package/src/commands/feed.test.ts +2 -2
  16. package/src/commands/feed.ts +12 -106
  17. package/src/commands/inspect.ts +10 -44
  18. package/src/commands/logs.ts +7 -63
  19. package/src/commands/metrics.test.ts +2 -2
  20. package/src/commands/metrics.ts +3 -17
  21. package/src/commands/monitor.ts +17 -7
  22. package/src/commands/replay.test.ts +2 -2
  23. package/src/commands/replay.ts +12 -135
  24. package/src/commands/run.ts +7 -23
  25. package/src/commands/sling.test.ts +53 -0
  26. package/src/commands/sling.ts +25 -10
  27. package/src/commands/status.ts +4 -17
  28. package/src/commands/supervisor.ts +18 -8
  29. package/src/commands/trace.test.ts +5 -6
  30. package/src/commands/trace.ts +11 -109
  31. package/src/config.ts +10 -0
  32. package/src/index.ts +2 -1
  33. package/src/logging/format.ts +214 -0
  34. package/src/logging/theme.ts +132 -0
  35. package/src/metrics/store.test.ts +46 -0
  36. package/src/metrics/store.ts +11 -0
  37. package/src/mulch/client.test.ts +20 -0
  38. package/src/mulch/client.ts +312 -45
  39. package/src/runtimes/claude.test.ts +616 -0
  40. package/src/runtimes/claude.ts +218 -0
  41. package/src/runtimes/registry.test.ts +53 -0
  42. package/src/runtimes/registry.ts +33 -0
  43. package/src/runtimes/types.ts +125 -0
  44. package/src/types.ts +4 -0
  45. package/src/worktree/tmux.test.ts +28 -13
  46. package/src/worktree/tmux.ts +14 -28
@@ -32,6 +32,7 @@ import { printSuccess } from "../logging/color.ts";
32
32
  import { createMailClient } from "../mail/client.ts";
33
33
  import { createMailStore } from "../mail/store.ts";
34
34
  import { createMulchClient } from "../mulch/client.ts";
35
+ import { getRuntime } from "../runtimes/registry.ts";
35
36
  import { openSessionStore } from "../sessions/compat.ts";
36
37
  import { createRunStore } from "../sessions/store.ts";
37
38
  import type { TrackerIssue } from "../tracker/factory.ts";
@@ -122,6 +123,7 @@ export interface SlingOptions {
122
123
  maxAgents?: string;
123
124
  skipReview?: boolean;
124
125
  dispatchMaxAgents?: string;
126
+ runtime?: string;
125
127
  }
126
128
 
127
129
  export interface AutoDispatchOptions {
@@ -699,10 +701,20 @@ export async function slingCommand(taskId: string, opts: SlingOptions): Promise<
699
701
 
700
702
  // 12. Create tmux session running claude in interactive mode
701
703
  const tmuxSessionName = `overstory-${config.project.name}-${name}`;
702
- const { model, env } = resolveModel(config, manifest, capability, agentDef.model);
703
- const claudeCmd = `claude --model ${model} --permission-mode bypassPermissions`;
704
- const pid = await createSession(tmuxSessionName, worktreePath, claudeCmd, {
705
- ...env,
704
+ const resolvedModel = resolveModel(config, manifest, capability, agentDef.model);
705
+ const runtime = getRuntime(opts.runtime, config);
706
+ const spawnCmd = runtime.buildSpawnCommand({
707
+ model: resolvedModel.model,
708
+ permissionMode: "bypass",
709
+ cwd: worktreePath,
710
+ env: {
711
+ ...runtime.buildEnv(resolvedModel),
712
+ OVERSTORY_AGENT_NAME: name,
713
+ OVERSTORY_WORKTREE_PATH: worktreePath,
714
+ },
715
+ });
716
+ const pid = await createSession(tmuxSessionName, worktreePath, spawnCmd, {
717
+ ...runtime.buildEnv(resolvedModel),
706
718
  OVERSTORY_AGENT_NAME: name,
707
719
  OVERSTORY_WORKTREE_PATH: worktreePath,
708
720
  });
@@ -743,7 +755,7 @@ export async function slingCommand(taskId: string, opts: SlingOptions): Promise<
743
755
  // 13b. Wait for Claude Code TUI to render before sending input.
744
756
  // Polling capture-pane is more reliable than a fixed sleep because
745
757
  // TUI init time varies by machine load and model state.
746
- await waitForTuiReady(tmuxSessionName);
758
+ await waitForTuiReady(tmuxSessionName, (content) => runtime.detectReady(content));
747
759
  // Buffer for the input handler to attach after initial render
748
760
  await Bun.sleep(1_000);
749
761
 
@@ -765,17 +777,20 @@ export async function slingCommand(taskId: string, opts: SlingOptions): Promise<
765
777
  }
766
778
 
767
779
  // 13d. Verify beacon was received — if pane still shows the welcome
768
- // screen ("Try "), resend the beacon. Claude Code's TUI sometimes
769
- // consumes the Enter keystroke during late initialization, swallowing
780
+ // screen (detectReady returns "ready"), resend the beacon. Claude Code's TUI
781
+ // sometimes consumes the Enter keystroke during late initialization, swallowing
770
782
  // the beacon text entirely (overstory-3271).
771
783
  const verifyAttempts = 5;
772
784
  for (let v = 0; v < verifyAttempts; v++) {
773
785
  await Bun.sleep(2_000);
774
786
  const paneContent = await capturePaneContent(tmuxSessionName);
775
- if (paneContent && !paneContent.includes('Try "')) {
776
- break; // Agent is processing — beacon was received
787
+ if (paneContent) {
788
+ const readyState = runtime.detectReady(paneContent);
789
+ if (readyState.phase !== "ready") {
790
+ break; // Agent is processing — beacon was received
791
+ }
777
792
  }
778
- // Still at welcome screen — resend beacon
793
+ // Still at welcome/idle screen — resend beacon
779
794
  await sendKeys(tmuxSessionName, beacon);
780
795
  await Bun.sleep(1_000);
781
796
  await sendKeys(tmuxSessionName, ""); // Follow-up Enter
@@ -11,6 +11,8 @@ import { loadConfig } from "../config.ts";
11
11
  import { ValidationError } from "../errors.ts";
12
12
  import { jsonOutput } from "../json.ts";
13
13
  import { accent, color } from "../logging/color.ts";
14
+ import { formatDuration } from "../logging/format.ts";
15
+ import { renderHeader } from "../logging/theme.ts";
14
16
  import { createMailStore } from "../mail/store.ts";
15
17
  import { createMergeQueue } from "../merge/queue.ts";
16
18
  import { createMetricsStore } from "../metrics/store.ts";
@@ -68,20 +70,6 @@ export async function getCachedTmuxSessions(
68
70
  }
69
71
  }
70
72
 
71
- /**
72
- * Format a duration in ms to a human-readable string.
73
- */
74
- function formatDuration(ms: number): string {
75
- const seconds = Math.floor(ms / 1000);
76
- if (seconds < 60) return `${seconds}s`;
77
- const minutes = Math.floor(seconds / 60);
78
- const remainSec = seconds % 60;
79
- if (minutes < 60) return `${minutes}m ${remainSec}s`;
80
- const hours = Math.floor(minutes / 60);
81
- const remainMin = minutes % 60;
82
- return `${hours}h ${remainMin}m`;
83
- }
84
-
85
73
  export interface VerboseAgentDetail {
86
74
  worktreePath: string;
87
75
  logsDir: string;
@@ -190,7 +178,7 @@ export async function gatherStatus(
190
178
  const metricsFile = Bun.file(metricsDbPath);
191
179
  if (await metricsFile.exists()) {
192
180
  const metricsStore = createMetricsStore(metricsDbPath);
193
- recentMetricsCount = metricsStore.getRecentSessions(100).length;
181
+ recentMetricsCount = metricsStore.countSessions();
194
182
  metricsStore.close();
195
183
  }
196
184
  } catch {
@@ -256,8 +244,7 @@ export function printStatus(data: StatusData): void {
256
244
  const now = Date.now();
257
245
  const w = process.stdout.write.bind(process.stdout);
258
246
 
259
- w("Overstory Status\n");
260
- w(`${"═".repeat(60)}\n\n`);
247
+ w(`${renderHeader("Overstory Status")}\n\n`);
261
248
  if (data.currentRunId) {
262
249
  w(`Run: ${accent(data.currentRunId)}\n`);
263
250
  }
@@ -22,6 +22,7 @@ import { loadConfig } from "../config.ts";
22
22
  import { AgentError, ValidationError } from "../errors.ts";
23
23
  import { jsonOutput } from "../json.ts";
24
24
  import { printHint, printSuccess } from "../logging/color.ts";
25
+ import { getRuntime } from "../runtimes/registry.ts";
25
26
  import { openSessionStore } from "../sessions/compat.ts";
26
27
  import { createTrackerClient, resolveBackend, trackerCliName } from "../tracker/factory.ts";
27
28
  import type { AgentSession } from "../types.ts";
@@ -160,26 +161,35 @@ async function startSupervisor(opts: {
160
161
  join(projectRoot, config.agents.baseDir),
161
162
  );
162
163
  const manifest = await manifestLoader.load();
163
- const { model, env } = resolveModel(config, manifest, "supervisor", "opus");
164
+ const resolvedModel = resolveModel(config, manifest, "supervisor", "opus");
165
+ const runtime = getRuntime(undefined, config);
164
166
 
165
167
  // Spawn tmux session at project root with Claude Code (interactive mode).
166
168
  // Inject the supervisor base definition via --append-system-prompt.
167
169
  const tmuxSession = `overstory-${config.project.name}-supervisor-${opts.name}`;
168
170
  const agentDefPath = join(projectRoot, ".overstory", "agent-defs", "supervisor.md");
169
171
  const agentDefFile = Bun.file(agentDefPath);
170
- let claudeCmd = `claude --model ${model} --permission-mode bypassPermissions`;
172
+ let appendSystemPrompt: string | undefined;
171
173
  if (await agentDefFile.exists()) {
172
- const agentDef = await agentDefFile.text();
173
- const escaped = agentDef.replace(/'/g, "'\\''");
174
- claudeCmd += ` --append-system-prompt '${escaped}'`;
174
+ appendSystemPrompt = await agentDefFile.text();
175
175
  }
176
- const pid = await createSession(tmuxSession, projectRoot, claudeCmd, {
177
- ...env,
176
+ const spawnCmd = runtime.buildSpawnCommand({
177
+ model: resolvedModel.model,
178
+ permissionMode: "bypass",
179
+ cwd: projectRoot,
180
+ appendSystemPrompt,
181
+ env: {
182
+ ...runtime.buildEnv(resolvedModel),
183
+ OVERSTORY_AGENT_NAME: opts.name,
184
+ },
185
+ });
186
+ const pid = await createSession(tmuxSession, projectRoot, spawnCmd, {
187
+ ...runtime.buildEnv(resolvedModel),
178
188
  OVERSTORY_AGENT_NAME: opts.name,
179
189
  });
180
190
 
181
191
  // Wait for Claude Code TUI to render before sending input
182
- await waitForTuiReady(tmuxSession);
192
+ await waitForTuiReady(tmuxSession, (content) => runtime.detectReady(content));
183
193
  await Bun.sleep(1_000);
184
194
 
185
195
  const beacon = buildSupervisorBeacon({
@@ -317,7 +317,7 @@ describe("traceCommand", () => {
317
317
  await traceCommand(["builder-1"]);
318
318
  const out = output();
319
319
 
320
- expect(out).toContain("=".repeat(70));
320
+ expect(out).toContain("".repeat(70));
321
321
  });
322
322
 
323
323
  test("event type labels are shown", async () => {
@@ -370,7 +370,7 @@ describe("traceCommand", () => {
370
370
  await traceCommand(["builder-1"]);
371
371
  const out = output();
372
372
 
373
- expect(out).toContain("duration=42ms");
373
+ expect(out).toContain("dur=42ms");
374
374
  });
375
375
 
376
376
  test("custom data fields are shown in detail", async () => {
@@ -389,8 +389,7 @@ describe("traceCommand", () => {
389
389
  await traceCommand(["builder-1"]);
390
390
  const out = output();
391
391
 
392
- expect(out).toContain("reason=testing");
393
- expect(out).toContain("count=5");
392
+ expect(out).toContain('data={"reason":"testing","count":5}');
394
393
  });
395
394
 
396
395
  test("date separator appears in timeline", async () => {
@@ -684,8 +683,8 @@ describe("traceCommand", () => {
684
683
 
685
684
  // The full 200-char value should not appear
686
685
  expect(out).not.toContain(longValue);
687
- // But a truncated version with "..." should
688
- expect(out).toContain("...");
686
+ // But a truncated version with "" should
687
+ expect(out).toContain("");
689
688
  });
690
689
 
691
690
  test("non-JSON data is shown raw if short", async () => {
@@ -11,23 +11,16 @@ import { loadConfig } from "../config.ts";
11
11
  import { ValidationError } from "../errors.ts";
12
12
  import { createEventStore } from "../events/store.ts";
13
13
  import { jsonOutput } from "../json.ts";
14
- import type { ColorFn } from "../logging/color.ts";
15
14
  import { accent, color } from "../logging/color.ts";
15
+ import {
16
+ buildEventDetail,
17
+ formatAbsoluteTime,
18
+ formatDate,
19
+ formatRelativeTime,
20
+ } from "../logging/format.ts";
21
+ import { eventLabel, renderHeader } from "../logging/theme.ts";
16
22
  import { openSessionStore } from "../sessions/compat.ts";
17
- import type { EventType, StoredEvent } from "../types.ts";
18
-
19
- /** Labels and colors for each event type. */
20
- const EVENT_LABELS: Record<EventType, { label: string; color: ColorFn }> = {
21
- tool_start: { label: "TOOL START", color: color.blue },
22
- tool_end: { label: "TOOL END ", color: color.blue },
23
- session_start: { label: "SESSION +", color: color.green },
24
- session_end: { label: "SESSION -", color: color.yellow },
25
- mail_sent: { label: "MAIL SENT ", color: color.cyan },
26
- mail_received: { label: "MAIL RECV ", color: color.cyan },
27
- spawn: { label: "SPAWN ", color: color.magenta },
28
- error: { label: "ERROR ", color: color.red },
29
- custom: { label: "CUSTOM ", color: color.gray },
30
- };
23
+ import type { StoredEvent } from "../types.ts";
31
24
 
32
25
  /**
33
26
  * Detect whether a target string looks like a task ID.
@@ -37,101 +30,13 @@ function looksLikeTaskId(target: string): boolean {
37
30
  return /^[a-z][a-z0-9]*-[a-z0-9]{3,}$/i.test(target);
38
31
  }
39
32
 
40
- /**
41
- * Format a relative time string from a timestamp.
42
- * Returns strings like "2m ago", "1h ago", "3d ago".
43
- */
44
- function formatRelativeTime(timestamp: string): string {
45
- const eventTime = new Date(timestamp).getTime();
46
- const now = Date.now();
47
- const diffMs = now - eventTime;
48
-
49
- if (diffMs < 0) return "just now";
50
-
51
- const seconds = Math.floor(diffMs / 1000);
52
- if (seconds < 60) return `${seconds}s ago`;
53
-
54
- const minutes = Math.floor(seconds / 60);
55
- if (minutes < 60) return `${minutes}m ago`;
56
-
57
- const hours = Math.floor(minutes / 60);
58
- if (hours < 24) return `${hours}h ago`;
59
-
60
- const days = Math.floor(hours / 24);
61
- return `${days}d ago`;
62
- }
63
-
64
- /**
65
- * Format an absolute time from an ISO timestamp.
66
- * Returns "HH:MM:SS" portion.
67
- */
68
- function formatAbsoluteTime(timestamp: string): string {
69
- const match = /T(\d{2}:\d{2}:\d{2})/.exec(timestamp);
70
- if (match?.[1]) {
71
- return match[1];
72
- }
73
- return timestamp;
74
- }
75
-
76
- /**
77
- * Format the date portion of an ISO timestamp.
78
- * Returns "YYYY-MM-DD".
79
- */
80
- function formatDate(timestamp: string): string {
81
- const match = /^(\d{4}-\d{2}-\d{2})/.exec(timestamp);
82
- if (match?.[1]) {
83
- return match[1];
84
- }
85
- return "";
86
- }
87
-
88
- /**
89
- * Build a detail string for a timeline event based on its type and fields.
90
- */
91
- function buildEventDetail(event: StoredEvent): string {
92
- const parts: string[] = [];
93
-
94
- if (event.toolName) {
95
- parts.push(`tool=${event.toolName}`);
96
- }
97
-
98
- if (event.toolDurationMs !== null) {
99
- parts.push(`duration=${event.toolDurationMs}ms`);
100
- }
101
-
102
- if (event.data) {
103
- try {
104
- const parsed: unknown = JSON.parse(event.data);
105
- if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) {
106
- const data = parsed as Record<string, unknown>;
107
- for (const [key, value] of Object.entries(data)) {
108
- if (value !== null && value !== undefined) {
109
- const strValue = typeof value === "string" ? value : JSON.stringify(value);
110
- // Truncate long values
111
- const truncated = strValue.length > 80 ? `${strValue.slice(0, 77)}...` : strValue;
112
- parts.push(`${key}=${truncated}`);
113
- }
114
- }
115
- }
116
- } catch {
117
- // data is not valid JSON; show it raw if short enough
118
- if (event.data.length <= 80) {
119
- parts.push(event.data);
120
- }
121
- }
122
- }
123
-
124
- return parts.join(" ");
125
- }
126
-
127
33
  /**
128
34
  * Print events as a formatted timeline with ANSI colors.
129
35
  */
130
36
  function printTimeline(events: StoredEvent[], agentName: string, useAbsoluteTime: boolean): void {
131
37
  const w = process.stdout.write.bind(process.stdout);
132
38
 
133
- w(`${color.bold(`Timeline for ${accent(agentName)}`)}\n`);
134
- w(`${"=".repeat(70)}\n`);
39
+ w(`${renderHeader(`Timeline for ${accent(agentName)}`)}\n`);
135
40
 
136
41
  if (events.length === 0) {
137
42
  w(`${color.dim("No events found.")}\n`);
@@ -157,10 +62,7 @@ function printTimeline(events: StoredEvent[], agentName: string, useAbsoluteTime
157
62
  ? formatAbsoluteTime(event.createdAt)
158
63
  : formatRelativeTime(event.createdAt);
159
64
 
160
- const eventInfo = EVENT_LABELS[event.eventType] ?? {
161
- label: event.eventType.padEnd(10),
162
- color: color.gray,
163
- };
65
+ const label = eventLabel(event.eventType);
164
66
 
165
67
  const levelColorFn =
166
68
  event.level === "error" ? color.red : event.level === "warn" ? color.yellow : null;
@@ -173,7 +75,7 @@ function printTimeline(events: StoredEvent[], agentName: string, useAbsoluteTime
173
75
 
174
76
  w(
175
77
  `${color.dim(timeStr.padStart(10))} ` +
176
- `${applyLevel(eventInfo.color(color.bold(eventInfo.label)))}` +
78
+ `${applyLevel(label.color(color.bold(label.full)))}` +
177
79
  `${agentLabel}${detailSuffix}\n`,
178
80
  );
179
81
  }
package/src/config.ts CHANGED
@@ -62,6 +62,9 @@ export const DEFAULT_CONFIG: OverstoryConfig = {
62
62
  verbose: false,
63
63
  redactSecrets: true,
64
64
  },
65
+ runtime: {
66
+ default: "claude",
67
+ },
65
68
  };
66
69
 
67
70
  const CONFIG_FILENAME = "config.yaml";
@@ -625,6 +628,13 @@ function validateConfig(config: OverstoryConfig): void {
625
628
  }
626
629
  }
627
630
 
631
+ // runtime.default must be a string if present
632
+ if (config.runtime !== undefined && typeof config.runtime.default !== "string") {
633
+ process.stderr.write(
634
+ `[overstory] WARNING: runtime.default must be a string. Got: ${typeof config.runtime.default}. Ignoring.\n`,
635
+ );
636
+ }
637
+
628
638
  // models: validate each value — accepts aliases and provider-prefixed refs
629
639
  const validAliases = ["sonnet", "opus", "haiku"];
630
640
  const toolHeavyRoles = ["builder", "scout"];
package/src/index.ts CHANGED
@@ -45,7 +45,7 @@ import { OverstoryError, WorktreeError } from "./errors.ts";
45
45
  import { jsonError } from "./json.ts";
46
46
  import { brand, chalk, muted, setQuiet } from "./logging/color.ts";
47
47
 
48
- export const VERSION = "0.6.11";
48
+ export const VERSION = "0.7.0";
49
49
 
50
50
  const rawArgs = process.argv.slice(2);
51
51
 
@@ -255,6 +255,7 @@ program
255
255
  .option("--max-agents <n>", "Max children per lead (overrides config)")
256
256
  .option("--skip-review", "Skip review phase for lead agents")
257
257
  .option("--dispatch-max-agents <n>", "Per-lead max agents ceiling (injected into overlay)")
258
+ .option("--runtime <name>", "Runtime adapter (default: config or claude)")
258
259
  .option("--json", "Output result as JSON")
259
260
  .action(async (taskId, opts) => {
260
261
  await slingCommand(taskId, opts);
@@ -0,0 +1,214 @@
1
+ /**
2
+ * Shared formatting utilities for overstory CLI output.
3
+ *
4
+ * Duration, timestamp, event detail, agent color mapping, and status color
5
+ * helpers used across all observability commands.
6
+ */
7
+
8
+ import type { StoredEvent } from "../types.ts";
9
+ import type { ColorFn } from "./color.ts";
10
+ import { color, noColor } from "./color.ts";
11
+ import { AGENT_COLORS } from "./theme.ts";
12
+
13
+ // === Duration ===
14
+
15
+ /**
16
+ * Formats a duration in milliseconds to a human-readable string.
17
+ * Examples: "0s", "12s", "3m 45s", "2h 15m"
18
+ */
19
+ export function formatDuration(ms: number): string {
20
+ if (ms === 0) return "0s";
21
+ const totalSeconds = Math.floor(ms / 1000);
22
+ const hours = Math.floor(totalSeconds / 3600);
23
+ const minutes = Math.floor((totalSeconds % 3600) / 60);
24
+ const seconds = totalSeconds % 60;
25
+ if (hours > 0) {
26
+ return minutes > 0 ? `${hours}h ${minutes}m` : `${hours}h`;
27
+ }
28
+ if (minutes > 0) {
29
+ return seconds > 0 ? `${minutes}m ${seconds}s` : `${minutes}m`;
30
+ }
31
+ return `${seconds}s`;
32
+ }
33
+
34
+ // === Timestamps ===
35
+
36
+ /**
37
+ * Extracts "HH:MM:SS" from an ISO 8601 timestamp string.
38
+ * Returns the raw substring if the timestamp is well-formed.
39
+ */
40
+ export function formatAbsoluteTime(timestamp: string): string {
41
+ // ISO format: "YYYY-MM-DDTHH:MM:SS..." or "YYYY-MM-DD HH:MM:SS..."
42
+ const match = timestamp.match(/T?(\d{2}:\d{2}:\d{2})/);
43
+ return match?.[1] ?? timestamp;
44
+ }
45
+
46
+ /**
47
+ * Extracts "YYYY-MM-DD" from an ISO 8601 timestamp string.
48
+ */
49
+ export function formatDate(timestamp: string): string {
50
+ const match = timestamp.match(/^(\d{4}-\d{2}-\d{2})/);
51
+ return match?.[1] ?? timestamp;
52
+ }
53
+
54
+ /**
55
+ * Formats a timestamp as a human-readable relative time string.
56
+ * Examples: "12s ago", "3m ago", "2h ago", "5d ago"
57
+ */
58
+ export function formatRelativeTime(timestamp: string): string {
59
+ const now = Date.now();
60
+ const then = new Date(timestamp).getTime();
61
+ const diffMs = now - then;
62
+ if (diffMs < 0) return "just now";
63
+ const diffSeconds = Math.floor(diffMs / 1000);
64
+ const diffMinutes = Math.floor(diffSeconds / 60);
65
+ const diffHours = Math.floor(diffMinutes / 60);
66
+ const diffDays = Math.floor(diffHours / 24);
67
+ if (diffDays > 0) return `${diffDays}d ago`;
68
+ if (diffHours > 0) return `${diffHours}h ago`;
69
+ if (diffMinutes > 0) return `${diffMinutes}m ago`;
70
+ return `${diffSeconds}s ago`;
71
+ }
72
+
73
+ // === Event Details ===
74
+
75
+ /**
76
+ * Builds a compact "key=value" detail string from a StoredEvent's fields.
77
+ * Values are truncated to maxValueLen (default 80) characters.
78
+ */
79
+ export function buildEventDetail(event: StoredEvent, maxValueLen = 80): string {
80
+ const parts: string[] = [];
81
+
82
+ if (event.toolName) {
83
+ parts.push(`tool=${event.toolName}`);
84
+ }
85
+ if (event.toolArgs) {
86
+ const truncated =
87
+ event.toolArgs.length > maxValueLen
88
+ ? `${event.toolArgs.slice(0, maxValueLen)}…`
89
+ : event.toolArgs;
90
+ parts.push(`args=${truncated}`);
91
+ }
92
+ if (event.toolDurationMs !== null && event.toolDurationMs !== undefined) {
93
+ parts.push(`dur=${event.toolDurationMs}ms`);
94
+ }
95
+ if (event.data) {
96
+ const truncated =
97
+ event.data.length > maxValueLen ? `${event.data.slice(0, maxValueLen)}…` : event.data;
98
+ parts.push(`data=${truncated}`);
99
+ }
100
+
101
+ return parts.join(" ");
102
+ }
103
+
104
+ // === Agent Color Mapping ===
105
+
106
+ /**
107
+ * Builds a stable color map for agents by first-appearance order in events.
108
+ * Agents are assigned colors from AGENT_COLORS cycling as needed.
109
+ */
110
+ export function buildAgentColorMap(events: StoredEvent[]): Map<string, ColorFn> {
111
+ const colorMap = new Map<string, ColorFn>();
112
+ let idx = 0;
113
+ for (const event of events) {
114
+ if (!colorMap.has(event.agentName)) {
115
+ const colorFn = AGENT_COLORS[idx % AGENT_COLORS.length] ?? noColor;
116
+ colorMap.set(event.agentName, colorFn);
117
+ idx++;
118
+ }
119
+ }
120
+ return colorMap;
121
+ }
122
+
123
+ /**
124
+ * Extends an existing agent color map with new agents from the given events.
125
+ * Used in follow mode to add agents discovered in incremental event batches.
126
+ */
127
+ export function extendAgentColorMap(colorMap: Map<string, ColorFn>, events: StoredEvent[]): void {
128
+ let idx = colorMap.size;
129
+ for (const event of events) {
130
+ if (!colorMap.has(event.agentName)) {
131
+ const colorFn = AGENT_COLORS[idx % AGENT_COLORS.length] ?? noColor;
132
+ colorMap.set(event.agentName, colorFn);
133
+ idx++;
134
+ }
135
+ }
136
+ }
137
+
138
+ // === Status Colors ===
139
+
140
+ /**
141
+ * Returns a color function for a merge status string.
142
+ * pending=yellow, merging=blue, conflict=red, merged=green
143
+ */
144
+ export function mergeStatusColor(status: string): ColorFn {
145
+ switch (status) {
146
+ case "pending":
147
+ return color.yellow;
148
+ case "merging":
149
+ return color.blue;
150
+ case "conflict":
151
+ return color.red;
152
+ case "merged":
153
+ return color.green;
154
+ default:
155
+ return (text: string) => text;
156
+ }
157
+ }
158
+
159
+ /**
160
+ * Returns a color function for a priority string.
161
+ * urgent=red, high=yellow, normal=identity, low=dim
162
+ */
163
+ export function priorityColor(priority: string): ColorFn {
164
+ switch (priority) {
165
+ case "urgent":
166
+ return color.red;
167
+ case "high":
168
+ return color.yellow;
169
+ case "normal":
170
+ return (text: string) => text;
171
+ case "low":
172
+ return color.dim;
173
+ default:
174
+ return (text: string) => text;
175
+ }
176
+ }
177
+
178
+ /**
179
+ * Returns a color function for a log level string.
180
+ * debug=gray, info=blue, warn=yellow, error=red
181
+ */
182
+ export function logLevelColor(level: string): ColorFn {
183
+ switch (level) {
184
+ case "debug":
185
+ return color.gray;
186
+ case "info":
187
+ return color.blue;
188
+ case "warn":
189
+ return color.yellow;
190
+ case "error":
191
+ return color.red;
192
+ default:
193
+ return (text: string) => text;
194
+ }
195
+ }
196
+
197
+ /**
198
+ * Returns a 3-character label for a log level string.
199
+ * debug="DBG", info="INF", warn="WRN", error="ERR"
200
+ */
201
+ export function logLevelLabel(level: string): string {
202
+ switch (level) {
203
+ case "debug":
204
+ return "DBG";
205
+ case "info":
206
+ return "INF";
207
+ case "warn":
208
+ return "WRN";
209
+ case "error":
210
+ return "ERR";
211
+ default:
212
+ return level.slice(0, 3).toUpperCase();
213
+ }
214
+ }