@caupulican/pi-adaptative 0.80.99 → 0.80.102

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 (129) hide show
  1. package/CHANGELOG.md +203 -0
  2. package/dist/core/agent-session.d.ts +72 -0
  3. package/dist/core/agent-session.d.ts.map +1 -1
  4. package/dist/core/agent-session.js +437 -46
  5. package/dist/core/agent-session.js.map +1 -1
  6. package/dist/core/autonomy/envelope-enforcement.d.ts +5 -0
  7. package/dist/core/autonomy/envelope-enforcement.d.ts.map +1 -1
  8. package/dist/core/autonomy/envelope-enforcement.js +28 -4
  9. package/dist/core/autonomy/envelope-enforcement.js.map +1 -1
  10. package/dist/core/autonomy/path-scope.d.ts.map +1 -1
  11. package/dist/core/autonomy/path-scope.js +33 -2
  12. package/dist/core/autonomy/path-scope.js.map +1 -1
  13. package/dist/core/bash-executor.d.ts.map +1 -1
  14. package/dist/core/bash-executor.js +15 -7
  15. package/dist/core/bash-executor.js.map +1 -1
  16. package/dist/core/context/brain-curator.d.ts +10 -0
  17. package/dist/core/context/brain-curator.d.ts.map +1 -1
  18. package/dist/core/context/brain-curator.js +19 -2
  19. package/dist/core/context/brain-curator.js.map +1 -1
  20. package/dist/core/context-gc.d.ts +8 -3
  21. package/dist/core/context-gc.d.ts.map +1 -1
  22. package/dist/core/context-gc.js +5 -0
  23. package/dist/core/context-gc.js.map +1 -1
  24. package/dist/core/delegation/worker-actions.d.ts +0 -10
  25. package/dist/core/delegation/worker-actions.d.ts.map +1 -1
  26. package/dist/core/delegation/worker-actions.js +5 -7
  27. package/dist/core/delegation/worker-actions.js.map +1 -1
  28. package/dist/core/delegation/worker-result.d.ts +6 -0
  29. package/dist/core/delegation/worker-result.d.ts.map +1 -1
  30. package/dist/core/delegation/worker-result.js +10 -4
  31. package/dist/core/delegation/worker-result.js.map +1 -1
  32. package/dist/core/delegation/worker-runner.d.ts +3 -0
  33. package/dist/core/delegation/worker-runner.d.ts.map +1 -1
  34. package/dist/core/delegation/worker-runner.js +6 -1
  35. package/dist/core/delegation/worker-runner.js.map +1 -1
  36. package/dist/core/doctor.d.ts +66 -0
  37. package/dist/core/doctor.d.ts.map +1 -0
  38. package/dist/core/doctor.js +144 -0
  39. package/dist/core/doctor.js.map +1 -0
  40. package/dist/core/learning/learning-audit.d.ts +13 -1
  41. package/dist/core/learning/learning-audit.d.ts.map +1 -1
  42. package/dist/core/learning/learning-audit.js +22 -1
  43. package/dist/core/learning/learning-audit.js.map +1 -1
  44. package/dist/core/learning/learning-gate.d.ts +3 -0
  45. package/dist/core/learning/learning-gate.d.ts.map +1 -1
  46. package/dist/core/learning/learning-gate.js +13 -4
  47. package/dist/core/learning/learning-gate.js.map +1 -1
  48. package/dist/core/models/default-model-suggestions.d.ts +34 -0
  49. package/dist/core/models/default-model-suggestions.d.ts.map +1 -0
  50. package/dist/core/models/default-model-suggestions.js +58 -0
  51. package/dist/core/models/default-model-suggestions.js.map +1 -0
  52. package/dist/core/models/local-registration.d.ts +2 -0
  53. package/dist/core/models/local-registration.d.ts.map +1 -1
  54. package/dist/core/models/local-registration.js +2 -1
  55. package/dist/core/models/local-registration.js.map +1 -1
  56. package/dist/core/models/local-runtime.d.ts +76 -2
  57. package/dist/core/models/local-runtime.d.ts.map +1 -1
  58. package/dist/core/models/local-runtime.js +233 -21
  59. package/dist/core/models/local-runtime.js.map +1 -1
  60. package/dist/core/profile-resource-selection.d.ts +15 -3
  61. package/dist/core/profile-resource-selection.d.ts.map +1 -1
  62. package/dist/core/profile-resource-selection.js +19 -9
  63. package/dist/core/profile-resource-selection.js.map +1 -1
  64. package/dist/core/resource-loader.d.ts +20 -0
  65. package/dist/core/resource-loader.d.ts.map +1 -1
  66. package/dist/core/resource-loader.js +42 -16
  67. package/dist/core/resource-loader.js.map +1 -1
  68. package/dist/core/settings-manager.d.ts +30 -0
  69. package/dist/core/settings-manager.d.ts.map +1 -1
  70. package/dist/core/settings-manager.js +65 -3
  71. package/dist/core/settings-manager.js.map +1 -1
  72. package/dist/core/skills.d.ts +14 -0
  73. package/dist/core/skills.d.ts.map +1 -1
  74. package/dist/core/skills.js +8 -0
  75. package/dist/core/skills.js.map +1 -1
  76. package/dist/core/slash-commands.d.ts.map +1 -1
  77. package/dist/core/slash-commands.js +1 -1
  78. package/dist/core/slash-commands.js.map +1 -1
  79. package/dist/core/tools/fff-search-backend.d.ts +34 -1
  80. package/dist/core/tools/fff-search-backend.d.ts.map +1 -1
  81. package/dist/core/tools/fff-search-backend.js +43 -4
  82. package/dist/core/tools/fff-search-backend.js.map +1 -1
  83. package/dist/core/tools/find.d.ts +16 -0
  84. package/dist/core/tools/find.d.ts.map +1 -1
  85. package/dist/core/tools/find.js +17 -3
  86. package/dist/core/tools/find.js.map +1 -1
  87. package/dist/core/tools/grep.d.ts +20 -0
  88. package/dist/core/tools/grep.d.ts.map +1 -1
  89. package/dist/core/tools/grep.js +15 -3
  90. package/dist/core/tools/grep.js.map +1 -1
  91. package/dist/main.d.ts.map +1 -1
  92. package/dist/main.js +17 -0
  93. package/dist/main.js.map +1 -1
  94. package/dist/modes/interactive/components/fitness-role-selector.d.ts +6 -2
  95. package/dist/modes/interactive/components/fitness-role-selector.d.ts.map +1 -1
  96. package/dist/modes/interactive/components/fitness-role-selector.js +55 -45
  97. package/dist/modes/interactive/components/fitness-role-selector.js.map +1 -1
  98. package/dist/modes/interactive/components/model-suggestion-selector.d.ts +13 -0
  99. package/dist/modes/interactive/components/model-suggestion-selector.d.ts.map +1 -0
  100. package/dist/modes/interactive/components/model-suggestion-selector.js +40 -0
  101. package/dist/modes/interactive/components/model-suggestion-selector.js.map +1 -0
  102. package/dist/modes/interactive/components/profile-resource-editor.d.ts +3 -0
  103. package/dist/modes/interactive/components/profile-resource-editor.d.ts.map +1 -1
  104. package/dist/modes/interactive/components/profile-resource-editor.js +16 -2
  105. package/dist/modes/interactive/components/profile-resource-editor.js.map +1 -1
  106. package/dist/modes/interactive/components/settings-selector.d.ts.map +1 -1
  107. package/dist/modes/interactive/components/settings-selector.js +98 -0
  108. package/dist/modes/interactive/components/settings-selector.js.map +1 -1
  109. package/dist/modes/interactive/interactive-mode.d.ts +8 -1
  110. package/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
  111. package/dist/modes/interactive/interactive-mode.js +57 -10
  112. package/dist/modes/interactive/interactive-mode.js.map +1 -1
  113. package/dist/utils/safe-write-stream.d.ts +8 -0
  114. package/dist/utils/safe-write-stream.d.ts.map +1 -1
  115. package/dist/utils/safe-write-stream.js +32 -0
  116. package/dist/utils/safe-write-stream.js.map +1 -1
  117. package/dist/utils/tools-manager.d.ts +92 -2
  118. package/dist/utils/tools-manager.d.ts.map +1 -1
  119. package/dist/utils/tools-manager.js +172 -32
  120. package/dist/utils/tools-manager.js.map +1 -1
  121. package/examples/extensions/custom-provider-anthropic/package-lock.json +2 -2
  122. package/examples/extensions/custom-provider-anthropic/package.json +1 -1
  123. package/examples/extensions/custom-provider-gitlab-duo/package.json +1 -1
  124. package/examples/extensions/sandbox/package-lock.json +2 -2
  125. package/examples/extensions/sandbox/package.json +1 -1
  126. package/examples/extensions/with-deps/package-lock.json +2 -2
  127. package/examples/extensions/with-deps/package.json +1 -1
  128. package/npm-shrinkwrap.json +12 -36
  129. package/package.json +4 -4
