gentle-pi 3.2.1 → 3.3.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 (71) hide show
  1. package/docs/gentle-shell.md +27 -14
  2. package/docs/readme-reference.md +41 -7
  3. package/docs/review-integration.md +22 -17
  4. package/extensions/gentle-agents.ts +85 -17
  5. package/extensions/gentle-ai.ts +123 -21
  6. package/extensions/gentle-shell.ts +379 -31
  7. package/extensions/gentle-todo.ts +19 -1
  8. package/lib/agents-view.ts +41 -14
  9. package/lib/agents-widget.ts +84 -13
  10. package/lib/command-palette-catalog.ts +1 -0
  11. package/lib/double-esc-cancel-policy.ts +138 -0
  12. package/lib/inprocess-reviewer.ts +260 -0
  13. package/lib/native-review-cli.ts +14 -0
  14. package/lib/odd-runtime-delegation-gate.ts +88 -0
  15. package/lib/review-host-relay.ts +246 -171
  16. package/lib/review-integration-v2.ts +110 -26
  17. package/lib/shell-bar.ts +150 -75
  18. package/lib/shell-card.ts +19 -9
  19. package/lib/shell-changes-view.ts +43 -5
  20. package/lib/shell-changes.ts +92 -5
  21. package/lib/shell-hover.ts +39 -0
  22. package/lib/shell-prompt.ts +10 -1
  23. package/lib/shell-sidebar-layout.ts +111 -15
  24. package/lib/shell-sidebar.ts +16 -0
  25. package/lib/shell-todo.ts +7 -1
  26. package/lib/shell-usage-view.ts +98 -10
  27. package/package.json +1 -1
  28. package/runtime/native-review-cli.mjs +14 -0
  29. package/runtime/review-integration-v2.mjs +110 -26
  30. package/scripts/gentle-ai-installer.mjs +10 -10
  31. package/scripts/maintainer/provider-relay-matrix.mjs +118 -47
  32. package/scripts/verify-package-files.mjs +3 -3
  33. package/tests/agents-grouping.test.ts +75 -18
  34. package/tests/agents-view.test.ts +28 -18
  35. package/tests/agents-widget.test.ts +100 -12
  36. package/tests/command-palette.test.ts +1 -0
  37. package/tests/devbinary/pi-host-relay.devtest.ts +176 -138
  38. package/tests/double-esc-cancel-policy.test.ts +194 -0
  39. package/tests/gentle-agents.test.ts +528 -5
  40. package/tests/gentle-ai-binary.test.ts +1 -1
  41. package/tests/gentle-ai-installer.test.ts +47 -47
  42. package/tests/gentle-ai.test.ts +69 -5
  43. package/tests/gentle-shell.test.ts +795 -23
  44. package/tests/gentle-todo.test.ts +17 -4
  45. package/tests/inprocess-reviewer.test.ts +368 -0
  46. package/tests/maintainer/provider-relay.maintest.ts +101 -143
  47. package/tests/native-review-capability-contract.test.ts +19 -1
  48. package/tests/odd-runtime-delegation-gate.test.ts +212 -0
  49. package/tests/orchestrator-rdd-ownership.test.ts +3 -3
  50. package/tests/package-manifest.test.ts +6 -6
  51. package/tests/review-host-relay-routing.test.ts +77 -0
  52. package/tests/review-host-relay.test.ts +277 -300
  53. package/tests/review-integration-v2-forward.test.ts +61 -0
  54. package/tests/review-integration-v2.test.ts +116 -1
  55. package/tests/review-relay-transport-agent.test.ts +22 -24
  56. package/tests/runtime-harness.mjs +11 -0
  57. package/tests/session-changes-shell.test.ts +27 -0
  58. package/tests/session-worktree-registry.test.ts +41 -0
  59. package/tests/shell-bar.test.ts +192 -124
  60. package/tests/shell-card.test.ts +5 -3
  61. package/tests/shell-changes-view.test.ts +47 -0
  62. package/tests/shell-changes.test.ts +177 -0
  63. package/tests/shell-hover.test.ts +19 -0
  64. package/tests/shell-prompt.test.ts +20 -0
  65. package/tests/shell-sidebar-fullscreen.test.ts +59 -0
  66. package/tests/shell-sidebar-layout.test.ts +243 -5
  67. package/tests/shell-sidebar.test.ts +25 -1
  68. package/tests/shell-todo.test.ts +36 -0
  69. package/tests/shell-usage-view.test.ts +120 -1
  70. package/lib/opaque-pi-reviewer-adapter.ts +0 -404
  71. 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,
