gentle-pi 3.2.1 → 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 (108) 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 +52 -15
  10. package/docs/readme-reference.md +67 -7
  11. package/docs/review-integration.md +22 -17
  12. package/extensions/ask-user-question.ts +210 -0
  13. package/extensions/gentle-agents.ts +93 -18
  14. package/extensions/gentle-ai.ts +180 -37
  15. package/extensions/gentle-shell.ts +476 -39
  16. package/extensions/gentle-todo.ts +19 -1
  17. package/extensions/quiet-tools.ts +28 -5
  18. package/extensions/startup-banner.ts +25 -10
  19. package/lib/agents-view.ts +41 -14
  20. package/lib/agents-widget.ts +84 -13
  21. package/lib/animation-policy.ts +52 -0
  22. package/lib/background-cache-warming.ts +38 -0
  23. package/lib/command-palette-catalog.ts +2 -0
  24. package/lib/double-esc-cancel-policy.ts +138 -0
  25. package/lib/inprocess-reviewer.ts +297 -0
  26. package/lib/native-review-cli.ts +50 -10
  27. package/lib/odd-runtime-delegation-gate.ts +88 -0
  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 +256 -171
  34. package/lib/review-integration-v2.ts +114 -27
  35. package/lib/shell-bar.ts +163 -75
  36. package/lib/shell-card.ts +19 -9
  37. package/lib/shell-changes-view.ts +43 -5
  38. package/lib/shell-changes.ts +92 -5
  39. package/lib/shell-hover.ts +39 -0
  40. package/lib/shell-prompt.ts +10 -1
  41. package/lib/shell-sidebar-layout.ts +118 -16
  42. package/lib/shell-sidebar.ts +16 -0
  43. package/lib/shell-todo.ts +7 -1
  44. package/lib/shell-usage-view.ts +103 -12
  45. package/lib/shell-usage.ts +120 -6
  46. package/package.json +1 -1
  47. package/runtime/native-review-cli.mjs +49 -9
  48. package/runtime/review-integration-v2.mjs +114 -27
  49. package/scripts/gentle-ai-installer.mjs +10 -10
  50. package/scripts/maintainer/provider-relay-matrix.mjs +118 -47
  51. package/scripts/verify-package-files.mjs +3 -4
  52. package/tests/agents-grouping.test.ts +75 -18
  53. package/tests/agents-view.test.ts +28 -18
  54. package/tests/agents-widget.test.ts +100 -12
  55. package/tests/animation-policy.test.ts +42 -0
  56. package/tests/ask-user-question.test.ts +435 -0
  57. package/tests/background-cache-warming.test.ts +60 -0
  58. package/tests/background-subagents.test.ts +68 -0
  59. package/tests/command-palette.test.ts +10 -0
  60. package/tests/devbinary/pi-host-relay.devtest.ts +176 -138
  61. package/tests/double-esc-cancel-policy.test.ts +194 -0
  62. package/tests/gentle-agents.test.ts +599 -7
  63. package/tests/gentle-ai-binary.test.ts +1 -1
  64. package/tests/gentle-ai-installer.test.ts +47 -47
  65. package/tests/gentle-ai.test.ts +125 -9
  66. package/tests/gentle-shell.test.ts +1149 -24
  67. package/tests/gentle-todo.test.ts +17 -4
  68. package/tests/inprocess-reviewer.test.ts +460 -0
  69. package/tests/maintainer/provider-relay.maintest.ts +101 -143
  70. package/tests/native-review-capability-contract.test.ts +34 -1
  71. package/tests/native-review-parity.test.ts +19 -0
  72. package/tests/odd-runtime-delegation-gate.test.ts +212 -0
  73. package/tests/orchestrator-rdd-ownership.test.ts +3 -3
  74. package/tests/package-manifest.test.ts +6 -17
  75. package/tests/questionnaire-schema.test.ts +274 -0
  76. package/tests/questionnaire-view.test.ts +446 -0
  77. package/tests/rdd-status-line.test.ts +21 -4
  78. package/tests/review-candidate-owner-retry.test.ts +63 -0
  79. package/tests/review-candidate-view.test.ts +15 -0
  80. package/tests/review-controller-native-routing.test.ts +86 -0
  81. package/tests/review-host-relay-routing.test.ts +77 -0
  82. package/tests/review-host-relay.test.ts +297 -299
  83. package/tests/review-integration-v2-forward.test.ts +61 -0
  84. package/tests/review-integration-v2.test.ts +146 -1
  85. package/tests/review-ledger-contract.test.ts +1 -2
  86. package/tests/review-relay-transport-agent.test.ts +129 -26
  87. package/tests/review-risk-assessment.test.ts +104 -0
  88. package/tests/runtime-harness.mjs +11 -0
  89. package/tests/session-changes-shell.test.ts +27 -0
  90. package/tests/session-worktree-registry.test.ts +41 -0
  91. package/tests/shell-bar.test.ts +200 -124
  92. package/tests/shell-card.test.ts +5 -3
  93. package/tests/shell-changes-view.test.ts +47 -0
  94. package/tests/shell-changes.test.ts +177 -0
  95. package/tests/shell-hover.test.ts +19 -0
  96. package/tests/shell-prompt.test.ts +20 -0
  97. package/tests/shell-sidebar-fullscreen.test.ts +59 -0
  98. package/tests/shell-sidebar-layout.test.ts +301 -8
  99. package/tests/shell-sidebar.test.ts +25 -1
  100. package/tests/shell-todo.test.ts +36 -0
  101. package/tests/shell-usage-view.test.ts +120 -1
  102. package/tests/shell-usage.test.ts +129 -0
  103. package/tests/skill-collision-prefixes.test.ts +1 -1
  104. package/tests/startup-banner.test.ts +93 -2
  105. package/docs/assets/brand/gentle-pi-banner.png +0 -0
  106. package/lib/opaque-pi-reviewer-adapter.ts +0 -404
  107. package/skills/release/SKILL.md +0 -137
  108. package/tests/opaque-pi-reviewer-adapter.test.ts +0 -410
