@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
package/README.md ADDED
@@ -0,0 +1,543 @@
1
+ # @cr1ms0n/pi-subagent
2
+
3
+ This is an independent community fork of **@parke.dev/pi-subagent 0.8.0**, originally by Luke Parke. It is not an official upstream release. The original MIT license and copyright are preserved. Upstream source: [LukasParke/pi-extensions](https://github.com/LukasParke/pi-extensions/tree/main/packages/pi-subagent).
4
+
5
+ This fork requires a user-owned `modelPolicy` in `~/.pi/subagent.json` before new subagent tasks can run, and displays actual models in the TUI.
6
+
7
+ Production-grade isolated subagents for [Pi](https://github.com/badlogic/pi-mono).
8
+
9
+ Delegate research, parallel exploration, and clean-context review to child Pi
10
+ processes. Named agent personas, cancellable background runs with completion
11
+ notifications and a live widget, mid-run steering, graceful budget wrap-ups,
12
+ automatic retry with model fallback, a stall watchdog, session resume and
13
+ context forking, worktree isolation with a diff/apply/discard loop, capability
14
+ profiles, a root/subagent/combined cost ledger, and a TUI inspector.
15
+
16
+ ## Install
17
+
18
+ Pi packages install from **npm**, **git**, or a **local path**:
19
+
20
+ ```bash
21
+ # npm (scoped; surfaces on the pi.dev gallery via the pi-package keyword)
22
+ # Note: unscoped "pi-subagent" is rejected by npm as too similar to "pi-sub-agent".
23
+ pi install npm:@cr1ms0n/pi-subagent@0.8.1
24
+
25
+ # latest npm
26
+ pi install npm:@cr1ms0n/pi-subagent
27
+
28
+ # npm is the supported package install path from the monorepo.
29
+ # For local development, install this workspace directly:
30
+ pi install /absolute/path/to/pi-extensions/packages/pi-subagent
31
+
32
+ # local checkout
33
+ pi install /absolute/path/to/pi-subagent
34
+ ```
35
+
36
+ Then start Pi normally. The package registers:
37
+
38
+ - tool: `subagent`
39
+ - command: `/subagents` (run inspector overlay)
40
+ - command: `/subagent-cost` (parent / subagent / combined usage on demand)
41
+
42
+ This fork is published manually after pack-content and offline checks — see
43
+ [docs/RELEASING.md](./docs/RELEASING.md).
44
+
45
+ ## Quick usage
46
+
47
+ ```ts
48
+ // Single foreground task; `model` must exactly match ~/.pi/subagent.json modelPolicy.
49
+ { task: "Find all call sites of parseConfig and summarize patterns.", description: "Map parseConfig usage", model: "<provider/model-id>" }
50
+
51
+ // Named agent — persona prompt from .pi/agents/reviewer.md; model still comes from modelPolicy.
52
+ { task: "Review this diff for security issues", agent: "reviewer", model: "<provider/model-id>" }
53
+
54
+ // Parallel read-only explorers (default profile: explore); each task needs its mapped model.
55
+ {
56
+ tasks: [
57
+ { task: "Map auth middleware flow", description: "Auth flow map", model: "<provider/model-id>" },
58
+ { task: "List all env vars used in server/", description: "Env var inventory", model: "<provider/model-id>" }
59
+ ]
60
+ }
61
+
62
+ // Parallel research with automatic fan-in: one read-only child folds all
63
+ // outputs into a single brief, delivered first.
64
+ {
65
+ tasks: [
66
+ { task: "Audit backend error handling", description: "Backend audit", model: "<exact model from current modelPolicy route>" },
67
+ { task: "Audit frontend error handling", description: "Frontend audit", model: "<exact model from current modelPolicy route>" }
68
+ ],
69
+ synthesis: "Merge both audits into one prioritized findings list"
70
+ }
71
+
72
+ // Background run — model is still required; the live widget shows the actual model
73
+ // and changes when a configured fallback is selected.
74
+ { task: "Audit dependency licenses", model: "<provider/model-id>", async: true }
75
+ // later
76
+ { action: "status", id: "abc123" }
77
+ { action: "wait", id: "abc123" } // interruptible; does not cancel
78
+ { action: "cancel", id: "abc123" }
79
+
80
+ // Collecting a background run also has its own tool, for reflexive mid-flow
81
+ // use. Identical semantics to action:"wait" (same handler underneath).
82
+ // A timeout returns a still-running notice WITHOUT cancelling or consuming
83
+ // the run, so it stays collectable.
84
+ subagent_wait { id: "abc123" }
85
+ subagent_wait { id: "abc123", timeout_ms: 30000 }
86
+
87
+ // Dry-run a spawn request: full validation + preflights (git repo, fork
88
+ // session, output paths), returns the resolved per-task plan (model, tools,
89
+ // budgets, isolation) without spawning anything.
90
+ { action: "plan", tasks: [{ task: "Implement feature A", model: "<exact model from current modelPolicy route>", isolation: "worktree" }] }
91
+
92
+ // Structured output: the child must end with a fenced json:result block
93
+ // matching the schema. Invalid output gets one automatic repair round;
94
+ // delivery is the clean JSON and details carry the parsed object.
95
+ {
96
+ task: "Audit the auth module",
97
+ model: "<exact model from current modelPolicy route>",
98
+ output_schema: {
99
+ type: "object",
100
+ required: ["findings", "risk"],
101
+ properties: {
102
+ findings: { type: "array", items: { type: "string" } },
103
+ risk: { type: "string", enum: ["low", "medium", "high"] }
104
+ }
105
+ }
106
+ }
107
+
108
+ // Fork the parent conversation into the child (needs a persisted session).
109
+ // The child starts from a branched copy of everything discussed so far.
110
+ { task: "Implement the plan we agreed on", model: "<exact model from current modelPolicy route>", context: "fork", profile: "general" }
111
+
112
+ // Budgets with graceful wrap-up: at the limit the child is steered to produce
113
+ // a final answer and given grace turns before any hard stop.
114
+ { task: "Audit deps", model: "<exact model from current modelPolicy route>", max_turns: 15, grace_turns: 2 }
115
+
116
+ // Automatic retry uses the configured fallback order. If fallback_models is
117
+ // supplied, it must exactly match modelPolicy; otherwise omit it.
118
+ { task: "Research X", model: "<provider/model-id>", max_retries: 1 }
119
+
120
+ // Steer a running child mid-run instead of cancel + retry. The message is
121
+ // delivered after the current assistant turn, before the next LLM call.
122
+ { action: "steer", id: "abc123", message: "Skip the tests directory; focus on src/" }
123
+ // Parallel runs: pass index to pick one live task.
124
+ { action: "steer", id: "abc123", index: 1, message: "Wrap up now" }
125
+
126
+ // Resume a child session
127
+ { task: "Continue from your findings and propose a fix plan", model: "<exact model from current modelPolicy route>", resume: "<session-id>" }
128
+
129
+ // Isolated writers
130
+ {
131
+ tasks: [
132
+ { task: "Implement feature A", model: "<exact model from current modelPolicy route>", profile: "general", isolation: "worktree" },
133
+ { task: "Implement feature B", model: "<exact model from current modelPolicy route>", profile: "general", isolation: "worktree" }
134
+ ]
135
+ }
136
+
137
+ // Close the worktree loop after the run finishes:
138
+ { action: "diff", id: "abc123", index: 0 } // inspect the patch
139
+ { action: "apply", id: "abc123", index: 0 } // land as uncommitted changes in your checkout
140
+ { action: "discard", id: "abc123", index: 1 } // drop worktree + branch
141
+ ```
142
+
143
+ The `/subagents` overlay mirrors the worktree loop interactively: `s` steer,
144
+ `a` apply, `x` discard on the selected run.
145
+
146
+ ## Side questions (`/btw`)
147
+
148
+ ```
149
+ /btw does this repo have a rate limiter?
150
+ /btw # prompts for the question
151
+ ```
152
+
153
+ `/btw` runs a one-off read-only subagent for _you_, not for the model. It uses
154
+ the same policy, budget, semaphore and process-lock machinery as any run, but
155
+ delivers its answer as a custom session entry, which does not participate in
156
+ LLM context. The main agent keeps working and never sees the question or the
157
+ answer — useful for checking something mid-task without derailing the
158
+ conversation or polluting the context window.
159
+
160
+ ## Backends
161
+
162
+ Children can run on a different agent CLI. Everything else — worktrees, process
163
+ locks, depth limits, budgets, orphan reclaim — is backend-agnostic and applies
164
+ unchanged.
165
+
166
+ ```ts
167
+ { task: "Summarize this module", model: "<exact model from current modelPolicy route>", backend: "codex", profile: "explore" }
168
+ { task: "Review this diff", model: "<exact model from current modelPolicy route>", backend: "claude", max_cost: 0.50 }
169
+ ```
170
+
171
+ Requires the corresponding CLI on PATH (`codex`, `claude`). Capabilities differ,
172
+ and **unsupported combinations are refused with an explanation rather than
173
+ silently ignored** — a dropped `max_cost` or unenforced read-only profile would
174
+ be a safety regression, not a minor degradation.
175
+
176
+ | | `pi` (default) | `codex` | `claude` |
177
+ | ------------------------------- | -------------- | ------------------------------------- | ---------------------- |
178
+ | `max_cost` | yes | **refused** (reports tokens, no cost) | yes (`total_cost_usd`) |
179
+ | read-only profile | tool allowlist | `--sandbox read-only` (OS-level) | `--allowedTools` |
180
+ | steering / graceful wrap-up | yes | **no** (no stdin channel) | **no** (one-shot) |
181
+ | `resume` | yes | yes | yes |
182
+ | `context:'fork'`, `fork_resume` | yes | **refused** | yes |
183
+ | `thinking` | yes | no | no |
184
+ | `output_schema` | yes | yes | yes |
185
+
186
+ A budget breach on a backend without steering hard-stops instead of asking the
187
+ child to wrap up. Codex's read-only sandbox is enforced by the OS, which is
188
+ stronger than a tool allowlist.
189
+
190
+ Set a persona's backend in agent frontmatter with `backend: codex`.
191
+
192
+ ## Profiles
193
+
194
+ | Profile | Tools | Writes |
195
+ | ---------------------------- | ------------------------------- | ------------------------------------ |
196
+ | `explore` (parallel default) | read/grep/find/ls + safe extras | no |
197
+ | `review` | same as explore | no |
198
+ | `general` | inherited active tools | yes if tools include bash/edit/write |
199
+
200
+ Parallel write-capable tasks sharing one checkout are rejected unless each uses
201
+ `isolation: "worktree"`, distinct `cwd`, or explicit `allow_shared_writes: true`.
202
+
203
+ ## Configuration
204
+
205
+ Defaults can be overridden in `~/.pi/subagent.json` and per-field via env vars
206
+ (env wins over file):
207
+
208
+ | Setting | Env var | Default |
209
+ | ----------------------- | ------------------------------------- | ------------------------------------- |
210
+ | `maxTasksPerRun` | `PI_SUBAGENT_MAX_TASKS` | 8 |
211
+ | `maxActiveProcesses` | `PI_SUBAGENT_MAX_ACTIVE` | 4 |
212
+ | `maxQueuedTasks` | `PI_SUBAGENT_MAX_QUEUED` | 32 |
213
+ | `maxGlobalActive` | `PI_SUBAGENT_MAX_GLOBAL_ACTIVE` | 16 |
214
+ | `defaultTimeoutMs` | `PI_SUBAGENT_TIMEOUT_MS` | 900000 |
215
+ | `maxDepth` | `PI_SUBAGENT_MAX_DEPTH` | 2 |
216
+ | `killGraceMs` | `PI_SUBAGENT_KILL_GRACE_MS` | 3000 |
217
+ | `sessionDir` | `PI_SUBAGENT_SESSION_DIR` | `~/.pi/subagent-sessions` |
218
+ | `worktreeDir` | `PI_SUBAGENT_WORKTREE_DIR` | `~/.pi/subagent-worktrees` |
219
+ | `lockDir` | `PI_SUBAGENT_LOCK_DIR` | `~/.pi/subagent-locks` |
220
+ | `worktreeRetentionDays` | `PI_SUBAGENT_WORKTREE_RETENTION_DAYS` | unused (lifecycle GC) |
221
+ | `sessionRetentionDays` | `PI_SUBAGENT_SESSION_RETENTION_DAYS` | unused (lifecycle GC) |
222
+ | `lockRetentionDays` | `PI_SUBAGENT_LOCK_RETENTION_DAYS` | 7 |
223
+ | `taskDefaults` | — | none |
224
+ | `graceTurns` | `PI_SUBAGENT_GRACE_TURNS` | 2 |
225
+ | `stallAfterMs` | `PI_SUBAGENT_STALL_AFTER_MS` | 90000 |
226
+ | `stallKillAfterMs` | `PI_SUBAGENT_STALL_KILL_AFTER_MS` | 90000 |
227
+ | `maxRetries` | `PI_SUBAGENT_MAX_RETRIES` | 1 |
228
+ | `widget` | `PI_SUBAGENT_WIDGET` | `background` (`off` disables) |
229
+ | `notifications` | `PI_SUBAGENT_NOTIFICATIONS` | `batched` (`off` disables) |
230
+ | (bin) | `PI_SUBAGENT_BIN` | auto (`process.execPath` + CLI entry) |
231
+
232
+ ### Model policy
233
+
234
+ New spawns are routed only by the user-owned `modelPolicy` in `~/.pi/subagent.json`.
235
+ Agent frontmatter, `taskDefaults`, parent-session model inheritance, and ad-hoc
236
+ fallback lists are ignored for model selection. The minimal template is:
237
+
238
+ ```json
239
+ {
240
+ "modelPolicy": {
241
+ "default": { "model": "<provider/model-id>", "fallbackModels": [] },
242
+ "agents": {
243
+ "<agent-name>": { "model": "<provider/model-id>", "fallbackModels": [] }
244
+ }
245
+ }
246
+ }
247
+ ```
248
+
249
+ Every new `task`/`tasks[]` item must pass the exact mapped `model`. Omit
250
+ `fallback_models` to use the configured list; when supplied it must match the
251
+ configured order exactly. Management actions remain available when this file is
252
+ missing, but new spawns and synthesis are rejected until it is valid. The
253
+ extension re-reads the policy on each dispatch and injects the current mapping
254
+ into the parent prompt. No provider catalog or credentials are read.
255
+
256
+ This guarantee applies to the Extension tool dispatch and its internal synthesis
257
+ handoff. The stable SDK exports (`runTasks` and `runSubagent`) are trusted
258
+ low-level library APIs and intentionally do not load the Extension's
259
+ `modelPolicy`; library callers must parse and validate their own policy before
260
+ passing `TaskSpec` values. Do not treat direct SDK calls as policy enforcement.
261
+
262
+ ### Named agent files
263
+
264
+ Define reusable subagent personas as markdown files, discovered from the same
265
+ conventional roots skills use (higher root wins name conflicts):
266
+
267
+ | Priority | Location | Scope |
268
+ | -------- | ----------------------------------------------------------------------- | --------------------------- |
269
+ | 1 | `.pi/agents/<name>.md` | project (authoritative) |
270
+ | 2 | `.agents/agents/<name>.md` | shared cross-tool workspace |
271
+ | 3 | `$PI_CODING_AGENT_DIR/agents/<name>.md` (default `~/.pi/agent/agents/`) | global |
272
+
273
+ The markdown body becomes the child's appended system prompt; frontmatter
274
+ supplies defaults using the same snake_case names as the tool parameters:
275
+
276
+ ```md
277
+ ---
278
+ description: Security-focused code reviewer
279
+ # Legacy model/fallback fields are ignored; use modelPolicy instead.
280
+ thinking: high
281
+ profile: review
282
+ max_turns: 20
283
+ spawns: false # or "*", "scout", "[reviewer, scout]"
284
+ ---
285
+
286
+ You are a security auditor. Review code for injection flaws, auth issues,
287
+ and sensitive data exposure. Report findings with file:line evidence and
288
+ severity ratings.
289
+
290
+ @include shared/review-checklist.md
291
+ ```
292
+
293
+ Agent files may also pin a structured contract with
294
+ `output_schema: {"type": "object", …}` (single-line inline JSON) or
295
+ `output_schema: @contract.json` (path relative to the agent file).
296
+
297
+ `spawns:` controls which agents a child of this persona may spawn:
298
+ `false` disables further nesting (no tool registered in that child),
299
+ `"*"` (or omit) is unrestricted, and a comma/bracket list is an allowlist
300
+ (agentless tasks are rejected under an allowlist). The policy is passed to
301
+ the child via `PI_SUBAGENT_SPAWNS` and enforced on each subsequent spawn.
302
+
303
+ Body lines that consist solely of `@include relative/path.md` expand that
304
+ file one level deep (relative to the agent file, same 64KB/symlink guards
305
+ as `@contract.json`). Missing or rejected includes leave the line verbatim;
306
+ includes do not recurse.
307
+
308
+ Invoke with `{ task: "…", agent: "reviewer", model: "<provider/model-id>" }`.
309
+ The agent file supplies persona/capability defaults only; its legacy
310
+ `model`/`fallback_models` fields are ignored. An explicit `system_prompt`
311
+ appends after the persona body.
312
+ Profiles still enforce capability: an agent declaring `profile: review` with
313
+ write tools fails closed. The agent catalog is advertised in the tool's
314
+ system-prompt guidelines (session start) and in bare `status` output (live),
315
+ and file changes are picked up within seconds — no restart needed.
316
+
317
+ ### Per-profile task defaults
318
+
319
+ `taskDefaults` in `~/.pi/subagent.json` remains available for non-model
320
+ fields such as thinking, budgets, and retry counts. Its legacy `model` and
321
+ `fallbackModels` fields are ignored; model routing belongs only to
322
+ `modelPolicy`. Invalid fields are dropped field-by-field.
323
+
324
+ Notes on behavior:
325
+
326
+ - `timeout_ms` covers queue time plus runtime, but timed-out tasks report
327
+ `state: "timeout"` with `timeoutPhase: "queued"|"starting"|"running"` so
328
+ agents can retry capacity issues without confusing them for task failures.
329
+ - Budget stops (`max_turns`, `max_cost`) trigger a **graceful wrap-up**: the
330
+ child is steered to produce its final answer NOW and allowed `graceTurns`
331
+ more turns before SIGTERM. Results end as `partial` with `wrappedUp: true`
332
+ when the child concluded in time. `graceTurns: 0` restores immediate stops.
333
+ - A **stall watchdog** flags children with no protocol activity for
334
+ `stallAfterMs` (a liveness probe distinguishes quiet-but-thinking from dead),
335
+ then kills after `stallKillAfterMs` more silence — feeding automatic retry
336
+ instead of burning the whole timeout.
337
+ - **Transient failures retry automatically** (queue timeouts, stalls, spawn
338
+ errors, provider errors) up to `maxRetries` extra attempts, escalating
339
+ through `fallback_models` when provided. Usage accumulates across attempts;
340
+ results record `attempts` and `attemptedModels`. Task-quality failures
341
+ (nonzero exit with complete protocol, cancellations, budget stops, running
342
+ timeouts) never retry.
343
+ - `context: "fork"` starts a single child from a real branched copy of the
344
+ parent conversation (`--fork` on the parent's session file). It requires a
345
+ persisted parent session, cannot combine with `resume`, and is rejected for
346
+ parallel fanout (context duplication × N is a cost bug, not a feature).
347
+ - **Structured output** (`output_schema`): the contract is appended to the
348
+ child's system prompt; the final message must end with a fenced
349
+ `json:result` block. Validation runs parent-side against a dependency-free
350
+ JSON-Schema subset (type/properties/required/items/enum/const — unknown
351
+ keywords are ignored, never rejected). Invalid output triggers **one
352
+ steer-based repair round**; still-invalid results end `partial` with
353
+ `structuredError` set and the raw text delivered — paid work is never
354
+ discarded. Validated parallel results feed the `synthesis` child as clean
355
+ JSON instead of prose.
356
+ - **Arg repair**: double-encoded task text (literal `\n` / `\"` escapes from
357
+ LLM re-encoding) is conservatively de-mangled once at validation time.
358
+ Identifier fields and paths are never touched.
359
+ Protocol streams truncated after useful assistant output also end as `partial`.
360
+ - Aborting a `wait` returns immediately without cancelling the background run.
361
+ - Child processes are launched via the same Node runtime + CLI entry as the
362
+ parent when possible (`PI_SUBAGENT_BIN` overrides). Bare `pi` on PATH is only
363
+ a logged last resort.
364
+ - Direct resume is exclusive **across processes** via durable locks under
365
+ `lockDir`. Lost runs block resume until startup orphan reconciliation kills
366
+ (or confirms dead) the recorded child process group.
367
+ - `maxGlobalActive` bounds concurrent children across every Pi parent process
368
+ on the machine (in addition to the per-session semaphore).
369
+ - Nested children at the depth ceiling do not re-register the subagent tool;
370
+ only top-level parents run maintenance/orphan reclaim/worktree GC.
371
+ - Preserved worktrees live under `worktreeDir` (durable, not `/tmp`) and are
372
+ garbage-collected on startup by **lifecycle**, not wall-clock retention: once
373
+ a run is over (not live, past a 1h concurrency race guard), the worktree's
374
+ unique work is archived as one applyable patch under
375
+ `<repo-container>/_patches/` and the directory is reclaimed immediately.
376
+ Branches holding commits that exist on no other ref are never deleted.
377
+ `diff`/`apply`/`discard` transparently fall back to the archived patch when
378
+ the directory is already gone. Live runs are never swept: the current
379
+ session's live worktrees plus any worktree recorded on a running run record
380
+ (concurrent Pi processes) are shielded machine-wide.
381
+ - Startup GC sweeps **every** repo container under `worktreeDir`, not just the
382
+ current checkout's, so repos you stop visiting are still reclaimed. A
383
+ container whose base repo no longer exists is kept and reported, never
384
+ deleted — its worktrees' object stores lived inside the deleted repo, so
385
+ unique work cannot be distinguished from a pristine checkout, let alone
386
+ archived. Empty containers (no worktrees, no archived patches) are removed.
387
+ - Child session transcripts are likewise distilled on lifecycle: when a run is
388
+ over and nothing on the parent branch references its session, the transcript
389
+ is reduced to a small `.digest.json` (task, final output, model, usage,
390
+ turn/tool/error counts) and the raw `.jsonl` is deleted. Resume needs the
391
+ transcript, so anything referenced or busy machine-wide is kept.
392
+ - `keep_background: true` on a task keeps processes the child intentionally
393
+ backgrounded (e.g. dev servers) alive after a clean exit.
394
+ - `include_wip: true` (with `isolation: "worktree"`) seeds the worktree with the
395
+ parent checkout's uncommitted changes so the child sees your dirty baseline.
396
+ `diff`/`apply` subtract that baseline when clean, else report the combined
397
+ delta with an explicit `[includes parent WIP]` warning.
398
+
399
+ ## Using the runner as a library
400
+
401
+ Import the stable public SDK from the package root or the explicit `/sdk`
402
+ subpath — do not reach into `src/*` internals (those paths are not part of the
403
+ supported contract):
404
+
405
+ ```ts
406
+ import {
407
+ runTasks,
408
+ runSubagent,
409
+ ChildRunner,
410
+ WorktreeManager,
411
+ Semaphore,
412
+ ProcessLockManager,
413
+ addUsage,
414
+ normalizeUsage,
415
+ emptyUsage,
416
+ type TaskSpec,
417
+ type TaskResult,
418
+ type RunState,
419
+ type UsageStats,
420
+ } from "@parke.dev/pi-subagent/sdk";
421
+ ```
422
+
423
+ The package root is an alias for the same SDK:
424
+ `import { runTasks } from "@cr1ms0n/pi-subagent"`.
425
+
426
+ The Extension dispatch path requires `model` to be the exact value from the
427
+ current `modelPolicy` route. This placeholder is illustrative only and does not
428
+ write or select a real configuration:
429
+
430
+ ```ts
431
+ const routeModel = "<exact model from current modelPolicy route>";
432
+ const task: TaskSpec = {
433
+ task: "Audit src/ for unsafe parsing",
434
+ profile: "explore",
435
+ model: routeModel,
436
+ timeoutMs: 10 * 60_000,
437
+ };
438
+ ```
439
+
440
+ Prefer `runTasks()` for multi-task / worktree orchestration (same path the
441
+ extension and pi-workflows use). `runSubagent()` runs a single child process
442
+ directly without the extension host, but durable coordination is **opt-in**.
443
+ Pass both `locks` (a `ProcessLockManager`) and a stable `runId` if you want
444
+ global concurrency slots and orphan reclaim to see the child. Without those
445
+ options no durable run record is written, so a parent restart cannot reclassify
446
+ the process and nested children vanish from reconcile. There is intentionally
447
+ no implicit default lock manager — embedding code that needs durability must
448
+ construct and share one.
449
+
450
+ The Pi extension entry is unchanged: package `pi.extensions` still points at
451
+ `./extensions/subagent.ts`.
452
+
453
+ ## Design invariants
454
+
455
+ 1. A run belongs to one parent session and cannot update another session.
456
+ 2. Per-session + machine-wide process caps and nesting depth limits prevent process storms.
457
+ 3. Cancellation prevents queued tasks from spawning.
458
+ 4. Direct resume of a child session is exclusive **across processes** via durable locks.
459
+ 5. Tool responses are capped to ~50KB/2000 lines; full output lives in
460
+ artifacts and `~/.pi/subagent-sessions`.
461
+ 6. Status is compact; wait is the one-shot deliverable.
462
+ 7. On parent session shutdown, live children are aborted and awaited briefly.
463
+ 8. On parent (re)start, orphan process groups recorded under `lockDir` are reaped
464
+ before any resume is allowed for the matching child session.
465
+ 9. Provider-reported usage is counted once per root message and terminal child run.
466
+ 10. Protocol completion prefers `agent_settled` (falls back to non-retrying `agent_end`).
467
+
468
+ ## Layout
469
+
470
+ ```
471
+ src/
472
+ index.ts # stable public SDK entry (@parke.dev/pi-subagent)
473
+ extension.ts # Pi wiring only
474
+ schema.ts # request schemas (subagent + subagent_wait)
475
+ btw.ts # /btw side questions (model-hidden entries)
476
+ backend.ts # backend adapter seam + capability gate
477
+ backends/ # pi | codex | claude adapters (invocation + parser)
478
+ policy.ts # profiles, normalization, write guards, agent resolution
479
+ agents.ts # named agent files (.pi/agents/, .agents/agents/, global)
480
+ launch.ts # resolve child pi via execPath / PI_SUBAGENT_BIN
481
+ process-lock.ts # durable session locks, global slots, orphan records
482
+ worktree.ts # git worktree isolation + diff/apply/discard
483
+ orchestrator.ts # multi-task execution, transient retry + model fallback
484
+ runner.ts # child process lifecycle, RPC channel, steering,
485
+ # graceful budget wrap-up, stall watchdog
486
+ protocol.ts # Pi RPC/JSON event parser (agent_settled-aware)
487
+ semaphore.ts # per-session concurrency limit
488
+ registry.ts # session-scoped run state + durable resume locks
489
+ persistence.ts # parent-session event folding
490
+ usage.ts # root/subagent/combined usage ledger
491
+ output.ts # exact global output caps
492
+ notifications.ts # batched background-run completion notifications
493
+ format.ts / ui.ts# renderers, ambient widget, /subagents overlay
494
+ ```
495
+
496
+ ## Develop
497
+
498
+ ```bash
499
+ npm install
500
+ npm run typecheck
501
+ npm test
502
+ npm run pack:check
503
+ ```
504
+
505
+ Tests use a deterministic `fake-pi` child. No live model calls are required.
506
+
507
+ ## Cost accounting
508
+
509
+ `status`, `/subagent-cost`, and the `/subagents` overlay header show separate
510
+ **root**, **subagent**, and **combined** totals based on provider-reported
511
+ usage. On Pi builds after v0.80.10, delivered runs also report their total
512
+ usage natively on the tool result
513
+ ([pi#6671](https://github.com/earendil-works/pi/pull/6671)), so Pi's own
514
+ footer, `/session`, and RPC totals include subagent spend — exactly once per
515
+ run; older Pi hosts ignore the field. Nested usage reported by a child's tool
516
+ results (e.g. grandchild subagents) folds into the run's totals and budgets.
517
+ The extension footer stays terse (running/ready counts only). Delivery and
518
+ replay do not double count runs. See
519
+ [docs/COST-ACCOUNTING.md](./docs/COST-ACCOUNTING.md).
520
+
521
+ ## Roadmap
522
+
523
+ Planned work — agent spawn policies, dry-run validation, engine hardening,
524
+ live transcripts — lives in [docs/ROADMAP.md](./docs/ROADMAP.md) (rationale
525
+ and design sketches) and [docs/PLAN.md](./docs/PLAN.md) (execution contract:
526
+ work breakdown, acceptance criteria, test plans, and release gates per phase).
527
+
528
+ ## Security
529
+
530
+ See [docs/SECURITY.md](./docs/SECURITY.md). Pi packages run with full system
531
+ access—review source before installing third-party packages.
532
+
533
+ ## Status
534
+
535
+ v0.1 focuses on the correct lifecycle engine:
536
+
537
+ - process + session ownership
538
+ - budgets, caps, profiles
539
+ - persistence + inspector
540
+ - worktree isolation helpers
541
+
542
+ Named agent catalogs and automatic chain workflows are intentionally deferred
543
+ until the core is battle-tested.
@@ -0,0 +1,125 @@
1
+ # Architecture contract
2
+
3
+ `pi-subagent` is split by ownership boundary:
4
+
5
+ - `runner.ts`: one child process, Pi RPC protocol (JSONL commands on stdin, events on
6
+ stdout — a superset of `--mode json`), cancellation, process trees, budgets, and a live
7
+ stdin command channel used for mid-run steering. Extension UI dialogs from headless
8
+ children are auto-cancelled so they can never hang a run; stdin is closed after
9
+ `agent_settled` so RPC children shut down cleanly. Budget breaches steer a wrap-up
10
+ message and allow grace turns before SIGTERM (`wrappedUp` marks a clean conclusion).
11
+ A stall watchdog flags protocol silence, probes liveness via `get_state`, and kills
12
+ after a second window so retry can take over. Group kills verify process start-time
13
+ identity (Linux `/proc`, macOS/BSD `ps lstart`) before signalling a possibly-recycled
14
+ PID; transcript joins happen only on message boundaries, not per-chunk ticks.
15
+ - Retry with model fallback lives in `orchestrator.ts` (`isTransientFailure`): queue
16
+ timeouts, stalls, spawn errors, and provider errors re-run the same spec on the next
17
+ fallback model with accumulated usage; task-quality failures never retry.
18
+ - `context: "fork"` spawns the child with `--fork <parent session file>` so it starts
19
+ from a real branched copy of the parent conversation. Fail-fast when the parent
20
+ session is not persisted; single-task only.
21
+ - `registry.ts`: one parent-session runtime, run state, snapshots, resume locks, and the
22
+ single LiveRun→snapshot/persisted-result projections used by every consumer.
23
+ - `semaphore.ts`: per-parent-runtime child-process limit.
24
+ - `process-lock.ts`: machine-wide durable coordination under `~/.pi/subagent-locks/` —
25
+ exclusive per-child-session resume locks, global concurrency slots, and run process
26
+ identity records for orphan reconcile.
27
+ - `launch.ts`: resolve the child `pi` invocation via `PI_SUBAGENT_BIN` or
28
+ `process.execPath` + CLI entry (bare PATH name only as last-resort fallback).
29
+ - `persistence.ts`: versioned active-branch event folding and bounded child transcript metadata.
30
+ - `maintenance.ts`: filesystem GC (session files) and abort-race helpers; kept out of persistence.
31
+ - `usage.ts`: provider-reported root/subagent/combined accounting.
32
+ - `policy.ts` / `schema.ts`: discriminated request validation and safe capability profiles.
33
+ - `config.ts`: defaults ← `~/.pi/subagent.json` ← `PI_SUBAGENT_*` env overrides.
34
+ - `structured.ts`: structured-output contract (dependency-free JSON-Schema subset
35
+ validation, fenced json:result extraction, contract/repair prompts) and
36
+ conservative double-encoded-arg repair. The runner gates the child's settle on
37
+ validation and runs one steer-based repair round before accepting failure.
38
+ - `agents.ts`: named agent files (`.pi/agents/`, `.agents/agents/`, global agent dir).
39
+ Flat-YAML frontmatter + markdown persona body; resolved in policy with explicit
40
+ params > agent file > profile taskDefaults > parent inheritance. Catalog refreshes
41
+ lazily (5s TTL) so new files work mid-session; symlinks and oversized files skipped.
42
+ - `notifications.ts`: background-run completion batching. Successes group within a
43
+ debounce window (hard cap on hold time); failures bypass batching and flush
44
+ immediately; delivered-state is re-checked at flush time so a consuming `wait`
45
+ suppresses the redundant notification.
46
+ - `ui.ts`: renderers, footer status and `/subagents` inspector. The ambient widget
47
+ (extension-side) shows BACKGROUND runs only — foreground runs render inline as the
48
+ tool result, so widget display would double-render them.
49
+ - `extension.ts`: wiring only; no business logic. Nested children at the depth ceiling do
50
+ not re-register the tool; only top-level parents run maintenance.
51
+
52
+ Invariants:
53
+
54
+ 1. A run belongs to exactly one parent session key and cannot update another session.
55
+ 2. No more than `maxActiveProcesses` children run per extension runtime, and no more than
56
+ `maxGlobalActive` across every Pi parent process on the machine.
57
+ 3. Cancellation prevents queued tasks from spawning.
58
+ 4. A child session may have only one direct resume writer at a time, enforced by an
59
+ in-memory lock *and* a durable file lock under `lockDir` that survives crashes and
60
+ coordinates across independent parent processes.
61
+ 5. Parallel write-capable tasks need isolated worktrees/distinct cwd or explicit unsafe opt-in.
62
+ 6. Every tool response is globally capped to 50 KB / 2,000 lines; full data lives in artifacts/transcripts.
63
+ 7. Status is compact; wait is the one-shot deliverable.
64
+ 8. On shutdown or tree navigation, child runs are cancelled and awaited for a bounded grace period.
65
+ 9. On startup, orphan process groups recorded under `lockDir` are reaped (SIGTERM then SIGKILL)
66
+ before any matching child session is eligible for resume. `$state: "lost"` is a labeling
67
+ that keeps `resumeBlocked` until reconciliation proves death.
68
+ 10. Billed usage is folded once per root message and once per full child run UUID.
69
+ 11. Checkpoint persistence events are lightweight (state, usage, process identity, pointers).
70
+ Full transcripts and final output are persisted exactly once, in the terminal event.
71
+ 12. High-frequency registry "changed" events coalesce (trailing window); state transitions,
72
+ new child sessions, billed-usage advances, and terminal events flush immediately.
73
+ 13. `wait` is interruptible: aborting a wait returns promptly and does NOT cancel the
74
+ background run. Only `cancel` (or parent shutdown) aborts a run.
75
+ 14. Budget stops (`max_turns`/`max_cost`) with at least one completed turn end as `partial`
76
+ and deliver their output normally. Streams truncated after useful assistant output also
77
+ end as `partial`. Timeouts report `state: "timeout"` with `timeoutPhase`.
78
+ 15. `timeout_ms` covers the whole task, including semaphore queue time, but the phase
79
+ (queued / starting / running) is recorded so agents can apply the right retry policy.
80
+ 16. Worktrees live under a durable root (`~/.pi/subagent-worktrees`), never a purgeable OS
81
+ tmpdir. Startup maintenance (top-level parents only) prunes stale git registrations,
82
+ removes unchanged leftovers, and sweeps changed-but-expired worktrees. Live-run
83
+ worktrees are always shielded. `include_wip` worktrees carry the parent's WIP patch in
84
+ the handle: `diff`/`apply` subtract it when subtraction is clean and otherwise report
85
+ the combined delta with an explicit `[includes parent WIP]` warning — never silently
86
+ wrong; a worktree containing only the untouched WIP patch counts as unchanged.
87
+ 17. Process-tree reaping after a clean exit can be disabled per task with `keep_background`
88
+ (for legitimately backgrounded work such as dev servers); forced stops always reap.
89
+ 18. Protocol completion prefers Pi's `agent_settled` event. Legacy `agent_end` without
90
+ `willRetry` is accepted for older Pi builds; `agent_end` with `willRetry: true` is
91
+ treated as non-terminal.
92
+ 19. Depth and spawn-policy parsing fail closed on malformed values: env scrubbing cannot
93
+ silently reset the depth counter to top-level, and a malformed `PI_SUBAGENT_SPAWNS`
94
+ disables spawning rather than unrestricting it.
95
+ 20. Budget breaches (`max_turns`/`max_cost`) steer a wrap-up message and allow grace
96
+ turns before SIGTERM; a child that concludes within grace ends `partial` with
97
+ `wrappedUp: true`. `graceTurns: 0` restores immediate stops.
98
+ 21. Transient failures (queued timeout, stall, spawn error, provider error, protocol
99
+ truncation) retry up to `maxRetries` extra attempts, escalating through
100
+ `fallback_models`; usage accumulates across attempts and `attemptedModels` is
101
+ recorded. Task-quality failures (nonzero exit with complete protocol, cancellation,
102
+ budget stop, running timeout) never retry.
103
+ 22. The stall watchdog treats protocol silence as suspect, not fatal: after
104
+ `stallAfterMs` the task is flagged and probed via `get_state` (a live child's
105
+ answer clears the flag); only continued silence for `stallKillAfterMs` more kills
106
+ the child — which is then a transient failure eligible for retry.
107
+ 23. Only `async: true` runs notify on completion and appear in the ambient widget.
108
+ Notification delivery respects delivered-once: a `wait` that consumed the run
109
+ suppresses the notification.
110
+ 24. Named agent files supply per-field defaults only; explicit request params always
111
+ win, and capability profiles fail closed regardless of what an agent file declares.
112
+ 25. Structured-output validation never discards paid work: schema failure after the
113
+ repair round downgrades completed → partial with `structuredError`, and the raw
114
+ text still delivers. Validation is enforced on the parent side of the process
115
+ boundary — the child cannot self-attest.
116
+ 26. Arg repair only decodes free-text fields with high-signal escape patterns
117
+ (literal \n or \") and no real newlines; identifier fields, tool lists, and
118
+ Windows-path-like strings are never modified.
119
+ 27. Global slots are depth-tiered: `tryAcquireGlobalSlot(runId, depth)` admits only while
120
+ `activeAtOrBelowDepth(depth) < maxGlobalActive - reservedFor(depth)`, holding slots
121
+ back for deeper tiers so a full-width spawn tree cannot deadlock on its own children.
122
+ Slot records without a `depth` field count as depth 0.
123
+ 28. `action: "plan"` is a truth oracle: it runs the exact validation and preflights of a
124
+ real spawn and returns the resolved plan without spawning — never a softer check, and
125
+ never a registry entry.