@os-eco/overstory-cli 0.8.0 → 0.8.3

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 (44) hide show
  1. package/README.md +3 -1
  2. package/agents/builder.md +2 -2
  3. package/agents/lead.md +2 -2
  4. package/agents/merger.md +2 -2
  5. package/agents/orchestrator.md +1 -1
  6. package/agents/reviewer.md +2 -2
  7. package/agents/scout.md +2 -2
  8. package/agents/supervisor.md +3 -3
  9. package/package.json +1 -1
  10. package/src/agents/overlay.test.ts +42 -0
  11. package/src/agents/overlay.ts +1 -0
  12. package/src/commands/dashboard.test.ts +86 -0
  13. package/src/commands/dashboard.ts +8 -4
  14. package/src/commands/feed.test.ts +8 -0
  15. package/src/commands/inspect.test.ts +156 -1
  16. package/src/commands/inspect.ts +19 -4
  17. package/src/commands/replay.test.ts +8 -0
  18. package/src/commands/sling.test.ts +34 -10
  19. package/src/commands/sling.ts +249 -136
  20. package/src/commands/status.test.ts +77 -0
  21. package/src/commands/status.ts +6 -3
  22. package/src/commands/stop.test.ts +186 -4
  23. package/src/commands/stop.ts +46 -14
  24. package/src/commands/trace.test.ts +8 -0
  25. package/src/config.test.ts +63 -0
  26. package/src/config.ts +29 -5
  27. package/src/index.ts +2 -2
  28. package/src/logging/theme.ts +4 -0
  29. package/src/runtimes/codex.test.ts +22 -8
  30. package/src/runtimes/codex.ts +21 -16
  31. package/src/runtimes/connections.test.ts +74 -0
  32. package/src/runtimes/connections.ts +34 -0
  33. package/src/runtimes/registry.test.ts +1 -1
  34. package/src/runtimes/registry.ts +2 -0
  35. package/src/runtimes/sapling.test.ts +1237 -0
  36. package/src/runtimes/sapling.ts +698 -0
  37. package/src/runtimes/types.ts +45 -0
  38. package/src/types.ts +7 -1
  39. package/src/watchdog/daemon.ts +34 -0
  40. package/src/watchdog/health.test.ts +102 -0
  41. package/src/watchdog/health.ts +140 -69
  42. package/src/worktree/process.test.ts +101 -0
  43. package/src/worktree/process.ts +111 -0
  44. package/src/worktree/tmux.ts +5 -0
@@ -20,6 +20,7 @@ import {
20
20
  checkRunSessionLimit,
21
21
  checkTaskLock,
22
22
  extractMulchRecordIds,
23
+ generateAgentName,
23
24
  getCurrentBranch,
24
25
  inferDomainsFromFiles,
25
26
  isRunningAsRoot,
@@ -342,6 +343,31 @@ describe("shouldShowScoutWarning", () => {
342
343
  });
343
344
  });
344
345
 
346
+ describe("generateAgentName", () => {
347
+ test("returns capability-taskId when no collision", () => {
348
+ expect(generateAgentName("builder", "overstory-2f10", [])).toBe("builder-overstory-2f10");
349
+ });
350
+
351
+ test("returns capability-taskId when takenNames is empty", () => {
352
+ expect(generateAgentName("scout", "task-123", [])).toBe("scout-task-123");
353
+ });
354
+
355
+ test("appends -2 when base name is taken", () => {
356
+ expect(generateAgentName("builder", "overstory-2f10", ["builder-overstory-2f10"])).toBe(
357
+ "builder-overstory-2f10-2",
358
+ );
359
+ });
360
+
361
+ test("skips taken suffixes and returns -3 when -2 is also taken", () => {
362
+ expect(
363
+ generateAgentName("builder", "overstory-2f10", [
364
+ "builder-overstory-2f10",
365
+ "builder-overstory-2f10-2",
366
+ ]),
367
+ ).toBe("builder-overstory-2f10-3");
368
+ });
369
+ });
370
+
345
371
  /**
346
372
  * Tests for hierarchy validation in sling.
347
373
  *
@@ -352,14 +378,12 @@ describe("shouldShowScoutWarning", () => {
352
378
  */
353
379
 
