pi-jar 0.1.5 → 0.1.7

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/README.md CHANGED
@@ -25,7 +25,7 @@ The showcase is captured from a real pi-jar terminal session: the torch-style `
25
25
  | **Tasks & questions** | A Claude/omp-style `jar_todo` checklist with subtasks and per-task progress that the agent maintains itself, and `jar_ask` structured questions with options, multi-select and free-form answers. |
26
26
  | **Change review** | Every file the agent or its editing subagents change is remembered as it was; `/diff` (<kbd>Ctrl</kbd>+<kbd>Alt</kbd>+<kbd>D</kbd>) shows changed files (each counted once) next to a colored diff, with accept or revert per file or all at once. |
27
27
  | **Background shells** | `jar_shell` runs dev servers, watchers and long tests in the background; the agent is woken when a watch pattern matches or the process exits, so it never polls. Running shells get a footer row; click it (or <kbd>Ctrl</kbd>+<kbd>Alt</kbd>+<kbd>A</kbd>) to tail or kill them. |
28
- | **Subagents** | The main agent becomes a moderator over a configurable retained pool (2, 4, 6, 8 or 16; default 4). `jar_delegate` spawns `scout`, `fork` or sandboxed `worktree` agents; `jar_subagent` peeks, steers, asks BTW questions, pauses, resumes and stops them. Workers can exchange bounded structured Q/A through `jar_discuss`; worktree changes stay isolated until stop, then safely enter `/diff`. |
28
+ | **Subagents** | The main agent becomes a moderator over a configurable retained pool (2, 4, 6, 8 or 16; default 4). `jar_delegate` starts `scout`, `fork` or sandboxed `worktree` agents and returns immediately, so the main agent can keep responding to user steering; Pi RPC event-bus messages deliver initial and resumed turn completions without polling. `jar_subagent` peeks, steers, asks BTW questions, pauses, resumes and stops them. Workers can exchange bounded structured Q/A through `jar_discuss`; worktree changes stay isolated until stop, then safely enter `/diff`. |
29
29
  | **Sessions & history** | Recent sessions (with their goal and plan) on the welcome card, one click from resuming; <kbd>Ctrl</kbd>+<kbd>Alt</kbd>+<kbd>H</kbd> searches earlier prompts; pasted images show as chips under the composer. |
30
30
  | **Footer & themes** | Responsive footer (model, effort, session, cwd, context, RAM, cost, quota, goal, roles, branch, live subagents and shells) with unicode, [Nerd Font](https://www.nerdfonts.com) or ascii icons, and seven dark themes. |
31
31
 
@@ -254,16 +254,17 @@ This is a pi-jar enhancement: [pi-advisor](https://github.com/philipbrembeck/pi-
254
254
  ```
255
255
  Use `parent` with `append` to add a genuinely new subtask to active work; `start`, `done`, `open`, `edit`, `list`, `add` and `delete` remain available.
256
256
  - **`jar_ask`** — structured questions: numbered options with descriptions, single or multi-select, *Type your own answer* (multi-line, paste-friendly) and *Chat about this* to discuss before choosing.
257
- - **`/diff`** — review what the agent (and its editing subagents) changed since the first edit to each file: files with `+/−` counts on the left, a numbered, colored diff on the right. `a` accept (keep, stop tracking), `r` then `y` revert (restore the original, or remove a file it created), `A`/`R` for all. The footer shows `± N files · /diff` while anything is unreviewed; each file counts once no matter who edited it. Only `edit`/`write` changes inside the project are tracked; shell-made changes are not.
258
- - **`jar_shell`** — `start` (with optional `name`, `watch` regex and `notify`), `list`, `output`, `kill`. Output is ANSI-stripped and bounded (2000 lines). When a watch matches or the process ends, a visible message wakes the agent (queued if it is busy). Each running shell gets a footer row; the activity view (`/jar shells`, a click on the row) tails it live (`x` kill, `f` follow). All shells stop when the session ends.
259
- - **`jar_delegate`** — spawns **retained session-scoped** subagents up to **Max subagents** in `/jar settings` (2, 4, 6, 8 or 16; default 4). Idle/paused children and live legacy workers count toward the shared limit, not the parent; over-cap batches are rejected before launch. Lowering the limit does not terminate existing children, but blocks new launches until there is room. The parent is given moderator context and should coordinate rather than duplicate their work. `mode: "scout"` is fresh/read-only and defaults to the `scout` role; `fork` is a true Pi session fork, read-only, and defaults to `reviewer`; `worktree` forks context and defaults to `worker`, editing inside a path-guarded disposable Git worktree. A settled retained agent becomes `idle`, keeping its process, model context and workspace for reuse. The old `write: true` direct shared-workspace mode remains one-shot for compatibility.
260
- - **`jar_subagent`** — moderator control plane: `peek` returns high-level task/checklist progress, current activity, changed files, remaining work, workspace and last report; `steer` redirects a running worker; `ask` injects a terse BTW question and waits for its answer; `pause` aborts the current operation but keeps context/workspace; `resume` wakes that same agent with an optional instruction; `stop` retires it and returns a structured handoff with progress, work directory, changed/applied files and what remains.
257
+ - **`/diff`** — review what the agent (and its editing subagents) changed since the first edit to each file: files with `+/−` counts on the left, a full-path header and numbered, colored diff on the right. `/` filters files; `t` switches unified/split views and `n`/`p` navigates hunks. Large changes offer summary or explicit windowed review (`v`), with clearly labeled coarse fallback and existing safety limits—not arbitrary-file streaming. See [diff review](docs/DIFF-REVIEW.md). `a` accept (keep, stop tracking), `r` then `y` revert (restore the original, or remove a file it created), `A`/`R` for all. The footer shows `± N files · /diff` while anything is unreviewed; each file counts once no matter who edited it. Only `edit`/`write` changes inside the project are tracked; shell-made changes are not.
258
+ - **`jar_shell`** — `start` (with optional `name`, `watch` regex, `notify` and task/service `purpose`), `list`, `peek`, bounded `wait`, `output`, `kill`. Output is ANSI-stripped and bounded (2000 lines). Watch/exit notifications are compact and coalesced; reads acknowledge observed events so obsolete completions do not replay. Relevant finite checks should be verified with `wait`/`peek` before claiming success; services should not block completion. Each running shell gets a footer row; the activity view (`/jar shells`, a click on the row) tails it live (`x` kill, `f` follow). All shells stop when the session ends.
259
+ - **`jar_delegate`** — spawns **retained session-scoped** subagents up to **Max subagents** in `/jar settings` (2, 4, 6, 8 or 16; default 4). Idle/paused children and live legacy workers count toward the shared limit, not the parent; over-cap batches are rejected before launch. Lowering the limit does not terminate existing children, but blocks new launches until there is room. The parent is given moderator context and should coordinate rather than duplicate their work. `mode: "scout"` is fresh/read-only and defaults to the `scout` role; `fork` is a true Pi session fork, read-only, and defaults to `reviewer`; `worktree` forks context and defaults to `worker`, editing inside a path-guarded disposable Git worktree. A settled retained agent becomes `idle`, keeping its process, model context and workspace for reuse. Launch returns before startup or the first turn completes, including the old one-shot `write: true` shared-workspace mode retained for compatibility.
260
+ - **`jar_subagent`** — moderator control plane: `peek` returns only task progress (done/total, the in-progress task, activity, the next open tasks), read from memory; `steer` redirects a running worker; `resume` wakes that same agent with an optional instruction. `ask`, `pause` and `stop` return **accepted operation receipts immediately**, never waiting for a child turn, Git reconciliation, or process shutdown; their answers and final handoffs arrive as `pi-jar.subagent` events. Acceptance does not mean a worker is already stopped or its files applied. Equivalent in-flight requests share an operation ID; conflicting controls are rejected, while `stop` can preempt an outstanding ask/pause. `pause` preserves context/workspace and `stop` reports progress, work directory, changed/applied files and what remains. When an initial or resumed turn ends, a visible `pi-jar.subagent` event-bus message wakes the moderator (or queues for its current run) with progress and the turn's report, so it can keep responding to user steering and never polls `peek`. Subagents use Pi's native `--mode rpc` JSONL protocol with streamed lifecycle events, not polling. Input queues and transcript buffers are bounded; shutdown signals the entire process group and drains inherited pipes for a bounded interval. Background control failures also produce a terminal event. A pause attempt settles or reports failure after 15 s, and a working child with no RPC activity for 10 minutes is stopped as hung.
261
+ - **Subagent capabilities** — each task can set `tools` or `inheritTools: true`; known-tool mode filters and child guards remain in force. See [tool capabilities](docs/SUBAGENT-TOOLS.md).
261
262
  - **Worktree finalization** — worktree edits do **not** touch the parent merely because a worker becomes idle. They remain private across pause/resume cycles. On `jar_subagent stop`, pi-jar verifies parent drift and reviewability, applies safe changes, and adds them to `/diff`; conflicts remain isolated and the workspace path is reported. Resolve parent drift and retry `stop` to reconcile; failed workspaces are retained rather than trimmed out of report history. Process closure is awaited before finalization. All replacements/rollback backups are prepared before parent mutation; failed multi-file application rolls back rather than silently leaving a partial result.
