@os-eco/overstory-cli 0.7.9 → 0.8.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/README.md +16 -7
  2. package/agents/coordinator.md +41 -0
  3. package/agents/orchestrator.md +239 -0
  4. package/package.json +1 -1
  5. package/src/agents/guard-rules.test.ts +372 -0
  6. package/src/commands/coordinator.test.ts +334 -0
  7. package/src/commands/coordinator.ts +366 -0
  8. package/src/commands/dashboard.test.ts +86 -0
  9. package/src/commands/dashboard.ts +8 -4
  10. package/src/commands/feed.test.ts +8 -0
  11. package/src/commands/init.test.ts +2 -1
  12. package/src/commands/init.ts +2 -2
  13. package/src/commands/inspect.test.ts +156 -1
  14. package/src/commands/inspect.ts +19 -4
  15. package/src/commands/replay.test.ts +8 -0
  16. package/src/commands/sling.ts +218 -121
  17. package/src/commands/status.test.ts +77 -0
  18. package/src/commands/status.ts +6 -3
  19. package/src/commands/stop.test.ts +134 -0
  20. package/src/commands/stop.ts +41 -11
  21. package/src/commands/trace.test.ts +8 -0
  22. package/src/commands/update.test.ts +465 -0
  23. package/src/commands/update.ts +263 -0
  24. package/src/config.test.ts +65 -1
  25. package/src/config.ts +23 -0
  26. package/src/e2e/init-sling-lifecycle.test.ts +3 -2
  27. package/src/index.ts +21 -2
  28. package/src/logging/theme.ts +4 -0
  29. package/src/runtimes/connections.test.ts +74 -0
  30. package/src/runtimes/connections.ts +34 -0
  31. package/src/runtimes/registry.test.ts +1 -1
  32. package/src/runtimes/registry.ts +2 -0
  33. package/src/runtimes/sapling.test.ts +1237 -0
  34. package/src/runtimes/sapling.ts +698 -0
  35. package/src/runtimes/types.ts +45 -0
  36. package/src/types.ts +5 -1
  37. package/src/watchdog/daemon.ts +34 -0
  38. package/src/watchdog/health.test.ts +102 -0
  39. package/src/watchdog/health.ts +140 -69
  40. package/src/worktree/process.test.ts +101 -0
  41. package/src/worktree/process.ts +111 -0
  42. package/src/worktree/tmux.ts +5 -0
@@ -18,7 +18,7 @@ import { createMetricsStore } from "../metrics/store.ts";
18
18
  import { createSessionStore } from "../sessions/store.ts";
19
19
  import { cleanupTempDir } from "../test-helpers.ts";
20
20
  import type { InsertEvent, SessionMetrics } from "../types.ts";
21
- import { gatherInspectData, inspectCommand } from "./inspect.ts";
21
+ import { gatherInspectData, inspectCommand, printInspectData } from "./inspect.ts";
22
22
 
23
23
  /** Helper to create an InsertEvent with sensible defaults. */
24
24
  function makeEvent(overrides: Partial<InsertEvent> = {}): InsertEvent {
@@ -565,6 +565,161 @@ describe("inspectCommand", () => {
565
565
  });
566
566
  });
567
567
 
