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

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 (121) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/dist/cli.js +3336 -3318
  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/eval/__tests__/process-entry-import.test.d.ts +1 -0
  14. package/dist/types/eval/bridge-timeout.d.ts +9 -1
  15. package/dist/types/eval/js/context-manager.d.ts +5 -3
  16. package/dist/types/eval/js/process-entry.d.ts +6 -0
  17. package/dist/types/eval/js/worker-core.d.ts +15 -1
  18. package/dist/types/eval/py/spawn-options.d.ts +10 -0
  19. package/dist/types/eval/py/tool-bridge.d.ts +1 -0
  20. package/dist/types/extensibility/custom-tools/types.d.ts +3 -0
  21. package/dist/types/extensibility/extensions/runner.d.ts +3 -1
  22. package/dist/types/extensibility/extensions/types.d.ts +3 -0
  23. package/dist/types/internal-urls/memory-protocol.d.ts +6 -7
  24. package/dist/types/main.d.ts +1 -0
  25. package/dist/types/modes/components/transcript-container.d.ts +3 -2
  26. package/dist/types/modes/magic-keyword-boundary.d.ts +9 -0
  27. package/dist/types/modes/orchestrate.d.ts +1 -1
  28. package/dist/types/modes/rpc/host-tools.d.ts +2 -0
  29. package/dist/types/modes/rpc/rpc-mode.d.ts +26 -6
  30. package/dist/types/modes/ultrathink.d.ts +1 -1
  31. package/dist/types/modes/utils/transcript-render-helpers.d.ts +12 -0
  32. package/dist/types/modes/workflow.d.ts +1 -1
  33. package/dist/types/session/agent-session.d.ts +6 -0
  34. package/dist/types/session/exit-diagnostics.d.ts +11 -0
  35. package/dist/types/slash-commands/helpers/active-oauth-account.d.ts +11 -0
  36. package/dist/types/subprocess/worker-client.d.ts +6 -0
  37. package/dist/types/tools/bash-skill-urls.d.ts +1 -0
  38. package/dist/types/web/search/provider.d.ts +10 -3
  39. package/dist/types/web/search/providers/codex.d.ts +5 -4
  40. package/package.json +12 -12
  41. package/src/advisor/__tests__/advisor.test.ts +830 -42
  42. package/src/advisor/advise-tool.ts +17 -1
  43. package/src/advisor/runtime.ts +288 -67
  44. package/src/autolearn/controller.ts +15 -3
  45. package/src/cli/args.ts +12 -0
  46. package/src/cli/auth-broker-cli.ts +30 -11
  47. package/src/cli/auth-gateway-cli.ts +5 -1
  48. package/src/cli/dry-balance-cli.ts +14 -4
  49. package/src/cli/flag-tables.ts +21 -7
  50. package/src/cli/update-cli.ts +62 -11
  51. package/src/cli/usage-cli.ts +58 -5
  52. package/src/cli/usage-error.ts +7 -0
  53. package/src/cli.ts +23 -1
  54. package/src/commands/acp.ts +11 -2
  55. package/src/commands/launch.ts +12 -3
  56. package/src/commands/token.ts +3 -1
  57. package/src/config/api-key-resolver.ts +12 -3
  58. package/src/config/config-file.ts +30 -12
  59. package/src/config/model-registry.ts +7 -7
  60. package/src/config/model-resolver.ts +21 -7
  61. package/src/config/models-config.ts +1 -1
  62. package/src/eval/__tests__/agent-bridge.test.ts +19 -14
  63. package/src/eval/__tests__/bridge-timeout.test.ts +106 -0
  64. package/src/eval/__tests__/js-context-manager.test.ts +158 -1
  65. package/src/eval/__tests__/kernel-spawn.test.ts +12 -0
  66. package/src/eval/__tests__/process-entry-import.test.ts +27 -0
  67. package/src/eval/agent-bridge.ts +121 -116
  68. package/src/eval/bridge-timeout.ts +20 -2
  69. package/src/eval/executor-base.ts +85 -7
  70. package/src/eval/jl/kernel.ts +2 -1
  71. package/src/eval/js/context-manager.ts +109 -32
  72. package/src/eval/js/process-entry.ts +27 -0
  73. package/src/eval/js/shared/runtime.ts +1 -1
  74. package/src/eval/js/worker-core.ts +70 -9
  75. package/src/eval/js/worker-entry.ts +1 -1
  76. package/src/eval/py/kernel.ts +2 -1
  77. package/src/eval/py/spawn-options.ts +13 -0
  78. package/src/eval/py/tool-bridge.ts +13 -14
  79. package/src/eval/rb/kernel.ts +2 -1
  80. package/src/extensibility/custom-tools/types.ts +3 -0
  81. package/src/extensibility/extensions/runner.ts +3 -0
  82. package/src/extensibility/extensions/types.ts +3 -0
  83. package/src/extensibility/plugins/manager.ts +21 -0
  84. package/src/internal-urls/memory-protocol.ts +13 -9
  85. package/src/lsp/client.ts +7 -1
  86. package/src/main.ts +29 -0
  87. package/src/mcp/tool-bridge.ts +57 -6
  88. package/src/modes/components/chat-transcript-builder.ts +22 -1
  89. package/src/modes/components/status-line/component.ts +10 -1
  90. package/src/modes/components/transcript-container.ts +110 -7
  91. package/src/modes/controllers/command-controller.ts +12 -4
  92. package/src/modes/controllers/event-controller.ts +80 -15
  93. package/src/modes/controllers/selector-controller.ts +15 -3
  94. package/src/modes/magic-keyword-boundary.ts +23 -0
  95. package/src/modes/orchestrate.ts +6 -5
  96. package/src/modes/print-mode.ts +9 -0
  97. package/src/modes/rpc/host-tools.ts +15 -0
  98. package/src/modes/rpc/rpc-mode.ts +123 -48
  99. package/src/modes/ultrathink.ts +6 -5
  100. package/src/modes/utils/transcript-render-helpers.ts +54 -0
  101. package/src/modes/utils/ui-helpers.ts +27 -1
  102. package/src/modes/workflow.ts +6 -5
  103. package/src/prompts/advisor/system.md +1 -0
  104. package/src/sdk.ts +33 -3
  105. package/src/session/agent-session.ts +239 -17
  106. package/src/session/exit-diagnostics.ts +108 -0
  107. package/src/session/streaming-output.ts +40 -12
  108. package/src/slash-commands/helpers/active-oauth-account.ts +22 -2
  109. package/src/slash-commands/helpers/logout.ts +23 -3
  110. package/src/slash-commands/helpers/usage-report.ts +14 -2
  111. package/src/subprocess/worker-client.ts +9 -2
  112. package/src/task/executor.ts +8 -0
  113. package/src/task/render.test.ts +36 -0
  114. package/src/task/render.ts +55 -43
  115. package/src/tools/bash-skill-urls.ts +4 -1
  116. package/src/tools/bash.ts +1 -0
  117. package/src/tools/write.ts +82 -9
  118. package/src/tools/yield.ts +29 -1
  119. package/src/web/search/index.ts +39 -22
  120. package/src/web/search/provider.ts +33 -16
  121. package/src/web/search/providers/codex.ts +68 -21
