@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.
Files changed (46) hide show
  1. package/CHANGELOG.md +352 -0
  2. package/LICENSE +21 -0
  3. package/README.md +543 -0
  4. package/docs/ARCHITECTURE.md +125 -0
  5. package/docs/COST-ACCOUNTING.md +66 -0
  6. package/docs/PLAN.md +325 -0
  7. package/docs/RELEASING.md +32 -0
  8. package/docs/ROADMAP.md +252 -0
  9. package/docs/SECURITY.md +85 -0
  10. package/docs/UI-OVERHAUL.md +186 -0
  11. package/docs/UX.md +141 -0
  12. package/extensions/subagent.ts +1 -0
  13. package/package.json +58 -0
  14. package/skills/subagent/SKILL.md +103 -0
  15. package/src/agents.ts +285 -0
  16. package/src/backend.ts +146 -0
  17. package/src/backends/claude.ts +384 -0
  18. package/src/backends/codex.ts +330 -0
  19. package/src/backends/index.ts +26 -0
  20. package/src/backends/pi.ts +94 -0
  21. package/src/btw.ts +34 -0
  22. package/src/config.ts +254 -0
  23. package/src/distill.ts +222 -0
  24. package/src/extension.ts +1527 -0
  25. package/src/format.ts +365 -0
  26. package/src/index.ts +60 -0
  27. package/src/launch.ts +120 -0
  28. package/src/maintenance.ts +6 -0
  29. package/src/model-policy.ts +157 -0
  30. package/src/notifications.ts +106 -0
  31. package/src/orchestrator.ts +247 -0
  32. package/src/output.ts +124 -0
  33. package/src/persistence.ts +334 -0
  34. package/src/policy.ts +500 -0
  35. package/src/process-lock.ts +687 -0
  36. package/src/protocol.ts +290 -0
  37. package/src/registry.ts +632 -0
  38. package/src/runner.ts +850 -0
  39. package/src/schema.ts +166 -0
  40. package/src/semaphore.ts +123 -0
  41. package/src/structured.ts +169 -0
  42. package/src/transcript.ts +360 -0
  43. package/src/types.ts +197 -0
  44. package/src/ui.ts +545 -0
  45. package/src/usage.ts +274 -0
  46. package/src/worktree.ts +753 -0