@@ -4137,6 +4140,39 @@ function reportProfilesDrops(ctx: ExtensionContext, path: string, drops: Profile
4137
4140
  }
4138
4141
  }
4139
4142
 
4143
+ /** Pi's own live-session controls: the ExtensionAPI's setModel/setThinkingLevel. */
4144
+ type LiveSession = Pick<ExtensionAPI, "setModel" | "setThinkingLevel">;
4145
+
4146
+ /**
4147
+ * Switch the running session to the profile's orchestrator. `settings.json`
4148
+ * is the default for new sessions only; Pi's `setModel`/`setThinkingLevel`
4149
+ * are what move the live one. Failures never undo the persisted default: the
4150
+ * profile is applied for the next session either way, and the note says what
4151
+ * this session did. Nothing here may throw — settings.json is already written
4152
+ * and the apply must finish reporting.
4153
+ */
4154
+ async function switchLiveOrchestrator(ctx: ExtensionContext, live: LiveSession, entry: AgentRoutingEntry): Promise<string> {
4155
+ const reference = parseOrchestratorModelRef(entry.model);
4156
+ if (reference === undefined) return "";
4157
+ const label = `${reference.provider}/${reference.model}`;
4158
+ const model = ctx.modelRegistry.find(reference.provider, reference.model);
4159
+ if (model === undefined) return `\n${label} is not in the model catalog; this session keeps its current model.`;
4160
+ let switched = false;
4161
+ try {
4162
+ switched = await live.setModel(model);
4163
+ } catch (error) {
4164
+ return `\nThis session could not switch to ${label}: ${sanitizeTerminalText(error instanceof Error ? error.message : String(error))}.`;
4165
+ }
4166
+ if (!switched) return `\nno authentication is configured for ${reference.provider}; this session keeps its current model.`;
4167
+ if (entry.thinking === undefined) return `\nThis session now runs on ${label}.`;
4168
+ try {
4169
+ live.setThinkingLevel(entry.thinking);
4170
+ } catch (error) {
4171
+ 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))}.`;
4172
+ }
4173
+ return `\nThis session now runs on ${label} · ${entry.thinking}.`;
4174
+ }
4175
+
4140
4176
  function profileSnapshotFrom(
4141
4177
  current: AgentModelConfig,
4142
4178
  settings: OrchestratorSettingsReadResult,
@@ -4150,6 +4186,7 @@ function profileSnapshotFrom(
4150
4186
 
4151
4187
  async function runProfilesPanelAction(
4152
4188
  ctx: ExtensionContext,
4189
+ live: LiveSession,
4153
4190
  path: string,
4154
4191
  file: AgentProfilesFile,
4155
4192
  result: Exclude<ProfilesPanelResult, { type: "close" }>,
@@ -4291,6 +4328,10 @@ async function runProfilesPanelAction(
4291
4328
  orchestratorRollback = () => restoreOrchestratorSettings(settingsPath, previous);
4292
4329
  orchestratorNote = `\nOrchestrator set to ${formatOrchestratorSelection(orchestratorEntry)} in ${sanitizeTerminalText(settingsPath)}.`;
4293
4330
  }
4331
+ // settings.json only governs future sessions. The session the user is
4332
+ // sitting in keeps its model until told otherwise, which made a profile
4333
+ // look applied while the orchestrator kept answering with the old one.
4334
+ orchestratorNote += await switchLiveOrchestrator(ctx, live, orchestratorEntry);
4294
4335
  }
4295
4336
  // A pin that does not resolve changes nothing at launch, so the global apply
4296
4337
  // above is what governs this repository. Saying so keeps a broken pin from
@@ -4537,7 +4578,7 @@ async function runProfilesPanelAction(
4537
4578
  }
4538
4579
  }
4539
4580
 
4540
- async function handleProfilesCommand(ctx: ExtensionContext): Promise<void> {
4581
+ async function handleProfilesCommand(ctx: ExtensionContext, live: LiveSession): Promise<void> {
4541
4582
  const path = profilesFilePath(gentleAiConfigHome());
4542
4583
  const read = readProfilesFileResult(path);
4543
4584
  if (read.status === "invalid") {
@@ -4587,7 +4628,7 @@ async function handleProfilesCommand(ctx: ExtensionContext): Promise<void> {
4587
4628
  );
4588
4629
  while (result.type !== "close") {
4589
4630
  selectedName = "name" in result ? result.name : undefined;
4590
- file = await runProfilesPanelAction(ctx, path, file, result);
4631
+ file = await runProfilesPanelAction(ctx, live, path, file, result);
4591
4632
  result = await showProfilesPanel(
4592
4633
  ctx,
4593
4634
  file,
@@ -6594,18 +6635,22 @@ function reviewHostRelayFailureReport(error: ReviewHostRelayError): Record<strin
6594
6635
  };
6595
6636
  }
6596
6637
 
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];
6638
+ // gentle-pi#311 P2 (superseding gentle-shell#1136 / #1158): the lens's
6639
+ // completion selection comes from its entry in the agent model routing
6640
+ // config, keyed by its routing key (`review-<lens>`). There is no extension
6641
+ // allowlist and no ambient default model: the in-process completion resolves
6642
+ // its provider through the live model registry the caller supplies, or it is
6643
+ // refused before materialize ever runs (validateReviewerSelectionConfiguration
6644
+ // in lib/review-host-relay.ts).
6645
+ function reviewHostRelaySelection(lens: string | undefined, config: AgentModelConfig): { selection?: string; thinking?: string; routingKey: string } {
6646
+ const routingKey = lens === undefined || lens.length === 0 ? "review capture" : lens.startsWith("review-") ? lens : `review-${lens}`;
6647
+ const entry = config[routingKey];
6604
6648
  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);
6649
+ const thinking = typeof entry === "object" && entry !== null && typeof (entry as AgentRoutingEntry).thinking === "string" ? (entry as AgentRoutingEntry).thinking : undefined;
6606
6650
  return {
6607
- ...(model === undefined ? {} : { reviewerModel: model }),
6608
- ...(extensionPaths.length === 0 ? {} : { reviewerExtensionPaths: extensionPaths }),
6651
+ ...(model === undefined ? {} : { selection: model }),
6652
+ ...(thinking === undefined ? {} : { thinking }),
6653
+ routingKey,
6609
6654
  };
6610
6655
  }
6611
6656
 
@@ -6708,6 +6753,7 @@ async function executeReviewHostRelayCapture(
6708
6753
  selections: Map<string, RetainedNativeStatusSelection>,
6709
6754
  route: RetainedNativeCaptureRoute | undefined,
6710
6755
  signal?: AbortSignal,
6756
+ modelRegistry?: InProcessReviewerRegistry,
6711
6757
  ): Promise<Record<string, unknown>> {
6712
6758
  try {
6713
6759
  if (slot.submission === undefined) {
@@ -6718,15 +6764,19 @@ async function executeReviewHostRelayCapture(
6718
6764
  );
6719
6765
  }
6720
6766
  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);
6767
+ // gentle-pi#311 P2 / P3: the lens's (or, for a v9 host-mediated role
6768
+ // slot, the fixed review-refuter/review-validator routing key's)
6769
+ // user-owned completion selection rides the request alongside the
6770
+ // live model registry; the relay validates and refuses a missing
6771
+ // registry or a routing entry with no configured model typed before
6772
+ // anything launches, never a fallback to a child.
6773
+ const launch = reviewHostRelaySelection(slot.routingKey ?? slot.lens, readModelConfig(cwd));
6725
6774
  return {
6726
6775
  captureArgumentTokens: slot.captureArgumentTokens,
6727
6776
  targetCwd: cwd,
6728
6777
  submission: slot.submission,
6729
6778
  ...launch,
6779
+ ...(modelRegistry === undefined ? {} : { reviewerRegistry: modelRegistry }),
6730
6780
  ...(signal === undefined ? {} : { signal }),
6731
6781
  };
6732
6782
  })());
@@ -6742,6 +6792,7 @@ async function executeReviewHostRelayCapture(
6742
6792
  ...(slot.lens === undefined ? {} : { lens: slot.lens }),
6743
6793
  ...(slot.order === undefined ? {} : { order: slot.order }),
6744
6794
  ...(slot.subjectHash === undefined ? {} : { subject_hash: slot.subjectHash }),
6795
+ ...(slot.routingKey === undefined ? {} : { role: slot.name }),
6745
6796
  prompt_bytes: result.promptByteLength,
6746
6797
  result_bytes: result.resultByteLength,
6747
6798
  submission: result.submission,
@@ -7323,6 +7374,11 @@ async function executeReviewCaptureOperation(
7323
7374
  candidateViews: CandidateViewRegistry | null = new CandidateViewRegistry(),
7324
7375
  retainedUntrackedSelections: Map<string, RetainedNativeStatusSelection> = new Map(),
7325
7376
  requireRegisteredRoute = false,
7377
+ // gentle-pi#311 P2: the live model registry a lens materialize capture
7378
+ // resolves its in-process completion through. Appended last (rather than
7379
+ // inserted) so every existing positional call site — none of which pass an
7380
+ // eighth argument — keeps compiling unchanged.
7381
+ modelRegistry?: InProcessReviewerRegistry,
7326
7382
  ): Promise<Record<string, unknown>> {
7327
7383
  const parameters = parseReviewCaptureParameters(parametersValue);
7328
7384
  if (nativeReviewCli === null || nativeReviewCli.targetStatus === undefined) {
@@ -7382,7 +7438,33 @@ async function executeReviewCaptureOperation(
7382
7438
  mutation_outcome: "none",
7383
7439
  };
7384
7440
  }
7385
- return withCorrectionTarget(await executeReviewHostRelayCapture(hostRelaySlots[0]!, nativeReviewCli, cwd, selected.binding, retainedUntrackedSelections, route, signal));
7441
+ return withCorrectionTarget(await executeReviewHostRelayCapture(hostRelaySlots[0]!, nativeReviewCli, cwd, selected.binding, retainedUntrackedSelections, route, signal, modelRegistry));
7442
+ }
7443
+
7444
+ // gentle-pi#311 P3: gentle-ai's v9 contract renders the refuter and
7445
+ // targeted-validator role captures host-mediated, exactly like the lens
7446
+ // slot above — same relay machinery, only its fixed review-refuter /
7447
+ // review-validator routing key (carried on the slot) and its own schema
7448
+ // differ. An older gentle-ai's self-contained --execute=true vector still
7449
+ // falls through to reviewProviderRoleVectorSlots below unchanged.
7450
+ const hostMediatedRoleSlots = reviewHostMediatedRoleSlots([selected.input]);
7451
+ if (hostMediatedRoleSlots.length === 1) {
7452
+ if (parameters.correctionLines !== undefined) return captureBindingRejected("correctionLines is valid only for a correction-plan capture");
7453
+ if (parameters.reviewerRunAcknowledged !== true) {
7454
+ return {
7455
+ tool: "gentle_review_capture",
7456
+ status: "blocked",
7457
+ outcome: "reviewer-model-run-forecast",
7458
+ cost_forecast: {
7459
+ transport: "pi_host_relay",
7460
+ model_runs: 1,
7461
+ roles: [hostMediatedRoleSlots[0]!.routingKey],
7462
+ },
7463
+ mutation_performed: false,
7464
+ mutation_outcome: "none",
7465
+ };
7466
+ }
7467
+ return withCorrectionTarget(await executeReviewHostRelayCapture(hostMediatedRoleSlots[0]!, nativeReviewCli, cwd, selected.binding, retainedUntrackedSelections, route, signal, modelRegistry));
7386
7468
  }
7387
7469
 
7388
7470
  if (selected.input.captureOperation === "review.capture-correction-plan") {
@@ -7461,6 +7543,8 @@ async function executeReviewCaptureGroupOperation(
7461
7543
  candidateViews: CandidateViewRegistry | null = new CandidateViewRegistry(),
7462
7544
  retainedUntrackedSelections: Map<string, RetainedNativeStatusSelection> = new Map(),
7463
7545
  requireRegisteredRoute = false,
7546
+ // gentle-pi#311 P2: see executeReviewCaptureOperation's matching parameter.
7547
+ modelRegistry?: InProcessReviewerRegistry,
7464
7548
  ): Promise<Record<string, unknown>> {
7465
7549
  const parameters = parseReviewCaptureGroupParameters(parametersValue);
7466
7550
  if (nativeReviewCli === null || nativeReviewCli.targetStatus === undefined) return { ...captureGroupRejected("native target STATUS is unavailable"), outcome: "native-status-unsupported" };
@@ -7501,7 +7585,8 @@ async function executeReviewCaptureGroupOperation(
7501
7585
  captureArgumentTokens: slot.captureArgumentTokens,
7502
7586
  targetCwd: cwd,
7503
7587
  submission: slot.submission!,
7504
- ...reviewHostRelayLaunchSelection(slot.lens, readModelConfig(cwd), process.env),
7588
+ ...reviewHostRelaySelection(slot.lens, readModelConfig(cwd)),
7589
+ ...(modelRegistry === undefined ? {} : { reviewerRegistry: modelRegistry }),
7505
7590
  ...(signal === undefined ? {} : { signal }),
7506
7591
  }));
7507
7592
  let prepared: readonly ReviewHostRelayPreparedResult[];
@@ -8648,6 +8733,11 @@ function createGentleAiExtensionForTesting(
8648
8733
  const candidateViews = dependencies.candidateViews === undefined ? new CandidateViewRegistry() : dependencies.candidateViews;
8649
8734
  const herdrLifecycle = createHerdrConfirmationLifecycle(pi.events);
8650
8735
  const permissionEnvironment = dependencies.processEnv ?? process.env;
8736
+ const oddDelegationGate = new OddRuntimeDelegationGate();
8737
+ const oddSessionId = (ctx: ExtensionContext): string => {
8738
+ try { return ctx.sessionManager.getSessionId(); }
8739
+ catch { return ""; }
8740
+ };
8651
8741
 
8652
8742
  const setReviewSessionPermissionStatus = (context: ExtensionContext, active: boolean): void => {
8653
8743
  try {
@@ -8769,6 +8859,7 @@ function createGentleAiExtensionForTesting(
8769
8859
  candidateViews,
8770
8860
  ((sessionKey: PendingReviewConsentSessionKey) => processRetainedNativeStatusSelections.get(sessionKey) ?? processRetainedNativeStatusSelections.set(sessionKey, new Map()).get(sessionKey)!)(pendingReviewConsentSessionKey(ctx, pendingReviewConsentFallbackKey)),
8771
8861
  true,
8862
+ ctx.modelRegistry,
8772
8863
  );
8773
8864
  return { content: [{ type: "text", text: JSON.stringify(details) }], details };
8774
8865
  },
@@ -8782,7 +8873,7 @@ function createGentleAiExtensionForTesting(
8782
8873
  promptSnippet: "Use one exact current STATUS collectBinding for one ordinary native capture; call fresh STATUS before every additional capture.",
8783
8874
  promptGuidelines: [
8784
8875
  "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.",
8876
+ "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
8877
  "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
8878
  ],
8788
8879
  parameters: REVIEW_CAPTURE_PARAMETERS,
@@ -8807,6 +8898,7 @@ function createGentleAiExtensionForTesting(
8807
8898
  candidateViews,
8808
8899
  ((sessionKey: PendingReviewConsentSessionKey) => processRetainedNativeStatusSelections.get(sessionKey) ?? processRetainedNativeStatusSelections.set(sessionKey, new Map()).get(sessionKey)!)(pendingReviewConsentSessionKey(ctx, pendingReviewConsentFallbackKey)),
8809
8900
  true,
8901
+ ctx.modelRegistry,
8810
8902
  );
8811
8903
  return {
8812
8904
  content: [{ type: "text", text: JSON.stringify(details) }],
@@ -9043,6 +9135,10 @@ function createGentleAiExtensionForTesting(
9043
9135
  const retiredSync = readAgentStartNames(event).includes("sdd-sync") || /\bSDD sync executor\b/i.test(event.systemPrompt ?? "");
9044
9136
  const isSddAgent = retiredSync || isSddAgentStartEvent(event);
9045
9137
  const isNamedAgent = isNamedAgentStartEvent(event);
9138
+ oddDelegationGate.start(
9139
+ oddSessionId(ctx),
9140
+ !isNamedAgent && !isSddAgent && permissionEnvironment.GENTLE_PI_AGENTS_CHILD !== "1",
9141
+ );
9046
9142
  const subagentDepthKey = pendingReviewConsentSessionKey(ctx, pendingReviewConsentFallbackKey);
9047
9143
  if (isSddAgent || isNamedAgent) {
9048
9144
  processAgentEndSubagentDepth.set(subagentDepthKey, (processAgentEndSubagentDepth.get(subagentDepthKey) ?? 0) + 1);
@@ -9152,6 +9248,7 @@ function createGentleAiExtensionForTesting(
9152
9248
  // consent, or chooses a partial candidate. Durable own-mutation receipts
9153
9249
  // gate STATUS and consume only the generation captured before that await.
9154
9250
  pi.on("agent_end", async (_event, ctx) => {
9251
+ oddDelegationGate.endChild(oddSessionId(ctx));
9155
9252
  if (nativeReviewCli?.reviewMode === undefined || nativeReviewCli.targetStatus === undefined) return;
9156
9253
  if (ctx.hasUI !== true || !reminderSessionActive) return;
9157
9254
  const sessionKey = pendingReviewConsentSessionKey(ctx, pendingReviewConsentFallbackKey);
@@ -9187,6 +9284,7 @@ function createGentleAiExtensionForTesting(
9187
9284
  pi.on("tool_result", (event, ctx) => {
9188
9285
  if (!reminderSessionActive || event.isError !== false || (event.toolName !== "write" && event.toolName !== "edit")) return;
9189
9286
  if (!isRecord(event.input) || typeof event.input.path !== "string" || !event.input.path.trim()) return;
9287
+ oddDelegationGate.recordSuccess(oddSessionId(ctx), event.toolName, event.input, ctx.cwd);
9190
9288
  try {
9191
9289
  const root = resolveSessionWorktree(event.input.path, ctx.cwd)?.root;
9192
9290
  if (root) recordReviewMutation(pi, ctx.sessionManager, root, { source: "direct", toolName: event.toolName, toolCallId: event.toolCallId });
@@ -9200,6 +9298,10 @@ function createGentleAiExtensionForTesting(
9200
9298
  event.input,
9201
9299
  );
9202
9300
  if (sensitivePathDenied) return sensitivePathDenied;
9301
+ const oddDelegationDenied = oddDelegationGate.beforeTool(
9302
+ oddSessionId(ctx), event.toolName, event.input, ctx.cwd, readActiveToolNames(pi),
9303
+ );
9304
+ if (oddDelegationDenied) return oddDelegationDenied;
9203
9305
  if (event.toolName === "subagent_run") {
9204
9306
  const sddAgent = sddDispatchAgentName(event.input);
9205
9307
  if (sddAgent === "invalid") {
@@ -9365,7 +9467,7 @@ function createGentleAiExtensionForTesting(
9365
9467
  pi.registerCommand("gentle:profiles", {
9366
9468
  description: "Create, switch, and manage global agent-model profiles for el Gentleman.",
9367
9469
  handler: async (_args, ctx) => {
9368
- await handleProfilesCommand(ctx);
9470
+ await handleProfilesCommand(ctx, pi);
9369
9471
  },
9370
9472
  });
9371
9473