@naxodev/apnea 0.2.0 → 0.2.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 (44) hide show
  1. package/README.md +18 -1
  2. package/SECURITY.md +36 -0
  3. package/briefs/orchestrator.md +4 -3
  4. package/dist/cli.js +8375 -15093
  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 +466 -260
  32. package/extension/services/operation-lock.ts +452 -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 +305 -67
  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,14 +5,28 @@ 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
15
24
  }
16
25
  export type RolePaneRef = { pane_id: string; label: string }
26
+ /** Persist pane ownership before any task prompt can reach the pane. */
27
+ export type BeforeInteractiveDelivery = (
28
+ pane: RolePaneRef,
29
+ ) => Effect.Effect<void, HerdrError>
17
30
  export type HerdrAvailability = "available" | "unavailable"
18
31
  export type InteractiveLaunch = {
19
32
  pane_id: string
@@ -28,7 +41,7 @@ export interface HerdrService {
28
41
  readonly enabled: Effect.Effect<boolean>
29
42
  /** Dispatch preflight that distinguishes a stale pane from CLI failures. */
30
43
  readonly availability: Effect.Effect<HerdrAvailability, HerdrError>
31
- readonly paneGet: (paneId: string) => Effect.Effect<PaneInfo>
44
+ readonly paneGet: (paneId: string) => Effect.Effect<PaneInfo, HerdrError>
32
45
  readonly paneRun: (
33
46
  paneId: string,
34
47
  command: string,
@@ -42,6 +55,8 @@ export interface HerdrService {
42
55
  interactiveCmd: string[],
43
56
  prompt: string,
44
57
  prefer: RolePaneRef | null,
58
+ beforeDelivery?: BeforeInteractiveDelivery,
59
+ onAcquisitionFailure?: () => Effect.Effect<void>,
45
60
  ) => Effect.Effect<InteractiveLaunch, HerdrError>
46
61
  }
47
62
 
@@ -61,22 +76,67 @@ export const paneReadRecentArgs = (paneId: string): string[] => [
61
76
  "text",
62
77
  ]
63
78
 
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,
79
+ const HERDR_QUERY_TIMEOUT_MS = 10_000
80
+ const HERDR_MUTATION_TIMEOUT_MS = 30_000
81
+ const HERDR_OUTPUT_LIMIT_BYTES = 10 * 1024 * 1024
82
+
83
+ type HerdrCliResult = { ok: boolean; json: unknown; raw: string }
84
+
85
+ function processRaw(error: ProcessError): string {
86
+ return "stdout" in error ? `${error.stdout}${error.stderr}` : error.message
87
+ }
88
+
89
+ export function herdrCli(
90
+ processService: ProcessService,
91
+ args: string[],
92
+ options: { mutation?: boolean; timeoutMs?: number } = {},
93
+ ): Effect.Effect<HerdrCliResult, HerdrError> {
94
+ const command = shellJoin(["herdr", ...args])
95
+ return Effect.gen(function* () {
96
+ const result = yield* Effect.result(
97
+ processService.run({
98
+ command: "herdr",
99
+ args,
100
+ timeoutMs: options.timeoutMs ?? HERDR_QUERY_TIMEOUT_MS,
101
+ outputLimitBytes: HERDR_OUTPUT_LIMIT_BYTES,
102
+ }),
103
+ )
104
+ if (Result.isFailure(result)) {
105
+ const error = result.failure
106
+ const raw = processRaw(error)
107
+ if (error instanceof ProcessExitError) {
108
+ return { ok: false, json: null, raw }
109
+ }
110
+ const deliveryUnknown =
111
+ options.mutation &&
112
+ (error instanceof ProcessTimeoutError ||
113
+ error instanceof ProcessCancelledError)
114
+ return yield* new HerdrError({
115
+ message: `${command} failed: ${error.message}${raw ? `: ${raw.trim()}` : ""}`,
116
+ command,
117
+ details: {
118
+ ...(deliveryUnknown ? { delivery: "unknown" } : {}),
119
+ process_error: error._tag,
120
+ },
121
+ })
122
+ }
123
+ const raw = `${result.success.stdout}${result.success.stderr}`
124
+ const line = result.success.stdout.trim().split(/\n/).filter(Boolean).pop()
125
+ if (!line) {
126
+ return yield* new HerdrError({
127
+ message: `${command} returned no JSON output`,
128
+ command,
129
+ })
130
+ }
131
+ try {
132
+ return { ok: true, json: JSON.parse(line), raw }
133
+ } catch {
134
+ return yield* new HerdrError({
135
+ message: `${command} returned malformed JSON: ${raw.trim() || "empty output"}`,
136
+ command,
137
+ })
138
+ }
68
139
  })
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
140
  }
81
141
 
82
142
  function resultOf(json: unknown): Record<string, unknown> | null {
@@ -137,98 +197,157 @@ export function probeHerdrAvailability(
137
197
  })
138
198
  }
139
199
 
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,
200
+ function herdrAvailability(
201
+ processService: ProcessService,
202
+ ): Effect.Effect<HerdrAvailability, HerdrError> {
203
+ if (process.env.HERDR_ENV !== "1" || !process.env.HERDR_PANE_ID) {
204
+ return Effect.succeed("unavailable")
160
205
  }
206
+ const current = process.env.HERDR_PANE_ID
207
+ return Effect.gen(function* () {
208
+ const r = yield* herdrCli(processService, ["pane", "get", current])
209
+ if (r.ok) return "available"
210
+ if (/pane_not_found|pane not found/i.test(r.raw)) return "unavailable"
211
+ return yield* new HerdrError({
212
+ message: `failed to verify current Herdr pane ${current}: ${r.raw.trim() || "unknown herdr error"}`,
213
+ command: "herdr pane get",
214
+ })
215
+ })
161
216
  }
162
217
 
163
- function paneAliveSync(paneId: string): boolean {
164
- return paneGetSync(paneId).ok
218
+ export function paneGet(
219
+ processService: ProcessService,
220
+ paneId: string,
221
+ ): Effect.Effect<PaneInfo, HerdrError> {
222
+ return Effect.gen(function* () {
223
+ const r = yield* herdrCli(processService, ["pane", "get", paneId])
224
+ if (!r.ok) {
225
+ return {
226
+ ok: false,
227
+ missing: /pane_not_found|pane not found/i.test(r.raw),
228
+ }
229
+ }
230
+ const res = resultOf(r.json)
231
+ if (!res?.pane || typeof res.pane !== "object") {
232
+ return yield* new HerdrError({
233
+ message: `herdr pane get returned no pane for ${paneId}`,
234
+ command: "herdr pane get",
235
+ })
236
+ }
237
+ const pane = res.pane as Record<string, unknown>
238
+ return {
239
+ ok: true,
240
+ agent_status: pane.agent_status ? String(pane.agent_status) : undefined,
241
+ label: pane.label ? String(pane.label) : undefined,
242
+ agent: pane.agent ? String(pane.agent) : undefined,
243
+ }
244
+ })
165
245
  }
166
246
 
167
- function paneReadRecentSync(paneId: string): string {
247
+ function paneReadRecent(
248
+ processService: ProcessService,
249
+ paneId: string,
250
+ ): Effect.Effect<string, HerdrError> {
168
251
  const args = paneReadRecentArgs(paneId)
169
- const r = spawnSync("herdr", args, {
170
- encoding: "utf8",
171
- maxBuffer: 10 * 1024 * 1024,
252
+ return Effect.gen(function* () {
253
+ const r = yield* Effect.result(
254
+ processService.run({
255
+ command: "herdr",
256
+ args,
257
+ timeoutMs: HERDR_QUERY_TIMEOUT_MS,
258
+ outputLimitBytes: HERDR_OUTPUT_LIMIT_BYTES,
259
+ }),
260
+ )
261
+ if (Result.isFailure(r)) {
262
+ const output = processRaw(r.failure)
263
+ .trim()
264
+ .split(/\r?\n/)
265
+ .slice(-80)
266
+ .join("\n")
267
+ throw new HerdrError({
268
+ message: `herdr pane read failed for ${paneId}${output ? `: ${output}` : ""}`,
269
+ command: shellJoin(["herdr", ...args]),
270
+ ...(output ? { details: { output } } : {}),
271
+ })
272
+ }
273
+ return r.success.stdout
172
274
  })
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
275
  }
187
276
 
188
277
  /** Prefer right on wide panes, down on tall/narrow ones. */
189
- function splitDirectionSync(): "right" | "down" {
278
+ function splitDirection(
279
+ processService: ProcessService,
280
+ ): Effect.Effect<"right" | "down", HerdrError> {
190
281
  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"
282
+ if (!current) return Effect.succeed("right")
283
+ return Effect.gen(function* () {
284
+ const r = yield* herdrCli(processService, [
285
+ "pane",
286
+ "layout",
287
+ "--pane",
288
+ current,
289
+ ])
290
+ const res = resultOf(r.json)
291
+ const layout = res?.layout as Record<string, unknown> | undefined
292
+ if (!Array.isArray(layout?.panes)) {
293
+ return yield* new HerdrError({
294
+ message: "herdr pane layout returned no panes",
295
+ command: "herdr pane layout",
296
+ })
297
+ }
298
+ const panes = layout.panes as Array<Record<string, unknown>>
299
+ const me = panes.find((p) => String(p.pane_id) === current)
300
+ const rect = me?.rect as { width?: number; height?: number } | undefined
301
+ if (rect?.width != null && rect?.height != null) {
302
+ return rect.width >= rect.height ? "right" : "down"
303
+ }
304
+ return "right"
305
+ })
202
306
  }
203
307
 
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
308
+ function splitPane(
309
+ processService: ProcessService,
310
+ ): Effect.Effect<string, HerdrError> {
311
+ return Effect.gen(function* () {
312
+ const direction = yield* splitDirection(processService)
313
+ const r = yield* herdrCli(
314
+ processService,
315
+ ["pane", "split", "--current", "--direction", direction, "--no-focus"],
316
+ { mutation: true },
317
+ )
318
+ if (!r.ok)
319
+ return yield* new HerdrError({
320
+ message: `herdr pane split failed: ${r.raw}`,
321
+ })
322
+ const res = resultOf(r.json)
323
+ const pane = res?.pane as Record<string, unknown> | undefined
324
+ const id = pane?.pane_id ? String(pane.pane_id) : null
325
+ if (!id) {
326
+ return yield* new HerdrError({
327
+ message: `herdr pane split: no pane_id in ${r.raw}`,
328
+ })
329
+ }
330
+ return id
331
+ })
225
332
  }
226
333
 
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
- }
334
+ function renamePane(
335
+ processService: ProcessService,
336
+ paneId: string,
337
+ label: string,
338
+ ): Effect.Effect<void, HerdrError> {
339
+ return Effect.gen(function* () {
340
+ const r = yield* herdrCli(
341
+ processService,
342
+ ["pane", "rename", paneId, label],
343
+ { mutation: true },
344
+ )
345
+ if (!r.ok) {
346
+ return yield* new HerdrError({
347
+ message: `herdr pane rename failed: ${r.raw}`,
348
+ })
349
+ }
350
+ })
232
351
  }
233
352
 
234
353
  /**
@@ -236,85 +355,97 @@ function renamePaneSync(paneId: string, label: string): void {
236
355
  * When a live agent TUI is focused, this submits a prompt (not a shell command).
237
356
  * When the pane is a bare shell, this runs a shell line.
238
357
  */
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
- }
358
+ function paneRun(
359
+ processService: ProcessService,
360
+ paneId: string,
361
+ command: string,
362
+ ): Effect.Effect<void, HerdrError> {
363
+ return Effect.gen(function* () {
364
+ const r = yield* herdrCli(
365
+ processService,
366
+ ["pane", "run", paneId, command],
367
+ { mutation: true },
368
+ )
369
+ if (!r.ok) {
370
+ return yield* new HerdrError({
371
+ message: `herdr pane run failed: ${r.raw}`,
372
+ command: "herdr pane run",
373
+ })
374
+ }
375
+ })
247
376
  }
248
377
 
249
378
  /** 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
- }
379
+ function paneSendKeys(
380
+ processService: ProcessService,
381
+ paneId: string,
382
+ keys: string[],
383
+ ): Effect.Effect<void, HerdrError> {
384
+ if (keys.length === 0) return Effect.void
385
+ return Effect.gen(function* () {
386
+ const r = yield* herdrCli(
387
+ processService,
388
+ ["pane", "send-keys", paneId, ...keys],
389
+ { mutation: true },
390
+ )
391
+ if (!r.ok) {
392
+ return yield* new HerdrError({
393
+ message: `herdr pane send-keys failed: ${r.raw}`,
394
+ })
395
+ }
396
+ })
256
397
  }
257
398
 
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
- }
399
+ function paneForegroundNames(
400
+ processService: ProcessService,
401
+ paneId: string,
402
+ ): Effect.Effect<string[]> {
403
+ return Effect.gen(function* () {
404
+ const r = yield* herdrCli(processService, [
405
+ "pane",
406
+ "process-info",
407
+ "--pane",
408
+ paneId,
409
+ ])
410
+ const res = resultOf(r.json)
411
+ const processInfo = res?.process_info as Record<string, unknown> | undefined
412
+ if (!Array.isArray(processInfo?.foreground_processes)) {
413
+ return yield* new HerdrError({
414
+ message: "herdr pane process-info returned no foreground_processes",
415
+ command: "herdr pane process-info",
416
+ })
276
417
  }
277
- const procs = json.result?.process_info?.foreground_processes ?? []
418
+ const procs = processInfo.foreground_processes as Array<{
419
+ name?: string
420
+ argv0?: string
421
+ cmdline?: string
422
+ }>
278
423
  return procs.map((p) => p.cmdline || p.argv0 || p.name || "?")
279
- } catch {
280
- return []
281
- }
424
+ }).pipe(Effect.catch(() => Effect.succeed([])))
282
425
  }
283
426
 
284
- function toHerdrError(e: unknown): HerdrError {
285
- return e instanceof HerdrError
286
- ? e
287
- : new HerdrError({ message: e instanceof Error ? e.message : String(e) })
427
+ function toHerdrError(error: unknown): HerdrError {
428
+ return error instanceof HerdrError
429
+ ? error
430
+ : new HerdrError({
431
+ message: error instanceof Error ? error.message : String(error),
432
+ })
288
433
  }
289
434
 
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(
435
+ function paneClose(
436
+ processService: ProcessService,
297
437
  paneId: string,
298
- command: string,
299
438
  ): 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,
439
+ return Effect.gen(function* () {
440
+ const r = yield* herdrCli(processService, ["pane", "close", paneId], {
441
+ mutation: true,
442
+ })
443
+ if (!r.ok) {
444
+ return yield* new HerdrError({
445
+ message: `herdr pane close failed: ${r.raw}`,
446
+ command: "herdr pane close",
447
+ })
448
+ }
318
449
  })
319
450
  }
320
451
 
@@ -333,7 +464,7 @@ function withLaunchDetails(
333
464
  export function cleanupFailedInteractiveLaunch(
334
465
  error: HerdrError,
335
466
  paneId: string,
336
- close: (paneId: string) => Effect.Effect<void, HerdrError> = paneClose,
467
+ close: (paneId: string) => Effect.Effect<void, HerdrError>,
337
468
  ): Effect.Effect<never, HerdrError> {
338
469
  return Effect.gen(function* () {
339
470
  const cleanup = yield* Effect.result(close(paneId))
@@ -349,16 +480,6 @@ export function cleanupFailedInteractiveLaunch(
349
480
  })
350
481
  }
351
482
 
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
483
  /** Unique label for a role slot (stable for the run when we reuse the pane). */
363
484
  function roleLabel(role: string, millis: number): string {
364
485
  const id = `${millis.toString(36)}-${Math.random().toString(36).slice(2, 6)}`
@@ -370,23 +491,28 @@ function roleLabel(role: string, millis: number): string {
370
491
  * Uses herdr wait when available; falls back to poll.
371
492
  */
372
493
  function waitAgentReady(
494
+ processService: ProcessService,
373
495
  paneId: string,
374
496
  timeoutMs = 90_000,
375
- ): Effect.Effect<string | undefined> {
497
+ ): Effect.Effect<string | undefined, HerdrError> {
376
498
  return Effect.gen(function* () {
377
499
  // Prefer Herdr's blocking wait (does not freeze our caller if we use it
378
500
  // 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
- ])
501
+ const r = yield* herdrCli(
502
+ processService,
503
+ [
504
+ "wait",
505
+ "agent-status",
506
+ paneId,
507
+ "--status",
508
+ "idle",
509
+ "--timeout",
510
+ String(timeoutMs),
511
+ ],
512
+ { timeoutMs: timeoutMs + 5_000 },
513
+ )
388
514
  if (r.ok) {
389
- const s = paneGetSync(paneId).agent_status
515
+ const s = (yield* paneGet(processService, paneId)).agent_status
390
516
  if (s === "idle" || s === "done") return s
391
517
  }
392
518
  // fall back: poll (done also counts as ready). Clock, not Date.now(): the
@@ -395,32 +521,34 @@ function waitAgentReady(
395
521
  const deadline =
396
522
  (yield* Clock.currentTimeMillis) + Math.min(timeoutMs, 30_000)
397
523
  while ((yield* Clock.currentTimeMillis) < deadline) {
398
- const s = paneGetSync(paneId).agent_status
524
+ const s = (yield* paneGet(processService, paneId)).agent_status
399
525
  if (s === "idle" || s === "done") return s
400
526
  yield* Effect.sleep(500)
401
527
  }
402
- return paneGetSync(paneId).agent_status
528
+ return (yield* paneGet(processService, paneId)).agent_status
403
529
  })
404
530
  }
405
531
 
406
532
  /**
407
533
  * The three pane operations the recovery ladder drives.
408
534
  *
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.
535
+ * Injectable so the recovery ladder can be tested without a Herdr process.
412
536
  */
413
537
  export type PromptProbes = {
414
- readonly status: () => string | undefined
538
+ readonly status: () => Effect.Effect<string | undefined, HerdrError>
415
539
  readonly sendKeys: (keys: string[]) => Effect.Effect<void, HerdrError>
416
540
  readonly run: (text: string) => Effect.Effect<void, HerdrError>
417
541
  }
418
542
 
419
- function livePromptProbes(paneId: string): PromptProbes {
543
+ function livePromptProbes(
544
+ processService: ProcessService,
545
+ paneId: string,
546
+ ): PromptProbes {
420
547
  return {
421
- status: () => paneGetSync(paneId).agent_status,
422
- sendKeys: (keys) => sendKeys(paneId, keys),
423
- run: (text) => paneRun(paneId, text),
548
+ status: () =>
549
+ Effect.map(paneGet(processService, paneId), (info) => info.agent_status),
550
+ sendKeys: (keys) => paneSendKeys(processService, paneId, keys),
551
+ run: (text) => paneRun(processService, paneId, text),
424
552
  }
425
553
  }
426
554
 
@@ -437,27 +565,42 @@ export function ensurePromptSubmitted(
437
565
  settleMs?: number
438
566
  workingWaitMs?: number
439
567
  probes?: PromptProbes
568
+ processService?: ProcessService
440
569
  },
441
- ): Effect.Effect<{
442
- accepted: boolean
443
- attempts: number
444
- last_status?: string
445
- }> {
570
+ ): Effect.Effect<
571
+ {
572
+ accepted: boolean
573
+ attempts: number
574
+ last_status?: string
575
+ },
576
+ HerdrError
577
+ > {
446
578
  return Effect.gen(function* () {
447
- const probes = opts?.probes ?? livePromptProbes(paneId)
579
+ const probes =
580
+ opts?.probes ??
581
+ (opts?.processService
582
+ ? livePromptProbes(opts.processService, paneId)
583
+ : undefined)
584
+ if (!probes) {
585
+ return yield* new HerdrError({
586
+ message: "prompt probes or process service are required",
587
+ })
588
+ }
448
589
  const settleMs = opts?.settleMs ?? 2500
449
590
  const workingWaitMs = opts?.workingWaitMs ?? 12_000
450
591
  let attempts = 1
451
592
 
452
- const waitForWorking = (ms: number): Effect.Effect<string | undefined> =>
593
+ const waitForWorking = (
594
+ ms: number,
595
+ ): Effect.Effect<string | undefined, HerdrError> =>
453
596
  Effect.gen(function* () {
454
597
  const deadline = (yield* Clock.currentTimeMillis) + ms
455
598
  while ((yield* Clock.currentTimeMillis) < deadline) {
456
- const s = probes.status()
599
+ const s = yield* probes.status()
457
600
  if (s === "working" || s === "blocked") return s
458
601
  yield* Effect.sleep(400)
459
602
  }
460
- return probes.status()
603
+ return yield* probes.status()
461
604
  })
462
605
 
463
606
  // Give the first paneRun a moment to flip status.
@@ -498,7 +641,7 @@ export function ensurePromptSubmitted(
498
641
  return {
499
642
  accepted: false,
500
643
  attempts,
501
- last_status: probes.status(),
644
+ last_status: yield* probes.status(),
502
645
  }
503
646
  }
504
647
  yield* Effect.sleep(settleMs)
@@ -519,12 +662,15 @@ export function ensurePromptSubmitted(
519
662
  * Never claims an unrelated pane by scanning labels alone.
520
663
  */
521
664
  function acquireRolePane(
665
+ processService: ProcessService,
522
666
  role: string,
523
667
  hostAdapter: ApneaHostAdapter,
524
668
  opts?: {
525
669
  prefer?: RolePaneRef | null
526
670
  /** Launch interactive harness only when creating a new pane */
527
671
  interactiveCmd?: string[]
672
+ beforeDelivery?: BeforeInteractiveDelivery
673
+ onAcquisitionFailure?: () => Effect.Effect<void>
528
674
  },
529
675
  ): Effect.Effect<RolePaneRef & { reused: boolean }, HerdrError> {
530
676
  return Effect.gen(function* () {
@@ -534,7 +680,13 @@ function acquireRolePane(
534
680
  })
535
681
  }
536
682
 
537
- if (opts?.prefer?.pane_id && paneAliveSync(opts.prefer.pane_id)) {
683
+ if (
684
+ opts?.prefer?.pane_id &&
685
+ (yield* paneGet(processService, opts.prefer.pane_id)).ok
686
+ ) {
687
+ if (opts.beforeDelivery) {
688
+ yield* Effect.uninterruptible(opts.beforeDelivery(opts.prefer))
689
+ }
538
690
  return {
539
691
  pane_id: opts.prefer.pane_id,
540
692
  label: opts.prefer.label,
@@ -544,25 +696,44 @@ function acquireRolePane(
544
696
 
545
697
  const millis = yield* Clock.currentTimeMillis
546
698
  const label = roleLabel(role, millis)
547
- const split = yield* Effect.result(
548
- Effect.try({
549
- try: () => splitPaneSync(),
550
- catch: toHerdrError,
699
+ // A split can create a pane before its response arrives. Finish the bounded
700
+ // acquisition and ownership save before observing cancellation.
701
+ const paneId = yield* Effect.uninterruptible(
702
+ Effect.gen(function* () {
703
+ const split = yield* Effect.result(splitPane(processService))
704
+ if (Result.isFailure(split)) {
705
+ // Restore proven non-delivery before a pending interruption can hide
706
+ // the split failure when this acquisition mask exits.
707
+ if (split.failure.details?.delivery !== "unknown") {
708
+ yield* opts?.onAcquisitionFailure?.() ?? Effect.void
709
+ }
710
+ return yield* withLaunchDetails(split.failure, {
711
+ delivery:
712
+ split.failure.details?.delivery === "unknown"
713
+ ? "unknown"
714
+ : "not_delivered",
715
+ newly_created: false,
716
+ })
717
+ }
718
+ const paneId = split.success
719
+ if (opts?.beforeDelivery) {
720
+ const persisted = yield* Effect.result(
721
+ opts.beforeDelivery({ pane_id: paneId, label }),
722
+ )
723
+ if (Result.isFailure(persisted)) {
724
+ return yield* cleanupFailedInteractiveLaunch(
725
+ persisted.failure,
726
+ paneId,
727
+ (id) => paneClose(processService, id),
728
+ )
729
+ }
730
+ }
731
+ return paneId
551
732
  }),
552
733
  )
553
- if (Result.isFailure(split)) {
554
- return yield* withLaunchDetails(split.failure, {
555
- delivery: "not_delivered",
556
- newly_created: false,
557
- })
558
- }
559
- const paneId = split.success
560
734
  const prepared = yield* Effect.result(
561
735
  Effect.gen(function* () {
562
- yield* Effect.try({
563
- try: () => renamePaneSync(paneId, label),
564
- catch: toHerdrError,
565
- })
736
+ yield* renamePane(processService, paneId, label)
566
737
  if (!opts?.interactiveCmd?.length) return
567
738
  // Launch the interactive harness only (no task argv).
568
739
  // Pi roles get PI_CODING_AGENT_DIR without pi-vimmode so pane-run pastes
@@ -576,11 +747,23 @@ function acquireRolePane(
576
747
  catch: toHerdrError,
577
748
  })
578
749
  const cmd = shellJoin(["cd", process.cwd(), "&&", "exec", ...launchCmd])
579
- yield* paneRun(paneId, cmd)
750
+ yield* paneRun(processService, paneId, cmd)
580
751
  }),
581
752
  )
582
753
  if (Result.isFailure(prepared)) {
583
- return yield* cleanupFailedInteractiveLaunch(prepared.failure, paneId)
754
+ if (prepared.failure.details?.delivery === "unknown") {
755
+ return yield* withLaunchDetails(prepared.failure, {
756
+ delivery: "unknown",
757
+ pane_id: paneId,
758
+ pane_label: label,
759
+ newly_created: true,
760
+ })
761
+ }
762
+ return yield* cleanupFailedInteractiveLaunch(
763
+ prepared.failure,
764
+ paneId,
765
+ (id) => paneClose(processService, id),
766
+ )
584
767
  }
585
768
  return { pane_id: paneId, label, reused: false }
586
769
  })
@@ -594,17 +777,20 @@ function acquireRolePane(
594
777
  * `claude -p` / `pi -p` dumping shell output.
595
778
  */
596
779
  function runInteractivePromptImpl(
780
+ processService: ProcessService,
597
781
  hostAdapter: ApneaHostAdapter,
598
782
  role: string,
599
783
  interactiveCmd: string[],
600
784
  prompt: string,
601
785
  prefer: RolePaneRef | null,
786
+ beforeDelivery?: BeforeInteractiveDelivery,
787
+ onAcquisitionFailure?: () => Effect.Effect<void>,
602
788
  ): Effect.Effect<InteractiveLaunch, HerdrError> {
603
789
  return Effect.gen(function* () {
604
790
  let preferUse: RolePaneRef | null = null
605
791
  if (prefer?.pane_id) {
606
792
  // One `pane get`: liveness and agent_status come from the same call.
607
- const info = paneGetSync(prefer.pane_id)
793
+ const info = yield* paneGet(processService, prefer.pane_id)
608
794
  // reuse only when a live agent can take a new prompt
609
795
  // working/blocked/unknown/shell-only → new pane
610
796
  if (
@@ -615,38 +801,64 @@ function runInteractivePromptImpl(
615
801
  }
616
802
  }
617
803
 
618
- const acquired = yield* acquireRolePane(role, hostAdapter, {
804
+ const acquired = yield* acquireRolePane(processService, role, hostAdapter, {
619
805
  prefer: preferUse,
620
806
  interactiveCmd: preferUse ? undefined : interactiveCmd,
807
+ beforeDelivery,
808
+ onAcquisitionFailure,
621
809
  })
622
810
 
623
- if (!acquired.reused) {
624
- yield* waitAgentReady(acquired.pane_id, 90_000)
625
- // still try even if not idle/done — some harnesses accept input
626
- // before status settles.
627
- } else {
628
- const st = paneGetSync(acquired.pane_id).agent_status
629
- if (st !== "idle" && st !== "done") {
630
- yield* waitAgentReady(acquired.pane_id, 30_000)
631
- }
632
- }
811
+ const ready = yield* Effect.result(
812
+ Effect.gen(function* () {
813
+ if (!acquired.reused) {
814
+ yield* waitAgentReady(processService, acquired.pane_id, 90_000)
815
+ // Some harnesses accept input before status settles.
816
+ } else {
817
+ const st = (yield* paneGet(processService, acquired.pane_id))
818
+ .agent_status
819
+ if (st !== "idle" && st !== "done") {
820
+ yield* waitAgentReady(processService, acquired.pane_id, 30_000)
821
+ }
822
+ }
633
823
 
634
- const beforePrompt = hostAdapter.beforeInteractivePrompt?.(interactiveCmd)
635
- if (beforePrompt) {
636
- // Host preparation is best-effort; command wrapping is the primary guard.
637
- yield* Effect.gen(function* () {
638
- yield* paneRun(acquired.pane_id, beforePrompt)
639
- yield* waitAgentReady(acquired.pane_id, 5_000)
640
- yield* Effect.sleep(300)
641
- }).pipe(Effect.ignore)
824
+ const beforePrompt =
825
+ hostAdapter.beforeInteractivePrompt?.(interactiveCmd)
826
+ if (beforePrompt) {
827
+ // Host preparation is best-effort; command wrapping is the primary guard.
828
+ yield* Effect.gen(function* () {
829
+ yield* paneRun(processService, acquired.pane_id, beforePrompt)
830
+ yield* waitAgentReady(processService, acquired.pane_id, 5_000)
831
+ yield* Effect.sleep(300)
832
+ }).pipe(Effect.ignore)
833
+ }
834
+ }),
835
+ )
836
+ if (Result.isFailure(ready)) {
837
+ if (!acquired.reused) {
838
+ return yield* cleanupFailedInteractiveLaunch(
839
+ ready.failure,
840
+ acquired.pane_id,
841
+ (id) => paneClose(processService, id),
842
+ )
843
+ }
844
+ return yield* withLaunchDetails(ready.failure, {
845
+ delivery: "not_delivered",
846
+ })
642
847
  }
643
848
 
644
849
  // Submit pointer into the live TUI (Herdr: pane run = text + Enter),
645
850
  // then confirm the agent actually started — do not trust fire-and-forget.
646
- const submitted = yield* Effect.result(paneRun(acquired.pane_id, prompt))
851
+ const submitted = yield* Effect.result(
852
+ Effect.gen(function* () {
853
+ yield* paneRun(processService, acquired.pane_id, prompt)
854
+ return yield* ensurePromptSubmitted(acquired.pane_id, prompt, {
855
+ processService,
856
+ })
857
+ }),
858
+ )
647
859
  if (Result.isFailure(submitted)) {
648
860
  return yield* withLaunchDetails(submitted.failure, {
649
- // The Herdr CLI can lose its response after the pane accepted text.
861
+ // Submission or acceptance probing can fail after the pane accepted text.
650
862
  // Closing or retrying here could kill or duplicate a live worker.
651
863
  delivery: "unknown",
652
864
  pane_id: acquired.pane_id,
@@ -654,7 +866,7 @@ function runInteractivePromptImpl(
654
866
  reused: acquired.reused,
655
867
  })
656
868
  }
657
- const submit = yield* ensurePromptSubmitted(acquired.pane_id, prompt)
869
+ const submit = submitted.success
658
870
  return {
659
871
  pane_id: acquired.pane_id,
660
872
  label: acquired.label,
@@ -673,32 +885,26 @@ function runInteractivePromptImpl(
673
885
  export const makeHerdrLive = (hostAdapter: ApneaHostAdapter) =>
674
886
  Layer.effect(
675
887
  Herdr,
676
- Effect.sync(() =>
677
- Herdr.of({
888
+ Effect.gen(function* () {
889
+ const processService = yield* Process
890
+ return Herdr.of({
678
891
  enabled: Effect.sync(herdrEnabledSync),
679
892
 
680
- availability: Effect.try({
681
- try: herdrAvailabilitySync,
682
- catch: toHerdrError,
683
- }),
893
+ availability: herdrAvailability(processService),
684
894
 
685
- paneGet: (paneId) => Effect.sync(() => paneGetSync(paneId)),
895
+ paneGet: (paneId) => paneGet(processService, paneId),
686
896
 
687
- paneRun,
897
+ paneRun: (paneId, command) => paneRun(processService, paneId, command),
688
898
 
689
- paneReadRecent: (paneId) =>
690
- Effect.try({
691
- try: () => paneReadRecentSync(paneId),
692
- catch: toHerdrError,
693
- }),
899
+ paneReadRecent: (paneId) => paneReadRecent(processService, paneId),
694
900
 
695
901
  paneForegroundNames: (paneId) =>
696
- Effect.sync(() => paneForegroundNamesSync(paneId)),
902
+ paneForegroundNames(processService, paneId),
697
903
 
698
904
  runInteractivePrompt: (...args) =>
699
- runInteractivePromptImpl(hostAdapter, ...args),
700
- }),
701
- ),
905
+ runInteractivePromptImpl(processService, hostAdapter, ...args),
906
+ })
907
+ }),
702
908
  )
703
909
 
704
910
  export const HerdrLive = makeHerdrLive(neutralHostAdapter)