shariq-pi-extensions 0.4.0 → 0.5.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.
Files changed (39) hide show
  1. package/docs/EXTENSIONS.md +1 -1
  2. package/extensions/ask-user/index.ts +60 -33
  3. package/extensions/background-terminals/README.md +5 -5
  4. package/extensions/background-terminals/index.ts +1 -1
  5. package/extensions/background-terminals/src/manager.ts +32 -7
  6. package/extensions/background-terminals/src/pty.ts +25 -8
  7. package/extensions/background-terminals/src/ui.ts +6 -15
  8. package/extensions/context-usage/index.ts +40 -17
  9. package/extensions/firecrawl-web/auth.ts +1 -1
  10. package/extensions/firecrawl-web/client.ts +15 -5
  11. package/extensions/firecrawl-web/output.ts +4 -1
  12. package/extensions/goal/extension.ts +57 -33
  13. package/extensions/input-mode/index.ts +10 -2
  14. package/extensions/performance-status/README.md +1 -1
  15. package/extensions/performance-status/index.ts +29 -20
  16. package/extensions/shared/README.md +4 -2
  17. package/extensions/shared/activity-dock.ts +33 -22
  18. package/extensions/shared/child-session.ts +4 -0
  19. package/extensions/shared/model-picker.ts +2 -2
  20. package/extensions/shared/settlement-delivery.ts +7 -1
  21. package/extensions/shared/tool-call-timeout.ts +8 -1
  22. package/extensions/shared/tool-card.ts +5 -3
  23. package/extensions/smart-compaction/engine.ts +39 -21
  24. package/extensions/smart-compaction/index.ts +17 -3
  25. package/extensions/smart-compaction/prompt.ts +1 -1
  26. package/extensions/subagents/README.md +4 -2
  27. package/extensions/subagents/index.ts +73 -26
  28. package/extensions/subagents/src/backends/pi.ts +42 -10
  29. package/extensions/subagents/src/catalog.ts +74 -4
  30. package/extensions/subagents/src/config.ts +13 -8
  31. package/extensions/subagents/src/manager.ts +4 -29
  32. package/extensions/task-list/README.md +1 -1
  33. package/extensions/task-list/index.ts +22 -11
  34. package/extensions/task-list/state.ts +19 -3
  35. package/extensions/web-fetch/README.md +3 -1
  36. package/extensions/web-fetch/index.ts +252 -263
  37. package/package.json +4 -2
  38. package/skills/subagents/SKILL.md +1 -1
  39. package/extensions/subagents/src/storage.ts +0 -65
@@ -36,7 +36,7 @@ Managed PTYs for servers, watchers, long builds, and interactive programs: up to
36
36
 
37
37
  ### [Web fetch](../extensions/web-fetch/README.md)
38
38
 
39
- `web_fetch` retrieves one known HTTP or HTTPS URL as Markdown, text, or HTML, with time and size limits and no Firecrawl credits.
39
+ `web_fetch` retrieves one known HTTP or HTTPS URL as Markdown, text, or HTML, with time and size limits and no Firecrawl credits. Local and private network addresses are blocked unless `PI_WEB_FETCH_ALLOW_PRIVATE=1` is set.
40
40
 
41
41
  ## Interface
42
42
 
@@ -77,8 +77,10 @@ function cleanMultiline(text: string): string {
77
77
  return sanitizeTerminalText(text).trim();
78
78
  }
79
79
 
80
+ // Compare labels the way they are displayed (whitespace collapsed), so two
81
+ // labels that look identical on screen are rejected as duplicates.
80
82
  function normalizedChoiceKey(label: string): string {
81
- return label.normalize("NFKC").toLocaleLowerCase("en-US");
83
+ return oneLine(label).normalize("NFKC").toLocaleLowerCase("en-US");
82
84
  }
83
85
 