@@ -1,5 +1,6 @@
1
1
  import { consumeReviewMutation, pendingReviewMutation, recordReviewMutation } from "../lib/review-reminder-receipt.ts";
2
2
  import { resolveSessionWorktree } from "../lib/session-worktree-registry.ts";
3
+ import { OddRuntimeDelegationGate } from "../lib/odd-runtime-delegation-gate.ts";
3
4
  import { resolveResearchCapabilities, renderResearchCapabilities } from "../lib/sdd-research-capabilities.ts";
4
5
  import { declareReviewRelayHandshake } from "../lib/review-relay-contract.ts";
5
6
  import { execFileSync } from "node:child_process";
@@ -116,6 +117,7 @@ import {
116
117
  readOrchestratorSettings,
117
118
  restoreOrchestratorSettings,
118
119
  type OrchestratorSettingsReadResult,
120
+ parseOrchestratorModelRef,
119
121
  } from "../lib/profiles-orchestrator.ts";
120
122
  import { measureAgentsViewLayout, type AgentsViewLayout } from "../lib/agents-view-layout.ts";
121
123
  import { NativeChoiceList } from "../lib/native-choice-list.ts";
@@ -132,11 +134,11 @@ import {
132
134
  REVIEW_HOST_RELAY_SUBMISSION_MISSING_MESSAGE,
133
135
  REVIEW_HOST_RELAY_UNAVAILABLE_MESSAGE,
134
136
  ReviewHostRelayError,
137
+ reviewHostMediatedRoleSlots,
135
138
  reviewHostRelaySlots,
136
139
  reviewHostRelayUnachievableDetail,
137
140
  reviewHostRelayUnachievableReason,
138
141
  reviewProviderRoleVectorSlots,
139
- resolveReviewHostRelayExtensionPaths,
140
142
  resolveReviewHostRelaySubmission,
141
143
  runReviewHostRelayReviewerGroup,
142
144
  runReviewHostRelaySlot,
@@ -147,6 +149,7 @@ import {
147
149
  type ReviewHostRelaySlot,
148
150
  type ReviewProviderRoleVectorSlot,
149
151
  } from "../lib/review-host-relay.ts";
152
+ import type { InProcessReviewerRegistry } from "../lib/inprocess-reviewer.ts";
150
153
  import {
151
154
  JOURNAL_STATUS,
152
155
  REVIEW_OPERATION,
@@ -196,6 +199,7 @@ import {
196
199
  nativeReviewRecoverAuthorization,
197
200
  normalizeNativeReviewCwd,
198
201
  NativeReviewCliError,
202
+ nativeUntrackedSelection,
199
203
  NativeReviewConsentBindingError,
200
204
  NativeReviewConsentRequiredError,
201
205
  NativeReviewIntegrationError,
@@ -1020,18 +1024,26 @@ async function resolveReviewAssessmentPlan(
1020
1024
 
1021
1025
  let assessment: ReviewAssessmentV1 | undefined;
1022
1026
  let unassessableDetail: string | undefined;
1027
+ let unassessableCode = "native-assess-unavailable";
1023
1028
  if (nativeReviewCli?.assess === undefined) {
1024
1029
  unassessableDetail = "native review assess is unavailable: the installed gentle-ai binary does not expose the assess command.";
1025
1030
  } else {
1026
1031
  try {
1027
1032
  const request: NativeReviewAssessRequest = {
1028
1033
  cwd,
1034
+ ...nativeUntrackedSelection(input),
1029
1035
  ...(input.baseRef === undefined ? {} : { baseRef: input.baseRef, committedOnly: true as const }),
1030
1036
  ...(signal === undefined ? {} : { signal }),
1031
1037
  };
1032
1038
  assessment = await nativeReviewCli.assess(request);
1033
1039
  } catch (error) {
1034
- 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.";
1035
1047
  }
1036
1048
  }
1037
1049
 
@@ -1047,7 +1059,7 @@ async function resolveReviewAssessmentPlan(
1047
1059
  return {
1048
1060
  schema: "gentle-pi.review-assessment-plan/v1",
1049
1061
  risk,
1050
- reasons: assessment?.reasons ?? (unassessableDetail === undefined ? [] : [{ code: "native-assess-unavailable", path: "", detail: unassessableDetail }]),
1062
+ reasons: assessment?.reasons ?? (unassessableDetail === undefined ? [] : [{ code: unassessableCode, path: "", detail: unassessableDetail }]),
1051
1063
  changedPaths: assessment?.changedPaths ?? 0,
1052
1064
  changedLines: assessment?.changedLines ?? 0,
1053
1065
  candidate: assessment === undefined ? null : { kind: assessment.candidate.kind, baseRef: assessment.candidate.baseRef },
@@ -4137,6 +4149,39 @@ function reportProfilesDrops(ctx: ExtensionContext, path: string, drops: Profile
4137
4149
  }
4138
4150
  }
4139
4151
 
4152
+ /** Pi's own live-session controls: the ExtensionAPI's setModel/setThinkingLevel. */
4153
+ type LiveSession = Pick<ExtensionAPI, "setModel" | "setThinkingLevel">;
4154
+
4155
+ /**
4156
+ * Switch the running session to the profile's orchestrator. `settings.json`
4157
+ * is the default for new sessions only; Pi's `setModel`/`setThinkingLevel`
4158
+ * are what move the live one. Failures never undo the persisted default: the
4159
+ * profile is applied for the next session either way, and the note says what
4160
+ * this session did. Nothing here may throw — settings.json is already written
4161
+ * and the apply must finish reporting.
4162
+ */
4163
+ async function switchLiveOrchestrator(ctx: ExtensionContext, live: LiveSession, entry: AgentRoutingEntry): Promise<string> {
4164
+ const reference = parseOrchestratorModelRef(entry.model);
4165
+ if (reference === undefined) return "";
4166
+ const label = `${reference.provider}/${reference.model}`;
4167
+ const model = ctx.modelRegistry.find(reference.provider, reference.model);
4168
+ if (model === undefined) return `\n${label} is not in the model catalog; this session keeps its current model.`;
4169
+ let switched = false;
4170
+ try {
4171
+ switched = await live.setModel(model);
4172
+ } catch (error) {
4173
+ return `\nThis session could not switch to ${label}: ${sanitizeTerminalText(error instanceof Error ? error.message : String(error))}.`;
4174
+ }
4175
+ if (!switched) return `\nno authentication is configured for ${reference.provider}; this session keeps its current model.`;
4176
+ if (entry.thinking === undefined) return `\nThis session now runs on ${label}.`;
4177
+ try {
4178
+ live.setThinkingLevel(entry.thinking);
4179
+ } catch (error) {
4180
+ return `\nThis session now runs on ${label}, but its thinking level could not be set to ${entry.thinking}: ${sanitizeTerminalText(error instanceof Error ? error.message : String(error))}.`;
4181
+ }
4182
+ return `\nThis session now runs on ${label} · ${entry.thinking}.`;
4183
+ }
4184
+
4140
4185
  function profileSnapshotFrom(
4141
4186
  current: AgentModelConfig,
4142
4187
  settings: OrchestratorSettingsReadResult,
@@ -4150,6 +4195,7 @@ function profileSnapshotFrom(
4150
4195
 
4151
4196
  async function runProfilesPanelAction(
4152
4197
  ctx: ExtensionContext,
4198
+ live: LiveSession,
4153
4199
  path: string,
4154
4200
  file: AgentProfilesFile,
4155
4201
  result: Exclude<ProfilesPanelResult, { type: "close" }>,
@@ -4291,6 +4337,10 @@ async function runProfilesPanelAction(
4291
4337
  orchestratorRollback = () => restoreOrchestratorSettings(settingsPath, previous);
4292
4338
  orchestratorNote = `\nOrchestrator set to ${formatOrchestratorSelection(orchestratorEntry)} in ${sanitizeTerminalText(settingsPath)}.`;
4293
4339
  }
4340
+ // settings.json only governs future sessions. The session the user is
4341
+ // sitting in keeps its model until told otherwise, which made a profile
4342
+ // look applied while the orchestrator kept answering with the old one.
4343
+ orchestratorNote += await switchLiveOrchestrator(ctx, live, orchestratorEntry);
4294
4344
  }
4295
4345
  // A pin that does not resolve changes nothing at launch, so the global apply
4296
4346
  // above is what governs this repository. Saying so keeps a broken pin from
@@ -4537,7 +4587,7 @@ async function runProfilesPanelAction(
4537
4587
  }
4538
4588
  }
4539
4589
 
4540
- async function handleProfilesCommand(ctx: ExtensionContext): Promise<void> {
4590
+ async function handleProfilesCommand(ctx: ExtensionContext, live: LiveSession): Promise<void> {
4541
4591
  const path = profilesFilePath(gentleAiConfigHome());
4542
4592
  const read = readProfilesFileResult(path);
4543
4593
  if (read.status === "invalid") {
@@ -4587,7 +4637,7 @@ async function handleProfilesCommand(ctx: ExtensionContext): Promise<void> {
4587
4637
  );
4588
4638
  while (result.type !== "close") {
4589
4639
  selectedName = "name" in result ? result.name : undefined;
4590
- file = await runProfilesPanelAction(ctx, path, file, result);
4640
+ file = await runProfilesPanelAction(ctx, live, path, file, result);
4591
4641
  result = await showProfilesPanel(
4592
4642
  ctx,
4593
4643
  file,
@@ -4785,7 +4835,7 @@ interface ReviewScopeParameters {
4785
4835
  // as `gentle_review` operation `assess` (not a dedicated tool), taking its
4786
4836
  // optional fields through the controller's existing generic `input` JSON
4787
4837
  // string, exactly like START's `{"mode":...,"baseRef":...}`.
4788
- interface ReviewAssessInput {
4838
+ interface ReviewAssessInput extends Pick<NativeReviewAssessRequest, "untrackedScope" | "expectedUntrackedInventory" | "intendedUntracked"> {
4789
4839
  baseRef?: string;
4790
4840
  committedOnly?: boolean;
4791
4841
  writerModelId?: string;
@@ -4804,7 +4854,7 @@ function isNativeReviewOutcome(value: unknown): value is NativeReviewOutcome {
4804
4854
  function parseReviewAssessInput(operation: ReviewControllerOperation, raw: string | undefined): ReviewAssessInput {
4805
4855
  if (raw === undefined) return {};
4806
4856
  const value = parseControllerJson(raw, operation);
4807
- const allowed = new Set(["baseRef", "committedOnly", "writerModelId", "writerEffort", "nativeReviewOutcome"]);
4857
+ const allowed = new Set(["baseRef", "committedOnly", "writerModelId", "writerEffort", "nativeReviewOutcome", "untrackedScope", "expectedUntrackedInventory", "intendedUntracked"]);
4808
4858
  const unexpected = Object.keys(value).find((key) => !allowed.has(key));
4809
4859
  if (unexpected !== undefined) throw new Error(`Review controller ${operation} input does not accept ${unexpected}`);
4810
4860
  const { baseRef, committedOnly, writerModelId, writerEffort, nativeReviewOutcome } = value;
@@ -4818,6 +4868,7 @@ function parseReviewAssessInput(operation: ReviewControllerOperation, raw: strin
4818
4868
  return {
4819
4869
  ...(baseRef === undefined ? {} : { baseRef: baseRef as string }),
4820
4870
  ...(committedOnly === undefined ? {} : { committedOnly: committedOnly as boolean }),
4871
+ ...nativeUntrackedSelection(value),
4821
4872
  ...(writerModelId === undefined ? {} : { writerModelId: writerModelId as string }),
4822
4873
  ...(writerEffort === undefined ? {} : { writerEffort: writerEffort as string }),
4823
4874
  ...(nativeReviewOutcome === undefined ? {} : { nativeReviewOutcome: nativeReviewOutcome as NativeReviewOutcome }),
@@ -6594,18 +6645,22 @@ function reviewHostRelayFailureReport(error: ReviewHostRelayError): Record<strin
6594
6645
  };
6595
6646
  }
6596
6647
 
6597
- // gentle-shell#1136 / #1158: the only two user-owned launch selections the
6598
- // relay accepts. The lens's model comes from the agent model routing config
6599
- // under the lens's agent name; the extension allowlist comes from the
6600
- // environment. Both are optional, and neither is ever invented here.
6601
- function reviewHostRelayLaunchSelection(lens: string | undefined, config: AgentModelConfig, environment: NodeJS.ProcessEnv): { reviewerModel?: string; reviewerExtensionPaths?: readonly string[] } {
6602
- const agentName = lens === undefined || lens.length === 0 ? undefined : lens.startsWith("review-") ? lens : `review-${lens}`;
6603
- const entry = agentName === undefined ? undefined : config[agentName];
6648
+ // gentle-pi#311 P2 (superseding gentle-shell#1136 / #1158): the lens's
6649
+ // completion selection comes from its entry in the agent model routing
6650
+ // config, keyed by its routing key (`review-<lens>`). There is no extension
6651
+ // allowlist and no ambient default model: the in-process completion resolves
6652
+ // its provider through the live model registry the caller supplies, or it is
6653
+ // refused before materialize ever runs (validateReviewerSelectionConfiguration
6654
+ // in lib/review-host-relay.ts).
6655
+ function reviewHostRelaySelection(lens: string | undefined, config: AgentModelConfig): { selection?: string; thinking?: string; routingKey: string } {
6656
+ const routingKey = lens === undefined || lens.length === 0 ? "review capture" : lens.startsWith("review-") ? lens : `review-${lens}`;
6657
+ const entry = config[routingKey];
6604
6658
  const model = typeof entry === "object" && entry !== null && typeof (entry as AgentRoutingEntry).model === "string" && (entry as AgentRoutingEntry).model!.length > 0 ? (entry as AgentRoutingEntry).model : undefined;
6605
- const extensionPaths = resolveReviewHostRelayExtensionPaths(environment);
6659
+ const thinking = typeof entry === "object" && entry !== null && typeof (entry as AgentRoutingEntry).thinking === "string" ? (entry as AgentRoutingEntry).thinking : undefined;
6606
6660
  return {
6607
- ...(model === undefined ? {} : { reviewerModel: model }),
6608
- ...(extensionPaths.length === 0 ? {} : { reviewerExtensionPaths: extensionPaths }),
6661
+ ...(model === undefined ? {} : { selection: model }),
6662
+ ...(thinking === undefined ? {} : { thinking }),
6663
+ routingKey,
6609
6664
  };
6610
6665
  }
6611
6666
 
@@ -6708,6 +6763,12 @@ async function executeReviewHostRelayCapture(
6708
6763
  selections: Map<string, RetainedNativeStatusSelection>,
6709
6764
  route: RetainedNativeCaptureRoute | undefined,
6710
6765
  signal?: AbortSignal,
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,
6711
6772
  ): Promise<Record<string, unknown>> {
6712
6773
  try {
6713
6774
  if (slot.submission === undefined) {
@@ -6718,15 +6779,20 @@ async function executeReviewHostRelayCapture(
6718
6779
  );
6719
6780
  }
6720
6781
  const result = await activeReviewHostRelayRunner((() => {
6721
- // gentle-shell#1136 / #1158: the lens's user-owned reviewer selection and
6722
- // the extension allowlist ride the request; the relay validates and
6723
- // refuses broken configurations typed before anything launches.
6724
- const launch = reviewHostRelayLaunchSelection(slot.lens, readModelConfig(cwd), process.env);
6782
+ // gentle-pi#311 P2 / P3: the lens's (or, for a v9 host-mediated role
6783
+ // slot, the fixed review-refuter/review-validator routing key's)
6784
+ // user-owned completion selection rides the request alongside the
6785
+ // live model registry; the relay validates and refuses a missing
6786
+ // registry or a routing entry with no configured model typed before
6787
+ // anything launches, never a fallback to a child.
6788
+ const launch = reviewHostRelaySelection(slot.routingKey ?? slot.lens, readModelConfig(cwd));
6725
6789
  return {
6726
6790
  captureArgumentTokens: slot.captureArgumentTokens,
6727
6791
  targetCwd: cwd,
6728
6792
  submission: slot.submission,
6729
6793
  ...launch,
6794
+ ...(modelRegistry === undefined ? {} : { reviewerRegistry: modelRegistry }),
6795
+ ...(reviewerSessionId === undefined ? {} : { reviewerSessionId }),
6730
6796
  ...(signal === undefined ? {} : { signal }),
6731
6797
  };
6732
6798
  })());
@@ -6742,6 +6808,7 @@ async function executeReviewHostRelayCapture(
6742
6808
  ...(slot.lens === undefined ? {} : { lens: slot.lens }),
6743
6809
  ...(slot.order === undefined ? {} : { order: slot.order }),
6744
6810
  ...(slot.subjectHash === undefined ? {} : { subject_hash: slot.subjectHash }),
6811
+ ...(slot.routingKey === undefined ? {} : { role: slot.name }),
6745
6812
  prompt_bytes: result.promptByteLength,
6746
6813
  result_bytes: result.resultByteLength,
6747
6814
  submission: result.submission,
@@ -7323,6 +7390,16 @@ async function executeReviewCaptureOperation(
7323
7390
  candidateViews: CandidateViewRegistry | null = new CandidateViewRegistry(),
7324
7391
  retainedUntrackedSelections: Map<string, RetainedNativeStatusSelection> = new Map(),
7325
7392
  requireRegisteredRoute = false,
7393
+ // gentle-pi#311 P2: the live model registry a lens materialize capture
7394
+ // resolves its in-process completion through. Appended last (rather than
7395
+ // inserted) so every existing positional call site — none of which pass an
7396
+ // eighth argument — keeps compiling unchanged.
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,
7326
7403
  ): Promise<Record<string, unknown>> {
7327
7404
  const parameters = parseReviewCaptureParameters(parametersValue);
7328
7405
  if (nativeReviewCli === null || nativeReviewCli.targetStatus === undefined) {
@@ -7382,7 +7459,33 @@ async function executeReviewCaptureOperation(
7382
7459
  mutation_outcome: "none",
7383
7460
  };
7384
7461
  }
7385
- return withCorrectionTarget(await executeReviewHostRelayCapture(hostRelaySlots[0]!, nativeReviewCli, cwd, selected.binding, retainedUntrackedSelections, route, signal));
7462
+ return withCorrectionTarget(await executeReviewHostRelayCapture(hostRelaySlots[0]!, nativeReviewCli, cwd, selected.binding, retainedUntrackedSelections, route, signal, modelRegistry, reviewerSessionId));
7463
+ }
7464
+
7465
+ // gentle-pi#311 P3: gentle-ai's v9 contract renders the refuter and
7466
+ // targeted-validator role captures host-mediated, exactly like the lens
7467
+ // slot above — same relay machinery, only its fixed review-refuter /
7468
+ // review-validator routing key (carried on the slot) and its own schema
7469
+ // differ. An older gentle-ai's self-contained --execute=true vector still
7470
+ // falls through to reviewProviderRoleVectorSlots below unchanged.
7471
+ const hostMediatedRoleSlots = reviewHostMediatedRoleSlots([selected.input]);
7472
+ if (hostMediatedRoleSlots.length === 1) {
7473
+ if (parameters.correctionLines !== undefined) return captureBindingRejected("correctionLines is valid only for a correction-plan capture");
7474
+ if (parameters.reviewerRunAcknowledged !== true) {
7475
+ return {
7476
+ tool: "gentle_review_capture",
7477
+ status: "blocked",
7478
+ outcome: "reviewer-model-run-forecast",
7479
+ cost_forecast: {
7480
+ transport: "pi_host_relay",
7481
+ model_runs: 1,
7482
+ roles: [hostMediatedRoleSlots[0]!.routingKey],
7483
+ },
7484
+ mutation_performed: false,
7485
+ mutation_outcome: "none",
7486
+ };
7487
+ }
7488
+ return withCorrectionTarget(await executeReviewHostRelayCapture(hostMediatedRoleSlots[0]!, nativeReviewCli, cwd, selected.binding, retainedUntrackedSelections, route, signal, modelRegistry, reviewerSessionId));
7386
7489
  }
7387
7490
 
7388
7491
  if (selected.input.captureOperation === "review.capture-correction-plan") {
@@ -7461,6 +7564,12 @@ async function executeReviewCaptureGroupOperation(
7461
7564
  candidateViews: CandidateViewRegistry | null = new CandidateViewRegistry(),
7462
7565
  retainedUntrackedSelections: Map<string, RetainedNativeStatusSelection> = new Map(),
7463
7566
  requireRegisteredRoute = false,
7567
+ // gentle-pi#311 P2: see executeReviewCaptureOperation's matching parameter.
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,
7464
7573
  ): Promise<Record<string, unknown>> {
7465
7574
  const parameters = parseReviewCaptureGroupParameters(parametersValue);
7466
7575
  if (nativeReviewCli === null || nativeReviewCli.targetStatus === undefined) return { ...captureGroupRejected("native target STATUS is unavailable"), outcome: "native-status-unsupported" };
@@ -7501,7 +7610,9 @@ async function executeReviewCaptureGroupOperation(
7501
7610
  captureArgumentTokens: slot.captureArgumentTokens,
7502
7611
  targetCwd: cwd,
7503
7612
  submission: slot.submission!,
7504
- ...reviewHostRelayLaunchSelection(slot.lens, readModelConfig(cwd), process.env),
7613
+ ...reviewHostRelaySelection(slot.lens, readModelConfig(cwd)),
7614
+ ...(modelRegistry === undefined ? {} : { reviewerRegistry: modelRegistry }),
7615
+ ...(reviewerSessionId === undefined ? {} : { reviewerSessionId }),
7505
7616
  ...(signal === undefined ? {} : { signal }),
7506
7617
  }));
7507
7618
  let prepared: readonly ReviewHostRelayPreparedResult[];
@@ -8232,11 +8343,13 @@ async function executeReviewControllerOperation(
8232
8343
  // native START are resolved, and re-derive the target for that range,
8233
8344
  // so all three agree on one base-diff identity. Adopting the offer
8234
8345
  // later left the workspace target and the base-diff candidate view
8235
- // disagreeing, and START failed with identity-mismatch. Both an
8236
- // explicit caller baseRef and any START with an untracked selection in
8237
- // play keep today's single-STATUS flow; only an adopted offer pays the
8238
- // second read-only STATUS.
8239
- 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) {
8240
8353
  const offeredBaseRef = offeredCommittedRangeBaseRef(target);
8241
8354
  if (offeredBaseRef !== undefined) {
8242
8355
  const renegotiated = await negotiatedStatusForHostTransport(nativeReviewCli, {
@@ -8244,6 +8357,8 @@ async function executeReviewControllerOperation(
8244
8357
  ...(parameters.lineageId === undefined ? {} : { lineageId: parameters.lineageId }),
8245
8358
  baseRef: offeredBaseRef,
8246
8359
  committedOnly: true,
8360
+ ...(untrackedSelection.untrackedScope === undefined ? {} : untrackedSelection),
8361
+ ...(untrackedSubmission === undefined ? {} : { intendedUntrackedSelection: untrackedSubmission }),
8247
8362
  ...(signal === undefined ? {} : { signal }),
8248
8363
  }, retainedUntrackedSelections, defaultCwd);
8249
8364
  if (renegotiated.transport !== undefined) return hostTransportUnavailable(parameters.operation, renegotiated.transport);
@@ -8290,11 +8405,7 @@ async function executeReviewControllerOperation(
8290
8405
  let nativeStartAttempted = false;
8291
8406
  try {
8292
8407
  const candidateRequest = { contributorRoot: defaultCwd, replayKey, ...(canonicalBaseRef === undefined ? {} : { baseRef: canonicalBaseRef, committedOnly: true }) };
8293
- candidateView = candidateViews?.createOrReuse({ ...candidateRequest, ...(candidateIntendedUntracked.length === 0 ? {} : { intendedUntracked: candidateIntendedUntracked }) });
8294
- if (candidateView !== undefined && candidateIntendedUntracked.length === 0 && candidateView.candidateTree !== target.projection.currentCandidateTree) {
8295
- candidateView.cleanup();
8296
- candidateView = candidateViews?.createOrReuse({ ...candidateRequest, intendedUntracked: [] });
8297
- }
8408
+ candidateView = candidateViews?.createOrReuse({ ...candidateRequest, intendedUntracked: candidateIntendedUntracked });
8298
8409
  if (candidateView !== undefined) assertNativeStartCandidateBinding(candidateView, target);
8299
8410
  let result: NativeStartResult;
8300
8411
  try {
@@ -8648,6 +8759,11 @@ function createGentleAiExtensionForTesting(
8648
8759
  const candidateViews = dependencies.candidateViews === undefined ? new CandidateViewRegistry() : dependencies.candidateViews;
8649
8760
  const herdrLifecycle = createHerdrConfirmationLifecycle(pi.events);
8650
8761
  const permissionEnvironment = dependencies.processEnv ?? process.env;
8762
+ const oddDelegationGate = new OddRuntimeDelegationGate();
8763
+ const oddSessionId = (ctx: ExtensionContext): string => {
8764
+ try { return ctx.sessionManager.getSessionId(); }
8765
+ catch { return ""; }
8766
+ };
8651
8767
 
8652
8768
  const setReviewSessionPermissionStatus = (context: ExtensionContext, active: boolean): void => {
8653
8769
  try {
@@ -8769,6 +8885,11 @@ function createGentleAiExtensionForTesting(
8769
8885
  candidateViews,
8770
8886
  ((sessionKey: PendingReviewConsentSessionKey) => processRetainedNativeStatusSelections.get(sessionKey) ?? processRetainedNativeStatusSelections.set(sessionKey, new Map()).get(sessionKey)!)(pendingReviewConsentSessionKey(ctx, pendingReviewConsentFallbackKey)),
8771
8887
  true,
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,
8772
8893
  );
8773
8894
  return { content: [{ type: "text", text: JSON.stringify(details) }], details };
8774
8895
  },
@@ -8782,7 +8903,7 @@ function createGentleAiExtensionForTesting(
8782
8903
  promptSnippet: "Use one exact current STATUS collectBinding for one ordinary native capture; call fresh STATUS before every additional capture.",
8783
8904
  promptGuidelines: [
8784
8905
  "Pass only lineageId, the JSON-serialized exact collectBinding from current STATUS, and the route-specific optional acknowledgement or correctionLines value. Never compose provider argument tokens, prompts, results, verdicts, or lens arrays.",
8785
- "A materialize reviewer slot first forecasts one model run; re-submit that same exact binding with reviewerRunAcknowledged: true to authorize one host relay. Correction-plan slots require correctionLines inside the provider-issued bounds, counted in diff lines (one replaced source line is one deletion plus one addition) — a different unit from the frozen logical correction budget. Refuter and validation vectors execute exactly once as provider-rendered.",
8906
+ "A materialize reviewer slot first forecasts one model run; re-submit that same exact binding with reviewerRunAcknowledged: true to authorize one host relay. Correction-plan slots require correctionLines inside the provider-issued bounds, counted in diff lines (one replaced source line is one deletion plus one addition) — a different unit from the frozen logical correction budget. A refuter or targeted-validator slot forecasts and runs the same way when the provider renders it host-mediated; an older provider's self-contained refuter/validation vector still executes exactly once as provider-rendered.",
8786
8907
  "A native terminal closure or nonterminal capture returns directly. Do not expect automatic STATUS, FINALIZE, receipt, delivery, or another capture; call fresh STATUS before any next capture.",
8787
8908
  ],
8788
8909
  parameters: REVIEW_CAPTURE_PARAMETERS,
@@ -8807,6 +8928,11 @@ function createGentleAiExtensionForTesting(
8807
8928
  candidateViews,
8808
8929
  ((sessionKey: PendingReviewConsentSessionKey) => processRetainedNativeStatusSelections.get(sessionKey) ?? processRetainedNativeStatusSelections.set(sessionKey, new Map()).get(sessionKey)!)(pendingReviewConsentSessionKey(ctx, pendingReviewConsentFallbackKey)),
8809
8930
  true,
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,
8810
8936
  );
8811
8937
  return {
8812
8938
  content: [{ type: "text", text: JSON.stringify(details) }],
@@ -9043,6 +9169,10 @@ function createGentleAiExtensionForTesting(
9043
9169
  const retiredSync = readAgentStartNames(event).includes("sdd-sync") || /\bSDD sync executor\b/i.test(event.systemPrompt ?? "");
9044
9170
  const isSddAgent = retiredSync || isSddAgentStartEvent(event);
9045
9171
  const isNamedAgent = isNamedAgentStartEvent(event);
9172
+ oddDelegationGate.start(
9173
+ oddSessionId(ctx),
9174
+ !isNamedAgent && !isSddAgent && permissionEnvironment.GENTLE_PI_AGENTS_CHILD !== "1",
9175
+ );
9046
9176
  const subagentDepthKey = pendingReviewConsentSessionKey(ctx, pendingReviewConsentFallbackKey);
9047
9177
  if (isSddAgent || isNamedAgent) {
9048
9178
  processAgentEndSubagentDepth.set(subagentDepthKey, (processAgentEndSubagentDepth.get(subagentDepthKey) ?? 0) + 1);
@@ -9152,6 +9282,7 @@ function createGentleAiExtensionForTesting(
9152
9282
  // consent, or chooses a partial candidate. Durable own-mutation receipts
9153
9283
  // gate STATUS and consume only the generation captured before that await.
9154
9284
  pi.on("agent_end", async (_event, ctx) => {
9285
+ oddDelegationGate.endChild(oddSessionId(ctx));
9155
9286
  if (nativeReviewCli?.reviewMode === undefined || nativeReviewCli.targetStatus === undefined) return;
9156
9287
  if (ctx.hasUI !== true || !reminderSessionActive) return;
9157
9288
  const sessionKey = pendingReviewConsentSessionKey(ctx, pendingReviewConsentFallbackKey);
@@ -9187,6 +9318,7 @@ function createGentleAiExtensionForTesting(
9187
9318
  pi.on("tool_result", (event, ctx) => {
9188
9319
  if (!reminderSessionActive || event.isError !== false || (event.toolName !== "write" && event.toolName !== "edit")) return;
9189
9320
  if (!isRecord(event.input) || typeof event.input.path !== "string" || !event.input.path.trim()) return;
9321
+ oddDelegationGate.recordSuccess(oddSessionId(ctx), event.toolName, event.input, ctx.cwd);
9190
9322
  try {
9191
9323
  const root = resolveSessionWorktree(event.input.path, ctx.cwd)?.root;
9192
9324
  if (root) recordReviewMutation(pi, ctx.sessionManager, root, { source: "direct", toolName: event.toolName, toolCallId: event.toolCallId });
@@ -9200,6 +9332,10 @@ function createGentleAiExtensionForTesting(
9200
9332
  event.input,
9201
9333
  );
9202
9334
  if (sensitivePathDenied) return sensitivePathDenied;
9335
+ const oddDelegationDenied = oddDelegationGate.beforeTool(
9336
+ oddSessionId(ctx), event.toolName, event.input, ctx.cwd, readActiveToolNames(pi),
9337
+ );
9338
+ if (oddDelegationDenied) return oddDelegationDenied;
9203
9339
  if (event.toolName === "subagent_run") {
9204
9340
  const sddAgent = sddDispatchAgentName(event.input);
9205
9341
  if (sddAgent === "invalid") {
@@ -9365,7 +9501,7 @@ function createGentleAiExtensionForTesting(
9365
9501
  pi.registerCommand("gentle:profiles", {
9366
9502
  description: "Create, switch, and manage global agent-model profiles for el Gentleman.",
9367
9503
  handler: async (_args, ctx) => {
9368
- await handleProfilesCommand(ctx);
9504
+ await handleProfilesCommand(ctx, pi);
9369
9505
  },
9370
9506
  });
9371
9507
 
@@ -9594,9 +9730,16 @@ function createGentleAiExtensionForTesting(
9594
9730
  // background subagents may be launched at all, so nothing in Pi may write
9595
9731
  // it. The only writer is this handler, reached only by explicit invocation.
9596
9732
  pi.registerCommand("gentle:background-subagents", {
9597
- 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.
9598
9736
  handler: async (args, ctx) => {
9599
- 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
+ }
9600
9743
  if (subAction !== "status" && subAction !== "enable" && subAction !== "disable") {
9601
9744
  ctx.ui.notify(`Unknown /gentle:background-subagents sub-action "${subAction}". Use status, enable, or disable.`, "warning");
9602
9745
  return;