pi-legion 3.0.3 → 4.0.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 +263 -2
- package/CONTRIBUTING.md +4 -17
- package/README.md +29 -22
- package/agents/worker.md +1 -1
- package/docs/contracts/display-liveness-v1.md +373 -0
- package/docs/contracts/drop-non-pi-kinds-v4.md +198 -0
- package/docs/contracts/esc-abort-v1.md +4 -4
- package/docs/contracts/esc-no-retire-v3.md +2 -2
- package/docs/contracts/launch-admission-queue-v1.md +839 -0
- package/docs/contracts/status-panel-v1.md +174 -0
- package/docs/inprocess-supervisor.md +37 -4
- package/docs/legion-v2-contract.md +18 -2
- package/docs/performance-2026-10-19.md +199 -0
- package/docs/spawn-mode-visibility-v1.md +206 -0
- package/docs/vexillatio-contract.md +3 -1
- package/index.ts +633 -84
- package/package.json +4 -2
- package/skills/pi-legion/SKILL.md +2 -3
- package/src/agents/agents.ts +17 -10
- package/src/agents/overrides.ts +0 -1
- package/src/agents/presets.ts +14 -40
- package/src/agents/settings.ts +26 -77
- package/src/agents/step-model.ts +7 -22
- package/src/api.ts +1 -35
- package/src/extension/event-bus.ts +80 -8
- package/src/extension/legion-commands.ts +43 -17
- package/src/extension/legion-doctor.ts +1 -1
- package/src/extension/legion-stop.ts +39 -13
- package/src/extension/legion-tools.ts +21 -4
- package/src/extension/liveness.ts +200 -0
- package/src/extension/notify.ts +6 -6
- package/src/extension/playbook.ts +6 -6
- package/src/extension/recycle.ts +6 -2
- package/src/extension/runtime.ts +422 -18
- package/src/extension/slash.ts +0 -21
- package/src/extension/spawn-render.ts +183 -0
- package/src/extension/summary.ts +1 -1
- package/src/legion/db.ts +165 -1
- package/src/legion/events.ts +81 -16
- package/src/legion/nodes.ts +13 -2
- package/src/legion/rebuild.ts +7 -1
- package/src/legion/usage.ts +58 -4
- package/src/runs/orchestrator.ts +1165 -62
- package/src/runs/worktree.ts +190 -21
- package/src/shared/ignored-kind.ts +57 -0
- package/src/shared/progress.ts +11 -0
- package/src/shared/session.ts +199 -19
- package/src/shared/types.ts +50 -66
- package/src/supervisor/admission-queue.ts +516 -0
- package/src/supervisor/inprocess-supervisor.ts +108 -33
- package/src/supervisor/proc-memory.ts +67 -0
- package/src/supervisor/routing-supervisor.ts +58 -1
- package/src/tui/live-overlay.ts +14 -0
- package/src/tui/session-messages-file.ts +100 -0
- package/src/tui/status.ts +103 -21
- package/src/tui/tree-view.ts +36 -3
- package/src/tui/viz/layout.ts +54 -3
- package/src/tui/viz/model.ts +67 -14
- package/src/tui/viz/palette.ts +11 -0
- package/src/tui/viz/panels.ts +222 -8
- package/src/tui/viz/railway.ts +28 -25
- package/src/tui/viz/view.ts +110 -23
- package/src/backends/legacy/client.ts +0 -638
- package/src/backends/legacy/cursor-chat.ts +0 -348
- package/src/backends/legacy/runner.ts +0 -101
- package/src/backends/legacy/types.ts +0 -138
- package/src/runs/kind.ts +0 -260
|
@@ -1,348 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Structured collection for cursor-kind children.
|
|
3
|
-
*
|
|
4
|
-
* cursor-agent persists every chat under `~/.cursor/chats/<projectHash>/<chatId>/`
|
|
5
|
-
* as a SQLite store (`store.db`, WAL mode) whose `blobs(id, data)` rows hold the
|
|
6
|
-
* conversation: user and assistant messages as JSON, tool traffic as protobuf
|
|
7
|
-
* blobs. herdr reports the chat id as `agent_session.value` (`source:
|
|
8
|
-
* "herdr:cursor"`), so locating the store is deterministic — no screen scraping.
|
|
9
|
-
* Why this beats reading the pane (measured 2026-09-20, v2026.09.18-9a7762b):
|
|
10
|
-
* - the assistant reply is stored verbatim — the TUI word-wraps long lines,
|
|
11
|
-
* which corrupted streamed verdict JSON enough to break JSON.parse;
|
|
12
|
-
* - a message is written atomically on turn completion: across every live
|
|
13
|
-
* chat, the last row is a complete assistant message, never a half-stream.
|
|
14
|
-
* "last message is assistant with text" IS the turn-settled signal;
|
|
15
|
-
* - F7's "non-pi kinds can only report unknown" no longer holds: the store
|
|
16
|
-
* carries the reply, so outcome/verdict derivation can use it directly.
|
|
17
|
-
*
|
|
18
|
-
* Schema notes (`meta.json` sits beside `store.db`):
|
|
19
|
-
* {"schemaVersion": 1, "cwd": "...", "title": "...", "createdAtMs": ...}
|
|
20
|
-
* `schemaVersion` is checked on read; a bump means the shape below changed and
|
|
21
|
-
* the parser should refuse rather than mis-parse.
|
|
22
|
-
*/
|
|
23
|
-
|
|
24
|
-
import * as fs from "node:fs";
|
|
25
|
-
import * as os from "node:os";
|
|
26
|
-
import * as path from "node:path";
|
|
27
|
-
// `node:module` is implemented by both Bun and Node, so this static import is
|
|
28
|
-
// safe — unlike node:sqlite, which Bun lacks entirely.
|
|
29
|
-
import { createRequire } from "node:module";
|
|
30
|
-
import type { ParsedSession, TurnRecord } from "../../shared/types.ts";
|
|
31
|
-
import { emptyParsedSession } from "../../shared/session.ts";
|
|
32
|
-
|
|
33
|
-
/**
|
|
34
|
-
* Minimal structural type both runtimes' SQLite drivers satisfy.
|
|
35
|
-
*
|
|
36
|
-
* IMPORTANT: sqlite is loaded LAZILY via createRequire at call time — pi
|
|
37
|
-
* ships as a Bun-compiled binary, Bun has no `node:sqlite`, and a static
|
|
38
|
-
* import would fail at module-resolution time, killing the whole extension
|
|
39
|
-
* on load even when no cursor child ever runs. Runtime branch: Bun →
|
|
40
|
-
* `bun:sqlite` (readonly: true), Node ≥22.5 → `node:sqlite`
|
|
41
|
-
* (readOnly: true).
|
|
42
|
-
*/
|
|
43
|
-
interface MinimalSqliteDb {
|
|
44
|
-
prepare(sql: string): { iterate(): IterableIterator<{ data: unknown }> };
|
|
45
|
-
close(): void;
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
/**
|
|
49
|
-
* Per-driver opener recipe: the module id and its own read-only option
|
|
50
|
-
* spelling (bun uses `readonly`, node uses `readOnly` — each driver gets only
|
|
51
|
-
* its own key, so no cross-runtime assumption about unknown-key tolerance).
|
|
52
|
-
*/
|
|
53
|
-
const SQLITE_DRIVERS: ReadonlyArray<{
|
|
54
|
-
id: string;
|
|
55
|
-
readonlyKey: "readonly" | "readOnly";
|
|
56
|
-
ctor: "Database" | "DatabaseSync";
|
|
57
|
-
}> = [
|
|
58
|
-
{ id: "bun:sqlite", readonlyKey: "readonly", ctor: "Database" },
|
|
59
|
-
{ id: "node:sqlite", readonlyKey: "readOnly", ctor: "DatabaseSync" },
|
|
60
|
-
];
|
|
61
|
-
|
|
62
|
-
/** Opens a SQLite file read-only (per-runtime driver, resolved lazily). */
|
|
63
|
-
type SqliteOpener = (dbPath: string) => MinimalSqliteDb;
|
|
64
|
-
|
|
65
|
-
/** Cached CommonJS require; created lazily so a hostile import.meta.url in a
|
|
66
|
-
* compiled binary cannot crash module load (the very bug this guards). */
|
|
67
|
-
let cachedRequire: ((id: string) => unknown) | null | undefined;
|
|
68
|
-
|
|
69
|
-
/** Cached opener; null once both drivers are known to be unavailable. */
|
|
70
|
-
let cachedOpener: SqliteOpener | null | undefined;
|
|
71
|
-
|
|
72
|
-
/** Get a CommonJS require for built-ins; null when unobtainable. */
|
|
73
|
-
function requireForBuiltins(): ((id: string) => unknown) | null {
|
|
74
|
-
if (cachedRequire !== undefined) return cachedRequire;
|
|
75
|
-
try {
|
|
76
|
-
// Deferred (not module-top-level) so even a weird virtual
|
|
77
|
-
// import.meta.url (Bun compiled binaries) only degrades cursor
|
|
78
|
-
// collection via the catch below, never extension loading.
|
|
79
|
-
cachedRequire = createRequire(import.meta.url);
|
|
80
|
-
} catch {
|
|
81
|
-
cachedRequire = null;
|
|
82
|
-
}
|
|
83
|
-
return cachedRequire;
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
/** Constructor shape shared by Bun's `Database` and Node's `DatabaseSync`. */
|
|
87
|
-
type SqliteDbCtor = new (
|
|
88
|
-
dbPath: string,
|
|
89
|
-
opts: Record<string, boolean>,
|
|
90
|
-
) => MinimalSqliteDb;
|
|
91
|
-
/** Pick the driver constructor out of a required module namespace. */
|
|
92
|
-
function ctorOf(
|
|
93
|
-
mod: unknown,
|
|
94
|
-
name: "Database" | "DatabaseSync",
|
|
95
|
-
): SqliteDbCtor | null {
|
|
96
|
-
if (typeof mod !== "object" || mod === null) return null;
|
|
97
|
-
// SAFETY: structural narrowing of an untyped module namespace — the
|
|
98
|
-
// typeof-function guard is the whole invariant; the constructor signature
|
|
99
|
-
// is then checked by TypeScript at the `new` site below.
|
|
100
|
-
const ctor = (mod as Record<string, unknown>)[name];
|
|
101
|
-
if (typeof ctor !== "function") return null;
|
|
102
|
-
return ctor as SqliteDbCtor;
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
/** Resolve the current runtime's SQLite driver lazily; null when unavailable. */
|
|
106
|
-
function sqliteOpener(): SqliteOpener | null {
|
|
107
|
-
if (cachedOpener !== undefined) return cachedOpener;
|
|
108
|
-
const req = requireForBuiltins();
|
|
109
|
-
if (req) {
|
|
110
|
-
for (const driver of SQLITE_DRIVERS) {
|
|
111
|
-
let mod: unknown;
|
|
112
|
-
try {
|
|
113
|
-
mod = req(driver.id);
|
|
114
|
-
} catch {
|
|
115
|
-
continue; // this runtime lacks the builtin
|
|
116
|
-
}
|
|
117
|
-
const Ctor = ctorOf(mod, driver.ctor);
|
|
118
|
-
if (Ctor) {
|
|
119
|
-
const opts: Record<string, boolean> = {};
|
|
120
|
-
opts[driver.readonlyKey] = true;
|
|
121
|
-
cachedOpener = (dbPath) => new Ctor(dbPath, opts);
|
|
122
|
-
return cachedOpener;
|
|
123
|
-
}
|
|
124
|
-
}
|
|
125
|
-
}
|
|
126
|
-
cachedOpener = null;
|
|
127
|
-
return null;
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
/** The only schemaVersion observed in the wild so far. */
|
|
131
|
-
const SUPPORTED_SCHEMA_VERSION = 1;
|
|
132
|
-
|
|
133
|
-
interface ChatMessage {
|
|
134
|
-
role: "user" | "assistant";
|
|
135
|
-
content: unknown;
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
/**
|
|
139
|
-
* Locate the chat directory for a herdr cursor session id.
|
|
140
|
-
*
|
|
141
|
-
* The id is the directory name; the project hash parent is found by scanning
|
|
142
|
-
* `~/.cursor/chats/<project>/<id>/` and matching `meta.json`'s `cwd` when the
|
|
143
|
-
* child's cwd is known (a worktree child must not bind to the parent
|
|
144
|
-
* checkout's chat).
|
|
145
|
-
*/
|
|
146
|
-
export function findCursorChatDir(
|
|
147
|
-
chatId: string,
|
|
148
|
-
opts: { cwd?: string; chatsRoot?: string } = {},
|
|
149
|
-
): string | null {
|
|
150
|
-
if (!chatId) return null;
|
|
151
|
-
const root = opts.chatsRoot ?? path.join(os.homedir(), ".cursor", "chats");
|
|
152
|
-
let entries: string[];
|
|
153
|
-
try {
|
|
154
|
-
entries = fs.readdirSync(root);
|
|
155
|
-
} catch {
|
|
156
|
-
return null;
|
|
157
|
-
}
|
|
158
|
-
const candidates: string[] = [];
|
|
159
|
-
for (const project of entries) {
|
|
160
|
-
const dir = path.join(root, project, chatId);
|
|
161
|
-
if (fs.existsSync(dir)) candidates.push(dir);
|
|
162
|
-
}
|
|
163
|
-
if (candidates.length === 0) return null;
|
|
164
|
-
if (candidates.length === 1) return candidates[0] ?? null;
|
|
165
|
-
// Several projects share the id (cursor reused it): prefer the one whose
|
|
166
|
-
// meta cwd matches the child's, else the newest.
|
|
167
|
-
if (opts.cwd) {
|
|
168
|
-
for (const dir of candidates) {
|
|
169
|
-
const meta = readMeta(path.join(dir, "meta.json"));
|
|
170
|
-
if (meta?.cwd && sameDir(meta.cwd, opts.cwd)) return dir;
|
|
171
|
-
}
|
|
172
|
-
}
|
|
173
|
-
return candidates.sort(
|
|
174
|
-
(a, b) => fs.statSync(b).mtimeMs - fs.statSync(a).mtimeMs,
|
|
175
|
-
)[0] ?? null;
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
function sameDir(a: string, b: string): boolean {
|
|
179
|
-
return path.resolve(a) === path.resolve(b);
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
function readMeta(file: string): {
|
|
183
|
-
schemaVersion?: number;
|
|
184
|
-
cwd?: string;
|
|
185
|
-
title?: string;
|
|
186
|
-
} | null {
|
|
187
|
-
try {
|
|
188
|
-
return JSON.parse(fs.readFileSync(file, "utf-8"));
|
|
189
|
-
} catch {
|
|
190
|
-
return null;
|
|
191
|
-
}
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
/**
|
|
195
|
-
* Parse a cursor chat store into the same `ParsedSession` shape the pi jsonl
|
|
196
|
-
* parser produces, so `collect()`'s main verdict/acceptance path applies
|
|
197
|
-
* unchanged.
|
|
198
|
-
*
|
|
199
|
-
* Empty session (unreadable/unsupported/turn not started) → `turns: []`, which
|
|
200
|
-
* the caller treats as "keep waiting", never as failure.
|
|
201
|
-
*/
|
|
202
|
-
export function parseCursorChat(chatDir: string): ParsedSession {
|
|
203
|
-
const dbPath = path.join(chatDir, "store.db");
|
|
204
|
-
if (!fs.existsSync(dbPath)) return emptyParsedSession();
|
|
205
|
-
|
|
206
|
-
const meta = readMeta(path.join(chatDir, "meta.json"));
|
|
207
|
-
if (
|
|
208
|
-
meta?.schemaVersion !== undefined &&
|
|
209
|
-
meta.schemaVersion !== SUPPORTED_SCHEMA_VERSION
|
|
210
|
-
) {
|
|
211
|
-
// Unknown future schema: refuse rather than mis-parse.
|
|
212
|
-
return emptyParsedSession();
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
const messages = readMessages(dbPath);
|
|
216
|
-
if (messages.length === 0) return emptyParsedSession();
|
|
217
|
-
return sessionFromMessages(messages);
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
/** Open the store read-only and pull the ordered user/assistant messages. */
|
|
221
|
-
function readMessages(dbPath: string): ChatMessage[] {
|
|
222
|
-
// readOnly keeps the child's WAL untouched; a missing db is an empty turn.
|
|
223
|
-
const open = sqliteOpener();
|
|
224
|
-
if (!open) return [];
|
|
225
|
-
let db: MinimalSqliteDb;
|
|
226
|
-
try {
|
|
227
|
-
db = open(dbPath);
|
|
228
|
-
} catch {
|
|
229
|
-
return [];
|
|
230
|
-
}
|
|
231
|
-
try {
|
|
232
|
-
const out: ChatMessage[] = [];
|
|
233
|
-
for (const row of db
|
|
234
|
-
.prepare("SELECT data FROM blobs ORDER BY rowid")
|
|
235
|
-
.iterate() as IterableIterator<{ data: unknown }>) {
|
|
236
|
-
const msg = parseMessageBlob(row.data);
|
|
237
|
-
if (msg) out.push(msg);
|
|
238
|
-
}
|
|
239
|
-
return out;
|
|
240
|
-
} catch {
|
|
241
|
-
return [];
|
|
242
|
-
} finally {
|
|
243
|
-
db.close();
|
|
244
|
-
}
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
function parseMessageBlob(data: unknown): ChatMessage | null {
|
|
248
|
-
if (typeof data !== "string" && !(data instanceof Uint8Array)) return null;
|
|
249
|
-
const text =
|
|
250
|
-
typeof data === "string" ? data : new TextDecoder().decode(data);
|
|
251
|
-
const trimmed = text.trimStart();
|
|
252
|
-
if (!trimmed.startsWith("{")) return null;
|
|
253
|
-
let parsed: unknown;
|
|
254
|
-
try {
|
|
255
|
-
parsed = JSON.parse(trimmed);
|
|
256
|
-
} catch {
|
|
257
|
-
return null;
|
|
258
|
-
}
|
|
259
|
-
if (typeof parsed !== "object" || parsed === null) return null;
|
|
260
|
-
const role = (parsed as { role?: unknown }).role;
|
|
261
|
-
if (role !== "user" && role !== "assistant") return null;
|
|
262
|
-
return { role, content: (parsed as { content?: unknown }).content };
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
function sessionFromMessages(messages: ChatMessage[]): ParsedSession {
|
|
266
|
-
const turns: TurnRecord[] = [];
|
|
267
|
-
let current: TurnRecord | null = null;
|
|
268
|
-
for (const msg of messages) {
|
|
269
|
-
if (msg.role === "user") {
|
|
270
|
-
// A user message starts a new turn. Skip injected non-task rows
|
|
271
|
-
// (cursor prepends <user_info>/<environment_context> payloads).
|
|
272
|
-
const text = textOf(msg.content);
|
|
273
|
-
if (turns.length === 0 && isInjectedUserRow(text)) continue;
|
|
274
|
-
const turn: TurnRecord = {
|
|
275
|
-
userText: text ?? "",
|
|
276
|
-
assistants: [],
|
|
277
|
-
toolResults: 0,
|
|
278
|
-
toolErrors: 0,
|
|
279
|
-
};
|
|
280
|
-
turns.push(turn);
|
|
281
|
-
current = turn;
|
|
282
|
-
continue;
|
|
283
|
-
}
|
|
284
|
-
// Assistant message: attach to the current turn, or an implicit turn
|
|
285
|
-
// when cursor wrote a reply without a task user row in the store.
|
|
286
|
-
if (!current) {
|
|
287
|
-
const turn: TurnRecord = {
|
|
288
|
-
userText: "",
|
|
289
|
-
assistants: [],
|
|
290
|
-
toolResults: 0,
|
|
291
|
-
toolErrors: 0,
|
|
292
|
-
};
|
|
293
|
-
turns.push(turn);
|
|
294
|
-
current = turn;
|
|
295
|
-
}
|
|
296
|
-
current.assistants.push({
|
|
297
|
-
stopReason: "stop",
|
|
298
|
-
text: textOf(msg.content),
|
|
299
|
-
errorMessage: null,
|
|
300
|
-
tools: [],
|
|
301
|
-
});
|
|
302
|
-
}
|
|
303
|
-
const last = turns.at(-1);
|
|
304
|
-
return {
|
|
305
|
-
...emptyParsedSession(),
|
|
306
|
-
turns,
|
|
307
|
-
output: last?.assistants.at(-1)?.text ?? "",
|
|
308
|
-
lastTurnOutput: last?.assistants.at(-1)?.text ?? null,
|
|
309
|
-
model: null,
|
|
310
|
-
};
|
|
311
|
-
}
|
|
312
|
-
|
|
313
|
-
function textOf(content: unknown): string | null {
|
|
314
|
-
if (typeof content === "string") return content;
|
|
315
|
-
if (!Array.isArray(content)) return null;
|
|
316
|
-
const texts: string[] = [];
|
|
317
|
-
for (const blk of content) {
|
|
318
|
-
if (
|
|
319
|
-
typeof blk === "object" &&
|
|
320
|
-
blk !== null &&
|
|
321
|
-
(blk as { type?: unknown }).type === "text"
|
|
322
|
-
) {
|
|
323
|
-
const t = (blk as { text?: unknown }).text;
|
|
324
|
-
if (typeof t === "string") texts.push(t);
|
|
325
|
-
}
|
|
326
|
-
}
|
|
327
|
-
return texts.length > 0 ? texts.join("\n\n") : null;
|
|
328
|
-
}
|
|
329
|
-
|
|
330
|
-
/**
|
|
331
|
-
* Injected context rows cursor writes before the task. They carry no user
|
|
332
|
-
* text and must not open a phantom turn.
|
|
333
|
-
*/
|
|
334
|
-
function isInjectedUserRow(text: string | null): boolean {
|
|
335
|
-
if (!text) return true;
|
|
336
|
-
return /^\s*<(?:user_info|environment_context|timestamp)\b/.test(text);
|
|
337
|
-
}
|
|
338
|
-
|
|
339
|
-
/**
|
|
340
|
-
* The turn-settled signal: the store's last message is an assistant reply with
|
|
341
|
-
* text. Measured across every live chat: rows are written atomically on turn
|
|
342
|
-
* completion, so a half-streamed answer never appears as a final row.
|
|
343
|
-
*/
|
|
344
|
-
export function cursorTurnSettled(parsed: ParsedSession): boolean {
|
|
345
|
-
const last = parsed.turns.at(-1);
|
|
346
|
-
if (!last || last.assistants.length === 0) return false;
|
|
347
|
-
return Boolean(last.assistants.at(-1)?.text);
|
|
348
|
-
}
|
|
@@ -1,101 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Command runner: spawns the `herdr` binary and captures stdout/stderr.
|
|
3
|
-
*
|
|
4
|
-
* Measured constraint (F21): herdr prints SUCCESS payloads on stdout but
|
|
5
|
-
* ERROR payloads on STDERR. Both must be parsed, or errors look like
|
|
6
|
-
* empty responses.
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
import { spawn } from "node:child_process";
|
|
10
|
-
import { createCappedOutput, resolveMaxOutputBytes } from "../../shared/output-cap.ts";
|
|
11
|
-
import type { CommandRunner } from "./types.ts";
|
|
12
|
-
|
|
13
|
-
const HERDR_BIN_ENV = "HERDR_BIN";
|
|
14
|
-
const HERDR_BIN_PATH_ENV = "HERDR_BIN_PATH";
|
|
15
|
-
|
|
16
|
-
export function resolveHerdrBin(env: NodeJS.ProcessEnv = process.env): string {
|
|
17
|
-
return env[HERDR_BIN_ENV] || env[HERDR_BIN_PATH_ENV] || "herdr";
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
export interface RunnerOptions {
|
|
21
|
-
bin?: string;
|
|
22
|
-
env?: NodeJS.ProcessEnv;
|
|
23
|
-
/** Default timeout applied when a call does not specify one. */
|
|
24
|
-
defaultTimeoutMs?: number;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
/**
|
|
28
|
-
* Create the default runner that shells out to herdr.
|
|
29
|
-
* Errors are surfaced as a non-zero `code` plus whatever text was produced;
|
|
30
|
-
* the caller (parseHerdrResponse) turns that into a structured HerdrError.
|
|
31
|
-
*/
|
|
32
|
-
export function createCommandRunner(
|
|
33
|
-
options: RunnerOptions = {},
|
|
34
|
-
): CommandRunner {
|
|
35
|
-
const bin = options.bin ?? resolveHerdrBin();
|
|
36
|
-
const baseEnv = options.env ?? process.env;
|
|
37
|
-
|
|
38
|
-
return (args, opts = {}) =>
|
|
39
|
-
new Promise((resolve) => {
|
|
40
|
-
let child: ReturnType<typeof spawn>;
|
|
41
|
-
try {
|
|
42
|
-
child = spawn(bin, args, {
|
|
43
|
-
shell: false,
|
|
44
|
-
windowsHide: true,
|
|
45
|
-
env: { ...baseEnv, ...(opts.env ?? {}) },
|
|
46
|
-
});
|
|
47
|
-
} catch (cause) {
|
|
48
|
-
resolve({ stdout: "", stderr: String(cause), code: -1 });
|
|
49
|
-
return;
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
const cap = resolveMaxOutputBytes(baseEnv);
|
|
53
|
-
const killOnOverflow = () => {
|
|
54
|
-
child.stdout?.destroy();
|
|
55
|
-
child.stderr?.destroy();
|
|
56
|
-
try {
|
|
57
|
-
child.kill("SIGKILL");
|
|
58
|
-
} catch {
|
|
59
|
-
/* already gone */
|
|
60
|
-
}
|
|
61
|
-
};
|
|
62
|
-
const out = createCappedOutput(cap, killOnOverflow);
|
|
63
|
-
const err = createCappedOutput(cap, killOnOverflow);
|
|
64
|
-
let extraStderr = "";
|
|
65
|
-
let settled = false;
|
|
66
|
-
const finish = (code: number) => {
|
|
67
|
-
if (settled) return;
|
|
68
|
-
settled = true;
|
|
69
|
-
if (timer) clearTimeout(timer);
|
|
70
|
-
const overflowed = out.truncated || err.truncated;
|
|
71
|
-
resolve({ stdout: out.text(), stderr: `${err.text()}${extraStderr}`, code: overflowed && code === 0 ? -3 : code });
|
|
72
|
-
};
|
|
73
|
-
|
|
74
|
-
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
75
|
-
const timeoutMs = opts.timeoutMs ?? options.defaultTimeoutMs;
|
|
76
|
-
if (timeoutMs && timeoutMs > 0) {
|
|
77
|
-
timer = setTimeout(() => {
|
|
78
|
-
try {
|
|
79
|
-
child.kill("SIGKILL");
|
|
80
|
-
} catch {
|
|
81
|
-
/* already gone */
|
|
82
|
-
}
|
|
83
|
-
extraStderr += `\n[timeout after ${timeoutMs}ms]`;
|
|
84
|
-
finish(-2);
|
|
85
|
-
}, timeoutMs);
|
|
86
|
-
timer.unref?.();
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
child.stdout?.on("data", (chunk) => {
|
|
90
|
-
out.push(chunk);
|
|
91
|
-
});
|
|
92
|
-
child.stderr?.on("data", (chunk) => {
|
|
93
|
-
err.push(chunk);
|
|
94
|
-
});
|
|
95
|
-
child.on("error", (e) => {
|
|
96
|
-
extraStderr += String(e);
|
|
97
|
-
finish(-1);
|
|
98
|
-
});
|
|
99
|
-
child.on("close", (code) => finish(code ?? 0));
|
|
100
|
-
});
|
|
101
|
-
}
|
|
@@ -1,138 +0,0 @@
|
|
|
1
|
-
import type { AgentKind } from "../../shared/types.ts";
|
|
2
|
-
|
|
3
|
-
/** @legacy Error envelope returned by the archived herdr CLI backend. */
|
|
4
|
-
export interface HerdrError {
|
|
5
|
-
code: string;
|
|
6
|
-
message: string;
|
|
7
|
-
details?: unknown;
|
|
8
|
-
}
|
|
9
|
-
|
|
10
|
-
/** @legacy Result envelope returned by the archived herdr CLI backend. */
|
|
11
|
-
export type HerdrResult<T> =
|
|
12
|
-
| { ok: true; value: T }
|
|
13
|
-
| { ok: false; error: HerdrError };
|
|
14
|
-
|
|
15
|
-
/** @legacy Herdr pane payload; RPC v2 does not create panes. */
|
|
16
|
-
export interface PaneInfo {
|
|
17
|
-
pane_id: string;
|
|
18
|
-
tab_id: string;
|
|
19
|
-
workspace_id: string;
|
|
20
|
-
agent_status?: string;
|
|
21
|
-
cwd?: string | null;
|
|
22
|
-
terminal_title_stripped?: string;
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
/** @legacy Herdr agent payload retained for archived clients. */
|
|
26
|
-
export interface AgentInfo {
|
|
27
|
-
name?: string | null;
|
|
28
|
-
pane_id: string;
|
|
29
|
-
tab_id: string;
|
|
30
|
-
workspace_id: string;
|
|
31
|
-
agent: string | null;
|
|
32
|
-
agent_status: string;
|
|
33
|
-
cwd?: string | null;
|
|
34
|
-
agent_session?: { kind: string; source: string; value: string } | null;
|
|
35
|
-
state_labels?: Record<string, string>;
|
|
36
|
-
tokens?: unknown;
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
/** @legacy Herdr tab payload retained for archived clients. */
|
|
40
|
-
export interface TabInfo {
|
|
41
|
-
tab_id: string;
|
|
42
|
-
workspace_id: string;
|
|
43
|
-
label?: string | null;
|
|
44
|
-
pane_count: number;
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
/** @legacy Herdr launch payload retained for archived clients. */
|
|
48
|
-
export interface AgentStartResult {
|
|
49
|
-
name: string;
|
|
50
|
-
paneId: string;
|
|
51
|
-
argv: string[];
|
|
52
|
-
sessionPath?: string;
|
|
53
|
-
agentStatus: string;
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
/** @legacy Herdr process-info payload retained for archived clients. */
|
|
57
|
-
export interface ProcessInfo {
|
|
58
|
-
foregroundProcesses: Array<{
|
|
59
|
-
argv: string[];
|
|
60
|
-
cmdline: string;
|
|
61
|
-
pid: number;
|
|
62
|
-
name: string;
|
|
63
|
-
cwd?: string;
|
|
64
|
-
}>;
|
|
65
|
-
shellPid?: number;
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
/** @legacy Injectable runner for the archived herdr CLI client. */
|
|
69
|
-
export type CommandRunner = (
|
|
70
|
-
args: string[],
|
|
71
|
-
opts?: { timeoutMs?: number; env?: Record<string, string | undefined> },
|
|
72
|
-
) => Promise<{ stdout: string; stderr: string; code: number }>;
|
|
73
|
-
|
|
74
|
-
/** @deprecated Archived v0.16.x backend contract; RPC v2 uses LegionSupervisor. */
|
|
75
|
-
export interface HerdrClient {
|
|
76
|
-
paneSplit(opts: {
|
|
77
|
-
target?: string;
|
|
78
|
-
current?: boolean;
|
|
79
|
-
direction: "right" | "down";
|
|
80
|
-
cwd?: string;
|
|
81
|
-
env?: Record<string, string>;
|
|
82
|
-
focus?: boolean;
|
|
83
|
-
}): Promise<HerdrResult<PaneInfo>>;
|
|
84
|
-
paneClose(paneId: string): Promise<HerdrResult<void>>;
|
|
85
|
-
paneRead(
|
|
86
|
-
paneId: string,
|
|
87
|
-
opts?: { source?: ReadSource; lines?: number },
|
|
88
|
-
): Promise<HerdrResult<string>>;
|
|
89
|
-
paneList(): Promise<HerdrResult<PaneInfo[]>>;
|
|
90
|
-
paneGet(paneId: string): Promise<HerdrResult<PaneInfo>>;
|
|
91
|
-
paneReportMetadata(opts: {
|
|
92
|
-
paneId: string;
|
|
93
|
-
source: string;
|
|
94
|
-
displayAgent?: string;
|
|
95
|
-
title?: string;
|
|
96
|
-
tokens?: Record<string, string>;
|
|
97
|
-
stateLabel?: { status: string; text: string };
|
|
98
|
-
ttlMs?: number;
|
|
99
|
-
clearStateLabels?: boolean;
|
|
100
|
-
}): Promise<HerdrResult<void>>;
|
|
101
|
-
tabCreate(opts: {
|
|
102
|
-
cwd?: string;
|
|
103
|
-
label?: string;
|
|
104
|
-
focus?: boolean;
|
|
105
|
-
env?: Record<string, string>;
|
|
106
|
-
workspaceId?: string;
|
|
107
|
-
}): Promise<HerdrResult<{ tab: TabInfo; rootPaneId: string }>>;
|
|
108
|
-
tabClose(tabId: string): Promise<HerdrResult<void>>;
|
|
109
|
-
tabList(workspaceId?: string): Promise<HerdrResult<TabInfo[]>>;
|
|
110
|
-
agentStart(opts: {
|
|
111
|
-
name: string;
|
|
112
|
-
kind: AgentKind;
|
|
113
|
-
paneId: string;
|
|
114
|
-
args?: string[];
|
|
115
|
-
timeoutMs?: number;
|
|
116
|
-
}): Promise<HerdrResult<AgentStartResult>>;
|
|
117
|
-
agentPrompt(
|
|
118
|
-
target: string,
|
|
119
|
-
text: string,
|
|
120
|
-
opts?: { wait?: boolean; timeoutMs?: number },
|
|
121
|
-
): Promise<HerdrResult<AgentInfo>>;
|
|
122
|
-
agentGet(target: string): Promise<HerdrResult<AgentInfo>>;
|
|
123
|
-
agentList(): Promise<HerdrResult<AgentInfo[]>>;
|
|
124
|
-
agentSendKeys(target: string, ...keys: string[]): Promise<HerdrResult<void>>;
|
|
125
|
-
agentWait(
|
|
126
|
-
target: string,
|
|
127
|
-
opts?: { until?: string[]; timeoutMs?: number },
|
|
128
|
-
): Promise<HerdrResult<void>>;
|
|
129
|
-
available(): Promise<boolean>;
|
|
130
|
-
integrationStatus(target: string): Promise<HerdrResult<string | null>>;
|
|
131
|
-
integrationInstall(target: string): Promise<HerdrResult<string>>;
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
export type ReadSource =
|
|
135
|
-
| "visible"
|
|
136
|
-
| "recent"
|
|
137
|
-
| "recent-unwrapped"
|
|
138
|
-
| "detection";
|