gentle-pi 3.3.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.
Files changed (88) hide show
  1. package/README.md +88 -59
  2. package/assets/orchestrator-delegation.md +1 -1
  3. package/bin/gentle-shell.mjs +198 -0
  4. package/docs/assets/brand/gentle-shell-banner.gif +0 -0
  5. package/docs/assets/diagrams/odd-workflow.svg +74 -0
  6. package/docs/assets/features/agents-view.png +0 -0
  7. package/docs/assets/features/changes-view.png +0 -0
  8. package/docs/assets/features/command-palette.png +0 -0
  9. package/docs/assets/features/profiles-routing.png +0 -0
  10. package/docs/gentle-agents-activity.md +95 -0
  11. package/docs/gentle-shell.md +26 -2
  12. package/docs/readme-reference.md +99 -6
  13. package/extensions/ask-user-choice.ts +70 -22
  14. package/extensions/ask-user-question.ts +338 -0
  15. package/extensions/gentle-agents.ts +41 -1
  16. package/extensions/gentle-ai.ts +59 -18
  17. package/extensions/gentle-shell.ts +99 -10
  18. package/extensions/quiet-tools.ts +28 -5
  19. package/extensions/startup-banner.ts +25 -10
  20. package/lib/agents-rpc-publisher.ts +342 -0
  21. package/lib/agents-runner.ts +7 -2
  22. package/lib/animation-policy.ts +52 -0
  23. package/lib/background-cache-warming.ts +38 -0
  24. package/lib/command-palette-catalog.ts +1 -0
  25. package/lib/gentle-shell-launcher.ts +482 -0
  26. package/lib/inprocess-reviewer.ts +38 -1
  27. package/lib/native-review-cli.ts +36 -10
  28. package/lib/questionnaire/questionnaire-view.ts +603 -0
  29. package/lib/questionnaire/schema.ts +82 -0
  30. package/lib/questionnaire/validate.ts +141 -0
  31. package/lib/review-candidate-view-owner.ts +20 -5
  32. package/lib/review-candidate-view.ts +9 -2
  33. package/lib/review-host-relay.ts +10 -0
  34. package/lib/review-integration-v2.ts +4 -1
  35. package/lib/rpc-host.ts +36 -0
  36. package/lib/shell-bar.ts +13 -0
  37. package/lib/shell-sidebar-layout.ts +10 -4
  38. package/lib/shell-usage-view.ts +5 -2
  39. package/lib/shell-usage.ts +120 -6
  40. package/package.json +5 -1
  41. package/runtime/gentle-shell-launcher.mjs +483 -0
  42. package/runtime/native-review-cli.mjs +35 -9
  43. package/runtime/review-integration-v2.mjs +4 -1
  44. package/scripts/build-runtime-modules.mjs +1 -0
  45. package/scripts/gentle-ai-installer.mjs +10 -10
  46. package/scripts/install-gentle-ai.mjs +14 -7
  47. package/scripts/install-tui-mode-setting.mjs +78 -1
  48. package/scripts/verify-package-files.mjs +6 -3
  49. package/tests/agents-rpc-publisher.test.ts +407 -0
  50. package/tests/agents-runner.test.ts +10 -0
  51. package/tests/animation-policy.test.ts +42 -0
  52. package/tests/ask-user-choice.test.ts +129 -0
  53. package/tests/ask-user-question.test.ts +661 -0
  54. package/tests/background-cache-warming.test.ts +60 -0
  55. package/tests/background-subagents.test.ts +68 -0
  56. package/tests/command-palette.test.ts +9 -0
  57. package/tests/gentle-agents.test.ts +161 -2
  58. package/tests/gentle-ai-binary.test.ts +1 -1
  59. package/tests/gentle-ai-installer.test.ts +47 -47
  60. package/tests/gentle-ai.test.ts +56 -4
  61. package/tests/gentle-shell-bin.test.ts +188 -0
  62. package/tests/gentle-shell-launcher.test.ts +718 -0
  63. package/tests/gentle-shell.test.ts +355 -2
  64. package/tests/inprocess-reviewer.test.ts +92 -0
  65. package/tests/install-tui-mode-guard.test.ts +99 -0
  66. package/tests/install-tui-mode-setting.test.ts +39 -1
  67. package/tests/native-review-capability-contract.test.ts +16 -1
  68. package/tests/native-review-parity.test.ts +19 -0
  69. package/tests/package-manifest.test.ts +6 -17
  70. package/tests/questionnaire-schema.test.ts +274 -0
  71. package/tests/questionnaire-view.test.ts +446 -0
  72. package/tests/rdd-status-line.test.ts +21 -4
  73. package/tests/review-candidate-owner-retry.test.ts +63 -0
  74. package/tests/review-candidate-view.test.ts +15 -0
  75. package/tests/review-controller-native-routing.test.ts +86 -0
  76. package/tests/review-host-relay.test.ts +21 -0
  77. package/tests/review-integration-v2.test.ts +30 -0
  78. package/tests/review-ledger-contract.test.ts +1 -2
  79. package/tests/review-relay-transport-agent.test.ts +107 -2
  80. package/tests/review-risk-assessment.test.ts +104 -0
  81. package/tests/rpc-host.test.ts +77 -0
  82. package/tests/shell-bar.test.ts +8 -0
  83. package/tests/shell-sidebar-layout.test.ts +60 -5
  84. package/tests/shell-usage.test.ts +129 -0
  85. package/tests/skill-collision-prefixes.test.ts +1 -1
  86. package/tests/startup-banner.test.ts +93 -2
  87. package/docs/assets/brand/gentle-pi-banner.png +0 -0
  88. package/skills/release/SKILL.md +0 -137
