@oh-my-pi/pi-coding-agent 16.5.0 → 16.5.2

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 (198) hide show
  1. package/CHANGELOG.md +91 -0
  2. package/dist/cli.js +3556 -3504
  3. package/dist/types/advisor/advise-tool.d.ts +12 -1
  4. package/dist/types/advisor/runtime.d.ts +41 -1
  5. package/dist/types/cli/args.d.ts +2 -0
  6. package/dist/types/cli/update-cli.d.ts +4 -1
  7. package/dist/types/cli/usage-cli.d.ts +3 -0
  8. package/dist/types/cli/usage-error.d.ts +4 -0
  9. package/dist/types/config/api-key-resolver.d.ts +2 -2
  10. package/dist/types/config/model-registry.d.ts +3 -3
  11. package/dist/types/config/model-resolver.d.ts +8 -1
  12. package/dist/types/config/models-config.d.ts +1 -1
  13. package/dist/types/config/settings-schema.d.ts +10 -0
  14. package/dist/types/discovery/substitute-plugin-root.d.ts +22 -0
  15. package/dist/types/eval/__tests__/process-entry-import.test.d.ts +1 -0
  16. package/dist/types/eval/backend.d.ts +3 -3
  17. package/dist/types/eval/bridge-timeout.d.ts +9 -1
  18. package/dist/types/eval/js/context-manager.d.ts +5 -3
  19. package/dist/types/eval/js/process-entry.d.ts +6 -0
  20. package/dist/types/eval/js/worker-core.d.ts +15 -1
  21. package/dist/types/eval/py/spawn-options.d.ts +10 -0
  22. package/dist/types/eval/py/tool-bridge.d.ts +1 -0
  23. package/dist/types/extensibility/custom-tools/types.d.ts +3 -0
  24. package/dist/types/extensibility/extensions/runner.d.ts +3 -1
  25. package/dist/types/extensibility/extensions/types.d.ts +3 -0
  26. package/dist/types/extensibility/extensions/wrapper.d.ts +3 -6
  27. package/dist/types/extensibility/plugins/bun-git-cache.d.ts +3 -0
  28. package/dist/types/goals/guided-setup.d.ts +12 -0
  29. package/dist/types/internal-urls/history-protocol.d.ts +3 -2
  30. package/dist/types/internal-urls/memory-protocol.d.ts +6 -7
  31. package/dist/types/internal-urls/registry-helpers.d.ts +19 -0
  32. package/dist/types/main.d.ts +1 -0
  33. package/dist/types/mcp/oauth-discovery.d.ts +2 -0
  34. package/dist/types/mcp/oauth-flow.d.ts +2 -0
  35. package/dist/types/modes/components/__tests__/dynamic-border.test.d.ts +1 -0
  36. package/dist/types/modes/components/agent-hub.d.ts +10 -0
  37. package/dist/types/modes/components/dynamic-border.d.ts +5 -3
  38. package/dist/types/modes/components/login-dialog.d.ts +2 -0
  39. package/dist/types/modes/components/mcp-add-wizard.d.ts +1 -0
  40. package/dist/types/modes/components/read-tool-group.d.ts +0 -2
  41. package/dist/types/modes/components/transcript-container.d.ts +3 -2
  42. package/dist/types/modes/controllers/selector-controller.d.ts +1 -0
  43. package/dist/types/modes/interactive-mode.d.ts +1 -0
  44. package/dist/types/modes/magic-keyword-boundary.d.ts +9 -0
  45. package/dist/types/modes/orchestrate.d.ts +1 -1
  46. package/dist/types/modes/rpc/host-tools.d.ts +2 -0
  47. package/dist/types/modes/rpc/rpc-mode.d.ts +26 -6
  48. package/dist/types/modes/types.d.ts +1 -0
  49. package/dist/types/modes/ultrathink.d.ts +1 -1
  50. package/dist/types/modes/utils/transcript-render-helpers.d.ts +12 -0
  51. package/dist/types/modes/workflow.d.ts +1 -1
  52. package/dist/types/session/agent-session.d.ts +6 -0
  53. package/dist/types/session/exit-diagnostics.d.ts +11 -0
  54. package/dist/types/session/messages.d.ts +15 -0
  55. package/dist/types/slash-commands/helpers/active-oauth-account.d.ts +11 -0
  56. package/dist/types/subprocess/worker-client.d.ts +6 -0
  57. package/dist/types/tools/bash-skill-urls.d.ts +1 -0
  58. package/dist/types/tools/grep.d.ts +0 -2
  59. package/dist/types/tools/read.d.ts +0 -4
  60. package/dist/types/web/search/provider.d.ts +10 -3
  61. package/dist/types/web/search/providers/codex.d.ts +5 -4
  62. package/package.json +12 -12
  63. package/src/advisor/__tests__/advisor.test.ts +917 -42
  64. package/src/advisor/advise-tool.ts +17 -1
  65. package/src/advisor/runtime.ts +270 -67
  66. package/src/autolearn/controller.ts +15 -3
  67. package/src/autoresearch/dashboard.ts +2 -2
  68. package/src/cli/args.ts +12 -0
  69. package/src/cli/auth-broker-cli.ts +30 -11
  70. package/src/cli/auth-gateway-cli.ts +5 -1
  71. package/src/cli/config-cli.ts +15 -3
  72. package/src/cli/dry-balance-cli.ts +14 -4
  73. package/src/cli/flag-tables.ts +21 -7
  74. package/src/cli/update-cli.ts +62 -11
  75. package/src/cli/usage-cli.ts +58 -5
  76. package/src/cli/usage-error.ts +7 -0
  77. package/src/cli.ts +23 -1
  78. package/src/commands/acp.ts +11 -2
  79. package/src/commands/launch.ts +12 -3
  80. package/src/commands/token.ts +3 -1
  81. package/src/config/api-key-resolver.ts +12 -3
  82. package/src/config/config-file.ts +30 -12
  83. package/src/config/model-registry.ts +7 -7
  84. package/src/config/model-resolver.ts +21 -7
  85. package/src/config/models-config.ts +1 -1
  86. package/src/config/settings-schema.ts +10 -0
  87. package/src/cursor.ts +2 -0
  88. package/src/discovery/claude-plugins.ts +9 -3
  89. package/src/discovery/omp-plugins.ts +6 -2
  90. package/src/discovery/substitute-plugin-root.ts +32 -0
  91. package/src/eval/__tests__/agent-bridge.test.ts +19 -14
  92. package/src/eval/__tests__/bridge-timeout.test.ts +106 -0
  93. package/src/eval/__tests__/js-context-manager.test.ts +158 -1
  94. package/src/eval/__tests__/kernel-spawn.test.ts +12 -0
  95. package/src/eval/__tests__/prelude-agent.test.ts +20 -0
  96. package/src/eval/__tests__/process-entry-import.test.ts +27 -0
  97. package/src/eval/agent-bridge.ts +121 -116
  98. package/src/eval/backend.ts +3 -3
  99. package/src/eval/bridge-timeout.ts +20 -2
  100. package/src/eval/executor-base.ts +85 -7
  101. package/src/eval/jl/kernel.ts +2 -1
  102. package/src/eval/js/context-manager.ts +109 -32
  103. package/src/eval/js/process-entry.ts +27 -0
  104. package/src/eval/js/shared/runtime.ts +1 -1
  105. package/src/eval/js/worker-core.ts +70 -9
  106. package/src/eval/js/worker-entry.ts +1 -1
  107. package/src/eval/py/__tests__/prelude.test.ts +72 -0
  108. package/src/eval/py/kernel.ts +2 -1
  109. package/src/eval/py/prelude.py +28 -1
  110. package/src/eval/py/spawn-options.ts +13 -0
  111. package/src/eval/py/tool-bridge.ts +13 -14
  112. package/src/eval/rb/kernel.ts +2 -1
  113. package/src/exec/bash-executor.ts +30 -43
  114. package/src/extensibility/custom-tools/types.ts +3 -0
  115. package/src/extensibility/extensions/runner.ts +3 -0
  116. package/src/extensibility/extensions/types.ts +3 -0
  117. package/src/extensibility/extensions/wrapper.ts +18 -18
  118. package/src/extensibility/plugins/bun-git-cache.ts +91 -0
  119. package/src/extensibility/plugins/legacy-pi-compat.ts +32 -16
  120. package/src/extensibility/plugins/manager.ts +28 -7
  121. package/src/goals/guided-setup.ts +29 -1
  122. package/src/internal-urls/history-protocol.ts +95 -15
  123. package/src/internal-urls/memory-protocol.ts +13 -9
  124. package/src/internal-urls/registry-helpers.ts +50 -1
  125. package/src/launch/broker.ts +38 -25
  126. package/src/lsp/client.ts +7 -1
  127. package/src/main.ts +29 -0
  128. package/src/mcp/oauth-discovery.ts +20 -1
  129. package/src/mcp/oauth-flow.ts +3 -1
  130. package/src/mcp/tool-bridge.ts +57 -6
  131. package/src/modes/components/__tests__/dynamic-border.test.ts +55 -0
  132. package/src/modes/components/agent-dashboard.ts +2 -2
  133. package/src/modes/components/agent-hub.ts +15 -2
  134. package/src/modes/components/agent-transcript-viewer.ts +2 -2
  135. package/src/modes/components/chat-transcript-builder.ts +26 -4
  136. package/src/modes/components/dynamic-border.ts +9 -6
  137. package/src/modes/components/extensions/extension-list.ts +2 -2
  138. package/src/modes/components/hook-selector.ts +10 -4
  139. package/src/modes/components/login-dialog.ts +5 -0
  140. package/src/modes/components/mcp-add-wizard.ts +5 -0
  141. package/src/modes/components/plan-review-overlay.ts +11 -11
  142. package/src/modes/components/read-tool-group.ts +1 -8
  143. package/src/modes/components/status-line/component.ts +10 -1
  144. package/src/modes/components/transcript-container.ts +110 -7
  145. package/src/modes/controllers/command-controller.ts +12 -4
  146. package/src/modes/controllers/event-controller.ts +80 -15
  147. package/src/modes/controllers/input-controller.ts +4 -2
  148. package/src/modes/controllers/mcp-command-controller.ts +6 -7
  149. package/src/modes/controllers/selector-controller.ts +24 -5
  150. package/src/modes/controllers/todo-command-controller.ts +18 -14
  151. package/src/modes/interactive-mode.ts +7 -3
  152. package/src/modes/magic-keyword-boundary.ts +23 -0
  153. package/src/modes/orchestrate.ts +6 -5
  154. package/src/modes/print-mode.ts +9 -0
  155. package/src/modes/prompt-action-autocomplete.ts +6 -1
  156. package/src/modes/rpc/host-tools.ts +15 -0
  157. package/src/modes/rpc/rpc-mode.ts +123 -48
  158. package/src/modes/types.ts +1 -1
  159. package/src/modes/ultrathink.ts +6 -5
  160. package/src/modes/utils/transcript-render-helpers.ts +54 -0
  161. package/src/modes/utils/ui-helpers.ts +27 -1
  162. package/src/modes/workflow.ts +6 -5
  163. package/src/prompts/advisor/system.md +1 -0
  164. package/src/prompts/system/system-prompt.md +1 -0
  165. package/src/prompts/tools/eval.md +2 -2
  166. package/src/prompts/tools/grep.md +1 -2
  167. package/src/prompts/tools/read.md +2 -4
  168. package/src/sdk.ts +61 -34
  169. package/src/session/agent-session.ts +283 -39
  170. package/src/session/exit-diagnostics.ts +108 -0
  171. package/src/session/messages.test.ts +66 -0
  172. package/src/session/messages.ts +37 -0
  173. package/src/session/streaming-output.ts +40 -12
  174. package/src/slash-commands/helpers/active-oauth-account.ts +22 -2
  175. package/src/slash-commands/helpers/logout.ts +23 -3
  176. package/src/slash-commands/helpers/usage-report.ts +14 -2
  177. package/src/subprocess/worker-client.ts +9 -2
  178. package/src/system-prompt.test.ts +36 -0
  179. package/src/system-prompt.ts +1 -1
  180. package/src/task/executor.ts +8 -0
  181. package/src/task/render.test.ts +36 -0
  182. package/src/task/render.ts +55 -43
  183. package/src/tools/bash-skill-urls.ts +4 -1
  184. package/src/tools/bash.ts +1 -0
  185. package/src/tools/browser/registry.ts +17 -3
  186. package/src/tools/eval.ts +14 -9
  187. package/src/tools/gh.ts +3 -1
  188. package/src/tools/grep.ts +5 -45
  189. package/src/tools/path-utils.ts +7 -1
  190. package/src/tools/read.ts +23 -74
  191. package/src/tools/write.ts +82 -9
  192. package/src/tools/yield.ts +29 -1
  193. package/src/utils/title-generator.ts +10 -6
  194. package/src/web/search/index.ts +39 -22
  195. package/src/web/search/provider.ts +33 -16
  196. package/src/web/search/providers/codex.ts +68 -21
  197. package/src/web/search/providers/perplexity-auth.ts +20 -11
  198. package/src/web/search/providers/perplexity.ts +14 -2