262
- - **`jar_democracy`** — **exceptional only**, for a persistent **super-complex** issue with several viable options and evidence of at least two distinct failed approaches. The moderator opens 2–8 evidence-backed options, resumes explicitly relevant idle/paused **fresh-context read-only scouts** or spawns fresh scouts within the same pool limit, and collects one private ballot per voter before publishing the tally. A **strict majority of the invited electorate** wins; ties, plurality without majority, and missing/invalid ballots require the user to choose among leading options, with evidence/tradeoffs rather than invented probabilities. TUI uses the structured question picker; RPC returns `needs-user` for the moderator to resolve with `jar_ask`. Voting returns a recommendation and **never implements it** or bypasses safety/approval. Prefer new scouts if past discussions could bias reused agents. Do not use democracy for ordinary complexity or routine decisions.
263
- - **`jar_discuss`** — a bounded shared discussion paper for virtual agent-to-agent discussion. Questions and answers have stable IDs plus author/target metadata; entries are capped at 1600 characters, the newest 64 are retained, and serialized UTF-8 is capped at 64 KiB (older entries are evicted until it fits). Agents use it for narrow Q/A, not as another transcript.
263
+ - **`jar_democracy`** — **exceptional only**, for a persistent **super-complex** issue with several viable options and evidence of at least two distinct failed approaches. The moderator opens 2–8 evidence-backed options, resumes explicitly relevant idle/paused **fresh-context read-only scouts** or spawns fresh scouts within the same pool limit, and collects one private ballot per voter before publishing the tally. A **strict majority of the invited electorate** wins; ties, plurality without majority, and missing/invalid ballots require the user to choose among leading options, with evidence/tradeoffs rather than invented probabilities. TUI uses the structured question picker; RPC returns `needs-user` for the moderator to resolve with `jar_ask`. Voting returns a recommendation and **never implements it** or bypasses safety/approval. Prefer new scouts if past discussions could bias reused agents. Do not use democracy for ordinary complexity or routine decisions. A round is bounded: a 10-minute ballot deadline records unfinished voters as failures, rationales are capped at 600 characters, and scouts spawned for the round are retired when it ends.
264
+ - **`jar_discuss`** — a bounded shared discussion paper for virtual agent-to-agent discussion. Questions and answers have stable IDs plus author/target metadata; entries are capped at 1600 characters, the newest 64 are retained, and serialized UTF-8 is capped at 64 KiB (older entries are evicted until it fits). `list` replies are capped at 4000 characters; `since` rereads only newer entries and `questionId` reads one thread in full. Agents use it for narrow Q/A, not as another transcript.
264
265
  - **Activity view** — one split view (<kbd>Ctrl</kbd>+<kbd>Alt</kbd>+<kbd>A</kbd>, `/jar activity`, or a click on a footer row) for subagents, background shells and other extensions' teammates: list on the left, live details on the right (auto-follows; scroll up to pause, `f` to follow again). For a subagent the details show its task, its own checklist with subtasks, and a transcript like omp's: each tool call and message is one collapsed line that expands on demand (`Tab` into it, `↑↓`, `Enter`, or click) to show arguments and output, while streaming text shows live. `s` opens a steering input: type a message and press `Enter` to redirect the running subagent. `x` stops just that one. Finished runs stay listed for review.
265
266
  - **`/jar history`** — separate, read-only timeline of the active branch (paging, search, expandable details). Pi's native transcript is untouched.
266
- - Pi's built-in read/shell/edit/write tool cards render compactly; the full output or diff stays one click or <kbd>Ctrl</kbd>+<kbd>O</kbd> away.
267
+ - Pi's built-in read/shell/edit/write tool cards render compactly: collapsed, each is one padded, state-colored card with its call line and a one-line summary (edit included), and partially streamed arguments never flicker to a fallback; the full output or diff stays one click or <kbd>Ctrl</kbd>+<kbd>O</kbd> away. Background sources (subagent streams, shell output) repaint the footer at most every 250 ms and the activity view every 100 ms, because every requested frame re-renders the whole transcript. The sliding bar some terminals show in the tab while the agent works is Pi's own `OSC 9;4` progress (`terminal.showTerminalProgress` in Pi settings), not pi-jar.
267
268
 
