gentle-pi 3.5.0 → 3.5.1

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,5 +1,5 @@
1
1
  // Generated by scripts/build-runtime-modules.mjs. Do not edit.
2
- import { join } from "node:path";
2
+ import { join, resolve as resolvePath } from "node:path";
3
3
 
4
4
  // The gentle-shell launcher: pure, side-effect-free functions over injected
5
5
  // env/fs/exec. `bin/gentle-shell.mjs` (T2) wires these into the real process,
@@ -28,6 +28,7 @@ function isPiSubcommand(token ) {
28
28
 
29
29
 
30
30
 
31
+
31
32
   
32
33
 
33
34
 
@@ -44,6 +45,7 @@ export function parseLauncherArgs(argv ) {
44
45
  link: false,
45
46
  isolated: false,
46
47
  home: undefined,
48
+ packageRoot: undefined,
47
49
  help: false,
48
50
  version: false,
49
51
  command: "home",
@@ -57,6 +59,7 @@ export function parseLauncherArgs(argv ) {
57
59
  let link = false;
58
60
  let isolated = false;
59
61
  let home ;
62
+ let packageRoot ;
60
63
  let help = false;
61
64
  let version = false;
62
65
  let error ;
@@ -109,6 +112,26 @@ export function parseLauncherArgs(argv ) {
109
112
  i += 1;
110
113
  continue;
111
114
  }
115
+ if (arg.startsWith("--package-root=")) {
116
+ const value = arg.slice("--package-root=".length);
117
+ if (value.length === 0) {
118
+ error = "--package-root requires a non-empty path argument";
119
+ continue;
120
+ }
121
+ packageRoot = value;
122
+ continue;
123
+ }
124
+ if (arg === "--package-root") {
125
+ const value = argv[i + 1];
126
+ if (value === undefined || value.length === 0) {
127
+ error = "--package-root requires a non-empty path argument";
128
+ if (value !== undefined) i += 1;
129
+ continue;
130
+ }
131
+ packageRoot = value;
132
+ i += 1;
133
+ continue;
134
+ }
112
135
  if (passthrough.length === 0 && isPiSubcommand(arg)) {
113
136
  piSubcommand = arg;
114
137
  }
@@ -125,7 +148,7 @@ export function parseLauncherArgs(argv ) {
125
148
  }
126
149
  }
127
150
 
128
- return { link, isolated, home, help, version, command: undefined, commandArgs: [], passthrough, piSubcommand, error };
151
+ return { link, isolated, home, packageRoot, help, version, command: undefined, commandArgs: [], passthrough, piSubcommand, error };
129
152
  }
130
153
 
131
154
  // --- home resolution -------------------------------------------------------
@@ -308,29 +331,325 @@ export function checkPeerVersionPin(packageJson , peerName
308
331
  return { ok: true, pinned };
309
332
  }
310
333
 
311
- // --- settings.json detection ---------------------------------------------------
334
+ // --- settings.json package declaration detection --------------------------
312
335
 
313
- function packageEntryDeclaresGentlePi(entry ) {
314
- const declares = (value ) => typeof value === "string" && (value === "npm:gentle-pi" || value.startsWith("npm:gentle-pi@"));
315
- if (declares(entry)) return true;
316
- if (entry !== null && typeof entry === "object") return declares((entry ).source);
317
- return false;
336
+ // Matches the raw git URL forms pi accepts without a `git:` prefix.
337
+ const GIT_URL_PATTERN = /^(?:https?|ssh|git):\/\//;
338
+
339
+ function entrySource(entry ) {
340
+ if (typeof entry === "string") return entry;
341
+ if (entry !== null && typeof entry === "object") {
342
+ const source = (entry ).source;
343
+ if (typeof source === "string") return source;
344
+ }
345
+ return undefined;
318
346
  }
319
347
 
320
- export function settingsDeclareGentlePi(settingsText ) {
321
- if (settingsText === undefined) return false;
348
+
349
+
350
+ // A settings `packages` entry is npm- or git-sourced only via an explicit
351
+ // `npm:`/`git:` prefix or a bare git URL; every other source (relative or
352
+ // absolute) is a local path, per pi's own package-source rules.
353
+ export function packageSourceKind(source ) {
354
+ if (source.startsWith("npm:")) return "npm";
355
+ if (source.startsWith("git:")) return "git";
356
+ if (GIT_URL_PATTERN.test(source)) return "git";
357
+ return "path";
358
+ }
359
+
360
+ function npmSourceDeclaresGentlePi(source ) {
361
+ return source === "npm:gentle-pi" || source.startsWith("npm:gentle-pi@");
362
+ }
363
+
364
+ // npm:<name> or npm:<name>@<version>, tolerating a scoped `@scope/name`: only
365
+ // the first `@` *after* the leading scope marker starts a version suffix.
366
+ function npmPackageName(source ) {
367
+ const spec = source.slice("npm:".length);
368
+ if (spec.startsWith("@")) {
369
+ const versionAt = spec.indexOf("@", 1);
370
+ return versionAt === -1 ? spec : spec.slice(0, versionAt);
371
+ }
372
+ const versionAt = spec.indexOf("@");
373
+ return versionAt === -1 ? spec : spec.slice(0, versionAt);
374
+ }
375
+
376
+ function parseSettingsPackages(settingsText ) {
377
+ if (settingsText === undefined) return undefined;
322
378
  let parsed ;
323
379
  try {
324
380
  parsed = JSON.parse(settingsText);
325
381
  } catch {
326
- return false;
382
+ return undefined;
327
383
  }
328
- if (typeof parsed !== "object" || parsed === null) return false;
384
+ if (typeof parsed !== "object" || parsed === null) return undefined;
329
385
  const packages = (parsed ).packages;
330
- if (!Array.isArray(packages)) return false;
386
+ return Array.isArray(packages) ? packages : undefined;
387
+ }
388
+
389
+ function packageEntryDeclaresGentlePi(entry ) {
390
+ const source = entrySource(entry);
391
+ return source !== undefined && packageSourceKind(source) === "npm" && npmSourceDeclaresGentlePi(source);
392
+ }
393
+
394
+ // Deprecated: recognises only an `npm:gentle-pi` declaration. Kept as a thin
395
+ // compatibility wrapper over the pre-existing behaviour for any caller that
396
+ // only cares about the npm case; findGentlePiDeclaration below also detects
397
+ // a path package whose own package.json names it "gentle-pi".
398
+ export function settingsDeclareGentlePi(settingsText ) {
399
+ const packages = parseSettingsPackages(settingsText);
400
+ if (packages === undefined) return false;
331
401
  return packages.some(packageEntryDeclaresGentlePi);
332
402
  }
333
403
 
404
+
405
+
406
+
407
+
408
+
409
+
410
+
411
+
412
+
413
+ // Detects a settings.json `packages` entry that already loads gentle-pi,
414
+ // either as `npm:gentle-pi[@version]` or as a local path (string or object
415
+ // `source`) whose own package.json declares `"name": "gentle-pi"`. Path
416
+ // entries are resolved relative to `opts.agentDir`, matching how pi itself
417
+ // resolves a settings-relative local path.
418
+ export function findGentlePiDeclaration(settingsText , opts ) {
419
+ const packages = parseSettingsPackages(settingsText);
420
+ if (packages === undefined) return undefined;
421
+
422
+ for (const entry of packages) {
423
+ const source = entrySource(entry);
424
+ if (source === undefined) continue;
425
+ const kind = packageSourceKind(source);
426
+ if (kind === "npm" && npmSourceDeclaresGentlePi(source)) return { kind: "npm" };
427
+ if (kind === "path") {
428
+ const dir = resolvePath(opts.agentDir, source);
429
+ if (opts.readPackageName(dir) === "gentle-pi") return { kind: "path", dir };
430
+ }
431
+ }
432
+ return undefined;
433
+ }
434
+
435
+ // --- take-over decision ---------------------------------------------------
436
+
437
+
438
+
439
+
440
+
441
+
442
+
443
+
444
+
445
+
446
+
447
+
448
+
449
+
450
+ export function decideTakeOver(input ) {
451
+ if (input.packageRootExplicit) return true;
452
+ if (input.declaration === undefined) return false;
453
+ if (input.declaration.kind === "npm") return false;
454
+ const realDeclaredDir = input.realDeclaredDir ?? input.declaration.dir;
455
+ return realDeclaredDir !== input.realPackageRoot;
456
+ }
457
+
458
+ // --- other-package injection planning --------------------------------------
459
+
460
+
461
+
462
+
463
+
464
+
465
+
466
+
467
+
468
+
469
+
470
+
471
+
472
+
473
+
474
+
475
+
476
+
477
+
478
+
479
+
480
+
481
+
482
+
483
+
484
+
485
+
486
+
487
+
488
+
489
+
490
+
491
+
492
+
493
+
494
+
495
+
496
+ function entryFilterKeys(entry ) {
497
+ if (entry === null || typeof entry !== "object") return [];
498
+ const record = entry ;
499
+ const keys = [];
500
+ if ("extensions" in record) keys.push("extensions");
501
+ if ("autoload" in record) keys.push("autoload");
502
+ return keys;
503
+ }
504
+
505
+ // Plans the `-e <dir>` flags a take-over must add for every OTHER settings
506
+ // package once `--no-extensions` drops normal settings-driven extension
507
+ // discovery. git-sourced packages are skipped (their install directory is
508
+ // not derivable without pi's own package manager) with a warning; object
509
+ // entries carrying `extensions`/`autoload` filters are still included, with
510
+ // a warning that the take-over cannot honour those filters (their skills,
511
+ // prompts, and themes still load through ordinary settings discovery, which
512
+ // --no-extensions does not affect).
513
+ export function otherPackageInjections(input ) {
514
+ const paths = [];
515
+ const warnings = [];
516
+ const packages = parseSettingsPackages(input.settingsText);
517
+ if (packages === undefined) return { paths, warnings };
518
+ const isDirectory = input.isDirectory ?? (() => true);
519
+ const realpath = input.realpath ?? ((dir ) => dir);
520
+
521
+ for (const entry of packages) {
522
+ const source = entrySource(entry);
523
+ if (source === undefined) continue;
524
+ const kind = packageSourceKind(source);
525
+
526
+ // Skip every gentle-pi entry unconditionally, not only the one
527
+ // matching `skip`'s kind: settings can carry more than one gentle-pi
528
+ // declaration (for example an npm:gentle-pi entry alongside the path
529
+ // declaration actually being taken over), and re-injecting any of
530
+ // them as an "other package" would double-load gentle-pi extensions.
531
+ if (kind === "npm" && npmSourceDeclaresGentlePi(source)) continue;
532
+ if (kind === "path") {
533
+ const dir = resolvePath(input.agentDir, source);
534
+ // Compared through realpath on BOTH sides (not the raw resolved
535
+ // strings): skip.dir may already be a realpath itself
536
+ // (bin/gentle-shell.mjs's --package-root take-over) or may not be
537
+ // (a plain settings.json declaration), so only comparing one side
538
+ // through realpath would break whichever case does not match that
539
+ // assumption. Realpath'ing both keeps the exact-match case
540
+ // (skip.dir derived from the very same source) trivially correct
541
+ // while also recognising a settings entry that reaches the same
542
+ // physical directory as skip through a symlink.
543
+ if (input.skip.kind === "path" && realpath(dir) === realpath(input.skip.dir)) continue;
544
+ }
545
+
546
+ if (kind === "git") {
547
+ warnings.push(
548
+ `gentle-shell: skipping git-sourced package "${source}" during takeover (its install directory is not derivable without pi's own package manager).`,
549
+ );
550
+ continue;
551
+ }
552
+
553
+ const filters = entryFilterKeys(entry);
554
+ if (filters.length > 0) {
555
+ warnings.push(
556
+ `gentle-shell: package "${source}" has ${filters.join("/")} filters that this takeover cannot honour for extensions; its skills, prompts, and themes still load through settings discovery.`,
557
+ );
558
+ }
559
+
560
+ const dir = kind === "npm" ? join(input.agentDir, "npm", "node_modules", npmPackageName(source)) : resolvePath(input.agentDir, source);
561
+ if (!isDirectory(dir)) {
562
+ warnings.push(`gentle-shell: skipping declared package "${source}": ${dir} is not a directory`);
563
+ continue;
564
+ }
565
+ paths.push(dir);
566
+ }
567
+ return { paths, warnings };
568
+ }
569
+
570
+ // --- loose extension discovery ----------------------------------------------
571
+
572
+
573
+
574
+
575
+
576
+
577
+
578
+
579
+
580
+
581
+
582
+
583
+
584
+
585
+
586
+
587
+ // scripts/build-runtime-modules.mjs rewrites every occurrence of a dot, the
588
+ // letters ts, and an immediately following closing quote (single or double)
589
+ // to end in mjs instead, when it generates runtime/gentle-shell-launcher.mjs
590
+ // — a plain `.replace(/\.ts(["'])/g, ...)` that cannot tell an import
591
+ // specifier from an ordinary string literal. Any other string ending the
592
+ // same way — a dot, the letters ts, and a closing quote right after — would
593
+ // get silently corrupted into the mjs form in the generated runtime module,
594
+ // so the three constants below are built by concatenation instead of
595
+ // written as literals that would trigger the same rewrite.
596
+ const TS_EXTENSION = `.t${"s"}`;
597
+ const INDEX_TS_FILENAME = `index${TS_EXTENSION}`;
598
+ const DECLARATION_FILE_SUFFIX = `.d${TS_EXTENSION}`;
599
+ const LOOSE_EXTENSION_FILE_PATTERN = /\.(?:ts|js|mjs)$/;
600
+
601
+ function isLooseExtensionFile(name ) {
602
+ if (name.startsWith(".")) return false;
603
+ if (name.endsWith(DECLARATION_FILE_SUFFIX)) return false;
604
+ return LOOSE_EXTENSION_FILE_PATTERN.test(name);
605
+ }
606
+
607
+ // Mirrors pi's own discoverExtensionsInDir (packages/coding-agent/src/core/
608
+ // extensions/loader.ts): direct *.ts/*.js/*.mjs files, plus <subdir>/index.ts
609
+ // (falling back to <subdir>/index.js) for a child directory that has one. No
610
+ // recursion beyond that one level, matching pi's own rule that a more complex
611
+ // nested package must use a package.json manifest instead.
612
+ //
613
+ // Unlike pi's own scan, hidden entries (dotfiles, and hidden subdirectories)
614
+ // and *.d.ts files are deliberately excluded here: pi's `-e <file>` flag hands
615
+ // the path straight to its module loader with no directory-discovery pass of
616
+ // its own (see buildPiInvocation's takeOver branch), so a hidden file or a
617
+ // type-only declaration file was never a runnable extension and would only
618
+ // surface a confusing "Cannot find module"/empty-module error once injected.
619
+ //
620
+ // Returns already-resolved absolute file paths, sorted by name so the result
621
+ // (and therefore -e ordering) does not depend on the host filesystem's
622
+ // unspecified readdir order.
623
+ export function discoverLooseExtensionEntries(dir , fs ) {
624
+ let entries ;
625
+ try {
626
+ entries = fs.readdir(dir);
627
+ } catch {
628
+ return [];
629
+ }
630
+
631
+ const sorted = [...entries].sort((a, b) => a.name.localeCompare(b.name));
632
+ const discovered = [];
633
+
634
+ for (const entry of sorted) {
635
+ if (entry.name.startsWith(".")) continue;
636
+
637
+ if (entry.isFile) {
638
+ if (isLooseExtensionFile(entry.name)) discovered.push(join(dir, entry.name));
639
+ continue;
640
+ }
641
+
642
+ if (!entry.isDirectory) continue;
643
+ const childDir = join(dir, entry.name);
644
+ const indexTs = join(childDir, INDEX_TS_FILENAME);
645
+ const indexJs = join(childDir, "index.js");
646
+ if (fs.exists(indexTs)) discovered.push(indexTs);
647
+ else if (fs.exists(indexJs)) discovered.push(indexJs);
648
+ }
649
+
650
+ return discovered;
651
+ }
652
+
334
653
  // --- pi invocation builder ---------------------------------------------------
335
654
 
336
655
 
@@ -349,35 +668,99 @@ export function settingsDeclareGentlePi(settingsText )
349
668
 
350
669
 
351
670
 
671
+  
672
+
673
+
674
+  
675
+  
676
+
352
677
 
353
678
 
354
679
 
355
680
 
356
- // The `-e/--theme/--skill/--prompt-template` injection is skipped when the
357
- // caller already confirmed the target settings.json declares the package
358
- // (the `--link` case with a pi-managed install), or when passthrough[0] is
359
- // one of pi's own subcommands: pi dispatches install/remove/uninstall/
360
- // update/list/config/auth on argv[0] before it even parses flags, so any
361
- // injected flag ahead of it stops pi from recognising its subcommand at
362
- // all — this is exactly the observed 2026-09-22 bug where `gentle-shell
363
- // install npm:x` opened an interactive pi session instead of running the
364
- // package manager. Isolated and path homes never declare the package, so
365
- // callers pass `settingsDeclareGentlePi: false` for those and the
366
- // injection always happens there, unless a pi subcommand is set.
681
+
682
+
683
+
684
+
685
+
686
+
687
+
688
+
689
+
690
+
691
+
692
+
693
+
694
+ function packageRootAssetArgs(packageRoot ) {
695
+ return ["--theme", join(packageRoot, "themes"), "--skill", join(packageRoot, "skills"), "--prompt-template", join(packageRoot, "prompts")];
696
+ }
697
+
698
+ function packageRootInjectionArgs(packageRoot ) {
699
+ return ["-e", packageRoot, ...packageRootAssetArgs(packageRoot)];
700
+ }
701
+
702
+ // Four cases, checked in this order — `piSubcommand` first, then `takeOver`:
703
+ // - piSubcommand: pi dispatches install/remove/uninstall/update/list/
704
+ // config/auth on argv[0] before it even parses flags, so any injected
705
+ // -e/--theme/--skill/--prompt-template flag ahead of it stops pi from
706
+ // recognising its subcommand at all — this is exactly the observed
707
+ // 2026-09-22 bug where `gentle-shell install npm:x` opened an
708
+ // interactive pi session instead of running the package manager. No
709
+ // injection of any kind (including a take-over's --no-extensions and
710
+ // other-package/loose-extension -e flags) may precede it.
711
+ // - takeOver: the target settings declare a *different* gentle-pi, or
712
+ // --package-root forced a takeover regardless of any declaration. This
713
+ // must win over the next two cases even when there is no declaration to
714
+ // report, or the plain branch would silently drop --no-extensions and
715
+ // the other-package injections while bin/gentle-shell.mjs still prints
716
+ // the "taking over" message. `--no-extensions` drops normal
717
+ // settings-driven extension discovery, so it is replaced by an explicit
718
+ // `-e <dir>` for every OTHER settings package (skills/prompts/themes
719
+ // for those packages still load through ordinary settings discovery,
720
+ // which --no-extensions does not affect), then an explicit `-e <file>`
721
+ // for every loose extension entry normal discovery would otherwise have
722
+ // found under <agentDir>/extensions and the project-local
723
+ // .pi/extensions, and finally this launcher's own packageRoot injected
724
+ // last so it wins any conflict. Every -e path is injected at most once
725
+ // (R3-001): a loose entry that duplicates an other-package path, or
726
+ // repeats within looseExtensionEntries itself, is skipped rather than
727
+ // loaded twice.
728
+ // - Not takeOver, no declaration: inject this launcher's own packageRoot,
729
+ // exactly as when nothing else in settings loads gentle-pi.
730
+ // - Not takeOver, with a declaration: no injection at all — the target
731
+ // settings already load a gentle-pi the launcher accepts as-is (the
732
+ // `--link` case with a pi-managed install matching this launcher).
367
733
  export function buildPiInvocation(input ) {
368
734
  const args = [...input.runtime.args];
369
- if (input.piSubcommand === undefined && !input.settingsDeclareGentlePi) {
370
- args.push(
371
- "-e",
372
- input.packageRoot,
373
- "--theme",
374
- join(input.packageRoot, "themes"),
375
- "--skill",
376
- join(input.packageRoot, "skills"),
377
- "--prompt-template",
378
- join(input.packageRoot, "prompts"),
379
- );
735
+
736
+ if (input.piSubcommand !== undefined) {
737
+ // No injection at all: pi must see the bare subcommand as argv[0].
738
+ } else if (input.takeOver) {
739
+ args.push("--no-extensions");
740
+ const injected = new Set ();
741
+ for (const otherPath of input.otherPackagePaths) {
742
+ if (injected.has(otherPath)) continue;
743
+ injected.add(otherPath);
744
+ args.push("-e", otherPath);
745
+ }
746
+ for (const entry of input.looseExtensionEntries ?? []) {
747
+ if (injected.has(entry)) continue;
748
+ injected.add(entry);
749
+ args.push("-e", entry);
750
+ }
751
+ // R3-003: the launcher's own package root must also be checked
752
+ // against the dedupe set instead of being appended unconditionally,
753
+ // or a settings package/loose entry that resolves to the same
754
+ // directory as --package-root would be injected twice.
755
+ if (!injected.has(input.packageRoot)) {
756
+ injected.add(input.packageRoot);
757
+ args.push("-e", input.packageRoot);
758
+ }
759
+ args.push(...packageRootAssetArgs(input.packageRoot));
760
+ } else if (input.declaration === undefined) {
761
+ args.push(...packageRootInjectionArgs(input.packageRoot));
380
762
  }
763
+
381
764
  args.push(...input.passthrough);
382
765
 
383
766
  return {
@@ -455,6 +838,8 @@ export function helpText() {
455
838
  " --link Use your existing pi agent home (never edits its settings.json).",
456
839
  " --isolated Use the dedicated ~/.gentle-shell/agent home (default).",
457
840
  " --home <path> Use a custom agent home directory.",
841
+ " --package-root <dir> Force this directory as the gentle-pi package to load, taking over",
842
+ " from any conflicting package the target settings.json already declares.",
458
843
  " --help, -h Show this help text.",
459
844
  " --version Show gentle-shell, pi, and home version information.",
460
845
  "",
@@ -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);