gentle-pi 3.3.0 → 3.4.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 (69) hide show
  1. package/README.md +63 -59
  2. package/assets/orchestrator-delegation.md +1 -1
  3. package/docs/assets/brand/gentle-shell-banner.gif +0 -0
  4. package/docs/assets/diagrams/odd-workflow.svg +74 -0
  5. package/docs/assets/features/agents-view.png +0 -0
  6. package/docs/assets/features/changes-view.png +0 -0
  7. package/docs/assets/features/command-palette.png +0 -0
  8. package/docs/assets/features/profiles-routing.png +0 -0
  9. package/docs/gentle-shell.md +26 -2
  10. package/docs/readme-reference.md +32 -6
  11. package/extensions/ask-user-question.ts +210 -0
  12. package/extensions/gentle-agents.ts +8 -1
  13. package/extensions/gentle-ai.ts +59 -18
  14. package/extensions/gentle-shell.ts +99 -10
  15. package/extensions/quiet-tools.ts +28 -5
  16. package/extensions/startup-banner.ts +25 -10
  17. package/lib/animation-policy.ts +52 -0
  18. package/lib/background-cache-warming.ts +38 -0
  19. package/lib/command-palette-catalog.ts +1 -0
  20. package/lib/inprocess-reviewer.ts +38 -1
  21. package/lib/native-review-cli.ts +36 -10
  22. package/lib/questionnaire/questionnaire-view.ts +603 -0
  23. package/lib/questionnaire/schema.ts +82 -0
  24. package/lib/questionnaire/validate.ts +141 -0
  25. package/lib/review-candidate-view-owner.ts +20 -5
  26. package/lib/review-candidate-view.ts +9 -2
  27. package/lib/review-host-relay.ts +10 -0
  28. package/lib/review-integration-v2.ts +4 -1
  29. package/lib/shell-bar.ts +13 -0
  30. package/lib/shell-sidebar-layout.ts +10 -4
  31. package/lib/shell-usage-view.ts +5 -2
  32. package/lib/shell-usage.ts +120 -6
  33. package/package.json +1 -1
  34. package/runtime/native-review-cli.mjs +35 -9
  35. package/runtime/review-integration-v2.mjs +4 -1
  36. package/scripts/gentle-ai-installer.mjs +10 -10
  37. package/scripts/verify-package-files.mjs +2 -3
  38. package/tests/animation-policy.test.ts +42 -0
  39. package/tests/ask-user-question.test.ts +435 -0
  40. package/tests/background-cache-warming.test.ts +60 -0
  41. package/tests/background-subagents.test.ts +68 -0
  42. package/tests/command-palette.test.ts +9 -0
  43. package/tests/gentle-agents.test.ts +71 -2
  44. package/tests/gentle-ai-binary.test.ts +1 -1
  45. package/tests/gentle-ai-installer.test.ts +47 -47
  46. package/tests/gentle-ai.test.ts +56 -4
  47. package/tests/gentle-shell.test.ts +355 -2
  48. package/tests/inprocess-reviewer.test.ts +92 -0
  49. package/tests/native-review-capability-contract.test.ts +16 -1
  50. package/tests/native-review-parity.test.ts +19 -0
  51. package/tests/package-manifest.test.ts +6 -17
  52. package/tests/questionnaire-schema.test.ts +274 -0
  53. package/tests/questionnaire-view.test.ts +446 -0
  54. package/tests/rdd-status-line.test.ts +21 -4
  55. package/tests/review-candidate-owner-retry.test.ts +63 -0
  56. package/tests/review-candidate-view.test.ts +15 -0
  57. package/tests/review-controller-native-routing.test.ts +86 -0
  58. package/tests/review-host-relay.test.ts +21 -0
  59. package/tests/review-integration-v2.test.ts +30 -0
  60. package/tests/review-ledger-contract.test.ts +1 -2
  61. package/tests/review-relay-transport-agent.test.ts +107 -2
  62. package/tests/review-risk-assessment.test.ts +104 -0
  63. package/tests/shell-bar.test.ts +8 -0
  64. package/tests/shell-sidebar-layout.test.ts +60 -5
  65. package/tests/shell-usage.test.ts +129 -0
  66. package/tests/skill-collision-prefixes.test.ts +1 -1
  67. package/tests/startup-banner.test.ts +93 -2
  68. package/docs/assets/brand/gentle-pi-banner.png +0 -0
  69. package/skills/release/SKILL.md +0 -137
