gentle-pi 3.5.0 → 3.6.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.
@@ -1033,6 +1033,18 @@ export const NATIVE_CLI_CONTRACTS = Object.freeze({
1033
1033
  // closed START/STATUS fields this row negotiates. riskEvidence and hint
1034
1034
  // remain dark; neither is proven to reach Pi's negotiated START path.
1035
1035
  "3.5.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 }),
1036
+ // v3.6.0 repeats 3.5.0: the published provider contract bundle is
1037
+ // byte-identical at 1.2.0, both binaries advertise capabilities/v2.6
1038
+ // with only build-identity differences, and no review-integration schema
1039
+ // changed between the v3.5.0 and v3.6.0 tags. riskEvidence and hint
1040
+ // remain dark; neither is proven to reach Pi's negotiated START path.
1041
+ "3.6.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 }),
1042
+ // v3.6.1 repeats 3.6.0: published provider-contract archives are byte-identical
1043
+ // (SHA-256 547b68e172cc87aa297309d61624e5fc2c24d407a494b53eeb5a2b053904352c).
1044
+ // The published v3.6.1 binary advertises capabilities/v2.6, and the tag diff
1045
+ // changes no review-integration schema or capability source. riskEvidence and
1046
+ // hint remain dark because neither is proven in Pi's negotiated START path.
1047
+ "3.6.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 }),
1036
1048
  });
1037
1049
 
1038
1050
 
@@ -36,7 +36,7 @@ const WINDOWS_SYSTEM_ROOT = "C:\\Windows";
36
36
  // version check below) derives from this constant instead of repeating the
37
37
  // literal, so a pin bump cannot leave a stale copy behind. See
38
38
  // scripts/install-gentle-ai.mjs for the incident that motivated this.
39
- export const INSTALLER_VERSION = "3.5.0";
39
+ export const INSTALLER_VERSION = "3.6.1";
40
40
  export const RELEASE_BASE_URL = `https://github.com/Gentleman-Programming/gentle-ai/releases/download/v${INSTALLER_VERSION}/`;