568
+ // === Headless agent support ===
569
+
570
+ describe("headless agent support", () => {
571
+ test("gatherInspectData skips tmux capture for headless agents (empty tmuxSession)", async () => {
572
+ const overstoryDir = join(tempDir, ".overstory");
573
+ const sessionsDbPath = join(overstoryDir, "sessions.db");
574
+ const store = createSessionStore(sessionsDbPath);
575
+ store.upsert({
576
+ id: "sess-h1",
577
+ agentName: "headless-agent",
578
+ capability: "builder",
579
+ worktreePath: "/tmp/wt",
580
+ branchName: "overstory/headless/task-1",
581
+ taskId: "overstory-h01",
582
+ tmuxSession: "", // headless
583
+ state: "working",
584
+ pid: process.pid,
585
+ parentAgent: null,
586
+ depth: 0,
587
+ runId: null,
588
+ startedAt: new Date().toISOString(),
589
+ lastActivity: new Date().toISOString(),
590
+ escalationLevel: 0,
591
+ stalledSince: null,
592
+ transcriptPath: null,
593
+ });
594
+ store.close();
595
+
596
+ // noTmux=false but tmuxSession="" — should skip tmux capture without error
597
+ const data = await gatherInspectData(tempDir, "headless-agent", { noTmux: false });
598
+ // tmuxOutput is null (no tmux) and no events yet → no fallback either
599
+ expect(data.session.agentName).toBe("headless-agent");
600
+ expect(data.session.tmuxSession).toBe("");
601
+ // tmuxOutput may be null (no events) or a string (fallback) — must not throw
602
+ });
603
+
604
+ test("gatherInspectData provides event-based output for headless agents with tool calls", async () => {
605
+ const overstoryDir = join(tempDir, ".overstory");
606
+ const sessionsDbPath = join(overstoryDir, "sessions.db");
607
+ const eventsDbPath = join(overstoryDir, "events.db");
608
+
609
+ const store = createSessionStore(sessionsDbPath);
610
+ store.upsert({
611
+ id: "sess-h2",
612
+ agentName: "headless-events",
613
+ capability: "builder",
614
+ worktreePath: "/tmp/wt",
615
+ branchName: "overstory/headless/task-2",
616
+ taskId: "overstory-h02",
617
+ tmuxSession: "", // headless
618
+ state: "working",
619
+ pid: process.pid,
620
+ parentAgent: null,
621
+ depth: 0,
622
+ runId: null,
623
+ startedAt: new Date().toISOString(),
624
+ lastActivity: new Date().toISOString(),
625
+ escalationLevel: 0,
626
+ stalledSince: null,
627
+ transcriptPath: null,
628
+ });
629
+ store.close();
630
+
631
+ const eventStore = createEventStore(eventsDbPath);
632
+ eventStore.insert(
633
+ makeEvent({ agentName: "headless-events", toolName: "Read", toolDurationMs: 50 }),
634
+ );
635
+ eventStore.insert(
636
+ makeEvent({ agentName: "headless-events", toolName: "Edit", toolDurationMs: 100 }),
637
+ );
638
+ eventStore.close();
639
+
640
+ const data = await gatherInspectData(tempDir, "headless-events", { noTmux: false });
641
+
642
+ // Should have fallback output
643
+ expect(data.tmuxOutput).not.toBeNull();
644
+ expect(data.tmuxOutput).toContain("Headless agent");
645
+ expect(data.tmuxOutput).toContain("Read");
646
+ });
647
+
648
+ test("printInspectData shows PID instead of tmux session for headless agents", () => {
649
+ const data = {
650
+ session: {
651
+ id: "sess-h3",
652
+ agentName: "headless-display",
653
+ capability: "builder",
654
+ worktreePath: "/tmp/wt",
655
+ branchName: "overstory/headless/task-3",
656
+ taskId: "overstory-h03",
657
+ tmuxSession: "", // headless
658
+ state: "working" as const,
659
+ pid: 99999,
660
+ parentAgent: null,
661
+ depth: 0,
662
+ runId: null,
663
+ startedAt: new Date().toISOString(),
664
+ lastActivity: new Date().toISOString(),
665
+ escalationLevel: 0,
666
+ stalledSince: null,
667
+ transcriptPath: null,
668
+ },
669
+ timeSinceLastActivity: 5000,
670
+ recentToolCalls: [],
671
+ currentFile: null,
672
+ toolStats: [],
673
+ tokenUsage: null,
674
+ tmuxOutput: null,
675
+ };
676
+
677
+ printInspectData(data);
678
+
679
+ const out = output();
680
+ expect(out).toContain("Process: PID");
681
+ expect(out).toContain("99999");
682
+ expect(out).toContain("headless");
683
+ expect(out).not.toContain("Tmux:");
684
+ });
685
+
686
+ test("printInspectData shows Recent Activity header for headless agents with tmuxOutput", () => {
687
+ const data = {
688
+ session: {
689
+ id: "sess-h4",
690
+ agentName: "headless-activity",
691
+ capability: "builder",
692
+ worktreePath: "/tmp/wt",
693
+ branchName: "overstory/headless/task-4",
694
+ taskId: "overstory-h04",
695
+ tmuxSession: "", // headless
696
+ state: "working" as const,
697
+ pid: 99998,
698
+ parentAgent: null,
699
+ depth: 0,
700
+ runId: null,
701
+ startedAt: new Date().toISOString(),
702
+ lastActivity: new Date().toISOString(),
703
+ escalationLevel: 0,
704
+ stalledSince: null,
705
+ transcriptPath: null,
706
+ },
707
+ timeSinceLastActivity: 5000,
708
+ recentToolCalls: [],
709
+ currentFile: null,
710
+ toolStats: [],
711
+ tokenUsage: null,
712
+ tmuxOutput: "[Headless agent — showing recent tool events]",
713
+ };
714
+
715
+ printInspectData(data);
716
+
717
+ const out = output();
718
+ expect(out).toContain("Recent Activity (headless)");
719
+ expect(out).not.toContain("Live Tmux Output");
720
+ });
721
+ });
722
+
568
723
  // === Human-readable output ===
