gentle-pi 3.4.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 CHANGED
@@ -12,8 +12,8 @@
12
12
  <a href="https://www.npmjs.com/package/gentle-pi"><img src="https://img.shields.io/npm/v/gentle-pi?style=for-the-badge&labelColor=1A1218&color=F095C8" alt="npm"></a>
13
13
  <a href="https://pi.dev/packages/gentle-pi"><img src="https://img.shields.io/badge/Pi-native-F095C8?style=for-the-badge&labelColor=1A1218" alt="Pi-native package"></a>
14
14
  <a href="LICENSE"><img src="https://img.shields.io/npm/l/gentle-pi?style=for-the-badge&labelColor=1A1218&color=F095C8" alt="MIT license"></a>
15
- <a href="https://github.com/Gentleman-Programming/gentle-pi/stargazers"><img src="https://img.shields.io/github/stars/Gentleman-Programming/gentle-pi?style=for-the-badge&labelColor=1A1218&color=F095C8" alt="GitHub stars"></a>
16
- <a href="https://github.com/Gentleman-Programming/gentle-pi"><img src="https://img.shields.io/github/last-commit/Gentleman-Programming/gentle-pi?style=for-the-badge&labelColor=1A1218&color=D7A0B8" alt="Last commit"></a>
15
+ <a href="https://github.com/Gentleman-Programming/gentle-shell/stargazers"><img src="https://img.shields.io/github/stars/Gentleman-Programming/gentle-shell?style=for-the-badge&labelColor=1A1218&color=F095C8" alt="GitHub stars"></a>
16
+ <a href="https://github.com/Gentleman-Programming/gentle-shell"><img src="https://img.shields.io/github/last-commit/Gentleman-Programming/gentle-shell?style=for-the-badge&labelColor=1A1218&color=D7A0B8" alt="Last commit"></a>
17
17
  </p>
18
18
 
19
19
  <p align="center">
@@ -37,7 +37,7 @@
37
37
  <p align="center"><strong>BUILT FOR PI</strong> &nbsp;·&nbsp; Coding-agent workspace &nbsp;·&nbsp; Focused agents &nbsp;·&nbsp; ODD</p>
38
38
 
39
39
  <p align="center">
40
- <a href="https://github.com/Gentleman-Programming/gentle-pi/stargazers"><strong>★ Star gentle-shell on GitHub</strong></a>
40
+ <a href="https://github.com/Gentleman-Programming/gentle-shell/stargazers"><strong>★ Star gentle-shell on GitHub</strong></a>
41
41
  </p>
42
42
 
43
43
  <div align="center">
@@ -185,13 +185,13 @@ Extension commands are only useful if you can find them. `alt+k` opens a curated
185
185
 
186
186
  ---
187
187
 
188
- ### What's new in v2.6.0
188
+ ### What's new in v3.5
189
189
 