@@ -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);
@@ -11344,6 +11506,13 @@ export class AgentSession {
11344
11506
  return false;
11345
11507
  }
11346
11508
 
11509
+ if (this.#acceptTerminalEmptyStopForPrompt && assistantMessage.stopReason === "stop") {
11510
+ this.#acceptTerminalEmptyStopForPrompt = false;
11511
+ this.#discardAcceptedTerminalEmptyStop(assistantMessage);
11512
+ this.#emptyStopRetryCount = 0;
11513
+ return false;
11514
+ }
11515
+
11347
11516
  this.#emptyStopRetryCount++;
11348
11517
  if (this.#emptyStopRetryCount > EMPTY_STOP_MAX_RETRIES) {
11349
11518
  const attempts = this.#emptyStopRetryCount - 1;
@@ -11561,6 +11730,38 @@ export class AgentSession {
11561
11730
  this.agent.appendMessage(assistantMessage);
11562
11731
  }
11563
11732
 
11733
+ #discardAcceptedTerminalEmptyStop(assistantMessage: AssistantMessage): void {
11734
+ const branch = this.sessionManager.getBranch();
11735
+ const branchEntry = branch
11736
+ .slice()
11737
+ .reverse()
11738
+ .find(
11739
+ entry =>
11740
+ entry.type === "message" &&
11741
+ entry.message.role === "assistant" &&
11742
+ this.#isSameAssistantMessage(entry.message, assistantMessage),
11743
+ );
11744
+ const parentEntry =
11745
+ branchEntry?.parentId === null || branchEntry?.parentId === undefined
11746
+ ? undefined
11747
+ : branch.find(entry => entry.id === branchEntry.parentId);
11748
+ const prunePrompt = parentEntry?.type === "custom_message";
11749
+
11750
+ this.#removeAssistantMessageFromActiveContext(assistantMessage, "accepted-terminal-empty-stop");
11751
+ if (prunePrompt && this.agent.state.messages.at(-1)?.role === "custom") {
11752
+ this.agent.replaceMessages(this.agent.state.messages.slice(0, -1));
11753
+ }
11754
+
11755
+ if (!branchEntry) return;
11756
+ const targetParentId = prunePrompt ? parentEntry.parentId : branchEntry.parentId;
11757
+ if (targetParentId === null) {
11758
+ this.sessionManager.resetLeaf();
11759
+ } else {
11760
+ this.sessionManager.branch(targetParentId);
11761
+ }
11762
+ this.sessionManager.appendCustomEntry("accepted-terminal-empty-stop");
11763
+ }
11764
+
11564
11765
  /**
11565
11766
  * Drop an assistant turn from BOTH the live agent context and the persisted
11566
11767
  * session branch (reparenting the leaf to the turn's parent), so a discarded
@@ -11890,7 +12091,7 @@ export class AgentSession {
11890
12091
  /**
11891
12092
  * Check if agent stopped with incomplete todos and prompt to continue.
11892
12093
  */