@@ -153,14 +153,18 @@ import {
153
153
  AdvisorEmissionGuard,
154
154
  type AdvisorMessageDetails,
155
155
  type AdvisorNote,
156
+ AdvisorOutputQuarantinedError,
156
157
  AdvisorRuntime,
157
158
  type AdvisorSeverity,
158
159
  AdvisorTranscriptRecorder,
159
160
  advisorTranscriptFilename,
161
+ annotateForStaleness,
162
+ buildAdvisorQuarantineSourceText,
160
163
  formatAdvisorBatchContent,
161
164
  getOrCreateAdvisorProviderSessionId,
162
165
  isAdvisorInterruptImmuneTurnActive,
163
166
  isInterruptingSeverity,
167
+ quarantineAdvisorUnsafeOutput,
164
168
  resolveAdvisorDeliveryChannel,
165
169
  slugifyAdvisorName,
166
170
  } from "../advisor";
@@ -183,7 +187,7 @@ import {
183
187
  resolveModelOverride,
184
188
  resolveModelRoleValue,
185
189
  } from "../config/model-resolver";
186
- import { MODEL_ROLE_IDS, MODEL_ROLES } from "../config/model-roles";
190
+ import { getKnownRoleIds, MODEL_ROLE_IDS, MODEL_ROLES } from "../config/model-roles";
187
191
  import { expandPromptTemplate, type PromptTemplate } from "../config/prompt-templates";
