@naxodev/apnea 0.1.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 (51) hide show
  1. package/README.md +25 -10
  2. package/SECURITY.md +32 -0
  3. package/briefs/orchestrator.md +4 -3
  4. package/dist/cli.js +8571 -15324
  5. package/docs/adr/0005-harness-profiles.md +1 -1
  6. package/docs/adr/0010-package-split.md +1 -1
  7. package/docs/protocol/artifacts.md +18 -2
  8. package/docs/protocol/config.md +22 -25
  9. package/docs/protocol/manual-gate.md +8 -8
  10. package/docs/protocol/overview.md +18 -5
  11. package/extension/adapters/commit.ts +5 -1
  12. package/extension/adapters/dispatch.ts +9 -1
  13. package/extension/adapters/setup.ts +15 -1
  14. package/extension/adapters/start.ts +5 -1
  15. package/extension/adapters/status.ts +17 -2
  16. package/extension/adapters/wait.ts +6 -1
  17. package/extension/api.ts +7 -1
  18. package/extension/cli/main.ts +67 -7
  19. package/extension/cli/parse.ts +172 -5
  20. package/extension/domain/herdr.ts +0 -86
  21. package/extension/domain/paths.ts +3 -13
  22. package/extension/domain/setup.ts +0 -20
  23. package/extension/domain/timeouts.ts +4 -0
  24. package/extension/domain/types.ts +64 -11
  25. package/extension/domain/verify-commands.ts +200 -108
  26. package/extension/errors.ts +51 -16
  27. package/extension/operation-hooks.ts +6 -0
  28. package/extension/registry.ts +29 -15
  29. package/extension/run-tool.ts +19 -2
  30. package/extension/schema/config.ts +86 -30
  31. package/extension/schema/frontmatter.ts +57 -0
  32. package/extension/schema/state.ts +226 -16
  33. package/extension/services/app-live.ts +2 -1
  34. package/extension/services/config.ts +6 -4
  35. package/extension/services/file-system.ts +346 -75
  36. package/extension/services/herdr.ts +393 -402
  37. package/extension/services/operation-lock.ts +418 -0
  38. package/extension/services/process.ts +477 -0
  39. package/extension/services/run-store.ts +38 -16
  40. package/extension/services/vcs.ts +1388 -86
  41. package/extension/workflows/commit.ts +222 -18
  42. package/extension/workflows/dispatch.ts +320 -220
  43. package/extension/workflows/setup.ts +61 -141
  44. package/extension/workflows/start.ts +6 -5
  45. package/extension/workflows/status.ts +2 -2
  46. package/extension/workflows/wait.ts +63 -134
  47. package/package.json +2 -3
  48. package/schemas/config.schema.json +11 -7
  49. package/schemas/state.schema.json +170 -12
  50. package/herdr-plugin/herdr-plugin.toml +0 -15
  51. package/herdr-plugin/scripts/run-task.sh +0 -8
@@ -1,19 +1,23 @@
1
- import { spawnSync } from "node:child_process"
2
1
  import * as fs from "node:fs"
3
- import * as os from "node:os"
4
2
  import * as path from "node:path"
5
3
  import { Clock, Context, Effect, Layer, Option, Result } from "effect"
6
- import {
7
- floatingTaskScriptBody,
8
- parseHerdrVersion,
9
- shellJoin,
10
- } from "../domain/herdr.ts"
4
+ import { shellJoin } from "../domain/herdr.ts"
11
5
  import { HerdrError } from "../errors.ts"
12
6
  import type { ApneaHostAdapter } from "../host-adapter.ts"
13
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"
14
16
 