84
86
  function normalizeQuestionInput(
@@ -124,6 +126,7 @@ function createEditorTheme(theme: Theme): EditorTheme {
124
126
  export class AskUserView implements Focusable {
125
127
  private selected = 0;
126
128
  private editing = false;
129
+ private questionOffset = 0;
127
130
  private cachedWidth?: number;
128
131
  private cachedLines?: string[];
129
132
  private _focused = false;
@@ -133,6 +136,7 @@ export class AskUserView implements Focusable {
133
136
  private readonly requestRender: () => void;
134
137
  private readonly finish: (selection: Selection | null) => void;
135
138
  private readonly editor: Editor;
139
+ private readonly maxRows: () => number;
136
140
 
137
141
  constructor(
138
142
  question: string,
@@ -141,6 +145,7 @@ export class AskUserView implements Focusable {
141
145
  requestRender: () => void,
142
146
  finish: (selection: Selection | null) => void,
143
147
  editor: Editor,
148
+ maxRows: () => number = () => Number.POSITIVE_INFINITY,
144
149
  ) {
145
150
  this.question = question;
146
151
  this.options = options;
@@ -148,6 +153,7 @@ export class AskUserView implements Focusable {
148
153
  this.requestRender = requestRender;
149
154
  this.finish = finish;
150
155
  this.editor = editor;
156
+ this.maxRows = maxRows;
151
157
  this.editor.onSubmit = (value) => {
152
158
  const answer = cleanMultiline(value).slice(0, MAX_ANSWER_LENGTH);
153
159
  if (!answer) {
@@ -189,6 +195,15 @@ export class AskUserView implements Focusable {
189
195
  }
190
196
 
191
197
  handleInput(data: string): void {
198
+ if (matchesKey(data, Key.ctrl("c"))) {
199
+ this.finish(null);
200
+ return;
201
+ }
202
+ if (matchesKey(data, Key.pageUp) || matchesKey(data, Key.pageDown)) {
203
+ this.questionOffset = Math.max(0, this.questionOffset + (matchesKey(data, Key.pageUp) ? -5 : 5));
204
+ this.refresh();
205
+ return;
206
+ }
192
207
  if (this.editing) {
193
208
  if (matchesKey(data, Key.escape)) {
194
209
  this.editing = false;
@@ -220,7 +235,7 @@ export class AskUserView implements Focusable {
220
235
  this.choose(this.selected);
221
236
  return;
222
237
  }
223
- if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl("c"))) {
238
+ if (matchesKey(data, Key.escape)) {
224
239
  this.finish(null);
225
240
  }
226
241
  }
@@ -229,54 +244,61 @@ export class AskUserView implements Focusable {
229
244
  if (this.cachedWidth === width && this.cachedLines) return this.cachedLines;
230
245
  const safeWidth = Math.max(1, width);
231
246
  const inner = Math.max(1, safeWidth - 4);
232
- const lines: string[] = [frameTop(this.theme, safeWidth, "Decision needed")];
233
-
234
- for (const line of wrapTextWithAnsi(this.theme.bold(cleanMultiline(this.question)), inner)) {
235
- lines.push(padLine(` ${this.theme.fg("text", line)}`, safeWidth));
236
- }
237
- lines.push(padLine("", safeWidth));
247
+ const questionLines = wrapTextWithAnsi(this.theme.bold(cleanMultiline(this.question)), inner)
248
+ .map((line) => padLine(` ${this.theme.fg("text", line)}`, safeWidth));
238
249
 
250
+ const rest: string[] = [padLine("", safeWidth)];
239
251
  this.options.forEach((option, index) => {
240
252
  const active = index === this.selected;
241
253
  const marker = option.custom ? "✎" : `${index + 1}.`;
242
254
  const prefix = active ? this.theme.fg("accent", "❯") : " ";
243
- const label = `${prefix} ${marker} ${oneLine(option.label)}`;
244
- lines.push(
245
- padLine(
255
+ // Wrap long labels so options that differ only near the end stay distinguishable.
256
+ const labelLines = wrapTextWithAnsi(oneLine(option.label), Math.max(8, inner - 4));
257
+ labelLines.forEach((text, lineIndex) => {
258
+ const label = lineIndex === 0 ? `${prefix} ${marker} ${text}` : `${" ".repeat(marker.length + 3)}${text}`;
259
+ rest.push(padLine(
246
260
  active
247
261
  ? this.theme.bg("selectedBg", this.theme.fg("accent", label))
248
262
  : this.theme.fg(option.custom ? "muted" : "text", label),
249
263
  safeWidth,
250
- ),
251
- );
264
+ ));
265
+ });
252
266
  if (option.description) {
253
267
  for (const description of wrapTextWithAnsi(oneLine(option.description), Math.max(8, inner - 4))) {
254
- lines.push(padLine(` ${this.theme.fg("muted", description)}`, safeWidth));
268
+ rest.push(padLine(` ${this.theme.fg("muted", description)}`, safeWidth));
255
269
  }
256
270
  }
257
271
  });
258
-
259
272
  if (this.editing) {
260
- lines.push(padLine("", safeWidth));
261
- lines.push(padLine(` ${this.theme.fg("muted", "Your answer")}`, safeWidth));
273
+ rest.push(padLine("", safeWidth));
274
+ rest.push(padLine(` ${this.theme.fg("muted", "Your answer")}`, safeWidth));
262
275
  for (const line of this.editor.render(Math.max(10, safeWidth - 4))) {
263
- lines.push(padLine(` ${line}`, safeWidth));
276
+ rest.push(padLine(` ${line}`, safeWidth));
264
277
  }
265
278
  }
279
+ rest.push(padLine("", safeWidth));
280
+
281
+ // Keep the choices, editor, and controls on screen; a long question scrolls.
282
+ const questionRoom = this.maxRows() - rest.length - 3;
283
+ let visibleQuestion = questionLines;
284
+ let scrollHint = "";
285
+ if (questionLines.length > questionRoom) {
286
+ const room = Math.max(1, questionRoom);
287
+ this.questionOffset = Math.min(this.questionOffset, Math.max(0, questionLines.length - room));
288
+ visibleQuestion = questionLines.slice(this.questionOffset, this.questionOffset + room);
289
+ scrollHint = `pgup/pgdn question ${this.questionOffset + 1}-${this.questionOffset + visibleQuestion.length}/${questionLines.length} · `;
290
+ }
266
291
 
267
- lines.push(padLine("", safeWidth));
268
- lines.push(
269
- padLine(
270
- ` ${this.theme.fg(
271
- "dim",
272
- this.editing
273
- ? "enter submit · escape choices"
274
- : `up/down or 1-${this.options.length} choose · enter confirm · escape dismiss`,
275
- )}`,
276
- safeWidth,
277
- ),
278
- );
279
- lines.push(frameBottom(this.theme, safeWidth));
292
+ const controls = this.editing
293
+ ? "enter submit · escape choices · ctrl+c dismiss"
294
+ : `up/down or 1-${this.options.length} choose · enter confirm · escape dismiss`;
295
+ const lines = [
296
+ frameTop(this.theme, safeWidth, "Decision needed"),
297
+ ...visibleQuestion,
298
+ ...rest,
299
+ padLine(` ${this.theme.fg("dim", `${scrollHint}${controls}`)}`, safeWidth),
300
+ frameBottom(this.theme, safeWidth),
301
+ ];
280
302
  this.cachedWidth = width;
281
303
  this.cachedLines = lines.map((line) => truncateToWidth(line, safeWidth, ""));
282
304
  return this.cachedLines;
@@ -315,6 +337,8 @@ async function openQuestion(
315
337
  () => tui.requestRender(),
316
338
  complete,
317
339
  new Editor(tui, createEditorTheme(theme)),
340
+ // Matches the overlay's maxHeight (86%) minus its margin.
341
+ () => Math.max(8, Math.floor((tui.terminal.rows || 24) * 0.86) - 2),
318
342
  );
319
343
  return {
320
344
  get focused() {
@@ -407,9 +431,12 @@ export default function askUserExtension(pi: ExtensionAPI) {
407
431
  invalidate() {},
408
432
  };
409
433
  },
410
- renderResult(result, _options, theme) {
434
+ renderResult(result, _options, theme, context) {
411
435
  const details = result.details as AskUserDetails | undefined;
412
- const text = !details || details.cancelled
436
+ const errorText = result.content.find((item) => item.type === "text")?.text ?? "invalid question";
437
+ const text = context.isError
438
+ ? `${theme.fg("error", "● not asked")} ${theme.fg("muted", oneLine(errorText))}`
439
+ : !details || details.cancelled
413
440
  ? theme.fg("warning", "● dismissed")
414
441
  : `${theme.fg("success", "● answered")} ${theme.fg("accent", oneLine(details.answer ?? ""))}`;
415
442
  return { render: (width: number) => [truncateToWidth(text, width)], invalidate() {} };
@@ -21,19 +21,19 @@ When a model-started terminal exits, its status and a bounded output tail are de
21
21
  - live counts and status;
22
22
  - terminal list with a selected-session inspector;
23
23
  - live output, elapsed time, dimensions, command, cwd, and full-log size;
24
- - real PTY input;
24
+ - real PTY input: every printable key goes to the process;
25
25
  - Ctrl+C delivery to the PTY;
26
- - scrolling and top/bottom navigation;
27
- - two-press process-stop guard.
26
+ - PgUp/PgDn scrolling;
27
+ - a two-press Ctrl+X process-stop guard in the detail view.
28
28
 
29
29
  ## Lifecycle and safety
30
30
 
31
31
  - Maximum eight concurrent terminals and 32 tracked entries.
32
32
  - Newest 2 MiB retained in memory per terminal.
33
33
  - Raw output spills to a mode-`0600` file under a mode-`0700` temporary session directory, capped at 64 MiB per terminal.
34
- - Spill backpressure pauses and resumes the PTY; reaching the cap stops log growth while retaining the newest live-output tail.
34
+ - Under Node, spill backpressure pauses and resumes the PTY. Bun's terminal API has no pause, so the 64 MiB cap is the only bound there. Reaching the cap stops log growth while retaining the newest live-output tail.
35
35
  - Pruning an old settled terminal deletes its spill file immediately; session shutdown removes the complete temporary directory.
36
36
  - Output is sanitized before TUI or model rendering.
37
37
  - Processes run in their own PTY process group and are stopped on session shutdown, replacement, or reload.
38
- - Shutdown and stop operations are bounded and escalate from SIGTERM to SIGKILL.
38
+ - Shutdown and stop operations are bounded and escalate from SIGTERM to SIGKILL. A stop finishes only when the whole process group is gone, including descendants that outlive the shell and ignore hangup.
39
39
  - Completion delivery is keyed by terminal id, so each terminal produces at most one result message.
@@ -432,7 +432,7 @@ export default function backgroundTerminals(pi: ExtensionAPI) {
432
432
  return;
433
433
  }
434
434
  if (trimmed.startsWith("stop ")) {
435
- const ids = [...new Set(trimmed.slice("stop ".length).split(/[\\s,]+/).filter(Boolean))];
435
+ const ids = [...new Set(trimmed.slice("stop ".length).split(/[\s,]+/).filter(Boolean))];
436
436
  if (ids.length === 0) {
437
437
  ctx.ui.notify("Usage: /term stop <id> [id…]", "error");
438
438
  return;
@@ -365,6 +365,7 @@ export class TerminalManager {
365
365
  // node-pty still knows how to terminate its direct child.
366
366
  }
367
367
  }
368
+ if (entry.snapshot.status !== "running") return;
368
369
  try {
369
370
  entry.pty.kill(signal);
370
371
  } catch {
@@ -372,6 +373,26 @@ export class TerminalManager {
372
373
  }
373
374
  }
374
375
 
376
+ /** The PTY's process group can outlive its leader when descendants ignore hangup. */
377
+ private groupAlive(entry: Entry): boolean {
378
+ if (process.platform === "win32") return false;
379
+ try {
380
+ process.kill(-entry.snapshot.pid, 0);
381
+ return true;
382
+ } catch (error) {
383
+ return (error as NodeJS.ErrnoException).code === "EPERM";
384
+ }
385
+ }
386
+
387
+ private async waitForStop(entry: Entry, timeoutMs: number): Promise<void> {
388
+ const deadline = Date.now() + timeoutMs;
389
+ while (Date.now() < deadline) {
390
+ if (entry.snapshot.status !== "running" && !this.groupAlive(entry)) return;
391
+ await Promise.race([entry.settled, wait(25)]);
392
+ if (entry.snapshot.status !== "running") await wait(25);
393
+ }
394
+ }
395
+
375
396
  async kill(ids: ReadonlyArray<string>): Promise<ReadonlyArray<TerminalSnapshot>> {
376
397
  const unique = [...new Set(ids)];
377
398
  const entries = unique.map((id) => {
@@ -380,13 +401,15 @@ export class TerminalManager {
380
401
  return entry;
381
402
  });
382
403
  await Promise.all(entries.map(async (entry) => {
383
- if (entry.snapshot.status !== "running") return;
384
- entry.killRequested = true;
404
+ // Stop is complete only when the whole group is gone, not just the leader.
405
+ const running = entry.snapshot.status === "running";
406
+ if (!running && !this.groupAlive(entry)) return;
407
+ if (running) entry.killRequested = true;
385
408
  this.signal(entry, "SIGTERM");
386
- await Promise.race([entry.settled, wait(TERM_GRACE_MS)]);
387
- if (entry.snapshot.status !== "running") return;
409
+ await this.waitForStop(entry, TERM_GRACE_MS);
410
+ if (entry.snapshot.status !== "running" && !this.groupAlive(entry)) return;
388
411
  this.signal(entry, "SIGKILL");
389
- await Promise.race([entry.settled, wait(KILL_GRACE_MS)]);
412
+ await this.waitForStop(entry, KILL_GRACE_MS);
390
413
  if (entry.snapshot.status === "running") {
391
414
  entry.snapshot.errorText ??= "Process exit was not observed after SIGKILL; output may be incomplete.";
392
415
  await this.finalize(entry, 137, 9);
@@ -471,8 +494,10 @@ export class TerminalManager {
471
494
  if (this.disposed) return;
472
495
  this.disposed = true;
473
496
  this.settledListener = undefined;
474
- const running = this.list().filter((snapshot) => snapshot.status === "running").map((snapshot) => snapshot.id);
475
- if (running.length > 0) await this.kill(running);
497
+ const live = [...this.entries.values()]
498
+ .filter((entry) => entry.snapshot.status === "running" || this.groupAlive(entry))
499
+ .map((entry) => entry.snapshot.id);
500
+ if (live.length > 0) await this.kill(live);
476
501
  this.listeners.clear();
477
502
  this.idListeners.clear();
478
503
  this.entries.clear();
@@ -30,6 +30,9 @@ class BunPtyAdapter implements UniversalPty {
30
30
  private readonly dataListeners = new Set<(chunk: string) => void>();
31
31
  private readonly exitListeners = new Set<(event: { exitCode: number; signal?: number }) => void>();
32
32
  private exited = false;
33
+ // Chunks can split a multibyte character; a streaming decoder carries the
34
+ // partial bytes into the next chunk instead of emitting replacement characters.
35
+ private readonly decoder = new TextDecoder("utf-8");
33
36
 
34
37
  constructor(options: SpawnUniversalPtyOptions) {
35
38
  const cmd = [options.file, ...options.args];
@@ -40,14 +43,7 @@ class BunPtyAdapter implements UniversalPty {
40
43
  cols: options.cols,
41
44
  rows: options.rows,
42
45
  data: (_term: unknown, chunk: Uint8Array | string) => {
43
- const text = typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8");
44
- for (const listener of this.dataListeners) {
45
- try {
46
- listener(text);
47
- } catch {
48
- // Ignore listener errors
49
- }
50
- }
46
+ this.emitData(typeof chunk === "string" ? chunk : this.decoder.decode(chunk, { stream: true }));
51
47
  },
52
48
  },
53
49
  });
@@ -57,6 +53,7 @@ class BunPtyAdapter implements UniversalPty {
57
53
  void this.proc.exited.then((exitCode: number) => {
58
54
  if (this.exited) return;
59
55
  this.exited = true;
56
+ this.emitData(this.decoder.decode());
60
57
  const signal = this.proc.signalCode
61
58
  ? typeof this.proc.signalCode === "number"
62
59
  ? this.proc.signalCode
@@ -72,6 +69,17 @@ class BunPtyAdapter implements UniversalPty {
72
69
  });
73
70
  }
74
71
 
72
+ private emitData(text: string) {
73
+ if (!text) return;
74
+ for (const listener of this.dataListeners) {
75
+ try {
76
+ listener(text);
77
+ } catch {
78
+ // Ignore listener errors
79
+ }
80
+ }
81
+ }
82
+
75
83
  onData(listener: (chunk: string) => void) {
76
84
  this.dataListeners.add(listener);
77
85
  return {
@@ -140,6 +148,15 @@ class NodePtyAdapter implements UniversalPty {
140
148
  this.pty.resize(cols, rows);
141
149
  }
142
150
 
151
+ // Flow control for spill backpressure: stop reading the PTY until the log drains.
152
+ pause(): void {
153
+ this.pty.pause();
154
+ }
155
+
156
+ resume(): void {
157
+ this.pty.resume();
158
+ }
159
+
143
160
  kill(signal?: string): void {
144
161
  this.pty.kill(signal);
145
162
  }
@@ -393,7 +393,8 @@ export class TerminalDetail implements Component, Focusable {
393
393
  this.close();
394
394
  return;
395
395
  }
396
- if (data === "x") {
396
+ // Printable keys belong to the input field, so detail controls use modifiers.
397
+ if (matchesKey(data, Key.ctrl("x"))) {
397
398
  if (terminal?.status !== "running") return;
398
399
  if (this.killArmed) {
399
400
  this.killArmed = false;
@@ -404,26 +405,16 @@ export class TerminalDetail implements Component, Focusable {
404
405
  this.tui.requestRender();
405
406
  return;
406
407
  }
407
- if (this.keybindings.matches(data, "tui.editor.pageUp") || data === "k") {
408
+ if (this.keybindings.matches(data, "tui.editor.pageUp")) {
408
409
  this.scrollOffset += Math.max(4, this.viewportHeight() - 2);
409
410
  this.tui.requestRender();
410
411
  return;
411
412
  }
412
- if (this.keybindings.matches(data, "tui.editor.pageDown") || data === "j") {
413
+ if (this.keybindings.matches(data, "tui.editor.pageDown")) {
413
414
  this.scrollOffset = Math.max(0, this.scrollOffset - Math.max(4, this.viewportHeight() - 2));
414
415
  this.tui.requestRender();
415
416
  return;
416
417
  }
417
- if (data === "g") {
418
- this.scrollOffset = Number.MAX_SAFE_INTEGER;
419
- this.tui.requestRender();
420
- return;
421
- }
422
- if (data === "G") {
423
- this.scrollOffset = 0;
424
- this.tui.requestRender();
425
- return;
426
- }
427
418
  this.killArmed = false;
428
419
  this.input.handleInput(data);
429
420
  this.tui.requestRender();
@@ -462,10 +453,10 @@ export class TerminalDetail implements Component, Focusable {
462
453
  lines.push(frameBottom(theme, width));
463
454
  if (terminal.status === "running") lines.push(...this.input.render(width));
464
455
  else lines.push(truncateToWidth(` ${theme.fg("muted", "Process settled; input is closed.")}`, width));
465
- const stop = this.killArmed ? theme.fg("warning", "x again to stop") : "x stop (confirm)";
456
+ const stop = this.killArmed ? theme.fg("warning", "ctrl+x again to stop") : "ctrl+x stop (confirm)";
466
457
  lines.push(
467
458
  truncateToWidth(
468
- theme.fg("dim", ` enter send · ctrl+c interrupt PTY · ${stop} · pgup/pgdn or j/k scroll · g/G top/bottom · ${configuredKeys(this.keybindings, "tui.select.cancel")} back`),
459
+ theme.fg("dim", ` enter send · ctrl+c interrupt PTY · ${stop} · pgup/pgdn scroll · ${configuredKeys(this.keybindings, "tui.select.cancel")} back`),
469
460
  width,
470
461
  ),
471
462
  );
@@ -2,6 +2,7 @@ import type { Component } from "@earendil-works/pi-tui";
2
2
  import type { ContextUsage, ExtensionAPI, ExtensionContext, SessionEntry, Theme, ToolInfo } from "@earendil-works/pi-coding-agent";
3
3
  import { buildSessionContext, convertToLlm, getAgentDir, keyText } from "@earendil-works/pi-coding-agent";
4
4
  import { truncateToWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
5
+ import { createHash } from "node:crypto";
5
6
  import { homedir } from "node:os";
6
7
  import { delimiter, join } from "node:path";
7
8
  import { stripAnsi } from "./lib/ansi.ts";
@@ -528,8 +529,10 @@ function splitConfigPaths(value: string | undefined, separator = delimiter): str
528
529
  return (value ?? "").split(separator).map((entry) => expandHomePath(entry.trim())).filter(Boolean);
529
530
  }
530
531
 
531
- function loadContextUsageConfig(cwd: string): ContextUsageConfig {
532
- const paths = [...configPaths("pi-context-usage", cwd), ...splitConfigPaths(process.env.PI_CONTEXT_USAGE_CONFIG)];
532
+ function loadContextUsageConfig(cwd: string, projectTrusted: boolean): ContextUsageConfig {
533
+ // The project file applies only to trusted projects; global and explicit env paths always apply.
534
+ const [globalPath, projectPath] = configPaths("pi-context-usage", cwd);
535
+ const paths = [globalPath!, ...(projectTrusted ? [projectPath!] : []), ...splitConfigPaths(process.env.PI_CONTEXT_USAGE_CONFIG)];
533
536
  return paths.reduce<ContextUsageConfig>(
534
537
  (config, filePath) => mergeContextUsageConfig(config, readJsonConfig(filePath, parseContextUsageConfig)),
535
538
  {},
@@ -1184,13 +1187,18 @@ function buildSnapshot(
1184
1187
  const session = buildSessionBreakdown(sessionManager);
1185
1188
  const contextUsage = getContextUsage?.();
1186
1189
 
1190
+ // Hash the full rendered inputs: same-length prompt or schema edits must
1191
+ // still invalidate cached counts.
1192
+ const contentHash = createHash("sha1")
1193
+ .update(systemPrompt)
1194
+ .update(JSON.stringify(pi.getAllTools().map((tool) => [tool.name, tool.description, tool.parameters])))
1195
+ .digest("hex");
1187
1196
  const signature = [
1188
- systemPrompt.length,
1197
+ contentHash,
1189
1198
  model ? `${model.provider}:${model.id}:${model.api}` : "no-model",
1190
1199
  `${heuristic.label}:${heuristic.textDenominator}:${heuristic.sessionDenominator}:${heuristic.toolDenominator}:${heuristic.toolNumerator}`,
1191
1200
  JSON.stringify(config),
1192
1201
  pi.getActiveTools().join(","),
1193
- pi.getAllTools().map((tool) => `${tool.name}:${tool.description.length}`).join(","),
1194
1202
  session ? `${session.thinkingChars}:${session.toolOutputChars}:${session.messageChars}:${session.messageCount}` : "no-session",
1195
1203
  contextUsage ? `${contextUsage.tokens}:${contextUsage.contextWindow}:${contextUsage.percent}` : "no-usage",
1196
1204
  ].join("|");
@@ -1481,9 +1489,10 @@ function renderSessionRows(snapshot: PrefixSnapshot, theme: Theme, width: number
1481
1489
  rows.push(renderMetricRow({
1482
1490
  label: "Total request",
1483
1491
  tokens: usage.tokens,
1484
- exact: true,
1492
+ // Pi reports this as an estimate (last usage plus trailing messages).
1493
+ exact: false,
1485
1494
  emphasis: true,
1486
- detail: percent && window ? `(${percent} / ${window} ctx)` : "(Pi usage)",
1495
+ detail: percent && window ? `(${percent} / ${window} ctx)` : "(Pi estimate)",
1487
1496
  }, theme, layout));
1488
1497
  rows.push(...renderContextBar(snapshot, estimate, theme, width));
1489
1498
  }
@@ -1536,7 +1545,7 @@ function renderSummary(snapshot: PrefixSnapshot, theme: Theme, width = 80): stri
1536
1545
  const percent = formatPercent(usage?.percent ?? null);
1537
1546
  const budgetParts = [
1538
1547
  `Harness ~${compactCount(harness)}`,
1539
- typeof request === "number" ? `Request ${compactCount(request)}` : undefined,
1548
+ typeof request === "number" ? `Request ~${compactCount(request)}` : undefined,
1540
1549
  window && window > 0 ? `/ ${contextWindowLabel(window)}` : undefined,
1541
1550
  percent ? `· ${percent}` : undefined,
1542
1551
  ].filter(Boolean).join(" ");
@@ -1656,8 +1665,9 @@ function renderExpanded(snapshot: PrefixSnapshot, theme: Theme, width: number):
1656
1665
  return lines;
1657
1666
  }
1658
1667
 
1668
+ // Wrap to the exact width Pi supplies; a minimum here would overflow narrow terminals.
1659
1669
  function wrapLines(lines: string[], width: number): string[] {
1660
- const maxWidth = Math.max(24, width);
1670
+ const maxWidth = Math.max(1, width);
1661
1671
  const out: string[] = [];
1662
1672
  for (const rawLine of lines) {
1663
1673
  if (rawLine.length === 0) {
@@ -1737,7 +1747,7 @@ class StartupContextComponent implements Component {
1737
1747
  this.cachedSignature = snapshot.signature;
1738
1748
  this.cachedMode = this.mode;
1739
1749
  this.cachedWidth = width;
1740
- this.cachedLines = wrapLines(body, Math.max(20, width));
1750
+ this.cachedLines = wrapLines(body, width);
1741
1751
  return this.cachedLines;
1742
1752
  } catch {
1743
1753
  this.cachedSignature = "context-usage-unavailable";
@@ -1746,7 +1756,7 @@ class StartupContextComponent implements Component {
1746
1756
  this.cachedLines = wrapLines([
1747
1757
  "",
1748
1758
  `${accent(undefined, "[Context Usage]")} unavailable while Pi finishes resuming this session`,
1749
- ], Math.max(20, width));
1759
+ ], width);
1750
1760
  return this.cachedLines;
1751
1761
  }
1752
1762
  }
@@ -1807,9 +1817,24 @@ function isContextBlockInstalled(block: StartupContextComponent): boolean {
1807
1817
  return Array.isArray(chat?.children) && chat.children.includes(block);
1808
1818
  }
1809
1819
 
1810
- function installContextBlock(block: StartupContextComponent): boolean {
1820
+ /**
1821
+ * With quietStartup Pi renders no resource rows to anchor on. Pi's document
1822
+ * container holds exactly [header, loaded resources, chat]; mount into the
1823
+ * (empty) loaded-resources slot so the card sits in the same place.
1824
+ */
1825
+ function findQuietStartupContainer(tui: unknown): ContainerLike | undefined {
1826
+ const isContainer = (value: unknown) => !!value && typeof value === "object" && Array.isArray((value as { children?: unknown }).children);
1827
+ const document = findContainerBy(tui, (children) => children.length === 3 && children.every(isContainer));
1828
+ const resources = document?.children[1] as ContainerLike | undefined;
1829
+ return resources && Array.isArray(resources.children) && resources.children.every((child) => isPrefixBlock(child) || isBlankComponent(child as Component))
1830
+ ? resources
1831
+ : undefined;
1832
+ }
1833
+
1834
+ function installContextBlock(block: StartupContextComponent, allowQuietFallback = false): boolean {
1811
1835
  const tui = g.__piContextUsageTui;
1812
- const chat = findResourceChatContainer(tui) ?? g.__piContextUsageChat;
1836
+ const chat = findResourceChatContainer(tui) ?? g.__piContextUsageChat ??
1837
+ (allowQuietFallback ? findQuietStartupContainer(tui) : undefined);
1813
1838
  if (!chat || !Array.isArray(chat.children)) return false;
1814
1839
 
1815
1840
  g.__piContextUsageChat = chat;
@@ -1828,7 +1853,8 @@ function scheduleInstall(block: StartupContextComponent): void {
1828
1853
  let attempts = 0;
1829
1854
  const attempt = () => {
1830
1855
  attempts++;
1831
- if (safely(() => installContextBlock(block), false)) return;
1856
+ // Prefer the resource-list anchor; fall back only after it has had time to render.
1857
+ if (safely(() => installContextBlock(block, attempts > 10), false)) return;
1832
1858
  if (attempts < 30) {
1833
1859
  g.__piContextUsageInstallTimer = setTimeout(attempt, 50);
1834
1860
  }
@@ -1918,11 +1944,8 @@ export default function piContextUsage(pi: ExtensionAPI) {
1918
1944
  pi.on("session_start", async (_event, ctx) => {
1919
1945
  if (!ctx.hasUI) return;
1920
1946
 
1921
- // Restore Pi's normal header; this extension now renders below Pi's loaded-resource list.
1922
- ctx.ui.setHeader(undefined);
1923
-
1924
1947
  const currentMode = g.__piContextUsageMode ?? DEFAULT_MODE;
1925
- const config = loadContextUsageConfig(ctx.cwd);
1948
+ const config = loadContextUsageConfig(ctx.cwd, ctx.isProjectTrusted());
1926
1949
  g.__piContextUsageModel = toModelSummary(ctx.model);
1927
1950
  markDirty();
1928
1951
  const block = new StartupContextComponent(
@@ -64,7 +64,7 @@ function readCliCredentials(home: string, platform: NodeJS.Platform): { apiKey?:
64
64
  function normalizeApiUrl(value: string | undefined): string {
65
65
  const input = value?.trim() || DEFAULT_API_URL;
66
66
  const url = new URL(input);
67
- const localHttp = url.protocol === "http:" && ["localhost", "127.0.0.1", "::1"].includes(url.hostname);
67
+ const localHttp = url.protocol === "http:" && ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname);
68
68
  if (url.protocol !== "https:" && !localHttp) throw new Error("Firecrawl API URL must use HTTPS (or localhost HTTP).");
69
69
  url.pathname = url.pathname.replace(/\/+$/, "");
70
70
  return url.toString().replace(/\/$/, "");
@@ -191,11 +191,16 @@ function retryAfterMs(value: string | null): number | undefined {
191
191
  function delay(ms: number, signal?: AbortSignal): Promise<void> {
192
192
  return new Promise((resolve, reject) => {
193
193
  if (signal?.aborted) return reject(signal.reason ?? new Error("Request aborted"));
194
- const timer = setTimeout(resolve, ms);
195
- signal?.addEventListener("abort", () => {
194
+ // Remove the listener on both paths so retries sharing a signal do not pile up.
195
+ const onAbort = () => {
196
196
  clearTimeout(timer);
197
- reject(signal.reason ?? new Error("Request aborted"));
198
- }, { once: true });
197
+ reject(signal?.reason ?? new Error("Request aborted"));
198
+ };
199
+ const timer = setTimeout(() => {
200
+ signal?.removeEventListener("abort", onAbort);
201
+ resolve();
202
+ }, ms);
203
+ signal?.addEventListener("abort", onAbort, { once: true });
199
204
  });
200
205
  }
201
206
 
@@ -260,7 +265,12 @@ export async function firecrawlRequest(
260
265
  });
261
266
 
262
267
  const contentLength = Number(response.headers.get("content-length") || 0);
263
- if (contentLength > MAX_RESPONSE_BYTES) throw new Error("Firecrawl response exceeded 25 MiB.");
268
+ if (contentLength > MAX_RESPONSE_BYTES) {
269
+ // Stop the transfer instead of leaving the body streaming in the background.
270
+ await response.body?.cancel("Firecrawl response exceeded 25 MiB.").catch(() => undefined);
271
+ timeout.abort(new Error("Firecrawl response exceeded 25 MiB."));
272
+ throw new Error("Firecrawl response exceeded 25 MiB.");
273
+ }
264
274
  const bytes = await readResponseBounded(response);
265
275
  const text = new TextDecoder().decode(bytes);
266
276
  let payload: unknown;
@@ -26,8 +26,11 @@ async function writeArtifact(operation: string, value: unknown): Promise<string>
26
26
  return path;
27
27
  }
28
28
 
29
+ // Room for the artifact footer, so the returned text stays within Pi's limits.
30
+ const FOOTER_RESERVE_BYTES = 512;
31
+
29
32
  async function boundOutput(text: string, operation: string, raw: unknown, forceArtifact = false): Promise<string> {
30
- const bounded = truncateHead(text, { maxBytes: DEFAULT_MAX_BYTES, maxLines: DEFAULT_MAX_LINES });
33
+ const bounded = truncateHead(text, { maxBytes: DEFAULT_MAX_BYTES - FOOTER_RESERVE_BYTES, maxLines: DEFAULT_MAX_LINES - 2 });
31
34
  if (!bounded.truncated && !forceArtifact) return text;
32
35
  const path = await writeArtifact(operation, raw);
33
36
  if (!bounded.truncated) return `${text}\n\n[Complete Firecrawl response saved to: ${path}]`;