pi-jar 0.1.6 → 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,10 +254,11 @@ 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 only task progress (done/total, the in-progress task, activity, the next open tasks), read from memory; `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. When a resumed turn ends, a visible `pi-jar.subagent` message wakes the moderator (or queues for its current run) with that progress and the turn's report, so it never polls `peek`. Every action returns on <kbd>Esc</kbd>, `pause` gives up waiting after 15 s, and a working child with no RPC activity for 10 minutes is stopped as hung.
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
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
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.
@@ -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.
@@ -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.
@@ -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";
@@ -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 (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,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
- // 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`)] : [];
@@ -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, task ? task.activeForm ?? task.title : undefined);
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.6",
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",
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
+ }
package/src/changes.ts CHANGED
@@ -6,6 +6,8 @@ export const MAX_TRACKED_BYTES = 1024 * 1024;
6
6
  export const MAX_TRACKED_FILES = 200;
7
7
  /** Bound total retained baselines so a long editing session cannot pin hundreds of MiB. */
8
8
  export const MAX_TRACKED_TOTAL_BYTES = 8 * 1024 * 1024;
9
+ /** Explicit full-review choice supports bounded matrix-free previews beyond the exact-diff budget. */
10
+ export const MAX_REVIEW_DIFF_LINES = 100_000;
9
11
  /** Keep the quadratic fallback small; larger edits render a summary instead of allocating a huge matrix. */
10
12
  const MAX_DIFF_CELLS = 500_000;
11
13
  const MAX_DIFF_MIDDLE_LINES = 4_000;