188
192
  import { buildServiceTierByFamily, serviceTierForAllFamilies, serviceTierSettingToTier } from "../config/service-tier";
189
193
  import type { Settings, SkillsSettings } from "../config/settings";
@@ -350,6 +354,7 @@ import {
350
354
  import { findCompactMode } from "./compact-modes";
351
355
  import {
352
356
  collectPendingToolCalls,
357
+ createInterruptedTurnAbortMessage,
353
358
  SESSION_EXIT_CUSTOM_TYPE,
354
359
  type SessionExitData,
355
360
  summarizeToolArguments,
@@ -416,6 +421,59 @@ const MID_RUN_TODO_NUDGE_MUTATING_TOOLS: Record<string, true> = {
416
421
  write: true,
417
422
  ast_edit: true,
418
423
  };
424
+ const MARKDOWN_PROMPT_PREFIX_RE = /^(?:>\s*)?(?:(?:[-*+]|\d+[.)])\s+)*/;
425
+ const PROMPT_LABEL_RE = /^(?:q(?:uestion)?|ask)\s*\d*\s*[:.)-]\s*/i;
426
+ const QUESTION_PROMPT_RE =
427
+ /^(?:what|which|when|where|why|how|who|whom|whose|do|does|did|can|could|would|will|should|is|are|am|may|shall)\b/i;
428
+ const USER_DIRECTED_PROMPT_RE = /\b(?:you|your|we|our)\b/i;
429
+ const USER_RESPONSE_CUE_RE =
430
+ /^(?:please\s+)?(?:confirm|reply|choose|pick|decide|advise)\b|^(?:please\s+)?answer\b|^(?:please\s+)?(?:let\s+me\s+know|tell\s+me)\b/i;
431
+
432
+ function assistantText(message: AssistantMessage): string {
433
+ return message.content
434
+ .filter((content): content is TextContent => content.type === "text")
435
+ .map(content => content.text)
436
+ .join("\n")
437
+ .trim();
438
+ }
439
+
440
+ interface PromptLine {
441
+ text: string;
442
+ hadPromptLabel: boolean;
443
+ }
444
+
445
+ function promptLine(line: string): PromptLine {
446
+ const withoutMarkdownPrefix = line.trim().replace(MARKDOWN_PROMPT_PREFIX_RE, "").trim();
447
+ const withoutPromptLabel = withoutMarkdownPrefix.replace(PROMPT_LABEL_RE, "").trim();
448
+ return {
449
+ text: withoutPromptLabel,
450
+ hadPromptLabel: withoutPromptLabel !== withoutMarkdownPrefix,
451
+ };
452
+ }
453
+
454
+ function isQuestionPromptLine(line: string): boolean {
455
+ const candidate = promptLine(line);
456
+ if (!/[??]\s*$/.test(candidate.text)) return false;
457
+ return (
458
+ candidate.hadPromptLabel ||
459
+ QUESTION_PROMPT_RE.test(candidate.text) ||
460
+ USER_DIRECTED_PROMPT_RE.test(candidate.text)
461
+ );
462
+ }
463
+
464
+ function isResponseCueLine(line: string): boolean {
465
+ const candidate = promptLine(line)
466
+ .text.replace(/[.!?。!?]+$/, "")
467
+ .trim();
468
+ return USER_RESPONSE_CUE_RE.test(candidate);
469
+ }
470
+
471
+ function isAwaitingUserAnswer(message: AssistantMessage): boolean {
472
+ const text = assistantText(message);
473
+ if (!text) return false;
474
+ const lastLine = text.split(/\r?\n/).at(-1)?.trim();
475
+ return lastLine !== undefined && (isQuestionPromptLine(lastLine) || isResponseCueLine(lastLine));
476
+ }
419
477
  /** `customType` for the hidden mid-run todo nudge; `display: false`, so it reaches
420
478
  * the model but never renders in the TUI or transcript. */
