@os-eco/overstory-cli 0.9.3 → 0.10.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 (116) hide show
  1. package/README.md +49 -18
  2. package/agents/builder.md +9 -8
  3. package/agents/coordinator.md +6 -6
  4. package/agents/lead.md +98 -82
  5. package/agents/merger.md +25 -14
  6. package/agents/reviewer.md +22 -16
  7. package/agents/scout.md +17 -12
  8. package/package.json +6 -3
  9. package/src/agents/capabilities.test.ts +85 -0
  10. package/src/agents/capabilities.ts +125 -0
  11. package/src/agents/headless-mail-injector.test.ts +448 -0
  12. package/src/agents/headless-mail-injector.ts +211 -0
  13. package/src/agents/headless-prompt.test.ts +102 -0
  14. package/src/agents/headless-prompt.ts +68 -0
  15. package/src/agents/hooks-deployer.test.ts +514 -14
  16. package/src/agents/hooks-deployer.ts +141 -0
  17. package/src/agents/overlay.test.ts +4 -4
  18. package/src/agents/overlay.ts +30 -8
  19. package/src/agents/turn-lock.test.ts +181 -0
  20. package/src/agents/turn-lock.ts +235 -0
  21. package/src/agents/turn-runner-dispatch.test.ts +182 -0
  22. package/src/agents/turn-runner-dispatch.ts +105 -0
  23. package/src/agents/turn-runner.test.ts +1450 -0
  24. package/src/agents/turn-runner.ts +1166 -0
  25. package/src/commands/clean.ts +56 -1
  26. package/src/commands/completions.test.ts +4 -1
  27. package/src/commands/coordinator.test.ts +127 -0
  28. package/src/commands/coordinator.ts +205 -6
  29. package/src/commands/dashboard.test.ts +188 -0
  30. package/src/commands/dashboard.ts +13 -3
  31. package/src/commands/doctor.ts +94 -77
  32. package/src/commands/group.test.ts +94 -0
  33. package/src/commands/group.ts +49 -20
  34. package/src/commands/init.test.ts +8 -0
  35. package/src/commands/init.ts +8 -1
  36. package/src/commands/log.test.ts +56 -11
  37. package/src/commands/log.ts +134 -69
  38. package/src/commands/mail.test.ts +162 -0
  39. package/src/commands/mail.ts +64 -9
  40. package/src/commands/merge.test.ts +112 -1
  41. package/src/commands/merge.ts +17 -4
  42. package/src/commands/monitor.ts +2 -1
  43. package/src/commands/nudge.test.ts +351 -4
  44. package/src/commands/nudge.ts +356 -34
  45. package/src/commands/run.test.ts +43 -7
  46. package/src/commands/serve/build.test.ts +202 -0
  47. package/src/commands/serve/build.ts +206 -0
  48. package/src/commands/serve/coordinator-actions.test.ts +339 -0
  49. package/src/commands/serve/coordinator-actions.ts +408 -0
  50. package/src/commands/serve/dev.test.ts +168 -0
  51. package/src/commands/serve/dev.ts +117 -0
  52. package/src/commands/serve/mail-actions.test.ts +312 -0
  53. package/src/commands/serve/mail-actions.ts +167 -0
  54. package/src/commands/serve/rest.test.ts +1323 -0
  55. package/src/commands/serve/rest.ts +708 -0
  56. package/src/commands/serve/static.ts +51 -0
  57. package/src/commands/serve/ws.test.ts +361 -0
  58. package/src/commands/serve/ws.ts +332 -0
  59. package/src/commands/serve.test.ts +459 -0
  60. package/src/commands/serve.ts +565 -0
  61. package/src/commands/sling.test.ts +85 -1
  62. package/src/commands/sling.ts +153 -64
  63. package/src/commands/status.test.ts +9 -0
  64. package/src/commands/status.ts +12 -4
  65. package/src/commands/stop.test.ts +174 -1
  66. package/src/commands/stop.ts +107 -8
  67. package/src/commands/supervisor.ts +2 -1
  68. package/src/commands/watch.test.ts +49 -4
  69. package/src/commands/watch.ts +153 -28
  70. package/src/commands/worktree.test.ts +319 -3
  71. package/src/commands/worktree.ts +86 -0
  72. package/src/config.test.ts +78 -0
  73. package/src/config.ts +43 -1
  74. package/src/doctor/consistency.test.ts +106 -0
  75. package/src/doctor/consistency.ts +50 -3
  76. package/src/doctor/serve.test.ts +95 -0
  77. package/src/doctor/serve.ts +86 -0
  78. package/src/doctor/types.ts +2 -1
  79. package/src/doctor/watchdog.ts +57 -1
  80. package/src/events/tailer.test.ts +234 -1
  81. package/src/events/tailer.ts +90 -0
  82. package/src/index.ts +53 -6
  83. package/src/json.ts +29 -0
  84. package/src/mail/client.ts +15 -2
  85. package/src/mail/store.test.ts +82 -0
  86. package/src/mail/store.ts +41 -4
  87. package/src/merge/lock.test.ts +149 -0
  88. package/src/merge/lock.ts +140 -0
  89. package/src/runtimes/__fixtures__/claude-stream-fixture.ts +22 -0
  90. package/src/runtimes/claude.test.ts +791 -1
  91. package/src/runtimes/claude.ts +323 -1
  92. package/src/runtimes/connections.test.ts +141 -1
  93. package/src/runtimes/connections.ts +73 -4
  94. package/src/runtimes/headless-connection.test.ts +264 -0
  95. package/src/runtimes/headless-connection.ts +158 -0
  96. package/src/runtimes/types.ts +10 -0
  97. package/src/schema-consistency.test.ts +1 -0
  98. package/src/sessions/store.test.ts +390 -24
  99. package/src/sessions/store.ts +184 -19
  100. package/src/test-setup.test.ts +31 -0
  101. package/src/test-setup.ts +28 -0
  102. package/src/types.ts +56 -1
  103. package/src/utils/pid.test.ts +85 -1
  104. package/src/utils/pid.ts +86 -1
  105. package/src/utils/process-scan.test.ts +53 -0
  106. package/src/utils/process-scan.ts +76 -0
  107. package/src/watchdog/daemon.test.ts +1520 -411
  108. package/src/watchdog/daemon.ts +442 -83
  109. package/src/watchdog/health.test.ts +157 -0
  110. package/src/watchdog/health.ts +92 -25
  111. package/src/worktree/process.test.ts +71 -0
  112. package/src/worktree/process.ts +25 -5
  113. package/src/worktree/tmux.test.ts +39 -0
  114. package/src/worktree/tmux.ts +23 -3
  115. package/templates/CLAUDE.md.tmpl +19 -8
  116. package/templates/overlay.md.tmpl +3 -2
