gentle-pi 3.3.0 → 3.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/README.md +88 -59
  2. package/assets/orchestrator-delegation.md +1 -1
  3. package/bin/gentle-shell.mjs +198 -0
  4. package/docs/assets/brand/gentle-shell-banner.gif +0 -0
  5. package/docs/assets/diagrams/odd-workflow.svg +74 -0
  6. package/docs/assets/features/agents-view.png +0 -0
  7. package/docs/assets/features/changes-view.png +0 -0
  8. package/docs/assets/features/command-palette.png +0 -0
  9. package/docs/assets/features/profiles-routing.png +0 -0
  10. package/docs/gentle-agents-activity.md +95 -0
  11. package/docs/gentle-shell.md +26 -2
  12. package/docs/readme-reference.md +99 -6
  13. package/extensions/ask-user-choice.ts +70 -22
  14. package/extensions/ask-user-question.ts +338 -0
  15. package/extensions/gentle-agents.ts +41 -1
  16. package/extensions/gentle-ai.ts +59 -18
  17. package/extensions/gentle-shell.ts +99 -10
  18. package/extensions/quiet-tools.ts +28 -5
  19. package/extensions/startup-banner.ts +25 -10
  20. package/lib/agents-rpc-publisher.ts +342 -0
  21. package/lib/agents-runner.ts +7 -2
  22. package/lib/animation-policy.ts +52 -0
  23. package/lib/background-cache-warming.ts +38 -0
  24. package/lib/command-palette-catalog.ts +1 -0
  25. package/lib/gentle-shell-launcher.ts +482 -0
  26. package/lib/inprocess-reviewer.ts +38 -1
  27. package/lib/native-review-cli.ts +36 -10
  28. package/lib/questionnaire/questionnaire-view.ts +603 -0
  29. package/lib/questionnaire/schema.ts +82 -0
  30. package/lib/questionnaire/validate.ts +141 -0
  31. package/lib/review-candidate-view-owner.ts +20 -5
  32. package/lib/review-candidate-view.ts +9 -2
  33. package/lib/review-host-relay.ts +10 -0
  34. package/lib/review-integration-v2.ts +4 -1
  35. package/lib/rpc-host.ts +36 -0
  36. package/lib/shell-bar.ts +13 -0
  37. package/lib/shell-sidebar-layout.ts +10 -4
  38. package/lib/shell-usage-view.ts +5 -2
  39. package/lib/shell-usage.ts +120 -6
  40. package/package.json +5 -1
  41. package/runtime/gentle-shell-launcher.mjs +483 -0
  42. package/runtime/native-review-cli.mjs +35 -9
  43. package/runtime/review-integration-v2.mjs +4 -1
  44. package/scripts/build-runtime-modules.mjs +1 -0
  45. package/scripts/gentle-ai-installer.mjs +10 -10
  46. package/scripts/install-gentle-ai.mjs +14 -7
  47. package/scripts/install-tui-mode-setting.mjs +78 -1
  48. package/scripts/verify-package-files.mjs +6 -3
  49. package/tests/agents-rpc-publisher.test.ts +407 -0
  50. package/tests/agents-runner.test.ts +10 -0
  51. package/tests/animation-policy.test.ts +42 -0
  52. package/tests/ask-user-choice.test.ts +129 -0
  53. package/tests/ask-user-question.test.ts +661 -0
  54. package/tests/background-cache-warming.test.ts +60 -0
  55. package/tests/background-subagents.test.ts +68 -0
  56. package/tests/command-palette.test.ts +9 -0
  57. package/tests/gentle-agents.test.ts +161 -2
  58. package/tests/gentle-ai-binary.test.ts +1 -1
  59. package/tests/gentle-ai-installer.test.ts +47 -47
  60. package/tests/gentle-ai.test.ts +56 -4
  61. package/tests/gentle-shell-bin.test.ts +188 -0
  62. package/tests/gentle-shell-launcher.test.ts +718 -0
  63. package/tests/gentle-shell.test.ts +355 -2
  64. package/tests/inprocess-reviewer.test.ts +92 -0
  65. package/tests/install-tui-mode-guard.test.ts +99 -0
  66. package/tests/install-tui-mode-setting.test.ts +39 -1
  67. package/tests/native-review-capability-contract.test.ts +16 -1
  68. package/tests/native-review-parity.test.ts +19 -0
  69. package/tests/package-manifest.test.ts +6 -17
  70. package/tests/questionnaire-schema.test.ts +274 -0
  71. package/tests/questionnaire-view.test.ts +446 -0
  72. package/tests/rdd-status-line.test.ts +21 -4
  73. package/tests/review-candidate-owner-retry.test.ts +63 -0
  74. package/tests/review-candidate-view.test.ts +15 -0
  75. package/tests/review-controller-native-routing.test.ts +86 -0
  76. package/tests/review-host-relay.test.ts +21 -0
  77. package/tests/review-integration-v2.test.ts +30 -0
  78. package/tests/review-ledger-contract.test.ts +1 -2
  79. package/tests/review-relay-transport-agent.test.ts +107 -2
  80. package/tests/review-risk-assessment.test.ts +104 -0
  81. package/tests/rpc-host.test.ts +77 -0
  82. package/tests/shell-bar.test.ts +8 -0
  83. package/tests/shell-sidebar-layout.test.ts +60 -5
  84. package/tests/shell-usage.test.ts +129 -0
  85. package/tests/skill-collision-prefixes.test.ts +1 -1
  86. package/tests/startup-banner.test.ts +93 -2
  87. package/docs/assets/brand/gentle-pi-banner.png +0 -0
  88. package/skills/release/SKILL.md +0 -137