190
- The [v2.6.0 release](https://github.com/Gentleman-Programming/gentle-pi/releases/tag/v2.6.0) brings a more persistent, inspectable Pi workspace:
190
+ The [v3.5.1 release](https://github.com/Gentleman-Programming/gentle-shell/releases/tag/v3.5.1) makes Gentle Shell runnable on its own:
191
191
 
192
- - **Shell:** `/gentle:changes` groups captured write/edit changes from the current agent session and its subagents, without startup repository scans; fullscreen navigation, sidebars, and mouse support stay available. See the [capture limits and shell-command coverage](docs/gentle-shell.md#what-appears-in-changes).
193
- - **Agents and profiles:** the Agents view shows orchestrator/session hierarchy, retained completion, abort, and lost-exit history, parent-child handoff, and model, effort, and usage observability. Named `/gentle:profiles` atomically route the orchestrator independently from packaged and review roles; applying one replaces the routing of every agent, a repository can be pinned to a profile with `p` so its subagent launches stop following the globally active profile, and the panel shows the routing the runtime actually uses even when `models.json` is sparse.
194
- - **Control and recovery:** native SDD requires parent-confirmed preflight; native review supports intended-untracked selection, consent, and provider continuations. Subsystems install with explicit recovery guidance when npm lifecycle scripts were skipped; Pi Git installs are recognized globally; custom ask responses are opt-in. Windows keeps child consoles hidden and fixes ownership mode; Gentle Todo keeps the next pending task visible when collapsed.
192
+ - **Standalone launcher:** `npm i -g gentle-pi` installs `gentle-shell`, which opens Pi with the Gentle Shell package loaded from its own home (`~/.gentle-shell/agent`) or, with `--link`, from your existing `~/.pi/agent`; `gentle-shell install npm:<pkg>` and the other pi subcommands run against the selected home. A bundled or `PATH` pi is used, never a modified one.
193
+ - **Link mode take-over:** when `~/.pi/agent` already declares gentle-pi as a path package, the launcher takes over extension loading (`--no-extensions` plus explicit `-e` for every other declared package and loose extension) so tools never register twice.
194
+ - **Interactive RPC hosts:** with `GENTLE_SHELL_INTERACTIVE_HOST=1` and `--mode rpc`, ask-user tools use pi's RPC dialogs and gentle-agents publishes live subagent activity for the desktop app. See the [reference](docs/readme-reference.md#interactive-rpc-hosts).
195
195
 
196
196
  ---
197
197
 
@@ -203,13 +203,38 @@ The [v2.6.0 release](https://github.com/Gentleman-Programming/gentle-pi/releases
203
203
 
204
204
  ## Get started
205
205
 
206
- Install the stable release, restart Pi, then synchronize the installed assets.
207
-
208
206
  > **Naming transition:** The product is called `gentle-shell`; the current npm package and repository remain `gentle-pi` until migration.
209
207
 
208
+ ### Path A: standalone `gentle-shell` (recommended, no pi changes)
209
+
210
+ `gentle-shell` opens Pi with the Gentle Shell package loaded, without installing it into your pi agent or editing its `settings.json`.
211
+
212
+ ```bash
213
+ npm i -g gentle-pi
214
+
215
+ # Own home, never touches your pi install
216
+ gentle-shell
217
+
218
+ # Reuse your pi sign-ins, models and chats instead
219
+ gentle-shell --link
220
+ ```
221
+
222
+ `gentle-shell` alone starts in its own home, `~/.gentle-shell/agent`. `gentle-shell --link` reuses `~/.pi/agent` as-is.
223
+
224
+ ```bash
225
+ # Make --link the default
226
+ gentle-shell home link
227
+ ```
228
+
229
+ Every other argument is forwarded to pi unchanged, for example `gentle-shell --mode rpc` or `gentle-shell -p "..."`. Full flags, env vars, and modes: **[launcher reference](docs/readme-reference.md#gentle-shell-launcher)**.
230
+
231
+ ### Path B: inside an existing pi
232
+
233
+ Install the stable release into an existing pi agent, restart Pi, then synchronize the installed assets.
234
+
210
235
  ```bash
211
- # Published stable release: v2.6.0
212
- pi install npm:gentle-pi@2.6.0
236
+ # Published stable release: v3.5.1
237
+ pi install npm:gentle-pi@3.5.1
213
238
 
214
239
  # Restart Pi, then run:
215
240
  gentle-ai sync
@@ -218,7 +243,7 @@ gentle-ai sync
218
243
  pi
219
244
  ```
220
245
 
221
- See the [v2.6.0 release notes](https://github.com/Gentleman-Programming/gentle-pi/releases/tag/v2.6.0) for version-specific changes.
246
+ See the [v3.5.1 release notes](https://github.com/Gentleman-Programming/gentle-shell/releases/tag/v3.5.1) for version-specific changes.
222
247
 
223
248
  ```text
224
249
  /gentle:status
@@ -229,6 +254,8 @@ See the [v2.6.0 release notes](https://github.com/Gentleman-Programming/gentle-p
229
254
 
230
255
  > **Fullscreen installation note:** a recognized global installation persists Pi’s `"tuiMode": "fullscreen"` setting. Project-local and other install paths do not receive that change.
231
256
 
257
+ > **Interactive RPC hosts:** the desktop app sets `GENTLE_SHELL_INTERACTIVE_HOST=1` automatically, without touching your Pi config — see the [installation reference](docs/readme-reference.md#interactive-rpc-hosts).
258
+
232
259
  For prerequisites, source-checkout instructions, full install behavior, and release policy, use the **[installation reference](docs/readme-reference.md#install)**. For everyday work, describe the outcome and follow [ODD](#odd--the-everyday-workflow).
233
260
 
234
261
  <p align="right"><a href="#top">Back to top ↑</a></p>
@@ -262,17 +289,17 @@ Start with the product-facing destination, then move into the operational refere
262
289
  This project is built in public. Bring a real workflow, a sharp question, a bug report, or a small improvement that makes the next person’s work clearer.
263
290
 
264
291
  <p align="center">
265
- <a href="https://github.com/Gentleman-Programming/gentle-pi/issues"><img src="https://img.shields.io/badge/Issues-join%20the%20conversation-F095C8?style=for-the-badge&labelColor=1A1218" alt="GitHub issues"></a>
266
- <a href="https://github.com/Gentleman-Programming/gentle-pi/graphs/contributors"><img src="https://img.shields.io/badge/Contributors-thank%20you-D7A0B8?style=for-the-badge&labelColor=1A1218" alt="Contributors"></a>
292
+ <a href="https://github.com/Gentleman-Programming/gentle-shell/issues"><img src="https://img.shields.io/badge/Issues-join%20the%20conversation-F095C8?style=for-the-badge&labelColor=1A1218" alt="GitHub issues"></a>
293
+ <a href="https://github.com/Gentleman-Programming/gentle-shell/graphs/contributors"><img src="https://img.shields.io/badge/Contributors-thank%20you-D7A0B8?style=for-the-badge&labelColor=1A1218" alt="Contributors"></a>
267
294
  <a href="https://discord.com/invite/gentleman-programming-769863833996754944"><img src="https://img.shields.io/badge/Discord-Gentleman%20Programming-F095C8?style=for-the-badge&labelColor=1A1218" alt="Gentleman Programming Discord"></a>
268
295
  </p>
269
296
 
270
297
  <p align="center">
271
- <a href="https://github.com/Gentleman-Programming/gentle-pi/graphs/contributors"><img src="https://contrib.rocks/image?repo=Gentleman-Programming/gentle-pi" alt="gentle-shell contributors"></a>
298
+ <a href="https://github.com/Gentleman-Programming/gentle-shell/graphs/contributors"><img src="https://contrib.rocks/image?repo=Gentleman-Programming/gentle-shell" alt="gentle-shell contributors"></a>
272
299
  </p>
273
300
 
274
- - Open an [issue](https://github.com/Gentleman-Programming/gentle-pi/issues) with the context needed to reproduce or understand the idea.
275
- - See the people shaping the project in the [contributors graph](https://github.com/Gentleman-Programming/gentle-pi/graphs/contributors).
301
+ - Open an [issue](https://github.com/Gentleman-Programming/gentle-shell/issues) with the context needed to reproduce or understand the idea.
302
+ - See the people shaping the project in the [contributors graph](https://github.com/Gentleman-Programming/gentle-shell/graphs/contributors).
276
303
  - Follow [Gentleman Programming](https://github.com/Gentleman-Programming) for the wider ecosystem.
277
304
 
278
305
  <p align="right"><a href="#top">Back to top ↑</a></p>
@@ -0,0 +1,373 @@
1
+ #!/usr/bin/env node
2
+ // Thin process/fs/exec glue around lib/gentle-shell-launcher.ts (built to
3
+ // runtime/gentle-shell-launcher.mjs). All decision logic — argv parsing, home
4
+ // resolution, pi resolution order, the version gate, and the pi invocation —
5
+ // lives in that pure, unit-tested module; this file only wires it to the real
6
+ // process, filesystem, and child process.
7
+ import { accessSync, constants as fsConstants, existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, statSync, writeFileSync } from "node:fs";
8
+ import { createRequire } from "node:module";
9
+ import { constants as osConstants, homedir } from "node:os";
10
+ import { delimiter, dirname, join, resolve as resolvePath } from "node:path";
11
+ import { spawn, spawnSync } from "node:child_process";
12
+ import { fileURLToPath } from "node:url";
13
+ import {
14
+ buildPiInvocation,
15
+ checkPiVersion,
16
+ decideTakeOver,
17
+ describeVersion,
18
+ discoverLooseExtensionEntries,
19
+ findGentlePiDeclaration,
20
+ helpText,
21
+ launcherConfigPath,
22
+ missingPiMessage,
23
+ otherPackageInjections,
24
+ parseLauncherArgs,
25
+ parseLauncherConfig,
26
+ planSpawn,
27
+ resolveHome,
28
+ resolvePiRuntime,
29
+ } from "../runtime/gentle-shell-launcher.mjs";
30
+ import { installIsolatedTuiModeSetting } from "../scripts/install-tui-mode-setting.mjs";
31
+
32
+ const packageRoot = dirname(dirname(fileURLToPath(import.meta.url)));
33
+
34
+ function fail(message, code) {
35
+ process.stderr.write(`${message}\n`);
36
+ process.exit(code);
37
+ }
38
+
39
+ function readJsonIfExists(path) {
40
+ try {
41
+ return readFileSync(path, "utf8");
42
+ } catch (error) {
43
+ if (error.code === "ENOENT") return undefined;
44
+ throw error;
45
+ }
46
+ }
47
+
48
+ // @earendil-works/pi-coding-agent ships as an optional peer dependency: it may
49
+ // not be installed at all, so a resolution failure here is expected, not an error.
50
+ function resolveBundledCli() {
51
+ try {
52
+ const require = createRequire(import.meta.url);
53
+ const pkgJsonPath = require.resolve("@earendil-works/pi-coding-agent/package.json");
54
+ const cliPath = join(dirname(pkgJsonPath), "dist", "bundle", "cli.js");
55
+ return existsSync(cliPath) ? cliPath : undefined;
56
+ } catch {
57
+ return undefined;
58
+ }
59
+ }
60
+
61
+ function findOnPath(name) {
62
+ const dirs = (process.env.PATH || "").split(delimiter).filter((entry) => entry.length > 0);
63
+ const extensions = process.platform === "win32" ? (process.env.PATHEXT || ".COM;.EXE;.BAT;.CMD").split(";") : [""];
64
+ for (const dir of dirs) {
65
+ for (const extension of extensions) {
66
+ const candidate = join(dir, `${name}${extension}`);
67
+ try {
68
+ accessSync(candidate, fsConstants.X_OK);
69
+ return candidate;
70
+ } catch {
71
+ // keep scanning
72
+ }
73
+ }
74
+ }
75
+ return undefined;
76
+ }
77
+
78
+ function signalExitCode(signal) {
79
+ const number = osConstants.signals[signal];
80
+ return 128 + (typeof number === "number" ? number : 0);
81
+ }
82
+
83
+ function ownPackageVersion() {
84
+ const packageJson = JSON.parse(readFileSync(join(packageRoot, "package.json"), "utf8"));
85
+ return packageJson.version;
86
+ }
87
+
88
+ function emptyArgs() {
89
+ return {
90
+ link: false,
91
+ isolated: false,
92
+ home: undefined,
93
+ packageRoot: undefined,
94
+ help: false,
95
+ version: false,
96
+ command: undefined,
97
+ commandArgs: [],
98
+ passthrough: [],
99
+ piSubcommand: undefined,
100
+ error: undefined,
101
+ };
102
+ }
103
+
104
+ // package.json "name" reader injected into findGentlePiDeclaration: a
105
+ // missing or unreadable package.json, or a non-string "name", is never an
106
+ // error here — it just means that path package is not gentle-pi.
107
+ function readPackageName(dir) {
108
+ try {
109
+ const pkg = JSON.parse(readFileSync(join(dir, "package.json"), "utf8"));
110
+ return typeof pkg.name === "string" ? pkg.name : undefined;
111
+ } catch {
112
+ return undefined;
113
+ }
114
+ }
115
+
116
+ // Best-effort realpath: a directory that does not exist (yet, or ever)
117
+ // cannot be realpath'd, so the take-over decision falls back to comparing
118
+ // the raw path instead of failing.
119
+ function safeRealpath(path) {
120
+ try {
121
+ return realpathSync(path);
122
+ } catch {
123
+ return path;
124
+ }
125
+ }
126
+
127
+ // Used to filter the loose extension dirs a take-over re-injects: a missing
128
+ // path, or one that is not a directory (for example a stray file named
129
+ // "extensions"), is silently excluded rather than passed to pi as -e.
130
+ function isDirectory(path) {
131
+ try {
132
+ return statSync(path).isDirectory();
133
+ } catch {
134
+ return false;
135
+ }
136
+ }
137
+
138
+ // Real-fs adapter for discoverLooseExtensionEntries (lib/gentle-shell-launcher.ts):
139
+ // statSync-based isFile/isDirectory (not readdirSync's Dirent, which uses
140
+ // lstat and so would treat a symlinked file or directory as neither) so a
141
+ // symlinked loose extension resolves the same way pi's own fs.existsSync-based
142
+ // checks would.
143
+ const looseExtensionFs = {
144
+ readdir(dir) {
145
+ let names;
146
+ try {
147
+ names = readdirSync(dir);
148
+ } catch (error) {
149
+ // resolveLooseExtensionEntries only calls this once isDirectory(dir)
150
+ // has already confirmed the directory exists, so a failure here (for
151
+ // example EACCES) is a real read failure, not a missing directory.
152
+ // Warn instead of silently dropping every loose extension it would
153
+ // have contributed (R4-loose-extension-enumeration-fails-silently).
154
+ process.stderr.write(`gentle-shell: could not read loose extension directory ${dir}: ${error.message} (skipping)\n`);
155
+ return [];
156
+ }
157
+ return names.map((name) => {
158
+ const entryPath = join(dir, name);
159
+ try {
160
+ const entryStat = statSync(entryPath);
161
+ return { name, isFile: entryStat.isFile(), isDirectory: entryStat.isDirectory() };
162
+ } catch {
163
+ return { name, isFile: false, isDirectory: false };
164
+ }
165
+ });
166
+ },
167
+ exists: existsSync,
168
+ };
169
+
170
+ // A loose extensions directory that is itself a self-contained extension —
171
+ // a package.json declaring a non-empty "pi.extensions" manifest — is passed
172
+ // through as a single -e <dir> instead of being broken into per-file
173
+ // entries: pi's own module loader (jiti) resolves that case directly,
174
+ // exactly as it would for any other explicitly configured package path. A
175
+ // root-level index.ts/index.js is deliberately NOT treated as that same
176
+ // marker: pi's own discovery loads it as just another loose file, so
177
+ // collapsing the whole directory on its presence silently dropped sibling
178
+ // loose files like extra.ts (R4-loose-index-collapses-sibling-extensions).
179
+ function readPiManifestExtensions(dir) {
180
+ try {
181
+ const pkg = JSON.parse(readFileSync(join(dir, "package.json"), "utf8"));
182
+ return Array.isArray(pkg?.pi?.extensions) ? pkg.pi.extensions : undefined;
183
+ } catch {
184
+ return undefined;
185
+ }
186
+ }
187
+
188
+ function looseDirHasOwnEntryPoint(dir) {
189
+ const manifestExtensions = readPiManifestExtensions(dir);
190
+ return manifestExtensions !== undefined && manifestExtensions.length > 0;
191
+ }
192
+
193
+ // Resolves one candidate loose-extensions directory (<agentDir>/extensions or
194
+ // <cwd>/.pi/extensions) into the -e entries a take-over must re-inject: the
195
+ // directory itself when it is a self-contained extension, otherwise every
196
+ // loose file discoverLooseExtensionEntries finds inside it. A missing or
197
+ // non-directory candidate resolves to no entries.
198
+ function resolveLooseExtensionEntries(dir) {
199
+ if (!isDirectory(dir)) return [];
200
+ if (looseDirHasOwnEntryPoint(dir)) return [dir];
201
+ return discoverLooseExtensionEntries(dir, looseExtensionFs);
202
+ }
203
+
204
+ function loadConfig() {
205
+ const configPath = launcherConfigPath(homedir());
206
+ const text = readJsonIfExists(configPath);
207
+ return text === undefined ? undefined : parseLauncherConfig(text);
208
+ }
209
+
210
+ function handleHomeCommand(commandArgs) {
211
+ if (commandArgs.length === 0) {
212
+ const resolved = resolveHome({ args: emptyArgs(), env: process.env, homedir: homedir(), config: loadConfig() });
213
+ process.stdout.write(`${resolved.mode} ${resolved.dir}\n`);
214
+ process.exit(0);
215
+ }
216
+ if (commandArgs.length > 1) fail("gentle-shell home accepts at most one argument. Run 'gentle-shell --help'.", 2);
217
+ const [value] = commandArgs;
218
+ if (value.length === 0) fail("gentle-shell home requires a non-empty argument. Run 'gentle-shell --help'.", 2);
219
+
220
+ const configPath = launcherConfigPath(homedir());
221
+ const configDir = dirname(configPath);
222
+ if (!existsSync(configDir)) mkdirSync(configDir, { recursive: true, mode: 0o700 });
223
+
224
+ if (value === "link" || value === "isolated") {
225
+ writeFileSync(configPath, `${JSON.stringify({ home: value }, null, 2)}\n`, "utf8");
226
+ process.stdout.write(`Saved home: ${value}\n`);
227
+ process.exit(0);
228
+ }
229
+ const dir = resolvePath(value);
230
+ writeFileSync(configPath, `${JSON.stringify({ home: dir }, null, 2)}\n`, "utf8");
231
+ process.stdout.write(`Saved home: path ${dir}\n`);
232
+ process.exit(0);
233
+ }
234
+
235
+ async function main() {
236
+ const args = parseLauncherArgs(process.argv.slice(2));
237
+ if (args.error !== undefined) fail(`${args.error}\nRun 'gentle-shell --help' for usage.`, 2);
238
+ if (args.help) {
239
+ process.stdout.write(`${helpText()}\n`);
240
+ process.exit(0);
241
+ }
242
+ if (args.command === "home") {
243
+ handleHomeCommand(args.commandArgs);
244
+ return;
245
+ }
246
+
247
+ const config = loadConfig();
248
+ let home = resolveHome({ args, env: process.env, homedir: homedir(), config });
249
+ if (home.mode === "path") home = { ...home, dir: resolvePath(home.dir) };
250
+
251
+ const runtime = resolvePiRuntime({
252
+ env: process.env,
253
+ resolveBundledCli,
254
+ findOnPath,
255
+ nodeExecPath: process.execPath,
256
+ });
257
+ if (runtime === undefined) fail(missingPiMessage(), 1);
258
+
259
+ const versionProbePlan = planSpawn({ command: runtime.command, args: [...runtime.args, "--version"], platform: process.platform });
260
+ const versionProbe = spawnSync(versionProbePlan.command, versionProbePlan.args, {
261
+ stdio: ["ignore", "pipe", "pipe"],
262
+ timeout: 15000,
263
+ encoding: "utf8",
264
+ shell: versionProbePlan.shell,
265
+ });
266
+ if (versionProbe.error) fail(`Could not run the pi runtime at "${runtime.command}": ${versionProbe.error.message}`, 1);
267
+ const versionCheck = checkPiVersion(versionProbe.stdout ?? "");
268
+ if (!versionCheck.ok) fail(versionCheck.message, 1);
269
+
270
+ if (args.version) {
271
+ process.stdout.write(`${describeVersion({ gentlePiVersion: ownPackageVersion(), piVersion: versionCheck.version, home })}\n`);
272
+ process.exit(0);
273
+ }
274
+
275
+ // Isolated-home bootstrap: only on a home gentle-shell has not seen before
276
+ // (link never bootstraps — it reuses the user's own pi agent home as-is).
277
+ if ((home.mode === "isolated" || home.mode === "path") && !existsSync(home.dir)) {
278
+ mkdirSync(home.dir, { recursive: true });
279
+ await installIsolatedTuiModeSetting(home.dir);
280
+ process.stderr.write(`gentle-shell: using a separate home at ${home.dir}. Run 'gentle-shell --link' to reuse your pi sign-ins and chats.\n`);
281
+ }
282
+
283
+ const packageRootExplicit = args.packageRoot !== undefined;
284
+ const effectivePackageRoot = packageRootExplicit ? resolvePath(args.packageRoot) : packageRoot;
285
+ // R4-forced-package-root-unvalidated / R3-005: an unvalidated --package-root
286
+ // forces a take-over (dropping normal extension discovery via
287
+ // --no-extensions) and then hands pi -e/--theme/--skill/--prompt-template
288
+ // flags pointing at directories that do not exist, turning an operator typo
289
+ // into an obscure pi loader failure instead of a clear launcher error.
290
+ if (packageRootExplicit && !isDirectory(effectivePackageRoot)) {
291
+ fail(`--package-root ${args.packageRoot} does not exist or is not a directory.`, 2);
292
+ }
293
+
294
+ let declaration;
295
+ let takeOver = false;
296
+ let otherPackagePaths = [];
297
+ let looseExtensionEntries = [];
298
+
299
+ // Only --link can read another gentle-pi declaration out of a real
300
+ // settings.json; isolated and --home homes never declare one, so they
301
+ // always get the plain injection (declaration stays undefined) unless
302
+ // --package-root itself forces a take-over below. A pi subcommand skips
303
+ // this whole block: buildPiInvocation ignores takeOver/declaration once
304
+ // piSubcommand is set, and running the take-over/loose-dir discovery
305
+ // anyway would still print a misleading "taking over gentle-pi..."
306
+ // message (and otherPackageInjections warnings) for a plain
307
+ // `gentle-shell install npm:x` that never actually takes anything over.
308
+ if (home.mode === "link" && args.piSubcommand === undefined) {
309
+ const settingsText = readJsonIfExists(join(home.dir, "settings.json"));
310
+ declaration = findGentlePiDeclaration(settingsText, { agentDir: home.dir, readPackageName });
311
+ const realEffectivePackageRoot = safeRealpath(effectivePackageRoot);
312
+ const realDeclaredDir = declaration?.kind === "path" ? safeRealpath(declaration.dir) : undefined;
313
+ takeOver = decideTakeOver({
314
+ declaration,
315
+ realPackageRoot: realEffectivePackageRoot,
316
+ realDeclaredDir,
317
+ packageRootExplicit,
318
+ });
319
+ if (takeOver) {
320
+ const skip = declaration ?? { kind: "path", dir: realEffectivePackageRoot };
321
+ const injections = otherPackageInjections({ settingsText, agentDir: home.dir, skip, isDirectory, realpath: safeRealpath });
322
+ otherPackagePaths = injections.paths;
323
+ for (const warning of injections.warnings) process.stderr.write(`${warning}\n`);
324
+ // --no-extensions drops pi's normal settings-driven extension
325
+ // discovery, which also covers loose (non-package) extensions
326
+ // under <agentDir>/extensions and the project-local
327
+ // <cwd>/.pi/extensions. Re-injecting either directory wholesale
328
+ // as `-e <dir>` does not work for a directory of loose files: pi's
329
+ // -e flag hands the path straight to its module loader with no
330
+ // directory-discovery pass, so a bare directory of loose files
331
+ // fails with "Cannot find module ...". Resolve each candidate
332
+ // into its actual loose file entries (or pass it through
333
+ // unchanged when it is itself a self-contained extension) so a
334
+ // take-over does not silently stop loading them.
335
+ looseExtensionEntries = [join(home.dir, "extensions"), join(process.cwd(), ".pi", "extensions")].flatMap(resolveLooseExtensionEntries);
336
+ const declaredFrom = declaration === undefined ? "the requested package root" : declaration.kind === "npm" ? "npm:gentle-pi" : declaration.dir;
337
+ process.stderr.write(
338
+ `gentle-shell: taking over gentle-pi from ${declaredFrom} for this run (settings unchanged; its skills, prompts, and themes still load alongside this launcher's).\n`,
339
+ );
340
+ }
341
+ }
342
+ // Isolated and --home homes have no declaration to take over: declaration
343
+ // stays undefined and buildPiInvocation injects effectivePackageRoot the
344
+ // same way it always has, --package-root included.
345
+
346
+ const invocation = buildPiInvocation({
347
+ runtime,
348
+ home,
349
+ packageRoot: effectivePackageRoot,
350
+ declaration,
351
+ takeOver,
352
+ otherPackagePaths,
353
+ looseExtensionEntries,
354
+ passthrough: args.passthrough,
355
+ piSubcommand: args.piSubcommand,
356
+ baseEnv: process.env,
357
+ });
358
+
359
+ const launchPlan = planSpawn({ command: invocation.command, args: invocation.args, platform: process.platform });
360
+ const child = spawn(launchPlan.command, launchPlan.args, { stdio: "inherit", env: invocation.env, shell: launchPlan.shell });
361
+ for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"]) {
362
+ process.on(signal, () => child.kill(signal));
363
+ }
364
+ child.on("error", (error) => fail(`Could not start pi: ${error.message}`, 1));
365
+ child.on("exit", (code, signal) => {
366
+ process.exit(signal ? signalExitCode(signal) : (code ?? 1));
367
+ });
368
+ }
369
+
370
+ main().catch((error) => {
371
+ process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
372
+ process.exit(1);
373
+ });
@@ -0,0 +1,95 @@
1
+ # Gentle Agents activity schema (`gentle-agents.activity/v1`)
2
+
3
+ An interactive RPC host — a client that runs `pi --mode rpc` itself, such as the Gentle Shell desktop app — receives live Gentle Agents subagent state as one bounded JSON document per coalescing window, so it can render a per-chat Helpers view without polling `subagent_status`.
4
+
5
+ Source map: [publisher](../lib/agents-rpc-publisher.ts), [wiring](../extensions/gentle-agents.ts), [store](../lib/agents-protocol.ts).
6
+
7
+ ## Turning it on
8
+
9
+ Set `GENTLE_SHELL_INTERACTIVE_HOST=1` on the `pi --mode rpc` process the host spawns directly. `lib/rpc-host.ts`'s `isInteractiveRpcHost(mode, env)` gates the feature on that exact value; any other value, or its absence, keeps RPC headless — the existing subagent-child behavior is byte-identical. `lib/agents-runner.ts` strips the variable from every subagent child's environment, so a subagent spawned by an interactive host never inherits it and stays headless itself.
10
+
11
+ ## Transport
12
+
13
+ Pi's `setWidget` is the only fire-and-forget RPC push structured enough to carry this: in RPC mode it accepts a `string[]` (sent as `extension_ui_request`) and silently ignores a component-factory function (the shape the TUI card above the editor uses). The publisher and the TUI card therefore share one widget key without colliding on the wire — a plain RPC host or a TUI session only ever sees the factory call, which its own transport ignores or renders locally.
14
+
15
+ ```json
16
+ {
17
+ "type": "extension_ui_request",
18
+ "method": "setWidget",
19
+ "widgetKey": "gentle-agents",
20
+ "widgetLines": ["{\"schema\":\"gentle-agents.activity/v1\", ...}"]
21
+ }
22
+ ```
23
+
24
+ `widgetLines` is always exactly one line: one JSON document, `JSON.stringify`'d, never pretty-printed. Parse it as `gentle-agents.activity/v1`.
25
+
26
+ ## Payload shape
27
+
28
+ ```jsonc
29
+ {
30
+ "schema": "gentle-agents.activity/v1",
31
+ "summary": { "running": 1, "queued": 0, "waiting": 0, "finished": 2 },
32
+ "tasks": [
33
+ {
34
+ "summary": {
35
+ "id": "t_abc123",
36
+ "agent": "explore",
37
+ "label": "Map the auth module",
38
+ "prompt": "Explore how authentication works…",
39
+ "status": "running",
40
+ "createdAt": 1732000000000,
41
+ "startedAt": 1732000000100,
42
+ "endedAt": null,
43
+ "lastStep": "reading lib/auth.ts",
44
+ "lastActivityAt": 1732000005000,
45
+ "turns": 2,
46
+ "toolCalls": 3,
47
+ "error": null
48
+ },
49
+ "thread": {
50
+ "version": 7,
51
+ "dropped": 0,
52
+ "items": [
53
+ { "kind": "text", "text": "Looking at the auth flow first." },
54
+ { "kind": "tool", "name": "read", "args": "{\"path\":\"lib/auth.ts\"}", "running": false, "isError": false, "output": "…file contents…" }
55
+ ]
56
+ }
57
+ }
58
+ ]
59
+ }
60
+ ```
61
+
62
+ `summary` is `TaskSummary` from `lib/agents-protocol.ts`, unchanged. Each task's `summary` is a field whitelist of its `TaskRecord`: `id`, `agent`, `label`, `prompt`, `status`, `createdAt`, `startedAt`, `endedAt`, `lastStep`, `lastActivityAt`, `turns`, `toolCalls`, `error`. Every other `TaskRecord` field — `cwd`, `parentSessionId`, `mode`, `model`, `thinking`, `sessionPath`, `result`, `tokens`, `cost` — is deliberately left out, the same discipline `lib/orchestrator-presence.ts`'s `projectActivity` already applies to same-profile peer discovery.
63
+
64
+ `thread.items` is a `ThreadItem[]` whitelist too: text/thinking/note items keep `{ kind, text }` (`text` bounded, see below); tool items carry `{ kind: "tool", name, args, running, isError, output }`, where `args` is the tool's argument object `JSON.stringify`'d (never the raw object). `thread.dropped` is the store's own ring-buffer drop counter (unrelated to the per-push item cap below); `thread.version` increments on every thread mutation.
65
+
66
+ Tasks are ordered `running`, `waiting`, `queued`, then finished tasks by `endedAt` descending (most recently finished first).
67
+
68
+ ## Bounds
69
+
70
+ Every bound below fails closed: a value that cannot fit is truncated or dropped, and `lib/agents-rpc-publisher.ts`'s `encodeActivityLines` never throws.
71
+
72
+ | Field | Bound |
73
+ |---|---|
74
+ | `summary.prompt` | 200 characters, trailing `…` |
75
+ | `summary.error`, `summary.label`, `summary.lastStep` | 500 characters, trailing `…` |
76
+ | tool `args` (stringified) | 500 characters, trailing `…` |
77
+ | tool `output` | 500 characters, trailing `…` |
78
+ | text/thinking/note item `text` | 2000 characters, trailing `…` |
79
+ | `thread.items` per task | last 40, most recent last |
80
+ | whole payload | 256 KiB |
81
+
82
+ Truncation always keeps the field's prefix and marks the cut with a trailing `…` (never a separate `truncated` flag) — the same convention `projectRpcActivity`'s other bounded fields already use.
83
+
84
+ When the whole-payload bound is still exceeded after the field- and item-level truncations above, `encodeActivityLines` shrinks the payload in this order:
85
+
86
+ 1. Halve every task's kept `thread.items` (repeatedly, down to one item each).
87
+ 2. Empty finished tasks' threads entirely.
88
+ 3. Drop whole finished tasks — oldest-finished first, by `endedAt`.
89
+ 4. Last resort: once only active (running/waiting/queued) tasks remain, each already down to one thread item, empty every remaining task's thread too — a summary-only payload.
90
+
91
+ An active task's `summary` (running, waiting or queued) is never dropped; only its `thread.items` shrink. Finished tasks can be dropped whole by step 3, oldest first.
92
+
93
+ ## Coalescing
94
+
95
+ `createRpcActivityPublisher` subscribes to `TaskStore#subscribeSummary` (task added, removed, or changed status) and to `TaskStore#subscribe(id)` for every known task, including ones added after `start()`. Changes inside a 150 ms window collapse into exactly one `setWidget("gentle-agents", [line])` call; `stop()` tears down every subscription and publishes one final frame.