15
17
  export type PaneInfo = {
16
18
  ok: boolean
19
+ /** True only when Herdr explicitly reports that this pane does not exist. */
20
+ missing?: boolean
17
21
  agent_status?: string
18
22
  label?: string
19
23
  agent?: string
@@ -33,9 +37,7 @@ export interface HerdrService {
33
37
  readonly enabled: Effect.Effect<boolean>
34
38
  /** Dispatch preflight that distinguishes a stale pane from CLI failures. */
35
39
  readonly availability: Effect.Effect<HerdrAvailability, HerdrError>
36
- readonly version: Effect.Effect<[number, number, number] | null>
37
- readonly hasApneaPlugin: Effect.Effect<boolean>
38
- readonly paneGet: (paneId: string) => Effect.Effect<PaneInfo>
40
+ readonly paneGet: (paneId: string) => Effect.Effect<PaneInfo, HerdrError>
39
41
  readonly paneRun: (
40
42
  paneId: string,
41
43
  command: string,
@@ -50,20 +52,6 @@ export interface HerdrService {
50
52
  prompt: string,
51
53
  prefer: RolePaneRef | null,
52
54
  ) => Effect.Effect<InteractiveLaunch, HerdrError>
53
- readonly writeFloatingTaskScript: (
54
- scriptAbs: string,
55
- root: string,
56
- cmd: string[],
57
- prompt: string,
58
- exitFileAbs: string,
59
- ) => Effect.Effect<void, HerdrError>
60
- readonly openFloatingPane: (
61
- taskScriptAbs: string,
62
- root: string,
63
- ) => Effect.Effect<void, HerdrError>
64
- readonly linkPlugin: (
65
- dir: string,
66
- ) => Effect.Effect<{ ok: boolean; raw: string }>
67
55
  }
68
56
 
69
57
  export class Herdr extends Context.Service<Herdr, HerdrService>()(
@@ -82,22 +70,67 @@ export const paneReadRecentArgs = (paneId: string): string[] => [
82
70
  "text",
83
71
  ]
84
72
 
85
- function herdrCli(args: string[]): { ok: boolean; json: unknown; raw: string } {
86
- const r = spawnSync("herdr", args, {
87
- encoding: "utf8",
88
- 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
+ }
89
133
  })
90
- const raw = `${r.stdout ?? ""}${r.stderr ?? ""}`
91
- if (r.status !== 0) {
92
- return { ok: false, json: null, raw }
93
- }
94
- // herdr often prints one JSON object
95
- const line = (r.stdout ?? "").trim().split(/\n/).filter(Boolean).pop() ?? ""
96
- try {
97
- return { ok: true, json: JSON.parse(line), raw }
98
- } catch {
99
- return { ok: true, json: null, raw }
100
- }
101
134
  }
102
135
 
103
136
  function resultOf(json: unknown): Record<string, unknown> | null {
@@ -118,11 +151,8 @@ function isExecutableFile(abs: string): boolean {
118
151
  }
119
152
 
120
153
  /**
121
- * Resolve a oneshot binary against the orchestrator environment.
122
- * Floating plugin popups get a stripped PATH (no ~/.local/bin etc.), so bare
123
- * names like `claude` exit 127 unless we bake an absolute path into the script.
124
- * Walks PATH directly — no `which` subprocess (which itself vanishes when PATH
125
- * is overridden for tests or minimal envs).
154
+ * Resolve a binary against the current environment. Walks PATH directly so
155
+ * setup detection does not depend on a separate `which` executable.
126
156
  */
127
157
  export function resolveExecutable(
128
158
  bin: string,
@@ -141,36 +171,6 @@ export function resolveExecutable(
141
171
  return null
142
172
  }
143
173
 
144
- /**
145
- * PATH for floating plugin panes: orchestrator PATH plus common user-local
146
- * bin dirs so child tools the oneshot agent spawns still resolve.
147
- */
148
- export function floatingPanePath(
149
- base: string = process.env.PATH ?? "",
150
- home: string = os.homedir(),
151
- ): string {
152
- const extras = [
153
- path.join(home, ".local", "bin"),
154
- path.join(home, ".bun", "bin"),
155
- "/opt/homebrew/bin",
156
- "/usr/local/bin",
157
- ]
158
- const parts = base.split(path.delimiter).filter(Boolean)
159
- const seen = new Set(parts)
160
- for (const extra of extras) {
161
- if (seen.has(extra)) continue
162
- try {
163
- if (fs.statSync(extra).isDirectory()) {
164
- parts.push(extra)
165
- seen.add(extra)
166
- }
167
- } catch {
168
- // skip missing dirs
169
- }
170
- }
171
- return parts.join(path.delimiter)
172
- }
173
-
174
174
  function herdrEnabledSync(): boolean {
175
175
  return process.env.HERDR_ENV === "1"
176
176
  }
@@ -191,98 +191,157 @@ export function probeHerdrAvailability(
191
191
  })
192
192
  }
193
193
 
194
- function herdrAvailabilitySync(): HerdrAvailability {
195
- return probeHerdrAvailability(
196
- {
197
- HERDR_ENV: process.env.HERDR_ENV,
198
- HERDR_PANE_ID: process.env.HERDR_PANE_ID,
199
- },
200
- (paneId) => herdrCli(["pane", "get", paneId]),
201
- )
202
- }
203
-
204
- function paneGetSync(paneId: string): PaneInfo {
205
- const r = herdrCli(["pane", "get", paneId])
206
- if (!r.ok) return { ok: false }
207
- const res = resultOf(r.json)
208
- const pane = (res?.pane as Record<string, unknown>) ?? {}
209
- return {
210
- ok: true,
211
- agent_status: pane.agent_status ? String(pane.agent_status) : undefined,
212
- label: pane.label ? String(pane.label) : undefined,
213
- 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")
214
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
+ })
215
210
  }
216
211
 
217
- function paneAliveSync(paneId: string): boolean {
218
- 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
+ })
219
239
  }