@@ -0,0 +1,60 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { readFileSync } from "node:fs";
4
+ import { installBackgroundCacheWarming, type WarmingState } from "../lib/background-cache-warming.ts";
5
+
6
+ test("native warming decisions use only current owned live background work", () => {
7
+ let handler: Parameters<Parameters<typeof installBackgroundCacheWarming>[0]["on"]>[1];
8
+ let state: WarmingState = { sessionId: "parent", ownedTaskIds: new Set(["task"]), tasks: [
9
+ { id: "task", parentSessionId: "parent", mode: "background", status: "queued" },
10
+ ] };
11
+ installBackgroundCacheWarming({ on(event, callback) {
12
+ assert.equal(event, "cache_warming_decision");
13
+ handler = callback;
14
+ } }, () => state);
15
+ const candidate = { warmCost: 0.01, missCost: 0.1, continuationProbability: 0.15, action: "stop" as const };
16
+ assert.deepEqual(handler!(candidate), { action: "warm" });
17
+ state.tasks[0].status = "running";
18
+ assert.deepEqual(handler!(candidate), { action: "warm" });
19
+ for (const status of ["waiting", "completed", "failed", "cancelled", "timed_out"] as const) {
20
+ state.tasks[0].status = status;
21
+ assert.equal(handler!(candidate), undefined, status);
22
+ }
23
+ state.tasks[0].status = "running";
24
+ for (const patch of [
25
+ { sessionId: undefined }, { sessionId: "other" }, { ownedTaskIds: new Set<string>() },
26
+ { tasks: [] }, { tasks: [{ ...state.tasks[0], mode: "task" }] },
27
+ ]) {
28
+ const original = state;
29
+ state = { ...state, ...patch };
30
+ assert.equal(handler!(candidate), undefined);
31
+ state = original;
32
+ }
33
+ for (const warmCost of [0.06, 1, NaN, Infinity, -1]) {
34
+ assert.deepEqual(handler!({ ...candidate, warmCost }), { action: "stop" });
35
+ }
36
+ for (const missCost of [0, -1, NaN, Infinity]) {
37
+ assert.deepEqual(handler!({ ...candidate, missCost }), { action: "stop" });
38
+ }
39
+ assert.deepEqual(handler!({ ...candidate, warmCost: 0, missCost: 0.05 }), { action: "warm" });
40
+ assert.deepEqual(handler!({ ...candidate, warmCost: 0, missCost: 0.049 }), { action: "stop" });
41
+ state.tasks = [];
42
+ assert.equal(handler!({ ...candidate, action: "warm" }), undefined, "ordinary idle keeps native action");
43
+ });
44
+
45
+ // Supplemental architecture guard; the extension lifecycle is exercised in gentle-agents.test.ts.
46
+ test("warming helper has no maintenance capabilities", () => {
47
+ const helper = readFileSync(new URL("../lib/background-cache-warming.ts", import.meta.url), "utf8");
48
+ assert.doesNotMatch(helper, /setTimeout|setInterval|sendMessage|sendUserMessage|subagent_|runner\./);
49
+ });
50
+
51
+ test("background prompt prohibits cache polling and documents explicit native idle opt-in", () => {
52
+ const prompt = readFileSync(new URL("../assets/orchestrator-delegation.md", import.meta.url), "utf8");
53
+ assert.match(prompt, /completion or cache maintenance/);
54
+ assert.match(prompt, /user-requested inspection, relevant scope change, input request, or suspected abnormal behavior/);
55
+ assert.match(prompt, /Never relaunch equivalent work merely because it is queued or running/);
56
+ const docs = readFileSync(new URL("../docs/readme-reference.md", import.meta.url), "utf8");
57
+ assert.match(docs, /"cacheWarming": "idle"/);
58
+ assert.match(docs, /\$0\.05/);
59
+ assert.match(docs, /"streaming".*no idle decision/s);
60
+ });
@@ -530,6 +530,28 @@ function notifyContext(
530
530
  } as unknown as ExtensionContext;