421
479
  const MID_RUN_TODO_NUDGE_MESSAGE_TYPE = "mid-run-todo-nudge";
@@ -1507,6 +1565,21 @@ function isAdvisorCard(message: AgentMessage): message is CustomMessage {
1507
1565
  return message.role === "custom" && message.customType === "advisor";
1508
1566
  }
1509
1567
 
1568
+ function isTerminalTextAssistantAnswer(message: AgentMessage | undefined): message is AssistantMessage {
1569
+ if (message?.role !== "assistant" || message.stopReason !== "stop") return false;
1570
+ let hasText = false;
1571
+ for (const part of message.content) {
1572
+ if (part.type === "toolCall") return false;
1573
+ if (part.type === "text") {
1574
+ if (part.text.trim().length > 0) hasText = true;
1575
+ continue;
1576
+ }
1577
+ if (part.type === "thinking" || part.type === "redactedThinking" || part.type === "fallback") continue;
1578
+ return false;
1579
+ }
1580
+ return hasText;
1581
+ }
1582
+
1510
1583
  /**
1511
1584
  * A queued message the user can restore to the editor / pull back as a draft.
1512
1585
  * Only genuinely user-authored messages qualify: plain user turns, or custom
@@ -1942,6 +2015,7 @@ export class AgentSession {
1942
2015
  // on `agent_end` can fire its next `prompt` before #promptWithMessage's finally
1943
2016
  #emptyStopRetryCount = 0;
1944
2017
  #unexpectedStopRetryCount = 0;
2018
+ #acceptTerminalEmptyStopForPrompt = false;
1945
2019
  #promptGeneration = 0;
1946
2020
  #pendingAgentEndEmit: AgentSessionEvent | undefined;
1947
2021
  #pendingContextSnapshot:
@@ -1982,6 +2056,7 @@ export class AgentSession {
1982
2056
  this.#emptyStopRetryCount = 0;
1983
2057
  this.#unexpectedStopRetryCount = 0;
1984
2058
  this.#yieldTerminationPending = false;
2059
+ this.#acceptTerminalEmptyStopForPrompt = false;
1985
2060
  }
1986
2061
 
1987
2062
  #acquirePowerAssertion(): void {
@@ -2518,7 +2593,7 @@ export class AgentSession {
2518
2593
  this.#advisorPrimaryTurnsCompleted++;
2519
2594
  if (this.#advisors.length > 0) {
2520
2595
  for (const a of this.#advisors) {
2521
- if (!a.runtime.disposed) a.runtime.onTurnEnd(messages);
2596
+ if (!a.runtime.disposed) a.runtime.onTurnEnd(messages, { willContinue: context?.willContinue });
2522
2597
  }
2523
2598
  const syncBacklog = this.settings.get("advisor.syncBacklog");
2524
2599
  if (syncBacklog !== "off") {
@@ -2862,6 +2937,14 @@ export class AgentSession {
2862
2937
 
2863
2938
  const names = config.tools === undefined ? ADVISOR_DEFAULT_TOOL_NAMES : new Set(config.tools);
2864
2939
  const tools = (this.#advisorTools ?? []).filter(t => names.has(t.name));
2940
+ const availableAdvisorToolNames = new Set<string>();
2941
+ availableAdvisorToolNames.add(adviseTool.name);
2942
+ for (const tool of tools) {
2943
+ availableAdvisorToolNames.add(tool.name);
2944
+ if (tool.customWireName !== undefined) availableAdvisorToolNames.add(tool.customWireName);
2945
+ }
2946
+ let quarantinedAdvisorOutput: string | undefined;
2947
+ let currentAdvisorInput = "";
2865
2948
 
2866
2949
  const primaryProviderSessionId = this.sessionId;
2867
2950
  const advisorSessionLabel = slug
@@ -2917,6 +3000,13 @@ export class AgentSession {
2917
3000
  onSseEvent: this.#onSseEvent,
2918
3001
  transformProviderContext: this.#transformProviderContext,
2919
3002
  intentTracing: false,
3003
+ transformAssistantMessage: message => {
3004
+ quarantinedAdvisorOutput = quarantineAdvisorUnsafeOutput(
3005
+ message,
3006
+ availableAdvisorToolNames,
3007
+ buildAdvisorQuarantineSourceText(currentAdvisorInput, advisorAgent.state.messages),
3008
+ );
3009
+ },
2920
3010
  telemetry: advisorTelemetry,
2921
3011
  serviceTier: undefined,
2922
3012
  serviceTierResolver: advisorServiceTierResolver,
@@ -2924,7 +3014,19 @@ export class AgentSession {
2924
3014
  advisorAgent.setDisableReasoning(shouldDisableReasoning(advisorThinkingLevel));
2925
3015
 
2926
3016
  const advisorAgentFacade: AdvisorAgent = {
2927
- prompt: input => advisorAgent.prompt(input),
3017
+ prompt: async input => {
3018
+ let quarantined: string | undefined;
3019
+ try {
3020
+ quarantinedAdvisorOutput = undefined;
3021
+ currentAdvisorInput = input;
3022
+ await advisorAgent.prompt(input);
3023
+ quarantined = quarantinedAdvisorOutput;
3024
+ } finally {
3025
+ quarantinedAdvisorOutput = undefined;
3026
+ currentAdvisorInput = "";
3027
+ }
3028
+ if (quarantined) throw new AdvisorOutputQuarantinedError(quarantined);
3029
+ },
2928
3030
  abort: reason => advisorAgent.abort(reason),
2929
3031
  reset: () => {
2930
3032
  advisorAgent.reset();
@@ -3034,18 +3136,33 @@ export class AgentSession {
3034
3136
  /**
3035
3137
  * Route one accepted advice note from `advisor` to the primary. Concern and
3036
3138
  * blocker interrupt the running agent through the steering channel; once the
3037
- * loop has yielded, `triggerTurn` resumes it. After a deliberate user interrupt
3038
- * auto-resume is suppressed while idle/unwinding (the note becomes a preserved
3039
- * card re-entering on resume); a live-streaming turn is steered in directly. A
3040
- * plain nit always rides the non-interrupting YieldQueue aside. Suppression by
3041
- * the per-advisor emission guard drops the note silently the model still saw
3042
- * `Recorded.`, so it isn't tempted to rephrase the same note past the dedupe.
3139
+ * loop has yielded, `triggerTurn` resumes it. If the loop already ended with a
3140
+ * terminal text answer and no queued work remains, the note is preserved as an
3141
+ * advisor card instead of waking a duplicate completion turn. After a deliberate
3142
+ * user interrupt auto-resume is suppressed while idle/unwinding (the note
3143
+ * becomes a preserved card re-entering on resume); a live-streaming turn is
3144
+ * steered in directly. A plain nit always rides the non-interrupting YieldQueue
3145
+ * aside. Suppression by the per-advisor emission guard drops the note silently —
3146
+ * the model still saw `Recorded.`, so it isn't tempted to rephrase the same note
3147
+ * past the dedupe.
3043
3148
  */
