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