11893
- async #checkTodoCompletion(): Promise<boolean> {
12094
+ async #checkTodoCompletion(message: AssistantMessage): Promise<boolean> {
11894
12095
  // Skip todo reminders when the most recent turn was driven by an explicit user force —
11895
12096
  // the user wanted exactly that tool, not a follow-up nag about incomplete todos.
11896
12097
  const lastServedLabel = this.#toolChoiceQueue.consumeLastServedLabel();
@@ -11955,6 +12156,13 @@ export class AgentSession {
11955
12156
  return false;
11956
12157
  }
11957
12158
 
12159
+ if (isAwaitingUserAnswer(message)) {
12160
+ logger.debug("Todo completion: assistant is waiting for user input; skipping reminder", {
12161
+ incomplete: incomplete.length,
12162
+ });
12163
+ return false;
12164
+ }
12165
+
11958
12166
  // Background async jobs (bash/task) owned by this agent re-wake the loop
11959
12167
  // when they complete: the result delivery enqueues an async-result
11960
12168
  // follow-up that continues the run, and todos are re-evaluated at that
@@ -15521,7 +15729,7 @@ export class AgentSession {
15521
15729
  this.#rekeyHindsightMemoryForCurrentSessionId();
15522
15730
  this.#rekeyMnemopiMemoryForCurrentSessionId();
15523
15731
 
15524
- const sessionContext = this.buildDisplaySessionContext();
15732
+ let sessionContext = this.buildDisplaySessionContext();
15525
15733
  const didReloadConversationChange =
15526
15734
  previousSessionContext !== undefined &&
15527
15735
  this.#didSessionMessagesChange(previousSessionContext.messages, sessionContext.messages);
@@ -15579,6 +15787,20 @@ export class AgentSession {
15579
15787
  }
15580
15788
  }
15581
15789
 
15790
+ const model = this.model;
15791
+ if (model) {
15792
+ const interruptedTurnAbort = createInterruptedTurnAbortMessage(this.sessionManager.getBranch(), {
15793
+ api: model.api,
15794
+ provider: model.provider,
15795
+ model: model.id,
15796
+ });
15797
+ if (interruptedTurnAbort) {
15798
+ this.sessionManager.appendMessage(interruptedTurnAbort);
15799
+ sessionContext = this.buildDisplaySessionContext();
15800
+ this.agent.replaceMessages(sessionContext.messages);
15801
+ }
15802
+ }
15803
+
15582
15804
  const hasThinkingEntry = this.sessionManager.getBranch().some(entry => entry.type === "thinking_level_change");
15583
15805
  const hasServiceTierEntry = this.sessionManager
15584
15806
  .getBranch()
@@ -1,4 +1,5 @@
1
1
  import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
2
+ import type { AssistantMessage } from "@oh-my-pi/pi-ai";
2
3
  import type { SessionEntry } from "./session-entries";
3
4
 
4
5
  export const TOOL_EXECUTION_START_CUSTOM_TYPE = "tool_execution_start";
@@ -53,10 +54,117 @@ interface ToolCallContent {
53
54
  arguments?: unknown;
54
55
  }
55
56
 
57
+ export interface AssistantModelMetadata {
58
+ api: AssistantMessage["api"];
59
+ provider: string;
60
+ model: string;
61
+ }
62
+
56
63
  function isObject(value: unknown): value is Record<string, unknown> {
57
64
  if (typeof value !== "object") return false;
58
65
  return value !== null;
59
66
  }