3149
+ #hasTerminalTextAnswerWithoutQueuedWork(): boolean {
3150
+ if (this.agent.hasQueuedMessages() || this.#pendingNextTurnMessages.length > 0) return false;
3151
+ const messages = this.agent.state.messages;
3152
+ let tail = messages.length - 1;
3153
+ while (tail >= 0 && isAdvisorCard(messages[tail])) tail--;
3154
+ return isTerminalTextAssistantAnswer(messages[tail]);
3155
+ }
3156
+
3044
3157
  #routeAdvice(advisor: ActiveAdvisor, note: string, severity?: AdvisorSeverity): void {
3045
3158
  if (!advisor.emissionGuard.accept(note)) {
3046
3159
  logger.debug("advisor advice suppressed by emission guard", { severity, advisor: advisor.name });
3047
3160
  return;
3048
3161
  }
3162
+ // When newer primary turns already arrived while the advisor model was
3163
+ // processing this batch, the advice was generated without seeing them.
3164
+ // Append a lightweight staleness caveat so the primary can weigh recency.
3165
+ const deliveredNote = annotateForStaleness(note, advisor.runtime.hasFreshBacklog);
3049
3166
  // The implicit single ("default") advisor stamps no source name, so its
3050
3167
  // agent-facing `<advisory>` bytes stay identical to the pre-multi-advisor path.
3051
3168
  const source = advisor.slug ? advisor.name : undefined;
@@ -3058,13 +3175,14 @@ export class AgentSession {
3058
3175
  // loop consumes a steer at its next boundary.
3059
3176
  streaming: this.agent.state.isStreaming,
3060
3177
  aborting: this.#abortInProgress,
3178
+ terminalAnswerNoQueuedWork: this.#hasTerminalTextAnswerWithoutQueuedWork(),
3061
3179
  interruptImmuneTurnActive: interrupting && this.#isAdvisorInterruptImmuneTurnActive(),
3062
3180
  });
3063
3181
  if (channel === "aside") {
3064
- this.yieldQueue.enqueue("advisor", { note, severity, advisor: source });
3182
+ this.yieldQueue.enqueue("advisor", { note: deliveredNote, severity, advisor: source });
3065
3183
  return;
3066
3184
  }
3067
- const notes: AdvisorNote[] = [{ note, severity, advisor: source }];
3185
+ const notes: AdvisorNote[] = [{ note: deliveredNote, severity, advisor: source }];
3068
3186
  const content = formatAdvisorBatchContent(notes);
3069
3187
  const details = { notes } satisfies AdvisorMessageDetails;
