pi-jar 0.1.6 → 0.1.8
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 +19 -7
- package/docs/DIFF-REVIEW.md +14 -0
- package/docs/SUBAGENT-TOOLS.md +24 -0
- package/docs/WORKFLOWS.md +2 -2
- package/extensions/index.ts +21 -23
- package/package.json +1 -1
- package/src/advisor.ts +17 -8
- package/src/async-process.ts +144 -0
- package/src/changes.ts +235 -28
- package/src/commit.ts +5 -6
- package/src/delegate-worktree.ts +135 -58
- package/src/delegate.ts +291 -88
- package/src/diff-inline.ts +80 -0
- package/src/diff-view.ts +292 -44
- package/src/discussion.ts +59 -21
- package/src/quota.ts +12 -1
- package/src/shell-notifications.ts +104 -0
- package/src/shells.ts +130 -26
- package/src/subagent-tools.ts +45 -0
- package/src/working.ts +3 -5
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`
|
|
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,13 +254,14 @@ 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 `
|
|
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.
|
|
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; `
|
|
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. Changed lines get a colored rail and tinted background, replaced lines highlight the changed words, code is syntax-highlighted for known languages, and collapsed regions are labeled `┄ N unchanged lines ┄`. `/` filters files; `t` switches unified and aligned side-by-side views (unified below 70 columns), `←`/`→` (or `[`/`]`) change the context from 0 lines to the whole file, `w` wraps long lines, and `n`/`p`, `Home`/`End` navigate. 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
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.
|
|
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). `list`
|
|
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). `ask`/`answer` reply with the new id only (never the text) plus, when they fit, unseen answers to your questions and questions addressed to you. `list` returns only entries by others that this agent has not seen yet (capped at 4000 characters; one line when nothing changed); `since: "d0"` rereads everything 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
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.
|
|
@@ -298,10 +299,11 @@ pi-jar/
|
|
|
298
299
|
│ ├── commit.ts /jar commit
|
|
299
300
|
│ ├── usage-view.ts /usage; context-view.ts is /context; panel.ts frames both
|
|
300
301
|
│ ├── split-view.ts shared two-pane frame
|
|
301
|
-
│ ├── changes.ts change tracker and
|
|
302
|
+
│ ├── changes.ts change tracker, line diff and split layout; diff-view.ts is /diff, diff-inline.ts its word highlights
|
|
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. Worktree snapshots and change inspection hash files in bounded batches (at most 1,000 files or 128 MiB per Git call), so startup and finalization cost a handful of Git processes rather than several per repository file. 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,14 @@
|
|
|
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 Git-style hunks. Each hunk header is preceded by a `┄ N unchanged lines ┄` label for the region it skips. `PgUp`/`PgDn`, `g`/`G`, `Home`/`End`, or the mouse wheel move through the preview; `n`/`p` jump hunks. Rendering windows the requested rows instead of constructing a rendered row for every file line.
|
|
8
|
+
- `t` toggles unified rows and aligned side-by-side Original/Updated columns: within a replacement, the k-th removed line sits beside the k-th added line, and a longer side continues alone. Panes narrower than 70 columns keep the unified layout. Toggling keeps the hunk at the top of the viewport.
|
|
9
|
+
- `←`/`→` (or `[`/`]`) step the context through 0, 1, 3 (default), 5, 10, 25 lines and the whole file. `w` wraps long lines onto continuation rows instead of truncating them; with wrapping, `End` shows the last full page.
|
|
10
|
+
- Changed lines carry a colored `▌` rail and the theme's tool success/error background. For a replaced line pair, the changed words are shown in inverse video, computed by a bounded token LCS (lines that share no word, very long lines and coarse previews are not emphasized). Files whose language Pi recognizes are syntax-highlighted per line with the active theme; lines over 1,000 characters keep plain diff colours.
|
|
11
|
+
- 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.
|
|
12
|
+
- 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.
|
|
13
|
+
|
|
14
|
+
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: aligned split rows, intraline highlights, rail markers, labeled collapsed regions, adjustable context and wrapping. No upstream source was copied, and pi-show-diffs' pre-apply approval and inline editing are not part of `/diff`, which reviews changes after they are applied.
|
|
@@ -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
|
@@ -120,7 +120,7 @@ Manual choices win: a model or effort you select yourself (model picker, cycling
|
|
|
120
120
|
## Change review
|
|
121
121
|
|
|
122
122
|
- A `tool_call` hook records a file's content right before the agent's **first** `edit` or `write` to it (project files only, up to 200 files, 1 MB each, text only). Later edits keep that baseline, so the review always shows everything since the agent started on the file.
|
|
123
|
-
- `/diff` (or <kbd>Ctrl</kbd>+<kbd>Alt</kbd>+<kbd>D</kbd>) compares the baseline with the file on disk using a line diff with 3 lines of context. Files that are back to their baseline drop out.
|
|
123
|
+
- `/diff` (or <kbd>Ctrl</kbd>+<kbd>Alt</kbd>+<kbd>D</kbd>) compares the baseline with the file on disk using a line diff with 3 lines of context by default (`←`/`→` change it, up to the whole file). Files that are back to their baseline drop out.
|
|
124
124
|
- **Accept** forgets the baseline. **Revert** writes the baseline back (or removes a file the agent created) after a second key press to confirm. Errors are reported and the file stays listed.
|
|
125
125
|
- Baselines live in memory for the session; changes made through `bash` or by you are not tracked.
|
|
126
126
|
- **Subagent edits** join the same review in two ways. Retained `worktree` writers remain isolated while working, idle or paused. Only `jar_subagent stop` finalizes them: pi-jar verifies that each parent target still equals the private baseline snapshot, copies reviewable safe results back, then captures/marks those parent files in the normal `ChangeTracker`. The deprecated one-shot `write: true` shared-workspace mode keeps its older `PI_JAR_BASELINE_DIR` bridge for compatibility.
|
|
@@ -146,7 +146,7 @@ watch matches or process exits → pi-jar.shell message → agent wakes (o
|
|
|
146
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
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`.
|
|
148
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.
|
|
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
|
|
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); both reply with the new id only, never echoing the text. Each process keeps an in-memory read cursor (the highest entry id it has been shown, reset when the paper path changes). `list` returns only unseen entries written by others — the newest that fit a 4000-character reply, each clipped to 400 characters, stating how many older entries it omitted — and advances the cursor; with nothing new it replies `No new entries (latest dN).` `since: "d12"` lists everything newer than that id instead (`since: "d0"` rereads the whole paper) and `questionId` reads one thread in full without moving the cursor. An `ask`/`answer` reply attaches unseen entries (≤1000 characters) only when all of them are relevant to the caller — answers to its questions or questions addressed to it — otherwise it adds `(N new entries; list to read)` and leaves the cursor alone, so nothing is skipped. 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.
|
|
150
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.
|
|
151
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.
|
|
152
152
|
- The deprecated `write: true` mode remains a one-shot shared-workspace compatibility path. New delegated implementation should use `mode: "worktree"`.
|
package/extensions/index.ts
CHANGED
|
@@ -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,
|
|
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";
|
|
@@ -73,25 +75,24 @@ export default function piJar(pi: ExtensionAPI): void {
|
|
|
73
75
|
if (messages.length !== event.messages.length) return { messages };
|
|
74
76
|
});
|
|
75
77
|
}
|
|
76
|
-
if (
|
|
77
|
-
const
|
|
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
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
|
|
89
|
-
const
|
|
90
|
-
if (
|
|
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,14 +188,12 @@ 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
|
-
//
|
|
194
|
-
|
|
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`)] : [];
|
|
@@ -318,9 +317,8 @@ export default function piJar(pi: ExtensionAPI): void {
|
|
|
318
317
|
composer.setActivity(enabled ? working.phase : "idle", animations);
|
|
319
318
|
try {
|
|
320
319
|
if (!enabled) { workingIndicatorKey = ""; ctx.ui.setWorkingMessage?.(); ctx.ui.setWorkingIndicator(); return; }
|
|
321
|
-
const task = todos?.current();
|
|
322
320
|
const view = working.view(animations, (color, text) => ctx.ui.theme?.fg(color, text) ?? text,
|
|
323
|
-
Date.now(), ctx.thinkingLevel
|
|
321
|
+
Date.now(), ctx.thinkingLevel);
|
|
324
322
|
ctx.ui.setWorkingMessage?.(view.message);
|
|
325
323
|
const indicatorKey = `${working.phase}:${animations}`;
|
|
326
324
|
if (indicatorKey !== workingIndicatorKey) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-jar",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.8",
|
|
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",
|
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 {
|
|
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 [
|
|
139
|
-
|
|
140
|
-
|
|
148
|
+
const [status, diff] = await Promise.all([
|
|
149
|
+
git(["status", "--short", "--branch"]),
|
|
150
|
+
git(["diff", "--stat", "HEAD"])
|
|
141
151
|
]);
|
|
142
|
-
|
|
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
|
+
}
|