@@ -0,0 +1,210 @@
1
+ import type { ExtensionAPI } 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 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
+
13
+ const QUESTION_TOOL_NAME = "ask_user_question";
14
+ const ASK_USER_QUESTION_BLOCKED_EVENT = "gentle-pi:ask-user-question:blocked";
15
+
16
+ /** Maximum characters kept from a renderCall question summary. */
17
+ const CALL_SUMMARY_LIMIT = 120;
18
+
19
+ /** Structured details returned by the tool for UI rendering and callers. */
20
+ interface QuestionnaireDetails {
21
+ cancelled?: boolean;
22
+ answers?: AnswerRow[];
23
+ error?: QuestionnaireError;
24
+ errorKind?: string;
25
+ }
26
+
27
+ /** Content plus details returned by `execute`. */
28
+ interface QuestionnaireToolResult {
29
+ content: Array<{ type: "text"; text: string }>;
30
+ details: QuestionnaireDetails;
31
+ }
32
+
33
+ /**
34
+ * Invalid-parameter result. `AgentToolResult` has no `isError` field, so this
35
+ * follows the repository convention for rejected tool input: a leading error
36
+ * sentence in `content` plus a machine-readable payload in `details`
37
+ * (`extensions/gentle-todo.ts` returns `Error: ...` with `details.error`).
38
+ */
39
+ function invalidQuestionnaireResult(error: QuestionnaireError): QuestionnaireToolResult {
40
+ return {
41
+ content: [{ type: "text", text: `Invalid questionnaire: ${error.message}` }],
42
+ details: { error, errorKind: error.code },
43
+ };
44
+ }
45
+
46
+ /** Non-interactive result; parity with ask_user_choice's TUI-only guard. */
47
+ function unavailableResult(): QuestionnaireToolResult {
48
+ return {
49
+ content: [{ type: "text", text: "Error: ask_user_question is unavailable outside the interactive TUI" }],
50
+ details: { errorKind: "unavailable_outside_tui" },
51
+ };
52
+ }
53
+
54
+ /**
55
+ * Human-readable body for one answer. A custom answer on a multiSelect
56
+ * question keeps the toggled options, so the text must name them explicitly:
57
+ * the free-text value alone would silently drop the user's selections. Plain
58
+ * custom answers (no selections) stay concise.
59
+ */
60
+ function answerBody(answer: AnswerRow): string {
61
+ if (answer.kind === "multi") return `selected: ${(answer.selected ?? []).join(", ")}`;
62
+ if (answer.kind === "custom") {
63
+ const body = `(custom) ${answer.answer ?? ""}`;
64
+ const selected = answer.selected ?? [];
65
+ return selected.length > 0 ? `${body} — selected: ${selected.join(", ")}` : body;
66
+ }
67
+ return answer.answer ?? "";
68
+ }
69
+
70
+ /**
71
+ * Compact LLM-facing transcript of the committed answers. Each row keeps the
72
+ * original one-based question index so a partially answered questionnaire
73
+ * (the last question committed early) still reads in order.
74
+ */
75
+ function answersText(answers: AnswerRow[]): string {
76
+ if (answers.length === 0) return "The user answered the questionnaire.";
77
+ const lines: string[] = [];
78
+ for (const answer of answers) {
79
+ const prefix = `${answer.questionIndex + 1}. ${answer.question}`;
80
+ lines.push(`${prefix} — ${answerBody(answer)}`);
81
+ if (answer.preview !== undefined) lines.push(` selected preview: ${answer.preview}`);
82
+ }
83
+ return lines.join("\n");
84
+ }
85
+
86
+ /** Single-line summary of one question for the collapsed tool call row. */
87
+ function callSummary(question: unknown, index: number): string {
88
+ const source = typeof question === "object" && question !== null ? question as { header?: unknown; options?: unknown } : {};
89
+ const header = typeof source.header === "string" ? source.header : "";
90
+ const labels = Array.isArray(source.options)
91
+ ? source.options
92
+ .map((option) => (typeof option === "object" && option !== null && typeof (option as { label?: unknown }).label === "string"
93
+ ? (option as { label: string }).label
94
+ : ""))
95
+ .filter((label) => label.length > 0)
96
+ : [];
97
+ const labelsPart = labels.length > 0 ? ` (${labels.join(", ")})` : "";
98
+ return `${index + 1}. ${header}${labelsPart}`;
99
+ }
100
+
101
+ function truncate(text: string, limit: number): string {
102
+ return text.length <= limit ? text : `${text.slice(0, Math.max(0, limit - 1))}…`;
103
+ }
104
+
105
+ /**
106
+ * Register the first-party questionnaire tool.
107
+ *
108
+ * Name-collision semantics (live-verified against the installed Pi runtime):
109
+ * - Tool names are exclusive across extensions. Pi has no precedence, override,
110
+ * or silent shadowing: loading two extensions that register the same tool
111
+ * name fails the whole load with a hard error
112
+ * (`Tool "ask_user_question" conflicts with <other extension>`; the runtime
113
+ * exits non-zero). The name is either free or fatal, full stop.
114
+ * - `registerTool` writes into the calling extension's own tool map keyed by
115
+ * name, so re-registering inside one extension overwrites that entry
116
+ * (`loader.js:240`). That same-name write is the only one Pi tolerates.
117
+ * - This first-party tool ships as THE `ask_user_question` provider. A competing
118
+ * provider such as the third-party `@juicesharp/rpiv-ask-user-question`
119
+ * package fails the load by design and must be removed from the user's Pi
120
+ * settings; that deletion is the documented migration path, not a runtime
121
+ * precedence choice.
122
+ */
123
+ export default function askUserQuestion(pi: ExtensionAPI): void {
124
+ pi.registerTool({
125
+ name: QUESTION_TOOL_NAME,
126
+ renderShell: "self",
127
+ label: "Ask User Question",
128
+ 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.",
129
+ promptGuidelines: [
130
+ "Use ask_user_question to collect decisions in one batch: ask one to four questions at a time, each with two to four options.",
131
+ "Keep each header a short chip of at most 16 characters and each option label at most 60 characters.",
132
+ "Add a preview to an option when the user needs to compare rich detail side-by-side with the options.",
133
+ "Set multiSelect when the choices are not mutually exclusive.",
134
+ "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.",
135
+ "Never use this tool for decisions that must not be delegated to the user.",
136
+ ],
137
+ parameters: QuestionParamsSchema,
138
+ executionMode: "sequential",
139
+ async execute(
140
+ _toolCallId: string,
141
+ params: QuestionParams,
142
+ _signal: AbortSignal | undefined,
143
+ _onUpdate: undefined,
144
+ ctx,
145
+ ): Promise<QuestionnaireToolResult> {
146
+ const error = validateQuestionnaire(params);
147
+ if (error) return invalidQuestionnaireResult(error);
148
+ if (ctx.mode !== "tui") return unavailableResult();
149
+
150
+ let selection: QuestionnaireResult | undefined;
151
+ try {
152
+ pi.events.emit(ASK_USER_QUESTION_BLOCKED_EVENT, { active: true });
153
+ selection = await ctx.ui.custom<QuestionnaireResult>((tui, theme, keybindings, done) => {
154
+ const view = new QuestionnaireView({
155
+ questions: params.questions,
156
+ theme,
157
+ keybindings,
158
+ onComplete: (result) => done(result),
159
+ });
160
+ // Native dock swap, never an overlay: the transcript stays scrollable
161
+ // while the questionnaire owns focus. No `overlay` option is passed.
162
+ const container = createNativeFullscreenInteraction({
163
+ keyboardTarget: view,
164
+ requestRender: () => tui.requestRender(),
165
+ });
166
+ container.addChild(new DynamicBorder((text: string) => theme.fg("accent", text)));
167
+ container.addChild(view);
168
+ container.addChild(new DynamicBorder((text: string) => theme.fg("accent", text)));
169
+ return container;
170
+ });
171
+ }
172
+ finally {
173
+ pi.events.emit(ASK_USER_QUESTION_BLOCKED_EVENT, { active: false });
174
+ }
175
+
176
+ if (selection === undefined || selection.cancelled) {
177
+ return {
178
+ content: [{ type: "text", text: "User cancelled the questionnaire" }],
179
+ details: { cancelled: true },
180
+ };
181
+ }
182
+ return {
183
+ content: [{ type: "text", text: answersText(selection.answers) }],
184
+ details: { answers: selection.answers },
185
+ };
186
+ },
187
+ renderCall(args: QuestionParams, theme) {
188
+ const questions = Array.isArray(args.questions) ? args.questions : [];
189
+ const summary = truncate(questions.map((question, index) => callSummary(question, index)).join(" "), CALL_SUMMARY_LIMIT);
190
+ return new Text(
191
+ theme.fg("toolTitle", theme.bold("ask_user_question ")) +
192
+ theme.fg("muted", summary),
193
+ 0,
194
+ 0,
195
+ );
196
+ },
197
+ renderResult(result, _options, theme) {
198
+ const details = result.details as QuestionnaireDetails | undefined;
199
+ if (details?.cancelled === true) return new Text(theme.fg("warning", "Cancelled"), 0, 0);
200
+ const answers = Array.isArray(details?.answers) ? details.answers : [];
201
+ if (answers.length === 0) return new Text(theme.fg("warning", "No answers"), 0, 0);
202
+ const lines = answers.map((answer) => {
203
+ if (answer.kind === "multi") return theme.fg("success", `✓ ${answer.question} — ${(answer.selected ?? []).join(", ")}`);
204
+ if (answer.kind === "custom") return theme.fg("success", `✓ ${answer.question} — ${answerBody(answer)}`);
205
+ return theme.fg("success", `✓ ${answer.question} — ${answer.answer ?? ""}`);
206
+ });
207
+ return new Text(lines.join("\n"), 0, 0);
208
+ },
209
+ });
210
+ }
@@ -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";
@@ -529,6 +530,12 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
529
530
  // started before /new or /resume stays in the store and comes back with
