gentle-pi 2.6.4 → 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,12 +1,12 @@
1
1
  import assert from "node:assert/strict";
2
2
  import { execFileSync, execFile } from "node:child_process";
3
- import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
3
+ import { mkdirSync, mkdtempSync, realpathSync, renameSync, rmSync, writeFileSync } from "node:fs";
4
4
  import { tmpdir } from "node:os";
5
5
  import { join } from "node:path";
6
6
  import test from "node:test";
7
7
  import { initTheme, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
8
8
  import type { TUI } from "@earendil-works/pi-tui";
9
- import installGentleShell, { buildShellBarModel, changesShortcut, devBinaryCard, fetchCodexUsage, loadFileDiff, shellGitRunner, openInExternalEditor, type GentlePromptEditor } from "../extensions/gentle-shell.ts";
9
+ import installGentleShell, { buildShellBarModel, createActiveProfileReader, changesShortcut, devBinaryCard, fetchCodexUsage, loadFileDiff, shellGitRunner, openInExternalEditor, type GentlePromptEditor } from "../extensions/gentle-shell.ts";
10
10
  import { CHANGE_STATUS } from "../lib/shell-changes.ts";
11
11
  import { sidebarState, type SidebarRail } from "../lib/shell-sidebar.ts";
12
12
  import type { ShellBarTheme } from "../lib/shell-bar.ts";
@@ -232,7 +232,8 @@ test("gentleShell installs the footer on session_start when a UI exists", () =>
232
232
 
233
233
  test("the fullscreen Status rail carries a live digest so a model switch refreshes it", async () => {
234
234
  const { pi, handlers } = fakePi();
235
- gentleShell(pi, { GENTLE_PI_SHELL_CHANGES_WATCH_MS: "off" });
235
+ let profile: string | undefined = "team";
236
+ gentleShell(pi, { GENTLE_PI_SHELL_CHANGES_WATCH_MS: "off" }, { activeProfile: () => profile });
236
237
  const entries: unknown[] = [];
237
238
  const { ctx, ui } = fakeContext({ entries });
238
239
  await fire(handlers, "session_start", ctx);
@@ -248,6 +249,14 @@ test("the fullscreen Status rail carries a live digest so a model switch refresh
248
249
  assert.equal(typeof rail.digest, "function", "the Status card paints live state and must declare a digest");
249
250
  assert.match(rail.render(46).join("\n"), /gpt-5\.5/);
250
251
 
252
+ assert.match(rail.render(46).join("\n"), /Profile.*team/);
253
+ const beforeProfile = live();
254
+ profile = "other";
255
+ assert.notEqual(live(), beforeProfile);
256
+ assert.match(rail.render(46).join("\n"), /Profile.*other/);
257
+ profile = undefined;
258
+ assert.doesNotMatch(rail.render(46).join("\n"), /Profile/);
259
+
251
260
  const beforeModel = live();
252
261
  (ctx.model as { id: string }).id = "gpt-5.6";
253
262
  assert.notEqual(live(), beforeModel, "/model must change the digest");
@@ -273,10 +282,44 @@ test("the fullscreen Status rail carries a live digest so a model switch refresh
273
282
  }
274
283
  });
275
284
 
285
+ test("profile reader follows store changes and rejects missing or invalid active markers", (t) => {
286
+ const root = mkdtempSync(join(tmpdir(), "shell-profile-"));
287
+ t.after(() => rmSync(root, { recursive: true, force: true }));
288
+ const path = join(root, "profiles.json");
289
+ const read = createActiveProfileReader({ GENTLE_PI_CONFIG_HOME: root });
290
+ const save = (active: string | undefined) => writeFileSync(path, JSON.stringify({
291
+ kind: "gentle-pi.agent_model_profiles", version: 1, active, profiles: { team: {}, other: {} },
292
+ }));
293
+ assert.equal(read(), undefined);
294
+ save("team");
295
+ assert.equal(read(), "team");
296
+ assert.equal(read(), "team");
297
+ save("other");
298
+ assert.equal(read(), "other");
299
+ const replacement = join(root, "replacement.json");
300
+ writeFileSync(replacement, JSON.stringify({ kind: "gentle-pi.agent_model_profiles", version: 1, active: "team", profiles: { team: {} } }));
301
+ renameSync(replacement, path);
302
+ assert.equal(read(), "team", "atomic replacement refreshes the cached profile");
303
+ const isolated = createActiveProfileReader({ GENTLE_PI_CONFIG_HOME: join(root, "other-home") });
304
+ assert.equal(isolated(), undefined);
305
+ assert.equal(read(), "team", "another shell's config home does not alter this cache");
306
+ save("missing");
307
+ assert.equal(read(), undefined);
308
+ save(undefined);
309
+ assert.equal(read(), undefined);
310
+ writeFileSync(path, "{broken");
311
+ assert.equal(read(), undefined);
312
+ save("team");
313
+ assert.equal(read(), "team");
314
+ rmSync(path);
315
+ assert.equal(read(), undefined);
316
+ });
317
+
276
318
  test("gentleShell stays out of the way without a UI or when disabled", () => {
277
319
  const disabled = fakePi();
278
320
  gentleShell(disabled.pi, { GENTLE_PI_SHELL: "0" });
279
- assert.equal(disabled.handlers.size, 0);
321
+ assert.equal(disabled.commands.size, 0);
322
+ assert.ok(disabled.handlers.has("tool_call"), "capture remains available to headless children");
280
323
 
281
324
  const headless = fakePi();
282
325
  gentleShell(headless.pi, {});
@@ -364,152 +407,87 @@ function renderFooter(ui: FakeUi): string {
364
407
  return factory(fakeTui, plainTheme, footerData).render(160)[0];
365
408
  }
366
409
 
367
- test("gentleShell shows working-tree changes in a widget below the editor and in the bar", async () => {
368
- const { pi, handlers, git } = fakePi([
369
- { numstat: "", porcelain: "" },
370
- { numstat: "10\t0\tlib/b.ts\n", porcelain: "A lib/b.ts\0" },
371
- ]);
372
- gentleShell(pi, { GENTLE_PI_SHELL_CHANGES_WATCH_MS: "off" });
373
- const { ctx, ui } = fakeContext();
374
- await fire(handlers, "session_start", ctx);
375
- assert.deepEqual(git[0].slice(0, 2), ["-C", "/repo"]);
376
- assert.equal(ui.widgets.has("gentle-shell-changes"), false);
377
- assert.doesNotMatch(renderFooter(ui), /±/);
378
-
379
- await fire(handlers, "tool_execution_end", ctx);
380
- const factory = ui.widgets.get("gentle-shell-changes") as (tui: unknown, theme: ShellBarTheme) => { render(width: number): string[] };
381
- const [line] = factory(fakeTui, plainTheme).render(120);
382
- assert.match(line, /^✎ 1 file · \+10 −0 · lib\/b\.ts +\/gentle:changes$/);
383
- assert.match(renderFooter(ui), /main ±1/);
384
- });
385
-
386
- test("gentleShell registers /gentle:changes and opens the overlay only when there are changes", async () => {
387
- const { pi, handlers, commands } = fakePi([
388
- { numstat: "", porcelain: "" },
389
- { numstat: "", porcelain: "" },
390
- { numstat: "10\t0\tlib/b.ts\n", porcelain: "A lib/b.ts\0" },
391
- ]);
392
- gentleShell(pi, {});
393
- const { ctx, ui } = fakeContext();
394
- await fire(handlers, "session_start", ctx);
395
- const command = commands.get("gentle:changes");
396
- assert.ok(command, "command not registered");
397
-
398
- await command.handler("", ctx);
399
- assert.deepEqual(ui.notices, ["No changes in the working tree."]);
400
- assert.equal(ui.overlay, undefined);
410
+ function sessionChange(ctx: ExtensionContext, id: string, root: string, path: string, before = "", after = "agent\n"): void {
411
+ const entries = ctx.sessionManager.getEntries() as any[];
412
+ entries.push({ type: "custom", customType: "gentle-pi.session-change/v1", data: {
413
+ sessionId: ctx.sessionManager.getSessionId(),
414
+ evidence: { id, root, path, before: before ? {kind:"text",text:before} : {kind:"absent"}, after:{kind:"text",text:after} },
415
+ } });
416
+ }
401
417
 
402
- await fire(handlers, "tool_execution_end", ctx);
403
- const opened = command.handler("", ctx);
404
- await new Promise((resolve) => setTimeout(resolve, 0));
405
- assert.equal(typeof ui.overlay, "function");
406
- ui.closeOverlay?.();
407
- await opened;
418
+ test("captured changes update the widget and bar without repository scans", async () => {
419
+ const { pi, handlers, git } = fakePi([{numstat:"999\t0\tforeign.ts\n",porcelain:"?? foreign.ts\0"}]);
420
+ gentleShell(pi,{});
421
+ const {ctx,ui}=fakeContext();
422
+ await fire(handlers,"session_start",ctx);
423
+ assert.equal(git.length,0);
424
+ assert.equal(ui.widgets.has("gentle-shell-changes"),false);
425
+ sessionChange(ctx,"a","/repo","lib/b.ts","","one\ntwo\n");
426
+ pi.events.emit("gentle-pi:session-change",{sessionId:ctx.sessionManager.getSessionId()});
427
+ await new Promise(resolve=>setImmediate(resolve));
428
+ const factory=ui.widgets.get("gentle-shell-changes") as any;
429
+ assert.match(factory(fakeTui,plainTheme).render(140)[0],/1 file · \+2 −0/);
430
+ assert.match(renderFooter(ui),/main ±1/);
431
+ assert.equal(git.length,0);
432
+ await fire(handlers,"session_shutdown",ctx);
408
433
  });
409
434
 
410
- test("changes overlay follows registered linked roots on open, refresh and polling and scopes diff git cwd", async () => {
411
- const { pi, handlers, commands, tools } = fakePi();
412
- let roots = ["/repo"];
413
- const diffs: string[][] = [];
414
- pi.exec = (async (_command: string, args: string[]) => {
415
- let stdout = "";
416
- if (args.includes("worktree")) stdout = roots.map((root) => `worktree ${root}\0branch refs/heads/${root.slice(1)}\0\0`).join("");
417
- else if (args.includes("status")) stdout = " M same.ts\0";
418
- else if (args.includes("--numstat")) stdout = "1\t1\tsame.ts\n";
419
- else { diffs.push(args); stdout = `+${args[1]}`; }
420
- return { stdout, stderr: "", code: 0, killed: false };
421
- }) as ExtensionAPI["exec"];
422
- gentleShell(pi, { GENTLE_PI_SHELL_CHANGES_WATCH_MS: "off", GENTLE_PI_SHELL_CHANGES_POLL_MS: "5" });
423
- const { ctx, ui } = fakeContext();
424
- await fire(handlers, "session_start", ctx);
425
- roots.push("/linked");
426
- await tools.get("session_worktree_register")!.execute("register", { path: "/linked" }, undefined, undefined, ctx);
427
- const open = commands.get("gentle:changes")!.handler("", ctx);
428
- try {
429
- await new Promise((resolve) => setTimeout(resolve, 20));
430
- assert.match(ui.overlayView!.render(120).join("\n"), /linked · linked/);
431
- assert.equal(diffs.length, 0);
432
- ui.overlayView!.handleInput("j");
433
- ui.overlayView!.handleInput("\r");
434
- ui.overlayView!.handleInput("j");
435
- await new Promise((resolve) => setTimeout(resolve, 0));
436
- assert.deepEqual(diffs[0], ["-C", "/linked", "diff", "HEAD", "--", "same.ts"]);
437
- ui.overlayView!.handleInput("\x1b[D");
438
- roots.push("/new");
439
- await tools.get("session_worktree_register")!.execute("register", { path: "/new" }, undefined, undefined, ctx);
440
- ui.overlayView!.handleInput("r");
441
- await new Promise((resolve) => setTimeout(resolve, 20));
442
- assert.match(ui.overlayView!.render(120).join("\n"), /new · new/);
443
- roots.push("/polled");
444
- await tools.get("session_worktree_register")!.execute("register", { path: "/polled" }, undefined, undefined, ctx);
445
- await new Promise((resolve) => setTimeout(resolve, 20));
446
- assert.match(ui.overlayView!.render(120).join("\n"), /polled · polled/);
447
- } finally {
448
- ui.closeOverlay?.();
449
- await open;
450
- await fire(handlers, "session_shutdown", ctx);
451
- }
435
+ test("Changes opens only for captured mutations, not registered dirty roots", async () => {
436
+ const {pi,handlers,commands,tools,git}=fakePi();
437
+ gentleShell(pi,{});
438
+ const {ctx,ui,overlayReady}=fakeContext();
439
+ await fire(handlers,"session_start",ctx);
440
+ await tools.get("session_worktree_register")!.execute("r",{path:"/linked"},undefined,undefined,ctx);
441
+ await commands.get("gentle:changes")!.handler("",ctx);
442
+ assert.match(ui.notices.join("\n"),/No captured agent changes/);
443
+ assert.equal(ui.overlay,undefined);
444
+ sessionChange(ctx,"a","/linked","file.ts");
445
+ await fire(handlers,"agent_end",ctx);
446
+ const opened=commands.get("gentle:changes")!.handler("",ctx);
447
+ await overlayReady;
448
+ assert.match(ui.overlayView!.render(140).join("\n"),/linked/);
449
+ assert.equal(git.length,0);
450
+ ui.closeOverlay?.(); await opened;
451
+ await fire(handlers,"session_shutdown",ctx);
452
452
  });
453
453
 
454
- test("successful direct tools register whole roots, failures and shell contents do not, and all UI counts agree", async () => {
455
- const { pi, handlers, tools, commands } = fakePi();
456
- const queried: string[] = [];
457
- pi.exec = (async (_command: string, args: string[]) => {
458
- const root = args[1];
459
- if (args.includes("status")) queried.push(root);
460
- const stdout = args.includes("worktree") ? ["/repo", "/used", "/failed", "/opaque", "/hidden"].map((path) => `worktree ${path}\0\0`).join("")
461
- : args.includes("status") ? " M preexisting.ts\0?? new.ts\0"
462
- : args.includes("--numstat") ? "2\t1\tpreexisting.ts\n" : "+diff";
463
- return { stdout, stderr: "", code: 0, killed: false };
464
- }) as ExtensionAPI["exec"];
465
- gentleShell(pi, { GENTLE_PI_SHELL_CHANGES_WATCH_MS: "off" });
466
- const { ctx, ui, overlayReady } = fakeContext();
467
- await fire(handlers, "session_start", ctx);
468
- const emit = async (name: string, event: unknown) => {
469
- for (const handler of handlers.get(name) ?? []) await handler(event, ctx);
470
- };
471
- const complete = async (toolName: string, input: unknown, isError = false) => {
472
- await emit("tool_execution_start", { toolCallId: "call", toolName, args: input });
473
- await emit("tool_result", { toolCallId: "call", toolName, input, isError: false });
474
- await emit("tool_execution_end", { toolCallId: "call", toolName, isError });
475
- };
476
- await complete("read", { path: "/failed" }, true);
477
- await complete("bash", { command: "cd /opaque && edit stuff", path: "/opaque" });
478
- assert.deepEqual(new Set(queried), new Set(["/repo"]));
479
- await complete("read", { path: "/used" });
480
- await complete("write", { path: "/used" });
481
- assert.deepEqual(new Set(queried), new Set(["/repo", "/used"]));
482
- assert.equal(ctx.sessionManager.getEntries().length, 2, "multiple completed tools dedupe");
483
- assert.match(renderFooter(ui), /±4/);
484
- const widget = ui.widgets.get("gentle-shell-changes") as (host: unknown, theme: unknown) => { render(width: number): string[] };
485
- assert.match(widget(fakeTui, plainTheme).render(140)[0], /4 files/);
486
- const open = commands.get("gentle:changes")!.handler("", ctx);
487
- await overlayReady;
488
- assert.doesNotMatch(ui.overlayView!.render(140).join("\n"), /hidden|opaque|failed/);
489
- ui.closeOverlay?.();
490
- await open;
491
- await tools.get("session_worktree_register")!.execute("explicit", { path: "/opaque" }, undefined, undefined, ctx);
492
- assert.match(renderFooter(ui), /±6/);
493
- await fire(handlers, "session_shutdown", ctx);
454
+ test("overlay groups captured roots and refreshes same-count diffs without HEAD or external files", async () => {
455
+ const {pi,handlers,commands,git}=fakePi();
456
+ gentleShell(pi,{GENTLE_PI_SHELL_CHANGES_POLL_MS:"5"});
457
+ const {ctx,ui,overlayReady}=fakeContext();
458
+ sessionChange(ctx,"a","/repo","same.ts","old\n","first\n");
459
+ sessionChange(ctx,"child:a","/linked","same.ts","old\n","child\n");
460
+ await fire(handlers,"session_start",ctx);
461
+ const opened=commands.get("gentle:changes")!.handler("",ctx);
462
+ await overlayReady;
463
+ try {
464
+ ui.overlayView!.handleInput("\r"); ui.overlayView!.handleInput("j");
465
+ await new Promise(resolve=>setTimeout(resolve,10));
466
+ assert.match(ui.overlayView!.render(140).join("\n"),/first/);
467
+ sessionChange(ctx,"b","/repo","same.ts","first\n","second\n");
468
+ pi.events.emit("gentle-pi:session-change",{sessionId:ctx.sessionManager.getSessionId()});
469
+ await new Promise(resolve=>setTimeout(resolve,20));
470
+ assert.match(ui.overlayView!.render(140).join("\n"),/second/);
471
+ assert.doesNotMatch(ui.overlayView!.render(140).join("\n"),/first/);
472
+ assert.equal(git.length,0);
473
+ } finally { ui.closeOverlay?.(); await opened; await fire(handlers,"session_shutdown",ctx); }
494
474
  });
495
475
 
496
- test("session switching drops pending tool and foreign launch events while same-session entries restore", async () => {
497
- const h = fakePi([{ numstat: "1\t0\ta.ts\n", porcelain: " M a.ts\0" }]);
498
- gentleShell(h.pi, { GENTLE_PI_SHELL_CHANGES_WATCH_MS: "off" });
499
- const first = fakeContext();
500
- await fire(h.handlers, "session_start", first.ctx);
501
- for (const handler of h.handlers.get("tool_execution_start") ?? []) await handler({ toolCallId: "old", toolName: "read", args: { path: "/foreign" } }, first.ctx);
502
- const next = fakeContext({ entries: [...first.ctx.sessionManager.getEntries()] });
503
- (next.ctx.sessionManager as unknown as { getSessionId(): string }).getSessionId = () => "new-session";
504
- await fire(h.handlers, "session_start", next.ctx);
505
- for (const handler of h.handlers.get("tool_result") ?? []) await handler({ toolCallId: "old", toolName: "read", input: { path: "/foreign" } }, next.ctx);
506
- for (const handler of h.handlers.get("tool_execution_end") ?? []) await handler({ toolCallId: "old", toolName: "read", isError: false }, next.ctx);
507
- const before = h.git.length;
508
- h.pi.events.emit("gentle-pi:session-worktree-changed", { sessionId: "shell-session", root: "/foreign" });
509
- assert.equal(h.git.length, before);
510
- assert.match(renderFooter(next.ui), /±1/);
511
- assert.ok(!h.git.some((args) => args[1] === "/foreign"));
512
- await fire(h.handlers, "session_shutdown", next.ctx);
476
+ test("new sessions ignore inherited captures, while reload restores the same session", async () => {
477
+ const h=fakePi(); gentleShell(h.pi,{});
478
+ const first=fakeContext();
479
+ sessionChange(first.ctx,"a","/repo","own.ts");
480
+ await fire(h.handlers,"session_start",first.ctx);
481
+ assert.match(renderFooter(first.ui),/±1/);
482
+ await fire(h.handlers,"session_start",first.ctx);
483
+ assert.match(renderFooter(first.ui),/±1/);
484
+ const next=fakeContext({entries:[...first.ctx.sessionManager.getEntries()]});
485
+ (next.ctx.sessionManager as any).getSessionId=()=>"new-session";
486
+ await fire(h.handlers,"session_start",next.ctx);
487
+ h.pi.events.emit("gentle-pi:session-change",{sessionId:"shell-session"});
488
+ assert.doesNotMatch(renderFooter(next.ui),/±1/);
489
+ assert.equal(h.git.length,0);
490
+ await fire(h.handlers,"session_shutdown",next.ctx);
513
491
  });
514
492
 
515
493
  test("registered canonical root governs real Git discovery, status and diff despite inherited routing", async (t) => {
@@ -550,10 +528,7 @@ test("registered canonical root governs real Git discovery, status and diff desp
550
528
  installGentleShell(h.pi, { GENTLE_PI_SHELL_CHANGES_WATCH_MS: "off" }, { devBinary: () => undefined, gitRunner: (cwd) => shellGitRunner(cwd, poisoned) });
551
529
  await fire(h.handlers, "session_start", ctx);
552
530
  t.after(() => fire(h.handlers, "session_shutdown", ctx));
553
- const widget = ui.widgets.get("gentle-shell-changes") as (host: unknown, theme: unknown) => { render(width: number): string[] };
554
- const rendered = widget(fakeTui, plainTheme).render(300).join("\n");
555
- assert.match(rendered, /selected-only\.txt/);
556
- assert.doesNotMatch(rendered, /foreign-only\.txt/);
531
+ assert.equal(ui.widgets.has("gentle-shell-changes"), false, "preexisting dirty files are not agent changes");
557
532
  const diff = await loadFileDiff(run, { path: "tracked.txt", added: 1, deleted: 1, status: CHANGE_STATUS.MODIFIED });
558
533
  assert.match(diff, /\+selected change/);
559
534
  assert.doesNotMatch(diff, /foreign change/);
@@ -567,12 +542,8 @@ test("registered canonical root governs real Git discovery, status and diff desp
567
542
  assert.equal(largeDiff.code, 0);
568
543
  assert.ok(largeDiff.stdout.length > 1024 * 1024, "output must not inherit execFile's default one MiB cap");
569
544
  assert.match(largeDiff.stdout, /\+selected final marker/);
570
- const open = h.commands.get("gentle:changes")!.handler("", ctx);
571
- await overlayReady;
572
- ui.overlayView!.handleInput("\r");
573
- ui.overlayView!.handleInput("j");
574
- ui.closeOverlay?.();
575
- await open;
545
+ await h.commands.get("gentle:changes")!.handler("", ctx);
546
+ assert.equal(ui.overlay, undefined);
576
547
  });
577
548
 
578
549
  test("loadFileDiff asks git for a HEAD diff, or a no-index diff for untracked files", async () => {
@@ -644,55 +615,24 @@ test("gentleShell binds the changes shortcut to the same handler as the command"
644
615
  const shortcut = shortcuts.get("alt+g");
645
616
  assert.ok(shortcut, "alt+g not registered");
646
617
  await shortcut.handler(ctx);
647
- assert.deepEqual(ui.notices, ["No changes in the working tree."]);
618
+ assert.match(ui.notices.join("\n"), /No captured agent changes/);
648
619
 
649
620
  const silent = fakePi();
650
621
  gentleShell(silent.pi, { GENTLE_PI_SHELL_CHANGES_KEY: "off" });
651
622
  assert.equal(silent.shortcuts.size, 0);
652
623
  });
653
624
 
654
- test("gentleShell keeps the open overlay in sync with git while it stays open", async () => {
655
- const { pi, handlers, commands } = fakePi([
656
- { numstat: "10\t0\tlib/b.ts\n", porcelain: "A lib/b.ts\0" },
657
- { numstat: "10\t0\tlib/b.ts\n", porcelain: "A lib/b.ts\0" },
658
- { numstat: "10\t0\tlib/b.ts\n3\t1\tlib/c.ts\n", porcelain: "A lib/b.ts\0 M lib/c.ts\0" },
659
- ]);
660
- gentleShell(pi, { GENTLE_PI_SHELL_CHANGES_POLL_MS: "5" });
661
- const { ctx, ui } = fakeContext();
662
- await fire(handlers, "session_start", ctx);
663
- const open = commands.get("gentle:changes")!.handler("", ctx);
664
- await new Promise((resolve) => setTimeout(resolve, 40));
665
- ui.overlayView!.handleInput("\r");
666
- const plain = ui.overlayView!.render(100).map(stripAnsi);
667
- assert.match(plain[2], /2 files · \+13 −1/);
668
- assert.match(plain[3], /lib\/c\.ts/);
669
- assert.match(renderFooter(ui), /main ±2/);
670
- ui.closeOverlay?.();
671
- await open;
672
- });
673
-
674
- test("gentleShell watches git in the background so the widget and bar follow external edits", async () => {
675
- const { pi, handlers, git } = fakePi([
676
- { numstat: "", porcelain: "" },
677
- { numstat: "", porcelain: "" },
678
- { numstat: "3\t1\tlib/c.ts\n", porcelain: " M lib/c.ts\0" },
679
- ]);
680
- gentleShell(pi, { GENTLE_PI_SHELL_CHANGES_WATCH_MS: "5" });
681
- const { ctx, ui } = fakeContext();
682
- await fire(handlers, "session_start", ctx);
683
- assert.equal(ui.widgets.has("gentle-shell-changes"), false);
684
- await new Promise((resolve) => setTimeout(resolve, 40));
685
- assert.equal(ui.widgets.has("gentle-shell-changes"), true);
686
- assert.match(renderFooter(ui), /main ±1/);
687
- assert.ok(git.length >= 6, "background watch should keep polling git");
688
-
689
- const widgetSetsBefore = ui.widgetSets;
690
- await new Promise((resolve) => setTimeout(resolve, 20));
691
- assert.equal(ui.widgetSets, widgetSetsBefore, "unchanged tree must not rewrite the widget");
692
- await fire(handlers, "session_shutdown", ctx);
693
- const gitCallsAfterShutdown = git.length;
694
- await new Promise((resolve) => setTimeout(resolve, 20));
695
- assert.equal(git.length, gitCallsAfterShutdown, "shutdown must stop the watch");
625
+ test("external edits do not pollute Changes or trigger background Git scans", async () => {
626
+ const {pi,handlers,git}=fakePi([{numstat:"4\t2\texternal.ts\n",porcelain:" M external.ts\0"}]);
627
+ gentleShell(pi,{GENTLE_PI_SHELL_CHANGES_WATCH_MS:"5"});
628
+ const {ctx,ui}=fakeContext();
629
+ await fire(handlers,"session_start",ctx);
630
+ await new Promise(resolve=>setTimeout(resolve,30));
631
+ await fire(handlers,"agent_end",ctx);
632
+ assert.equal(git.length,0);
633
+ assert.equal(ui.widgets.has("gentle-shell-changes"),false);
634
+ assert.doesNotMatch(renderFooter(ui),/±/);
635
+ await fire(handlers,"session_shutdown",ctx);
696
636
  });
697
637
 
698
638
  const JWT = `h.${Buffer.from(JSON.stringify({ "https://api.openai.com/auth": { chatgpt_account_id: "acct-1" } })).toString("base64url")}.s`;
@@ -232,11 +232,25 @@ test("2.9.0 repeats 2.8.2 because the negotiated lane Pi consumes is unchanged",
232
232
  assert.deepEqual(contract, NATIVE_CLI_CONTRACTS["2.8.2"] as Record<string, boolean>);
233
233
  });
234
234
 
235
+ test("2.9.1 repeats 2.9.0 because the negotiated lane Pi consumes is unchanged", () => {
236
+ // v2.9.1 shipped restoring compatible OpenCode review consent (#4584) and
237
+ // deriving Claude Code SDD dispatch authority from the session transcript
238
+ // (#4575, #4551). Diffing contracts/review-integration/v2 and
239
+ // contracts/review-provider-contract between the v2.9.0 and v2.9.1 tags
240
+ // in the gentle-ai source tree showed zero byte changes, so this row
241
+ // repeats 2.9.0. riskEvidence and hint remain dark because neither is
242
+ // proven to reach Pi's negotiated START path.
243
+ const contract = NATIVE_CLI_CONTRACTS["2.9.1"] as Record<string, boolean>;
244
+ assert.equal(contract.riskEvidence, false);
245
+ assert.equal(contract.hint, false);
246
+ assert.deepEqual(contract, NATIVE_CLI_CONTRACTS["2.9.0"] as Record<string, boolean>);
247
+ });
248
+
235
249
  test("no shipped version key was added beyond the pin bump", () => {
236
250
  // Rows are promises to consumers, so a new key only ever appears in a
237
251
  // dedicated commit alongside a pin bump, never as a side effect. v2.2.4 and
238
252
  // v2.3.0 shipped upstream while Pi stayed on 2.2.3 and were never pinned,
239
253
  // so they get no row: a row asserts ground truth measured against a binary
240
254
  // Pi actually ran, and the table only has to be ascending, not gapless.
241
- assert.deepEqual(Object.keys(NATIVE_CLI_CONTRACTS), [...DARK_VERSIONS, "2.2.0", "2.2.1", "2.2.2", "2.2.3", "2.4.0", "2.5.0-rc.3", "2.5.0", "2.6.0", "2.7.0", "2.8.0", "2.8.1", "2.8.2", "2.9.0"]);
255
+ assert.deepEqual(Object.keys(NATIVE_CLI_CONTRACTS), [...DARK_VERSIONS, "2.2.0", "2.2.1", "2.2.2", "2.2.3", "2.4.0", "2.5.0-rc.3", "2.5.0", "2.6.0", "2.7.0", "2.8.0", "2.8.1", "2.8.2", "2.9.0", "2.9.1"]);
242
256
  });
@@ -278,20 +278,20 @@ test("package manifest installs pi-pretty through a wrapper without bundling nat
278
278
  );
279
279
  });
280
280
 
281
- test("package verification binds the published Gentle AI v2.9.0 runtime pin", () => {
281
+ test("package verification binds the published Gentle AI v2.9.1 runtime pin", () => {
282
282
  const installer = readFileSync(join(PACKAGE_ROOT, "scripts", "gentle-ai-installer.mjs"), "utf8");
283
283
  const binary = readFileSync(join(PACKAGE_ROOT, "lib", "gentle-ai-binary.ts"), "utf8");
284
284
  const verifier = readFileSync(join(PACKAGE_ROOT, "scripts", "verify-package-files.mjs"), "utf8");
285
285
 
286
- assert.match(installer, /INSTALLER_VERSION = "2\.9\.0"/);
286
+ assert.match(installer, /INSTALLER_VERSION = "2\.9\.1"/);
287
287
  assert.match(installer, /GENTLE_AI_WINDOWS_SOURCE_PACKAGE.*GENTLE_AI_WINDOWS_SOURCE_MODULE/);
288
- assert.match(installer, /GENTLE_AI_WINDOWS_SOURCE_MODULE_CHECKSUM = "h1:WqmYw1UfVYCbtOxU1uTuiYq9\+ROCUIDrmKIOjyYggQI="/);
288
+ assert.match(installer, /GENTLE_AI_WINDOWS_SOURCE_MODULE_CHECKSUM = "h1:roCuxlF\+L4YOb1X0los4RpjcLfxAmwmHxteSQMcfThY="/);
289
289
  assert.match(installer, /GOTOOLCHAIN: "local"/);
290
290
  assert.match(installer, /GOSUMDB: "sum\.golang\.org"/);
291
291
  assert.match(binary, /GENTLE_AI_VERSION = INSTALLER_VERSION/);
292
292
  assert.match(binary, /GO_SUMDB_SOURCE_BUILD/);
293
293
  assert.match(binary, /GENTLE_AI_WINDOWS_SOURCE_MODULE_CHECKSUM/);
294
- assert.match(verifier, /v2\.9\.0/);
294
+ assert.match(verifier, /v2\.9\.1/);
295
295
  });
296
296
 
297
297
 
@@ -1509,9 +1509,9 @@ test("pi-pretty wrapper uses real package path resolution for pnpm symlink insta
1509
1509
  assert.match(wrapper, /quietToolsEnabled/);
1510
1510
  });
1511
1511
 
1512
- test("v2.6.4 release package and runtime stop before publication", () => {
1512
+ test("v2.7.0 release package and runtime stop before publication", () => {
1513
1513
  const packageJson = readPackageJson();
1514
- assert.equal(packageJson.version, "2.6.4", "the release manifest must remain explicitly pinned to v2.6.4");
1514
+ assert.equal(packageJson.version, "2.7.0", "the release manifest must remain explicitly pinned to v2.7.0");
1515
1515
  assert.equal(
1516
1516
  packageJson.scripts?.test,
1517
1517
  "node --experimental-strip-types --test tests/*.test.ts && pnpm run check:provider-contract && pnpm run test:harness",
@@ -0,0 +1,68 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { mkdtemp, writeFile, rm, realpath } from "node:fs/promises";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { installSessionChangeCapture } from "../lib/session-change-capture.ts";
7
+ import { SessionChanges, SESSION_CHANGE_ENTRY } from "../lib/session-changes.ts";
8
+
9
+ async function fixture(run: (f: any) => Promise<void>, child = false) {
10
+ const root = await realpath(await mkdtemp(join(tmpdir(), "change-capture-")));
11
+ const handlers = new Map<string, Function>();
12
+ const entries: any[] = [];
13
+ const listeners = new Map<string, Function>();
14
+ const pi = { on: (key, fn) => handlers.set(key, fn), appendEntry: (customType, data) => entries.push({type:"custom",customType,data}),
15
+ events: { on: (key, fn) => { listeners.set(key, fn); return () => listeners.delete(key); }, emit: (key, data) => listeners.get(key)?.(data) } };
16
+ let id = "session";
17
+ const ctx = { cwd:root, sessionManager: { getSessionId: () => id, getEntries: () => entries } };
18
+ installSessionChangeCapture(pi as never, child ? {GENTLE_PI_AGENTS_CHILD:"1"} : {}, () => ({root,commonDir:root}));
19
+ const fire = (key, event = {}) => handlers.get(key)?.(event, ctx);
20
+ try { await fire("session_start"); await run({root, entries, ctx, fire, switchSession: () => id = "other"}); }
21
+ finally { await rm(root, {recursive:true,force:true}); }
22
+ }
23
+
24
+ test("capture reads no inventory at startup and ignores read-only tools", async () => fixture(async ({fire, entries}) => {
25
+ await fire("tool_call", {toolCallId:"r",toolName:"read",input:{path:"missing"}});
26
+ await fire("tool_result", {toolCallId:"r",toolName:"read",input:{path:"missing"},isError:false});
27
+ await fire("tool_execution_end", {toolCallId:"r",toolName:"read",isError:false});
28
+ assert.deepEqual(entries, []);
29
+ }));
30
+
31
+ test("successful writes capture only the exact tool target and persist for reload", async () => fixture(async ({root,fire,entries}) => {
32
+ await writeFile(join(root,"file"),"human baseline\n");
33
+ const event = {toolCallId:"w",toolName:"write",input:{path:"file",content:"agent output\n"}};
34
+ await fire("tool_call",event);
35
+ await writeFile(join(root,"file"),event.input.content);
36
+ await fire("tool_result",{...event,isError:false});
37
+ assert.equal(entries.length,0);
38
+ await fire("tool_execution_end",{toolCallId:"w",toolName:"write",isError:false});
39
+ assert.equal(entries[0].customType,SESSION_CHANGE_ENTRY);
40
+ await writeFile(join(root,"file"),"later human output\n");
41
+ const changes = new SessionChanges("session",entries);
42
+ assert.match(changes.loadDiff(root,changes.model.files[0]),/\+agent output/);
43
+ assert.doesNotMatch(changes.loadDiff(root,changes.model.files[0]),/later human/);
44
+ }));
45
+
46
+ test("failed and stale-session tool outcomes never add session changes", async () => fixture(async ({root,fire,entries,switchSession}) => {
47
+ const event={toolCallId:"w",toolName:"write",input:{path:"new",content:"agent\n"}};
48
+ await fire("tool_call",event);
49
+ await writeFile(join(root,"new"),event.input.content);
50
+ await fire("tool_result",{...event,isError:false});
51
+ await fire("tool_execution_end",{toolCallId:"w",toolName:"write",isError:true});
52
+ assert.equal(entries.length,0);
53
+ await fire("tool_call",{...event,toolCallId:"x"});
54
+ switchSession();
55
+ await fire("tool_result",{...event,toolCallId:"x",isError:false});
56
+ await fire("tool_execution_end",{toolCallId:"x",toolName:"write",isError:false});
57
+ assert.equal(entries.length,0);
58
+ }));
59
+
60
+ test("child carries bounded evidence in the existing tool-result details transport", async () => fixture(async ({root,fire,entries}) => {
61
+ const event={toolCallId:"w",toolName:"write",input:{path:"new",content:"agent\n"}};
62
+ await fire("tool_call",event); await writeFile(join(root,"new"),event.input.content);
63
+ const result=await fire("tool_result",{...event,isError:false,details:{original:"preserved"}});
64
+ assert.equal(result.details.original,"preserved");
65
+ assert.equal(result.details.gentleSessionChange.id,"w");
66
+ assert.equal(result.details.gentleSessionChange.path,"new");
67
+ assert.deepEqual(entries,[]);
68
+ },true));
@@ -0,0 +1,38 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import shell from "../extensions/gentle-shell.ts";
4
+ import { SESSION_CHANGE_ENTRY, SESSION_CHANGE_EVENT } from "../lib/session-changes.ts";
5
+
6
+ function fixture(entries: any[] = []) {
7
+ const handlers = new Map<string, Function[]>(), listeners = new Map<string, Function[]>();
8
+ const commands = new Map<string, any>(), widgets = new Map<string, any>();
9
+ let gitCalls = 0;
10
+ const pi: any = { on: (key, fn) => handlers.set(key, [...(handlers.get(key) ?? []), fn]),
11
+ events: { on: (key, fn) => { listeners.set(key,[...(listeners.get(key) ?? []), fn]); return () => {}; }, emit: (key,data) => listeners.get(key)?.forEach(fn=>fn(data)) },
12
+ appendEntry: (customType,data) => entries.push({type:"custom",customType,data}), registerTool() {}, registerShortcut() {}, registerMessageRenderer() {},
13
+ registerCommand: (key, registration) => commands.set(key,registration) };
14
+ const notices: string[] = [];
15
+ const ctx: any = { hasUI:true, cwd:"/repo", sessionManager:{getSessionId:()=>"session",getEntries:()=>entries},
16
+ ui: { setFooter() {}, getEditorComponent:()=>({}), setWorkingVisible() {}, setWidget:(key,value)=>widgets.set(key,value), notify:(text)=>notices.push(text) } };
17
+ shell(pi,{}, {resolveWorktree:()=>({root:"/repo",commonDir:"/git"}),devBinary:()=>undefined,
18
+ gitRunner:()=>async()=>{gitCalls++; return await new Promise<any>(()=>{});} });
19
+ const fire=async(key,event={})=>{for(const fn of handlers.get(key)??[]) await fn(event,ctx);};
20
+ return {pi,ctx,entries,notices,commands,widgets,fire,gitCalls:()=>gitCalls};
21
+ }
22
+ test("Gentle Shell startup never waits for a repository scan",async()=>{
23
+ const f=fixture();
24
+ await Promise.race([f.fire("session_start"),new Promise((_,reject)=>setTimeout(()=>reject(new Error("startup blocked by Git inventory")),100))]);
25
+ assert.equal(f.gitCalls(),0);
26
+ await f.commands.get("gentle:changes").handler("",f.ctx);
27
+ assert.match(f.notices.join("\n"),/captured.*agent|agent.*changes/i);
28
+ await f.fire("session_shutdown");
29
+ });
30
+ test("reload and new evidence refresh Changes without Git or live file reads",async()=>{
31
+ const f=fixture([{type:"custom",customType:SESSION_CHANGE_ENTRY,data:{sessionId:"session",evidence:{id:"child:1",root:"/repo",path:"own.ts",before:{kind:"absent"},after:{kind:"text",text:"own\n"}}}}]);
32
+ await Promise.race([f.fire("session_start"),new Promise((_,reject)=>setTimeout(()=>reject(new Error("startup blocked")),100))]);
33
+ f.pi.events.emit(SESSION_CHANGE_EVENT,{sessionId:"session"});
34
+ await new Promise(resolve=>setImmediate(resolve));
35
+ assert.equal(f.gitCalls(),0);
36
+ assert.equal(typeof f.widgets.get("gentle-shell-changes"),"function");
37
+ await f.fire("session_shutdown");
38
+ });