220
240
 
221
- function paneReadRecentSync(paneId: string): string {
241
+ function paneReadRecent(
242
+ processService: ProcessService,
243
+ paneId: string,
244
+ ): Effect.Effect<string, HerdrError> {
222
245
  const args = paneReadRecentArgs(paneId)
223
- const r = spawnSync("herdr", args, {
224
- encoding: "utf8",
225
- 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
226
268
  })
227
- if (r.status !== 0 || r.error) {
228
- const output = `${r.stdout ?? ""}${r.stderr ?? ""}${r.error?.message ?? ""}`
229
- .trim()
230
- .split(/\r?\n/)
231
- .slice(-80)
232
- .join("\n")
233
- throw new HerdrError({
234
- message: `herdr pane read failed for ${paneId}${output ? `: ${output}` : ""}`,
235
- command: shellJoin(["herdr", ...args]),
236
- ...(output ? { details: { output } } : {}),
237
- })
238
- }
239
- return r.stdout ?? ""
240
269
  }
241
270
 
242
271
  /** Prefer right on wide panes, down on tall/narrow ones. */
243
- function splitDirectionSync(): "right" | "down" {
272
+ function splitDirection(
273
+ processService: ProcessService,
274
+ ): Effect.Effect<"right" | "down", HerdrError> {
244
275
  const current = process.env.HERDR_PANE_ID
245
- if (!current) return "right"
246
- const r = herdrCli(["pane", "layout", "--pane", current])
247
- const res = resultOf(r.json)
248
- const layout = res?.layout as Record<string, unknown> | undefined
249
- const panes = (layout?.panes as Array<Record<string, unknown>>) ?? []
250
- const me = panes.find((p) => String(p.pane_id) === current)
251
- const rect = me?.rect as { width?: number; height?: number } | undefined
252
- if (rect?.width != null && rect?.height != null) {
253
- return rect.width >= rect.height ? "right" : "down"
254
- }
255
- 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
+ })
256
300
  }
257
301
 
258
- function splitPaneSync(): string {
259
- const direction = splitDirectionSync()
260
- const r = herdrCli([
261
- "pane",
262
- "split",
263
- "--current",
264
- "--direction",
265
- direction,
266
- "--no-focus",
267
- ])
268
- if (!r.ok)
269
- throw new HerdrError({ message: `herdr pane split failed: ${r.raw}` })
270
- const res = resultOf(r.json)
271
- const pane = res?.pane as Record<string, unknown> | undefined
272
- const id = pane?.pane_id ? String(pane.pane_id) : null
273
- if (!id) {
274
- throw new HerdrError({
275
- message: `herdr pane split: no pane_id in ${r.raw}`,
276
- })
277
- }
278
- 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
+ })
279
326
  }
280
327
 
281
- function renamePaneSync(paneId: string, label: string): void {
282
- const r = herdrCli(["pane", "rename", paneId, label])
283
- if (!r.ok) {
284
- throw new HerdrError({ message: `herdr pane rename failed: ${r.raw}` })
285
- }
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
+ })
286
345
  }
287
346
 
288
347
  /**
@@ -290,103 +349,97 @@ function renamePaneSync(paneId: string, label: string): void {
290
349
  * When a live agent TUI is focused, this submits a prompt (not a shell command).
291
350
  * When the pane is a bare shell, this runs a shell line.
292
351
  */
