@mars-sea/dsh-commandcode-provider 0.11.7 → 0.11.9

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/lib/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { homedir } from "node:os";
1
+ import { homedir, tmpdir } from "node:os";
2
2
  import { dirname, join } from "node:path";
3
3
  import z from "@deepseek-ai/schemastery";
4
4
  import { MAX_TIMER_DELAY_MS } from "@deepseek-ai/dsh-timeout";
@@ -6,7 +6,7 @@ import * as dshLlm from "@deepseek-ai/dsh-llm";
6
6
  import { CONTEXT_WINDOW_EXCEEDED_CODE, LlmAdapter, LlmError, ReasoningEffortId, ToolCallId, assertUsableApiKey, attributionHeaders, errorChain, isContextWindowExceededError, offloadedImageText, resolveRetryPolicy, textOnlyImageText } from "@deepseek-ai/dsh-llm";
7
7
  import { credentialRef } from "@deepseek-ai/dsh-credentials";
8
8
  import { launchEnvironmentOf } from "@deepseek-ai/dsh-launch-environment";
9
- import { existsSync, readFileSync } from "node:fs";
9
+ import { appendFileSync, existsSync, mkdirSync, readFileSync } from "node:fs";
10
10
  import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
11
11
  import { randomBytes, randomUUID } from "node:crypto";
12
12
  import { TypertRemoteService } from "@deepseek-ai/dsh-typert-protocol";
@@ -203,6 +203,11 @@ const KNOWN_EFFORTS = {
203
203
  "high"
204
204
  ],
205
205
  "sakana/fugu-ultra": ["high", "xhigh"],
