gentle-pi 2.6.3 → 2.7.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.
@@ -1,13 +1,14 @@
1
1
  import { CustomEditor, keyHint, type ExtensionAPI, type ExtensionContext, type KeybindingsManager } from "@earendil-works/pi-coding-agent";
2
2
  import type { EditorTheme, TUI } from "@earendil-works/pi-tui";
3
3
  import { execFile, spawnSync } from "node:child_process";
4
- import { readFile } from "node:fs/promises";
4
+ import { statSync } from "node:fs";
5
+ import { profilesFilePath, readProfilesFileResult } from "../lib/agent-profiles.ts";
5
6
  import * as os from "node:os";
6
7
  import { join } from "node:path";
7
8
  import { renderShellBar, renderShellSidebarBar, shellEnabled, type ShellBarModel, type ShellBarTheme } from "../lib/shell-bar.ts";
8
- import { CHANGE_STATUS, WorktreeChangesTracker, renderChangesWidget, type ChangedFile, type ChangesModel, type GitRunner, type LineCounter, type WorktreeChanges } from "../lib/shell-changes.ts";
9
+ import { CHANGE_STATUS, renderChangesWidget, type ChangedFile, type ChangesModel, type GitRunner, type WorktreeChanges } from "../lib/shell-changes.ts";
9
10
  import { WorktreeChangesView } from "../lib/shell-changes-view.ts";
10
- import { SessionWorktreeRegistry, SESSION_WORKTREE_CHANGED, resolveSessionWorktree, toolWorktreePath, worktreeGitEnvironment, type WorktreeResolver } from "../lib/session-worktree-registry.ts";
11
+ import { SessionWorktreeRegistry, resolveSessionWorktree, worktreeGitEnvironment, type WorktreeResolver } from "../lib/session-worktree-registry.ts";
11
12
  import { CARD_TONE, renderCard, type Card, type CardTheme } from "../lib/shell-card.ts";
12
13
  import { GentleAiDevBinaryOverrideError, resolveGentleAiDevBinaryOverride } from "../lib/gentle-ai-binary.ts";
13
14
  import { framePromptLines, PROMPT_HINT, PROMPT_STATE, withPromptHint, type PromptState } from "../lib/shell-prompt.ts";