@@ -27,6 +29,29 @@ function readText(path: string): string | null | undefined {
27
29
 
28
30
  const splitLines = (text: string) => text === "" ? [] : text.replace(/\r\n/g, "\n").replace(/\n$/, "").split("\n");
29
31
 
32
+ export function diffLineCount(text: string): number {
33
+ if (!text) return 0;
34
+ let count = 1;
35
+ for (let index = 0; index < text.length; index++) if (text.charCodeAt(index) === 10) count++;
36
+ if (text.endsWith("\n")) count--;
37
+ return count;
38
+ }
39
+
40
+ /** Safe O(n) fallback that preserves common prefix/suffix and clearly marks coarse replacements. */
41
+ export function coarseLineDiff(before: string, after: string): DiffOp[] {
42
+ const a = splitLines(before), b = splitLines(after);
43
+ let start = 0;
44
+ while (start < a.length && start < b.length && a[start] === b[start]) start++;
45
+ let endA = a.length, endB = b.length;
46
+ while (endA > start && endB > start && a[endA - 1] === b[endB - 1]) { endA--; endB--; }
47
+ return [
48
+ ...a.slice(0, start).map(text => ({ op: " " as const, text })),
49
+ ...a.slice(start, endA).map(text => ({ op: "-" as const, text })),
50
+ ...b.slice(start, endB).map(text => ({ op: "+" as const, text })),
51
+ ...a.slice(endA).map(text => ({ op: " " as const, text }))
52
+ ];
53
+ }
54
+
30
55
  /** Line diff: trims the common prefix/suffix, then an LCS over the changed middle. */
31
56
  export function lineDiff(before: string, after: string): DiffOp[] | undefined {
32
57
  const a = splitLines(before), b = splitLines(after);
@@ -57,38 +82,89 @@ export function lineDiff(before: string, after: string): DiffOp[] | undefined {
57
82
 
58
83
  /** Unified hunks with `context` lines around each change. */
59
84
  export function diffRows(ops: readonly DiffOp[], context = 3): DiffRow[] {
60
- const rows: DiffRow[] = [];
61
- const changed = ops.map((op) => op.op !== " ");
62
- let oldLine = 1, newLine = 1;
63
- const positions = ops.map((op) => {
64
- const at = { oldLine, newLine };
65
- if (op.op !== "+") oldLine++;
66
- if (op.op !== "-") newLine++;
67
- return at;
68
- });
69
- let index = 0;
85
+ return diffRowsWindow(ops, context, 0, Number.MAX_SAFE_INTEGER);
86
+ }
87
+
88
+ const linePositionCache = new WeakMap<readonly DiffOp[], { old: Uint32Array; next: Uint32Array }>();
89
+
90
+ type DiffHunk = { from: number; end: number; offset: number };
91
+ const hunkCache = new WeakMap<readonly DiffOp[], { context: number; offsets: number[]; rows: number; hunks: DiffHunk[] }>();
92
+
93
+ /** Logical rendered row offsets for hunk navigation, without materializing unchanged rows. */
94
+ export function diffHunkOffsets(ops: readonly DiffOp[], context = 3): number[] {
95
+ const cached = hunkCache.get(ops);
96
+ if (cached?.context === context) return cached.offsets.slice();
97
+ const offsets: number[] = [];
98
+ const hunks: DiffHunk[] = [];
99
+ let rendered = 0, index = 0;
70
100
  while (index < ops.length) {
71
- if (!changed[index]) { index++; continue; }
101
+ if (ops[index]!.op === " ") { index++; continue; }
72
102
  const from = Math.max(0, index - context);
73
103
  let to = index;
74
- // Extend the hunk while the next change is within 2 × context lines.
75
104
  while (to < ops.length) {
76
105
  let next = to + 1;
77
- while (next < ops.length && !changed[next]) next++;
106
+ while (next < ops.length && ops[next]!.op === " ") next++;
78
107
  if (next < ops.length && next - to <= context * 2) { to = next; continue; }
79
108
  break;
80
109
  }
81
110
  const end = Math.min(ops.length, to + context + 1);
82
- const slice = ops.slice(from, end);
83
- const oldCount = slice.filter((op) => op.op !== "+").length;
84
- const newCount = slice.filter((op) => op.op !== "-").length;
85
- rows.push({ kind: "hunk", text: `@@ -${positions[from]!.oldLine},${oldCount} +${positions[from]!.newLine},${newCount} @@` });
86
- for (let at = from; at < end; at++) {
111
+ offsets.push(rendered);
112
+ hunks.push({ from, end, offset: rendered });
113
+ rendered += 1 + end - from;
114
+ index = end;
115
+ }
116
+ hunkCache.set(ops, { context, offsets, rows: rendered, hunks });
117
+ return offsets.slice();
118
+ }
119
+
120
+ export function diffRowCount(ops: readonly DiffOp[], context = 3): number {
121
+ if (hunkCache.get(ops)?.context !== context) diffHunkOffsets(ops, context);
122
+ return hunkCache.get(ops)!.rows;
123
+ }
124
+
125
+ /** Compute only the requested output window; diff operations stay indexed without a second full row array. */
126
+ export function diffRowsWindow(ops: readonly DiffOp[], context = 3, offset = 0, limit = Number.MAX_SAFE_INTEGER): DiffRow[] {
127
+ const rows: DiffRow[] = [];
128
+ const firstRow = Math.max(0, offset);
129
+ const lastRow = firstRow + Math.max(0, limit);
130
+ if (hunkCache.get(ops)?.context !== context) diffHunkOffsets(ops, context);
131
+ const layout = hunkCache.get(ops)!;
132
+ if (firstRow >= layout.rows || lastRow <= firstRow) return rows;
133
+ let positions = linePositionCache.get(ops);
134
+ if (!positions) {
135
+ const old = new Uint32Array(ops.length + 1), next = new Uint32Array(ops.length + 1);
136
+ let oldLine = 1, newLine = 1;
137
+ for (let at = 0; at < ops.length; at++) {
138
+ const op = ops[at]!;
139
+ old[at] = oldLine; next[at] = newLine;
140
+ if (op.op !== "+") oldLine++;
141
+ if (op.op !== "-") newLine++;
142
+ }
143
+ old[ops.length] = oldLine;
144
+ next[ops.length] = newLine;
145
+ positions = { old, next };
146
+ linePositionCache.set(ops, positions);
147
+ }
148
+ // Binary-search the first intersecting hunk, then visit only visible operations. In
149
+ // particular, a 100k-line replacement must not slice/filter/allocate 100k rows per frame.
150
+ let lo = 0, hi = layout.hunks.length;
151
+ while (lo < hi) {
152
+ const mid = (lo + hi) >>> 1;
153
+ const hunk = layout.hunks[mid]!;
154
+ if (hunk.offset + 1 + hunk.end - hunk.from <= firstRow) lo = mid + 1;
155
+ else hi = mid;
156
+ }
157
+ for (let index = lo; index < layout.hunks.length; index++) {
158
+ const { from, end, offset: start } = layout.hunks[index]!;
159
+ if (start >= lastRow) break;
160
+ if (start >= firstRow) rows.push({ kind: "hunk", text: `@@ -${positions.old[from]!},${positions.old[end]! - positions.old[from]!} +${positions.next[from]!},${positions.next[end]! - positions.next[from]!} @@` });
161
+ const visibleFrom = from + Math.max(0, firstRow - start - 1);
162
+ const visibleEnd = Math.min(end, from + lastRow - start - 1);
163
+ for (let at = visibleFrom; at < visibleEnd; at++) {
87
164
  const op = ops[at]!;
88
165
  rows.push({ kind: op.op === "+" ? "add" : op.op === "-" ? "remove" : "context", text: op.text,
89
- ...(op.op !== "+" ? { oldLine: positions[at]!.oldLine } : {}), ...(op.op !== "-" ? { newLine: positions[at]!.newLine } : {}) });
166
+ ...(op.op !== "+" ? { oldLine: positions.old[at]! } : {}), ...(op.op !== "-" ? { newLine: positions.next[at]! } : {}) });
90
167
  }
91
- index = end;
92
168
  }
93
169
  return rows;
94
170
  }
@@ -174,13 +250,9 @@ export class ChangeTracker {
174
250
  if (now === undefined) { this.drop(path); continue; }
175
251
  if (now === before) { this.dirty.delete(path); continue; }
176
252
  const ops = lineDiff(before ?? "", now ?? "");
253
+ const summaryOps = ops ?? coarseLineDiff(before ?? "", now ?? "");
177
254
  let added = 0, removed = 0;
178
- if (ops) {
179
- for (const op of ops) { if (op.op === "+") added++; else if (op.op === "-") removed++; }
180
- } else {
181
- added = splitLines(now ?? "").length;
182
- removed = splitLines(before ?? "").length;
183
- }
255
+ for (const op of summaryOps) { if (op.op === "+") added++; else if (op.op === "-") removed++; }
184
256
  const rel = relative(this.cwd(), path);
185
257
  const change: FileChange = {
186
258
  path, rel: rel && !rel.startsWith("..") ? rel : path,
package/src/commit.ts CHANGED
@@ -4,6 +4,7 @@ import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import type { ModelRoleManager } from "./model-roles.ts";
6
6
  import { askRole, type SideUsage } from "./side-model.ts";
7
+ import { runProcess } from "./async-process.ts";
7
8
 
8
9
  const DIFF_LIMIT = 60_000;
9
10
 
@@ -27,13 +28,11 @@ export function commitPrompt(stat: string, diff: string, log: string, hint: stri
27
28
  }
28
29
 
29
30
  /** `/jar commit [note]`: draft a message for the staged changes with the commit role, review it, commit. Never pushes. */
30
- export async function jarCommit(pi: ExtensionAPI, ctx: ExtensionContext, roles: ModelRoleManager, usage: SideUsage | undefined, hint = ""): Promise<void> {
31
+ export async function jarCommit(_pi: ExtensionAPI, ctx: ExtensionContext, roles: ModelRoleManager, usage: SideUsage | undefined, hint = ""): Promise<void> {
31
32
  if (!ctx.hasUI) { ctx.ui.notify("/jar commit needs the interactive UI to review the message", "warning"); return; }
32
- const git = async (...args: string[]) => {
33
- const result = await pi.exec("git", args, { cwd: ctx.cwd, timeout: 20_000 });
34
- if (result.code !== 0) throw new Error(`git ${args[0]}: ${(result.stderr || result.stdout).trim() || "exit " + result.code}`);
35
- return result.stdout;
36
- };
33
+ const git = async (...args: string[]) => (await runProcess(process.env.PI_JAR_GIT_PATH?.trim() || "git", args, {
34
+ cwd: ctx.cwd, signal: ctx.signal, timeoutMs: 20_000, maxOutputBytes: 64 * 1024 * 1024
35
+ })).toString("utf8");
37
36
  try {
38
37
  await git("rev-parse", "--is-inside-work-tree");
39
38
  let stat = await git("diff", "--cached", "--stat");