41
41
  export const GENTLE_AI_INSTALL_METHOD = Object.freeze({
42
42
  SIGNED_RELEASE_ASSET: "signed-release-asset",
@@ -45,10 +45,10 @@ export const GENTLE_AI_INSTALL_METHOD = Object.freeze({
45
45
  export const GENTLE_AI_WINDOWS_SOURCE_PACKAGE_PATH = "github.com/gentleman-programming/gentle-ai/v3/cmd/gentle-ai";
46
46
  export const GENTLE_AI_WINDOWS_SOURCE_MODULE = "github.com/gentleman-programming/gentle-ai/v3";
47
47
  export const GENTLE_AI_WINDOWS_SOURCE_TAG = `v${INSTALLER_VERSION}`;
48
- // `go mod download -json github.com/gentleman-programming/gentle-ai/v3@v3.5.0`
48
+ // `go mod download -json github.com/gentleman-programming/gentle-ai/v3@v3.6.1`
49
49
  // with GOSUMDB=sum.golang.org reports this exact module SumDB checksum, and the
50
- // tag resolves to commit 0b335e43, the published v3.5.0 release head.
51
- export const GENTLE_AI_WINDOWS_SOURCE_MODULE_CHECKSUM = "h1:3y+Nb7CtgGB3ne3bSNxkzV6j19etkOnGDXIiuv7tMp8=";
50
+ // tag resolves to commit 623624ed, the published v3.6.1 release head.
51
+ export const GENTLE_AI_WINDOWS_SOURCE_MODULE_CHECKSUM = "h1:De+eaJuMPxsaiq8KQzWUKCC5FTaFiA3BqDg9nyh/k9Y=";
52
52
  export const GENTLE_AI_WINDOWS_SOURCE_PACKAGE = `${GENTLE_AI_WINDOWS_SOURCE_PACKAGE_PATH}@${GENTLE_AI_WINDOWS_SOURCE_TAG}`;
53
53
  export const GENTLE_AI_WINDOWS_MINIMUM_GO_VERSION = "1.25.10";
54
54
  export const GENTLE_AI_GO_TOOLCHAIN_UNAVAILABLE_CODE = "GENTLE_AI_GO_TOOLCHAIN_UNAVAILABLE";
@@ -67,7 +67,7 @@ export class GentleAiInstallerError extends Error {
67
67
  // Sentinel used while a re-pinned gentle-ai release is not yet published. A
68
68
  // sentinel digest can never match a real SHA-256, so installation fails closed,
69
69
  // and verify-package-files.mjs refuses to pack/publish while any digest below
70
- // still holds it. The v3.5.0 digests are pinned from the published release:
70
+ // still holds it. The v3.6.1 digests are pinned from the published release:
71
71
  // archive sha256 values verified against the minisign-signed checksums.txt and
72
72
  // freshly computed hashes; binary sha256 values computed from the extracted
73
73
  // executables.
@@ -109,15 +109,15 @@ async function downloadPinnedGentleAiAsset(asset, destination, options) {
109
109
  }
110
110
 
111
111
  // Windows is absent from signed release archives on purpose. gentle-ai stopped
112
- // distributing unsigned Windows builds in c4b764d0, so v3.5.0 publishes signed
112
+ // distributing unsigned Windows builds in c4b764d0, so v3.6.1 publishes signed
113
113
  // Darwin/Linux archives only. Windows x64/arm64 uses the separately verified
114
114
  // exact-tag Go SumDB source-build path below; restore archive rows only when
115
115
  // upstream ships signed Windows assets.
116
116
  export const GENTLE_AI_RELEASE_ASSETS = Object.freeze({
117
- "darwin/amd64": asset("gentle-ai_3.5.0_darwin_amd64.tar.gz", "483b8363cc2b717d224ee7a15c834ed518854c31a1d79d84b6ea0551fd7905cf", "06e5ce2936f0b0579fa382b5cd59cf42c23b07404b162d9ff9a036dd107093a4", "gentle-ai"),
118
- "darwin/arm64": asset("gentle-ai_3.5.0_darwin_arm64.tar.gz", "d7a5c233ce23e8fc2ce917ed3e6a2e643655e6e8322fddb3c522324d4cf8ae52", "ec7757fe13f6cd5c9dffa6466d75c5cd68992eac44e684d330c838282c5e2e7e", "gentle-ai"),
119
- "linux/amd64": asset("gentle-ai_3.5.0_linux_amd64.tar.gz", "3ea016622981f5e4f4a80632fb9c4673149c98f4e5cecc4b979b41bda758ba4a", "7a4202f75c8b90056f0ecd8fceaaef9efe407abc6f98bb42a3ee1e5ad69eb197", "gentle-ai"),
120
- "linux/arm64": asset("gentle-ai_3.5.0_linux_arm64.tar.gz", "aca1997b3ae8439c17883cde39c3ba577ae38352ad6be1d1c4b2c6bc428c4505", "4d81f356f6e221bc5730a280bae049df8315232a2470089993200577e36e97cc", "gentle-ai"),
117
+ "darwin/amd64": asset("gentle-ai_3.6.1_darwin_amd64.tar.gz", "75f9839035eb25e2bc8eb324a365154cd6ecf869cc3db7885cbc9892008ac9bd", "fb9c286d58fddb0234660aa71a4ed0850f7d015f21c5f6c2059fdf70dc3bc0ae", "gentle-ai"),
118
+ "darwin/arm64": asset("gentle-ai_3.6.1_darwin_arm64.tar.gz", "be46c0884f31f485ca9663ee819156e058e1b1e665165a72105f42bb449560a2", "e13779ba1dfa23c31403650d76cceb0d523e56904530426c6bdedca9d7937550", "gentle-ai"),
119
+ "linux/amd64": asset("gentle-ai_3.6.1_linux_amd64.tar.gz", "251db626a3b774665066eeedfb9edbb1bfe60e874fbe98af1df65587b19b3c2f", "7208d5e3185293b70779f174a9deac4962f503a8873d4b3529ad7d444e1308a2", "gentle-ai"),
120
+ "linux/arm64": asset("gentle-ai_3.6.1_linux_arm64.tar.gz", "d6acc74454e6135af9714c497bc4791ed7149ee3c52d16f378cc085817274db5", "e0db95f4a7b9837060ba6de599f2ff15460ceb1b550c65d4225aaccc657ca979", "gentle-ai"),
121
121
  });
122
122
 
123
123
  // A pinned asset is either a signed archive or, for a prerelease pin only,
@@ -17,6 +17,23 @@ const PI_MANAGED_SEGMENT_SEQUENCES = [
17
17
  ["git", "github.com", "Gentleman-Programming"],
18
18
  ];
19
19
 
20
+ // Gentle Shell's default theme (themes/Gentleman-Cute.json, "name":
21
+ // "Gentleman-Cute"): applied by withIsolatedHomeDefaults below whenever a
22
+ // home's settings do not already declare one, never overriding a user's own
23
+ // choice.
24
+ export const DEFAULT_THEME_NAME = "Gentleman-Cute";
25
+
26
+ // Pure merge: returns `value` with fullscreen tuiMode always applied, and —
27
+ // only when `value` does not already declare a "theme" key — the default
28
+ // theme above added too. No filesystem access, so it is unit-testable
29
+ // without a tempdir; installIsolatedTuiModeSetting below is the only caller,
30
+ // applying this to a freshly bootstrapped home's settings.
31
+ export function withIsolatedHomeDefaults(value) {
32
+ const next = { ...value, tuiMode: "fullscreen" };
33
+ if (!("theme" in value)) next.theme = DEFAULT_THEME_NAME;
34
+ return next;
35
+ }
36
+
20
37
  function containsSequence(segments, sequence) {
21
38
  for (let start = 0; start + sequence.length <= segments.length; start += 1) {
22
39
  if (sequence.every((part, offset) => segments[start + offset] === part)) return true;
@@ -147,7 +164,9 @@ export async function installTuiModeSetting(options = {}) {
147
164
  }
148
165
  }
149
166
 
150
- /** Writes tuiMode: "fullscreen" into a directory gentle-shell's own isolated-home
167
+ /** Writes tuiMode: "fullscreen" and — when the home has no theme of its own
168
+ * yet — the default Gentle Shell theme (withIsolatedHomeDefaults,
169
+ * DEFAULT_THEME_NAME above) into a directory gentle-shell's own isolated-home
151
170
  * bootstrap (T2) just created and owns. Unlike installTuiModeSetting, there is no
152
171
  * "physically installed under this home's npm/node_modules" ownership check to
153
172
  * satisfy: the caller already knows it created `dir` moments ago as gentle-shell's
@@ -170,7 +189,7 @@ export async function installIsolatedTuiModeSetting(dir) {
170
189
  const fd = openSync(staging, "wx", original.stat ? original.stat.mode & 0o777 : 0o600);
171
190
  try {
172
191
  if (original.stat) fchmodSync(fd, original.stat.mode & 0o777);
173
- writeFileSync(fd, `${JSON.stringify({ ...original.value, tuiMode: "fullscreen" }, null, 2)}\n`, "utf8");
192
+ writeFileSync(fd, `${JSON.stringify(withIsolatedHomeDefaults(original.value), null, 2)}\n`, "utf8");
174
193
  fsyncSync(fd);
175
194
  } finally { closeSync(fd); }
176
195
  assertDirectories([home]);
@@ -342,7 +342,7 @@ async function main() {
342
342
  });
343
343
 
344
344
  if (driftedContracts.length > 0) {
345
- console.error("gentle-pi packaged review-integration/v1 and review-integration/v2 contract bytes drifted from the pinned v3.5.0 runtime's vendored Gentle AI contract artifacts:");
345
+ console.error("gentle-pi packaged review-integration/v1 and review-integration/v2 contract bytes drifted from the pinned v3.6.1 runtime's vendored Gentle AI contract artifacts:");
346
346
  for (const drift of driftedContracts) console.error(`- ${drift.relativePath}: expected ${drift.expected}, got ${drift.actual}`);
347
347
  process.exit(1);
348
348
  }
@@ -387,7 +387,7 @@ async function main() {
387
387
  process.exit(1);
388
388
  }
389
389
 
390
- console.log(`gentle-pi package resource check passed (${requiredPaths.length} files; ${Object.keys(contractHashes).length} exact byte-pinned contract artifacts for the v3.5.0 runtime).`);
390
+ console.log(`gentle-pi package resource check passed (${requiredPaths.length} files; ${Object.keys(contractHashes).length} exact byte-pinned contract artifacts for the v3.6.1 runtime).`);
391
391
  }
392
392
 
393
393
  const isMainModule = process.argv[1] !== undefined && import.meta.url === pathToFileURL(process.argv[1]).href;
@@ -185,6 +185,41 @@ test("projectRpcActivity honors a custom maxThreadItems override", () => {
185
185
  assert.deepEqual((activity.tasks[0]!.thread.items as { text: string }[]).map((item) => item.text), ["n3", "n4"]);
186
186
  });
187
187
 
188
+ // Regression for the desktop app's Helpers tab showing helpers from every
189
+ // session: `TaskStore` restores finished tasks of every session from disk at
190
+ // startup, so an RPC push scoped to `opts.parentSessionId` must project only
191
+ // the current session's tasks, and its `summary` counts must match.
192
+ test("projectRpcActivity with parentSessionId keeps only that session's tasks and computes the summary from them", () => {
193
+ const store = new TaskStore();
194
+ store.add(task("mine-running", "s1", { status: TASK_STATUS.RUNNING }));
195
+ store.add(task("mine-finished", "s1", { status: TASK_STATUS.COMPLETED, endedAt: 100 }));
196
+ store.add(task("other-running", "s2", { status: TASK_STATUS.RUNNING }));
197
+ store.add(task("other-finished", "s2", { status: TASK_STATUS.COMPLETED, endedAt: 200 }));
198
+
199
+ const activity = projectRpcActivity(store, { parentSessionId: "s1" });
200
+
201
+ assert.deepEqual(
202
+ activity.tasks.map((entry) => entry.summary.id).sort(),
203
+ ["mine-finished", "mine-running"],
204
+ "only the requested session's tasks are projected",
205
+ );
206
+ assert.deepEqual(activity.summary, store.summary("s1"), "summary counts are scoped to the same session filter, not the whole store");
207
+ assert.notDeepEqual(activity.summary, store.summary(), "an unfiltered summary would have counted the other session's tasks too");
208
+ });
209
+
210
+ // Tasks restored on demand (e.g. opening an older task from another session
211
+ // in the overlay) can land in the shared store without ever being live in
212
+ // the current session; the RPC projection must still exclude them by id.
213
+ test("projectRpcActivity with parentSessionId excludes a task from another session even when it is the only one in the store", () => {
214
+ const store = new TaskStore();
215
+ store.add(task("not-mine", "other-session"));
216
+
217
+ const activity = projectRpcActivity(store, { parentSessionId: "current-session" });
218
+
219
+ assert.deepEqual(activity.tasks, []);
220
+ assert.deepEqual(activity.summary, { running: 0, queued: 0, waiting: 0, finished: 0 });
221
+ });
222
+
188
223
  test("encodeActivityLines returns the activity untouched as a single line when it already fits", () => {
189
224
  const activity: RpcActivity = { schema: ACTIVITY_SCHEMA, summary: { running: 0, queued: 0, waiting: 0, finished: 0 }, tasks: [] };
190
225
 
@@ -366,6 +401,37 @@ test("createRpcActivityPublisher stop unsubscribes everything and publishes exac
366
401
  assert.equal(calls.length, 1, "no further frame is published after stop");
367
402
  });
368
403
 
404
+ // `parentSessionId` is a value captured at construction, not a live getter.
405
+ // Honoring a mid-process session switch (a resumed/new/forked session) is
406
+ // the caller's job: stop the old publisher and construct a new one scoped
407
+ // to the new session id, exactly as `extensions/gentle-agents.ts` does on
408
+ // every `session_start`. This locks in that only the newly scoped publisher
409
+ // ever sees the other session's tasks.
410
+ test("createRpcActivityPublisher recreated with a new parentSessionId after a session switch publishes only the new session's tasks", () => {
411
+ const store = new TaskStore();
412
+ store.add(task("session-a-task", "session-a"));
413
+ const scheduler = fakeScheduler();
414
+ const calls: string[][] = [];
415
+ const ui = { setWidget: (_key: string, lines: string[]) => calls.push(lines) };
416
+
417
+ const first = createRpcActivityPublisher({ store, ui, schedule: scheduler.schedule, parentSessionId: "session-a" });
418
+ first.start();
419
+ scheduler.flushAll();
420
+ const firstActivity = JSON.parse(calls.at(-1)![0]!) as RpcActivity;
421
+ assert.deepEqual(firstActivity.tasks.map((entry) => entry.summary.id), ["session-a-task"]);
422
+
423
+ first.stop();
424
+ calls.length = 0;
425
+ store.add(task("session-b-task", "session-b"));
426
+
427
+ const second = createRpcActivityPublisher({ store, ui, schedule: scheduler.schedule, parentSessionId: "session-b" });
428
+ second.start();
429
+ scheduler.flushAll();
430
+
431
+ const secondActivity = JSON.parse(calls.at(-1)![0]!) as RpcActivity;
432
+ assert.deepEqual(secondActivity.tasks.map((entry) => entry.summary.id), ["session-b-task"], "the publisher scoped to the new session must never carry the old session's task");
433
+ });
434
+
369
435
  test("createRpcActivityPublisher flush publishes immediately and cancels a pending coalescing timer", () => {
370
436
  const store = new TaskStore();
371
437
  store.add(task("t1", "s1"));
@@ -469,6 +469,52 @@ test("gentle-agents notifies once, deduplicated, when the RPC activity publisher
469
469
  assert.equal(notify.mock.calls[0]?.arguments[1], "warning");
470
470
  });
471
471
 
472
+ // Regression for the desktop app's Helpers tab showing helpers from every
473
+ // session: a resumed session's own finished tasks restore from disk into
474
+ // the shared `TaskStore` (see "resuming a session restores its own
475
+ // finished tasks as history, never another session's" above for the
476
+ // overlay-render side of this), and the RPC activity publisher created on
477
+ // `session_start` must scope its `setWidget` payload to the same session,
478
+ // never surfacing another session's restored task.
479
+ test("gentle-agents' RPC activity payload excludes a restored task from another session", async (t) => {
480
+ const h = fakePi();
481
+ const runtime = deps();
482
+ const scheduler = fakeScheduler();
483
+ runtime.deps.schedule = scheduler.schedule;
484
+ runtime.deps.env = { PATH: "/bin", GENTLE_SHELL_INTERACTIVE_HOST: "1" };
485
+ const historyHome = join(root, "rpc-restore-history-home");
486
+ const own: TaskRecord = { id: "own-1", agent: "explore-a", mode: "background", prompt: "p", label: "p", cwd, parentSessionId: "resumed-session", status: TASK_STATUS.COMPLETED, createdAt: 1, startedAt: 1, endedAt: 100, model: "m", thinking: undefined, sessionPath: null, error: null, result: "done", lastStep: "responded", lastActivityAt: 100, turns: 1, toolCalls: 0, tokens: 0, cost: 0 };
487
+ const other: TaskRecord = { ...own, id: "not-mine", agent: "explore-other", parentSessionId: "other-session" };
488
+ await saveTask(historyDir(historyHome), own, emptyThread());
489
+ await saveTask(historyDir(historyHome), other, emptyThread());
490
+ runtime.deps.home = historyHome;
491
+ gentleAgents(h.pi, {}, runtime.deps);
492
+ const { ctx } = fakeContext();
493
+ Object.assign(ctx, { mode: "rpc", hasUI: true });
494
+ ctx.sessionManager.getSessionId = () => "resumed-session";
495
+ const setWidget = t.mock.method(ctx.ui, "setWidget");
496
+
497
+ await h.fire("session_start", ctx, { reason: "resume" });
498
+
499
+ // The disk history read behind restoreSessionHistory is fire-and-forget
500
+ // real async I/O, unrelated to the fake coalescing scheduler; poll both
501
+ // until the resumed session's own restored task reaches a flushed frame.
502
+ let activity: { tasks: Array<{ summary: { id: string } }> } | undefined;
503
+ for (let attempt = 0; attempt < 40 && !activity; attempt += 1) {
504
+ await new Promise((resolve) => setTimeout(resolve, 25));
505
+ scheduler.flushAll();
506
+ const activityCalls = setWidget.mock.calls.filter((call) => call.arguments[0] === "gentle-agents" && Array.isArray(call.arguments[1]));
507
+ if (activityCalls.length === 0) continue;
508
+ const [, lines] = activityCalls.at(-1)!.arguments as unknown as [string, string[]];
509
+ const parsed = JSON.parse(lines[0]!) as { tasks: Array<{ summary: { id: string } }> };
510
+ if (parsed.tasks.some((entry) => entry.summary.id === "own-1")) activity = parsed;
511
+ }
512
+
513
+ assert.ok(activity, "the RPC activity payload must eventually include the resumed session's own restored task");
514
+ assert.ok(!activity!.tasks.some((entry) => entry.summary.id === "not-mine"), "another session's restored task must never appear in the RPC activity payload");
515
+ await h.fire("session_shutdown", ctx);
516
+ });
517
+
472
518
  test("all nine subagent registrations own their transcript shell", () => {
473
519
  const { pi, tools } = fakePi();
474
520
  gentleAgents(pi, {}, deps().deps);
@@ -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.5.0",
100
+ tag: "v3.6.1",
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,