@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,334 @@
1
+ import { Buffer } from "node:buffer";
2
+ import type { SubagentConfig } from "./config.js";
3
+ import type { ChildProcessIdentity, RunMode, RunSnapshot, RunState, TaskProfile, TimeoutPhase, UsageStats } from "./types.js";
4
+ import { emptyUsage } from "./types.js";
5
+
6
+ export const RUN_ENTRY_TYPE = "subagent-run-v1";
7
+
8
+ export interface PersistenceAdapter {
9
+ /** Append to the currently-owned parent session. Implementations must reject stale ownership. */
10
+ appendEntry(type: string, payload: unknown): void;
11
+ /** Active-branch entries only, in branch order. */
12
+ getEntries(): Array<{ type?: string; customType?: string; data?: unknown; timestamp?: number }>;
13
+ }
14
+
15
+ export interface PersistedResult {
16
+ backend?: import("./types.js").BackendName;
17
+ label: string;
18
+ task: string;
19
+ state: RunState;
20
+ exitCode: number | null;
21
+ stopReason?: string;
22
+ timeoutPhase?: TimeoutPhase;
23
+ errorMessage?: string;
24
+ usage: UsageStats;
25
+ model?: string;
26
+ thinking?: "off" | "minimal" | "low" | "medium" | "high" | "xhigh";
27
+ profile?: TaskProfile;
28
+ canWrite?: boolean;
29
+ outputFile?: string;
30
+ outputMode?: "inline" | "file-only";
31
+ worktree?: { cwd: string; branch: string; baseCommit: string; changed: boolean; diffSummary?: string };
32
+ sessionId?: string;
33
+ process?: ChildProcessIdentity;
34
+ finalOutput?: string;
35
+ transcript?: string;
36
+ /** Budget-stopped child that wrapped up gracefully within its grace turns. */
37
+ wrappedUp?: boolean;
38
+ /** Set while no protocol activity has been seen for the stall window. */
39
+ stalledSince?: number;
40
+ /** Total attempts including retries (present when > 1). */
41
+ attempts?: number;
42
+ /** Models tried across attempts, in order. */
43
+ attemptedModels?: string[];
44
+ /** Parsed structured result when output_schema validated. */
45
+ structuredOutput?: unknown;
46
+ /** Validation errors when output_schema was requested but failed. */
47
+ structuredError?: string;
48
+ }
49
+
50
+ export interface PersistenceEventData {
51
+ mode?: RunMode;
52
+ state?: RunState;
53
+ startedAt?: number;
54
+ endedAt?: number;
55
+ taskPreviews?: string[];
56
+ summary?: string;
57
+ delivered?: boolean;
58
+ resumeBlocked?: boolean;
59
+ results?: PersistedResult[];
60
+ resultIndex?: number;
61
+ childSessionId?: string;
62
+ progress?: string;
63
+ turn?: number;
64
+ }
65
+
66
+ export interface PersistenceEvent {
67
+ schemaVersion: 1;
68
+ id: string;
69
+ sessionKey: string;
70
+ timestamp: number;
71
+ sequence: number;
72
+ type: "start" | "checkpoint" | "terminal" | "delivered" | "dismissed";
73
+ data: PersistenceEventData;
74
+ }
75
+
76
+ function isRunState(value: unknown): value is RunState {
77
+ return ["queued", "running", "completed", "partial", "failed", "cancelled", "lost", "timeout"].includes(
78
+ String(value),
79
+ );
80
+ }
81
+
82
+ function isTimeoutPhase(value: unknown): value is TimeoutPhase {
83
+ return ["queued", "starting", "running", "cancelling"].includes(String(value));
84
+ }
85
+
86
+ function normalizeProcess(value: unknown): ChildProcessIdentity | undefined {
87
+ if (!value || typeof value !== "object") return undefined;
88
+ const p = value as Partial<ChildProcessIdentity>;
89
+ if (typeof p.pid !== "number" || !Number.isFinite(p.pid) || p.pid <= 0) return undefined;
90
+ return {
91
+ pid: p.pid,
92
+ startTime: typeof p.startTime === "number" && Number.isFinite(p.startTime) ? p.startTime : 0,
93
+ pgid: typeof p.pgid === "number" && Number.isFinite(p.pgid) ? p.pgid : undefined,
94
+ hostname: typeof p.hostname === "string" ? p.hostname : undefined,
95
+ };
96
+ }
97
+
98
+ function isRunMode(value: unknown): value is RunMode {
99
+ return value === "single" || value === "parallel";
100
+ }
101
+
102
+ function normalizeUsage(value: unknown): UsageStats {
103
+ if (!value || typeof value !== "object") return emptyUsage();
104
+ const input = value as Partial<UsageStats>;
105
+ const finite = (n: unknown) => (typeof n === "number" && Number.isFinite(n) && n >= 0 ? n : 0);
106
+ return {
107
+ input: finite(input.input),
108
+ output: finite(input.output),
109
+ cacheRead: finite(input.cacheRead),
110
+ cacheWrite: finite(input.cacheWrite),
111
+ reasoning: finite(input.reasoning),
112
+ cost: finite(input.cost),
113
+ costInput: finite(input.costInput),
114
+ costOutput: finite(input.costOutput),
115
+ costCacheRead: finite(input.costCacheRead),
116
+ costCacheWrite: finite(input.costCacheWrite),
117
+ contextTokens: finite(input.contextTokens),
118
+ turns: finite(input.turns),
119
+ };
120
+ }
121
+
122
+ function utf8Prefix(value: string, maxBytes: number): string {
123
+ const buffer = Buffer.from(value, "utf8");
124
+ if (buffer.length <= maxBytes) return value;
125
+ let end = maxBytes;
126
+ while (end > 0 && (buffer[end] & 0xc0) === 0x80) end--;
127
+ return buffer.subarray(0, end).toString("utf8");
128
+ }
129
+
130
+ function normalizeResult(value: unknown): PersistedResult | undefined {
131
+ if (!value || typeof value !== "object") return undefined;
132
+ const r = value as Partial<PersistedResult>;
133
+ if (typeof r.label !== "string" || typeof r.task !== "string") return undefined;
134
+ return {
135
+ label: r.label,
136
+ task: r.task,
137
+ state: isRunState(r.state) ? r.state : "running",
138
+ exitCode: typeof r.exitCode === "number" || r.exitCode === null ? r.exitCode : null,
139
+ stopReason: typeof r.stopReason === "string" ? r.stopReason : undefined,
140
+ timeoutPhase: isTimeoutPhase(r.timeoutPhase) ? r.timeoutPhase : undefined,
141
+ errorMessage: typeof r.errorMessage === "string" ? utf8Prefix(r.errorMessage, 2_000) : undefined,
142
+ usage: normalizeUsage(r.usage),
143
+ model: typeof r.model === "string" ? r.model : undefined,
144
+ thinking: ["off", "minimal", "low", "medium", "high", "xhigh"].includes(String(r.thinking)) ? r.thinking : undefined,
145
+ profile: ["explore", "review", "general"].includes(String(r.profile)) ? r.profile : undefined,
146
+ canWrite: typeof r.canWrite === "boolean" ? r.canWrite : undefined,
147
+ outputFile: typeof r.outputFile === "string" ? r.outputFile : undefined,
148
+ outputMode: r.outputMode === "file-only" ? "file-only" : r.outputMode === "inline" ? "inline" : undefined,
149
+ worktree:
150
+ r.worktree &&
151
+ typeof r.worktree.cwd === "string" &&
152
+ typeof r.worktree.branch === "string" &&
153
+ typeof r.worktree.baseCommit === "string"
154
+ ? { ...r.worktree, changed: r.worktree.changed === true }
155
+ : undefined,
156
+ sessionId: typeof r.sessionId === "string" ? r.sessionId : undefined,
157
+ process: normalizeProcess(r.process),
158
+ finalOutput: typeof r.finalOutput === "string" ? utf8Prefix(r.finalOutput, 16_384) : undefined,
159
+ transcript: typeof r.transcript === "string" ? utf8Prefix(r.transcript, 32_768) : undefined,
160
+ wrappedUp: r.wrappedUp === true ? true : undefined,
161
+ stalledSince: typeof r.stalledSince === "number" && Number.isFinite(r.stalledSince) ? r.stalledSince : undefined,
162
+ attempts: typeof r.attempts === "number" && Number.isInteger(r.attempts) && r.attempts > 1 ? r.attempts : undefined,
163
+ attemptedModels: Array.isArray(r.attemptedModels)
164
+ ? r.attemptedModels.filter((m): m is string => typeof m === "string").slice(0, 10)
165
+ : undefined,
166
+ structuredOutput: r.structuredOutput !== undefined && Buffer.byteLength(JSON.stringify(r.structuredOutput) ?? "", "utf8") <= 32_768
167
+ ? r.structuredOutput
168
+ : undefined,
169
+ structuredError: typeof r.structuredError === "string" ? utf8Prefix(r.structuredError, 1_000) : undefined,
170
+ };
171
+ }
172
+
173
+ function unwrapEvent(entry: { type?: string; customType?: string; data?: unknown }): PersistenceEvent | undefined {
174
+ if (entry.customType !== RUN_ENTRY_TYPE && entry.type !== RUN_ENTRY_TYPE) return undefined;
175
+ const raw = entry.data as Partial<PersistenceEvent> | undefined;
176
+ if (!raw || raw.schemaVersion !== 1 || typeof raw.id !== "string" || typeof raw.sessionKey !== "string") {
177
+ return undefined;
178
+ }
179
+ if (!["start", "checkpoint", "terminal", "delivered", "dismissed"].includes(String(raw.type))) {
180
+ return undefined;
181
+ }
182
+ return {
183
+ schemaVersion: 1,
184
+ id: raw.id,
185
+ sessionKey: raw.sessionKey,
186
+ timestamp: typeof raw.timestamp === "number" ? raw.timestamp : 0,
187
+ sequence: typeof raw.sequence === "number" ? raw.sequence : 0,
188
+ type: raw.type as PersistenceEvent["type"],
189
+ data: raw.data && typeof raw.data === "object" ? raw.data : {},
190
+ };
191
+ }
192
+
193
+ /**
194
+ * Versioned event persistence. Restoration folds every event on the active branch,
195
+ * rather than treating the latest delta as a complete snapshot.
196
+ */
197
+ export class PersistenceLayer {
198
+ private sequence = 0;
199
+
200
+ constructor(
201
+ private readonly adapter: PersistenceAdapter,
202
+ private readonly config: SubagentConfig,
203
+ ) {
204
+ for (const entry of adapter.getEntries()) {
205
+ const event = unwrapEvent(entry);
206
+ if (event) this.sequence = Math.max(this.sequence, event.sequence);
207
+ }
208
+ }
209
+
210
+ persist(
211
+ id: string,
212
+ sessionKey: string,
213
+ type: PersistenceEvent["type"],
214
+ data: PersistenceEventData,
215
+ _terminalHint?: boolean,
216
+ ): void {
217
+ if (!id || !sessionKey) return;
218
+ const event: PersistenceEvent = {
219
+ schemaVersion: 1,
220
+ id,
221
+ sessionKey,
222
+ timestamp: Date.now(),
223
+ sequence: ++this.sequence,
224
+ type,
225
+ data,
226
+ };
227
+ this.adapter.appendEntry(RUN_ENTRY_TYPE, event);
228
+ }
229
+
230
+ /** Fold active-branch events in their session order. */
231
+ rebuild(sessionKey: string): Map<string, RunSnapshot> {
232
+ const snapshots = new Map<string, RunSnapshot>();
233
+
234
+ for (const entry of this.adapter.getEntries()) {
235
+ const event = unwrapEvent(entry);
236
+ if (!event || event.sessionKey !== sessionKey) continue;
237
+
238
+ let snapshot = snapshots.get(event.id);
239
+ if (!snapshot) {
240
+ snapshot = {
241
+ schemaVersion: 1,
242
+ id: event.id,
243
+ sessionKey,
244
+ mode: "single",
245
+ state: "running",
246
+ startedAt: event.timestamp || Date.now(),
247
+ taskPreviews: [],
248
+ delivered: false,
249
+ resumeBlocked: false,
250
+ results: [],
251
+ };
252
+ }
253
+
254
+ const d = event.data;
255
+ if (typeof d.resumeBlocked === "boolean") snapshot.resumeBlocked = d.resumeBlocked;
256
+ if (isRunMode(d.mode)) snapshot.mode = d.mode;
257
+ if (isRunState(d.state)) snapshot.state = d.state;
258
+ if (typeof d.startedAt === "number") snapshot.startedAt = d.startedAt;
259
+ if (typeof d.endedAt === "number") snapshot.endedAt = d.endedAt;
260
+ if (Array.isArray(d.taskPreviews) && d.taskPreviews.every((x) => typeof x === "string")) {
261
+ snapshot.taskPreviews = [...d.taskPreviews];
262
+ }
263
+ if (typeof d.summary === "string") snapshot.summary = d.summary;
264
+ if (typeof d.delivered === "boolean") snapshot.delivered = d.delivered;
265
+ if (Array.isArray(d.results)) {
266
+ snapshot.results = d.results.map(normalizeResult).filter((r): r is PersistedResult => !!r);
267
+ }
268
+
269
+ if (event.type === "checkpoint" && typeof d.childSessionId === "string") {
270
+ const index = typeof d.resultIndex === "number" ? d.resultIndex : 0;
271
+ const result = snapshot.results[index];
272
+ if (result) result.sessionId = d.childSessionId;
273
+ }
274
+ if (event.type === "delivered" || event.type === "dismissed") snapshot.delivered = true;
275
+
276
+ snapshots.set(event.id, snapshot);
277
+ }
278
+
279
+ for (const [id, snapshot] of snapshots) {
280
+ if (snapshot.state === "running" || snapshot.state === "queued") {
281
+ // "lost" means ownership cannot be proven after a parent disruption.
282
+ // Orphan reconcile (ProcessLockManager) must have run before callers
283
+ // consider the session safe to resume; rebuild keeps resumeBlocked set
284
+ // until a later reconciler clears it.
285
+ snapshots.set(id, {
286
+ ...snapshot,
287
+ state: "lost",
288
+ endedAt: Date.now(),
289
+ resumeBlocked: true,
290
+ summary:
291
+ snapshot.summary ||
292
+ "Run ownership was lost (parent interrupted). Resume is blocked until orphan reconciliation confirms the child is dead.",
293
+ });
294
+ }
295
+ }
296
+
297
+ return snapshots;
298
+ }
299
+
300
+ markDelivered(id: string, sessionKey: string): void {
301
+ this.persist(id, sessionKey, "delivered", { delivered: true });
302
+ }
303
+
304
+ /** All child session ids referenced by any run event on the active branch. */
305
+ referencedSessionIds(): Set<string> {
306
+ const ids = new Set<string>();
307
+ for (const entry of this.adapter.getEntries()) {
308
+ const event = unwrapEvent(entry);
309
+ if (!event) continue;
310
+ if (typeof event.data.childSessionId === "string") ids.add(event.data.childSessionId);
311
+ if (Array.isArray(event.data.results)) {
312
+ for (const result of event.data.results) {
313
+ const sessionId = (result as Partial<PersistedResult>)?.sessionId;
314
+ if (typeof sessionId === "string" && sessionId) ids.add(sessionId);
315
+ }
316
+ }
317
+ }
318
+ return ids;
319
+ }
320
+
321
+ /**
322
+ * Non-destructive planner. Filesystem scanning/deletion is intentionally
323
+ * outside persistence (see maintenance.ts). "keep" is the union of caller
324
+ * references and every child session referenced on the active branch.
325
+ */
326
+ planRetention(referencedSessionIds: Set<string>): { keep: string[]; candidates: string[] } {
327
+ const keep = new Set([...referencedSessionIds, ...this.referencedSessionIds()]);
328
+ if (!this.config.sessionRetentionDays || this.config.sessionRetentionDays <= 0) {
329
+ return { keep: [...keep], candidates: [] };
330
+ }
331
+ // Candidates are resolved by the filesystem sweep; the planner only fixes the keep set.
332
+ return { keep: [...keep], candidates: [] };
333
+ }
334
+ }