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.
@@ -135,15 +135,33 @@ Callers own keyboard policy, theme state, and business actions.
135
135
 
136
136
  ## Install
137
137
 
138
+ Two paths reach the same package. Path A stays standalone; Path B installs into an existing pi.
139
+
140
+ ### Path A: standalone `gentle-shell` (recommended, no pi changes)
141
+
138
142
  ```bash
139
- pi install npm:gentle-pi@2.6.0
143
+ npm i -g gentle-pi
144
+
145
+ # Own home, never touches your pi install
146
+ gentle-shell
147
+
148
+ # Reuse your pi sign-ins, models and chats instead
149
+ gentle-shell --link
140
150
  ```
141
151
 
142
- The stable release is [`v2.6.0`](https://github.com/Gentleman-Programming/gentle-pi/releases/tag/v2.6.0). Restart Pi after installation, then run `gentle-ai sync`. That published release pairs with Gentle AI `v2.8.0` and provider contract `1.2.0`; capabilities `v2.5` are retained. The command above installs that exact published version.
152
+ `gentle-shell` alone starts in its own home, `~/.gentle-shell/agent`. `gentle-shell --link` reuses `~/.pi/agent` as-is. Run `gentle-shell home link` to make `--link` the default. Full flags, env vars, and modes: [gentle-shell launcher](#gentle-shell-launcher).
153
+
154
+ ### Path B: inside an existing pi
155
+
156
+ ```bash
157
+ pi install npm:gentle-pi@3.5.1
158
+ ```
159
+
160
+ The stable release is [`v3.5.1`](https://github.com/Gentleman-Programming/gentle-shell/releases/tag/v3.5.1). Restart Pi after installation, then run `gentle-ai sync`. That published release pairs with Gentle AI `v2.8.0` and provider contract `1.2.0`; capabilities `v2.5` are retained. The command above installs that exact published version.
143
161
 
144
162
  ### Source checkout
145
163
 
146
- This checkout prepares `gentle-pi` `3.4.0`; it is source state, not a published release. Its package-local native runtime pin is Gentle AI `v3.5.0`, distinct from the published `v2.6.0` pairing.
164
+ This checkout prepares `gentle-pi` `3.5.1`; it is source state, not a published release. Its package-local native runtime pin is Gentle AI `v3.5.0`, distinct from the published `v3.5.1` pairing.
147
165
 
148
166
  The native SDD status consumer accepts both the pinned producer's legacy
149
167
  `apply`/`verify`/`remediate`/`archive` instruction record and the classical
@@ -161,13 +179,13 @@ Classical direct-archive behavior is compatibility-tested with an identified
161
179
  upstream development build, not presented as a published fix or version bump.
162
180
  The complete classical flow awaits a compatible published native version; this
163
181
  change does not bump the pin. Ordinary attempt governance and research/planning simplification remain separate
164
- work under [SDD parity #1051](https://github.com/Gentleman-Programming/gentle-pi/issues/1051).
182
+ work under [SDD parity #1051](https://github.com/Gentleman-Programming/gentle-shell/issues/1051).
165
183
 
166
184
  ### Pi compatibility
167
185
 
168
186
  The current package requires Pi 0.85.1 or newer (development tests pin 0.85.1). Use the latest Pi release; gentle-pi does not update your installed Pi automatically. Children, including any `GENTLE_PI_AGENTS_PI` override, must emit `agent_settled`: `agent_end` records a run's output but is not completion because retries or queued continuations may follow.
169
187
 
170
- The [`v2.6.0` release](https://github.com/Gentleman-Programming/gentle-pi/releases/tag/v2.6.0) adds persistent registered worktrees and grouped `/gentle:changes` views; fuller workspace interaction details are in the [Gentle Shell reference](gentle-shell.md). It also adds named atomic `/gentle:profiles`, parent-confirmed native SDD preflight transport, native review intended-untracked selection and provider continuations, and opt-in custom ask responses. Pi recognizes its global Git-managed package path; subsystems install with explicit recovery guidance when npm lifecycle work was skipped. Windows keeps child consoles hidden and fixes ownership mode; Gentle Todo keeps the next pending task visible when collapsed.
188
+ The [`v2.6.0` release](https://github.com/Gentleman-Programming/gentle-shell/releases/tag/v2.6.0) added persistent registered worktrees and grouped `/gentle:changes` views; fuller workspace interaction details are in the [Gentle Shell reference](gentle-shell.md). It also adds named atomic `/gentle:profiles`, parent-confirmed native SDD preflight transport, native review intended-untracked selection and provider continuations, and opt-in custom ask responses. Pi recognizes its global Git-managed package path; subsystems install with explicit recovery guidance when npm lifecycle work was skipped. Windows keeps child consoles hidden and fixes ownership mode; Gentle Todo keeps the next pending task visible when collapsed.
171
189
 
172
190
  ### Install-time fullscreen
173
191
 
@@ -179,18 +197,30 @@ Malformed/nonobject JSON, symlink/nonregular settings, unsafe paths, or a busy s
179
197
 
180
198
  ### RDD history and opt-in
181
199
 
182
- Native RDD was introduced in `gentle-pi` `v0.15.0` on 2026-07-10 with bounded review transactions. The current stable release, [`v2.6.0`](https://github.com/Gentleman-Programming/gentle-pi/releases/tag/v2.6.0), includes native RDD:
200
+ Native RDD was introduced in `gentle-pi` `v0.15.0` on 2026-07-10 with bounded review transactions. The current stable release, [`v3.5.1`](https://github.com/Gentleman-Programming/gentle-shell/releases/tag/v3.5.1), includes native RDD:
183
201
 
184
202
  ```bash
185
203
  # Stable release
186
- pi install npm:gentle-pi@2.6.0
204
+ pi install npm:gentle-pi@3.5.1
187
205
  ```
188
206
 
189
207
  RDD remains opt-in. Enable it only through an explicit user decision with `/gentle:review-mode enable`; `status` lets you inspect the mode without changing it.
190
208
 
191
209
  The source checkout's RDD integration installs Gentle AI only into its private `.gentle-ai/` directory. Darwin and Linux use pinned release assets with asset and executable SHA-256 verification (signed archives for source pin `v3.5.0`; raw prerelease binaries only under a prerelease pin). Windows x64 and arm64 build the exact `v3.5.0` source tag with a local Go 1.25.10+ toolchain, a sealed Go environment, `GOTOOLCHAIN=local`, and `GOSUMDB=sum.golang.org`; it does not download Go automatically. Windows provenance is Go-toolchain plus SumDB evidence and postinstall tamper detection, **not** Authenticode or protection against a malicious joint binary-and-manifest replacement. Package-private locks coordinate cooperative concurrent or crashed installers; their tombstones fail closed. A malicious same-user process with write access to package-private `node_modules` is outside that protocol because it can already replace package code, binary, or manifest, and portable Node has no pathname-delete CAS. It never uses `PATH` or a global `gentle-ai` installation. For development or offline installs only, set `GENTLE_PI_SKIP_GENTLE_AI_INSTALL=1`; native review operations then fail closed with an actionable `package-local-binary-missing` error. To recover explicitly, if `GENTLE_PI_SKIP_GENTLE_AI_INSTALL` is set, remove or unset it before changing to the installed `gentle-pi` package directory. Then run `node scripts/install-gentle-ai.mjs`. This invokes the package-owned installer without relying on a global binary or npm configuration change. A missing binary can result from skipped lifecycle scripts, but does not prove that lifecycle scripts were disabled.
192
210
 
193
- Recommended companion packages:
211
+ Recommended companion packages, into the standalone `gentle-shell` home:
212
+
213
+ ```bash
214
+ gentle-shell install npm:pi-intercom
215
+ gentle-shell install npm:gentle-engram
216
+ gentle-shell install npm:pi-web-access
217
+ gentle-shell install npm:pi-lens
218
+ gentle-shell install npm:@juicesharp/rpiv-ask-user-question
219
+ ```
220
+
221
+ `--link` before the subcommand (for example `gentle-shell --link install npm:pi-intercom`) targets `~/.pi/agent` instead of the isolated home.
222
+
223
+ Or, when `gentle-pi` is installed inside an existing pi:
194
224
 
195
225
  ```bash
196
226
  pi install npm:pi-intercom
@@ -225,6 +255,94 @@ An orphan branch with commits and no parent has no branch point to name as `base
225
255
  - 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
256
  - Omit `baseRef` while the branch is still unborn (no commits yet); the review uses Git's empty tree as the base automatically.
227
257
 
258
+ ## gentle-shell launcher
259
+
260
+ `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.
261
+
262
+ ```bash
263
+ gentle-shell [options] [-- pi-args...]
264
+ gentle-shell home [link|isolated|<path>]
265
+ ```
266
+
267
+ ### Flags
268
+
269
+ | Flag | Effect |
270
+ | --- | --- |
271
+ | `--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`. |
272
+ | `--isolated` | Home is `GENTLE_SHELL_HOME` or `~/.gentle-shell/agent`. No credential seeding. Default when nothing else is configured. |
273
+ | `--home <path>` | Home is the given directory. |
274
+ | `--package-root <dir>` | Force this directory as the gentle-pi package to load, taking over from any conflicting package the target `settings.json` already declares (see "Loading the package" below). |
275
+ | `--help`, `-h` | Print usage (flags, commands, env vars) and exit 0. |
276
+ | `--version` | Print `gentle-shell <version>`, `pi <version>`, and `home <mode> <dir>`, then exit 0. |
277
+ | `--` | Everything after is forwarded to pi verbatim, even text that looks like a `gentle-shell` flag. |
278
+
279
+ `--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.
280
+
281
+ ### `home` subcommand and `~/.gentle-shell/config.json`
282
+
283
+ `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.
284
+
285
+ ### Managing packages
286
+
287
+ `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).
288
+
289
+ `gentle-shell update` and `gentle-shell list` follow that same home selection, so they inspect and update packages in whichever home the effective flag or persisted `home` config points to.
290
+
291
+ ### pi runtime resolution
292
+
293
+ 1. `GENTLE_SHELL_PI` — path to a pi executable, when set to a non-empty value.
294
+ 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.
295
+ 3. `pi` on `PATH`.
296
+
297
+ 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.
298
+
299
+ ### Environment variables
300
+
301
+ | Variable | Effect |
302
+ | --- | --- |
303
+ | `GENTLE_SHELL_PI` | Overrides pi runtime resolution (see above). |
304
+ | `GENTLE_SHELL_HOME` | Overrides the isolated home directory (default `~/.gentle-shell/agent`). |
305
+ | `PI_CODING_AGENT_DIR` | Read to resolve the `--link` home; also set on the pi child process to the effective home. |
306
+ | `GENTLE_PI_AGENT_HOME` | Set on the pi child process to the effective home; gentle-pi's own home resolution reads it back. |
307
+
308
+ ### Loading the package
309
+
310
+ Unless the target home's `settings.json` already declares gentle-pi (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 this 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 — and any take-over below — is skipped entirely and pi sees the bare subcommand, e.g. `gentle-shell install npm:x` runs exactly `pi install npm:x`. A subcommand never triggers a take-over, even against a home whose settings declare a conflicting gentle-pi; see "Managing packages" above.
311
+
312
+ A declaration is recognized either as `npm:gentle-pi[@version]` in the `packages` array, or as a local path package (string or `{"source": "..."}` entry, relative or absolute) whose own `package.json` names it `"gentle-pi"` — the shape produced when gentle-pi is developed from a checkout and referenced by path in `settings.json` instead of installed via `pi install npm:gentle-pi`.
313
+
314
+ - **A pi subcommand as the first forwarded argument**: no injection and no take-over at all, regardless of any declaration — pi must see the bare subcommand as `argv[0]`.
315
+ - **npm declaration matching this launcher's own install**: no injection — pi already loads gentle-pi from the declared package.
316
+ - **No declaration at all, or a path declaration that resolves (after `realpath`) to this launcher's own package root**: the same plain injection as above.
317
+ - **A declaration that resolves to a *different* gentle-pi** (a different checkout declared by path, for example) **— take-over**: `gentle-shell` prints `taking over gentle-pi from <declared source> for this run (settings unchanged; its skills, prompts, and themes still load alongside this launcher's)` to stderr, then runs pi with `--no-extensions` followed by an explicit `-e <dir>` for every *other* package already in settings (npm entries resolve to `<agent dir>/npm/node_modules/<name>`; path entries resolve relative to the settings file), then loose extension entries for `<agent dir>/extensions` and the project-local `<cwd>/.pi/extensions` (each candidate directory only consulted when it already exists), and finally its own `-e <package root> --theme ... --skill ... --prompt-template ...`. `settings.json` itself is never modified, and every `-e` path — including the launcher's own package root — is injected at most once even if it would otherwise repeat.
318
+
319
+ A declared *other* package whose resolved directory does not actually exist (a hand-edited `settings.json`, a failed or interrupted `pi install`, or an npm store laid out anywhere other than `<agent dir>/npm/node_modules`) is skipped with a stderr warning naming the source and the resolved path, instead of being handed to pi as an unresolvable `-e` that would fail the whole launch with "Cannot find module".
320
+
321
+ `--no-extensions` disables pi's normal directory-discovery pass, and pi's `-e` flag hands a path straight to its module loader with no discovery of its own — passing a loose extensions directory as-is via `-e <dir>` fails with "Cannot find module" unless that directory is itself a self-contained extension. So each loose candidate directory is resolved before injection: a directory that is itself a self-contained extension (a `package.json` declaring a `pi.extensions` manifest) is passed through as a single `-e <dir>`; otherwise its direct `*.ts`/`*.js`/`*.mjs` files — including a root-level `index.ts`/`index.js`, which is just another loose file — and any `<subdir>/index.ts`/`index.js` are discovered individually — mirroring pi's own directory scan — and each is injected as its own `-e <file>`. Hidden entries (dotfiles) and `*.d.ts` declaration files are skipped, since neither was ever a runnable extension.
322
+
323
+ A git-sourced other package is skipped with a stderr warning, since its install directory cannot be derived without pi's own package manager; an object entry with `extensions` or `autoload` filters is still included but warned about, because the take-over cannot honor those filters for extension discovery — that package's skills, prompts, and themes still load normally through settings discovery, which `--no-extensions` does not affect.
324
+
325
+ **Known limitation**: the take-over never removes the original declaration from `settings.json`, so its skills, prompt templates, and themes are still discovered alongside this launcher's own — only its extensions are replaced by `--no-extensions` plus the injected `-e` flags above.
326
+ - **`--package-root <dir>`**: forces a take-over using `<dir>` as the package root, even when settings already declare a matching `npm:gentle-pi`, or when there is no declaration at all. Use it to test a different gentle-pi checkout against a home whose settings already point at another one. Has no effect when the forwarded arguments start with a pi subcommand, since a subcommand skips the take-over entirely. The take-over path itself is only ever reached for `--link`: with `--isolated` or `--home <path>`, `--package-root` still changes which directory is injected, but always through the same plain injection as "no declaration at all" above — no `--no-extensions`, and no other-package or loose-extension re-injection — since those homes never carry a `settings.json` declaration to take over from. `--package-root` must also name an existing directory; a missing or non-directory path fails fast with a clear error instead of launching pi with unresolvable flags.
327
+
328
+ This take-over exists because two gentle-pi copies loaded at once — the declared one plus this launcher's own injection — register the same tools and extensions twice, which pi reports as tool conflicts (for example `Tool ask_user_choice conflicts with ...`).
329
+
330
+ ### First run in an isolated or custom home
331
+
332
+ 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.
333
+
334
+ ### Windows shims
335
+
336
+ 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.
337
+
338
+ ### Postinstall fullscreen guard
339
+
340
+ 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).`
341
+
342
+ ### Interactive RPC hosts
343
+
344
+ 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.
345
+
228
346
  ## Quick start
229
347
 
230
348
  ```text
@@ -308,7 +426,7 @@ Reconciliation is intentionally narrow: native code may quarantine only the boun
308
426
 
309
427
  Native lifecycle status remains informational. VALIDATE does not authorize delivery; commit, push, PR, and release commands follow ordinary repository policy. Recovery grants no new budget, and legacy graph bundle export/import is retired.
310
428
 
311
- This is the post-U8 boundary, not the final architecture. [Issue #191](https://github.com/Gentleman-Programming/gentle-pi/issues/191) is the immediate final unit in this same delivery: extract the remaining Pi command-projection and lifecycle-gate surface from `review-transaction.ts`, repoint runtime enforcement, then delete only dependencies proven unreachable without weakening graph-v1 Judgment Day. The branch-wide High-tier 4R runs after that extraction, before the single size-exception PR.
429
+ This is the post-U8 boundary, not the final architecture. [Issue #191](https://github.com/Gentleman-Programming/gentle-shell/issues/191) is the immediate final unit in this same delivery: extract the remaining Pi command-projection and lifecycle-gate surface from `review-transaction.ts`, repoint runtime enforcement, then delete only dependencies proven unreachable without weakening graph-v1 Judgment Day. The branch-wide High-tier 4R runs after that extraction, before the single size-exception PR.
312
430
 
313
431
  ### Review Lens Selection (architecture reference)
314
432
 
@@ -1071,10 +1189,10 @@ tag="v${version}"
1071
1189
  git fetch --no-tags origin "refs/tags/${tag}"
1072
1190
  test "$(git rev-parse 'FETCH_HEAD^{commit}')" = "$(git rev-parse "${tag}^{commit}")"
1073
1191
  gh workflow run publish.yml \
1074
- --repo Gentleman-Programming/gentle-pi \
1192
+ --repo Gentleman-Programming/gentle-shell \
1075
1193
  --ref main \
1076
1194
  -f tag="${tag}"
1077
- gh run watch <run-id> --repo Gentleman-Programming/gentle-pi --exit-status
1195
+ gh run watch <run-id> --repo Gentleman-Programming/gentle-shell --exit-status
1078
1196
  npm view gentle-pi@<version> version --registry=https://registry.npmjs.org/
1079
1197
  npm dist-tag ls gentle-pi --registry=https://registry.npmjs.org/
1080
1198
  ```
@@ -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, interactiveTui: boolean): void {
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 (interactiveTui === isActive) return;
154
- const next = interactiveTui
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
- throw new Error("ask_user_choice is unavailable outside the interactive TUI");
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
- if (selection === undefined) {
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 === "tui");
331
+ reconcileToolAvailability(pi, isInteractiveMode(ctx.mode, process.env));
284
332
  });
285
333
  }
@@ -1,14 +1,15 @@
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 { Text } from "@earendil-works/pi-tui";
4
4
  import { createNativeFullscreenInteraction } from "../lib/native-fullscreen-interaction.ts";
5
- import { type QuestionParams, QuestionParamsSchema } from "../lib/questionnaire/schema.ts";
5
+ import { type QuestionData, type QuestionParams, QuestionParamsSchema } from "../lib/questionnaire/schema.ts";
6
6
  import {
7
7
  QuestionnaireView,
8
8
  type AnswerRow,
9
9
  type QuestionnaireResult,
10
10
  } from "../lib/questionnaire/questionnaire-view.ts";
11
11
  import { validateQuestionnaire, type QuestionnaireError } from "../lib/questionnaire/validate.ts";
12
+ import { isInteractiveRpcHost } from "../lib/rpc-host.ts";
12
13
 
13
14
  const QUESTION_TOOL_NAME = "ask_user_question";
14
15
  const ASK_USER_QUESTION_BLOCKED_EVENT = "gentle-pi:ask-user-question:blocked";
@@ -51,6 +52,124 @@ function unavailableResult(): QuestionnaireToolResult {
51
52
  };
52
53
  }
53
54
 
55
+ /** Same cancellation shape the TUI questionnaire returns for its Escape key. */
56
+ function cancelledResult(): QuestionnaireToolResult {
57
+ return {
58
+ content: [{ type: "text", text: "User cancelled the questionnaire" }],
59
+ details: { cancelled: true },
60
+ };
61
+ }
62
+
63
+ /** Label for the trailing "finish this question" entry in a multiSelect round. */
64
+ const MULTI_SELECT_DONE_LABEL = "Done";
65
+
66
+ /** One toggle round's select prompt: `[x] label` / `[ ] label` plus Done. */
67
+ function multiSelectRoundOptions(question: QuestionData, toggled: readonly boolean[]): string[] {
68
+ return [
69
+ ...question.options.map((choiceOption, index) => `${toggled[index] ? "[x]" : "[ ]"} ${choiceOption.label}`),
70
+ MULTI_SELECT_DONE_LABEL,
71
+ ];
72
+ }
73
+
74
+ /**
75
+ * Floor for the toggle-round safety cap so a misbehaving or chatty host
76
+ * cannot spin this loop forever, and every question -- however few options
77
+ * it has -- still gets room to toggle, untoggle, and retoggle before Done.
78
+ */
79
+ const MULTI_SELECT_ROUND_CAP_FLOOR = 32;
80
+
81
+ /**
82
+ * Hard bound on toggle rounds for one question: every option must be
83
+ * toggleable at least once, plus one round for the explicit Done and one
84
+ * spare round for a single correction (an un-toggle), so the cap scales
85
+ * with the option count (`options.length + 2`), floored at
86
+ * `MULTI_SELECT_ROUND_CAP_FLOOR` for small questions.
87
+ */
88
+ function multiSelectRoundCap(optionsLength: number): number {
89
+ return Math.max(MULTI_SELECT_ROUND_CAP_FLOOR, optionsLength + 2);
90
+ }
91
+
92
+ /** Committed multiSelect answer from the current toggle state (explicit Done, or every option toggled on). */
93
+ function finishMultiSelect(question: QuestionData, toggled: readonly boolean[]): AnswerRow {
94
+ return {
95
+ questionIndex: -1, // overwritten by the caller with the question's position
96
+ question: question.question,
97
+ kind: "multi",
98
+ answer: null,
99
+ selected: question.options.filter((_choiceOption, index) => toggled[index]).map((choiceOption) => choiceOption.label),
100
+ };
101
+ }
102
+
103
+ /**
104
+ * Resolves one multiSelect question by looping `ctx.ui.select` over toggle
105
+ * rounds, bounded by `multiSelectRoundCap`. Returns `undefined` on
106
+ * cancellation, when the cap is hit without an explicit Done, and when the
107
+ * host returns an answer that matches none of the current round's options:
108
+ * a partial toggle state must never commit silently in either case.
109
+ */
110
+ export async function askMultiSelect(
111
+ ctx: Pick<ExtensionContext, "ui">,
112
+ question: QuestionData,
113
+ ): Promise<AnswerRow | undefined> {
114
+ const toggled = question.options.map(() => false);
115
+ const roundCap = multiSelectRoundCap(question.options.length);
116
+ for (let round = 0; round < roundCap; round++) {
117
+ const roundOptions = multiSelectRoundOptions(question, toggled);
118
+ const picked = await ctx.ui.select(`${question.header}: ${question.question}`, roundOptions);
119
+ if (picked === undefined) return undefined;
120
+ const pickedIndex = roundOptions.indexOf(picked);
121
+ if (pickedIndex === question.options.length) return finishMultiSelect(question, toggled); // explicit Done
122
+ if (pickedIndex === -1) return undefined; // unrecognised answer: never commit a partial state
123
+ toggled[pickedIndex] = !toggled[pickedIndex];
124
+ if (toggled.every(Boolean)) return finishMultiSelect(question, toggled);
125
+ }
126
+ // The safety cap was spent without an explicit Done: refuse to commit a
127
+ // partial selection silently and cancel instead.
128
+ return undefined;
129
+ }
130
+
131
+ /** Resolves one single-select question through `ctx.ui.select`. Returns `undefined` on cancellation. */
132
+ async function askSingleSelect(
133
+ ctx: Pick<ExtensionContext, "ui">,
134
+ question: QuestionData,
135
+ ): Promise<AnswerRow | undefined> {
136
+ const labels = question.options.map((choiceOption) => choiceOption.label);
137
+ const picked = await ctx.ui.select(`${question.header}: ${question.question}`, labels);
138
+ if (picked === undefined) return undefined;
139
+ const chosen = question.options.find((choiceOption) => choiceOption.label === picked);
140
+ return {
141
+ questionIndex: -1, // overwritten by the caller with the question's position
142
+ question: question.question,
143
+ kind: "option",
144
+ answer: picked,
145
+ ...(chosen?.preview !== undefined ? { preview: chosen.preview } : {}),
146
+ };
147
+ }
148
+
149
+ /**
150
+ * Interactive-RPC-host fallback for the TUI questionnaire: one
151
+ * `ctx.ui.select` dialog per question (looped for multiSelect), keeping the
152
+ * exact TUI result shapes. A cancel on any question cancels the whole
153
+ * questionnaire, matching the TUI Escape key. There is no schema-declared
154
+ * free-text option (`lib/questionnaire/schema.ts` has none), so the
155
+ * always-available "Type something." row has no RPC-dialog equivalent here.
156
+ */
157
+ async function askThroughDialogs(
158
+ ctx: Pick<ExtensionContext, "ui">,
159
+ params: QuestionParams,
160
+ ): Promise<QuestionnaireToolResult> {
161
+ const answers: AnswerRow[] = [];
162
+ for (let questionIndex = 0; questionIndex < params.questions.length; questionIndex++) {
163
+ const question = params.questions[questionIndex]!;
164
+ const answer = question.multiSelect
165
+ ? await askMultiSelect(ctx, question)
166
+ : await askSingleSelect(ctx, question);
167
+ if (answer === undefined) return cancelledResult();
168
+ answers.push({ ...answer, questionIndex });
169
+ }
170
+ return { content: [{ type: "text", text: answersText(answers) }], details: { answers } };
171
+ }
172
+
54
173
  /**
55
174
  * Human-readable body for one answer. A custom answer on a multiSelect
56
175
  * question keeps the toggled options, so the text must name them explicitly:
@@ -145,7 +264,16 @@ export default function askUserQuestion(pi: ExtensionAPI): void {
145
264
  ): Promise<QuestionnaireToolResult> {
146
265
  const error = validateQuestionnaire(params);
147
266
  if (error) return invalidQuestionnaireResult(error);
148
- if (ctx.mode !== "tui") return unavailableResult();
267
+ if (ctx.mode !== "tui") {
268
+ if (!isInteractiveRpcHost(ctx.mode, process.env)) return unavailableResult();
269
+ try {
270
+ pi.events.emit(ASK_USER_QUESTION_BLOCKED_EVENT, { active: true });
271
+ return await askThroughDialogs(ctx, params);
272
+ }
273
+ finally {
274
+ pi.events.emit(ASK_USER_QUESTION_BLOCKED_EVENT, { active: false });
275
+ }
276
+ }
149
277
 
150
278
  let selection: QuestionnaireResult | undefined;
151
279
  try {
@@ -28,6 +28,8 @@ import { historyDir, loadHistory, loadStoredTask, pruneHistory, saveTask } from
28
28
  import { sessionToMarkdown } from "../lib/agents-transcript.ts";
29
29
  import { AgentsView } from "../lib/agents-view.ts";
30
30
  import { PresencePublisher } from "../lib/orchestrator-presence.ts";
31
+ import { createRpcActivityPublisher, type RpcActivityPublisher } from "../lib/agents-rpc-publisher.ts";
32
+ import { isInteractiveRpcHost } from "../lib/rpc-host.ts";
31
33
  import { createNativeFullscreenInteraction } from "../lib/native-fullscreen-interaction.ts";
32
34
  import { AGENTS_GLYPH, renderAgentsCard, widgetExpiryMs, widgetRows } from "../lib/agents-widget.ts";
33
35
  import { CARD_TONE, renderCard } from "../lib/shell-card.ts";
@@ -476,6 +478,12 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
476
478
  let sidebarTui: TUI | undefined;
477
479
  let sessions: ExtensionContext["sessionManager"] | undefined;
478
480
  let presence: PresencePublisher | undefined;
481
+ let rpcActivityPublisher: RpcActivityPublisher | undefined;
482
+ // Messages already surfaced to the user this session through the RPC
483
+ // activity publisher's `onError`, so a recurring push failure (the
484
+ // coalescing window retries every burst) notifies at most once per
485
+ // session instead of flooding the UI. Reset on every `session_start`.
486
+ let notifiedRpcActivityErrors: Set<string> | undefined;
479
487
  const overlays = new Set<AgentsView>();
480
488
  const publishActivity = () => {
481
489
  if (!sessions) return;
@@ -1450,12 +1458,37 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
1450
1458
  publishActivity();
1451
1459
  } catch { presence = undefined; }
1452
1460
  void startSessionTransport(ctx);
1461
+ // The desktop app's own pi process: publish live subagent state through
1462
+ // setWidget's RPC-mode string[] path. Plain headless RPC (no variable) and
1463
+ // TUI are untouched -- the TUI card above the editor is showWidget's own
1464
+ // factory push, ignored by pi's RPC transport since it is not an array.
1465
+ rpcActivityPublisher?.stop();
1466
+ rpcActivityPublisher = undefined;
1467
+ notifiedRpcActivityErrors = new Set();
1468
+ if (ctx.hasUI && isInteractiveRpcHost(ctx.mode, deps.env)) {
1469
+ rpcActivityPublisher = createRpcActivityPublisher({
1470
+ store,
1471
+ ui: { setWidget: (key, lines) => ctx.ui.setWidget(key, lines) },
1472
+ now: deps.now,
1473
+ schedule: deps.schedule,
1474
+ parentSessionId: activeSessionId(),
1475
+ onError: (error) => {
1476
+ const message = `Gentle Agents activity push failed: ${error instanceof Error ? error.message : String(error)}`;
1477
+ if (notifiedRpcActivityErrors?.has(message)) return;
1478
+ notifiedRpcActivityErrors?.add(message);
1479
+ ctx.ui.notify(message, "warning");
1480
+ },
1481
+ });
1482
+ rpcActivityPublisher.start();
1483
+ }
1453
1484
  });
1454
1485
  pi.on("session_shutdown", async () => {
1455
1486
  completions.dropAll();
1456
1487
  activeAgentRuns = 0;
1457
1488
  presence?.dispose();
1458
1489
  presence = undefined;
1490
+ rpcActivityPublisher?.stop();
1491
+ rpcActivityPublisher = undefined;
1459
1492
  cancelClock?.();
1460
1493
  for (const view of overlays) { view.handleInput("q"); view.dispose(); }
1461
1494
  overlays.clear();