293
- function paneRunSync(paneId: string, command: string): void {
294
- const r = herdrCli(["pane", "run", paneId, command])
295
- if (!r.ok) {
296
- throw new HerdrError({
297
- message: `herdr pane run failed: ${r.raw}`,
298
- command: "herdr pane run",
299
- })
300
- }
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
+ })
301
370
  }
302
371
 
303
372
  /** Send raw key names (e.g. Escape, Enter) into a pane. */
304
- function paneSendKeysSync(paneId: string, keys: string[]): void {
305
- if (keys.length === 0) return
306
- const r = herdrCli(["pane", "send-keys", paneId, ...keys])
307
- if (!r.ok) {
308
- throw new HerdrError({ message: `herdr pane send-keys failed: ${r.raw}` })
309
- }
310
- }
311
-
312
- function herdrVersionSync(): [number, number, number] | null {
313
- return parseHerdrVersion(herdrCli(["--version"]).raw)
314
- }
315
-
316
- function hasApneaPluginSync(): boolean {
317
- const r = herdrCli(["plugin", "list", "--plugin", "apnea", "--json"])
318
- const json = r.json
319
- if (json) {
320
- const res = resultOf(json)
321
- const plugins = (res?.plugins as Array<Record<string, unknown>>) ?? []
322
- if (plugins.some((p) => p.plugin_id === "apnea" || p.id === "apnea")) {
323
- return true
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
+ })
324
389
  }
325
- }
326
- // Fallback when JSON shape is unexpected but the id still appears in output.
327
- return /"(?:plugin_id|id)"\s*:\s*"apnea"/.test(r.raw)
390
+ })
328
391
  }
329
392
 
330
- function paneForegroundNamesSync(paneId: string): string[] {
331
- try {
332
- const r = spawnSync("herdr", ["pane", "process-info", "--pane", paneId], {
333
- encoding: "utf8",
334
- maxBuffer: 2 * 1024 * 1024,
335
- })
336
- if (r.status !== 0) return []
337
- const line = (r.stdout ?? "").trim().split(/\n/).filter(Boolean).pop() ?? ""
338
- const json = JSON.parse(line) as {
339
- result?: {
340
- process_info?: {
341
- foreground_processes?: Array<{
342
- name?: string
343
- argv0?: string
344
- cmdline?: string
345
- }>
346
- }
347
- }
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
+ })
348
411
  }
349
- 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
+ }>
350
417
  return procs.map((p) => p.cmdline || p.argv0 || p.name || "?")
351
- } catch {
352
- return []
353
- }
418
+ }).pipe(Effect.catch(() => Effect.succeed([])))
354
419
  }
355
420
 
356
- function toHerdrError(e: unknown): HerdrError {
357
- return e instanceof HerdrError
358
- ? e
359
- : 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
+ })
360
427
  }
361
428
 
362
- /**
363
- * Effect wrappers for the throwing `*Sync` helpers. A `throw` inside
364
- * `Effect.gen` is a defect, and defects pass straight through `Effect.ignore` /
365
- * `Effect.option` — so every sync herdr call must go through `Effect.try` for
366
- * best-effort recovery blocks to actually be best-effort.
367
- */
368
- function paneRun(
429
+ function paneClose(
430
+ processService: ProcessService,
369
431
  paneId: string,
370
- command: string,
371
432
  ): Effect.Effect<void, HerdrError> {
372
- return Effect.try({
373
- try: () => paneRunSync(paneId, command),
374
- catch: toHerdrError,
375
- })
376
- }
377
-
378
- function paneClose(paneId: string): Effect.Effect<void, HerdrError> {
379
- return Effect.try({
380
- try: () => {
381
- const r = herdrCli(["pane", "close", paneId])
382
- if (!r.ok) {
383
- throw new HerdrError({
384
- message: `herdr pane close failed: ${r.raw}`,
385
- command: "herdr pane close",
386
- })
387
- }
388
- },
389
- 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
+ }
390
443
  })
391
444
  }
392
445
 
