gentle-pi 3.4.0 → 3.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +25 -0
- package/bin/gentle-shell.mjs +198 -0
- package/docs/gentle-agents-activity.md +95 -0
- package/docs/readme-reference.md +67 -0
- 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 +482 -0
- package/lib/rpc-host.ts +36 -0
- package/package.json +5 -1
- package/runtime/gentle-shell-launcher.mjs +483 -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 +407 -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 +90 -0
- package/tests/gentle-shell-bin.test.ts +188 -0
- package/tests/gentle-shell-launcher.test.ts +718 -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
|
@@ -229,8 +229,33 @@ See the [v2.6.0 release notes](https://github.com/Gentleman-Programming/gentle-p
|
|
|
229
229
|
|
|
230
230
|
> **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
231
|
|
|
232
|
+
> **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).
|
|
233
|
+
|
|
232
234
|
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
235
|
|
|
236
|
+
### Without touching your pi
|
|
237
|
+
|
|
238
|
+
`gentle-shell` opens Pi with the Gentle Shell package loaded, without installing it into your pi agent or editing its `settings.json`.
|
|
239
|
+
|
|
240
|
+
```bash
|
|
241
|
+
npm i -g gentle-pi
|
|
242
|
+
|
|
243
|
+
# Own home, never touches your pi install
|
|
244
|
+
gentle-shell
|
|
245
|
+
|
|
246
|
+
# Reuse your pi sign-ins, models and chats instead
|
|
247
|
+
gentle-shell --link
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
`gentle-shell` alone starts in its own home, `~/.gentle-shell/agent`. `gentle-shell --link` reuses `~/.pi/agent` as-is.
|
|
251
|
+
|
|
252
|
+
```bash
|
|
253
|
+
# Make --link the default
|
|
254
|
+
gentle-shell home link
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
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)**.
|
|
258
|
+
|
|
234
259
|
<p align="right"><a href="#top">Back to top ↑</a></p>
|
|
235
260
|
|
|
236
261
|
<p align="center">
|
|
@@ -0,0 +1,198 @@
|
|
|
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, readFileSync, 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
|
+
describeVersion,
|
|
17
|
+
helpText,
|
|
18
|
+
launcherConfigPath,
|
|
19
|
+
missingPiMessage,
|
|
20
|
+
parseLauncherArgs,
|
|
21
|
+
parseLauncherConfig,
|
|
22
|
+
planSpawn,
|
|
23
|
+
resolveHome,
|
|
24
|
+
resolvePiRuntime,
|
|
25
|
+
settingsDeclareGentlePi,
|
|
26
|
+
} from "../runtime/gentle-shell-launcher.mjs";
|
|
27
|
+
import { installIsolatedTuiModeSetting } from "../scripts/install-tui-mode-setting.mjs";
|
|
28
|
+
|
|
29
|
+
const packageRoot = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
30
|
+
|
|
31
|
+
function fail(message, code) {
|
|
32
|
+
process.stderr.write(`${message}\n`);
|
|
33
|
+
process.exit(code);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function readJsonIfExists(path) {
|
|
37
|
+
try {
|
|
38
|
+
return readFileSync(path, "utf8");
|
|
39
|
+
} catch (error) {
|
|
40
|
+
if (error.code === "ENOENT") return undefined;
|
|
41
|
+
throw error;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// @earendil-works/pi-coding-agent ships as an optional peer dependency: it may
|
|
46
|
+
// not be installed at all, so a resolution failure here is expected, not an error.
|
|
47
|
+
function resolveBundledCli() {
|
|
48
|
+
try {
|
|
49
|
+
const require = createRequire(import.meta.url);
|
|
50
|
+
const pkgJsonPath = require.resolve("@earendil-works/pi-coding-agent/package.json");
|
|
51
|
+
const cliPath = join(dirname(pkgJsonPath), "dist", "bundle", "cli.js");
|
|
52
|
+
return existsSync(cliPath) ? cliPath : undefined;
|
|
53
|
+
} catch {
|
|
54
|
+
return undefined;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function findOnPath(name) {
|
|
59
|
+
const dirs = (process.env.PATH || "").split(delimiter).filter((entry) => entry.length > 0);
|
|
60
|
+
const extensions = process.platform === "win32" ? (process.env.PATHEXT || ".COM;.EXE;.BAT;.CMD").split(";") : [""];
|
|
61
|
+
for (const dir of dirs) {
|
|
62
|
+
for (const extension of extensions) {
|
|
63
|
+
const candidate = join(dir, `${name}${extension}`);
|
|
64
|
+
try {
|
|
65
|
+
accessSync(candidate, fsConstants.X_OK);
|
|
66
|
+
return candidate;
|
|
67
|
+
} catch {
|
|
68
|
+
// keep scanning
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return undefined;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function signalExitCode(signal) {
|
|
76
|
+
const number = osConstants.signals[signal];
|
|
77
|
+
return 128 + (typeof number === "number" ? number : 0);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function ownPackageVersion() {
|
|
81
|
+
const packageJson = JSON.parse(readFileSync(join(packageRoot, "package.json"), "utf8"));
|
|
82
|
+
return packageJson.version;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function emptyArgs() {
|
|
86
|
+
return { link: false, isolated: false, home: undefined, help: false, version: false, command: undefined, commandArgs: [], passthrough: [], error: undefined };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function loadConfig() {
|
|
90
|
+
const configPath = launcherConfigPath(homedir());
|
|
91
|
+
const text = readJsonIfExists(configPath);
|
|
92
|
+
return text === undefined ? undefined : parseLauncherConfig(text);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function handleHomeCommand(commandArgs) {
|
|
96
|
+
if (commandArgs.length === 0) {
|
|
97
|
+
const resolved = resolveHome({ args: emptyArgs(), env: process.env, homedir: homedir(), config: loadConfig() });
|
|
98
|
+
process.stdout.write(`${resolved.mode} ${resolved.dir}\n`);
|
|
99
|
+
process.exit(0);
|
|
100
|
+
}
|
|
101
|
+
if (commandArgs.length > 1) fail("gentle-shell home accepts at most one argument. Run 'gentle-shell --help'.", 2);
|
|
102
|
+
const [value] = commandArgs;
|
|
103
|
+
if (value.length === 0) fail("gentle-shell home requires a non-empty argument. Run 'gentle-shell --help'.", 2);
|
|
104
|
+
|
|
105
|
+
const configPath = launcherConfigPath(homedir());
|
|
106
|
+
const configDir = dirname(configPath);
|
|
107
|
+
if (!existsSync(configDir)) mkdirSync(configDir, { recursive: true, mode: 0o700 });
|
|
108
|
+
|
|
109
|
+
if (value === "link" || value === "isolated") {
|
|
110
|
+
writeFileSync(configPath, `${JSON.stringify({ home: value }, null, 2)}\n`, "utf8");
|
|
111
|
+
process.stdout.write(`Saved home: ${value}\n`);
|
|
112
|
+
process.exit(0);
|
|
113
|
+
}
|
|
114
|
+
const dir = resolvePath(value);
|
|
115
|
+
writeFileSync(configPath, `${JSON.stringify({ home: dir }, null, 2)}\n`, "utf8");
|
|
116
|
+
process.stdout.write(`Saved home: path ${dir}\n`);
|
|
117
|
+
process.exit(0);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async function main() {
|
|
121
|
+
const args = parseLauncherArgs(process.argv.slice(2));
|
|
122
|
+
if (args.error !== undefined) fail(`${args.error}\nRun 'gentle-shell --help' for usage.`, 2);
|
|
123
|
+
if (args.help) {
|
|
124
|
+
process.stdout.write(`${helpText()}\n`);
|
|
125
|
+
process.exit(0);
|
|
126
|
+
}
|
|
127
|
+
if (args.command === "home") {
|
|
128
|
+
handleHomeCommand(args.commandArgs);
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const config = loadConfig();
|
|
133
|
+
let home = resolveHome({ args, env: process.env, homedir: homedir(), config });
|
|
134
|
+
if (home.mode === "path") home = { ...home, dir: resolvePath(home.dir) };
|
|
135
|
+
|
|
136
|
+
const runtime = resolvePiRuntime({
|
|
137
|
+
env: process.env,
|
|
138
|
+
resolveBundledCli,
|
|
139
|
+
findOnPath,
|
|
140
|
+
nodeExecPath: process.execPath,
|
|
141
|
+
});
|
|
142
|
+
if (runtime === undefined) fail(missingPiMessage(), 1);
|
|
143
|
+
|
|
144
|
+
const versionProbePlan = planSpawn({ command: runtime.command, args: [...runtime.args, "--version"], platform: process.platform });
|
|
145
|
+
const versionProbe = spawnSync(versionProbePlan.command, versionProbePlan.args, {
|
|
146
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
147
|
+
timeout: 15000,
|
|
148
|
+
encoding: "utf8",
|
|
149
|
+
shell: versionProbePlan.shell,
|
|
150
|
+
});
|
|
151
|
+
if (versionProbe.error) fail(`Could not run the pi runtime at "${runtime.command}": ${versionProbe.error.message}`, 1);
|
|
152
|
+
const versionCheck = checkPiVersion(versionProbe.stdout ?? "");
|
|
153
|
+
if (!versionCheck.ok) fail(versionCheck.message, 1);
|
|
154
|
+
|
|
155
|
+
if (args.version) {
|
|
156
|
+
process.stdout.write(`${describeVersion({ gentlePiVersion: ownPackageVersion(), piVersion: versionCheck.version, home })}\n`);
|
|
157
|
+
process.exit(0);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Isolated-home bootstrap: only on a home gentle-shell has not seen before
|
|
161
|
+
// (link never bootstraps — it reuses the user's own pi agent home as-is).
|
|
162
|
+
if ((home.mode === "isolated" || home.mode === "path") && !existsSync(home.dir)) {
|
|
163
|
+
mkdirSync(home.dir, { recursive: true });
|
|
164
|
+
await installIsolatedTuiModeSetting(home.dir);
|
|
165
|
+
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`);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
let linkDeclaresGentlePi = false;
|
|
169
|
+
if (home.mode === "link") {
|
|
170
|
+
const settingsText = readJsonIfExists(join(home.dir, "settings.json"));
|
|
171
|
+
linkDeclaresGentlePi = settingsDeclareGentlePi(settingsText);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const invocation = buildPiInvocation({
|
|
175
|
+
runtime,
|
|
176
|
+
home,
|
|
177
|
+
packageRoot,
|
|
178
|
+
settingsDeclareGentlePi: home.mode === "link" ? linkDeclaresGentlePi : false,
|
|
179
|
+
passthrough: args.passthrough,
|
|
180
|
+
piSubcommand: args.piSubcommand,
|
|
181
|
+
baseEnv: process.env,
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
const launchPlan = planSpawn({ command: invocation.command, args: invocation.args, platform: process.platform });
|
|
185
|
+
const child = spawn(launchPlan.command, launchPlan.args, { stdio: "inherit", env: invocation.env, shell: launchPlan.shell });
|
|
186
|
+
for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"]) {
|
|
187
|
+
process.on(signal, () => child.kill(signal));
|
|
188
|
+
}
|
|
189
|
+
child.on("error", (error) => fail(`Could not start pi: ${error.message}`, 1));
|
|
190
|
+
child.on("exit", (code, signal) => {
|
|
191
|
+
process.exit(signal ? signalExitCode(signal) : (code ?? 1));
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
main().catch((error) => {
|
|
196
|
+
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
|
|
197
|
+
process.exit(1);
|
|
198
|
+
});
|
|
@@ -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.
|
package/docs/readme-reference.md
CHANGED
|
@@ -225,6 +225,73 @@ An orphan branch with commits and no parent has no branch point to name as `base
|
|
|
225
225
|
- Create an empty root commit to open the branch: `git commit --allow-empty -m "chore: open the feature branch"`. The next commit can then use that root commit as its `baseRef`.
|
|
226
226
|
- Omit `baseRef` while the branch is still unborn (no commits yet); the review uses Git's empty tree as the base automatically.
|
|
227
227
|
|
|
228
|
+
## gentle-shell launcher
|
|
229
|
+
|
|
230
|
+
`gentle-shell` (installed by `npm i -g gentle-pi`, exposed as the package's `bin`) opens pi with the Gentle Shell package loaded, without installing it into your pi agent or touching its `settings.json`. It is a thin `bin/gentle-shell.mjs` wrapper around the pure, unit-tested `lib/gentle-shell-launcher.ts` (built to `runtime/gentle-shell-launcher.mjs`); the wrapper owns process, filesystem, and child-process wiring only.
|
|
231
|
+
|
|
232
|
+
```bash
|
|
233
|
+
gentle-shell [options] [-- pi-args...]
|
|
234
|
+
gentle-shell home [link|isolated|<path>]
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
### Flags
|
|
238
|
+
|
|
239
|
+
| Flag | Effect |
|
|
240
|
+
| --- | --- |
|
|
241
|
+
| `--link` | Home is `PI_CODING_AGENT_DIR` or `~/.pi/agent`. Reuses your existing pi sign-ins, models, and chats; never writes to its `settings.json`. |
|
|
242
|
+
| `--isolated` | Home is `GENTLE_SHELL_HOME` or `~/.gentle-shell/agent`. No credential seeding. Default when nothing else is configured. |
|
|
243
|
+
| `--home <path>` | Home is the given directory. |
|
|
244
|
+
| `--help`, `-h` | Print usage (flags, commands, env vars) and exit 0. |
|
|
245
|
+
| `--version` | Print `gentle-shell <version>`, `pi <version>`, and `home <mode> <dir>`, then exit 0. |
|
|
246
|
+
| `--` | Everything after is forwarded to pi verbatim, even text that looks like a `gentle-shell` flag. |
|
|
247
|
+
|
|
248
|
+
`--link`, `--isolated`, and `--home` are mutually exclusive; combining two is a usage error, as is `--home` or `--home=` with an empty value. Effective-home precedence: an explicit flag wins, then the persisted `home` subcommand choice, then the `--isolated` default. Every argument gentle-shell does not recognize — `--mode rpc`, `-p "..."`, etc. — is forwarded to pi unchanged.
|
|
249
|
+
|
|
250
|
+
### `home` subcommand and `~/.gentle-shell/config.json`
|
|
251
|
+
|
|
252
|
+
`gentle-shell home` alone prints the effective mode and directory (`<mode> <dir>`) without persisting anything. `gentle-shell home link`, `gentle-shell home isolated`, or `gentle-shell home <path>` persists that choice to `~/.gentle-shell/config.json` as `{"home": "link" | "isolated" | "<path>"}`, so a later plain `gentle-shell` picks it up; a flag on a given invocation still overrides the persisted config without rewriting it.
|
|
253
|
+
|
|
254
|
+
### Managing packages
|
|
255
|
+
|
|
256
|
+
`gentle-shell install npm:<pkg>`, `gentle-shell remove ...`, `gentle-shell list`, `gentle-shell update ...`, `gentle-shell config`, and `gentle-shell auth ...` run pi's own commands against the resolved home — the `--isolated` home by default, or your own pi home with `--link`. A launcher flag before the subcommand (`--link`, `--isolated`, `--home <path>`) still selects which home the subcommand runs against. Running `gentle-shell install npm:gentle-pi` inside the isolated home is unnecessary: the launcher already loads the Gentle Shell package itself (see "Loading the package" below).
|
|
257
|
+
|
|
258
|
+
### pi runtime resolution
|
|
259
|
+
|
|
260
|
+
1. `GENTLE_SHELL_PI` — path to a pi executable, when set to a non-empty value.
|
|
261
|
+
2. The bundled `@earendil-works/pi-coding-agent` resolved next to gentle-pi (`dist/bundle/cli.js`, run with the current `node`), when installed as its optional peer dependency.
|
|
262
|
+
3. `pi` on `PATH`.
|
|
263
|
+
|
|
264
|
+
If none resolve, `gentle-shell` exits 1 naming all three options. Once a runtime is found, its `pi --version` must be at least `0.85.1` (the pinned peer minimum): an older version exits 1 naming the found and required versions, and unparsable `--version` output exits 1 naming the required minimum.
|
|
265
|
+
|
|
266
|
+
### Environment variables
|
|
267
|
+
|
|
268
|
+
| Variable | Effect |
|
|
269
|
+
| --- | --- |
|
|
270
|
+
| `GENTLE_SHELL_PI` | Overrides pi runtime resolution (see above). |
|
|
271
|
+
| `GENTLE_SHELL_HOME` | Overrides the isolated home directory (default `~/.gentle-shell/agent`). |
|
|
272
|
+
| `PI_CODING_AGENT_DIR` | Read to resolve the `--link` home; also set on the pi child process to the effective home. |
|
|
273
|
+
| `GENTLE_PI_AGENT_HOME` | Set on the pi child process to the effective home; gentle-pi's own home resolution reads it back. |
|
|
274
|
+
|
|
275
|
+
### Loading the package
|
|
276
|
+
|
|
277
|
+
Unless the target home's `settings.json` already lists `npm:gentle-pi` in its `packages` array (checked only for `--link`), every invocation injects `-e <package root> --theme <root>/themes --skill <root>/skills --prompt-template <root>/prompts` ahead of the forwarded arguments, so the Gentle Shell extensions, themes, skills, and prompt templates load without a separate `pi install`. Isolated and `--home <path>` homes never declare the package, so they always get the injection — except when the forwarded arguments start with one of pi's own subcommands (`install`, `remove`, `uninstall`, `update`, `list`, `config`, `auth`): pi dispatches those on `argv[0]` before it parses any flags, so the injection is skipped entirely and pi sees the bare subcommand, e.g. `gentle-shell install npm:x` runs exactly `pi install npm:x`.
|
|
278
|
+
|
|
279
|
+
### First run in an isolated or custom home
|
|
280
|
+
|
|
281
|
+
The first time `gentle-shell` resolves to an isolated or `--home <path>` home that does not already exist, it creates the directory, writes `"tuiMode": "fullscreen"` into its `settings.json`, and prints one hint to stderr pointing at `--link`. A `--link` home is never bootstrapped this way — it is assumed to already exist as your pi agent home. Later runs against the same home skip both the write and the hint.
|
|
282
|
+
|
|
283
|
+
### Windows shims
|
|
284
|
+
|
|
285
|
+
On win32, when the resolved pi command ends in `.cmd` or `.bat` — the shape an npm-installed `pi` or a `GENTLE_SHELL_PI` override commonly takes — `gentle-shell` runs it through `cmd.exe` as one quoted command line instead of spawning it directly, because current Node releases refuse to spawn a batch file without `shell: true`. This applies to both the version probe and the real launch.
|
|
286
|
+
|
|
287
|
+
### Postinstall fullscreen guard
|
|
288
|
+
|
|
289
|
+
gentle-pi's postinstall only writes the global `tuiMode: fullscreen` setting when the running package directory is a pi-managed install: under an `npm/node_modules` segment, or the exact `git/github.com/Gentleman-Programming` Git layout. `npm i -g gentle-pi`, a development checkout, and other layouts are recognized and skipped, logging `gentle-pi skipped enabling fullscreen in global Pi settings: <dir> is not a pi-managed install (npm install -g, a git checkout, and npx all land here).`
|
|
290
|
+
|
|
291
|
+
### Interactive RPC hosts
|
|
292
|
+
|
|
293
|
+
Setting `GENTLE_SHELL_INTERACTIVE_HOST=1` on a `pi --mode rpc` process turns on two things a plain headless RPC host does not get: dialogs for `ask_user_question` and `ask_user_choice` (one `ctx.ui.select` prompt per question, looped for multiSelect), and Gentle Agents' helper activity pushed live through `setWidget`. A subagent child spawned by such a host never inherits the variable, so nested children stay headless regardless of their parent. See the [activity payload reference](gentle-agents-activity.md) for the exact schema, field bounds, and shrink order.
|
|
294
|
+
|
|
228
295
|
## Quick start
|
|
229
296
|
|
|
230
297
|
```text
|
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
1
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { DynamicBorder } from "@earendil-works/pi-coding-agent";
|
|
3
3
|
import { Container, Input, isKeyRelease, matchesKey, Text, type KeybindingsManager, type TuiMouseEvent } from "@earendil-works/pi-tui";
|
|
4
4
|
import { type Static, Type } from "typebox";
|
|
5
5
|
import { NativeChoiceList } from "../lib/native-choice-list.ts";
|
|
6
6
|
import { createNativeFullscreenInteraction } from "../lib/native-fullscreen-interaction.ts";
|
|
7
|
+
import { isInteractiveMode, isInteractiveRpcHost } from "../lib/rpc-host.ts";
|
|
7
8
|
|
|
8
9
|
const CHOICE_TOOL_NAME = "ask_user_choice";
|
|
9
10
|
const ASK_USER_CHOICE_BLOCKED_EVENT = "gentle-pi:ask-user-choice:blocked";
|
|
@@ -147,11 +148,11 @@ class ChoiceModeView extends Container {
|
|
|
147
148
|
}
|
|
148
149
|
}
|
|
149
150
|
|
|
150
|
-
function reconcileToolAvailability(pi: ExtensionAPI,
|
|
151
|
+
function reconcileToolAvailability(pi: ExtensionAPI, interactive: boolean): void {
|
|
151
152
|
const active = pi.getActiveTools();
|
|
152
153
|
const isActive = active.includes(CHOICE_TOOL_NAME);
|
|
153
|
-
if (
|
|
154
|
-
const next =
|
|
154
|
+
if (interactive === isActive) return;
|
|
155
|
+
const next = interactive
|
|
155
156
|
? [...new Set([...active, CHOICE_TOOL_NAME])]
|
|
156
157
|
: active.filter((name) => name !== CHOICE_TOOL_NAME);
|
|
157
158
|
pi.setActiveTools(next);
|
|
@@ -161,6 +162,57 @@ function resultDetails(params: ChoiceParams): ChoiceDetails {
|
|
|
161
162
|
return { question: params.question, options: params.options };
|
|
162
163
|
}
|
|
163
164
|
|
|
165
|
+
interface ChoiceToolResult {
|
|
166
|
+
content: Array<{ type: "text"; text: string }>;
|
|
167
|
+
details: ChoiceDetails;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Same result shapes for both the TUI and the RPC-dialog fallback. */
|
|
171
|
+
function choiceToolResult(params: ChoiceParams, selection: ChoiceResult | undefined): ChoiceToolResult {
|
|
172
|
+
if (selection === undefined) {
|
|
173
|
+
return {
|
|
174
|
+
content: [{ type: "text", text: "User cancelled the choice" }],
|
|
175
|
+
details: { ...resultDetails(params), cancelled: true },
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
if ("customResponse" in selection) {
|
|
179
|
+
return {
|
|
180
|
+
content: [{ type: "text", text: `User responded: ${selection.customResponse}` }],
|
|
181
|
+
details: { ...resultDetails(params), customResponse: selection.customResponse },
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
return {
|
|
185
|
+
content: [{ type: "text", text: `User selected: ${selection.index}. ${selection.label} (value: ${selection.value})` }],
|
|
186
|
+
details: { ...resultDetails(params), selection },
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** Label for the opt-in free-text entry appended to the RPC-dialog select options. */
|
|
191
|
+
const OTHER_OPTION_LABEL = "Other…";
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Interactive-RPC-host fallback: one `ctx.ui.select` over the option labels
|
|
195
|
+
* (plus "Other…" when `allowCustomResponse`), then `ctx.ui.input` for the
|
|
196
|
+
* free-text response. Keeps the exact TUI result shapes; a cancel at either
|
|
197
|
+
* step cancels the choice, matching the TUI Escape key.
|
|
198
|
+
*/
|
|
199
|
+
async function askThroughDialogs(
|
|
200
|
+
ctx: Pick<ExtensionContext, "ui">,
|
|
201
|
+
params: ChoiceParams,
|
|
202
|
+
): Promise<ChoiceResult | undefined> {
|
|
203
|
+
const labels = params.options.map((choiceOption) => choiceOption.label);
|
|
204
|
+
const dialogOptions = params.allowCustomResponse === true ? [...labels, OTHER_OPTION_LABEL] : labels;
|
|
205
|
+
const picked = await ctx.ui.select(params.question, dialogOptions);
|
|
206
|
+
if (picked === undefined) return undefined;
|
|
207
|
+
if (params.allowCustomResponse === true && picked === OTHER_OPTION_LABEL) {
|
|
208
|
+
const customResponse = await ctx.ui.input(params.question, "Type your response");
|
|
209
|
+
return customResponse === undefined ? undefined : { customResponse };
|
|
210
|
+
}
|
|
211
|
+
const index = params.options.findIndex((choiceOption) => choiceOption.label === picked);
|
|
212
|
+
const option = params.options[index];
|
|
213
|
+
return option === undefined ? undefined : { value: option.value, label: option.label, index: index + 1 };
|
|
214
|
+
}
|
|
215
|
+
|
|
164
216
|
export default function askUserChoice(pi: ExtensionAPI): void {
|
|
165
217
|
pi.registerTool({
|
|
166
218
|
name: CHOICE_TOOL_NAME,
|
|
@@ -175,7 +227,18 @@ export default function askUserChoice(pi: ExtensionAPI): void {
|
|
|
175
227
|
executionMode: "sequential",
|
|
176
228
|
async execute(_toolCallId, params: ChoiceParams, _signal, _onUpdate, ctx) {
|
|
177
229
|
if (ctx.mode !== "tui") {
|
|
178
|
-
|
|
230
|
+
if (!isInteractiveRpcHost(ctx.mode, process.env)) {
|
|
231
|
+
throw new Error("ask_user_choice is unavailable outside the interactive TUI");
|
|
232
|
+
}
|
|
233
|
+
let rpcSelection: ChoiceResult | undefined;
|
|
234
|
+
try {
|
|
235
|
+
pi.events.emit(ASK_USER_CHOICE_BLOCKED_EVENT, { active: true });
|
|
236
|
+
rpcSelection = await askThroughDialogs(ctx, params);
|
|
237
|
+
}
|
|
238
|
+
finally {
|
|
239
|
+
pi.events.emit(ASK_USER_CHOICE_BLOCKED_EVENT, { active: false });
|
|
240
|
+
}
|
|
241
|
+
return choiceToolResult(params, rpcSelection);
|
|
179
242
|
}
|
|
180
243
|
|
|
181
244
|
const items = params.options.map((option, index) => ({
|
|
@@ -239,22 +302,7 @@ export default function askUserChoice(pi: ExtensionAPI): void {
|
|
|
239
302
|
pi.events.emit(ASK_USER_CHOICE_BLOCKED_EVENT, { active: false });
|
|
240
303
|
}
|
|
241
304
|
|
|
242
|
-
|
|
243
|
-
return {
|
|
244
|
-
content: [{ type: "text", text: "User cancelled the choice" }],
|
|
245
|
-
details: { ...resultDetails(params), cancelled: true },
|
|
246
|
-
};
|
|
247
|
-
}
|
|
248
|
-
if ("customResponse" in selection) {
|
|
249
|
-
return {
|
|
250
|
-
content: [{ type: "text", text: `User responded: ${selection.customResponse}` }],
|
|
251
|
-
details: { ...resultDetails(params), customResponse: selection.customResponse },
|
|
252
|
-
};
|
|
253
|
-
}
|
|
254
|
-
return {
|
|
255
|
-
content: [{ type: "text", text: `User selected: ${selection.index}. ${selection.label} (value: ${selection.value})` }],
|
|
256
|
-
details: { ...resultDetails(params), selection },
|
|
257
|
-
};
|
|
305
|
+
return choiceToolResult(params, selection);
|
|
258
306
|
},
|
|
259
307
|
renderCall(args, theme) {
|
|
260
308
|
const options = Array.isArray(args.options) ? args.options : [];
|
|
@@ -280,6 +328,6 @@ export default function askUserChoice(pi: ExtensionAPI): void {
|
|
|
280
328
|
});
|
|
281
329
|
|
|
282
330
|
pi.on("before_agent_start", (_event, ctx) => {
|
|
283
|
-
reconcileToolAvailability(pi, ctx.mode
|
|
331
|
+
reconcileToolAvailability(pi, isInteractiveMode(ctx.mode, process.env));
|
|
284
332
|
});
|
|
285
333
|
}
|