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 +45 -18
- package/bin/gentle-shell.mjs +373 -0
- package/docs/gentle-agents-activity.md +95 -0
- package/docs/readme-reference.md +129 -11
- package/extensions/ask-user-choice.ts +70 -22
- package/extensions/ask-user-question.ts +131 -3
- package/extensions/gentle-agents.ts +33 -0
- package/lib/agents-rpc-publisher.ts +342 -0
- package/lib/agents-runner.ts +7 -2
- package/lib/gentle-shell-launcher.ts +867 -0
- package/lib/rpc-host.ts +36 -0
- package/package.json +5 -1
- package/runtime/gentle-shell-launcher.mjs +868 -0
- package/scripts/build-runtime-modules.mjs +1 -0
- package/scripts/install-gentle-ai.mjs +14 -7
- package/scripts/install-tui-mode-setting.mjs +78 -1
- package/scripts/verify-package-files.mjs +4 -0
- package/tests/agents-rpc-publisher.test.ts +473 -0
- package/tests/agents-runner.test.ts +10 -0
- package/tests/ask-user-choice.test.ts +129 -0
- package/tests/ask-user-question.test.ts +227 -1
- package/tests/gentle-agents.test.ts +136 -0
- package/tests/gentle-shell-bin.test.ts +836 -0
- package/tests/gentle-shell-launcher.test.ts +1367 -0
- package/tests/install-tui-mode-guard.test.ts +99 -0
- package/tests/install-tui-mode-setting.test.ts +39 -1
- package/tests/package-manifest.test.ts +2 -2
- package/tests/rpc-host.test.ts +77 -0
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-
|
|
16
|
-
<a href="https://github.com/Gentleman-Programming/gentle-
|
|
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> · Coding-agent workspace · Focused agents · ODD</p>
|
|
38
38
|
|
|
39
39
|
<p align="center">
|
|
40
|
-
<a href="https://github.com/Gentleman-Programming/gentle-
|
|
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
|
|
188
|
+
### What's new in v3.5
|
|
189
189
|
|
|
190
|
-
The [
|
|
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
|
-
- **
|
|
193
|
-
- **
|
|
194
|
-
- **
|
|
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:
|
|
212
|
-
pi install npm:gentle-pi@
|
|
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 [
|
|
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-
|
|
266
|
-
<a href="https://github.com/Gentleman-Programming/gentle-
|
|
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-
|
|
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-
|
|
275
|
-
- See the people shaping the project in the [contributors graph](https://github.com/Gentleman-Programming/gentle-
|
|
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.
|