@oh-my-pi/pi-coding-agent 17.2.10 → 17.2.11

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 (120) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/dist/{CHANGELOG-rhwzpexa.md → CHANGELOG-d9xenpn9.md} +40 -0
  3. package/dist/cli.js +3025 -3016
  4. package/dist/types/capability/mcp.d.ts +11 -0
  5. package/dist/types/capability/skill.d.ts +6 -0
  6. package/dist/types/cli/command-help.d.ts +3 -0
  7. package/dist/types/commands/share.d.ts +25 -0
  8. package/dist/types/commit/agentic/index.d.ts +3 -1
  9. package/dist/types/commit/execute.d.ts +32 -0
  10. package/dist/types/commit/index.d.ts +2 -1
  11. package/dist/types/commit/pipeline.d.ts +7 -1
  12. package/dist/types/config/model-registry.d.ts +4 -0
  13. package/dist/types/config/settings-schema.d.ts +1 -34
  14. package/dist/types/discovery/agent-plugin-format.d.ts +143 -0
  15. package/dist/types/discovery/agent-plugins.d.ts +1 -0
  16. package/dist/types/discovery/contained-path.d.ts +34 -0
  17. package/dist/types/discovery/index.d.ts +1 -0
  18. package/dist/types/extensibility/custom-tools/types.d.ts +2 -2
  19. package/dist/types/extensibility/extensions/loader.d.ts +7 -0
  20. package/dist/types/extensibility/extensions/types.d.ts +11 -0
  21. package/dist/types/extensibility/shared-events.d.ts +3 -2
  22. package/dist/types/extensibility/skills.d.ts +5 -0
  23. package/dist/types/mcp/transports/header-policy.d.ts +45 -0
  24. package/dist/types/mcp/types.d.ts +15 -0
  25. package/dist/types/modes/components/agent-hub.d.ts +2 -0
  26. package/dist/types/modes/components/agent-transcript-viewer.d.ts +2 -0
  27. package/dist/types/modes/components/chat-transcript-builder.d.ts +2 -0
  28. package/dist/types/modes/components/custom-editor.d.ts +1 -2
  29. package/dist/types/modes/components/status-line/types.d.ts +2 -0
  30. package/dist/types/modes/components/tool-execution.d.ts +2 -0
  31. package/dist/types/modes/utils/transcript-render-helpers.d.ts +3 -2
  32. package/dist/types/secrets/index.d.ts +23 -1
  33. package/dist/types/session/agent-session-events.d.ts +2 -2
  34. package/dist/types/session/agent-session.d.ts +1 -1
  35. package/dist/types/session/messages.d.ts +1 -0
  36. package/dist/types/session/prewalk.d.ts +3 -1
  37. package/dist/types/session/session-tools.d.ts +8 -0
  38. package/dist/types/session/turn-recovery.d.ts +7 -2
  39. package/dist/types/task/index.d.ts +2 -0
  40. package/dist/types/tools/shell-tokenize.d.ts +16 -0
  41. package/package.json +13 -13
  42. package/scripts/legacy-pi-virtual-module.ts +4 -1
  43. package/src/capability/mcp.ts +11 -0
  44. package/src/capability/skill.ts +6 -0
  45. package/src/cli/command-help.ts +4 -0
  46. package/src/cli/gallery-cli.ts +1 -1
  47. package/src/cli-commands.ts +5 -0
  48. package/src/commands/commit.ts +16 -3
  49. package/src/commands/share.ts +71 -0
  50. package/src/commit/agentic/index.ts +39 -21
  51. package/src/commit/execute.ts +56 -0
  52. package/src/commit/index.ts +2 -1
  53. package/src/commit/pipeline.ts +20 -7
  54. package/src/config/model-discovery.ts +1 -1
  55. package/src/config/model-registry.ts +21 -1
  56. package/src/config/settings-schema.ts +2 -33
  57. package/src/config/settings.ts +46 -0
  58. package/src/discovery/agent-plugin-format.ts +551 -0
  59. package/src/discovery/agent-plugins.ts +341 -0
  60. package/src/discovery/claude-plugins.ts +21 -5
  61. package/src/discovery/contained-path.ts +78 -0
  62. package/src/discovery/helpers.ts +23 -9
  63. package/src/discovery/index.ts +1 -0
  64. package/src/discovery/omp-plugins.ts +20 -7
  65. package/src/exec/non-interactive-env.ts +1 -0
  66. package/src/extensibility/custom-tools/types.ts +2 -2
  67. package/src/extensibility/extensions/loader.ts +78 -14
  68. package/src/extensibility/extensions/runner.ts +6 -0
  69. package/src/extensibility/extensions/types.ts +12 -0
  70. package/src/extensibility/plugins/marketplace/manager.ts +2 -1
  71. package/src/extensibility/shared-events.ts +3 -2
  72. package/src/extensibility/skills.ts +9 -0
  73. package/src/internal-urls/skill-protocol.ts +14 -0
  74. package/src/internal-urls/vault-protocol.ts +2 -2
  75. package/src/launch/client.ts +11 -1
  76. package/src/launch/protocol.ts +7 -2
  77. package/src/main.ts +3 -2
  78. package/src/mcp/config.ts +3 -0
  79. package/src/mcp/manager.ts +12 -9
  80. package/src/mcp/transports/header-policy.ts +95 -0
  81. package/src/mcp/transports/http.ts +35 -52
  82. package/src/mcp/transports/sse.ts +20 -27
  83. package/src/mcp/types.ts +15 -0
  84. package/src/modes/acp/acp-agent.ts +15 -4
  85. package/src/modes/components/agent-dashboard.ts +2 -0
  86. package/src/modes/components/agent-hub.ts +5 -0
  87. package/src/modes/components/agent-transcript-viewer.ts +3 -0
  88. package/src/modes/components/chat-transcript-builder.ts +3 -0
  89. package/src/modes/components/custom-editor.ts +0 -9
  90. package/src/modes/components/status-line/component.ts +1 -0
  91. package/src/modes/components/status-line/segments.ts +6 -4
  92. package/src/modes/components/status-line/types.ts +2 -0
  93. package/src/modes/components/tool-execution.ts +13 -16
  94. package/src/modes/components/tree-selector.ts +62 -3
  95. package/src/modes/controllers/command-controller.ts +4 -1
  96. package/src/modes/controllers/event-controller.ts +15 -15
  97. package/src/modes/controllers/input-controller.ts +20 -2
  98. package/src/modes/controllers/selector-controller.ts +1 -0
  99. package/src/modes/utils/transcript-render-helpers.ts +4 -2
  100. package/src/modes/utils/ui-helpers.ts +1 -0
  101. package/src/sdk.ts +6 -49
  102. package/src/secrets/index.ts +52 -1
  103. package/src/session/agent-session-events.ts +2 -2
  104. package/src/session/agent-session.ts +33 -14
  105. package/src/session/messages.ts +1 -0
  106. package/src/session/prewalk.ts +57 -17
  107. package/src/session/session-context.ts +3 -5
  108. package/src/session/session-handoff.ts +5 -1
  109. package/src/session/session-tools.ts +37 -3
  110. package/src/session/todo-tracker.ts +11 -1
  111. package/src/session/turn-recovery.ts +94 -72
  112. package/src/slash-commands/builtin-registry.ts +12 -8
  113. package/src/task/index.ts +21 -6
  114. package/src/tools/bash-skill-urls.ts +15 -0
  115. package/src/tools/bash.ts +10 -11
  116. package/src/tools/debug.ts +1 -1
  117. package/src/tools/jtd-to-json-schema.ts +123 -21
  118. package/src/tools/read.ts +5 -4
  119. package/src/tools/shell-tokenize.ts +98 -0
  120. package/src/web/search/providers/exa.ts +1 -1