@@ -0,0 +1,264 @@
1
+ import { afterEach, describe, expect, test } from "bun:test";
2
+ import { HeadlessClaudeConnection, hasNudge } from "./headless-connection.ts";
3
+ import type { RuntimeConnection } from "./types.ts";
4
+
5
+ /**
6
+ * Tests use real subprocesses (sleep, cat, echo) — no mocking.
7
+ * Processes spawned in tests are cleaned up via proc.kill() in afterEach
8
+ * where applicable.
9
+ */
10
+
11
+ describe("HeadlessClaudeConnection", () => {
12
+ const cleanup: Array<() => void> = [];
13
+
14
+ afterEach(() => {
15
+ for (const fn of cleanup.splice(0)) {
16
+ try {
17
+ fn();
18
+ } catch {
19
+ // ignore cleanup errors
20
+ }
21
+ }
22
+ });
23
+
24
+ describe("sendPrompt / followUp", () => {
25
+ test("sendPrompt writes text to stdin and the process reads it", async () => {
26
+ const proc = Bun.spawn(["cat"], {
27
+ stdin: "pipe",
28
+ stdout: "pipe",
29
+ stderr: "pipe",
30
+ });
31
+ cleanup.push(() => proc.kill());
32
+
33
+ const conn = new HeadlessClaudeConnection(proc.pid, proc.stdin);
34
+ await conn.sendPrompt("hello from sendPrompt\n");
35
+ proc.stdin.end();
36
+
37
+ const text = await new Response(proc.stdout).text();
38
+ expect(text.trim()).toBe("hello from sendPrompt");
39
+ });
40
+
41
+ test("followUp writes text to stdin and the process reads it", async () => {
42
+ const proc = Bun.spawn(["cat"], {
43
+ stdin: "pipe",
44
+ stdout: "pipe",
45
+ stderr: "pipe",
46
+ });
47
+ cleanup.push(() => proc.kill());
48
+
49
+ const conn = new HeadlessClaudeConnection(proc.pid, proc.stdin);
50
+ await conn.followUp("hello from followUp\n");
51
+ proc.stdin.end();
52
+
53
+ const text = await new Response(proc.stdout).text();
54
+ expect(text.trim()).toBe("hello from followUp");
55
+ });
56
+
57
+ test("multiple followUp calls each write to stdin in order", async () => {
58
+ const proc = Bun.spawn(["cat"], {
59
+ stdin: "pipe",
60
+ stdout: "pipe",
61
+ stderr: "pipe",
62
+ });
63
+ cleanup.push(() => proc.kill());
64
+
65
+ const conn = new HeadlessClaudeConnection(proc.pid, proc.stdin);
66
+ await conn.followUp("line1\n");
67
+ await conn.followUp("line2\n");
68
+ proc.stdin.end();
69
+
70
+ const text = await new Response(proc.stdout).text();
71
+ expect(text).toBe("line1\nline2\n");
72
+ });
73
+ });
74
+
75
+ describe("getState", () => {
76
+ test("returns working when process is alive", async () => {
77
+ const proc = Bun.spawn(["sleep", "60"], {
78
+ stdin: "pipe",
79
+ stdout: "pipe",
80
+ stderr: "pipe",
81
+ });
82
+ cleanup.push(() => proc.kill());
83
+
84
+ const conn = new HeadlessClaudeConnection(proc.pid, proc.stdin);
85
+ const state = await conn.getState();
86
+ expect(state.status).toBe("working");
87
+ });
88
+
89
+ test("returns error when process has exited", async () => {
90
+ const proc = Bun.spawn(["echo", "done"], {
91
+ stdin: "pipe",
92
+ stdout: "pipe",
93
+ stderr: "pipe",
94
+ });
95
+ // Drain stdout so the process can exit cleanly
96
+ await new Response(proc.stdout).text();
97
+ await proc.exited;
98
+
99
+ const conn = new HeadlessClaudeConnection(proc.pid, proc.stdin);
100
+ const state = await conn.getState();
101
+ // PID is no longer running — kill(pid, 0) throws ESRCH
102
+ expect(state.status).toBe("error");
103
+ });
104
+ });
105
+
106
+ describe("abort", () => {
107
+ test("terminates a running process via SIGTERM", async () => {
108
+ const proc = Bun.spawn(["sleep", "60"], {
109
+ stdin: "pipe",
110
+ stdout: "pipe",
111
+ stderr: "pipe",
112
+ });
113
+
114
+ const conn = new HeadlessClaudeConnection(proc.pid, proc.stdin, {
115
+ sigkillDelayMs: 500,
116
+ });
117
+ await conn.abort();
118
+
119
+ const exitCode = await proc.exited;
120
+ // Process should have exited (signal exit codes are negative on some systems,
121
+ // or a non-zero code is expected; just verify it exited)
122
+ expect(typeof exitCode).toBe("number");
123
+ });
124
+
125
+ test("abort on already-exited process is a no-op (does not throw)", async () => {
126
+ const proc = Bun.spawn(["echo", "bye"], {
127
+ stdin: "pipe",
128
+ stdout: "pipe",
129
+ stderr: "pipe",
130
+ });
131
+ await new Response(proc.stdout).text();
132
+ await proc.exited;
133
+
134
+ const conn = new HeadlessClaudeConnection(proc.pid, proc.stdin, {
135
+ sigkillDelayMs: 100,
136
+ });
137
+ await expect(conn.abort()).resolves.toBeUndefined();
138
+ });
139
+ });
140
+
141
+ describe("pid", () => {
142
+ test("exposes the process PID", async () => {
143
+ const proc = Bun.spawn(["sleep", "1"], {
144
+ stdin: "pipe",
145
+ stdout: "pipe",
146
+ stderr: "pipe",
147
+ });
148
+ cleanup.push(() => proc.kill());
149
+
150
+ const conn = new HeadlessClaudeConnection(proc.pid, proc.stdin);
151
+ expect(conn.pid).toBe(proc.pid);
152
+ expect(conn.pid).toBeGreaterThan(0);
153
+ });
154
+ });
155
+
156
+ describe("close", () => {
157
+ test("close() does not throw and leaves process running", async () => {
158
+ const proc = Bun.spawn(["sleep", "60"], {
159
+ stdin: "pipe",
160
+ stdout: "pipe",
161
+ stderr: "pipe",
162
+ });
163
+ cleanup.push(() => proc.kill());
164
+
165
+ const conn = new HeadlessClaudeConnection(proc.pid, proc.stdin);
166
+ expect(() => conn.close()).not.toThrow();
167
+
168
+ // Process is still alive after close()
169
+ const state = await conn.getState();
170
+ expect(state.status).toBe("working");
171
+ });
172
+ });
173
+
174
+ describe("nudge", () => {
175
+ test("nudge writes a stream-json user-message envelope to stdin", async () => {
176
+ const proc = Bun.spawn(["cat"], {
177
+ stdin: "pipe",
178
+ stdout: "pipe",
179
+ stderr: "pipe",
180
+ });
181
+ cleanup.push(() => proc.kill());
182
+
183
+ const conn = new HeadlessClaudeConnection(proc.pid, proc.stdin);
184
+ await conn.nudge("hello nudge");
185
+ proc.stdin.end();
186
+
187
+ const text = await new Response(proc.stdout).text();
188
+ const parsed = JSON.parse(text.trim()) as Record<string, unknown>;
189
+ expect(parsed.type).toBe("user");
190
+ const msg = parsed.message as Record<string, unknown>;
191
+ expect(msg.role).toBe("user");
192
+ const content = msg.content as Array<Record<string, unknown>>;
193
+ expect(content[0]?.type).toBe("text");
194
+ expect(content[0]?.text).toBe("hello nudge");
195
+ });
196
+
197
+ test("nudge returns Queued status (headless stdin-buffer caveat)", async () => {
198
+ const proc = Bun.spawn(["cat"], {
199
+ stdin: "pipe",
200
+ stdout: "pipe",
201
+ stderr: "pipe",
202
+ });
203
+ cleanup.push(() => proc.kill());
204
+
205
+ const conn = new HeadlessClaudeConnection(proc.pid, proc.stdin);
206
+ const result = await conn.nudge("any message");
207
+ proc.stdin.end();
208
+ expect(result.status).toBe("Queued");
209
+ });
210
+
211
+ test("nudge envelope ends with a newline (NDJSON line terminator)", async () => {
212
+ const proc = Bun.spawn(["cat"], {
213
+ stdin: "pipe",
214
+ stdout: "pipe",
215
+ stderr: "pipe",
216
+ });
217
+ cleanup.push(() => proc.kill());
218
+
219
+ const conn = new HeadlessClaudeConnection(proc.pid, proc.stdin);
220
+ await conn.nudge("newline check");
221
+ proc.stdin.end();
222
+
223
+ const raw = await new Response(proc.stdout).text();
224
+ expect(raw.endsWith("\n")).toBe(true);
225
+ });
226
+ });
227
+ });
228
+
229
+ describe("hasNudge", () => {
230
+ test("returns true for HeadlessClaudeConnection (has nudge method)", () => {
231
+ const proc = Bun.spawn(["sleep", "1"], {
232
+ stdin: "pipe",
233
+ stdout: "pipe",
234
+ stderr: "pipe",
235
+ });
236
+ proc.kill();
237
+
238
+ const conn = new HeadlessClaudeConnection(proc.pid, proc.stdin);
239
+ expect(hasNudge(conn)).toBe(true);
240
+ });
241
+
242
+ test("returns false for a plain RuntimeConnection without nudge", () => {
243
+ const plain: RuntimeConnection = {
244
+ sendPrompt: async () => {},
245
+ followUp: async () => {},
246
+ abort: async () => {},
247
+ getState: async () => ({ status: "idle" as const }),
248
+ close: () => {},
249
+ };
250
+ expect(hasNudge(plain)).toBe(false);
251
+ });
252
+
253
+ test("returns false for an object with nudge as a non-function", () => {
254
+ const weird = {
255
+ sendPrompt: async () => {},
256
+ followUp: async () => {},
257
+ abort: async () => {},
258
+ getState: async () => ({ status: "idle" as const }),
259
+ close: () => {},
260
+ nudge: "not a function",
261
+ } as unknown as RuntimeConnection;
262
+ expect(hasNudge(weird)).toBe(false);
263
+ });
264
+ });
@@ -0,0 +1,158 @@
1
+ /**
2
+ * RuntimeConnection implementation for headless Claude Code subprocesses.
3
+ *
4
+ * Wraps a spawned process handle to provide the RuntimeConnection contract:
5
+ * - sendPrompt / followUp → write to process stdin
6
+ * - nudge → write a stream-json user-message envelope to stdin (see NudgeResult)
7
+ * - getState → poll process liveness via kill(pid, 0)
8
+ * - abort → SIGTERM with SIGKILL escalation after timeout
9
+ *
10
+ * Created by registerHeadlessConnection() in connections.ts when a headless
11
+ * agent is spawned via spawnHeadlessAgent() with an agentName.
12
+ */
13
+
14
+ import type { ConnectionState, RuntimeConnection } from "./types.ts";
15
+
16
+ /**
17
+ * Result of a nudge() call on a headless agent.
18
+ *
19
+ * "Delivered" — the process acknowledged the message (reserved for future use).
20
+ * "Queued" — the message was written to the process stdin buffer. Claude Code
21
+ * does not reliably poll stdin while an API stream is in flight, so
22
+ * the message may not be processed until the current turn completes.
23
+ * Phase 4 will implement abort + fork-from-last-turn + resume for
24
+ * immediate mid-task steering.
25
+ */
26
+ export type NudgeResult = { status: "Delivered" | "Queued" };
27
+
28
+ /**
29
+ * Extension of RuntimeConnection that supports runtime-agnostic nudge delivery.
30
+ * Implemented by HeadlessClaudeConnection; tmux-based agents do not implement this.
31
+ * The `ov nudge` command uses hasNudge() to route through this path when available.
32
+ */
33
+ export interface NudgeableConnection extends RuntimeConnection {
34
+ nudge(text: string): Promise<NudgeResult>;
35
+ }
36
+
37
+ /** Type guard: true when conn exposes a nudge() method (i.e. is a NudgeableConnection). */
38
+ export function hasNudge(conn: RuntimeConnection): conn is NudgeableConnection {
39
+ return "nudge" in conn && typeof (conn as { nudge?: unknown }).nudge === "function";
40
+ }
41
+
42
+ /**
43
+ * RuntimeConnection backed by a headless Claude Code subprocess.
44
+ *
45
+ * Communicates via stdin/stdout using Claude Code's stream-json format.
46
+ * Process liveness is determined by kill(pid, 0) — no tmux required.
47
+ */
48
+ export class HeadlessClaudeConnection implements RuntimeConnection {
49
+ readonly #pid: number;
50
+ readonly #stdin: { write(data: string | Uint8Array): number | Promise<number> };
51
+ readonly #sigkillDelayMs: number;
52
+
53
+ constructor(
54
+ pid: number,
55
+ stdin: { write(data: string | Uint8Array): number | Promise<number> },
56
+ opts?: { sigkillDelayMs?: number },
57
+ ) {
58
+ this.#pid = pid;
59
+ this.#stdin = stdin;
60
+ this.#sigkillDelayMs = opts?.sigkillDelayMs ?? 2000;
61
+ }
62
+
63
+ /** OS-level process ID of the underlying subprocess. */
64
+ get pid(): number {
65
+ return this.#pid;
66
+ }
67
+
68
+ /**
69
+ * Send initial prompt to the agent via stdin.
70
+ * Claude Code headless reads the first stdin line as the prompt.
71
+ */
72
+ async sendPrompt(text: string): Promise<void> {
73
+ await this.#stdin.write(text);
74
+ }
75
+
76
+ /**
77
+ * Send follow-up message to the agent via stdin.
78
+ * Replaces tmux send-keys for headless runtimes.
79
+ */
80
+ async followUp(text: string): Promise<void> {
81
+ await this.#stdin.write(text);
82
+ }
83
+
84
+ /**
85
+ * Send a nudge message to the headless Claude Code process.
86
+ *
87
+ * Writes a stream-json user-message envelope to stdin. Claude Code reads
88
+ * this as a follow-up user turn between the current and next tool call.
89
+ * However, Claude Code does not reliably poll stdin while an API stream is
90
+ * in flight — the message sits in the pipe buffer until the current turn
91
+ * completes. The return value is always "Queued" to surface this caveat.
92
+ * Phase 4 will implement abort + fork-from-last-turn + resume for
93
+ * immediate mid-task steering.
94
+ */
95
+ async nudge(text: string): Promise<NudgeResult> {
96
+ const envelope = `${JSON.stringify({
97
+ type: "user",
98
+ message: { role: "user", content: [{ type: "text", text }] },
99
+ })}\n`;
100
+ await this.#stdin.write(envelope);
101
+ return { status: "Queued" };
102
+ }
103
+
104
+ /**
105
+ * Terminate the agent process.
106
+ *
107
+ * Sends SIGTERM first, then polls every 50ms. If the process has not exited
108
+ * within sigkillDelayMs (default 2000ms), escalates to SIGKILL.
109
+ */
110
+ async abort(): Promise<void> {
111
+ try {
112
+ process.kill(this.#pid, "SIGTERM");
113
+ } catch {
114
+ return; // process already exited
115
+ }
116
+
117
+ const deadline = Date.now() + this.#sigkillDelayMs;
118
+ while (Date.now() < deadline) {
119
+ await Bun.sleep(50);
120
+ try {
121
+ process.kill(this.#pid, 0);
122
+ } catch {
123
+ return; // exited cleanly after SIGTERM
124
+ }
125
+ }
126
+
127
+ try {
128
+ process.kill(this.#pid, "SIGKILL");
129
+ } catch {
130
+ // already exited between last poll and SIGKILL
131
+ }
132
+ }
133
+
134
+ /**
135
+ * Query process state by polling PID liveness.
136
+ *
137
+ * Returns { status: "working" } when the process is alive,
138
+ * { status: "error" } when the PID is no longer running.
139
+ * Headless processes are either actively working or dead — no idle state.
140
+ */
141
+ async getState(): Promise<ConnectionState> {
142
+ try {
143
+ process.kill(this.#pid, 0);
144
+ return { status: "working" };
145
+ } catch {
146
+ return { status: "error" };
147
+ }
148
+ }
149
+
150
+ /**
151
+ * Release connection resources.
152
+ * No-op — the subprocess handle is not retained by this object.
153
+ * Call abort() first if process termination is needed.
154
+ */
155
+ close(): void {
156
+ // Nothing to release.
157
+ }
158
+ }
@@ -58,6 +58,8 @@ export interface HooksDef {
58
58
  worktreePath: string;
59
59
  /** Quality gates agents must pass before reporting completion. */
60
60
  qualityGates?: QualityGate[];
61
+ /** When true, skip hooks file deployment. Headless agents don't use settings.local.json. */
62
+ isHeadless?: boolean;
61
63
  }
62
64
 
63
65
  // === Transcripts ===
@@ -124,6 +126,14 @@ export interface DirectSpawnOpts {
124
126
  model?: string;
125
127
  /** Path to the instruction/overlay file for this agent. */
126
128
  instructionPath: string;
129
+ /**
130
+ * Runtime-provided session id to resume on follow-up spawns.
131
+ *
132
+ * Present only on follow-up spawns — when a non-empty string, the runtime
133
+ * adapter emits the runtime-specific resume flag in its argv (claude:
134
+ * `--resume <id>`). Absent, undefined, null, or empty string → no resume.
135
+ */
136
+ resumeSessionId?: string | null;
127
137
  }
128
138
 
129
139
  /** Structured event emitted by a headless agent on stdout (NDJSON). */
@@ -55,6 +55,7 @@ describe("SQL schema consistency", () => {
55
55
  "agent_name",
56
56
  "branch_name",
57
57
  "capability",
58
+ "claude_session_id",
58
59
  "depth",
59
60
  "escalation_level",
60
61
  "id",