67
+ function isPendingToolCallDiagnostic(value: unknown): value is PendingToolCallDiagnostic {
68
+ if (!isObject(value) || typeof value.toolName !== "string") return false;
69
+ if ("toolCallId" in value && typeof value.toolCallId !== "string") return false;
70
+ if ("intent" in value && typeof value.intent !== "string") return false;
71
+ if ("assistantTimestamp" in value && typeof value.assistantTimestamp !== "number") return false;
72
+ if ("startedAt" in value && typeof value.startedAt !== "string") return false;
73
+ return true;
74
+ }
75
+
76
+ function readPendingToolCalls(value: unknown): PendingToolCallDiagnostic[] | undefined {
77
+ if (!Array.isArray(value) || !value.every(isPendingToolCallDiagnostic)) return undefined;
78
+ return value;
79
+ }
80
+
81
+ function readSessionExit(entry: SessionEntry): SessionExitData | undefined {
82
+ if (entry.type !== "custom" || entry.customType !== SESSION_EXIT_CUSTOM_TYPE || !isObject(entry.data)) {
83
+ return undefined;
84
+ }
85
+ const { reason, kind, recordedAt } = entry.data;
86
+ if (
87
+ typeof reason !== "string" ||
88
+ (kind !== "normal" && kind !== "signal" && kind !== "fatal" && kind !== "process_exit") ||
89
+ typeof recordedAt !== "string"
90
+ ) {
91
+ return undefined;
92
+ }
93
+ return {
94
+ reason,
95
+ kind,
96
+ recordedAt,
97
+ pendingToolCalls: readPendingToolCalls(entry.data.pendingToolCalls),
98
+ };
99
+ }
100
+
101
+ /**
102
+ * createInterruptedTurnAbortMessage returns a terminal assistant record when
103
+ * the latest persisted process exit follows a non-terminal conversation tail.
104
+ */
105
+ export function createInterruptedTurnAbortMessage(
106
+ entries: readonly SessionEntry[],
107
+ fallbackModel?: AssistantModelMetadata,
108
+ ): AssistantMessage | undefined {
109
+ let exitIndex = -1;
110
+ let exit: SessionExitData | undefined;
111
+ for (let index = entries.length - 1; index >= 0; index--) {
112
+ const candidate = readSessionExit(entries[index]!);
113
+ if (!candidate) continue;
114
+ exitIndex = index;
115
+ exit = candidate;
116
+ break;
117
+ }
118
+ if (!exit || (exit.kind === "normal" && !exit.pendingToolCalls?.length)) return undefined;
119
+
120
+ let tailIndex = -1;
121
+ let tail: AgentMessage | undefined;
122
+ for (let index = entries.length - 1; index >= 0; index--) {
123
+ const entry = entries[index]!;
124
+ if (entry.type !== "message") continue;
125
+ tailIndex = index;
126
+ tail = entry.message;
127
+ break;
128
+ }
129
+ if (!tail || tailIndex > exitIndex) return undefined;
130
+ if (tail.role === "assistant" && !tail.content.some(isToolCallContent)) return undefined;
131
+
132
+ let previousAssistant: AssistantMessage | undefined;
133
+ for (let index = tailIndex; index >= 0; index--) {
134
+ const entry = entries[index]!;
135
+ if (entry.type !== "message" || entry.message.role !== "assistant") continue;
136
+ previousAssistant = entry.message;
137
+ break;
138
+ }
139
+ if (
140
+ tail.role === "toolResult" &&
141
+ (previousAssistant?.stopReason === "error" || previousAssistant?.stopReason === "aborted")
142
+ ) {
143
+ return undefined;
144
+ }
145
+ const model = previousAssistant ?? fallbackModel;
146
+ if (!model) return undefined;
147
+
148
+ const recordedAt = Date.parse(exit.recordedAt);
149
+ return {
150
+ role: "assistant",
151
+ content: [],
152
+ api: model.api,
153
+ provider: model.provider,
154
+ model: model.model,
155
+ usage: {
156
+ input: 0,
157
+ output: 0,
158
+ cacheRead: 0,
159
+ cacheWrite: 0,
160
+ totalTokens: 0,
161
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
162
+ },
163
+ stopReason: "aborted",
164
+ errorMessage: "Previous OMP process exited before completing the turn.",
165
+ timestamp: Number.isFinite(recordedAt) ? recordedAt : Date.now(),
166
+ };
167
+ }
60
168
 