@@ -286,12 +286,20 @@ class TreeList implements Component {
286
286
 
287
287
  // Apply filter mode
288
288
  let passesFilter = true;
289
- // Entry types hidden in default view (settings/bookkeeping)
289
+ // Entry types hidden in default view (settings/bookkeeping). These carry
290
+ // no conversation content, so the tree only shows them in "all" mode.
290
291
  const isSettingsEntry =
291
292
  entry.type === "label" ||
292
293
  entry.type === "custom" ||
293
294
  entry.type === "model_change" ||
294
- entry.type === "thinking_level_change";
295
+ entry.type === "thinking_level_change" ||
296
+ entry.type === "service_tier_change" ||
297
+ entry.type === "title_change" ||
298
+ entry.type === "credential_pin" ||
299
+ entry.type === "session_init" ||
300
+ entry.type === "ttsr_injection" ||
301
+ entry.type === "mode_change" ||
302
+ entry.type === "reset_boundary";
295
303
 
296
304
  switch (this.#filterMode) {
297
305
  case "user-only":
@@ -390,6 +398,34 @@ class TreeList implements Component {
390
398
  case "label":
391
399
  parts.push("label", entry.label ?? "");
392
400
  break;
401
+ case "service_tier_change":
402
+ parts.push("service tier");
403
+ if (entry.serviceTier) {
404
+ const serviceTier = entry.serviceTier;
405
+ for (const family in serviceTier) {
406
+ const tier = serviceTier[family as keyof typeof serviceTier];
407
+ if (tier) parts.push(family, tier);
408
+ }
409
+ }
410
+ break;
411
+ case "title_change":
412
+ parts.push("title", entry.title);
413
+ break;
414
+ case "mode_change":
415
+ parts.push("mode", entry.mode);
416
+ break;
417
+ case "credential_pin":
418
+ parts.push("credential pin", entry.provider);
419
+ break;
420
+ case "ttsr_injection":
421
+ parts.push("ttsr injection", ...entry.injectedRules);
422
+ break;
423
+ case "reset_boundary":
424
+ parts.push("reset boundary");
425
+ break;
426
+ case "session_init":
427
+ parts.push("session init");
428
+ break;
393
429
  }
394
430
 
395
431
  return parts.join(" ");
@@ -658,8 +694,31 @@ class TreeList implements Component {
658
694
  case "label":
659
695
  result = theme.fg("dim", `[label: ${entry.label ?? "(cleared)"}]`);
660
696
  break;
697
+ case "service_tier_change": {
698
+ // Per-family map, or null when the session went back to the default.
699
+ const tiers = entry.serviceTier
700
+ ? Object.entries(entry.serviceTier)
701
+ .map(([family, tier]) => `${family}:${tier}`)
702
+ .join(" ")
703
+ : "(default)";
704
+ result = theme.fg("dim", `[service tier: ${tiers}]`);
705
+ break;
706
+ }
707
+ case "title_change":
708
+ result = theme.fg("dim", `[title: ${normalize(entry.title)}]`);
709
+ break;
710
+ case "mode_change":
711
+ result = theme.fg("dim", `[mode: ${entry.mode}]`);
712
+ break;
713
+ case "credential_pin":
714
+ result = theme.fg("dim", `[credential pin: ${entry.provider}]`);
715
+ break;
661
716
  default:
662
- result = "";
717
+ // Bookkeeping entries with nothing worth spelling out still get their
718
+ // type. A row that renders to the empty string is worse than a
719
+ // useless one: it draws as a bare bullet with no way to tell what it
720
+ // is or why the tree has a gap in it.
721
+ result = theme.fg("dim", `[${entry.type.replaceAll("_", " ")}]`);
663
722
  }
664
723
 
665
724
  return isSelected ? theme.bold(result) : result;
@@ -1436,7 +1436,10 @@ export class CommandController {
1436
1436
  }
1437
1437
  } catch (error) {
1438
1438
  const message = error instanceof Error ? error.message : String(error);
1439
- if (message === "Handoff cancelled" || (error instanceof Error && error.name === "AbortError")) {
1439
+ // `session.handoff()` normalizes genuine cancellations to this exact message; a
1440
+ // provider error (even one named AbortError) is re-thrown verbatim so it surfaces
1441
+ // as a real failure instead of a false "cancelled".
1442
+ if (message === "Handoff cancelled") {
1440
1443
  this.ctx.showError("Handoff cancelled");
1441
1444
  } else {
1442
1445
  this.ctx.showError(`Handoff failed: ${message}`);
@@ -1073,6 +1073,7 @@ export class EventController {
1073
1073
  content.name,
1074
1074
  renderArgs,
1075
1075
  {
1076
+ useBuiltInRenderer: this.ctx.viewSession.hasBuiltInTool(content.name),
1076
1077
  snapshots: getFileSnapshotStore(this.ctx.viewSession),
1077
1078
  clipboard: getEditClipboard(this.ctx.viewSession),
1078
1079
  showImages: settings.get("terminal.showImages"),
@@ -1322,6 +1323,7 @@ export class EventController {
1322
1323
  event.toolName,
1323
1324
  event.args,
1324
1325
  {
1326
+ useBuiltInRenderer: this.ctx.viewSession.hasBuiltInTool(event.toolName),
1325
1327
  snapshots: getFileSnapshotStore(this.ctx.viewSession),
1326
1328
  clipboard: getEditClipboard(this.ctx.viewSession),
1327
1329
  showImages: settings.get("terminal.showImages"),
@@ -1920,21 +1922,19 @@ export class EventController {
1920
1922
  this.ctx.retryLoader = undefined;
1921
1923
  this.ctx.statusContainer.disposeChildren();
1922
1924
  }
1923
- if (event.success) {
1924
- let appliedRecovered = false;
1925
- for (const recovered of event.recoveredErrors ?? []) {
1926
- const component = this.#takeRetrySupersededAssistantComponent(recovered.persistenceKey);
1927
- if (!component) continue;
1928
- component.applyRetryRecovery(recovered.retryRecovery);
1929
- if (this.#pinnedErrorComponent === component) this.#pinnedErrorComponent = undefined;
1930
- appliedRecovered = true;
1931
- }
1932
- if (appliedRecovered || (event.recoveredErrors?.length ?? 0) > 0) {
1933
- this.ctx.clearPinnedError();
1934
- }
1935
- this.#clearRetrySupersededAssistantComponents();
1936
- } else {
1937
- this.#clearRetrySupersededAssistantComponents();
1925
+ let appliedRetryUpdate = false;
1926
+ for (const retryError of event.retryErrors ?? []) {
1927
+ const component = this.#takeRetrySupersededAssistantComponent(retryError.persistenceKey);
1928
+ if (!component) continue;
1929
+ component.applyRetryRecovery(retryError.retryRecovery);
1930
+ if (this.#pinnedErrorComponent === component) this.#pinnedErrorComponent = undefined;
1931
+ appliedRetryUpdate = true;
1932
+ }
1933
+ if (appliedRetryUpdate || (event.retryErrors?.length ?? 0) > 0) {
1934
+ this.ctx.clearPinnedError();
1935
+ }
1936
+ this.#clearRetrySupersededAssistantComponents();
1937
+ if (!event.success) {
1938
1938
  this.ctx.showError(`Retry failed after ${event.attempt} attempts: ${event.finalError || "Unknown error"}`);
1939
1939
  }
1940
1940
  this.#ensureWorkingLoaderWhileStreaming();
@@ -13,6 +13,7 @@ import { renderSegmentTrack } from "../../modes/components/segment-track";
13
13
  import { StrippedToolCallsPlaceholder } from "../../modes/components/stripped-tool-calls-placeholder";
14
14
  import { TinyTitleDownloadProgressComponent } from "../../modes/components/tiny-title-download-progress";
15
15
  import { ToolExecutionComponent } from "../../modes/components/tool-execution";
16
+ import { TreeSelectorComponent } from "../../modes/components/tree-selector";
16
17
  import { expandEmoticons } from "../../modes/emoji-autocomplete";
17
18
  import { materializeImageReferenceLinks, shiftImageMarkers } from "../../modes/image-references";
18
19
  import { createPromptActionAutocompleteProvider } from "../../modes/prompt-action-autocomplete";
@@ -177,6 +178,7 @@ export class InputController {
177
178
  #focusedPasteListenerInstalled = false;
178
179
  #btwBranchListenerInstalled = false;
179
180
  #btwCopyListenerInstalled = false;
181
+ #expandToolsListenerInstalled = false;
180
182
  // Tap counter for the double-← gesture; reset whenever a quiet gap
181
183
  // (>= LEFT_DOUBLE_TAP_MAX_GAP_MS) starts a fresh sequence. See
182
184
  // #detectLeftDoubleTap.
@@ -278,6 +280,24 @@ export class InputController {
278
280
  return { consume: true };
279
281
  });
280
282
  }
283
+ if (!this.#expandToolsListenerInstalled) {
284
+ this.#expandToolsListenerInstalled = true;
285
+ // `app.tools.expand` (Ctrl+O) toggles the transcript's tool-output
286
+ // preview. It must fire regardless of focus so a truncated edit stays
287
+ // expandable while an approval prompt / select dialog holds keyboard
288
+ // focus (#7837). Defers when the main transcript is not the active
289
+ // surface (a fullscreen/anchored overlay — agent hub, transcript
290
+ // viewer, log viewer, model picker) or when the focused component
291
+ // rebinds Ctrl+O for its own use (the tree selector's filter cycle).
292
+ this.ctx.ui.addInputListener(data => {
293
+ if (!this.ctx.keybindings.matches(data, "app.tools.expand")) return undefined;
294
+ if (this.ctx.ui.hasOverlay()) return undefined;
295
+ if (this.ctx.ui.getFocused() instanceof TreeSelectorComponent && matchesKey(data, "ctrl+o"))
296
+ return undefined;
297
+ this.toggleToolOutputExpansion();
298
+ return { consume: true };
299
+ });
300
+ }
281
301
  this.ctx.editor.onEscape = () => {
282
302
  // Side-channel panels are the topmost view. Esc dismisses them before
283
303
  // touching loop mode, maintenance, or the underlying main turn.
@@ -456,8 +476,6 @@ export class InputController {
456
476
  this.ctx.keybindings.getKeys("app.clipboard.copyPrompt"),
457
477
  );
458
478
  this.ctx.editor.onCopyPrompt = () => this.handleCopyPrompt();
459
- this.ctx.editor.setActionKeys("app.tools.expand", this.ctx.keybindings.getKeys("app.tools.expand"));
460
- this.ctx.editor.onExpandTools = () => this.toggleToolOutputExpansion();
461
479
  this.ctx.editor.setActionKeys(
462
480
  "app.tools.toggleVisibility",
463
481
  this.ctx.keybindings.getKeys("app.tools.toggleVisibility"),
@@ -2026,6 +2026,7 @@ export class SelectorController {
2026
2026
  remote: this.ctx.collabGuest?.hubRemote,
2027
2027
  ui: this.ctx.ui,
2028
2028
  getTool: name => this.ctx.session.getToolByName(name),
2029
+ isBuiltInTool: name => this.ctx.session.hasBuiltInTool(name),
2029
2030
  getMessageRenderer: type => this.ctx.session.extensionRunner?.getMessageRenderer(type),
2030
2031
  cwd: this.ctx.sessionManager.getCwd(),
2031
2032
  hideThinkingBlock: () => this.ctx.effectiveHideThinkingBlock,
@@ -211,13 +211,15 @@ function sanitizeRecoveredRetryNote(note: string): string {
211
211
 
212
212
  /**
213
213
  * Resolve the turn-ending assistant error presentation, if any.
214
- * Silent and user-interrupt aborts yield no label. Recovered auto-retry errors
215
- * collapse to a single non-error note; terminal errors keep the full red presentation.
214
+ * Silent and user-interrupt aborts yield no label. Recovered retry attempts
215
+ * render a compact note; attempts superseded by an exhausted budget are hidden
216
+ * while the final terminal error keeps its full presentation.
216
217
  */
217
218
  export function resolveAssistantErrorPresentation(
218
219
  message: AssistantAgentMessage,
219
220
  retryAttempt = 0,
220
221
  ): AssistantErrorPresentation {
222
+ if (message.retryRecovery?.status === "superseded") return { kind: "none" };
221
223
  if (message.retryRecovery?.status === "recovered") {
222
224
  return {
223
225
  kind: "compact-recovered",
@@ -500,6 +500,7 @@ export class UiHelpers {
500
500
  content.name,
501
501
  renderArgs,
502
502
  {
503
+ useBuiltInRenderer: this.ctx.viewSession.hasBuiltInTool(content.name),
503
504
  snapshots: getFileSnapshotStore(this.ctx.viewSession),
504
505
  clipboard: getEditClipboard(this.ctx.viewSession),
505
506
  showImages: settings.get("terminal.showImages"),
package/src/sdk.ts CHANGED
@@ -123,18 +123,12 @@ import lateDiagnosticTemplate from "./prompts/tools/lsp-late-diagnostic.md" with
123
123
  import { AgentLifecycleManager } from "./registry/agent-lifecycle";
124
124
  import { type AgentRef, AgentRegistry, MAIN_AGENT_ID } from "./registry/agent-registry";
125
125
  import {
126
- builtinCredentialSecretEntries,
127
- collectEnvSecrets,
126
+ buildSecretObfuscator,
128
127
  deobfuscateSessionContext,
129
128
  deobfuscateToolArguments,
130
- getExistingSecretPlaceholderKey,
131
- getSecretPlaceholderKey,
132
- getSecretPlaceholderKeySync,
133
- loadSecrets,
134
129
  obfuscateMessages,
135
130
  obfuscateProviderContext,
136
- SecretObfuscator,
137
- secretEntriesNeedPlaceholderKey,
131
+ type SecretObfuscator,
138
132
  } from "./secrets";
139
133
  import { AgentSession, type InitialRetryFallbackState, type PlanYolo, type Prewalk } from "./session/agent-session";
140
134
  import { discoverAuthStorage as discoverAuthStorageFromConfig } from "./session/auth-broker-config";
@@ -1039,7 +1033,7 @@ function createCustomToolsExtension(tools: CustomTool[]): ExtensionFactory {
1039
1033
  success: event.success,
1040
1034
  attempt: event.attempt,
1041
1035
  finalError: event.finalError,
1042
- recoveredErrors: event.recoveredErrors,
1036
+ retryErrors: event.retryErrors,
1043
1037
  },
1044
1038
  ctx,
1045
1039
  ),
@@ -1377,46 +1371,9 @@ async function createAgentSessionScoped(options: CreateAgentSessionOptions): Pro
1377
1371
 
1378
1372
  // Load and create secret obfuscator early so resumed session state and prompt warnings
1379
1373
  // reflect actual loaded secrets, not just the setting toggle.
1380
- let obfuscator: SecretObfuscator | undefined;
1381
- if (settings.get("secrets.enabled")) {
1382
- const fileEntries = await logger.time("loadSecrets", loadSecrets, cwd, agentDir);
1383
- const envEntries = collectEnvSecrets();
1384
- // Built-in credential-pattern entries come last so user-configured entries
1385
- // (plain literals, custom regexes) take precedence in the scan order.
1386
- const allEntries = [...envEntries, ...fileEntries, ...builtinCredentialSecretEntries()];
1387
- // Only CONFIGURED entries force startup key creation: a configured
1388
- // obfuscate-mode secret — or a default (no custom `replacement`)
1389
- // replace-mode regex whose key-derived idempotent fallback marker needs a
1390
- // stable key across restarts (see `secretEntryNeedsPlaceholderKey`) —
1391
- // mints placeholders as soon as the obfuscator is built. The built-in
1392
- // credential-pattern entry matches dynamically, so it resolves the
1393
- // persisted key lazily on first match instead of creating the key file
1394
- // for every secrets.enabled session; a session whose content never
1395
- // contains a credential-shaped token must not require the key, otherwise a
1396
- // headless run on an unwritable default config root pays for a feature it
1397
- // does not use.
1398
- const needsPlaceholderKey = secretEntriesNeedPlaceholderKey([...envEntries, ...fileEntries]);
1399
- const explicitAgentDir = options.agentDir;
1400
- const placeholderKey = needsPlaceholderKey
1401
- ? await getSecretPlaceholderKey(explicitAgentDir)
1402
- : await getExistingSecretPlaceholderKey(explicitAgentDir);
1403
- if (allEntries.length > 0) {
1404
- obfuscator = new SecretObfuscator(
1405
- allEntries,
1406
- placeholderKey ?? (() => getSecretPlaceholderKeySync(explicitAgentDir)),
1407
- );
1408
- }
1409
- if (obfuscator?.hasSecrets() !== true && placeholderKey !== undefined) {
1410
- // No configured entry produced an active secret (e.g. only ignored short
1411
- // plain entries, or no entries at all), but a persisted key exists. Build a
1412
- // redaction-only obfuscator so a tool read of the key file does not ship the
1413
- // reusable HMAC key to the provider.
1414
- obfuscator = new SecretObfuscator(
1415
- [{ type: "plain", mode: "replace", content: placeholderKey }],
1416
- placeholderKey,
1417
- );
1418
- }
1419
- }
1374
+ const obfuscator: SecretObfuscator | undefined = settings.get("secrets.enabled")
1375
+ ? await buildSecretObfuscator(cwd, agentDir, options.agentDir)
1376
+ : undefined;
1420
1377
  const secretsEnabled = obfuscator?.hasSecrets() === true;
1421
1378
 
1422
1379
  // An abnormal process exit after a non-terminal message tail is durable
@@ -4,7 +4,13 @@ import * as path from "node:path";
4
4
  import { SENSITIVE_TOKEN_RE } from "@oh-my-pi/pi-ai/providers/transform-messages";
5
5
  import { getSecretPlaceholderKeyPath, isEnoent, logger } from "@oh-my-pi/pi-utils";
6
6
  import { YAML } from "bun";
7
- import { regexHasUnresolvableShortMatchFallback, type SecretEntry, sanitizeSecretFriendlyName } from "./obfuscator";
7
+ import {
8
+ regexHasUnresolvableShortMatchFallback,
9
+ type SecretEntry,
10
+ SecretObfuscator,
11
+ sanitizeSecretFriendlyName,
12
+ secretEntriesNeedPlaceholderKey,
13
+ } from "./obfuscator";
8
14
  import { compileSecretRegex } from "./regex";
9
15
 
10
16
  const PLACEHOLDER_KEY_RE = /^[A-Za-z0-9_-]{43}$/;
@@ -223,6 +229,51 @@ export function builtinCredentialSecretEntries(): SecretEntry[] {
223
229
  ];
224
230
  }
225
231
 
232
+ /**
233
+ * Build the session secret obfuscator from every configured source: secrets.yml
234
+ * (project + global), secret-shaped environment variables, and the built-in
235
+ * credential patterns. Callers gate on `secrets.enabled`.
236
+ *
237
+ * Only CONFIGURED entries force startup key creation: a configured
238
+ * obfuscate-mode secret — or a default (no custom `replacement`) replace-mode
239
+ * regex whose key-derived idempotent fallback marker needs a stable key across
240
+ * restarts (see `secretEntryNeedsPlaceholderKey`) — mints placeholders as soon
241
+ * as the obfuscator is built. The built-in credential-pattern entry matches
242
+ * dynamically, so it resolves the persisted key lazily on first match instead
243
+ * of creating the key file for every secrets-enabled session.
244
+ *
245
+ * When no configured entry produced an active secret but a persisted key
246
+ * exists, returns a redaction-only obfuscator so a tool read of the key file
247
+ * does not ship the reusable HMAC key to the provider. Returns undefined when
248
+ * there is nothing to protect.
249
+ *
250
+ * `keyDir` is the explicit agent dir override for the placeholder-key file
251
+ * (default XDG/agent location when omitted).
252
+ */
253
+ export async function buildSecretObfuscator(
254
+ cwd: string,
255
+ agentDir: string,
256
+ keyDir?: string,
257
+ ): Promise<SecretObfuscator | undefined> {
258
+ const fileEntries = await logger.time("loadSecrets", loadSecrets, cwd, agentDir);
259
+ const envEntries = collectEnvSecrets();
260
+ // Built-in credential-pattern entries come last so user-configured entries
261
+ // (plain literals, custom regexes) take precedence in the scan order.
262
+ const allEntries = [...envEntries, ...fileEntries, ...builtinCredentialSecretEntries()];
263
+ const needsPlaceholderKey = secretEntriesNeedPlaceholderKey([...envEntries, ...fileEntries]);
264
+ const placeholderKey = needsPlaceholderKey
265
+ ? await getSecretPlaceholderKey(keyDir)
266
+ : await getExistingSecretPlaceholderKey(keyDir);
267
+ let obfuscator: SecretObfuscator | undefined;
268
+ if (allEntries.length > 0) {
269
+ obfuscator = new SecretObfuscator(allEntries, placeholderKey ?? (() => getSecretPlaceholderKeySync(keyDir)));
270
+ }
271
+ if (obfuscator?.hasSecrets() !== true && placeholderKey !== undefined) {
272
+ obfuscator = new SecretObfuscator([{ type: "plain", mode: "replace", content: placeholderKey }], placeholderKey);
273
+ }
274
+ return obfuscator;
275
+ }
276
+
226
277
  async function loadSecretsFile(filePath: string): Promise<SecretEntry[]> {
227
278
  try {
228
279
  const text = await Bun.file(filePath).text();
@@ -2,7 +2,7 @@ import type { AgentEvent, ThinkingLevel } from "@oh-my-pi/pi-agent-core";
2
2
  import type { CompactionResult } from "@oh-my-pi/pi-agent-core/compaction";
3
3
  import type { Effort } from "@oh-my-pi/pi-ai";
4
4
  import type { Rule } from "../capability/rule";
5
- import type { RecoveredRetryError } from "../extensibility/shared-events";
5
+ import type { RetryErrorUpdate } from "../extensibility/shared-events";
6
6
  import type { Goal, GoalModeState } from "../goals/state";
7
7
  import type { ConfiguredThinkingLevel } from "../thinking";
8
8
  import type { TodoItem } from "../tools/todo";
@@ -43,7 +43,7 @@ export type AgentSessionEvent =
43
43
  success: boolean;
44
44
  attempt: number;
45
45
  finalError?: string;
46
- recoveredErrors?: RecoveredRetryError[];
46
+ retryErrors?: RetryErrorUpdate[];
47
47
  }
48
48
  | { type: "retry_fallback_applied"; from: string; to: string; role: string }
49
49
  | { type: "retry_fallback_succeeded"; model: string; role: string }
@@ -304,7 +304,7 @@ import {
304
304
  USER_INTERRUPT_LABEL,
305
305
  } from "./messages";
306
306
  import { ModelControls, type ModelControlsHost } from "./model-controls";
307
- import { PrewalkCoordinator, type PrewalkCoordinatorHost } from "./prewalk";
307
+ import { isPrewalkPlanNudge, PrewalkCoordinator, type PrewalkCoordinatorHost } from "./prewalk";
308
308
  import {
309
309
  isAdvisorCard,
310
310
  isDisplayableQueuedMessage,
@@ -881,8 +881,8 @@ export class AgentSession {
881
881
  /**
882
882
  * Arm prewalk outside the normal startup path so an explicit slash command starts immediately.
883
883
  */
884
- armPrewalk(target: Model, thinkingLevel?: ConfiguredThinkingLevel): void {
885
- this.#prewalk.arm(target, thinkingLevel);
884
+ armPrewalk(target: Model, thinkingLevel?: ConfiguredThinkingLevel): boolean {
885
+ return this.#prewalk.arm(target, thinkingLevel);
886
886
  }
887
887
 
888
888
  /** Validate the active plan artifact and shape an `xd://propose` result for review-mode hosts. */
@@ -2489,14 +2489,17 @@ export class AgentSession {
2489
2489
  const persistMessageEnd = () => {
2490
2490
  // Check if this is a hook/custom message
2491
2491
  if (event.message.role === "hookMessage" || event.message.role === "custom") {
2492
- // Persist as CustomMessageEntry
2493
- this.sessionManager.appendCustomMessageEntry(
2494
- event.message.customType,
2495
- event.message.content,
2496
- event.message.display,
2497
- event.message.details,
2498
- event.message.attribution ?? "agent",
2499
- );
2492
+ // Prewalk's plan nudge is a one-run steering instruction. Persisting it would
2493
+ // resurrect the consumed prompt on resume, fork, or any context rebuild.
2494
+ if (!isPrewalkPlanNudge(event.message)) {
2495
+ this.sessionManager.appendCustomMessageEntry(
2496
+ event.message.customType,
2497
+ event.message.content,
2498
+ event.message.display,
2499
+ event.message.details,
2500
+ event.message.attribution ?? "agent",
2501
+ );
2502
+ }
2500
2503
  if (event.message.role === "custom" && event.message.customType === "ttsr-injection") {
2501
2504
  this.#ttsr.markInjectedFromDetails(event.message.details);
2502
2505
  }
@@ -3017,11 +3020,23 @@ export class AgentSession {
3017
3020
  }
3018
3021
  this.#beginInFlight();
3019
3022
  try {
3020
- await this.#recovery.maybeRestoreRetryFallbackPrimary();
3023
+ const reverted = await this.#recovery.maybeRestoreRetryFallbackPrimary();
3021
3024
  if (signal.aborted || this.#isDisposed) {
3022
3025
  this.#skipAgentContinue("post-restore-unavailable", options);
3023
3026
  return;
3024
3027
  }
3028
+ // A cooldown-expiry revert can drop the active window below the
3029
+ // accumulated context. The user-prompt path re-checks context after
3030
+ // the revert via runPrePromptCompactionIfNeeded; the auto-continue
3031
+ // path must do the same so agent.continue() never sends a
3032
+ // predictably oversized request to the reverted (smaller) model.
3033
+ if (reverted) {
3034
+ await this.#maintenance.runPrePromptCompactionIfNeeded([]);
3035
+ if (signal.aborted || this.#isDisposed) {
3036
+ this.#skipAgentContinue("post-restore-unavailable", options);
3037
+ return;
3038
+ }
3039
+ }
3025
3040
  if (this.settings.get("retry.usageAwareFallback")) {
3026
3041
  if (!(await this.#runQueuedUsageAwarePreflight(signal))) {
3027
3042
  this.#skipAgentContinue("session-unavailable", options);
@@ -3440,7 +3455,7 @@ export class AgentSession {
3440
3455
  success: event.success,
3441
3456
  attempt: event.attempt,
3442
3457
  finalError: event.finalError,
3443
- recoveredErrors: event.recoveredErrors,
3458
+ retryErrors: event.retryErrors,
3444
3459
  });
3445
3460
  } else if (event.type === "ttsr_triggered") {
3446
3461
  await this.#extensionRunner.emit({ type: "ttsr_triggered", rules: event.rules });
@@ -5273,11 +5288,13 @@ export class AgentSession {
5273
5288
 
5274
5289
  if (result?.systemPrompt !== undefined) {
5275
5290
  baseXdevCatalogDelivered = false;
5276
- this.agent.setSystemPrompt(result.systemPrompt);
5291
+ this.#tools.setTurnSystemPromptOverride(result.systemPrompt);
5277
5292
  } else {
5293
+ this.#tools.clearTurnSystemPromptOverride();
5278
5294
  this.agent.setSystemPrompt(beforeAgentStartSystemPrompt);
5279
5295
  }
5280
5296
  } else {
5297
+ this.#tools.clearTurnSystemPromptOverride();
5281
5298
  this.agent.setSystemPrompt(beforeAgentStartSystemPrompt);
5282
5299
  }
5283
5300
 
@@ -5342,6 +5359,8 @@ export class AgentSession {
5342
5359
  await this.#waitForPostPromptRecovery(generation);
5343
5360
  }
5344
5361
  } finally {
5362
+ // The per-turn before_agent_start override lives only for this turn.
5363
+ this.#tools.clearTurnSystemPromptOverride();
5345
5364
  this.#usagePreflightReadyForNextModelCall = false;
5346
5365
  this.#endInFlight();
5347
5366
  }
@@ -42,6 +42,7 @@ import { formatOutputNotice } from "../tools/output-meta";
42
42
  export const SKILL_PROMPT_MESSAGE_TYPE = "skill-prompt";
43
43
  export const LSP_LATE_DIAGNOSTIC_MESSAGE_TYPE = "lsp-late-diagnostic";
44
44
  export const BACKGROUND_TAN_DISPATCH_MESSAGE_TYPE = "background-tan-dispatch";
45
+ export const PREWALK_PLAN_MESSAGE_TYPE = "prewalk-plan";
45
46
 
46
47
  /**
47
48
  * Logs provider-error turns so their actual cause is available outside the
@@ -14,11 +14,16 @@ import { type ConfiguredThinkingLevel, prewalkWouldBeNoop } from "../thinking";
14
14
  import type { PlanProposalHandler } from "../tools/resolve";
15
15
  import { ToolError } from "../tools/tool-errors";
16
16
  import type { PlanYolo, Prewalk } from "./agent-session-types";
17
+ import { PREWALK_PLAN_MESSAGE_TYPE } from "./messages";
17
18
  import type { SessionManager } from "./session-manager";
18
19
 
19
- const PREWALK_PLAN_MESSAGE_TYPE = "prewalk-plan";
20
20
  const PREWALK_CONTINUE_MESSAGE_TYPE = "prewalk-continue";
21
21
  const PREWALK_CHECKLIST_MESSAGE_TYPE = "prewalk-checklist";
22
+
23
+ /** Hidden plan steering is consumed within the live run and must not reappear after a context rebuild. */
24
+ export function isPrewalkPlanNudge(message: AgentMessage): boolean {
25
+ return message.role === "custom" && message.customType === PREWALK_PLAN_MESSAGE_TYPE;
26
+ }
22
27
  const PREWALK_ACTION_TOOLS: Record<string, true> = {
23
28
  edit: true,
24
29
  write: true,
@@ -99,10 +104,40 @@ export class PrewalkCoordinator {
99
104
  return this.#prewalk;
100
105
  }
101
106
 
107
+ #isNoop(prewalk: Prewalk): boolean {
108
+ return prewalkWouldBeNoop(
109
+ this.#host.model(),
110
+ this.#host.configuredThinkingLevel(),
111
+ prewalk.target,
112
+ prewalk.thinkingLevel,
113
+ );
114
+ }
115
+
116
+ #clearPrewalkState(): void {
117
+ this.#prewalk = undefined;
118
+ this.#planInjected = false;
119
+ this.#continuePending = false;
120
+ this.#todoSeen = false;
121
+ }
122
+
123
+ #disarmNoop(prewalk: Prewalk): void {
124
+ this.#clearPrewalkState();
125
+ this.#host.emitNotice(
126
+ "info",
127
+ `Prewalk: target ${prewalk.target.provider}/${prewalk.target.id} already matches the active model and thinking level; nothing to switch.`,
128
+ "prewalk",
129
+ );
130
+ }
131
+
102
132
  /** Advances the one-way prewalk switch at a completed assistant-turn boundary. */
103
133
  async advanceAtTurnEnd(liveMessages: AgentMessage[], context: AgentTurnEndContext | undefined): Promise<void> {
104
134
  const prewalk = this.#prewalk;
105
135
  if (!prewalk || context?.message.role !== "assistant") return;
136
+ if (this.#isNoop(prewalk)) {
137
+ this.#scrubPlanNudge(liveMessages);
138
+ this.#disarmNoop(prewalk);
139
+ return;
140
+ }
106
141
  if (context.toolResults.some(result => result.toolName === "todo" && !result.isError)) this.#todoSeen = true;
107
142
 
108
143
  const hasToolResults = context.toolResults.length > 0;
@@ -147,18 +182,12 @@ export class PrewalkCoordinator {
147
182
  }
148
183
  this.#scrubPlanNudge(liveMessages);
149
184
  const target = prewalk.target;
150
- const currentModel = this.#host.model();
151
- if (prewalkWouldBeNoop(currentModel, this.#host.configuredThinkingLevel(), target, prewalk.thinkingLevel)) {
152
- this.#prewalk = undefined;
153
- this.#host.emitNotice(
154
- "info",
155
- `Prewalk: target ${target.provider}/${target.id} already matches the active model and thinking level; nothing to switch.`,
156
- "prewalk",
157
- );
185
+ if (this.#isNoop(prewalk)) {
186
+ this.#disarmNoop(prewalk);
158
187
  return;
159
188
  }
160
189
  await this.#host.setModelTemporary(target, prewalk.thinkingLevel, { ephemeral: true });
161
- this.#prewalk = undefined;
190
+ this.#clearPrewalkState();
162
191
  this.#host.emitNotice(
163
192
  "info",
164
193
  `Prewalk: switched to ${target.provider}/${target.id} after first ${action.toolName} call.`,
@@ -175,18 +204,29 @@ export class PrewalkCoordinator {
175
204
  }
176
205
 
177
206
  /** Arms a prewalk immediately for an explicit slash-command request. */
178
- arm(target: Model, thinkingLevel?: ConfiguredThinkingLevel): void {
179
- if (this.#prewalk) {
207
+ arm(target: Model, thinkingLevel?: ConfiguredThinkingLevel): boolean {
208
+ const active = this.#prewalk;
209
+ if (active) {
180
210
  this.#host.emitNotice(
181
211
  "info",
182
- `Prewalk: already armed for ${this.#prewalk.target.provider}/${this.#prewalk.target.id}, waiting for the first edit/write.`,
212
+ `Prewalk: already armed for ${active.target.provider}/${active.target.id}, waiting for the first edit/write.`,
183
213
  "prewalk",
184
214
  );
185
- return;
215
+ return (
216
+ active.target.provider === target.provider &&
217
+ active.target.id === target.id &&
218
+ active.thinkingLevel === thinkingLevel
219
+ );
220
+ }
221
+ const candidate = { target, thinkingLevel };
222
+ if (this.#isNoop(candidate)) {
223
+ this.#disarmNoop(candidate);
224
+ return false;
186
225
  }
187
- this.#prewalk = { target, thinkingLevel };
226
+ this.#prewalk = candidate;
188
227
  this.#planInjected = true;
189
228
  this.#continuePending = true;
229
+ this.#todoSeen = false;
190
230
  this.#host.agent.steer({
191
231
  role: "custom",
192
232
  customType: PREWALK_PLAN_MESSAGE_TYPE,
@@ -200,6 +240,7 @@ export class PrewalkCoordinator {
200
240
  `Prewalk: armed for ${target.provider}/${target.id} — will switch at the first edit/write once the todo list exists.`,
201
241
  "prewalk",
202
242
  );
243
+ return true;
203
244
  }
204
245
 
205
246
  /** Lazily enables plan-yolo's plan phase before the first prompt is built. */
@@ -220,8 +261,7 @@ export class PrewalkCoordinator {
220
261
 
221
262
  #scrubPlanNudge(liveMessages: AgentMessage[]): void {
222
263
  if (!this.#planInjected) return;
223
- const isPlanNudge = (message: AgentMessage): boolean =>
224
- message.role === "custom" && message.customType === PREWALK_PLAN_MESSAGE_TYPE;
264
+ const isPlanNudge = isPrewalkPlanNudge;
225
265
  for (let index = liveMessages.length - 1; index >= 0; index--) {
226
266
  if (!isPlanNudge(liveMessages[index])) continue;
227
267
  invalidateMessageCache(liveMessages[index]);