@oh-my-pi/pi-coding-agent 17.3.3 → 17.3.5

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 (154) hide show
  1. package/CHANGELOG.md +81 -0
  2. package/dist/{CHANGELOG-s5t1xdmy.md → CHANGELOG-tt9k4jpr.md} +81 -0
  3. package/dist/cli.js +3724 -3705
  4. package/dist/docs-index.generated.txt +1 -1
  5. package/dist/types/config/model-discovery.d.ts +2 -0
  6. package/dist/types/config/model-registry.d.ts +6 -1
  7. package/dist/types/config/settings-schema.d.ts +10 -0
  8. package/dist/types/debug/report-bundle.d.ts +7 -2
  9. package/dist/types/extensibility/extensions/loader.d.ts +2 -0
  10. package/dist/types/extensibility/extensions/runner.d.ts +3 -3
  11. package/dist/types/extensibility/extensions/types.d.ts +2 -0
  12. package/dist/types/extensibility/legacy-pi-ai-shim.d.ts +4 -0
  13. package/dist/types/launch/presence.d.ts +11 -0
  14. package/dist/types/lsp/client.d.ts +22 -0
  15. package/dist/types/lsp/workspace-diagnostics.d.ts +2 -0
  16. package/dist/types/markit/converters/pdf/index.d.ts +2 -1
  17. package/dist/types/mcp/config.d.ts +3 -1
  18. package/dist/types/modes/components/tool-execution.d.ts +3 -1
  19. package/dist/types/registry/agent-lifecycle.d.ts +11 -0
  20. package/dist/types/registry/agent-registry.d.ts +4 -0
  21. package/dist/types/session/agent-session-types.d.ts +0 -2
  22. package/dist/types/session/agent-session.d.ts +20 -0
  23. package/dist/types/session/date-cwd-reminder.d.ts +22 -0
  24. package/dist/types/session/irc-bridge.d.ts +2 -0
  25. package/dist/types/session/messages.d.ts +4 -0
  26. package/dist/types/session/session-loader.d.ts +18 -11
  27. package/dist/types/session/session-maintenance.d.ts +6 -2
  28. package/dist/types/session/session-manager.d.ts +19 -3
  29. package/dist/types/session/session-tools.d.ts +0 -1
  30. package/dist/types/session/turn-recovery.d.ts +7 -7
  31. package/dist/types/tools/browser/attach.d.ts +16 -0
  32. package/dist/types/tools/browser/launch.d.ts +2 -2
  33. package/dist/types/tools/browser.d.ts +1 -1
  34. package/dist/types/tools/builtin-names.d.ts +1 -1
  35. package/dist/types/tools/hub/jobs.d.ts +6 -0
  36. package/dist/types/tools/hub/types.d.ts +6 -0
  37. package/dist/types/tools/read-pdf.d.ts +15 -0
  38. package/dist/types/utils/external-editor.d.ts +7 -0
  39. package/dist/types/utils/markit.d.ts +3 -4
  40. package/dist/types/vibe/runtime.d.ts +3 -3
  41. package/package.json +13 -16
  42. package/scripts/build-binary.ts +0 -2
  43. package/scripts/bundle-dist.ts +0 -1
  44. package/src/auto-thinking/classifier.ts +37 -23
  45. package/src/cli/models-cli.ts +1 -1
  46. package/src/cli/read-cli.ts +2 -0
  47. package/src/commit/analysis/conventional.ts +15 -9
  48. package/src/commit/analysis/summary.ts +15 -9
  49. package/src/commit/changelog/generate.ts +15 -9
  50. package/src/commit/map-reduce/map-phase.ts +3 -19
  51. package/src/commit/map-reduce/reduce-phase.ts +15 -9
  52. package/src/config/model-discovery.ts +3 -3
  53. package/src/config/model-registry.ts +29 -16
  54. package/src/config/settings-schema.ts +13 -0
  55. package/src/debug/report-bundle.ts +25 -59
  56. package/src/discovery/claude-plugins.ts +2 -1
  57. package/src/discovery/claude.ts +4 -2
  58. package/src/discovery/helpers.ts +5 -0
  59. package/src/edit/renderer.ts +48 -19
  60. package/src/extensibility/extensions/loader.ts +9 -5
  61. package/src/extensibility/extensions/runner.ts +124 -21
  62. package/src/extensibility/extensions/types.ts +2 -0
  63. package/src/extensibility/extensions/wrapper.ts +16 -10
  64. package/src/extensibility/legacy-pi-ai-shim.ts +4 -0
  65. package/src/extensibility/plugins/marketplace/manager.ts +15 -18
  66. package/src/irc/bus.ts +1 -1
  67. package/src/launch/broker.ts +8 -1
  68. package/src/launch/presence.ts +77 -1
  69. package/src/lsp/client.ts +35 -3
  70. package/src/lsp/clients/biome-client.ts +47 -93
  71. package/src/lsp/servers.ts +17 -11
  72. package/src/lsp/tool.ts +2 -4
  73. package/src/lsp/workspace-diagnostics.ts +24 -2
  74. package/src/markit/NOTICE +8 -8
  75. package/src/markit/converters/pdf/index.ts +14 -126
  76. package/src/mcp/client.ts +8 -9
  77. package/src/mcp/config.ts +50 -6
  78. package/src/memories/index.ts +29 -25
  79. package/src/mnemopi/backend.ts +13 -11
  80. package/src/modes/components/ask-dialog.ts +25 -5
  81. package/src/modes/components/tool-execution.ts +10 -6
  82. package/src/modes/components/welcome.ts +4 -1
  83. package/src/modes/controllers/extension-ui-controller.ts +52 -32
  84. package/src/modes/controllers/selector-controller.ts +6 -0
  85. package/src/modes/controllers/tan-command-controller.ts +1 -0
  86. package/src/modes/interactive-mode.ts +49 -10
  87. package/src/modes/rpc/rpc-client.ts +7 -0
  88. package/src/modes/theme/defaults/birch.json +2 -2
  89. package/src/modes/utils/hotkeys-markdown.ts +1 -1
  90. package/src/prompts/system/checkpoint-active-notice.md +5 -0
  91. package/src/prompts/system/date-cwd-reminder.md +3 -0
  92. package/src/prompts/system/project-prompt.md +0 -1
  93. package/src/prompts/system/rewind-report.md +1 -3
  94. package/src/prompts/tools/browser.md +1 -0
  95. package/src/prompts/tools/rewind.md +1 -13
  96. package/src/registry/agent-lifecycle.ts +34 -0
  97. package/src/registry/agent-registry.ts +27 -2
  98. package/src/registry/persisted-agents.ts +40 -20
  99. package/src/sdk.ts +32 -2
  100. package/src/session/agent-session-types.ts +0 -2
  101. package/src/session/agent-session.ts +178 -46
  102. package/src/session/date-cwd-reminder.ts +77 -0
  103. package/src/session/irc-bridge.ts +5 -0
  104. package/src/session/messages.ts +5 -1
  105. package/src/session/session-loader.ts +106 -64
  106. package/src/session/session-maintenance.ts +189 -115
  107. package/src/session/session-manager.ts +142 -35
  108. package/src/session/session-persistence.ts +4 -4
  109. package/src/session/session-storage.ts +18 -5
  110. package/src/session/session-tools.ts +5 -10
  111. package/src/session/turn-recovery.ts +102 -20
  112. package/src/session/unexpected-stop-classifier.ts +33 -21
  113. package/src/system-prompt.ts +0 -5
  114. package/src/task/executor.ts +4 -15
  115. package/src/task/persisted-revive.ts +3 -6
  116. package/src/tools/ask.ts +10 -3
  117. package/src/tools/browser/attach.ts +80 -25
  118. package/src/tools/browser/launch.ts +44 -15
  119. package/src/tools/browser/relay/daemon.ts +3 -7
  120. package/src/tools/browser/render.ts +1 -1
  121. package/src/tools/browser/shared-daemon.ts +3 -7
  122. package/src/tools/browser.ts +5 -5
  123. package/src/tools/builtin-names.ts +7 -3
  124. package/src/tools/checkpoint.ts +1 -7
  125. package/src/tools/hub/index.ts +1 -1
  126. package/src/tools/hub/jobs.ts +20 -3
  127. package/src/tools/hub/types.ts +6 -0
  128. package/src/tools/read-pdf.ts +135 -0
  129. package/src/tools/read.ts +63 -37
  130. package/src/tools/render-utils.ts +53 -21
  131. package/src/tools/think.ts +15 -3
  132. package/src/tts/speech-enhancer.ts +19 -14
  133. package/src/utils/commit-message-generator.ts +14 -12
  134. package/src/utils/external-editor.ts +24 -5
  135. package/src/utils/markit.ts +6 -44
  136. package/src/utils/title-generator.ts +23 -19
  137. package/src/vibe/runtime.ts +32 -17
  138. package/dist/types/markit/converters/pdf/columns.d.ts +0 -35
  139. package/dist/types/markit/converters/pdf/extract.d.ts +0 -10
  140. package/dist/types/markit/converters/pdf/grid.d.ts +0 -25
  141. package/dist/types/markit/converters/pdf/headers.d.ts +0 -24
  142. package/dist/types/markit/converters/pdf/render.d.ts +0 -24
  143. package/dist/types/markit/converters/pdf/types.d.ts +0 -75
  144. package/dist/types/tools/read-pdf-images.d.ts +0 -12
  145. package/dist/types/utils/mupdf-wasm-embed.d.ts +0 -1
  146. package/scripts/embed-mupdf-wasm.ts +0 -67
  147. package/src/markit/converters/pdf/columns.ts +0 -103
  148. package/src/markit/converters/pdf/extract.ts +0 -598
  149. package/src/markit/converters/pdf/grid.ts +0 -780
  150. package/src/markit/converters/pdf/headers.ts +0 -106
  151. package/src/markit/converters/pdf/render.ts +0 -501
  152. package/src/markit/converters/pdf/types.ts +0 -84
  153. package/src/tools/read-pdf-images.ts +0 -250
  154. package/src/utils/mupdf-wasm-embed.ts +0 -12
