@naxodev/apnea 0.2.0 → 0.2.1

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 +18 -1
  2. package/SECURITY.md +32 -0
  3. package/briefs/orchestrator.md +4 -3
  4. package/dist/cli.js +8283 -15058
  5. package/docs/protocol/artifacts.md +18 -2
  6. package/docs/protocol/config.md +15 -3
  7. package/docs/protocol/manual-gate.md +8 -8
  8. package/docs/protocol/overview.md +17 -4
  9. package/extension/adapters/commit.ts +5 -1
  10. package/extension/adapters/dispatch.ts +9 -1
  11. package/extension/adapters/setup.ts +15 -1
  12. package/extension/adapters/start.ts +5 -1
  13. package/extension/adapters/status.ts +17 -2
  14. package/extension/adapters/wait.ts +6 -1
  15. package/extension/api.ts +7 -1
  16. package/extension/cli/main.ts +67 -7
  17. package/extension/cli/parse.ts +172 -5
  18. package/extension/domain/paths.ts +2 -11
  19. package/extension/domain/timeouts.ts +4 -0
  20. package/extension/domain/types.ts +65 -3
  21. package/extension/errors.ts +51 -16
  22. package/extension/operation-hooks.ts +6 -0
  23. package/extension/registry.ts +29 -15
  24. package/extension/run-tool.ts +19 -2
  25. package/extension/schema/config.ts +58 -16
  26. package/extension/schema/frontmatter.ts +57 -0
  27. package/extension/schema/state.ts +210 -13
  28. package/extension/services/app-live.ts +2 -1
  29. package/extension/services/config.ts +6 -4
  30. package/extension/services/file-system.ts +346 -75
  31. package/extension/services/herdr.ts +389 -242
  32. package/extension/services/operation-lock.ts +418 -0
  33. package/extension/services/process.ts +477 -0
  34. package/extension/services/run-store.ts +38 -16
  35. package/extension/services/vcs.ts +1258 -328
  36. package/extension/workflows/commit.ts +214 -13
  37. package/extension/workflows/dispatch.ts +274 -57
  38. package/extension/workflows/setup.ts +59 -32
  39. package/extension/workflows/start.ts +6 -4
  40. package/extension/workflows/status.ts +2 -2
  41. package/extension/workflows/wait.ts +62 -77
  42. package/package.json +2 -2
  43. package/schemas/config.schema.json +5 -1
  44. package/schemas/state.schema.json +165 -11
@@ -1,4 +1,3 @@
1
- import { spawnSync } from "node:child_process"
2
1
  import * as fs from "node:fs"
3
2
  import * as path from "node:path"
4
3
  import { Clock, Context, Effect, Layer, Option, Result } from "effect"
@@ -6,9 +5,19 @@ import { shellJoin } from "../domain/herdr.ts"
6
5
  import { HerdrError } from "../errors.ts"
7
6
  import type { ApneaHostAdapter } from "../host-adapter.ts"
8
7
  import { neutralHostAdapter } from "../host-adapter.ts"
8
+ import {
9
+ Process,
10
+ ProcessCancelledError,
11
+ ProcessExitError,
12
+ ProcessTimeoutError,
13
+ type ProcessError,
14
+ type ProcessService,
15
+ } from "./process.ts"
9
16
 
