@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.
- package/CONTEXT.md +61 -0
- package/CONTRIBUTING.md +21 -0
- package/LICENSE +21 -0
- package/README.md +163 -0
- package/SECURITY.md +35 -0
- package/briefs/coder.md +40 -0
- package/briefs/orchestrator.md +49 -0
- package/briefs/planner.md +54 -0
- package/briefs/reviewer.md +40 -0
- package/dist/cli.js +39397 -0
- package/docs/adr/0001-completion-signaling.md +3 -0
- package/docs/adr/0002-orchestrator-authority.md +3 -0
- package/docs/adr/0003-verify-at-gate.md +3 -0
- package/docs/adr/0004-artifact-layout-and-naming.md +3 -0
- package/docs/adr/0005-harness-profiles.md +5 -0
- package/docs/adr/0006-config-trust-model.md +3 -0
- package/docs/adr/0007-jj-first-commits.md +3 -0
- package/docs/adr/0008-effect-v4-internals.md +3 -0
- package/docs/adr/0009-cli-driver-split.md +9 -0
- package/docs/adr/0010-package-split.md +23 -0
- package/docs/protocol/artifacts.md +68 -0
- package/docs/protocol/config.md +186 -0
- package/docs/protocol/manual-gate.md +38 -0
- package/docs/protocol/overview.md +96 -0
- package/extension/adapters/commit.ts +15 -0
- package/extension/adapters/dispatch.ts +15 -0
- package/extension/adapters/setup.ts +34 -0
- package/extension/adapters/start.ts +16 -0
- package/extension/adapters/status.ts +24 -0
- package/extension/adapters/wait.ts +20 -0
- package/extension/api.ts +16 -0
- package/extension/cli/format.ts +44 -0
- package/extension/cli/human-gate.ts +44 -0
- package/extension/cli/main.ts +218 -0
- package/extension/cli/parse.ts +48 -0
- package/extension/domain/artifact-kind.ts +26 -0
- package/extension/domain/frontmatter.ts +69 -0
- package/extension/domain/herdr.ts +109 -0
- package/extension/domain/paths.ts +139 -0
- package/extension/domain/recovery.ts +25 -0
- package/extension/domain/rounds.ts +16 -0
- package/extension/domain/setup.ts +158 -0
- package/extension/domain/slug.ts +9 -0
- package/extension/domain/state-machine.ts +132 -0
- package/extension/domain/timeouts.ts +24 -0
- package/extension/domain/types.ts +145 -0
- package/extension/domain/verify-commands.ts +128 -0
- package/extension/errors.ts +247 -0
- package/extension/host-adapter.ts +8 -0
- package/extension/registry.ts +323 -0
- package/extension/result.ts +55 -0
- package/extension/run-tool.ts +43 -0
- package/extension/schema/config.ts +315 -0
- package/extension/schema/frontmatter.ts +34 -0
- package/extension/schema/state.ts +119 -0
- package/extension/services/app-live.ts +24 -0
- package/extension/services/config.ts +103 -0
- package/extension/services/file-system.ts +178 -0
- package/extension/services/herdr.ts +860 -0
- package/extension/services/run-store.ts +99 -0
- package/extension/services/vcs.ts +246 -0
- package/extension/workflows/commit.ts +148 -0
- package/extension/workflows/dispatch.ts +693 -0
- package/extension/workflows/reset.ts +26 -0
- package/extension/workflows/setup.ts +301 -0
- package/extension/workflows/start.ts +149 -0
- package/extension/workflows/status.ts +45 -0
- package/extension/workflows/wait.ts +793 -0
- package/herdr-plugin/herdr-plugin.toml +15 -0
- package/herdr-plugin/scripts/run-task.sh +8 -0
- package/package.json +75 -0
- package/schemas/artifact-frontmatter.md +38 -0
- package/schemas/config.schema.json +50 -0
- 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)
|