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.
- package/README.md +43 -41
- package/bin/gentle-shell.mjs +183 -8
- package/docs/readme-reference.md +63 -12
- package/lib/gentle-shell-launcher.ts +421 -36
- package/package.json +1 -1
- package/runtime/gentle-shell-launcher.mjs +420 -35
- package/tests/agents-rpc-publisher.test.ts +66 -0
- package/tests/gentle-agents.test.ts +46 -0
- package/tests/gentle-shell-bin.test.ts +651 -3
- package/tests/gentle-shell-launcher.test.ts +659 -10
- package/tests/package-manifest.test.ts +2 -2
|
@@ -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
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
return
|
|
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
|
-
|
|
321
|
-
|
|
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
|
|
382
|
+
return undefined;
|
|
327
383
|
}
|
|
328
|
-
if (typeof parsed !== "object" || parsed === null) return
|
|
384
|
+
if (typeof parsed !== "object" || parsed === null) return undefined;
|
|
329
385
|
const packages = (parsed ).packages;
|
|
330
|
-
|
|
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
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
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
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
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);
|