10
17
  export type PaneInfo = {
11
18
  ok: boolean
19
+ /** True only when Herdr explicitly reports that this pane does not exist. */
20
+ missing?: boolean
12
21
  agent_status?: string
13
22
  label?: string
14
23
  agent?: string
@@ -28,7 +37,7 @@ export interface HerdrService {
28
37
  readonly enabled: Effect.Effect<boolean>
29
38
  /** Dispatch preflight that distinguishes a stale pane from CLI failures. */
30
39
  readonly availability: Effect.Effect<HerdrAvailability, HerdrError>
31
- readonly paneGet: (paneId: string) => Effect.Effect<PaneInfo>
40
+ readonly paneGet: (paneId: string) => Effect.Effect<PaneInfo, HerdrError>
32
41
  readonly paneRun: (
33
42
  paneId: string,
34
43
  command: string,
@@ -61,22 +70,67 @@ export const paneReadRecentArgs = (paneId: string): string[] => [
61
70
  "text",
62
71
  ]
63
72
 
64
- function herdrCli(args: string[]): { ok: boolean; json: unknown; raw: string } {
65
- const r = spawnSync("herdr", args, {
66
- encoding: "utf8",
67
- maxBuffer: 10 * 1024 * 1024,
73
+ const HERDR_QUERY_TIMEOUT_MS = 10_000
74
+ const HERDR_MUTATION_TIMEOUT_MS = 30_000
75
+ const HERDR_OUTPUT_LIMIT_BYTES = 10 * 1024 * 1024
76
+
77
+ type HerdrCliResult = { ok: boolean; json: unknown; raw: string }
78
+
79
+ function processRaw(error: ProcessError): string {
80
+ return "stdout" in error ? `${error.stdout}${error.stderr}` : error.message
81
+ }
82
+
83
+ export function herdrCli(
84
+ processService: ProcessService,
85
+ args: string[],
86
+ options: { mutation?: boolean; timeoutMs?: number } = {},
87
+ ): Effect.Effect<HerdrCliResult, HerdrError> {
88
+ const command = shellJoin(["herdr", ...args])
89
+ return Effect.gen(function* () {
90
+ const result = yield* Effect.result(
91
+ processService.run({
92
+ command: "herdr",
93
+ args,
94
+ timeoutMs: options.timeoutMs ?? HERDR_QUERY_TIMEOUT_MS,
95
+ outputLimitBytes: HERDR_OUTPUT_LIMIT_BYTES,
96
+ }),
97
+ )
98
+ if (Result.isFailure(result)) {
99
+ const error = result.failure
100
+ const raw = processRaw(error)
101
+ if (error instanceof ProcessExitError) {
102
+ return { ok: false, json: null, raw }
103
+ }
104
+ const deliveryUnknown =
105
+ options.mutation &&
106
+ (error instanceof ProcessTimeoutError ||
107
+ error instanceof ProcessCancelledError)
108
+ return yield* new HerdrError({
109
+ message: `${command} failed: ${error.message}${raw ? `: ${raw.trim()}` : ""}`,
110
+ command,
111
+ details: {
112
+ ...(deliveryUnknown ? { delivery: "unknown" } : {}),
113
+ process_error: error._tag,
114
+ },
115
+ })
116
+ }
117
+ const raw = `${result.success.stdout}${result.success.stderr}`
118
+ const line = result.success.stdout.trim().split(/\n/).filter(Boolean).pop()
119
+ if (!line) {
120
+ return yield* new HerdrError({
121
+ message: `${command} returned no JSON output`,
122
+ command,
123
+ })
124
+ }
125
+ try {
126
+ return { ok: true, json: JSON.parse(line), raw }
127
+ } catch {
128
+ return yield* new HerdrError({
129
+ message: `${command} returned malformed JSON: ${raw.trim() || "empty output"}`,
130
+ command,
131
+ })
132
+ }
68
133
  })
69
- const raw = `${r.stdout ?? ""}${r.stderr ?? ""}`
70
- if (r.status !== 0) {
71
- return { ok: false, json: null, raw }
72
- }
73
- // herdr often prints one JSON object
74
- const line = (r.stdout ?? "").trim().split(/\n/).filter(Boolean).pop() ?? ""
75
- try {
76
- return { ok: true, json: JSON.parse(line), raw }
77
- } catch {
78
- return { ok: true, json: null, raw }
79
- }
80
134
  }
81
135
 
82
136
  function resultOf(json: unknown): Record<string, unknown> | null {
@@ -137,98 +191,157 @@ export function probeHerdrAvailability(
137
191
  })
138
192
  }
139
193
 
140
- function herdrAvailabilitySync(): HerdrAvailability {
141
- return probeHerdrAvailability(
142
- {
143
- HERDR_ENV: process.env.HERDR_ENV,
144
- HERDR_PANE_ID: process.env.HERDR_PANE_ID,
145
- },
146
- (paneId) => herdrCli(["pane", "get", paneId]),
147
- )
148
- }
149
-
150
- function paneGetSync(paneId: string): PaneInfo {
151
- const r = herdrCli(["pane", "get", paneId])
152
- if (!r.ok) return { ok: false }
153
- const res = resultOf(r.json)
154
- const pane = (res?.pane as Record<string, unknown>) ?? {}
155
- return {
156
- ok: true,
157
- agent_status: pane.agent_status ? String(pane.agent_status) : undefined,
158
- label: pane.label ? String(pane.label) : undefined,
159
- agent: pane.agent ? String(pane.agent) : undefined,
194
+ function herdrAvailability(
195
+ processService: ProcessService,
196
+ ): Effect.Effect<HerdrAvailability, HerdrError> {
197
+ if (process.env.HERDR_ENV !== "1" || !process.env.HERDR_PANE_ID) {
198
+ return Effect.succeed("unavailable")
160
199
  }
200
+ const current = process.env.HERDR_PANE_ID
201
+ return Effect.gen(function* () {
202
+ const r = yield* herdrCli(processService, ["pane", "get", current])
203
+ if (r.ok) return "available"
204
+ if (/pane_not_found|pane not found/i.test(r.raw)) return "unavailable"
205
+ return yield* new HerdrError({
206
+ message: `failed to verify current Herdr pane ${current}: ${r.raw.trim() || "unknown herdr error"}`,
207
+ command: "herdr pane get",
208
+ })
209
+ })
161
210
  }
162
211
 
163
- function paneAliveSync(paneId: string): boolean {
164
- return paneGetSync(paneId).ok
212
+ export function paneGet(
213
+ processService: ProcessService,
214
+ paneId: string,
215
+ ): Effect.Effect<PaneInfo, HerdrError> {
216
+ return Effect.gen(function* () {
217
+ const r = yield* herdrCli(processService, ["pane", "get", paneId])
218
+ if (!r.ok) {
219
+ return {
220
+ ok: false,
221
+ missing: /pane_not_found|pane not found/i.test(r.raw),
222
+ }
223
+ }
224
+ const res = resultOf(r.json)
225
+ if (!res?.pane || typeof res.pane !== "object") {
226
+ return yield* new HerdrError({
227
+ message: `herdr pane get returned no pane for ${paneId}`,
228
+ command: "herdr pane get",
229
+ })
230
+ }
231
+ const pane = res.pane as Record<string, unknown>
232
+ return {
233
+ ok: true,
234
+ agent_status: pane.agent_status ? String(pane.agent_status) : undefined,
235
+ label: pane.label ? String(pane.label) : undefined,
236
+ agent: pane.agent ? String(pane.agent) : undefined,
237
+ }
238
+ })
165
239
  }
166
240
 
167
- function paneReadRecentSync(paneId: string): string {
241
+ function paneReadRecent(
242
+ processService: ProcessService,
243
+ paneId: string,
244
+ ): Effect.Effect<string, HerdrError> {
168
245
  const args = paneReadRecentArgs(paneId)
169
- const r = spawnSync("herdr", args, {
170
- encoding: "utf8",
171
- maxBuffer: 10 * 1024 * 1024,
246
+ return Effect.gen(function* () {
247
+ const r = yield* Effect.result(
248
+ processService.run({
249
+ command: "herdr",
250
+ args,
251
+ timeoutMs: HERDR_QUERY_TIMEOUT_MS,
252
+ outputLimitBytes: HERDR_OUTPUT_LIMIT_BYTES,
253
+ }),
254
+ )
255
+ if (Result.isFailure(r)) {
256
+ const output = processRaw(r.failure)
257
+ .trim()
258
+ .split(/\r?\n/)
259
+ .slice(-80)
260
+ .join("\n")
261
+ throw new HerdrError({
262
+ message: `herdr pane read failed for ${paneId}${output ? `: ${output}` : ""}`,
263
+ command: shellJoin(["herdr", ...args]),
264
+ ...(output ? { details: { output } } : {}),
265
+ })
266
+ }
267
+ return r.success.stdout
172
268
  })
173
- if (r.status !== 0 || r.error) {
174
- const output = `${r.stdout ?? ""}${r.stderr ?? ""}${r.error?.message ?? ""}`
175
- .trim()
176
- .split(/\r?\n/)
177
- .slice(-80)
178
- .join("\n")
179
- throw new HerdrError({
180
- message: `herdr pane read failed for ${paneId}${output ? `: ${output}` : ""}`,
181
- command: shellJoin(["herdr", ...args]),
182
- ...(output ? { details: { output } } : {}),
183
- })
184
- }
185
- return r.stdout ?? ""
186
269
  }
187
270
 
188
271
  /** Prefer right on wide panes, down on tall/narrow ones. */
189
- function splitDirectionSync(): "right" | "down" {
272
+ function splitDirection(
273
+ processService: ProcessService,
274
+ ): Effect.Effect<"right" | "down", HerdrError> {
190
275
  const current = process.env.HERDR_PANE_ID
191
- if (!current) return "right"
192
- const r = herdrCli(["pane", "layout", "--pane", current])
193
- const res = resultOf(r.json)
194
- const layout = res?.layout as Record<string, unknown> | undefined
195
- const panes = (layout?.panes as Array<Record<string, unknown>>) ?? []
196
- const me = panes.find((p) => String(p.pane_id) === current)
197
- const rect = me?.rect as { width?: number; height?: number } | undefined
198
- if (rect?.width != null && rect?.height != null) {
199
- return rect.width >= rect.height ? "right" : "down"
200
- }
201
- return "right"
276
+ if (!current) return Effect.succeed("right")
277
+ return Effect.gen(function* () {
278
+ const r = yield* herdrCli(processService, [
279
+ "pane",
280
+ "layout",
281
+ "--pane",
282
+ current,
283
+ ])
284
+ const res = resultOf(r.json)
285
+ const layout = res?.layout as Record<string, unknown> | undefined
286
+ if (!Array.isArray(layout?.panes)) {
287
+ return yield* new HerdrError({
288
+ message: "herdr pane layout returned no panes",
289
+ command: "herdr pane layout",
290
+ })
291
+ }
292
+ const panes = layout.panes as Array<Record<string, unknown>>
293
+ const me = panes.find((p) => String(p.pane_id) === current)
294
+ const rect = me?.rect as { width?: number; height?: number } | undefined
295
+ if (rect?.width != null && rect?.height != null) {
296
+ return rect.width >= rect.height ? "right" : "down"
297
+ }
298
+ return "right"
299
+ })
202
300
  }
203
301
 
204
- function splitPaneSync(): string {
205
- const direction = splitDirectionSync()
206
- const r = herdrCli([
207
- "pane",
208
- "split",
209
- "--current",
210
- "--direction",
211
- direction,
212
- "--no-focus",
213
- ])
214
- if (!r.ok)
215
- throw new HerdrError({ message: `herdr pane split failed: ${r.raw}` })
216
- const res = resultOf(r.json)
217
- const pane = res?.pane as Record<string, unknown> | undefined
218
- const id = pane?.pane_id ? String(pane.pane_id) : null
219
- if (!id) {
220
- throw new HerdrError({
221
- message: `herdr pane split: no pane_id in ${r.raw}`,
222
- })
223
- }
224
- return id
302
+ function splitPane(
303
+ processService: ProcessService,
304
+ ): Effect.Effect<string, HerdrError> {
305
+ return Effect.gen(function* () {
306
+ const direction = yield* splitDirection(processService)
307
+ const r = yield* herdrCli(
308
+ processService,
309
+ ["pane", "split", "--current", "--direction", direction, "--no-focus"],
310
+ { mutation: true },
311
+ )
312
+ if (!r.ok)
313
+ return yield* new HerdrError({
314
+ message: `herdr pane split failed: ${r.raw}`,
315
+ })
316
+ const res = resultOf(r.json)
317
+ const pane = res?.pane as Record<string, unknown> | undefined
318
+ const id = pane?.pane_id ? String(pane.pane_id) : null
319
+ if (!id) {
320
+ return yield* new HerdrError({
321
+ message: `herdr pane split: no pane_id in ${r.raw}`,
322
+ })
323
+ }
324
+ return id
325
+ })
225
326
  }
226
327
 
227
- function renamePaneSync(paneId: string, label: string): void {
228
- const r = herdrCli(["pane", "rename", paneId, label])
229
- if (!r.ok) {
230
- throw new HerdrError({ message: `herdr pane rename failed: ${r.raw}` })
231
- }
328
+ function renamePane(
329
+ processService: ProcessService,
330
+ paneId: string,
331
+ label: string,
332
+ ): Effect.Effect<void, HerdrError> {
333
+ return Effect.gen(function* () {
334
+ const r = yield* herdrCli(
335
+ processService,
336
+ ["pane", "rename", paneId, label],
337
+ { mutation: true },
338
+ )
339
+ if (!r.ok) {
340
+ return yield* new HerdrError({
341
+ message: `herdr pane rename failed: ${r.raw}`,
342
+ })
343
+ }
344
+ })
232
345
  }
233
346
 
234
347
  /**
@@ -236,85 +349,97 @@ function renamePaneSync(paneId: string, label: string): void {
236
349
  * When a live agent TUI is focused, this submits a prompt (not a shell command).
237
350
  * When the pane is a bare shell, this runs a shell line.
238
351
  */
239
- function paneRunSync(paneId: string, command: string): void {
240
- const r = herdrCli(["pane", "run", paneId, command])
241
- if (!r.ok) {
242
- throw new HerdrError({
243
- message: `herdr pane run failed: ${r.raw}`,
244
- command: "herdr pane run",
245
- })
246
- }
352
+ function paneRun(
353
+ processService: ProcessService,
354
+ paneId: string,
355
+ command: string,
356
+ ): Effect.Effect<void, HerdrError> {
357
+ return Effect.gen(function* () {
358
+ const r = yield* herdrCli(
359
+ processService,
360
+ ["pane", "run", paneId, command],
361
+ { mutation: true },
362
+ )
363
+ if (!r.ok) {
364
+ return yield* new HerdrError({
365
+ message: `herdr pane run failed: ${r.raw}`,
366
+ command: "herdr pane run",
367
+ })
368
+ }
369
+ })
247
370
  }
248
371
 
249
372
  /** Send raw key names (e.g. Escape, Enter) into a pane. */
250
- function paneSendKeysSync(paneId: string, keys: string[]): void {
251
- if (keys.length === 0) return
252
- const r = herdrCli(["pane", "send-keys", paneId, ...keys])
253
- if (!r.ok) {
254
- throw new HerdrError({ message: `herdr pane send-keys failed: ${r.raw}` })
255
- }
373
+ function paneSendKeys(
374
+ processService: ProcessService,
375
+ paneId: string,
376
+ keys: string[],
377
+ ): Effect.Effect<void, HerdrError> {
378
+ if (keys.length === 0) return Effect.void
379
+ return Effect.gen(function* () {
380
+ const r = yield* herdrCli(
381
+ processService,
382
+ ["pane", "send-keys", paneId, ...keys],
383
+ { mutation: true },
384
+ )
385
+ if (!r.ok) {
386
+ return yield* new HerdrError({
387
+ message: `herdr pane send-keys failed: ${r.raw}`,
388
+ })
389
+ }
390
+ })
256
391
  }
257
392
 
258
- function paneForegroundNamesSync(paneId: string): string[] {
259
- try {
260
- const r = spawnSync("herdr", ["pane", "process-info", "--pane", paneId], {
261
- encoding: "utf8",
262
- maxBuffer: 2 * 1024 * 1024,
263
- })
264
- if (r.status !== 0) return []
265
- const line = (r.stdout ?? "").trim().split(/\n/).filter(Boolean).pop() ?? ""
266
- const json = JSON.parse(line) as {
267
- result?: {
268
- process_info?: {
269
- foreground_processes?: Array<{
270
- name?: string
271
- argv0?: string
272
- cmdline?: string
273
- }>
274
- }
275
- }
393
+ function paneForegroundNames(
394
+ processService: ProcessService,
395
+ paneId: string,
396
+ ): Effect.Effect<string[]> {
397
+ return Effect.gen(function* () {
398
+ const r = yield* herdrCli(processService, [
399
+ "pane",
400
+ "process-info",
401
+ "--pane",
402
+ paneId,
403
+ ])
404
+ const res = resultOf(r.json)
405
+ const processInfo = res?.process_info as Record<string, unknown> | undefined
406
+ if (!Array.isArray(processInfo?.foreground_processes)) {
407
+ return yield* new HerdrError({
408
+ message: "herdr pane process-info returned no foreground_processes",
409
+ command: "herdr pane process-info",
410
+ })
276
411
  }
277
- const procs = json.result?.process_info?.foreground_processes ?? []
412
+ const procs = processInfo.foreground_processes as Array<{
413
+ name?: string
414
+ argv0?: string
415
+ cmdline?: string
416
+ }>
278
417
  return procs.map((p) => p.cmdline || p.argv0 || p.name || "?")
279
- } catch {
280
- return []
281
- }
418
+ }).pipe(Effect.catch(() => Effect.succeed([])))
282
419
  }
283
420
 
284
- function toHerdrError(e: unknown): HerdrError {
285
- return e instanceof HerdrError
286
- ? e
287
- : new HerdrError({ message: e instanceof Error ? e.message : String(e) })
421
+ function toHerdrError(error: unknown): HerdrError {
422
+ return error instanceof HerdrError
423
+ ? error
424
+ : new HerdrError({
425
+ message: error instanceof Error ? error.message : String(error),
426
+ })
288
427
  }
289
428
 
290
- /**
291
- * Effect wrappers for the throwing `*Sync` helpers. A `throw` inside
292
- * `Effect.gen` is a defect, and defects pass straight through `Effect.ignore` /
293
- * `Effect.option` — so every sync herdr call must go through `Effect.try` for
294
- * best-effort recovery blocks to actually be best-effort.
295
- */
296
- function paneRun(
429
+ function paneClose(
430
+ processService: ProcessService,
297
431
  paneId: string,
298
- command: string,
299
432
  ): Effect.Effect<void, HerdrError> {
300
- return Effect.try({
301
- try: () => paneRunSync(paneId, command),
302
- catch: toHerdrError,
303
- })
304
- }
305
-
306
- function paneClose(paneId: string): Effect.Effect<void, HerdrError> {
307
- return Effect.try({
308
- try: () => {
309
- const r = herdrCli(["pane", "close", paneId])
310
- if (!r.ok) {
311
- throw new HerdrError({
312
- message: `herdr pane close failed: ${r.raw}`,
313
- command: "herdr pane close",
314
- })
315
- }
316
- },
317
- catch: toHerdrError,
433
+ return Effect.gen(function* () {
434
+ const r = yield* herdrCli(processService, ["pane", "close", paneId], {
435
+ mutation: true,
436
+ })
437
+ if (!r.ok) {
438
+ return yield* new HerdrError({
439
+ message: `herdr pane close failed: ${r.raw}`,
440
+ command: "herdr pane close",
441
+ })
442
+ }
318
443
  })
319
444
  }
320
445
 
@@ -333,7 +458,7 @@ function withLaunchDetails(
333
458
  export function cleanupFailedInteractiveLaunch(
334
459
  error: HerdrError,
335
460
  paneId: string,
336
- close: (paneId: string) => Effect.Effect<void, HerdrError> = paneClose,
461
+ close: (paneId: string) => Effect.Effect<void, HerdrError>,
337
462
  ): Effect.Effect<never, HerdrError> {
338
463
  return Effect.gen(function* () {
339
464
  const cleanup = yield* Effect.result(close(paneId))
@@ -349,16 +474,6 @@ export function cleanupFailedInteractiveLaunch(
349
474
  })
350
475
  }
351
476
 
352
- function sendKeys(
353
- paneId: string,
354
- keys: string[],
355
- ): Effect.Effect<void, HerdrError> {
356
- return Effect.try({
357
- try: () => paneSendKeysSync(paneId, keys),
358
- catch: toHerdrError,
359
- })
360
- }
361
-
362
477
  /** Unique label for a role slot (stable for the run when we reuse the pane). */
363
478
  function roleLabel(role: string, millis: number): string {
364
479
  const id = `${millis.toString(36)}-${Math.random().toString(36).slice(2, 6)}`
@@ -370,23 +485,28 @@ function roleLabel(role: string, millis: number): string {
370
485
  * Uses herdr wait when available; falls back to poll.
371
486
  */
372
487
  function waitAgentReady(
488
+ processService: ProcessService,
373
489
  paneId: string,
374
490
  timeoutMs = 90_000,
375
- ): Effect.Effect<string | undefined> {
491
+ ): Effect.Effect<string | undefined, HerdrError> {
376
492
  return Effect.gen(function* () {
377
493
  // Prefer Herdr's blocking wait (does not freeze our caller if we use it
378
494
  // only for short readiness; dispatch is already a tool call).
379
- const r = herdrCli([
380
- "wait",
381
- "agent-status",
382
- paneId,
383
- "--status",
384
- "idle",
385
- "--timeout",
386
- String(timeoutMs),
387
- ])
495
+ const r = yield* herdrCli(
496
+ processService,
497
+ [
498
+ "wait",
499
+ "agent-status",
500
+ paneId,
501
+ "--status",
502
+ "idle",
503
+ "--timeout",
504
+ String(timeoutMs),
505
+ ],
506
+ { timeoutMs: timeoutMs + 5_000 },
507
+ )
388
508
  if (r.ok) {
389
- const s = paneGetSync(paneId).agent_status
509
+ const s = (yield* paneGet(processService, paneId)).agent_status
390
510
  if (s === "idle" || s === "done") return s
391
511
  }
392
512
  // fall back: poll (done also counts as ready). Clock, not Date.now(): the
@@ -395,32 +515,34 @@ function waitAgentReady(
395
515
  const deadline =
396
516
  (yield* Clock.currentTimeMillis) + Math.min(timeoutMs, 30_000)
397
517
  while ((yield* Clock.currentTimeMillis) < deadline) {
398
- const s = paneGetSync(paneId).agent_status
518
+ const s = (yield* paneGet(processService, paneId)).agent_status
399
519
  if (s === "idle" || s === "done") return s
400
520
  yield* Effect.sleep(500)
401
521
  }
402
- return paneGetSync(paneId).agent_status
522
+ return (yield* paneGet(processService, paneId)).agent_status
403
523
  })
404
524
  }
405
525
 
406
526
  /**
407
527
  * The three pane operations the recovery ladder drives.
408
528
  *
409
- * Injectable because the ladder cannot otherwise be tested: Bun's `spawnSync`
410
- * resolves binaries against the process's real PATH and ignores mutations to
411
- * `process.env.PATH`, so a fake `herdr` placed on a temp PATH is never invoked.
529
+ * Injectable so the recovery ladder can be tested without a Herdr process.
412
530
  */
413
531
  export type PromptProbes = {
414
- readonly status: () => string | undefined
532
+ readonly status: () => Effect.Effect<string | undefined, HerdrError>
415
533
  readonly sendKeys: (keys: string[]) => Effect.Effect<void, HerdrError>
416
534
  readonly run: (text: string) => Effect.Effect<void, HerdrError>
417
535
  }
418
536
 
419
- function livePromptProbes(paneId: string): PromptProbes {
537
+ function livePromptProbes(
538
+ processService: ProcessService,
539
+ paneId: string,
540
+ ): PromptProbes {
420
541
  return {
421
- status: () => paneGetSync(paneId).agent_status,
422
- sendKeys: (keys) => sendKeys(paneId, keys),
423
- run: (text) => paneRun(paneId, text),
542
+ status: () =>
543
+ Effect.map(paneGet(processService, paneId), (info) => info.agent_status),
544
+ sendKeys: (keys) => paneSendKeys(processService, paneId, keys),
545
+ run: (text) => paneRun(processService, paneId, text),
424
546
  }
425
547
  }
426
548
 
@@ -437,27 +559,42 @@ export function ensurePromptSubmitted(
437
559
  settleMs?: number
438
560
  workingWaitMs?: number
439
561
  probes?: PromptProbes
562
+ processService?: ProcessService
440
563
  },
441
- ): Effect.Effect<{
442
- accepted: boolean
443
- attempts: number
444
- last_status?: string
445
- }> {
564
+ ): Effect.Effect<
565
+ {
566
+ accepted: boolean
567
+ attempts: number
568
+ last_status?: string
569
+ },
570
+ HerdrError
571
+ > {
446
572
  return Effect.gen(function* () {
447
- const probes = opts?.probes ?? livePromptProbes(paneId)
573
+ const probes =
574
+ opts?.probes ??
575
+ (opts?.processService
576
+ ? livePromptProbes(opts.processService, paneId)
577
+ : undefined)
578
+ if (!probes) {
579
+ return yield* new HerdrError({
580
+ message: "prompt probes or process service are required",
581
+ })
582
+ }
448
583
  const settleMs = opts?.settleMs ?? 2500
449
584
  const workingWaitMs = opts?.workingWaitMs ?? 12_000
450
585
  let attempts = 1
451
586
 
452
- const waitForWorking = (ms: number): Effect.Effect<string | undefined> =>
587
+ const waitForWorking = (
588
+ ms: number,
589
+ ): Effect.Effect<string | undefined, HerdrError> =>
453
590
  Effect.gen(function* () {
454
591
  const deadline = (yield* Clock.currentTimeMillis) + ms
455
592
  while ((yield* Clock.currentTimeMillis) < deadline) {
456
- const s = probes.status()
593
+ const s = yield* probes.status()
457
594
  if (s === "working" || s === "blocked") return s
458
595
  yield* Effect.sleep(400)
459
596
  }
460
- return probes.status()
597
+ return yield* probes.status()
461
598
  })
462
599
 
463
600
  // Give the first paneRun a moment to flip status.
@@ -498,7 +635,7 @@ export function ensurePromptSubmitted(
498
635
  return {
499
636
  accepted: false,
500
637
  attempts,
501
- last_status: probes.status(),
638
+ last_status: yield* probes.status(),
502
639
  }
503
640
  }
504
641
  yield* Effect.sleep(settleMs)
@@ -519,6 +656,7 @@ export function ensurePromptSubmitted(
519
656
  * Never claims an unrelated pane by scanning labels alone.
520
657
  */
521
658
  function acquireRolePane(
659
+ processService: ProcessService,
522
660
  role: string,
523
661
  hostAdapter: ApneaHostAdapter,
524
662
  opts?: {
@@ -534,7 +672,10 @@ function acquireRolePane(
534
672
  })
535
673
  }
536
674
 
537
- if (opts?.prefer?.pane_id && paneAliveSync(opts.prefer.pane_id)) {
675
+ if (
676
+ opts?.prefer?.pane_id &&
677
+ (yield* paneGet(processService, opts.prefer.pane_id)).ok
678
+ ) {
538
679
  return {
539
680
  pane_id: opts.prefer.pane_id,
540
681
  label: opts.prefer.label,
@@ -544,25 +685,20 @@ function acquireRolePane(
544
685
 
545
686
  const millis = yield* Clock.currentTimeMillis
546
687
  const label = roleLabel(role, millis)
547
- const split = yield* Effect.result(
548
- Effect.try({
549
- try: () => splitPaneSync(),
550
- catch: toHerdrError,
551
- }),
552
- )
688
+ const split = yield* Effect.result(splitPane(processService))
553
689
  if (Result.isFailure(split)) {
554
690
  return yield* withLaunchDetails(split.failure, {
555
- delivery: "not_delivered",
691
+ delivery:
692
+ split.failure.details?.delivery === "unknown"
693
+ ? "unknown"
694
+ : "not_delivered",
556
695
  newly_created: false,
557
696
  })
558
697
  }
559
698
  const paneId = split.success
560
699
  const prepared = yield* Effect.result(
561
700
  Effect.gen(function* () {
562
- yield* Effect.try({
563
- try: () => renamePaneSync(paneId, label),
564
- catch: toHerdrError,
565
- })
701
+ yield* renamePane(processService, paneId, label)
566
702
  if (!opts?.interactiveCmd?.length) return
567
703
  // Launch the interactive harness only (no task argv).
568
704
  // Pi roles get PI_CODING_AGENT_DIR without pi-vimmode so pane-run pastes
@@ -576,11 +712,23 @@ function acquireRolePane(
576
712
  catch: toHerdrError,
577
713
  })
578
714
  const cmd = shellJoin(["cd", process.cwd(), "&&", "exec", ...launchCmd])
579
- yield* paneRun(paneId, cmd)
715
+ yield* paneRun(processService, paneId, cmd)
580
716
  }),
581
717
  )
582
718
  if (Result.isFailure(prepared)) {
583
- return yield* cleanupFailedInteractiveLaunch(prepared.failure, paneId)
719
+ if (prepared.failure.details?.delivery === "unknown") {
720
+ return yield* withLaunchDetails(prepared.failure, {
721
+ delivery: "unknown",
722
+ pane_id: paneId,
723
+ pane_label: label,
724
+ newly_created: true,
725
+ })
726
+ }
727
+ return yield* cleanupFailedInteractiveLaunch(
728
+ prepared.failure,
729
+ paneId,
730
+ (id) => paneClose(processService, id),
731
+ )
584
732
  }
585
733
  return { pane_id: paneId, label, reused: false }
586
734
  })
@@ -594,6 +742,7 @@ function acquireRolePane(
594
742
  * `claude -p` / `pi -p` dumping shell output.
595
743
  */
596
744
  function runInteractivePromptImpl(
745
+ processService: ProcessService,
597
746
  hostAdapter: ApneaHostAdapter,
598
747
  role: string,
599
748
  interactiveCmd: string[],
@@ -604,7 +753,7 @@ function runInteractivePromptImpl(
604
753
  let preferUse: RolePaneRef | null = null
605
754
  if (prefer?.pane_id) {
606
755
  // One `pane get`: liveness and agent_status come from the same call.
607
- const info = paneGetSync(prefer.pane_id)
756
+ const info = yield* paneGet(processService, prefer.pane_id)
608
757
  // reuse only when a live agent can take a new prompt
609
758
  // working/blocked/unknown/shell-only → new pane
610
759
  if (
@@ -615,19 +764,19 @@ function runInteractivePromptImpl(
615
764
  }
616
765
  }
617
766
 
618
- const acquired = yield* acquireRolePane(role, hostAdapter, {
767
+ const acquired = yield* acquireRolePane(processService, role, hostAdapter, {
619
768
  prefer: preferUse,
620
769
  interactiveCmd: preferUse ? undefined : interactiveCmd,
621
770
  })
622
771
 
623
772
  if (!acquired.reused) {
624
- yield* waitAgentReady(acquired.pane_id, 90_000)
773
+ yield* waitAgentReady(processService, acquired.pane_id, 90_000)
625
774
  // still try even if not idle/done — some harnesses accept input
626
775
  // before status settles.
627
776
  } else {
628
- const st = paneGetSync(acquired.pane_id).agent_status
777
+ const st = (yield* paneGet(processService, acquired.pane_id)).agent_status
629
778
  if (st !== "idle" && st !== "done") {
630
- yield* waitAgentReady(acquired.pane_id, 30_000)
779
+ yield* waitAgentReady(processService, acquired.pane_id, 30_000)
631
780
  }
632
781
  }
633
782
 
@@ -635,15 +784,17 @@ function runInteractivePromptImpl(
635
784
  if (beforePrompt) {
636
785
  // Host preparation is best-effort; command wrapping is the primary guard.
637
786
  yield* Effect.gen(function* () {
638
- yield* paneRun(acquired.pane_id, beforePrompt)
639
- yield* waitAgentReady(acquired.pane_id, 5_000)
787
+ yield* paneRun(processService, acquired.pane_id, beforePrompt)
788
+ yield* waitAgentReady(processService, acquired.pane_id, 5_000)
640
789
  yield* Effect.sleep(300)
641
790
  }).pipe(Effect.ignore)
642
791
  }
643
792
 
644
793
  // Submit pointer into the live TUI (Herdr: pane run = text + Enter),
645
794
  // then confirm the agent actually started — do not trust fire-and-forget.
646
- const submitted = yield* Effect.result(paneRun(acquired.pane_id, prompt))
795
+ const submitted = yield* Effect.result(
796
+ paneRun(processService, acquired.pane_id, prompt),
797
+ )
647
798
  if (Result.isFailure(submitted)) {
648
799
  return yield* withLaunchDetails(submitted.failure, {
649
800
  // The Herdr CLI can lose its response after the pane accepted text.
@@ -654,7 +805,9 @@ function runInteractivePromptImpl(
654
805
  reused: acquired.reused,
655
806
  })
656
807
  }
657
- const submit = yield* ensurePromptSubmitted(acquired.pane_id, prompt)
808
+ const submit = yield* ensurePromptSubmitted(acquired.pane_id, prompt, {
809
+ processService,
810
+ })
658
811
  return {
659
812
  pane_id: acquired.pane_id,
660
813
  label: acquired.label,
@@ -673,32 +826,26 @@ function runInteractivePromptImpl(
673
826
  export const makeHerdrLive = (hostAdapter: ApneaHostAdapter) =>
674
827
  Layer.effect(
675
828
  Herdr,
676
- Effect.sync(() =>
677
- Herdr.of({
829
+ Effect.gen(function* () {
830
+ const processService = yield* Process
831
+ return Herdr.of({
678
832
  enabled: Effect.sync(herdrEnabledSync),
679
833
 
680
- availability: Effect.try({
681
- try: herdrAvailabilitySync,
682
- catch: toHerdrError,
683
- }),
834
+ availability: herdrAvailability(processService),
684
835
 
685
- paneGet: (paneId) => Effect.sync(() => paneGetSync(paneId)),
836
+ paneGet: (paneId) => paneGet(processService, paneId),
686
837
 
687
- paneRun,
838
+ paneRun: (paneId, command) => paneRun(processService, paneId, command),
688
839
 
689
- paneReadRecent: (paneId) =>
690
- Effect.try({
691
- try: () => paneReadRecentSync(paneId),
692
- catch: toHerdrError,
693
- }),
840
+ paneReadRecent: (paneId) => paneReadRecent(processService, paneId),
694
841
 
695
842
  paneForegroundNames: (paneId) =>
696
- Effect.sync(() => paneForegroundNamesSync(paneId)),
843
+ paneForegroundNames(processService, paneId),
697
844
 
698
845
  runInteractivePrompt: (...args) =>
699
- runInteractivePromptImpl(hostAdapter, ...args),
700
- }),
701
- ),
846
+ runInteractivePromptImpl(processService, hostAdapter, ...args),
847
+ })
848
+ }),
702
849
  )
703
850
 
704
851
  export const HerdrLive = makeHerdrLive(neutralHostAdapter)