@@ -0,0 +1,338 @@
1
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { DynamicBorder } from "@earendil-works/pi-coding-agent";
3
+ import { Text } from "@earendil-works/pi-tui";
4
+ import { createNativeFullscreenInteraction } from "../lib/native-fullscreen-interaction.ts";
5
+ import { type QuestionData, type QuestionParams, QuestionParamsSchema } from "../lib/questionnaire/schema.ts";
6
+ import {
7
+ QuestionnaireView,
8
+ type AnswerRow,
9
+ type QuestionnaireResult,
10
+ } from "../lib/questionnaire/questionnaire-view.ts";
11
+ import { validateQuestionnaire, type QuestionnaireError } from "../lib/questionnaire/validate.ts";
12
+ import { isInteractiveRpcHost } from "../lib/rpc-host.ts";
13
+
14
+ const QUESTION_TOOL_NAME = "ask_user_question";
15
+ const ASK_USER_QUESTION_BLOCKED_EVENT = "gentle-pi:ask-user-question:blocked";
16
+
17
+ /** Maximum characters kept from a renderCall question summary. */
18
+ const CALL_SUMMARY_LIMIT = 120;
19
+
20
+ /** Structured details returned by the tool for UI rendering and callers. */
21
+ interface QuestionnaireDetails {
22
+ cancelled?: boolean;
23
+ answers?: AnswerRow[];
24
+ error?: QuestionnaireError;
25
+ errorKind?: string;
26
+ }
27
+
28
+ /** Content plus details returned by `execute`. */
29
+ interface QuestionnaireToolResult {
30
+ content: Array<{ type: "text"; text: string }>;
31
+ details: QuestionnaireDetails;
32
+ }
33
+
34
+ /**
35
+ * Invalid-parameter result. `AgentToolResult` has no `isError` field, so this
36
+ * follows the repository convention for rejected tool input: a leading error
37
+ * sentence in `content` plus a machine-readable payload in `details`
38
+ * (`extensions/gentle-todo.ts` returns `Error: ...` with `details.error`).
39
+ */
40
+ function invalidQuestionnaireResult(error: QuestionnaireError): QuestionnaireToolResult {
41
+ return {
42
+ content: [{ type: "text", text: `Invalid questionnaire: ${error.message}` }],
43
+ details: { error, errorKind: error.code },
44
+ };
45
+ }
46
+
47
+ /** Non-interactive result; parity with ask_user_choice's TUI-only guard. */
48
+ function unavailableResult(): QuestionnaireToolResult {
49
+ return {
50
+ content: [{ type: "text", text: "Error: ask_user_question is unavailable outside the interactive TUI" }],
51
+ details: { errorKind: "unavailable_outside_tui" },
52
+ };
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
+
173
+ /**
174
+ * Human-readable body for one answer. A custom answer on a multiSelect
175
+ * question keeps the toggled options, so the text must name them explicitly:
176
+ * the free-text value alone would silently drop the user's selections. Plain
177
+ * custom answers (no selections) stay concise.
178
+ */
179
+ function answerBody(answer: AnswerRow): string {
180
+ if (answer.kind === "multi") return `selected: ${(answer.selected ?? []).join(", ")}`;
181
+ if (answer.kind === "custom") {
182
+ const body = `(custom) ${answer.answer ?? ""}`;
183
+ const selected = answer.selected ?? [];
184
+ return selected.length > 0 ? `${body} — selected: ${selected.join(", ")}` : body;
185
+ }
186
+ return answer.answer ?? "";
187
+ }
188
+
189
+ /**
190
+ * Compact LLM-facing transcript of the committed answers. Each row keeps the
191
+ * original one-based question index so a partially answered questionnaire
192
+ * (the last question committed early) still reads in order.
193
+ */
194
+ function answersText(answers: AnswerRow[]): string {
195
+ if (answers.length === 0) return "The user answered the questionnaire.";
196
+ const lines: string[] = [];
197
+ for (const answer of answers) {
198
+ const prefix = `${answer.questionIndex + 1}. ${answer.question}`;
199
+ lines.push(`${prefix} — ${answerBody(answer)}`);
200
+ if (answer.preview !== undefined) lines.push(` selected preview: ${answer.preview}`);
201
+ }
202
+ return lines.join("\n");
203
+ }
204
+
205
+ /** Single-line summary of one question for the collapsed tool call row. */
206
+ function callSummary(question: unknown, index: number): string {
207
+ const source = typeof question === "object" && question !== null ? question as { header?: unknown; options?: unknown } : {};
208
+ const header = typeof source.header === "string" ? source.header : "";
209
+ const labels = Array.isArray(source.options)
210
+ ? source.options
211
+ .map((option) => (typeof option === "object" && option !== null && typeof (option as { label?: unknown }).label === "string"
212
+ ? (option as { label: string }).label
213
+ : ""))
214
+ .filter((label) => label.length > 0)
215
+ : [];
216
+ const labelsPart = labels.length > 0 ? ` (${labels.join(", ")})` : "";
217
+ return `${index + 1}. ${header}${labelsPart}`;
218
+ }
219
+
220
+ function truncate(text: string, limit: number): string {
221
+ return text.length <= limit ? text : `${text.slice(0, Math.max(0, limit - 1))}…`;
222
+ }
223
+
224
+ /**
225
+ * Register the first-party questionnaire tool.
226
+ *
227
+ * Name-collision semantics (live-verified against the installed Pi runtime):
228
+ * - Tool names are exclusive across extensions. Pi has no precedence, override,
229
+ * or silent shadowing: loading two extensions that register the same tool
230
+ * name fails the whole load with a hard error
231
+ * (`Tool "ask_user_question" conflicts with <other extension>`; the runtime
232
+ * exits non-zero). The name is either free or fatal, full stop.
233
+ * - `registerTool` writes into the calling extension's own tool map keyed by
234
+ * name, so re-registering inside one extension overwrites that entry
235
+ * (`loader.js:240`). That same-name write is the only one Pi tolerates.
236
+ * - This first-party tool ships as THE `ask_user_question` provider. A competing
237
+ * provider such as the third-party `@juicesharp/rpiv-ask-user-question`
238
+ * package fails the load by design and must be removed from the user's Pi
239
+ * settings; that deletion is the documented migration path, not a runtime
240
+ * precedence choice.
241
+ */
242
+ export default function askUserQuestion(pi: ExtensionAPI): void {
243
+ pi.registerTool({
244
+ name: QUESTION_TOOL_NAME,
245
+ renderShell: "self",
246
+ label: "Ask User Question",
247
+ description: "Ask one to four structured questions in a single call, each with two to four ordered options, and read the user's answers back in one result.",
248
+ promptGuidelines: [
249
+ "Use ask_user_question to collect decisions in one batch: ask one to four questions at a time, each with two to four options.",
250
+ "Keep each header a short chip of at most 16 characters and each option label at most 60 characters.",
251
+ "Add a preview to an option when the user needs to compare rich detail side-by-side with the options.",
252
+ "Set multiSelect when the choices are not mutually exclusive.",
253
+ "The free-text \"Type something.\" row is always available and is also how the user bails out into a normal conversation; never rely on it as a hidden escape hatch.",
254
+ "Never use this tool for decisions that must not be delegated to the user.",
255
+ ],
256
+ parameters: QuestionParamsSchema,
257
+ executionMode: "sequential",
258
+ async execute(
259
+ _toolCallId: string,
260
+ params: QuestionParams,
261
+ _signal: AbortSignal | undefined,
262
+ _onUpdate: undefined,
263
+ ctx,
264
+ ): Promise<QuestionnaireToolResult> {
265
+ const error = validateQuestionnaire(params);
266
+ if (error) return invalidQuestionnaireResult(error);
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
+ }
277
+
278
+ let selection: QuestionnaireResult | undefined;
279
+ try {
280
+ pi.events.emit(ASK_USER_QUESTION_BLOCKED_EVENT, { active: true });
281
+ selection = await ctx.ui.custom<QuestionnaireResult>((tui, theme, keybindings, done) => {
282
+ const view = new QuestionnaireView({
283
+ questions: params.questions,
284
+ theme,
285
+ keybindings,
286
+ onComplete: (result) => done(result),
287
+ });
288
+ // Native dock swap, never an overlay: the transcript stays scrollable
289
+ // while the questionnaire owns focus. No `overlay` option is passed.
290
+ const container = createNativeFullscreenInteraction({
291
+ keyboardTarget: view,
292
+ requestRender: () => tui.requestRender(),
293
+ });
294
+ container.addChild(new DynamicBorder((text: string) => theme.fg("accent", text)));
295
+ container.addChild(view);
296
+ container.addChild(new DynamicBorder((text: string) => theme.fg("accent", text)));
297
+ return container;
298
+ });
299
+ }
300
+ finally {
301
+ pi.events.emit(ASK_USER_QUESTION_BLOCKED_EVENT, { active: false });
302
+ }
303
+
304
+ if (selection === undefined || selection.cancelled) {
305
+ return {
306
+ content: [{ type: "text", text: "User cancelled the questionnaire" }],
307
+ details: { cancelled: true },
308
+ };
309
+ }
310
+ return {
311
+ content: [{ type: "text", text: answersText(selection.answers) }],
312
+ details: { answers: selection.answers },
313
+ };
314
+ },
315
+ renderCall(args: QuestionParams, theme) {
316
+ const questions = Array.isArray(args.questions) ? args.questions : [];
317
+ const summary = truncate(questions.map((question, index) => callSummary(question, index)).join(" "), CALL_SUMMARY_LIMIT);
318
+ return new Text(
319
+ theme.fg("toolTitle", theme.bold("ask_user_question ")) +
320
+ theme.fg("muted", summary),
321
+ 0,
322
+ 0,
323
+ );
324
+ },
325
+ renderResult(result, _options, theme) {
326
+ const details = result.details as QuestionnaireDetails | undefined;
327
+ if (details?.cancelled === true) return new Text(theme.fg("warning", "Cancelled"), 0, 0);
328
+ const answers = Array.isArray(details?.answers) ? details.answers : [];
329
+ if (answers.length === 0) return new Text(theme.fg("warning", "No answers"), 0, 0);
330
+ const lines = answers.map((answer) => {
331
+ if (answer.kind === "multi") return theme.fg("success", `✓ ${answer.question} — ${(answer.selected ?? []).join(", ")}`);
332
+ if (answer.kind === "custom") return theme.fg("success", `✓ ${answer.question} — ${answerBody(answer)}`);
333
+ return theme.fg("success", `✓ ${answer.question} — ${answer.answer ?? ""}`);
334
+ });
335
+ return new Text(lines.join("\n"), 0, 0);
336
+ },
337
+ });
338
+ }
@@ -17,6 +17,7 @@ import { invalidateSidebar } from "../lib/shell-sidebar-layout.ts";
17
17
  import { createCompletionQueue } from "../lib/agents-completion-delivery.ts";