61
169
  function isToolCallContent(value: unknown): value is ToolCallContent {
62
170
  if (!isObject(value)) return false;
@@ -735,6 +735,7 @@ export class OutputSink {
735
735
  #truncated = false;
736
736
  #lastChunkTime = 0;
737
737
  #pendingChunk = "";
738
+ #pendingChunkTimer: Timer | undefined;
738
739
 
739
740
  // Per-line column cap streaming state (persists across `push` calls so a
740
741
  // long line split across chunks still trips the same trigger).
@@ -806,20 +807,18 @@ export class OutputSink {
806
807
  push(chunk: string): void {
807
808
  chunk = sanitizeWithOptionalSixelPassthrough(chunk, sanitizeText);
808
809
 
809
- // Throttled onChunk: coalesce chunks arriving inside the throttle window
810
- // and flush the buffered concatenation on the next eligible tick (plus a
811
- // final flush in dump()) so the preview never has silent gaps.
810
+ // Throttled onChunk: coalesce chunks arriving inside the throttle window.
811
+ // A timer flushes quiet tails at the throttle boundary; dump() catches a
812
+ // final pending chunk when the process exits before that timer fires.
812
813
  // Live preview gets the raw (pre-cap) chunk so the TUI never lags behind
813
814
  // what reached the sink — the column cap is for the persisted LLM view.
814
815
  if (this.#onChunk) {
815
816
  const now = Date.now();
816
817
  if (now - this.#lastChunkTime >= this.#chunkThrottleMs) {
817
- this.#lastChunkTime = now;
818
- const merged = this.#pendingChunk + chunk;
819
- this.#pendingChunk = "";
820
- this.#onChunk(merged);
818
+ this.#emitPendingChunkWith(chunk, now);
821
819
  } else {
822
820
  this.#pendingChunk += chunk;
821
+ this.#schedulePendingChunkFlush();
823
822
  }
824
823
  }
825
824
 
@@ -1121,6 +1120,7 @@ export class OutputSink {
1121
1120
  * branch in `dump()` against stale totals.
1122
1121
  */
1123
1122
  replace(text: string): void {
1123
+ this.#clearPendingChunkTimer();
1124
1124
  this.#buffer = text;
1125
1125
  this.#bufferBytes = Buffer.byteLength(text, "utf-8");
1126
1126
  this.#head = "";
@@ -1138,6 +1138,38 @@ export class OutputSink {
1138
1138
  this.#pendingChunk = "";
1139
1139
  }
1140
1140
 
1141
+ #clearPendingChunkTimer(): void {
1142
+ if (!this.#pendingChunkTimer) return;
1143
+ clearTimeout(this.#pendingChunkTimer);
1144
+ this.#pendingChunkTimer = undefined;
1145
+ }
1146
+
1147
+ #emitPendingChunkWith(chunk: string, now: number): void {
1148
+ this.#clearPendingChunkTimer();
1149
+ this.#lastChunkTime = now;
1150
+ const merged = this.#pendingChunk + chunk;
1151
+ this.#pendingChunk = "";
1152
+ this.#onChunk?.(merged);
1153
+ }
1154
+
1155
+ #flushPendingChunk(): void {
1156
+ if (this.#pendingChunk.length === 0) {
1157
+ this.#clearPendingChunkTimer();
1158
+ return;
1159
+ }
1160
+ this.#emitPendingChunkWith("", Date.now());
1161
+ }
1162
+
1163
+ #schedulePendingChunkFlush(): void {
1164
+ if (this.#chunkThrottleMs <= 0 || this.#pendingChunkTimer) return;
1165
+ const elapsed = Date.now() - this.#lastChunkTime;
1166
+ const delay = Math.max(0, this.#chunkThrottleMs - elapsed);
1167
+ this.#pendingChunkTimer = setTimeout(() => {
1168
+ this.#pendingChunkTimer = undefined;
1169
+ this.#flushPendingChunk();
1170
+ }, delay);
1171
+ }
1172
+
1141
1173
  /**
1142
1174
  * Replay the rolling tail ring back into the artifact sink. When bytes
1143
1175
  * were actually dropped from the middle (the head budget was exhausted
@@ -1179,11 +1211,7 @@ export class OutputSink {
1179
1211
 
1180
1212
  // Flush any chunk still held back by the throttle so the live preview
1181
1213
  // ends with the complete stream.
1182
- if (this.#onChunk && this.#pendingChunk.length > 0) {
1183
- const pending = this.#pendingChunk;
1184
- this.#pendingChunk = "";
1185
- this.#onChunk(pending);
1186
- }
1214
+ this.#flushPendingChunk();
1187
1215
  const totalLines = this.#sawData ? this.#totalLines + 1 : 0;
1188
1216
 
1189
1217
  if (this.#file) {