@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.
- package/README.md +25 -10
- package/SECURITY.md +32 -0
- package/briefs/orchestrator.md +4 -3
- package/dist/cli.js +8571 -15324
- package/docs/adr/0005-harness-profiles.md +1 -1
- package/docs/adr/0010-package-split.md +1 -1
- package/docs/protocol/artifacts.md +18 -2
- package/docs/protocol/config.md +22 -25
- package/docs/protocol/manual-gate.md +8 -8
- package/docs/protocol/overview.md +18 -5
- 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/herdr.ts +0 -86
- package/extension/domain/paths.ts +3 -13
- package/extension/domain/setup.ts +0 -20
- package/extension/domain/timeouts.ts +4 -0
- package/extension/domain/types.ts +64 -11
- package/extension/domain/verify-commands.ts +200 -108
- 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 +86 -30
- package/extension/schema/frontmatter.ts +57 -0
- package/extension/schema/state.ts +226 -16
- 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 +393 -402
- 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 +1388 -86
- package/extension/workflows/commit.ts +222 -18
- package/extension/workflows/dispatch.ts +320 -220
- package/extension/workflows/setup.ts +61 -141
- package/extension/workflows/start.ts +6 -5
- package/extension/workflows/status.ts +2 -2
- package/extension/workflows/wait.ts +63 -134
- package/package.json +2 -3
- package/schemas/config.schema.json +11 -7
- package/schemas/state.schema.json +170 -12
- package/herdr-plugin/herdr-plugin.toml +0 -15
- 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
|
|
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
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
|
122
|
-
*
|
|
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
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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
|
|
218
|
-
|
|
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
|
|
241
|
+
function paneReadRecent(
|
|
242
|
+
processService: ProcessService,
|
|
243
|
+
paneId: string,
|
|
244
|
+
): Effect.Effect<string, HerdrError> {
|
|
222
245
|
const args = paneReadRecentArgs(paneId)
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
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
|
|
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
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
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
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
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
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
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
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
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
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
}
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
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
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
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 =
|
|
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
|
-
}
|
|
352
|
-
return []
|
|
353
|
-
}
|
|
418
|
+
}).pipe(Effect.catch(() => Effect.succeed([])))
|
|
354
419
|
}
|
|
355
420
|
|
|
356
|
-
function toHerdrError(
|
|
357
|
-
return
|
|
358
|
-
?
|
|
359
|
-
: 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
|
+
})
|
|
360
427
|
}
|
|
361
428
|
|
|
362
|
-
|
|
363
|
-
|
|
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.
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
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
|
|
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
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
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 =
|
|
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 =
|
|
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
|
|
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
|
|
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(
|
|
537
|
+
function livePromptProbes(
|
|
538
|
+
processService: ProcessService,
|
|
539
|
+
paneId: string,
|
|
540
|
+
): PromptProbes {
|
|
492
541
|
return {
|
|
493
|
-
status: () =>
|
|
494
|
-
|
|
495
|
-
|
|
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
|
-
|
|
515
|
-
|
|
516
|
-
|
|
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 =
|
|
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 = (
|
|
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 (
|
|
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:
|
|
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*
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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(
|
|
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
|
|
743
|
-
*
|
|
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.
|
|
750
|
-
|
|
829
|
+
Effect.gen(function* () {
|
|
830
|
+
const processService = yield* Process
|
|
831
|
+
return Herdr.of({
|
|
751
832
|
enabled: Effect.sync(herdrEnabledSync),
|
|
752
833
|
|
|
753
|
-
availability:
|
|
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) =>
|
|
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
|
-
|
|
843
|
+
paneForegroundNames(processService, paneId),
|
|
774
844
|
|
|
775
845
|
runInteractivePrompt: (...args) =>
|
|
776
|
-
runInteractivePromptImpl(hostAdapter, ...args),
|
|
777
|
-
|
|
778
|
-
|
|
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)
|