@@ -58,7 +58,7 @@ import { GatewayRegistry } from "./gateways/channel-provider.js";
58
58
  import { buildGoalContinuationPrompt, } from "./goals/goal-continuation-prompt.js";
59
59
  import { buildGoalRuntimeSnapshot, } from "./goals/goal-runtime-snapshot.js";
60
60
  import { appendGoalStateSnapshot, getLatestGoalStateSnapshot } from "./goals/session-goal-state.js";
61
- import { appendLearningAuditSnapshot, getLearningAuditSnapshots, proposalFromReflectionWrite, rollbackPlanForReflectionWrite, } from "./learning/learning-audit.js";
61
+ import { APPLY_WRITE_REFUSED_REASON_CODE, appendLearningAuditSnapshot, contradictionsForReflectionWrite, getLearningAuditSnapshots, proposalFromReflectionWrite, rollbackPlanForReflectionWrite, } from "./learning/learning-audit.js";
62
62
  import { evaluateLearningDecision } from "./learning/learning-gate.js";
63
63
  import { ObservationStore, observationKey } from "./learning/observation-store.js";
64
64
  import { decideDemand, ReflectionEngine, } from "./learning/reflection-engine.js";
@@ -80,6 +80,8 @@ import { bufferModelRouterSessionCustomMessage, bufferModelRouterSessionMessage,
80
80
  import { formatModelRouterStatus, getRecentModelRouterDecisions, MODEL_ROUTER_DECISION_CUSTOM_TYPE, } from "./model-router/status.js";
81
81
  import { shouldEscalateModelRouterTool } from "./model-router/tool-escalation.js";
82
82
  import { FitnessStore } from "./models/fitness-store.js";
83
+ import { OLLAMA_PROVIDER } from "./models/local-registration.js";
84
+ import { OllamaRuntime } from "./models/local-runtime.js";
83
85
  import { expandPromptTemplate } from "./prompt-templates.js";
84
86
  import { runModelFitnessProbe } from "./research/model-fitness.js";
85
87
  import { runResearch } from "./research/research-runner.js";
@@ -153,6 +155,12 @@ function persistModelRouterDecision(sessionManager, decision) {
153
155
  const AUTONOMY_TELEMETRY_CUSTOM_TYPE = "autonomy-telemetry";
154
156
  /** G8: bound on the in-memory gate-outcome history. Oldest entries evict once the cap is reached. */
155
157
  const GATE_OUTCOME_HISTORY_LIMIT = 50;
158
+ /** User-facing router tiers in ascending order — "learning" is never selected for a user turn, so
159
+ * it has no place in the escalation ladder (#27's _ensureRouteModelReady walks this forward only). */
160
+ const MODEL_ROUTER_TIER_ORDER = ["cheap", "medium", "expensive"];
161
+ /** How long the #31 "install ollama now?" confirm waits before auto-dismissing (same as a "No") —
162
+ * long enough to read and decide, short enough that an unattended session doesn't hang a turn on it. */
163
+ const OLLAMA_INSTALL_CONFIRM_TIMEOUT_MS = 30_000;
156
164
  /** Read a packed grep/find tool result's `details.artifactId`, if present, without `any`. */
157
165
  function extractArtifactId(message) {
158
166
  if (!message || message.role !== "toolResult")
@@ -239,6 +247,8 @@ export class AgentSession {
239
247
  _brainCurator = new BrainCurator();
240
248
  _lastCurationSkipReason = undefined;
241
249
  _inertExtensionWarnings = [];
250
+ /** Extensions the active resource profile removed from the runtime set (surfaced in /context). */
251
+ _profileDeniedExtensionCount = 0;
242
252
  _lastPreDigestSkipReason = undefined;
243
253
  _unboundToolGrantWarnings = [];
244
254
  _toolArtifactStore = undefined;
@@ -268,6 +278,12 @@ export class AgentSession {
268
278
  _cwd;
269
279
  _agentDir;
270
280
  _collectWorkspaceSources;
281
+ _localRuntimeDeps;
282
+ /** Lazy, cached by baseUrl so the router path and any other caller share one instance per server. */
283
+ _localRuntimes = new Map();
284
+ /** Server URLs confirmed reachable THIS session — skips the health-check round trip on every
285
+ * local-routed turn once warm. Keyed the same way as _localRuntimes. */
286
+ _localRuntimeConfirmedUp = new Set();
271
287
  _extensionRunnerRef;
272
288
  _initialActiveToolNames;
273
289
  _allowedToolNames;
@@ -338,6 +354,7 @@ export class AgentSession {
338
354
  this._cwd = config.cwd;
339
355
  this._agentDir = config.agentDir ?? getAgentDir();
340
356
  this._collectWorkspaceSources = config.collectWorkspaceSources ?? collectWorkspaceSources;
357
+ this._localRuntimeDeps = config.localRuntimeDeps;
341
358
  this._modelRegistry = config.modelRegistry;
342
359
  this._extensionRunnerRef = config.extensionRunnerRef;
343
360
  this._initialActiveToolNames = config.initialActiveToolNames;
@@ -1018,6 +1035,7 @@ export class AgentSession {
1018
1035
  adjustments: { memoryEvidenceTokens, enforcementSavedTokens },
1019
1036
  extraObservations: [
1020
1037
  ...this._resourceLoader.getAgentsDiagnostics().map((diagnostic) => diagnostic.message),
1038
+ ...this._profileDeniedResourceObservations(),
1021
1039
  ...this._inertExtensionWarnings,
1022
1040
  ...this._unboundToolGrantWarnings,
1023
1041
  // G7: auto-built per-turn foreground envelope (observe-only; not enforced). Falls back to a
@@ -1441,6 +1459,16 @@ export class AgentSession {
1441
1459
  }
1442
1460
  // Track last assistant message for auto-compaction check
1443
1461
  _lastAssistantMessage = undefined;
1462
+ /**
1463
+ * User messages already painted to the UI by an early, synthetic `message_start` fired from
1464
+ * `_promptUnserialized` — before the model-router judge's bounded LLM call — so the prompt
1465
+ * appears immediately instead of hanging until routing finishes. The real agent-loop run emits
1466
+ * its own authoritative `message_start` for the SAME message object once the turn actually
1467
+ * starts; `_handleAgentEvent` consumes (deletes) it from this set to suppress that one duplicate
1468
+ * listener notification. Persistence is untouched: it stays keyed off `message_end`, which is
1469
+ * never added here and never suppressed.
1470
+ */
1471
+ _earlyDisplayedUserMessages = new Set();
1444
1472
  /** Internal handler for agent events - shared by subscribe and reconnect */
1445
1473
  _handleAgentEvent = async (event) => {
1446
1474
  // When a user message starts, check if it's from either queue and remove it BEFORE emitting
@@ -1470,8 +1498,14 @@ export class AgentSession {
1470
1498
  const suppressRetryPromptEvent = this._isModelRouterRetry &&
1471
1499
  (event.type === "message_start" || event.type === "message_end") &&
1472
1500
  (event.message.role === "user" || event.message.role === "custom");
1501
+ // This is the authoritative message_start for a user message already painted early (see
1502
+ // _promptUnserialized). Set#delete both tests and consumes membership in one step, so only
1503
+ // this one duplicate is suppressed and a later, unrelated user message is never affected.
1504
+ const suppressAlreadyDisplayedUserMessage = event.type === "message_start" &&
1505
+ event.message.role === "user" &&
1506
+ this._earlyDisplayedUserMessages.delete(event.message);
1473
1507
  // Notify all listeners
1474
- if (!suppressRetryPromptEvent) {
1508
+ if (!suppressRetryPromptEvent && !suppressAlreadyDisplayedUserMessage) {
1475
1509
  this._emit(event.type === "agent_end" ? { ...event, willRetry: this._willRetryAfterAgentEnd(event) } : event);
1476
1510
  }
1477
1511
  // Handle session/context retention. Tool result details are UI/log metadata,
@@ -2021,7 +2055,7 @@ export class AgentSession {
2021
2055
  - Treat current-turn evidence as a cue, not proof; prefer longitudinal corroboration before changing durable behavior.
2022
2056
  - Active-task work remains primary: learning runs must not interrupt user-visible execution or claim task completion.`;
2023
2057
  }
2024
- _rebuildSystemPrompt(toolNames) {
2058
+ _buildSystemPromptOptionsForToolNames(toolNames) {
2025
2059
  const validToolNames = toolNames.filter((name) => this._toolRegistry.has(name));
2026
2060
  const toolSnippets = {};
2027
2061
  const promptGuidelines = [];
@@ -2054,7 +2088,7 @@ export class AgentSession {
2054
2088
  // to invoke) a skill its profile blocks.
2055
2089
  const loadedSkills = this._resourceLoader.getActiveSkills();
2056
2090
  const loadedContextFiles = this._resourceLoader.getAgentsFiles().agentsFiles;
2057
- this._baseSystemPromptOptions = {
2091
+ return {
2058
2092
  cwd: this._cwd,
2059
2093
  skills: loadedSkills,
2060
2094
  contextFiles: loadedContextFiles,
@@ -2065,8 +2099,21 @@ export class AgentSession {
2065
2099
  promptGuidelines,
2066
2100
  extensions: [...this._extensionRunner.activeExtensions],
2067
2101
  };
2102
+ }
2103
+ _rebuildSystemPrompt(toolNames) {
2104
+ this._baseSystemPromptOptions = this._buildSystemPromptOptionsForToolNames(toolNames);
2068
2105
  return buildSystemPrompt(this._baseSystemPromptOptions);
2069
2106
  }
2107
+ /**
2108
+ * Build a system prompt for a specific tool surface WITHOUT touching the session's base prompt
2109
+ * state. Used for a router-swapped turn (G4): the routed model runs against a filtered tool set,
2110
+ * so it must also receive a system prompt whose tool guidelines/snippets match that filtered
2111
+ * surface — but the change is per-turn, so it must not mutate `_baseSystemPromptOptions` (which
2112
+ * later turns and extension events read).
2113
+ */
2114
+ _buildSystemPromptForToolNames(toolNames) {
2115
+ return buildSystemPrompt(this._buildSystemPromptOptionsForToolNames(toolNames));
2116
+ }
2070
2117
  // =========================================================================
2071
2118
  // Prompting
2072
2119
  // =========================================================================
@@ -2180,6 +2227,190 @@ export class AgentSession {
2180
2227
  return undefined;
2181
2228
  }
2182
2229
  }
2230
+ /**
2231
+ * Shared {@link OllamaRuntime} for a given server, lazily created and cached by baseUrl so every
2232
+ * caller — the router's readiness gate below and any host UI's own model-lifecycle commands
2233
+ * (e.g. `/models`) — sees and can stop the SAME pi-managed process instead of each tracking its
2234
+ * own untracked child.
2235
+ */
2236
+ getLocalRuntime(baseUrl) {
2237
+ const key = baseUrl ?? "default";
2238
+ let runtime = this._localRuntimes.get(key);
2239
+ if (!runtime) {
2240
+ runtime = new OllamaRuntime({ agentDir: this._agentDir, baseUrl, deps: this._localRuntimeDeps });
2241
+ this._localRuntimes.set(key, runtime);
2242
+ }
2243
+ return runtime;
2244
+ }
2245
+ /** models.json registers a local model's baseUrl as `<server>/v1` (OpenAI-compat); the runtime's
2246
+ * own health/boot endpoints are on the Ollama-native server root. */
2247
+ _deriveOllamaServerUrl(modelBaseUrl) {
2248
+ return modelBaseUrl.replace(/\/v1\/?$/, "");
2249
+ }
2250
+ /**
2251
+ * If the last assistant message in this session was an error from THIS exact local server, a
2252
+ * cached "confirmed up" flag would be stale (the server may have died mid-session) — drop it so
2253
+ * the next ensure-check is a real one instead of trusting stale state.
2254
+ */
2255
+ _invalidateLocalRuntimeIfLastCallFailed(model, serverUrl) {
2256
+ const lastAssistant = this._findLastAssistantMessage();
2257
+ if (lastAssistant?.stopReason === "error" &&
2258
+ lastAssistant.provider === OLLAMA_PROVIDER &&
2259
+ lastAssistant.model === model.id) {
2260
+ this._localRuntimeConfirmedUp.delete(serverUrl);
2261
+ }
2262
+ }
2263
+ /**
2264
+ * Ensure a routed model is actually reachable before the turn calls it. No-op (and free) for any
2265
+ * non-local model — this only ever does network/process work for the `ollama` provider. Caches a
2266
+ * "confirmed up this session" flag per server so a steady-state session pays the health-check
2267
+ * round trip once, not on every turn; invalidated above when a prior local call actually failed,
2268
+ * so a server that died mid-session gets re-detected rather than trusted forever. Boots via
2269
+ * `startReuseExisting()` — never owned storage — so the turn sees the user's OWN pulled models,
2270
+ * the same server `/models` commands and the user's own `ollama` CLI already talk to. Never
2271
+ * installs anything itself (installGuide is GUIDE MODE: printed, never executed).
2272
+ */
2273
+ async _ensureLocalModelReady(model) {
2274
+ if (model.provider !== OLLAMA_PROVIDER) {
2275
+ return { ready: true, reason: "not_local" };
2276
+ }
2277
+ const serverUrl = this._deriveOllamaServerUrl(model.baseUrl);
2278
+ this._invalidateLocalRuntimeIfLastCallFailed(model, serverUrl);
2279
+ if (this._localRuntimeConfirmedUp.has(serverUrl)) {
2280
+ return { ready: true, reason: "confirmed_up_cached" };
2281
+ }
2282
+ const runtime = this.getLocalRuntime(serverUrl);
2283
+ const status = await runtime.detect();
2284
+ if (status.serverUp) {
2285
+ this._localRuntimeConfirmedUp.add(serverUrl);
2286
+ return { ready: true, reason: "already_running" };
2287
+ }
2288
+ if (!status.binaryPath) {
2289
+ return { ready: false, reason: "binary_missing", installGuide: runtime.installGuide() };
2290
+ }
2291
+ const started = await runtime.startReuseExisting();
2292
+ if (started.started) {
2293
+ this._localRuntimeConfirmedUp.add(serverUrl);
2294
+ }
2295
+ return { ready: started.started, reason: started.reason };
2296
+ }
2297
+ /**
2298
+ * #31: the ONE case a routed local model's unreadiness can be fixed automatically is a missing
2299
+ * ollama binary — an unreachable server can't be helped by installing, so that reason is left to
2300
+ * the graceful-fallback warning below unchanged. Only offered when there's an interactive UI to
2301
+ * ask through: headless/RPC/print sessions have no _extensionUIContext and fall straight through,
2302
+ * same as declining or timing out (both resolve confirm() to false). Reverses "pi never runs
2303
+ * installers itself" specifically for this one path — the user is asked first, the download is
2304
+ * pi's own (never curl|sh), and it lands in pi's own runtimes dir (see OllamaRuntime.installManaged).
2305
+ *
2306
+ * Pauses/resumes the routing working-indicator around the confirm dialog itself (re-emitting
2307
+ * routing_end/routing_start — both already idempotent, see interactive-mode.ts's handlers) so an
2308
+ * animated spinner doesn't fight a dialog the user is trying to read and answer; the indicator
2309
+ * comes back for the download/extract that follows a "yes", which is genuine processing feedback.
2310
+ */
2311
+ async _maybeInstallOllamaOnConsent(model, readiness) {
2312
+ const ui = this._extensionUIContext;
2313
+ if (!ui || readiness.ready || readiness.reason !== "binary_missing")
2314
+ return readiness;
2315
+ const modelLabel = formatModelRouterModel(model);
2316
+ this._emit({ type: "routing_end" });
2317
+ let confirmed;
2318
+ try {
2319
+ confirmed = await ui.confirm("Install Ollama?", `Ollama isn't installed, so the local model "${modelLabel}" can't run. Pi can download and ` +
2320
+ "install it now (a large one-time download, possibly over 1 GB depending on your platform) " +
2321
+ "into its own runtimes folder — never curl|sh, never touching anything outside pi's own " +
2322
+ "directory. Install it now?", { timeout: OLLAMA_INSTALL_CONFIRM_TIMEOUT_MS });
2323
+ }
2324
+ finally {
2325
+ this._emit({ type: "routing_start" });
2326
+ }
2327
+ if (!confirmed)
2328
+ return readiness;
2329
+ const serverUrl = this._deriveOllamaServerUrl(model.baseUrl);
2330
+ const runtime = this.getLocalRuntime(serverUrl);
2331
+ let installResult;
2332
+ try {
2333
+ installResult = await runtime.installManaged((status) => ui.setStatus("ollama-install", status));
2334
+ }
2335
+ finally {
2336
+ ui.setStatus("ollama-install", undefined);
2337
+ }
2338
+ if (!installResult.ok) {
2339
+ return { ready: false, reason: "install_failed", installAttemptError: installResult.error };
2340
+ }
2341
+ return this._ensureLocalModelReady(model);
2342
+ }
2343
+ /**
2344
+ * Router-swap gate (#27): a turn routed to a local model (any tier, including an executor-direct
2345
+ * route — both carry tier "cheap") must not dead-end the turn just because ollama isn't up.
2346
+ * Never a SILENT swap: every fallback is announced in a warning that states (i) the local model
2347
+ * was unavailable and WHY — binary missing surfaces the install guide inline; any other reason
2348
+ * gets a "check that ollama is running" hint — and (ii) which tier is now handling the turn, so
2349
+ * the cost shift is never a surprise. Escalates cheap -> medium -> expensive, skipping any
2350
+ * unconfigured intermediate tier, reusing the router's own existing "model unavailable"
2351
+ * resolution (_resolveConfiguredTierModel) rather than inventing a new fallback mechanism.
2352
+ * Escalation is bounded: tier strictly increases each hop, so it terminates within two hops.
2353
+ *
2354
+ * Before the warning/escalation below: #31's consent gate gets one shot at fixing a missing
2355
+ * binary interactively (see _maybeInstallOllamaOnConsent) — declining, timing out, running
2356
+ * headless, or the install attempt itself failing all fall through here unchanged, just with an
2357
+ * honest reason (an install that failed is worded as a failed install, not re-labeled as if
2358
+ * nothing was ever tried).
2359
+ */
2360
+ async _ensureRouteModelReady(resolved) {
2361
+ let current = resolved;
2362
+ while (current && current.model.provider === OLLAMA_PROVIDER) {
2363
+ let readiness = await this._ensureLocalModelReady(current.model);
2364
+ if (!readiness.ready) {
2365
+ readiness = await this._maybeInstallOllamaOnConsent(current.model, readiness);
2366
+ }
2367
+ if (readiness.ready)
2368
+ return current;
2369
+ // Walk the remaining tiers in order (never back down to cheap) and take the first one that
2370
+ // actually resolves — an unconfigured intermediate tier (e.g. no mediumModel set) must be
2371
+ // skipped, not treated as "no fallback available".
2372
+ const startIndex = MODEL_ROUTER_TIER_ORDER.indexOf(current.decision.tier);
2373
+ let escalated;
2374
+ for (let i = startIndex + 1; startIndex !== -1 && i < MODEL_ROUTER_TIER_ORDER.length; i++) {
2375
+ const tier = MODEL_ROUTER_TIER_ORDER[i];
2376
+ const model = this._resolveConfiguredTierModel(tier);
2377
+ if (model) {
2378
+ escalated = { tier, model };
2379
+ break;
2380
+ }
2381
+ }
2382
+ const modelLabel = formatModelRouterModel(current.model);
2383
+ const whyText = readiness.installAttemptError
2384
+ ? `pi tried to install it just now, but the install attempt failed: ${readiness.installAttemptError}`
2385
+ : readiness.installGuide
2386
+ ? ["the ollama binary is not installed.", ...readiness.installGuide].join("\n")
2387
+ : `its server is not reachable (${readiness.reason}) — check that ollama is running.`;
2388
+ const fallbackText = escalated
2389
+ ? `Falling back to the ${escalated.tier} tier for this turn.`
2390
+ : "No other tier is configured — falling back to the session's default model.";
2391
+ this._emit({
2392
+ type: "warning",
2393
+ message: `Local model "${modelLabel}" is unavailable: ${whyText}\n${fallbackText}`,
2394
+ });
2395
+ if (!escalated)
2396
+ return undefined; // no higher tier resolves — caller falls back to the session default
2397
+ current = {
2398
+ model: escalated.model,
2399
+ decision: {
2400
+ ...current.decision,
2401
+ tier: escalated.tier,
2402
+ fallbackFrom: current.decision.tier,
2403
+ reasonCode: "local_model_not_ready_fallback",
2404
+ reasons: [
2405
+ ...current.decision.reasons,
2406
+ `Local model not ready (${readiness.reason}); escalated to ${escalated.tier}`,
2407
+ ],
2408
+ model: formatModelRouterModel(escalated.model),
2409
+ },
2410
+ };
2411
+ }
2412
+ return current;
2413
+ }
2183
2414
  _resolveModelRouterTurnRoute(prompt) {
2184
2415
  const settings = this.settingsManager.getModelRouterSettings();
2185
2416
  if (!settings.enabled) {
@@ -2300,7 +2531,9 @@ export class AgentSession {
2300
2531
  systemPrompt,
2301
2532
  messages: [{ role: "user", content: [{ type: "text", text: userPrompt }], timestamp: Date.now() }],
2302
2533
  model: judgeModel,
2303
- thinkingLevel: "off",
2534
+ // Per-tier thinking (R1): judgeThinking overrides the judge's own completion; unset
2535
+ // keeps today's "off" (the judge is a cheap classification call by default).
2536
+ thinkingLevel: settings.judgeThinking ?? "off",
2304
2537
  maxTokens: ROUTE_JUDGE_MAX_OUTPUT_TOKENS,
2305
2538
  signal,
2306
2539
  // The judge system prompt is static — the provider can cache the prefix.
@@ -2370,6 +2603,12 @@ export class AgentSession {
2370
2603
  const previousModel = this.agent.state.model;
2371
2604
  const previousThinkingLevel = this.agent.state.thinkingLevel;
2372
2605
  const previousTurnTools = this.agent.state.tools;
2606
+ const previousSystemPrompt = this.agent.state.systemPrompt;
2607
+ // G4 swap bookkeeping (Bug G): the exact references the swap below assigns, so the finally can
2608
+ // restore ONLY what IT put there — never assigned when no swap happens (e.g. a full-class
2609
+ // routed profile).
2610
+ let swappedTools;
2611
+ let swappedSystemPrompt;
2373
2612
  const previousActiveModelRouterIntent = this._activeModelRouterIntent;
2374
2613
  const previousActiveModelRouterRoute = this._activeModelRouterRoute;
2375
2614
  const previousModelRouterSessionBuffer = this._modelRouterSessionBuffer;
@@ -2401,7 +2640,24 @@ export class AgentSession {
2401
2640
  }
2402
2641
  if (!modelsAreEqual(this.model, routedModel)) {
2403
2642
  this.agent.state.model = routedModel;
2404
- this.agent.state.thinkingLevel = clampThinkingLevel(routedModel, previousThinkingLevel);
2643
+ // Per-tier thinking (R1): a configured tier/executor thinking level overrides the inherited
2644
+ // session thinking for THIS routed turn only; unset falls back to exactly today's
2645
+ // inherit-and-clamp behavior. Executor routes carry tier "cheap" too, so reasonCode is
2646
+ // checked first — otherwise an executor turn would silently pick up cheapThinking instead.
2647
+ // The judge's own completion has a separate knob (judgeThinking) applied at its call site.
2648
+ const routerThinkingSettings = this.settingsManager.getModelRouterSettings();
2649
+ const configuredThinking = !routeDecision
2650
+ ? undefined
2651
+ : routeDecision.reasonCode === "executor_direct"
2652
+ ? routerThinkingSettings.executorThinking
2653
+ : routeDecision.tier === "cheap"
2654
+ ? routerThinkingSettings.cheapThinking
2655
+ : routeDecision.tier === "medium"
2656
+ ? routerThinkingSettings.mediumThinking
2657
+ : routeDecision.tier === "expensive"
2658
+ ? routerThinkingSettings.expensiveThinking
2659
+ : undefined;
2660
+ this.agent.state.thinkingLevel = clampThinkingLevel(routedModel, configuredThinking ?? previousThinkingLevel);
2405
2661
  // G4: capability tool-filtering follows the ROUTED model for the turn. Without this a
2406
2662
  // cheap/local routed model inherits the session model's full tool surface — schemas it
2407
2663
  // pays for on every request and may not be able to drive at all.
@@ -2411,7 +2667,16 @@ export class AgentSession {
2411
2667
  });
2412
2668
  if (routedProfile.class !== "full") {
2413
2669
  const allowed = new Set(filterToolNamesForCapability(previousTurnTools.map((tool) => tool.name), routedProfile));
2414
- this.agent.state.tools = previousTurnTools.filter((tool) => allowed.has(tool.name));
2670
+ swappedTools = previousTurnTools.filter((tool) => allowed.has(tool.name));
2671
+ this.agent.state.tools = swappedTools;
2672
+ // G4: the system prompt follows the ROUTED model's filtered surface too — otherwise the
2673
+ // cheap/local model is billed for (and told about) tool guidelines/snippets it can't call.
2674
+ // Per-turn only; restored in the finally. A live extension override of the prompt is left
2675
+ // alone (only shed when we're on the base prompt).
2676
+ if (this.agent.state.systemPrompt === this._baseSystemPrompt) {
2677
+ swappedSystemPrompt = this._buildSystemPromptForToolNames(this.agent.state.tools.map((tool) => tool.name));
2678
+ this.agent.state.systemPrompt = swappedSystemPrompt;
2679
+ }
2415
2680
  }
2416
2681
  }
2417
2682
  try {
@@ -2445,6 +2710,16 @@ export class AgentSession {
2445
2710
  };
2446
2711
  this._lastModelRouterDecision = completedDecision;
2447
2712
  }
2713
+ else {
2714
+ // The muscle missed AND the reflex brain could not refine the request into a toolkit
2715
+ // instruction (no fit brain model, or no confident plan). There is deliberately NO
2716
+ // frontier fallback here, so surface the miss instead of letting it stand silently —
2717
+ // otherwise the routed turn just ends with an unrun command and no explanation.
2718
+ this._emit({
2719
+ type: "warning",
2720
+ message: "Executor lane: the toolkit command did not run and the reflex brain could not refine it into an explicit instruction; leaving the turn as-is (no automatic escalation).",
2721
+ });
2722
+ }
2448
2723
  }
2449
2724
  if (bufferRoutedTurn && this._modelRouterEscalationRequested) {
2450
2725
  this.agent.state.messages.splice(originalHistoryLength);
@@ -2480,7 +2755,18 @@ export class AgentSession {
2480
2755
  if (modelsAreEqual(this.agent.state.model, routedModel)) {
2481
2756
  this.agent.state.model = previousModel;
2482
2757
  this.agent.state.thinkingLevel = previousThinkingLevel;
2483
- this.agent.state.tools = previousTurnTools;
2758
+ // Symmetric restore (Bug G): undo tools/systemPrompt only if each is STILL the exact
2759
+ // reference/string the G4 swap above assigned (never assigned at all when the routed
2760
+ // profile was full-class — then there is nothing to restore either). An extension calling
2761
+ // setActiveToolsByName mid-turn reassigns both to its own values without touching the
2762
+ // model — the model guard above still passes, but that live change is legitimate and must
2763
+ // survive rather than being silently reverted to the stale pre-turn snapshot.
2764
+ if (swappedTools !== undefined && this.agent.state.tools === swappedTools) {
2765
+ this.agent.state.tools = previousTurnTools;
2766
+ }
2767
+ if (swappedSystemPrompt !== undefined && this.agent.state.systemPrompt === swappedSystemPrompt) {
2768
+ this.agent.state.systemPrompt = previousSystemPrompt;
2769
+ }
2484
2770
  // The registry may have changed mid-turn (command-time registerProvider): re-resolve
2485
2771
  // the restored model so a provider override is not dropped with the routed model.
2486
2772
  this._refreshCurrentModelFromRegistry();
@@ -2605,6 +2891,10 @@ export class AgentSession {
2605
2891
  let messages;
2606
2892
  let routedTurnModel;
2607
2893
  let routedTurnRouteDecision;
2894
+ // Built and painted early (see below) so a later throw in this try block — e.g. no model
2895
+ // selected/authenticated — can un-register it from _earlyDisplayedUserMessages instead of
2896
+ // leaking the reference forever.
2897
+ let userMessage;
2608
2898
  // R4 effectiveness feedback: remember the recall page + the query so we can score, after the
2609
2899
  // response, whether the agent actually used the recalled context.
2610
2900
  let injectedRecall = "";
@@ -2667,13 +2957,38 @@ export class AgentSession {
2667
2957
  }
2668
2958
  // Flush any pending bash messages before the new prompt
2669
2959
  this._flushPendingBashMessages();
2960
+ // Build the user message now — before the router judge — and paint it to the UI
2961
+ // immediately via a synthetic message_start. The judge is a real bounded LLM completion
2962
+ // (seconds), not a regex; awaiting it first made the prompt appear to hang. The
2963
+ // authoritative message_start emitted later for this SAME object is suppressed in
2964
+ // _handleAgentEvent (see _earlyDisplayedUserMessages) so it is still shown exactly once.
2965
+ const userContent = [{ type: "text", text: expandedText }];
2966
+ if (currentImages) {
2967
+ userContent.push(...currentImages);
2968
+ }
2969
+ userMessage = {
2970
+ role: "user",
2971
+ content: userContent,
2972
+ timestamp: Date.now(),
2973
+ };
2974
+ this._earlyDisplayedUserMessages.add(userMessage);
2975
+ this._emit({ type: "message_start", message: userMessage });
2976
+ // Bracket the routing/prep phase (judge, model/auth checks, compaction, ...) so the UI can
2977
+ // show general "working" feedback for it — otherwise the user stares at their own echoed
2978
+ // prompt with nothing happening for however long the judge takes. routing_end is emitted
2979
+ // exactly once below: either in the catch block (this phase failed) or right after the try
2980
+ // block (this phase succeeded, whether or not it produced a turn to run).
2981
+ this._emit({ type: "routing_start" });
2670
2982
  const resolvedRouteInfo = await this._resolveModelRouterTurnRouteJudged(expandedText, {
2671
2983
  // Internally generated turns (goal continuation, lane follow-ups) never consult the judge:
2672
2984
  // the regex floor already classified them, and a 20-turn loop must not buy 20 judge calls.
2673
2985
  skipJudge: options?.autoContinueGoal === false,
2674
2986
  });
2675
- routedTurnModel = resolvedRouteInfo?.model;
2676
- routedTurnRouteDecision = resolvedRouteInfo?.decision;
2987
+ // #27: a route landing on a local (ollama) model must not hard-fail the turn just because
2988
+ // the server isn't up yet — boot/reuse it here, or escalate to a non-local tier.
2989
+ const readyRouteInfo = await this._ensureRouteModelReady(resolvedRouteInfo);
2990
+ routedTurnModel = readyRouteInfo?.model;
2991
+ routedTurnRouteDecision = readyRouteInfo?.decision;
2677
2992
  const requestModel = routedTurnModel ?? this.model;
2678
2993
  // Validate model
2679
2994
  if (!requestModel) {
@@ -2719,16 +3034,9 @@ export class AgentSession {
2719
3034
  // recall must never break a turn
2720
3035
  }
2721
3036
  }
2722
- // Add user message
2723
- const userContent = [{ type: "text", text: expandedText }];
2724
- if (currentImages) {
2725
- userContent.push(...currentImages);
2726
- }
2727
- messages.push({
2728
- role: "user",
2729
- content: userContent,
2730
- timestamp: Date.now(),
2731
- });
3037
+ // Add user message (built earlier, before the router judge, so it could be painted
3038
+ // immediately see the early message_start emit above).
3039
+ messages.push(userMessage);
2732
3040
  // Inject any pending "nextTurn" messages as context alongside the user message
2733
3041
  for (const msg of this._pendingNextTurnMessages) {
2734
3042
  messages.push(msg);
@@ -2759,9 +3067,22 @@ export class AgentSession {
2759
3067
  }
2760
3068
  }
2761
3069
  catch (error) {
3070
+ // The turn never reached _runAgentPrompt, so the authoritative message_start that would
3071
+ // normally consume this entry (see _handleAgentEvent) never fires — un-register it here
3072
+ // instead of leaking the reference.
3073
+ if (userMessage) {
3074
+ this._earlyDisplayedUserMessages.delete(userMessage);
3075
+ }
3076
+ // The routing/prep phase (routing_start above) failed before ever reaching the turn — end
3077
+ // it here, or the UI's "working" indicator for it spins forever with nothing behind it.
3078
+ this._emit({ type: "routing_end" });
2762
3079
  preflightResult?.(false);
2763
3080
  throw error;
2764
3081
  }
3082
+ // The routing/prep phase is over — either we're about to hand off into the turn (which emits
3083
+ // its own agent_start/streaming events right after), or messages is unexpectedly unset and we
3084
+ // bail below. Either way nothing is left "routing" past this point.
3085
+ this._emit({ type: "routing_end" });
2765
3086
  if (!messages) {
2766
3087
  return;
2767
3088
  }
@@ -3939,15 +4260,22 @@ export class AgentSession {
3939
4260
  }
3940
4261
  _filterExtensionsForRuntime(extensions) {
3941
4262
  this._inertExtensionWarnings = [];
4263
+ this._profileDeniedExtensionCount = 0;
3942
4264
  if (this.settingsManager.getActiveResourceProfileNames().length === 0) {
3943
- return this.settingsManager.hasExplicitActiveResourceProfileSelection()
3944
- ? []
3945
- : extensions.filter((extension) => extension.sourceInfo.source === "inline");
4265
+ if (this.settingsManager.hasExplicitActiveResourceProfileSelection()) {
4266
+ // An explicit profile selection that resolves to no active profile is a deliberate
4267
+ // deny-all every extension is withheld by that choice.
4268
+ this._profileDeniedExtensionCount = extensions.length;
4269
+ return [];
4270
+ }
4271
+ // No profile in play: only inline/SDK extensions load by default. That is the baseline, not
4272
+ // a profile denial, so it is not counted as withheld.
4273
+ return extensions.filter((extension) => extension.sourceInfo.source === "inline");
3946
4274
  }
3947
4275
  const hasToolOrCommandGate = this._hasToolOrCommandProfileGate();
3948
- return extensions
3949
- .filter((extension) => this.settingsManager.isResourceAllowedByProfile("extensions", extension.path, extension.sourceInfo.baseDir))
3950
- .map((extension) => {
4276
+ const allowedExtensions = extensions.filter((extension) => this.settingsManager.isResourceAllowedByProfile("extensions", extension.path, extension.sourceInfo.baseDir));
4277
+ this._profileDeniedExtensionCount = extensions.length - allowedExtensions.length;
4278
+ return allowedExtensions.map((extension) => {
3951
4279
  if (!hasToolOrCommandGate)
3952
4280
  return extension;
3953
4281
  const tools = new Map(Array.from(extension.tools.entries()).filter(([name]) => this._isToolOrCommandAllowedByProfile(name)));
@@ -3962,6 +4290,36 @@ export class AgentSession {
3962
4290
  return { ...extension, tools, commands };
3963
4291
  });
3964
4292
  }
4293
+ /**
4294
+ * /context observations for skills/prompts/extensions the active resource profile removed from
4295
+ * listings — the analog of the withheld-AGENTS.md warning. Strict UAC makes these silently absent,
4296
+ * so a lean profile's effect on the resource surface stays visible. Counts are profile-scoped
4297
+ * (skills/prompts via the profile-independent discovery universe filtered by the live profile
4298
+ * filter; extensions via the runtime filter's denied tally). Empty when nothing is withheld.
4299
+ *
4300
+ * Uses `isResourceDeniedByActiveProfile` (profile-only), not `isResourceAllowedByProfile` (which
4301
+ * also folds in the user's own legacy `disabledResources` list): a plain user-disabled resource
4302
+ * must never be misattributed to "the active resource profile" — that case is already surfaced by
4303
+ * the G14 disable-wins warning. With no active profile at all, the helper always reports nothing
4304
+ * denied, so this naturally stays silent (extensions keep their own runtime-filter-derived count,
4305
+ * which is already correctly zero absent a profile).
4306
+ */
4307
+ _profileDeniedResourceObservations() {
4308
+ const observations = [];
4309
+ const withheld = (kind, paths) => paths.filter((path) => this.settingsManager.isResourceDeniedByActiveProfile(kind, path, this._cwd)).length;
4310
+ const skillsWithheld = withheld("skills", this._resourceLoader.getDiscoverableSkillPaths());
4311
+ if (skillsWithheld > 0) {
4312
+ observations.push(`${skillsWithheld} skill(s) withheld by the active resource profile — grant the "skills" kind to restore them`);
4313
+ }
4314
+ const promptsWithheld = withheld("prompts", this._resourceLoader.getDiscoverablePromptPaths());
4315
+ if (promptsWithheld > 0) {
4316
+ observations.push(`${promptsWithheld} prompt(s) withheld by the active resource profile — grant the "prompts" kind to restore them`);
4317
+ }
4318
+ if (this._profileDeniedExtensionCount > 0) {
4319
+ observations.push(`${this._profileDeniedExtensionCount} extension(s) withheld by the active resource profile — grant the "extensions" kind to restore them`);
4320
+ }
4321
+ return observations;
4322
+ }
3965
4323
  /**
3966
4324
  * Re-resolve the active resource profile's model/thinking from current settings and apply it.
3967
4325
  * Only acts when the profile actually binds model/thinking AND that field was not set by an
@@ -5726,6 +6084,8 @@ export class AgentSession {
5726
6084
  maxWallClockMs: settings.maxWallClockMs,
5727
6085
  usageReportId,
5728
6086
  signal: this._workerDelegationAbort.signal,
6087
+ // Parent validation must use the same relative-path baseline the runner reports in.
6088
+ cwd: this._cwd,
5729
6089
  // Write lane (G2): runner-side action application through the envelope path scope.
5730
6090
  applyActions: workerRequest.envelope.capabilities.includes("write_files")
5731
6091
  ? (actions) => applyWorkerActions({ actions, envelope: workerRequest.envelope, cwd: this._cwd })
@@ -6104,7 +6464,10 @@ export class AgentSession {
6104
6464
  proposal,
6105
6465
  confidence: policy.reflectionSourceConfidence,
6106
6466
  observations,
6107
- contradictions: 0,
6467
+ // A replace/remove supersedes an existing durable fact — the reflection engine's
6468
+ // confront-before-write conflict signal — so it routes through approval instead of
6469
+ // silently overwriting prior memory. Additive writes contradict nothing.
6470
+ contradictions: contradictionsForReflectionWrite(write),
6108
6471
  settings: {
6109
6472
  enabled: true,
6110
6473
  autoApplyEnabled: policy.autoApplyEnabled,
@@ -6112,6 +6475,7 @@ export class AgentSession {
6112
6475
  minObservations: policy.minObservations,
6113
6476
  allowedAutoApplyLayers: policy.allowedAutoApplyLayers,
6114
6477
  requireRollbackPlan: policy.requireRollbackPlan,
6478
+ autoApplySupersessions: policy.autoApplySupersessions,
6115
6479
  },
6116
6480
  })
6117
6481
  : {
@@ -6147,20 +6511,25 @@ export class AgentSession {
6147
6511
  },
6148
6512
  });
6149
6513
  }
6150
- if (decision.kind === "apply") {
6151
- await this._applyReflectionWrite(write, signal);
6152
- }
6514
+ // The gate's decision and the write's actual outcome are two different questions: the memory
6515
+ // tool can refuse a write (budget exceeded, drift, threat) via details.success:false without
6516
+ // throwing. Capture that outcome instead of assuming "decision.kind === apply" means it landed
6517
+ // — otherwise a refused write leaves a phantom "apply" audit whose rollback later fails
6518
+ // not-found (or, worse, misfires against whatever now occupies that text).
6519
+ const applied = decision.kind === "apply" ? await this._applyReflectionWrite(write, signal) : false;
6520
+ const writeFailed = decision.kind === "apply" && !applied;
6153
6521
  if (decision.kind !== "no-op") {
6154
6522
  auditSequence += 1;
6155
6523
  appendLearningAuditSnapshot(this.sessionManager, {
6156
6524
  id: `audit-${auditSequence}`,
6157
6525
  proposalId,
6158
6526
  layer: proposal.layer,
6159
- action: decision.kind === "apply" ? "apply" : "propose",
6527
+ action: writeFailed ? "apply_failed" : decision.kind === "apply" ? "apply" : "propose",
6160
6528
  summary: proposal.summary,
6161
- reasonCode: decision.reasonCode,
6529
+ reasonCode: writeFailed ? APPLY_WRITE_REFUSED_REASON_CODE : decision.reasonCode,
6162
6530
  decision,
6163
- rollback,
6531
+ // No rollback plan on a failed apply — nothing durable landed, so there is nothing to undo.
6532
+ rollback: writeFailed ? undefined : rollback,
6164
6533
  createdAt: new Date().toISOString(),
6165
6534
  });
6166
6535
  }
@@ -6196,28 +6565,41 @@ export class AgentSession {
6196
6565
  const rollback = audit.rollback;
6197
6566
  if (!rollback)
6198
6567
  return { ok: false, reason: "no_rollback_plan" };
6568
+ // Every inverse must be VERIFIED-applied before the rollback audit is appended: a silently
6569
+ // failed inverse that still recorded "rollback" would permanently self-lock the change
6570
+ // behind already_rolled_back while the durable write is in fact still live.
6199
6571
  switch (rollback.kind) {
6200
6572
  case "memory_remove": {
6201
6573
  if (!rollback.target)
6202
6574
  return { ok: false, reason: "missing_rollback_target" };
6203
- await this._applyReflectionWrite({ kind: "memory_remove", target: rollback.target });
6575
+ if (!(await this._applyReflectionWrite({ kind: "memory_remove", target: rollback.target }))) {
6576
+ return { ok: false, reason: "rollback_apply_failed" };
6577
+ }
6204
6578
  break;
6205
6579
  }
6206
6580
  case "memory_restore": {
6207
6581
  if (!rollback.target || rollback.previous === undefined) {
6208
6582
  return { ok: false, reason: "missing_rollback_target" };
6209
6583
  }
6210
- await this._applyReflectionWrite({
6584
+ const applied = await this._applyReflectionWrite({
6211
6585
  kind: "memory_replace",
6212
6586
  target: rollback.target,
6213
6587
  text: rollback.previous,
6214
6588
  });
6589
+ if (!applied)
6590
+ return { ok: false, reason: "rollback_apply_failed" };
6215
6591
  break;
6216
6592
  }
6217
6593
  case "memory_add": {
6218
6594
  if (rollback.previous === undefined)
6219
6595
  return { ok: false, reason: "missing_rollback_target" };
6220
- await this._applyReflectionWrite({ kind: "memory_add", section: "MEMORY", text: rollback.previous });
6596
+ const applied = await this._applyReflectionWrite({
6597
+ kind: "memory_add",
6598
+ section: "MEMORY",
6599
+ text: rollback.previous,
6600
+ });
6601
+ if (!applied)
6602
+ return { ok: false, reason: "rollback_apply_failed" };
6221
6603
  break;
6222
6604
  }
6223
6605
  case "archive_skill": {
@@ -6245,28 +6627,34 @@ export class AgentSession {
6245
6627
  /**
6246
6628
  * Apply one reflection write through the bundled `memory` tool. `memory_replace`/`memory_remove`
6247
6629
  * don't carry a target file, so we try MEMORY.md first and fall back to USER.md when the substring
6248
- * isn't found there. Best-effort: failures are swallowed (reflection must never break a turn).
6630
+ * isn't found there. Never throws (reflection must never break a turn); returns whether the write
6631
+ * actually applied so callers that MUST know — rollback's once-only accounting — can react instead
6632
+ * of recording a success that never happened.
6249
6633
  */
6250
6634
  async _applyReflectionWrite(write, signal) {
6251
6635
  // R7 memory-to-behavior: a recurring procedure is compiled into an executable skill file rather
6252
6636
  // than stored as a flat fact. Written under the agent skills dir so it loads like any user skill.
6253
6637
  if (write.kind === "promote_skill") {
6254
- this._promoteReflectionSkill(write.name, write.description, write.body);
6255
- return;
6638
+ return this._promoteReflectionSkill(write.name, write.description, write.body);
6256
6639
  }
6257
6640
  const memTool = this._memoryManager.getToolDefinitions().find((t) => t.name === "memory");
6258
6641
  const exec = memTool?.execute;
6259
6642
  if (!exec)
6260
- return;
6643
+ return false;
6261
6644
  const run = (params) => exec("reflection", params, signal, undefined, undefined);
6262
6645
  if (write.kind === "memory_add") {
6263
6646
  try {
6264
- await run({ action: "add", target: write.section === "USER" ? "user" : "memory", content: write.text });
6647
+ const res = await run({
6648
+ action: "add",
6649
+ target: write.section === "USER" ? "user" : "memory",
6650
+ content: write.text,
6651
+ });
6652
+ return res?.details?.success === true;
6265
6653
  }
6266
6654
  catch {
6267
6655
  // best-effort; reflection writes must never throw into the turn loop
6656
+ return false;
6268
6657
  }
6269
- return;
6270
6658
  }
6271
6659
  // replace / remove carry no target file — try MEMORY.md, then USER.md. The memory tool reports
6272
6660
  // outcomes via `details.success` (it catches its own errors rather than throwing). Only a
@@ -6279,15 +6667,16 @@ export class AgentSession {
6279
6667
  : { action: "remove", target, oldContent: write.target };
6280
6668
  const res = await run(params);
6281
6669
  if (res?.details?.success === true)
6282
- return; // applied
6670
+ return true; // applied
6283
6671
  if (!/not found/i.test(String(res?.details?.error ?? "")))
6284
- return; // real failure — don't misapply
6672
+ return false; // real failure — don't misapply
6285
6673
  // substring simply absent from this file — try the next target
6286
6674
  }
6287
6675
  catch {
6288
6676
  // defensive: if the tool ever does throw, try the next target
6289
6677
  }
6290
6678
  }
6679
+ return false;
6291
6680
  }
6292
6681
  /**
6293
6682
  * R7: write a reflection-promoted skill as `<agentDir>/skills/<name>/SKILL.md` so it loads like any
@@ -6301,21 +6690,23 @@ export class AgentSession {
6301
6690
  .replace(/^-+|-+$/g, "")
6302
6691
  .slice(0, 64);
6303
6692
  if (!name || !body.trim())
6304
- return;
6693
+ return false;
6305
6694
  try {
6306
6695
  const dir = join(this._agentDir, "skills", name);
6307
6696
  const file = join(dir, "SKILL.md");
6308
6697
  if (existsSync(file))
6309
- return; // do not overwrite an existing skill
6698
+ return false; // do not overwrite an existing skill
6310
6699
  mkdirSync(dir, { recursive: true });
6311
6700
  const safeDescription = description.replace(/[\r\n]+/g, " ").trim();
6312
6701
  // `promoted: true` marks this as reflection-generated so the curator (#32) can lifecycle-manage
6313
6702
  // it (archive/consolidate) WITHOUT ever touching hand-authored user skills.
6314
6703
  const content = `---\nname: ${name}\ndescription: ${safeDescription}\npromoted: true\n---\n\n<!-- Auto-generated by the reflection engine (R7 memory-to-behavior). Review and refine. -->\n\n${body.trim()}\n`;
6315
6704
  writeFileSync(file, content, "utf-8");
6705
+ return true;
6316
6706
  }
6317
6707
  catch {
6318
6708
  // promotion must never break a turn
6709
+ return false;
6319
6710
  }
6320
6711
  }
6321
6712
  getContextUsage() {