@naxodev/apnea 0.1.0

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 (74) hide show
  1. package/CONTEXT.md +61 -0
  2. package/CONTRIBUTING.md +21 -0
  3. package/LICENSE +21 -0
  4. package/README.md +163 -0
  5. package/SECURITY.md +35 -0
  6. package/briefs/coder.md +40 -0
  7. package/briefs/orchestrator.md +49 -0
  8. package/briefs/planner.md +54 -0
  9. package/briefs/reviewer.md +40 -0
  10. package/dist/cli.js +39397 -0
  11. package/docs/adr/0001-completion-signaling.md +3 -0
  12. package/docs/adr/0002-orchestrator-authority.md +3 -0
  13. package/docs/adr/0003-verify-at-gate.md +3 -0
  14. package/docs/adr/0004-artifact-layout-and-naming.md +3 -0
  15. package/docs/adr/0005-harness-profiles.md +5 -0
  16. package/docs/adr/0006-config-trust-model.md +3 -0
  17. package/docs/adr/0007-jj-first-commits.md +3 -0
  18. package/docs/adr/0008-effect-v4-internals.md +3 -0
  19. package/docs/adr/0009-cli-driver-split.md +9 -0
  20. package/docs/adr/0010-package-split.md +23 -0
  21. package/docs/protocol/artifacts.md +68 -0
  22. package/docs/protocol/config.md +186 -0
  23. package/docs/protocol/manual-gate.md +38 -0
  24. package/docs/protocol/overview.md +96 -0
  25. package/extension/adapters/commit.ts +15 -0
  26. package/extension/adapters/dispatch.ts +15 -0
  27. package/extension/adapters/setup.ts +34 -0
  28. package/extension/adapters/start.ts +16 -0
  29. package/extension/adapters/status.ts +24 -0
  30. package/extension/adapters/wait.ts +20 -0
  31. package/extension/api.ts +16 -0
  32. package/extension/cli/format.ts +44 -0
  33. package/extension/cli/human-gate.ts +44 -0
  34. package/extension/cli/main.ts +218 -0
  35. package/extension/cli/parse.ts +48 -0
  36. package/extension/domain/artifact-kind.ts +26 -0
  37. package/extension/domain/frontmatter.ts +69 -0
  38. package/extension/domain/herdr.ts +109 -0
  39. package/extension/domain/paths.ts +139 -0
  40. package/extension/domain/recovery.ts +25 -0
  41. package/extension/domain/rounds.ts +16 -0
  42. package/extension/domain/setup.ts +158 -0
  43. package/extension/domain/slug.ts +9 -0
  44. package/extension/domain/state-machine.ts +132 -0
  45. package/extension/domain/timeouts.ts +24 -0
  46. package/extension/domain/types.ts +145 -0
  47. package/extension/domain/verify-commands.ts +128 -0
  48. package/extension/errors.ts +247 -0
  49. package/extension/host-adapter.ts +8 -0
  50. package/extension/registry.ts +323 -0
  51. package/extension/result.ts +55 -0
  52. package/extension/run-tool.ts +43 -0
  53. package/extension/schema/config.ts +315 -0
  54. package/extension/schema/frontmatter.ts +34 -0
  55. package/extension/schema/state.ts +119 -0
  56. package/extension/services/app-live.ts +24 -0
  57. package/extension/services/config.ts +103 -0
  58. package/extension/services/file-system.ts +178 -0
  59. package/extension/services/herdr.ts +860 -0
  60. package/extension/services/run-store.ts +99 -0
  61. package/extension/services/vcs.ts +246 -0
  62. package/extension/workflows/commit.ts +148 -0
  63. package/extension/workflows/dispatch.ts +693 -0
  64. package/extension/workflows/reset.ts +26 -0
  65. package/extension/workflows/setup.ts +301 -0
  66. package/extension/workflows/start.ts +149 -0
  67. package/extension/workflows/status.ts +45 -0
  68. package/extension/workflows/wait.ts +793 -0
  69. package/herdr-plugin/herdr-plugin.toml +15 -0
  70. package/herdr-plugin/scripts/run-task.sh +8 -0
  71. package/package.json +75 -0
  72. package/schemas/artifact-frontmatter.md +38 -0
  73. package/schemas/config.schema.json +50 -0
  74. package/schemas/state.schema.json +63 -0
