@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
@@ -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
+ });
@@ -3,7 +3,8 @@
3
3
  *
4
4
  * Explicitly terminates a running agent by:
5
5
  * 1. Looking up the agent session by name
6
- * 2. Killing its tmux session (if alive)
6
+ * 2a. For TUI agents: killing its tmux session (if alive)
7
+ * 2b. For headless agents (tmuxSession === ''): sending SIGTERM to the process tree
7
8
  * 3. Marking it as completed in the SessionStore
8
9
  * 4. Optionally removing its worktree (--clean-worktree)
9
10
  */
@@ -15,7 +16,7 @@ import { jsonOutput } from "../json.ts";
15
16
  import { printSuccess, printWarning } from "../logging/color.ts";
16
17
  import { openSessionStore } from "../sessions/compat.ts";
17
18
  import { removeWorktree } from "../worktree/manager.ts";
18
- import { isSessionAlive, killSession } from "../worktree/tmux.ts";
19
+ import { isProcessAlive, isSessionAlive, killProcessTree, killSession } from "../worktree/tmux.ts";
19
20
 
20
21
  export interface StopOptions {
21
22
  force?: boolean;
@@ -36,6 +37,10 @@ export interface StopDeps {
36
37
  options?: { force?: boolean; forceBranch?: boolean },
37
38
  ) => Promise<void>;
38
39
  };
40
+ _process?: {
41
+ isAlive: (pid: number) => boolean;
42
+ killTree: (pid: number) => Promise<void>;
43
+ };
39
44
  }
40
45
 
41
46
  /**
@@ -43,7 +48,7 @@ export interface StopDeps {
43
48
  *
44
49
  * @param agentName - Name of the agent to stop
45
50
  * @param opts - Command options
46
- * @param deps - Optional dependency injection for testing (tmux, worktree)
51
+ * @param deps - Optional dependency injection for testing (tmux, worktree, process)
47
52
  */
48
53
  export async function stopCommand(
49
54
  agentName: string,
@@ -63,6 +68,7 @@ export async function stopCommand(
63
68
 
64
69
  const tmux = deps._tmux ?? { isSessionAlive, killSession };
65
70
  const worktree = deps._worktree ?? { remove: removeWorktree };
71
+ const proc = deps._process ?? { isAlive: isProcessAlive, killTree: killProcessTree };
66
72
 
67
73
  const cwd = process.cwd();
68
74
  const config = await loadConfig(cwd);
@@ -84,10 +90,25 @@ export async function stopCommand(
84
90
  throw new AgentError(`Agent "${agentName}" is already zombie (dead)`, { agentName });
85
91
  }
86
92
 
87
- // Kill tmux session if alive
88
- const alive = await tmux.isSessionAlive(session.tmuxSession);
89
- if (alive) {
90
- await tmux.killSession(session.tmuxSession);
93
+ const isHeadless = session.tmuxSession === "" && session.pid !== null;
94
+
95
+ let tmuxKilled = false;
96
+ let pidKilled = false;
97
+
98
+ if (isHeadless && session.pid !== null) {
99
+ // Headless agent: kill via process tree instead of tmux
100
+ const alive = proc.isAlive(session.pid);
101
+ if (alive) {
102
+ await proc.killTree(session.pid);
103
+ pidKilled = true;
104
+ }
105
+ } else {
106
+ // TUI agent: kill via tmux session
107
+ const alive = await tmux.isSessionAlive(session.tmuxSession);
108
+ if (alive) {
109
+ await tmux.killSession(session.tmuxSession);
110
+ tmuxKilled = true;
111
+ }
91
112
  }
92
113
 
93
114
  // Mark session as completed
@@ -115,16 +136,25 @@ export async function stopCommand(
115
136
  agentName,
116
137
  sessionId: session.id,
117
138
  capability: session.capability,
118
- tmuxKilled: alive,
139
+ tmuxKilled,
140
+ pidKilled,
119
141
  worktreeRemoved,
120
142
  force,
121
143
  });
122
144
  } else {
123
145
  printSuccess("Agent stopped", agentName);
124
- if (alive) {
125
- process.stdout.write(` Tmux session killed: ${session.tmuxSession}\n`);
146
+ if (isHeadless) {
147
+ if (pidKilled) {
148
+ process.stdout.write(` Process tree killed: PID ${session.pid}\n`);
149
+ } else {
150
+ process.stdout.write(` Process was already dead (PID ${session.pid})\n`);
151
+ }
126
152
  } else {
127
- process.stdout.write(` Tmux session was already dead\n`);
153
+ if (tmuxKilled) {
154
+ process.stdout.write(` Tmux session killed: ${session.tmuxSession}\n`);
155
+ } else {
156
+ process.stdout.write(` Tmux session was already dead\n`);
157
+ }
128
158
  }
129
159
  if (cleanWorktree && worktreeRemoved) {
130
160
  process.stdout.write(` Worktree removed: ${session.worktreePath}\n`);
@@ -639,6 +639,10 @@ describe("traceCommand", () => {
639
639
  "spawn",
640
640
  "error",
641
641
  "custom",
642
+ "turn_start",
643
+ "turn_end",
644
+ "progress",
645
+ "result",
642
646
  ] as const;
643
647
  for (const eventType of eventTypes) {
644
648
  store.insert(
@@ -664,6 +668,10 @@ describe("traceCommand", () => {
664
668
  expect(out).toContain("SPAWN");
665
669
  expect(out).toContain("ERROR");
666
670
  expect(out).toContain("CUSTOM");
671
+ expect(out).toContain("TURN START");
672
+ expect(out).toContain("TURN END");
673
+ expect(out).toContain("PROGRESS");
674
+ expect(out).toContain("RESULT");
667
675
  });
668
676
 
669
677
  test("long data values are truncated", async () => {