569
724
 
570
725
  describe("human-readable output", () => {
@@ -193,13 +193,24 @@ export async function gatherInspectData(
193
193
  }
194
194
  }
195
195
 
196
- // tmux capture
196
+ // tmux capture (skipped for headless agents where tmuxSession is empty)
197
197
  let tmuxOutput: string | null = null;
198
198
  if (!opts.noTmux && session.tmuxSession) {
199
199
  const lines = opts.tmuxLines ?? 30;
200
200
  tmuxOutput = await captureTmux(session.tmuxSession, lines);
201
201
  }
202
202
 
203
+ // Headless fallback: show recent events as live output when no tmux
204
+ if (!tmuxOutput && session.tmuxSession === "" && recentToolCalls.length > 0) {
205
+ const lines: string[] = ["[Headless agent — showing recent tool events]", ""];
206
+ for (const call of recentToolCalls.slice(0, 15)) {
207
+ const time = new Date(call.timestamp).toLocaleTimeString();
208
+ const dur = call.durationMs !== null ? `${call.durationMs}ms` : "pending";
209
+ lines.push(` [${time}] ${call.toolName.padEnd(15)} ${dur}`);
210
+ }
211
+ tmuxOutput = lines.join("\n");
212
+ }
213
+
203
214
  return {
204
215
  session,
205
216
  timeSinceLastActivity,
@@ -233,7 +244,11 @@ export function printInspectData(data: InspectData): void {
233
244
  w(`Parent: ${accent(session.parentAgent)} (depth: ${session.depth})\n`);
234
245
  }
235
246
  w(`Started: ${session.startedAt}\n`);
236
- w(`Tmux: ${accent(session.tmuxSession)}\n`);
247
+ if (session.tmuxSession) {
248
+ w(`Tmux: ${accent(session.tmuxSession)}\n`);
249
+ } else if (session.pid !== null) {
250
+ w(`Process: PID ${accent(String(session.pid))} (headless)\n`);
251
+ }
237
252
  w("\n");
238
253
 
239
254
  // Current file
@@ -287,9 +302,9 @@ export function printInspectData(data: InspectData): void {
287
302
  w("\n");
288
303
  }
289
304
 
290
- // tmux output
305
+ // tmux output (or headless fallback)
291
306
  if (data.tmuxOutput) {
292
- w("Live Tmux Output\n");
307
+ w(data.session.tmuxSession ? "Live Tmux Output\n" : "Recent Activity (headless)\n");
293
308
  w(`${separator()}\n`);
294
309
  w(`${data.tmuxOutput}\n`);
295
310
  w(`${separator()}\n`);
@@ -701,6 +701,10 @@ describe("replayCommand", () => {
701
701
  "spawn",
702
702
  "error",
703
703
  "custom",
704
+ "turn_start",
705
+ "turn_end",
706
+ "progress",
707
+ "result",
704
708
  ] as const;
705
709
  for (const eventType of eventTypes) {
706
710
  store.insert(
@@ -724,6 +728,10 @@ describe("replayCommand", () => {
724
728
  expect(out).toContain("SPAWN");
725
729
  expect(out).toContain("ERROR");
726
730
  expect(out).toContain("CUSTOM");
731
+ expect(out).toContain("TURN START");
732
+ expect(out).toContain("TURN END");
733
+ expect(out).toContain("PROGRESS");
734
+ expect(out).toContain("RESULT");
727
735
  });
728
736
 
729
737
  test("long data values are truncated", async () => {
@@ -18,6 +18,7 @@
18
18
  * 14. Return AgentSession
19
19
  */
20
20
 
21
+ import { mkdirSync } from "node:fs";
21
22
  import { mkdir } from "node:fs/promises";
22
23
  import { join, resolve } from "node:path";
23
24
  import { createIdentity, loadIdentity } from "../agents/identity.ts";
@@ -31,6 +32,7 @@ import { printSuccess } from "../logging/color.ts";
31
32
  import { createMailClient } from "../mail/client.ts";
32
33
  import { createMailStore } from "../mail/store.ts";
33
34
  import { createMulchClient } from "../mulch/client.ts";
35
+ import { setConnection } from "../runtimes/connections.ts";
34
36
  import { getRuntime } from "../runtimes/registry.ts";
35
37
  import { openSessionStore } from "../sessions/compat.ts";
36
38
  import { createRunStore } from "../sessions/store.ts";
@@ -38,6 +40,7 @@ import type { TrackerIssue } from "../tracker/factory.ts";
38
40
  import { createTrackerClient, resolveBackend, trackerCliName } from "../tracker/factory.ts";
39
41
  import type { AgentSession, OverlayConfig } from "../types.ts";
40
42
  import { createWorktree } from "../worktree/manager.ts";
43
+ import { spawnHeadlessAgent } from "../worktree/process.ts";
41
44
  import {
42
45
  capturePaneContent,
43
46
  createSession,
@@ -836,142 +839,236 @@ export async function slingCommand(taskId: string, opts: SlingOptions): Promise<
836
839
  }
837
840
  }
838
841
 
839
- // 11c. Preflight: verify tmux is available before attempting session creation
840
- await ensureTmuxAvailable();
842
+ // 11c. Spawn: headless runtimes bypass tmux entirely; tmux path is unchanged.
843
+ if (runtime.headless === true && runtime.buildDirectSpawn) {
844
+ const directEnv = {
845
+ ...runtime.buildEnv(resolvedModel),
846
+ OVERSTORY_AGENT_NAME: name,
847
+ OVERSTORY_WORKTREE_PATH: worktreePath,
848
+ };
849
+ const argv = runtime.buildDirectSpawn({
850
+ cwd: worktreePath,
851
+ env: directEnv,
852
+ model: resolvedModel.model,
853
+ instructionPath: runtime.instructionPath,
854
+ });
855
+
856
+ // Create a timestamped log dir for this headless agent session.
857
+ // Redirecting stdout/stderr to files prevents OS pipe buffer backpressure:
858
+ // when nobody reads the pipe, the child blocks on write() after ~64 KB and
859
+ // becomes a zombie. File writes have no such limit.
860
+ //
861
+ // Exception: RPC-capable runtimes need a live stdout pipe to receive
862
+ // JSON-RPC 2.0 responses (getState). In that case stdoutFile is omitted
863
+ // and the caller consumes the stream via the RuntimeConnection.
864
+ const hasRpcConnect = typeof runtime.connect === "function";
865
+ const logTimestamp = new Date().toISOString().replace(/[:.]/g, "-");
866
+ const agentLogDir = join(overstoryDir, "logs", name, logTimestamp);
867
+ mkdirSync(agentLogDir, { recursive: true });
868
+
869
+ const headlessProc = await spawnHeadlessAgent(argv, {
870
+ cwd: worktreePath,
871
+ env: { ...(process.env as Record<string, string>), ...directEnv },
872
+ stdoutFile: hasRpcConnect ? undefined : join(agentLogDir, "stdout.log"),
873
+ stderrFile: join(agentLogDir, "stderr.log"),
874
+ });
875
+
876
+ // Wire up RPC connection for runtimes that support it (e.g., Sapling).
877
+ // The connection is stored in the module-level registry so the watchdog
878
+ // and other subsystems can call getState() for health checks.
879
+ if (hasRpcConnect && headlessProc.stdout && runtime.connect) {
880
+ const connection = runtime.connect({
881
+ stdin: headlessProc.stdin,
882
+ stdout: headlessProc.stdout,
883
+ });
884
+ setConnection(name, connection);
885
+ }
841
886
 
842
- // 12. Create tmux session running claude in interactive mode
843
- const tmuxSessionName = `overstory-${config.project.name}-${name}`;
844
- const spawnCmd = runtime.buildSpawnCommand({
845
- model: resolvedModel.model,
846
- permissionMode: "bypass",
847
- cwd: worktreePath,
848
- env: {
887
+ // 13. Record session with empty tmuxSession (no tmux pane for headless agents).
888
+ const session: AgentSession = {
889
+ id: `session-${Date.now()}-${name}`,
890
+ agentName: name,
891
+ capability,
892
+ worktreePath,
893
+ branchName,
894
+ taskId: taskId,
895
+ tmuxSession: "",
896
+ state: "booting",
897
+ pid: headlessProc.pid,
898
+ parentAgent: parentAgent,
899
+ depth,
900
+ runId,
901
+ startedAt: new Date().toISOString(),
902
+ lastActivity: new Date().toISOString(),
903
+ escalationLevel: 0,
904
+ stalledSince: null,
905
+ transcriptPath: null,
906
+ };
907
+ store.upsert(session);
908
+
909
+ const runStore = createRunStore(join(overstoryDir, "sessions.db"));
910
+ try {
911
+ runStore.incrementAgentCount(runId);
912
+ } finally {
913
+ runStore.close();
914
+ }
915
+
916
+ // 14. Output result (headless)
917
+ if (opts.json ?? false) {
918
+ jsonOutput("sling", {
919
+ agentName: name,
920
+ capability,
921
+ taskId,
922
+ branch: branchName,
923
+ worktree: worktreePath,
924
+ tmuxSession: "",
925
+ pid: headlessProc.pid,
926
+ });
927
+ } else {
928
+ printSuccess("Agent launched (headless)", name);
929
+ process.stdout.write(` Task: ${taskId}\n`);
930
+ process.stdout.write(` Branch: ${branchName}\n`);
931
+ process.stdout.write(` Worktree: ${worktreePath}\n`);
932
+ process.stdout.write(` PID: ${headlessProc.pid}\n`);
933
+ }
934
+ } else {
935
+ // 11c. Preflight: verify tmux is available before attempting session creation
936
+ await ensureTmuxAvailable();
937
+
938
+ // 12. Create tmux session running claude in interactive mode
939
+ const tmuxSessionName = `overstory-${config.project.name}-${name}`;
940
+ const spawnCmd = runtime.buildSpawnCommand({
941
+ model: resolvedModel.model,
942
+ permissionMode: "bypass",
943
+ cwd: worktreePath,
944
+ env: {
945
+ ...runtime.buildEnv(resolvedModel),
946
+ OVERSTORY_AGENT_NAME: name,
947
+ OVERSTORY_WORKTREE_PATH: worktreePath,
948
+ },
949
+ });
950
+ const pid = await createSession(tmuxSessionName, worktreePath, spawnCmd, {
849
951
  ...runtime.buildEnv(resolvedModel),
850
952
  OVERSTORY_AGENT_NAME: name,
851
953
  OVERSTORY_WORKTREE_PATH: worktreePath,
852
- },
853
- });
854
- const pid = await createSession(tmuxSessionName, worktreePath, spawnCmd, {
855
- ...runtime.buildEnv(resolvedModel),
856
- OVERSTORY_AGENT_NAME: name,
857
- OVERSTORY_WORKTREE_PATH: worktreePath,
858
- });
954
+ });
859
955
 
860
- // 13. Record session BEFORE sending the beacon so that hook-triggered
861
- // updateLastActivity() can find the entry and transition booting->working.
862
- // Without this, a race exists: hooks fire before the session is persisted,
863
- // leaving the agent stuck in "booting" (overstory-036f).
864
- const session: AgentSession = {
865
- id: `session-${Date.now()}-${name}`,
866
- agentName: name,
867
- capability,
868
- worktreePath,
869
- branchName,
870
- taskId: taskId,
871
- tmuxSession: tmuxSessionName,
872
- state: "booting",
873
- pid,
874
- parentAgent: parentAgent,
875
- depth,
876
- runId,
877
- startedAt: new Date().toISOString(),
878
- lastActivity: new Date().toISOString(),
879
- escalationLevel: 0,
880
- stalledSince: null,
881
- transcriptPath: null,
882
- };
956
+ // 13. Record session BEFORE sending the beacon so that hook-triggered
957
+ // updateLastActivity() can find the entry and transition booting->working.
958
+ // Without this, a race exists: hooks fire before the session is persisted,
959
+ // leaving the agent stuck in "booting" (overstory-036f).
960
+ const session: AgentSession = {
961
+ id: `session-${Date.now()}-${name}`,
962
+ agentName: name,
963
+ capability,
964
+ worktreePath,
965
+ branchName,
966
+ taskId: taskId,
967
+ tmuxSession: tmuxSessionName,
968
+ state: "booting",
969
+ pid,
970
+ parentAgent: parentAgent,
971
+ depth,
972
+ runId,
973
+ startedAt: new Date().toISOString(),
974
+ lastActivity: new Date().toISOString(),
975
+ escalationLevel: 0,
976
+ stalledSince: null,
977
+ transcriptPath: null,
978
+ };
883
979
 
884
- store.upsert(session);
980
+ store.upsert(session);
885
981
 
886
- // Increment agent count for the run
887
- const runStore = createRunStore(join(overstoryDir, "sessions.db"));
888
- try {
889
- runStore.incrementAgentCount(runId);
890
- } finally {
891
- runStore.close();
892
- }
982
+ // Increment agent count for the run
983
+ const runStore = createRunStore(join(overstoryDir, "sessions.db"));
984
+ try {
985
+ runStore.incrementAgentCount(runId);
986
+ } finally {
987
+ runStore.close();
988
+ }
893
989
 
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
- }
990
+ // 13b. Give slow shells time to finish initializing before polling for TUI readiness.
991
+ const shellDelay = config.runtime?.shellInitDelayMs ?? 0;
992
+ if (shellDelay > 0) {
993
+ await Bun.sleep(shellDelay);
994
+ }
899
995
 
900
- // Wait for Claude Code TUI to render before sending input.
901
- // Polling capture-pane is more reliable than a fixed sleep because
902
- // TUI init time varies by machine load and model state.
903
- await waitForTuiReady(tmuxSessionName, (content) => runtime.detectReady(content));
904
- // Buffer for the input handler to attach after initial render
905
- await Bun.sleep(1_000);
996
+ // Wait for Claude Code TUI to render before sending input.
997
+ // Polling capture-pane is more reliable than a fixed sleep because
998
+ // TUI init time varies by machine load and model state.
999
+ await waitForTuiReady(tmuxSessionName, (content) => runtime.detectReady(content));
1000
+ // Buffer for the input handler to attach after initial render
1001
+ await Bun.sleep(1_000);
906
1002
 
907
- const beacon = buildBeacon({
908
- agentName: name,
909
- capability,
910
- taskId,
911
- parentAgent,
912
- depth,
913
- instructionPath: runtime.instructionPath,
914
- });
915
- await sendKeys(tmuxSessionName, beacon);
916
-
917
- // 13c. Follow-up Enters with increasing delays to ensure submission.
918
- // Claude Code's TUI may consume early Enters during late initialization
919
- // (overstory-yhv6). An Enter on an empty input line is harmless.
920
- for (const delay of [1_000, 2_000, 3_000, 5_000]) {
921
- await Bun.sleep(delay);
922
- await sendKeys(tmuxSessionName, "");
923
- }
1003
+ const beacon = buildBeacon({
1004
+ agentName: name,
1005
+ capability,
1006
+ taskId,
1007
+ parentAgent,
1008
+ depth,
1009
+ instructionPath: runtime.instructionPath,
1010
+ });
1011
+ await sendKeys(tmuxSessionName, beacon);
1012
+
1013
+ // 13c. Follow-up Enters with increasing delays to ensure submission.
1014
+ // Claude Code's TUI may consume early Enters during late initialization
1015
+ // (overstory-yhv6). An Enter on an empty input line is harmless.
1016
+ for (const delay of [1_000, 2_000, 3_000, 5_000]) {
1017
+ await Bun.sleep(delay);
1018
+ await sendKeys(tmuxSessionName, "");
1019
+ }
924
1020
 
925
- // 13d. Verify beacon was received — if pane still shows the welcome
926
- // screen (detectReady returns "ready"), resend the beacon. Claude Code's TUI
927
- // sometimes consumes the Enter keystroke during late initialization, swallowing
928
- // the beacon text entirely (overstory-3271).
929
- //
930
- // Skipped for runtimes that return false from requiresBeaconVerification().
931
- // Pi's TUI idle and processing states are indistinguishable via detectReady
932
- // (both show "pi v..." header and the token-usage status bar), so the loop
933
- // would incorrectly conclude the beacon was not received and spam duplicate
934
- // startup messages.
935
- const needsVerification =
936
- !runtime.requiresBeaconVerification || runtime.requiresBeaconVerification();
937
- if (needsVerification) {
938
- const verifyAttempts = 5;
939
- for (let v = 0; v < verifyAttempts; v++) {
940
- await Bun.sleep(2_000);
941
- const paneContent = await capturePaneContent(tmuxSessionName);
942
- if (paneContent) {
943
- const readyState = runtime.detectReady(paneContent);
944
- if (readyState.phase !== "ready") {
945
- break; // Agent is processing — beacon was received
1021
+ // 13d. Verify beacon was received — if pane still shows the welcome
1022
+ // screen (detectReady returns "ready"), resend the beacon. Claude Code's TUI
1023
+ // sometimes consumes the Enter keystroke during late initialization, swallowing
1024
+ // the beacon text entirely (overstory-3271).
1025
+ //
1026
+ // Skipped for runtimes that return false from requiresBeaconVerification().
1027
+ // Pi's TUI idle and processing states are indistinguishable via detectReady
1028
+ // (both show "pi v..." header and the token-usage status bar), so the loop
1029
+ // would incorrectly conclude the beacon was not received and spam duplicate
1030
+ // startup messages.
1031
+ const needsVerification =
1032
+ !runtime.requiresBeaconVerification || runtime.requiresBeaconVerification();
1033
+ if (needsVerification) {
1034
+ const verifyAttempts = 5;
1035
+ for (let v = 0; v < verifyAttempts; v++) {
1036
+ await Bun.sleep(2_000);
1037
+ const paneContent = await capturePaneContent(tmuxSessionName);
1038
+ if (paneContent) {
1039
+ const readyState = runtime.detectReady(paneContent);
1040
+ if (readyState.phase !== "ready") {
1041
+ break; // Agent is processing — beacon was received
1042
+ }
946
1043
  }
1044
+ // Still at welcome/idle screen — resend beacon
1045
+ await sendKeys(tmuxSessionName, beacon);
1046
+ await Bun.sleep(1_000);
1047
+ await sendKeys(tmuxSessionName, ""); // Follow-up Enter
947
1048
  }
948
- // Still at welcome/idle screen — resend beacon
949
- await sendKeys(tmuxSessionName, beacon);
950
- await Bun.sleep(1_000);
951
- await sendKeys(tmuxSessionName, ""); // Follow-up Enter
952
1049
  }
953
- }
954
-
955
- // 14. Output result
956
- const output = {
957
- agentName: name,
958
- capability,
959
- taskId,
960
- branch: branchName,
961
- worktree: worktreePath,
962
- tmuxSession: tmuxSessionName,
963
- pid,
964
- };
965
1050
 
966
- if (opts.json ?? false) {
967
- jsonOutput("sling", output);
968
- } else {
969
- printSuccess("Agent launched", name);
970
- process.stdout.write(` Task: ${taskId}\n`);
971
- process.stdout.write(` Branch: ${branchName}\n`);
972
- process.stdout.write(` Worktree: ${worktreePath}\n`);
973
- process.stdout.write(` Tmux: ${tmuxSessionName}\n`);
974
- process.stdout.write(` PID: ${pid}\n`);
1051
+ // 14. Output result
1052
+ const output = {
1053
+ agentName: name,
1054
+ capability,
1055
+ taskId,
1056
+ branch: branchName,
1057
+ worktree: worktreePath,
1058
+ tmuxSession: tmuxSessionName,
1059
+ pid,
1060
+ };
1061
+
1062
+ if (opts.json ?? false) {
1063
+ jsonOutput("sling", output);
1064
+ } else {
1065
+ printSuccess("Agent launched", name);
1066
+ process.stdout.write(` Task: ${taskId}\n`);
1067
+ process.stdout.write(` Branch: ${branchName}\n`);
1068
+ process.stdout.write(` Worktree: ${worktreePath}\n`);
1069
+ process.stdout.write(` Tmux: ${tmuxSessionName}\n`);
1070
+ process.stdout.write(` PID: ${pid}\n`);
1071
+ }
975
1072
  }
976
1073
  } finally {
977
1074
  store.close();