@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.
- package/README.md +7 -9
- package/agents/lead.md +20 -19
- package/package.json +5 -3
- package/src/agents/overlay.test.ts +23 -0
- package/src/agents/overlay.ts +5 -4
- package/src/commands/coordinator.ts +21 -9
- package/src/commands/costs.test.ts +1 -1
- package/src/commands/costs.ts +13 -20
- package/src/commands/dashboard.ts +38 -138
- package/src/commands/doctor.test.ts +1 -1
- package/src/commands/doctor.ts +2 -2
- package/src/commands/ecosystem.ts +2 -1
- package/src/commands/errors.test.ts +4 -5
- package/src/commands/errors.ts +4 -62
- package/src/commands/feed.test.ts +2 -2
- package/src/commands/feed.ts +12 -106
- package/src/commands/inspect.ts +10 -44
- package/src/commands/logs.ts +7 -63
- package/src/commands/metrics.test.ts +2 -2
- package/src/commands/metrics.ts +3 -17
- package/src/commands/monitor.ts +17 -7
- package/src/commands/replay.test.ts +2 -2
- package/src/commands/replay.ts +12 -135
- package/src/commands/run.ts +7 -23
- package/src/commands/sling.test.ts +53 -0
- package/src/commands/sling.ts +25 -10
- package/src/commands/status.ts +4 -17
- package/src/commands/supervisor.ts +18 -8
- package/src/commands/trace.test.ts +5 -6
- package/src/commands/trace.ts +11 -109
- package/src/config.ts +10 -0
- package/src/index.ts +2 -1
- package/src/logging/format.ts +214 -0
- package/src/logging/theme.ts +132 -0
- package/src/metrics/store.test.ts +46 -0
- package/src/metrics/store.ts +11 -0
- package/src/mulch/client.test.ts +20 -0
- package/src/mulch/client.ts +312 -45
- package/src/runtimes/claude.test.ts +616 -0
- package/src/runtimes/claude.ts +218 -0
- package/src/runtimes/registry.test.ts +53 -0
- package/src/runtimes/registry.ts +33 -0
- package/src/runtimes/types.ts +125 -0
- package/src/types.ts +4 -0
- package/src/worktree/tmux.test.ts +28 -13
- package/src/worktree/tmux.ts +14 -28
package/src/commands/sling.ts
CHANGED
|
@@ -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
|
|
703
|
-
const
|
|
704
|
-
const
|
|
705
|
-
|
|
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 (
|
|
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
|
|
776
|
-
|
|
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
|
package/src/commands/status.ts
CHANGED
|
@@ -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.
|
|
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
|
|
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
|
|
172
|
+
let appendSystemPrompt: string | undefined;
|
|
171
173
|
if (await agentDefFile.exists()) {
|
|
172
|
-
|
|
173
|
-
const escaped = agentDef.replace(/'/g, "'\\''");
|
|
174
|
-
claudeCmd += ` --append-system-prompt '${escaped}'`;
|
|
174
|
+
appendSystemPrompt = await agentDefFile.text();
|
|
175
175
|
}
|
|
176
|
-
const
|
|
177
|
-
|
|
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("
|
|
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("
|
|
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
|
|
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 "
|
|
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 () => {
|
package/src/commands/trace.ts
CHANGED
|
@@ -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 {
|
|
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(`${
|
|
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
|
|
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(
|
|
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.
|
|
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
|
+
}
|