18
18
  import { AGENT_MODE, discoverAgents, parseAgentDefinition, loadAgentsConfig, resolveAgentProfile, withPinnedModelProfiles, type AgentDefinition, type AgentMode } from "../lib/agents-config.ts";
19
19
  import { resolveBackgroundSubagentsPolicy } from "../lib/background-subagents-policy.ts";
20
+ import { installBackgroundCacheWarming } from "../lib/background-cache-warming.ts";
20
21
  import { isFinished, TASK_EVENT, TASK_STATUS, TaskStore, type AskRequest, type TaskRecord } from "../lib/agents-protocol.ts";
21
22
  import { AgentRunner, piCommand, abortReasonText, plannedCommands, type RemediationPlan, type RemediationScope, REMEDIATION_PLAN_ENV, parseRemediationPlan, type AskAnswer, type RunnerDeps, type SddChangeSelection, type TaskRequest } from "../lib/agents-runner.ts";
22
23
  import { ChildMessenger, type IpcEndpoint } from "../lib/agents-messaging.ts";
@@ -27,6 +28,8 @@ import { historyDir, loadHistory, loadStoredTask, pruneHistory, saveTask } from
27
28
  import { sessionToMarkdown } from "../lib/agents-transcript.ts";
28
29
  import { AgentsView } from "../lib/agents-view.ts";
