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 +17 -5
- package/docs/DIFF-REVIEW.md +11 -0
- package/docs/SUBAGENT-TOOLS.md +24 -0
- 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 +98 -26
- package/src/commit.ts +5 -6
- package/src/delegate-worktree.ts +72 -57
- package/src/delegate.ts +284 -87
- package/src/diff-view.ts +141 -30
- 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,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 `
|
|
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. `/` 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.
|
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.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 {
|
|
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
|
+
}
|
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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 (
|
|
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 &&
|
|
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
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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]
|
|
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 (
|
|
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(
|
|
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
|
-
|
|
34
|
-
|
|
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");
|