gentle-pi 3.2.0 → 3.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.
Files changed (80) hide show
  1. package/assets/orchestrator-delegation.md +13 -8
  2. package/assets/orchestrator.md +2 -2
  3. package/docs/gentle-shell.md +40 -17
  4. package/docs/readme-reference.md +41 -7
  5. package/docs/review-integration.md +25 -11
  6. package/extensions/gentle-agents.ts +85 -17
  7. package/extensions/gentle-ai.ts +179 -12
  8. package/extensions/gentle-shell.ts +408 -38
  9. package/extensions/gentle-todo.ts +19 -1
  10. package/lib/agents-view.ts +41 -14
  11. package/lib/agents-widget.ts +84 -13
  12. package/lib/command-palette-catalog.ts +1 -0
  13. package/lib/double-esc-cancel-policy.ts +138 -0
  14. package/lib/inprocess-reviewer.ts +260 -0
  15. package/lib/model-routing-authority.ts +1 -1
  16. package/lib/native-review-cli.ts +23 -0
  17. package/lib/odd-runtime-delegation-gate.ts +88 -0
  18. package/lib/review-host-relay.ts +262 -94
  19. package/lib/review-integration-v2.ts +110 -26
  20. package/lib/shell-bar.ts +158 -29
  21. package/lib/shell-card.ts +19 -9
  22. package/lib/shell-changes-view.ts +43 -5
  23. package/lib/shell-changes.ts +92 -5
  24. package/lib/shell-hover.ts +39 -0
  25. package/lib/shell-prompt.ts +10 -1
  26. package/lib/shell-sidebar-layout.ts +111 -15
  27. package/lib/shell-sidebar.ts +16 -0
  28. package/lib/shell-todo.ts +7 -1
  29. package/lib/shell-usage-view.ts +98 -10
  30. package/lib/shell-usage.ts +226 -10
  31. package/package.json +2 -1
  32. package/runtime/native-review-cli.mjs +23 -0
  33. package/runtime/review-integration-v2.mjs +110 -26
  34. package/scripts/gentle-ai-installer.mjs +10 -10
  35. package/scripts/maintainer/provider-relay-matrix.mjs +118 -47
  36. package/scripts/mirror-odd-routing.mjs +242 -0
  37. package/scripts/verify-package-files.mjs +3 -3
  38. package/tests/agents-grouping.test.ts +75 -18
  39. package/tests/agents-view.test.ts +28 -18
  40. package/tests/agents-widget.test.ts +100 -12
  41. package/tests/command-palette.test.ts +1 -0
  42. package/tests/devbinary/pi-host-relay.devtest.ts +176 -138
  43. package/tests/double-esc-cancel-policy.test.ts +194 -0
  44. package/tests/gentle-agents.test.ts +528 -5
  45. package/tests/gentle-ai-binary.test.ts +1 -1
  46. package/tests/gentle-ai-installer.test.ts +47 -47
  47. package/tests/gentle-ai.test.ts +69 -5
  48. package/tests/gentle-shell.test.ts +903 -25
  49. package/tests/gentle-todo.test.ts +17 -4
  50. package/tests/inprocess-reviewer.test.ts +368 -0
  51. package/tests/maintainer/provider-relay.maintest.ts +101 -143
  52. package/tests/native-review-capability-contract.test.ts +32 -1
  53. package/tests/odd-routing-canonical-ratchet.test.ts +293 -0
  54. package/tests/odd-routing-contract.test.ts +57 -0
  55. package/tests/odd-runtime-delegation-gate.test.ts +212 -0
  56. package/tests/orchestrator-rdd-ownership.test.ts +3 -3
  57. package/tests/package-manifest.test.ts +6 -6
  58. package/tests/review-controller-native-routing.test.ts +60 -1
  59. package/tests/review-host-relay-routing.test.ts +77 -0
  60. package/tests/review-host-relay.test.ts +285 -239
  61. package/tests/review-integration-v2-forward.test.ts +61 -0
  62. package/tests/review-integration-v2.test.ts +116 -1
  63. package/tests/review-relay-transport-agent.test.ts +83 -0
  64. package/tests/runtime-harness.mjs +11 -0
  65. package/tests/session-changes-shell.test.ts +27 -0
  66. package/tests/session-worktree-registry.test.ts +41 -0
  67. package/tests/shell-bar.test.ts +224 -6
  68. package/tests/shell-card.test.ts +5 -3
  69. package/tests/shell-changes-view.test.ts +47 -0
  70. package/tests/shell-changes.test.ts +177 -0
  71. package/tests/shell-hover.test.ts +19 -0
  72. package/tests/shell-prompt.test.ts +20 -0
  73. package/tests/shell-sidebar-fullscreen.test.ts +59 -0
  74. package/tests/shell-sidebar-layout.test.ts +243 -5
  75. package/tests/shell-sidebar.test.ts +25 -1
  76. package/tests/shell-todo.test.ts +36 -0
  77. package/tests/shell-usage-view.test.ts +123 -3
  78. package/tests/shell-usage.test.ts +254 -6
  79. package/lib/opaque-pi-reviewer-adapter.ts +0 -284
  80. package/tests/opaque-pi-reviewer-adapter.test.ts +0 -266
@@ -1,5 +1,6 @@
1
1
  import { Key, matchesKey, truncateToWidth, visibleWidth, type Component, type TuiMouseEvent, type TuiMouseEventResult } from "@earendil-works/pi-tui";
2
2
  import { measureAgentsViewLayout, type AgentsViewLayout } from "./agents-view-layout.ts";
3
+ import { HOVER_ROLE, paintHoverable } from "./shell-hover.ts";
3
4
  import { emptyThread, isFinished, TASK_STATUS, type TaskRecord, type TaskStore, type TaskThread, type ThreadItem } from "./agents-protocol.ts";