530
531
  // its session. Before the first session_start there is nothing to scope by.
531
532
  const activeSessionId = (): string | undefined => (sessions === undefined ? undefined : sessions.getSessionId() ?? "");
533
+ // Pi 0.86.1 adds this event; the package's pinned 0.85.1 types predate it.
534
+ installBackgroundCacheWarming(pi as unknown as Parameters<typeof installBackgroundCacheWarming>[0], () => ({
535
+ sessionId: activeSessionId(),
536
+ ownedTaskIds,
537
+ tasks: store.list(activeSessionId()),
538
+ }));
532
539
  const visibleTasks = (): TaskRecord[] => store.list(activeSessionId());
533
540
  type SessionTransport = { generation: number; sessionId: string; sessionManager: ExtensionContext["sessionManager"]; client: SessionTransportClient; listener: SessionTransportListener; registry: SessionTransportRegistry; close(): Promise<void> };
534
541
  let transportGeneration = 0;
@@ -1168,7 +1175,7 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
1168
1175
  if (observe) metricTasks.set(task.id, metrics);
1169
1176
  ownedTaskIds.add(task.id);
1170
1177
  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));
1178
+ 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
1179
  // A tool call aborted by the host (a human interrupting the turn, a timeout)
1173
1180
  // would otherwise leave the child running and end the call with no result and
1174
1181
  // no recorded reason. Cancel through the runner so the lifecycle runs and the
@@ -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;