3070
3188
  if (channel === "preserve") {
@@ -4527,7 +4645,7 @@ export class AgentSession {
4527
4645
  await emitAgentEndNotification();
4528
4646
  return;
4529
4647
  }
4530
- const todoContinuationScheduled = await this.#checkTodoCompletion();
4648
+ const todoContinuationScheduled = await this.#checkTodoCompletion(msg);
4531
4649
  if (todoContinuationScheduled) {
4532
4650
  await emitAgentEndNotification();
4533
4651
  return;
@@ -7124,6 +7242,8 @@ export class AgentSession {
7124
7242
  abort: () => {
7125
7243
  this.agent.abort();
7126
7244
  },
7245
+ settings: this.settings,
7246
+ localProtocolOptions: this.#localProtocolOptions(),
7127
7247
  });
7128
7248
 
7129
7249
  for (const customTool of mcpTools) {
@@ -7668,6 +7788,30 @@ export class AgentSession {
7668
7788
  return this.#resolveRoleModelFull(role, this.#modelRegistry.getAvailable(), this.model);
7669
7789
  }
7670
7790
 
7791
+ /**
7792
+ * Resolve the explicit thinking suffix that should apply when a temporary
7793
+ * picker selects a model already assigned to a configured role.
7794
+ */
7795
+ resolveTemporaryModelThinkingLevel(model: Model): ConfiguredThinkingLevel | undefined {
7796
+ const availableModels = this.#modelRegistry.getAvailable();
7797
+ if (availableModels.length === 0) return undefined;
7798
+
7799
+ const matchPreferences = getModelMatchPreferences(this.settings);
7800
+ for (const role of getKnownRoleIds(this.settings)) {
7801
+ const roleValue = this.settings.getModelRole(role);
7802
+ if (!roleValue) continue;
7803
+
7804
+ const resolved = resolveModelRoleValue(roleValue, availableModels, {
7805
+ settings: this.settings,
7806
+ matchPreferences,
7807
+ });
7808
+ if (!resolved.explicitThinkingLevel || resolved.thinkingLevel === undefined || !resolved.model) continue;
7809
+ if (modelsAreEqual(resolved.model, model)) return resolved.thinkingLevel;
7810
+ }
7811
+
7812
+ return undefined;
7813
+ }
7814
+
7671
7815
  get promptTemplates(): ReadonlyArray<PromptTemplate> {
7672
7816
  return this.#promptTemplates;
7673
7817
  }
@@ -8172,6 +8316,7 @@ export class AgentSession {
8172
8316
  options?: Pick<PromptOptions, "toolChoice" | "images" | "skipCompactionCheck"> & {
8173
8317
  prependMessages?: AgentMessage[];
8174
8318
  skipPostPromptRecoveryWait?: boolean;
8319
+ acceptTerminalEmptyStop?: boolean;
8175
8320
  },
8176
8321
  ): Promise<void> {
8177
8322
  this.#beginInFlight();
@@ -8188,6 +8333,7 @@ export class AgentSession {
8188
8333
  this.#mutationsSinceLastTodoTouch = 0;
8189
8334
  this.#midRunNudgeCount = 0;
8190
8335
  this.#resetPromptMaintenanceState();
8336
+ this.#acceptTerminalEmptyStopForPrompt = options?.acceptTerminalEmptyStop === true;
8191
8337
 
8192
8338
  await this.#maybeRestoreRetryFallbackPrimary();
8193
8339
 
@@ -8729,12 +8875,21 @@ export class AgentSession {
8729
8875
  }
8730
8876
  }
8731
8877
 
8732
- async #promptAgentInitiatedMessage(message: CustomMessage): Promise<void> {
8878
+ async #promptAgentInitiatedMessage(
8879
+ message: CustomMessage,
8880
+ options?: { acceptTerminalEmptyStop?: boolean },
8881
+ ): Promise<void> {
8733
8882
  this.#beginInFlight();
8734
8883
  try {
8884
+ const acceptTerminalEmptyStop = options?.acceptTerminalEmptyStop === true;
8885
+ if (acceptTerminalEmptyStop) {
8886
+ this.#resetPromptMaintenanceState();
8887
+ }
8888
+ this.#acceptTerminalEmptyStopForPrompt = acceptTerminalEmptyStop;
8735
8889
  await this.agent.prompt(message);
8736
8890
  await this.#waitForPostPromptRecovery();
8737
8891
  } finally {
8892
+ this.#acceptTerminalEmptyStopForPrompt = false;
8738
8893
  this.#endInFlight();
8739
8894
  }
8740
8895
  }
@@ -8789,7 +8944,12 @@ export class AgentSession {
8789
8944
  */
8790
8945
  async sendCustomMessage<T = unknown>(
8791
8946
  message: CustomMessagePayload<T>,
8792
- options?: { triggerTurn?: boolean; deliverAs?: "steer" | "followUp" | "nextTurn"; queueChipText?: string },
8947
+ options?: {
8948
+ triggerTurn?: boolean;
8949
+ deliverAs?: "steer" | "followUp" | "nextTurn";
8950
+ queueChipText?: string;
8951
+ acceptTerminalEmptyStop?: boolean;
8952
+ },
8793
8953
  ): Promise<boolean> {
8794
8954
  const normalizedPayload = normalizeCustomMessagePayload<T>(message);
8795
8955
  const details =
@@ -8832,7 +8992,9 @@ export class AgentSession {
8832
8992
  this.#queueHiddenNextTurnMessage(normalizedAppMessage, false);
8833
8993
  return false;
8834
8994
  }
8835
- await this.#promptAgentInitiatedMessage(normalizedAppMessage);
8995
+ await this.#promptAgentInitiatedMessage(normalizedAppMessage, {
8996
+ acceptTerminalEmptyStop: options.acceptTerminalEmptyStop === true,
8997
+ });
8836
8998
  return true;
8837
8999
  }
8838
9000
  this.agent.appendMessage(normalizedAppMessage);
@@ -10269,24 +10431,37 @@ export class AgentSession {
10269
10431
 
10270
10432
  // Strategy honored on manual /compact too. Custom instructions (public
10271
10433
  // user focus OR internal plan-mode guidance) imply a directed LLM
10272
- // summary; a text-only model cannot read snapcompact frames. When
10273
- // snapcompact itself was requested, fail locally instead of silently
10274
- // converting the "no LLM call" path into a provider-backed summary.
10434
+ // summary; a text-only model cannot read snapcompact frames.
10275
10435
  const wantsSnapcompact =
10276
10436
  compactionPrep.kind !== "fromHook" &&
10277
10437
  effectiveSettings.strategy === "snapcompact" &&
10278
10438
  !customInstructions &&
10279
10439
  !options?.internalGuidance;
10280
- const snapcompactReady = wantsSnapcompact;
10440
+ // `/compact snapcompact` is an explicit no-LLM archive request: honor
10441
+ // its contract by failing locally rather than silently shipping the
10442
+ // transcript to a provider. The default-configured snapcompact
10443
+ // strategy, in contrast, falls back to LLM compaction (mirroring the
10444
+ // auto-compaction path) so a routine /compact still completes on a
10445
+ // text-only model (issue #5064).
10446
+ const explicitSnapcompact = compactMode?.name === "snapcompact";
10447
+ let snapcompactReady = wantsSnapcompact;
10281
10448
  const snapcompactShapeSetting = this.settings.get("snapcompact.shape");
10282
10449
  let snapcompactShape: snapcompact.Shape | undefined;
10283
10450
  if (wantsSnapcompact && !this.model.input.includes("image")) {
10451
+ if (explicitSnapcompact) {
10452
+ this.emitNotice(
10453
+ "warning",
10454
+ `snapcompact needs a vision-capable model (${this.model.id} is text-only)`,
10455
+ "compaction",
10456
+ );
10457
+ throw new Error(`snapcompact cannot run locally: ${this.model.id} is text-only.`);
10458
+ }
10284
10459
  this.emitNotice(
10285
10460
  "warning",
10286
- `snapcompact needs a vision-capable model (${this.model.id} is text-only)`,
10461
+ `snapcompact needs a vision-capable model (${this.model.id} is text-only); falling back to LLM compaction`,
10287
10462
  "compaction",
10288
10463
  );
10289
- throw new Error(`snapcompact cannot run locally: ${this.model.id} is text-only.`);
10464
+ snapcompactReady = false;
10290
10465
  } else if (snapcompactReady) {
10291
10466
  const text = snapcompact.serializeConversation(
10292
10467
  convertToLlm(preparation.messagesToSummarize.concat(preparation.turnPrefixMessages)),
@@ -11344,6 +11519,13 @@ export class AgentSession {
11344
11519
  return false;
11345
11520
  }
11346
11521
 
11522
+ if (this.#acceptTerminalEmptyStopForPrompt && assistantMessage.stopReason === "stop") {
11523
+ this.#acceptTerminalEmptyStopForPrompt = false;
11524
+ this.#discardAcceptedTerminalEmptyStop(assistantMessage);
11525
+ this.#emptyStopRetryCount = 0;
11526
+ return false;
11527
+ }
11528
+
11347
11529
  this.#emptyStopRetryCount++;
11348
11530
  if (this.#emptyStopRetryCount > EMPTY_STOP_MAX_RETRIES) {
11349
11531
  const attempts = this.#emptyStopRetryCount - 1;
@@ -11561,6 +11743,38 @@ export class AgentSession {
11561
11743
  this.agent.appendMessage(assistantMessage);
11562
11744
  }
11563
11745
 
11746
+ #discardAcceptedTerminalEmptyStop(assistantMessage: AssistantMessage): void {
11747
+ const branch = this.sessionManager.getBranch();
11748
+ const branchEntry = branch
11749
+ .slice()
11750
+ .reverse()
11751
+ .find(
11752
+ entry =>
11753
+ entry.type === "message" &&
11754
+ entry.message.role === "assistant" &&
11755
+ this.#isSameAssistantMessage(entry.message, assistantMessage),
11756
+ );
11757
+ const parentEntry =
11758
+ branchEntry?.parentId === null || branchEntry?.parentId === undefined
11759
+ ? undefined
11760
+ : branch.find(entry => entry.id === branchEntry.parentId);
11761
+ const prunePrompt = parentEntry?.type === "custom_message";
11762
+
11763
+ this.#removeAssistantMessageFromActiveContext(assistantMessage, "accepted-terminal-empty-stop");
11764
+ if (prunePrompt && this.agent.state.messages.at(-1)?.role === "custom") {
11765
+ this.agent.replaceMessages(this.agent.state.messages.slice(0, -1));
11766
+ }
11767
+
11768
+ if (!branchEntry) return;
11769
+ const targetParentId = prunePrompt ? parentEntry.parentId : branchEntry.parentId;
11770
+ if (targetParentId === null) {
11771
+ this.sessionManager.resetLeaf();
11772
+ } else {
11773
+ this.sessionManager.branch(targetParentId);
11774
+ }
11775
+ this.sessionManager.appendCustomEntry("accepted-terminal-empty-stop");
11776
+ }
11777
+
11564
11778
  /**
11565
11779
  * Drop an assistant turn from BOTH the live agent context and the persisted
11566
11780
  * session branch (reparenting the leaf to the turn's parent), so a discarded
@@ -11890,7 +12104,7 @@ export class AgentSession {
11890
12104
  /**
11891
12105
  * Check if agent stopped with incomplete todos and prompt to continue.
11892
12106
  */
11893
- async #checkTodoCompletion(): Promise<boolean> {
12107
+ async #checkTodoCompletion(message: AssistantMessage): Promise<boolean> {
11894
12108
  // Skip todo reminders when the most recent turn was driven by an explicit user force —
11895
12109
  // the user wanted exactly that tool, not a follow-up nag about incomplete todos.
11896
12110
  const lastServedLabel = this.#toolChoiceQueue.consumeLastServedLabel();
@@ -11955,6 +12169,13 @@ export class AgentSession {
11955
12169
  return false;
11956
12170
  }
11957
12171
 
12172
+ if (isAwaitingUserAnswer(message)) {
12173
+ logger.debug("Todo completion: assistant is waiting for user input; skipping reminder", {
12174
+ incomplete: incomplete.length,
12175
+ });
12176
+ return false;
12177
+ }
12178
+
11958
12179
  // Background async jobs (bash/task) owned by this agent re-wake the loop
11959
12180
  // when they complete: the result delivery enqueues an async-result
11960
12181
  // follow-up that continues the run, and todos are re-evaluated at that
@@ -13829,19 +14050,23 @@ export class AgentSession {
13829
14050
  }
13830
14051
 
13831
14052
  /**
13832
- * Retry an empty, reason-less provider abort: a turn that ended `aborted`
13833
- * with no content and the generic sentinel (bare `abort()`), but only while
13834
- * the session is neither aborting nor tearing down. A user/lifecycle abort
13835
- * (`#abortInProgress`), a dispose-driven abort (`#isDisposed`), or a
13836
- * session-induced streaming-edit guard abort (`#streamingEditAbortTriggered` —
13837
- * auto-generated-file guard or failed-patch preview) is deliberate and MUST
13838
- * settle the turn instead: routing it through retry would orphan
13839
- * `#retryPromise` on a continuation the guard skips (hanging the in-flight
13840
- * `prompt()`) or silently undo the guard's intended abort.
14053
+ * Retry an empty, reason-less provider abort: a turn with no content that
14054
+ * carries the generic sentinel (bare `abort()`), whether the provider
14055
+ * finalized it as `stopReason: "aborted"` or leaked it as `stopReason:
14056
+ * "error"` (a stalled/dropped stream reported as an error rather than an
14057
+ * abort — issue #5375). Only fires while the session is neither aborting nor
14058
+ * tearing down. A user/lifecycle abort (`#abortInProgress`), a dispose-driven
14059
+ * abort (`#isDisposed`), or a session-induced streaming-edit guard abort
14060
+ * (`#streamingEditAbortTriggered` auto-generated-file guard or failed-patch
14061
+ * preview) is deliberate and MUST settle the turn instead: routing it through
14062
+ * retry would orphan `#retryPromise` on a continuation the guard skips
14063
+ * (hanging the in-flight `prompt()`) or silently undo the guard's intended
14064
+ * abort. Deliberate user interrupts (`UserInterrupt`) and silent aborts carry
14065
+ * their own marker, not the generic sentinel, so they never match here.
13841
14066
  */
13842
14067
  #isRetryableReasonlessAbort(message: AssistantMessage): boolean {
13843
14068
  if (
13844
- message.stopReason !== "aborted" ||
14069
+ (message.stopReason !== "aborted" && message.stopReason !== "error") ||
13845
14070
  message.content.length !== 0 ||
13846
14071
  this.#abortInProgress ||
13847
14072
  this.#isDisposed ||
@@ -13851,7 +14076,7 @@ export class AgentSession {
13851
14076
  }
13852
14077
 
13853
14078
  const id = this.#classifyRetryMessage(message);
13854
- if (AIError.is(id, AIError.Flag.Abort)) return true;
14079
+ if (message.stopReason === "aborted" && AIError.is(id, AIError.Flag.Abort)) return true;
13855
14080
  if (!this.#isGenericAbortSentinel(message)) return false;
13856
14081
 
13857
14082
  message.errorId = AIError.create(AIError.Flag.Abort);
@@ -15521,7 +15746,7 @@ export class AgentSession {
15521
15746
  this.#rekeyHindsightMemoryForCurrentSessionId();
15522
15747
  this.#rekeyMnemopiMemoryForCurrentSessionId();
15523
15748
 
15524
- const sessionContext = this.buildDisplaySessionContext();
15749
+ let sessionContext = this.buildDisplaySessionContext();
15525
15750
  const didReloadConversationChange =
15526
15751
  previousSessionContext !== undefined &&
15527
15752
  this.#didSessionMessagesChange(previousSessionContext.messages, sessionContext.messages);
@@ -15579,6 +15804,20 @@ export class AgentSession {
15579
15804
  }
15580
15805
  }
15581
15806
 
15807
+ const model = this.model;
15808
+ if (model) {
15809
+ const interruptedTurnAbort = createInterruptedTurnAbortMessage(this.sessionManager.getBranch(), {
15810
+ api: model.api,
15811
+ provider: model.provider,
15812
+ model: model.id,
15813
+ });
15814
+ if (interruptedTurnAbort) {
15815
+ this.sessionManager.appendMessage(interruptedTurnAbort);
15816
+ sessionContext = this.buildDisplaySessionContext();
15817
+ this.agent.replaceMessages(sessionContext.messages);
15818
+ }
15819
+ }
15820
+
15582
15821
  const hasThinkingEntry = this.sessionManager.getBranch().some(entry => entry.type === "thinking_level_change");
15583
15822
  const hasServiceTierEntry = this.sessionManager
15584
15823
  .getBranch()
@@ -15993,7 +16232,7 @@ export class AgentSession {
15993
16232
  // User message: leaf = parent (null if root), text goes to editor
15994
16233
  newLeafId = targetEntry.parentId;
15995
16234
  editorText = this.#extractUserMessageText(targetEntry.message.content);
15996
- } else if (targetEntry.type === "custom_message") {
16235
+ } else if (targetEntry.type === "custom_message" && targetEntry.customType !== SKILL_PROMPT_MESSAGE_TYPE) {
15997
16236
  // Custom message: leaf = parent (null if root), text goes to editor
15998
16237
  newLeafId = targetEntry.parentId;
15999
16238
  editorText =
@@ -16004,7 +16243,10 @@ export class AgentSession {
16004
16243
  .map(c => c.text)
16005
16244
  .join("");
16006
16245
  } else {
16007
- // Non-user message: leaf = selected node
16246
+ // Non-user message (or a user-invoked skill-prompt injection): land the
16247
+ // leaf on the selected node so it stays on the active branch. Skill
16248
+ // prompts are custom_message entries but must not be re-editable — their
16249
+ // content is a large expanded body, not a user turn (issue #5374).
16008
16250
  newLeafId = targetId;
16009
16251
  }
16010
16252
 
@@ -16357,10 +16599,12 @@ export class AgentSession {
16357
16599
  }
16358
16600
 
16359
16601
  #ingestProviderUsageHeaders(response: ProviderResponseMetadata, model?: Model): void {
16360
- if (model?.provider !== "anthropic") return;
16361
- this.#modelRegistry.authStorage.ingestUsageHeaders("anthropic", response.headers, {
16602
+ const provider = model?.provider;
16603
+ if (!provider) return;
16604
+ // No-op for providers whose usage strategy lacks a header parser.
16605
+ this.#modelRegistry.authStorage.ingestUsageHeaders(provider, response.headers, {
16362
16606
  sessionId: this.agent.sessionId,
16363
- baseUrl: this.#modelRegistry.getProviderBaseUrl?.("anthropic"),
16607
+ baseUrl: this.#modelRegistry.getProviderBaseUrl?.(provider),
16364
16608
  });
16365
16609
  }
16366
16610