@os-eco/overstory-cli 0.7.6 → 0.7.8
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 +104 -2
- package/package.json +1 -1
- package/src/commands/coordinator.test.ts +131 -2
- package/src/commands/coordinator.ts +40 -9
- package/src/commands/costs.test.ts +5 -0
- package/src/commands/costs.ts +1 -1
- package/src/commands/dashboard.test.ts +101 -10
- package/src/commands/dashboard.ts +95 -61
- package/src/commands/log.ts +2 -0
- package/src/commands/sling.test.ts +63 -1
- package/src/commands/sling.ts +37 -2
- package/src/config.test.ts +68 -0
- package/src/config.ts +16 -0
- package/src/index.ts +2 -1
- package/src/metrics/pricing.test.ts +258 -0
- package/src/metrics/store.test.ts +227 -0
- package/src/metrics/store.ts +40 -5
- package/src/runtimes/claude.test.ts +1 -1
- package/src/runtimes/codex.test.ts +741 -0
- package/src/runtimes/codex.ts +228 -0
- package/src/runtimes/pi.test.ts +1 -1
- package/src/runtimes/registry.test.ts +6 -6
- package/src/runtimes/registry.ts +2 -0
- package/src/schema-consistency.test.ts +1 -0
- package/src/types.ts +8 -0
- package/src/worktree/tmux.test.ts +49 -0
- package/src/worktree/tmux.ts +33 -0
|
@@ -24,6 +24,7 @@ import { createEventStore } from "../events/store.ts";
|
|
|
24
24
|
import { accent, brand, color, visibleLength } from "../logging/color.ts";
|
|
25
25
|
import {
|
|
26
26
|
buildAgentColorMap,
|
|
27
|
+
extendAgentColorMap,
|
|
27
28
|
formatDuration,
|
|
28
29
|
formatEventLine,
|
|
29
30
|
formatRelativeTime,
|
|
@@ -128,10 +129,10 @@ export { pad, truncate, horizontalLine };
|
|
|
128
129
|
|
|
129
130
|
/**
|
|
130
131
|
* Compute agent panel height from screen height and agent count.
|
|
131
|
-
* min 8 rows, max floor(height * 0.
|
|
132
|
+
* min 8 rows, max floor(height * 0.35), grows with agent count (+4 for chrome).
|
|
132
133
|
*/
|
|
133
134
|
export function computeAgentPanelHeight(height: number, agentCount: number): number {
|
|
134
|
-
return Math.max(8, Math.min(Math.floor(height * 0.
|
|
135
|
+
return Math.max(8, Math.min(Math.floor(height * 0.35), agentCount + 4));
|
|
135
136
|
}
|
|
136
137
|
|
|
137
138
|
/**
|
|
@@ -241,6 +242,49 @@ export function closeDashboardStores(stores: DashboardStores): void {
|
|
|
241
242
|
}
|
|
242
243
|
}
|
|
243
244
|
|
|
245
|
+
/**
|
|
246
|
+
* Rolling event buffer with incremental dedup by lastSeenId.
|
|
247
|
+
* Maintains a fixed-size window of the most recent events.
|
|
248
|
+
*/
|
|
249
|
+
export class EventBuffer {
|
|
250
|
+
private events: StoredEvent[] = [];
|
|
251
|
+
private lastSeenId = 0;
|
|
252
|
+
private colorMap: Map<string, (s: string) => string> = new Map();
|
|
253
|
+
private readonly maxSize: number;
|
|
254
|
+
|
|
255
|
+
constructor(maxSize = 100) {
|
|
256
|
+
this.maxSize = maxSize;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
poll(eventStore: EventStore): void {
|
|
260
|
+
const since = new Date(Date.now() - 60 * 1000).toISOString();
|
|
261
|
+
const allEvents = eventStore.getTimeline({ since, limit: 1000 });
|
|
262
|
+
const newEvents = allEvents.filter((e) => e.id > this.lastSeenId);
|
|
263
|
+
|
|
264
|
+
if (newEvents.length === 0) return;
|
|
265
|
+
|
|
266
|
+
extendAgentColorMap(this.colorMap, newEvents);
|
|
267
|
+
this.events = [...this.events, ...newEvents].slice(-this.maxSize);
|
|
268
|
+
|
|
269
|
+
const lastEvent = newEvents[newEvents.length - 1];
|
|
270
|
+
if (lastEvent) {
|
|
271
|
+
this.lastSeenId = lastEvent.id;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
getEvents(): StoredEvent[] {
|
|
276
|
+
return this.events;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
getColorMap(): Map<string, (s: string) => string> {
|
|
280
|
+
return this.colorMap;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
get size(): number {
|
|
284
|
+
return this.events.length;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
244
288
|
/** Tracker data cached between dashboard ticks (10s TTL). */
|
|
245
289
|
interface TrackerCache {
|
|
246
290
|
tasks: TrackerIssue[];
|
|
@@ -263,6 +307,7 @@ interface DashboardData {
|
|
|
263
307
|
};
|
|
264
308
|
tasks: TrackerIssue[];
|
|
265
309
|
recentEvents: StoredEvent[];
|
|
310
|
+
feedColorMap: Map<string, (s: string) => string>;
|
|
266
311
|
}
|
|
267
312
|
|
|
268
313
|
/**
|
|
@@ -289,6 +334,7 @@ async function loadDashboardData(
|
|
|
289
334
|
stores: DashboardStores,
|
|
290
335
|
runId?: string | null,
|
|
291
336
|
thresholds?: { staleMs: number; zombieMs: number },
|
|
337
|
+
eventBuffer?: EventBuffer,
|
|
292
338
|
): Promise<DashboardData> {
|
|
293
339
|
// Get all sessions from the pre-opened session store
|
|
294
340
|
const allSessions = stores.sessionStore.getAll();
|
|
@@ -450,13 +496,14 @@ async function loadDashboardData(
|
|
|
450
496
|
tasks = trackerCache.tasks;
|
|
451
497
|
}
|
|
452
498
|
|
|
453
|
-
// Load recent events
|
|
499
|
+
// Load recent events via incremental buffer (or fallback to empty)
|
|
454
500
|
let recentEvents: StoredEvent[] = [];
|
|
455
|
-
|
|
501
|
+
let feedColorMap: Map<string, (s: string) => string> = new Map();
|
|
502
|
+
if (eventBuffer && stores.eventStore) {
|
|
456
503
|
try {
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
504
|
+
eventBuffer.poll(stores.eventStore);
|
|
505
|
+
recentEvents = [...eventBuffer.getEvents()].reverse();
|
|
506
|
+
feedColorMap = eventBuffer.getColorMap();
|
|
460
507
|
} catch {
|
|
461
508
|
/* best effort */
|
|
462
509
|
}
|
|
@@ -470,6 +517,7 @@ async function loadDashboardData(
|
|
|
470
517
|
metrics: { totalSessions, avgDuration, byCapability },
|
|
471
518
|
tasks,
|
|
472
519
|
recentEvents,
|
|
520
|
+
feedColorMap,
|
|
473
521
|
};
|
|
474
522
|
}
|
|
475
523
|
|
|
@@ -496,7 +544,7 @@ export function renderAgentPanel(
|
|
|
496
544
|
panelHeight: number,
|
|
497
545
|
startRow: number,
|
|
498
546
|
): string {
|
|
499
|
-
const leftWidth =
|
|
547
|
+
const leftWidth = fullWidth;
|
|
500
548
|
let output = "";
|
|
501
549
|
|
|
502
550
|
// Panel header
|
|
@@ -650,7 +698,7 @@ export function renderFeedPanel(
|
|
|
650
698
|
let output = "";
|
|
651
699
|
|
|
652
700
|
// Header
|
|
653
|
-
const headerLine = `${dimBox.vertical} ${brand.bold("Feed")} (
|
|
701
|
+
const headerLine = `${dimBox.vertical} ${brand.bold("Feed")} (live)`;
|
|
654
702
|
const headerPadding = " ".repeat(
|
|
655
703
|
Math.max(0, panelWidth - visibleLength(headerLine) - visibleLength(dimBox.vertical)),
|
|
656
704
|
);
|
|
@@ -676,7 +724,8 @@ export function renderFeedPanel(
|
|
|
676
724
|
return output;
|
|
677
725
|
}
|
|
678
726
|
|
|
679
|
-
const colorMap =
|
|
727
|
+
const colorMap =
|
|
728
|
+
data.feedColorMap.size > 0 ? data.feedColorMap : buildAgentColorMap(data.recentEvents);
|
|
680
729
|
const visibleEvents = data.recentEvents.slice(0, maxRows);
|
|
681
730
|
|
|
682
731
|
for (let i = 0; i < visibleEvents.length; i++) {
|
|
@@ -733,12 +782,10 @@ export function renderFeedPanel(
|
|
|
733
782
|
*/
|
|
734
783
|
function renderMailPanel(
|
|
735
784
|
data: DashboardData,
|
|
736
|
-
|
|
737
|
-
|
|
785
|
+
panelWidth: number,
|
|
786
|
+
panelHeight: number,
|
|
738
787
|
startRow: number,
|
|
739
788
|
): string {
|
|
740
|
-
const panelHeight = Math.floor(height * 0.3);
|
|
741
|
-
const panelWidth = Math.floor(width * 0.5);
|
|
742
789
|
let output = "";
|
|
743
790
|
|
|
744
791
|
const unreadCount = data.status.unreadMailCount;
|
|
@@ -787,13 +834,11 @@ function renderMailPanel(
|
|
|
787
834
|
*/
|
|
788
835
|
function renderMergeQueuePanel(
|
|
789
836
|
data: DashboardData,
|
|
790
|
-
|
|
791
|
-
|
|
837
|
+
panelWidth: number,
|
|
838
|
+
panelHeight: number,
|
|
792
839
|
startRow: number,
|
|
793
840
|
startCol: number,
|
|
794
841
|
): string {
|
|
795
|
-
const panelHeight = Math.floor(height * 0.3);
|
|
796
|
-
const panelWidth = width - startCol + 1;
|
|
797
842
|
let output = "";
|
|
798
843
|
|
|
799
844
|
const headerLine = `${dimBox.vertical} ${brand.bold("Merge Queue")} (${data.mergeQueue.length})`;
|
|
@@ -847,26 +892,20 @@ function renderMetricsPanel(
|
|
|
847
892
|
const separator = dimHorizontalLine(width, dimBox.tee, dimBox.teeRight);
|
|
848
893
|
output += `${CURSOR.cursorTo(startRow, 1)}${separator}\n`;
|
|
849
894
|
|
|
850
|
-
const headerLine = `${dimBox.vertical} ${brand.bold("Metrics")}`;
|
|
851
|
-
const headerPadding = " ".repeat(
|
|
852
|
-
Math.max(0, width - visibleLength(headerLine) - visibleLength(dimBox.vertical)),
|
|
853
|
-
);
|
|
854
|
-
output += `${CURSOR.cursorTo(startRow + 1, 1)}${headerLine}${headerPadding}${dimBox.vertical}\n`;
|
|
855
|
-
|
|
856
895
|
const totalSessions = data.metrics.totalSessions;
|
|
857
896
|
const avgDur = formatDuration(data.metrics.avgDuration);
|
|
858
897
|
const byCapability = Object.entries(data.metrics.byCapability)
|
|
859
898
|
.map(([cap, count]) => `${cap}:${count}`)
|
|
860
899
|
.join(", ");
|
|
861
900
|
|
|
862
|
-
const metricsLine = `${dimBox.vertical} Total
|
|
901
|
+
const metricsLine = `${dimBox.vertical} ${brand.bold("Metrics")} Total: ${totalSessions} | Avg: ${avgDur} | ${byCapability}`;
|
|
863
902
|
const metricsPadding = " ".repeat(
|
|
864
903
|
Math.max(0, width - visibleLength(metricsLine) - visibleLength(dimBox.vertical)),
|
|
865
904
|
);
|
|
866
|
-
output += `${CURSOR.cursorTo(startRow +
|
|
905
|
+
output += `${CURSOR.cursorTo(startRow + 1, 1)}${metricsLine}${metricsPadding}${dimBox.vertical}\n`;
|
|
867
906
|
|
|
868
907
|
const bottomBorder = dimHorizontalLine(width, dimBox.bottomLeft, dimBox.bottomRight);
|
|
869
|
-
output += `${CURSOR.cursorTo(startRow +
|
|
908
|
+
output += `${CURSOR.cursorTo(startRow + 2, 1)}${bottomBorder}\n`;
|
|
870
909
|
|
|
871
910
|
return output;
|
|
872
911
|
}
|
|
@@ -883,50 +922,42 @@ function renderDashboard(data: DashboardData, interval: number): void {
|
|
|
883
922
|
// Header (rows 1-2)
|
|
884
923
|
output += renderHeader(width, interval, data.currentRunId);
|
|
885
924
|
|
|
886
|
-
// Agent panel
|
|
925
|
+
// Agent panel: full width, capped at 35% of height
|
|
887
926
|
const agentPanelStart = 3;
|
|
888
|
-
|
|
889
|
-
// Dynamic agent panel height
|
|
890
927
|
const agentCount = data.status.agents.length;
|
|
891
928
|
const agentPanelHeight = computeAgentPanelHeight(height, agentCount);
|
|
929
|
+
output += renderAgentPanel(data, width, agentPanelHeight, agentPanelStart);
|
|
892
930
|
|
|
893
|
-
//
|
|
894
|
-
const
|
|
895
|
-
const
|
|
896
|
-
const
|
|
931
|
+
// Middle zone: Feed (left 60%) | Tasks (right 40%)
|
|
932
|
+
const middleStart = agentPanelStart + agentPanelHeight + 1;
|
|
933
|
+
const compactPanelHeight = 5; // fixed for mail/merge panels
|
|
934
|
+
const metricsHeight = 3; // separator + data + border
|
|
935
|
+
const middleHeight = Math.max(6, height - middleStart - compactPanelHeight - metricsHeight);
|
|
897
936
|
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
const feedHeight = agentPanelHeight - rightHalf;
|
|
937
|
+
const feedWidth = Math.floor(width * 0.6);
|
|
938
|
+
output += renderFeedPanel(data, 1, feedWidth, middleHeight, middleStart);
|
|
901
939
|
|
|
902
|
-
|
|
903
|
-
|
|
940
|
+
const taskWidth = width - feedWidth;
|
|
941
|
+
const taskStartCol = feedWidth + 1;
|
|
942
|
+
output += renderTasksPanel(data, taskStartCol, taskWidth, middleHeight, middleStart);
|
|
904
943
|
|
|
905
|
-
//
|
|
906
|
-
|
|
944
|
+
// Compact panels: Mail (left 50%) | Merge Queue (right 50%) — fixed 5 rows
|
|
945
|
+
const compactStart = middleStart + middleHeight;
|
|
946
|
+
const mailWidth = Math.floor(width * 0.5);
|
|
947
|
+
output += renderMailPanel(data, mailWidth, compactPanelHeight, compactStart);
|
|
907
948
|
|
|
908
|
-
|
|
909
|
-
|
|
949
|
+
const mergeStartCol = mailWidth + 1;
|
|
950
|
+
const mergeWidth = width - mailWidth;
|
|
951
|
+
output += renderMergeQueuePanel(
|
|
910
952
|
data,
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
953
|
+
mergeWidth,
|
|
954
|
+
compactPanelHeight,
|
|
955
|
+
compactStart,
|
|
956
|
+
mergeStartCol,
|
|
915
957
|
);
|
|
916
958
|
|
|
917
|
-
//
|
|
918
|
-
const
|
|
919
|
-
|
|
920
|
-
// Mail panel (left 50%)
|
|
921
|
-
output += renderMailPanel(data, width, height, middlePanelStart);
|
|
922
|
-
|
|
923
|
-
// Merge queue panel (right 50%)
|
|
924
|
-
const mergeQueueCol = Math.floor(width * 0.5) + 1;
|
|
925
|
-
output += renderMergeQueuePanel(data, width, height, middlePanelStart, mergeQueueCol);
|
|
926
|
-
|
|
927
|
-
// Metrics panel (bottom strip)
|
|
928
|
-
const middlePanelHeight = Math.floor(height * 0.3);
|
|
929
|
-
const metricsStart = middlePanelStart + middlePanelHeight + 1;
|
|
959
|
+
// Metrics footer
|
|
960
|
+
const metricsStart = compactStart + compactPanelHeight;
|
|
930
961
|
output += renderMetricsPanel(data, width, height, metricsStart);
|
|
931
962
|
|
|
932
963
|
process.stdout.write(output);
|
|
@@ -963,6 +994,9 @@ async function executeDashboard(opts: DashboardOpts): Promise<void> {
|
|
|
963
994
|
// Open stores once for the entire poll loop lifetime
|
|
964
995
|
const stores = openDashboardStores(root);
|
|
965
996
|
|
|
997
|
+
// Create rolling event buffer (persisted across poll ticks)
|
|
998
|
+
const eventBuffer = new EventBuffer(100);
|
|
999
|
+
|
|
966
1000
|
// Compute health thresholds once from config (reused across poll ticks)
|
|
967
1001
|
const thresholds = {
|
|
968
1002
|
staleMs: config.watchdog.staleThresholdMs,
|
|
@@ -984,7 +1018,7 @@ async function executeDashboard(opts: DashboardOpts): Promise<void> {
|
|
|
984
1018
|
|
|
985
1019
|
// Poll loop
|
|
986
1020
|
while (running) {
|
|
987
|
-
const data = await loadDashboardData(root, stores, runId, thresholds);
|
|
1021
|
+
const data = await loadDashboardData(root, stores, runId, thresholds, eventBuffer);
|
|
988
1022
|
renderDashboard(data, interval);
|
|
989
1023
|
await Bun.sleep(interval);
|
|
990
1024
|
}
|
package/src/commands/log.ts
CHANGED
|
@@ -583,6 +583,7 @@ async function runLog(opts: {
|
|
|
583
583
|
const cost = estimateCost(usage);
|
|
584
584
|
const metricsDbPath = join(config.project.root, ".overstory", "metrics.db");
|
|
585
585
|
const metricsStore = createMetricsStore(metricsDbPath);
|
|
586
|
+
const agentSession = getAgentSession(config.project.root, opts.agent);
|
|
586
587
|
metricsStore.recordSnapshot({
|
|
587
588
|
agentName: opts.agent,
|
|
588
589
|
inputTokens: usage.inputTokens,
|
|
@@ -591,6 +592,7 @@ async function runLog(opts: {
|
|
|
591
592
|
cacheCreationTokens: usage.cacheCreationTokens,
|
|
592
593
|
estimatedCostUsd: cost,
|
|
593
594
|
modelUsed: usage.modelUsed,
|
|
595
|
+
runId: agentSession?.runId ?? null,
|
|
594
596
|
createdAt: new Date().toISOString(),
|
|
595
597
|
});
|
|
596
598
|
metricsStore.close();
|
|
@@ -1,8 +1,13 @@
|
|
|
1
|
-
import { describe, expect, test } from "bun:test";
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { realpathSync } from "node:fs";
|
|
3
|
+
import { mkdtemp } from "node:fs/promises";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
2
6
|
import { resolveModel, resolveProviderEnv } from "../agents/manifest.ts";
|
|
3
7
|
import { HierarchyError } from "../errors.ts";
|
|
4
8
|
import { ClaudeRuntime } from "../runtimes/claude.ts";
|
|
5
9
|
import { getRuntime } from "../runtimes/registry.ts";
|
|
10
|
+
import { cleanupTempDir, createTempGitRepo } from "../test-helpers.ts";
|
|
6
11
|
import type { AgentManifest, OverstoryConfig } from "../types.ts";
|
|
7
12
|
import {
|
|
8
13
|
type AutoDispatchOptions,
|
|
@@ -15,6 +20,7 @@ import {
|
|
|
15
20
|
checkRunSessionLimit,
|
|
16
21
|
checkTaskLock,
|
|
17
22
|
extractMulchRecordIds,
|
|
23
|
+
getCurrentBranch,
|
|
18
24
|
inferDomainsFromFiles,
|
|
19
25
|
isRunningAsRoot,
|
|
20
26
|
parentHasScouts,
|
|
@@ -1274,3 +1280,59 @@ describe("extractMulchRecordIds", () => {
|
|
|
1274
1280
|
expect(result).toContainEqual({ id: "mx-2ce43d", domain: "typescript" });
|
|
1275
1281
|
});
|
|
1276
1282
|
});
|
|
1283
|
+
|
|
1284
|
+
describe("getCurrentBranch", () => {
|
|
1285
|
+
let repoDir: string;
|
|
1286
|
+
|
|
1287
|
+
beforeEach(async () => {
|
|
1288
|
+
repoDir = realpathSync(await createTempGitRepo());
|
|
1289
|
+
});
|
|
1290
|
+
|
|
1291
|
+
afterEach(async () => {
|
|
1292
|
+
await cleanupTempDir(repoDir);
|
|
1293
|
+
});
|
|
1294
|
+
|
|
1295
|
+
test("returns the current branch name", async () => {
|
|
1296
|
+
const branch = await getCurrentBranch(repoDir);
|
|
1297
|
+
expect(branch).toMatch(/^(main|master)$/);
|
|
1298
|
+
});
|
|
1299
|
+
|
|
1300
|
+
test("returns feature branch name after checkout", async () => {
|
|
1301
|
+
const proc = Bun.spawn(["git", "checkout", "-b", "feature/test-branch"], {
|
|
1302
|
+
cwd: repoDir,
|
|
1303
|
+
stdout: "pipe",
|
|
1304
|
+
stderr: "pipe",
|
|
1305
|
+
});
|
|
1306
|
+
await proc.exited;
|
|
1307
|
+
const branch = await getCurrentBranch(repoDir);
|
|
1308
|
+
expect(branch).toBe("feature/test-branch");
|
|
1309
|
+
});
|
|
1310
|
+
|
|
1311
|
+
test("returns null for detached HEAD", async () => {
|
|
1312
|
+
const hashProc = Bun.spawn(["git", "rev-parse", "HEAD"], {
|
|
1313
|
+
cwd: repoDir,
|
|
1314
|
+
stdout: "pipe",
|
|
1315
|
+
stderr: "pipe",
|
|
1316
|
+
});
|
|
1317
|
+
const hash = (await new Response(hashProc.stdout).text()).trim();
|
|
1318
|
+
await hashProc.exited;
|
|
1319
|
+
const proc = Bun.spawn(["git", "checkout", hash], {
|
|
1320
|
+
cwd: repoDir,
|
|
1321
|
+
stdout: "pipe",
|
|
1322
|
+
stderr: "pipe",
|
|
1323
|
+
});
|
|
1324
|
+
await proc.exited;
|
|
1325
|
+
const branch = await getCurrentBranch(repoDir);
|
|
1326
|
+
expect(branch).toBeNull();
|
|
1327
|
+
});
|
|
1328
|
+
|
|
1329
|
+
test("returns null for non-git directory", async () => {
|
|
1330
|
+
const tmpDir = realpathSync(await mkdtemp(join(tmpdir(), "overstory-notgit-")));
|
|
1331
|
+
try {
|
|
1332
|
+
const branch = await getCurrentBranch(tmpDir);
|
|
1333
|
+
expect(branch).toBeNull();
|
|
1334
|
+
} finally {
|
|
1335
|
+
await cleanupTempDir(tmpDir);
|
|
1336
|
+
}
|
|
1337
|
+
});
|
|
1338
|
+
});
|
package/src/commands/sling.ts
CHANGED
|
@@ -124,6 +124,7 @@ export interface SlingOptions {
|
|
|
124
124
|
dispatchMaxAgents?: string;
|
|
125
125
|
runtime?: string;
|
|
126
126
|
noScoutCheck?: boolean;
|
|
127
|
+
baseBranch?: string;
|
|
127
128
|
}
|
|
128
129
|
|
|
129
130
|
export interface AutoDispatchOptions {
|
|
@@ -389,6 +390,28 @@ export function extractMulchRecordIds(primeText: string): Array<{ id: string; do
|
|
|
389
390
|
return results;
|
|
390
391
|
}
|
|
391
392
|
|
|
393
|
+
/**
|
|
394
|
+
* Get the current git branch name for the repo at the given path.
|
|
395
|
+
*
|
|
396
|
+
* Returns null if in detached HEAD state, the directory is not a git repo,
|
|
397
|
+
* or git exits non-zero.
|
|
398
|
+
*
|
|
399
|
+
* @param repoRoot - Absolute path to the git repository root
|
|
400
|
+
*/
|
|
401
|
+
export async function getCurrentBranch(repoRoot: string): Promise<string | null> {
|
|
402
|
+
const proc = Bun.spawn(["git", "rev-parse", "--abbrev-ref", "HEAD"], {
|
|
403
|
+
cwd: repoRoot,
|
|
404
|
+
stdout: "pipe",
|
|
405
|
+
stderr: "pipe",
|
|
406
|
+
});
|
|
407
|
+
const [stdout, exitCode] = await Promise.all([new Response(proc.stdout).text(), proc.exited]);
|
|
408
|
+
if (exitCode !== 0) return null;
|
|
409
|
+
const branch = stdout.trim();
|
|
410
|
+
// "HEAD" is returned when in detached HEAD state
|
|
411
|
+
if (branch === "HEAD" || branch === "") return null;
|
|
412
|
+
return branch;
|
|
413
|
+
}
|
|
414
|
+
|
|
392
415
|
/**
|
|
393
416
|
* Entry point for `ov sling <task-id> [flags]`.
|
|
394
417
|
*
|
|
@@ -658,11 +681,17 @@ export async function slingCommand(taskId: string, opts: SlingOptions): Promise<
|
|
|
658
681
|
const worktreeBaseDir = join(config.project.root, config.worktrees.baseDir);
|
|
659
682
|
await mkdir(worktreeBaseDir, { recursive: true });
|
|
660
683
|
|
|
684
|
+
// Resolve base branch: --base-branch flag > current HEAD > config.project.canonicalBranch
|
|
685
|
+
const baseBranch =
|
|
686
|
+
opts.baseBranch ??
|
|
687
|
+
(await getCurrentBranch(config.project.root)) ??
|
|
688
|
+
config.project.canonicalBranch;
|
|
689
|
+
|
|
661
690
|
const { path: worktreePath, branch: branchName } = await createWorktree({
|
|
662
691
|
repoRoot: config.project.root,
|
|
663
692
|
baseDir: worktreeBaseDir,
|
|
664
693
|
agentName: name,
|
|
665
|
-
baseBranch
|
|
694
|
+
baseBranch,
|
|
666
695
|
taskId: taskId,
|
|
667
696
|
});
|
|
668
697
|
|
|
@@ -862,7 +891,13 @@ export async function slingCommand(taskId: string, opts: SlingOptions): Promise<
|
|
|
862
891
|
runStore.close();
|
|
863
892
|
}
|
|
864
893
|
|
|
865
|
-
// 13b.
|
|
894
|
+
// 13b. Give slow shells time to finish initializing before polling for TUI readiness.
|
|
895
|
+
const shellDelay = config.runtime?.shellInitDelayMs ?? 0;
|
|
896
|
+
if (shellDelay > 0) {
|
|
897
|
+
await Bun.sleep(shellDelay);
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
// Wait for Claude Code TUI to render before sending input.
|
|
866
901
|
// Polling capture-pane is more reliable than a fixed sleep because
|
|
867
902
|
// TUI init time varies by machine load and model state.
|
|
868
903
|
await waitForTuiReady(tmuxSessionName, (content) => runtime.detectReady(content));
|
package/src/config.test.ts
CHANGED
|
@@ -775,6 +775,74 @@ project:
|
|
|
775
775
|
await expect(loadConfig(tempDir)).rejects.toThrow(ValidationError);
|
|
776
776
|
});
|
|
777
777
|
|
|
778
|
+
test("resets negative shellInitDelayMs to 0 with warning", async () => {
|
|
779
|
+
await writeConfig("runtime:\n shellInitDelayMs: -100\n");
|
|
780
|
+
const origWrite = process.stderr.write;
|
|
781
|
+
let capturedStderr = "";
|
|
782
|
+
process.stderr.write = ((s: string | Uint8Array) => {
|
|
783
|
+
if (typeof s === "string") capturedStderr += s;
|
|
784
|
+
return true;
|
|
785
|
+
}) as typeof process.stderr.write;
|
|
786
|
+
try {
|
|
787
|
+
const config = await loadConfig(tempDir);
|
|
788
|
+
expect(config.runtime?.shellInitDelayMs).toBe(0);
|
|
789
|
+
} finally {
|
|
790
|
+
process.stderr.write = origWrite;
|
|
791
|
+
}
|
|
792
|
+
expect(capturedStderr).toContain("WARNING: runtime.shellInitDelayMs");
|
|
793
|
+
});
|
|
794
|
+
|
|
795
|
+
test("resets Infinity shellInitDelayMs to 0 with warning", async () => {
|
|
796
|
+
await writeConfig("runtime:\n shellInitDelayMs: .inf\n");
|
|
797
|
+
const origWrite = process.stderr.write;
|
|
798
|
+
let capturedStderr = "";
|
|
799
|
+
process.stderr.write = ((s: string | Uint8Array) => {
|
|
800
|
+
if (typeof s === "string") capturedStderr += s;
|
|
801
|
+
return true;
|
|
802
|
+
}) as typeof process.stderr.write;
|
|
803
|
+
try {
|
|
804
|
+
const config = await loadConfig(tempDir);
|
|
805
|
+
expect(config.runtime?.shellInitDelayMs).toBe(0);
|
|
806
|
+
} finally {
|
|
807
|
+
process.stderr.write = origWrite;
|
|
808
|
+
}
|
|
809
|
+
expect(capturedStderr).toContain("WARNING: runtime.shellInitDelayMs");
|
|
810
|
+
});
|
|
811
|
+
|
|
812
|
+
test("warns when shellInitDelayMs exceeds 30s", async () => {
|
|
813
|
+
await writeConfig("runtime:\n shellInitDelayMs: 60000\n");
|
|
814
|
+
const origWrite = process.stderr.write;
|
|
815
|
+
let capturedStderr = "";
|
|
816
|
+
process.stderr.write = ((s: string | Uint8Array) => {
|
|
817
|
+
if (typeof s === "string") capturedStderr += s;
|
|
818
|
+
return true;
|
|
819
|
+
}) as typeof process.stderr.write;
|
|
820
|
+
try {
|
|
821
|
+
const config = await loadConfig(tempDir);
|
|
822
|
+
expect(config.runtime?.shellInitDelayMs).toBe(60000);
|
|
823
|
+
} finally {
|
|
824
|
+
process.stderr.write = origWrite;
|
|
825
|
+
}
|
|
826
|
+
expect(capturedStderr).toContain("WARNING: runtime.shellInitDelayMs is 60000ms");
|
|
827
|
+
});
|
|
828
|
+
|
|
829
|
+
test("accepts valid shellInitDelayMs without warning", async () => {
|
|
830
|
+
await writeConfig("runtime:\n shellInitDelayMs: 2000\n");
|
|
831
|
+
const origWrite = process.stderr.write;
|
|
832
|
+
let capturedStderr = "";
|
|
833
|
+
process.stderr.write = ((s: string | Uint8Array) => {
|
|
834
|
+
if (typeof s === "string") capturedStderr += s;
|
|
835
|
+
return true;
|
|
836
|
+
}) as typeof process.stderr.write;
|
|
837
|
+
try {
|
|
838
|
+
const config = await loadConfig(tempDir);
|
|
839
|
+
expect(config.runtime?.shellInitDelayMs).toBe(2000);
|
|
840
|
+
} finally {
|
|
841
|
+
process.stderr.write = origWrite;
|
|
842
|
+
}
|
|
843
|
+
expect(capturedStderr).not.toContain("shellInitDelayMs");
|
|
844
|
+
});
|
|
845
|
+
|
|
778
846
|
test("rejects qualityGate with empty description", async () => {
|
|
779
847
|
await writeConfig(`
|
|
780
848
|
project:
|
package/src/config.ts
CHANGED
|
@@ -64,6 +64,7 @@ export const DEFAULT_CONFIG: OverstoryConfig = {
|
|
|
64
64
|
},
|
|
65
65
|
runtime: {
|
|
66
66
|
default: "claude",
|
|
67
|
+
shellInitDelayMs: 0,
|
|
67
68
|
pi: {
|
|
68
69
|
provider: "anthropic",
|
|
69
70
|
modelMap: {
|
|
@@ -664,6 +665,21 @@ function validateConfig(config: OverstoryConfig): void {
|
|
|
664
665
|
}
|
|
665
666
|
}
|
|
666
667
|
|
|
668
|
+
// runtime.shellInitDelayMs: validate if present
|
|
669
|
+
if (config.runtime?.shellInitDelayMs !== undefined) {
|
|
670
|
+
const delay = config.runtime.shellInitDelayMs;
|
|
671
|
+
if (typeof delay !== "number" || delay < 0 || !Number.isFinite(delay)) {
|
|
672
|
+
process.stderr.write(
|
|
673
|
+
`[overstory] WARNING: runtime.shellInitDelayMs must be a non-negative number. Got: ${delay}. Using default (0).\n`,
|
|
674
|
+
);
|
|
675
|
+
config.runtime.shellInitDelayMs = 0;
|
|
676
|
+
} else if (delay > 30_000) {
|
|
677
|
+
process.stderr.write(
|
|
678
|
+
`[overstory] WARNING: runtime.shellInitDelayMs is ${delay}ms (>${30}s). This adds delay before every agent spawn. Consider a lower value.\n`,
|
|
679
|
+
);
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
|
|
667
683
|
// models: validate each value — accepts aliases and provider-prefixed refs
|
|
668
684
|
const validAliases = ["sonnet", "opus", "haiku"];
|
|
669
685
|
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.7.
|
|
48
|
+
export const VERSION = "0.7.8";
|
|
49
49
|
|
|
50
50
|
const rawArgs = process.argv.slice(2);
|
|
51
51
|
|
|
@@ -267,6 +267,7 @@ program
|
|
|
267
267
|
.option("--no-scout-check", "Suppress the parentHasScouts scout-before-build warning")
|
|
268
268
|
.option("--dispatch-max-agents <n>", "Per-lead max agents ceiling (injected into overlay)")
|
|
269
269
|
.option("--runtime <name>", "Runtime adapter (default: config or claude)")
|
|
270
|
+
.option("--base-branch <branch>", "Base branch for worktree creation (default: current HEAD)")
|
|
270
271
|
.option("--json", "Output result as JSON")
|
|
271
272
|
.action(async (taskId, opts) => {
|
|
272
273
|
await slingCommand(taskId, opts);
|