@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.
- package/CHANGELOG.md +81 -0
- package/dist/{CHANGELOG-s5t1xdmy.md → CHANGELOG-tt9k4jpr.md} +81 -0
- package/dist/cli.js +3724 -3705
- package/dist/docs-index.generated.txt +1 -1
- package/dist/types/config/model-discovery.d.ts +2 -0
- package/dist/types/config/model-registry.d.ts +6 -1
- package/dist/types/config/settings-schema.d.ts +10 -0
- package/dist/types/debug/report-bundle.d.ts +7 -2
- package/dist/types/extensibility/extensions/loader.d.ts +2 -0
- package/dist/types/extensibility/extensions/runner.d.ts +3 -3
- package/dist/types/extensibility/extensions/types.d.ts +2 -0
- package/dist/types/extensibility/legacy-pi-ai-shim.d.ts +4 -0
- package/dist/types/launch/presence.d.ts +11 -0
- package/dist/types/lsp/client.d.ts +22 -0
- package/dist/types/lsp/workspace-diagnostics.d.ts +2 -0
- package/dist/types/markit/converters/pdf/index.d.ts +2 -1
- package/dist/types/mcp/config.d.ts +3 -1
- package/dist/types/modes/components/tool-execution.d.ts +3 -1
- package/dist/types/registry/agent-lifecycle.d.ts +11 -0
- package/dist/types/registry/agent-registry.d.ts +4 -0
- package/dist/types/session/agent-session-types.d.ts +0 -2
- package/dist/types/session/agent-session.d.ts +20 -0
- package/dist/types/session/date-cwd-reminder.d.ts +22 -0
- package/dist/types/session/irc-bridge.d.ts +2 -0
- package/dist/types/session/messages.d.ts +4 -0
- package/dist/types/session/session-loader.d.ts +18 -11
- package/dist/types/session/session-maintenance.d.ts +6 -2
- package/dist/types/session/session-manager.d.ts +19 -3
- package/dist/types/session/session-tools.d.ts +0 -1
- package/dist/types/session/turn-recovery.d.ts +7 -7
- package/dist/types/tools/browser/attach.d.ts +16 -0
- package/dist/types/tools/browser/launch.d.ts +2 -2
- package/dist/types/tools/browser.d.ts +1 -1
- package/dist/types/tools/builtin-names.d.ts +1 -1
- package/dist/types/tools/hub/jobs.d.ts +6 -0
- package/dist/types/tools/hub/types.d.ts +6 -0
- package/dist/types/tools/read-pdf.d.ts +15 -0
- package/dist/types/utils/external-editor.d.ts +7 -0
- package/dist/types/utils/markit.d.ts +3 -4
- package/dist/types/vibe/runtime.d.ts +3 -3
- package/package.json +13 -16
- package/scripts/build-binary.ts +0 -2
- package/scripts/bundle-dist.ts +0 -1
- package/src/auto-thinking/classifier.ts +37 -23
- package/src/cli/models-cli.ts +1 -1
- package/src/cli/read-cli.ts +2 -0
- package/src/commit/analysis/conventional.ts +15 -9
- package/src/commit/analysis/summary.ts +15 -9
- package/src/commit/changelog/generate.ts +15 -9
- package/src/commit/map-reduce/map-phase.ts +3 -19
- package/src/commit/map-reduce/reduce-phase.ts +15 -9
- package/src/config/model-discovery.ts +3 -3
- package/src/config/model-registry.ts +29 -16
- package/src/config/settings-schema.ts +13 -0
- package/src/debug/report-bundle.ts +25 -59
- package/src/discovery/claude-plugins.ts +2 -1
- package/src/discovery/claude.ts +4 -2
- package/src/discovery/helpers.ts +5 -0
- package/src/edit/renderer.ts +48 -19
- package/src/extensibility/extensions/loader.ts +9 -5
- package/src/extensibility/extensions/runner.ts +124 -21
- package/src/extensibility/extensions/types.ts +2 -0
- package/src/extensibility/extensions/wrapper.ts +16 -10
- package/src/extensibility/legacy-pi-ai-shim.ts +4 -0
- package/src/extensibility/plugins/marketplace/manager.ts +15 -18
- package/src/irc/bus.ts +1 -1
- package/src/launch/broker.ts +8 -1
- package/src/launch/presence.ts +77 -1
- package/src/lsp/client.ts +35 -3
- package/src/lsp/clients/biome-client.ts +47 -93
- package/src/lsp/servers.ts +17 -11
- package/src/lsp/tool.ts +2 -4
- package/src/lsp/workspace-diagnostics.ts +24 -2
- package/src/markit/NOTICE +8 -8
- package/src/markit/converters/pdf/index.ts +14 -126
- package/src/mcp/client.ts +8 -9
- package/src/mcp/config.ts +50 -6
- package/src/memories/index.ts +29 -25
- package/src/mnemopi/backend.ts +13 -11
- package/src/modes/components/ask-dialog.ts +25 -5
- package/src/modes/components/tool-execution.ts +10 -6
- package/src/modes/components/welcome.ts +4 -1
- package/src/modes/controllers/extension-ui-controller.ts +52 -32
- package/src/modes/controllers/selector-controller.ts +6 -0
- package/src/modes/controllers/tan-command-controller.ts +1 -0
- package/src/modes/interactive-mode.ts +49 -10
- package/src/modes/rpc/rpc-client.ts +7 -0
- package/src/modes/theme/defaults/birch.json +2 -2
- package/src/modes/utils/hotkeys-markdown.ts +1 -1
- package/src/prompts/system/checkpoint-active-notice.md +5 -0
- package/src/prompts/system/date-cwd-reminder.md +3 -0
- package/src/prompts/system/project-prompt.md +0 -1
- package/src/prompts/system/rewind-report.md +1 -3
- package/src/prompts/tools/browser.md +1 -0
- package/src/prompts/tools/rewind.md +1 -13
- package/src/registry/agent-lifecycle.ts +34 -0
- package/src/registry/agent-registry.ts +27 -2
- package/src/registry/persisted-agents.ts +40 -20
- package/src/sdk.ts +32 -2
- package/src/session/agent-session-types.ts +0 -2
- package/src/session/agent-session.ts +178 -46
- package/src/session/date-cwd-reminder.ts +77 -0
- package/src/session/irc-bridge.ts +5 -0
- package/src/session/messages.ts +5 -1
- package/src/session/session-loader.ts +106 -64
- package/src/session/session-maintenance.ts +189 -115
- package/src/session/session-manager.ts +142 -35
- package/src/session/session-persistence.ts +4 -4
- package/src/session/session-storage.ts +18 -5
- package/src/session/session-tools.ts +5 -10
- package/src/session/turn-recovery.ts +102 -20
- package/src/session/unexpected-stop-classifier.ts +33 -21
- package/src/system-prompt.ts +0 -5
- package/src/task/executor.ts +4 -15
- package/src/task/persisted-revive.ts +3 -6
- package/src/tools/ask.ts +10 -3
- package/src/tools/browser/attach.ts +80 -25
- package/src/tools/browser/launch.ts +44 -15
- package/src/tools/browser/relay/daemon.ts +3 -7
- package/src/tools/browser/render.ts +1 -1
- package/src/tools/browser/shared-daemon.ts +3 -7
- package/src/tools/browser.ts +5 -5
- package/src/tools/builtin-names.ts +7 -3
- package/src/tools/checkpoint.ts +1 -7
- package/src/tools/hub/index.ts +1 -1
- package/src/tools/hub/jobs.ts +20 -3
- package/src/tools/hub/types.ts +6 -0
- package/src/tools/read-pdf.ts +135 -0
- package/src/tools/read.ts +63 -37
- package/src/tools/render-utils.ts +53 -21
- package/src/tools/think.ts +15 -3
- package/src/tts/speech-enhancer.ts +19 -14
- package/src/utils/commit-message-generator.ts +14 -12
- package/src/utils/external-editor.ts +24 -5
- package/src/utils/markit.ts +6 -44
- package/src/utils/title-generator.ts +23 -19
- package/src/vibe/runtime.ts +32 -17
- package/dist/types/markit/converters/pdf/columns.d.ts +0 -35
- package/dist/types/markit/converters/pdf/extract.d.ts +0 -10
- package/dist/types/markit/converters/pdf/grid.d.ts +0 -25
- package/dist/types/markit/converters/pdf/headers.d.ts +0 -24
- package/dist/types/markit/converters/pdf/render.d.ts +0 -24
- package/dist/types/markit/converters/pdf/types.d.ts +0 -75
- package/dist/types/tools/read-pdf-images.d.ts +0 -12
- package/dist/types/utils/mupdf-wasm-embed.d.ts +0 -1
- package/scripts/embed-mupdf-wasm.ts +0 -67
- package/src/markit/converters/pdf/columns.ts +0 -103
- package/src/markit/converters/pdf/extract.ts +0 -598
- package/src/markit/converters/pdf/grid.ts +0 -780
- package/src/markit/converters/pdf/headers.ts +0 -106
- package/src/markit/converters/pdf/render.ts +0 -501
- package/src/markit/converters/pdf/types.ts +0 -84
- package/src/tools/read-pdf-images.ts +0 -250
- 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<
|
|
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
|
|
678
|
-
|
|
679
|
-
|
|
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
|
|
723
|
+
await this.#dropAssistantTurnDurably(assistantMessage);
|
|
701
724
|
return "terminal";
|
|
702
725
|
}
|
|
703
|
-
|
|
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<
|
|
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):
|
|
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
|
|
1107
|
-
* have results. The failed assistant/tool-result pair
|
|
1108
|
-
* continuation cannot replay completed side effects;
|
|
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
|
-
|
|
1124
|
-
AIError.retriable(id)
|
|
1125
|
-
|
|
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
|
|
20
|
-
* `disableReasoning`
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
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
|
|
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 =
|
|
97
|
+
const maxTokens = ONLINE_REASONING_SAFE_MAX_TOKENS;
|
|
90
98
|
|
|
91
|
-
const response = await
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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") {
|
package/src/system-prompt.ts
CHANGED
|
@@ -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 ?? "") : "",
|
package/src/task/executor.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
159
|
-
//
|
|
160
|
-
|
|
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
|
-
|
|
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 &&
|
|
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
|
|
40
|
-
|
|
41
|
-
|
|
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}${
|
|
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
|
|
85
|
-
|
|
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
|
|
125
|
-
*
|
|
126
|
-
*
|
|
127
|
-
*
|
|
128
|
-
*
|
|
129
|
-
*
|
|
130
|
-
*
|
|
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
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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
|
|
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
|
-
|
|
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 —
|
|
434
|
-
*
|
|
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
|
|