29
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";
30
33
  import { createNativeFullscreenInteraction } from "../lib/native-fullscreen-interaction.ts";
31
34
  import { AGENTS_GLYPH, renderAgentsCard, widgetExpiryMs, widgetRows } from "../lib/agents-widget.ts";
32
35
  import { CARD_TONE, renderCard } from "../lib/shell-card.ts";
@@ -475,6 +478,12 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
475
478
  let sidebarTui: TUI | undefined;
476
479
  let sessions: ExtensionContext["sessionManager"] | undefined;
477
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;
478
487
  const overlays = new Set<AgentsView>();
479
488
  const publishActivity = () => {
480
489
  if (!sessions) return;
@@ -529,6 +538,12 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
529
538
  // started before /new or /resume stays in the store and comes back with
530
539
  // its session. Before the first session_start there is nothing to scope by.
531
540
  const activeSessionId = (): string | undefined => (sessions === undefined ? undefined : sessions.getSessionId() ?? "");
541
+ // Pi 0.86.1 adds this event; the package's pinned 0.85.1 types predate it.
542
+ installBackgroundCacheWarming(pi as unknown as Parameters<typeof installBackgroundCacheWarming>[0], () => ({
543
+ sessionId: activeSessionId(),
544
+ ownedTaskIds,
545
+ tasks: store.list(activeSessionId()),
546
+ }));
532
547
  const visibleTasks = (): TaskRecord[] => store.list(activeSessionId());
533
548
  type SessionTransport = { generation: number; sessionId: string; sessionManager: ExtensionContext["sessionManager"]; client: SessionTransportClient; listener: SessionTransportListener; registry: SessionTransportRegistry; close(): Promise<void> };
534
549
  let transportGeneration = 0;
@@ -1168,7 +1183,7 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
1168
1183
  if (observe) metricTasks.set(task.id, metrics);
1169
1184
  ownedTaskIds.add(task.id);
1170
1185
  store.subscribe(task.id, () => { publishActivity(); requestRender(); });
1171
- if (request.mode === AGENT_MODE.BACKGROUND) return text(`Started ${task.agent} in the background as task ${task.id}. Use subagent_status or subagent_result with that id.`, taskDetails(task));
1186
+ if (request.mode === AGENT_MODE.BACKGROUND) return text(`Started ${task.agent} in the background as task ${task.id}. Retain that id; completion is pushed automatically. Never sleep or periodically poll subagent_status/subagent_result for completion or cache maintenance. Inspect status only at a real orchestration decision boundary; never relaunch equivalent queued/running work.`, taskDetails(task));
1172
1187
  // A tool call aborted by the host (a human interrupting the turn, a timeout)
1173
1188
  // would otherwise leave the child running and end the call with no result and
1174
1189
  // no recorded reason. Cancel through the runner so the lifecycle runs and the
@@ -1443,12 +1458,37 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
1443
1458
  publishActivity();
1444
1459
  } catch { presence = undefined; }
