@bitkyc08/opencodex 2.7.33 → 2.7.34

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 (64) hide show
  1. package/README.ja.md +1 -1
  2. package/README.ko.md +1 -1
  3. package/README.md +21 -10
  4. package/README.ru.md +1 -1
  5. package/README.zh-CN.md +1 -1
  6. package/gui/dist/assets/index-BkmJJgg6.js +52 -0
  7. package/gui/dist/assets/index-Sg-7L_oZ.css +1 -0
  8. package/gui/dist/index.html +2 -2
  9. package/package.json +1 -1
  10. package/src/adapters/anthropic.ts +13 -6
  11. package/src/adapters/cursor/discovery.ts +39 -4
  12. package/src/adapters/cursor/exec-policy.ts +11 -13
  13. package/src/adapters/cursor/live-transport.ts +22 -4
  14. package/src/adapters/cursor/protobuf-events.ts +140 -8
  15. package/src/adapters/cursor/protobuf-request.ts +15 -0
  16. package/src/adapters/cursor/request-builder.ts +10 -5
  17. package/src/adapters/cursor/transport.ts +3 -2
  18. package/src/adapters/cursor/types.ts +14 -0
  19. package/src/adapters/kiro-constants.ts +12 -0
  20. package/src/adapters/kiro-errors.ts +111 -2
  21. package/src/adapters/kiro-events.ts +154 -35
  22. package/src/adapters/kiro-retry.ts +116 -32
  23. package/src/adapters/kiro-tools.ts +30 -20
  24. package/src/adapters/kiro-wire.ts +47 -6
  25. package/src/adapters/kiro.ts +891 -228
  26. package/src/adapters/openai-chat.ts +12 -5
  27. package/src/adapters/openai-responses.ts +7 -2
  28. package/src/bridge.ts +109 -26
  29. package/src/claude/outbound.ts +27 -4
  30. package/src/cli/index.ts +1 -1
  31. package/src/codex/catalog.ts +375 -33
  32. package/src/combos/index.ts +3 -0
  33. package/src/combos/request.ts +4 -4
  34. package/src/combos/resolve.ts +2 -2
  35. package/src/combos/types.ts +104 -2
  36. package/src/config.ts +70 -1
  37. package/src/lib/eventstream-decoder.ts +9 -0
  38. package/src/oauth/index.ts +3 -1
  39. package/src/oauth/kiro-credentials.ts +48 -20
  40. package/src/oauth/login-cli.ts +2 -0
  41. package/src/providers/derive.ts +8 -0
  42. package/src/providers/kiro-models.ts +2 -2
  43. package/src/providers/openai-sidecar.ts +28 -1
  44. package/src/providers/registry.ts +39 -2
  45. package/src/responses/parser.ts +22 -10
  46. package/src/responses/schema.ts +1 -0
  47. package/src/responses/state.ts +50 -10
  48. package/src/router.ts +15 -3
  49. package/src/server/auth-cors.ts +7 -0
  50. package/src/server/claude-messages.ts +6 -0
  51. package/src/server/index.ts +6 -3
  52. package/src/server/management-api.ts +187 -43
  53. package/src/server/ports.ts +4 -2
  54. package/src/server/request-log.ts +3 -2
  55. package/src/server/responses-item-id-repair.ts +281 -0
  56. package/src/server/responses.ts +274 -73
  57. package/src/types.ts +109 -16
  58. package/src/update/job.ts +81 -1
  59. package/src/vision/describe.ts +2 -1
  60. package/src/web-search/executor.ts +2 -1
  61. package/src/web-search/loop.ts +9 -1
  62. package/src/web-search/progress-stream.ts +12 -10
  63. package/gui/dist/assets/index-D6Fcl4yM.css +0 -1
  64. package/gui/dist/assets/index-d63HMU0x.js +0 -52