268
269
  ## Settings
269
270
 
@@ -302,6 +303,7 @@ pi-jar/
302
303
  │ ├── shells.ts background shells and jar_shell
303
304
  │ ├── delegate.ts retained subagents, moderator controls and RPC lifecycle
304
305
  │ ├── delegate-worktree.ts isolated Git worktrees, path guard and safe reconciliation
306
+ │ ├── async-process.ts cancellable async subprocess runner with deadlines and tree cleanup
305
307
  │ ├── discussion.ts bounded structured cross-agent Q/A paper
306
308
  │ ├── democracy.ts exceptional scout ballots, strict majority and user tie-break
307
309
  │ ├── activity-view.ts subagent/shell details view
@@ -315,6 +317,16 @@ pi-jar/
315
317
  └── docs/ design, workflows, flame, integrations, development
316
318
  ```
317
319
 
320
+ ## Git executable and subprocess safety
321
+
322
+ pi-jar runs worktree Git plumbing asynchronously with a 30-second per-command deadline and terminates the process group on timeout or cancellation. To bypass a Git proxy/wrapper (for example, git-ai), set `PI_JAR_GIT_PATH` to the real Git executable before starting Pi:
323
+
324
+ ```bash
325
+ PI_JAR_GIT_PATH=/usr/bin/git pi
326
+ ```
327
+
328
+ This setting covers worktree snapshot, inspection, reconciliation and cleanup, advisor repository context (5-second deadline), and `/jar commit` Git calls (20-second deadline). Regular shell commands and Pi's built-in tools are unchanged.
329
+
318
330
  ## Documentation
319
331
 
320
332
  - [docs/SHOWCASE.md](docs/SHOWCASE.md) — README demo capture and media guidelines.
@@ -0,0 +1,11 @@
1
+ # Diff review
2
+
3
+ `/diff` is pi-jar's post-edit review UI: it inspects captured before/after content, then lets you accept (stop tracking) or explicitly confirm a revert. It is inspired by the split-pane, hunk-navigation and bounded-rendering ideas in [pi-show-diffs](https://github.com/xRyul/pi-show-diffs), but does not copy its code or change the tool pre-approval flow.
4
+
5
+ - Type `/` in the file list to filter by absolute/relative path or change status (content search is intentionally omitted to keep keystrokes bounded over large retained snapshots). Press Enter to keep the filter, Escape to clear it, and `n`/`N` to move through matching files.
6
+ - The right pane starts with the selected file's absolute path and change summary.
7
+ - Normal previews render a viewport of unified Git-style hunks. `PgUp`/`PgDn`, `g`/`G`, or the mouse wheel move through the preview; `n`/`p` jump hunks. `t` toggles unified and side-by-side Original/Updated columns. Rendering windows the requested rows instead of constructing a rendered row for every file line.
8
+ - Changes over 256 KiB or 1,500 combined lines initially show summary counts. Press `v` to explicitly review a bounded preview or return to summary. Previews are capped at 100,000 combined lines per file. When exact LCS diff exceeds its existing CPU/memory budget, the full-review choice uses a clearly marked coarse replacement hunk that preserves common prefix/suffix and line order.
9
+ - Safe revert tracking remains capped at 1 MiB per file and 8 MiB of retained baselines. Binary, oversized, or otherwise untracked files cannot be reverted from this UI. The summary does not imply content beyond those safety caps is reviewable.
10
+
11
+ The implementation uses the pi-tui custom component and windowed split-frame renderer already used by pi-jar. Upstream visual reference: [xRyul/pi-show-diffs README](https://github.com/xRyul/pi-show-diffs#diff-review-ux), MIT-licensed. No upstream source was copied.
@@ -50,7 +50,7 @@ ctx.ui.setStatus("pi-jar.quota.openai-codex", JSON.stringify({
50
50
  | `pi-jar.goal` | goal events (`set`, `status`, `round`; v1 `set`/`clear` accepted) | active branch |
51
51
  | `pi-jar.plan` | plan state (`enabled`, `steps`, `text`, `path`, `title`) | active branch |
52
52
 
53
- Hidden context messages (`pi-jar.plan-context`, `pi-jar.plan-reminder`, `pi-jar.goal-context`, `pi-jar.goal-continuation`, `pi-jar.suggest-reminder`) are filtered or deduplicated in the `context` hook, so they never accumulate.
53
+ Hidden context messages (`pi-jar.plan-context`, `pi-jar.plan-reminder`, `pi-jar.goal-context`, `pi-jar.goal-continuation`, `pi-jar.moderator-context`, `pi-jar.suggest-reminder`) are filtered or deduplicated in the `context` hook, so they never accumulate. Pi applies a `context` rewrite to one LLM call only and re-sends the full persisted history before the next, so the pruning runs on every call.
54
54
 
55
55
  ## Tools pi-jar registers
56
56
 
@@ -65,7 +65,7 @@ Hidden context messages (`pi-jar.plan-context`, `pi-jar.plan-reminder`, `pi-jar.
65
65
  | `jar_delegate` | up to four parallel subagents (`tasks: [{task, name?, role?}]`, `write?`) |
66
66
  | `jar_advisor` | second opinion from the `advisor` role (`question?`, `draft?`) |
67
67
 
68
- `jar_shell` events arrive as a visible `pi-jar.shell` custom message that triggers (or queues) an agent turn. `jar_delegate` runs child processes with `PI_JAR_CHILD=1`, which keeps pi-jar in the child from registering `jar_delegate` again, and publishes each running subagent through the role contract above as `pi-jar.role.delegate-<batch>-<n>`. The advisor publishes `pi-jar.role.advisor` while it is consulting, and its `/advisor` and gate answers arrive as visible `pi-jar.advisor` custom messages.
68
+ `jar_shell` events arrive as a visible `pi-jar.shell` custom message that triggers (or queues) an agent turn; when a subagent turn started by `jar_subagent resume` ends, a visible `pi-jar.subagent` message does the same. `jar_delegate` runs child processes with `PI_JAR_CHILD=1`, which keeps pi-jar in the child from registering `jar_delegate` again, and publishes each running subagent through the role contract above as `pi-jar.role.delegate-<batch>-<n>`. The advisor publishes `pi-jar.role.advisor` while it is consulting, and its `/advisor` and gate answers arrive as visible `pi-jar.advisor` custom messages.
69
69
 
70
70
  ## Commands that overlap other packages
71
71
 