354
380
  describe("validateHierarchy", () => {
355
- test("rejects builder when parentAgent is null", () => {
356
- expect(() => validateHierarchy(null, "builder", "test-builder", 0, false)).toThrow(
357
- HierarchyError,
358
- );
381
+ test("allows builder when parentAgent is null", () => {
382
+ expect(() => validateHierarchy(null, "builder", "test-builder", 0, false)).not.toThrow();
359
383
  });
360
384
 
361
- test("rejects scout when parentAgent is null", () => {
362
- expect(() => validateHierarchy(null, "scout", "test-scout", 0, false)).toThrow(HierarchyError);
385
+ test("allows scout when parentAgent is null", () => {
386
+ expect(() => validateHierarchy(null, "scout", "test-scout", 0, false)).not.toThrow();
363
387
  });
364
388
 
365
389
  test("rejects reviewer when parentAgent is null", () => {
@@ -404,15 +428,15 @@ describe("validateHierarchy", () => {
404
428
 
405
429
  test("error has correct fields and code", () => {
406
430
  try {
407
- validateHierarchy(null, "builder", "my-builder", 0, false);
431
+ validateHierarchy(null, "reviewer", "my-reviewer", 0, false);
408
432
  expect.unreachable("should have thrown");
409
433
  } catch (err) {
410
434
  expect(err).toBeInstanceOf(HierarchyError);
411
435
  const he = err as HierarchyError;
412
436
  expect(he.code).toBe("HIERARCHY_VIOLATION");
413
- expect(he.agentName).toBe("my-builder");
414
- expect(he.requestedCapability).toBe("builder");
415
- expect(he.message).toContain("builder");
437
+ expect(he.agentName).toBe("my-reviewer");
438
+ expect(he.requestedCapability).toBe("reviewer");
439
+ expect(he.message).toContain("reviewer");
416
440
  expect(he.message).toContain("lead");
417
441
  }
418
442
  });
@@ -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";
@@ -38,6 +39,7 @@ import type { TrackerIssue } from "../tracker/factory.ts";
38
39
  import { createTrackerClient, resolveBackend, trackerCliName } from "../tracker/factory.ts";
39
40
  import type { AgentSession, OverlayConfig } from "../types.ts";
40
41
  import { createWorktree } from "../worktree/manager.ts";
42
+ import { spawnHeadlessAgent } from "../worktree/process.ts";
41
43
  import {
42
44
  capturePaneContent,
43
45
  createSession,
@@ -75,6 +77,29 @@ export function calculateStaggerDelay(
75
77
  return remaining > 0 ? remaining : 0;
76
78
  }
77
79
 
80
+ /**
81
+ * Generate a unique agent name from capability and taskId.
82
+ * Base: capability-taskId. If that collides with takenNames,
83
+ * appends -2, -3, etc. up to 100. Falls back to -Date.now() for guaranteed uniqueness.
84
+ */
85
+ export function generateAgentName(
86
+ capability: string,
87
+ taskId: string,
88
+ takenNames: readonly string[],
89
+ ): string {
90
+ const base = `${capability}-${taskId}`;
91
+ if (!takenNames.includes(base)) {
92
+ return base;
93
+ }
94
+ for (let i = 2; i <= 100; i++) {
95
+ const candidate = `${base}-${i}`;
96
+ if (!takenNames.includes(candidate)) {
97
+ return candidate;
98
+ }
99
+ }
100
+ return `${base}-${Date.now()}`;
101
+ }
102
+
78
103
  /**
79
104
  * Check if the current process is running as root (UID 0).
80
105
  * Returns true if running as root, false otherwise.
@@ -345,9 +370,10 @@ export function validateHierarchy(
345
370
  return;
346
371
  }
347
372
 
348
- if (parentAgent === null && capability !== "lead") {
373
+ const directSpawnCapabilities = ["lead", "scout", "builder"];
374
+ if (parentAgent === null && !directSpawnCapabilities.includes(capability)) {
349
375
  throw new HierarchyError(
350
- `Coordinator cannot spawn "${capability}" directly. Only "lead" is allowed without --parent. Use a lead as intermediary, or pass --force-hierarchy to bypass.`,
376
+ `Coordinator cannot spawn "${capability}" directly. Only lead, scout, and builder are allowed without --parent. Use a lead as intermediary, or pass --force-hierarchy to bypass.`,
351
377
  { agentName: name, requestedCapability: capability },
352
378
  );
353
379
  }
@@ -426,7 +452,9 @@ export async function slingCommand(taskId: string, opts: SlingOptions): Promise<
426
452
  }
427
453
 
428
454
  const capability = opts.capability ?? "builder";
429
- const name = opts.name;
455
+ const rawName = opts.name?.trim() ?? "";
456
+ const nameWasAutoGenerated = rawName.length === 0;
457
+ let name = nameWasAutoGenerated ? `${capability}-${taskId}` : rawName;
430
458
  const specPath = opts.spec ?? null;
431
459
  const filesRaw = opts.files;
432
460
  const parentAgent = opts.parent ?? null;
@@ -436,10 +464,6 @@ export async function slingCommand(taskId: string, opts: SlingOptions): Promise<
436
464
  const skipScout = opts.skipScout ?? false;
437
465
  const skipTaskCheck = opts.skipTaskCheck ?? false;
438
466
 
439
- if (!name || name.trim().length === 0) {
440
- throw new ValidationError("--name is required for sling", { field: "name" });
441
- }
442
-
443
467
  if (Number.isNaN(depth) || depth < 0) {
444
468
  throw new ValidationError("--depth must be a non-negative integer", {
445
469
  field: "depth",
@@ -594,11 +618,16 @@ export async function slingCommand(taskId: string, opts: SlingOptions): Promise<
594
618
  );
595
619
  }
596
620
 
597
- const existing = store.getByName(name);
598
- if (existing && existing.state !== "zombie" && existing.state !== "completed") {
599
- throw new AgentError(`Agent name "${name}" is already in use (state: ${existing.state})`, {
600
- agentName: name,
601
- });
621
+ if (nameWasAutoGenerated) {
622
+ const takenNames = activeSessions.map((s) => s.agentName);
623
+ name = generateAgentName(capability, taskId, takenNames);
624
+ } else {
625
+ const existing = store.getByName(name);
626
+ if (existing && existing.state !== "zombie" && existing.state !== "completed") {
627
+ throw new AgentError(`Agent name "${name}" is already in use (state: ${existing.state})`, {
628
+ agentName: name,
629
+ });
630
+ }
602
631
  }
603
632
 
604
633
  // 5d. Task-level locking: prevent concurrent agents on the same task ID.
@@ -714,6 +743,9 @@ export async function slingCommand(taskId: string, opts: SlingOptions): Promise<
714
743
  }
715
744
  }
716
745
 
746
+ // Resolve runtime before overlayConfig so we can pass runtime.instructionPath
747
+ const runtime = getRuntime(opts.runtime, config);
748
+
717
749
  const overlayConfig: OverlayConfig = {
718
750
  agentName: name,
719
751
  taskId: taskId,
@@ -739,11 +771,9 @@ export async function slingCommand(taskId: string, opts: SlingOptions): Promise<
739
771
  qualityGates: config.project.qualityGates,
740
772
  trackerCli: trackerCliName(resolvedBackend),
741
773
  trackerName: resolvedBackend,
774
+ instructionPath: runtime.instructionPath,
742
775
  };
743
776
 
744
- // Resolve runtime before writeOverlay so we can pass runtime.instructionPath
745
- const runtime = getRuntime(opts.runtime, config);
746
-
747
777
  try {
748
778
  await writeOverlay(worktreePath, overlayConfig, config.project.root, runtime.instructionPath);
749
779
  } catch (err) {
@@ -836,142 +866,225 @@ export async function slingCommand(taskId: string, opts: SlingOptions): Promise<
836
866
  }
837
867
  }
838
868
 
839
- // 11c. Preflight: verify tmux is available before attempting session creation
840
- await ensureTmuxAvailable();
869
+ // 11c. Spawn: headless runtimes bypass tmux entirely; tmux path is unchanged.
870
+ if (runtime.headless === true && runtime.buildDirectSpawn) {
871
+ const directEnv = {
872
+ ...runtime.buildEnv(resolvedModel),
873
+ OVERSTORY_AGENT_NAME: name,
874
+ OVERSTORY_WORKTREE_PATH: worktreePath,
875
+ };
876
+ const argv = runtime.buildDirectSpawn({
877
+ cwd: worktreePath,
878
+ env: directEnv,
879
+ model: resolvedModel.model,
880
+ instructionPath: runtime.instructionPath,
881
+ });
882
+
883
+ // Create a timestamped log dir for this headless agent session.
884
+ // Always redirect stdout to a file. This prevents SIGPIPE death:
885
+ // ov sling exits after spawning, closing the pipe's read end.
886
+ // If stdout is a pipe, the agent dies on the next write (SIGPIPE).
887
+ // File writes have no such limit, and the agent survives the CLI exit.
888
+ //
889
+ // Note: RPC connection wiring is intentionally omitted here. The RPC pipe
890
+ // is only useful when the spawner stays alive to consume it. ov sling is
891
+ // a short-lived CLI — any connection created here dies with the process.
892
+ const logTimestamp = new Date().toISOString().replace(/[:.]/g, "-");
893
+ const agentLogDir = join(overstoryDir, "logs", name, logTimestamp);
894
+ mkdirSync(agentLogDir, { recursive: true });
895
+
896
+ const headlessProc = await spawnHeadlessAgent(argv, {
897
+ cwd: worktreePath,
898
+ env: { ...(process.env as Record<string, string>), ...directEnv },
899
+ stdoutFile: join(agentLogDir, "stdout.log"),
900
+ stderrFile: join(agentLogDir, "stderr.log"),
901
+ });
841
902
 
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: {
903
+ // 13. Record session with empty tmuxSession (no tmux pane for headless agents).
904
+ const session: AgentSession = {
905
+ id: `session-${Date.now()}-${name}`,
906
+ agentName: name,
907
+ capability,
908
+ worktreePath,
909
+ branchName,
910
+ taskId: taskId,
911
+ tmuxSession: "",
912
+ state: "booting",
913
+ pid: headlessProc.pid,
914
+ parentAgent: parentAgent,
915
+ depth,
916
+ runId,
917
+ startedAt: new Date().toISOString(),
918
+ lastActivity: new Date().toISOString(),
919
+ escalationLevel: 0,
920
+ stalledSince: null,
921
+ transcriptPath: null,
922
+ };
923
+ store.upsert(session);
924
+
925
+ const runStore = createRunStore(join(overstoryDir, "sessions.db"));
926
+ try {
927
+ runStore.incrementAgentCount(runId);
928
+ } finally {
929
+ runStore.close();
930
+ }
931
+
932
+ // 14. Output result (headless)
933
+ if (opts.json ?? false) {
934
+ jsonOutput("sling", {
935
+ agentName: name,
936
+ capability,
937
+ taskId,
938
+ branch: branchName,
939
+ worktree: worktreePath,
940
+ tmuxSession: "",
941
+ pid: headlessProc.pid,
942
+ });
943
+ } else {
944
+ printSuccess("Agent launched (headless)", name);
945
+ process.stdout.write(` Task: ${taskId}\n`);
946
+ process.stdout.write(` Branch: ${branchName}\n`);
947
+ process.stdout.write(` Worktree: ${worktreePath}\n`);
948
+ process.stdout.write(` PID: ${headlessProc.pid}\n`);
949
+ }
950
+ } else {
951
+ // 11c. Preflight: verify tmux is available before attempting session creation
952
+ await ensureTmuxAvailable();
953
+
954
+ // 12. Create tmux session running claude in interactive mode
955
+ const tmuxSessionName = `overstory-${config.project.name}-${name}`;
956
+ const spawnCmd = runtime.buildSpawnCommand({
957
+ model: resolvedModel.model,
958
+ permissionMode: "bypass",
959
+ cwd: worktreePath,
960
+ env: {
961
+ ...runtime.buildEnv(resolvedModel),
962
+ OVERSTORY_AGENT_NAME: name,
963
+ OVERSTORY_WORKTREE_PATH: worktreePath,
964
+ },
965
+ });
966
+ const pid = await createSession(tmuxSessionName, worktreePath, spawnCmd, {
849
967
  ...runtime.buildEnv(resolvedModel),
850
968
  OVERSTORY_AGENT_NAME: name,
851
969
  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
- });
970
+ });
859
971
 
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
- };
972
+ // 13. Record session BEFORE sending the beacon so that hook-triggered
973
+ // updateLastActivity() can find the entry and transition booting->working.
974
+ // Without this, a race exists: hooks fire before the session is persisted,
975
+ // leaving the agent stuck in "booting" (overstory-036f).
976
+ const session: AgentSession = {
977
+ id: `session-${Date.now()}-${name}`,
978
+ agentName: name,
979
+ capability,
980
+ worktreePath,
981
+ branchName,
982
+ taskId: taskId,
983
+ tmuxSession: tmuxSessionName,
984
+ state: "booting",
985
+ pid,
986
+ parentAgent: parentAgent,
987
+ depth,
988
+ runId,
989
+ startedAt: new Date().toISOString(),
990
+ lastActivity: new Date().toISOString(),
991
+ escalationLevel: 0,
992
+ stalledSince: null,
993
+ transcriptPath: null,
994
+ };
883
995
 
884
- store.upsert(session);
996
+ store.upsert(session);
885
997
 
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
- }
998
+ // Increment agent count for the run
999
+ const runStore = createRunStore(join(overstoryDir, "sessions.db"));
1000
+ try {
1001
+ runStore.incrementAgentCount(runId);
1002
+ } finally {
1003
+ runStore.close();
1004
+ }
893
1005
 
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
- }
1006
+ // 13b. Give slow shells time to finish initializing before polling for TUI readiness.
1007
+ const shellDelay = config.runtime?.shellInitDelayMs ?? 0;
1008
+ if (shellDelay > 0) {
1009
+ await Bun.sleep(shellDelay);
1010
+ }
899
1011
 
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);
1012
+ // Wait for Claude Code TUI to render before sending input.
1013
+ // Polling capture-pane is more reliable than a fixed sleep because
1014
+ // TUI init time varies by machine load and model state.
1015
+ await waitForTuiReady(tmuxSessionName, (content) => runtime.detectReady(content));
1016
+ // Buffer for the input handler to attach after initial render
1017
+ await Bun.sleep(1_000);
906
1018
 
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
- }
1019
+ const beacon = buildBeacon({
1020
+ agentName: name,
1021
+ capability,
1022
+ taskId,
1023
+ parentAgent,
1024
+ depth,
1025
+ instructionPath: runtime.instructionPath,
1026
+ });
1027
+ await sendKeys(tmuxSessionName, beacon);
1028
+
1029
+ // 13c. Follow-up Enters with increasing delays to ensure submission.
1030
+ // Claude Code's TUI may consume early Enters during late initialization
1031
+ // (overstory-yhv6). An Enter on an empty input line is harmless.
1032
+ for (const delay of [1_000, 2_000, 3_000, 5_000]) {
1033
+ await Bun.sleep(delay);
1034
+ await sendKeys(tmuxSessionName, "");
1035
+ }
924
1036
 
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
1037
+ // 13d. Verify beacon was received — if pane still shows the welcome
1038
+ // screen (detectReady returns "ready"), resend the beacon. Claude Code's TUI
1039
+ // sometimes consumes the Enter keystroke during late initialization, swallowing
1040
+ // the beacon text entirely (overstory-3271).
1041
+ //
1042
+ // Skipped for runtimes that return false from requiresBeaconVerification().
1043
+ // Pi's TUI idle and processing states are indistinguishable via detectReady
1044
+ // (both show "pi v..." header and the token-usage status bar), so the loop
1045
+ // would incorrectly conclude the beacon was not received and spam duplicate
1046
+ // startup messages.
1047
+ const needsVerification =
1048
+ !runtime.requiresBeaconVerification || runtime.requiresBeaconVerification();
1049
+ if (needsVerification) {
1050
+ const verifyAttempts = 5;
1051
+ for (let v = 0; v < verifyAttempts; v++) {
1052
+ await Bun.sleep(2_000);
1053
+ const paneContent = await capturePaneContent(tmuxSessionName);
1054
+ if (paneContent) {
1055
+ const readyState = runtime.detectReady(paneContent);
1056
+ if (readyState.phase !== "ready") {
1057
+ break; // Agent is processing — beacon was received
1058
+ }
946
1059
  }
1060
+ // Still at welcome/idle screen — resend beacon
1061
+ await sendKeys(tmuxSessionName, beacon);
1062
+ await Bun.sleep(1_000);
1063
+ await sendKeys(tmuxSessionName, ""); // Follow-up Enter
947
1064
  }
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
1065
  }
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
1066
 
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`);
1067
+ // 14. Output result
1068
+ const output = {
1069
+ agentName: name,
1070
+ capability,
1071
+ taskId,
1072
+ branch: branchName,
1073
+ worktree: worktreePath,
1074
+ tmuxSession: tmuxSessionName,
1075
+ pid,
1076
+ };
1077
+
1078
+ if (opts.json ?? false) {
1079
+ jsonOutput("sling", output);
1080
+ } else {
1081
+ printSuccess("Agent launched", name);
1082
+ process.stdout.write(` Task: ${taskId}\n`);
1083
+ process.stdout.write(` Branch: ${branchName}\n`);
1084
+ process.stdout.write(` Worktree: ${worktreePath}\n`);
1085
+ process.stdout.write(` Tmux: ${tmuxSessionName}\n`);
1086
+ process.stdout.write(` PID: ${pid}\n`);
1087
+ }
975
1088
  }
976
1089
  } finally {
977
1090
  store.close();
@@ -349,6 +349,83 @@ describe("run scoping", () => {
349
349
  });
350
350
  });
351
351
 
352
+ describe("headless agent alive markers", () => {
353
+ let chunks: string[];
354
+ let originalWrite: typeof process.stdout.write;
355
+
356
+ beforeEach(() => {
357
+ chunks = [];
358
+ originalWrite = process.stdout.write;
359
+ process.stdout.write = ((chunk: string) => {
360
+ chunks.push(chunk);
361
+ return true;
362
+ }) as typeof process.stdout.write;
363
+ });
364
+
365
+ afterEach(() => {
366
+ process.stdout.write = originalWrite;
367
+ });
368
+
369
+ function output(): string {
370
+ return chunks.join("");
371
+ }
372
+
373
+ test("printStatus shows green marker for headless agent with alive PID", () => {
374
+ // Use own process PID — guaranteed alive
375
+ const alivePid = process.pid;
376
+ const agent = makeAgent({
377
+ agentName: "headless-builder",
378
+ tmuxSession: "", // headless: no tmux
379
+ pid: alivePid,
380
+ state: "working",
381
+ });
382
+ const data = makeStatusData({
383
+ agents: [agent],
384
+ tmuxSessions: [], // no tmux sessions
385
+ });
386
+ printStatus(data);
387
+ const out = output();
388
+ // Green marker is ">" — check it appears in the output
389
+ expect(out).toContain("headless-builder");
390
+ expect(out).toContain(">");
391
+ });
392
+
393
+ test("printStatus shows red marker for headless agent with dead PID", () => {
394
+ const deadPid = 2_147_483_647; // max int, virtually guaranteed non-existent
395
+ const agent = makeAgent({
396
+ agentName: "dead-headless-builder",
397
+ tmuxSession: "", // headless: no tmux
398
+ pid: deadPid,
399
+ state: "working",
400
+ });
401
+ const data = makeStatusData({
402
+ agents: [agent],
403
+ tmuxSessions: [],
404
+ });
405
+ printStatus(data);
406
+ const out = output();
407
+ expect(out).toContain("dead-headless-builder");
408
+ expect(out).toContain("x");
409
+ });
410
+
411
+ test("printStatus uses tmux check (not PID) for tmux-based agents", () => {
412
+ const agent = makeAgent({
413
+ agentName: "tmux-builder",
414
+ tmuxSession: "overstory-test-builder",
415
+ pid: process.pid, // alive PID, but should use tmux check
416
+ state: "working",
417
+ });
418
+ // tmuxSessions empty → tmux dead → red marker
419
+ const data = makeStatusData({
420
+ agents: [agent],
421
+ tmuxSessions: [],
422
+ });
423
+ printStatus(data);
424
+ const out = output();
425
+ expect(out).toContain("x");
426
+ });
427
+ });
428
+
352
429
  describe("--watch deprecation", () => {
353
430
  test("help text marks --watch as deprecated", async () => {
354
431
  const chunks: string[] = [];
@@ -20,7 +20,7 @@ import { openSessionStore } from "../sessions/compat.ts";
20
20
  import type { AgentSession } from "../types.ts";
21
21
  import { evaluateHealth } from "../watchdog/health.ts";
22
22
  import { listWorktrees } from "../worktree/manager.ts";
23
- import { listSessions } from "../worktree/tmux.ts";
23
+ import { isProcessAlive, listSessions } from "../worktree/tmux.ts";
24
24
 
25
25
  // ---------------------------------------------------------------------------
26
26
  // Subprocess result cache (TTL-based, module-level)
@@ -260,8 +260,11 @@ export function printStatus(data: StatusData): void {
260
260
  ? new Date(agent.lastActivity).getTime()
261
261
  : now;
262
262
  const duration = formatDuration(endTime - new Date(agent.startedAt).getTime());
263
- const tmuxAlive = tmuxSessionNames.has(agent.tmuxSession);
264
- const aliveMarker = tmuxAlive ? color.green(">") : color.red("x");
263
+ const isHeadless = agent.tmuxSession === "" && agent.pid !== null;
264
+ const alive = isHeadless
265
+ ? agent.pid !== null && isProcessAlive(agent.pid)
266
+ : tmuxSessionNames.has(agent.tmuxSession);
267
+ const aliveMarker = alive ? color.green(">") : color.red("x");
265
268
  w(` ${aliveMarker} ${accent(agent.agentName)} [${agent.capability}] `);
266
269
  w(`${agent.state} | ${accent(agent.taskId)} | ${duration}\n`);
267
270