pi-jar 0.2.8 → 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.
@@ -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 { isReadStub, readRangeKey, resolveToolPath, type ReadStubDetails } from "./context-diet.ts";
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 = 600;
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. */
@@ -55,7 +55,10 @@ export function sessionDisplayName(name?: string, id?: string): string {
55
55
  return `${first}-${last}`;
56
56
  }
57
57
 
58
+ /** Empty-cell cursor: pi-tui ≤1.1 paints it inline; 1.2+ wraps it in fake-cursor APC markers resolved at render time. */
58
59
  const CURSOR = "\x1b[7m \x1b[0m";
60
+ const FAKE_CURSOR = "\x1b_pi:fc\x07 \x1b_pi:/fc\x07";
61
+ const hasCursor = (row: string) => row.includes(FAKE_CURSOR) || row.includes(CURSOR);
59
62
  const BORDER = /^─+(?:\s*[↑↓]\s*\d+\s*more\s*─*)?$/;
60
63
  /** Index of the editor's bottom border: known row count when available, else the first plain border row. */
61
64
  function bottomBorderIndex(lines: readonly string[], visibleRows?: number): number {
@@ -66,7 +69,7 @@ function bottomBorderIndex(lines: readonly string[], visibleRows?: number): numb
66
69
  const overflowLabel = (line: string | undefined) => /[↑↓]\s*\d+\s*more/.exec(stripTerminalSequences(line ?? ""))?.[0];
67
70
 
68
71
  export interface RoundedInputOptions {
69
- /** Active profile shown between the mascot and human session title. */
72
+ /** Active profile shown beside the human session title. */
70
73
  profile?: string;
71
74
  /** Dim inline suggestion shown after the cursor when the draft is empty. */
72
75
  ghost?: string;
@@ -105,9 +108,10 @@ export function roundedInput(lines: string[], width: number, focused: boolean, t
105
108
  if (lastFrame?.key === key && lastFrame.border === border && lastFrame.dim === options.dim && sameLines(lastFrame.lines, lines)) return lastFrame.framed.slice();
106
109
  const bottom = bottomBorderIndex(lines, options.visibleRows);
107
110
  const inner = width - 2;
111
+ const prefix = icon ? `${icon} · ` : "";
108
112
  const meta = profile !== undefined
109
- ? `${icon} · (${profile})${session ? " " + cleanText(session, 120) : ""}`
110
- : session ? `${icon} · session ${session}` : icon;
113
+ ? `${prefix}(${profile})${session ? " " + cleanText(session, 120) : ""}`
114
+ : session ? `${prefix}session ${session}` : icon;
111
115
  const title = truncateToWidth(` ${meta} `, width - 3);
112
116
  const paintedTitle = options.paintedIcon && title.includes(icon) ? title.replace(icon, options.paintedIcon) : title;
113
117
  const above = overflowLabel(lines[0]);
@@ -125,7 +129,7 @@ export function roundedInput(lines: string[], width: number, focused: boolean, t
125
129
  const fit = (text: string) => fittedLines.get(text, (value) => truncateToWidth(value, width));
126
130
  const content = lines.slice(1, bottom).map((line, index) => {
127
131
  let row = line;
128
- if (index === 0 && options.ghost && row.includes(CURSOR)) {
132
+ if (index === 0 && options.ghost && hasCursor(row)) {
129
133
  const base = row.replace(/ +$/, "");
130
134
  const room = inner - visibleWidth(base);
131
135
  // ascii's tab "icon" is already the word.
@@ -143,11 +147,11 @@ export function roundedInput(lines: string[], width: number, focused: boolean, t
143
147
 
144
148
  export interface ComposerDecor {
145
149
  /** Title icon width source (plain text). */
146
- icon(): string;
150
+ icon(width: number): string;
147
151
  /** Painted icon, or undefined to use theme border colors. */
148
- paintedIcon(): string | undefined;
149
- /** Flame-tip row above the frame, or undefined when there is no room. */
150
- tip(width: number): string | undefined;
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;
151
155
  session(): string;
152
156
  profile?(): string;
153
157
  ghost(): string | undefined;
@@ -177,13 +181,13 @@ class RoundedEditor extends CustomEditor {
177
181
  }
178
182
 
179
183
  /** Let drafts grow to ~60% of the terminal (Pi's editor caps at 30%) before scrolling. */
180
- private renderTall(width: number): string[] {
184
+ private renderTall(width: number, perchRows: number): string[] {
181
185
  const self = this as unknown as { tui?: { terminal?: { rows?: number } } };
182
186
  const tui = self.tui;
183
187
  const rows = tui?.terminal?.rows;
184
188
  if (!tui || !rows || !Number.isFinite(rows)) return super.render(width);
185
189
  const tall = new Proxy(tui, { get: (target, key, receiver) => key === "terminal"
186
- ? 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) })
187
191
  : Reflect.get(target, key, receiver) });
188
192
  try {
189
193
  self.tui = tall;
@@ -194,26 +198,25 @@ class RoundedEditor extends CustomEditor {
194
198
  }
195
199
 
196
200
  override render(width: number): string[] {
197
- const inner = this.renderTall(width < 8 ? width : width - 2);
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);
198
204
  const text = this.getText();
199
205
  const empty = text === "";
200
206
  const below = this.decor.attachments(text);
201
207
  const ghost = empty && !this.isShowingAutocomplete() ? this.decor.ghost() : undefined;
202
208
  this.ghostShown = !!ghost;
203
209
  const visibleRows = (this as unknown as { renderedVisibleLineCount?: number }).renderedVisibleLineCount;
204
- const icon = this.decor.icon();
210
+ const icon = this.decor.icon(width);
205
211
  const session = this.decor.session();
206
212
  const hint = this.decor.hint();
207
- const paintedIcon = this.decor.paintedIcon();
213
+ const paintedIcon = this.decor.paintedIcon(width);
208
214
  const framed = roundedInput(inner, width, this.focused, this.colors, this.paint, icon, session, {
209
215
  ...(ghost ? { ghost } : {}), ...(hint ? { hint } : {}), ...(paintedIcon ? { paintedIcon } : {}),
210
216
  ...(visibleRows != null ? { visibleRows } : {}), profile: this.decor.profile?.(), dim: this.decor.dim,
211
217
  ...(below ? { below } : {})
212
218
  });
213
- const tip = width >= 8 ? this.decor.tip(width) : undefined;
214
- this.tipRows = tip ? 1 : 0;
215
- // The tip is a few painted cells; measuring (cached) skips re-segmenting it on every frame.
216
- return tip ? [visibleWidth(tip) <= width ? tip : truncateToWidth(tip, width), ...framed] : framed;
219
+ return [...perch.map(row => visibleWidth(row) <= width ? row : truncateToWidth(row, width)), ...framed];
217
220
  }
218
221
 
219
222
  override handleInput(data: string): void {
@@ -227,7 +230,9 @@ class RoundedEditor extends CustomEditor {
227
230
 
228
231
  override handleMouse(event: MouseEvent) {
229
232
  const y = event.y - this.tipRows;
230
- if ((y === 0 || y < 0) && faceColumns(event.x)) {
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) {
231
236
  if (event.type === "click" && event.button === "left") { this.decor.poke(); return { handled: true, focus: true }; }
232
237
  return undefined;
233
238
  }
@@ -275,16 +280,16 @@ class ThemedEditor implements EditorComponent {
275
280
  const text = this.base.getText();
276
281
  const ghost = text === "" ? this.decor.ghost() : undefined;
277
282
  const below = this.decor.attachments(text);
278
- const icon = this.decor.icon();
283
+ const icon = this.decor.icon(width);
279
284
  const session = this.decor.session();
280
- const paintedIcon = this.decor.paintedIcon();
285
+ const paintedIcon = this.decor.paintedIcon(width);
281
286
  const framed = roundedInput(this.base.render(width < 8 ? width : width - 2), width, this.focused, this.theme, this.paint, icon, session, {
282
287
  ...(ghost ? { ghost } : {}), ...(paintedIcon ? { paintedIcon } : {}), profile: this.decor.profile?.(), dim: this.decor.dim,
283
288
  ...(below ? { below } : {})
284
289
  });
285
- const tip = width >= 8 ? this.decor.tip(width) : undefined;
286
- this.tipRows = tip ? 1 : 0;
287
- return tip ? [visibleWidth(tip) <= width ? tip : truncateToWidth(tip, width), ...framed] : framed;
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];
288
293
  }
289
294
  addToHistory(text: string) { this.base.addToHistory?.(text); }
290
295
  insertTextAtCursor(text: string) { this.base.insertTextAtCursor?.(text); }
@@ -294,8 +299,13 @@ class ThemedEditor implements EditorComponent {
294
299
  setAutocompleteMaxVisible(value: number) { this.base.setAutocompleteMaxVisible?.(value); }
295
300
  handleMouse(event: MouseEvent) {
296
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
+ }
297
307
  if (y < 0) return undefined;
298
- 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) });
299
309
  }
300
310
  }
301
311
 
@@ -306,7 +316,6 @@ export class ComposerStyle {
306
316
  private tui?: TUI;
307
317
  private timer?: ReturnType<typeof setTimeout>;
308
318
  private phase: WorkingPhase = "idle";
309
- private frame = 0;
310
319
  private animations = true;
311
320
  private mascotOn = true;
312
321
  private session = "";
@@ -321,18 +330,23 @@ export class ComposerStyle {
321
330
  private chips?: { text: string; cwd: string; at: number; chips: string[] };
322
331
 
323
332
  private readonly decor: ComposerDecor = {
324
- icon: () => this.mascotOn ? mascotFace(this.animations ? this.mascot.mood() : "idle") : "pi",
325
- paintedIcon: () => {
333
+ icon: (width) => !this.mascotOn ? "pi" : this.fullSprite(width) ? ""
334
+ : mascotFace(this.animations ? this.mascot.mood() : "idle"),
335
+ paintedIcon: (width) => {
326
336
  if (!this.mascotOn) return undefined;
337
+ if (this.fullSprite(width)) return undefined;
327
338
  const mood = this.animations ? this.mascot.mood() : "idle";
328
339
  return paintFace(mascotFace(mood), mood, this.colors ? (color, text) => this.colors!.fg(color, text) : undefined);
329
340
  },
330
- tip: (width) => {
341
+ perch: (width) => {
331
342
  if (!this.mascotOn || width < 40) return undefined;
332
343
  const mood = this.animations ? this.mascot.mood() : "idle";
333
- const animationFrame = this.phase === "idle" ? this.frame : Math.floor(Date.now() / COMPOSER_ACTIVE_INTERVAL_MS);
334
- const tip = this.animations ? this.mascot.tip(Date.now(), animationFrame) : this.mascot.tip(0, 0);
335
- return " ".repeat(3) + paintTip(tip, mood, this.colors ? (color, text) => this.colors!.fg(color, text) : undefined);
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)];
336
350
  },
337
351
  session: () => this.session,
338
352
  profile: () => this.profile,
@@ -360,6 +374,11 @@ export class ComposerStyle {
360
374
  }
361
375
  };
362
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
+
363
382
  /** Share the suggestion state rendered as ghost text. */
364
383
  attachSuggestions(state: SuggestionState): void {
365
384
  this.unsubscribe?.();
@@ -395,7 +414,6 @@ export class ComposerStyle {
395
414
  if (!changed) return;
396
415
  this.phase = phase;
397
416
  this.animations = animations;
398
- this.frame = 0;
399
417
  this.restartTimer();
400
418
  if (this.enabled) this.tui?.requestRender();
401
419
  }
@@ -403,12 +421,12 @@ export class ComposerStyle {
403
421
  /**
404
422
  * Pi's own streaming/tool updates already repaint active sessions. Never add another active
405
423
  * animation loop: active frames derive from wall time when Pi naturally renders. Idle only
406
- * schedules exact blink/sleep/flash transitions.
424
+ * schedules exact blink/sleep transitions and bounded reaction frames until a flash expires.
407
425
  */
408
426
  private restartTimer(): void {
409
427
  this.stopTimer();
410
428
  if (!this.enabled || !this.animations || !this.mascotOn) return;
411
- this.lastKey = this.mascot.key(Date.now(), this.frame);
429
+ this.lastKey = this.mascot.key();
412
430
  this.scheduleTimer();
413
431
  }
414
432
  private scheduleTimer(): void {
@@ -417,8 +435,7 @@ export class ComposerStyle {
417
435
  if (delay === undefined) return;
418
436
  this.timer = setTimeout(() => {
419
437
  this.timer = undefined;
420
- this.frame++;
421
- const key = this.mascot.key(Date.now(), this.frame);
438
+ const key = this.mascot.key();
422
439
  if (key !== this.lastKey) {
423
440
  this.lastKey = key;
424
441
  this.tui?.requestRender();
@@ -468,7 +485,6 @@ export class ComposerStyle {
468
485
  this.stopTimer();
469
486
  this.tui = undefined;
470
487
  this.phase = "idle";
471
- this.frame = 0;
472
488
  this.session = "";
473
489
  try {
474
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
- if (!retained.length) return;
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 only when there are ≥2 independent chunks that each need ≥10 tool calls; otherwise do the work yourself.",
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 { skillPlans[index] = planSkills(config.skills, rules, catalog, SUBAGENT_CONFIG_FILE); }
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);
@@ -61,20 +61,28 @@ const INVERSE_ON = "\x1b[7m";
61
61
  const INVERSE_OFF = "\x1b[27m";
62
62
 
63
63
  /**
64
- * Insert inverse-video markers around `spans` of the visible text inside an SGR-styled string.
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) { out += escape[0]; at += escape[0].length; continue; }
74
- while (span < spans.length && open && visible >= spans[span]![1]) { out += INVERSE_OFF; open = false; span++; }
75
- if (span < spans.length && !open && visible >= spans[span]![0]) { out += INVERSE_ON; open = true; }
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 + INVERSE_OFF : 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 !== false;
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
- return changes ? emphasize(styled, kind === "remove" ? changes.before : changes.after) : styled;
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. Truncation emits full resets, so a tint is re-applied after each one.
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.replaceAll(RESET, RESET + tint) : content) + RESET;
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", "Original"), column, "…", true) + fg("dim", " │ ") + fg("accent", "Updated"));
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 = true;
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 bg = (theme as { getBgAnsi?: (color: "toolSuccessBg" | "toolErrorBg") => string }).getBgAnsi?.bind(theme);
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;