@os-eco/overstory-cli 0.8.0 → 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.
@@ -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();
@@ -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
 
@@ -20,6 +20,38 @@ import { cleanupTempDir, createTempGitRepo } from "../test-helpers.ts";
20
20
  import type { AgentSession } from "../types.ts";
21
21
  import { type StopDeps, stopCommand } from "./stop.ts";
22
22
 
23
+ // --- Fake Process (for headless agents) ---
24
+
25
+ /** Track calls to fake process for assertions. */
26
+ interface ProcessCallTracker {
27
+ isAlive: Array<{ pid: number; result: boolean }>;
28
+ killTree: Array<{ pid: number }>;
29
+ }
30
+
31
+ /** Build a fake process DI object with configurable PID liveness. */
32
+ function makeFakeProcess(pidAliveMap: Record<number, boolean> = {}): {
33
+ proc: NonNullable<StopDeps["_process"]>;
34
+ calls: ProcessCallTracker;
35
+ } {
36
+ const calls: ProcessCallTracker = {
37
+ isAlive: [],
38
+ killTree: [],
39
+ };
40
+
41
+ const proc: NonNullable<StopDeps["_process"]> = {
42
+ isAlive: (pid: number): boolean => {
43
+ const alive = pidAliveMap[pid] ?? false;
44
+ calls.isAlive.push({ pid, result: alive });
45
+ return alive;
46
+ },
47
+ killTree: async (pid: number): Promise<void> => {
48
+ calls.killTree.push({ pid });
49
+ },
50
+ };
51
+
52
+ return { proc, calls };
53
+ }
54
+
23
55
  // --- Fake Tmux ---
24
56
 
25
57
  /** Track calls to fake tmux for assertions. */
@@ -405,3 +437,105 @@ describe("stopCommand --clean-worktree", () => {
405
437
  expect(parsed.worktreeRemoved).toBe(false);
406
438
  });
407
439
  });
440
+
441
+ describe("stopCommand headless agents", () => {
442
+ const HEADLESS_PID = 99999;
443
+
444
+ function makeHeadlessSession(overrides: Partial<AgentSession> = {}): AgentSession {
445
+ return makeAgentSession({
446
+ tmuxSession: "",
447
+ pid: HEADLESS_PID,
448
+ ...overrides,
449
+ });
450
+ }
451
+
452
+ function makeHeadlessDeps(
453
+ pidAliveMap: Record<number, boolean> = {},
454
+ worktreeConfig?: { shouldFail?: boolean },
455
+ ): {
456
+ deps: StopDeps;
457
+ tmuxCalls: TmuxCallTracker;
458
+ procCalls: ProcessCallTracker;
459
+ worktreeCalls: WorktreeCallTracker;
460
+ } {
461
+ const { tmux, calls: tmuxCalls } = makeFakeTmux({});
462
+ const { proc, calls: procCalls } = makeFakeProcess(pidAliveMap);
463
+ const { worktree, calls: worktreeCalls } = makeFakeWorktree(worktreeConfig?.shouldFail);
464
+ return {
465
+ deps: { _tmux: tmux, _worktree: worktree, _process: proc },
466
+ tmuxCalls,
467
+ procCalls,
468
+ worktreeCalls,
469
+ };
470
+ }
471
+
472
+ test("stops headless agent by killing process tree (no tmux interaction)", async () => {
473
+ const session = makeHeadlessSession({ state: "working" });
474
+ saveSessionsToDb([session]);
475
+
476
+ const { deps, tmuxCalls, procCalls } = makeHeadlessDeps({ [HEADLESS_PID]: true });
477
+ const output = await captureStdout(() => stopCommand("my-builder", {}, deps));
478
+
479
+ // PID was killed
480
+ expect(procCalls.killTree).toHaveLength(1);
481
+ expect(procCalls.killTree[0]?.pid).toBe(HEADLESS_PID);
482
+ // Tmux was NOT touched
483
+ expect(tmuxCalls.isSessionAlive).toHaveLength(0);
484
+ expect(tmuxCalls.killSession).toHaveLength(0);
485
+
486
+ expect(output).toContain("Agent stopped");
487
+ expect(output).toContain("Process tree killed");
488
+ expect(output).toContain(String(HEADLESS_PID));
489
+
490
+ const { store } = openSessionStore(overstoryDir);
491
+ const updated = store.getByName("my-builder");
492
+ store.close();
493
+ expect(updated?.state).toBe("completed");
494
+ });
495
+
496
+ test("handles headless agent with already-dead PID gracefully", async () => {
497
+ const session = makeHeadlessSession({ state: "working" });
498
+ saveSessionsToDb([session]);
499
+
500
+ // PID is NOT alive
501
+ const { deps, procCalls } = makeHeadlessDeps({ [HEADLESS_PID]: false });
502
+ const output = await captureStdout(() => stopCommand("my-builder", {}, deps));
503
+
504
+ expect(procCalls.killTree).toHaveLength(0);
505
+ expect(output).toContain("Agent stopped");
506
+ expect(output).toContain("Process was already dead");
507
+
508
+ const { store } = openSessionStore(overstoryDir);
509
+ const updated = store.getByName("my-builder");
510
+ store.close();
511
+ expect(updated?.state).toBe("completed");
512
+ });
513
+
514
+ test("--json output includes pidKilled for headless agent", async () => {
515
+ const session = makeHeadlessSession({ state: "working" });
516
+ saveSessionsToDb([session]);
517
+
518
+ const { deps } = makeHeadlessDeps({ [HEADLESS_PID]: true });
519
+ const output = await captureStdout(() => stopCommand("my-builder", { json: true }, deps));
520
+
521
+ const parsed = JSON.parse(output.trim()) as Record<string, unknown>;
522
+ expect(parsed.success).toBe(true);
523
+ expect(parsed.stopped).toBe(true);
524
+ expect(parsed.pidKilled).toBe(true);
525
+ expect(parsed.tmuxKilled).toBe(false);
526
+ expect(parsed.agentName).toBe("my-builder");
527
+ });
528
+
529
+ test("--clean-worktree works for headless agent", async () => {
530
+ const session = makeHeadlessSession({ state: "working" });
531
+ saveSessionsToDb([session]);
532
+
533
+ const { deps, worktreeCalls } = makeHeadlessDeps({ [HEADLESS_PID]: true });
534
+ const output = await captureStdout(() =>
535
+ stopCommand("my-builder", { cleanWorktree: true }, deps),
536
+ );
537
+
538
+ expect(output).toContain(`Worktree removed: ${session.worktreePath}`);
539
+ expect(worktreeCalls.remove).toHaveLength(1);
540
+ });
541
+ });