@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.
@@ -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.5), grows with agent count (+4 for chrome).
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.5), agentCount + 4));
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 from event store
499
+ // Load recent events via incremental buffer (or fallback to empty)
454
500
  let recentEvents: StoredEvent[] = [];
455
- if (stores.eventStore) {
501
+ let feedColorMap: Map<string, (s: string) => string> = new Map();
502
+ if (eventBuffer && stores.eventStore) {
456
503
  try {
457
- const fiveMinAgo = new Date(Date.now() - 5 * 60 * 1000).toISOString();
458
- const events = stores.eventStore.getTimeline({ since: fiveMinAgo, limit: 20 });
459
- recentEvents = [...events].reverse(); // most recent first
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 = Math.floor(fullWidth * 0.6);
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")} (last 5 min)`;
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 = buildAgentColorMap(data.recentEvents);
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
- width: number,
737
- height: number,
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
- width: number,
791
- height: number,
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 sessions: ${totalSessions} | Avg duration: ${avgDur} | By capability: ${byCapability}`;
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 + 2, 1)}${metricsLine}${metricsPadding}${dimBox.vertical}\n`;
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 + 3, 1)}${bottomBorder}\n`;
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 start row
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
- // Column widths
894
- const leftWidth = Math.floor(width * 0.6);
895
- const rightWidth = width - leftWidth;
896
- const rightStartCol = leftWidth + 1;
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
- // Right column split (Tasks upper / Feed lower)
899
- const rightHalf = Math.floor(agentPanelHeight / 2);
900
- const feedHeight = agentPanelHeight - rightHalf;
937
+ const feedWidth = Math.floor(width * 0.6);
938
+ output += renderFeedPanel(data, 1, feedWidth, middleHeight, middleStart);
901
939
 
902
- // Render left: agents (60% wide, dynamic height)
903
- output += renderAgentPanel(data, width, agentPanelHeight, agentPanelStart);
940
+ const taskWidth = width - feedWidth;
941
+ const taskStartCol = feedWidth + 1;
942
+ output += renderTasksPanel(data, taskStartCol, taskWidth, middleHeight, middleStart);
904
943
 
905
- // Render right-upper: tasks
906
- output += renderTasksPanel(data, rightStartCol, rightWidth, rightHalf, agentPanelStart);
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
- // Render right-lower: feed
909
- output += renderFeedPanel(
949
+ const mergeStartCol = mailWidth + 1;
950
+ const mergeWidth = width - mailWidth;
951
+ output += renderMergeQueuePanel(
910
952
  data,
911
- rightStartCol,
912
- rightWidth,
913
- feedHeight,
914
- agentPanelStart + rightHalf,
953
+ mergeWidth,
954
+ compactPanelHeight,
955
+ compactStart,
956
+ mergeStartCol,
915
957
  );
916
958
 
917
- // Middle panels (mail/merge) start after agent block
918
- const middlePanelStart = agentPanelStart + agentPanelHeight + 1;
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
  }
@@ -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
+ });
@@ -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: config.project.canonicalBranch,
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. Wait for Claude Code TUI to render before sending input.
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));
@@ -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.6";
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);