@cr1ms0n/pi-subagent 0.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +352 -0
- package/LICENSE +21 -0
- package/README.md +543 -0
- package/docs/ARCHITECTURE.md +125 -0
- package/docs/COST-ACCOUNTING.md +66 -0
- package/docs/PLAN.md +325 -0
- package/docs/RELEASING.md +32 -0
- package/docs/ROADMAP.md +252 -0
- package/docs/SECURITY.md +85 -0
- package/docs/UI-OVERHAUL.md +186 -0
- package/docs/UX.md +141 -0
- package/extensions/subagent.ts +1 -0
- package/package.json +58 -0
- package/skills/subagent/SKILL.md +103 -0
- package/src/agents.ts +285 -0
- package/src/backend.ts +146 -0
- package/src/backends/claude.ts +384 -0
- package/src/backends/codex.ts +330 -0
- package/src/backends/index.ts +26 -0
- package/src/backends/pi.ts +94 -0
- package/src/btw.ts +34 -0
- package/src/config.ts +254 -0
- package/src/distill.ts +222 -0
- package/src/extension.ts +1527 -0
- package/src/format.ts +365 -0
- package/src/index.ts +60 -0
- package/src/launch.ts +120 -0
- package/src/maintenance.ts +6 -0
- package/src/model-policy.ts +157 -0
- package/src/notifications.ts +106 -0
- package/src/orchestrator.ts +247 -0
- package/src/output.ts +124 -0
- package/src/persistence.ts +334 -0
- package/src/policy.ts +500 -0
- package/src/process-lock.ts +687 -0
- package/src/protocol.ts +290 -0
- package/src/registry.ts +632 -0
- package/src/runner.ts +850 -0
- package/src/schema.ts +166 -0
- package/src/semaphore.ts +123 -0
- package/src/structured.ts +169 -0
- package/src/transcript.ts +360 -0
- package/src/types.ts +197 -0
- package/src/ui.ts +545 -0
- package/src/usage.ts +274 -0
- package/src/worktree.ts +753 -0
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as os from "node:os";
|
|
3
|
+
import * as path from "node:path";
|
|
4
|
+
import { oneLine } from "./format.js";
|
|
5
|
+
|
|
6
|
+
/** Distinguishable tail outcomes for the live transcript view. */
|
|
7
|
+
export type TailSessionStatus = "missing" | "empty" | "ok";
|
|
8
|
+
|
|
9
|
+
export interface TailSessionResult {
|
|
10
|
+
status: TailSessionStatus;
|
|
11
|
+
lines: string[];
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const DEFAULT_MAX_LINES = 80;
|
|
15
|
+
/** Bound the end-window so we never slurp large session files. */
|
|
16
|
+
const DEFAULT_MAX_BYTES = 64 * 1024;
|
|
17
|
+
const TEXT_PREVIEW = 160;
|
|
18
|
+
const ARG_PREVIEW = 80;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Resolve `sessionDir/<…sessionId….jsonl>` the same way the retention sweep
|
|
22
|
+
* matches files: basename without extension equals or contains the session id.
|
|
23
|
+
* Prefers an exact `{id}.jsonl` match; otherwise the newest mtime include-match.
|
|
24
|
+
*/
|
|
25
|
+
export function resolveSessionFilePath(sessionDir: string, sessionId: string): string | undefined {
|
|
26
|
+
if (!sessionDir || !sessionId) return undefined;
|
|
27
|
+
const exact = path.join(sessionDir, `${sessionId}.jsonl`);
|
|
28
|
+
try {
|
|
29
|
+
if (fs.statSync(exact).isFile()) return exact;
|
|
30
|
+
} catch {
|
|
31
|
+
/* fall through to directory scan */
|
|
32
|
+
}
|
|
33
|
+
let entries: string[];
|
|
34
|
+
try {
|
|
35
|
+
entries = fs.readdirSync(sessionDir);
|
|
36
|
+
} catch {
|
|
37
|
+
return undefined;
|
|
38
|
+
}
|
|
39
|
+
let best: { file: string; mtimeMs: number } | undefined;
|
|
40
|
+
for (const name of entries) {
|
|
41
|
+
if (!name.endsWith(".jsonl")) continue;
|
|
42
|
+
const base = name.slice(0, -".jsonl".length);
|
|
43
|
+
if (base !== sessionId && !base.includes(sessionId)) continue;
|
|
44
|
+
const file = path.join(sessionDir, name);
|
|
45
|
+
let mtimeMs = 0;
|
|
46
|
+
try {
|
|
47
|
+
mtimeMs = fs.statSync(file).mtimeMs;
|
|
48
|
+
} catch {
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
if (!best || mtimeMs > best.mtimeMs) best = { file, mtimeMs };
|
|
52
|
+
}
|
|
53
|
+
return best?.file;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Resolve a child transcript file for non-pi backends.
|
|
59
|
+
*
|
|
60
|
+
* The vendor CLIs write their own JSONL transcripts in fixed locations:
|
|
61
|
+
* codex → $CODEX_HOME/sessions/YYYY/MM/DD/rollout-<ts>-<thread_id>.jsonl
|
|
62
|
+
* claude → ~/.claude/projects/<slugified-cwd>/<session_id>.jsonl
|
|
63
|
+
*
|
|
64
|
+
* We locate by session id rather than reconstructing the timestamp/slug, so a
|
|
65
|
+
* layout tweak degrades to "not found" instead of showing the wrong run.
|
|
66
|
+
* Returns undefined when nothing matches, which the UI reports honestly.
|
|
67
|
+
*/
|
|
68
|
+
export function resolveBackendSessionFilePath(
|
|
69
|
+
backend: "pi" | "codex" | "claude",
|
|
70
|
+
sessionId: string,
|
|
71
|
+
options: { sessionDir?: string; home?: string; cwd?: string } = {},
|
|
72
|
+
): string | undefined {
|
|
73
|
+
if (!sessionId) return undefined;
|
|
74
|
+
if (backend === "pi") {
|
|
75
|
+
return options.sessionDir ? resolveSessionFilePath(options.sessionDir, sessionId) : undefined;
|
|
76
|
+
}
|
|
77
|
+
const home = options.home ?? os.homedir();
|
|
78
|
+
if (backend === "codex") {
|
|
79
|
+
const root = process.env.CODEX_HOME
|
|
80
|
+
? path.join(process.env.CODEX_HOME, "sessions")
|
|
81
|
+
: path.join(home, ".codex", "sessions");
|
|
82
|
+
return findByIdRecursive(root, sessionId, 4);
|
|
83
|
+
}
|
|
84
|
+
// claude: one directory per project, named from the cwd with separators
|
|
85
|
+
// replaced by dashes (e.g. /private/tmp/x → -private-tmp-x).
|
|
86
|
+
const projects = path.join(home, ".claude", "projects");
|
|
87
|
+
const cwd = options.cwd ?? process.cwd();
|
|
88
|
+
const slug = cwd.replace(/[/\\]/g, "-");
|
|
89
|
+
const direct = path.join(projects, slug, `${sessionId}.jsonl`);
|
|
90
|
+
try {
|
|
91
|
+
if (fs.statSync(direct).isFile()) return direct;
|
|
92
|
+
} catch {
|
|
93
|
+
/* fall through to a bounded scan */
|
|
94
|
+
}
|
|
95
|
+
return findByIdRecursive(projects, sessionId, 2);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Bounded breadth-limited search for `*<sessionId>*.jsonl`. Depth-capped so a
|
|
100
|
+
* deep or hostile tree cannot turn a UI refresh into a filesystem walk.
|
|
101
|
+
*/
|
|
102
|
+
function findByIdRecursive(root: string, sessionId: string, maxDepth: number): string | undefined {
|
|
103
|
+
const queue: Array<{ dir: string; depth: number }> = [{ dir: root, depth: 0 }];
|
|
104
|
+
let best: { file: string; mtimeMs: number } | undefined;
|
|
105
|
+
let visited = 0;
|
|
106
|
+
while (queue.length) {
|
|
107
|
+
const { dir, depth } = queue.shift()!;
|
|
108
|
+
if (depth > maxDepth || visited > 512) break;
|
|
109
|
+
visited++;
|
|
110
|
+
let entries: fs.Dirent[];
|
|
111
|
+
try {
|
|
112
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
113
|
+
} catch {
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
for (const entry of entries) {
|
|
117
|
+
const full = path.join(dir, entry.name);
|
|
118
|
+
if (entry.isDirectory()) {
|
|
119
|
+
if (depth < maxDepth) queue.push({ dir: full, depth: depth + 1 });
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
if (!entry.name.endsWith(".jsonl") || !entry.name.includes(sessionId)) continue;
|
|
123
|
+
let mtimeMs = 0;
|
|
124
|
+
try {
|
|
125
|
+
mtimeMs = fs.statSync(full).mtimeMs;
|
|
126
|
+
} catch {
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
if (!best || mtimeMs > best.mtimeMs) best = { file: full, mtimeMs };
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return best?.file;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Read the last `maxLines` compact lines from a session `.jsonl` file.
|
|
137
|
+
* Uses a bounded byte window from the end (never slurps unbounded files),
|
|
138
|
+
* skips unparseable lines, and ignores a partial leading line when reading
|
|
139
|
+
* mid-file. Missing file → `{ status: "missing" }`; empty/no-renderable
|
|
140
|
+
* content → `{ status: "empty" }`.
|
|
141
|
+
*/
|
|
142
|
+
export function tailSessionFile(
|
|
143
|
+
filePath: string,
|
|
144
|
+
maxLines: number = DEFAULT_MAX_LINES,
|
|
145
|
+
maxBytes: number = DEFAULT_MAX_BYTES,
|
|
146
|
+
render: (raw: string) => string | null = renderSessionLine,
|
|
147
|
+
): TailSessionResult {
|
|
148
|
+
if (!filePath) return { status: "missing", lines: [] };
|
|
149
|
+
|
|
150
|
+
let fd: number;
|
|
151
|
+
try {
|
|
152
|
+
fd = fs.openSync(filePath, "r");
|
|
153
|
+
} catch (error: unknown) {
|
|
154
|
+
if (isErrno(error) && error.code === "ENOENT") return { status: "missing", lines: [] };
|
|
155
|
+
return { status: "missing", lines: [] };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
try {
|
|
159
|
+
const stat = fs.fstatSync(fd);
|
|
160
|
+
if (!stat.size) return { status: "empty", lines: [] };
|
|
161
|
+
|
|
162
|
+
const window = Math.min(stat.size, Math.max(1, maxBytes));
|
|
163
|
+
const start = Math.max(0, stat.size - window);
|
|
164
|
+
const buf = Buffer.alloc(window);
|
|
165
|
+
const bytesRead = fs.readSync(fd, buf, 0, window, start);
|
|
166
|
+
const text = buf.toString("utf8", 0, bytesRead);
|
|
167
|
+
|
|
168
|
+
// Drop incomplete leading fragment when the window starts mid-line.
|
|
169
|
+
let body = text;
|
|
170
|
+
if (start > 0) {
|
|
171
|
+
const firstNl = body.indexOf("\n");
|
|
172
|
+
if (firstNl === -1) return { status: "empty", lines: [] };
|
|
173
|
+
body = body.slice(firstNl + 1);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const rawLines = body.split("\n");
|
|
177
|
+
// Trailing partial line (no final newline) is still attempted; unparseable → skip.
|
|
178
|
+
const rendered: string[] = [];
|
|
179
|
+
for (const raw of rawLines) {
|
|
180
|
+
const compact = render(raw);
|
|
181
|
+
if (compact === null) continue;
|
|
182
|
+
// Assistant messages may expand to multiple compact lines (text + tools).
|
|
183
|
+
for (const line of compact.split("\n")) {
|
|
184
|
+
if (line) rendered.push(line);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const lines = rendered.length > maxLines ? rendered.slice(rendered.length - maxLines) : rendered;
|
|
189
|
+
if (!lines.length) return { status: "empty", lines: [] };
|
|
190
|
+
return { status: "ok", lines };
|
|
191
|
+
} finally {
|
|
192
|
+
try {
|
|
193
|
+
fs.closeSync(fd);
|
|
194
|
+
} catch {
|
|
195
|
+
/* ignore */
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function isErrno(error: unknown): error is NodeJS.ErrnoException {
|
|
201
|
+
return typeof error === "object" && error !== null && "code" in error;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Parse one JSONL session entry into compact display line(s), or null to skip. */
|
|
205
|
+
export function renderSessionLine(raw: string): string | null {
|
|
206
|
+
const trimmed = raw.trim();
|
|
207
|
+
if (!trimmed) return null;
|
|
208
|
+
let entry: unknown;
|
|
209
|
+
try {
|
|
210
|
+
entry = JSON.parse(trimmed);
|
|
211
|
+
} catch {
|
|
212
|
+
return null;
|
|
213
|
+
}
|
|
214
|
+
if (!entry || typeof entry !== "object") return null;
|
|
215
|
+
|
|
216
|
+
const rec = entry as Record<string, unknown>;
|
|
217
|
+
if (rec.type === "message" && rec.message && typeof rec.message === "object") {
|
|
218
|
+
return renderMessage(rec.message as Record<string, unknown>);
|
|
219
|
+
}
|
|
220
|
+
if (rec.type === "custom_message" && rec.display) {
|
|
221
|
+
const text = contentText(rec.content);
|
|
222
|
+
return text ? `note: ${oneLine(text, TEXT_PREVIEW)}` : null;
|
|
223
|
+
}
|
|
224
|
+
// Header / model changes / thinking / compaction / labels: not conversation.
|
|
225
|
+
return null;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function renderMessage(message: Record<string, unknown>): string | null {
|
|
229
|
+
const role = message.role;
|
|
230
|
+
if (role === "user") {
|
|
231
|
+
const text = contentText(message.content);
|
|
232
|
+
return text ? `user: ${oneLine(text, TEXT_PREVIEW)}` : null;
|
|
233
|
+
}
|
|
234
|
+
if (role === "assistant") {
|
|
235
|
+
if (!Array.isArray(message.content)) return null;
|
|
236
|
+
const parts: string[] = [];
|
|
237
|
+
for (const part of message.content) {
|
|
238
|
+
if (!part || typeof part !== "object") continue;
|
|
239
|
+
const p = part as Record<string, unknown>;
|
|
240
|
+
if (p.type === "text" && typeof p.text === "string" && p.text) {
|
|
241
|
+
parts.push(`assistant: ${oneLine(p.text, TEXT_PREVIEW)}`);
|
|
242
|
+
} else if (p.type === "toolCall") {
|
|
243
|
+
const name = typeof p.name === "string" ? p.name : "tool";
|
|
244
|
+
const args = p.arguments !== undefined ? oneLine(JSON.stringify(p.arguments), ARG_PREVIEW) : "";
|
|
245
|
+
parts.push(args ? `→ ${name} ${args}` : `→ ${name}`);
|
|
246
|
+
}
|
|
247
|
+
// skip thinking/reasoning blobs for compact live tail
|
|
248
|
+
}
|
|
249
|
+
return parts.length ? parts.join("\n") : null;
|
|
250
|
+
}
|
|
251
|
+
if (role === "toolResult") {
|
|
252
|
+
const name = typeof message.toolName === "string" ? message.toolName : "tool";
|
|
253
|
+
const text = contentText(message.content);
|
|
254
|
+
const err = message.isError ? " [error]" : "";
|
|
255
|
+
return text ? `← ${name}${err} ${oneLine(text, TEXT_PREVIEW)}` : `← ${name}${err}`;
|
|
256
|
+
}
|
|
257
|
+
return null;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function contentText(content: unknown): string {
|
|
261
|
+
if (typeof content === "string") return content;
|
|
262
|
+
if (!Array.isArray(content)) return "";
|
|
263
|
+
return content
|
|
264
|
+
.filter((part): part is { type: "text"; text: string } => {
|
|
265
|
+
if (!part || typeof part !== "object") return false;
|
|
266
|
+
const p = part as Record<string, unknown>;
|
|
267
|
+
return p.type === "text" && typeof p.text === "string";
|
|
268
|
+
})
|
|
269
|
+
.map((part) => part.text)
|
|
270
|
+
.join("");
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Codex rollout renderer. Codex writes `{type,payload}` envelopes; the
|
|
275
|
+
* conversation lives in `response_item` messages and `event_msg` tool events.
|
|
276
|
+
* Developer/permissions preamble is skipped: it is boilerplate, not content.
|
|
277
|
+
*/
|
|
278
|
+
export function renderCodexSessionLine(raw: string): string | null {
|
|
279
|
+
const trimmed = raw.trim();
|
|
280
|
+
if (!trimmed) return null;
|
|
281
|
+
let entry: any;
|
|
282
|
+
try {
|
|
283
|
+
entry = JSON.parse(trimmed);
|
|
284
|
+
} catch {
|
|
285
|
+
return null;
|
|
286
|
+
}
|
|
287
|
+
const payload = entry?.payload;
|
|
288
|
+
if (!payload || typeof payload !== "object") return null;
|
|
289
|
+
if (entry.type === "response_item" && payload.type === "message") {
|
|
290
|
+
// Skip the injected sandbox/permissions preamble.
|
|
291
|
+
if (payload.role === "developer") return null;
|
|
292
|
+
const text = codexText(payload.content);
|
|
293
|
+
if (!text) return null;
|
|
294
|
+
const role = payload.role === "assistant" ? "assistant" : payload.role === "user" ? "user" : String(payload.role ?? "?");
|
|
295
|
+
return `${role}: ${oneLine(text, TEXT_PREVIEW)}`;
|
|
296
|
+
}
|
|
297
|
+
if (entry.type === "response_item" && (payload.type === "function_call" || payload.type === "local_shell_call")) {
|
|
298
|
+
const name = payload.name ?? "shell";
|
|
299
|
+
const args = typeof payload.arguments === "string" ? payload.arguments : JSON.stringify(payload.action ?? {});
|
|
300
|
+
return `tool ${name}(${oneLine(args, ARG_PREVIEW)})`;
|
|
301
|
+
}
|
|
302
|
+
if (entry.type === "event_msg" && payload.type === "agent_reasoning") {
|
|
303
|
+
const text = typeof payload.text === "string" ? payload.text : "";
|
|
304
|
+
return text ? `thinking: ${oneLine(text, TEXT_PREVIEW)}` : null;
|
|
305
|
+
}
|
|
306
|
+
return null;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function codexText(content: unknown): string {
|
|
310
|
+
if (typeof content === "string") return content;
|
|
311
|
+
if (!Array.isArray(content)) return "";
|
|
312
|
+
return content
|
|
313
|
+
.map((part: any) =>
|
|
314
|
+
typeof part?.text === "string" && (part.type === "input_text" || part.type === "output_text" || part.type === "text")
|
|
315
|
+
? part.text
|
|
316
|
+
: "",
|
|
317
|
+
)
|
|
318
|
+
.filter(Boolean)
|
|
319
|
+
.join("");
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* Claude Code transcript renderer. Claude writes `{type:"assistant"|"user",
|
|
324
|
+
* message:{...}}` plus bookkeeping entries (queue-operation, hooks) that are
|
|
325
|
+
* not conversation.
|
|
326
|
+
*/
|
|
327
|
+
export function renderClaudeSessionLine(raw: string): string | null {
|
|
328
|
+
const trimmed = raw.trim();
|
|
329
|
+
if (!trimmed) return null;
|
|
330
|
+
let entry: any;
|
|
331
|
+
try {
|
|
332
|
+
entry = JSON.parse(trimmed);
|
|
333
|
+
} catch {
|
|
334
|
+
return null;
|
|
335
|
+
}
|
|
336
|
+
if (entry?.type === "assistant" || entry?.type === "user") {
|
|
337
|
+
const content = entry.message?.content;
|
|
338
|
+
if (Array.isArray(content)) {
|
|
339
|
+
const parts: string[] = [];
|
|
340
|
+
for (const part of content) {
|
|
341
|
+
if (part?.type === "text" && typeof part.text === "string" && part.text.trim()) {
|
|
342
|
+
parts.push(`${entry.type}: ${oneLine(part.text, TEXT_PREVIEW)}`);
|
|
343
|
+
} else if (part?.type === "tool_use") {
|
|
344
|
+
parts.push(`tool ${part.name ?? "?"}(${oneLine(JSON.stringify(part.input ?? {}), ARG_PREVIEW)})`);
|
|
345
|
+
} else if (part?.type === "tool_result") {
|
|
346
|
+
const text = typeof part.content === "string" ? part.content : JSON.stringify(part.content ?? "");
|
|
347
|
+
parts.push(` -> ${oneLine(text, ARG_PREVIEW)}`);
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
return parts.length ? parts.join("\n") : null;
|
|
351
|
+
}
|
|
352
|
+
if (typeof content === "string" && content.trim()) return `${entry.type}: ${oneLine(content, TEXT_PREVIEW)}`;
|
|
353
|
+
}
|
|
354
|
+
return null;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/** Renderer for a backend's own transcript dialect. */
|
|
358
|
+
export function sessionLineRenderer(backend: "pi" | "codex" | "claude"): (raw: string) => string | null {
|
|
359
|
+
return backend === "codex" ? renderCodexSessionLine : backend === "claude" ? renderClaudeSessionLine : renderSessionLine;
|
|
360
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import type { Message } from "@earendil-works/pi-ai";
|
|
2
|
+
|
|
3
|
+
export type RunMode = "single" | "parallel";
|
|
4
|
+
export type RunState = "queued" | "running" | "completed" | "partial" | "failed" | "cancelled" | "lost" | "timeout";
|
|
5
|
+
/** Distinct timeout phases so agents can retry queue pressure without "fixing" unfinished work. */
|
|
6
|
+
export type TimeoutPhase = "queued" | "starting" | "running" | "cancelling";
|
|
7
|
+
export type TaskProfile = "explore" | "review" | "general";
|
|
8
|
+
export type OutputMode = "inline" | "file-only";
|
|
9
|
+
|
|
10
|
+
/** Durable identity of a spawned child process for orphan reconcile. */
|
|
11
|
+
export interface ChildProcessIdentity {
|
|
12
|
+
pid: number;
|
|
13
|
+
/** Platform-specific process start identity; 0 when unknown. */
|
|
14
|
+
startTime: number;
|
|
15
|
+
pgid?: number;
|
|
16
|
+
hostname?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface UsageStats {
|
|
20
|
+
input: number;
|
|
21
|
+
output: number;
|
|
22
|
+
cacheRead: number;
|
|
23
|
+
cacheWrite: number;
|
|
24
|
+
/** Reasoning is a subset of output when providers report it. */
|
|
25
|
+
reasoning?: number;
|
|
26
|
+
/** Provider-reported total cost. */
|
|
27
|
+
cost: number;
|
|
28
|
+
costInput?: number;
|
|
29
|
+
costOutput?: number;
|
|
30
|
+
costCacheRead?: number;
|
|
31
|
+
costCacheWrite?: number;
|
|
32
|
+
/** Most recent turn's context size; not additive across turns. */
|
|
33
|
+
contextTokens: number;
|
|
34
|
+
turns: number;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export type BackendName = "pi" | "codex" | "claude";
|
|
38
|
+
|
|
39
|
+
export interface TaskSpec {
|
|
40
|
+
/** Agent CLI powering this child. Defaults to "pi". */
|
|
41
|
+
backend?: BackendName;
|
|
42
|
+
task: string;
|
|
43
|
+
/** Short human label shown in UIs and result indexes. */
|
|
44
|
+
label?: string;
|
|
45
|
+
systemPrompt?: string;
|
|
46
|
+
model?: string;
|
|
47
|
+
thinking?: "off" | "minimal" | "low" | "medium" | "high" | "xhigh";
|
|
48
|
+
tools?: string[];
|
|
49
|
+
profile: TaskProfile;
|
|
50
|
+
canWrite?: boolean;
|
|
51
|
+
cwd?: string;
|
|
52
|
+
timeoutMs: number;
|
|
53
|
+
maxTurns?: number;
|
|
54
|
+
maxCost?: number;
|
|
55
|
+
output?: string;
|
|
56
|
+
outputMode?: OutputMode;
|
|
57
|
+
resume?: string;
|
|
58
|
+
forkResume?: boolean;
|
|
59
|
+
isolation?: "shared" | "worktree";
|
|
60
|
+
allowSharedWrites?: boolean;
|
|
61
|
+
/** Seed worktree with parent checkout WIP (worktree isolation only). */
|
|
62
|
+
includeWip?: boolean;
|
|
63
|
+
/** Opt out of process-tree reaping after a clean exit (e.g. child-started dev servers). */
|
|
64
|
+
keepBackground?: boolean;
|
|
65
|
+
/** Wrap-up grace turns after a max_turns/max_cost breach before SIGTERM. 0 = immediate stop. */
|
|
66
|
+
graceTurns?: number;
|
|
67
|
+
/** Ordered backup models tried on transient provider failures. */
|
|
68
|
+
fallbackModels?: string[];
|
|
69
|
+
/** Extra attempts on transient failures (queue timeout, stall, provider error). */
|
|
70
|
+
maxRetries?: number;
|
|
71
|
+
/** Fork the parent conversation into the child (real branched session). */
|
|
72
|
+
contextFork?: boolean;
|
|
73
|
+
/** Parent session file used for contextFork. */
|
|
74
|
+
parentSessionFile?: string;
|
|
75
|
+
/** What this child may itself spawn; encoded into PI_SUBAGENT_SPAWNS. */
|
|
76
|
+
spawns?: false | "*" | string[];
|
|
77
|
+
/** JSON-Schema subset the child's final fenced json:result block must satisfy. */
|
|
78
|
+
outputSchema?: Record<string, unknown>;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export interface TaskResult {
|
|
82
|
+
label: string;
|
|
83
|
+
task: string;
|
|
84
|
+
state: RunState;
|
|
85
|
+
exitCode: number | null;
|
|
86
|
+
signal?: NodeJS.Signals;
|
|
87
|
+
messages: Message[];
|
|
88
|
+
stderr: string;
|
|
89
|
+
usage: UsageStats;
|
|
90
|
+
model?: string;
|
|
91
|
+
thinking?: TaskSpec["thinking"];
|
|
92
|
+
profile?: TaskProfile;
|
|
93
|
+
/** Backend that produced this result (pi | codex | claude). */
|
|
94
|
+
backend?: BackendName;
|
|
95
|
+
canWrite?: boolean;
|
|
96
|
+
stopReason?: string;
|
|
97
|
+
/** Present when stopReason is a timeout-like outcome. */
|
|
98
|
+
timeoutPhase?: TimeoutPhase;
|
|
99
|
+
errorMessage?: string;
|
|
100
|
+
index?: number;
|
|
101
|
+
outputFile?: string;
|
|
102
|
+
outputMode?: OutputMode;
|
|
103
|
+
worktree?: { cwd: string; branch: string; baseCommit: string; changed: boolean; diffSummary?: string };
|
|
104
|
+
sessionId?: string;
|
|
105
|
+
/** Child process identity (persisted for orphan reclaim). */
|
|
106
|
+
process?: ChildProcessIdentity;
|
|
107
|
+
startedAt?: number;
|
|
108
|
+
/** When the semaphore slot was acquired (runtime clock starts here). */
|
|
109
|
+
acquiredAt?: number;
|
|
110
|
+
endedAt?: number;
|
|
111
|
+
liveText?: string;
|
|
112
|
+
/** Incrementally-built compact transcript (assistant text, tool calls, tool results). */
|
|
113
|
+
transcript?: string;
|
|
114
|
+
/** True when a budget-stopped child wrapped up gracefully in its grace turns. */
|
|
115
|
+
wrappedUp?: boolean;
|
|
116
|
+
/** Set while no protocol activity has been seen for the stall window. */
|
|
117
|
+
stalledSince?: number;
|
|
118
|
+
/** Total attempts including retries (present when > 1). */
|
|
119
|
+
attempts?: number;
|
|
120
|
+
/** Models tried across attempts, in order. */
|
|
121
|
+
attemptedModels?: string[];
|
|
122
|
+
/** Parsed structured result when output_schema was requested and validated. */
|
|
123
|
+
structuredOutput?: unknown;
|
|
124
|
+
/** Validation errors when output_schema was requested but the result failed. */
|
|
125
|
+
structuredError?: string;
|
|
126
|
+
protocol: {
|
|
127
|
+
headerSeen: boolean;
|
|
128
|
+
assistantEndSeen: boolean;
|
|
129
|
+
agentEndSeen: boolean;
|
|
130
|
+
agentSettledSeen: boolean;
|
|
131
|
+
validEvents: number;
|
|
132
|
+
parseErrors: number;
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export interface RunSnapshot {
|
|
137
|
+
schemaVersion: 1;
|
|
138
|
+
id: string;
|
|
139
|
+
sessionKey: string;
|
|
140
|
+
mode: RunMode;
|
|
141
|
+
state: RunState;
|
|
142
|
+
startedAt: number;
|
|
143
|
+
endedAt?: number;
|
|
144
|
+
taskPreviews: string[];
|
|
145
|
+
summary?: string;
|
|
146
|
+
delivered: boolean;
|
|
147
|
+
/** True when a previous owner was killed on reconcile; resume must not auto-reopen. */
|
|
148
|
+
resumeBlocked?: boolean;
|
|
149
|
+
results: Array<{
|
|
150
|
+
label: string;
|
|
151
|
+
task: string;
|
|
152
|
+
state: RunState;
|
|
153
|
+
exitCode: number | null;
|
|
154
|
+
stopReason?: string;
|
|
155
|
+
timeoutPhase?: TimeoutPhase;
|
|
156
|
+
errorMessage?: string;
|
|
157
|
+
usage: UsageStats;
|
|
158
|
+
model?: string;
|
|
159
|
+
thinking?: TaskSpec["thinking"];
|
|
160
|
+
profile?: TaskProfile;
|
|
161
|
+
canWrite?: boolean;
|
|
162
|
+
outputFile?: string;
|
|
163
|
+
backend?: BackendName;
|
|
164
|
+
outputMode?: OutputMode;
|
|
165
|
+
worktree?: { cwd: string; branch: string; baseCommit: string; changed: boolean; diffSummary?: string };
|
|
166
|
+
sessionId?: string;
|
|
167
|
+
process?: ChildProcessIdentity;
|
|
168
|
+
finalOutput?: string;
|
|
169
|
+
transcript?: string;
|
|
170
|
+
wrappedUp?: boolean;
|
|
171
|
+
stalledSince?: number;
|
|
172
|
+
attempts?: number;
|
|
173
|
+
attemptedModels?: string[];
|
|
174
|
+
structuredOutput?: unknown;
|
|
175
|
+
structuredError?: string;
|
|
176
|
+
}>;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export interface ToolDetails {
|
|
180
|
+
mode: RunMode;
|
|
181
|
+
results: TaskResult[];
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export const emptyUsage = (): UsageStats => ({
|
|
185
|
+
input: 0,
|
|
186
|
+
output: 0,
|
|
187
|
+
cacheRead: 0,
|
|
188
|
+
cacheWrite: 0,
|
|
189
|
+
reasoning: 0,
|
|
190
|
+
cost: 0,
|
|
191
|
+
costInput: 0,
|
|
192
|
+
costOutput: 0,
|
|
193
|
+
costCacheRead: 0,
|
|
194
|
+
costCacheWrite: 0,
|
|
195
|
+
contextTokens: 0,
|
|
196
|
+
turns: 0,
|
|
197
|
+
});
|