pi-herdr-managed-agents 0.1.0-beta.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.
@@ -0,0 +1,1518 @@
1
+ import { spawn } 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 { randomBytes, randomUUID } from "node:crypto";
6
+ import type { Cause, Scope } from "effect";
7
+ import { Effect, Queue, Stream } from "effect";
8
+ import type { SubagentBackend, SubagentSession } from "../backend.ts";
9
+ import { resolvePiModel } from "./pi.ts";
10
+ import type {
11
+ BackendName,
12
+ ReasoningEffort,
13
+ RunOutcome,
14
+ SpawnTask,
15
+ SubagentEvent,
16
+ SubagentMeta,
17
+ } from "../domain.ts";
18
+ import { SendError, SpawnError } from "../domain.ts";
19
+
20
+ const COMMAND_OUTPUT_MAX_BYTES = 2 * 1024 * 1024;
21
+ const LIVE_READ_INTERVAL_MS = 500;
22
+ const INTERRUPT_STAGE_TIMEOUT_MS = 750;
23
+ const MONITOR_RETRY_ATTEMPTS = 3;
24
+ const MONITOR_RETRY_DELAY_MS = 150;
25
+ const CODEX_PATH_CACHE_RETRY_MS = 1_000;
26
+ const FINAL_OUTPUT_MAX_LENGTH = 1024 * 1024;
27
+ const CHILD_EXCLUDED_TOOLS = [
28
+ "subagent_spawn",
29
+ "subagent_wait",
30
+ "subagent_send",
31
+ "subagent_cancel",
32
+ "subagent_check",
33
+ "subagent_list",
34
+ "workflow",
35
+ "ask_user",
36
+ ].join(",");
37
+
38
+ interface CommandResult {
39
+ readonly stdout: string;
40
+ readonly stderr: string;
41
+ }
42
+
43
+ type JsonRecord = Record<string, unknown>;
44
+
45
+ function record(value: unknown): JsonRecord | undefined {
46
+ return value !== null && typeof value === "object" && !Array.isArray(value)
47
+ ? (value as JsonRecord)
48
+ : undefined;
49
+ }
50
+
51
+ function stringValue(value: unknown) {
52
+ return typeof value === "string" ? value : undefined;
53
+ }
54
+
55
+ function boundedError(error: unknown) {
56
+ return (error instanceof Error ? error.message : String(error)).slice(
57
+ 0,
58
+ 4096,
59
+ );
60
+ }
61
+
62
+ export function isMissingHerdrTarget(error: unknown) {
63
+ const message = boundedError(error);
64
+ return (
65
+ message.includes('"code":"agent_not_found"') ||
66
+ message.includes('"code":"pane_not_found"') ||
67
+ message.includes('"code":"tab_not_found"')
68
+ );
69
+ }
70
+
71
+ function isAbortedHerdrCommand(error: unknown) {
72
+ return boundedError(error).includes("Herdr command aborted.");
73
+ }
74
+
75
+ function delay(ms: number) {
76
+ return new Promise<void>((resolve) => setTimeout(resolve, ms));
77
+ }
78
+
79
+ export async function retryHerdrMonitoring<A>(
80
+ operation: () => Promise<A>,
81
+ signal?: AbortSignal,
82
+ ) {
83
+ let lastError: unknown;
84
+ for (let attempt = 0; attempt < MONITOR_RETRY_ATTEMPTS; attempt++) {
85
+ if (signal?.aborted) throw new Error("Herdr command aborted.");
86
+ try {
87
+ return await operation();
88
+ } catch (error) {
89
+ if (isMissingHerdrTarget(error) || isAbortedHerdrCommand(error)) {
90
+ throw error;
91
+ }
92
+ lastError = error;
93
+ if (attempt + 1 < MONITOR_RETRY_ATTEMPTS) {
94
+ await delay(MONITOR_RETRY_DELAY_MS * (attempt + 1));
95
+ }
96
+ }
97
+ }
98
+ throw lastError;
99
+ }
100
+
101
+ function commandError(
102
+ args: ReadonlyArray<string>,
103
+ code: number | null,
104
+ stderr: string,
105
+ ) {
106
+ const detail = stderr.trim();
107
+ return new Error(
108
+ `herdr ${args.join(" ")} failed (${code === null ? "terminated" : `code ${code}`})${detail ? `: ${detail}` : ""}`,
109
+ );
110
+ }
111
+
112
+ function runHerdr(
113
+ args: ReadonlyArray<string>,
114
+ options: { signal?: AbortSignal; timeoutMs?: number } = {},
115
+ ) {
116
+ return new Promise<CommandResult>((resolve, reject) => {
117
+ const child = spawn("herdr", [...args], {
118
+ env: process.env,
119
+ stdio: ["ignore", "pipe", "pipe"],
120
+ windowsHide: true,
121
+ });
122
+ let stdout = "";
123
+ let stderr = "";
124
+ let settled = false;
125
+ let timer: ReturnType<typeof setTimeout> | undefined;
126
+
127
+ const finish = (operation: () => void) => {
128
+ if (settled) return;
129
+ settled = true;
130
+ if (timer) clearTimeout(timer);
131
+ options.signal?.removeEventListener("abort", onAbort);
132
+ operation();
133
+ };
134
+ const terminate = (error: Error) => {
135
+ if (settled) return;
136
+ try {
137
+ child.kill("SIGTERM");
138
+ } catch {
139
+ // The process may already have exited.
140
+ }
141
+ const killTimer = setTimeout(() => {
142
+ try {
143
+ child.kill("SIGKILL");
144
+ } catch {
145
+ // The process may already have exited.
146
+ }
147
+ }, 500);
148
+ killTimer.unref();
149
+ finish(() => reject(error));
150
+ };
151
+ const onAbort = () => terminate(new Error("Herdr command aborted."));
152
+
153
+ if (options.signal) {
154
+ if (options.signal.aborted) onAbort();
155
+ else options.signal.addEventListener("abort", onAbort, { once: true });
156
+ }
157
+ if (options.timeoutMs !== undefined && !settled) {
158
+ timer = setTimeout(
159
+ () =>
160
+ terminate(
161
+ new Error(
162
+ `Herdr command timed out after ${options.timeoutMs} ms: herdr ${args.join(" ")}`,
163
+ ),
164
+ ),
165
+ options.timeoutMs,
166
+ );
167
+ }
168
+
169
+ child.stdout.setEncoding("utf8");
170
+ child.stderr.setEncoding("utf8");
171
+ child.stdout.on("data", (chunk: string) => {
172
+ stdout = `${stdout}${chunk}`.slice(-COMMAND_OUTPUT_MAX_BYTES);
173
+ });
174
+ child.stderr.on("data", (chunk: string) => {
175
+ stderr = `${stderr}${chunk}`.slice(-COMMAND_OUTPUT_MAX_BYTES);
176
+ });
177
+ child.once("error", (error) => finish(() => reject(error)));
178
+ child.once("exit", (code) => {
179
+ finish(() => {
180
+ if (options.signal?.aborted) {
181
+ reject(new Error("Herdr command aborted."));
182
+ } else if (code === 0) {
183
+ resolve({ stdout, stderr });
184
+ } else {
185
+ reject(commandError(args, code, stderr));
186
+ }
187
+ });
188
+ });
189
+ });
190
+ }
191
+
192
+ function parseJsonOutput(output: string) {
193
+ const lines = output.split("\n");
194
+ for (let index = lines.length - 1; index >= 0; index--) {
195
+ const line = lines[index]?.trim();
196
+ if (line) return JSON.parse(line) as JsonRecord;
197
+ }
198
+ throw new Error("Herdr returned no JSON response.");
199
+ }
200
+
201
+ function nestedString(value: unknown, ...keys: string[]) {
202
+ let current: unknown = value;
203
+ for (const key of keys) current = record(current)?.[key];
204
+ return stringValue(current);
205
+ }
206
+
207
+ function herdrAvailable() {
208
+ return process.env.HERDR_ENV === "1" && Boolean(process.env.HERDR_PANE_ID);
209
+ }
210
+
211
+ export function createdTabTarget(response: JsonRecord) {
212
+ const result = record(response.result);
213
+ const rootPane = record(result?.root_pane);
214
+ const tab = record(result?.tab);
215
+ const paneId = stringValue(rootPane?.pane_id);
216
+ const tabId = stringValue(tab?.tab_id);
217
+ return paneId && tabId ? { paneId, tabId } : undefined;
218
+ }
219
+
220
+ export function normalizedEffortForClaude(effort: ReasoningEffort | undefined) {
221
+ switch (effort) {
222
+ case "off":
223
+ case "minimal":
224
+ return "low";
225
+ case "low":
226
+ case "medium":
227
+ case "high":
228
+ case "xhigh":
229
+ case "max":
230
+ return effort;
231
+ case undefined:
232
+ return undefined;
233
+ }
234
+ }
235
+
236
+ export function normalizedEffortForNativeCodex(
237
+ effort: ReasoningEffort | undefined,
238
+ ) {
239
+ switch (effort) {
240
+ case "off":
241
+ return "none";
242
+ case "minimal":
243
+ return "low";
244
+ case "low":
245
+ case "medium":
246
+ case "high":
247
+ case "xhigh":
248
+ return effort;
249
+ case "max":
250
+ return "xhigh";
251
+ case undefined:
252
+ return undefined;
253
+ }
254
+ }
255
+
256
+ function modelLabel(
257
+ kind: BackendName,
258
+ task: SpawnTask,
259
+ resolvedPiModel: string | undefined,
260
+ ) {
261
+ if (kind === "pi") return resolvedPiModel ?? "default";
262
+ return task.model ?? "default";
263
+ }
264
+
265
+ export function agentArguments(
266
+ kind: BackendName,
267
+ task: SpawnTask,
268
+ resolvedPiModel: string | undefined,
269
+ nativeSessionId?: string,
270
+ ) {
271
+ if (kind === "pi") {
272
+ const args = [
273
+ "--name",
274
+ `subagent: ${task.title}`,
275
+ "--exclude-tools",
276
+ CHILD_EXCLUDED_TOOLS,
277
+ task.parent.projectTrusted ? "--approve" : "--no-approve",
278
+ ];
279
+ const thinking = task.reasoningEffort ?? task.parent.inheritedThinkingLevel;
280
+ if (resolvedPiModel) args.push("--model", resolvedPiModel);
281
+ if (thinking) args.push("--thinking", thinking);
282
+ return args;
283
+ }
284
+
285
+ if (kind === "claude") {
286
+ const args = ["--name", `subagent: ${task.title}`];
287
+ if (nativeSessionId) args.push("--session-id", nativeSessionId);
288
+ if (task.mode === "plan") {
289
+ args.push("--permission-mode", "plan");
290
+ } else {
291
+ args.push("--dangerously-skip-permissions");
292
+ }
293
+ args.push("--disallowed-tools", "Agent,Task,Workflow");
294
+ if (task.model) args.push("--model", task.model);
295
+ const effort = normalizedEffortForClaude(task.reasoningEffort);
296
+ if (effort) args.push("--effort", effort);
297
+ return args;
298
+ }
299
+
300
+ const args = [
301
+ "--dangerously-bypass-approvals-and-sandbox",
302
+ "--disable",
303
+ "multi_agent",
304
+ ];
305
+ if (task.parent.projectTrusted) args.push("--dangerously-bypass-hook-trust");
306
+ if (task.model) args.push("--model", task.model);
307
+ const effort = normalizedEffortForNativeCodex(task.reasoningEffort);
308
+ if (effort) args.push("--config", `model_reasoning_effort=\"${effort}\"`);
309
+ return args;
310
+ }
311
+
312
+ function agentSession(agent: JsonRecord | undefined) {
313
+ const session = record(agent?.agent_session);
314
+ const kind = stringValue(session?.kind);
315
+ const value = stringValue(session?.value);
316
+ return kind && value ? { kind, value } : undefined;
317
+ }
318
+
319
+ export function claudeSessionPath(cwd: string, sessionId: string) {
320
+ let canonicalCwd = cwd;
321
+ try {
322
+ canonicalCwd = fs.realpathSync(cwd);
323
+ } catch {
324
+ // Claude receives the original cwd when it cannot be canonicalized.
325
+ }
326
+ const projectDirectory = canonicalCwd.replace(/[^a-zA-Z0-9]/g, "-");
327
+ return path.join(
328
+ process.env.CLAUDE_CONFIG_DIR ?? path.join(os.homedir(), ".claude"),
329
+ "projects",
330
+ projectDirectory,
331
+ `${sessionId}.jsonl`,
332
+ );
333
+ }
334
+
335
+ const codexSessionPathCache = new Map<
336
+ string,
337
+ { readonly checkedAt: number; readonly sessionFilePath?: string }
338
+ >();
339
+
340
+ function findFileContaining(root: string, needle: string) {
341
+ if (!fs.existsSync(root)) return undefined;
342
+ const pending = [root];
343
+ let visited = 0;
344
+ while (pending.length > 0 && visited < 20_000) {
345
+ const directory = pending.pop();
346
+ if (!directory) break;
347
+ let entries: fs.Dirent[];
348
+ try {
349
+ entries = fs.readdirSync(directory, { withFileTypes: true });
350
+ } catch {
351
+ continue;
352
+ }
353
+ for (const entry of entries) {
354
+ visited++;
355
+ const candidate = path.join(directory, entry.name);
356
+ if (entry.isDirectory()) pending.push(candidate);
357
+ else if (
358
+ entry.isFile() &&
359
+ entry.name.includes(needle) &&
360
+ entry.name.endsWith(".jsonl")
361
+ ) {
362
+ return candidate;
363
+ }
364
+ }
365
+ }
366
+ return undefined;
367
+ }
368
+
369
+ function resolveSessionPath(
370
+ kind: BackendName,
371
+ task: SpawnTask,
372
+ session: { kind: string; value: string } | undefined,
373
+ ) {
374
+ if (!session) return undefined;
375
+ if (session.kind === "path") return session.value;
376
+ if (kind === "claude") return claudeSessionPath(task.cwd, session.value);
377
+ if (kind === "codex") {
378
+ const codexHome =
379
+ process.env.CODEX_HOME ?? path.join(os.homedir(), ".codex");
380
+ const sessionsRoot = path.join(codexHome, "sessions");
381
+ const cacheKey = `${sessionsRoot}\0${session.value}`;
382
+ const cached = codexSessionPathCache.get(cacheKey);
383
+ if (cached?.sessionFilePath && fs.existsSync(cached.sessionFilePath)) {
384
+ return cached.sessionFilePath;
385
+ }
386
+ if (cached && Date.now() - cached.checkedAt < CODEX_PATH_CACHE_RETRY_MS) {
387
+ return cached.sessionFilePath;
388
+ }
389
+ const sessionFilePath = findFileContaining(sessionsRoot, session.value);
390
+ codexSessionPathCache.set(cacheKey, {
391
+ checkedAt: Date.now(),
392
+ sessionFilePath,
393
+ });
394
+ return sessionFilePath;
395
+ }
396
+ return undefined;
397
+ }
398
+
399
+ function textParts(content: unknown) {
400
+ if (typeof content === "string") return content;
401
+ if (!Array.isArray(content)) return "";
402
+ return content
403
+ .flatMap((part) => {
404
+ const item = record(part);
405
+ const type = stringValue(item?.type);
406
+ const text = stringValue(item?.text);
407
+ return text &&
408
+ (type === "text" || type === "output_text" || type === "input_text")
409
+ ? [text]
410
+ : [];
411
+ })
412
+ .join("\n")
413
+ .trim();
414
+ }
415
+
416
+ function userText(entry: JsonRecord) {
417
+ const message = record(entry.message);
418
+ if (message?.role === "user") {
419
+ const text = textParts(message.content);
420
+ if (text) return text;
421
+ }
422
+
423
+ const payload = record(entry.payload);
424
+ if (entry.type === "response_item" && payload?.role === "user") {
425
+ const text = textParts(payload.content);
426
+ if (text) return text;
427
+ }
428
+ if (entry.type === "event_msg" && payload?.type === "user_message") {
429
+ const text = stringValue(payload.message);
430
+ if (text?.trim()) return text.trim();
431
+ }
432
+ return undefined;
433
+ }
434
+
435
+ function promptMatches(
436
+ kind: BackendName,
437
+ expectedPrompt: string | undefined,
438
+ actualPrompt: string,
439
+ ) {
440
+ if (expectedPrompt === undefined) return false;
441
+ const expected = expectedPrompt.trim();
442
+ const actual = actualPrompt.trim();
443
+ if (actual === expected) return true;
444
+
445
+ if (kind === "claude" && expected.startsWith("/")) {
446
+ const separator = expected.search(/\s/);
447
+ const command = separator < 0 ? expected : expected.slice(0, separator);
448
+ const args = separator < 0 ? "" : expected.slice(separator).trim();
449
+ return (
450
+ actual.includes(`<command-name>${command}</command-name>`) &&
451
+ (!args || actual.includes(`<command-args>${args}</command-args>`))
452
+ );
453
+ }
454
+ return false;
455
+ }
456
+
457
+ function assistantRecord(kind: BackendName, entry: JsonRecord) {
458
+ const message = record(entry.message);
459
+ const payload = record(entry.payload);
460
+ let text: string | undefined;
461
+ let errorText: string | undefined;
462
+ let terminal = false;
463
+
464
+ if (
465
+ kind === "pi" &&
466
+ entry.type === "message" &&
467
+ message?.role === "assistant"
468
+ ) {
469
+ text = textParts(message.content) || undefined;
470
+ terminal = message.stopReason === "stop" || message.stopReason === "length";
471
+ if (message.stopReason === "error") {
472
+ errorText =
473
+ stringValue(message.errorMessage)?.trim() ||
474
+ "The Pi child model request failed.";
475
+ }
476
+ } else if (
477
+ kind === "claude" &&
478
+ entry.type === "assistant" &&
479
+ message?.role === "assistant"
480
+ ) {
481
+ text = textParts(message.content) || undefined;
482
+ terminal =
483
+ message.stop_reason === "end_turn" ||
484
+ message.stop_reason === "stop_sequence";
485
+ } else if (
486
+ kind === "codex" &&
487
+ entry.type === "response_item" &&
488
+ payload?.type === "message" &&
489
+ payload.role === "assistant"
490
+ ) {
491
+ text = textParts(payload.content) || undefined;
492
+ terminal = payload.phase === "final_answer";
493
+ } else if (
494
+ kind === "codex" &&
495
+ entry.type === "event_msg" &&
496
+ payload?.type === "agent_message"
497
+ ) {
498
+ text = stringValue(payload.message)?.trim() || undefined;
499
+ terminal = payload.phase === "final_answer";
500
+ } else if (
501
+ kind === "codex" &&
502
+ entry.type === "event_msg" &&
503
+ payload?.type === "task_complete"
504
+ ) {
505
+ text = stringValue(payload.last_agent_message)?.trim() || undefined;
506
+ terminal = Boolean(text);
507
+ }
508
+
509
+ return { text, terminal, errorText };
510
+ }
511
+
512
+ export function captureSessionCursor(sessionFilePath: string | undefined) {
513
+ if (!sessionFilePath) return { sessionFilePath, offset: 0 };
514
+ try {
515
+ return { sessionFilePath, offset: fs.statSync(sessionFilePath).size };
516
+ } catch {
517
+ return { sessionFilePath, offset: 0 };
518
+ }
519
+ }
520
+
521
+ export function sessionRunSince(
522
+ kind: BackendName,
523
+ sessionFilePath: string | undefined,
524
+ cursor: ReturnType<typeof captureSessionCursor>,
525
+ expectedPrompt?: string,
526
+ ) {
527
+ if (!sessionFilePath || !fs.existsSync(sessionFilePath)) {
528
+ return { userSeen: false, promptSeen: false, matchingPromptCount: 0 };
529
+ }
530
+ let contents: string;
531
+ try {
532
+ const bytes = fs.readFileSync(sessionFilePath);
533
+ const offset =
534
+ cursor.sessionFilePath === sessionFilePath
535
+ ? Math.min(cursor.offset, bytes.length)
536
+ : 0;
537
+ contents = bytes.subarray(offset).toString("utf8");
538
+ } catch {
539
+ return { userSeen: false, promptSeen: false, matchingPromptCount: 0 };
540
+ }
541
+
542
+ let userSeen = false;
543
+ let promptSeen = false;
544
+ let matchingPromptCount = 0;
545
+ let partialText: string | undefined;
546
+ let finalText: string | undefined;
547
+ let runError: string | undefined;
548
+ for (const rawLine of contents.split("\n")) {
549
+ const line = rawLine.trim();
550
+ if (!line) continue;
551
+ try {
552
+ const entry = JSON.parse(line) as JsonRecord;
553
+ const user = userText(entry);
554
+ if (user) {
555
+ userSeen = true;
556
+ const matches = promptMatches(kind, expectedPrompt, user);
557
+ if (matches) matchingPromptCount++;
558
+ if (promptSeen || matches) {
559
+ promptSeen = true;
560
+ partialText = undefined;
561
+ finalText = undefined;
562
+ runError = undefined;
563
+ }
564
+ continue;
565
+ }
566
+ if (!promptSeen) continue;
567
+ const assistant = assistantRecord(kind, entry);
568
+ if (assistant.errorText) {
569
+ runError = assistant.errorText.slice(0, 4096);
570
+ finalText = undefined;
571
+ }
572
+ if (!assistant.text) continue;
573
+ partialText = assistant.text.slice(0, FINAL_OUTPUT_MAX_LENGTH);
574
+ if (assistant.terminal) {
575
+ finalText = partialText;
576
+ runError = undefined;
577
+ }
578
+ } catch {
579
+ // Ignore malformed or partially flushed trailing records.
580
+ }
581
+ }
582
+ return {
583
+ userSeen,
584
+ promptSeen,
585
+ matchingPromptCount,
586
+ partialText,
587
+ finalText,
588
+ runError,
589
+ };
590
+ }
591
+
592
+ export function definedSessionMetaPatch(
593
+ session: { kind: string; value: string } | undefined,
594
+ sessionFilePath: string | undefined,
595
+ ) {
596
+ return {
597
+ ...(session?.kind === "id"
598
+ ? { nativeSessionId: session.value }
599
+ : undefined),
600
+ ...(sessionFilePath !== undefined ? { sessionFilePath } : undefined),
601
+ } satisfies Partial<SubagentMeta>;
602
+ }
603
+
604
+ class AsyncLock {
605
+ private tail = Promise.resolve();
606
+
607
+ async run<A>(operation: () => Promise<A>) {
608
+ const previous = this.tail;
609
+ let release: () => void = () => {};
610
+ this.tail = new Promise<void>((resolve) => {
611
+ release = resolve;
612
+ });
613
+ await previous;
614
+ try {
615
+ return await operation();
616
+ } finally {
617
+ release();
618
+ }
619
+ }
620
+ }
621
+
622
+ function makeHerdrSession(kind: BackendName, task: SpawnTask) {
623
+ return Effect.gen(function* () {
624
+ if (!herdrAvailable()) {
625
+ return yield* new SpawnError({
626
+ message:
627
+ "Herdr subagents require the parent Pi session to run inside a Herdr pane.",
628
+ });
629
+ }
630
+ if (!task.parent.projectTrusted && kind !== "pi") {
631
+ return yield* new SpawnError({
632
+ message: `Interactive ${kind} subagents require a trusted working directory.`,
633
+ });
634
+ }
635
+
636
+ let resolvedPiModel: string | undefined;
637
+ if (kind === "pi") {
638
+ const registry = task.parent.modelRegistry;
639
+ if (!registry) {
640
+ return yield* new SpawnError({
641
+ message: "pi backend requires the parent session's model registry.",
642
+ });
643
+ }
644
+ const resolved = yield* Effect.try({
645
+ try: () =>
646
+ resolvePiModel(registry, task.model, task.parent.inheritedModel),
647
+ catch: (error) => new SpawnError({ message: boundedError(error) }),
648
+ });
649
+ resolvedPiModel = resolved
650
+ ? `${resolved.provider}/${resolved.id}`
651
+ : undefined;
652
+ }
653
+
654
+ const events = yield* Queue.make<SubagentEvent, Cause.Done>();
655
+ const emit = (event: SubagentEvent) => Queue.offerUnsafe(events, event);
656
+ const agentName = `pi_${kind}_${randomBytes(4).toString("hex")}`.slice(
657
+ 0,
658
+ 32,
659
+ );
660
+ let tabId: string | undefined;
661
+ let paneId: string | undefined;
662
+ let runController: AbortController | undefined;
663
+ let liveTimer: ReturnType<typeof setInterval> | undefined;
664
+ let liveReadPending = false;
665
+ const lifecycleLock = new AsyncLock();
666
+ // Own Claude's identity before sending work; a late/missing integration
667
+ // report must not make us lose its transcript and resubmit the task.
668
+ const claudeSessionId = kind === "claude" ? randomUUID() : undefined;
669
+
670
+ const state = {
671
+ closed: false,
672
+ activeRun: false,
673
+ runSerial: 0,
674
+ promptObserved: false,
675
+ queuedSteers: [] as Array<{
676
+ readonly text: string;
677
+ readonly requiredOccurrence: number;
678
+ }>,
679
+ activePrompt: task.prompt,
680
+ activeCursor: captureSessionCursor(undefined),
681
+ meta: {
682
+ backend: kind,
683
+ modelLabel: modelLabel(kind, task, resolvedPiModel),
684
+ herdrAgentName: agentName,
685
+ ...(claudeSessionId
686
+ ? {
687
+ nativeSessionId: claudeSessionId,
688
+ sessionFilePath: claudeSessionPath(task.cwd, claudeSessionId),
689
+ }
690
+ : {}),
691
+ } satisfies SubagentMeta as SubagentMeta,
692
+ };
693
+
694
+ const terminal = yield* Effect.acquireRelease(
695
+ Effect.tryPromise({
696
+ try: async () => {
697
+ const parentPaneId = process.env.HERDR_PANE_ID;
698
+ if (!parentPaneId) throw new Error("HERDR_PANE_ID is unavailable.");
699
+ const parent = await runHerdr(["pane", "get", parentPaneId]);
700
+ const workspaceId = nestedString(
701
+ parseJsonOutput(parent.stdout),
702
+ "result",
703
+ "pane",
704
+ "workspace_id",
705
+ );
706
+ if (!workspaceId) {
707
+ throw new Error("Herdr parent pane returned no workspace id.");
708
+ }
709
+ const created = await runHerdr([
710
+ "tab",
711
+ "create",
712
+ "--workspace",
713
+ workspaceId,
714
+ "--cwd",
715
+ task.cwd,
716
+ "--label",
717
+ `${kind}: ${task.title}`,
718
+ "--no-focus",
719
+ ]);
720
+ const target = createdTabTarget(parseJsonOutput(created.stdout));
721
+ if (!target) {
722
+ throw new Error("Herdr tab creation returned no tab or pane id.");
723
+ }
724
+ return target;
725
+ },
726
+ catch: (error) => new SpawnError({ message: boundedError(error) }),
727
+ }),
728
+ (target) =>
729
+ Effect.promise(() =>
730
+ runHerdr(["tab", "close", target.tabId], {
731
+ timeoutMs: 3_000,
732
+ }).then(
733
+ () => undefined,
734
+ () =>
735
+ runHerdr(["pane", "close", target.paneId], {
736
+ timeoutMs: 3_000,
737
+ }).then(
738
+ () => undefined,
739
+ () => undefined,
740
+ ),
741
+ ),
742
+ ),
743
+ );
744
+ tabId = terminal.tabId;
745
+ paneId = terminal.paneId;
746
+ state.meta = {
747
+ ...state.meta,
748
+ herdrTabId: tabId,
749
+ herdrPaneId: paneId,
750
+ };
751
+
752
+ yield* Effect.addFinalizer(() =>
753
+ Effect.sync(() => {
754
+ state.closed = true;
755
+ runController?.abort();
756
+ if (liveTimer) clearInterval(liveTimer);
757
+ liveTimer = undefined;
758
+ Queue.endUnsafe(events);
759
+ }),
760
+ );
761
+
762
+ yield* Effect.tryPromise({
763
+ try: async () => {
764
+ await runHerdr(["pane", "rename", paneId!, `${kind}: ${task.title}`]);
765
+ const startArgs = [
766
+ "agent",
767
+ "start",
768
+ agentName,
769
+ "--kind",
770
+ kind,
771
+ "--pane",
772
+ paneId!,
773
+ "--timeout",
774
+ "120000",
775
+ "--",
776
+ ...agentArguments(kind, task, resolvedPiModel, claudeSessionId),
777
+ ];
778
+ for (let attempt = 0; ; attempt++) {
779
+ try {
780
+ await runHerdr(startArgs);
781
+ break;
782
+ } catch (error) {
783
+ if (
784
+ attempt >= 20 ||
785
+ !boundedError(error).includes("agent_pane_busy")
786
+ ) {
787
+ throw error;
788
+ }
789
+ await delay(100);
790
+ }
791
+ }
792
+
793
+ if (kind === "claude" && task.mode !== "plan") {
794
+ for (let attempt = 0; attempt < 30; attempt++) {
795
+ const screen = await runHerdr([
796
+ "pane",
797
+ "read",
798
+ paneId!,
799
+ "--source",
800
+ "visible",
801
+ ]).then((result) => result.stdout);
802
+ if (
803
+ screen.includes("Bypass Permissions mode") &&
804
+ screen.includes("Yes, I accept")
805
+ ) {
806
+ await runHerdr(["pane", "send-keys", paneId!, "down"]);
807
+ await delay(250);
808
+ await runHerdr(["pane", "send-keys", paneId!, "enter"]);
809
+ await runHerdr([
810
+ "agent",
811
+ "wait",
812
+ agentName,
813
+ "--until",
814
+ "idle",
815
+ "--until",
816
+ "done",
817
+ "--timeout",
818
+ "30000",
819
+ ]);
820
+ break;
821
+ }
822
+ await delay(100);
823
+ }
824
+ }
825
+
826
+ // agent.start can return while a native TUI is still completing
827
+ // startup work (notably Codex MCP initialization). Prompts sent during
828
+ // that window can be swallowed while startup state changes falsely
829
+ // satisfy agent prompt --wait.
830
+ await delay(kind === "pi" ? 1_000 : 5_000);
831
+ },
832
+ catch: (error) => new SpawnError({ message: boundedError(error) }),
833
+ });
834
+
835
+ const refreshMeta = async () => {
836
+ try {
837
+ const response = await runHerdr(["agent", "get", agentName], {
838
+ timeoutMs: 3_000,
839
+ });
840
+ const agent = record(
841
+ record(parseJsonOutput(response.stdout).result)?.agent,
842
+ );
843
+ const session = agentSession(agent);
844
+ const sessionFilePath = resolveSessionPath(kind, task, session);
845
+ const patch = definedSessionMetaPatch(session, sessionFilePath);
846
+ if (Object.keys(patch).length > 0) {
847
+ state.meta = { ...state.meta, ...patch };
848
+ emit({ _tag: "MetaChanged", meta: patch });
849
+ }
850
+ } catch {
851
+ // Session identity is best-effort; callers retry before settling.
852
+ }
853
+ };
854
+
855
+ const readTerminal = async (
856
+ source: "visible" | "recent-unwrapped",
857
+ lines?: number,
858
+ ) => {
859
+ if (!paneId) return undefined;
860
+ // Address the pane rather than the agent alias so output remains readable
861
+ // after a CLI exits and Herdr clears the live agent name.
862
+ const args = ["pane", "read", paneId, "--source", source];
863
+ if (lines !== undefined) args.push("--lines", String(lines));
864
+ const result = await runHerdr(args, { timeoutMs: 3_000 });
865
+ return result.stdout.trim();
866
+ };
867
+
868
+ const stopLiveReads = () => {
869
+ if (liveTimer) clearInterval(liveTimer);
870
+ liveTimer = undefined;
871
+ liveReadPending = false;
872
+ };
873
+
874
+ const startLiveReads = () => {
875
+ stopLiveReads();
876
+ liveTimer = setInterval(() => {
877
+ if (state.closed || !state.activeRun || liveReadPending) return;
878
+ liveReadPending = true;
879
+ void readTerminal("visible")
880
+ .then((text) => {
881
+ if (text && state.activeRun) emit({ _tag: "LiveSnapshot", text });
882
+ })
883
+ .catch(() => undefined)
884
+ .finally(() => {
885
+ liveReadPending = false;
886
+ });
887
+ }, LIVE_READ_INTERVAL_MS);
888
+ };
889
+
890
+ const getAgentStatus = (signal?: AbortSignal) =>
891
+ retryHerdrMonitoring(async () => {
892
+ const response = await runHerdr(["agent", "get", agentName], {
893
+ signal,
894
+ timeoutMs: 3_000,
895
+ });
896
+ return nestedString(
897
+ parseJsonOutput(response.stdout),
898
+ "result",
899
+ "agent",
900
+ "agent_status",
901
+ );
902
+ }, signal);
903
+
904
+ const waitForStatus = (
905
+ statuses: ReadonlyArray<"idle" | "working" | "blocked" | "done">,
906
+ signal: AbortSignal,
907
+ ) =>
908
+ retryHerdrMonitoring(async () => {
909
+ const args = ["agent", "wait", agentName];
910
+ for (const status of statuses) args.push("--until", status);
911
+ const response = await runHerdr(args, { signal });
912
+ return nestedString(
913
+ parseJsonOutput(response.stdout),
914
+ "result",
915
+ "agent",
916
+ "agent_status",
917
+ );
918
+ }, signal);
919
+
920
+ const ensureTargetPresent = async () => {
921
+ if (!paneId) throw new Error("Herdr subagent pane is closed.");
922
+ try {
923
+ await Promise.all([
924
+ runHerdr(["pane", "get", paneId], { timeoutMs: 3_000 }),
925
+ runHerdr(["agent", "get", agentName], { timeoutMs: 3_000 }),
926
+ ]);
927
+ } catch (error) {
928
+ if (isMissingHerdrTarget(error)) {
929
+ state.closed = true;
930
+ paneId = undefined;
931
+ stopLiveReads();
932
+ Queue.endUnsafe(events);
933
+ }
934
+ throw error;
935
+ }
936
+ };
937
+
938
+ const observedPrompt = async (
939
+ prompt: string,
940
+ cursor: ReturnType<typeof captureSessionCursor>,
941
+ ) => {
942
+ for (let attempt = 0; attempt < 30; attempt++) {
943
+ await refreshMeta();
944
+ if (
945
+ sessionRunSince(kind, state.meta.sessionFilePath, cursor, prompt)
946
+ .promptSeen
947
+ ) {
948
+ return true;
949
+ }
950
+ await delay(100);
951
+ }
952
+ return false;
953
+ };
954
+
955
+ const updateSteeringQueueUnlocked = async () => {
956
+ if (state.queuedSteers.length === 0) return;
957
+ await refreshMeta();
958
+ const pending = state.queuedSteers.filter(
959
+ (queued) =>
960
+ sessionRunSince(
961
+ kind,
962
+ state.meta.sessionFilePath,
963
+ state.activeCursor,
964
+ queued.text,
965
+ ).matchingPromptCount < queued.requiredOccurrence,
966
+ );
967
+ if (pending.length === state.queuedSteers.length) return;
968
+ state.queuedSteers = pending;
969
+ emit({
970
+ _tag: "QueueChanged",
971
+ queued: pending.map(({ text }) => ({ text, kind: "steer" })),
972
+ });
973
+ };
974
+
975
+ const updateSteeringQueue = () =>
976
+ lifecycleLock.run(updateSteeringQueueUnlocked);
977
+
978
+ const collectFinalResult = async (
979
+ cursor: ReturnType<typeof captureSessionCursor>,
980
+ prompt: string,
981
+ ) => {
982
+ let latest = sessionRunSince(
983
+ kind,
984
+ state.meta.sessionFilePath,
985
+ cursor,
986
+ prompt,
987
+ );
988
+ for (let attempt = 0; attempt < 50; attempt++) {
989
+ await refreshMeta();
990
+ await updateSteeringQueue();
991
+ latest = sessionRunSince(
992
+ kind,
993
+ state.meta.sessionFilePath,
994
+ cursor,
995
+ prompt,
996
+ );
997
+ if (
998
+ state.queuedSteers.length === 0 &&
999
+ (latest.finalText || latest.runError)
1000
+ ) {
1001
+ return latest;
1002
+ }
1003
+ await delay(Math.min(100 + attempt * 4, 300));
1004
+ }
1005
+ return latest;
1006
+ };
1007
+
1008
+ const settleUnlocked = (serial: number, outcome: RunOutcome) => {
1009
+ if (state.closed || !state.activeRun || serial !== state.runSerial)
1010
+ return false;
1011
+ state.activeRun = false;
1012
+ state.queuedSteers = [];
1013
+ runController = undefined;
1014
+ stopLiveReads();
1015
+ emit({ _tag: "QueueChanged", queued: [] });
1016
+ emit({ _tag: "RunSettled", outcome });
1017
+ return true;
1018
+ };
1019
+
1020
+ const settle = (serial: number, outcome: RunOutcome) =>
1021
+ lifecycleLock.run(async () => settleUnlocked(serial, outcome));
1022
+
1023
+ const confirmNativeStopped = async () => {
1024
+ try {
1025
+ const result = await runHerdr(
1026
+ [
1027
+ "agent",
1028
+ "wait",
1029
+ agentName,
1030
+ "--until",
1031
+ "idle",
1032
+ "--until",
1033
+ "done",
1034
+ "--timeout",
1035
+ String(INTERRUPT_STAGE_TIMEOUT_MS),
1036
+ ],
1037
+ { timeoutMs: INTERRUPT_STAGE_TIMEOUT_MS + 200 },
1038
+ );
1039
+ const status = nestedString(
1040
+ parseJsonOutput(result.stdout),
1041
+ "result",
1042
+ "agent",
1043
+ "agent_status",
1044
+ );
1045
+ return status === "idle" || status === "done";
1046
+ } catch (error) {
1047
+ return isMissingHerdrTarget(error);
1048
+ }
1049
+ };
1050
+
1051
+ const stopNativeRun = async () => {
1052
+ await runHerdr(["agent", "send-keys", agentName, "esc"], {
1053
+ timeoutMs: 500,
1054
+ }).catch(() => undefined);
1055
+ if (await confirmNativeStopped()) return true;
1056
+ if (paneId) {
1057
+ await runHerdr(["pane", "send-keys", paneId, "ctrl+c"], {
1058
+ timeoutMs: 500,
1059
+ }).catch(() => undefined);
1060
+ }
1061
+ return confirmNativeStopped();
1062
+ };
1063
+
1064
+ const closeCurrentPane = async () => {
1065
+ if (!paneId && !tabId) return true;
1066
+ const closingPaneId = paneId;
1067
+ const closingTabId = tabId;
1068
+ try {
1069
+ if (closingTabId) {
1070
+ await runHerdr(["tab", "close", closingTabId], {
1071
+ timeoutMs: 1_000,
1072
+ });
1073
+ } else if (closingPaneId) {
1074
+ await runHerdr(["pane", "close", closingPaneId], {
1075
+ timeoutMs: 1_000,
1076
+ });
1077
+ }
1078
+ tabId = undefined;
1079
+ paneId = undefined;
1080
+ return true;
1081
+ } catch (error) {
1082
+ if (isMissingHerdrTarget(error)) {
1083
+ tabId = undefined;
1084
+ paneId = undefined;
1085
+ return true;
1086
+ }
1087
+ return false;
1088
+ }
1089
+ };
1090
+
1091
+ const waitForRun = async (
1092
+ serial: number,
1093
+ prompt: string,
1094
+ controller: AbortController,
1095
+ ) => {
1096
+ let cursor = state.activeCursor;
1097
+ try {
1098
+ await refreshMeta();
1099
+ cursor = captureSessionCursor(state.meta.sessionFilePath);
1100
+ state.activeCursor = cursor;
1101
+
1102
+ const submitPrompt = () =>
1103
+ runHerdr(["agent", "prompt", agentName, prompt], {
1104
+ signal: controller.signal,
1105
+ });
1106
+ const waitUntilSettled = () =>
1107
+ waitForStatus(["idle", "done", "blocked"], controller.signal);
1108
+ const acceptClaudeWarning = async () => {
1109
+ if (kind !== "claude") return false;
1110
+ const screen = await readTerminal("visible").catch(() => undefined);
1111
+ if (
1112
+ !screen?.includes("Bypass Permissions mode") ||
1113
+ !screen.includes("Yes, I accept")
1114
+ ) {
1115
+ return false;
1116
+ }
1117
+ if (!paneId) return false;
1118
+ await runHerdr(["pane", "send-keys", paneId, "down"]);
1119
+ await delay(250);
1120
+ await runHerdr(["pane", "send-keys", paneId, "enter"]);
1121
+ await delay(500);
1122
+ return true;
1123
+ };
1124
+
1125
+ try {
1126
+ await submitPrompt();
1127
+ } catch (error) {
1128
+ const warningAccepted = await acceptClaudeWarning();
1129
+ const promptStalled = boundedError(error).includes(
1130
+ "agent_prompt_stalled",
1131
+ );
1132
+ if (!warningAccepted && !promptStalled) throw error;
1133
+ // A stalled command can already have reached the native agent.
1134
+ // Do not paste the task twice. Only submit an existing Claude paste
1135
+ // when the screen positively identifies it and no user turn exists.
1136
+ if (
1137
+ !(await observedPrompt(prompt, cursor)) &&
1138
+ kind === "claude" &&
1139
+ paneId
1140
+ ) {
1141
+ const screen = await readTerminal("visible");
1142
+ const status = await getAgentStatus(controller.signal);
1143
+ if (
1144
+ screen?.includes("[Pasted text #") &&
1145
+ (status === "idle" || status === "done")
1146
+ ) {
1147
+ await runHerdr(["pane", "send-keys", paneId, "enter"], {
1148
+ signal: controller.signal,
1149
+ });
1150
+ }
1151
+ }
1152
+ }
1153
+ await acceptClaudeWarning();
1154
+
1155
+ if (!(await observedPrompt(prompt, cursor))) {
1156
+ throw new Error(
1157
+ `Herdr could not correlate the submitted prompt with the native transcript (${state.meta.sessionFilePath ?? "session identity unavailable"}). The prompt was not resent: it may already have executed. Inspect the existing session and its integration before retrying.`,
1158
+ );
1159
+ }
1160
+ if (
1161
+ !controller.signal.aborted &&
1162
+ state.activeRun &&
1163
+ serial === state.runSerial
1164
+ ) {
1165
+ state.promptObserved = true;
1166
+ }
1167
+
1168
+ // `agent wait --until idle` can otherwise match the pre-run idle state
1169
+ // before Herdr notices the native TUI has started. First observe either
1170
+ // a busy state or an assistant response in the correlated transcript.
1171
+ let completedBeforeWait = false;
1172
+ let workStarted = false;
1173
+ for (let attempt = 0; attempt < 150; attempt++) {
1174
+ await refreshMeta();
1175
+ const run = sessionRunSince(
1176
+ kind,
1177
+ state.meta.sessionFilePath,
1178
+ cursor,
1179
+ prompt,
1180
+ );
1181
+ const currentStatus = await getAgentStatus(controller.signal);
1182
+ if (currentStatus === "working" || currentStatus === "blocked") {
1183
+ workStarted = true;
1184
+ break;
1185
+ }
1186
+ if (
1187
+ (run.finalText || run.runError) &&
1188
+ (currentStatus === "idle" || currentStatus === "done")
1189
+ ) {
1190
+ completedBeforeWait = true;
1191
+ break;
1192
+ }
1193
+ await delay(100);
1194
+ }
1195
+ if (!completedBeforeWait && !workStarted) {
1196
+ throw new Error(
1197
+ "Herdr recorded the prompt, but never observed the native agent start work.",
1198
+ );
1199
+ }
1200
+
1201
+ // Do not use `agent prompt --wait`: Herdr applies a finite command
1202
+ // timeout, so long reviews can be reported as failed while the native
1203
+ // agent keeps working in its pane. Submission and completion are two
1204
+ // independently abortable operations instead.
1205
+ let status = completedBeforeWait
1206
+ ? await getAgentStatus(controller.signal)
1207
+ : await waitUntilSettled();
1208
+ let blockedSeen = false;
1209
+
1210
+ while (true) {
1211
+ if (
1212
+ controller.signal.aborted ||
1213
+ !state.activeRun ||
1214
+ serial !== state.runSerial
1215
+ ) {
1216
+ return;
1217
+ }
1218
+ if (status === "working") {
1219
+ status = await waitUntilSettled();
1220
+ continue;
1221
+ }
1222
+ if (status === "blocked") {
1223
+ blockedSeen = true;
1224
+ emit({
1225
+ _tag: "InputRequired",
1226
+ message:
1227
+ "The native Herdr agent is waiting for input in its terminal.",
1228
+ });
1229
+ do {
1230
+ await delay(100);
1231
+ status = await getAgentStatus(controller.signal);
1232
+ } while (status === "blocked");
1233
+ emit({ _tag: "InputResolved" });
1234
+ continue;
1235
+ }
1236
+ if (status !== "idle" && status !== "done") {
1237
+ throw new Error(
1238
+ `Herdr agent ended in unexpected status ${status ?? "unknown"}.`,
1239
+ );
1240
+ }
1241
+
1242
+ const completion = await collectFinalResult(cursor, prompt);
1243
+ const finalText = completion.finalText;
1244
+ if (finalText) {
1245
+ const settled = await lifecycleLock.run(async () => {
1246
+ if (
1247
+ controller.signal.aborted ||
1248
+ !state.activeRun ||
1249
+ serial !== state.runSerial ||
1250
+ state.queuedSteers.length > 0
1251
+ ) {
1252
+ return false;
1253
+ }
1254
+ emit({
1255
+ _tag: "AssistantMessage",
1256
+ parts: [{ type: "text", text: finalText }],
1257
+ });
1258
+ return settleUnlocked(serial, {
1259
+ _tag: "Completed",
1260
+ finalText,
1261
+ });
1262
+ });
1263
+ if (settled) return;
1264
+ status = await getAgentStatus(controller.signal);
1265
+ continue;
1266
+ }
1267
+ if (completion.runError) throw new Error(completion.runError);
1268
+ status = await getAgentStatus(controller.signal);
1269
+ if (status === "working" || status === "blocked") continue;
1270
+ if (!blockedSeen || status === "done") {
1271
+ throw new Error(
1272
+ "Herdr agent became idle without a correlated final response.",
1273
+ );
1274
+ }
1275
+
1276
+ // Planning and permission TUIs can briefly report idle after a menu
1277
+ // response while waiting for parent feedback. Keep the managed run
1278
+ // alive until the next work/blocked transition instead of treating
1279
+ // this input boundary as completion.
1280
+ status = await waitForStatus(
1281
+ ["working", "blocked", "done"],
1282
+ controller.signal,
1283
+ );
1284
+ }
1285
+ } catch (error) {
1286
+ if (
1287
+ controller.signal.aborted ||
1288
+ !state.activeRun ||
1289
+ serial !== state.runSerial
1290
+ )
1291
+ return;
1292
+ // Missing transcript evidence is not evidence of a stopped process.
1293
+ // Interrupt first; retain the tab for diagnosis if stopping succeeds.
1294
+ const stopped = await stopNativeRun();
1295
+ const shouldClose = !stopped;
1296
+ const closed = shouldClose ? await closeCurrentPane() : false;
1297
+ if (shouldClose && !closed) {
1298
+ emit({
1299
+ _tag: "BackendError",
1300
+ message:
1301
+ "Herdr monitoring failed and the native agent could not be stopped or closed; keeping it tracked as running.",
1302
+ });
1303
+ return;
1304
+ }
1305
+ await refreshMeta();
1306
+ const partialText =
1307
+ sessionRunSince(kind, state.meta.sessionFilePath, cursor, prompt)
1308
+ .partialText ?? "";
1309
+ await settle(serial, {
1310
+ _tag: "Failed",
1311
+ errorText: boundedError(error),
1312
+ partialText: partialText || undefined,
1313
+ });
1314
+ if (closed) {
1315
+ state.closed = true;
1316
+ stopLiveReads();
1317
+ Queue.endUnsafe(events);
1318
+ }
1319
+ }
1320
+ };
1321
+
1322
+ const startRun = (prompt: string) => {
1323
+ const serial = ++state.runSerial;
1324
+ state.activeRun = true;
1325
+ state.promptObserved = false;
1326
+ state.queuedSteers = [];
1327
+ state.activePrompt = prompt;
1328
+ state.activeCursor = captureSessionCursor(state.meta.sessionFilePath);
1329
+ runController = new AbortController();
1330
+ emit({ _tag: "UserMessage", text: prompt });
1331
+ emit({ _tag: "RunStarted" });
1332
+ startLiveReads();
1333
+ void waitForRun(serial, prompt, runController);
1334
+ };
1335
+
1336
+ emit({ _tag: "MetaChanged", meta: state.meta });
1337
+ yield* Effect.promise(refreshMeta);
1338
+ startRun(task.prompt);
1339
+
1340
+ return {
1341
+ meta: Effect.sync(() => state.meta),
1342
+ events: Stream.fromQueue(events),
1343
+ send: (text) =>
1344
+ Effect.tryPromise({
1345
+ try: async () => {
1346
+ if (state.closed) throw new Error("Subagent session is closed.");
1347
+ if (!state.activeRun) {
1348
+ await lifecycleLock.run(async () => {
1349
+ if (state.closed) {
1350
+ throw new Error("Subagent session is closed.");
1351
+ }
1352
+ if (state.activeRun) {
1353
+ throw new Error(
1354
+ "Subagent run changed while preparing the follow-up.",
1355
+ );
1356
+ }
1357
+ await ensureTargetPresent();
1358
+ startRun(text);
1359
+ });
1360
+ return;
1361
+ }
1362
+
1363
+ const serial = state.runSerial;
1364
+ const controller = runController;
1365
+ if (!controller) throw new Error("Subagent run is stopping.");
1366
+ await lifecycleLock.run(async () => {
1367
+ if (
1368
+ state.closed ||
1369
+ !state.activeRun ||
1370
+ state.runSerial !== serial ||
1371
+ controller.signal.aborted
1372
+ ) {
1373
+ throw new Error("Subagent run is no longer active.");
1374
+ }
1375
+ for (
1376
+ let attempt = 0;
1377
+ !state.promptObserved && attempt < 30;
1378
+ attempt++
1379
+ ) {
1380
+ if (controller.signal.aborted) {
1381
+ throw new Error("Subagent run is stopping.");
1382
+ }
1383
+ await delay(100);
1384
+ }
1385
+ if (!state.promptObserved) {
1386
+ throw new Error(
1387
+ "Cannot steer before the managed prompt is recorded.",
1388
+ );
1389
+ }
1390
+ for (
1391
+ let attempt = 0;
1392
+ !state.meta.sessionFilePath && attempt < 30;
1393
+ attempt++
1394
+ ) {
1395
+ await refreshMeta();
1396
+ if (!state.meta.sessionFilePath) await delay(100);
1397
+ }
1398
+ if (!state.meta.sessionFilePath) {
1399
+ throw new Error(
1400
+ "Cannot steer before the native transcript is available.",
1401
+ );
1402
+ }
1403
+ const observedOccurrences = sessionRunSince(
1404
+ kind,
1405
+ state.meta.sessionFilePath,
1406
+ state.activeCursor,
1407
+ text,
1408
+ ).matchingPromptCount;
1409
+ const requiredOccurrence =
1410
+ Math.max(
1411
+ observedOccurrences,
1412
+ ...state.queuedSteers
1413
+ .filter((queued) => queued.text === text)
1414
+ .map((queued) => queued.requiredOccurrence),
1415
+ ) + 1;
1416
+ await runHerdr(["agent", "prompt", agentName, text], {
1417
+ signal: controller.signal,
1418
+ });
1419
+ if (
1420
+ state.closed ||
1421
+ !state.activeRun ||
1422
+ state.runSerial !== serial ||
1423
+ controller.signal.aborted
1424
+ ) {
1425
+ throw new Error("Subagent run ended while steering.");
1426
+ }
1427
+ state.queuedSteers.push({ text, requiredOccurrence });
1428
+ emit({ _tag: "UserMessage", text });
1429
+ emit({
1430
+ _tag: "QueueChanged",
1431
+ queued: state.queuedSteers.map((queued) => ({
1432
+ text: queued.text,
1433
+ kind: "steer",
1434
+ })),
1435
+ });
1436
+ await updateSteeringQueueUnlocked();
1437
+ });
1438
+ },
1439
+ catch: (error) => new SendError({ message: boundedError(error) }),
1440
+ }),
1441
+ interrupt: Effect.promise(async (effectSignal) => {
1442
+ if (state.closed || !state.activeRun) return;
1443
+ const serial = state.runSerial;
1444
+ runController?.abort();
1445
+
1446
+ let interrupted = false;
1447
+ try {
1448
+ interrupted = await lifecycleLock.run(async () => {
1449
+ if (
1450
+ state.closed ||
1451
+ !state.activeRun ||
1452
+ state.runSerial !== serial
1453
+ ) {
1454
+ return true;
1455
+ }
1456
+ const stopped = await stopNativeRun();
1457
+ const closed = !stopped && (await closeCurrentPane());
1458
+ if (!stopped && !closed) {
1459
+ emit({
1460
+ _tag: "BackendError",
1461
+ message:
1462
+ "Herdr could not confirm interruption or close the native agent pane; force-disposal is pending.",
1463
+ });
1464
+ return false;
1465
+ }
1466
+ await refreshMeta();
1467
+ const partialText =
1468
+ sessionRunSince(
1469
+ kind,
1470
+ state.meta.sessionFilePath,
1471
+ state.activeCursor,
1472
+ state.activePrompt,
1473
+ ).partialText ?? "";
1474
+ settleUnlocked(serial, {
1475
+ _tag: "Interrupted",
1476
+ partialText: partialText || undefined,
1477
+ });
1478
+ if (closed) {
1479
+ state.closed = true;
1480
+ stopLiveReads();
1481
+ Queue.endUnsafe(events);
1482
+ }
1483
+ return true;
1484
+ });
1485
+ } catch (error) {
1486
+ emit({
1487
+ _tag: "BackendError",
1488
+ message: `Herdr interruption failed; force-disposal is pending: ${boundedError(error)}`,
1489
+ });
1490
+ }
1491
+
1492
+ if (!interrupted && !effectSignal.aborted) {
1493
+ await new Promise<void>((resolve) => {
1494
+ if (effectSignal.aborted) resolve();
1495
+ else {
1496
+ effectSignal.addEventListener("abort", () => resolve(), {
1497
+ once: true,
1498
+ });
1499
+ }
1500
+ });
1501
+ }
1502
+ }),
1503
+ } satisfies SubagentSession;
1504
+ });
1505
+ }
1506
+
1507
+ export function createHerdrBackend(kind: BackendName): SubagentBackend {
1508
+ return {
1509
+ name: kind,
1510
+ capabilities: {
1511
+ steering: true,
1512
+ modelSelection: true,
1513
+ reasoningEffort: true,
1514
+ },
1515
+ available: Effect.sync(herdrAvailable),
1516
+ spawn: (task) => makeHerdrSession(kind, task),
1517
+ };
1518
+ }