@@ -74,6 +74,11 @@ const EMPTY_STOP_MAX_RETRIES = 3;
74
74
  const SIBLING_UNBLOCK_BUFFER_MS = 1_000;
75
75
  const NON_WHITESPACE_RE = /\S/;
76
76
  const USAGE_PREFLIGHT_BLOCKED_PREFIX = "Usage preflight blocked:";
77
+ const STREAM_STALL_ERROR_RE = /stream stall/i;
78
+ const HTTP2_STREAM_RESET_ERROR_RE =
79
+ /stream closed with error code\s+nghttp2_(?:internal_error|refused_stream)|nghttp2_(?:internal_error|refused_stream)|HTTP2(?:StreamReset|RefusedStream)/i;
80
+ const IMMUTABLE_ANTHROPIC_THINKING_ERROR_PATTERN =
81
+ /messages\.\d+\.content\.\d+.*\b(?:thinking|redacted_thinking)\b.*\blatest assistant message cannot be modified\b/is;
77
82
 
78
83
  function hasNonWhitespace(value: string): boolean {
79
84
  return NON_WHITESPACE_RE.test(value);
@@ -404,8 +409,8 @@ export class TurnRecovery {
404
409
  return this.#handleUnexpectedAssistantStop(message);
405
410
  }
406
411
 
407
- /** Removes a persisted failed assistant turn after its persistence slot settles. */
408
- dropPersistedAssistantTurn(message: AssistantMessage): Promise<void> {
412
+ /** Removes a persisted failed assistant turn after its persistence slot settles; returns the dropped branch entry id. */
413
+ dropPersistedAssistantTurn(message: AssistantMessage): Promise<string | undefined> {
409
414
  return this.#dropPersistedAssistantTurn(message);
410
415
  }
411
416
 
@@ -674,15 +679,33 @@ export class TurnRecovery {
674
679
  this.#emptyStopRetryCount++;
675
680
  if (this.#emptyStopRetryCount > EMPTY_STOP_MAX_RETRIES) {
676
681
  const attempts = this.#emptyStopRetryCount - 1;
677
- const finalError = providerEmptyOutput
678
- ? "Assistant returned no final output after retry cap; try switching models"
679
- : "Assistant returned empty stop after retry cap; try switching models or `/shake images` to remove archived frames";
682
+ const outputTokens = assistantMessage.usage.output;
683
+ const outputTokensExcludingKnownReasoning = Math.max(
684
+ 0,
685
+ outputTokens - (assistantMessage.usage.reasoningTokens ?? 0),
686
+ );
687
+ let finalError: string;
688
+ if (providerEmptyOutput) {
689
+ finalError = "Assistant returned no final output after retry cap; try switching models";
690
+ } else if (outputTokensExcludingKnownReasoning > 0 && assistantMessage.content.length === 0) {
691
+ // Billed non-reasoning output on a truly zero-block stop means content was
692
+ // generated and then dropped downstream (a filter/refusal flattened to
693
+ // `finish_reason: "stop"` by a proxy, or a lossy API translation) — the
694
+ // context/`/shake images` hint is wrong here, so name the billed output
695
+ // instead. Known reasoning-only usage is not evidence that deliverable
696
+ // content was dropped, and thinking-only stops retain a thinking block.
697
+ finalError = `Assistant returned an empty stop after retry cap, but the provider billed ${outputTokens} output token${outputTokens === 1 ? "" : "s"} for it; content was generated and then dropped before delivery, which usually points to a provider-side content filter or a lossy API translation rather than a context problem`;
698
+ } else {
699
+ finalError =
700
+ "Assistant returned empty stop after retry cap; try switching models or `/shake images` to remove archived frames";
701
+ }
680
702
  assistantMessage.errorMessage = finalError;
681
703
  if (providerEmptyOutput) assistantMessage.errorId = AIError.create();
682
704
  logger.warn(finalError, {
683
705
  attempts,
684
706
  model: assistantMessage.model,
685
707
  provider: assistantMessage.provider,
708
+ outputTokens,
686
709
  });
687
710
  await this.#host.emitSessionEvent({
688
711
  type: "auto_retry_end",
@@ -697,10 +720,14 @@ export class TurnRecovery {
697
720
  // provider usage can anchor the next prompt at the full failed-request size
698
721
  // and re-trigger compaction at the same boundary. Remove every capped
699
722
  // empty output; toolUse orphans still need this for Anthropic history.
700
- await this.dropPersistedAssistantTurn(assistantMessage);
723
+ await this.#dropAssistantTurnDurably(assistantMessage);
701
724
  return "terminal";
702
725
  }
703
- this.discardAssistantTurn(assistantMessage);
726
+ // The reparented leaf must be durably persisted before the retry continues:
727
+ // the loader rebuilds the active branch from the last physical entry, so an
728
+ // in-memory-only reparent lets the empty stop resurface on reload or after
729
+ // a mid-retry process kill.
730
+ await this.#dropAssistantTurnDurably(assistantMessage);
704
731
  this.#host.agent.appendMessage({
705
732
  role: "developer",
706
733
  content: [{ type: "text", text: this.#emptyStopRetryReminder() }],
@@ -823,9 +850,19 @@ export class TurnRecovery {
823
850
  * replays the failed turn, while no-recovery paths leave the persisted entry
824
851
  * (and the user-visible transcript line) in place.
825
852
  */
826
- async #dropPersistedAssistantTurn(assistantMessage: AssistantMessage): Promise<void> {
853
+ async #dropPersistedAssistantTurn(assistantMessage: AssistantMessage): Promise<string | undefined> {
827
854
  await this.#host.waitForSessionMessagePersistence(assistantMessage);
828
- this.discardAssistantTurn(assistantMessage);
855
+ return this.discardAssistantTurn(assistantMessage);
856
+ }
857
+
858
+ /**
859
+ * Drop the failed turn from active context and the persisted branch, then
860
+ * durably persist the reparented leaf so the empty stop cannot resurface on
861
+ * reload or after a mid-retry process kill.
862
+ */
863
+ async #dropAssistantTurnDurably(assistantMessage: AssistantMessage): Promise<void> {
864
+ const droppedEntryId = await this.#dropPersistedAssistantTurn(assistantMessage);
865
+ if (droppedEntryId) await this.#host.sessionManager.discardEntryDurably(droppedEntryId);
829
866
  }
830
867
 
831
868
  /**
@@ -917,7 +954,7 @@ export class TurnRecovery {
917
954
  * the Gemini header-runaway interrupt, which must not replay a partial,
918
955
  * loop-fueling thinking block.
919
956
  */
920
- discardAssistantTurn(assistantMessage: AssistantMessage): void {
957
+ discardAssistantTurn(assistantMessage: AssistantMessage): string | undefined {
921
958
  this.removeAssistantMessageFromActiveContext(assistantMessage);
922
959
 
923
960
  const branch = this.#host.sessionManager.getBranch();
@@ -939,7 +976,7 @@ export class TurnRecovery {
939
976
  this.#isSameAssistantMessage(entry.message as AssistantMessage, assistantMessage),
940
977
  );
941
978
  if (!branchEntry) {
942
- return;
979
+ return undefined;
943
980
  }
944
981
  this.#host.withBashBranchTransition(() => {
945
982
  if (branchEntry.parentId === null) {
@@ -948,6 +985,7 @@ export class TurnRecovery {
948
985
  this.#host.sessionManager.branch(branchEntry.parentId);
949
986
  }
950
987
  });
988
+ return branchEntry.id;
951
989
  }
952
990
 
953
991
  #isSameAssistantMessage(left: AssistantMessage, right: AssistantMessage): boolean {
@@ -1030,6 +1068,14 @@ export class TurnRecovery {
1030
1068
  isRetryableError(message: AssistantMessage): boolean {
1031
1069
  if (message.stopReason !== "error") return false;
1032
1070
  if (this.#isUsagePreflightBlocked(message)) return false;
1071
+ const model = this.#host.model();
1072
+ const immutableAnthropicThinkingError =
1073
+ model?.api === "anthropic-messages" &&
1074
+ (message.errorStatus === 400 ||
1075
+ message.errorId === 400 ||
1076
+ message.errorMessage?.startsWith("400 ") === true) &&
1077
+ IMMUTABLE_ANTHROPIC_THINKING_ERROR_PATTERN.test(message.errorMessage ?? "");
1078
+ if (immutableAnthropicThinkingError) return false;
1033
1079
 
1034
1080
  const id = this.#classifyRetryMessage(message);
1035
1081
  // Context overflow is handled by compaction, not retry.
@@ -1103,10 +1149,10 @@ export class TurnRecovery {
1103
1149
  }
1104
1150
 
1105
1151
  /**
1106
- * Classify a reasonless abort or stream stall whose emitted tool calls all
1107
- * have results. The failed assistant/tool-result pair stays in context so
1108
- * continuation cannot replay completed side effects; synthetic results tell
1109
- * the next turn that an unexecuted call must be reissued.
1152
+ * Classify a reasonless abort, idle stream stall, or HTTP/2 stream reset whose
1153
+ * emitted tool calls all have results. The failed assistant/tool-result pair
1154
+ * stays in context so continuation cannot replay completed side effects;
1155
+ * synthetic results tell the next turn that an unexecuted call must be reissued.
1110
1156
  */
1111
1157
  classifyResolvedInterruptedToolTurn(message: AssistantMessage): "reasonless-abort" | "stream-stall" | undefined {
1112
1158
  const id = this.#classifyRetryMessage(message);
@@ -1118,20 +1164,28 @@ export class TurnRecovery {
1118
1164
  !this.#host.isDisposed() &&
1119
1165
  !this.#host.streamingEditAbortTriggered() &&
1120
1166
  ((message.stopReason === "aborted" && AIError.is(id, AIError.Flag.Abort)) || genericAbort);
1167
+ const errorMessage = message.errorMessage ?? "";
1121
1168
  const streamStall =
1169
+ message.stopReason === "error" && STREAM_STALL_ERROR_RE.test(errorMessage) && AIError.retriable(id);
1170
+ const transportReset =
1122
1171
  message.stopReason === "error" &&
1123
- message.errorMessage?.toLowerCase().includes("stream stall") === true &&
1124
- AIError.retriable(id);
1125
- if (!reasonlessAbort && !streamStall) return undefined;
1172
+ HTTP2_STREAM_RESET_ERROR_RE.test(errorMessage) &&
1173
+ AIError.retriable(id) &&
1174
+ !this.#host.abortInProgress() &&
1175
+ !this.#host.isDisposed() &&
1176
+ !this.#host.streamingEditAbortTriggered();
1177
+ if (!reasonlessAbort && !streamStall && !transportReset) return undefined;
1126
1178
  if (reasonlessAbort && genericAbort) message.errorId = AIError.create(AIError.Flag.Abort);
1127
1179
 
1128
- // The Cursor server-execution marker gate applies only to the stream-stall
1180
+ // The Cursor server-execution marker gate applies only to the idle stream-stall
1129
1181
  // path: an unmarked/unresolved Cursor block there means the server has not
1130
1182
  // finished executing, so resuming would race it. A reasonless abort instead
1131
1183
  // ends the turn and the agent loop pairs every un-run call (Cursor's unmarked
1132
1184
  // `todo`/MCP blocks included) with a synthetic `executed: false` result, so
1133
1185
  // the tool-result reconciliation below is the safety gate and the marker is
1134
- // irrelevant.
1186
+ // irrelevant. An HTTP/2 RST_STREAM / NGHTTP2_* close also ends the Connect
1187
+ // stream, so there is no in-flight server exec to race — unmarked MCP/todo
1188
+ // blocks are safe to continue once every emitted call has a result.
1135
1189
  const resolvedToolCallIds: string[] = [];
1136
1190
  for (const block of message.content) {
1137
1191
  if (block.type !== "toolCall") continue;
@@ -1536,11 +1590,32 @@ export class TurnRecovery {
1536
1590
  if (!role) return false;
1537
1591
 
1538
1592
  const ceiling = this.#host.thinkingLevelCeiling();
1593
+ const latestAssistant = this.#host.agent.state.messages.findLast(
1594
+ (message): message is AssistantMessage => message.role === "assistant" && message !== failedMessage,
1595
+ );
1539
1596
  for (const selector of this.findRetryFallbackCandidates(role, currentSelector)) {
1540
1597
  if (this.isRetryFallbackSelectorSuppressed(selector)) continue;
1541
1598
  const resolved = resolveModelOverride([selector.raw], this.#host.modelRegistry, this.#host.settings);
1542
1599
  const candidate = resolved.model ?? this.#host.modelRegistry.find(selector.provider, selector.id);
1543
1600
  if (!candidate) continue;
1601
+ // Anthropic signatures and redacted blocks are model-bound, while the
1602
+ // latest assistant response must remain byte-identical. A same-provider
1603
+ // model switch can satisfy neither constraint, so keep retrying the
1604
+ // source model or consider a later cross-provider candidate whose
1605
+ // message transform can safely demote the foreign thinking.
1606
+ if (
1607
+ candidate.api === "anthropic-messages" &&
1608
+ latestAssistant?.api === "anthropic-messages" &&
1609
+ latestAssistant.provider === candidate.provider &&
1610
+ latestAssistant.model !== candidate.id &&
1611
+ latestAssistant.content.some(
1612
+ block =>
1613
+ (block.type === "thinking" && Boolean(block.thinkingSignature?.trim())) ||
1614
+ block.type === "redactedThinking",
1615
+ )
1616
+ ) {
1617
+ continue;
1618
+ }
1544
1619
  // A candidate whose effort floor exceeds the per-spawn ceiling would be
1545
1620
  // clamped UP past the cap by its model floor — skip it entirely.
1546
1621
  if (ceiling !== undefined && !modelSupportsEffortCeiling(candidate, ceiling)) continue;
@@ -1603,6 +1678,13 @@ export class TurnRecovery {
1603
1678
  if (this.#isUsagePreflightBlocked(message)) return false;
1604
1679
  const model = this.#host.model();
1605
1680
  if (!model) return false;
1681
+ const immutableAnthropicThinkingError =
1682
+ model.api === "anthropic-messages" &&
1683
+ (message.errorStatus === 400 ||
1684
+ message.errorId === 400 ||
1685
+ message.errorMessage?.startsWith("400 ") === true) &&
1686
+ IMMUTABLE_ANTHROPIC_THINKING_ERROR_PATTERN.test(message.errorMessage ?? "");
1687
+ if (immutableAnthropicThinkingError) return false;
1606
1688
  const retrySettings = this.#host.settings.getGroup("retry");
1607
1689
  if (!retrySettings.enabled || !retrySettings.modelFallback) return false;
1608
1690
  if (this.isClassifierRefusal(message)) return false;
@@ -1,4 +1,4 @@
1
- import { type AssistantMessage, completeSimple } from "@oh-my-pi/pi-ai";
1
+ import { type AssistantMessage, completeSimple, retryTransientCompletion } from "@oh-my-pi/pi-ai";
2
2
  import { logger, prompt } from "@oh-my-pi/pi-utils";
3
3
 
4
4
  import type { ModelRegistry } from "../config/model-registry";
@@ -16,13 +16,21 @@ const CLASSIFIER_SYSTEM_PROMPT = prompt.render(unexpectedStopClassifierPrompt);
16
16
  */
17
17
  const ANSWER_MAX_TOKENS = 16;
18
18
  /**
19
- * Online classifier budget. Sized to survive backends that ignore
20
- * `disableReasoning` (e.g. Qwen3 via llama.cpp catalogued `reasoning: false`
21
- * but still emitting thinking): the yes/no keyword needs to land after any
22
- * unavoidable thinking preamble. `maxTokens` is a hard cap — non-thinking
23
- * completions still return in a single word (issue #4355).
19
+ * Online classifier budget. Sized against two independent constraints:
20
+ * - Backends that ignore `disableReasoning` still emit a thinking preamble
21
+ * (e.g. Qwen3 via llama.cpp catalogued `reasoning: false` but still thinking;
22
+ * Anthropic via LiteLLM/Vertex, whose `openai-completions` route downgrades a
23
+ * disabled request to the lowest reasoning effort instead of turning thinking
24
+ * off). The yes/no keyword must have room to land after that preamble
25
+ * (issue #4355).
26
+ * - Anthropic-dialect proxies reject `max_tokens <= thinking.budget_tokens`. The
27
+ * pinned lowest effort maps to at least Anthropic's 1024-token minimum budget,
28
+ * so the cap MUST comfortably exceed 1024 or the request 400s with
29
+ * `max_tokens must be greater than thinking.budget_tokens` (issue #8610).
30
+ * `maxTokens` is a hard cap — non-thinking completions still return in a single
31
+ * word.
24
32
  */
25
- const REASONING_SAFE_MAX_TOKENS = 1024;
33
+ const ONLINE_REASONING_SAFE_MAX_TOKENS = 4096;
26
34
 
27
35
  export interface ClassifyUnexpectedStopDeps {
28
36
  settings: Settings;
@@ -86,21 +94,25 @@ async function classifyOnline(text: string, deps: ClassifyUnexpectedStopDeps): P
86
94
  throw new Error(`unexpected-stop: no API key for ${model.provider}/${model.id}`);
87
95
  }
88
96
  const metadata = deps.metadataResolver?.(model.provider);
89
- const maxTokens = REASONING_SAFE_MAX_TOKENS;
97
+ const maxTokens = ONLINE_REASONING_SAFE_MAX_TOKENS;
90
98
 
91
- const response = await completeSimple(
92
- model,
93
- {
94
- systemPrompt: [CLASSIFIER_SYSTEM_PROMPT],
95
- messages: [{ role: "user", content: text, timestamp: Date.now() }],
96
- },
97
- {
98
- apiKey: deps.registry.resolver(model, deps.sessionId),
99
- maxTokens,
100
- disableReasoning: true,
101
- metadata,
102
- signal: deps.signal,
103
- },
99
+ const response = await retryTransientCompletion(
100
+ () =>
101
+ completeSimple(
102
+ model,
103
+ {
104
+ systemPrompt: [CLASSIFIER_SYSTEM_PROMPT],
105
+ messages: [{ role: "user", content: text, timestamp: Date.now() }],
106
+ },
107
+ {
108
+ apiKey: deps.registry.resolver(model, deps.sessionId),
109
+ maxTokens,
110
+ disableReasoning: true,
111
+ metadata,
112
+ signal: deps.signal,
113
+ },
114
+ ),
115
+ { signal: deps.signal },
104
116
  );
105
117
 
106
118
  if (response.stopReason === "error") {
@@ -27,7 +27,6 @@ import systemPromptTemplate from "./prompts/system/system-prompt.md" with { type
27
27
  import { normalizeConcurrencyLimit } from "./task/parallel";
28
28
  import { usesCodexTaskPrompt } from "./task/prompt-policy";
29
29
  import { type ActiveRepoContext, resolveActiveRepoContext } from "./utils/active-repo-context";
30
- import { formatLocalCalendarDate } from "./utils/local-date";
31
30
  import { normalizePromptPath } from "./utils/prompt-path";
32
31
  import { AGENTS_MD_LIMIT, buildWorkspaceTree, type WorkspaceTree } from "./workspace-tree";
33
32
 
@@ -784,8 +783,6 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}):
784
783
  }
785
784
  }
786
785
 
787
- const date = formatLocalCalendarDate();
788
- const dateTime = date;
789
786
  const promptCwd = normalizePromptPath(resolvedCwd);
790
787
  const activeRepoContextPrompt = renderActiveRepoContextPrompt(activeRepoContext);
791
788
 
@@ -870,8 +867,6 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}):
870
867
  skills: filteredSkills,
871
868
  rules: rules ?? [],
872
869
  alwaysApplyRules: injectedAlwaysApplyRules,
873
- date,
874
- dateTime,
875
870
  cwd: promptCwd,
876
871
  additionalWorkspaceRoots: additionalWorkspaceRoots.filter(d => path.resolve(d) !== path.resolve(resolvedCwd)),
877
872
  model: includeModelInPrompt ? (model ?? "") : "",
@@ -2803,19 +2803,6 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
2803
2803
  const progress = monitor.progress;
2804
2804
  let unsubscribe: (() => void) | null = null;
2805
2805
  let reviveSession: AgentReviver | null = null;
2806
- // Adopted (kept-alive) subagents flip registry status from session events on
2807
- // later turns: revive/wake → running, turn drained → idle. The subscription
2808
- // intentionally survives this run; a disposed session emits nothing, so it
2809
- // needs no teardown.
2810
- const installRegistryStatusSync = (target: AgentSession): void => {
2811
- target.subscribe(event => {
2812
- if (event.type === "agent_start") {
2813
- AgentRegistry.global().setStatus(id, "running", target);
2814
- } else if (event.type === "agent_end") {
2815
- AgentRegistry.global().setStatus(id, "idle", target);
2816
- }
2817
- });
2818
- };
2819
2806
  const installIrcWakeTurnMonitor = (target: AgentSession): void => {
2820
2807
  attachIrcWakeTurnMonitor(target, {
2821
2808
  id,
@@ -3162,7 +3149,9 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
3162
3149
  sessionCreatedAt = performance.now();
3163
3150
 
3164
3151
  monitor.setActiveSession(session);
3165
- installRegistryStatusSync(session);
3152
+ // Run-state notifications precede deferrable wire-level `agent_end`,
3153
+ // so adopted keep-alive lifecycle cannot get stuck during prompt unwind.
3154
+ AgentRegistry.global().syncSessionStatus(id, session);
3166
3155
  if (sessionFile !== null && worktree === undefined) {
3167
3156
  // Lifecycle reviver: park closed the JSONL writer, so reopening takes
3168
3157
  // the single-writer lock cleanly and restores the full message history
@@ -3178,7 +3167,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
3178
3167
  const { session: revived } = await createAgentSession(
3179
3168
  buildSubagentSessionOptions(reopened, expectedAgentRef),
3180
3169
  );
3181
- installRegistryStatusSync(revived);
3170
+ AgentRegistry.global().syncSessionStatus(id, revived);
3182
3171
  installIrcWakeTurnMonitor(revived);
3183
3172
  return revived;
3184
3173
  };
@@ -155,12 +155,9 @@ export function createPersistedSubagentReviverFactory(
155
155
  await session.setActiveToolsByName([...init.tools, ...session.getMountedXdevToolNames()]);
156
156
  // Cold revives must drive registry status themselves — createAgentSession
157
157
  // doesn't wire this generically (the live path does it in the executor).
158
- // Without it the idle-TTL timer never clears on a turn and the lifecycle
159
- // could park the agent mid-run.
160
- session.subscribe(event => {
161
- if (event.type === "agent_start") registry.setStatus(ref.id, "running", session);
162
- else if (event.type === "agent_end") registry.setStatus(ref.id, "idle", session);
163
- });
158
+ // The internal run-state signal precedes deferrable public `agent_end`,
159
+ // keeping idle-TTL ownership synchronized even while prompts unwind.
160
+ registry.syncSessionStatus(ref.id, session);
164
161
  // Persisted files predate an agent-source field, so cold-revived frames
165
162
  // report the runtime-neutral `user` source; name comes from the ref.
166
163
  const wakeAgent: AgentDefinition = {
package/src/tools/ask.ts CHANGED
@@ -716,7 +716,7 @@ function formatQuestionResult(result: QuestionResult): string {
716
716
  ? `${result.id}: [${result.selectedOptions.join(", ")}]${suffix}`
717
717
  : `${result.id}: ${result.selectedOptions[0]}${suffix}`;
718
718
  }
719
- return `${result.id}: (cancelled)${noteSuffix}`;
719
+ return result.multi ? `${result.id}: []${noteSuffix}` : `${result.id}: (cancelled)${noteSuffix}`;
720
720
  }
721
721
 
722
722
  function formatSingleQuestionResponse(result: {
@@ -753,7 +753,8 @@ function formatSingleQuestionResponse(result: {
753
753
  : `User added note: ${result.note}`,
754
754
  );
755
755
  }
756
- return responseParts.length > 0 ? responseParts.join("\n") : "User cancelled the selection";
756
+ if (responseParts.length > 0) return responseParts.join("\n");
757
+ return result.multi ? "User did not select any options" : "User cancelled the selection";
757
758
  }
758
759
 
759
760
  // =============================================================================
@@ -949,9 +950,15 @@ export class AskTool implements AgentTool<typeof askSchema, AskToolDetails> {
949
950
  }
950
951
  if (params.questions.length === 1) {
951
952
  const result = results[0];
953
+ // An empty multi-select submission is a valid "select none"
954
+ // answer (#8265 review); only a truly empty single-select
955
+ // result counts as cancellation.
952
956
  if (
953
957
  !result ||
954
- (!result.timedOut && result.selectedOptions.length === 0 && result.customInput === undefined)
958
+ (!result.timedOut &&
959
+ !result.multi &&
960
+ result.selectedOptions.length === 0 &&
961
+ result.customInput === undefined)
955
962
  ) {
956
963
  context.abort();
957
964
  throw new ToolAbortError("Ask tool was cancelled by the user");
@@ -1,5 +1,6 @@
1
1
  import * as net from "node:net";
2
2
  import { Process, ProcessStatus } from "@oh-my-pi/pi-natives";
3
+ import type { Socket } from "bun";
3
4
  import type { Browser, Page } from "puppeteer-core";
4
5
  import { ToolError, throwIfAborted } from "../tool-errors";
5
6
 
@@ -29,31 +30,92 @@ export async function findFreeCdpPort(): Promise<number> {
29
30
  return promise;
30
31
  }
31
32
 
33
+ /**
34
+ * Loopback HTTP/1.1 GET that never routes through a proxy, resolving to the
35
+ * response status code (or null when the endpoint is unreachable, aborted,
36
+ * malformed, or slow past `timeoutMs`).
37
+ *
38
+ * Chrome's DevTools endpoint listens on loopback and speaks plain HTTP/1.1.
39
+ * Both `fetch` and Bun's `node:http` honor `HTTP_PROXY`/`HTTPS_PROXY` and
40
+ * forward even `127.0.0.1` requests to the proxy unless `NO_PROXY` covers them,
41
+ * so a local proxy that 502s internal addresses makes a healthy daemon look
42
+ * dead and the CDP readiness checks tear it down (issue #8567). Talking to the
43
+ * socket over raw TCP sidesteps proxy env entirely.
44
+ */
45
+ export async function probeCdpStatus(
46
+ url: string,
47
+ opts: { timeoutMs: number; signal?: AbortSignal },
48
+ ): Promise<number | null> {
49
+ let target: URL;
50
+ try {
51
+ target = new URL(url);
52
+ } catch {
53
+ return null;
54
+ }
55
+ if (opts.signal?.aborted) return null;
56
+ const port = target.port ? Number(target.port) : 80;
57
+ const requestPath = `${target.pathname}${target.search}` || "/";
58
+ const { promise, resolve } = Promise.withResolvers<number | null>();
59
+ let socket: Socket<undefined> | undefined;
60
+ let settled = false;
61
+ const finish = (status: number | null) => {
62
+ if (settled) return;
63
+ settled = true;
64
+ clearTimeout(timer);
65
+ opts.signal?.removeEventListener("abort", onAbort);
66
+ try {
67
+ socket?.end();
68
+ } catch {
69
+ // socket already torn down
70
+ }
71
+ resolve(status);
72
+ };
73
+ const onAbort = () => finish(null);
74
+ const timer = setTimeout(() => finish(null), opts.timeoutMs);
75
+ opts.signal?.addEventListener("abort", onAbort, { once: true });
76
+ let buffered = "";
77
+ try {
78
+ socket = await Bun.connect({
79
+ hostname: target.hostname,
80
+ port,
81
+ socket: {
82
+ open(s) {
83
+ s.write(`GET ${requestPath} HTTP/1.1\r\nHost: ${target.hostname}:${port}\r\nConnection: close\r\n\r\n`);
84
+ },
85
+ data(_s, chunk) {
86
+ buffered += chunk.toString("latin1");
87
+ const match = /^HTTP\/\d(?:\.\d)? (\d{3})/.exec(buffered);
88
+ if (match) finish(Number(match[1]));
89
+ },
90
+ error() {
91
+ finish(null);
92
+ },
93
+ close() {
94
+ finish(null);
95
+ },
96
+ },
97
+ });
98
+ } catch {
99
+ finish(null);
100
+ }
101
+ return promise;
102
+ }
103
+
32
104
  /** Poll `${cdpUrl}/json/version` until it responds with 200, with abort + timeout support. */
33
105
  export async function waitForCdp(cdpUrl: string, timeoutMs: number, signal?: AbortSignal): Promise<void> {
34
106
  const deadline = Date.now() + timeoutMs;
35
- let lastErr: unknown;
36
107
  const probeUrl = `${cdpUrl.replace(/\/+$/, "")}/json/version`;
108
+ let lastStatus: number | null = null;
37
109
  while (Date.now() < deadline) {
38
110
  throwIfAborted(signal);
39
- const probeTimeout = AbortSignal.timeout(2000);
40
- const probeSignal = signal ? AbortSignal.any([signal, probeTimeout]) : probeTimeout;
41
- try {
42
- const res = await fetch(probeUrl, { signal: probeSignal });
43
- if (res.ok) {
44
- await res.body?.cancel();
45
- return;
46
- }
47
- lastErr = new Error(`HTTP ${res.status}`);
48
- await res.body?.cancel();
49
- } catch (err) {
50
- if (signal?.aborted) throwIfAborted(signal);
51
- lastErr = err;
52
- }
111
+ const status = await probeCdpStatus(probeUrl, { timeoutMs: 2000, signal });
112
+ if (status !== null && status >= 200 && status < 300) return;
113
+ lastStatus = status;
53
114
  await Bun.sleep(150);
54
115
  }
116
+ throwIfAborted(signal);
55
117
  throw new ToolError(
56
- `Timed out waiting for CDP endpoint ${cdpUrl}${lastErr instanceof Error ? `: ${lastErr.message}` : ""}`,
118
+ `Timed out waiting for CDP endpoint ${cdpUrl}${lastStatus !== null ? `: HTTP ${lastStatus}` : ""}`,
57
119
  );
58
120
  }
59
121
 
@@ -81,15 +143,8 @@ function findCdpPortInArgs(args: string[]): number | null {
81
143
 
82
144
  /** One-shot probe: returns true when `/json/version` answers 200 within the timeout. */
83
145
  async function probeCdpAt(port: number, signal?: AbortSignal): Promise<boolean> {
84
- const probeTimeout = AbortSignal.timeout(1500);
85
- const probeSignal = signal ? AbortSignal.any([signal, probeTimeout]) : probeTimeout;
86
- try {
87
- const res = await fetch(`http://127.0.0.1:${port}/json/version`, { signal: probeSignal });
88
- await res.body?.cancel();
89
- return res.ok;
90
- } catch {
91
- return false;
92
- }
146
+ const status = await probeCdpStatus(`http://127.0.0.1:${port}/json/version`, { timeoutMs: 1500, signal });
147
+ return status !== null && status >= 200 && status < 300;
93
148
  }
94
149
 
95
150
  /**
@@ -121,23 +121,36 @@ async function loadBrowsers(): Promise<typeof BrowsersNs> {
121
121
  }
122
122
 
123
123
  /**
124
- * Resolve the Chromium executable puppeteer will launch, honoring
125
- * PUPPETEER_EXECUTABLE_PATH before system browser detection and lazily
126
- * downloading Chromium otherwise. The browser is cached under
127
- * ~/.omp/puppeteer (getPuppeteerDir). Returns undefined when platform
128
- * detection fails (puppeteer default resolution takes over). Exported so
129
- * real-browser tests can probe launchability and skip on hosts missing
130
- * Chrome's system libraries.
124
+ * Resolve the Chromium executable puppeteer will launch.
125
+ *
126
+ * `PUPPETEER_EXECUTABLE_PATH` always wins. On macOS the isolated Chrome for
127
+ * Testing binary is preferred over a detected system Chrome: a headless
128
+ * daemon launched from a system `Google Chrome.app` bundle shares its
129
+ * LaunchServices bundle identity (`com.google.Chrome`), so macOS can deliver
130
+ * the user's open-URL Apple Events to the daemon and silently swallow their
131
+ * link clicks (#8673). Chrome for Testing uses a dedicated bundle id
132
+ * (`com.google.chrome.for.testing`) that is never a user's default handler;
133
+ * system Chrome is used on macOS only when Chrome for Testing cannot be
134
+ * obtained. Other platforms keep the download-avoiding system Chrome
135
+ * preference and fall back to Chrome for Testing. The managed browser is
136
+ * cached under ~/.omp/puppeteer (getPuppeteerDir). Returns undefined when
137
+ * platform detection fails (puppeteer default resolution takes over).
138
+ * Exported so real-browser tests can probe launchability and skip on hosts
139
+ * missing Chrome's system libraries.
131
140
  */
132
141
  let chromiumExecutablePromise: Promise<string | undefined> | undefined;
133
142
  export async function ensureChromiumExecutable(): Promise<string | undefined> {
134
143
  const envPath = process.env.PUPPETEER_EXECUTABLE_PATH;
135
144
  if (envPath) return envPath;
136
- const sysChrome = await resolveSystemChromium();
137
- if (sysChrome) return sysChrome;
138
- if (chromiumExecutablePromise) return chromiumExecutablePromise;
145
+ // macOS: never route a background daemon through the user's GUI Chrome
146
+ // bundle; prefer the isolated Chrome for Testing binary instead (#8673).
147
+ const preferManagedChromium = process.platform === "darwin";
148
+ if (!preferManagedChromium) {
149
+ const sysChrome = await resolveSystemChromium();
150
+ if (sysChrome) return sysChrome;
151
+ }
139
152
 
140
- chromiumExecutablePromise = (async () => {
153
+ chromiumExecutablePromise ??= (async () => {
141
154
  const browsers = await loadBrowsers();
142
155
  const platform = browsers.detectBrowserPlatform();
143
156
  if (!platform) {
@@ -185,7 +198,23 @@ export async function ensureChromiumExecutable(): Promise<string | undefined> {
185
198
  "Set PUPPETEER_EXECUTABLE_PATH to use an existing Chrome/Chromium binary, or install one manually.",
186
199
  );
187
200
  });
188
- return chromiumExecutablePromise;
201
+
202
+ try {
203
+ return await chromiumExecutablePromise;
204
+ } catch (err) {
205
+ if (!preferManagedChromium) throw err;
206
+ // Chrome for Testing could not be obtained on macOS; degrade to the
207
+ // system Chrome bundle rather than leaving the browser tool unusable.
208
+ const sysChrome = await resolveSystemChromium();
209
+ if (!sysChrome) throw err;
210
+ logger.warn(
211
+ "Chrome for Testing unavailable; falling back to the system Chrome bundle. On macOS this can let the " +
212
+ "headless browser daemon capture your link clicks (#8673). Set PUPPETEER_EXECUTABLE_PATH to a " +
213
+ "dedicated Chromium to avoid this.",
214
+ { path: sysChrome, error: (err as Error).message },
215
+ );
216
+ return sysChrome;
217
+ }
189
218
  }
190
219
 
191
220
  let resolvedChromium: string | null | undefined; // undefined = unchecked; null = not found
@@ -430,8 +459,8 @@ export interface SharedBrowserLaunchSpec {
430
459
  * Resolve the executable and complete argv for a shared Chromium the daemon
431
460
  * broker spawns directly (no puppeteer inside the broker). Mirrors
432
461
  * `launchHeadlessBrowser` flag assembly — puppeteer's default args minus the
433
- * stealth-suppressed set — plus `--remote-debugging-port=0` so every client
434
- * attaches over CDP. Returns null when no Chromium executable resolves;
462
+ * stealth-suppressed set — suppresses Puppeteer's unowned startup window, and
463
+ * exposes CDP on an ephemeral port. Returns null when no executable resolves;
435
464
  * callers fall back to a process-local launch.
436
465
  */
437
466
  export async function resolveSharedBrowserLaunchSpec(opts: {
@@ -451,7 +480,7 @@ export async function resolveSharedBrowserLaunchSpec(opts: {
451
480
  });
452
481
  return {
453
482
  executablePath,
454
- args: [...defaults.filter(arg => !ignored.has(arg)), "--remote-debugging-port=0"],
483
+ args: [...defaults.filter(arg => !ignored.has(arg)), "--no-startup-window", "--remote-debugging-port=0"],
455
484
  };
456
485
  }
457
486