package/src/types.ts CHANGED
@@ -9,6 +9,8 @@ export interface OcxParsedRequest {
9
9
  _previousResponseInputExpanded?: boolean;
10
10
  /** Provider-private stable Cursor conversation id resolved from the Responses previous_response_id chain. */
11
11
  _cursorConversationId?: string;
12
+ /** Provider-private continuation metadata resolved from the Responses previous_response_id chain. */
13
+ _providerContinuation?: OcxProviderContinuationState;
12
14
  /**
13
15
  * The hosted `{type:"web_search", ...}` tool config, stashed when Codex enables web search. Routed
14
16
  * (non-OpenAI) providers can't run it server-side, so the proxy re-exposes it as a function tool and
@@ -28,6 +30,12 @@ export interface OcxParsedRequest {
28
30
  * (see src/responses/compaction.ts).
29
31
  */
30
32
  _compactionRequest?: boolean;
33
+ /**
34
+ * True when the current request newly introduced a stored compaction summary/marker. Historical
35
+ * markers restored by previous_response_id expansion were already acknowledged and do not reset
36
+ * provider-private continuation caches again on every later turn.
37
+ */
38
+ _contextCompactionBoundary?: boolean;
31
39
  }
32
40
 
33
41
  export interface OcxContext {
@@ -51,6 +59,8 @@ export interface OcxUserMessage {
51
59
  export interface OcxAssistantMessage {
52
60
  role: "assistant";
53
61
  content: OcxAssistantContentPart[];
62
+ /** Responses message phase, preserved when replaying translated provider output. */
63
+ phase?: OcxMessagePhase;
54
64
  model?: string;
55
65
  timestamp: number;
56
66
  }
@@ -69,6 +79,8 @@ export interface OcxToolResultMessage {
69
79
  toolNamespace?: string;
70
80
  /** Text, or content parts when a tool (e.g. Codex view_image) returns an image in its output. */
71
81
  content: string | OcxContentPart[];
82
+ /** True when the Responses result contained opaque encrypted output Kiro cannot translate. */
83
+ containsEncryptedContent?: boolean;
72
84
  isError: boolean;
73
85
  timestamp: number;
74
86
  }
@@ -191,9 +203,26 @@ export interface OcxRequestOptions {
191
203
  promptCacheKey?: string;
192
204
  }
193
205
 
206
+ export type OcxMessagePhase = "commentary" | "final_answer";
207
+
208
+ /**
209
+ * Provider-private state that must follow a locally expanded `previous_response_id` chain.
210
+ * Kept out of public Responses output and persisted only in the bounded local continuation cache.
211
+ */
212
+ export interface OcxProviderContinuationState {
213
+ cursor?: {
214
+ conversationId?: string;
215
+ checkpointUsable?: boolean;
216
+ };
217
+ kiro?: {
218
+ conversationId?: string;
219
+ };
220
+ [provider: string]: Record<string, unknown> | undefined;
221
+ }
222
+
194
223
  export type AdapterEvent =
195
224
  | { type: "heartbeat" }
196
- | { type: "text_delta"; text: string }
225
+ | { type: "text_delta"; text: string; phase?: OcxMessagePhase }
197
226
  | { type: "thinking_delta"; thinking: string }
198
227
  // Anthropic extended-thinking round-trip: signature_delta for the current thinking block, and
199
228
  // opaque redacted_thinking blocks. Both must be replayed verbatim or tool-use turns 400.
@@ -211,10 +240,35 @@ export type AdapterEvent =
211
240
  // the SAME output index, so the activity animates instead of flashing completed instantly.
212
241
  | { type: "web_search_call_begin"; id: string }
213
242
  | { type: "web_search_call_end"; id: string; queries: string[]; status?: "completed" | "failed"; sources?: OcxUrlCitation[] }
214
- | { type: "done"; usage?: OcxUsage; stopReason?: string }
243
+ | {
244
+ type: "done";
245
+ usage?: OcxUsage;
246
+ stopReason?: string;
247
+ endTurn?: boolean;
248
+ providerState?: OcxProviderContinuationState;
249
+ }
250
+ | {
251
+ type: "incomplete";
252
+ reason: string;
253
+ message?: string;
254
+ usage?: OcxUsage;
255
+ retryable?: boolean;
256
+ endTurn?: boolean;
257
+ providerState?: OcxProviderContinuationState;
258
+ }
215
259
  // `usage` carries best-effort partial consumption when a turn dies before a clean done
216
260
  // (e.g. cursor upstream 502 mid-stream), so failed requests can log real token counts.
217
- | { type: "error"; message: string; usage?: OcxUsage };
261
+ | {
262
+ type: "error";
263
+ message: string;
264
+ usage?: OcxUsage;
265
+ /** Authoritative upstream/proxy status when known; avoids message-based classification. */
266
+ status?: number;
267
+ /** Responses error type and code when the adapter has a structured provider failure. */
268
+ errorType?: string;
269
+ code?: string;
270
+ retryable?: boolean;
271
+ };
218
272
 
219
273
  /**
220
274
  * A web source backing a search answer. Surfaced on the search-end event and rendered by the bridge
@@ -399,6 +453,11 @@ export interface OcxConfig {
399
453
  * the resolved sub-agent roster block ("" when nothing resolves).
400
454
  */
401
455
  injectionPrompt?: string;
456
+ /**
457
+ * Proxy-authored multi-agent developer guidance. Undefined/true = enabled for
458
+ * backward compatibility; false suppresses both v1 and v2 guidance injection.
459
+ */
460
+ multiAgentGuidanceEnabled?: boolean;
402
461
  /**
403
462
  * Global hard ceiling for the reasoning effort of EVERY proxied turn (main agent AND
404
463
  * sub-agents). Ladder value "low".."max"; incoming efforts ranking above it are rewritten
@@ -520,6 +579,12 @@ export interface OcxComboConfig {
520
579
  stickyLimit?: number;
521
580
  /** Used when the client omits reasoning.effort. null/omitted leaves the target default unchanged. */
522
581
  defaultEffort?: OcxComboDefaultEffort | null;
582
+ /**
583
+ * Optional public model name replacing the default `combo/<id>` slug. Bare names
584
+ * without "/" are allowed (e.g. "deepseek-v4-flash") so the combo can answer to a
585
+ * mandated model id; exact-match requests route here before any provider resolution.
586
+ */
587
+ alias?: string;
523
588
  }
524
589
 
525
590
  /**
@@ -614,9 +679,24 @@ export interface OpenRouterProviderRouting {
614
679
  allowFallbacks?: boolean;
615
680
  }
616
681
 
682
+ export interface ResponsesItemIdRepairConfig {
683
+ /** Exact `message` item ids that the proxy should rewrite to request-local canonical ids. */
684
+ message?: string[];
685
+ /** Exact `reasoning` item ids that the proxy should rewrite to request-local canonical ids. */
686
+ reasoning?: string[];
687
+ /** Backfill missing `output_item.done` / terminal snapshot ids from the matching output_index. */
688
+ repairMissingTerminalIds?: boolean;
689
+ }
690
+
617
691
  export interface OcxProviderConfig {
618
692
  adapter: string;
619
693
  baseUrl: string;
694
+ /**
695
+ * Optional relative resource path for key-auth openai-responses requests. Must start with `/`
696
+ * and must not include a URL scheme, query string, or fragment. When omitted, the adapter keeps
697
+ * the legacy `/v1/responses` construction.
698
+ */
699
+ responsesPath?: string;
620
700
  /**
621
701
  * Explicit opt-in for non-registry private-network destinations such as localhost, RFC1918,
622
702
  * link-local, or unique-local upstreams. Metadata endpoints remain blocked.
@@ -661,6 +741,13 @@ export interface OcxProviderConfig {
661
741
  modelInputModalities?: Record<string, string[]>;
662
742
  /** Model-specific max input token limits. Values cap auto_compact_token_limit. */
663
743
  modelMaxInputTokens?: Record<string, number>;
744
+ /**
745
+ * Provider-wide fallback for chat-completions `max_tokens` when the caller omits
746
+ * Responses `max_output_tokens`. Adapters still let an explicit request win.
747
+ */
748
+ defaultMaxOutputTokens?: number;
749
+ /** Model-specific fallback output token budgets. Exact/model-pattern entries beat the provider default. */
750
+ modelMaxOutputTokens?: Record<string, number>;
664
751
  headers?: Record<string, string>;
665
752
  /** Default provider-routing preferences for models sent through the canonical OpenRouter API. */
666
753
  openRouterRouting?: OpenRouterProviderRouting;
@@ -731,6 +818,12 @@ export interface OcxProviderConfig {
731
818
  * fields. Default off; only enable for providers that document this parameter.
732
819
  */
733
820
  promptCacheKey?: boolean;
821
+ /**
822
+ * Provider-local passthrough SSE repair for broken openai-responses gateways that reuse exact
823
+ * placeholder message/reasoning ids or omit the terminal id after a stable added event.
824
+ * Disabled by default; function_call ids and call_id pairing are never rewritten.
825
+ */
826
+ responsesItemIdRepair?: ResponsesItemIdRepairConfig;
734
827
  /** Model ids whose tool_choice only accepts `auto` or `none`; forced/named choices are downgraded. */
735
828
  autoToolChoiceOnlyModels?: string[];
736
829
  /** Model ids that expect prior assistant `reasoning_content` to be preserved in chat history. */
@@ -778,23 +871,23 @@ export interface OcxProviderConfig {
778
871
  desktopExecutor?: import("./adapters/cursor/native-exec-desktop").DesktopExecutorConfig;
779
872
  /**
780
873
  * Cursor adapter only: unsafe opt-in escape hatch for Cursor server-driven built-in local
781
- * read/write/delete/ls/grep/shell/fetch execution. Defaults to false so remote Cursor messages
782
- * cannot bypass Codex approval/sandbox semantics. Explicit MCP and desktop executors remain
783
- * controlled by their own opt-in config.
874
+ * read/write/delete/ls/grep/shell/fetch execution. Prefer `nativeLocalExec: "on"` for new
875
+ * configs; this legacy boolean remains a server-local explicit opt-in for existing operators.
876
+ * Defaults to false so remote Cursor messages cannot bypass Codex approval/sandbox semantics.
877
+ * Explicit MCP and desktop executors remain controlled by their own opt-in config.
784
878
  */
785
879
  unsafeAllowNativeLocalExec?: boolean;
786
880
  /**
787
881
  * Cursor adapter only: native local exec policy mode (exec-policy.ts).
788
- * "codex-sandbox" (default) allows server-driven local exec only when the
789
- * request's instructions/developer text declares the Codex danger-full-access
790
- * sandbox (approves the normal full-access flow, denies undeclared requests);
791
- * "off" rejects all server-driven local exec; "on" always allows (same as legacy
792
- * unsafeAllowNativeLocalExec:true). NOTE: the declaration is CALLER-CONTROLLED prose
793
- * the proxy cannot verify it. Enable "codex-sandbox" only where every client
794
- * that can reach the data plane is trusted: the default loopback bind admits
795
- * ANY process on this host without auth (including other local users on
796
- * multi-user machines), and isAllowedRequestOrigin blocks non-loopback
797
- * browser origins by default but not loopback-origin or origin-less callers.
882
+ * "off" (default) rejects server-driven local exec; "on" always allows it for this
883
+ * provider and should be used only for a trusted local experiment on a host where every
884
+ * data-plane caller is trusted. "codex-sandbox" is accepted for backwards compatibility
885
+ * but is fail-closed like "off": Responses instructions/system/developer text is
886
+ * caller-controlled prose, and opencodex has no trustworthy per-request attestation that it
887
+ * reflects a real Codex sandbox state. The default loopback bind admits ANY local process
888
+ * without auth (including other local users on multi-user machines), and
889
+ * isAllowedRequestOrigin blocks non-loopback browser origins by default but not
890
+ * loopback-origin or origin-less callers.
798
891
  */
799
892
  nativeLocalExec?: "off" | "codex-sandbox" | "on";
800
893
  }
package/src/update/job.ts CHANGED
@@ -5,6 +5,7 @@ import { fileURLToPath } from "node:url";
5
5
  import { atomicWriteFile, getConfigDir, loadConfig, readPid, readRuntimePort } from "../config";
6
6
  import { killProxy } from "../lib/process-control";
7
7
  import { waitForPortAvailable } from "../server/ports";
8
+ import { proxyIdentityAt } from "../server/proxy-liveness";
8
9
  import { isServiceInstalled } from "../service";
9
10
  import {
10
11
  type Channel,
@@ -24,6 +25,8 @@ const RELEASE_NOTES_URL = "https://github.com/lidge-jun/opencodex/releases/lates
24
25
  const UPDATE_JOB_FILENAME = "update-job.json";
25
26
  const UPDATE_TIMEOUT_MS = 180_000;
26
27
  const RESTART_TIMEOUT_MS = 60_000;
28
+ const RESTART_HEALTH_TIMEOUT_MS = 15_000;
29
+ const RESTART_STABILITY_WINDOW_MS = 15_000;
27
30
 
28
31
  export type UpdateJobStatus = "running" | "restarting" | "succeeded" | "failed";
29
32
 
@@ -291,6 +294,9 @@ export interface RestartIo {
291
294
  waitForPort?: typeof waitForPortAvailable;
292
295
  spawnStart?: (job: UpdateJobState, installer: Installer, port?: number) => void;
293
296
  serviceInstalledFn?: () => boolean;
297
+ probeProxy?: (port: number, hostname?: string) => Promise<boolean>;
298
+ sleepMs?: (ms: number) => Promise<void>;
299
+ now?: () => number;
294
300
  /** Service-mode install/reinstall command (defaults to spawnSync via runLoggedCommand). */
295
301
  runService?: (
296
302
  job: UpdateJobState,
@@ -377,6 +383,79 @@ export function restartAfterUpdateForTests(
377
383
  return restartAfterUpdate(job, captured, io);
378
384
  }
379
385
 
386
+ function restartFailureHint(port: number): string {
387
+ return `Update installed, but the restarted proxy did not stay healthy on port ${port}. `
388
+ + "Try 'ocx start'. If the update log shows bun postinstall or EPERM warnings, "
389
+ + "reinstall with 'npm install -g --allow-scripts=bun @bitkyc08/opencodex'.";
390
+ }
391
+
392
+ /**
393
+ * Confirm that the detached/service restart really came back and stayed up. The GUI worker
394
+ * used to mark success immediately after spawning the new process, which hid Windows cases
395
+ * where npm left the bundled Bun runtime half-updated and the restarted proxy died seconds
396
+ * later. A healthy /healthz must appear, then remain healthy for one short stability window.
397
+ */
398
+ async function confirmRestartedProxy(
399
+ job: UpdateJobState,
400
+ captured: { port: number; hostname: string },
401
+ io: RestartIo = {},
402
+ ): Promise<boolean> {
403
+ /* [Decision Log]
404
+ - 목적과 의도: GUI update job이 detached restart 요청만 보고 성공 처리하지 않도록, 실제 프록시 복귀 여부를 확인한다.
405
+ - 기존 구현 및 제약 조건: update-job.json은 spawn/service reinstall 직후 `succeeded`로 끝났고, Windows npm/Bun 교체 실패처럼 몇 초 후 죽는 재시작을 잡지 못했다.
406
+ - 검토한 주요 대안: (1) 포트 점유만 확인 — 외부 프로세스/죽기 직전 프로세스를 성공으로 오인할 수 있다. (2) 무기한 /healthz 폴링 — UX가 느려지고 worker 종료 시점이 불명확하다. (3) 짧은 healthy 등장 + 안정성 창 확인 — 실제 복귀를 확인하면서도 대기 시간을 제한할 수 있다.
407
+ - 선택한 방식: identity-aware /healthz probe가 일정 시간 안에 나타나고, 추가 안정성 창 동안 유지되는지 확인한다.
408
+ - 다른 대안 대신 이 방식을 선택한 이유: GUI는 "업데이트가 설치됐지만 재시작은 실패"를 분리해 알려줘야 하며, 이 방식이 가장 적은 오탐으로 그 경계를 만든다.
409
+ - 장점, 단점 및 영향: 장점은 silent restart failure가 update-job 상태로 드러난다는 점이다. 단점은 성공 판정이 최대 30초 늦어질 수 있다는 점이며, 대신 실제 복귀를 더 정확히 반영한다.
410
+ */
411
+ const probe = io.probeProxy ?? (async (port: number, hostname?: string) => (
412
+ !!(await proxyIdentityAt(port, { hostname }))
413
+ ));
414
+ const sleep = io.sleepMs ?? (async (ms: number) => {
415
+ await new Promise(resolve => setTimeout(resolve, ms));
416
+ });
417
+ const now = io.now ?? (() => Date.now());
418
+ const port = captured.port;
419
+ const hostname = captured.hostname;
420
+ const startDeadline = now() + RESTART_HEALTH_TIMEOUT_MS;
421
+
422
+ while (now() < startDeadline) {
423
+ if (await probe(port, hostname)) {
424
+ updateJob(job, {}, `Proxy reported healthy on ${hostname}:${port}; confirming it stays up...`);
425
+ const stableUntil = now() + RESTART_STABILITY_WINDOW_MS;
426
+ while (now() < stableUntil) {
427
+ if (!(await probe(port, hostname))) {
428
+ updateJob(job, {
429
+ status: "failed",
430
+ restarted: false,
431
+ error: `proxy restart became unhealthy on ${hostname}:${port}`,
432
+ }, restartFailureHint(port));
433
+ return false;
434
+ }
435
+ await sleep(500);
436
+ }
437
+ updateJob(job, {}, `Proxy stayed healthy for ${Math.trunc(RESTART_STABILITY_WINDOW_MS / 1000)}s after restart.`);
438
+ return true;
439
+ }
440
+ await sleep(250);
441
+ }
442
+
443
+ updateJob(job, {
444
+ status: "failed",
445
+ restarted: false,
446
+ error: `proxy restart never became healthy on ${hostname}:${port}`,
447
+ }, restartFailureHint(port));
448
+ return false;
449
+ }
450
+
451
+ export function confirmRestartAfterUpdateForTests(
452
+ job: UpdateJobState,
453
+ captured: { port: number; hostname: string },
454
+ io: RestartIo,
455
+ ): Promise<boolean> {
456
+ return confirmRestartedProxy(job, captured, io);
457
+ }
458
+
380
459
  export async function runGuiUpdateWorker(jobId: string, channel: Channel, restart: boolean): Promise<void> {
381
460
  let job = readUpdateJob(jobId);
382
461
  const check = checkForUpdate(channel);
@@ -457,7 +536,8 @@ export async function runGuiUpdateWorker(jobId: string, channel: Channel, restar
457
536
  if (restart) {
458
537
  job = updateJob(job, { status: "restarting" }, "Update installed. Restarting proxy...");
459
538
  await restartAfterUpdate(job, captured);
460
- updateJob(job, { status: "succeeded", restarted: true }, "Restart requested.");
539
+ if (!(await confirmRestartedProxy(job, captured))) return;
540
+ updateJob(job, { status: "succeeded", restarted: true }, "Restart requested and proxy is healthy.");
461
541
  return;
462
542
  }
463
543
 
@@ -1,6 +1,7 @@
1
1
  import type { OcxProviderConfig } from "../types";
2
2
  import { FORWARD_HEADERS } from "../adapters/openai-responses";
3
3
  import { signalWithTimeout, cancelBodyOnAbort } from "../lib/abort";
4
+ import { redactSecretString } from "../lib/redact";
4
5
  import { sidecarEnter } from "../lib/sidecar-tracker";
5
6
  import { fetchWithResetRetry } from "../lib/upstream-retry";
6
7
  import { parseSidecarSSE } from "../web-search/parse";
@@ -99,7 +100,7 @@ export async function describeImage(
99
100
  if (!res.ok) {
100
101
  const t = await res.text().catch(() => "");
101
102
  console.warn(`[vision] sidecar HTTP ${res.status} (${Date.now() - t0}ms)`);
102
- return { text: "", error: `vision sidecar HTTP ${res.status}: ${t.slice(0, 200)}` };
103
+ return { text: "", error: `vision sidecar HTTP ${res.status}: ${redactSecretString(t.slice(0, 200))}` };
103
104
  }
104
105
  const detachBodyGuard = cancelBodyOnAbort(res.body, linkedSignal.signal);
105
106
  let parsed;
@@ -1,6 +1,7 @@
1
1
  import type { OcxProviderConfig } from "../types";
2
2
  import { FORWARD_HEADERS } from "../adapters/openai-responses";
3
3
  import { signalWithTimeout, cancelBodyOnAbort } from "../lib/abort";
4
+ import { redactSecretString } from "../lib/redact";
4
5
  import { sidecarEnter } from "../lib/sidecar-tracker";
5
6
  import { fetchWithResetRetry } from "../lib/upstream-retry";
6
7
  import { parseSidecarSSE, type WebSearchResult } from "./parse";
@@ -84,7 +85,7 @@ export async function runWebSearch(
84
85
  if (!res.ok) {
85
86
  const t = await res.text().catch(() => "");
86
87
  console.warn(`[web-search] sidecar HTTP ${res.status} for query "${query.slice(0, 80)}" (${Date.now() - t0}ms)`);
87
- return { text: "", sources: [], error: `sidecar HTTP ${res.status}: ${t.slice(0, 200)}` };
88
+ return { text: "", sources: [], error: `sidecar HTTP ${res.status}: ${redactSecretString(t.slice(0, 200))}` };
88
89
  }
89
90
  const detachBodyGuard = cancelBodyOnAbort(res.body, linkedSignal.signal);
90
91
  try {
@@ -364,6 +364,13 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise<Respons
364
364
  inactivityTimeoutMs: routedModelStallTimeoutMs,
365
365
  })) {
366
366
  if (event.type === "heartbeat") yield event;
367
+ // Kiro's explicit-completion protocol marks ordinary assistant text as commentary while
368
+ // it performs a bounded final-answer retry. That text is safe to surface immediately and
369
+ // is exactly what the native Kiro transport streams. Keeping it in the search scanner made
370
+ // Codex show only `Working` until both Kiro attempts had finished (often 30-40 seconds).
371
+ // Tool events remain buffered below, so the decision to invoke the hosted sidecar is still
372
+ // atomic and no search call can escape before its stream has validated successfully.
373
+ else if (event.type === "text_delta" && event.phase === "commentary") yield event;
367
374
  else events.push(event);
368
375
  }
369
376
  } catch (error) {
@@ -373,7 +380,8 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise<Respons
373
380
  throw new LoopError(502, `Provider stream error: ${error instanceof Error ? error.message : String(error)}`);
374
381
  }
375
382
 
376
- const terminalIndexes = events.flatMap((event, index) => event.type === "done" || event.type === "error" ? [index] : []);
383
+ const terminalIndexes = events.flatMap((event, index) =>
384
+ event.type === "done" || event.type === "incomplete" || event.type === "error" ? [index] : []);
377
385
  if (terminalIndexes.length !== 1 || terminalIndexes[0] !== events.length - 1) {
378
386
  throw new LoopError(502, `Web-search adapter stream protocol error: expected one final terminal event, received ${terminalIndexes.length}`);
379
387
  }
@@ -241,7 +241,7 @@ export async function* parseStreamWithProgress(
241
241
  resetInactivity();
242
242
 
243
243
  const parserPump = (async (): Promise<void> => {
244
- let heldDone: Extract<AdapterEvent, { type: "done" }> | undefined;
244
+ let heldTerminal: Extract<AdapterEvent, { type: "done" | "incomplete" }> | undefined;
245
245
  try {
246
246
  iterator = parseStream(tappedResponse);
247
247
  if (settled) {
@@ -250,11 +250,11 @@ export async function* parseStreamWithProgress(
250
250
  }
251
251
  while (true) {
252
252
  let result: IteratorResult<AdapterEvent>;
253
- if (heldDone) {
253
+ if (heldTerminal) {
254
254
  let drainTimer: ReturnType<typeof setTimeout> | undefined;
255
255
  const drainTimeout = new Promise<never>((_, reject) => {
256
256
  drainTimer = setTimeout(() => reject(new WebSearchStreamProtocolError(
257
- `adapter did not return within ${postTerminalDrainTimeoutMs}ms after done`,
257
+ `adapter did not return within ${postTerminalDrainTimeoutMs}ms after ${heldTerminal?.type ?? "terminal event"}`,
258
258
  )), postTerminalDrainTimeoutMs);
259
259
  });
260
260
  const stoppedDuringDrain = stopped.then(reason => { throw reason; });
@@ -268,8 +268,8 @@ export async function* parseStreamWithProgress(
268
268
  }
269
269
 
270
270
  if (result.done) {
271
- if (!heldDone) throw new WebSearchStreamProtocolError("adapter returned without a done event");
272
- await handoff.deliver(heldDone);
271
+ if (!heldTerminal) throw new WebSearchStreamProtocolError("adapter returned without a terminal event");
272
+ await handoff.deliver(heldTerminal);
273
273
  if (!settled) {
274
274
  settled = true;
275
275
  clearInactivity();
@@ -281,17 +281,19 @@ export async function* parseStreamWithProgress(
281
281
  }
282
282
 
283
283
  const event = result.value;
284
- if (heldDone) {
284
+ if (heldTerminal) {
285
285
  throw new WebSearchStreamProtocolError(
286
- event.type === "done" ? "adapter yielded more than one done event" : "adapter yielded an event after done",
286
+ event.type === "done" || event.type === "incomplete"
287
+ ? "adapter yielded more than one terminal event"
288
+ : "adapter yielded an event after its terminal event",
287
289
  );
288
290
  }
289
291
  if (event.type === "error") {
290
292
  fail(new Error(event.message));
291
293
  return;
292
294
  }
293
- if (event.type === "done") {
294
- heldDone = event;
295
+ if (event.type === "done" || event.type === "incomplete") {
296
+ heldTerminal = event;
295
297
  continue;
296
298
  }
297
299
  await handoff.deliver(event);
@@ -300,7 +302,7 @@ export async function* parseStreamWithProgress(
300
302
  fail(error instanceof WebSearchStreamProtocolError
301
303
  ? error
302
304
  : new WebSearchStreamProtocolError(
303
- `adapter threw${heldDone ? " after done" : ""}: ${error instanceof Error ? error.message : String(error)}`,
305
+ `adapter threw${heldTerminal ? " after its terminal event" : ""}: ${error instanceof Error ? error.message : String(error)}`,
304
306
  ));
305
307
  }
306
308
  })();