@@ -405,7 +458,7 @@ function withLaunchDetails(
405
458
  export function cleanupFailedInteractiveLaunch(
406
459
  error: HerdrError,
407
460
  paneId: string,
408
- close: (paneId: string) => Effect.Effect<void, HerdrError> = paneClose,
461
+ close: (paneId: string) => Effect.Effect<void, HerdrError>,
409
462
  ): Effect.Effect<never, HerdrError> {
410
463
  return Effect.gen(function* () {
411
464
  const cleanup = yield* Effect.result(close(paneId))
@@ -421,16 +474,6 @@ export function cleanupFailedInteractiveLaunch(
421
474
  })
422
475
  }
423
476
 
424
- function sendKeys(
425
- paneId: string,
426
- keys: string[],
427
- ): Effect.Effect<void, HerdrError> {
428
- return Effect.try({
429
- try: () => paneSendKeysSync(paneId, keys),
430
- catch: toHerdrError,
431
- })
432
- }
433
-
434
477
  /** Unique label for a role slot (stable for the run when we reuse the pane). */
435
478
  function roleLabel(role: string, millis: number): string {
436
479
  const id = `${millis.toString(36)}-${Math.random().toString(36).slice(2, 6)}`
@@ -442,23 +485,28 @@ function roleLabel(role: string, millis: number): string {
442
485
  * Uses herdr wait when available; falls back to poll.
443
486
  */
444
487
  function waitAgentReady(
488
+ processService: ProcessService,
445
489
  paneId: string,
446
490
  timeoutMs = 90_000,
447
- ): Effect.Effect<string | undefined> {
491
+ ): Effect.Effect<string | undefined, HerdrError> {
448
492
  return Effect.gen(function* () {
449
493
  // Prefer Herdr's blocking wait (does not freeze our caller if we use it
450
494
  // only for short readiness; dispatch is already a tool call).
451
- const r = herdrCli([
452
- "wait",
453
- "agent-status",
454
- paneId,
455
- "--status",
456
- "idle",
457
- "--timeout",
458
- String(timeoutMs),
459
- ])
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
+ )
460
508
  if (r.ok) {
461
- const s = paneGetSync(paneId).agent_status
509
+ const s = (yield* paneGet(processService, paneId)).agent_status
462
510
  if (s === "idle" || s === "done") return s
463
511
  }
464
512
  // fall back: poll (done also counts as ready). Clock, not Date.now(): the
@@ -467,32 +515,34 @@ function waitAgentReady(
467
515
  const deadline =
468
516
  (yield* Clock.currentTimeMillis) + Math.min(timeoutMs, 30_000)
469
517
  while ((yield* Clock.currentTimeMillis) < deadline) {
470
- const s = paneGetSync(paneId).agent_status
518
+ const s = (yield* paneGet(processService, paneId)).agent_status
471
519
  if (s === "idle" || s === "done") return s
472
520
  yield* Effect.sleep(500)
473
521
  }
474
- return paneGetSync(paneId).agent_status
522
+ return (yield* paneGet(processService, paneId)).agent_status
475
523
  })
476
524
  }
477
525
 
478
526
  /**
479
527
  * The three pane operations the recovery ladder drives.
480
528
  *
481
- * Injectable because the ladder cannot otherwise be tested: Bun's `spawnSync`
482
- * resolves binaries against the process's real PATH and ignores mutations to
483
- * `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.
484
530
  */
485
531
  export type PromptProbes = {
486
- readonly status: () => string | undefined
532
+ readonly status: () => Effect.Effect<string | undefined, HerdrError>
487
533
  readonly sendKeys: (keys: string[]) => Effect.Effect<void, HerdrError>
488
534
  readonly run: (text: string) => Effect.Effect<void, HerdrError>
489
535
  }
490
536
 
491
- function livePromptProbes(paneId: string): PromptProbes {
537
+ function livePromptProbes(
538
+ processService: ProcessService,
539
+ paneId: string,
540
+ ): PromptProbes {
492
541
  return {
493
- status: () => paneGetSync(paneId).agent_status,
494
- sendKeys: (keys) => sendKeys(paneId, keys),
495
- 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),
496
546
  }
497
547
  }
498
548
 
@@ -509,27 +559,42 @@ export function ensurePromptSubmitted(
509
559
  settleMs?: number
510
560
  workingWaitMs?: number
511
561
  probes?: PromptProbes
562
+ processService?: ProcessService
512
563
  },
513
- ): Effect.Effect<{
514
- accepted: boolean
515
- attempts: number
516
- last_status?: string
517
- }> {
564
+ ): Effect.Effect<
565
+ {
566
+ accepted: boolean
567
+ attempts: number
568
+ last_status?: string
569
+ },
570
+ HerdrError
571
+ > {
518
572
  return Effect.gen(function* () {
519
- 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
+ }
520
583
  const settleMs = opts?.settleMs ?? 2500
521
584
  const workingWaitMs = opts?.workingWaitMs ?? 12_000
522
585
  let attempts = 1
523
586
 
524
- const waitForWorking = (ms: number): Effect.Effect<string | undefined> =>
587
+ const waitForWorking = (
588
+ ms: number,
589
+ ): Effect.Effect<string | undefined, HerdrError> =>
525
590
  Effect.gen(function* () {
526
591
  const deadline = (yield* Clock.currentTimeMillis) + ms
527
592
  while ((yield* Clock.currentTimeMillis) < deadline) {
528
- const s = probes.status()
593
+ const s = yield* probes.status()
529
594
  if (s === "working" || s === "blocked") return s
530
595
  yield* Effect.sleep(400)
531
596
  }
532
- return probes.status()
597
+ return yield* probes.status()
533
598
  })
534
599
 
535
600
  // Give the first paneRun a moment to flip status.
@@ -570,7 +635,7 @@ export function ensurePromptSubmitted(
570
635
  return {
571
636
  accepted: false,
572
637
  attempts,
573
- last_status: probes.status(),
638
+ last_status: yield* probes.status(),
574
639
  }
575
640
  }
576
641
  yield* Effect.sleep(settleMs)
@@ -591,6 +656,7 @@ export function ensurePromptSubmitted(
591
656
  * Never claims an unrelated pane by scanning labels alone.
592
657
  */
593
658
  function acquireRolePane(
659
+ processService: ProcessService,
594
660
  role: string,
595
661
  hostAdapter: ApneaHostAdapter,
596
662
  opts?: {
@@ -606,7 +672,10 @@ function acquireRolePane(
606
672
  })
607
673
  }
608
674
 
609
- 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
+ ) {
610
679
  return {
611
680
  pane_id: opts.prefer.pane_id,
612
681
  label: opts.prefer.label,
@@ -616,25 +685,20 @@ function acquireRolePane(
616
685
 
617
686
  const millis = yield* Clock.currentTimeMillis
618
687
  const label = roleLabel(role, millis)
619
- const split = yield* Effect.result(
620
- Effect.try({
621
- try: () => splitPaneSync(),
622
- catch: toHerdrError,
623
- }),
624
- )
688
+ const split = yield* Effect.result(splitPane(processService))
625
689
  if (Result.isFailure(split)) {
626
690
  return yield* withLaunchDetails(split.failure, {
627
- delivery: "not_delivered",
691
+ delivery:
692
+ split.failure.details?.delivery === "unknown"
693
+ ? "unknown"
694
+ : "not_delivered",
628
695
  newly_created: false,
629
696
  })
630
697
  }
631
698
  const paneId = split.success
632
699
  const prepared = yield* Effect.result(
633
700
  Effect.gen(function* () {
634
- yield* Effect.try({
635
- try: () => renamePaneSync(paneId, label),
636
- catch: toHerdrError,
637
- })
701
+ yield* renamePane(processService, paneId, label)
638
702
  if (!opts?.interactiveCmd?.length) return
639
703
  // Launch the interactive harness only (no task argv).
640
704
  // Pi roles get PI_CODING_AGENT_DIR without pi-vimmode so pane-run pastes
@@ -648,11 +712,23 @@ function acquireRolePane(
648
712
  catch: toHerdrError,
649
713
  })
650
714
  const cmd = shellJoin(["cd", process.cwd(), "&&", "exec", ...launchCmd])
651
- yield* paneRun(paneId, cmd)
715
+ yield* paneRun(processService, paneId, cmd)
652
716
  }),
653
717
  )
654
718
  if (Result.isFailure(prepared)) {
655
- 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
+ )
656
732
  }
657
733
  return { pane_id: paneId, label, reused: false }
658
734
  })
@@ -666,6 +742,7 @@ function acquireRolePane(
666
742
  * `claude -p` / `pi -p` dumping shell output.
667
743
  */
668
744
  function runInteractivePromptImpl(
745
+ processService: ProcessService,
669
746
  hostAdapter: ApneaHostAdapter,
670
747
  role: string,
671
748
  interactiveCmd: string[],
@@ -676,7 +753,7 @@ function runInteractivePromptImpl(
676
753
  let preferUse: RolePaneRef | null = null
677
754
  if (prefer?.pane_id) {
678
755
  // One `pane get`: liveness and agent_status come from the same call.
679
- const info = paneGetSync(prefer.pane_id)
756
+ const info = yield* paneGet(processService, prefer.pane_id)
680
757
  // reuse only when a live agent can take a new prompt
681
758
  // working/blocked/unknown/shell-only → new pane
682
759
  if (
@@ -687,19 +764,19 @@ function runInteractivePromptImpl(
687
764
  }
688
765
  }
689
766
 
690
- const acquired = yield* acquireRolePane(role, hostAdapter, {
767
+ const acquired = yield* acquireRolePane(processService, role, hostAdapter, {
691
768
  prefer: preferUse,
692
769
  interactiveCmd: preferUse ? undefined : interactiveCmd,
693
770
  })
694
771
 
695
772
  if (!acquired.reused) {
696
- yield* waitAgentReady(acquired.pane_id, 90_000)
773
+ yield* waitAgentReady(processService, acquired.pane_id, 90_000)
697
774
  // still try even if not idle/done — some harnesses accept input
698
775
  // before status settles.
699
776
  } else {
700
- const st = paneGetSync(acquired.pane_id).agent_status
777
+ const st = (yield* paneGet(processService, acquired.pane_id)).agent_status
701
778
  if (st !== "idle" && st !== "done") {
702
- yield* waitAgentReady(acquired.pane_id, 30_000)
779
+ yield* waitAgentReady(processService, acquired.pane_id, 30_000)
703
780
  }
704
781
  }
705
782
 
@@ -707,15 +784,17 @@ function runInteractivePromptImpl(
707
784
  if (beforePrompt) {
708
785
  // Host preparation is best-effort; command wrapping is the primary guard.
709
786
  yield* Effect.gen(function* () {
710
- yield* paneRun(acquired.pane_id, beforePrompt)
711
- yield* waitAgentReady(acquired.pane_id, 5_000)
787
+ yield* paneRun(processService, acquired.pane_id, beforePrompt)
788
+ yield* waitAgentReady(processService, acquired.pane_id, 5_000)
712
789
  yield* Effect.sleep(300)
713
790
  }).pipe(Effect.ignore)
714
791
  }
715
792
 
716
793
  // Submit pointer into the live TUI (Herdr: pane run = text + Enter),
717
794
  // then confirm the agent actually started — do not trust fire-and-forget.
718
- const submitted = yield* Effect.result(paneRun(acquired.pane_id, prompt))
795
+ const submitted = yield* Effect.result(
796
+ paneRun(processService, acquired.pane_id, prompt),
797
+ )
719
798
  if (Result.isFailure(submitted)) {
720
799
  return yield* withLaunchDetails(submitted.failure, {
721
800
  // The Herdr CLI can lose its response after the pane accepted text.
@@ -726,7 +805,9 @@ function runInteractivePromptImpl(
726
805
  reused: acquired.reused,
727
806
  })
728
807
  }
729
- const submit = yield* ensurePromptSubmitted(acquired.pane_id, prompt)
808
+ const submit = yield* ensurePromptSubmitted(acquired.pane_id, prompt, {
809
+ processService,
810
+ })
730
811
  return {
731
812
  pane_id: acquired.pane_id,
732
813
  label: acquired.label,
@@ -739,122 +820,32 @@ function runInteractivePromptImpl(
739
820
  }
740
821
 
741
822
  /**
742
- * Thin Herdr service: pane lifecycle, interactive-prompt dispatch, and
743
- * floating-oneshot popups. Depends on nothing (spawns + node:fs directly,
744
- * like `VcsLive`'s `run`).
823
+ * Thin Herdr service for pane lifecycle and interactive-prompt dispatch.
824
+ * Depends on nothing, like `VcsLive`'s `run`.
745
825
  */
746
826
  export const makeHerdrLive = (hostAdapter: ApneaHostAdapter) =>
747
827
  Layer.effect(
748
828
  Herdr,
749
- Effect.sync(() =>
750
- Herdr.of({
829
+ Effect.gen(function* () {
830
+ const processService = yield* Process
831
+ return Herdr.of({
751
832
  enabled: Effect.sync(herdrEnabledSync),
752
833
 
753
- availability: Effect.try({
754
- try: herdrAvailabilitySync,
755
- catch: toHerdrError,
756
- }),
757
-
758
- version: Effect.sync(herdrVersionSync),
759
-
760
- hasApneaPlugin: Effect.sync(hasApneaPluginSync),
834
+ availability: herdrAvailability(processService),
761
835
 
762
- paneGet: (paneId) => Effect.sync(() => paneGetSync(paneId)),
836
+ paneGet: (paneId) => paneGet(processService, paneId),
763
837
 
764
- paneRun,
838
+ paneRun: (paneId, command) => paneRun(processService, paneId, command),
765
839
 
766
- paneReadRecent: (paneId) =>
767
- Effect.try({
768
- try: () => paneReadRecentSync(paneId),
769
- catch: toHerdrError,
770
- }),
840
+ paneReadRecent: (paneId) => paneReadRecent(processService, paneId),
771
841
 
772
842
  paneForegroundNames: (paneId) =>
773
- Effect.sync(() => paneForegroundNamesSync(paneId)),
843
+ paneForegroundNames(processService, paneId),
774
844
 
775
845
  runInteractivePrompt: (...args) =>
776
- runInteractivePromptImpl(hostAdapter, ...args),
777
-
778
- writeFloatingTaskScript: (scriptAbs, root, cmd, prompt, exitFileAbs) =>
779
- Effect.try({
780
- try: () => {
781
- if (cmd.length === 0) {
782
- throw new HerdrError({
783
- message:
784
- "floating oneshot cmd is empty; set cmd_oneshot on the role profile",
785
- })
786
- }
787
- const bin = cmd[0]
788
- if (bin === undefined || bin === "") {
789
- throw new HerdrError({
790
- message:
791
- "floating oneshot binary is empty; set cmd_oneshot on the role profile",
792
- })
793
- }
794
- const resolved = resolveExecutable(bin)
795
- if (!resolved) {
796
- throw new HerdrError({
797
- message: `floating oneshot binary "${bin}" not found on PATH; use an absolute cmd_oneshot or set pane_style=regular`,
798
- })
799
- }
800
- const resolvedCmd = [resolved, ...cmd.slice(1)]
801
- // No `exec`: EXIT trap must run after the oneshot exits (Hangup
802
- // included). End-of-options `--` before the prompt so variadic
803
- // flags like Claude's `--allowedTools <tools...>` cannot swallow
804
- // the prompt as another tool.
805
- const body = floatingTaskScriptBody({
806
- root,
807
- resolvedCmd,
808
- prompt,
809
- exitFileAbs,
810
- })
811
- fs.writeFileSync(scriptAbs, body, "utf8")
812
- fs.chmodSync(scriptAbs, 0o755)
813
- },
814
- catch: toHerdrError,
815
- }),
816
-
817
- openFloatingPane: (taskScriptAbs, _root) =>
818
- Effect.try({
819
- try: () => {
820
- const r = herdrCli([
821
- "plugin",
822
- "pane",
823
- "open",
824
- "--plugin",
825
- "apnea",
826
- "--entrypoint",
827
- "worker",
828
- "--placement",
829
- "popup",
830
- "--env",
831
- `APNEA_TASK_SCRIPT=${taskScriptAbs}`,
832
- "--env",
833
- `PATH=${floatingPanePath()}`,
834
- ])
835
- if (!r.ok) {
836
- const raw = r.raw.trim()
837
- if (/popup already open/i.test(raw)) {
838
- throw new HerdrError({
839
- message:
840
- "floating popup already open — herdr allows only one; dismiss it or workflow_wait for the in-flight oneshot before dispatching again",
841
- })
842
- }
843
- throw new HerdrError({
844
- message: `herdr plugin pane open failed: ${raw || r.raw}`,
845
- })
846
- }
847
- },
848
- catch: toHerdrError,
849
- }),
850
-
851
- linkPlugin: (dir) =>
852
- Effect.sync(() => {
853
- const r = herdrCli(["plugin", "link", dir])
854
- return { ok: r.ok, raw: r.raw }
855
- }),
856
- }),
857
- ),
846
+ runInteractivePromptImpl(processService, hostAdapter, ...args),
847
+ })
848
+ }),
858
849
  )
859
850
 
860
851
  export const HerdrLive = makeHerdrLive(neutralHostAdapter)