4
5
  import { renderThreadItem, type AgentsThreadTheme } from "./agents-thread-view.ts";
5
6
  import { formatElapsed } from "./agents-widget.ts";
@@ -42,7 +43,7 @@ const ROLE = {
42
43
  FRAME: "border",
43
44
  TITLE: "customMessageLabel",
44
45
  SELECTED: "accent",
45
- HOVER: "warning",
46
+ HOVER: HOVER_ROLE,
46
47
  NAME: "text",
47
48
  NAME_IDLE: "muted",
48
49
  META: "dim",
@@ -69,7 +70,7 @@ const GLYPH_ROLE: Record<string, string> = {
69
70
  [TASK_STATUS.CANCELLED]: "dim",
70
71
  [TASK_STATUS.TIMED_OUT]: "error",
71
72
  };
72
- export const SESSION_FINISHED_TTL_MS = 15 * 60_000;
73
+ const MAX_FINISHED_PER_SESSION = 200;
73
74
  const SCOPE_LABEL: Record<ViewScope, string> = { [VIEW_SCOPE.SESSION]: "this session", [VIEW_SCOPE.ALL]: "all sessions" };
74
75
  const SCOPE_KEY: Record<ViewScope, string> = { [VIEW_SCOPE.SESSION]: "all sessions", [VIEW_SCOPE.ALL]: "this session" };
75
76
  const EMPTY_LIST = "no tasks yet";
@@ -428,7 +429,8 @@ export class AgentsView {
428
429
 
429
430
  private counts(): string {
430
431
  const active = this.tasks.filter((task) => !isFinished(task.status)).length;
431
- return `${active} active`;
432
+ const finished = this.tasks.length - active;
433
+ return `${active} active · ${finished} finished`;
432
434
  }
433
435
 
434
436
  private actionableTask(): TaskRecord | undefined {
@@ -470,8 +472,12 @@ export class AgentsView {
470
472
  }
471
473
 
472
474
  private inScope(task: TaskRecord, _now: number): boolean {
473
- return !isFinished(task.status) && (task.parentSessionId === this.deps.sessionId
474
- || (this.scope === VIEW_SCOPE.ALL && this.remoteThreads.has(task.id)));
475
+ // A local task (this session's own, active or finished history) always
476
+ // belongs; refreshTasks() is the single authority on which finished ones
477
+ // survive its own retention cap. A remote (presence) task never keeps
478
+ // history -- it drops out of scope the moment it finishes.
479
+ if (task.parentSessionId === this.deps.sessionId) return true;
480
+ return !isFinished(task.status) && this.scope === VIEW_SCOPE.ALL && this.remoteThreads.has(task.id);
475
481
  }
476
482
 
477
483
  private directoryRoot(): boolean {
@@ -534,8 +540,19 @@ export class AgentsView {
534
540
  }
535
541
 
536
542
  private refreshTasks(): void {
537
- this.tasks = this.deps.store.list().filter((task) => task.parentSessionId === this.deps.sessionId
538
- && !isFinished(task.status) && (this.deps.isLocalTask?.(task) ?? true));
543
+ // Finished history bypasses isLocalTask: that dep exists to keep an
544
+ // unrelated task resolveTask pulled in on demand (e.g. `subagent_result`
545
+ // for an id from another session) out of the live panel, never to hide
546
+ // this session's own finished subagents -- those belong here whether
547
+ // they were restored on resume or just finished live.
548
+ const local = this.deps.store.list().filter((task) => task.parentSessionId === this.deps.sessionId
549
+ && (isFinished(task.status) || (this.deps.isLocalTask?.(task) ?? true)));
550
+ const active = local.filter((task) => !isFinished(task.status));
551
+ const finished = local
552
+ .filter((task) => isFinished(task.status))
553
+ .sort((a, b) => (b.endedAt ?? 0) - (a.endedAt ?? 0))
554
+ .slice(0, MAX_FINISHED_PER_SESSION);
555
+ this.tasks = [...active, ...finished];
539
556
  if (this.scope === VIEW_SCOPE.ALL) this.tasks.push(...this.peers.flatMap((group) => group.tasks));
540
557
  const groups = this.sessionGroups();
541
558
  const present = new Set(groups.map((group) => group.id));
@@ -564,7 +581,15 @@ export class AgentsView {
564
581
  group.tasks.push(task);
565
582
  }
566
583
  for (const group of groups.values()) {
567
- group.tasks.sort((a, b) => b.createdAt - a.createdAt || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
584
+ // Active tasks first (newest created), then finished ones as history
585
+ // (newest ended first) -- a task never reorders within its own group
586
+ // just because it finished.
587
+ group.tasks.sort((a, b) => {
588
+ const finishedRank = Number(isFinished(a.status)) - Number(isFinished(b.status));
589
+ if (finishedRank !== 0) return finishedRank;
590
+ if (isFinished(a.status)) return (b.endedAt ?? 0) - (a.endedAt ?? 0);
591
+ return b.createdAt - a.createdAt || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0);
592
+ });
568
593
  }
569
594
  if (this.scope === VIEW_SCOPE.ALL) {
570
595
  const localId = `session:${this.deps.sessionId}`;
@@ -637,16 +662,18 @@ export class AgentsView {
637
662
  const theme = this.deps.theme;
638
663
  this.taskRegion(row).render(this.pointerLayout?.listWidth ?? 1);
639
664
  const selected = entry.id === this.selectedRow()?.id;
640
- const hovered = entry.id === this.hoveredId;
641
- const emphasis = selected ? ROLE.SELECTED : hovered ? ROLE.HOVER : undefined;
642
- const marker = emphasis ? theme.fg(emphasis, selected ? "▸" : "▹") : " ";
665
+ // Selection always outranks hover; the shared hover painter only
666
+ // applies once selection is ruled out.
667
+ const hovered = !selected && entry.id === this.hoveredId;
668
+ const markerRole = selected ? ROLE.SELECTED : hovered ? ROLE.HOVER : undefined;
669
+ const marker = markerRole ? theme.fg(markerRole, selected ? "▸" : "▹") : " ";
643
670
  if (entry.kind === "heading") {
644
671
  const state = this.isExpanded(entry.group) ? "▾" : "▸";
645
- return `${marker}${theme.fg(emphasis ?? ROLE.SELECTED, state)} ${theme.fg(emphasis ?? ROLE.NAME_IDLE, this.groupHeading(entry.group))}`;
672
+ return `${marker}${paintHoverable(theme, state, hovered, ROLE.SELECTED)} ${paintHoverable(theme, this.groupHeading(entry.group), hovered, selected ? ROLE.SELECTED : ROLE.NAME_IDLE)}`;
646
673
  }
647
674
  const task = entry.task;
648
675
  const glyph = theme.fg(GLYPH_ROLE[task.status] ?? ROLE.META, GLYPH[task.status] ?? "?");
649
- const name = theme.fg(emphasis ?? ROLE.NAME_IDLE, `Subagent ${task.agent}`);
676
+ const name = paintHoverable(theme, `Subagent ${task.agent}`, hovered, selected ? ROLE.SELECTED : ROLE.NAME_IDLE);
650
677
  const time = task.startedAt === null ? "" : theme.fg(ROLE.META, formatElapsed((task.endedAt ?? this.deps.now()) - task.startedAt));
651
678
  return `${marker} ${theme.fg(ROLE.META, "└")} ${glyph} ${name} ${time}`;
652
679
  }
@@ -756,7 +783,7 @@ export class AgentsView {
756
783
  actions.push({ x: 2 + visibleWidth(text), width: label.length, region: control.region });
757
784
  control.region.render(label.length);
758
785
  const hovered = (control.region === this.followRegion && this.hoveredControl === "follow") || (control.region === this.openRegion && this.hoveredControl === "open");
759
- text += this.deps.theme.fg(!control.enabled ? ROLE.META : hovered ? ROLE.HOVER : ROLE.KEY, label);
786
+ text += control.enabled ? paintHoverable(this.deps.theme, label, hovered, ROLE.KEY) : this.deps.theme.fg(ROLE.META, label);
760
787
  }
761
788
  if (required > width) {
762
789
  const more = this.actionRegion("more", () => {
@@ -29,7 +29,28 @@ interface Columns {
29
29
  name: number;
30
30
  task: number;
31
31
  meta: number;
32
- fullMetrics: boolean;
32
+ // Columnar rows give each surviving metadata field its own fixed,
33
+ // right-aligned column sized to the widest value among shown tasks, so
34
+ // model·effort, tokens, cost, and elapsed line up vertically across rows.
35
+ // A narrow card may have dropped some of those columns and still be
36
+ // columnar; false means the clipped single-string fallback.
37
+ columnar: boolean;
38
+ // Populated only when columnar is true.
39
+ metaWidths?: MetaColumnWidths;
40
+ }
41
+
42
+ interface MetaFields {
43
+ exec: string;
44
+ tokens: string;
45
+ cost: string;
46
+ elapsed: string;
47
+ }
48
+
49
+ interface MetaColumnWidths {
50
+ exec: number;
51
+ tokens: number;
52
+ cost: number;
53
+ elapsed: number;
33
54
  }
34
55
 
35
56
  const LOOK: Record<TaskStatus, StatusLook> = {
@@ -150,11 +171,48 @@ function executionLabel(task: TaskRecord, width = Infinity): string {
150
171
  return clip(model, width - visibleWidth(suffix)) + suffix;
151
172
  }
152
173
 
153
- function meta(task: TaskRecord, now: number, fullMetrics: boolean): string {
154
- if (task.status === TASK_STATUS.QUEUED) return "queued";
155
- if (!fullMetrics) return executionLabel(task);
156
- const parts = [executionLabel(task), task.tokens > 0 ? formatTokens(task.tokens) : "", task.cost > 0 ? `$${task.cost.toFixed(2)}` : "", elapsed(task, now)];
157
- return parts.filter((part) => part.length > 0).join(" · ");
174
+ // Narrow (non-columnar) rows keep the single-string contract: a queued
175
+ // task shows the bare word, everything else shows its exec label.
176
+ function narrowMetaText(task: TaskRecord): string {
177
+ return task.status === TASK_STATUS.QUEUED ? "queued" : executionLabel(task);
178
+ }
179
+
180
+ // Queued rows carry no model, token, or cost data yet, so every field but
181
+ // elapsed stays blank — and elapsed itself becomes the literal word "queued"
182
+ // rather than the empty string startedAt === null would otherwise produce.
183
+ function metaFields(task: TaskRecord, now: number): MetaFields {
184
+ if (task.status === TASK_STATUS.QUEUED) return { exec: "", tokens: "", cost: "", elapsed: "queued" };
185
+ return {
186
+ exec: executionLabel(task),
187
+ tokens: task.tokens > 0 ? formatTokens(task.tokens) : "",
188
+ cost: task.cost > 0 ? `$${task.cost.toFixed(2)}` : "",
189
+ elapsed: elapsed(task, now),
190
+ };
191
+ }
192
+
193
+ function metaColumnWidths(tasks: readonly TaskRecord[], now: number): MetaColumnWidths {
194
+ const fields = tasks.map((task) => metaFields(task, now));
195
+ const widest = (pick: (field: MetaFields) => string) => Math.max(0, ...fields.map((field) => visibleWidth(pick(field))));
196
+ return { exec: widest((field) => field.exec), tokens: widest((field) => field.tokens), cost: widest((field) => field.cost), elapsed: widest((field) => field.elapsed) };
197
+ }
198
+
199
+ // A column whose widest value is empty across every shown task (e.g. no task
200
+ // carries a cost yet) is dropped entirely, together with its separator —
201
+ // exactly like the old joined string dropped an empty field, but decided once
202
+ // for the whole card rather than per row, so the remaining columns still align.
203
+ function metaTotalWidth(widths: MetaColumnWidths): number {
204
+ const active = [widths.exec, widths.tokens, widths.cost, widths.elapsed].filter((width) => width > 0);
205
+ return active.reduce((sum, width) => sum + width, 0) + Math.max(0, active.length - 1) * visibleWidth(" · ");
206
+ }
207
+
208
+ function metaRowText(task: TaskRecord, now: number, widths: MetaColumnWidths): string {
209
+ const fields = metaFields(task, now);
210
+ const parts: string[] = [];
211
+ if (widths.exec > 0) parts.push(fields.exec.padStart(widths.exec));
212
+ if (widths.tokens > 0) parts.push(fields.tokens.padStart(widths.tokens));
213
+ if (widths.cost > 0) parts.push(fields.cost.padStart(widths.cost));
214
+ if (widths.elapsed > 0) parts.push(fields.elapsed.padStart(widths.elapsed));
215
+ return parts.join(" · ");
158
216
  }
159
217
 
160
218
  function taskText(task: TaskRecord): string {
@@ -163,27 +221,40 @@ function taskText(task: TaskRecord): string {
163
221
  return task.label;
164
222
  }
165
223
 
166
- // Narrow cards give up the task and usage columns before execution metadata.
224
+ // Narrow cards degrade per column, decided once for the whole card so the
225
+ // surviving columns keep lining up: the task text goes first, then the
226
+ // model·effort label, then tokens, then cost. Elapsed is the one value the
227
+ // reader cannot rebuild from anything else on screen, so it is the last to go
228
+ // (gentle-shell#1143). Only when even elapsed alone does not fit does the row
229
+ // fall back to the clipped single-string label.
167
230
  function columns(tasks: readonly TaskRecord[], inner: number, now: number): Columns {
168
231
  const name = Math.max(0, Math.min(NAME_MAX, inner - 3, Math.max(...tasks.map((task) => visibleWidth(task.agent)))));
169
232
  const fixed = 1 + GLYPH_GAP.length + name + COLUMN_GAP.length;
170
- const metaWidth = (fullMetrics: boolean) => Math.max(...tasks.map((task) => visibleWidth(meta(task, now, fullMetrics))));
171
- const full = metaWidth(true);
233
+ const metaWidths = metaColumnWidths(tasks, now);
234
+ const full = metaTotalWidth(metaWidths);
172
235
  const task = inner - fixed - full - COLUMN_GAP.length;
173
- if (task >= TASK_MIN) return { inner, name, meta: full, task, fullMetrics: true };
174
- return { inner, name, meta: Math.max(0, Math.min(inner - fixed, metaWidth(false))), task: 0, fullMetrics: false };
236
+ if (task >= TASK_MIN) return { inner, name, meta: full, task, columnar: true, metaWidths };
237
+ let widths = metaWidths;
238
+ for (const drop of ["exec", "tokens", "cost"] as const) {
239
+ if (metaTotalWidth(widths) <= inner - fixed) break;
240
+ widths = { ...widths, [drop]: 0 };
241
+ }
242
+ const meta = metaTotalWidth(widths);
243
+ if (meta > 0 && meta <= inner - fixed) return { inner, name, meta, task: 0, columnar: true, metaWidths: widths };
244
+ const narrow = Math.max(0, ...tasks.map((task) => visibleWidth(narrowMetaText(task))));
245
+ return { inner, name, meta: Math.max(0, Math.min(inner - fixed, narrow)), task: 0, columnar: false };
175
246
  }
176
247
 
177
248
  function row(task: TaskRecord, theme: CardTheme, cols: Columns, now: number, allowMetadataRow: boolean): string[] {
178
249
  const look = LOOK[task.status];
179
250
  const name = clip(task.agent, cols.name);
180
251
  const head = `${theme.fg(look.role, look.glyph)}${GLYPH_GAP}${theme.fg(NAME_ROLE, name)}${" ".repeat(cols.name - visibleWidth(name))}`;
181
- const metadata = cols.fullMetrics ? meta(task, now, true) : task.status === TASK_STATUS.QUEUED ? clip("queued", cols.meta) : executionLabel(task, cols.meta);
252
+ const metadata = cols.columnar && cols.metaWidths ? metaRowText(task, now, cols.metaWidths) : task.status === TASK_STATUS.QUEUED ? clip("queued", cols.meta) : executionLabel(task, cols.meta);
182
253
  const tail = theme.fg(META_ROLE, " ".repeat(Math.max(0, cols.meta - visibleWidth(metadata))) + metadata);
183
254
  if (cols.inner < 3) return [theme.fg(look.role, clip(look.glyph, cols.inner))];
184
255
  // The scrollable sidebar can preserve identity and execution metadata on
185
256
  // separate rows. The height-capped above-editor widget keeps its row budget.
186
- if (allowMetadataRow && cols.task === 0 && task.status !== TASK_STATUS.QUEUED && visibleWidth(executionLabel(task)) > cols.meta) {
257
+ if (allowMetadataRow && !cols.columnar && cols.task === 0 && task.status !== TASK_STATUS.QUEUED && visibleWidth(executionLabel(task)) > cols.meta) {
187
258
  return [head, theme.fg(META_ROLE, executionLabel(task, cols.inner))];
188
259
  }
189
260
  if (cols.task === 0) return [`${head}${" ".repeat(Math.max(0, cols.inner - visibleWidth(head) - visibleWidth(tail)))}${tail}`];
@@ -25,6 +25,7 @@ export const COMMAND_PALETTE_CATALOG: readonly CommandPaletteCatalogGroup[] = [
25
25
  { command: "gentle:persona", label: "Switch persona" },
26
26
  { command: "gentle:review-mode", label: "Review mode (receipt-driven development)" },
27
27
  { command: "gentle:background-subagents", label: "Background subagents" },
28
+ { command: "gentle:double-esc-cancel", label: "Require double Esc to cancel" },
28
29
  { command: "gentle:telemetry", label: "Telemetry" },
29
30
  { command: "gentle:banner", label: "Startup banner" },
30
31
  { command: "gentle:banner-color", label: "Banner color" },
@@ -0,0 +1,138 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { gentlePiConfigHome } from "./agent-home.ts";
4
+
5
+ // ---------------------------------------------------------------------------
6
+ // Double-esc-cancel policy — global file > env > default off (issue #1163)
7
+ //
8
+ // Deliberately global-only (no project-file layer, unlike
9
+ // background-subagents): this preference changes what a keypress does while
10
+ // the agent is working, and a per-project override for a personal habit like
11
+ // this would make the same key do two different things depending on which
12
+ // repo happens to be open. Otherwise mirrors the resolution shape of
13
+ // lib/background-subagents-policy.ts, minus that project-file layer.
14
+ // ---------------------------------------------------------------------------
15
+
16
+ export type DoubleEscCancelPolicy = "on" | "off";
17
+
18
+ /** Which of the three sources decided the effective policy. */
19
+ export type DoubleEscCancelSource = "global_file" | "environment" | "default";
20
+
21
+ export interface DoubleEscCancelResolution {
22
+ policy: DoubleEscCancelPolicy;
23
+ source: DoubleEscCancelSource;
24
+ /** The deciding file was present but failed the strict decode. */
25
+ malformed: boolean;
26
+ globalFile: string;
27
+ globalFileExists: boolean;
28
+ /** The raw env value, reported even when it is unrecognized and inert. */
29
+ envValue: string | undefined;
30
+ }
31
+
32
+ export interface LoadDoubleEscCancelOptions {
33
+ /** Override the config home directory (used in tests to avoid touching ~/.pi). */
34
+ gentlePiConfigHome?: string;
35
+ /** Override the environment lookup (used in tests). */
36
+ env?: Record<string, string | undefined>;
37
+ }
38
+
39
+ export const DOUBLE_ESC_CANCEL_SCHEMA = "gentle-pi.double-esc-cancel/v1";
40
+ export const DOUBLE_ESC_CANCEL_FILE = "double-esc-cancel.json";
41
+
42
+ // Pi's own idle double-Esc (empty editor -> /tree or /fork, interactive-mode.js
43
+ // `lastEscapeTime`) uses a 500ms window. Canceling a running turn throws away
44
+ // in-flight work and is harder to undo than switching prompts, so this
45
+ // confirmation deliberately gets double that time to land the second press.
46
+ export const DOUBLE_ESC_CANCEL_WINDOW_MS = 1000;
47
+
48
+ function isRecord(value: unknown): value is Record<string, unknown> {
49
+ return typeof value === "object" && value !== null && !Array.isArray(value);
50
+ }
51
+
52
+ /**
53
+ * Strict decode of {"schema":"gentle-pi.double-esc-cancel/v1","policy":"on"|"off"}.
54
+ * Any malformed shape (bad JSON, wrong schema, unknown keys, invalid policy)
55
+ * returns undefined so the caller fails closed to "off".
56
+ */
57
+ export function parseDoubleEscCancelPolicyFile(raw: string): DoubleEscCancelPolicy | undefined {
58
+ let parsed: unknown;
59
+ try {
60
+ parsed = JSON.parse(raw);
61
+ } catch {
62
+ return undefined;
63
+ }
64
+ if (!isRecord(parsed)) return undefined;
65
+ if (parsed.schema !== DOUBLE_ESC_CANCEL_SCHEMA) return undefined;
66
+ if (parsed.policy !== "on" && parsed.policy !== "off") return undefined;
67
+ if (Object.keys(parsed).length !== 2) return undefined;
68
+ return parsed.policy;
69
+ }
70
+
71
+ /**
72
+ * Resolve the double-esc-cancel policy AND the source that decided it.
73
+ *
74
+ * Resolution order (first hit wins):
75
+ * 1. Global file `${configHome}/double-esc-cancel.json`
76
+ * (configHome honors GENTLE_PI_CONFIG_HOME, default ~/.pi/gentle-ai)
77
+ * 2. Env var GENTLE_PI_DOUBLE_ESC_CANCEL ("on" | "off")
78
+ * 3. Default "off"
79
+ *
80
+ * A present-but-malformed file fails closed to "off" instead of falling
81
+ * through to the env var, and stays attributed to that file: "off decided
82
+ * by a broken global file" and "off by default" are different situations,
83
+ * and only the first one is a mistake to fix.
84
+ */
85
+ export function resolveDoubleEscCancelPolicy(
86
+ options: LoadDoubleEscCancelOptions = {},
87
+ ): DoubleEscCancelResolution {
88
+ const env = options.env ?? process.env;
89
+ const envValue = env.GENTLE_PI_DOUBLE_ESC_CANCEL;
90
+ let globalFile = "";
91
+ try {
92
+ const configHome = options.gentlePiConfigHome ?? gentlePiConfigHome(env);
93
+ globalFile = join(configHome, DOUBLE_ESC_CANCEL_FILE);
94
+ const globalFileExists = existsSync(globalFile);
95
+ if (globalFileExists) {
96
+ let decoded: DoubleEscCancelPolicy | undefined;
97
+ try {
98
+ decoded = parseDoubleEscCancelPolicyFile(readFileSync(globalFile, "utf8"));
99
+ } catch {
100
+ // Unreadable is indistinguishable from unusable at this layer, and
101
+ // both must fail closed on the file that claimed the decision.
102
+ decoded = undefined;
103
+ }
104
+ return decoded === undefined
105
+ ? { policy: "off", source: "global_file", malformed: true, globalFile, globalFileExists, envValue }
106
+ : { policy: decoded, source: "global_file", malformed: false, globalFile, globalFileExists, envValue };
107
+ }
108
+ if (envValue === "on" || envValue === "off") {
109
+ return { policy: envValue, source: "environment", malformed: false, globalFile, globalFileExists, envValue };
110
+ }
111
+ return { policy: "off", source: "default", malformed: false, globalFile, globalFileExists, envValue };
112
+ } catch {
113
+ return { policy: "off", source: "default", malformed: false, globalFile, globalFileExists: false, envValue };
114
+ }
115
+ }
116
+
117
+ /**
118
+ * The effective policy alone, for callers that do not report a source.
119
+ * It delegates so the loader and the resolver can never disagree.
120
+ */
121
+ export function loadDoubleEscCancelPolicy(options: LoadDoubleEscCancelOptions = {}): DoubleEscCancelPolicy {
122
+ return resolveDoubleEscCancelPolicy(options).policy;
123
+ }
124
+
125
+ /**
126
+ * Write the global policy file, creating the config home when needed.
127
+ * Used by both `/gentle:double-esc-cancel enable` and `... disable`.
128
+ */
129
+ export function writeDoubleEscCancelPolicy(
130
+ policy: DoubleEscCancelPolicy,
131
+ options: { gentlePiConfigHome?: string } = {},
132
+ ): string {
133
+ const configHome = options.gentlePiConfigHome ?? gentlePiConfigHome();
134
+ const path = join(configHome, DOUBLE_ESC_CANCEL_FILE);
135
+ mkdirSync(configHome, { recursive: true });
136
+ writeFileSync(path, `${JSON.stringify({ schema: DOUBLE_ESC_CANCEL_SCHEMA, policy }, null, 2)}\n`);
137
+ return path;
138
+ }
@@ -0,0 +1,260 @@
1
+ // In-process reviewer completion (gentle-ai#4611; gentle-pi#311 P1).
2
+ //
3
+ // The relay child this module replaced ran a locked-down `pi --print` process
4
+ // with extension discovery disabled, which dropped extension-registered
5
+ // providers ("Model not found") and stripped env-provided API keys; its env
6
+ // allowlist required per-provider manual configuration and Go roles had no
7
+ // parity with it. This module is that transport's replacement for a single
8
+ // reviewer completion: it resolves
9
+ // the caller's "provider/id" selection through pi's live model registry,
10
+ // authenticates through the registry's own resolver, and completes exactly
11
+ // one frozen prompt as a single user message — no systemPrompt, no tools, no
12
+ // session, no extension hooks.
13
+ //
14
+ // Every I/O seam is injected (`registry`, `complete`, `now`), so this module
15
+ // runs under tests with no network and no pi process. gentle-pi#311 P2 wires
16
+ // this into the lens relay (lib/review-host-relay.ts); P3 wires the provider
17
+ // role vectors. This file stays a pure completion, never invoked from here.
18
+
19
+ import type { Api, AssistantMessage, Context, Model, ProviderHeaders, SimpleStreamOptions, TextContent, ThinkingLevel } from "@earendil-works/pi-ai";
20
+ import type { completeSimple } from "@earendil-works/pi-ai/compat";
21
+ import { SAFE_MODEL_ID_PATTERN } from "./model-routing-authority.ts";
22
+
23
+ // ---------------------------------------------------------------------------
24
+ // Registry seam — a structural subset of pi's live ModelRegistry
25
+ // (@earendil-works/pi-coding-agent core/model-registry.ts). Only `find` and
26
+ // `getApiKeyAndHeaders` are needed here; the real registry's resolved auth
27
+ // carries extra optional fields (`baseUrl`, `env`) that this narrower shape
28
+ // simply ignores.
29
+ // ---------------------------------------------------------------------------
30
+
31
+ export interface InProcessReviewerRegistry {
32
+ find(provider: string, modelId: string): Model<Api> | undefined;
33
+ getApiKeyAndHeaders(model: Model<Api>): Promise<
34
+ | { readonly ok: true; readonly apiKey?: string; readonly headers?: ProviderHeaders }
35
+ | { readonly ok: false; readonly error: string }
36
+ >;
37
+ }
38
+
39
+ export const INPROCESS_REVIEWER_FAILURE = {
40
+ SELECTION_INVALID: "selection-invalid",
41
+ MODEL_NOT_FOUND: "model-not-found",
42
+ AUTH_UNAVAILABLE: "auth-unavailable",
43
+ THINKING_INVALID: "thinking-invalid",
44
+ TOOL_CALL_ATTEMPTED: "tool-call-attempted",
45
+ EMPTY_OUTPUT: "empty-output",
46
+ OUTPUT_TOO_LARGE: "output-too-large",
47
+ TIMED_OUT: "timed-out",
48
+ ABORTED: "aborted",
49
+ PROVIDER_FAILED: "provider-failed",
50
+ } as const;
51
+ export type InProcessReviewerFailureCode = (typeof INPROCESS_REVIEWER_FAILURE)[keyof typeof INPROCESS_REVIEWER_FAILURE];
52
+
53
+ export interface InProcessReviewerRequest {
54
+ /** "provider/id" from models.json routing; already validated by SAFE_MODEL_ID_PATTERN upstream, re-validated here. */
55
+ readonly selection: string;
56
+ /** Routing thinking label: off | minimal | low | medium | high | xhigh | max. Omitted is treated as "off". */
57
+ readonly thinking?: string;
58
+ /** Frozen Go-materialized prompt bytes, submitted verbatim as the one user message. */
59
+ readonly prompt: Buffer;
60
+ readonly timeoutMs: number;
61
+ readonly signal?: AbortSignal;
62
+ /** e.g. "review-risk" — only used to name the routing config key in refusal messages. */
63
+ readonly routingKey: string;
64
+ }
65
+
66
+ export type InProcessReviewerOutcome =
67
+ | { readonly kind: "text"; readonly text: string; readonly reviewerModel: string }
68
+ | { readonly kind: "refused"; readonly code: InProcessReviewerFailureCode; readonly message: string; readonly evidence?: Record<string, unknown> };
69
+
70
+ export interface InProcessReviewerDeps {
71
+ readonly registry: InProcessReviewerRegistry;
72
+ readonly complete: typeof completeSimple;
73
+ /** Test seam for the single user message's timestamp; defaults to Date.now. */
74
+ readonly now?: () => number;
75
+ }
76
+
77
+ // No existing bound covers the reviewer's completion text: the child this
78
+ // module replaced returned its extracted text unbounded. 4 MiB matches this
79
+ // repo's established convention for this class of bound (lib/session-changes.ts
80
+ // MAX_SESSION_BYTES, lib/provider-contract-bundle.ts MAX_FILE_BYTES).
81
+ export const INPROCESS_REVIEWER_OUTPUT_MAX_BYTES = 4 * 1024 * 1024;
82
+
83
+ // pi-ai's `SimpleStreamOptions.reasoning` accepts every routing label except
84
+ // "off" (`ThinkingLevel = "minimal" | "low" | "medium" | "high" | "xhigh" |
85
+ // "max"`), and pi-ai itself clamps a level to what the selected model
86
+ // supports through its `thinkingLevelMap`. The label is therefore forwarded
87
+ // verbatim: the routing config owns the choice and the library owns the
88
+ // per-provider mapping, so this module never second-guesses either.
89
+ const THINKING_LABELS = new Set<ThinkingLevel>(["minimal", "low", "medium", "high", "xhigh", "max"]);
90
+ const ERROR_EXCERPT_MAX_CHARS = 512;
91
+
92
+ function parseSelection(selection: string): { provider: string; modelId: string } | undefined {
93
+ if (typeof selection !== "string" || selection.length === 0 || !SAFE_MODEL_ID_PATTERN.test(selection)) return undefined;
94
+ const separatorIndex = selection.indexOf("/");
95
+ if (separatorIndex <= 0 || separatorIndex === selection.length - 1) return undefined;
96
+ return { provider: selection.slice(0, separatorIndex), modelId: selection.slice(separatorIndex + 1) };
97
+ }
98
+
99
+ type ReasoningResolution = { readonly ok: true; readonly reasoning?: ThinkingLevel } | { readonly ok: false };
100
+
101
+ /**
102
+ * "off" (or an omitted label) omits `reasoning` entirely; every other pi-ai
103
+ * level is forwarded verbatim; an unrecognized label is a typed refusal. A model with
104
+ * `reasoning === false` never receives the field, regardless of the label —
105
+ * checked last so an unknown label is still refused even for a non-reasoning
106
+ * model, instead of silently passing validation because it would be dropped
107
+ * anyway.
108
+ */
109
+ function resolveReasoning(thinking: string | undefined, model: Model<Api>): ReasoningResolution {
110
+ if (thinking === undefined || thinking === "off") return { ok: true };
111
+ if (!THINKING_LABELS.has(thinking as ThinkingLevel)) return { ok: false };
112
+ return model.reasoning === false ? { ok: true } : { ok: true, reasoning: thinking as ThinkingLevel };
113
+ }
114
+
115
+ function sanitizeErrorExcerpt(error: unknown): string {
116
+ const raw = error instanceof Error ? error.message : String(error);
117
+ const collapsed = raw.replace(/\s+/g, " ").trim();
118
+ return collapsed.length <= ERROR_EXCERPT_MAX_CHARS ? collapsed : `${collapsed.slice(0, ERROR_EXCERPT_MAX_CHARS - 1)}…`;
119
+ }
120
+
121
+ function refuse(code: InProcessReviewerFailureCode, message: string, evidence?: Record<string, unknown>): InProcessReviewerOutcome {
122
+ return { kind: "refused", code, message, ...(evidence === undefined ? {} : { evidence }) };
123
+ }
124
+
125
+ function isTextContent(part: { type?: unknown }): part is TextContent {
126
+ return part.type === "text";
127
+ }
128
+
129
+ /**
130
+ * Runs one reviewer completion in-process: resolve the model, authenticate,
131
+ * map the routing thinking label, and complete exactly one frozen prompt as
132
+ * a single user message. Never retries, never falls back to another model,
133
+ * never reads process.env — every seam is injected through `deps`.
134
+ */
135
+ export async function runInProcessReviewer(request: InProcessReviewerRequest, deps: InProcessReviewerDeps): Promise<InProcessReviewerOutcome> {
136
+ const parsed = parseSelection(request.selection);
137
+ if (parsed === undefined) {
138
+ return refuse(
139
+ INPROCESS_REVIEWER_FAILURE.SELECTION_INVALID,
140
+ `Invalid model selection ${JSON.stringify(request.selection)} for ${request.routingKey}; expected the "provider/id" shape.`,
141
+ );
142
+ }
143
+
144
+ const model = deps.registry.find(parsed.provider, parsed.modelId);
145
+ if (model === undefined) {
146
+ return refuse(
147
+ INPROCESS_REVIEWER_FAILURE.MODEL_NOT_FOUND,
148
+ `No model matches selection ${JSON.stringify(request.selection)} configured for ${request.routingKey}; assign ${request.routingKey} a model that the interactive pi's model list actually shows.`,
149
+ );
150
+ }
151
+
152
+ const auth = await deps.registry.getApiKeyAndHeaders(model);
153
+ // Negation narrowing (`!auth.ok`) does not eliminate the `ok: true` arm of
154
+ // this discriminated union under this project's `strict: false` tsconfig;
155
+ // an explicit `=== false` comparison narrows correctly in both directions.
156
+ if (auth.ok === false) {
157
+ return refuse(
158
+ INPROCESS_REVIEWER_FAILURE.AUTH_UNAVAILABLE,
159
+ `No credentials available for provider ${JSON.stringify(parsed.provider)} (used by ${request.routingKey}): ${auth.error}`,
160
+ );
161
+ }
162
+
163
+ const reasoning = resolveReasoning(request.thinking, model);
164
+ if (reasoning.ok === false) {
165
+ return refuse(
166
+ INPROCESS_REVIEWER_FAILURE.THINKING_INVALID,
167
+ `Unknown thinking level ${JSON.stringify(request.thinking)} for ${request.routingKey}; use one of off, minimal, low, medium, high, xhigh, max.`,
168
+ );
169
+ }
170
+
171
+ // The caller's own signal (if any) and a floor timeout race together:
172
+ // whichever fires first aborts the completion. The catch branch below
173
+ // tells them apart by which underlying signal actually fired, never by
174
+ // inspecting the thrown error's shape, which providers are free to vary.
175
+ const timeoutSignal = AbortSignal.timeout(request.timeoutMs);
176
+ const combinedSignal = request.signal === undefined ? timeoutSignal : AbortSignal.any([request.signal, timeoutSignal]);
177
+
178
+ const context: Context = {
179
+ messages: [
180
+ {
181
+ role: "user",
182
+ content: [{ type: "text", text: request.prompt.toString("utf8") }],
183
+ timestamp: (deps.now ?? Date.now)(),
184
+ },
185
+ ],
186
+ };
187
+ const options: SimpleStreamOptions = {
188
+ signal: combinedSignal,
189
+ timeoutMs: request.timeoutMs,
190
+ ...(auth.apiKey === undefined ? {} : { apiKey: auth.apiKey }),
191
+ ...(auth.headers === undefined ? {} : { headers: auth.headers }),
192
+ ...(reasoning.reasoning === undefined ? {} : { reasoning: reasoning.reasoning }),
193
+ };
194
+
195
+ // An abort is classified by which signal actually fired, never by the
196
+ // error's shape or the message's text, and the same classification serves
197
+ // both settlement paths: a provider may reject on abort, or — the pi-ai
198
+ // provider convention — resolve an AssistantMessage with `stopReason:
199
+ // "aborted"` carrying whatever text streamed before the cut. Either way a
200
+ // fired signal is a timeout or a caller abort, never empty or usable output.
201
+ const abortRefusal = (): InProcessReviewerOutcome | undefined => {
202
+ if (timeoutSignal.aborted) {
203
+ return refuse(INPROCESS_REVIEWER_FAILURE.TIMED_OUT, `Reviewer completion for ${request.routingKey} exceeded its ${request.timeoutMs}ms bound.`);
204
+ }
205
+ if (request.signal?.aborted === true) {
206
+ return refuse(INPROCESS_REVIEWER_FAILURE.ABORTED, `Reviewer completion for ${request.routingKey} was aborted by the caller.`);
207
+ }
208
+ return undefined;
209
+ };
210
+
211
+ let assistant: AssistantMessage;
212
+ try {
213
+ assistant = await deps.complete(model, context, options);
214
+ } catch (error) {
215
+ return abortRefusal() ?? refuse(INPROCESS_REVIEWER_FAILURE.PROVIDER_FAILED, `Reviewer completion failed for ${request.routingKey}: ${sanitizeErrorExcerpt(error)}`);
216
+ }
217
+
218
+ const resolvedAbort = abortRefusal();
219
+ if (resolvedAbort !== undefined) return resolvedAbort;
220
+ if (assistant.stopReason === "aborted") {
221
+ // No signal of ours fired, so the provider cut the completion on its
222
+ // own: that is a provider failure, and its partial text is not a review.
223
+ return refuse(
224
+ INPROCESS_REVIEWER_FAILURE.PROVIDER_FAILED,
225
+ `Reviewer completion failed for ${request.routingKey}: the provider reported an aborted completion (${assistant.errorMessage ?? "no provider message"}).`,
226
+ );
227
+ }
228
+
229
+ if (assistant.stopReason === "error") {
230
+ return refuse(
231
+ INPROCESS_REVIEWER_FAILURE.PROVIDER_FAILED,
232
+ `Reviewer completion failed for ${request.routingKey}: ${assistant.errorMessage ?? "unknown provider error"}`,
233
+ );
234
+ }
235
+ if (assistant.content.some((part) => part.type === "toolCall")) {
236
+ return refuse(
237
+ INPROCESS_REVIEWER_FAILURE.TOOL_CALL_ATTEMPTED,
238
+ `Reviewer attempted a tool call for ${request.routingKey}; the in-process reviewer completion must answer in text only.`,
239
+ );
240
+ }
241
+
242
+ const text = assistant.content.filter(isTextContent).map((part) => part.text).join("");
243
+ if (text.length === 0) {
244
+ return refuse(
245
+ INPROCESS_REVIEWER_FAILURE.EMPTY_OUTPUT,
246
+ `Reviewer produced no text for ${request.routingKey} (stopReason: ${assistant.stopReason}).`,
247
+ { stopReason: assistant.stopReason, ...(assistant.errorMessage === undefined ? {} : { errorMessage: assistant.errorMessage }) },
248
+ );
249
+ }
250
+
251
+ const textBytes = Buffer.byteLength(text, "utf8");
252
+ if (textBytes > INPROCESS_REVIEWER_OUTPUT_MAX_BYTES) {
253
+ return refuse(
254
+ INPROCESS_REVIEWER_FAILURE.OUTPUT_TOO_LARGE,
255
+ `Reviewer output for ${request.routingKey} exceeds the ${INPROCESS_REVIEWER_OUTPUT_MAX_BYTES}-byte bound (received ${textBytes} bytes).`,
256
+ );
257
+ }
258
+
259
+ return { kind: "text", text, reviewerModel: `${model.provider}/${model.id}` };
260
+ }