531
531
  }
532
532
 
533
+ /**
534
+ * The same minimal context as `notifyContext`, but interactive: `hasUI` is true
535
+ * and `ui.select` answers the command's no-argument menu. `notifyContext` stays
536
+ * `hasUI: false` so every existing sub-action test keeps the non-menu path.
537
+ */
538
+ function selectingContext(
539
+ cwd: string,
540
+ notices: Array<{ message: string; type?: string }>,
541
+ select: (title: string, options: string[]) => Promise<string | undefined>,
542
+ ): ExtensionContext {
543
+ return {
544
+ cwd,
545
+ hasUI: true,
546
+ ui: {
547
+ notify: (message: string, type?: string) => {
548
+ notices.push({ message, type });
549
+ },
550
+ select,
551
+ },
552
+ } as unknown as ExtensionContext;
553
+ }
554
+
533
555
  /** Point the command's global config home at a scratch dir, never at ~/.pi. */
534
556
  function scopedEnv(t: TestContext, values: Record<string, string | undefined>): void {
535
557
  const previous = new Map<string, string | undefined>();
@@ -590,6 +612,52 @@ test("no argument reports the effective policy, the deciding default, and the ca
590
612
  );
591
613
  });
592
614
 
615
+ test("no argument opens a selectable menu and applies the chosen sub-action", async (t) => {
616
+ const cwd = makeScratch("gp-bg-cmd-select-");
617
+ const configHome = join(makeScratch("gp-bg-home-"), "gentle-ai");
618
+ const globalFile = join(configHome, "background-subagents.json");
619
+ scopedEnv(t, {
620
+ GENTLE_PI_CONFIG_HOME: configHome,
621
+ GENTLE_PI_BACKGROUND_SUBAGENTS: undefined,
622
+ });
623
+ const command = registeredCommands().get("gentle:background-subagents");
624
+ assert.ok(command, "gentle:background-subagents must be registered");
625
+
626
+ // A dismissed menu (undefined selection) reports nothing and writes nothing.
627
+ // It runs first so the untouched config home can prove the no-write: a second
628
+ // scopedEnv save point in one test would restore out of order at teardown.
629
+ const dismissed: Array<{ message: string; type?: string }> = [];
630
+ await command!.handler("", selectingContext(cwd, dismissed, async () => undefined));
631
+ assert.equal(dismissed.length, 0, "a dismissed menu reports nothing");
632
+ assert.equal(existsSync(globalFile), false, "a dismissed menu writes no file");
633
+
634
+ // Choosing "enable" writes the global file and reports exactly once, in the
635
+ // same shape the direct `enable` sub-action already reports.
636
+ const notices: Array<{ message: string; type?: string }> = [];
637
+ await command!.handler(
638
+ "",
639
+ selectingContext(cwd, notices, async (title, options) => {
640
+ assert.equal(title, "Background subagents policy");
641
+ assert.deepEqual(options, ["status", "enable", "disable"]);
642
+ return "enable";
643
+ }),
644
+ );
645
+ assert.equal(notices.length, 1, "one invocation reports exactly once");
646
+ assert.equal(notices[0]!.type, "info");
647
+ assert.equal(
648
+ notices[0]!.message,
649
+ [
650
+ `background subagents: on (decided by global file ${globalFile}; capability: absent)`,
651
+ `Wrote on to the global file ${globalFile}.`,
652
+ "Resolution order (first hit wins): project file, global file, GENTLE_PI_BACKGROUND_SUBAGENTS, built-in default off.",
653
+ ].join("\n"),
654
+ );
655
+ assert.deepEqual(JSON.parse(readFileSync(globalFile, "utf8")), {
656
+ schema: "gentle-pi.background-subagents/v1",
657
+ policy: "on",
658
+ });
659
+ });
660
+
593
661
  test("status names the project file that decided and the global file it shadows", async (t) => {
594
662
  const cwd = makeScratch("gp-bg-cmd-project-");
595
663
  const configHome = join(makeScratch("gp-bg-home-"), "gentle-ai");
@@ -32,6 +32,14 @@ function createPalette(groups: readonly CommandPaletteGroup[], theme?: CommandPa
32
32
  return { palette, results };
33
33
  }
34
34
 
35
+ test("animations is discoverable under Configuration with its live description", () => {
36
+ const groups = buildCommandPaletteGroups([{ name: "gentle:animations", description: "status|quality|performance|potato" }], {});
37
+ assert.equal(groups[0]?.title, "Configuration");
38
+ assert.equal(groups[0]?.items[0]?.command, "gentle:animations");
39
+ assert.match(groups[0]?.items[0]?.label ?? "", /[Aa]nimation/);
40
+ assert.equal(groups[0]?.items[0]?.description, "status|quality|performance|potato");
41
+ });
42
+
35
43
  // --- rankPaletteGroups -------------------------------------------------------
36
44
 
37
45
  test("rankPaletteGroups ranks label-prefix, label-contains, label-subsequence, command-contains, then description-contains", () => {
@@ -337,6 +345,7 @@ test("COMMAND_PALETTE_CATALOG matches the curated command set, in order", () =>
337
345
  "gentle:review-mode",
338
346
  "gentle:background-subagents",
339
347
  "gentle:double-esc-cancel",
348
+ "gentle:animations",
340
349
  "gentle:telemetry",
341
350
  "gentle:banner",
342
351
  "gentle:banner-color",
@@ -147,8 +147,10 @@ function fakePi() {
147
147
  await fire("session_shutdown", ctx, { reason: "quit" });
148
148
  };
149
149
  const fire = async (event: string, ctx: ExtensionContext, payload: unknown = {}) => {
150
+ const results: unknown[] = [];
150
151
  try {
151
- for (const handler of handlers.get(event) ?? []) await handler(payload, ctx);
152
+ for (const handler of handlers.get(event) ?? []) results.push(await handler(payload, ctx));
153
+ return results;
152
154
  } finally {
153
155
  if (event === "session_start") {
154
156
  activeSession = ctx;
@@ -237,6 +239,73 @@ function deps(): { deps: Partial<AgentsDeps>; children: FakeChild[]; spawned: st
237
239
  };
238
240
  }
239
241
 
242
+ test("cache warming follows actual Gentle Agents ownership and completion lifecycle", async (t) => {
243
+ const h = fakePi();
244
+ const runtime = deps();
245
+ let store: TaskStore | undefined;
246
+ const list = TaskStore.prototype.list;
247
+ t.mock.method(TaskStore.prototype, "list", function (this: TaskStore, ...args: Parameters<TaskStore["list"]>) {
248
+ store = this;
249
+ return list.apply(this, args);
250
+ });
251
+ const scheduled = t.mock.fn(() => () => {});
252
+ runtime.deps.schedule = scheduled;
253
+ gentleAgents(h.pi, {}, runtime.deps);
254
+ const { ctx } = fakeContext();
255
+ let sessionId = "warming-parent";
256
+ ctx.sessionManager.getSessionId = () => sessionId;
257
+ await h.fire("session_start", ctx);
258
+ const candidate = { type: "cache_warming_decision", warmCost: 0.01, missCost: 0.1, continuationProbability: 0.15, action: "stop" };
259
+ const run = t.mock.method(AgentRunner.prototype, "run");
260
+ const status = t.mock.method(h.tools.get("subagent_status")!, "execute");
261
+ const result = t.mock.method(h.tools.get("subagent_result")!, "execute");
262
+ const decide = async (expected: unknown) => {
263
+ const before = [runtime.spawned.length, run.mock.callCount(), scheduled.mock.callCount(), status.mock.callCount(), result.mock.callCount()];
264
+ const sent = [...h.sent], entries = [...h.entries];
265
+ const childTraffic = runtime.children.map(child => JSON.stringify([child.written, child.sent, child.killed]));
266
+ const timeout = t.mock.method(globalThis, "setTimeout");
267
+ const interval = t.mock.method(globalThis, "setInterval");
268
+ try {
269
+ assert.deepEqual(await h.fire("cache_warming_decision", ctx, candidate), [expected]);
270
+ assert.equal(timeout.mock.callCount(), 0);
271
+ assert.equal(interval.mock.callCount(), 0);
272
+ } finally { timeout.mock.restore(); interval.mock.restore(); }
273
+ assert.deepEqual([runtime.spawned.length, run.mock.callCount(), scheduled.mock.callCount(), status.mock.callCount(), result.mock.callCount()], before);
274
+ assert.deepEqual(h.sent, sent, "no maintenance or duplicate completion message");
275
+ assert.deepEqual(h.entries, entries, "no maintenance context entry");
276
+ assert.deepEqual(runtime.children.map(child => JSON.stringify([child.written, child.sent, child.killed])), childTraffic, "no child inspection, steering, or cancellation");
277
+ };
278
+ await decide(undefined);
279
+ const launch = await h.tools.get("subagent_run")!.execute("warming-launch", { agent: "explore", task: "Map warming", mode: "background" }, undefined, undefined, ctx);
280
+ await tick();
281
+ const taskId = (launch.details.gentleAgents as { taskId: string }).taskId;
282
+ assert.equal(runtime.children.length, 1);
283
+ await decide({ action: "warm" });
284
+ // The same live owned child is foreign when the active session changes.
285
+ sessionId = "other-parent";
286
+ await decide(undefined);
287
+ sessionId = "warming-parent";
288
+ await decide({ action: "warm" });
289
+ runtime.children[0].emit({ type: "agent_end", messages: [{ role: "assistant", content: [{ type: "text", text: "mapped" }] }] });
290
+ await tick();
291
+ await decide({ action: "warm" });
292
+ assert.equal(h.sent.length, 0, "agent_end is not settlement");
293
+ runtime.children[0].emit({ type: "agent_settled" });
294
+ await tick();
295
+ await decide(undefined);
296
+ assert.equal(h.sent.length, 1);
297
+ assert.equal(h.sent[0].message.customType, "gentle-agents.result");
298
+ assert.match(JSON.stringify(h.sent[0].message), new RegExp(taskId));
299
+ assert.equal(h.sent[0].options.triggerTurn, true);
300
+ assert.equal(run.mock.callCount(), 1, "warming never launches equivalent work");
301
+ // Seed the actual history-restore path with a stale live-looking record:
302
+ // sharing the parent ID and running status must not confer ownership.
303
+ assert.equal(store!.restore({ ...store!.get(taskId)!, id: "warming-restored", status: TASK_STATUS.RUNNING }, emptyThread()), true);
304
+ await decide(undefined);
305
+ await h.fire("session_shutdown", ctx);
306
+ await decide(undefined);
307
+ });
308
+
240
309
  const PRINT_BACKGROUND_ERROR = "Background subagents are unavailable in print mode: pi -p exits before a parent session can receive results. Use task mode, RPC mode, or interactive Pi.";
241
310
 
242
311
  for (const continuation of [false, true]) {
@@ -310,6 +379,96 @@ for (const mode of ["print", "tui", "rpc"] as const) {
310
379
  });
311
380
  }
312
381
 
382
+ /** Controllable fake for `AgentsDeps.schedule`: records every scheduled callback instead of running it, so a test can fire the RPC publisher's coalescing window deterministically. */
383
+ function fakeScheduler() {
384
+ const pending: Array<{ id: number; fn: () => void }> = [];
385
+ let nextId = 0;
386
+ return {
387
+ schedule: (fn: () => void, _ms: number) => {
388
+ const id = nextId++;
389
+ pending.push({ id, fn });
390
+ return () => {
391
+ const index = pending.findIndex((entry) => entry.id === id);
392
+ if (index !== -1) pending.splice(index, 1);
393
+ };
394
+ },
395
+ flushAll: () => {
396
+ const due = pending.splice(0, pending.length);
397
+ for (const entry of due) entry.fn();
398
+ },
399
+ };
400
+ }
401
+
402
+ for (const scenario of [
403
+ { label: "an interactive RPC host", mode: "rpc", env: { PATH: "/bin", GENTLE_SHELL_INTERACTIVE_HOST: "1" }, expectPublish: true },
404
+ { label: "plain RPC without the interactive-host variable", mode: "rpc", env: { PATH: "/bin" }, expectPublish: false },
405
+ { label: "TUI", mode: "tui", env: { PATH: "/bin" }, expectPublish: false },
406
+ ] as const) {
407
+ test(`gentle-agents publishes the live activity payload through setWidget only on ${scenario.label}`, async (t) => {
408
+ const h = fakePi();
409
+ const runtime = deps();
410
+ const scheduler = fakeScheduler();
411
+ runtime.deps.schedule = scheduler.schedule;
412
+ runtime.deps.env = scenario.env;
413
+ gentleAgents(h.pi, {}, runtime.deps);
414
+ const { ctx } = fakeContext();
415
+ Object.assign(ctx, { mode: scenario.mode, hasUI: scenario.mode === "tui" || scenario.mode === "rpc" });
416
+ const setWidget = t.mock.method(ctx.ui, "setWidget");
417
+
418
+ await h.fire("session_start", ctx);
419
+ scheduler.flushAll(); // consume the publisher's own start-time frame, if any
420
+ setWidget.mock.resetCalls();
421
+
422
+ await h.tools.get("subagent_run")!.execute("control", { agent: "explore", task: "Map", mode: "background" }, undefined, undefined, ctx);
423
+ scheduler.flushAll();
424
+
425
+ // The TUI card's own setWidget call always carries a component-factory
426
+ // function, never an array; only the RPC publisher pushes an array.
427
+ const activityCalls = setWidget.mock.calls.filter((call) => call.arguments[0] === "gentle-agents" && Array.isArray(call.arguments[1]));
428
+ if (!scenario.expectPublish) {
429
+ assert.deepEqual(activityCalls, [], "no array-shaped setWidget push outside an interactive RPC host");
430
+ return;
431
+ }
432
+ assert.equal(activityCalls.length, 1, "one push per coalescing window");
433
+ // The fake `ui.setWidget` types `content` as the TUI-only component factory;
434
+ // the RPC publisher instead calls it with a plain `string[]` (real pi's
435
+ // RPC-mode contract), which needs an unknown-mediated cast here.
436
+ const [key, lines] = activityCalls[0]!.arguments as unknown as [string, string[]];
437
+ assert.equal(key, "gentle-agents");
438
+ assert.equal(lines.length, 1);
439
+ const activity = JSON.parse(lines[0]!) as { schema: string; tasks: Array<{ summary: { id: string } }> };
440
+ assert.equal(activity.schema, "gentle-agents.activity/v1");
441
+ assert.ok(activity.tasks.some((task) => typeof task.summary.id === "string" && task.summary.id.length > 0), "the newly launched task must be in the payload");
442
+ });
443
+ }
444
+
445
+ test("gentle-agents notifies once, deduplicated, when the RPC activity publisher's setWidget throws", async (t) => {
446
+ const h = fakePi();
447
+ const runtime = deps();
448
+ const scheduler = fakeScheduler();
449
+ runtime.deps.schedule = scheduler.schedule;
450
+ runtime.deps.env = { PATH: "/bin", GENTLE_SHELL_INTERACTIVE_HOST: "1" };
451
+ gentleAgents(h.pi, {}, runtime.deps);
452
+ const { ctx } = fakeContext();
453
+ Object.assign(ctx, { mode: "rpc", hasUI: true });
454
+ const notify = t.mock.method(ctx.ui, "notify");
455
+ // Only the publisher's array-shaped push fails; the TUI card's own
456
+ // component-factory push (`showWidget`) must stay untouched.
457
+ ctx.ui.setWidget = ((_key: string, content: unknown) => {
458
+ if (Array.isArray(content)) throw new Error("boom");
459
+ }) as typeof ctx.ui.setWidget;
460
+
461
+ await h.fire("session_start", ctx);
462
+ scheduler.flushAll(); // the publisher's own start-time frame fails: one notify
463
+
464
+ await h.tools.get("subagent_run")!.execute("control", { agent: "explore", task: "Map", mode: "background" }, undefined, undefined, ctx);
465
+ scheduler.flushAll(); // a second flush with the same recurring failure must not notify again
466
+
467
+ assert.equal(notify.mock.callCount(), 1, "the same recurring setWidget failure is deduplicated to one notify per session");
468
+ assert.match(String(notify.mock.calls[0]?.arguments[0]), /boom/);
469
+ assert.equal(notify.mock.calls[0]?.arguments[1], "warning");
470
+ });
471
+
313
472
  test("all nine subagent registrations own their transcript shell", () => {
314
473
  const { pi, tools } = fakePi();
315
474
  gentleAgents(pi, {}, deps().deps);
@@ -1838,7 +1997,7 @@ test("default Node spawn adapter distinguishes IPC-only and permission-capable c
1838
1997
  const { ctx } = fakeContext();
1839
1998
  (ctx.sessionManager as unknown as { getCwd(): string }).getCwd = () => sessionCwd;
1840
1999
  await h.fire("session_start", ctx);
1841
- shutdown.push(() => h.fire("session_shutdown", ctx));
2000
+ shutdown.push(async () => { await h.fire("session_shutdown", ctx); });
1842
2001
  return { h, ctx, result: h.tools.get("subagent_run")!.execute(`spawn-${mode}`, { agent: "explore", task: `Capture ${mode}`, mode }, undefined, undefined, ctx) };
1843
2002
  };
1844
2003
  const task = await launch("task", { PATH: "/bin", FIXTURE: "task" });
@@ -97,7 +97,7 @@ async function writeWindowsSourceBinary(packageRoot: string): Promise<{ binaryPa
97
97
  method: "go-sumdb-source-build",
98
98
  package: "github.com/gentleman-programming/gentle-ai/v3/cmd/gentle-ai",
99
99
  module: "github.com/gentleman-programming/gentle-ai/v3",
100
- tag: "v3.4.0",
100
+ tag: "v3.5.0",
101
101
  architecture: process.arch === "x64" ? "x64" : "arm64",
102
102
  binarySha256: createHash("sha256").update(binary).digest("hex"),
103
103
  moduleChecksum: GENTLE_AI_WINDOWS_SOURCE_MODULE_CHECKSUM,