pi-jar 0.2.9 → 0.3.0
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 +20 -21
- package/docs/DIFF-REVIEW.md +5 -4
- package/docs/EFFICIENCY_PLAN.md +8 -9
- package/docs/FLAME.md +19 -16
- package/docs/INTEGRATIONS.md +1 -1
- package/docs/SHOWCASE.md +43 -1
- package/docs/SUBAGENT-TOOLS.md +6 -0
- package/extensions/index.ts +6 -60
- package/package.json +1 -1
- package/src/cache-breaks.ts +12 -1
- package/src/compact-tools.ts +23 -1
- package/src/composer.ts +51 -38
- package/src/delegate.ts +21 -7
- package/src/diff-inline.ts +14 -6
- package/src/diff-view.ts +32 -9
- package/src/footer.ts +0 -4
- package/src/mascot.ts +134 -22
- package/src/profiles.ts +5 -6
- package/src/settings-ui.ts +13 -30
- package/src/settings.ts +1 -21
- package/src/subagent-tools.ts +28 -4
- package/src/task-tool.ts +2 -1
- package/src/context-budget.ts +0 -226
- package/src/context-diet.ts +0 -235
package/src/compact-tools.ts
CHANGED
|
@@ -13,7 +13,29 @@ import {
|
|
|
13
13
|
} from "@earendil-works/pi-coding-agent";
|
|
14
14
|
import { Box, Container, Text } from "@earendil-works/pi-tui";
|
|
15
15
|
import { Type } from "typebox";
|
|
16
|
-
import {
|
|
16
|
+
import { homedir } from "node:os";
|
|
17
|
+
import { join, resolve } from "node:path";
|
|
18
|
+
import { fileURLToPath } from "node:url";
|
|
19
|
+
|
|
20
|
+
interface ReadStubDetails { unchangedSinceRead: number }
|
|
21
|
+
const isReadStub = (details: unknown): details is ReadStubDetails => !!details && typeof details === "object"
|
|
22
|
+
&& typeof (details as ReadStubDetails).unchangedSinceRead === "number";
|
|
23
|
+
|
|
24
|
+
/** Match Pi's native read path spelling without probing the filesystem. */
|
|
25
|
+
function resolveToolPath(path: string, cwd: string): string | undefined {
|
|
26
|
+
let value = path.replace(/[\u00A0\u2000-\u200A\u202F\u205F\u3000]/g, " ");
|
|
27
|
+
if (value.startsWith("@")) value = value.slice(1);
|
|
28
|
+
if (value === "~" || value.startsWith("~/")) value = join(homedir(), value.slice(2));
|
|
29
|
+
else if (value.startsWith("file://")) {
|
|
30
|
+
try { value = fileURLToPath(value); } catch { return undefined; }
|
|
31
|
+
}
|
|
32
|
+
return resolve(cwd, value);
|
|
33
|
+
}
|
|
34
|
+
function readRangeKey(offset: unknown, limit: unknown): string | undefined {
|
|
35
|
+
const start = offset === undefined ? 1 : typeof offset === "number" ? Math.max(offset, 1) : undefined;
|
|
36
|
+
if (start === undefined || (limit !== undefined && typeof limit !== "number")) return undefined;
|
|
37
|
+
return `${start}:${limit ?? ""}`;
|
|
38
|
+
}
|
|
17
39
|
|
|
18
40
|
type ToolResult = { content: Array<{ type: string; text?: string }>; details?: unknown };
|
|
19
41
|
|
package/src/composer.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { CustomEditor, type ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { Key, matchesKey, stripTerminalSequences, truncateToWidth, visibleWidth, type EditorComponent, type EditorTheme, type TUI, type TuiMouseEvent } from "@earendil-works/pi-tui";
|
|
3
|
-
import { Mascot, MASCOT_FACE_WIDTH, mascotFace, paintFace, paintTip, type MascotMood } from "./mascot.ts";
|
|
3
|
+
import { Mascot, MASCOT_FACE_WIDTH, MASCOT_SPRITE_WIDTH, MASCOT_FRAME_MS, mascotFace, mascotTip, mascotPerch, paintFace, paintTip, type MascotMood } from "./mascot.ts";
|
|
4
4
|
import type { SuggestionState } from "./suggest.ts";
|
|
5
5
|
import type { WorkingPhase } from "./working.ts";
|
|
6
6
|
import { imageChip, imageInfo, imagePaths } from "./attachments.ts";
|
|
@@ -18,7 +18,7 @@ type MouseEvent = Parameters<NonNullable<EditorComponent["handleMouse"]>>[0];
|
|
|
18
18
|
const PHASE_MOODS: Record<WorkingPhase, readonly MascotMood[]> = {
|
|
19
19
|
idle: ["idle"], generating: ["happy", "thinking"], tool: ["tool"], waiting: ["waiting"]
|
|
20
20
|
};
|
|
21
|
-
export const COMPOSER_ACTIVE_INTERVAL_MS =
|
|
21
|
+
export const COMPOSER_ACTIVE_INTERVAL_MS = MASCOT_FRAME_MS;
|
|
22
22
|
/** How long composer image chips trust a previous filesystem check for the same draft. */
|
|
23
23
|
const ATTACHMENT_RECHECK_MS = 1000;
|
|
24
24
|
/** Plain one-line face for a phase; every phase has the same width. */
|
|
@@ -69,7 +69,7 @@ function bottomBorderIndex(lines: readonly string[], visibleRows?: number): numb
|
|
|
69
69
|
const overflowLabel = (line: string | undefined) => /[↑↓]\s*\d+\s*more/.exec(stripTerminalSequences(line ?? ""))?.[0];
|
|
70
70
|
|
|
71
71
|
export interface RoundedInputOptions {
|
|
72
|
-
/** Active profile shown
|
|
72
|
+
/** Active profile shown beside the human session title. */
|
|
73
73
|
profile?: string;
|
|
74
74
|
/** Dim inline suggestion shown after the cursor when the draft is empty. */
|
|
75
75
|
ghost?: string;
|
|
@@ -108,9 +108,10 @@ export function roundedInput(lines: string[], width: number, focused: boolean, t
|
|
|
108
108
|
if (lastFrame?.key === key && lastFrame.border === border && lastFrame.dim === options.dim && sameLines(lastFrame.lines, lines)) return lastFrame.framed.slice();
|
|
109
109
|
const bottom = bottomBorderIndex(lines, options.visibleRows);
|
|
110
110
|
const inner = width - 2;
|
|
111
|
+
const prefix = icon ? `${icon} · ` : "";
|
|
111
112
|
const meta = profile !== undefined
|
|
112
|
-
? `${
|
|
113
|
-
: session ? `${
|
|
113
|
+
? `${prefix}(${profile})${session ? " " + cleanText(session, 120) : ""}`
|
|
114
|
+
: session ? `${prefix}session ${session}` : icon;
|
|
114
115
|
const title = truncateToWidth(` ${meta} `, width - 3);
|
|
115
116
|
const paintedTitle = options.paintedIcon && title.includes(icon) ? title.replace(icon, options.paintedIcon) : title;
|
|
116
117
|
const above = overflowLabel(lines[0]);
|
|
@@ -146,11 +147,11 @@ export function roundedInput(lines: string[], width: number, focused: boolean, t
|
|
|
146
147
|
|
|
147
148
|
export interface ComposerDecor {
|
|
148
149
|
/** Title icon width source (plain text). */
|
|
149
|
-
icon(): string;
|
|
150
|
+
icon(width: number): string;
|
|
150
151
|
/** Painted icon, or undefined to use theme border colors. */
|
|
151
|
-
paintedIcon(): string | undefined;
|
|
152
|
-
/**
|
|
153
|
-
|
|
152
|
+
paintedIcon(width: number): string | undefined;
|
|
153
|
+
/** Pixel sprite (or compact crown) above the frame, omitted when there is no room. */
|
|
154
|
+
perch(width: number): readonly string[] | undefined;
|
|
154
155
|
session(): string;
|
|
155
156
|
profile?(): string;
|
|
156
157
|
ghost(): string | undefined;
|
|
@@ -180,13 +181,13 @@ class RoundedEditor extends CustomEditor {
|
|
|
180
181
|
}
|
|
181
182
|
|
|
182
183
|
/** Let drafts grow to ~60% of the terminal (Pi's editor caps at 30%) before scrolling. */
|
|
183
|
-
private renderTall(width: number): string[] {
|
|
184
|
+
private renderTall(width: number, perchRows: number): string[] {
|
|
184
185
|
const self = this as unknown as { tui?: { terminal?: { rows?: number } } };
|
|
185
186
|
const tui = self.tui;
|
|
186
187
|
const rows = tui?.terminal?.rows;
|
|
187
188
|
if (!tui || !rows || !Number.isFinite(rows)) return super.render(width);
|
|
188
189
|
const tall = new Proxy(tui, { get: (target, key, receiver) => key === "terminal"
|
|
189
|
-
? new Proxy(target.terminal!, { get: (term, name) => name === "rows" ? rows * 2 : Reflect.get(term, name, term) })
|
|
190
|
+
? new Proxy(target.terminal!, { get: (term, name) => name === "rows" ? Math.max(rows, (rows - (perchRows > 1 ? perchRows : 0)) * 2) : Reflect.get(term, name, term) })
|
|
190
191
|
: Reflect.get(target, key, receiver) });
|
|
191
192
|
try {
|
|
192
193
|
self.tui = tall;
|
|
@@ -197,26 +198,25 @@ class RoundedEditor extends CustomEditor {
|
|
|
197
198
|
}
|
|
198
199
|
|
|
199
200
|
override render(width: number): string[] {
|
|
200
|
-
const
|
|
201
|
+
const perch = width >= 8 ? this.decor.perch(width) ?? [] : [];
|
|
202
|
+
this.tipRows = perch.length;
|
|
203
|
+
const inner = this.renderTall(width < 8 ? width : width - 2, perch.length);
|
|
201
204
|
const text = this.getText();
|
|
202
205
|
const empty = text === "";
|
|
203
206
|
const below = this.decor.attachments(text);
|
|
204
207
|
const ghost = empty && !this.isShowingAutocomplete() ? this.decor.ghost() : undefined;
|
|
205
208
|
this.ghostShown = !!ghost;
|
|
206
209
|
const visibleRows = (this as unknown as { renderedVisibleLineCount?: number }).renderedVisibleLineCount;
|
|
207
|
-
const icon = this.decor.icon();
|
|
210
|
+
const icon = this.decor.icon(width);
|
|
208
211
|
const session = this.decor.session();
|
|
209
212
|
const hint = this.decor.hint();
|
|
210
|
-
const paintedIcon = this.decor.paintedIcon();
|
|
213
|
+
const paintedIcon = this.decor.paintedIcon(width);
|
|
211
214
|
const framed = roundedInput(inner, width, this.focused, this.colors, this.paint, icon, session, {
|
|
212
215
|
...(ghost ? { ghost } : {}), ...(hint ? { hint } : {}), ...(paintedIcon ? { paintedIcon } : {}),
|
|
213
216
|
...(visibleRows != null ? { visibleRows } : {}), profile: this.decor.profile?.(), dim: this.decor.dim,
|
|
214
217
|
...(below ? { below } : {})
|
|
215
218
|
});
|
|
216
|
-
|
|
217
|
-
this.tipRows = tip ? 1 : 0;
|
|
218
|
-
// The tip is a few painted cells; measuring (cached) skips re-segmenting it on every frame.
|
|
219
|
-
return tip ? [visibleWidth(tip) <= width ? tip : truncateToWidth(tip, width), ...framed] : framed;
|
|
219
|
+
return [...perch.map(row => visibleWidth(row) <= width ? row : truncateToWidth(row, width)), ...framed];
|
|
220
220
|
}
|
|
221
221
|
|
|
222
222
|
override handleInput(data: string): void {
|
|
@@ -230,7 +230,9 @@ class RoundedEditor extends CustomEditor {
|
|
|
230
230
|
|
|
231
231
|
override handleMouse(event: MouseEvent) {
|
|
232
232
|
const y = event.y - this.tipRows;
|
|
233
|
-
|
|
233
|
+
const inPet = y < 0 ? event.x >= 3 && event.x < 3 + (this.tipRows > 1 ? MASCOT_SPRITE_WIDTH : MASCOT_FACE_WIDTH)
|
|
234
|
+
: y === 0 && this.decor.icon(event.width).startsWith("▐") && faceColumns(event.x);
|
|
235
|
+
if (inPet) {
|
|
234
236
|
if (event.type === "click" && event.button === "left") { this.decor.poke(); return { handled: true, focus: true }; }
|
|
235
237
|
return undefined;
|
|
236
238
|
}
|
|
@@ -278,16 +280,16 @@ class ThemedEditor implements EditorComponent {
|
|
|
278
280
|
const text = this.base.getText();
|
|
279
281
|
const ghost = text === "" ? this.decor.ghost() : undefined;
|
|
280
282
|
const below = this.decor.attachments(text);
|
|
281
|
-
const icon = this.decor.icon();
|
|
283
|
+
const icon = this.decor.icon(width);
|
|
282
284
|
const session = this.decor.session();
|
|
283
|
-
const paintedIcon = this.decor.paintedIcon();
|
|
285
|
+
const paintedIcon = this.decor.paintedIcon(width);
|
|
284
286
|
const framed = roundedInput(this.base.render(width < 8 ? width : width - 2), width, this.focused, this.theme, this.paint, icon, session, {
|
|
285
287
|
...(ghost ? { ghost } : {}), ...(paintedIcon ? { paintedIcon } : {}), profile: this.decor.profile?.(), dim: this.decor.dim,
|
|
286
288
|
...(below ? { below } : {})
|
|
287
289
|
});
|
|
288
|
-
const
|
|
289
|
-
this.tipRows =
|
|
290
|
-
return
|
|
290
|
+
const perch = width >= 8 ? this.decor.perch(width) ?? [] : [];
|
|
291
|
+
this.tipRows = perch.length;
|
|
292
|
+
return [...perch.map(row => visibleWidth(row) <= width ? row : truncateToWidth(row, width)), ...framed];
|
|
291
293
|
}
|
|
292
294
|
addToHistory(text: string) { this.base.addToHistory?.(text); }
|
|
293
295
|
insertTextAtCursor(text: string) { this.base.insertTextAtCursor?.(text); }
|
|
@@ -297,8 +299,13 @@ class ThemedEditor implements EditorComponent {
|
|
|
297
299
|
setAutocompleteMaxVisible(value: number) { this.base.setAutocompleteMaxVisible?.(value); }
|
|
298
300
|
handleMouse(event: MouseEvent) {
|
|
299
301
|
const y = event.y - this.tipRows;
|
|
302
|
+
if ((y < 0 && event.x >= 3 && event.x < 3 + (this.tipRows > 1 ? MASCOT_SPRITE_WIDTH : MASCOT_FACE_WIDTH))
|
|
303
|
+
|| (y === 0 && this.decor.icon(event.width).startsWith("▐") && faceColumns(event.x))) {
|
|
304
|
+
if (event.type === "click" && event.button === "left") { this.decor.poke(); return { handled: true, focus: true }; }
|
|
305
|
+
return undefined;
|
|
306
|
+
}
|
|
300
307
|
if (y < 0) return undefined;
|
|
301
|
-
return this.base.handleMouse?.({ ...event, y, x: event.x - 1, width: Math.max(1, event.width - 2) });
|
|
308
|
+
return this.base.handleMouse?.({ ...event, y, x: event.x - 1, width: Math.max(1, event.width - 2), height: Math.max(1, event.height - this.tipRows) });
|
|
302
309
|
}
|
|
303
310
|
}
|
|
304
311
|
|
|
@@ -309,7 +316,6 @@ export class ComposerStyle {
|
|
|
309
316
|
private tui?: TUI;
|
|
310
317
|
private timer?: ReturnType<typeof setTimeout>;
|
|
311
318
|
private phase: WorkingPhase = "idle";
|
|
312
|
-
private frame = 0;
|
|
313
319
|
private animations = true;
|
|
314
320
|
private mascotOn = true;
|
|
315
321
|
private session = "";
|
|
@@ -324,18 +330,23 @@ export class ComposerStyle {
|
|
|
324
330
|
private chips?: { text: string; cwd: string; at: number; chips: string[] };
|
|
325
331
|
|
|
326
332
|
private readonly decor: ComposerDecor = {
|
|
327
|
-
icon: () => this.mascotOn ?
|
|
328
|
-
|
|
333
|
+
icon: (width) => !this.mascotOn ? "pi" : this.fullSprite(width) ? ""
|
|
334
|
+
: mascotFace(this.animations ? this.mascot.mood() : "idle"),
|
|
335
|
+
paintedIcon: (width) => {
|
|
329
336
|
if (!this.mascotOn) return undefined;
|
|
337
|
+
if (this.fullSprite(width)) return undefined;
|
|
330
338
|
const mood = this.animations ? this.mascot.mood() : "idle";
|
|
331
339
|
return paintFace(mascotFace(mood), mood, this.colors ? (color, text) => this.colors!.fg(color, text) : undefined);
|
|
332
340
|
},
|
|
333
|
-
|
|
341
|
+
perch: (width) => {
|
|
334
342
|
if (!this.mascotOn || width < 40) return undefined;
|
|
335
343
|
const mood = this.animations ? this.mascot.mood() : "idle";
|
|
336
|
-
const
|
|
337
|
-
|
|
338
|
-
|
|
344
|
+
const frame = this.animations ? Math.floor(Date.now() / COMPOSER_ACTIVE_INTERVAL_MS) : 0;
|
|
345
|
+
if (this.fullSprite(width)) {
|
|
346
|
+
return mascotPerch(mood, frame, this.colors ? (color, text) => this.colors!.fg(color, text) : undefined)
|
|
347
|
+
.map(row => " " + row);
|
|
348
|
+
}
|
|
349
|
+
return [" " + paintTip(mascotTip(mood, frame), mood, this.colors ? (color, text) => this.colors!.fg(color, text) : undefined)];
|
|
339
350
|
},
|
|
340
351
|
session: () => this.session,
|
|
341
352
|
profile: () => this.profile,
|
|
@@ -363,6 +374,11 @@ export class ComposerStyle {
|
|
|
363
374
|
}
|
|
364
375
|
};
|
|
365
376
|
|
|
377
|
+
/** Keep short/narrow terminals usable; the pet and mood spark reserve six rows. */
|
|
378
|
+
private fullSprite(width: number): boolean {
|
|
379
|
+
return width >= 60 && (this.tui?.terminal?.rows ?? process.stdout.rows ?? 24) >= 24;
|
|
380
|
+
}
|
|
381
|
+
|
|
366
382
|
/** Share the suggestion state rendered as ghost text. */
|
|
367
383
|
attachSuggestions(state: SuggestionState): void {
|
|
368
384
|
this.unsubscribe?.();
|
|
@@ -398,7 +414,6 @@ export class ComposerStyle {
|
|
|
398
414
|
if (!changed) return;
|
|
399
415
|
this.phase = phase;
|
|
400
416
|
this.animations = animations;
|
|
401
|
-
this.frame = 0;
|
|
402
417
|
this.restartTimer();
|
|
403
418
|
if (this.enabled) this.tui?.requestRender();
|
|
404
419
|
}
|
|
@@ -406,12 +421,12 @@ export class ComposerStyle {
|
|
|
406
421
|
/**
|
|
407
422
|
* Pi's own streaming/tool updates already repaint active sessions. Never add another active
|
|
408
423
|
* animation loop: active frames derive from wall time when Pi naturally renders. Idle only
|
|
409
|
-
* schedules exact blink/sleep
|
|
424
|
+
* schedules exact blink/sleep transitions and bounded reaction frames until a flash expires.
|
|
410
425
|
*/
|
|
411
426
|
private restartTimer(): void {
|
|
412
427
|
this.stopTimer();
|
|
413
428
|
if (!this.enabled || !this.animations || !this.mascotOn) return;
|
|
414
|
-
this.lastKey = this.mascot.key(
|
|
429
|
+
this.lastKey = this.mascot.key();
|
|
415
430
|
this.scheduleTimer();
|
|
416
431
|
}
|
|
417
432
|
private scheduleTimer(): void {
|
|
@@ -420,8 +435,7 @@ export class ComposerStyle {
|
|
|
420
435
|
if (delay === undefined) return;
|
|
421
436
|
this.timer = setTimeout(() => {
|
|
422
437
|
this.timer = undefined;
|
|
423
|
-
this.
|
|
424
|
-
const key = this.mascot.key(Date.now(), this.frame);
|
|
438
|
+
const key = this.mascot.key();
|
|
425
439
|
if (key !== this.lastKey) {
|
|
426
440
|
this.lastKey = key;
|
|
427
441
|
this.tui?.requestRender();
|
|
@@ -471,7 +485,6 @@ export class ComposerStyle {
|
|
|
471
485
|
this.stopTimer();
|
|
472
486
|
this.tui = undefined;
|
|
473
487
|
this.phase = "idle";
|
|
474
|
-
this.frame = 0;
|
|
475
488
|
this.session = "";
|
|
476
489
|
try {
|
|
477
490
|
if (ctx.hasUI && ctx.mode === "tui" && this.owner && ctx.ui.getEditorComponent() === this.owner) {
|
package/src/delegate.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { spawn, type ChildProcess } from "node:child_process";
|
|
2
2
|
import { signalProcessTree } from "./async-process.ts";
|
|
3
|
-
import { CHILD_TOOLS_ENV, EMPTY_SUBAGENT_CONFIG, parseRules, RECURSIVE_TOOLS, SHELL_TOOLS, SUBAGENT_CONFIG_FILE, WEB_TOOLS, type RuleList, type SubagentConfig } from "./subagent-tools.ts";
|
|
3
|
+
import { CHILD_SKILL_READS_ENV, CHILD_TOOLS_ENV, skillReadRoots, EMPTY_SUBAGENT_CONFIG, parseRules, RECURSIVE_TOOLS, SHELL_TOOLS, SUBAGENT_CONFIG_FILE, WEB_TOOLS, type RuleList, type SubagentConfig } from "./subagent-tools.ts";
|
|
4
4
|
import { planExtensions, planSkills, type ExtensionPlan, type ResourceCatalog } from "./subagent-resources.ts";
|
|
5
5
|
import { existsSync, mkdtempSync, realpathSync } from "node:fs";
|
|
6
6
|
import { rm } from "node:fs/promises";
|
|
@@ -710,7 +710,7 @@ export function startDelegate(run: DelegateRun, args: string[], prompt: string,
|
|
|
710
710
|
let child: ChildProcess;
|
|
711
711
|
try {
|
|
712
712
|
const env = { ...process.env };
|
|
713
|
-
for (const key of [CHILD_BASELINE_ENV, CHILD_WORKTREE_ENV, CHILD_TOOLS_ENV, ...DISCUSSION_ENV_KEYS]) delete env[key];
|
|
713
|
+
for (const key of [CHILD_BASELINE_ENV, CHILD_WORKTREE_ENV, CHILD_TOOLS_ENV, CHILD_SKILL_READS_ENV, ...DISCUSSION_ENV_KEYS]) delete env[key];
|
|
714
714
|
child = spawnProcess(invocation.command, invocation.args, { cwd, shell: false, detached: process.platform !== "win32", stdio: ["pipe", "pipe", "pipe"], env: { ...env, ...hooks.env?.(), [CHILD_ENV]: "1" } });
|
|
715
715
|
} catch (error) {
|
|
716
716
|
finish("failed", error instanceof Error ? error.message : String(error));
|
|
@@ -1387,12 +1387,20 @@ export function registerDelegate(pi: ExtensionAPI, roles: ModelRoleManager, regi
|
|
|
1387
1387
|
pi.on?.("before_agent_start", (_event, ctx) => {
|
|
1388
1388
|
controllerContext = ctx;
|
|
1389
1389
|
const retained = registry.records().filter((record) => isRetained(record.run.state));
|
|
1390
|
-
|
|
1390
|
+
// Give routing guidance before the first delegation, not only after a fleet exists.
|
|
1391
|
+
// Stable system text avoids accumulating another hidden transcript message on every prompt.
|
|
1392
|
+
const systemPrompt = _event.systemPrompt + "\n\n" + [
|
|
1393
|
+
"[PI-JAR DELEGATION]",
|
|
1394
|
+
"For substantial work, identify independent, bounded tasks before starting tool-heavy implementation. Use jar_delegate when parallel ownership saves work; do small, tightly coupled tasks directly. Do not invent a tool-call count or delegate merely to fill the pool.",
|
|
1395
|
+
"Use scout for fresh research, external documentation and cross-repository discovery; fork for read-only review with conversation context; worktree only for isolated implementation. Worktrees cannot use shells or arbitrary paths outside their workspace. Research inaccessible references with a scout or the main agent, then send relevant findings to the worker.",
|
|
1396
|
+
"Grant needed active tools and skills per task, respecting user allowlists/disallows. Reuse retained agents, avoid duplicate work and polling, and stop worktree agents to reconcile changes before verifying them."
|
|
1397
|
+
].join("\n");
|
|
1398
|
+
if (!retained.length) return { systemPrompt };
|
|
1391
1399
|
const fleet = retained.map((record) => {
|
|
1392
1400
|
const unread = discussionUnread(record.key);
|
|
1393
1401
|
return `- ${record.key} · ${record.run.name} · ${record.run.state}${unread ? ` · ${plural(unread, "unread discussion message")}` : ""} · ${record.run.role}: ${cleanText(record.run.task, 140)}`;
|
|
1394
1402
|
}).join("\n");
|
|
1395
|
-
return { message: { customType: "pi-jar.moderator-context", display: false, content: [
|
|
1403
|
+
return { systemPrompt, message: { customType: "pi-jar.moderator-context", display: false, content: [
|
|
1396
1404
|
"[PI-JAR MODERATOR MODE]",
|
|
1397
1405
|
"Retained subagents own their delegated tasks: don't duplicate their work. Their turn ends and ask/pause/stop results arrive as pi-jar.subagent events; an accepted receipt is not completion, so keep working and never poll peek.",
|
|
1398
1406
|
"Hibernated agents are idle with the process closed; resume and ask relaunch them intact. Stop finished workers to reconcile worktree changes into /diff, then synthesize from their reports.",
|
|
@@ -1491,7 +1499,7 @@ export function registerDelegate(pi: ExtensionAPI, roles: ModelRoleManager, regi
|
|
|
1491
1499
|
description: `Start retained subagents and return immediately; the pool limit counts idle, paused and hibernated agents. fork and worktree inherit this conversation; a worktree's isolated Git changes reach /diff only when you stop it, and at most ${RECOVERY_LIMIT} worktree workspaces may be active or awaiting recovery (resolve with jar_subagent stop or discard). Turn completions arrive as pi-jar.subagent events with a short summary and changed files; jar_subagent report returns a full report. Settled agents hibernate (process closed, context kept) until resumed.`,
|
|
1492
1500
|
promptSnippet: "Start retained parallel subagents: scout (cheap read-only discovery), fork (read-only review with your context), worktree (isolated implementation).",
|
|
1493
1501
|
promptGuidelines: [
|
|
1494
|
-
"Delegate
|
|
1502
|
+
"Delegate substantial independent tasks when parallel ownership saves work; handle small or tightly coupled tasks directly. Choose scout for external research, fork for contextual read-only review, and worktree for isolated edits; worktree agents cannot use shells or arbitrary external paths.",
|
|
1495
1503
|
"After delegating, moderate: completions arrive as pi-jar.subagent events, so keep working and never poll; don't redo delegated work; reuse retained agents (jar_subagent resume) instead of spawning; stop finished workers to apply worktree changes into /diff."
|
|
1496
1504
|
],
|
|
1497
1505
|
parameters: Parameters,
|
|
@@ -1564,7 +1572,8 @@ export function registerDelegate(pi: ExtensionAPI, roles: ModelRoleManager, regi
|
|
|
1564
1572
|
let extensionPlan: ExtensionPlan | undefined;
|
|
1565
1573
|
const skillPlans: Array<readonly string[] | undefined> = requested.map(() => undefined);
|
|
1566
1574
|
const ruled = (rules: RuleList) => rules.allow.length > 0 || rules.deny.length > 0;
|
|
1567
|
-
if (ruled(config.extensions) || ruled(config.skills) || skillRules.some(Boolean)
|
|
1575
|
+
if (ruled(config.extensions) || ruled(config.skills) || skillRules.some(Boolean)
|
|
1576
|
+
|| (options.loadResources && requested.some(item => (item.mode ?? params.mode) === "worktree"))) {
|
|
1568
1577
|
if (!options.loadResources) {
|
|
1569
1578
|
return { content: [{ type: "text", text: "This Pi host cannot resolve extensions/skills for subagents; remove extension/skill rules." }], details: { runs: [], write: false }, isError: true };
|
|
1570
1579
|
}
|
|
@@ -1576,7 +1585,11 @@ export function registerDelegate(pi: ExtensionAPI, roles: ModelRoleManager, regi
|
|
|
1576
1585
|
return { content: [{ type: "text", text: `Cannot plan subagent extensions: ${(error as Error).message}` }], details: { runs: [], write: false }, isError: true };
|
|
1577
1586
|
}
|
|
1578
1587
|
for (const [index, rules] of skillRules.entries()) {
|
|
1579
|
-
try {
|
|
1588
|
+
try {
|
|
1589
|
+
skillPlans[index] = planSkills(config.skills, rules, catalog, SUBAGENT_CONFIG_FILE);
|
|
1590
|
+
if ((requested[index]!.mode ?? params.mode) === "worktree" && skillPlans[index] === undefined)
|
|
1591
|
+
skillPlans[index] = catalog.skills.filter(resource => resource.enabled).map(resource => resource.path);
|
|
1592
|
+
}
|
|
1580
1593
|
catch (error) {
|
|
1581
1594
|
return { content: [{ type: "text", text: `Task ${index + 1}: ${(error as Error).message}` }], details: { runs: [], write: false }, isError: true };
|
|
1582
1595
|
}
|
|
@@ -1751,6 +1764,7 @@ export function registerDelegate(pi: ExtensionAPI, roles: ModelRoleManager, regi
|
|
|
1751
1764
|
run.workspace = worktree.root;
|
|
1752
1765
|
cwd = worktree.cwd;
|
|
1753
1766
|
env[CHILD_WORKTREE_ENV] = worktree.root;
|
|
1767
|
+
env[CHILD_SKILL_READS_ENV] = JSON.stringify(skillReadRoots(skillPlans[run.index - 1] ?? []));
|
|
1754
1768
|
} catch (error) {
|
|
1755
1769
|
await cleanupPrivate(run.index, true);
|
|
1756
1770
|
if (discarded.has(index) || stopRequested.has(index)) markStartStopped(run);
|
package/src/diff-inline.ts
CHANGED
|
@@ -61,20 +61,28 @@ const INVERSE_ON = "\x1b[7m";
|
|
|
61
61
|
const INVERSE_OFF = "\x1b[27m";
|
|
62
62
|
|
|
63
63
|
/**
|
|
64
|
-
* Insert
|
|
64
|
+
* Insert emphasis markers around `spans` of the visible text inside an SGR-styled string.
|
|
65
|
+
* Defaults to inverse video; background markers can restore the surrounding line tint on exit.
|
|
65
66
|
* Offsets count visible code units, so styling (syntax colours) never shifts the emphasis.
|
|
66
67
|
*/
|
|
67
|
-
export function emphasize(styled: string, spans: readonly Span[]): string {
|
|
68
|
+
export function emphasize(styled: string, spans: readonly Span[], markers = { on: INVERSE_ON, off: INVERSE_OFF }): string {
|
|
68
69
|
if (!spans.length) return styled;
|
|
69
70
|
let out = "", visible = 0, span = 0, open = false;
|
|
70
71
|
for (let at = 0; at < styled.length;) {
|
|
71
72
|
SGR.lastIndex = at;
|
|
72
73
|
const escape = SGR.exec(styled);
|
|
73
|
-
if (escape) {
|
|
74
|
-
|
|
75
|
-
|
|
74
|
+
if (escape) {
|
|
75
|
+
out += escape[0];
|
|
76
|
+
// Syntax highlighters reset SGR between tokens; keep the changed span visible across them.
|
|
77
|
+
if (open && (escape[0] === "\x1b[0m" || escape[0] === "\x1b[m"
|
|
78
|
+
|| escape[0] === (markers.on === INVERSE_ON ? INVERSE_OFF : "\x1b[49m"))) out += markers.on;
|
|
79
|
+
at += escape[0].length;
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
while (span < spans.length && open && visible >= spans[span]![1]) { out += markers.off; open = false; span++; }
|
|
83
|
+
if (span < spans.length && !open && visible >= spans[span]![0]) { out += markers.on; open = true; }
|
|
76
84
|
out += styled[at++];
|
|
77
85
|
visible++;
|
|
78
86
|
}
|
|
79
|
-
return open ? out +
|
|
87
|
+
return open ? out + markers.off : out;
|
|
80
88
|
}
|
package/src/diff-view.ts
CHANGED
|
@@ -57,6 +57,23 @@ export interface DiffPaint {
|
|
|
57
57
|
syntax?: (text: string) => string | undefined;
|
|
58
58
|
/** SGR background sequence that tints a changed line's text. */
|
|
59
59
|
tint?: (kind: "add" | "remove") => string;
|
|
60
|
+
/** Stronger SGR background for changed words; absent hooks keep inverse-video emphasis. */
|
|
61
|
+
strongTint?: (kind: "add" | "remove") => string;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** OMP-style soft/strong backgrounds, derived from native theme truecolor sequences when available. */
|
|
65
|
+
export function diffBackgrounds(canvas: string, added: string, removed: string): Pick<DiffPaint, "tint" | "strongTint"> | undefined {
|
|
66
|
+
const rgb = (ansi: string) => {
|
|
67
|
+
const match = /\x1b\[(?:38|48);2;(\d+);(\d+);(\d+)m/.exec(ansi);
|
|
68
|
+
return match ? match.slice(1).map(Number) : undefined;
|
|
69
|
+
};
|
|
70
|
+
const base = rgb(canvas), add = rgb(added), remove = rgb(removed);
|
|
71
|
+
if (!base || !add || !remove || [...base, ...add, ...remove].some(value => value > 255)) return undefined;
|
|
72
|
+
const light = base[0]! * 0.2126 + base[1]! * 0.7152 + base[2]! * 0.0722 > 127;
|
|
73
|
+
const mix = (color: number[], amount: number) => `\x1b[48;2;${base.map((value, index) => Math.round(value + (color[index]! - value) * amount)).join(";")}m`;
|
|
74
|
+
const soft = { add: mix(add, light ? 0.24 : 0.18), remove: mix(remove, light ? 0.24 : 0.18) };
|
|
75
|
+
const strong = { add: mix(add, light ? 0.48 : 0.42), remove: mix(remove, light ? 0.48 : 0.42) };
|
|
76
|
+
return { tint: kind => soft[kind], strongTint: kind => strong[kind] };
|
|
60
77
|
}
|
|
61
78
|
|
|
62
79
|
/** Context sizes cycled by ←/→ (and [/]); Infinity shows whole files. */
|
|
@@ -93,7 +110,7 @@ export function renderDiffWindow(change: FileChange, width: number, fg: (color:
|
|
|
93
110
|
const rows = Math.max(1, Math.min(options.rows ?? PREVIEW_WINDOW, PREVIEW_WINDOW));
|
|
94
111
|
const scroll = Math.max(0, options.scroll ?? 0);
|
|
95
112
|
const context = options.context ?? 3;
|
|
96
|
-
const unified = options.unified
|
|
113
|
+
const unified = options.unified === true || width < MIN_SPLIT_WIDTH;
|
|
97
114
|
const wrap = options.wrap === true;
|
|
98
115
|
const paint = options.paint ?? {};
|
|
99
116
|
const total = unified ? diffRowCount(ops, context) : diffSplitRowCount(ops, context);
|
|
@@ -113,10 +130,12 @@ export function renderDiffWindow(change: FileChange, width: number, fg: (color:
|
|
|
113
130
|
const styled = paint.syntax?.(display) ?? fg(KIND_COLOR[kind], display);
|
|
114
131
|
if (bounded || kind === "context" || pair === undefined) return styled;
|
|
115
132
|
const changes = kind === "remove" ? inlineChanges(display, safeLine(pair)) : inlineChanges(safeLine(pair), display);
|
|
116
|
-
|
|
133
|
+
const strong = paint.strongTint?.(kind);
|
|
134
|
+
return changes ? emphasize(styled, kind === "remove" ? changes.before : changes.after,
|
|
135
|
+
strong ? { on: strong, off: paint.tint?.(kind) ?? "\x1b[49m" } : undefined) : styled;
|
|
117
136
|
};
|
|
118
137
|
// Rail, numbers and sign, then text truncated or wrapped into the cell; continuation lines keep
|
|
119
|
-
// the rail only.
|
|
138
|
+
// the rail only. Syntax/wrapping/truncation resets re-apply the surrounding soft tint.
|
|
120
139
|
const cell = (kind: Kind, lead: string, text: string, width: number, pair?: string): string[] => {
|
|
121
140
|
const leadWidth = visibleWidth(lead);
|
|
122
141
|
const textWidth = Math.max(1, width - leadWidth - 2);
|
|
@@ -126,7 +145,7 @@ export function renderDiffWindow(change: FileChange, width: number, fg: (color:
|
|
|
126
145
|
return (wrap ? wrapTextWithAnsi(styled, textWidth) : [truncateToWidth(styled, textWidth, "…")]).map((body, index) => {
|
|
127
146
|
const prefix = index ? " ".repeat(leadWidth + 1) : fg("dim", lead) + fg(KIND_COLOR[kind], SIGN[kind]);
|
|
128
147
|
const content = body + " ".repeat(Math.max(0, textWidth - visibleWidth(body)));
|
|
129
|
-
return rail + prefix + (tint ? tint + content.
|
|
148
|
+
return rail + prefix + (tint ? tint + content.replace(/\x1b\[(?:0|49)?m/g, reset => reset + tint) : content) + RESET;
|
|
130
149
|
});
|
|
131
150
|
};
|
|
132
151
|
const hunkLine = (text: string, hidden: number | undefined) => truncateToWidth(
|
|
@@ -149,7 +168,7 @@ export function renderDiffWindow(change: FileChange, width: number, fg: (color:
|
|
|
149
168
|
: "Coarse preview: replacement groups are approximate."));
|
|
150
169
|
if (!unified) {
|
|
151
170
|
const column = Math.max(8, Math.floor((width - 3) / 2));
|
|
152
|
-
lines.push(truncateToWidth(fg("accent", "
|
|
171
|
+
lines.push(truncateToWidth(fg("accent", "Before"), column, "…", true) + fg("dim", " │ ") + fg("accent", "After"));
|
|
153
172
|
}
|
|
154
173
|
// Fill the viewport with whole logical rows; a first row taller than the viewport is clipped.
|
|
155
174
|
let used = 0, shown = 0;
|
|
@@ -180,7 +199,7 @@ export async function openDiffView(ctx: ExtensionContext, tracker: ChangeTracker
|
|
|
180
199
|
let query = "";
|
|
181
200
|
let searchMode = false;
|
|
182
201
|
let fullReview = false;
|
|
183
|
-
let unified =
|
|
202
|
+
let unified = false;
|
|
184
203
|
let contextIndex = CONTEXT_STEPS.indexOf(3);
|
|
185
204
|
let wrap = false;
|
|
186
205
|
let bodyRows = 0;
|
|
@@ -216,7 +235,11 @@ export async function openDiffView(ctx: ExtensionContext, tracker: ChangeTracker
|
|
|
216
235
|
const paintFor = (change: FileChange): DiffPaint => {
|
|
217
236
|
if (paintCache?.path !== change.path) {
|
|
218
237
|
const language = getLanguageFromPath(change.path);
|
|
219
|
-
const
|
|
238
|
+
const native = theme as { getBgAnsi?: (color: "toolPendingBg" | "toolSuccessBg" | "toolErrorBg") => string;
|
|
239
|
+
getFgAnsi?: (color: "toolDiffAdded" | "toolDiffRemoved") => string };
|
|
240
|
+
const bg = native.getBgAnsi?.bind(theme);
|
|
241
|
+
const foreground = native.getFgAnsi?.bind(theme);
|
|
242
|
+
const backgrounds = bg && foreground ? diffBackgrounds(bg("toolPendingBg"), foreground("toolDiffAdded"), foreground("toolDiffRemoved")) : undefined;
|
|
220
243
|
paintCache = { path: change.path, paint: {
|
|
221
244
|
// Per-line highlighting is bounded; very long lines, or a host without Pi's highlighter
|
|
222
245
|
// theme initialised, keep plain diff colours instead of failing the review.
|
|
@@ -224,7 +247,7 @@ export async function openDiffView(ctx: ExtensionContext, tracker: ChangeTracker
|
|
|
224
247
|
if (text.length > 1000) return undefined;
|
|
225
248
|
try { return highlightCode(text, language)[0]; } catch { return undefined; }
|
|
226
249
|
} } : {}),
|
|
227
|
-
...(bg ? { tint: (kind: "add" | "remove") => bg(kind === "add" ? "toolSuccessBg" : "toolErrorBg") } : {})
|
|
250
|
+
...(backgrounds ?? (bg ? { tint: (kind: "add" | "remove") => bg(kind === "add" ? "toolSuccessBg" : "toolErrorBg") } : {}))
|
|
228
251
|
} };
|
|
229
252
|
}
|
|
230
253
|
return paintCache.paint;
|
|
@@ -378,7 +401,7 @@ export async function openDiffView(ctx: ExtensionContext, tracker: ChangeTracker
|
|
|
378
401
|
...optionList(theme, ACTIONS.map((item) => item.label), -1, ACTIONS.map((item) => item.key)),
|
|
379
402
|
fg(searchMode ? "accent" : "dim", searchMode ? `/${query} · Enter apply · Esc clear`
|
|
380
403
|
: query ? `/${query} · n/N next match · / new search`
|
|
381
|
-
: `${SEARCH_HINT} · n/p hunk · t split · ←/→ context (${contextLabel()}) · w wrap${wrap ? " on" : ""} · v full`),
|
|
404
|
+
: `${SEARCH_HINT} · n/p hunk · t ${unified ? "split" : "unified"} · ←/→ context (${contextLabel()}) · w wrap${wrap ? " on" : ""} · v full`),
|
|
382
405
|
message ? fg(armed ? "warning" : "dim", message) : fg("dim", "↑↓ file · PgUp/PgDn/Home/End scroll · a accept · r revert · Esc close")
|
|
383
406
|
];
|
|
384
407
|
const split = splitFrame(theme, width, title, list, content.slice(0, bodyRows), footer, bodyRows, listWidth);
|
package/src/footer.ts
CHANGED
|
@@ -18,8 +18,6 @@ export interface FooterView extends JarStatus {
|
|
|
18
18
|
cwd?: string;
|
|
19
19
|
settings?: FooterSettings;
|
|
20
20
|
context: string;
|
|
21
|
-
/** The last call's prompt is past the context budget: the context metric turns amber. */
|
|
22
|
-
overBudget?: boolean;
|
|
23
21
|
goal?: string;
|
|
24
22
|
/** pi-jar's own short indicators (changes to review), shown before extras. */
|
|
25
23
|
chips?: readonly string[];
|
|
@@ -153,8 +151,6 @@ function footerLines(view: FooterView, width: number, theme: FooterTheme, hits:
|
|
|
153
151
|
// Context is never dropped; optional metrics move to a second line on medium widths.
|
|
154
152
|
const metrics = [...(show.context ? [context] : []), ...(show.memory && contentWidth >= 90 && memory ? [memory] : []), ...(show.cost && contentWidth >= 52 && cost ? [cost] : []), ...(show.quota && contentWidth >= 90 ? windows : [])];
|
|
155
153
|
let right = theme.fg("dim", metrics.join(" · "));
|
|
156
|
-
// Context always leads the metrics when shown, so only its own color changes past the budget.
|
|
157
|
-
if (view.overBudget && show.context) right = theme.fg("warning", context) + (metrics.length > 1 ? theme.fg("dim", " · " + metrics.slice(1).join(" · ")) : "");
|
|
158
154
|
if (contentWidth < 52 && attention && contentWidth >= 26) {
|
|
159
155
|
const room = Math.max(0, contentWidth - Math.min(visibleWidth(left), 12) - visibleWidth(right) - 4);
|
|
160
156
|
right = theme.fg(primary?.state === "failed" ? "error" : "warning", truncateToWidth(attention, Math.min(room, 14))) + " " + right;
|