@@ -0,0 +1,24 @@
1
+ # Subagent tool capabilities
2
+
3
+ Subagents use Pi's standard `--tools` allowlist. Each `jar_delegate` task can customize that allowlist independently:
4
+
5
+ ```json
6
+ {
7
+ "tasks": [
8
+ { "task": "Research the API docs", "mode": "scout", "tools": ["read", "web_search", "fetch_content"] },
9
+ { "task": "Implement the change", "mode": "worktree", "inheritTools": true }
10
+ ]
11
+ }
12
+ ```
13
+
14
+ - With neither option, existing mode defaults are unchanged: `scout` and `fork` are read-only; `worktree` gets the path-guarded file tools; legacy `direct` retains Pi's default allowlist.
15
+ - `tools` replaces the mode's default tool list. Names must be active in the parent session. Enable the relevant extension and tool in the parent first. Tool names vary by extension; use the exact active name (for example, a configured web/search extension's names).
16
+ - `inheritTools: true` snapshots the parent's currently active tool names, then filters them for mode safety. It does not grant tools registered later. Pi hosts must expose `getActiveTools()` (or the integration must supply an active-name getter); otherwise inheritance/configurable lists fail with an actionable error.
17
+ - `scout` and `fork` accept only known read-only/discovery tools and named web tools; edit/write, shell and arbitrary extension tools are rejected even if explicitly requested.
18
+ - `worktree` accepts known path-guarded file tools and named web tools. Shell and arbitrary extension tools are rejected. `multi_file_edit` is included in defaults only when active in the parent; each effective edit target, default path and recognized alternate path field is validated, including traversal and existing symlink escapes.
19
+ - Named web capabilities are `web_enable`, `web_search`, `fetch_content`, `get_search_content` and `source_check`. Newly enabled tools must still be in the child's original capability list.
20
+ - These are tool-call restrictions, not an OS sandbox. Extensions execute trusted code; named web tools can access external services, and legacy direct-write mode is not workspace-confined.
21
+ - `jar_delegate`, `jar_subagent`, and `jar_democracy` are always removed from inherited tools and rejected in explicit lists. A child cannot recursively spawn more subagents.
22
+ - Tool capabilities are per task; a batch can mix modes and allowlists. A fork still inherits the parent's conversation context, independently of its tool list.
23
+
24
+ If configurable tools are unavailable on a Pi host, use mode defaults or upgrade to a host exposing its active tool list. Avoid inheriting every tool for convenience; grant the narrowest capability needed for the assignment.
package/docs/WORKFLOWS.md CHANGED
@@ -142,9 +142,11 @@ watch matches or process exits → pi-jar.shell message → agent wakes (o
142
142
  - The main agent is the **moderator** while a retained fleet exists. A hidden bounded context lists the fleet and tells the parent to decompose, route, peek, steer, resolve disagreements and synthesize instead of duplicating a worker's task. The optional `moderator` role gives this purpose a configurable model without forcing a model switch.
143
143
  - `jar_delegate` shares a session-wide **Max subagents** setting in `/jar settings`: exactly 2, 4, 6, 8 or 16, default 4. All retained live children (including idle/paused and live legacy one-shots) count; the moderator does not. Concurrent/reentrant batches reserve capacity atomically and reject over-cap requests before any launch. Lowering the limit preserves the fleet and prevents further spawning until room is available. `scout` runs `pi --mode rpc --no-session`, is read-only and defaults to the `scout` role. `fork` uses `--fork <parent-session>`, is read-only and defaults to `reviewer`. `worktree` uses the same real context fork, defaults to `worker`, and runs in a detached disposable Git worktree. Explicit `task.role` overrides those defaults; each role uses its primary/fallback candidates before falling back to the parent's current model.
144
144
  - Retained children do not exit on `agent_settled`. They become `idle`, keeping the same RPC process and model context. `jar_subagent pause` sends `clear_queue` + `abort` and leaves the agent `paused`; `resume` sends a new prompt to that same child. `steer` redirects a working child. `ask` pauses an active task, crosses a settled boundary, then starts a compact BTW-only prompt, so the old task report cannot be mistaken for the answer. It preserves the prior paused state and assigned scope for later resume.
145
- - `jar_subagent peek` is the moderator's cheap status surface: task, state, model/role/mode, checklist progress, current activity, changed files, remaining checklist leaves, workspace and last report. `stop` is destructive/final: abort an active operation if necessary, close the child, return a handoff (workspace, changed/applied files, remaining work, last report), and finalize safe worktree changes.
145
+ - `jar_subagent peek` is the moderator's cheap status surface and reads only task progress from memory: key, name, state, done/total checklist leaves, the in-progress task, current activity and the next three open tasks (no git, transcript or report reads). `stop` is destructive/final: abort an active operation if necessary, close the child, return a handoff (workspace, changed/applied files, remaining work, last report), and finalize safe worktree changes.
146
+ - The moderator never has to poll: when a turn started by `jar_subagent resume` ends, pi-jar coalesces the settled agents into one visible `pi-jar.subagent` message (their `peek` progress plus the turn's report, clipped to 1200 characters) that triggers or queues a moderator turn. `pause`, `ask` and `stop` cancel that wake-up because the moderator is handling the agent directly.
147
+ - Nothing in the moderator path waits unbounded. `jar_subagent` returns as soon as the tool call is aborted (the child keeps its state); `pause` reports "not settled yet" after 15 s if the child ignores abort and still pauses when it settles; a working child that sends no RPC event for 10 minutes is killed as hung. After a child exits, its stdio gets 2 s to drain: a grandchild (MCP server, shell) that inherited the pipes cannot keep `stop` or session shutdown waiting for `close`.
146
148
  - Worktree setup snapshots the exact starting workspace: `HEAD` plus tracked dirty changes and non-ignored untracked files are committed only inside the detached worktree. The worktree persists across idle/pause/resume. On stop, the parent target must still byte/mode-match the snapshot and the change must be trackable by `/diff`; otherwise nothing is overwritten and the workspace/error is reported. Conflicted workspaces survive report-history trimming; after resolving parent drift, retry `stop`. Finalization waits for RPC stdio closure. Snapshot blobs preserve filesystem bytes without executing clean/smudge filters or hooks. The path guard handles Pi path normalization, rejects lexical/symlink/dangling-link escapes and Git control metadata, checks read-tool filename fallbacks (NFD, curly apostrophes, AM/PM spaces), and rechecks both source and destination ancestors during reconciliation. All replacements/rollback backups are prepared before parent mutation; failed multi-file application restores earlier files. Exceptional rollback failures explicitly report actual applied paths for recovery. Worktree children receive only `read,edit,write,grep,find,ls,jar_todo,jar_discuss`; shell tools are absent. This is filesystem-write isolation, not a network/process sandbox.
147
- - `jar_discuss` gives moderator and children a session-scoped structured paper. `ask` appends a question with stable id/from/to fields, `answer` references the question id, and `list` reads recent entries. Text is capped at 1600 characters per entry, at most 64 entries are retained, and the serialized UTF-8 JSON paper is strictly bounded to 64 KiB with oldest-entry eviction and atomic rewrites. It is intentionally for terse inter-agent Q/A rather than another transcript.
149
+ - `jar_discuss` gives moderator and children a session-scoped structured paper. `ask` appends a question with stable id/from/to fields and `answer` references the question id (validated under the same lock as the write). `list` returns the newest entries within a 4000-character reply, each clipped to 400 characters, and says how many older entries it omitted; `since: "d12"` returns only newer entries (a no-change reread costs one line) and `questionId` reads one thread in full. Text is capped at 1600 characters per entry, at most 64 entries are retained, and the compact UTF-8 JSON paper is strictly bounded to 64 KiB with oldest-entry eviction and atomic rewrites. The file lock waits by yielding to the event loop (at most ~1 s), never blocking the UI. It is intentionally for terse inter-agent Q/A rather than another transcript; agents are told not to poll it.
148
150
  - Fork/worktree children inherit the parent model conversation but reset pi-jar goal/plan/todo automation. All child modes inherit explicit extension flags, set `PI_JAR_CHILD=1` to prevent recursive delegation, and share the discussion-paper path/name through environment variables.
149
151
  - Progress (current tool, streaming text, tools used, turns, cost) stays in the activity view. Retained `idle` and `paused` agents remain visible in the footer/activity fleet; the newest eight ordinary retired reports remain for review, while unresolved private workspaces remain recoverable until explicit finalization/session cleanup. `s` in the activity view still steers a working subagent and `x` stops the selected agent through the same registry.
150
152
  - The deprecated `write: true` mode remains a one-shot shared-workspace compatibility path. New delegated implementation should use `mode: "worktree"`.
@@ -154,8 +156,8 @@ watch matches or process exits → pi-jar.shell message → agent wakes (o
154
156
  `jar_democracy` is **not** a default decomposition or decision tool. The moderator must supply `complexity: "super-complex"`, a concrete complexity justification, the persistent issue, at least two distinct failed approaches with evidence, and 2–8 uniquely identified options with tradeoffs. These gates are runtime-validated in addition to prompt guidance; the moderator is responsible for truthful evidence.
155
157
 
156
158
  - Nominate relevant idle/paused **scouts** with `agents`, and set the total electorate with `voters` (default 4, or 2 at a cap of 2; always bounded by the configured cap). Forks, writable workers, busy agents and duplicate nominations are rejected. Reuse preserves context; prefer fresh scouts if discussion history could bias a ballot.
157
- - Unfilled seats spawn fresh read-only scouts through the normal delegate controller. The same session-wide capacity applies; the moderator must explicitly stop unneeded agents if there is no room. Voting never silently kills existing workers. Voters are retained for follow-up.
158
- - Each voter investigates independently and returns one nonce-bound option ID with a short evidence-based rationale. Voters are instructed not to use shared discussion. Results are collected before any tally is exposed; malformed/stale/missing/error ballots are recorded, never inferred.
159
+ - Unfilled seats spawn fresh read-only scouts through the normal delegate controller. The same session-wide capacity applies; the moderator must explicitly stop unneeded agents if there is no room. Voting never silently kills existing workers. Scouts spawned for the round are retired once it ends, so they never keep holding pool slots; nominated scouts stay retained.
160
+ - Each voter checks the evidence briefly and independently and returns one nonce-bound option ID with a rationale (at most 600 characters; failure reasons 200). Voters are instructed not to use shared discussion. Results are collected before any tally is exposed; malformed/stale/missing/error ballots are recorded, never inferred. A round has a 10-minute ballot deadline: unfinished voters are cancelled and recorded as failures (a nominated scout cancelled this way is retired), so a hung voter can never hold the round open.
159
161
  - A **strict majority** means more than half of **all invited voters**, with at least two valid ballots. The largest plurality is not a majority. Missing ballots do not reduce this denominator or allow a single response to decide.
160
162
  - Ties, plurality without majority, and insufficient ballots return `needs-user`. The leading options are presented to the user with their evidence/tradeoffs in the TUI question picker, including custom/chat/cancel paths. In RPC, the moderator must use `jar_ask` before proceeding. Recommend the most plausible option from actual evidence; do not fabricate numerical probabilities or silently break a tie.
161
163
  - A majority is a recommendation, not permission to execute dangerous work. The tool never implements the selected option, and ordinary safety/user-approval requirements remain in force. Only one round may run at a time; abort invalidates incomplete results.
@@ -36,7 +36,7 @@ import { TASK_ENTRY, TodoStore, todoProgress, todoTotals } from "../src/tasks.ts
36
36
  import { formatCost, sessionCost } from "../src/usage.ts";
37
37
  import { WorkingState } from "../src/working.ts";
38
38
  import { ChangeTracker } from "../src/changes.ts";
39
- import { CHILD_ENV, DelegateRegistry, registerDelegate, type DelegateRun } from "../src/delegate.ts";
39
+ import { CHILD_ENV, DelegateRegistry, registerDelegate, SUBAGENT_MESSAGE, type DelegateRun } from "../src/delegate.ts";
40
40
  import { CHILD_WORKTREE_ENV, workspacePathAllowed } from "../src/delegate-worktree.ts";
41
41
  import { createDiscussionPaper, DISCUSSION_FILE_ENV, disposeDiscussionPaper, registerDiscussionTool, SUBAGENT_NAME_ENV } from "../src/discussion.ts";
42
42
  import { openActivityView, type ActivityTarget } from "../src/activity-view.ts";
@@ -45,7 +45,9 @@ import { collectPrompts, openPromptSearch } from "../src/prompt-search.ts";
45
45
  import { ago, recentSessions, sessionDetails, type RecentSession } from "../src/session-gallery.ts";
46
46
  import { clearSessionBranchCache, sessionBranch } from "../src/session-branch.ts";
47
47
  import { registerChangeReview } from "../src/diff-view.ts";
48
- import { registerShells, SHELL_MESSAGE, shellEventMessage, ShellManager, type ShellEvent, type ShellJob } from "../src/shells.ts";
48
+ import { registerShells, ShellManager, type ShellEvent, type ShellJob } from "../src/shells.ts";
49
+ import { registerShellNotifications } from "../src/shell-notifications.ts";
50
+ import { CHILD_TOOLS_ENV, childToolAllowlist, RECURSIVE_TOOLS, worktreeToolViolation } from "../src/subagent-tools.ts";
49
51
  import { hopefulWelcomeMessage, welcomeHit, welcomeLines, type WelcomeAction } from "../src/welcome.ts";
50
52
 
51
53
  const WELCOME_KEY = "pi-jar.welcome";
@@ -66,32 +68,31 @@ export default function piJar(pi: ExtensionAPI): void {
66
68
  // and handlers are disabled. Remove only parent automation, not task/user context.
67
69
  const parentAutomation = new Set([
68
70
  "pi-jar.goal-context", "pi-jar.goal-continuation",
69
- "pi-jar.plan-context", "pi-jar.plan-reminder", "pi-jar.moderator-context"
71
+ "pi-jar.plan-context", "pi-jar.plan-reminder", "pi-jar.moderator-context", SUBAGENT_MESSAGE
70
72
  ]);
71
73
  pi.on("context", (event) => {
72
74
  const messages = event.messages.filter((message) => !parentAutomation.has((message as { customType?: string }).customType ?? ""));
73
75
  if (messages.length !== event.messages.length) return { messages };
74
76
  });
75
77
  }
76
- if (delegatedWorktree) {
77
- const pathTools = new Set(["read", "edit", "write", "grep", "find", "ls"]);
78
- const shellTools = new Set(["bash", "powershell", "jar_shell"]);
78
+ if (delegatedChild) {
79
+ const allowed = childToolAllowlist(process.env[CHILD_TOOLS_ENV]);
79
80
  pi.on("tool_call", (event, ctx) => {
80
- if (shellTools.has(event.toolName)) {
81
- return { block: true, reason: "Sandboxed worktree subagents cannot run shell tools." };
82
- }
83
- if (!pathTools.has(event.toolName)) return;
84
- const input = event.input && typeof event.input === "object" ? event.input as Record<string, unknown> : {};
85
- if (!workspacePathAllowed(delegatedWorktree, ctx.cwd, ".")) {
86
- return { block: true, reason: "Sandboxed worktree subagent working directory is outside its workspace." };
81
+ // Enforce capabilities per call as well as CLI startup. A web/MCP bootstrap must not
82
+ // accidentally activate unrelated tools or permit recursive orchestration.
83
+ if (RECURSIVE_TOOLS.has(event.toolName) || (allowed && !allowed.has(event.toolName))) {
84
+ return { block: true, reason: "Tool was not granted to this subagent: " + event.toolName };
87
85
  }
88
- for (const key of ["path", "file_path", "cwd"]) {
89
- const value = input[key];
90
- if (typeof value === "string" && value && !workspacePathAllowed(delegatedWorktree, ctx.cwd, value, event.toolName === "read")) {
91
- return { block: true, reason: `Sandboxed worktree subagent cannot access outside its workspace: ${value}` };
92
- }
86
+ if (delegatedWorktree) {
87
+ const reason = worktreeToolViolation(delegatedWorktree, ctx.cwd, event.toolName, event.input);
88
+ if (reason) return { block: true, reason };
93
89
  }
94
90
  });
91
+ pi.on("tool_result", event => {
92
+ if (!allowed || event.toolName !== "web_enable") return;
93
+ const available = new Set(pi.getAllTools().map(tool => tool.name));
94
+ pi.setActiveTools([...allowed].filter(name => available.has(name)));
95
+ });
95
96
  }
96
97
  installCompactBuiltinTools(pi);
97
98
  let demo = false;
@@ -187,19 +188,28 @@ export default function piJar(pi: ExtensionAPI): void {
187
188
  registerChangeReview(pi, () => changes, refreshChanges);
188
189
  let shells: ShellManager | undefined;
189
190
  registerShells(pi, () => shells);
191
+ const shellNotifications = registerShellNotifications(pi, () => shells);
190
192
  const onShellEvent = (event: ShellEvent) => {
191
193
  footerTui?.requestRender();
192
194
  if (!event.job.notify) return;
193
- // Wake the agent (or queue for its current run) so it never has to poll.
194
- try {
195
- pi.sendMessage({ customType: SHELL_MESSAGE, content: shellEventMessage(event), display: true, details: { id: event.job.id, kind: event.kind } },
196
- { triggerTurn: true, deliverAs: "followUp" });
197
- } catch (error) { console.error("pi-jar: could not deliver shell event", error); }
195
+ // Queue compact metadata until a model boundary; observed completions are acknowledged.
196
+ shellNotifications.notify();
198
197
  };
199
198
  /** Footer indicators owned by pi-jar; live subagents and shells get their own rows. */
200
199
  const footerChips = () => changeCount ? [withIcon("changes", `${changeCount} file${changeCount === 1 ? "" : "s"} · /diff`)] : [];
200
+ /**
201
+ * Subagent streams and shell output change the footer many times a second; every requested frame
202
+ * re-renders the whole transcript, so background sources repaint it at most this often.
203
+ */
204
+ const BACKGROUND_REPAINT_MS = 250;
205
+ let backgroundRepaint: NodeJS.Timeout | undefined;
206
+ const repaintFooterSoon = () => {
207
+ if (backgroundRepaint) return;
208
+ backgroundRepaint = setTimeout(() => { backgroundRepaint = undefined; footerTui?.requestRender(); }, BACKGROUND_REPAINT_MS);
209
+ backgroundRepaint.unref?.();
210
+ };
201
211
  const subagents = new DelegateRegistry();
202
- subagents.subscribe(() => footerTui?.requestRender());
212
+ subagents.subscribe(repaintFooterSoon);
203
213
  /** Finished work stays visible briefly so a quick run is not a flicker. */
204
214
  const ACTIVITY_LINGER_MS = 5000;
205
215
  const subagentState = (run: DelegateRun): ActivityState => run.state === "working" ? "running" : run.state === "queued" ? "queued"
@@ -307,9 +317,8 @@ export default function piJar(pi: ExtensionAPI): void {
307
317
  composer.setActivity(enabled ? working.phase : "idle", animations);
308
318
  try {
309
319
  if (!enabled) { workingIndicatorKey = ""; ctx.ui.setWorkingMessage?.(); ctx.ui.setWorkingIndicator(); return; }
310
- const task = todos?.current();
311
320
  const view = working.view(animations, (color, text) => ctx.ui.theme?.fg(color, text) ?? text,
312
- Date.now(), ctx.thinkingLevel, task ? task.activeForm ?? task.title : undefined);
321
+ Date.now(), ctx.thinkingLevel);
313
322
  ctx.ui.setWorkingMessage?.(view.message);
314
323
  const indicatorKey = `${working.phase}:${animations}`;
315
324
  if (indicatorKey !== workingIndicatorKey) {
@@ -653,7 +662,7 @@ export default function piJar(pi: ExtensionAPI): void {
653
662
  discussionFile = createDiscussionPaper();
654
663
  }
655
664
  shells = new ShellManager(onShellEvent);
656
- shells.onChange = () => footerTui?.requestRender();
665
+ shells.onChange = repaintFooterSoon;
657
666
  goals = new GoalStore((entry) => pi.appendEntry(GOAL_ENTRY, entry));
658
667
  let branch: readonly unknown[] = [];
659
668
  try { branch = sessionBranch(ctx); } catch { /* keep empty */ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-jar",
3
- "version": "0.1.5",
3
+ "version": "0.1.7",
4
4
  "description": "A warm, role-aware Pi terminal workspace with animated UI, plan and goal workflows, tasks, change review, model roles, background shells and subagents.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -275,13 +275,22 @@ export async function openActivityView(ctx: ExtensionContext, sources: ActivityS
275
275
  const fg: Fg = (color, text) => theme.fg(color as never, text);
276
276
  const shells = sources.shells;
277
277
  const previous = shells?.onChange;
278
- const unsubscribe = sources.subagents.subscribe(() => tui.requestRender());
279
- if (shells) shells.onChange = () => { previous?.(); tui.requestRender(); };
278
+ // Live streams notify far faster than a transcript view needs to repaint; ~10 frames a second reads as live.
279
+ let repaint: NodeJS.Timeout | undefined;
280
+ const repaintSoon = () => {
281
+ if (repaint || closed) return;
282
+ repaint = setTimeout(() => { repaint = undefined; if (!closed) tui.requestRender(); }, 100);
283
+ repaint.unref?.();
284
+ };
285
+ const unsubscribe = sources.subagents.subscribe(repaintSoon);
286
+ if (shells) shells.onChange = () => { previous?.(); repaintSoon(); };
280
287
  const cleanup = () => {
281
288
  if (closed) return;
282
289
  closed = true;
283
290
  clearInterval(timer);
284
291
  timer = undefined;
292
+ clearTimeout(repaint);
293
+ repaint = undefined;
285
294
  unsubscribe();
286
295
  if (shells) shells.onChange = previous;
287
296
  };
package/src/advisor.ts CHANGED
@@ -5,6 +5,7 @@ import type { ModelRoleManager } from "./model-roles.ts";
5
5
  import { askRole, type SideUsage } from "./side-model.ts";
6
6
  import { ROLE_PREFIX } from "./status.ts";
7
7
  import { sessionBranch } from "./session-branch.ts";
8
+ import { runProcess } from "./async-process.ts";
8
9
 
9
10
  export const ADVISOR_TOOL = "jar_advisor";
10
11
  export const ADVISOR_MESSAGE = "pi-jar.advisor";
@@ -119,7 +120,13 @@ export class StuckDetector {
119
120
  private take(): boolean { if (this.gates >= GATES_PER_PROMPT) return false; this.gates++; return true; }
120
121
  }
121
122
 
122
- export interface AdvisorOptions { enabled: () => boolean; gates: () => boolean; usage: SideUsage }
123
+ export interface AdvisorOptions {
124
+ enabled: () => boolean;
125
+ gates: () => boolean;
126
+ usage: SideUsage;
127
+ /** Injectable subprocess boundary for hosts/tests; defaults to bounded asynchronous execution. */
128
+ processRunner?: typeof runProcess;
129
+ }
123
130
 
124
131
  /** First-class advisor: the jar_advisor tool, /advisor, and automatic gates for loops and failure streaks. */
125
132
  export function registerAdvisor(pi: ExtensionAPI, roles: ModelRoleManager, options: AdvisorOptions) {
@@ -133,14 +140,16 @@ export function registerAdvisor(pi: ExtensionAPI, roles: ModelRoleManager, optio
133
140
  } catch { /* status is decoration */ }
134
141
  };
135
142
 
136
- const gitState = async (ctx: ExtensionContext): Promise<string> => {
143
+ const gitState = async (ctx: ExtensionContext, signal?: AbortSignal): Promise<string> => {
144
+ const git = (args: string[]) => (options.processRunner ?? runProcess)(process.env.PI_JAR_GIT_PATH?.trim() || "git", args, {
145
+ cwd: ctx.cwd, signal, timeoutMs: 5_000, maxOutputBytes: GIT_LIMIT
146
+ }).then(output => output.toString("utf8"));
137
147
  try {
138
- const [st, stat] = await Promise.all([
139
- pi.exec("git", ["status", "--short", "--branch"], { cwd: ctx.cwd, timeout: 5_000 }),
140
- pi.exec("git", ["diff", "--stat", "HEAD"], { cwd: ctx.cwd, timeout: 5_000 })
148
+ const [status, diff] = await Promise.all([
149
+ git(["status", "--short", "--branch"]),
150
+ git(["diff", "--stat", "HEAD"])
141
151
  ]);
142
- if (st.code !== 0) return "";
143
- return (st.stdout.trim() + (stat.code === 0 && stat.stdout.trim() ? "\n" + stat.stdout.trim() : "")).slice(0, GIT_LIMIT);
152
+ return (status.trim() + (diff.trim() ? "\n" + diff.trim() : "")).slice(0, GIT_LIMIT);
144
153
  } catch { return ""; } // repository state is optional context; not a git repo or git missing
145
154
  };
146
155
 
@@ -149,7 +158,7 @@ export function registerAdvisor(pi: ExtensionAPI, roles: ModelRoleManager, optio
149
158
  status(ctx, request.trigger ?? request.question ?? "reviewing the current direction");
150
159
  try {
151
160
  const conversation = transcript(sessionBranch(ctx) as readonly Entry[]);
152
- return await askRole(ctx, roles, options.usage, "advisor", ADVISOR_SYSTEM, advisorPrompt(request, conversation, await gitState(ctx)), signal);
161
+ return await askRole(ctx, roles, options.usage, "advisor", ADVISOR_SYSTEM, advisorPrompt(request, conversation, await gitState(ctx, signal)), signal);
153
162
  } finally { if (--busy === 0) status(ctx); }
154
163
  };
155
164
 
@@ -0,0 +1,144 @@
1
+ import { spawn, type ChildProcess } from "node:child_process";
2
+
3
+ export class ProcessExecutionError extends Error {
4
+ readonly exitCode?: number;
5
+ readonly signal?: NodeJS.Signals | null;
6
+ constructor(message: string, exitCode?: number, signal?: NodeJS.Signals | null) {
7
+ super(message);
8
+ this.name = "ProcessExecutionError";
9
+ this.exitCode = exitCode;
10
+ this.signal = signal;
11
+ }
12
+ }
13
+
14
+ export interface ProcessOptions {
15
+ cwd?: string;
16
+ input?: string | Buffer;
17
+ signal?: AbortSignal;
18
+ timeoutMs?: number;
19
+ maxOutputBytes?: number;
20
+ env?: NodeJS.ProcessEnv;
21
+ }
22
+
23
+ /** The caller must spawn POSIX children with detached:true so they own their process group. */
24
+ export function signalProcessTree(child: ChildProcess, signal: NodeJS.Signals = "SIGKILL"): void {
25
+ if (child.pid !== undefined && process.platform !== "win32") {
26
+ try { process.kill(-child.pid, signal); return; } catch { /* group may already have exited */ }
27
+ } else if (child.pid !== undefined && process.platform === "win32") {
28
+ // Node's kill() only terminates the leader on Windows. taskkill /T includes descendants.
29
+ try {
30
+ const killer = spawn("taskkill", ["/pid", String(child.pid), "/T", "/F"], { stdio: "ignore", windowsHide: true });
31
+ killer.on("error", () => { try { child.kill(signal); } catch { /* already exited */ } });
32
+ const timer = setTimeout(() => { try { killer.kill(); } catch { /* already exited */ } }, 1000);
33
+ killer.once("close", () => clearTimeout(timer));
34
+ timer.unref();
35
+ return;
36
+ } catch { /* fall back to killing the leader */ }
37
+ }
38
+ if (child.exitCode !== null || child.signalCode) return;
39
+ try { child.kill(signal); } catch { /* already exited */ }
40
+ }
41
+
42
+ /** Bounded, cancellable execution. Neither a hung leader nor inherited pipes can hold the caller. */
43
+ export function runProcess(command: string, args: readonly string[], options: ProcessOptions = {}): Promise<Buffer> {
44
+ const timeoutMs = options.timeoutMs ?? 30_000;
45
+ const maxOutputBytes = options.maxOutputBytes ?? 64 * 1024 * 1024;
46
+ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0 || timeoutMs > 2_147_483_647) {
47
+ return Promise.reject(new RangeError("timeoutMs must be a finite positive timer duration"));
48
+ }
49
+ if (!Number.isSafeInteger(maxOutputBytes) || maxOutputBytes < 0) {
50
+ return Promise.reject(new RangeError("maxOutputBytes must be a non-negative safe integer"));
51
+ }
52
+ if (options.signal?.aborted) return Promise.reject(abortError(options.signal.reason));
53
+
54
+ return new Promise<Buffer>((resolve, reject) => {
55
+ let child: ChildProcess;
56
+ try {
57
+ child = spawn(command, [...args], {
58
+ cwd: options.cwd, env: options.env, stdio: ["pipe", "pipe", "pipe"],
59
+ detached: process.platform !== "win32", windowsHide: true
60
+ });
61
+ } catch (error) { reject(error); return; }
62
+
63
+ const stdout: Buffer[] = [];
64
+ const stderr: Buffer[] = [];
65
+ let outputBytes = 0;
66
+ let settled = false;
67
+ let exitTimer: ReturnType<typeof setTimeout> | undefined;
68
+ let stdinError: Error | undefined;
69
+ const cleanup = () => {
70
+ clearTimeout(timer);
71
+ clearTimeout(exitTimer);
72
+ options.signal?.removeEventListener("abort", onAbort);
73
+ };
74
+ const destroyPipes = () => {
75
+ child.stdin?.destroy();
76
+ child.stdout?.destroy();
77
+ child.stderr?.destroy();
78
+ };
79
+ const fail = (error: Error) => {
80
+ if (settled) return;
81
+ settled = true;
82
+ cleanup();
83
+ signalProcessTree(child);
84
+ destroyPipes();
85
+ reject(error);
86
+ };
87
+ const complete = (code: number | null, signal: NodeJS.Signals | null) => {
88
+ if (settled) return;
89
+ settled = true;
90
+ cleanup();
91
+ destroyPipes();
92
+ const detail = Buffer.concat(stderr).toString("utf8").trim();
93
+ if (code !== 0) reject(new ProcessExecutionError(`${command} exited ${code ?? signal ?? "unknown"}${detail ? `: ${detail}` : ""}`, code ?? undefined, signal));
94
+ else if (stdinError) reject(new ProcessExecutionError(`${command} stdin failed: ${stdinError.message}`, code, signal));
95
+ else resolve(Buffer.concat(stdout));
96
+ };
97
+ const onAbort = () => fail(abortError(options.signal?.reason));
98
+ const timer = setTimeout(() => fail(new ProcessExecutionError(`${command} timed out after ${timeoutMs}ms`)), timeoutMs);
99
+ timer.unref();
100
+ options.signal?.addEventListener("abort", onAbort, { once: true });
101
+
102
+ const collect = (target: Buffer[]) => (chunk: Buffer | string) => {
103
+ if (settled) return;
104
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
105
+ outputBytes += buffer.length;
106
+ if (outputBytes > maxOutputBytes) {
107
+ fail(new ProcessExecutionError(`${command} exceeded ${maxOutputBytes} output bytes`));
108
+ } else target.push(buffer);
109
+ };
110
+ child.stdout!.on("data", collect(stdout));
111
+ child.stderr!.on("data", collect(stderr));
112
+ child.stdout!.on("error", fail);
113
+ child.stderr!.on("error", fail);
114
+ child.stdin!.on("error", (error: NodeJS.ErrnoException) => {
115
+ // An early exit often produces EPIPE. Consume it and preserve the child's exit/stderr;
116
+ // it must never become an uncaught stream error that crashes Pi.
117
+ stdinError = error;
118
+ if (error.code !== "EPIPE" && error.code !== "ERR_STREAM_DESTROYED") fail(error);
119
+ });
120
+ child.once("error", fail);
121
+ child.once("close", complete);
122
+ child.once("exit", (code, signal) => {
123
+ if (settled) return;
124
+ // A descendant can retain stdout after its leader exits. Drain briefly, then kill the
125
+ // remaining group and settle explicitly rather than relying on a never-arriving close.
126
+ exitTimer = setTimeout(() => {
127
+ signalProcessTree(child);
128
+ complete(code, signal);
129
+ }, 250);
130
+ exitTimer.unref();
131
+ });
132
+
133
+ if (options.signal?.aborted) { onAbort(); return; }
134
+ // end() writes the complete buffer using Node stream backpressure, then supplies EOF.
135
+ try { child.stdin!.end(options.input); } catch (error) { fail(error instanceof Error ? error : new Error(String(error))); }
136
+ });
137
+ }
138
+
139
+ function abortError(reason: unknown): Error {
140
+ if (reason instanceof Error) return reason;
141
+ const error = new Error(reason === undefined ? "process aborted" : String(reason));
142
+ error.name = "AbortError";
143
+ return error;
144
+ }