1445
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
+ }
1446
1484
  });
1447
1485
  pi.on("session_shutdown", async () => {
1448
1486
  completions.dropAll();
1449
1487
  activeAgentRuns = 0;
1450
1488
  presence?.dispose();
1451
1489
  presence = undefined;
1490
+ rpcActivityPublisher?.stop();
1491
+ rpcActivityPublisher = undefined;
1452
1492
  cancelClock?.();
1453
1493
  for (const view of overlays) { view.handleInput("q"); view.dispose(); }
1454
1494
  overlays.clear();
@@ -199,6 +199,7 @@ import {
199
199
  nativeReviewRecoverAuthorization,
200
200
  normalizeNativeReviewCwd,
201
201
  NativeReviewCliError,
202
+ nativeUntrackedSelection,
202
203
  NativeReviewConsentBindingError,
203
204
  NativeReviewConsentRequiredError,
204
205
  NativeReviewIntegrationError,
@@ -1023,18 +1024,26 @@ async function resolveReviewAssessmentPlan(
1023
1024
 
1024
1025
  let assessment: ReviewAssessmentV1 | undefined;
1025
1026
  let unassessableDetail: string | undefined;
1027
+ let unassessableCode = "native-assess-unavailable";
1026
1028
  if (nativeReviewCli?.assess === undefined) {
1027
1029
  unassessableDetail = "native review assess is unavailable: the installed gentle-ai binary does not expose the assess command.";
1028
1030
  } else {
1029
1031
  try {
1030
1032
  const request: NativeReviewAssessRequest = {
1031
1033
  cwd,
1034
+ ...nativeUntrackedSelection(input),
1032
1035
  ...(input.baseRef === undefined ? {} : { baseRef: input.baseRef, committedOnly: true as const }),
1033
1036
  ...(signal === undefined ? {} : { signal }),
1034
1037
  };
1035
1038
  assessment = await nativeReviewCli.assess(request);
1036
1039
  } catch (error) {
1037
- unassessableDetail = `native review assess failed: ${error instanceof Error ? error.message : String(error)}`;
1040
+ const nativeError = asNativeReviewCliError(error);
1041
+ unassessableCode = nativeError?.code ?? unassessableCode;
1042
+ // Only the sanitized process surface may supply native evidence.
1043
+ // Arbitrary thrown messages can contain argv or environment values.
1044
+ unassessableDetail = nativeError?.diagnostics.stderr
1045
+ ? `native review assess failed: ${nativeError.diagnostics.stderr}`
1046
+ : "native review assess failed; no sanitized stderr diagnostic is available.";
1038
1047
  }
1039
1048
  }
1040
1049
 
@@ -1050,7 +1059,7 @@ async function resolveReviewAssessmentPlan(
1050
1059
  return {
1051
1060
  schema: "gentle-pi.review-assessment-plan/v1",
1052
1061
  risk,
1053
- reasons: assessment?.reasons ?? (unassessableDetail === undefined ? [] : [{ code: "native-assess-unavailable", path: "", detail: unassessableDetail }]),
1062
+ reasons: assessment?.reasons ?? (unassessableDetail === undefined ? [] : [{ code: unassessableCode, path: "", detail: unassessableDetail }]),
1054
1063
  changedPaths: assessment?.changedPaths ?? 0,
1055
1064
  changedLines: assessment?.changedLines ?? 0,
1056
1065
  candidate: assessment === undefined ? null : { kind: assessment.candidate.kind, baseRef: assessment.candidate.baseRef },
@@ -4826,7 +4835,7 @@ interface ReviewScopeParameters {
4826
4835
  // as `gentle_review` operation `assess` (not a dedicated tool), taking its
4827
4836
  // optional fields through the controller's existing generic `input` JSON
4828
4837
  // string, exactly like START's `{"mode":...,"baseRef":...}`.
4829
- interface ReviewAssessInput {
4838
+ interface ReviewAssessInput extends Pick<NativeReviewAssessRequest, "untrackedScope" | "expectedUntrackedInventory" | "intendedUntracked"> {
4830
4839
  baseRef?: string;
4831
4840
  committedOnly?: boolean;
4832
4841
  writerModelId?: string;
@@ -4845,7 +4854,7 @@ function isNativeReviewOutcome(value: unknown): value is NativeReviewOutcome {
4845
4854
  function parseReviewAssessInput(operation: ReviewControllerOperation, raw: string | undefined): ReviewAssessInput {
4846
4855
  if (raw === undefined) return {};
4847
4856
  const value = parseControllerJson(raw, operation);
4848
- const allowed = new Set(["baseRef", "committedOnly", "writerModelId", "writerEffort", "nativeReviewOutcome"]);
4857
+ const allowed = new Set(["baseRef", "committedOnly", "writerModelId", "writerEffort", "nativeReviewOutcome", "untrackedScope", "expectedUntrackedInventory", "intendedUntracked"]);
4849
4858
  const unexpected = Object.keys(value).find((key) => !allowed.has(key));
4850
4859
  if (unexpected !== undefined) throw new Error(`Review controller ${operation} input does not accept ${unexpected}`);
4851
4860
  const { baseRef, committedOnly, writerModelId, writerEffort, nativeReviewOutcome } = value;
@@ -4859,6 +4868,7 @@ function parseReviewAssessInput(operation: ReviewControllerOperation, raw: strin
4859
4868
  return {
4860
4869
  ...(baseRef === undefined ? {} : { baseRef: baseRef as string }),
4861
4870
  ...(committedOnly === undefined ? {} : { committedOnly: committedOnly as boolean }),
4871
+ ...nativeUntrackedSelection(value),
4862
4872
  ...(writerModelId === undefined ? {} : { writerModelId: writerModelId as string }),
4863
4873
  ...(writerEffort === undefined ? {} : { writerEffort: writerEffort as string }),
4864
4874
  ...(nativeReviewOutcome === undefined ? {} : { nativeReviewOutcome: nativeReviewOutcome as NativeReviewOutcome }),
@@ -6754,6 +6764,11 @@ async function executeReviewHostRelayCapture(
6754
6764
  route: RetainedNativeCaptureRoute | undefined,
6755
6765
  signal?: AbortSignal,
6756
6766
  modelRegistry?: InProcessReviewerRegistry,
6767
+ // The caller's live session id: forwarded into the relay request so an
6768
+ // OpenCode-routed reviewer completion carries its x-opencode-session
6769
+ // attribution header. Appended last so every existing positional call site
6770
+ // keeps compiling unchanged.
6771
+ reviewerSessionId?: string,
6757
6772
  ): Promise<Record<string, unknown>> {
6758
6773
  try {
6759
6774
  if (slot.submission === undefined) {
@@ -6777,6 +6792,7 @@ async function executeReviewHostRelayCapture(
6777
6792
  submission: slot.submission,
6778
6793
  ...launch,
6779
6794
  ...(modelRegistry === undefined ? {} : { reviewerRegistry: modelRegistry }),
6795
+ ...(reviewerSessionId === undefined ? {} : { reviewerSessionId }),
6780
6796
  ...(signal === undefined ? {} : { signal }),
6781
6797
  };
6782
6798
  })());
@@ -7379,6 +7395,11 @@ async function executeReviewCaptureOperation(
7379
7395
  // inserted) so every existing positional call site — none of which pass an
7380
7396
  // eighth argument — keeps compiling unchanged.
7381
7397
  modelRegistry?: InProcessReviewerRegistry,
7398
+ // The caller's live session id, threaded into every relay request this
7399
+ // capture launches so an OpenCode-routed reviewer model carries its
7400
+ // x-opencode-session attribution header. Appended last for the same
7401
+ // positional-call-site reason as modelRegistry above.
7402
+ reviewerSessionId?: string,
7382
7403
  ): Promise<Record<string, unknown>> {
7383
7404
  const parameters = parseReviewCaptureParameters(parametersValue);
7384
7405
  if (nativeReviewCli === null || nativeReviewCli.targetStatus === undefined) {
@@ -7438,7 +7459,7 @@ async function executeReviewCaptureOperation(
7438
7459
  mutation_outcome: "none",
7439
7460
  };
7440
7461
  }
7441
- return withCorrectionTarget(await executeReviewHostRelayCapture(hostRelaySlots[0]!, nativeReviewCli, cwd, selected.binding, retainedUntrackedSelections, route, signal, modelRegistry));
7462
+ return withCorrectionTarget(await executeReviewHostRelayCapture(hostRelaySlots[0]!, nativeReviewCli, cwd, selected.binding, retainedUntrackedSelections, route, signal, modelRegistry, reviewerSessionId));
7442
7463
  }
7443
7464
 
7444
7465
  // gentle-pi#311 P3: gentle-ai's v9 contract renders the refuter and
@@ -7464,7 +7485,7 @@ async function executeReviewCaptureOperation(
7464
7485
  mutation_outcome: "none",
7465
7486
  };
7466
7487
  }
7467
- return withCorrectionTarget(await executeReviewHostRelayCapture(hostMediatedRoleSlots[0]!, nativeReviewCli, cwd, selected.binding, retainedUntrackedSelections, route, signal, modelRegistry));
7488
+ return withCorrectionTarget(await executeReviewHostRelayCapture(hostMediatedRoleSlots[0]!, nativeReviewCli, cwd, selected.binding, retainedUntrackedSelections, route, signal, modelRegistry, reviewerSessionId));
7468
7489
  }
7469
7490
 
7470
7491
  if (selected.input.captureOperation === "review.capture-correction-plan") {
@@ -7545,6 +7566,10 @@ async function executeReviewCaptureGroupOperation(
7545
7566
  requireRegisteredRoute = false,
7546
7567
  // gentle-pi#311 P2: see executeReviewCaptureOperation's matching parameter.
7547
7568
  modelRegistry?: InProcessReviewerRegistry,
7569
+ // The caller's live session id, set on every grouped relay request so an
7570
+ // OpenCode-routed reviewer model carries its x-opencode-session attribution
7571
+ // header. Appended last for the same positional-call-site reason as above.
7572
+ reviewerSessionId?: string,
7548
7573
  ): Promise<Record<string, unknown>> {
7549
7574
  const parameters = parseReviewCaptureGroupParameters(parametersValue);
7550
7575
  if (nativeReviewCli === null || nativeReviewCli.targetStatus === undefined) return { ...captureGroupRejected("native target STATUS is unavailable"), outcome: "native-status-unsupported" };
@@ -7587,6 +7612,7 @@ async function executeReviewCaptureGroupOperation(
7587
7612
  submission: slot.submission!,
7588
7613
  ...reviewHostRelaySelection(slot.lens, readModelConfig(cwd)),
7589
7614
  ...(modelRegistry === undefined ? {} : { reviewerRegistry: modelRegistry }),
7615
+ ...(reviewerSessionId === undefined ? {} : { reviewerSessionId }),
7590
7616
  ...(signal === undefined ? {} : { signal }),
7591
7617
  }));
7592
7618
  let prepared: readonly ReviewHostRelayPreparedResult[];
@@ -8317,11 +8343,13 @@ async function executeReviewControllerOperation(
8317
8343
  // native START are resolved, and re-derive the target for that range,
8318
8344
  // so all three agree on one base-diff identity. Adopting the offer
8319
8345
  // later left the workspace target and the base-diff candidate view
8320
- // disagreeing, and START failed with identity-mismatch. Both an
8321
- // explicit caller baseRef and any START with an untracked selection in
8322
- // play keep today's single-STATUS flow; only an adopted offer pays the
8323
- // second read-only STATUS.
8324
- if (canonicalBaseRef === undefined && untrackedSelection.untrackedScope === undefined && untrackedSubmission === undefined) {
8346
+ // disagreeing, and START failed with identity-mismatch. An explicit
8347
+ // caller baseRef still wins (it already is the adopted range), but an
8348
+ // in-play untracked selection now also pays this second read-only
8349
+ // STATUS: the renegotiated target is a base-diff projection, so the
8350
+ // candidate view must be materialized WITH the offered base instead of
8351
+ // the base-less view that tripped candidate-target-projection-drift.
8352
+ if (canonicalBaseRef === undefined) {
8325
8353
  const offeredBaseRef = offeredCommittedRangeBaseRef(target);
8326
8354
  if (offeredBaseRef !== undefined) {
8327
8355
  const renegotiated = await negotiatedStatusForHostTransport(nativeReviewCli, {
@@ -8329,6 +8357,8 @@ async function executeReviewControllerOperation(
8329
8357
  ...(parameters.lineageId === undefined ? {} : { lineageId: parameters.lineageId }),
8330
8358
  baseRef: offeredBaseRef,
8331
8359
  committedOnly: true,
8360
+ ...(untrackedSelection.untrackedScope === undefined ? {} : untrackedSelection),
8361
+ ...(untrackedSubmission === undefined ? {} : { intendedUntrackedSelection: untrackedSubmission }),
8332
8362
  ...(signal === undefined ? {} : { signal }),
8333
8363
  }, retainedUntrackedSelections, defaultCwd);
8334
8364
  if (renegotiated.transport !== undefined) return hostTransportUnavailable(parameters.operation, renegotiated.transport);
@@ -8375,11 +8405,7 @@ async function executeReviewControllerOperation(
8375
8405
  let nativeStartAttempted = false;
8376
8406
  try {
8377
8407
  const candidateRequest = { contributorRoot: defaultCwd, replayKey, ...(canonicalBaseRef === undefined ? {} : { baseRef: canonicalBaseRef, committedOnly: true }) };
8378
- candidateView = candidateViews?.createOrReuse({ ...candidateRequest, ...(candidateIntendedUntracked.length === 0 ? {} : { intendedUntracked: candidateIntendedUntracked }) });
8379
- if (candidateView !== undefined && candidateIntendedUntracked.length === 0 && candidateView.candidateTree !== target.projection.currentCandidateTree) {
8380
- candidateView.cleanup();
8381
- candidateView = candidateViews?.createOrReuse({ ...candidateRequest, intendedUntracked: [] });
8382
- }
8408
+ candidateView = candidateViews?.createOrReuse({ ...candidateRequest, intendedUntracked: candidateIntendedUntracked });
8383
8409
  if (candidateView !== undefined) assertNativeStartCandidateBinding(candidateView, target);
8384
8410
  let result: NativeStartResult;
8385
8411
  try {
@@ -8860,6 +8886,10 @@ function createGentleAiExtensionForTesting(
8860
8886
  ((sessionKey: PendingReviewConsentSessionKey) => processRetainedNativeStatusSelections.get(sessionKey) ?? processRetainedNativeStatusSelections.set(sessionKey, new Map()).get(sessionKey)!)(pendingReviewConsentSessionKey(ctx, pendingReviewConsentFallbackKey)),
8861
8887
  true,
8862
8888
  ctx.modelRegistry,
8889
+ // The live session id rides into the reviewer side-call so an
8890
+ // OpenCode-routed completion carries its attribution headers
8891
+ // (pi adds those inside the main agent loop; this is not that loop).
8892
+ reviewSessionManagerAndId(ctx)?.sessionId,
8863
8893
  );
8864
8894
  return { content: [{ type: "text", text: JSON.stringify(details) }], details };
8865
8895
  },
@@ -8899,6 +8929,10 @@ function createGentleAiExtensionForTesting(
8899
8929
  ((sessionKey: PendingReviewConsentSessionKey) => processRetainedNativeStatusSelections.get(sessionKey) ?? processRetainedNativeStatusSelections.set(sessionKey, new Map()).get(sessionKey)!)(pendingReviewConsentSessionKey(ctx, pendingReviewConsentFallbackKey)),
8900
8930
  true,
8901
8931
  ctx.modelRegistry,
8932
+ // The live session id rides into the reviewer side-call so an
8933
+ // OpenCode-routed completion carries its attribution headers
8934
+ // (pi adds those inside the main agent loop; this is not that loop).
8935
+ reviewSessionManagerAndId(ctx)?.sessionId,
8902
8936
  );
8903
8937
  return {
8904
8938
  content: [{ type: "text", text: JSON.stringify(details) }],
@@ -9696,9 +9730,16 @@ function createGentleAiExtensionForTesting(
9696
9730
  // background subagents may be launched at all, so nothing in Pi may write
9697
9731
  // it. The only writer is this handler, reached only by explicit invocation.
9698
9732
  pi.registerCommand("gentle:background-subagents", {
9699
- description: "Show or set the managed background-subagents policy (status|enable|disable). Every sub-action is user-initiated only; Pi automation never toggles it.",
9733
+ description: "Show or set the managed background-subagents policy; no argument opens a selectable menu (status|enable|disable). Every sub-action is user-initiated only; Pi automation never toggles it.",
9734
+ // No argument opens a selectable menu when an interactive UI is present;
9735
+ // headless callers and fakes without ui.select keep the status fallback.
9700
9736
  handler: async (args, ctx) => {
9701
- const subAction = args.trim().length === 0 ? "status" : args.trim();
9737
+ let subAction = args.trim().length === 0 ? "status" : args.trim();
9738
+ if (args.trim().length === 0 && ctx.hasUI && typeof ctx.ui.select === "function") {
9739
+ const selected = await ctx.ui.select("Background subagents policy", ["status", "enable", "disable"]);
9740
+ if (selected === undefined) return;
9741
+ subAction = selected;
9742
+ }
9702
9743
  if (subAction !== "status" && subAction !== "enable" && subAction !== "disable") {
9703
9744
  ctx.ui.notify(`Unknown /gentle:background-subagents sub-action "${subAction}". Use status, enable, or disable.`, "warning");
9704
9745
  return;