@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,384 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Claude Code backend — spawns the `claude` CLI in
|
|
3
|
+
* `--print --output-format stream-json` mode and translates its event stream
|
|
4
|
+
* into our normalized `ProtocolUpdate` shape.
|
|
5
|
+
*
|
|
6
|
+
* **Why the CLI and not `@anthropic-ai/claude-agent-sdk`:** the reference
|
|
7
|
+
* implementation we ported from drives the SDK in-process, which would add a
|
|
8
|
+
* heavy hard dependency and put a second agent runtime inside the parent. The
|
|
9
|
+
* CLI exposes everything we need (`--output-format stream-json`,
|
|
10
|
+
* `--allowedTools`, `--append-system-prompt`, `--json-schema`, `--resume`,
|
|
11
|
+
* `--fork-session`) and keeps the process-per-child model that all of our
|
|
12
|
+
* safety machinery is built around. Zero new dependencies.
|
|
13
|
+
*
|
|
14
|
+
* Event vocabulary (captured from claude-code 2.1.219):
|
|
15
|
+
*
|
|
16
|
+
* {"type":"system","subtype":"init","session_id":"…","tools":[…],"model":"…"}
|
|
17
|
+
* {"type":"assistant","message":{"role":"assistant","content":[…],"usage":{…}}}
|
|
18
|
+
* {"type":"user","message":{…}} // tool results
|
|
19
|
+
* {"type":"result","subtype":"success","total_cost_usd":0.01,"usage":{…},
|
|
20
|
+
* "num_turns":3,"result":"final text","is_error":false}
|
|
21
|
+
*
|
|
22
|
+
* Capability notes:
|
|
23
|
+
* - **Cost IS reported** (`total_cost_usd` on the result event), so `max_cost`
|
|
24
|
+
* is honored. It arrives once at the end rather than per turn, so the budget
|
|
25
|
+
* check fires on the terminal event — enforcement is real but coarse.
|
|
26
|
+
* - **Tool restriction** via `--allowedTools`, so explore/review map cleanly.
|
|
27
|
+
* - **No mid-run steering** in one-shot print mode (streaming stdin input is
|
|
28
|
+
* possible but adds a second protocol; deliberately out of scope for now),
|
|
29
|
+
* so graceful budget wrap-up is unsupported and a breach hard-stops.
|
|
30
|
+
* - `--json-schema` provides native structured output.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import * as fs from "node:fs/promises";
|
|
34
|
+
import * as os from "node:os";
|
|
35
|
+
import * as path from "node:path";
|
|
36
|
+
import type { Message } from "@earendil-works/pi-ai";
|
|
37
|
+
import type {
|
|
38
|
+
BackendAdapter,
|
|
39
|
+
BackendCapabilities,
|
|
40
|
+
BackendInvocation,
|
|
41
|
+
BackendLaunchContext,
|
|
42
|
+
BackendParser,
|
|
43
|
+
} from "../backend.js";
|
|
44
|
+
import type { ProtocolUpdate } from "../protocol.js";
|
|
45
|
+
import type { TaskResult, TaskSpec, UsageStats } from "../types.js";
|
|
46
|
+
import { emptyUsage } from "../types.js";
|
|
47
|
+
import { schemaContract } from "../structured.js";
|
|
48
|
+
|
|
49
|
+
const CLAUDE_CAPABILITIES: BackendCapabilities = {
|
|
50
|
+
steer: false,
|
|
51
|
+
gracefulWrapUp: false,
|
|
52
|
+
// total_cost_usd on the terminal result event.
|
|
53
|
+
costReporting: true,
|
|
54
|
+
resume: true,
|
|
55
|
+
fork: true,
|
|
56
|
+
toolRestriction: true,
|
|
57
|
+
thinking: false,
|
|
58
|
+
outputSchema: true,
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const TRANSCRIPT_MAX_LINES = 2000;
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Map our tool names onto Claude Code's. Unmapped names are passed through so
|
|
65
|
+
* a caller can name Claude tools directly.
|
|
66
|
+
*/
|
|
67
|
+
const TOOL_NAME_MAP: Record<string, string> = {
|
|
68
|
+
read: "Read",
|
|
69
|
+
grep: "Grep",
|
|
70
|
+
find: "Glob",
|
|
71
|
+
glob: "Glob",
|
|
72
|
+
ls: "Read",
|
|
73
|
+
bash: "Bash",
|
|
74
|
+
edit: "Edit",
|
|
75
|
+
write: "Write",
|
|
76
|
+
web_search: "WebSearch",
|
|
77
|
+
web_fetch: "WebFetch",
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
function mapTools(tools: readonly string[]): string[] {
|
|
81
|
+
const mapped = new Set<string>();
|
|
82
|
+
for (const tool of tools) {
|
|
83
|
+
if (tool === "subagent") continue;
|
|
84
|
+
mapped.add(TOOL_NAME_MAP[tool] ?? tool);
|
|
85
|
+
}
|
|
86
|
+
return [...mapped];
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export class ClaudeParser implements BackendParser {
|
|
90
|
+
private buffer = "";
|
|
91
|
+
private sessionId?: string;
|
|
92
|
+
private model?: string;
|
|
93
|
+
private messages: Message[] = [];
|
|
94
|
+
private usage: UsageStats = emptyUsage();
|
|
95
|
+
private liveText = "";
|
|
96
|
+
private transcriptLines: string[] = [];
|
|
97
|
+
private transcriptJoined?: string;
|
|
98
|
+
private parseErrors = 0;
|
|
99
|
+
private validEvents = 0;
|
|
100
|
+
private initSeen = false;
|
|
101
|
+
private resultSeen = false;
|
|
102
|
+
private assistantSeen = false;
|
|
103
|
+
private errorMessage?: string;
|
|
104
|
+
private apiErrorSeen = false;
|
|
105
|
+
|
|
106
|
+
feed(data: Buffer | string): ProtocolUpdate[] {
|
|
107
|
+
this.buffer += typeof data === "string" ? data : data.toString("utf8");
|
|
108
|
+
const updates: ProtocolUpdate[] = [];
|
|
109
|
+
let newline: number;
|
|
110
|
+
while ((newline = this.buffer.indexOf("\n")) !== -1) {
|
|
111
|
+
const line = this.buffer.slice(0, newline);
|
|
112
|
+
this.buffer = this.buffer.slice(newline + 1);
|
|
113
|
+
updates.push(...this.handleLine(line));
|
|
114
|
+
}
|
|
115
|
+
return updates;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
flush(): ProtocolUpdate[] {
|
|
119
|
+
if (!this.buffer.trim()) {
|
|
120
|
+
this.buffer = "";
|
|
121
|
+
return [];
|
|
122
|
+
}
|
|
123
|
+
const line = this.buffer;
|
|
124
|
+
this.buffer = "";
|
|
125
|
+
return this.handleLine(line);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
private handleLine(raw: string): ProtocolUpdate[] {
|
|
129
|
+
const trimmed = raw.trim();
|
|
130
|
+
if (!trimmed || !trimmed.startsWith("{")) return [];
|
|
131
|
+
let event: any;
|
|
132
|
+
try {
|
|
133
|
+
event = JSON.parse(trimmed);
|
|
134
|
+
} catch {
|
|
135
|
+
this.parseErrors++;
|
|
136
|
+
return [];
|
|
137
|
+
}
|
|
138
|
+
if (!event || typeof event !== "object") {
|
|
139
|
+
this.parseErrors++;
|
|
140
|
+
return [];
|
|
141
|
+
}
|
|
142
|
+
this.validEvents++;
|
|
143
|
+
|
|
144
|
+
// The terminal event is identified by type:"result" (it has no subtype
|
|
145
|
+
// discriminator we can rely on beyond that).
|
|
146
|
+
if (event.type === "result") return this.handleResult(event);
|
|
147
|
+
if (event.type === "system") {
|
|
148
|
+
if (event.subtype === "init") {
|
|
149
|
+
this.initSeen = true;
|
|
150
|
+
if (typeof event.session_id === "string" && event.session_id) {
|
|
151
|
+
this.sessionId = event.session_id;
|
|
152
|
+
if (typeof event.model === "string") this.model = event.model;
|
|
153
|
+
return [{ type: "session", sessionId: event.session_id }];
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return [];
|
|
157
|
+
}
|
|
158
|
+
if (event.type === "assistant") return this.handleAssistant(event);
|
|
159
|
+
if (event.type === "user") {
|
|
160
|
+
// Tool results echoed back; record for the transcript only.
|
|
161
|
+
const content = event.message?.content;
|
|
162
|
+
if (Array.isArray(content)) {
|
|
163
|
+
for (const part of content) {
|
|
164
|
+
if (part?.type === "tool_result") this.pushTranscript(`[tool result] ${previewOf(part.content)}`);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return [];
|
|
168
|
+
}
|
|
169
|
+
return [];
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
private handleAssistant(event: any): ProtocolUpdate[] {
|
|
173
|
+
const message = event.message;
|
|
174
|
+
if (!message || typeof message !== "object") return [];
|
|
175
|
+
// Rate-limit / API-error messages arrive as synthetic assistant turns.
|
|
176
|
+
if (event.error || event.is_api_error_message === true) {
|
|
177
|
+
const text = textOf(message.content);
|
|
178
|
+
this.apiErrorSeen = true;
|
|
179
|
+
this.errorMessage = text || `Claude API error (${event.error ?? "unknown"})`;
|
|
180
|
+
return [{ type: "fatal", error: this.errorMessage }];
|
|
181
|
+
}
|
|
182
|
+
const text = textOf(message.content);
|
|
183
|
+
this.mergeUsage(message.usage);
|
|
184
|
+
if (Array.isArray(message.content)) {
|
|
185
|
+
for (const part of message.content) {
|
|
186
|
+
if (part?.type === "tool_use") this.pushTranscript(`[tool] ${part.name ?? "?"} ${previewOf(part.input)}`);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
const updates: ProtocolUpdate[] = [];
|
|
190
|
+
if (text) {
|
|
191
|
+
this.assistantSeen = true;
|
|
192
|
+
this.liveText = text;
|
|
193
|
+
this.pushTranscript(text);
|
|
194
|
+
updates.push({ type: "live-text", delta: text, liveText: this.liveText });
|
|
195
|
+
}
|
|
196
|
+
if (typeof message.model === "string" && message.model !== "<synthetic>") this.model = message.model;
|
|
197
|
+
const normalized = {
|
|
198
|
+
role: "assistant",
|
|
199
|
+
content: Array.isArray(message.content) ? message.content : [{ type: "text", text }],
|
|
200
|
+
provider: "anthropic",
|
|
201
|
+
api: "claude-code",
|
|
202
|
+
model: this.model ?? "claude",
|
|
203
|
+
stopReason: message.stop_reason === "tool_use" ? "toolUse" : "stop",
|
|
204
|
+
timestamp: Date.now(),
|
|
205
|
+
usage: message.usage,
|
|
206
|
+
} as unknown as Message;
|
|
207
|
+
this.messages.push(normalized);
|
|
208
|
+
updates.push({ type: "message", message: normalized, usage: { ...this.usage } });
|
|
209
|
+
return updates;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
private handleResult(event: any): ProtocolUpdate[] {
|
|
213
|
+
this.resultSeen = true;
|
|
214
|
+
const cost = typeof event.total_cost_usd === "number" && Number.isFinite(event.total_cost_usd)
|
|
215
|
+
? event.total_cost_usd
|
|
216
|
+
: 0;
|
|
217
|
+
this.mergeUsage(event.usage);
|
|
218
|
+
this.usage = {
|
|
219
|
+
...this.usage,
|
|
220
|
+
cost: cost,
|
|
221
|
+
turns: typeof event.num_turns === "number" && event.num_turns > 0 ? event.num_turns : this.usage.turns,
|
|
222
|
+
};
|
|
223
|
+
if (event.is_error === true) {
|
|
224
|
+
const text = typeof event.result === "string" ? event.result : "Claude reported an error";
|
|
225
|
+
this.errorMessage = text;
|
|
226
|
+
// Surface the final text so paid partial work is not lost.
|
|
227
|
+
if (text && !this.liveText) this.liveText = text;
|
|
228
|
+
return [{ type: "fatal", error: text }];
|
|
229
|
+
}
|
|
230
|
+
if (typeof event.result === "string" && event.result) {
|
|
231
|
+
this.liveText = event.result;
|
|
232
|
+
this.assistantSeen = true;
|
|
233
|
+
}
|
|
234
|
+
return [{ type: "agent-end" }, { type: "agent-settled" }];
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
private mergeUsage(usage: any): void {
|
|
238
|
+
if (!usage || typeof usage !== "object") return;
|
|
239
|
+
const num = (value: unknown): number =>
|
|
240
|
+
typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : 0;
|
|
241
|
+
this.usage = {
|
|
242
|
+
...this.usage,
|
|
243
|
+
input: Math.max(this.usage.input, num(usage.input_tokens)),
|
|
244
|
+
output: Math.max(this.usage.output, num(usage.output_tokens)),
|
|
245
|
+
cacheRead: Math.max(this.usage.cacheRead, num(usage.cache_read_input_tokens)),
|
|
246
|
+
cacheWrite: Math.max(this.usage.cacheWrite, num(usage.cache_creation_input_tokens)),
|
|
247
|
+
turns: Math.max(this.usage.turns, 1),
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
private pushTranscript(line: string): void {
|
|
252
|
+
this.transcriptLines.push(line);
|
|
253
|
+
if (this.transcriptLines.length > TRANSCRIPT_MAX_LINES) {
|
|
254
|
+
this.transcriptLines.splice(0, this.transcriptLines.length - TRANSCRIPT_MAX_LINES);
|
|
255
|
+
}
|
|
256
|
+
this.transcriptJoined = undefined;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
getTranscript(): string | undefined {
|
|
260
|
+
if (this.transcriptJoined === undefined) this.transcriptJoined = this.transcriptLines.join("\n");
|
|
261
|
+
return this.transcriptJoined || undefined;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
getLiveText(): string {
|
|
265
|
+
return this.liveText;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
getMessages(): Message[] {
|
|
269
|
+
return this.messages;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
finalize(exitCode: number | null, signal?: NodeJS.Signals, stderr = ""): TaskResult {
|
|
273
|
+
this.flush();
|
|
274
|
+
const protocol = {
|
|
275
|
+
headerSeen: this.initSeen,
|
|
276
|
+
assistantEndSeen: this.assistantSeen,
|
|
277
|
+
agentEndSeen: this.resultSeen,
|
|
278
|
+
agentSettledSeen: this.resultSeen,
|
|
279
|
+
validEvents: this.validEvents,
|
|
280
|
+
parseErrors: this.parseErrors,
|
|
281
|
+
};
|
|
282
|
+
const completeProtocol = this.initSeen && this.resultSeen;
|
|
283
|
+
const failed = !!this.errorMessage || this.apiErrorSeen;
|
|
284
|
+
const successfulExit = exitCode === 0 && !signal && !failed;
|
|
285
|
+
const hasUsefulOutput = this.assistantSeen && this.liveText.length > 0;
|
|
286
|
+
let state: TaskResult["state"];
|
|
287
|
+
if (successfulExit && completeProtocol) state = "completed";
|
|
288
|
+
else if (hasUsefulOutput && !failed) state = "partial";
|
|
289
|
+
else state = "failed";
|
|
290
|
+
const stopReason = signal
|
|
291
|
+
? "unexpected_signal"
|
|
292
|
+
: failed
|
|
293
|
+
? "error"
|
|
294
|
+
: exitCode !== 0
|
|
295
|
+
? "nonzero_exit"
|
|
296
|
+
: !completeProtocol
|
|
297
|
+
? "protocol_error"
|
|
298
|
+
: "stop";
|
|
299
|
+
return {
|
|
300
|
+
label: "subagent",
|
|
301
|
+
task: "",
|
|
302
|
+
state,
|
|
303
|
+
exitCode: exitCode === 0 && state === "failed" ? 1 : exitCode,
|
|
304
|
+
signal,
|
|
305
|
+
messages: [...this.messages],
|
|
306
|
+
stderr,
|
|
307
|
+
usage: { ...this.usage },
|
|
308
|
+
model: this.model ?? "claude",
|
|
309
|
+
stopReason,
|
|
310
|
+
errorMessage:
|
|
311
|
+
this.errorMessage ||
|
|
312
|
+
(signal ? `Claude subagent terminated unexpectedly by ${signal}` : undefined) ||
|
|
313
|
+
(exitCode !== 0 ? `Claude subagent exited with code ${exitCode}` : undefined) ||
|
|
314
|
+
(state === "partial" && !completeProtocol ? "Claude event stream truncated; partial output preserved" : undefined),
|
|
315
|
+
liveText: this.liveText || undefined,
|
|
316
|
+
transcript: this.getTranscript(),
|
|
317
|
+
protocol,
|
|
318
|
+
sessionId: this.sessionId,
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function textOf(content: unknown): string {
|
|
324
|
+
if (typeof content === "string") return content;
|
|
325
|
+
if (!Array.isArray(content)) return "";
|
|
326
|
+
return content
|
|
327
|
+
.filter((part: any) => part?.type === "text" && typeof part.text === "string")
|
|
328
|
+
.map((part: any) => part.text)
|
|
329
|
+
.join("");
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function previewOf(value: unknown): string {
|
|
333
|
+
const text = typeof value === "string" ? value : JSON.stringify(value ?? "");
|
|
334
|
+
return text.length > 200 ? `${text.slice(0, 199)}…` : text;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
export class ClaudeBackend implements BackendAdapter {
|
|
338
|
+
readonly name = "claude" as const;
|
|
339
|
+
readonly capabilities = CLAUDE_CAPABILITIES;
|
|
340
|
+
|
|
341
|
+
async buildInvocation(spec: TaskSpec, _context: BackendLaunchContext): Promise<BackendInvocation> {
|
|
342
|
+
const args = ["--print", "--output-format", "stream-json", "--verbose"];
|
|
343
|
+
if (spec.model) args.push("--model", spec.model);
|
|
344
|
+
if (spec.maxTurns !== undefined) args.push("--max-turns", String(spec.maxTurns));
|
|
345
|
+
|
|
346
|
+
if (spec.tools !== undefined) {
|
|
347
|
+
const tools = mapTools(spec.tools);
|
|
348
|
+
// Claude has no "no tools at all" flag; an empty allowlist is the
|
|
349
|
+
// closest honest equivalent.
|
|
350
|
+
args.push("--allowedTools", ...(tools.length ? tools : ["Read"]));
|
|
351
|
+
}
|
|
352
|
+
if (!spec.canWrite) {
|
|
353
|
+
// Belt and braces: even with a read-only allowlist, explicitly deny the
|
|
354
|
+
// mutating tools in case a future default adds one back.
|
|
355
|
+
args.push("--disallowedTools", "Edit", "Write", "NotebookEdit");
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
if (spec.resume) {
|
|
359
|
+
args.push("--resume", spec.resume);
|
|
360
|
+
if (spec.forkResume) args.push("--fork-session");
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
const cleanupDirs: string[] = [];
|
|
364
|
+
const appendPrompt = [
|
|
365
|
+
spec.systemPrompt?.trim(),
|
|
366
|
+
spec.outputSchema ? schemaContract(spec.outputSchema) : undefined,
|
|
367
|
+
].filter(Boolean).join("\n\n");
|
|
368
|
+
if (appendPrompt) args.push("--append-system-prompt", appendPrompt);
|
|
369
|
+
if (spec.outputSchema) {
|
|
370
|
+
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "pi-subagent-claude-schema-"));
|
|
371
|
+
cleanupDirs.push(dir);
|
|
372
|
+
const schemaPath = path.join(dir, "output-schema.json");
|
|
373
|
+
await fs.writeFile(schemaPath, JSON.stringify(spec.outputSchema), { encoding: "utf8", mode: 0o600 });
|
|
374
|
+
args.push("--json-schema", schemaPath);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
args.push(spec.task);
|
|
378
|
+
return { command: "claude", args, cleanupDirs };
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
createParser(): BackendParser {
|
|
382
|
+
return new ClaudeParser();
|
|
383
|
+
}
|
|
384
|
+
}
|