@maxiaochao/pi-toolkit 0.9.6 → 0.10.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/CHANGELOG.md +6 -0
- package/README.md +1 -1
- package/package.json +5 -5
- package/packages/herdr-subagents/CHANGELOG.md +41 -0
- package/packages/herdr-subagents/README.md +155 -0
- package/packages/herdr-subagents/TESTING.md +24 -0
- package/packages/herdr-subagents/agents/explorer.md +8 -0
- package/packages/herdr-subagents/agents/reviewer.md +8 -0
- package/packages/{tiny-subagent → herdr-subagents}/agents/worker.md +1 -0
- package/packages/herdr-subagents/docs/demo.gif +0 -0
- package/packages/herdr-subagents/docs/screenshot.png +0 -0
- package/packages/herdr-subagents/extensions/herdr-subagents.ts +258 -0
- package/packages/herdr-subagents/extensions/subagent-report.ts +65 -0
- package/packages/{tiny-subagent → herdr-subagents}/package.json +7 -6
- package/packages/herdr-subagents/src/batch-runner.ts +219 -0
- package/packages/herdr-subagents/src/blocking.ts +175 -0
- package/packages/herdr-subagents/src/config.ts +125 -0
- package/packages/herdr-subagents/src/continue-run.ts +87 -0
- package/packages/herdr-subagents/src/dispatcher.ts +73 -0
- package/packages/herdr-subagents/src/errors.ts +3 -0
- package/packages/herdr-subagents/src/fleet-controller.ts +82 -0
- package/packages/herdr-subagents/src/fleet.ts +56 -0
- package/packages/herdr-subagents/src/herdr.ts +224 -0
- package/packages/herdr-subagents/src/history.ts +210 -0
- package/packages/herdr-subagents/src/live-agent.ts +87 -0
- package/packages/herdr-subagents/src/monitor.ts +18 -0
- package/packages/herdr-subagents/src/ownership.ts +17 -0
- package/packages/herdr-subagents/src/parameters.ts +21 -0
- package/packages/{tiny-subagent → herdr-subagents}/src/personas.ts +13 -2
- package/packages/herdr-subagents/src/protocol.ts +23 -0
- package/packages/herdr-subagents/src/queued-runs.ts +70 -0
- package/packages/herdr-subagents/src/reconcile.ts +167 -0
- package/packages/herdr-subagents/src/records.ts +213 -0
- package/packages/herdr-subagents/src/result-path.ts +7 -0
- package/packages/herdr-subagents/src/run-locator.ts +19 -0
- package/packages/herdr-subagents/src/run-pane-allocator.ts +78 -0
- package/packages/herdr-subagents/src/run-status.ts +23 -0
- package/packages/herdr-subagents/src/runner.ts +250 -0
- package/packages/herdr-subagents/src/state.ts +11 -0
- package/packages/herdr-subagents/src/tab-cleanup.ts +68 -0
- package/packages/herdr-subagents/src/tool.ts +217 -0
- package/packages/herdr-subagents/src/validation.ts +38 -0
- package/packages/herdr-subagents/tests/extension-contract.ts +225 -0
- package/packages/herdr-subagents/tests/run-tests.mjs +889 -0
- package/packages/todo/extensions/todo.ts +1 -1
- package/packages/tiny-subagent/CHANGELOG.md +0 -12
- package/packages/tiny-subagent/README.md +0 -128
- package/packages/tiny-subagent/TESTING.md +0 -20
- package/packages/tiny-subagent/docs/demo.gif +0 -0
- package/packages/tiny-subagent/docs/screenshot.png +0 -0
- package/packages/tiny-subagent/extensions/tiny-subagent.ts +0 -228
- package/packages/tiny-subagent/src/runner.ts +0 -319
- package/packages/tiny-subagent/tests/run-tests.mjs +0 -111
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { readdirSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import type { HerdrAutomation } from "./herdr.ts";
|
|
4
|
+
import { liveAgentName, promptLiveAgent } from "./live-agent.ts";
|
|
5
|
+
import { writeJsonAtomic } from "./state.ts";
|
|
6
|
+
import { cleanupRunTab } from "./tab-cleanup.ts";
|
|
7
|
+
import { RunPaneAllocator } from "./run-pane-allocator.ts";
|
|
8
|
+
import { aggregateTaskStatus, applyRunStatus } from "./run-status.ts";
|
|
9
|
+
import { applyReport, applyTaskStatus, readRunRecord, readTaskRecord, settleAttemptFailure } from "./records.ts";
|
|
10
|
+
import { isAbortError } from "./errors.ts";
|
|
11
|
+
|
|
12
|
+
export interface ContinueQueuedRunOptions {
|
|
13
|
+
cleanup?: boolean;
|
|
14
|
+
onStalled?: (message: string) => void;
|
|
15
|
+
onCleanupError?: (message: string) => void;
|
|
16
|
+
onUpdate?: () => void | Promise<void>;
|
|
17
|
+
signal?: AbortSignal;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function continueQueuedRun(runDirectory: string, herdr: HerdrAutomation, options: ContinueQueuedRunOptions = {}): Promise<void> {
|
|
21
|
+
const runFile = join(runDirectory, "run.json");
|
|
22
|
+
const run = await readRunRecord(runFile);
|
|
23
|
+
const taskDirectories = readdirSync(join(runDirectory, "tasks")).sort().map((entry) => join(runDirectory, "tasks", entry));
|
|
24
|
+
const queuedDirectories: string[] = [];
|
|
25
|
+
let blockedCount = 0;
|
|
26
|
+
for (const directory of taskDirectories) {
|
|
27
|
+
const status = (await readTaskRecord(join(directory, "task.json"))).status;
|
|
28
|
+
if (status === "queued") queuedDirectories.push(directory);
|
|
29
|
+
if (status === "blocked") blockedCount += 1;
|
|
30
|
+
}
|
|
31
|
+
if (!queuedDirectories.length) return;
|
|
32
|
+
const availableSlots = Math.max(0, (Number(run.effectiveConcurrency) || 1) - blockedCount);
|
|
33
|
+
if (availableSlots === 0) return;
|
|
34
|
+
const concurrency = Math.min(availableSlots, queuedDirectories.length);
|
|
35
|
+
const paneAllocator = new RunPaneAllocator({ herdr, run, runFile, signal: options.signal, validateExistingPane: true, onCleanupError: options.onCleanupError });
|
|
36
|
+
let nextIndex = 0;
|
|
37
|
+
const runOne = async (directory: string): Promise<string> => {
|
|
38
|
+
const taskFile = join(directory, "task.json");
|
|
39
|
+
const task = await readTaskRecord(taskFile);
|
|
40
|
+
let paneId = "";
|
|
41
|
+
const reportFile = join(directory, "report.json");
|
|
42
|
+
try {
|
|
43
|
+
paneId = await paneAllocator.allocate(directory);
|
|
44
|
+
const agentName = liveAgentName(task.id, Number(task.attempt ?? 0) + 1);
|
|
45
|
+
const paneLabel = `${String(task.order).padStart(2, "0")} · ${task.name ?? task.agent}`;
|
|
46
|
+
await herdr.renamePane(paneId, paneLabel, options.signal);
|
|
47
|
+
applyTaskStatus(task, "running");
|
|
48
|
+
Object.assign(task, { tabId: run.tabId, paneId, paneLabel, agentName, attempt: Number(task.attempt ?? 0) + 1, startedAt: new Date().toISOString() });
|
|
49
|
+
await writeJsonAtomic(taskFile, task);
|
|
50
|
+
await options.onUpdate?.();
|
|
51
|
+
const report = (await promptLiveAgent({ herdr, task: { ...task, id: task.id, sessionFile: task.sessionFile }, paneId, prompt: task.prompt, systemPromptFile: join(directory, "system-prompt.md"), agentName, signal: options.signal, stalledWarningMs: run.stalledWarningSeconds ? Number(run.stalledWarningSeconds) * 1000 : undefined, onStalled: () => options.onStalled?.(`${task.name ?? task.agent} appears stalled; it is still running.`) })).report;
|
|
52
|
+
applyReport(task, report);
|
|
53
|
+
} catch (error) {
|
|
54
|
+
await settleAttemptFailure(task, reportFile, {
|
|
55
|
+
cancelled: isAbortError(error, options.signal),
|
|
56
|
+
error: error instanceof Error ? error.message : String(error),
|
|
57
|
+
name: String(task.name ?? task.agent ?? "Task"),
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
await writeJsonAtomic(taskFile, task);
|
|
61
|
+
await options.onUpdate?.();
|
|
62
|
+
return task.status;
|
|
63
|
+
};
|
|
64
|
+
const worker = async () => {
|
|
65
|
+
while (true) {
|
|
66
|
+
const index = nextIndex++;
|
|
67
|
+
if (index >= queuedDirectories.length) return;
|
|
68
|
+
if (await runOne(queuedDirectories[index]) === "blocked") return;
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
await Promise.all(Array.from({ length: concurrency }, worker));
|
|
72
|
+
if (options.signal?.aborted) {
|
|
73
|
+
for (const directory of taskDirectories) {
|
|
74
|
+
const taskFile = join(directory, "task.json");
|
|
75
|
+
const task = await readTaskRecord(taskFile);
|
|
76
|
+
if (task.status !== "queued") continue;
|
|
77
|
+
applyTaskStatus(task, "cancelled", { error: "Parent Pi session closed." });
|
|
78
|
+
await writeJsonAtomic(taskFile, task);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
const statuses = await Promise.all(taskDirectories.map(async (directory) => (await readTaskRecord(join(directory, "task.json"))).status));
|
|
82
|
+
applyRunStatus(run, options.signal?.aborted ? "cancelled" : aggregateTaskStatus(statuses));
|
|
83
|
+
await writeJsonAtomic(runFile, run);
|
|
84
|
+
if (options.cleanup !== false && run.status !== "blocked" && run.tabId) {
|
|
85
|
+
await cleanupRunTab({ herdr, tabId: run.tabId, runFile, runRecord: run, signal: options.signal, onError: options.onCleanupError, failureStatus: "failed" });
|
|
86
|
+
}
|
|
87
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
interface QueuedRun<T> {
|
|
2
|
+
id: string;
|
|
3
|
+
execute: () => Promise<T>;
|
|
4
|
+
resolve: (value: T) => void;
|
|
5
|
+
reject: (error: unknown) => void;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface DispatcherSnapshot {
|
|
9
|
+
activeRunId?: string;
|
|
10
|
+
queuedRunIds: string[];
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export class RunDispatcher {
|
|
14
|
+
private activeRunId?: string;
|
|
15
|
+
private readonly queue: QueuedRun<unknown>[] = [];
|
|
16
|
+
private readonly onChange?: (snapshot: DispatcherSnapshot) => void;
|
|
17
|
+
private paused = false;
|
|
18
|
+
private retainedRunId?: string;
|
|
19
|
+
|
|
20
|
+
constructor(onChange?: (snapshot: DispatcherSnapshot) => void) { this.onChange = onChange; }
|
|
21
|
+
|
|
22
|
+
submit<T>(id: string, execute: () => Promise<T>): Promise<T> {
|
|
23
|
+
const promise = new Promise<T>((resolve, reject) => this.queue.push({ id, execute, resolve: resolve as (value: unknown) => void, reject }));
|
|
24
|
+
this.changed();
|
|
25
|
+
void this.drain();
|
|
26
|
+
return promise;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
snapshot(): DispatcherSnapshot {
|
|
30
|
+
return { ...(this.activeRunId ? { activeRunId: this.activeRunId } : {}), queuedRunIds: this.queue.map((run) => run.id) };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
pause(): void { this.paused = true; this.changed(); }
|
|
34
|
+
resume(): void { this.paused = false; this.changed(); void this.drain(); }
|
|
35
|
+
retain(id: string): void {
|
|
36
|
+
if (this.activeRunId !== id) throw new Error(`Cannot retain inactive run '${id}'.`);
|
|
37
|
+
this.retainedRunId = id;
|
|
38
|
+
this.changed();
|
|
39
|
+
}
|
|
40
|
+
release(id: string): boolean {
|
|
41
|
+
if (this.activeRunId !== id || this.retainedRunId !== id) return false;
|
|
42
|
+
this.retainedRunId = undefined;
|
|
43
|
+
this.activeRunId = undefined;
|
|
44
|
+
this.changed();
|
|
45
|
+
void this.drain();
|
|
46
|
+
return true;
|
|
47
|
+
}
|
|
48
|
+
cancelQueued(reason: Error): string[] {
|
|
49
|
+
const cancelled = this.queue.splice(0);
|
|
50
|
+
for (const run of cancelled) run.reject(reason);
|
|
51
|
+
this.changed();
|
|
52
|
+
return cancelled.map((run) => run.id);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
private changed(): void { this.onChange?.(this.snapshot()); }
|
|
56
|
+
|
|
57
|
+
private async drain(): Promise<void> {
|
|
58
|
+
if (this.activeRunId || this.paused) return;
|
|
59
|
+
const run = this.queue.shift();
|
|
60
|
+
if (!run) { this.changed(); return; }
|
|
61
|
+
this.activeRunId = run.id;
|
|
62
|
+
this.changed();
|
|
63
|
+
try { run.resolve(await run.execute()); }
|
|
64
|
+
catch (error) { run.reject(error); }
|
|
65
|
+
finally {
|
|
66
|
+
if (this.retainedRunId !== run.id) {
|
|
67
|
+
this.activeRunId = undefined;
|
|
68
|
+
this.changed();
|
|
69
|
+
void this.drain();
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import type { ExtensionUIContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { Container, isKeyRelease, matchesKey, Text } from "@earendil-works/pi-tui";
|
|
3
|
+
import type { HerdrAutomation } from "./herdr.ts";
|
|
4
|
+
import { fleetEditorHasFocus, FleetSelection, fleetLines } from "./fleet.ts";
|
|
5
|
+
import { focusActiveTask } from "./monitor.ts";
|
|
6
|
+
import { isActiveStatus } from "./records.ts";
|
|
7
|
+
import type { HerdrSubagentsBatchDetails } from "./tool.ts";
|
|
8
|
+
|
|
9
|
+
export class FleetController {
|
|
10
|
+
private activeBatch?: HerdrSubagentsBatchDetails;
|
|
11
|
+
private readonly selection = new FleetSelection(matchesKey);
|
|
12
|
+
private unsubscribeInput?: () => void;
|
|
13
|
+
private ui?: ExtensionUIContext;
|
|
14
|
+
private tui?: { focusedComponent?: unknown };
|
|
15
|
+
|
|
16
|
+
constructor(
|
|
17
|
+
private readonly herdr: HerdrAutomation,
|
|
18
|
+
private readonly isEditor: (value: unknown) => boolean,
|
|
19
|
+
private readonly createWidget?: (lines: string[]) => Container,
|
|
20
|
+
) {}
|
|
21
|
+
|
|
22
|
+
get active(): HerdrSubagentsBatchDetails | undefined { return this.activeBatch; }
|
|
23
|
+
|
|
24
|
+
bind(ui: ExtensionUIContext, tuiMode: boolean): void {
|
|
25
|
+
this.unsubscribeInput?.();
|
|
26
|
+
this.ui = ui;
|
|
27
|
+
this.unsubscribeInput = tuiMode ? ui.onTerminalInput((data) => this.handleInput(data)) : undefined;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
sync(details: unknown): void {
|
|
31
|
+
const batch = details as Partial<HerdrSubagentsBatchDetails>;
|
|
32
|
+
if (!batch.activity || !batch.label || !batch.status) return;
|
|
33
|
+
this.activeBatch = isActiveStatus(batch.status) ? batch as HerdrSubagentsBatchDetails : undefined;
|
|
34
|
+
this.render();
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
setActive(batch: HerdrSubagentsBatchDetails | undefined): void {
|
|
38
|
+
this.activeBatch = batch;
|
|
39
|
+
this.render();
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
clearRun(runId?: string): void {
|
|
43
|
+
if (!runId || this.activeBatch?.runId === runId) this.activeBatch = undefined;
|
|
44
|
+
this.render();
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
resetSelection(): void { this.selection.reset(); this.render(); }
|
|
48
|
+
async focus(taskNumber: number): Promise<boolean> { return this.activeBatch ? focusActiveTask(this.activeBatch, taskNumber, this.herdr) : false; }
|
|
49
|
+
|
|
50
|
+
shutdown(): void {
|
|
51
|
+
this.unsubscribeInput?.();
|
|
52
|
+
this.unsubscribeInput = undefined;
|
|
53
|
+
this.activeBatch = undefined;
|
|
54
|
+
this.selection.reset();
|
|
55
|
+
this.render();
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
render(): void {
|
|
59
|
+
if (!this.ui) return;
|
|
60
|
+
this.ui.setWidget("herdr-subagents", this.activeBatch ? ((tui) => {
|
|
61
|
+
this.tui = tui as { focusedComponent?: unknown };
|
|
62
|
+
const lines = fleetLines(this.activeBatch as HerdrSubagentsBatchDetails, this.selection);
|
|
63
|
+
if (this.createWidget) return this.createWidget(lines);
|
|
64
|
+
const container = new Container();
|
|
65
|
+
for (const line of lines) container.addChild(new Text(line, 1, 0));
|
|
66
|
+
return container;
|
|
67
|
+
}) : undefined, { placement: "belowEditor" });
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
private handleInput(data: string): { consume: true } | undefined {
|
|
71
|
+
if (!this.activeBatch || isKeyRelease(data)) return undefined;
|
|
72
|
+
if (!fleetEditorHasFocus(this.tui?.focusedComponent, this.isEditor)) {
|
|
73
|
+
this.selection.reset();
|
|
74
|
+
this.render();
|
|
75
|
+
return undefined;
|
|
76
|
+
}
|
|
77
|
+
const result = this.selection.handle(data, this.ui?.getEditorText() ?? "", this.activeBatch.activity.length);
|
|
78
|
+
if (result.changed) this.render();
|
|
79
|
+
if (result.focusTask) void focusActiveTask(this.activeBatch, result.focusTask, this.herdr);
|
|
80
|
+
return result.consume ? { consume: true } : undefined;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { BatchResult } from "./batch-runner.ts";
|
|
2
|
+
|
|
3
|
+
type FleetKey = "up" | "down" | "enter" | "escape";
|
|
4
|
+
type KeyMatcher = (data: string, key: FleetKey) => boolean;
|
|
5
|
+
|
|
6
|
+
const legacyKeys: Record<FleetKey, string> = { up: "\u001b[A", down: "\u001b[B", enter: "\r", escape: "\u001b" };
|
|
7
|
+
const legacyMatchesKey: KeyMatcher = (data, key) => data === legacyKeys[key];
|
|
8
|
+
|
|
9
|
+
export interface FleetInputResult { consume?: boolean; focusTask?: number; changed?: boolean; }
|
|
10
|
+
|
|
11
|
+
export function fleetEditorHasFocus(focused: unknown, isEditor: (value: unknown) => boolean): boolean {
|
|
12
|
+
return isEditor(focused);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export class FleetSelection {
|
|
16
|
+
private selected = 0;
|
|
17
|
+
private selecting = false;
|
|
18
|
+
private readonly matchesKey: KeyMatcher;
|
|
19
|
+
|
|
20
|
+
constructor(matchesKey: KeyMatcher = legacyMatchesKey) { this.matchesKey = matchesKey; }
|
|
21
|
+
|
|
22
|
+
reset(): void { this.selected = 0; this.selecting = false; }
|
|
23
|
+
isSelecting(): boolean { return this.selecting; }
|
|
24
|
+
selectedIndex(taskCount: number): number { return Math.min(this.selected, Math.max(0, taskCount - 1)); }
|
|
25
|
+
|
|
26
|
+
handle(data: string, editorText: string, taskCount: number): FleetInputResult {
|
|
27
|
+
if (taskCount === 0) { this.reset(); return {}; }
|
|
28
|
+
if (!this.selecting) {
|
|
29
|
+
if (!this.matchesKey(data, "down") || editorText.length !== 0) return {};
|
|
30
|
+
this.selecting = true;
|
|
31
|
+
this.selected = 0;
|
|
32
|
+
return { consume: true, changed: true };
|
|
33
|
+
}
|
|
34
|
+
if (this.matchesKey(data, "up") || this.matchesKey(data, "down")) {
|
|
35
|
+
const delta = this.matchesKey(data, "up") ? -1 : 1;
|
|
36
|
+
this.selected = (this.selected + delta + taskCount) % taskCount;
|
|
37
|
+
return { consume: true, changed: true };
|
|
38
|
+
}
|
|
39
|
+
if (this.matchesKey(data, "enter")) return { consume: true, focusTask: this.selected + 1 };
|
|
40
|
+
if (this.matchesKey(data, "escape")) { this.selecting = false; return { consume: true, changed: true }; }
|
|
41
|
+
this.selecting = false;
|
|
42
|
+
return { changed: true };
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function fleetLines(batch: Pick<BatchResult, "label" | "activity">, selection: FleetSelection): string[] {
|
|
47
|
+
const counts = new Map<string, number>();
|
|
48
|
+
for (const task of batch.activity) counts.set(task.status, (counts.get(task.status) ?? 0) + 1);
|
|
49
|
+
const summary = ["running", "queued", "blocked", "failed", "completed", "cancelled"]
|
|
50
|
+
.filter((status) => counts.has(status)).map((status) => `${status} ${counts.get(status)}`).join(" · ");
|
|
51
|
+
const selected = selection.selectedIndex(batch.activity.length);
|
|
52
|
+
return [
|
|
53
|
+
`Subagents · ${batch.label} · ${summary}`,
|
|
54
|
+
...batch.activity.map((task, index) => `${selection.isSelecting() && index === selected ? "›" : " "} ${String(task.index + 1).padStart(2, "0")} · ${task.name} ${task.status}`),
|
|
55
|
+
];
|
|
56
|
+
}
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
|
|
3
|
+
export interface CreateTabRequest {
|
|
4
|
+
workspaceId: string;
|
|
5
|
+
cwd: string;
|
|
6
|
+
label: string;
|
|
7
|
+
env: Record<string, string>;
|
|
8
|
+
focus: boolean;
|
|
9
|
+
signal?: AbortSignal;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface StartAgentRequest {
|
|
13
|
+
name: string;
|
|
14
|
+
kind: "pi";
|
|
15
|
+
paneId: string;
|
|
16
|
+
args: string[];
|
|
17
|
+
signal?: AbortSignal;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface PromptAgentRequest {
|
|
21
|
+
target: string;
|
|
22
|
+
prompt: string;
|
|
23
|
+
signal?: AbortSignal;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface SplitPaneRequest {
|
|
27
|
+
paneId: string;
|
|
28
|
+
cwd: string;
|
|
29
|
+
direction: "right" | "down";
|
|
30
|
+
focus: boolean;
|
|
31
|
+
env?: Record<string, string>;
|
|
32
|
+
signal?: AbortSignal;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface HerdrAutomation {
|
|
36
|
+
createTab(request: CreateTabRequest): Promise<{ tabId: string; paneId: string }>;
|
|
37
|
+
startAgent(request: StartAgentRequest): Promise<void>;
|
|
38
|
+
promptAgent(request: PromptAgentRequest): Promise<{ status?: string }>;
|
|
39
|
+
splitPane(request: SplitPaneRequest): Promise<{ paneId: string }>;
|
|
40
|
+
renamePane(paneId: string, label: string, signal?: AbortSignal): Promise<void>;
|
|
41
|
+
renameTab(tabId: string, label: string, signal?: AbortSignal): Promise<void>;
|
|
42
|
+
focusPane(paneId: string, signal?: AbortSignal): Promise<void>;
|
|
43
|
+
isTabFocused(tabId: string, signal?: AbortSignal): Promise<boolean>;
|
|
44
|
+
waitForTabUnfocused(tabId: string, signal?: AbortSignal): Promise<void>;
|
|
45
|
+
paneExists(paneId: string, signal?: AbortSignal): Promise<boolean>;
|
|
46
|
+
isTaskRunning(paneId: string, signal?: AbortSignal): Promise<boolean>;
|
|
47
|
+
closeTab(tabId: string, signal?: AbortSignal): Promise<void>;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
interface HerdrResponse {
|
|
51
|
+
result?: {
|
|
52
|
+
tab?: { tab_id?: string; focused?: boolean };
|
|
53
|
+
root_pane?: { pane_id?: string };
|
|
54
|
+
pane?: { pane_id?: string };
|
|
55
|
+
agent?: { status?: string };
|
|
56
|
+
process_info?: { foreground_processes?: Array<{ name?: string; argv?: string[] }> };
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export class HerdrCommandError extends Error {
|
|
61
|
+
readonly args: string[];
|
|
62
|
+
readonly exitCode: number | null;
|
|
63
|
+
readonly stderr: string;
|
|
64
|
+
|
|
65
|
+
constructor(
|
|
66
|
+
message: string,
|
|
67
|
+
args: string[],
|
|
68
|
+
exitCode: number | null,
|
|
69
|
+
stderr: string,
|
|
70
|
+
) {
|
|
71
|
+
super(message);
|
|
72
|
+
this.name = "HerdrCommandError";
|
|
73
|
+
this.args = args;
|
|
74
|
+
this.exitCode = exitCode;
|
|
75
|
+
this.stderr = stderr;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function runCommand(binary: string, args: string[], signal?: AbortSignal): Promise<string> {
|
|
80
|
+
return await new Promise<string>((resolve, reject) => {
|
|
81
|
+
let stdout = "";
|
|
82
|
+
let stderr = "";
|
|
83
|
+
let spawnError: Error | undefined;
|
|
84
|
+
const child = spawn(binary, args, {
|
|
85
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
86
|
+
env: process.env,
|
|
87
|
+
shell: false,
|
|
88
|
+
});
|
|
89
|
+
child.stdout.on("data", (chunk: Buffer | string) => {
|
|
90
|
+
stdout += chunk.toString();
|
|
91
|
+
});
|
|
92
|
+
child.stderr.on("data", (chunk: Buffer | string) => {
|
|
93
|
+
stderr += chunk.toString();
|
|
94
|
+
});
|
|
95
|
+
child.once("error", (error) => {
|
|
96
|
+
spawnError = error;
|
|
97
|
+
});
|
|
98
|
+
const abort = () => child.kill("SIGTERM");
|
|
99
|
+
const removeAbortListener = () => signal?.removeEventListener("abort", abort);
|
|
100
|
+
child.once("close", (code) => {
|
|
101
|
+
removeAbortListener();
|
|
102
|
+
if (spawnError) {
|
|
103
|
+
reject(new HerdrCommandError(spawnError.message, args, 1, stderr));
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
if (code !== 0) {
|
|
107
|
+
const detail = stderr.trim() || stdout.trim() || `exit code ${code}`;
|
|
108
|
+
reject(new HerdrCommandError(`Herdr command failed: ${detail}`, args, code, stderr));
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
resolve(stdout);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
if (signal?.aborted) abort();
|
|
115
|
+
else signal?.addEventListener("abort", abort, { once: true });
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function parseResponse(output: string, operation: string): HerdrResponse {
|
|
120
|
+
try {
|
|
121
|
+
return JSON.parse(output) as HerdrResponse;
|
|
122
|
+
} catch {
|
|
123
|
+
throw new Error(`${operation} returned invalid JSON`);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export class CliHerdrAutomation implements HerdrAutomation {
|
|
128
|
+
private readonly binary: string;
|
|
129
|
+
|
|
130
|
+
constructor(binary = process.env.PI_HERDR_SUBAGENTS_HERDR_BINARY?.trim() || "herdr") {
|
|
131
|
+
this.binary = binary;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async createTab(request: CreateTabRequest): Promise<{ tabId: string; paneId: string }> {
|
|
135
|
+
const args = ["tab", "create", "--workspace", request.workspaceId, "--cwd", request.cwd, "--label", request.label];
|
|
136
|
+
for (const [key, value] of Object.entries(request.env).sort(([left], [right]) => left.localeCompare(right))) {
|
|
137
|
+
args.push("--env", `${key}=${value}`);
|
|
138
|
+
}
|
|
139
|
+
args.push(request.focus ? "--focus" : "--no-focus");
|
|
140
|
+
const response = parseResponse(await runCommand(this.binary, args, request.signal), "herdr tab create");
|
|
141
|
+
const tabId = response.result?.tab?.tab_id;
|
|
142
|
+
const paneId = response.result?.root_pane?.pane_id;
|
|
143
|
+
if (!tabId || !paneId) throw new Error("herdr tab create omitted the tab or root pane id");
|
|
144
|
+
return { tabId, paneId };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async startAgent(request: StartAgentRequest): Promise<void> {
|
|
148
|
+
await runCommand(this.binary, ["agent", "start", request.name, "--kind", request.kind, "--pane", request.paneId, "--", ...request.args], request.signal);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async promptAgent(request: PromptAgentRequest): Promise<{ status?: string }> {
|
|
152
|
+
const response = parseResponse(
|
|
153
|
+
await runCommand(this.binary, ["agent", "prompt", request.target, request.prompt, "--wait"], request.signal),
|
|
154
|
+
"herdr agent prompt",
|
|
155
|
+
);
|
|
156
|
+
return { status: response.result?.agent?.status };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async splitPane(request: SplitPaneRequest): Promise<{ paneId: string }> {
|
|
160
|
+
const args = ["pane", "split", request.paneId, "--direction", request.direction, "--cwd", request.cwd, request.focus ? "--focus" : "--no-focus"];
|
|
161
|
+
for (const [key, value] of Object.entries(request.env ?? {}).sort(([left], [right]) => left.localeCompare(right))) args.push("--env", `${key}=${value}`);
|
|
162
|
+
const response = parseResponse(await runCommand(this.binary, args, request.signal), "herdr pane split");
|
|
163
|
+
const paneId = response.result?.pane?.pane_id;
|
|
164
|
+
if (!paneId) throw new Error("herdr pane split omitted the pane id");
|
|
165
|
+
return { paneId };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
async renamePane(paneId: string, label: string, signal?: AbortSignal): Promise<void> {
|
|
169
|
+
await runCommand(this.binary, ["pane", "rename", paneId, label], signal);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
async renameTab(tabId: string, label: string, signal?: AbortSignal): Promise<void> {
|
|
173
|
+
await runCommand(this.binary, ["tab", "rename", tabId, label], signal);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
async focusPane(paneId: string, signal?: AbortSignal): Promise<void> {
|
|
177
|
+
await runCommand(this.binary, ["agent", "focus", paneId], signal);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
async isTabFocused(tabId: string, signal?: AbortSignal): Promise<boolean> {
|
|
181
|
+
const response = parseResponse(await runCommand(this.binary, ["tab", "get", tabId], signal), "herdr tab get");
|
|
182
|
+
return response.result?.tab?.focused === true;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
async waitForTabUnfocused(tabId: string, signal?: AbortSignal): Promise<void> {
|
|
186
|
+
while (await this.isTabFocused(tabId, signal)) {
|
|
187
|
+
await new Promise<void>((resolve, reject) => {
|
|
188
|
+
const onAbort = () => { clearTimeout(timer); reject(signal?.reason); };
|
|
189
|
+
const timer = setTimeout(() => { signal?.removeEventListener("abort", onAbort); resolve(); }, 500);
|
|
190
|
+
if (signal?.aborted) onAbort();
|
|
191
|
+
else signal?.addEventListener("abort", onAbort, { once: true });
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
async paneExists(paneId: string, signal?: AbortSignal): Promise<boolean> {
|
|
197
|
+
try { await runCommand(this.binary, ["pane", "get", paneId], signal); return true; }
|
|
198
|
+
catch (error) {
|
|
199
|
+
if (error instanceof HerdrCommandError && /pane[_ -]?not[_ -]?found|unknown pane/i.test(`${error.message}\n${error.stderr}`)) return false;
|
|
200
|
+
throw error;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
async isTaskRunning(paneId: string, signal?: AbortSignal): Promise<boolean> {
|
|
205
|
+
try {
|
|
206
|
+
const response = parseResponse(await runCommand(this.binary, ["pane", "process-info", "--pane", paneId], signal), "herdr pane process-info");
|
|
207
|
+
return (response.result?.process_info?.foreground_processes ?? []).some((process) => {
|
|
208
|
+
if (process.name === "pi") return true;
|
|
209
|
+
return (process.argv ?? []).some((argument) => {
|
|
210
|
+
const normalized = argument.replaceAll("\\", "/");
|
|
211
|
+
const file = normalized.split("/").at(-1);
|
|
212
|
+
return file === "pi" || (/pi-coding-agent/.test(normalized) && /(?:^|\/)cli\.js$/.test(normalized)) || /pi-coding-agent\/dist\/bundle\//.test(normalized);
|
|
213
|
+
});
|
|
214
|
+
});
|
|
215
|
+
} catch (error) {
|
|
216
|
+
if (error instanceof HerdrCommandError && /pane[_ -]?not[_ -]?found|unknown pane/i.test(`${error.message}\n${error.stderr}`)) return false;
|
|
217
|
+
throw error;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
async closeTab(tabId: string, signal?: AbortSignal): Promise<void> {
|
|
222
|
+
await runCommand(this.binary, ["tab", "close", tabId], signal);
|
|
223
|
+
}
|
|
224
|
+
}
|