@@ -15,6 +16,8 @@ import { accountIdFromToken, CODEX_PROVIDER, CODEX_USAGE_URL, parseCodexUsage, p
15
16
  import { UsageView } from "../lib/shell-usage-view.ts";
16
17
  import { sidebarPart } from "../lib/shell-sidebar.ts";
17
18
  import { installSidebar, invalidateSidebar } from "../lib/shell-sidebar-layout.ts";
19
+ import { SessionChanges, SESSION_CHANGE_EVENT } from "../lib/session-changes.ts";
20
+ import { installSessionChangeCapture } from "../lib/session-change-capture.ts";
18
21
 
19
22
  // Gentle Shell: the visual layer gentle-pi puts on top of pi. It installs the
20
23
  // status bar, the petal prompt, the working-tree changes widget and overlay,
@@ -39,6 +42,7 @@ interface ShellBarComponent {
39
42
  }
40
43
 
41
44
  interface BuildOptions {
45
+ profile?: string;
42
46
  home?: string;
43
47
  dirty?: number;
44
48
  usage?: ProviderUsage;
@@ -47,6 +51,7 @@ interface BuildOptions {
47
51
  export type DevBinaryNotice = { state: "active"; path: string; sha256: string } | { state: "invalid"; reason: string };
48
52
 
49
53
  export interface ShellDeps {
54
+ activeProfile(): string | undefined;
50
55
  fetch: typeof fetch;
51
56
  now(): number;
52
57
  devBinary(): DevBinaryNotice | undefined;
@@ -54,6 +59,31 @@ export interface ShellDeps {
54
59
  gitRunner(cwd: string): GitRunner;
55
60
  }
56
61
 
62
+ // The rail digest runs every frame. Cache parsing by file identity and metadata,
63
+ // not just mtime: profile writes replace the store atomically. Keep the cache
64
+ // local to this shell instance and recheck on the next frame after panel edits.
65
+ export function createActiveProfileReader(env: NodeJS.ProcessEnv = process.env): () => string | undefined {
66
+ const path = profilesFilePath(env.GENTLE_PI_CONFIG_HOME ?? join(os.homedir(), ".pi", "gentle-ai"));
67
+ let fingerprint: string | undefined;
68
+ let name: string | undefined;
69
+ return () => {
70
+ try {
71
+ const stat = statSync(path, { bigint: true });
72
+ const next = `${stat.dev}:${stat.ino}:${stat.size}:${stat.mtimeNs}:${stat.ctimeNs}`;
73
+ if (next !== fingerprint) {
74
+ const result = readProfilesFileResult(path);
75
+ name = result.status === "valid" ? result.file.active : undefined;
76
+ fingerprint = next;
77
+ }
78
+ return name;
79
+ } catch {
80
+ fingerprint = undefined;
81
+ name = undefined;
82
+ return undefined;
83
+ }
84
+ };
85
+ }
86
+
57
87
  function ambientDevBinary(): DevBinaryNotice | undefined {
58
88
  try {
59
89
  const override = resolveGentleAiDevBinaryOverride();
@@ -64,7 +94,7 @@ function ambientDevBinary(): DevBinaryNotice | undefined {
64
94
  }
65
95
  }
66
96
 
67
- const defaultShellDeps: ShellDeps = { fetch: (...args) => globalThis.fetch(...args), now: () => Date.now(), devBinary: ambientDevBinary, resolveWorktree: resolveSessionWorktree, gitRunner: shellGitRunner };
97
+ const defaultShellDeps: Omit<ShellDeps, "activeProfile"> = { fetch: (...args) => globalThis.fetch(...args), now: () => Date.now(), devBinary: ambientDevBinary, resolveWorktree: resolveSessionWorktree, gitRunner: shellGitRunner };
68
98
 
69
99
  interface AssistantUsageEntry {
70
100
  type: string;
@@ -104,6 +134,7 @@ export function buildShellBarModel(
104
134
  .map(([, text]) => text);
105
135
  return {
106
136
  cwd: shortenHome(ctx.sessionManager.getCwd(), home),
137
+ profile: options.profile,
107
138
  branch: footerData.getGitBranch(),
108
139
  dirty: options.dirty,
109
140
  sessionName: ctx.sessionManager.getSessionName(),
@@ -221,7 +252,6 @@ const CHANGES_WIDGET_KEY = "gentle-shell-changes";
221
252
  const CHANGES_COMMAND_NAME = "gentle:changes";
222
253
  const CHANGES_SHORTCUT_DEFAULT = "alt+g";
223
254
  const CHANGES_POLL_DEFAULT_MS = 2000;
224
- const CHANGES_WATCH_DEFAULT_MS = 5000;
225
255
  const GIT_TIMEOUT_MS = 5000;
226
256
  const OVERLAY_HEIGHT_RATIO = 0.8;
227
257
  const OVERLAY_MIN_ROWS = 8;
@@ -246,14 +276,6 @@ export function shellGitRunner(cwd: string, env: NodeJS.ProcessEnv = process.env
246
276
  });
247
277
  }
248
278
 
249
- function lineCounter(cwd: string): LineCounter {
250
- return async (path: string) => {
251
- const text = await readFile(join(cwd, path), "utf8");
252
- if (text.length === 0) return 0;
253
- return text.split("\n").length - (text.endsWith("\n") ? 1 : 0);
254
- };
255
- }
256
-
257
279
  export async function loadFileDiff(git: GitRunner, file: ChangedFile): Promise<string> {
258
280
  const args = file.status === CHANGE_STATUS.UNTRACKED ? ["diff", "--no-index", "--", "/dev/null", file.path] : ["diff", "HEAD", "--", file.path];
259
281
  const result = await git(args);
@@ -295,28 +317,19 @@ function changesPollMs(env: NodeJS.ProcessEnv): number {
295
317
  return positiveMs(env.GENTLE_PI_SHELL_CHANGES_POLL_MS, CHANGES_POLL_DEFAULT_MS);
296
318
  }
297
319
 
298
- // Background watch: the widget and the bar follow edits made outside pi.
299
- // 0 or "off" disables it; tool events still refresh.
300
- function changesWatchMs(env: NodeJS.ProcessEnv): number | undefined {
301
- const value = env.GENTLE_PI_SHELL_CHANGES_WATCH_MS?.trim().toLowerCase();
302
- if (value === "0" || value === "off") return undefined;
303
- return positiveMs(value, CHANGES_WATCH_DEFAULT_MS);
304
- }
305
-
306
320
  function changesFingerprint(model: ChangesModel): string {
307
- return model.files.map((file) => `${file.path}:${file.status}:${file.added}:${file.deleted}`).join("|");
321
+ return model.files.map((file) => `${file.path}:${file.status}:${file.added}:${file.deleted}:${file.diffRevision ?? ""}:${file.countsUnavailable ?? ""}`).join("|");
308
322
  }
309
323
 
310
324
  interface OverlayDeps {
311
- git(root: string): GitRunner;
325
+ loadDiff(root: string, file: ChangedFile): string;
312
326
  worktrees(): WorktreeChanges[];
313
327
  refresh(): Promise<ChangesModel>;
314
328
  apply(ctx: ExtensionContext, model: ChangesModel): void;
315
329
  pollMs: number;
316
330
  }
317
331
 
318
- // While the overlay is open, git is polled so edits made outside pi (nvim,
319
- // another agent, a git checkout) show up without reopening it.
332
+ // Refresh only the captured session model. Never read live files or Git here.
320
333
  async function showChangesOverlay(ctx: ExtensionContext, deps: OverlayDeps): Promise<void> {
321
334
  let host: ExternalEditorHost | undefined;
322
335
  let view: WorktreeChangesView | undefined;
@@ -334,7 +347,7 @@ async function showChangesOverlay(ctx: ExtensionContext, deps: OverlayDeps): Pro
334
347
  view = new WorktreeChangesView(deps.worktrees(), {
335
348
  theme,
336
349
  rows: () => Math.max(OVERLAY_MIN_ROWS, Math.floor(tui.terminal.rows * OVERLAY_HEIGHT_RATIO)),
337
- loadDiff: (root, file) => loadFileDiff(deps.git(root), file),
350
+ loadDiff: (root, file) => Promise.resolve(deps.loadDiff(root, file)),
338
351
  onOpen: (root, file) => done({ root, file }),
339
352
  onRefresh: () => void refresh(),
340
353
  onClose: () => done(null),
@@ -451,8 +464,9 @@ export async function fetchCodexUsage(token: string | undefined, fetchFn: typeof
451
464
  }
452
465
 
453
466
  export default function gentleShell(pi: ExtensionAPI, env: NodeJS.ProcessEnv = process.env, overrides: Partial<ShellDeps> = {}): void {
467
+ installSessionChangeCapture(pi, env, overrides.resolveWorktree ?? resolveSessionWorktree);
454
468
  if (!shellEnabled(env)) return;
455
- const deps: ShellDeps = { ...defaultShellDeps, ...overrides };
469
+ const deps: ShellDeps = { ...defaultShellDeps, activeProfile: createActiveProfileReader(env), ...overrides };
456
470
  const usage = new UsageStore();
457
471
  let renderHost: ShellRenderHost | undefined;
458
472
  let usageFetchedAt = 0;
@@ -500,11 +514,9 @@ export default function gentleShell(pi: ExtensionAPI, env: NodeJS.ProcessEnv = p
500
514
  },
501
515
  });
502
516
  let prompt: GentlePromptEditor | undefined;
503
- let changes: WorktreeChangesTracker | undefined;
517
+ let changes: SessionChanges | undefined;
504
518
  let registry: SessionWorktreeRegistry | undefined;
505
519
  let currentContext: ExtensionContext | undefined;
506
- const pendingTools = new Map<string, { sessionId: string; path?: string }>();
507
- let watch: NodeJS.Timeout | undefined;
508
520
  let shown = "";
509
521
  const applyChanges = (ctx: ExtensionContext, model: ChangesModel) => {
510
522
  const fingerprint = changesFingerprint(model);
@@ -515,22 +527,20 @@ export default function gentleShell(pi: ExtensionAPI, env: NodeJS.ProcessEnv = p
515
527
  const refreshChanges = async (ctx: ExtensionContext) => {
516
528
  const tracker = changes;
517
529
  if (!tracker || !ctx.hasUI || registry?.sessionId !== ctx.sessionManager.getSessionId()) return;
530
+ tracker.restore(ctx.sessionManager.getEntries());
518
531
  const model = await tracker.refresh();
519
532
  if (changes === tracker) applyChanges(ctx, model);
520
533
  };
521
- const stopWatch = () => {
522
- if (watch) clearInterval(watch);
523
- watch = undefined;
524
- };
525
- const unsubscribeWorktrees = pi.events.on(SESSION_WORKTREE_CHANGED, (data) => {
534
+ const unsubscribeWorktrees = pi.events.on(SESSION_CHANGE_EVENT, (data) => {
526
535
  if (!currentContext || !registry || (data as { sessionId?: string } | undefined)?.sessionId !== registry.sessionId) return;
536
+ if ((data as { notice?: string }).notice) currentContext.ui.notify((data as { notice: string }).notice, "warning");
527
537
  void refreshChanges(currentContext);
528
538
  });
529
539
  pi.registerTool({
530
540
  name: "session_worktree_register",
531
541
  renderShell: "self",
532
542
  label: "Register session worktree",
533
- description: "Register a worktree used by this session, including earlier work or opaque shell use. Only the same Git clone is accepted. Shows ALL dirty files in that root, including preexisting and untracked files; never infers roots from shell commands or prose.",
543
+ description: "Register a worktree in the same Git clone for session coordination. Registration does not attribute file changes; Changes shows captured write/edit operations only.",
534
544
  parameters: { type: "object", required: ["path"], additionalProperties: false, properties: { path: { type: "string", description: "Worktree path to include in this session." } } } as never,
535
545
  async execute(_id, params, _signal, _onUpdate, ctx) {
536
546
  if (!registry || registry.sessionId !== ctx.sessionManager.getSessionId()) throw new Error("No active session worktree registry.");
@@ -540,16 +550,13 @@ export default function gentleShell(pi: ExtensionAPI, env: NodeJS.ProcessEnv = p
540
550
  },
541
551
  });
542
552
  pi.on("session_start", async (_event, ctx) => {
543
- stopWatch();
544
553
  registry?.close();
545
- pendingTools.clear();
546
554
  currentContext = ctx;
547
555
  changes = undefined;
548
556
  registry = new SessionWorktreeRegistry(pi, ctx.sessionManager, ctx.cwd, deps.resolveWorktree);
549
557
  registry.start();
550
- const sessionRegistry = registry;
551
558
  if (!ctx.hasUI) return;
552
- changes = new WorktreeChangesTracker(deps.gitRunner(ctx.cwd), deps.gitRunner, lineCounter, () => sessionRegistry.roots());
559
+ changes = new SessionChanges(ctx.sessionManager.getSessionId(), ctx.sessionManager.getEntries());
553
560
  const tracker = changes;
554
561
  ctx.ui.setFooter((tui, theme, footerData) => {
555
562
  renderHost = { requestRender: () => tui.requestRender(), invalidateSidebar: () => invalidateSidebar(tui) };
@@ -558,7 +565,7 @@ export default function gentleShell(pi: ExtensionAPI, env: NodeJS.ProcessEnv = p
558
565
  // part for: model, effort, context, cost, session name and extension
559
566
  // statuses. The digest is what keeps the fullscreen memo honest, and it
560
567
  // rebuilds the model exactly as the narrow bottom bar does every frame.
561
- const footerModel = () => buildShellBarModel(pi, ctx, footerData, { dirty: tracker.model.files.length, usage: usage.get(ctx.model?.provider ?? "") });
568
+ const footerModel = () => buildShellBarModel(pi, ctx, footerData, { dirty: tracker.model.files.length, usage: usage.get(ctx.model?.provider ?? ""), profile: deps.activeProfile() });
562
569
  const part = sidebarPart(tui, "footer", bottom, {
563
570
  digest: () => JSON.stringify(footerModel()),
564
571
  render: (width) => renderShellSidebarBar(footerModel(), theme, width),
@@ -580,24 +587,15 @@ export default function gentleShell(pi: ExtensionAPI, env: NodeJS.ProcessEnv = p
580
587
  ? (_tui, theme) => spaced(cardComponent(devBinaryCard(notice), theme, { expanded: true }))
581
588
  : undefined,
582
589
  );
583
- await tracker.start();
584
590
  if (changes !== tracker) return;
585
591
  shown = "";
586
592
  applyChanges(ctx, tracker.model);
587
- stopWatch();
588
- const watchMs = changesWatchMs(env);
589
- if (watchMs) {
590
- watch = setInterval(() => void refreshChanges(ctx), watchMs);
591
- watch.unref();
592
- }
593
593
  });
594
594
  pi.on("session_shutdown", () => {
595
- stopWatch();
596
595
  registry?.close();
597
596
  registry = undefined;
598
597
  changes = undefined;
599
598
  currentContext = undefined;
600
- pendingTools.clear();
601
599
  unsubscribeWorktrees();
602
600
  });
603
601
  const openChanges = async (ctx: ExtensionContext) => {
@@ -605,19 +603,19 @@ export default function gentleShell(pi: ExtensionAPI, env: NodeJS.ProcessEnv = p
605
603
  const tracker = changes;
606
604
  const model = await tracker.refresh();
607
605
  if (model.files.length === 0) {
608
- ctx.ui.notify("No changes in the working tree.", "info");
606
+ ctx.ui.notify("No captured agent changes. Only successful write/edit operations from this session and its subagents are shown; shell changes are not attributed.", "info");
609
607
  return;
610
608
  }
611
- await showChangesOverlay(ctx, { git: deps.gitRunner, worktrees: () => tracker.worktrees, refresh: () => tracker.refresh(), apply: applyChanges, pollMs: changesPollMs(env) });
609
+ await showChangesOverlay(ctx, { loadDiff: (root, file) => tracker.loadDiff(root, file), worktrees: () => tracker.worktrees, refresh: () => tracker.refresh(), apply: applyChanges, pollMs: changesPollMs(env) });
612
610
  };
613
611
  pi.registerCommand(CHANGES_COMMAND_NAME, {
614
- description: "Browse this session's registered dirty worktrees and their changes against HEAD. Press o on a file to open $EDITOR.",
612
+ description: "Browse captured write/edit changes from this agent session and its subagents, excluding preexisting and external edits. Shell changes are not attributed. Press o to open $EDITOR.",
615
613
  handler: async (_args, ctx) => openChanges(ctx),
616
614
  });
617
615
  const shortcut = changesShortcut(env);
618
616
  if (shortcut) {
619
617
  pi.registerShortcut(shortcut as Parameters<ExtensionAPI["registerShortcut"]>[0], {
620
- description: "Open the working tree changes",
618
+ description: "Open captured agent session changes",
621
619
  handler: async (ctx) => openChanges(ctx),
622
620
  });
623
621
  }
@@ -631,20 +629,4 @@ export default function gentleShell(pi: ExtensionAPI, env: NodeJS.ProcessEnv = p
631
629
  await refreshChanges(ctx);
632
630
  void refreshUsage(ctx, false);
633
631
  });
634
- pi.on("tool_execution_start", (event, ctx) => {
635
- pendingTools.set(event.toolCallId, { sessionId: ctx.sessionManager.getSessionId() });
636
- });
637
- pi.on("tool_result", (event, ctx) => {
638
- const pending = pendingTools.get(event.toolCallId);
639
- if (pending?.sessionId === ctx.sessionManager.getSessionId()) pending.path = toolWorktreePath(event.toolName, event.input);
640
- });
641
- pi.on("tool_execution_end", async (event, ctx) => {
642
- const pending = pendingTools.get(event.toolCallId);
643
- pendingTools.delete(event.toolCallId);
644
- if (!event.isError && pending?.path !== undefined && pending.sessionId === registry?.sessionId && pending.sessionId === ctx.sessionManager.getSessionId()) {
645
- try { registry.register(pending.path, `tool:${event.toolName}`); }
646
- catch { /* Non-project paths and missing roots do not expand the registry. */ }
647
- }
648
- await refreshChanges(ctx);
649
- });
650
632
  }
@@ -1,4 +1,5 @@
1
1
  import { createHash } from "node:crypto";
2
+ import { isSessionChangeEvidence, type SessionChangeEvidence } from "./session-changes.ts";
2
3
  import type { Duplex, Readable, Writable } from "node:stream";
3
4
  import { stripVTControlCharacters } from "node:util";
4
5
  import { RESEARCH_SELECTION_ENV, RESEARCH_ARTIFACT_ENV, type ResearchArtifactIntent } from "./sdd-research-capabilities.ts";
@@ -100,7 +101,7 @@ export interface RunnerHooks {
100
101
  onNotification?(task: TaskRecord, message: string): boolean | void;
101
102
  onQuery?(task: TaskRecord, requestId: string, message: string): boolean | void;
102
103
  // Parent-only observation of a paired successful filesystem tool, not prose.
103
- onSuccessfulMutation?(task: TaskRecord, tool: { toolName: "write" | "edit"; toolCallId: string; path: string }): void | Promise<void>;
104
+ onSuccessfulMutation?(task: TaskRecord, tool: { toolName: "write" | "edit"; toolCallId: string; path: string; evidence?: SessionChangeEvidence }): void | Promise<void>;
104
105
  }
105
106
 
106
107
  export interface RemediationHarnessPlan { command?: string; naReason?: string }
@@ -821,7 +822,11 @@ export class AgentRunner {
821
822
  live.mutationStarts.delete(event.callId);
822
823
  const task = this.store.get(id);
823
824
  if (mutation && task && raw.isError === false && !event.isError) {
824
- try { void Promise.resolve(this.hooks.onSuccessfulMutation?.(task, mutation)).catch(() => {}); }
825
+ try {
826
+ const evidence = (raw.result as { details?: { gentleSessionChange?: unknown } } | undefined)?.details?.gentleSessionChange;
827
+ const observed = isSessionChangeEvidence(evidence) && evidence.id === mutation.toolCallId ? { ...mutation, evidence: structuredClone(evidence) } : mutation;
828
+ void Promise.resolve(this.hooks.onSuccessfulMutation?.(task, observed)).catch(() => {});
829
+ }
825
830
  catch { /* Bookkeeping failure must not rewrite a successful tool or stop the child. */ }
826
831
  }
827
832
  }
@@ -993,6 +993,15 @@ export const NATIVE_CLI_CONTRACTS = Object.freeze({
993
993
  // remain dark because neither is proven to reach the negotiated START
994
994
  // path Pi consumes.
995
995
  "2.9.0": Object.freeze({ start: true, finalize: true, validate: true, bindSdd: true, status: true, inventory: true, reclaim: true, recover: true, abandon: true, quarantineLegacy: true, reconcileAuthority: true, repairLegacyAlias: true, mode: true, riskEvidence: false, hint: false, delivery: true }),
996
+ // v2.9.1 shipped restoring compatible OpenCode review consent (#4584) and
997
+ // deriving Claude Code SDD dispatch authority from the session transcript
998
+ // (#4575, #4551). Ground-truthed by diffing contracts/review-integration/v2
999
+ // and contracts/review-provider-contract between the v2.9.0 and v2.9.1 tags
1000
+ // in the gentle-ai source tree: zero bytes changed. Neither change touches
1001
+ // the closed START/STATUS fields this row negotiates, so it repeats 2.9.0
1002
+ // exactly. riskEvidence and hint remain dark because neither is proven to
1003
+ // reach the negotiated START path Pi consumes.
1004
+ "2.9.1": Object.freeze({ start: true, finalize: true, validate: true, bindSdd: true, status: true, inventory: true, reclaim: true, recover: true, abandon: true, quarantineLegacy: true, reconcileAuthority: true, repairLegacyAlias: true, mode: true, riskEvidence: false, hint: false, delivery: true }),
996
1005
  });
997
1006
 
998
1007
  export interface NativeReviewProcessDiagnostics {
@@ -0,0 +1,87 @@
1
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { generateUnifiedPatch } from "@earendil-works/pi-coding-agent";
3
+ import { lstat, realpath } from "node:fs/promises";
4
+ import { dirname, relative, resolve } from "node:path";
5
+ import { homedir } from "node:os";
6
+ import { resolveSessionWorktree, type WorktreeResolver } from "./session-worktree-registry.ts";
7
+ import { SessionChanges, readChangeSnapshot, sameSnapshot, textSnapshot, isSessionChangeEvidence,
8
+ SESSION_CHANGE_ENTRY, SESSION_CHANGE_EVENT, SESSION_CHANGE_RELAY, type SessionChangeEvidence, type ChangeSnapshot } from "./session-changes.ts";
9
+
10
+ interface Pending { sessionId: string; inputPath: string; path: string; root: string; relativePath: string; before: ChangeSnapshot; toolName: string; evidence?: SessionChangeEvidence }
11
+ const normalized = (text: string) => text.replace(/^\uFEFF/, "").replace(/\r\n/g, "\n");
12
+ const unknown = (): ChangeSnapshot => ({ kind: "unavailable", reason: "Tool snapshots could not be verified; diff unavailable." });
13
+
14
+ /** Observe explicit write/edit outcomes, never infer ownership from Git status or shell text. */
15
+ export function installSessionChangeCapture(pi: ExtensionAPI, env: NodeJS.ProcessEnv = process.env, resolver: WorktreeResolver = resolveSessionWorktree): void {
16
+ const child = env.GENTLE_PI_AGENTS_CHILD === "1";
17
+ const pending = new Map<string, Pending>();
18
+ let current: ExtensionContext | undefined;
19
+ let store: SessionChanges | undefined;
20
+ const publish = (evidence: SessionChangeEvidence) => {
21
+ if (!current || !store || current.sessionManager.getSessionId() !== store.sessionId) return;
22
+ if (store.record(evidence)) pi.appendEntry(SESSION_CHANGE_ENTRY, { sessionId: store.sessionId, evidence });
23
+ pi.events.emit(SESSION_CHANGE_EVENT, { sessionId: store.sessionId, notice: store.notice });
24
+ };
25
+ pi.on("session_start", (_event, ctx) => {
26
+ pending.clear(); current = ctx;
27
+ store = new SessionChanges(ctx.sessionManager.getSessionId(), ctx.sessionManager.getEntries());
28
+ });
29
+ const off = pi.events.on(SESSION_CHANGE_RELAY, (value) => {
30
+ const data = value as { sessionId?: string; evidence?: unknown };
31
+ if (child || !current || data?.sessionId !== current.sessionManager.getSessionId() || !isSessionChangeEvidence(data.evidence)) return;
32
+ try {
33
+ const own = resolver(current.cwd, current.cwd);
34
+ const target = resolver(data.evidence.root, current.cwd);
35
+ if (own && target?.root === data.evidence.root && own.commonDir === target.commonDir) publish(data.evidence);
36
+ } catch { /* Observation cannot change tool outcomes. */ }
37
+ });
38
+ pi.on("tool_call", async (event, ctx) => {
39
+ const input = event.input as Record<string, unknown>;
40
+ if (!["write", "edit"].includes(event.toolName) || typeof input.path !== "string" || pending.size >= 32) return;
41
+ const sessionId = ctx.sessionManager.getSessionId();
42
+ try {
43
+ let spelling = input.path.replace(/^@/, "").replace(/[\u00a0\u2000-\u200a\u202f\u205f\u3000]/g, " ");
44
+ if (spelling === "~" || spelling.startsWith("~/")) spelling = homedir() + spelling.slice(1);
45
+ const path = resolve(ctx.cwd, spelling);
46
+ try { if ((await lstat(path)).isSymbolicLink()) return; } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") return; }
47
+ let ancestor = dirname(path);
48
+ for (let depth = 0; depth < 32; depth++) {
49
+ try { await lstat(ancestor); break; } catch { if (dirname(ancestor) === ancestor) return; ancestor = dirname(ancestor); }
50
+ }
51
+ const canonicalAncestor = await realpath(ancestor);
52
+ const canonicalPath = resolve(canonicalAncestor, relative(ancestor, path));
53
+ const own = resolver(ctx.cwd, ctx.cwd), target = resolver(canonicalAncestor, ctx.cwd);
54
+ if (!own || !target || own.commonDir !== target.commonDir) return;
55
+ const before = await readChangeSnapshot(canonicalPath);
56
+ if (ctx.sessionManager.getSessionId() !== sessionId || current?.sessionManager.getSessionId() !== sessionId) return;
57
+ pending.set(event.toolCallId, { sessionId, inputPath: input.path, path: canonicalPath, root: target.root, relativePath: relative(target.root, canonicalPath), before, toolName: event.toolName });
58
+ } catch { /* No bookkeeping error blocks an edit. */ }
59
+ });
60
+ pi.on("tool_result", async (event, ctx) => {
61
+ const item = pending.get(event.toolCallId);
62
+ if (!item || item.sessionId !== ctx.sessionManager.getSessionId() || item.toolName !== event.toolName || event.input.path !== item.inputPath || event.isError !== false) { pending.delete(event.toolCallId); return; }
63
+ try {
64
+ const after = await readChangeSnapshot(item.path);
65
+ let before = item.before;
66
+ let verified = after;
67
+ if (event.toolName === "write" && after.kind === "text" && (typeof event.input.content !== "string" || !sameSnapshot(after, textSnapshot(event.input.content)))) {
68
+ before = unknown(); verified = unknown();
69
+ }
70
+ if (event.toolName === "edit" && before.kind === "text" && after.kind === "text") {
71
+ const patch = (event.details as { patch?: unknown } | undefined)?.patch;
72
+ if (typeof event.input.path !== "string" || patch !== generateUnifiedPatch(event.input.path, normalized(before.text), normalized(after.text))) { before = unknown(); verified = unknown(); }
73
+ }
74
+ if (item.sessionId !== ctx.sessionManager.getSessionId() || pending.get(event.toolCallId) !== item) return;
75
+ item.evidence = { id: event.toolCallId, root: item.root, path: item.relativePath, before, after: verified };
76
+ // Use the existing RPC tool-result envelope, not a new IPC channel or a model message.
77
+ if (child) return { details: { ...(event.details && typeof event.details === "object" ? event.details : {}), gentleSessionChange: item.evidence } };
78
+ } catch { pending.delete(event.toolCallId); }
79
+ });
80
+ pi.on("tool_execution_end", (event, ctx) => {
81
+ const item = pending.get(event.toolCallId); pending.delete(event.toolCallId);
82
+ if (!child && event.isError === false && item?.evidence && item.sessionId === ctx.sessionManager.getSessionId()) {
83
+ try { publish(item.evidence); } catch { /* Preserve the tool's outcome. */ }
84
+ }
85
+ });
86
+ pi.on("session_shutdown", () => { pending.clear(); current = undefined; store = undefined; off(); });
87
+ }
@@ -0,0 +1,140 @@
1
+ import { constants } from "node:fs";
2
+ import { createHash } from "node:crypto";
3
+ import { open } from "node:fs/promises";
4
+ import { isAbsolute } from "node:path";
5
+ import { generateUnifiedPatch } from "@earendil-works/pi-coding-agent";
6
+ import { changesModel, type ChangedFile, type WorktreeChanges } from "./shell-changes.ts";
7
+
8
+ export const SESSION_CHANGE_ENTRY = "gentle-pi.session-change/v1";
9
+ export const SESSION_CHANGE_EVENT = "gentle-pi:session-change";
10
+ export const SESSION_CHANGE_RELAY = "gentle-pi:child-session-change";
11
+ export const MAX_CHANGE_BYTES = 64 * 1024;
12
+ const MAX_RECORDS = 256;
13
+ const MAX_SESSION_BYTES = 4 * 1024 * 1024;
14
+ const MAX_LINES = 2000;
15
+ export type ChangeSnapshot = { kind: "text"; text: string } | { kind: "absent" } | { kind: "unavailable"; reason: string };
16
+ export interface SessionChangeEvidence {
17
+ id: string;
18
+ root: string;
19
+ path: string;
20
+ before: ChangeSnapshot;
21
+ after: ChangeSnapshot;
22
+ }
23
+ type Entry = { type?: string; customType?: string; data?: unknown };
24
+ interface FileState { before: ChangeSnapshot; after: ChangeSnapshot; unavailable?: string; diff?: string }
25
+
26
+ const unavailable = (reason: string): ChangeSnapshot => ({ kind: "unavailable", reason });
27
+ export function textSnapshot(text: string): ChangeSnapshot {
28
+ if (Buffer.byteLength(text) > MAX_CHANGE_BYTES || text.split("\n", MAX_LINES + 1).length > MAX_LINES) return unavailable("File exceeds the session diff limit.");
29
+ if (text.includes("\0")) return unavailable("Binary file; line counts unavailable.");
30
+ return { kind: "text", text };
31
+ }
32
+
33
+ /** Read only the named mutation target. Never traverse, follow symlinks, or open a FIFO for blocking I/O. */
34
+ export async function readChangeSnapshot(path: string): Promise<ChangeSnapshot> {
35
+ let file: Awaited<ReturnType<typeof open>> | undefined;
36
+ try {
37
+ file = await open(path, constants.O_RDONLY | constants.O_NONBLOCK | (constants.O_NOFOLLOW ?? 0));
38
+ const stat = await file.stat();
39
+ if (!stat.isFile() || stat.size > MAX_CHANGE_BYTES) return unavailable("Nonregular or large file; diff unavailable.");
40
+ const buffer = Buffer.alloc(MAX_CHANGE_BYTES + 1);
41
+ const { bytesRead } = await file.read(buffer, 0, buffer.length, 0);
42
+ if (bytesRead !== stat.size) return unavailable("File changed while its snapshot was being read.");
43
+ return textSnapshot(new TextDecoder("utf-8", { fatal: true }).decode(buffer.subarray(0, bytesRead)));
44
+ } catch (error) {
45
+ return (error as NodeJS.ErrnoException).code === "ENOENT" ? { kind: "absent" } : unavailable("Snapshot unavailable.");
46
+ } finally { await file?.close().catch(() => {}); }
47
+ }
48
+
49
+ function validSnapshot(value: unknown): value is ChangeSnapshot {
50
+ if (!value || typeof value !== "object") return false;
51
+ const v = value as ChangeSnapshot;
52
+ return v.kind === "absent" || (v.kind === "text" && typeof v.text === "string" && textSnapshot(v.text).kind === "text") ||
53
+ (v.kind === "unavailable" && typeof v.reason === "string" && v.reason.length <= 200);
54
+ }
55
+ export function isSessionChangeEvidence(value: unknown): value is SessionChangeEvidence {
56
+ if (!value || typeof value !== "object") return false;
57
+ const v = value as SessionChangeEvidence;
58
+ return typeof v.id === "string" && v.id.length > 0 && v.id.length <= 256 &&
59
+ typeof v.root === "string" && isAbsolute(v.root) && v.root.length <= 4096 &&
60
+ typeof v.path === "string" && v.path.length > 0 && v.path.length <= 4096 &&
61
+ !isAbsolute(v.path) && !/[\0\r\n]/.test(v.root + v.path) &&
62
+ !v.path.split(/[\\/]/).some(part => part === ".." || part === ".git") &&
63
+ validSnapshot(v.before) && validSnapshot(v.after);
64
+ }
65
+ export const sameSnapshot = (a: ChangeSnapshot, b: ChangeSnapshot): boolean =>
66
+ a.kind === b.kind && (a.kind === "absent" || (a.kind === "text" && b.kind === "text" && a.text === b.text));
67
+ const snapshotText = (value: ChangeSnapshot) => value.kind === "text" ? value.text : "";
68
+
69
+ export class SessionChanges {
70
+ private readonly seen = new Set<string>();
71
+ private readonly files = new Map<string, Map<string, FileState>>();
72
+ private bytes = 0;
73
+ notice: string | undefined;
74
+ readonly sessionId: string;
75
+ private cached: WorktreeChanges[] | undefined;
76
+ constructor(sessionId: string, entries: readonly Entry[] = []) { this.sessionId = sessionId; this.restore(entries); }
77
+
78
+ restore(entries: readonly Entry[]): void {
79
+ for (const entry of entries) {
80
+ if (entry.type !== "custom" || entry.customType !== SESSION_CHANGE_ENTRY) continue;
81
+ const data = entry.data as { sessionId?: string; evidence?: unknown } | undefined;
82
+ if (data?.sessionId === this.sessionId && isSessionChangeEvidence(data.evidence)) this.record(data.evidence);
83
+ }
84
+ }
85
+ record(evidence: SessionChangeEvidence): boolean {
86
+ if (!isSessionChangeEvidence(evidence) || this.seen.has(evidence.id)) return false;
87
+ const bytes = Buffer.byteLength(JSON.stringify(evidence));
88
+ if (this.seen.size >= MAX_RECORDS || this.bytes + bytes > MAX_SESSION_BYTES) {
89
+ this.notice = "Session change capture limit reached; additional changes are not displayed.";
90
+ return false;
91
+ }
92
+ this.seen.add(evidence.id);
93
+ this.bytes += bytes;
94
+ if (sameSnapshot(evidence.before, evidence.after)) return false;
95
+ let files = this.files.get(evidence.root);
96
+ if (!files) this.files.set(evidence.root, files = new Map());
97
+ const previous = files.get(evidence.path);
98
+ const state: FileState = { before: previous?.before ?? structuredClone(evidence.before), after: structuredClone(evidence.after) };
99
+ state.unavailable = previous?.unavailable;
100
+ if (previous && !sameSnapshot(previous.after, evidence.before)) state.unavailable = "Snapshot continuity lost (external or unobserved edit); session diff unavailable.";
101
+ if (evidence.before.kind === "unavailable") state.unavailable ??= evidence.before.reason;
102
+ if (evidence.after.kind === "unavailable") state.unavailable ??= evidence.after.reason;
103
+ // Keep the final state even after an own revert, to detect later external edits.
104
+ this.patch(evidence.path, state);
105
+ files.set(evidence.path, state);
106
+ this.cached = undefined;
107
+ return true;
108
+ }
109
+ private changed(root: string): ChangedFile[] {
110
+ return [...(this.files.get(root) ?? [])].flatMap(([path, state]) => {
111
+ if (!state.unavailable && sameSnapshot(state.before, state.after)) return [];
112
+ const patch = this.patch(path, state);
113
+ const patchLines = patch.split("\n");
114
+ const firstHunk = patchLines.findIndex(line => line.startsWith("@@"));
115
+ const lines = firstHunk < 0 ? [] : patchLines.slice(firstHunk + 1);
116
+ return [{
117
+ path, diffRevision: createHash("sha256").update(patch).digest("hex"), status: state.before.kind === "absent" ? "added" as const : state.after.kind === "absent" ? "deleted" as const : "modified" as const,
118
+ added: state.unavailable ? 0 : lines.filter(line => line.startsWith("+")).length,
119
+ deleted: state.unavailable ? 0 : lines.filter(line => line.startsWith("-")).length,
120
+ ...(state.unavailable ? { countsUnavailable: state.unavailable } : {}),
121
+ }];
122
+ });
123
+ }
124
+ private patch(path: string, state: FileState): string {
125
+ if (state.unavailable) return state.unavailable;
126
+ return state.diff ??= generateUnifiedPatch(path, snapshotText(state.before), snapshotText(state.after));
127
+ }
128
+ get worktrees(): WorktreeChanges[] {
129
+ return this.cached ??= [...this.files.keys()].map(root => ({ root, model: changesModel(this.changed(root)) })).filter(tree => tree.model.files.length > 0);
130
+ }
131
+ get model() {
132
+ const trees = this.worktrees;
133
+ return changesModel(trees.flatMap(tree => tree.model.files.map(file => ({ ...file, path: trees.length === 1 ? file.path : tree.root + "/" + file.path }))));
134
+ }
135
+ async refresh() { return this.model; }
136
+ loadDiff(root: string, file: ChangedFile): string {
137
+ const state = this.files.get(root)?.get(file.path);
138
+ return state ? this.patch(file.path, state) : "No captured agent diff for this file.";
139
+ }
140
+ }
package/lib/shell-bar.ts CHANGED
@@ -11,6 +11,7 @@ export { gaugeTone, renderGauge, type GaugeTone };
11
11
  // verified without a live TUI.
12
12
 
13
13
  export interface ShellBarModel {
14
+ profile?: string;
14
15
  cwd: string;
15
16
  branch: string | null;
16
17
  dirty: number | undefined;
@@ -138,7 +139,11 @@ export function renderShellSidebarBar(model: ShellBarModel, theme: ShellBarTheme
138
139
  },
139
140
  {
140
141
  title: "Model",
141
- lines: [value(model.modelId), ...(model.effort ? [`${label("Effort")} ${theme.fg(ROLE.EFFORT, model.effort)}`] : [])],
142
+ lines: [
143
+ value(model.modelId),
144
+ ...(model.effort ? [`${label("Effort")} ${theme.fg(ROLE.EFFORT, model.effort)}`] : []),
145
+ ...(model.profile ? [`${label("Profile")} ${value(sanitizeStatus(model.profile))}`] : []),
146
+ ],
142
147
  },
143
148
  {
144
149
  title: "Context",
@@ -74,6 +74,7 @@ const FILE_STATUS = {
74
74
  } as const;
75
75
 
76
76
  function fileCounts(file: ChangedFile, theme: ChangesViewTheme): string {
77
+ if (file.countsUnavailable) return theme.fg("dim", "counts unavailable");
77
78
  return `${theme.fg(ROLE.ADDED, `+${file.added}`)} ${theme.fg(ROLE.REMOVED, `-${file.deleted}`)}`;
78
79
  }
79
80
 
@@ -126,7 +127,7 @@ function displayText(text: string): string {
126
127
  }
127
128
 
128
129
  function fingerprint(file: ChangedFile): string {
129
- return `${file.status}:${file.added}:${file.deleted}`;
130
+ return `${file.status}:${file.added}:${file.deleted}:${file.diffRevision ?? ""}:${file.countsUnavailable ?? ""}`;
130
131
  }
131
132
 
132
133
  export interface WorktreeChangesViewDeps {
@@ -19,6 +19,8 @@ export interface ChangedFile {
19
19
  added: number;
20
20
  deleted: number;
21
21
  status: ChangeStatus;
22
+ countsUnavailable?: string;
23
+ diffRevision?: string;
22
24
  }
23
25
 
24
26
  export interface ChangesModel {
@@ -115,7 +117,7 @@ export function changesModel(changed: ChangedFile[]): ChangesModel {
115
117
 
116
118
  export function changesSummary(model: ChangesModel): string {
117
119
  const noun = model.files.length === 1 ? "file" : "files";
118
- return `${model.files.length} ${noun} · +${model.added} −${model.deleted}`;
120
+ return `${model.files.length} ${noun} · +${model.added} −${model.deleted}${model.files.some(file => file.countsUnavailable) ? " · partial counts" : ""}`;
119
121
  }
120
122
 
121
123
  // One line: summary, the files joined by dots, and the command pushed to
@@ -124,7 +126,7 @@ export function renderChangesWidget(model: ChangesModel, theme: ChangesTheme, wi
124
126
  if (model.files.length === 0) return [];
125
127
  const noun = model.files.length === 1 ? "file" : "files";
126
128
  const dot = theme.fg("muted", "·");
127
- const head = `${theme.fg("accent", WIDGET_GLYPH)} ${theme.fg("text", `${model.files.length} ${noun}`)} ${dot} ${theme.fg("success", `+${model.added}`)} ${theme.fg("error", `−${model.deleted}`)}`;
129
+ const head = `${theme.fg("accent", WIDGET_GLYPH)} ${theme.fg("text", `${model.files.length} ${noun}`)} ${dot} ${theme.fg("success", `+${model.added}`)} ${theme.fg("error", `−${model.deleted}`)}${model.files.some(file => file.countsUnavailable) ? " · partial counts" : ""}`;
128
130
  const hint = theme.fg("dim", CHANGES_COMMAND);
129
131
  const list = model.files.map((file) => theme.fg("muted", file.path)).join(` ${dot} `);
130
132
  const left = `${head} ${dot} ${list}`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gentle-pi",
3
- "version": "2.6.3",
3
+ "version": "2.7.0",
4
4
  "description": "Turn Pi into el Gentleman: a senior-architect development harness with SDD/OpenSpec, subagents, strict TDD evidence, review guardrails, and skill discovery.",
5
5
  "license": "MIT",
6
6
  "type": "module",