206
+ "stepfun/Step-5-Preview": [
207
+ "low",
208
+ "medium",
209
+ "high"
210
+ ],
206
211
  "tencent/hy4-preview": [
207
212
  "low",
208
213
  "medium",
@@ -219,6 +224,12 @@ const KNOWN_EFFORTS = {
219
224
  "high",
220
225
  "xhigh"
221
226
  ],
227
+ "xai/grok-4.7": [
228
+ "low",
229
+ "medium",
230
+ "high",
231
+ "xhigh"
232
+ ],
222
233
  "z-ai/glm-5.3-flash": [
223
234
  "low",
224
235
  "high",
@@ -340,11 +351,16 @@ const KNOWN_IMAGE_MODELS = /* @__PURE__ */ new Set([
340
351
  "moonshotai/Kimi-K3",
341
352
  "sakana/fugu-ultra",
342
353
  "stepfun/Step-3.7-Flash",
354
+ "stepfun/Step-5-Preview",
343
355
  "thinkingmachines/inkling",
344
356
  "thinkingmachines/inkling-small",
345
357
  "xai/grok-4.5",
346
358
  "xai/grok-4.6",
359
+ "xai/grok-4.7",
347
360
  "xiaomi/mimo-v2.5",
361
+ "xiaomi/mimo-v2.6-flash",
362
+ "xiaomi/mimo-v2.6-pro",
363
+ "xiaomi/mimo-v2.6-pro-ultraspeed",
348
364
  "z-ai/glm-5.3-flash",
349
365
  "z-ai/glm-5.3-flashx"
350
366
  ]);
@@ -458,7 +474,11 @@ function requiresMessagesEndpoint(modelId) {
458
474
  * free `inclusionai/ling-3.0-flash-sante:free` (Go); command-code@1.53.0
459
475
  * added `deepseek/deepseek-v4.1-flash` (Go); command-code@1.56.0 added
460
476
  * `Qwen/Qwen3.8-Omni-Flash` (Go); command-code@1.57.0 added
461
- * `z-ai/glm-5.3-flashx` (Go).
477
+ * `z-ai/glm-5.3-flashx` (Go); command-code@1.59.0 added `xai/grok-4.7` (GOAT)
478
+ * and 1.60.0 added `stepfun/Step-5-Preview` (Go); command-code@1.62.0 added the
479
+ * MiMo V2.6 family — `xiaomi/mimo-v2.6-flash` + `xiaomi/mimo-v2.6-pro` (Go) and
480
+ * `xiaomi/mimo-v2.6-pro-ultraspeed` (GOAT) — so the 1.58.0 -> 1.62.0 window's
481
+ * only tier changes are additions and the superset chain still holds.
462
482
  *
463
483
  * The Provider API exposes no plan metadata, so this snapshot is the source of
464
484
  * truth for the picker's plan annotation — it answers "which plan do I need to
@@ -501,6 +521,7 @@ const KNOWN_PLANS = {
501
521
  "poolside/laguna-s-2.1-free": "go",
502
522
  "stepfun/Step-3.5-Flash": "go",
503
523
  "stepfun/Step-3.7-Flash": "go",
524
+ "stepfun/Step-5-Preview": "go",
504
525
  "tencent/hy3-paid": "go",
505
526
  "tencent/hy4-preview": "go",
506
527
  "thinkingmachines/inkling": "go",
@@ -508,6 +529,8 @@ const KNOWN_PLANS = {
508
529
  "xai/grok-4.5": "go",
509
530
  "xiaomi/mimo-v2.5": "go",
510
531
  "xiaomi/mimo-v2.5-pro": "go",
532
+ "xiaomi/mimo-v2.6-flash": "go",
533
+ "xiaomi/mimo-v2.6-pro": "go",
511
534
  "z-ai/glm-5.3-flash": "go",
512
535
  "z-ai/glm-5.3-flashx": "go",
513
536
  "zai-org/GLM-5": "go",
@@ -521,6 +544,8 @@ const KNOWN_PLANS = {
521
544
  "meta/muse-spark-1.2": "goat",
522
545
  "meta/muse-spark-1.3": "goat",
523
546
  "xai/grok-4.6": "goat",
547
+ "xai/grok-4.7": "goat",
548
+ "xiaomi/mimo-v2.6-pro-ultraspeed": "goat",
524
549
  "claude-haiku-4-5-20251001": "pro",
525
550
  "claude-sonnet-4-6": "pro",
526
551
  "claude-sonnet-5": "pro",
@@ -691,6 +716,10 @@ const KNOWN_DEALS = {
691
716
  "MiniMaxAI/MiniMax-M3": { label: "50% off" },
692
717
  "xiaomi/mimo-v2.5-pro": { label: "99% off" },
693
718
  "xiaomi/mimo-v2.5": { label: "98% off" },
719
+ "xai/grok-4.7": {
720
+ label: "40% off",
721
+ expiresAt: "2026-09-27T23:59:59.999Z"
722
+ },
694
723
  "poolside/laguna-s-2.1-free": {
695
724
  label: "FREE",
696
725
  free: true
@@ -1273,6 +1302,26 @@ const MODEL_PRICE_ROWS = [
1273
1302
  1
1274
1303
  ] }]
1275
1304
  },
1305
+ {
1306
+ id: "grok-4.7",
1307
+ rates: [
1308
+ 1.2,
1309
+ 3.6,
1310
+ .3
1311
+ ],
1312
+ contextTiers: [{
1313
+ "maxContext": 2e5,
1314
+ "rates": [
1315
+ 1.2,
1316
+ 3.6,
1317
+ .3
1318
+ ]
1319
+ }, { "rates": [
1320
+ 2.4,
1321
+ 7.2,
1322
+ .6
1323
+ ] }]
1324
+ },
1276
1325
  {
1277
1326
  id: "inkling",
1278
1327
  rates: [
@@ -1353,6 +1402,30 @@ const MODEL_PRICE_ROWS = [
1353
1402
  .0036
1354
1403
  ]
1355
1404
  },
1405
+ {
1406
+ id: "mimo-v2.6-flash",
1407
+ rates: [
1408
+ .14,
1409
+ .28,
1410
+ .0028
1411
+ ]
1412
+ },
1413
+ {
1414
+ id: "mimo-v2.6-pro",
1415
+ rates: [
1416
+ .435,
1417
+ .87,
1418
+ .0036
1419
+ ]
1420
+ },
1421
+ {
1422
+ id: "mimo-v2.6-pro-ultraspeed",
1423
+ rates: [
1424
+ 4.35,
1425
+ 8.7,
1426
+ .036
1427
+ ]
1428
+ },
1356
1429
  {
1357
1430
  id: "minimax-m2.5",
1358
1431
  rates: [
@@ -1578,6 +1651,14 @@ const MODEL_PRICE_ROWS = [
1578
1651
  .04
1579
1652
  ]
1580
1653
  },
1654
+ {
1655
+ id: "step-5-preview",
1656
+ rates: [
1657
+ 1,
1658
+ 2.7,
1659
+ .05
1660
+ ]
1661
+ },
1581
1662
  {
1582
1663
  id: "tencent/hy3-paid",
1583
1664
  rates: [
@@ -2571,6 +2652,181 @@ function scaleBy(size, factor) {
2571
2652
  };
2572
2653
  }
2573
2654
  //#endregion
2655
+ //#region src/stream-trace.ts
2656
+ /**
2657
+ * Opt-in raw-stream trace for the Command Code transports.
2658
+ *
2659
+ * Why this exists: this route's failure mode of record is a response that ENDS
2660
+ * EARLY. The gateway, an intermediary proxy, or the network closes the socket
2661
+ * mid-generation, so the adapter's read loop sees `done` without ever seeing
2662
+ * the transport's terminal event. From inside the adapter those two endings are
2663
+ * indistinguishable — the provider cut the stream, a proxy timed out, or the
2664
+ * socket was dropped — and the only artifact that separates them is the raw
2665
+ * wire: which events actually arrived, when the last one landed, how long the
2666
+ * socket then sat silent, and how it ended. Without that, a truncated answer is
2667
+ * a report with no evidence attached.
2668
+ *
2669
+ * Enable it per process:
2670
+ *
2671
+ * ```sh
2672
+ * DSH_COMMANDCODE_TRACE=/tmp/cc-stream.jsonl dsh web
2673
+ * DSH_COMMANDCODE_TRACE=1 dsh web # -> $TMPDIR/dsh-commandcode-stream.jsonl
2674
+ * ```
2675
+ *
2676
+ * Any value that is not a flag is a path, and the explicit off-spellings
2677
+ * (`0`, `false`, `no`, `off`) are recognized as "off" rather than being taken
2678
+ * for a file name.
2679
+ *
2680
+ * Every model request appends one JSONL record per raw chunk plus lifecycle
2681
+ * records (`response`, `eof`, `idle-timeout`, `read-error`, `end`,
2682
+ * `stream-close`), each stamped with `at` — milliseconds since that request's
2683
+ * first byte. The chunk records carry the provider's own payload verbatim
2684
+ * (bounded per chunk), because a truncated answer is exactly what is being
2685
+ * diagnosed: treat the file as conversation content, not as a log safe to
2686
+ * share. It never carries the request body, an API key, or any request header.
2687
+ *
2688
+ * Deliberate trade-offs:
2689
+ * - OFF costs one `process.env` read per request.
2690
+ * - ON appends synchronously, one file open per chunk. The trace must survive
2691
+ * a host killed mid-diagnosis, and the microseconds it costs are irrelevant
2692
+ * beside the seconds-scale silences it is there to measure.
2693
+ * - A trace that cannot be written turns itself off instead of failing the
2694
+ * request it is observing: a diagnostic must never become the outage.
2695
+ *
2696
+ * @module
2697
+ */
2698
+ /** Environment variable that turns the trace on. */
2699
+ const STREAM_TRACE_ENV = "DSH_COMMANDCODE_TRACE";
2700
+ /** File used when the variable is set to a bare flag instead of a path. */
2701
+ const STREAM_TRACE_DEFAULT_FILE = "dsh-commandcode-stream.jsonl";
2702
+ /** One raw chunk is recorded verbatim up to this many characters. */
2703
+ const STREAM_TRACE_MAX_TEXT = 16384;
2704
+ /** Values of {@link STREAM_TRACE_ENV} that mean "trace to the default file". */
2705
+ const FLAG_VALUES = /* @__PURE__ */ new Set([
2706
+ "1",
2707
+ "true",
2708
+ "yes",
2709
+ "on"
2710
+ ]);
2711
+ /**
2712
+ * Values that explicitly mean OFF.
2713
+ *
2714
+ * A user turning the trace off writes `false` as readily as they write `1` to
2715
+ * turn it on, and without this set every one of these spellings fell through to
2716
+ * "…so it must be a path" — the trace switched ON and wrote the conversation to
2717
+ * a file NAMED `false` (or `0`, or `off`) in the process's working directory.
2718
+ */
2719
+ const OFF_VALUES = /* @__PURE__ */ new Set([
2720
+ "0",
2721
+ "false",
2722
+ "no",
2723
+ "off"
2724
+ ]);
2725
+ /** The inert trace used whenever the switch is off. */
2726
+ const OFF = {
2727
+ enabled: false,
2728
+ record: () => void 0,
2729
+ close: () => void 0
2730
+ };
2731
+ /**
2732
+ * Resolve the trace file for this process.
2733
+ *
2734
+ * @param raw - the variable's raw value; defaults to the live environment.
2735
+ * @returns the file to append to, or undefined when the trace is off.
2736
+ */
2737
+ function resolveStreamTracePath(raw = process.env[STREAM_TRACE_ENV]) {
2738
+ const value = raw?.trim();
2739
+ if (value === void 0 || value === "") return void 0;
2740
+ const lower = value.toLowerCase();
2741
+ if (OFF_VALUES.has(lower)) return void 0;
2742
+ if (FLAG_VALUES.has(lower)) return join(tmpdir(), STREAM_TRACE_DEFAULT_FILE);
2743
+ return value;
2744
+ }
2745
+ /**
2746
+ * Clamp one recorded payload so a single chunk cannot consume the whole cap.
2747
+ *
2748
+ * @param text - the text to record.
2749
+ * @param max - characters to keep, defaulting to {@link STREAM_TRACE_MAX_TEXT}.
2750
+ * @returns `text` unchanged, or its head plus a marker naming what was dropped.
2751
+ */
2752
+ function boundTraceText(text, max = STREAM_TRACE_MAX_TEXT) {
2753
+ if (text.length <= max) return text;
2754
+ return `${text.slice(0, max)}…[+${text.length - max} chars]`;
2755
+ }
2756
+ /**
2757
+ * Open one request's trace.
2758
+ *
2759
+ * @param scope - what the records describe (the `provider/model` route).
2760
+ * @param options - `path` overrides the environment (tests pass a temp file);
2761
+ * `now` overrides the clock the `at` stamps are measured with.
2762
+ * @returns a writer that never throws and never fails the request.
2763
+ */
2764
+ function openStreamTrace(scope, options = {}) {
2765
+ const path = options.path ?? resolveStreamTracePath();
2766
+ if (path === void 0) return OFF;
2767
+ const now = options.now ?? Date.now;
2768
+ const startedAt = now();
2769
+ let written = 0;
2770
+ let stopped = false;
2771
+ let capped = false;
2772
+ const terminals = /* @__PURE__ */ new Set();
2773
+ try {
2774
+ mkdirSync(dirname(path), { recursive: true });
2775
+ } catch {
2776
+ return OFF;
2777
+ }
2778
+ const write = (record) => {
2779
+ if (stopped) return;
2780
+ const terminal = record.event === "end" || record.event === "stream-close";
2781
+ if (capped && !terminal) return;
2782
+ if (terminal && terminals.has(String(record.event))) return;
2783
+ let line;
2784
+ try {
2785
+ line = `${JSON.stringify({
2786
+ at: now() - startedAt,
2787
+ scope,
2788
+ ...record
2789
+ })}\n`;
2790
+ } catch {
2791
+ return;
2792
+ }
2793
+ if (terminal) {
2794
+ if (Buffer.byteLength(line) > 16384) return;
2795
+ terminals.add(String(record.event));
2796
+ } else if (written + Buffer.byteLength(line) > 4194304) {
2797
+ capped = true;
2798
+ line = `${JSON.stringify({
2799
+ at: now() - startedAt,
2800
+ scope,
2801
+ event: "trace-capped",
2802
+ bytes: written
2803
+ })}\n`;
2804
+ }
2805
+ try {
2806
+ appendFileSync(path, line);
2807
+ written += Buffer.byteLength(line);
2808
+ } catch {
2809
+ stopped = true;
2810
+ }
2811
+ };
2812
+ write({ event: "stream-open" });
2813
+ return {
2814
+ enabled: true,
2815
+ record(event, fields) {
2816
+ write(fields === void 0 ? { event } : {
2817
+ event,
2818
+ ...fields
2819
+ });
2820
+ },
2821
+ close() {
2822
+ write({
2823
+ event: "stream-close",
2824
+ bytes: written
2825
+ });
2826
+ }
2827
+ };
2828
+ }
2829
+ //#endregion
2574
2830
  //#region src/adapter.ts
2575
2831
  /**
2576
2832
  * DeepSeek Harness LLM adapter for the Command Code Provider API.
@@ -2580,7 +2836,7 @@ function scaleBy(size, factor) {
2580
2836
  * and API key or subscription, and Command Code's terms apply.
2581
2837
  *
2582
2838
  * Wire protocol (reverse-engineered by the pi plugin, command-code@1.28.4;
2583
- * re-verified against command-code@1.58.0 — endpoints, request shape, and
2839
+ * re-verified against command-code@1.62.0 — endpoints, request shape, and
2584
2840
  * stream events unchanged):
2585
2841
  * POST {apiBase}/alpha/generate
2586
2842
  * body: { config, memory, taste, skills, params: { model, messages, tools,
@@ -2593,7 +2849,7 @@ function scaleBy(size, factor) {
2593
2849
  * per-request options thunk and an API-key resolver from the plugin entry
2594
2850
  * (src/index.ts), so a settings change reaches the very next request.
2595
2851
  */
2596
- const COMMAND_CODE_CLI_VERSION = "1.58.0";
2852
+ const COMMAND_CODE_CLI_VERSION = "1.62.0";
2597
2853
  const DEFAULT_API_BASE = "https://api.commandcode.ai";
2598
2854
  const DEFAULT_GENERATE_MAX_TOKENS = 64e3;
2599
2855
  const DEFAULT_MAX_OUTPUT_TOKENS = 65536;
@@ -2686,15 +2942,21 @@ function periodEndValue(value) {
2686
2942
  * Terminal stream-error markers from the official CLI (`Xw` in command-code's
2687
2943
  * cli.mjs): these always mean "retrying cannot succeed", so the adapter must
2688
2944
  * not classify them as transient server errors.
2945
+ *
2946
+ * Written with spaces rather than the CLI's underscores, because
2947
+ * {@link hasTerminalStreamMarker} normalizes the separator before matching:
2948
+ * a JSON body usually carries `premium credits exhausted` while the CLI's own
2949
+ * list is written `premium_credits_exhausted`, and both are the same refusal
2950
+ * (the pre-stream classifier normalizes identically).
2689
2951
  */
2690
2952
  const TERMINAL_STREAM_ERROR_MARKERS = [
2691
- "premium_credits_exhausted",
2692
- "model_not_in_plan",
2953
+ "premium credits exhausted",
2954
+ "model not in plan",
2693
2955
  "insufficient credits"
2694
2956
  ];
2695
2957
  function hasTerminalStreamMarker(message) {
2696
- const lower = message.toLowerCase();
2697
- return TERMINAL_STREAM_ERROR_MARKERS.some((marker) => lower.includes(marker));
2958
+ const normalized = message.toLowerCase().replaceAll("_", " ");
2959
+ return TERMINAL_STREAM_ERROR_MARKERS.some((marker) => normalized.includes(marker));
2698
2960
  }
2699
2961
  /**
2700
2962
  * Context-window wording the provider uses to reject a request that outgrew
@@ -2733,7 +2995,10 @@ function isContextOverflowDetail(detail) {
2733
2995
  * (issue #39).
2734
2996
  * - Credits/plan wording is terminal, so an exhausted balance or a model
2735
2997
  * outside the plan is not retried as if it were a transient rate limit.
2736
- * - Only then does the status/`isRetryable` pair decide, as before.
2998
+ * - Only then does the status/`isRetryable` pair decide — and an explicit
2999
+ * refusal (`isRetryable: false`) or a terminal marker OUTRANKS a status, so a
3000
+ * 5xx carrying "insufficient credits" stays terminal instead of entering the
3001
+ * retry cadence as `SERVER`.
2737
3002
  */
2738
3003
  function streamErrorToLlmError(value, fallbackMessage) {
2739
3004
  const record = isRecord(value) ? value : void 0;
@@ -2748,7 +3013,7 @@ function streamErrorToLlmError(value, fallbackMessage) {
2748
3013
  const statusOption = statusCode !== void 0 ? { status: statusCode } : void 0;
2749
3014
  if (isContextOverflowDetail(detail)) return new LlmError(`Command Code stream error: ${message};Command Code API 拒绝了这次请求:内容超出模型上下文窗口。正在压缩上下文后重试;如仍失败,请新建会话或减少上下文`, CONTEXT_WINDOW_EXCEEDED_CODE, statusOption);
2750
3015
  const terminal = hasTerminalStreamMarker(detail);
2751
- if (!(isRetryable === true || (statusCode !== void 0 ? statusCode !== void 0 && (statusCode === 429 || statusCode >= 500) : isRetryable !== false && !terminal))) return new LlmError(`Command Code stream error: ${message}`, "PROVIDER_STREAM_ERROR", statusOption);
3016
+ if (!(isRetryable === true || (isRetryable === false || terminal ? false : statusCode !== void 0 ? statusCode !== void 0 && (statusCode === 429 || statusCode >= 500) : true))) return new LlmError(`Command Code stream error: ${message}`, "PROVIDER_STREAM_ERROR", statusOption);
2752
3017
  return new LlmError(`Command Code stream error: ${message}`, "SERVER", statusOption);
2753
3018
  }
2754
3019
  function recordOrEmpty(value) {
@@ -2952,42 +3217,32 @@ async function writeModelsCache(cachePath, models) {
2952
3217
  }
2953
3218
  }
2954
3219
  /**
2955
- * Whether an inbound message is a tool RESULT — a `user`-role message the
2956
- * producer tagged with `source: { kind: 'tool' }`.
3220
+ * >=0.1.7 puts toolCallId/isError on a role:'tool' message and carries raw
3221
+ * content blocks. Older engines wrap those fields in the first tool-result
3222
+ * block of a role:'user' message. Read structurally so the bundle still builds
3223
+ * and loads against older peers. Pairing and emission MUST use this same view:
3224
+ * otherwise a dropped result either drops its call too (making the model
3225
+ * forget completed work), or leaves an unanswered call on the wire.
2957
3226
  *
2958
- * The tag is read defensively because it is PRODUCER-supplied and nothing
2959
- * validates it: dsh-llm's `Message` type declares `source` required, but the
2960
- * runtime never checks it and every adapter shipped before this one ignored the
2961
- * field outright (`dsh-llm-deepseek` never reads it). A third-party plugin that
2962
- * assembles its own history can therefore omit it and run correctly everywhere
2963
- * else — dsh-mneme's memory pipeline did — which made this adapter, the first
2964
- * to actually read the field, the first to crash on such a message with
2965
- * `TypeError: Cannot read properties of undefined (reading 'kind')` in the
2966
- * serialization phase, 0–8 ms in, before any request left the machine and with
2967
- * nothing in the error naming the message or the producer (issue #47).
2968
- *
2969
- * Two rules keep an untagged message from costing the turn:
2970
- *
2971
- * - A PRESENT tag is authoritative. `kind: 'user'`, `'model'`, `'plugin'` or
2972
- * any producer-added kind means "not a tool result", whatever the content
2973
- * looks like — the tag answers *who produced this*.
2974
- * - An ABSENT tag falls back to the content shape. A `user` message whose
2975
- * first block is a `tool-result` is a tool result by the harness's own
2976
- * definition (`ToolResultMessage` is exactly that shape with a
2977
- * `ToolMessageSource`), and the fallback is not cosmetic: `pairedToolCalls()`
2978
- * counts a `tool-result` block from ANY message, so classifying such a
2979
- * message as a plain user message would drop it at emission while its call
2980
- * still counted as paired — leaving the assistant's tool call unanswered on
2981
- * the wire, which the gateway rejects outright. FIRST block, not "any block":
2982
- * the tool-result branch reads `content[0]` and skips anything else, so
2983
- * claiming a mixed message as a tool result would discard its other blocks
2984
- * instead of sending them.
2985
- */
2986
- function isToolResultMessage(message) {
2987
- if (message.role !== "user") return false;
3227
+ * For the legacy envelope a present source tag remains authoritative; an
3228
+ * absent tag falls back to the first block (issue #47). The modern role is
3229
+ * itself the discriminator, as it is for the Harness's own serializers.
3230
+ */
3231
+ function toolResultOf(message) {
3232
+ const current = message;
3233
+ if (current.role === "tool") {
3234
+ if (typeof current.toolCallId !== "string" || current.toolCallId === "") return void 0;
3235
+ return {
3236
+ toolCallId: current.toolCallId,
3237
+ isError: current.isError ?? false,
3238
+ content: current.content
3239
+ };
3240
+ }
3241
+ if (message.role !== "user") return void 0;
2988
3242
  const kind = message.source?.kind;
2989
- if (kind !== void 0) return kind === "tool";
2990
- return message.content?.[0]?.type === "tool-result";
3243
+ if (kind !== void 0 && kind !== "tool") return void 0;
3244
+ const block = message.content[0];
3245
+ return block?.type === "tool-result" ? block : void 0;
2991
3246
  }
2992
3247
  /**
2993
3248
  * Collect the tool calls that have a paired tool result, plus each call's
@@ -3000,12 +3255,13 @@ function pairedToolCalls(messages) {
3000
3255
  const callIds = /* @__PURE__ */ new Set();
3001
3256
  const names = /* @__PURE__ */ new Map();
3002
3257
  const resultIds = /* @__PURE__ */ new Set();
3003
- for (const message of messages) for (const block of message.content) {
3004
- if (message.role === "assistant" && block.type === "tool-call") {
3258
+ for (const message of messages) {
3259
+ const result = toolResultOf(message);
3260
+ if (result) resultIds.add(result.toolCallId);
3261
+ for (const block of message.content) if (message.role === "assistant" && block.type === "tool-call") {
3005
3262
  callIds.add(block.id);
3006
3263
  names.set(block.id, block.name);
3007
3264
  }
3008
- if (block.type === "tool-result") resultIds.add(block.toolCallId);
3009
3265
  }
3010
3266
  return {
3011
3267
  ids: new Set([...callIds].filter((id) => resultIds.has(id))),
@@ -3313,7 +3569,8 @@ async function messagesToCC(messages, readImage) {
3313
3569
  };
3314
3570
  for (const message of messages) {
3315
3571
  if (message.role === "system") continue;
3316
- if (message.role === "user" && !isToolResultMessage(message)) {
3572
+ const result = toolResultOf(message);
3573
+ if (message.role === "user" && !result) {
3317
3574
  flushPendingImages();
3318
3575
  const parts = [];
3319
3576
  for (const block of message.content) {
@@ -3356,18 +3613,17 @@ async function messagesToCC(messages, readImage) {
3356
3613
  });
3357
3614
  continue;
3358
3615
  }
3359
- if (isToolResultMessage(message)) {
3360
- const block = message.content[0];
3361
- if (!block || block.type !== "tool-result" || !paired.has(block.toolCallId)) continue;
3362
- const media = toolResultMedia(block);
3363
- const wireToolCallId = wireIds.get(block.toolCallId) ?? block.toolCallId;
3616
+ if (result) {
3617
+ if (!paired.has(result.toolCallId)) continue;
3618
+ const media = toolResultMedia(result);
3619
+ const wireToolCallId = wireIds.get(result.toolCallId) ?? result.toolCallId;
3364
3620
  out.push({
3365
3621
  role: "tool",
3366
3622
  content: [{
3367
3623
  type: "tool-result",
3368
3624
  toolCallId: wireToolCallId,
3369
- toolName: toolNames.get(block.toolCallId) || "unknown",
3370
- output: block.isError ? {
3625
+ toolName: toolNames.get(result.toolCallId) || "unknown",
3626
+ output: result.isError ? {
3371
3627
  type: "error-text",
3372
3628
  value: toolResultTextForWire(media)
3373
3629
  } : {
@@ -3425,7 +3681,8 @@ async function messagesToOpenAI(messages, readImage) {
3425
3681
  };
3426
3682
  for (const message of messages) {
3427
3683
  if (message.role === "system") continue;
3428
- if (message.role === "user" && !isToolResultMessage(message)) {
3684
+ const result = toolResultOf(message);
3685
+ if (message.role === "user" && !result) {
3429
3686
  flushPendingImages();
3430
3687
  const parts = [];
3431
3688
  for (const block of message.content) {
@@ -3471,11 +3728,10 @@ async function messagesToOpenAI(messages, readImage) {
3471
3728
  out.push(assistant);
3472
3729
  continue;
3473
3730
  }
3474
- if (isToolResultMessage(message)) {
3475
- const block = message.content[0];
3476
- if (!block || block.type !== "tool-result" || !paired.has(block.toolCallId)) continue;
3477
- const media = toolResultMedia(block);
3478
- const wireToolCallId = wireIds.get(block.toolCallId) ?? block.toolCallId;
3731
+ if (result) {
3732
+ if (!paired.has(result.toolCallId)) continue;
3733
+ const media = toolResultMedia(result);
3734
+ const wireToolCallId = wireIds.get(result.toolCallId) ?? result.toolCallId;
3479
3735
  out.push({
3480
3736
  role: "tool",
3481
3737
  tool_call_id: wireToolCallId,
@@ -3736,6 +3992,7 @@ function createBlockAssembler() {
3736
3992
  reasoningIndex: -1,
3737
3993
  reasoningContent: "",
3738
3994
  sawContent: false,
3995
+ finishReason: void 0,
3739
3996
  openAiToolCalls: []
3740
3997
  };
3741
3998
  }
@@ -3955,8 +4212,10 @@ var CommandCodeAdapter = class extends LlmAdapter {
3955
4212
  /** The headers every authenticated account endpoint shares. */
3956
4213
  async accountHeaders(apiKey) {
3957
4214
  const connection = this.deps.options();
4215
+ const raw = apiKey ?? await this.deps.resolveApiKey(connection);
4216
+ const named = connection.apiKeyEnv;
3958
4217
  return {
3959
- Authorization: `Bearer ${apiKey ?? await this.deps.resolveApiKey(connection)}`,
4218
+ Authorization: `Bearer ${assertUsableApiKey(raw, "llm-commandcode", typeof named === "string" && named !== "" ? named : "the stored credential")}`,
3960
4219
  "x-command-code-version": COMMAND_CODE_CLI_VERSION,
3961
4220
  "x-cli-environment": "production",
3962
4221
  ...attributionHeaders()
@@ -3968,11 +4227,16 @@ var CommandCodeAdapter = class extends LlmAdapter {
3968
4227
  * failure accounting: the billing probe fails open silently, the usage
3969
4228
  * report books failures per endpoint. Non-2xx and non-record bodies come
3970
4229
  * back without a record; only a transport throw propagates to the caller.
4230
+ *
4231
+ * `timeoutMs` is the caller's budget for this one request. The default is the
4232
+ * CATALOG probe's short cap, which fits the picker's fail-open reads; the
4233
+ * usage report deliberately passes the connection's own request budget so an
4234
+ * account query is not held to a stricter (and invisible) limit than chat.
3971
4235
  */
3972
- async fetchEndpointJson(url, headers) {
4236
+ async fetchEndpointJson(url, headers, timeoutMs = MODELS_TIMEOUT_MS) {
3973
4237
  const response = await this.fetchImpl(url, {
3974
4238
  headers,
3975
- signal: AbortSignal.timeout(MODELS_TIMEOUT_MS)
4239
+ signal: AbortSignal.timeout(timeoutMs)
3976
4240
  });
3977
4241
  if (!response.ok) return { status: response.status };
3978
4242
  const parsed = await response.json();
@@ -4130,15 +4394,39 @@ var CommandCodeAdapter = class extends LlmAdapter {
4130
4394
  * view. Requires a usable API key (throws `MISSING_CREDENTIAL` otherwise).
4131
4395
  * Pass `apiKey` to report on a specific account of a multi-account pool;
4132
4396
  * the default resolves the currently active account.
4397
+ *
4398
+ * Two failure modes are NOT "the network is down", and the report must say
4399
+ * so rather than let {@link classifyTotalFailure} blame the connection:
4400
+ *
4401
+ * - A key that no HTTP header can carry (a stray newline from a paste, a
4402
+ * full-width character) makes `fetch` throw a `TypeError` **before any
4403
+ * I/O**, for every endpoint at once. The chat path refuses such a key
4404
+ * through `assertUsableApiKey`; this path must too, and it answers
4405
+ * `blocked: 'invalid-key'` instead of four phantom transport failures.
4406
+ * - The four endpoints get the SAME per-request budget as a chat call
4407
+ * (`connection.requestTimeoutMs`, default 60 s), not the catalog's 10 s
4408
+ * probe budget: on a slow link a 10 s cap made every account query time
4409
+ * out while chat kept working, which is exactly a "check your network"
4410
+ * banner that never clears.
4133
4411
  */
4134
4412
  async getUsage(apiKey) {
4135
- const base = this.deps.options().apiBase;
4136
- const headers = await this.accountHeaders(apiKey);
4413
+ const connection = this.deps.options();
4414
+ const base = connection.apiBase;
4415
+ let headers;
4416
+ try {
4417
+ headers = await this.accountHeaders(apiKey);
4418
+ } catch (error) {
4419
+ if (error instanceof LlmError && error.code === "INVALID_CREDENTIAL") return {
4420
+ failures: [error.message],
4421
+ blocked: "invalid-key"
4422
+ };
4423
+ throw error;
4424
+ }
4137
4425
  const failures = [];
4138
4426
  const failedStatuses = [];
4139
4427
  const getJson = async (path) => {
4140
4428
  try {
4141
- const { status, record } = await this.fetchEndpointJson(`${base}${path}`, headers);
4429
+ const { status, record } = await this.fetchEndpointJson(`${base}${path}`, headers, connection.requestTimeoutMs);
4142
4430
  if (record === void 0) {
4143
4431
  failures.push(`${path}: HTTP ${status}`);
4144
4432
  failedStatuses.push(status);
@@ -4224,7 +4512,7 @@ var CommandCodeAdapter = class extends LlmAdapter {
4224
4512
  reported = true;
4225
4513
  if (!window.exceeded) continue;
4226
4514
  exceeded = true;
4227
- resetAt = Math.max(resetAt, window.resetAt);
4515
+ if (window.resetAt > 0 && window.resetAt - Date.now() <= MAX_TRUSTED_RESET_MS) resetAt = Math.max(resetAt, window.resetAt);
4228
4516
  }
4229
4517
  if (!reported) return void 0;
4230
4518
  return {
@@ -4332,6 +4620,19 @@ var CommandCodeAdapter = class extends LlmAdapter {
4332
4620
  const reader = response.body.getReader();
4333
4621
  const decoder = new TextDecoder();
4334
4622
  let buffer = "";
4623
+ const trace = openStreamTrace(`${options.provider}/${options.model}`);
4624
+ const streamStartedAt = Date.now();
4625
+ let chunkCount = 0;
4626
+ let totalBytes = 0;
4627
+ let lastChunkAt = streamStartedAt;
4628
+ trace.record("response", {
4629
+ protocol,
4630
+ endpoint: protocol === "cli" ? `${connection.apiBase}/alpha/generate` : `${connection.apiBase}/provider/v1/chat/completions`,
4631
+ status: response.status,
4632
+ attempts: tried.size,
4633
+ maxTokens,
4634
+ ...reasoningEffort === void 0 ? {} : { reasoningEffort }
4635
+ });
4335
4636
  let idleTimer;
4336
4637
  let idleFired = false;
4337
4638
  const armIdle = () => {
@@ -4348,9 +4649,16 @@ var CommandCodeAdapter = class extends LlmAdapter {
4348
4649
  }
4349
4650
  };
4350
4651
  const asm = createBlockAssembler();
4351
- const handle = (event) => handleEvent(asm, protocol, event);
4652
+ let finish;
4653
+ let usage;
4654
+ let usageEmitted = false;
4655
+ let endRecorded = false;
4656
+ function* handle(event) {
4657
+ for (const chunk of handleEvent(asm, protocol, event)) if (chunk.type === "finish") finish = chunk;
4658
+ else if (chunk.type === "usage") usage = chunk.usage;
4659
+ else yield chunk;
4660
+ }
4352
4661
  try {
4353
- let finished = false;
4354
4662
  for (;;) {
4355
4663
  let read;
4356
4664
  armIdle();
@@ -4358,44 +4666,104 @@ var CommandCodeAdapter = class extends LlmAdapter {
4358
4666
  read = await reader.read();
4359
4667
  } catch (error) {
4360
4668
  if (options.signal?.aborted) throw error;
4669
+ trace.record("read-error", {
4670
+ chunks: chunkCount,
4671
+ bytes: totalBytes,
4672
+ silentMs: Date.now() - lastChunkAt,
4673
+ message: errorChain(error)
4674
+ });
4361
4675
  throw new LlmError(`Command Code API stream from ${connection.apiBase} failed while reading: ${errorChain(error)};Command Code API 流式响应中途断开——网络波动所致,重试通常可恢复`, "TRANSPORT", { cause: error });
4362
4676
  } finally {
4363
4677
  clearIdle();
4364
4678
  }
4365
4679
  const { done, value } = read;
4366
4680
  if (done) {
4681
+ trace.record("eof", {
4682
+ chunks: chunkCount,
4683
+ bytes: totalBytes,
4684
+ silentMs: Date.now() - lastChunkAt,
4685
+ totalMs: Date.now() - streamStartedAt,
4686
+ finishSeen: finish !== void 0,
4687
+ idleFired,
4688
+ buffered: buffer.length
4689
+ });
4367
4690
  if (idleFired) throw new LlmError(`Command Code API stream from ${connection.apiBase} was idle for ${connection.streamIdleTimeoutMs}ms (no events) and was treated as a dead connection;Command Code API 流式响应已 ${connection.streamIdleTimeoutMs} 毫秒无任何事件,被判定为死连接——长思考模型可在设置中调大流空闲超时`, "TIMEOUT");
4368
- if (buffer.trim()) for (const chunk of handle(parseStreamEventLine(buffer))) {
4369
- yield chunk;
4370
- if (chunk.type === "finish") finished = true;
4371
- }
4691
+ if (buffer.trim()) yield* handle(parseStreamEventLine(buffer));
4372
4692
  break;
4373
4693
  }
4374
- buffer += decoder.decode(value, { stream: true });
4694
+ const text = decoder.decode(value, { stream: true });
4695
+ buffer += text;
4696
+ chunkCount += 1;
4697
+ totalBytes += value.byteLength;
4698
+ lastChunkAt = Date.now();
4699
+ trace.record("chunk", {
4700
+ n: chunkCount,
4701
+ bytes: value.byteLength,
4702
+ text: boundTraceText(text)
4703
+ });
4375
4704
  const lines = buffer.split("\n");
4376
4705
  buffer = lines.pop() ?? "";
4377
- for (const line of lines) {
4378
- const chunks = handle(parseStreamEventLine(line));
4379
- for (const chunk of chunks) {
4380
- yield chunk;
4381
- if (chunk.type === "finish") finished = true;
4382
- }
4383
- }
4384
- if (finished) break;
4706
+ for (const line of lines) yield* handle(parseStreamEventLine(line));
4707
+ if (finish !== void 0) break;
4385
4708
  }
4386
- if (!finished) {
4387
- yield* closeText(asm);
4388
- yield* closeReasoning(asm);
4389
- if (protocol === "openai") yield* emitOpenAiToolCalls(asm);
4390
- if (!asm.sawContent) throw new LlmError("Command Code returned an empty response;Command Code 返回了空响应,重试通常可恢复", "EMPTY_RESPONSE");
4709
+ if (usage !== void 0) {
4710
+ usageEmitted = true;
4391
4711
  yield {
4392
- type: "finish",
4393
- reason: { kind: "stop" }
4712
+ type: "usage",
4713
+ usage
4394
4714
  };
4395
4715
  }
4716
+ if (finish !== void 0) {
4717
+ const failure = asm.sawContent ? void 0 : emptyCompletionError(asm.finishReason, maxTokens, usage);
4718
+ trace.record("end", {
4719
+ outcome: failure === void 0 ? "finished" : failure.code === "OUTPUT_TOKEN_LIMIT" ? "output-token-limit" : "empty",
4720
+ finishReason: asm.finishReason,
4721
+ ...failure === void 0 ? {} : { code: failure.code },
4722
+ maxTokens,
4723
+ ...usage === void 0 ? {} : { usage },
4724
+ chunks: chunkCount,
4725
+ bytes: totalBytes,
4726
+ totalMs: Date.now() - streamStartedAt,
4727
+ sawContent: asm.sawContent
4728
+ });
4729
+ endRecorded = true;
4730
+ if (failure !== void 0) throw failure;
4731
+ yield finish;
4732
+ } else {
4733
+ const elapsed = Date.now() - streamStartedAt;
4734
+ const detail = `${chunkCount} chunk(s), ${totalBytes} bytes, ${elapsed}ms`;
4735
+ trace.record("end", {
4736
+ outcome: asm.sawContent ? "stream-closed" : "empty",
4737
+ chunks: chunkCount,
4738
+ bytes: totalBytes,
4739
+ totalMs: elapsed,
4740
+ sawContent: asm.sawContent
4741
+ });
4742
+ endRecorded = true;
4743
+ if (!asm.sawContent) throw new LlmError("Command Code returned an empty response;Command Code 返回了空响应,重试通常可恢复", "EMPTY_RESPONSE");
4744
+ throw new LlmError(`Command Code API stream from ${connection.apiBase} ended before its finish event (${detail}) — the response was closed mid-generation, so this is not the model stopping by itself;Command Code API 流式响应在结束事件之前被关闭(生成中途断流),并非模型主动结束——请重试;若频繁出现,可用 ${STREAM_TRACE_ENV}=<文件路径> 抓取原始流以定位断流原因`, "STREAM_CLOSED");
4745
+ }
4746
+ } catch (error) {
4747
+ if (!usageEmitted && usage !== void 0) yield {
4748
+ type: "usage",
4749
+ usage
4750
+ };
4751
+ if (!endRecorded) trace.record("end", {
4752
+ outcome: options.signal?.aborted ? "aborted" : "error",
4753
+ code: error instanceof LlmError ? error.code : "unknown",
4754
+ finishReason: asm.finishReason,
4755
+ maxTokens,
4756
+ ...usage === void 0 ? {} : { usage },
4757
+ sawContent: asm.sawContent,
4758
+ chunks: chunkCount,
4759
+ bytes: totalBytes,
4760
+ totalMs: Date.now() - streamStartedAt
4761
+ });
4762
+ throw error;
4396
4763
  } finally {
4397
4764
  clearIdle();
4398
4765
  cleanup();
4766
+ trace.close();
4399
4767
  await reader.cancel().catch(() => void 0);
4400
4768
  reader.releaseLock();
4401
4769
  }
@@ -4486,7 +4854,7 @@ function handleCliEvent(asm, event) {
4486
4854
  }
4487
4855
  const delta = stringValue(event.text) ?? "";
4488
4856
  asm.textContent += delta;
4489
- asm.sawContent = true;
4857
+ if (delta.trim() !== "") asm.sawContent = true;
4490
4858
  chunks.push({
4491
4859
  type: "text-delta",
4492
4860
  index: asm.textIndex,
@@ -4549,6 +4917,7 @@ function handleCliEvent(asm, event) {
4549
4917
  break;
4550
4918
  }
4551
4919
  case "finish": {
4920
+ asm.finishReason = stringValue(event.finishReason);
4552
4921
  chunks.push(...closeText(asm), ...closeReasoning(asm));
4553
4922
  const usage = isRecord(event.totalUsage) ? event.totalUsage : void 0;
4554
4923
  if (usage) {
@@ -4562,6 +4931,8 @@ function handleCliEvent(asm, event) {
4562
4931
  cacheReadTokens: cacheRead,
4563
4932
  cacheWriteTokens: cacheWrite
4564
4933
  };
4934
+ const reasoningTokens = numberValue((isRecord(usage.outputTokenDetails) ? usage.outputTokenDetails : void 0)?.reasoningTokens) ?? numberValue(usage.reasoningTokens);
4935
+ if (reasoningTokens !== void 0) tokenUsage.reasoningTokens = reasoningTokens;
4565
4936
  chunks.push({
4566
4937
  type: "usage",
4567
4938
  usage: tokenUsage
@@ -4626,39 +4997,38 @@ function handleOpenAIEvent(asm, event) {
4626
4997
  });
4627
4998
  }
4628
4999
  asm.textContent += contentDelta;
4629
- asm.sawContent = true;
5000
+ if (contentDelta.trim() !== "") asm.sawContent = true;
4630
5001
  chunks.push({
4631
5002
  type: "text-delta",
4632
5003
  index: asm.textIndex,
4633
5004
  text: contentDelta
4634
5005
  });
4635
5006
  }
4636
- if (Array.isArray(delta.tool_calls)) {
5007
+ if (Array.isArray(delta.tool_calls)) for (const rawCall of delta.tool_calls) {
5008
+ if (!isRecord(rawCall)) continue;
4637
5009
  asm.sawContent = true;
4638
- for (const rawCall of delta.tool_calls) {
4639
- if (!isRecord(rawCall)) continue;
4640
- const callIndex = numberValue(rawCall.index) ?? 0;
4641
- let existing = asm.openAiToolCalls.find((call) => call.index === callIndex);
4642
- const fn = isRecord(rawCall.function) ? rawCall.function : void 0;
4643
- const id = stringValue(rawCall.id);
4644
- const name = fn === void 0 ? void 0 : stringValue(fn.name);
4645
- const argDelta = fn === void 0 ? void 0 : stringValue(fn.arguments) ?? (fn.arguments === void 0 ? "" : JSON.stringify(fn.arguments));
4646
- if (!existing) {
4647
- existing = {
4648
- index: callIndex,
4649
- name: name ?? "",
4650
- arguments: argDelta ?? ""
4651
- };
4652
- if (id !== void 0) existing.id = id;
4653
- asm.openAiToolCalls.push(existing);
4654
- } else {
4655
- if (id !== void 0 && existing.id === void 0) existing.id = id;
4656
- if (name !== void 0 && existing.name === "") existing.name = name;
4657
- if (argDelta !== void 0) existing.arguments += argDelta;
4658
- }
5010
+ const callIndex = numberValue(rawCall.index) ?? 0;
5011
+ let existing = asm.openAiToolCalls.find((call) => call.index === callIndex);
5012
+ const fn = isRecord(rawCall.function) ? rawCall.function : void 0;
5013
+ const id = stringValue(rawCall.id);
5014
+ const name = fn === void 0 ? void 0 : stringValue(fn.name);
5015
+ const argDelta = fn === void 0 ? void 0 : stringValue(fn.arguments) ?? (fn.arguments === void 0 ? "" : JSON.stringify(fn.arguments));
5016
+ if (!existing) {
5017
+ existing = {
5018
+ index: callIndex,
5019
+ name: name ?? "",
5020
+ arguments: argDelta ?? ""
5021
+ };
5022
+ if (id !== void 0) existing.id = id;
5023
+ asm.openAiToolCalls.push(existing);
5024
+ } else {
5025
+ if (id !== void 0 && existing.id === void 0) existing.id = id;
5026
+ if (name !== void 0 && existing.name === "") existing.name = name;
5027
+ if (argDelta !== void 0) existing.arguments += argDelta;
4659
5028
  }
4660
5029
  }
4661
5030
  if (choice.finish_reason !== void 0 && choice.finish_reason !== null) {
5031
+ asm.finishReason = stringValue(choice.finish_reason);
4662
5032
  chunks.push(...closeText(asm), ...closeReasoning(asm), ...emitOpenAiToolCalls(asm));
4663
5033
  if (event.usage !== void 0) chunks.push({
4664
5034
  type: "usage",
@@ -4706,6 +5076,13 @@ function mapFinishReason(reason) {
4706
5076
  if (reason === "length" || reason === "max_tokens" || reason === "max-tokens" || reason === "max_output_tokens") return { kind: "max-tokens" };
4707
5077
  return { kind: "stop" };
4708
5078
  }
5079
+ /** A terminal marker without text or tools is a failed attempt, not a completed turn. */
5080
+ function emptyCompletionError(reason, maxTokens, usage) {
5081
+ const detail = `finish_reason=${reason ?? "unknown"}, max_tokens=${maxTokens}, outputTokens=${usage?.outputTokens ?? "unknown"}, reasoningTokens=${usage?.reasoningTokens ?? "unknown"}`;
5082
+ if (mapFinishReason(reason).kind === "max-tokens") return new LlmError(`Command Code reached the output token limit without producing answer text or a tool call (${detail});Command Code 已达到输出 token 上限,但没有生成正文或工具调用;请调整思考强度,或在模型和接口上限内增加输出预算`, "OUTPUT_TOKEN_LIMIT");
5083
+ if (reason === "content_filter" || reason === "content-filter") return new LlmError(`Command Code filtered the response (${detail});Command Code 拦截了响应,未生成正文或工具调用`, "CONTENT_FILTER");
5084
+ return new LlmError(`Command Code finished without producing answer text or a tool call (${detail});Command Code 返回了空响应(可能只有思考内容),重试通常可恢复`, "EMPTY_RESPONSE");
5085
+ }
4709
5086
  /**
4710
5087
  * True when a Provider API pre-stream rejection is Command Code's Go-plan
4711
5088
  * gate (`upgrade_required`). Only this exact class of rejection should fall
@@ -4750,8 +5127,25 @@ function mapFinishReason(reason) {
4750
5127
  * or a model outside its plan (`MODEL_NOT_IN_PLAN`). Rotating must NOT mark
4751
5128
  * the account: the fact is about the model or the balance, it can change
4752
5129
  * without the key changing, and a `:free` model can still be served by an
4753
- * account with an empty balance.
5130
+ * account with an empty balance. These codes are recognized on EVERY
5131
+ * status — a 5xx that proxies the provider's own credits/plan body is
5132
+ * still an account fact, and only the prose scan is confined to 4xx.
4754
5133
  */
5134
+ /**
5135
+ * The provider's structured "this account cannot serve this request" codes.
5136
+ *
5137
+ * Status-independent on purpose, mirroring the CLI's classifier, which reads
5138
+ * the code before any status guard. A 5xx that carries one of these must not
5139
+ * be retried as a transient provider failure: the same request against the
5140
+ * same account cannot succeed, so the caller rotates past it (without marking
5141
+ * the key) instead of replaying it on the harness's retry cadence.
5142
+ */
5143
+ const ACCOUNT_UNAVAILABLE_CODES = /* @__PURE__ */ new Set([
5144
+ "USAGE_EXCEEDED",
5145
+ "INSUFFICIENT_CREDITS",
5146
+ "PREMIUM_CREDITS_EXHAUSTED",
5147
+ "MODEL_NOT_IN_PLAN"
5148
+ ]);
4755
5149
  function classifyAccountRejection(status, errText) {
4756
5150
  if (status === 429 || readProviderErrorCode(errText)?.toUpperCase() === "RATE_LIMITED") {
4757
5151
  const evidence = readWindowLimitEvidence(errText);
@@ -4762,10 +5156,11 @@ function classifyAccountRejection(status, errText) {
4762
5156
  };
4763
5157
  }
4764
5158
  if (status === 401) return { reason: "invalid-credential" };
5159
+ const code = readProviderErrorCode(errText)?.toUpperCase();
5160
+ if (code !== void 0 && ACCOUNT_UNAVAILABLE_CODES.has(code)) return { reason: "unavailable" };
4765
5161
  if (status < 400 || status >= 500) return void 0;
4766
5162
  const lower = errText.toLowerCase().replaceAll("_", " ");
4767
- const code = readProviderErrorCode(errText)?.toUpperCase();
4768
- if (code === "USAGE_EXCEEDED" || code === "INSUFFICIENT_CREDITS" || code === "PREMIUM_CREDITS_EXHAUSTED" || code === "MODEL_NOT_IN_PLAN" || lower.includes("insufficient credits") || lower.includes("premium credits exhausted") || lower.includes("model not in plan") || /insufficient (credit|balance)/.test(lower) || /out of credit/.test(lower) || /not (available|included)[^.]{0,40}plan/.test(lower) || lower.includes("upgrade your plan")) return { reason: "unavailable" };
5163
+ if (lower.includes("insufficient credits") || lower.includes("premium credits exhausted") || lower.includes("model not in plan") || /insufficient (credit|balance)/.test(lower) || /out of credit/.test(lower) || /not (available|included)[^.]{0,40}plan/.test(lower) || lower.includes("upgrade your plan")) return { reason: "unavailable" };
4769
5164
  }
4770
5165
  /** The machine-readable `error.code`/`error.type` of a pre-stream failure body. */
4771
5166
  function readProviderErrorCode(errText) {
@@ -4779,6 +5174,16 @@ function readProviderErrorCode(errText) {
4779
5174
  }
4780
5175
  }
4781
5176
  /**
5177
+ * How far ahead a provider-published reset may be and still be trusted.
5178
+ *
5179
+ * Generous on purpose: the weekly window is the longest this provider meters,
5180
+ * and the value is a plausibility bound rather than a policy. Beyond it the
5181
+ * reset is dropped rather than clamped, which leaves the account's mark
5182
+ * `unknown` — the state the pool's probe pass re-checks — instead of a
5183
+ * cooldown that never expires.
5184
+ */
5185
+ const MAX_TRUSTED_RESET_MS = 2592e6;
5186
+ /**
4782
5187
  * The usage-window evidence carried by a rejected request, mirroring the CLI's
4783
5188
  * `parseWindowLimitError` → `resolveWindowLabel`/`extractResetAtMs` pair.
4784
5189
  *
@@ -4812,7 +5217,8 @@ function readWindowLimitEvidence(errText) {
4812
5217
  const window = named === "fiveHour" || named === "weekly" || named === "daily" ? named : /usage limit for your plan/i.test(message) || /usage limit for your plan/i.test(errText) ? /weekly/i.test(message) ? "weekly" : "fiveHour" : void 0;
4813
5218
  const stamp = /resets at (\d{4}-\d{2}-\d{2}T[\d:.]+Z)/i.exec(message)?.[1] ?? /resets at (\d{4}-\d{2}-\d{2}T[\d:.]+Z)/i.exec(errText)?.[1];
4814
5219
  const stamped = stamp === void 0 ? void 0 : Date.parse(stamp);
4815
- const resetAtMs = seconds !== void 0 && seconds > 0 ? Math.round(seconds * 1e3) : stamped !== void 0 && !Number.isNaN(stamped) ? stamped : void 0;
5220
+ const published = seconds !== void 0 && seconds > 0 ? Math.round(seconds * 1e3) : stamped !== void 0 && !Number.isNaN(stamped) ? stamped : void 0;
5221
+ const resetAtMs = published !== void 0 && Number.isFinite(published) && published - Date.now() <= MAX_TRUSTED_RESET_MS ? published : void 0;
4816
5222
  return {
4817
5223
  ...window === void 0 ? {} : { window },
4818
5224
  ...resetAtMs === void 0 ? {} : { resetAtMs }
@@ -5612,7 +6018,10 @@ function isCallbackCredentials(value) {
5612
6018
  /**
5613
6019
  * One browser-login attempt machine. Single-flight by design: `begin()` while
5614
6020
  * waiting returns the live attempt's status instead of starting a second one;
5615
- * a terminal state makes the next `begin()` start fresh.
6021
+ * a start that is still binding its port is REJOINED through
6022
+ * {@link CommandCodeLoginFlow.begin}'s in-flight promise, because the status
6023
+ * face cannot say `waiting` until that bind settles; a terminal state makes the
6024
+ * next `begin()` start fresh.
5616
6025
  */
5617
6026
  var CommandCodeLoginFlow = class {
5618
6027
  deps;
@@ -5630,6 +6039,17 @@ var CommandCodeLoginFlow = class {
5630
6039
  * the user cancelled and flipping the page back to success.
5631
6040
  */
5632
6041
  attemptSeq = 0;
6042
+ /**
6043
+ * The start currently binding a port, if any. Every `begin()` that arrives
6044
+ * before it settles joins it: two independent starts would each bind their
6045
+ * own loopback server, and only the LAST one is reachable by
6046
+ * {@link CommandCodeLoginFlow.teardown} — the orphan keeps listening and
6047
+ * answering `/callback` for the process's lifetime, and ten of them exhaust
6048
+ * the port window so browser login dies until the Host restarts.
6049
+ */
6050
+ starting;
6051
+ /** A `cancel()` that arrived while a start was still binding (see {@link CommandCodeLoginFlow.cancel}). */
6052
+ cancelPending = false;
5633
6053
  disposed = false;
5634
6054
  constructor(deps) {
5635
6055
  this.deps = deps;
@@ -5651,6 +6071,22 @@ var CommandCodeLoginFlow = class {
5651
6071
  async begin() {
5652
6072
  if (this.disposed) throw new Error("login flow has been disposed");
5653
6073
  if (this.statusValue.state === "waiting" && this.server !== void 0) return this.statusValue;
6074
+ if (this.starting !== void 0) return this.starting;
6075
+ this.cancelPending = false;
6076
+ const starting = this.startAttempt();
6077
+ this.starting = starting;
6078
+ try {
6079
+ return await starting;
6080
+ } finally {
6081
+ if (this.starting === starting) this.starting = void 0;
6082
+ }
6083
+ }
6084
+ /**
6085
+ * The binding half of {@link CommandCodeLoginFlow.begin}: one attempt, one
6086
+ * server, one published `waiting` status. Callers reach it only through
6087
+ * `begin()`'s single-flight fence.
6088
+ */
6089
+ async startAttempt() {
5654
6090
  this.teardown();
5655
6091
  const attempt = ++this.attemptSeq;
5656
6092
  const port = await this.findPort();
@@ -5662,6 +6098,16 @@ var CommandCodeLoginFlow = class {
5662
6098
  };
5663
6099
  });
5664
6100
  await this.bindServer(port, expectedState);
6101
+ if (this.disposed || this.cancelPending) {
6102
+ const cancelled = !this.disposed;
6103
+ this.cancelPending = false;
6104
+ this.teardown();
6105
+ if (cancelled) this.setStatus({
6106
+ state: "failed",
6107
+ reason: "cancelled"
6108
+ });
6109
+ return this.statusValue;
6110
+ }
5665
6111
  const apiBase = this.readApiBase();
5666
6112
  this.setStatus({
5667
6113
  state: "waiting",
@@ -5683,19 +6129,29 @@ var CommandCodeLoginFlow = class {
5683
6129
  settled.then((credentials) => this.complete(attempt, credentials), (failure) => this.failFrom(attempt, failure));
5684
6130
  return this.statusValue;
5685
6131
  }
5686
- /** Cancel a waiting attempt; terminal states are untouched. */
6132
+ /**
6133
+ * Cancel a waiting attempt; terminal states are untouched. A start that is
6134
+ * still binding a port is flagged instead of ignored — it cannot be torn
6135
+ * down from here (its server does not exist yet), so `startAttempt` observes
6136
+ * the flag once the bind settles and retires it.
6137
+ */
5687
6138
  cancel() {
5688
- if (this.disposed || this.statusValue.state !== "waiting") return;
5689
- this.teardown();
5690
- this.setStatus({
5691
- state: "failed",
5692
- reason: "cancelled"
5693
- });
6139
+ if (this.disposed) return;
6140
+ if (this.statusValue.state === "waiting") {
6141
+ this.teardown();
6142
+ this.setStatus({
6143
+ state: "failed",
6144
+ reason: "cancelled"
6145
+ });
6146
+ return;
6147
+ }
6148
+ if (this.starting !== void 0) this.cancelPending = true;
5694
6149
  }
5695
6150
  /** Stop everything; a waiting attempt ends cancelled. Idempotent. */
5696
6151
  dispose() {
5697
6152
  if (this.disposed) return;
5698
6153
  this.disposed = true;
6154
+ this.cancelPending = false;
5699
6155
  const wasWaiting = this.statusValue.state === "waiting";
5700
6156
  this.teardown();
5701
6157
  if (wasWaiting) this.setStatus({
@@ -5731,8 +6187,12 @@ var CommandCodeLoginFlow = class {
5731
6187
  const server = createServer((request, response) => this.handleCallback(request, response, expectedState));
5732
6188
  this.server = server;
5733
6189
  server.once("error", (error) => {
5734
- if (this.server !== server) return;
6190
+ if (this.server !== server) {
6191
+ closeServer(server);
6192
+ return;
6193
+ }
5735
6194
  this.server = void 0;
6195
+ closeServer(server);
5736
6196
  const tagged = new LoginSettleError("error", `Could not bind the login callback server on port ${port}: ${error.code ?? error.message}`);
5737
6197
  if (binding) {
5738
6198
  binding = false;
@@ -5901,11 +6361,17 @@ var CommandCodeLoginFlow = class {
5901
6361
  /** Close the server and watchdog without touching the published status. */
5902
6362
  teardown() {
5903
6363
  this.clearTimer();
5904
- this.server?.close();
6364
+ closeServer(this.server);
5905
6365
  this.server = void 0;
5906
6366
  this.settle = void 0;
5907
6367
  }
5908
6368
  };
6369
+ /** Close one loopback server without letting "not running"/"already closed" escape. */
6370
+ function closeServer(server) {
6371
+ try {
6372
+ server?.close();
6373
+ } catch {}
6374
+ }
5909
6375
  /** A tagged settle failure carrying the stable copy reason. */
5910
6376
  var LoginSettleError = class extends Error {
5911
6377
  reason;
@@ -5979,7 +6445,8 @@ function selectCommandCodeSearchProvider(web, enable) {
5979
6445
  function commandCodeSearchSelection() {
5980
6446
  return {
5981
6447
  owner: false,
5982
- displaced: void 0
6448
+ displaced: void 0,
6449
+ preexisting: false
5983
6450
  };
5984
6451
  }
5985
6452
  /**
@@ -5997,8 +6464,14 @@ function commandCodeSearchSelection() {
5997
6464
  * auto-select — already says what the user wants.
5998
6465
  * - When the field already reads `commandcode` at first touch (e.g. a
5999
6466
  * surviving runtime the plugin did not set, or a manual
6000
- * `searchProvider: commandcode` pin), `displaced` stays undefined so the
6001
- * later disable is a no-op rather than a guess at the factory default.
6467
+ * `searchProvider: commandcode` pin), `displaced` stays undefined and
6468
+ * `preexisting` is set, so the later disable is a no-op rather than a guess
6469
+ * at the factory default. A "no-op" means the field is left ALONE: writing
6470
+ * that `undefined` back would destroy the user's own pin, and dsh-web reads
6471
+ * a cleared `searchProviderId` as auto-select — where a second usable
6472
+ * provider (the shipped `deepseek-official` is usable whenever a DeepSeek
6473
+ * key resolves) makes EVERY later search throw
6474
+ * `WEB_PROVIDER_AMBIGUOUS`.
6002
6475
  *
6003
6476
  * Never throws: like the low-level rewrite, a hardened runtime shape degrades
6004
6477
  * to registered-but-unselected.
@@ -6012,14 +6485,16 @@ function applyCommandCodeSearchSelection(web, state, enable) {
6012
6485
  return;
6013
6486
  }
6014
6487
  const prior = field.searchProviderId;
6015
- state.displaced = prior === "commandcode" ? void 0 : prior;
6488
+ state.preexisting = prior === COMMANDCODE_SEARCH_PROVIDER_ID;
6489
+ state.displaced = state.preexisting ? void 0 : prior;
6016
6490
  field.searchProviderId = COMMANDCODE_SEARCH_PROVIDER_ID;
6017
6491
  state.owner = true;
6018
6492
  return;
6019
6493
  }
6020
6494
  if (state.owner) {
6021
6495
  state.owner = false;
6022
- field.searchProviderId = state.displaced;
6496
+ if (!state.preexisting) field.searchProviderId = state.displaced;
6497
+ state.preexisting = false;
6023
6498
  return;
6024
6499
  }
6025
6500
  } catch {}
@@ -6321,8 +6796,8 @@ function buildCommandCodeTuiSection(deps) {
6321
6796
  label: "API key",
6322
6797
  descriptions: { zh: "API 密钥" },
6323
6798
  secret: { ref },
6324
- hint: `Stored in the credential store as ${ref}, never in settings.yaml.`,
6325
- hintDescriptions: { zh: `保存在凭据库(${ref}),不会写入 settings.yaml。` }
6799
+ hint: `Stored in the credential store as ${ref}, never in configuration files.`,
6800
+ hintDescriptions: { zh: `保存在凭据库(${ref}),不会写入配置文件。` }
6326
6801
  },
6327
6802
  {
6328
6803
  path: ["apiBase"],
@@ -6600,6 +7075,67 @@ function transportBudgetMessage(failureMessage, maxRetries) {
6600
7075
  return `${failureMessage};Command Code API 连接在连续重试后仍失败——已停止重试以免整轮卡死。插件已自动重试 ${maxRetries} 次。通常说明当前网络到 api.commandcode.ai 的连接不通,常见原因是代理未生效:DSH 只读取环境变量 HTTPS_PROXY/HTTP_PROXY(系统代理/PAC 不会被读取),请确认启动 DSH 的终端里已导出代理,或先关闭代理直连后重试。网络恢复后直接重发即可 — Command Code API transport still failed after ${maxRetries} automatic retries, which were stopped so the turn could not stall for minutes. This is a connectivity problem between this machine and api.commandcode.ai, not a context-window or request-size rejection. Common cause: the proxy is not reaching the harness — DSH reads HTTPS_PROXY/HTTP_PROXY from the environment only (a Windows system proxy/PAC setting is not consulted), so export it in the launching shell, or turn the proxy off and retry. Resend when the network recovers; the budget is per request and has been reset`;
6601
7076
  }
6602
7077
  //#endregion
7078
+ //#region src/config-volatile.ts
7079
+ /**
7080
+ * Mark one schema field volatile when the installed schemastery supports it.
7081
+ * @param schema - The field schema (any generation).
7082
+ * @returns The schema carrying `meta.volatile`, or the schema itself on an
7083
+ * engine whose schemastery predates `.volatile()`.
7084
+ */
7085
+ function markVolatile(schema) {
7086
+ const candidate = schema;
7087
+ if (typeof candidate.volatile !== "function") return schema;
7088
+ return candidate.volatile();
7089
+ }
7090
+ /**
7091
+ * Mark every field of a Config field dict volatile, except the named
7092
+ * composition-only secrets (`apiKey` here, which no settings surface writes).
7093
+ *
7094
+ * The result is a NEW dict of schema instances: callers that also need an
7095
+ * UNMARKED schema for the legacy settings generation must build the fields
7096
+ * twice (`configFields()`), never share one dict — `.volatile()` clones per
7097
+ * call, and a shared instance would carry the mark into the legacy schema.
7098
+ *
7099
+ * @param fields - Freshly built field schemas.
7100
+ * @param skip - Field names that must stay unmarked.
7101
+ * @returns The field dict with every non-skipped field marked.
7102
+ */
7103
+ function markVolatileFields(fields, skip = []) {
7104
+ return Object.fromEntries(Object.entries(fields).map(([name, schema]) => [name, skip.includes(name) ? schema : markVolatile(schema)]));
7105
+ }
7106
+ /**
7107
+ * Whether a resolved config value is a volatile reference.
7108
+ *
7109
+ * Duck-typed on the two properties `createVolatile` produces (frozen object
7110
+ * with a `get` method) rather than cosmokit's private `write` symbol, because
7111
+ * cosmokit is not a peer of this bundle. Config values are plain parsed JSON
7112
+ * (or `!!jsExpr` wrappers), which are never frozen, so the test cannot
7113
+ * misfire on an ordinary field.
7114
+ */
7115
+ function isVolatileRef(value) {
7116
+ return typeof value === "object" && value !== null && !Array.isArray(value) && Object.isFrozen(value) && typeof value.get === "function";
7117
+ }
7118
+ /**
7119
+ * Read one config object as PLAIN values across generations.
7120
+ *
7121
+ * Each top-level field found to be a volatile reference is read through
7122
+ * `.get()` — freshly on every call, so a live update lands on the very next
7123
+ * read. When no field is a reference the input object is returned as-is,
7124
+ * keeping reference identity stable for identity-keyed memos on engines that
7125
+ * hand `apply()` plain config.
7126
+ */
7127
+ function unwrapVolatileConfig(config) {
7128
+ const source = config;
7129
+ if (typeof source !== "object" || source === null) return config;
7130
+ let found = false;
7131
+ const plain = {};
7132
+ for (const [field, value] of Object.entries(source)) if (isVolatileRef(value)) {
7133
+ plain[field] = value.get();
7134
+ found = true;
7135
+ } else plain[field] = value;
7136
+ return found ? plain : config;
7137
+ }
7138
+ //#endregion
6603
7139
  //#region src/index.ts
6604
7140
  /**
6605
7141
  * dsh-commandcode-provider — DeepSeek Harness LLM provider plugin for Command
@@ -6609,9 +7145,10 @@ function transportBudgetMessage(failureMessage, maxRetries) {
6609
7145
  * the configurable-provider directory, so the web Models page shows a
6610
7146
  * "Command Code" card with an API-key field and the model picker lists the
6611
7147
  * live Command Code model catalog. Connection facts resolve per request over
6612
- * the optional `llm-commandcode` user-settings section and the credential
6613
- * seam, so a changed key, endpoint, or cache path reaches the next request
6614
- * without a restart.
7148
+ * the plugin's live config — the `llm-commandcode` settings section through
7149
+ * dsh 0.1.6, profile Config (volatile fields, unwrapped per read) from 0.1.7
7150
+ * — and the credential seam, so a changed key, endpoint, or cache path
7151
+ * reaches the next request without a restart.
6615
7152
  *
6616
7153
  * ```yaml
6617
7154
  * - id: llm-commandcode
@@ -6636,41 +7173,84 @@ const DEFAULT_API_KEY_ENV = "COMMANDCODE_API_KEY";
6636
7173
  const PROVIDER = "commandcode";
6637
7174
  /** Default models cache path (mirrors the pi plugin's on-disk cache). */
6638
7175
  const DEFAULT_MODELS_CACHE_PATH = join(homedir(), ".commandcode", "models-cache.json");
6639
- const Config = z.object({
6640
- apiKeyEnv: z.string().role("credential-ref").default(DEFAULT_API_KEY_ENV),
6641
- apiKey: z.string().role("secret"),
6642
- apiBase: z.string(),
6643
- workingDir: z.string(),
6644
- modelsCachePath: z.string(),
6645
- requestTimeoutMs: z.number().min(1).max(MAX_TIMER_DELAY_MS),
6646
- streamIdleTimeoutMs: z.number().min(1).max(MAX_TIMER_DELAY_MS),
6647
- transportMaxRetries: z.number().min(0).max(50),
6648
- filterModelsByPlan: z.boolean(),
6649
- visibleModels: z.array(z.string()),
6650
- /**
6651
- * Per-model visibility overrides for the terminal settings page's checkbox
6652
- * list, keyed by catalog id. dsh-TUI addresses a staged edit by its field
6653
- * PATH, so two checkboxes sharing one path would overwrite each other's
6654
- * draft and the section's last model would decide every write; a map gives
6655
- * each checkbox a path of its own. An id listed here wins over
6656
- * {@link visibleModels}; ids absent here keep following it.
6657
- */
6658
- modelVisibility: z.dict(z.boolean()),
6659
- webSearch: z.boolean().default(true),
6660
- showSidebarQuota: z.boolean().default(false),
6661
- accounts: z.array(z.object({
6662
- label: z.string(),
6663
- apiKeyEnv: z.string().role("credential-ref"),
6664
- /** Literal key for one extra slot; see the top-level `apiKey` secret note. */
6665
- apiKey: z.string().role("secret")
6666
- })),
6667
- activeAccount: z.string(),
6668
- modelAccountRules: z.array(z.object({
6669
- models: z.array(z.string()),
6670
- account: z.string()
6671
- })),
6672
- lang: z.string().pattern(/^(zh|en)$/).default("zh")
6673
- });
7176
+ /**
7177
+ * The Config schema's fields, freshly built on every call.
7178
+ *
7179
+ * A FACTORY, not a shared dict, and that is load-bearing: the exported
7180
+ * {@link Config} marks every field volatile while {@link LegacySettingsSchema}
7181
+ * must carry no mark at all, and schemastery's schema instances are
7182
+ * single-purpose objects — sharing one dict would leak the marks into the
7183
+ * legacy registration (see `config-volatile.ts` for why that is a boot-time
7184
+ * `ValidationError` on ≤0.1.6 engines whose schemastery resolved to ≥3.18.3).
7185
+ *
7186
+ * @returns One fresh schema instance per Config field.
7187
+ */
7188
+ function configFields() {
7189
+ return {
7190
+ apiKeyEnv: z.string().role("credential-ref").default(DEFAULT_API_KEY_ENV),
7191
+ apiKey: z.string().role("secret"),
7192
+ apiBase: z.string(),
7193
+ workingDir: z.string(),
7194
+ modelsCachePath: z.string(),
7195
+ requestTimeoutMs: z.number().min(1).max(MAX_TIMER_DELAY_MS),
7196
+ streamIdleTimeoutMs: z.number().min(1).max(MAX_TIMER_DELAY_MS),
7197
+ transportMaxRetries: z.number().min(0).max(50),
7198
+ filterModelsByPlan: z.boolean(),
7199
+ visibleModels: z.array(z.string()),
7200
+ /**
7201
+ * Per-model visibility overrides for the terminal settings page's checkbox
7202
+ * list, keyed by catalog id. dsh-TUI addresses a staged edit by its field
7203
+ * PATH, so two checkboxes sharing one path would overwrite each other's
7204
+ * draft and the section's last model would decide every write; a map gives
7205
+ * each checkbox a path of its own. An id listed here wins over
7206
+ * {@link Config.visibleModels}; ids absent here keep following it.
7207
+ */
7208
+ modelVisibility: z.dict(z.boolean()),
7209
+ webSearch: z.boolean().default(true),
7210
+ showSidebarQuota: z.boolean().default(false),
7211
+ accounts: z.array(z.object({
7212
+ label: z.string(),
7213
+ apiKeyEnv: z.string().role("credential-ref"),
7214
+ /** Literal key for one extra slot; see the top-level `apiKey` secret note. */
7215
+ apiKey: z.string().role("secret")
7216
+ })),
7217
+ activeAccount: z.string(),
7218
+ modelAccountRules: z.array(z.object({
7219
+ models: z.array(z.string()),
7220
+ account: z.string()
7221
+ })),
7222
+ lang: z.string().pattern(/^(zh|en)$/).default("zh")
7223
+ };
7224
+ }
7225
+ /**
7226
+ * The 0.1.7-generation schema: every field volatile except the
7227
+ * composition-only `apiKey` secret.
7228
+ *
7229
+ * dsh 0.1.7's settings forms are projected from the schema's `meta.volatile`
7230
+ * nodes, so an unmarked field would be invisible to AND unwritable from the
7231
+ * settings page and refused by form-edit path validation — and on that
7232
+ * generation the loader hands `apply()` a live reference per marked field,
7233
+ * committing later writes without remounting this fiber (the
7234
+ * `loader/volatile-update` listener at the bottom of `apply` covers the facts
7235
+ * that are not re-derived per read). The mark is inert on engines whose
7236
+ * schemastery predates `.volatile()`.
7237
+ */
7238
+ const Config = z.object(markVolatileFields(configFields(), ["apiKey"]));
7239
+ /**
7240
+ * The ≤0.1.6-generation registration schema: the same fields, NO volatile
7241
+ * marks.
7242
+ *
7243
+ * The legacy `installSection(owner, ns, schema, entry, hooks)` re-validates the
7244
+ * `entry` it is handed (`register()` → `resolve()` → `schema(mergeLayers(base,
7245
+ * section))`) and its `describe()` structuredClones that same `entry`. Since
7246
+ * schemastery ≥3.18.3 creates references at parse time on EVERY generation —
7247
+ * `dsh-settings` through 0.1.6 declares `schemastery: ^3.18.2`, so old engines
7248
+ * freshly installed today resolve 3.18.3 — a marked schema plus the raw
7249
+ * reference-carrying `config` is a boot-time `ValidationError` there. This
7250
+ * schema is therefore unmarked, and `apply` pairs it with
7251
+ * `unwrapVolatileConfig(config)`.
7252
+ */
7253
+ const LegacySettingsSchema = z.object(configFields());
6674
7254
  /**
6675
7255
  * The one explicit resolve step from raw config to validated connection
6676
7256
  * facts. Programmatic construction may bypass Schemastery normalization, so
@@ -6704,7 +7284,8 @@ function readModelVisibility(raw) {
6704
7284
  }
6705
7285
  function apply(ctx, config) {
6706
7286
  installCostProjection(ctx);
6707
- let current = () => config;
7287
+ let source = () => config;
7288
+ const current = () => unwrapVolatileConfig(source());
6708
7289
  let lastRaw;
6709
7290
  let lastGood;
6710
7291
  const options = () => {
@@ -6917,18 +7498,38 @@ function apply(ctx, config) {
6917
7498
  }, "dsh-commandcode-provider: tui settings handle");
6918
7499
  });
6919
7500
  ctx.inject(["settings"], (settingsCtx) => {
6920
- settingsCtx.settings.installSection(ctx, NS, Config, config, {
6921
- setSource: (source) => {
6922
- current = source;
6923
- },
6924
- onChange: () => {
6925
- applySearchSelection(current().webSearch ?? true);
6926
- refreshTuiSettings?.();
6927
- }
6928
- });
7501
+ const service = settingsCtx.settings;
7502
+ if (typeof service.installSection === "function") {
7503
+ service.installSection(ctx, NS, LegacySettingsSchema, unwrapVolatileConfig(config), {
7504
+ setSource: (next) => {
7505
+ source = next;
7506
+ },
7507
+ onChange: () => {
7508
+ applySearchSelection(current().webSearch ?? true);
7509
+ refreshTuiSettings?.();
7510
+ }
7511
+ });
7512
+ return;
7513
+ }
7514
+ if (typeof service.configure === "function") settingsCtx.effect(() => {
7515
+ const dispose = service.configure?.({ auto: false }, ctx.fiber);
7516
+ return () => {
7517
+ dispose?.();
7518
+ };
7519
+ }, "dsh-commandcode-provider: settings auto-form policy");
6929
7520
  });
7521
+ const loaderEvents = ctx;
7522
+ ctx.effect(() => {
7523
+ const off = loaderEvents.on("loader/volatile-update", () => {
7524
+ applySearchSelection(current().webSearch ?? true);
7525
+ refreshTuiSettings?.();
7526
+ });
7527
+ return () => {
7528
+ if (typeof off === "function") off();
7529
+ };
7530
+ }, "dsh-commandcode-provider: volatile config updates");
6930
7531
  }
6931
7532
  //#endregion
6932
- export { ACTIVE_ACCOUNT_AUTO, BILLING_ACCESS_TTL_MS, COMMANDCODE_SEARCH_PROVIDER_ID, COMMAND_CODE_CLI_VERSION, CommandCodeAccountPool, CommandCodeAdapter, CommandCodeLoginFlow, CommandCodeSearchProvider, CommandCodeUsageService, Config, DEFAULT_API_BASE, DEFAULT_GENERATE_MAX_TOKENS, DEFAULT_MAX_OUTPUT_TOKENS, DEFAULT_MODELS_CACHE_PATH, DEFAULT_REQUEST_TIMEOUT_MS, DEFAULT_STREAM_IDLE_TIMEOUT_MS, DEFAULT_WEB_SEARCH_PROVIDER_ID, KNOWN_DEALS, KNOWN_EFFORTS, KNOWN_IMAGE_MODELS, KNOWN_PEAK_PRICING, KNOWN_PLANS, KNOWN_SUBSCRIPTION_PLANS, KNOWN_THINKING_MODELS, LANG_AUTO, LOGIN_ALLOWED_ORIGINS, LOGIN_BEGIN_ENDPOINT, LOGIN_BODY_LIMIT_BYTES, LOGIN_CANCEL_ENDPOINT, LOGIN_MAX_PORT_ATTEMPTS, LOGIN_START_PORT, LOGIN_STATUS_ENDPOINT, LOGIN_TIMEOUT_MS, PLAN_LABELS, PLAN_ORDER, PROVIDER, USAGE_REPORT_ENDPOINT, accountUsable, apply, applyCommandCodeSearchSelection, applyCommandCodeTuiSettings, applyCommands, applyUsageRemote, buildCommandAuthUrl, buildCommandCodeTuiSection, capabilityDescription, commandCodeSearchSelection, commandDefinition, compareByPlan, dealLabel, formatContext, inject, loginStatusSchema, matchModelRule, modelVisibleInPlan, name, parseLoginStatus, peakPricingLabel, peakPricingState, planLabel, projectSlugFromPath, resolveAdapterOptions, resolveAuthFileApiKey, selectAccountForModel, selectActiveAccount, selectCommandCodeSearchProvider, studioBaseForApiBase, subscriptionPlanInfo, usageReportSchema, validateCommandApiKey };
7533
+ export { ACTIVE_ACCOUNT_AUTO, BILLING_ACCESS_TTL_MS, COMMANDCODE_SEARCH_PROVIDER_ID, COMMAND_CODE_CLI_VERSION, CommandCodeAccountPool, CommandCodeAdapter, CommandCodeLoginFlow, CommandCodeSearchProvider, CommandCodeUsageService, Config, DEFAULT_API_BASE, DEFAULT_GENERATE_MAX_TOKENS, DEFAULT_MAX_OUTPUT_TOKENS, DEFAULT_MODELS_CACHE_PATH, DEFAULT_REQUEST_TIMEOUT_MS, DEFAULT_STREAM_IDLE_TIMEOUT_MS, DEFAULT_WEB_SEARCH_PROVIDER_ID, KNOWN_DEALS, KNOWN_EFFORTS, KNOWN_IMAGE_MODELS, KNOWN_PEAK_PRICING, KNOWN_PLANS, KNOWN_SUBSCRIPTION_PLANS, KNOWN_THINKING_MODELS, LANG_AUTO, LOGIN_ALLOWED_ORIGINS, LOGIN_BEGIN_ENDPOINT, LOGIN_BODY_LIMIT_BYTES, LOGIN_CANCEL_ENDPOINT, LOGIN_MAX_PORT_ATTEMPTS, LOGIN_START_PORT, LOGIN_STATUS_ENDPOINT, LOGIN_TIMEOUT_MS, LegacySettingsSchema, PLAN_LABELS, PLAN_ORDER, PROVIDER, USAGE_REPORT_ENDPOINT, accountUsable, apply, applyCommandCodeSearchSelection, applyCommandCodeTuiSettings, applyCommands, applyUsageRemote, buildCommandAuthUrl, buildCommandCodeTuiSection, capabilityDescription, commandCodeSearchSelection, commandDefinition, compareByPlan, dealLabel, formatContext, inject, loginStatusSchema, matchModelRule, modelVisibleInPlan, name, parseLoginStatus, peakPricingLabel, peakPricingState, planLabel, projectSlugFromPath, resolveAdapterOptions, resolveAuthFileApiKey, selectAccountForModel, selectActiveAccount, selectCommandCodeSearchProvider, studioBaseForApiBase, subscriptionPlanInfo, usageReportSchema, validateCommandApiKey };
6933
7534
 
6934
7535
  //# sourceMappingURL=index.js.map