@@ -0,0 +1,330 @@
1
+ /**
2
+ * Codex backend — spawns `codex exec --json` and translates its JSONL event
3
+ * stream into our normalized `ProtocolUpdate` shape.
4
+ *
5
+ * Event vocabulary (captured from codex-cli 0.144.6, `codex exec --json`):
6
+ *
7
+ * {"type":"thread.started","thread_id":"019f96…"}
8
+ * {"type":"turn.started"}
9
+ * {"type":"item.started","item":{"id":"item_1","type":"command_execution",…}}
10
+ * {"type":"item.completed","item":{"id":"item_0","type":"agent_message","text":"…"}}
11
+ * {"type":"turn.completed","usage":{"input_tokens":…,"output_tokens":…,
12
+ * "cached_input_tokens":…,"reasoning_output_tokens":…}}
13
+ *
14
+ * Notable capability facts, verified against the real CLI rather than assumed:
15
+ *
16
+ * - **No cost reporting.** `turn.completed.usage` carries token counts but no
17
+ * dollar figure, and Codex bills against the user's own plan. So
18
+ * `costReporting: false` and `max_cost` is refused rather than ignored.
19
+ * - **Read-only IS enforceable**, via `--sandbox read-only` — a real OS-level
20
+ * sandbox, arguably stronger than our tool allowlist. So explore/review
21
+ * profiles map cleanly and `toolRestriction` is true.
22
+ * - **No mid-run steering.** `codex exec` is one-shot; there is no stdin
23
+ * command channel. Steering and graceful budget wrap-up are therefore
24
+ * unsupported: a budget breach hard-stops instead of asking for a summary.
25
+ * - **Native structured output** via `--output-schema <file>`.
26
+ * - **Resume** exists (`codex exec resume`), but not session *forking*.
27
+ */
28
+
29
+ import * as fs from "node:fs/promises";
30
+ import * as os from "node:os";
31
+ import * as path from "node:path";
32
+ import type { Message } from "@earendil-works/pi-ai";
33
+ import type {
34
+ BackendAdapter,
35
+ BackendCapabilities,
36
+ BackendInvocation,
37
+ BackendLaunchContext,
38
+ BackendParser,
39
+ } from "../backend.js";
40
+ import type { ProtocolUpdate } from "../protocol.js";
41
+ import type { TaskResult, UsageStats } from "../types.js";
42
+ import { emptyUsage } from "../types.js";
43
+ import type { TaskSpec } from "../types.js";
44
+ import { schemaContract } from "../structured.js";
45
+
46
+ const CODEX_CAPABILITIES: BackendCapabilities = {
47
+ steer: false,
48
+ gracefulWrapUp: false,
49
+ // Token counts only; no dollar cost, and billing runs through the user's plan.
50
+ costReporting: false,
51
+ resume: true,
52
+ fork: false,
53
+ // `--sandbox read-only` is a real OS sandbox, not an honor-system allowlist.
54
+ toolRestriction: true,
55
+ thinking: false,
56
+ outputSchema: true,
57
+ };
58
+
59
+ const TRANSCRIPT_MAX_LINES = 2000;
60
+
61
+ /** Translates Codex's JSONL events into our normalized update stream. */
62
+ export class CodexParser implements BackendParser {
63
+ private buffer = "";
64
+ private threadId?: string;
65
+ private messages: Message[] = [];
66
+ private usage: UsageStats = emptyUsage();
67
+ private liveText = "";
68
+ private lastAssistantText = "";
69
+ private transcriptLines: string[] = [];
70
+ private transcriptJoined?: string;
71
+ private parseErrors = 0;
72
+ private validEvents = 0;
73
+ private threadStarted = false;
74
+ private turnCompleted = false;
75
+ private assistantSeen = false;
76
+ private errorMessage?: string;
77
+
78
+ feed(data: Buffer | string): ProtocolUpdate[] {
79
+ this.buffer += typeof data === "string" ? data : data.toString("utf8");
80
+ const updates: ProtocolUpdate[] = [];
81
+ let newline: number;
82
+ while ((newline = this.buffer.indexOf("\n")) !== -1) {
83
+ const line = this.buffer.slice(0, newline);
84
+ this.buffer = this.buffer.slice(newline + 1);
85
+ updates.push(...this.handleLine(line));
86
+ }
87
+ return updates;
88
+ }
89
+
90
+ flush(): ProtocolUpdate[] {
91
+ if (!this.buffer.trim()) {
92
+ this.buffer = "";
93
+ return [];
94
+ }
95
+ const line = this.buffer;
96
+ this.buffer = "";
97
+ return this.handleLine(line);
98
+ }
99
+
100
+ private handleLine(raw: string): ProtocolUpdate[] {
101
+ const trimmed = raw.trim();
102
+ if (!trimmed) return [];
103
+ // Codex writes tracing/ERROR lines to stdout in some builds; skip non-JSON
104
+ // rather than counting it as a protocol violation.
105
+ if (!trimmed.startsWith("{")) return [];
106
+ let event: any;
107
+ try {
108
+ event = JSON.parse(trimmed);
109
+ } catch {
110
+ this.parseErrors++;
111
+ return [];
112
+ }
113
+ if (!event || typeof event !== "object" || typeof event.type !== "string") {
114
+ this.parseErrors++;
115
+ return [];
116
+ }
117
+ this.validEvents++;
118
+
119
+ switch (event.type) {
120
+ case "thread.started": {
121
+ this.threadStarted = true;
122
+ if (typeof event.thread_id === "string" && event.thread_id) {
123
+ this.threadId = event.thread_id;
124
+ return [{ type: "session", sessionId: event.thread_id }];
125
+ }
126
+ return [];
127
+ }
128
+ case "turn.started":
129
+ return [];
130
+ case "item.started":
131
+ return this.handleItem(event.item, false);
132
+ case "item.completed":
133
+ return this.handleItem(event.item, true);
134
+ case "turn.completed": {
135
+ this.turnCompleted = true;
136
+ this.applyUsage(event.usage);
137
+ const updates: ProtocolUpdate[] = [];
138
+ // Materialize the final assistant text as a message so downstream
139
+ // usage folding and output extraction behave like the pi backend.
140
+ if (this.lastAssistantText) {
141
+ const message = this.assistantMessage(this.lastAssistantText);
142
+ this.messages.push(message);
143
+ updates.push({ type: "message", message, usage: { ...this.usage } });
144
+ }
145
+ updates.push({ type: "agent-end" }, { type: "agent-settled" });
146
+ return updates;
147
+ }
148
+ case "turn.failed":
149
+ case "error": {
150
+ const message = typeof event.message === "string" ? event.message
151
+ : typeof event.error === "string" ? event.error
152
+ : "Codex reported a failure";
153
+ this.errorMessage = message;
154
+ return [{ type: "fatal", error: message }];
155
+ }
156
+ default:
157
+ return [];
158
+ }
159
+ }
160
+
161
+ private handleItem(item: any, completed: boolean): ProtocolUpdate[] {
162
+ if (!item || typeof item !== "object" || typeof item.type !== "string") return [];
163
+ if (item.type === "agent_message") {
164
+ const text = typeof item.text === "string" ? item.text : "";
165
+ if (!text) return [];
166
+ this.assistantSeen = true;
167
+ if (completed) {
168
+ this.lastAssistantText = text;
169
+ this.liveText = text;
170
+ this.pushTranscript(text);
171
+ return [{ type: "live-text", delta: text, liveText: this.liveText }];
172
+ }
173
+ return [];
174
+ }
175
+ if (item.type === "command_execution" && completed) {
176
+ const command = typeof item.command === "string" ? item.command : "";
177
+ const exit = item.exit_code === null || item.exit_code === undefined ? "?" : String(item.exit_code);
178
+ if (command) this.pushTranscript(`$ ${command} (exit ${exit})`);
179
+ return [];
180
+ }
181
+ if (item.type === "reasoning" && completed) {
182
+ const text = typeof item.text === "string" ? item.text : "";
183
+ if (text) this.pushTranscript(`[reasoning] ${text}`);
184
+ return [];
185
+ }
186
+ return [];
187
+ }
188
+
189
+ private applyUsage(usage: any): void {
190
+ if (!usage || typeof usage !== "object") return;
191
+ const num = (value: unknown): number =>
192
+ typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : 0;
193
+ // Codex reports cumulative per-turn token counts and no cost. Leaving cost
194
+ // at zero is deliberate and matches capabilities.costReporting === false.
195
+ this.usage = {
196
+ ...this.usage,
197
+ input: num(usage.input_tokens),
198
+ output: num(usage.output_tokens),
199
+ cacheRead: num(usage.cached_input_tokens),
200
+ reasoning: num(usage.reasoning_output_tokens),
201
+ turns: this.usage.turns + 1,
202
+ };
203
+ }
204
+
205
+ private assistantMessage(text: string): Message {
206
+ return {
207
+ role: "assistant",
208
+ content: [{ type: "text", text }],
209
+ provider: "codex",
210
+ api: "codex-exec",
211
+ model: "codex",
212
+ stopReason: "stop",
213
+ timestamp: Date.now(),
214
+ } as unknown as Message;
215
+ }
216
+
217
+ private pushTranscript(line: string): void {
218
+ this.transcriptLines.push(line);
219
+ if (this.transcriptLines.length > TRANSCRIPT_MAX_LINES) {
220
+ this.transcriptLines.splice(0, this.transcriptLines.length - TRANSCRIPT_MAX_LINES);
221
+ }
222
+ this.transcriptJoined = undefined;
223
+ }
224
+
225
+ getTranscript(): string | undefined {
226
+ if (this.transcriptJoined === undefined) this.transcriptJoined = this.transcriptLines.join("\n");
227
+ return this.transcriptJoined || undefined;
228
+ }
229
+
230
+ getLiveText(): string {
231
+ return this.liveText;
232
+ }
233
+
234
+ getMessages(): Message[] {
235
+ return this.messages;
236
+ }
237
+
238
+ finalize(exitCode: number | null, signal?: NodeJS.Signals, stderr = ""): TaskResult {
239
+ this.flush();
240
+ const protocol = {
241
+ headerSeen: this.threadStarted,
242
+ assistantEndSeen: this.assistantSeen,
243
+ agentEndSeen: this.turnCompleted,
244
+ agentSettledSeen: this.turnCompleted,
245
+ validEvents: this.validEvents,
246
+ parseErrors: this.parseErrors,
247
+ };
248
+ const completeProtocol = this.threadStarted && this.turnCompleted;
249
+ const successfulExit = exitCode === 0 && !signal && !this.errorMessage;
250
+ const hasUsefulOutput = this.assistantSeen && (this.liveText.length > 0 || this.usage.turns > 0);
251
+ let state: TaskResult["state"];
252
+ if (successfulExit && completeProtocol) state = "completed";
253
+ else if (hasUsefulOutput && !this.errorMessage) state = "partial";
254
+ else state = "failed";
255
+ const stopReason = signal
256
+ ? "unexpected_signal"
257
+ : exitCode !== 0
258
+ ? "nonzero_exit"
259
+ : !completeProtocol
260
+ ? "protocol_error"
261
+ : "stop";
262
+ return {
263
+ label: "subagent",
264
+ task: "",
265
+ state,
266
+ exitCode: exitCode === 0 && state === "failed" ? 1 : exitCode,
267
+ signal,
268
+ messages: [...this.messages],
269
+ stderr,
270
+ usage: { ...this.usage },
271
+ model: "codex",
272
+ stopReason,
273
+ errorMessage:
274
+ this.errorMessage ||
275
+ (signal ? `Codex subagent terminated unexpectedly by ${signal}` : undefined) ||
276
+ (exitCode !== 0 ? `Codex subagent exited with code ${exitCode}` : undefined) ||
277
+ (state === "partial" && !completeProtocol ? "Codex event stream truncated; partial output preserved" : undefined),
278
+ liveText: this.liveText || undefined,
279
+ transcript: this.getTranscript(),
280
+ protocol,
281
+ sessionId: this.threadId,
282
+ };
283
+ }
284
+ }
285
+
286
+ export class CodexBackend implements BackendAdapter {
287
+ readonly name = "codex" as const;
288
+ readonly capabilities = CODEX_CAPABILITIES;
289
+
290
+ async buildInvocation(spec: TaskSpec, _context: BackendLaunchContext): Promise<BackendInvocation> {
291
+ const args = ["exec", "--json", "--skip-git-repo-check"];
292
+ // Map our profile onto Codex's real OS sandbox. canWrite is already the
293
+ // resolved outcome of profile + tool policy, so it is the honest input.
294
+ args.push("--sandbox", spec.canWrite ? "workspace-write" : "read-only");
295
+ if (spec.model) args.push("--model", spec.model);
296
+
297
+ const cleanupDirs: string[] = [];
298
+ if (spec.outputSchema) {
299
+ const dir = await fs.mkdtemp(path.join(os.tmpdir(), "pi-subagent-codex-schema-"));
300
+ cleanupDirs.push(dir);
301
+ const schemaPath = path.join(dir, "output-schema.json");
302
+ await fs.writeFile(schemaPath, JSON.stringify(spec.outputSchema), { encoding: "utf8", mode: 0o600 });
303
+ args.push("--output-schema", schemaPath);
304
+ }
305
+
306
+ // Codex has no --append-system-prompt; fold persona + schema contract into
307
+ // the prompt itself so the same contract text still reaches the model.
308
+ const preamble = [
309
+ spec.systemPrompt?.trim(),
310
+ spec.outputSchema ? schemaContract(spec.outputSchema) : undefined,
311
+ ].filter(Boolean).join("\n\n");
312
+ const prompt = preamble ? `${preamble}\n\n---\n\n${spec.task}` : spec.task;
313
+
314
+ if (spec.resume) {
315
+ // `codex exec resume <id>` takes the prompt after the id.
316
+ args.splice(1, 0, "resume");
317
+ args.push(spec.resume);
318
+ }
319
+ args.push(prompt);
320
+
321
+ return { command: "codex", args, cleanupDirs };
322
+ }
323
+
324
+ createParser(): BackendParser {
325
+ return new CodexParser();
326
+ }
327
+
328
+ // No stdin command channel: steering/stop/ui-cancel are intentionally absent
329
+ // so `checkCapabilities` refuses features that would silently no-op.
330
+ }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Backend registry.
3
+ *
4
+ * Backends are constructed lazily and cached, so a user who never asks for
5
+ * the Claude backend never pays for importing its SDK.
6
+ */
7
+
8
+ import type { BackendAdapter, BackendName } from "../backend.js";
9
+ import { PiBackend } from "./pi.js";
10
+ import { CodexBackend } from "./codex.js";
11
+ import { ClaudeBackend } from "./claude.js";
12
+
13
+ const cache = new Map<BackendName, BackendAdapter>();
14
+
15
+ export function resolveBackend(name: BackendName): BackendAdapter {
16
+ const cached = cache.get(name);
17
+ if (cached) return cached;
18
+ const backend: BackendAdapter =
19
+ name === "codex" ? new CodexBackend() : name === "claude" ? new ClaudeBackend() : new PiBackend();
20
+ cache.set(name, backend);
21
+ return backend;
22
+ }
23
+
24
+ export { PiBackend } from "./pi.js";
25
+ export { CodexBackend } from "./codex.js";
26
+ export { ClaudeBackend } from "./claude.js";
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Pi backend — the original and default. Spawns `pi --mode rpc` and speaks
3
+ * Pi's documented JSON event stream over stdio.
4
+ *
5
+ * This is a straight extraction of the logic that lived inline in
6
+ * `ChildRunner.run()`; behavior is unchanged. It is the only backend that
7
+ * supports every capability, because the protocol was designed for it.
8
+ */
9
+
10
+ import * as fs from "node:fs/promises";
11
+ import * as os from "node:os";
12
+ import * as path from "node:path";
13
+ import type { BackendAdapter, BackendCapabilities, BackendInvocation, BackendLaunchContext, BackendParser } from "../backend.js";
14
+ import { ProtocolParser } from "../protocol.js";
15
+ import { schemaContract } from "../structured.js";
16
+ import type { TaskSpec } from "../types.js";
17
+
18
+ const PI_CAPABILITIES: BackendCapabilities = {
19
+ steer: true,
20
+ gracefulWrapUp: true,
21
+ costReporting: true,
22
+ resume: true,
23
+ fork: true,
24
+ toolRestriction: true,
25
+ thinking: true,
26
+ outputSchema: true,
27
+ };
28
+
29
+ export class PiBackend implements BackendAdapter {
30
+ readonly name = "pi" as const;
31
+ readonly capabilities = PI_CAPABILITIES;
32
+
33
+ async buildInvocation(spec: TaskSpec, context: BackendLaunchContext): Promise<BackendInvocation> {
34
+ // RPC mode keeps a live stdin command channel so steering messages can be
35
+ // injected mid-run. The event stream on stdout is a superset of json mode.
36
+ const args = ["--mode", "rpc", "--session-dir", context.sessionDir];
37
+ if (spec.forkResume && spec.resume) args.push("--fork", spec.resume);
38
+ else if (spec.resume) args.push("--session", spec.resume);
39
+ else if (spec.contextFork) {
40
+ // Context fork: the child starts from a real branched copy of the
41
+ // parent conversation, then receives the task as its next prompt.
42
+ // Fail fast rather than silently degrading to a fresh session.
43
+ if (!spec.parentSessionFile) {
44
+ throw new Error("context:'fork' requires a persisted parent session (none available). Save the session or use context:'fresh'.");
45
+ }
46
+ await fs.access(spec.parentSessionFile).catch(() => {
47
+ throw new Error(`context:'fork' failed: parent session file ${spec.parentSessionFile} is not readable.`);
48
+ });
49
+ args.push("--fork", spec.parentSessionFile);
50
+ }
51
+ if (spec.model) args.push("--model", spec.model);
52
+ if (spec.thinking) args.push("--thinking", spec.thinking);
53
+ if (spec.tools !== undefined) {
54
+ const tools = spec.tools.filter((tool) => tool !== "subagent");
55
+ if (tools.length === 0) args.push("--no-tools");
56
+ else args.push("--tools", tools.join(","));
57
+ }
58
+ // Persona/system prompt first, structured-output contract last (highest salience).
59
+ const appendPrompt = [spec.systemPrompt?.trim(), spec.outputSchema ? schemaContract(spec.outputSchema) : undefined]
60
+ .filter(Boolean)
61
+ .join("\n\n");
62
+ const cleanupDirs: string[] = [];
63
+ if (appendPrompt) {
64
+ const tempPromptDir = await fs.mkdtemp(path.join(os.tmpdir(), "pi-subagent-prompt-"));
65
+ cleanupDirs.push(tempPromptDir);
66
+ const promptPath = path.join(tempPromptDir, "system-prompt.md");
67
+ await fs.writeFile(promptPath, appendPrompt, { encoding: "utf8", mode: 0o600 });
68
+ args.push("--append-system-prompt", promptPath);
69
+ }
70
+
71
+ const invocation = context.getPiCommand(args);
72
+ return { command: invocation.command, args: invocation.args, cleanupDirs };
73
+ }
74
+
75
+ createParser(): BackendParser {
76
+ return new ProtocolParser();
77
+ }
78
+
79
+ steerCommand(message: string): unknown {
80
+ return { type: "steer", message };
81
+ }
82
+
83
+ promptCommand(message: string): unknown {
84
+ return { type: "prompt", message };
85
+ }
86
+
87
+ uiCancelCommand(id: string): unknown {
88
+ return { type: "extension_ui_response", id, cancelled: true };
89
+ }
90
+
91
+ stateCommand(): unknown {
92
+ return { type: "get_state" };
93
+ }
94
+ }
package/src/btw.ts ADDED
@@ -0,0 +1,34 @@
1
+ /**
2
+ * `/btw` — "by the way" side questions.
3
+ *
4
+ * A user aside runs as a normal subagent run, but its result is delivered to
5
+ * the TUI through `pi.appendEntry()`, which does not participate in LLM
6
+ * context. The main agent therefore keeps working without seeing the question
7
+ * or the answer, while the user still gets a rendered result in the
8
+ * transcript. Inspired by davis7dotsh/my-pi-setup's by-the-way feature.
9
+ */
10
+
11
+ /** Custom entry type for `/btw` results (model-hidden by construction). */
12
+ export const BTW_ENTRY_TYPE = "subagent-btw";
13
+
14
+ export interface BtwEntry {
15
+ state: "running" | "done" | "failed";
16
+ question: string;
17
+ label: string;
18
+ answer?: string;
19
+ }
20
+
21
+ export const BTW_LABEL_MAX_LENGTH = 60;
22
+
23
+ /**
24
+ * Compact transcript label from the first non-empty line of the question.
25
+ * Counts code points so multi-byte characters are never split mid-glyph.
26
+ */
27
+ export function btwLabel(question: string): string {
28
+ const firstLine = question.split("\n").find((line) => line.trim())?.trim();
29
+ const collapsed = firstLine?.replace(/\s+/g, " ") ?? "";
30
+ if (!collapsed) return "by the way";
31
+ const points = Array.from(collapsed);
32
+ if (points.length <= BTW_LABEL_MAX_LENGTH) return collapsed;
33
+ return `${points.slice(0, BTW_LABEL_MAX_LENGTH - 1).join("")}…`;
34
+ }