@@ -0,0 +1,860 @@
1
+ import { spawnSync } from "node:child_process"
2
+ import * as fs from "node:fs"
3
+ import * as os from "node:os"
4
+ import * as path from "node:path"
5
+ import { Clock, Context, Effect, Layer, Option, Result } from "effect"
6
+ import {
7
+ floatingTaskScriptBody,
8
+ parseHerdrVersion,
9
+ shellJoin,
10
+ } from "../domain/herdr.ts"
11
+ import { HerdrError } from "../errors.ts"
12
+ import type { ApneaHostAdapter } from "../host-adapter.ts"
13
+ import { neutralHostAdapter } from "../host-adapter.ts"
14
+
15
+ export type PaneInfo = {
16
+ ok: boolean
17
+ agent_status?: string
18
+ label?: string
19
+ agent?: string
20
+ }
21
+ export type RolePaneRef = { pane_id: string; label: string }
22
+ export type HerdrAvailability = "available" | "unavailable"
23
+ export type InteractiveLaunch = {
24
+ pane_id: string
25
+ label: string
26
+ reused: boolean
27
+ prompt_accepted: boolean
28
+ prompt_attempts: number
29
+ last_status?: string
30
+ }
31
+
32
+ export interface HerdrService {
33
+ readonly enabled: Effect.Effect<boolean>
34
+ /** Dispatch preflight that distinguishes a stale pane from CLI failures. */
35
+ 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>
39
+ readonly paneRun: (
40
+ paneId: string,
41
+ command: string,
42
+ ) => Effect.Effect<void, HerdrError>
43
+ readonly paneReadRecent: (
44
+ paneId: string,
45
+ ) => Effect.Effect<string | null, HerdrError>
46
+ readonly paneForegroundNames: (paneId: string) => Effect.Effect<string[]>
47
+ readonly runInteractivePrompt: (
48
+ role: string,
49
+ interactiveCmd: string[],
50
+ prompt: string,
51
+ prefer: RolePaneRef | null,
52
+ ) => 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
+ }
68
+
69
+ export class Herdr extends Context.Service<Herdr, HerdrService>()(
70
+ "apnea/Herdr",
71
+ ) {}
72
+
73
+ export const paneReadRecentArgs = (paneId: string): string[] => [
74
+ "pane",
75
+ "read",
76
+ paneId,
77
+ "--source",
78
+ "recent-unwrapped",
79
+ "--lines",
80
+ "80",
81
+ "--format",
82
+ "text",
83
+ ]
84
+
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,
89
+ })
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
+ }
102
+
103
+ function resultOf(json: unknown): Record<string, unknown> | null {
104
+ if (!json || typeof json !== "object") return null
105
+ const o = json as Record<string, unknown>
106
+ if (o.result && typeof o.result === "object")
107
+ return o.result as Record<string, unknown>
108
+ return o
109
+ }
110
+
111
+ function isExecutableFile(abs: string): boolean {
112
+ try {
113
+ fs.accessSync(abs, fs.constants.X_OK)
114
+ return fs.statSync(abs).isFile()
115
+ } catch {
116
+ return false
117
+ }
118
+ }
119
+
120
+ /**
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).
126
+ */
127
+ export function resolveExecutable(
128
+ bin: string,
129
+ envPath: string | undefined = process.env.PATH,
130
+ ): string | null {
131
+ if (!bin) return null
132
+ if (bin.includes("/") || bin.includes("\\")) {
133
+ const abs = path.isAbsolute(bin) ? bin : path.resolve(bin)
134
+ return isExecutableFile(abs) ? abs : null
135
+ }
136
+ for (const dir of (envPath ?? "").split(path.delimiter)) {
137
+ if (!dir) continue
138
+ const candidate = path.join(dir, bin)
139
+ if (isExecutableFile(candidate)) return candidate
140
+ }
141
+ return null
142
+ }
143
+
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
+ function herdrEnabledSync(): boolean {
175
+ return process.env.HERDR_ENV === "1"
176
+ }
177
+
178
+ export function probeHerdrAvailability(
179
+ env: { HERDR_ENV?: string; HERDR_PANE_ID?: string },
180
+ paneGet: (paneId: string) => { ok: boolean; raw: string },
181
+ ): HerdrAvailability {
182
+ if (env.HERDR_ENV !== "1") return "unavailable"
183
+ const current = env.HERDR_PANE_ID
184
+ if (!current) return "unavailable"
185
+ const r = paneGet(current)
186
+ if (r.ok) return "available"
187
+ if (/pane_not_found|pane not found/i.test(r.raw)) return "unavailable"
188
+ throw new HerdrError({
189
+ message: `failed to verify current Herdr pane ${current}: ${r.raw.trim() || "unknown herdr error"}`,
190
+ command: "herdr pane get",
191
+ })
192
+ }
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,
214
+ }
215
+ }
216
+
217
+ function paneAliveSync(paneId: string): boolean {
218
+ return paneGetSync(paneId).ok
219
+ }
220
+
221
+ function paneReadRecentSync(paneId: string): string {
222
+ const args = paneReadRecentArgs(paneId)
223
+ const r = spawnSync("herdr", args, {
224
+ encoding: "utf8",
225
+ maxBuffer: 10 * 1024 * 1024,
226
+ })
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
+ }
241
+
242
+ /** Prefer right on wide panes, down on tall/narrow ones. */
243
+ function splitDirectionSync(): "right" | "down" {
244
+ 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"
256
+ }
257
+
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
279
+ }
280
+
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
+ }
286
+ }
287
+
288
+ /**
289
+ * Send text + Enter into a pane.
290
+ * When a live agent TUI is focused, this submits a prompt (not a shell command).
291
+ * When the pane is a bare shell, this runs a shell line.
292
+ */
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
+ }
301
+ }
302
+
303
+ /** 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
324
+ }
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)
328
+ }
329
+
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
+ }
348
+ }
349
+ const procs = json.result?.process_info?.foreground_processes ?? []
350
+ return procs.map((p) => p.cmdline || p.argv0 || p.name || "?")
351
+ } catch {
352
+ return []
353
+ }
354
+ }
355
+
356
+ function toHerdrError(e: unknown): HerdrError {
357
+ return e instanceof HerdrError
358
+ ? e
359
+ : new HerdrError({ message: e instanceof Error ? e.message : String(e) })
360
+ }
361
+
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(
369
+ paneId: string,
370
+ command: string,
371
+ ): 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,
390
+ })
391
+ }
392
+
393
+ function withLaunchDetails(
394
+ error: HerdrError,
395
+ details: Record<string, unknown>,
396
+ ): HerdrError {
397
+ return new HerdrError({
398
+ message: error.message,
399
+ ...(error.command !== undefined ? { command: error.command } : {}),
400
+ details: { ...(error.details ?? {}), ...details },
401
+ })
402
+ }
403
+
404
+ /** Close a pane that cannot have received the task prompt without hiding the launch error. */
405
+ export function cleanupFailedInteractiveLaunch(
406
+ error: HerdrError,
407
+ paneId: string,
408
+ close: (paneId: string) => Effect.Effect<void, HerdrError> = paneClose,
409
+ ): Effect.Effect<never, HerdrError> {
410
+ return Effect.gen(function* () {
411
+ const cleanup = yield* Effect.result(close(paneId))
412
+ return yield* withLaunchDetails(error, {
413
+ delivery: "not_delivered",
414
+ pane_id: paneId,
415
+ newly_created: true,
416
+ pane_cleanup: Result.isSuccess(cleanup) ? "closed" : "failed",
417
+ ...(Result.isFailure(cleanup)
418
+ ? { pane_cleanup_error: cleanup.failure.message }
419
+ : {}),
420
+ })
421
+ })
422
+ }
423
+
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
+ /** Unique label for a role slot (stable for the run when we reuse the pane). */
435
+ function roleLabel(role: string, millis: number): string {
436
+ const id = `${millis.toString(36)}-${Math.random().toString(36).slice(2, 6)}`
437
+ return `apnea:${role}:${id}`
438
+ }
439
+
440
+ /**
441
+ * Wait until agent reports idle or done (ready for a prompt).
442
+ * Uses herdr wait when available; falls back to poll.
443
+ */
444
+ function waitAgentReady(
445
+ paneId: string,
446
+ timeoutMs = 90_000,
447
+ ): Effect.Effect<string | undefined> {
448
+ return Effect.gen(function* () {
449
+ // Prefer Herdr's blocking wait (does not freeze our caller if we use it
450
+ // 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
+ ])
460
+ if (r.ok) {
461
+ const s = paneGetSync(paneId).agent_status
462
+ if (s === "idle" || s === "done") return s
463
+ }
464
+ // fall back: poll (done also counts as ready). Clock, not Date.now(): the
465
+ // sleep below is virtualized under TestClock, so a wall-clock deadline
466
+ // would never be reached in a test.
467
+ const deadline =
468
+ (yield* Clock.currentTimeMillis) + Math.min(timeoutMs, 30_000)
469
+ while ((yield* Clock.currentTimeMillis) < deadline) {
470
+ const s = paneGetSync(paneId).agent_status
471
+ if (s === "idle" || s === "done") return s
472
+ yield* Effect.sleep(500)
473
+ }
474
+ return paneGetSync(paneId).agent_status
475
+ })
476
+ }
477
+
478
+ /**
479
+ * The three pane operations the recovery ladder drives.
480
+ *
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.
484
+ */
485
+ export type PromptProbes = {
486
+ readonly status: () => string | undefined
487
+ readonly sendKeys: (keys: string[]) => Effect.Effect<void, HerdrError>
488
+ readonly run: (text: string) => Effect.Effect<void, HerdrError>
489
+ }
490
+
491
+ function livePromptProbes(paneId: string): PromptProbes {
492
+ return {
493
+ status: () => paneGetSync(paneId).agent_status,
494
+ sendKeys: (keys) => sendKeys(paneId, keys),
495
+ run: (text) => paneRun(paneId, text),
496
+ }
497
+ }
498
+
499
+ /**
500
+ * After submitting a prompt, confirm the agent actually started working.
501
+ * Claude often parks multi-line paste in the input without submitting;
502
+ * pi+vim can leave the prompt in INSERT mode. Recover with Escape+Enter
503
+ * (then one full re-submit) before giving up.
504
+ */
505
+ export function ensurePromptSubmitted(
506
+ paneId: string,
507
+ prompt: string,
508
+ opts?: {
509
+ settleMs?: number
510
+ workingWaitMs?: number
511
+ probes?: PromptProbes
512
+ },
513
+ ): Effect.Effect<{
514
+ accepted: boolean
515
+ attempts: number
516
+ last_status?: string
517
+ }> {
518
+ return Effect.gen(function* () {
519
+ const probes = opts?.probes ?? livePromptProbes(paneId)
520
+ const settleMs = opts?.settleMs ?? 2500
521
+ const workingWaitMs = opts?.workingWaitMs ?? 12_000
522
+ let attempts = 1
523
+
524
+ const waitForWorking = (ms: number): Effect.Effect<string | undefined> =>
525
+ Effect.gen(function* () {
526
+ const deadline = (yield* Clock.currentTimeMillis) + ms
527
+ while ((yield* Clock.currentTimeMillis) < deadline) {
528
+ const s = probes.status()
529
+ if (s === "working" || s === "blocked") return s
530
+ yield* Effect.sleep(400)
531
+ }
532
+ return probes.status()
533
+ })
534
+
535
+ // Give the first paneRun a moment to flip status.
536
+ yield* Effect.sleep(settleMs)
537
+ let status = yield* waitForWorking(workingWaitMs)
538
+ if (status === "working" || status === "blocked") {
539
+ return { accepted: true, attempts, last_status: status }
540
+ }
541
+
542
+ // Paste often lands without submit — Enter alone recovers Claude;
543
+ // Escape first exits pi-vim INSERT so Enter can actually submit.
544
+ // `*Sync` helpers throw, and a throw inside Effect.gen is a *defect* that
545
+ // Effect.ignore/Effect.option do not catch — wrap in Effect.try so a dead
546
+ // pane stays best-effort instead of aborting dispatch before state is saved.
547
+ attempts += 1
548
+ yield* Effect.ignore(
549
+ Effect.gen(function* () {
550
+ yield* probes.sendKeys(["Escape"])
551
+ yield* Effect.sleep(150)
552
+ yield* probes.sendKeys(["Enter"])
553
+ }),
554
+ )
555
+ status = yield* waitForWorking(workingWaitMs)
556
+ if (status === "working" || status === "blocked") {
557
+ return { accepted: true, attempts, last_status: status }
558
+ }
559
+
560
+ // Full re-submit once (covers lost/mangled first paste).
561
+ attempts += 1
562
+ const resubmitted = yield* Effect.option(
563
+ Effect.gen(function* () {
564
+ yield* probes.sendKeys(["Escape"])
565
+ yield* Effect.sleep(100)
566
+ yield* probes.run(prompt)
567
+ }),
568
+ )
569
+ if (Option.isNone(resubmitted)) {
570
+ return {
571
+ accepted: false,
572
+ attempts,
573
+ last_status: probes.status(),
574
+ }
575
+ }
576
+ yield* Effect.sleep(settleMs)
577
+ status = yield* waitForWorking(workingWaitMs)
578
+ return {
579
+ accepted: status === "working" || status === "blocked",
580
+ attempts,
581
+ last_status: status,
582
+ }
583
+ })
584
+ }
585
+
586
+ /**
587
+ * Resolve a pane for a role:
588
+ * - reuse `prefer` if that pane_id is still alive
589
+ * - otherwise split a new pane with a unique label
590
+ *
591
+ * Never claims an unrelated pane by scanning labels alone.
592
+ */
593
+ function acquireRolePane(
594
+ role: string,
595
+ hostAdapter: ApneaHostAdapter,
596
+ opts?: {
597
+ prefer?: RolePaneRef | null
598
+ /** Launch interactive harness only when creating a new pane */
599
+ interactiveCmd?: string[]
600
+ },
601
+ ): Effect.Effect<RolePaneRef & { reused: boolean }, HerdrError> {
602
+ return Effect.gen(function* () {
603
+ if (!herdrEnabledSync()) {
604
+ return yield* new HerdrError({
605
+ message: "not inside Herdr (HERDR_ENV!=1); cannot manage panes",
606
+ })
607
+ }
608
+
609
+ if (opts?.prefer?.pane_id && paneAliveSync(opts.prefer.pane_id)) {
610
+ return {
611
+ pane_id: opts.prefer.pane_id,
612
+ label: opts.prefer.label,
613
+ reused: true,
614
+ }
615
+ }
616
+
617
+ const millis = yield* Clock.currentTimeMillis
618
+ const label = roleLabel(role, millis)
619
+ const split = yield* Effect.result(
620
+ Effect.try({
621
+ try: () => splitPaneSync(),
622
+ catch: toHerdrError,
623
+ }),
624
+ )
625
+ if (Result.isFailure(split)) {
626
+ return yield* withLaunchDetails(split.failure, {
627
+ delivery: "not_delivered",
628
+ newly_created: false,
629
+ })
630
+ }
631
+ const paneId = split.success
632
+ const prepared = yield* Effect.result(
633
+ Effect.gen(function* () {
634
+ yield* Effect.try({
635
+ try: () => renamePaneSync(paneId, label),
636
+ catch: toHerdrError,
637
+ })
638
+ if (!opts?.interactiveCmd?.length) return
639
+ // Launch the interactive harness only (no task argv).
640
+ // Pi roles get PI_CODING_AGENT_DIR without pi-vimmode so pane-run pastes
641
+ // are not trapped in modal INSERT. Materializing that dir touches the
642
+ // filesystem, so keep its failure a typed HerdrError, not a defect.
643
+ const interactiveCmd = opts.interactiveCmd
644
+ const launchCmd = yield* Effect.try({
645
+ try: () =>
646
+ hostAdapter.prepareInteractiveCommand?.(interactiveCmd) ??
647
+ interactiveCmd,
648
+ catch: toHerdrError,
649
+ })
650
+ const cmd = shellJoin(["cd", process.cwd(), "&&", "exec", ...launchCmd])
651
+ yield* paneRun(paneId, cmd)
652
+ }),
653
+ )
654
+ if (Result.isFailure(prepared)) {
655
+ return yield* cleanupFailedInteractiveLaunch(prepared.failure, paneId)
656
+ }
657
+ return { pane_id: paneId, label, reused: false }
658
+ })
659
+ }
660
+
661
+ /**
662
+ * Open the interactive harness TUI in a pane (or reuse), wait until idle,
663
+ * then submit a short pointer prompt via `pane run` (text + Enter).
664
+ *
665
+ * This is the Herdr-recommended path: live agent you can watch, not
666
+ * `claude -p` / `pi -p` dumping shell output.
667
+ */
668
+ function runInteractivePromptImpl(
669
+ hostAdapter: ApneaHostAdapter,
670
+ role: string,
671
+ interactiveCmd: string[],
672
+ prompt: string,
673
+ prefer: RolePaneRef | null,
674
+ ): Effect.Effect<InteractiveLaunch, HerdrError> {
675
+ return Effect.gen(function* () {
676
+ let preferUse: RolePaneRef | null = null
677
+ if (prefer?.pane_id) {
678
+ // One `pane get`: liveness and agent_status come from the same call.
679
+ const info = paneGetSync(prefer.pane_id)
680
+ // reuse only when a live agent can take a new prompt
681
+ // working/blocked/unknown/shell-only → new pane
682
+ if (
683
+ info.ok &&
684
+ (info.agent_status === "idle" || info.agent_status === "done")
685
+ ) {
686
+ preferUse = prefer
687
+ }
688
+ }
689
+
690
+ const acquired = yield* acquireRolePane(role, hostAdapter, {
691
+ prefer: preferUse,
692
+ interactiveCmd: preferUse ? undefined : interactiveCmd,
693
+ })
694
+
695
+ if (!acquired.reused) {
696
+ yield* waitAgentReady(acquired.pane_id, 90_000)
697
+ // still try even if not idle/done — some harnesses accept input
698
+ // before status settles.
699
+ } else {
700
+ const st = paneGetSync(acquired.pane_id).agent_status
701
+ if (st !== "idle" && st !== "done") {
702
+ yield* waitAgentReady(acquired.pane_id, 30_000)
703
+ }
704
+ }
705
+
706
+ const beforePrompt = hostAdapter.beforeInteractivePrompt?.(interactiveCmd)
707
+ if (beforePrompt) {
708
+ // Host preparation is best-effort; command wrapping is the primary guard.
709
+ yield* Effect.gen(function* () {
710
+ yield* paneRun(acquired.pane_id, beforePrompt)
711
+ yield* waitAgentReady(acquired.pane_id, 5_000)
712
+ yield* Effect.sleep(300)
713
+ }).pipe(Effect.ignore)
714
+ }
715
+
716
+ // Submit pointer into the live TUI (Herdr: pane run = text + Enter),
717
+ // then confirm the agent actually started — do not trust fire-and-forget.
718
+ const submitted = yield* Effect.result(paneRun(acquired.pane_id, prompt))
719
+ if (Result.isFailure(submitted)) {
720
+ return yield* withLaunchDetails(submitted.failure, {
721
+ // The Herdr CLI can lose its response after the pane accepted text.
722
+ // Closing or retrying here could kill or duplicate a live worker.
723
+ delivery: "unknown",
724
+ pane_id: acquired.pane_id,
725
+ pane_label: acquired.label,
726
+ reused: acquired.reused,
727
+ })
728
+ }
729
+ const submit = yield* ensurePromptSubmitted(acquired.pane_id, prompt)
730
+ return {
731
+ pane_id: acquired.pane_id,
732
+ label: acquired.label,
733
+ reused: acquired.reused,
734
+ prompt_accepted: submit.accepted,
735
+ prompt_attempts: submit.attempts,
736
+ last_status: submit.last_status,
737
+ }
738
+ })
739
+ }
740
+
741
+ /**
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`).
745
+ */
746
+ export const makeHerdrLive = (hostAdapter: ApneaHostAdapter) =>
747
+ Layer.effect(
748
+ Herdr,
749
+ Effect.sync(() =>
750
+ Herdr.of({
751
+ enabled: Effect.sync(herdrEnabledSync),
752
+
753
+ availability: Effect.try({
754
+ try: herdrAvailabilitySync,
755
+ catch: toHerdrError,
756
+ }),
757
+
758
+ version: Effect.sync(herdrVersionSync),
759
+
760
+ hasApneaPlugin: Effect.sync(hasApneaPluginSync),
761
+
762
+ paneGet: (paneId) => Effect.sync(() => paneGetSync(paneId)),
763
+
764
+ paneRun,
765
+
766
+ paneReadRecent: (paneId) =>
767
+ Effect.try({
768
+ try: () => paneReadRecentSync(paneId),
769
+ catch: toHerdrError,
770
+ }),
771
+
772
+ paneForegroundNames: (paneId) =>
773
+ Effect.sync(() => paneForegroundNamesSync(paneId)),
774
+
775
+ 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
+ ),
858
+ )
859
+
860
+ export const HerdrLive = makeHerdrLive(neutralHostAdapter)