@mars-sea/dsh-commandcode-provider 0.11.8 → 0.11.10

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";
@@ -284,6 +284,27 @@ const KNOWN_EFFORTS = {
284
284
  "xhigh",
285
285
  "max"
286
286
  ],
287
+ "claude-opus-5-5": [
288
+ "low",
289
+ "medium",
290
+ "high",
291
+ "xhigh",
292
+ "max"
293
+ ],
294
+ "gpt-6-luna": [
295
+ "low",
296
+ "medium",
297
+ "high",
298
+ "xhigh",
299
+ "max"
300
+ ],
301
+ "gpt-6-sol": [
302
+ "low",
303
+ "medium",
304
+ "high",
305
+ "xhigh",
306
+ "max"
307
+ ],
287
308
  "MiniMaxAI/MiniMax-M3": [
288
309
  "low",
289
310
  "medium",
@@ -320,6 +341,7 @@ const KNOWN_IMAGE_MODELS = /* @__PURE__ */ new Set([
320
341
  "claude-haiku-4-5-20251001",
321
342
  "claude-opus-4-7",
322
343
  "claude-opus-4-8",
344
+ "claude-opus-5-5",
323
345
  "claude-opus-5",
324
346
  "claude-sonnet-4-6",
325
347
  "claude-sonnet-5",
@@ -339,6 +361,8 @@ const KNOWN_IMAGE_MODELS = /* @__PURE__ */ new Set([
339
361
  "gpt-5.6-sol",
340
362
  "gpt-5.6-terra",
341
363
  "gpt-6-astra",
364
+ "gpt-6-luna",
365
+ "gpt-6-sol",
342
366
  "meta/muse-spark-1.1",
343
367
  "meta/muse-spark-1.2",
344
368
  "meta/muse-spark-1.2-contributor",
@@ -365,6 +389,69 @@ const KNOWN_IMAGE_MODELS = /* @__PURE__ */ new Set([
365
389
  "z-ai/glm-5.3-flashx"
366
390
  ]);
367
391
  /**
392
+ * Models WITHOUT a zero-data-retention upstream, per the official CLI's own
393
+ * registry (`command-code@1.64.0` `dist/cli.mjs`: `modelSupportsZdr(id)` is
394
+ * exactly `!nonZdrSet.has(canonicalize(id))`, and `knownModelSupportsZdr`
395
+ * carries the same membership in the sibling route table — the union is this
396
+ * set). The official docs (commandcode.ai/docs/resources/zdr) put it in prose
397
+ * — "99% of our models have ZDR-capable upstreams … only a small handful of
398
+ * models are affected" — so the CLI's exclusion list is the only per-model
399
+ * evidence there is; a ZDR request naming one of these fails with HTTP 422
400
+ * `cmd_zdr_no_providers` instead of routing through a provider that retains.
401
+ *
402
+ * Why a NEGATIVE set, and why "not listed" answers TRUE: 99% of the catalog is
403
+ * covered, so the maintained difference is the exception list. This helper is
404
+ * informational; the adapter sends the ZDR header for EVERY request when the
405
+ * switch is on. The provider remains the routing authority and refuses an
406
+ * unsupported model rather than silently dropping the privacy guarantee.
407
+ *
408
+ * `minimax/minimax-m3-free` is the one entry the public catalog
409
+ * (`/provider/v1/models`) does not serve (it is CLI/pricing-visible and hidden
410
+ * from the picker); it stays listed because the CLI carries it and a Go-plan
411
+ * request can still name it.
412
+ *
413
+ * Keep in sync via the dsh-commandcode-upstream skill: the CLI's registry data
414
+ * (its `zdr:{only:[…]}` provider routes and the per-provider `zdr`/`noTraining`
415
+ * flags) is upstream-internal routing, not a per-model contract, so this table
416
+ * is the snapshot of the exclusion set and nothing more. The 1.62.0 → 1.64.0
417
+ * diff of that union is EMPTY: both anchors were extracted from both bundles
418
+ * during the 2026-09-23 check and each carries the same 20 members — which is
419
+ * how rare a change here is expected to be. `meituan/LongCat-2.0` is the one
420
+ * member the two anchors disagree about (it is in `modelSupportsZdr`'s set in
421
+ * both releases and in neither `knownModelSupportsZdr` set), so the union is
422
+ * what this table follows; reading only the sibling route table would drop it.
423
+ */
424
+ const KNOWN_NON_ZDR_MODELS = /* @__PURE__ */ new Set([
425
+ "MiniMaxAI/MiniMax-M3",
426
+ "Qwen/Qwen3.8-Max-0902",
427
+ "meituan/LongCat-2.0",
428
+ "meta/muse-spark-1.1",
429
+ "meta/muse-spark-1.2",
430
+ "meta/muse-spark-1.2-contributor",
431
+ "meta/muse-spark-1.3",
432
+ "meta/muse-spark-1.3-contributor",
433
+ "minimax/minimax-m3-free",
434
+ "poolside/laguna-s-2.1-free",
435
+ "sakana/fugu-ultra",
436
+ "stepfun/Step-3.7-Flash",
437
+ "stepfun/Step-5-Preview",
438
+ "xai/grok-4.5",
439
+ "xai/grok-4.6",
440
+ "xiaomi/mimo-v2.6-flash",
441
+ "xiaomi/mimo-v2.6-pro",
442
+ "xiaomi/mimo-v2.6-pro-ultraspeed",
443
+ "z-ai/glm-5.3-flashx",
444
+ "zai-org/GLM-5.2-Fast"
445
+ ]);
446
+ /**
447
+ * Whether the CLI snapshot lists a ZDR-capable upstream for `modelId`. This is
448
+ * informational, never a reason to omit the header when ZDR is enabled: the
449
+ * provider may add coverage or lack capacity after this snapshot was taken.
450
+ */
451
+ function supportsZeroDataRetention(modelId) {
452
+ return !KNOWN_NON_ZDR_MODELS.has(modelId);
453
+ }
454
+ /**
368
455
  * Models the official CLI's model table (command-code@1.53.0) marks
369
456
  * `reasoning:!0` but defines no selectable `reasoning_effort` levels — they
370
457
  * think automatically, with Command Code driving the depth. This is the
@@ -508,6 +595,7 @@ const KNOWN_PLANS = {
508
595
  "deepseek/deepseek-v4-flash-vision-exp": "go",
509
596
  "deepseek/deepseek-v4-pro": "go",
510
597
  "gpt-5.6-luna": "go",
598
+ "gpt-6-luna": "go",
511
599
  "meituan/LongCat-2.0": "go",
512
600
  "inclusionai/ling-3.0-flash-sante:free": "go",
513
601
  "meta/muse-spark-1.2-contributor": "go",
@@ -558,11 +646,13 @@ const KNOWN_PLANS = {
558
646
  "gpt-5.4-mini": "pro",
559
647
  "gpt-5.5": "pro",
560
648
  "gpt-5.6-terra": "pro",
649
+ "gpt-6-sol": "pro",
561
650
  "meta/muse-spark-1.1": "pro",
562
651
  "claude-fable-5-1": "provider",
563
652
  "claude-fable-5": "provider",
564
653
  "claude-opus-4-7": "provider",
565
654
  "claude-opus-4-8": "provider",
655
+ "claude-opus-5-5": "provider",
566
656
  "claude-opus-5": "provider",
567
657
  "gpt-6-astra": "provider",
568
658
  "sakana/fugu-ultra": "provider"
@@ -955,6 +1045,15 @@ const MODEL_PRICE_ROWS = [
955
1045
  6.25
956
1046
  ]
957
1047
  },
1048
+ {
1049
+ id: "claude-opus-5-5",
1050
+ rates: [
1051
+ 4,
1052
+ 20,
1053
+ .2,
1054
+ 5
1055
+ ]
1056
+ },
958
1057
  {
959
1058
  id: "claude-sonnet-4-6",
960
1059
  rates: [
@@ -1274,6 +1373,52 @@ const MODEL_PRICE_ROWS = [
1274
1373
  25
1275
1374
  ] }]
1276
1375
  },
1376
+ {
1377
+ id: "gpt-6-luna",
1378
+ rates: [
1379
+ .1,
1380
+ .5,
1381
+ .01,
1382
+ .125
1383
+ ],
1384
+ contextTiers: [{
1385
+ "maxContext": 272e3,
1386
+ "rates": [
1387
+ .1,
1388
+ .5,
1389
+ .01,
1390
+ .125
1391
+ ]
1392
+ }, { "rates": [
1393
+ .2,
1394
+ .75,
1395
+ .02,
1396
+ .25
1397
+ ] }]
1398
+ },
1399
+ {
1400
+ id: "gpt-6-sol",
1401
+ rates: [
1402
+ 2,
1403
+ 10,
1404
+ .2,
1405
+ 2.5
1406
+ ],
1407
+ contextTiers: [{
1408
+ "maxContext": 272e3,
1409
+ "rates": [
1410
+ 2,
1411
+ 10,
1412
+ .2,
1413
+ 2.5
1414
+ ]
1415
+ }, { "rates": [
1416
+ 4,
1417
+ 15,
1418
+ .4,
1419
+ 5
1420
+ ] }]
1421
+ },
1277
1422
  {
1278
1423
  id: "grok-4.5",
1279
1424
  rates: [
@@ -1856,11 +2001,11 @@ function costGroupKey(group, table) {
1856
2001
  //#endregion
1857
2002
  //#region src/cost-projection.ts
1858
2003
  const record$2 = (v) => typeof v === "object" && v !== null && !Array.isArray(v) ? v : void 0;
1859
- const finite = (v) => typeof v === "number" && Number.isFinite(v) && v >= 0;
2004
+ const finite$1 = (v) => typeof v === "number" && Number.isFinite(v) && v >= 0;
1860
2005
  function tokensOf(value) {
1861
2006
  const u = record$2(value);
1862
- if (!u || !finite(u.inputTokens) || !finite(u.outputTokens)) return void 0;
1863
- if (u.cacheReadTokens !== void 0 && !finite(u.cacheReadTokens) || u.cacheWriteTokens !== void 0 && !finite(u.cacheWriteTokens)) return void 0;
2007
+ if (!u || !finite$1(u.inputTokens) || !finite$1(u.outputTokens)) return void 0;
2008
+ if (u.cacheReadTokens !== void 0 && !finite$1(u.cacheReadTokens) || u.cacheWriteTokens !== void 0 && !finite$1(u.cacheWriteTokens)) return void 0;
1864
2009
  return {
1865
2010
  uncachedInputTokens: u.inputTokens,
1866
2011
  outputTokens: u.outputTokens,
@@ -1886,13 +2031,13 @@ function usageOf(type, data) {
1886
2031
  }
1887
2032
  function parseState(value) {
1888
2033
  const s = record$2(value), facts = record$2(s?.facts);
1889
- if (!s || !facts || !finite(facts.pricingKey) || !Array.isArray(facts.groups)) throw new TypeError("invalid commandCodeCost state");
2034
+ if (!s || !facts || !finite$1(facts.pricingKey) || !Array.isArray(facts.groups)) throw new TypeError("invalid commandCodeCost state");
1890
2035
  const group = (v) => {
1891
2036
  const g = record$2(v), t = record$2(g?.tokens);
1892
- return !!g && typeof g.provider === "string" && typeof g.model === "string" && (g.at === null || finite(g.at)) && finite(g.contextTokens) && !!t && COST_TOKEN_KEYS.every((k) => finite(t[k]));
2037
+ return !!g && typeof g.provider === "string" && typeof g.model === "string" && (g.at === null || finite$1(g.at)) && finite$1(g.contextTokens) && !!t && COST_TOKEN_KEYS.every((k) => finite$1(t[k]));
1893
2038
  };
1894
2039
  const sel = record$2(s.selection), last = record$2(s.last);
1895
- if (!(s.selection === null || sel && typeof sel.provider === "string" && typeof sel.model === "string") || !(s.at === null || finite(s.at)) || !facts.groups.every(group) || !(s.last === null || last && finite(last.turn) && finite(last.step) && group(last.group))) throw new TypeError("invalid commandCodeCost state");
2040
+ if (!(s.selection === null || sel && typeof sel.provider === "string" && typeof sel.model === "string") || !(s.at === null || finite$1(s.at)) || !facts.groups.every(group) || !(s.last === null || last && finite$1(last.turn) && finite$1(last.step) && group(last.group))) throw new TypeError("invalid commandCodeCost state");
1896
2041
  return value;
1897
2042
  }
1898
2043
  /** Optional registry uses only synchronous parse/view; no new runtime module is required. */
@@ -1926,11 +2071,11 @@ function createCostProjection(table = modelPriceTable()) {
1926
2071
  }
1927
2072
  if (event.type === "step/start" || event.type === "llm/retry-started") return {
1928
2073
  ...state,
1929
- at: finite(event.time) ? event.time : null,
2074
+ at: finite$1(event.time) ? event.time : null,
1930
2075
  last: null
1931
2076
  };
1932
2077
  const tokens = usageOf(event.type, data);
1933
- if (tokens === void 0 || !finite(data.turn) || !finite(data.step)) return state;
2078
+ if (tokens === void 0 || !finite$1(data.turn) || !finite$1(data.step)) return state;
1934
2079
  const previous = state.last?.turn === data.turn && state.last.step === data.step ? state.last.group : void 0;
1935
2080
  const group = {
1936
2081
  provider: state.selection?.provider ?? "",
@@ -2652,6 +2797,181 @@ function scaleBy(size, factor) {
2652
2797
  };
2653
2798
  }
2654
2799
  //#endregion
2800
+ //#region src/stream-trace.ts
2801
+ /**
2802
+ * Opt-in raw-stream trace for the Command Code transports.
2803
+ *
2804
+ * Why this exists: this route's failure mode of record is a response that ENDS
2805
+ * EARLY. The gateway, an intermediary proxy, or the network closes the socket
2806
+ * mid-generation, so the adapter's read loop sees `done` without ever seeing
2807
+ * the transport's terminal event. From inside the adapter those two endings are
2808
+ * indistinguishable — the provider cut the stream, a proxy timed out, or the
2809
+ * socket was dropped — and the only artifact that separates them is the raw
2810
+ * wire: which events actually arrived, when the last one landed, how long the
2811
+ * socket then sat silent, and how it ended. Without that, a truncated answer is
2812
+ * a report with no evidence attached.
2813
+ *
2814
+ * Enable it per process:
2815
+ *
2816
+ * ```sh
2817
+ * DSH_COMMANDCODE_TRACE=/tmp/cc-stream.jsonl dsh web
2818
+ * DSH_COMMANDCODE_TRACE=1 dsh web # -> $TMPDIR/dsh-commandcode-stream.jsonl
2819
+ * ```
2820
+ *
2821
+ * Any value that is not a flag is a path, and the explicit off-spellings
2822
+ * (`0`, `false`, `no`, `off`) are recognized as "off" rather than being taken
2823
+ * for a file name.
2824
+ *
2825
+ * Every model request appends one JSONL record per raw chunk plus lifecycle
2826
+ * records (`response`, `eof`, `idle-timeout`, `read-error`, `end`,
2827
+ * `stream-close`), each stamped with `at` — milliseconds since that request's
2828
+ * first byte. The chunk records carry the provider's own payload verbatim
2829
+ * (bounded per chunk), because a truncated answer is exactly what is being
2830
+ * diagnosed: treat the file as conversation content, not as a log safe to
2831
+ * share. It never carries the request body, an API key, or any request header.
2832
+ *
2833
+ * Deliberate trade-offs:
2834
+ * - OFF costs one `process.env` read per request.
2835
+ * - ON appends synchronously, one file open per chunk. The trace must survive
2836
+ * a host killed mid-diagnosis, and the microseconds it costs are irrelevant
2837
+ * beside the seconds-scale silences it is there to measure.
2838
+ * - A trace that cannot be written turns itself off instead of failing the
2839
+ * request it is observing: a diagnostic must never become the outage.
2840
+ *
2841
+ * @module
2842
+ */
2843
+ /** Environment variable that turns the trace on. */
2844
+ const STREAM_TRACE_ENV = "DSH_COMMANDCODE_TRACE";
2845
+ /** File used when the variable is set to a bare flag instead of a path. */
2846
+ const STREAM_TRACE_DEFAULT_FILE = "dsh-commandcode-stream.jsonl";
2847
+ /** One raw chunk is recorded verbatim up to this many characters. */
2848
+ const STREAM_TRACE_MAX_TEXT = 16384;
2849
+ /** Values of {@link STREAM_TRACE_ENV} that mean "trace to the default file". */
2850
+ const FLAG_VALUES = /* @__PURE__ */ new Set([
2851
+ "1",
2852
+ "true",
2853
+ "yes",
2854
+ "on"
2855
+ ]);
2856
+ /**
2857
+ * Values that explicitly mean OFF.
2858
+ *
2859
+ * A user turning the trace off writes `false` as readily as they write `1` to
2860
+ * turn it on, and without this set every one of these spellings fell through to
2861
+ * "…so it must be a path" — the trace switched ON and wrote the conversation to
2862
+ * a file NAMED `false` (or `0`, or `off`) in the process's working directory.
2863
+ */
2864
+ const OFF_VALUES = /* @__PURE__ */ new Set([
2865
+ "0",
2866
+ "false",
2867
+ "no",
2868
+ "off"
2869
+ ]);
2870
+ /** The inert trace used whenever the switch is off. */
2871
+ const OFF = {
2872
+ enabled: false,
2873
+ record: () => void 0,
2874
+ close: () => void 0
2875
+ };
2876
+ /**
2877
+ * Resolve the trace file for this process.
2878
+ *
2879
+ * @param raw - the variable's raw value; defaults to the live environment.
2880
+ * @returns the file to append to, or undefined when the trace is off.
2881
+ */
2882
+ function resolveStreamTracePath(raw = process.env[STREAM_TRACE_ENV]) {
2883
+ const value = raw?.trim();
2884
+ if (value === void 0 || value === "") return void 0;
2885
+ const lower = value.toLowerCase();
2886
+ if (OFF_VALUES.has(lower)) return void 0;
2887
+ if (FLAG_VALUES.has(lower)) return join(tmpdir(), STREAM_TRACE_DEFAULT_FILE);
2888
+ return value;
2889
+ }
2890
+ /**
2891
+ * Clamp one recorded payload so a single chunk cannot consume the whole cap.
2892
+ *
2893
+ * @param text - the text to record.
2894
+ * @param max - characters to keep, defaulting to {@link STREAM_TRACE_MAX_TEXT}.
2895
+ * @returns `text` unchanged, or its head plus a marker naming what was dropped.
2896
+ */
2897
+ function boundTraceText(text, max = STREAM_TRACE_MAX_TEXT) {
2898
+ if (text.length <= max) return text;
2899
+ return `${text.slice(0, max)}…[+${text.length - max} chars]`;
2900
+ }
2901
+ /**
2902
+ * Open one request's trace.
2903
+ *
2904
+ * @param scope - what the records describe (the `provider/model` route).
2905
+ * @param options - `path` overrides the environment (tests pass a temp file);
2906
+ * `now` overrides the clock the `at` stamps are measured with.
2907
+ * @returns a writer that never throws and never fails the request.
2908
+ */
2909
+ function openStreamTrace(scope, options = {}) {
2910
+ const path = options.path ?? resolveStreamTracePath();
2911
+ if (path === void 0) return OFF;
2912
+ const now = options.now ?? Date.now;
2913
+ const startedAt = now();
2914
+ let written = 0;
2915
+ let stopped = false;
2916
+ let capped = false;
2917
+ const terminals = /* @__PURE__ */ new Set();
2918
+ try {
2919
+ mkdirSync(dirname(path), { recursive: true });
2920
+ } catch {
2921
+ return OFF;
2922
+ }
2923
+ const write = (record) => {
2924
+ if (stopped) return;
2925
+ const terminal = record.event === "end" || record.event === "stream-close";
2926
+ if (capped && !terminal) return;
2927
+ if (terminal && terminals.has(String(record.event))) return;
2928
+ let line;
2929
+ try {
2930
+ line = `${JSON.stringify({
2931
+ at: now() - startedAt,
2932
+ scope,
2933
+ ...record
2934
+ })}\n`;
2935
+ } catch {
2936
+ return;
2937
+ }
2938
+ if (terminal) {
2939
+ if (Buffer.byteLength(line) > 16384) return;
2940
+ terminals.add(String(record.event));
2941
+ } else if (written + Buffer.byteLength(line) > 4194304) {
2942
+ capped = true;
2943
+ line = `${JSON.stringify({
2944
+ at: now() - startedAt,
2945
+ scope,
2946
+ event: "trace-capped",
2947
+ bytes: written
2948
+ })}\n`;
2949
+ }
2950
+ try {
2951
+ appendFileSync(path, line);
2952
+ written += Buffer.byteLength(line);
2953
+ } catch {
2954
+ stopped = true;
2955
+ }
2956
+ };
2957
+ write({ event: "stream-open" });
2958
+ return {
2959
+ enabled: true,
2960
+ record(event, fields) {
2961
+ write(fields === void 0 ? { event } : {
2962
+ event,
2963
+ ...fields
2964
+ });
2965
+ },
2966
+ close() {
2967
+ write({
2968
+ event: "stream-close",
2969
+ bytes: written
2970
+ });
2971
+ }
2972
+ };
2973
+ }
2974
+ //#endregion
2655
2975
  //#region src/adapter.ts
2656
2976
  /**
2657
2977
  * DeepSeek Harness LLM adapter for the Command Code Provider API.
@@ -2674,7 +2994,7 @@ function scaleBy(size, factor) {
2674
2994
  * per-request options thunk and an API-key resolver from the plugin entry
2675
2995
  * (src/index.ts), so a settings change reaches the very next request.
2676
2996
  */
2677
- const COMMAND_CODE_CLI_VERSION = "1.62.0";
2997
+ const COMMAND_CODE_CLI_VERSION = "1.64.0";
2678
2998
  const DEFAULT_API_BASE = "https://api.commandcode.ai";
2679
2999
  const DEFAULT_GENERATE_MAX_TOKENS = 64e3;
2680
3000
  const DEFAULT_MAX_OUTPUT_TOKENS = 65536;
@@ -2767,15 +3087,21 @@ function periodEndValue(value) {
2767
3087
  * Terminal stream-error markers from the official CLI (`Xw` in command-code's
2768
3088
  * cli.mjs): these always mean "retrying cannot succeed", so the adapter must
2769
3089
  * not classify them as transient server errors.
3090
+ *
3091
+ * Written with spaces rather than the CLI's underscores, because
3092
+ * {@link hasTerminalStreamMarker} normalizes the separator before matching:
3093
+ * a JSON body usually carries `premium credits exhausted` while the CLI's own
3094
+ * list is written `premium_credits_exhausted`, and both are the same refusal
3095
+ * (the pre-stream classifier normalizes identically).
2770
3096
  */
2771
3097
  const TERMINAL_STREAM_ERROR_MARKERS = [
2772
- "premium_credits_exhausted",
2773
- "model_not_in_plan",
3098
+ "premium credits exhausted",
3099
+ "model not in plan",
2774
3100
  "insufficient credits"
2775
3101
  ];
2776
3102
  function hasTerminalStreamMarker(message) {
2777
- const lower = message.toLowerCase();
2778
- return TERMINAL_STREAM_ERROR_MARKERS.some((marker) => lower.includes(marker));
3103
+ const normalized = message.toLowerCase().replaceAll("_", " ");
3104
+ return TERMINAL_STREAM_ERROR_MARKERS.some((marker) => normalized.includes(marker));
2779
3105
  }
2780
3106
  /**
2781
3107
  * Context-window wording the provider uses to reject a request that outgrew
@@ -2792,6 +3118,19 @@ function isContextOverflowDetail(detail) {
2792
3118
  return isContextWindowExceededError(detail) || CLI_CONTEXT_OVERFLOW_PATTERN.test(detail);
2793
3119
  }
2794
3120
  /**
3121
+ * Whether one rejection says "ZDR requested, no ZDR-capable upstream": the
3122
+ * provider spells the code `cmd_zdr_no_providers` in JSON error bodies and
3123
+ * `CMD_ZDR_NO_PROVIDERS` in plain-text ones (the official CLI's own
3124
+ * classifier checks both spellings, and its error text tells the user to
3125
+ * unset `CMD_ZDR`). Underscore-separated matching normalizes the separator, so
3126
+ * one comparison covers the variants — the same normalization the pre-stream
3127
+ * rejection classifier and `hasTerminalStreamMarker` already use.
3128
+ */
3129
+ const ZDR_NO_PROVIDERS_PATTERN = /cmd[_\s-]?zdr[_\s-]?no[_\s-]?providers/i;
3130
+ function isZdrNoProviders(providerCode, providerDetail, errText) {
3131
+ return ZDR_NO_PROVIDERS_PATTERN.test(providerCode ?? "") || ZDR_NO_PROVIDERS_PATTERN.test(providerDetail) || ZDR_NO_PROVIDERS_PATTERN.test(errText);
3132
+ }
3133
+ /**
2795
3134
  * Classify one in-band stream `error` payload into the failure the caller
2796
3135
  * throws. Shared by both transports — the CLI transport delivers it as an
2797
3136
  * `error` event (`{ type: 'error', error }`), the Provider API transport as a
@@ -2814,7 +3153,10 @@ function isContextOverflowDetail(detail) {
2814
3153
  * (issue #39).
2815
3154
  * - Credits/plan wording is terminal, so an exhausted balance or a model
2816
3155
  * outside the plan is not retried as if it were a transient rate limit.
2817
- * - Only then does the status/`isRetryable` pair decide, as before.
3156
+ * - Only then does the status/`isRetryable` pair decide — and an explicit
3157
+ * refusal (`isRetryable: false`) or a terminal marker OUTRANKS a status, so a
3158
+ * 5xx carrying "insufficient credits" stays terminal instead of entering the
3159
+ * retry cadence as `SERVER`.
2818
3160
  */
2819
3161
  function streamErrorToLlmError(value, fallbackMessage) {
2820
3162
  const record = isRecord(value) ? value : void 0;
@@ -2829,7 +3171,7 @@ function streamErrorToLlmError(value, fallbackMessage) {
2829
3171
  const statusOption = statusCode !== void 0 ? { status: statusCode } : void 0;
2830
3172
  if (isContextOverflowDetail(detail)) return new LlmError(`Command Code stream error: ${message};Command Code API 拒绝了这次请求:内容超出模型上下文窗口。正在压缩上下文后重试;如仍失败,请新建会话或减少上下文`, CONTEXT_WINDOW_EXCEEDED_CODE, statusOption);
2831
3173
  const terminal = hasTerminalStreamMarker(detail);
2832
- 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);
3174
+ 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);
2833
3175
  return new LlmError(`Command Code stream error: ${message}`, "SERVER", statusOption);
2834
3176
  }
2835
3177
  function recordOrEmpty(value) {
@@ -3033,42 +3375,32 @@ async function writeModelsCache(cachePath, models) {
3033
3375
  }
3034
3376
  }
3035
3377
  /**
3036
- * Whether an inbound message is a tool RESULT — a `user`-role message the
3037
- * producer tagged with `source: { kind: 'tool' }`.
3038
- *
3039
- * The tag is read defensively because it is PRODUCER-supplied and nothing
3040
- * validates it: dsh-llm's `Message` type declares `source` required, but the
3041
- * runtime never checks it and every adapter shipped before this one ignored the
3042
- * field outright (`dsh-llm-deepseek` never reads it). A third-party plugin that
3043
- * assembles its own history can therefore omit it and run correctly everywhere
3044
- * else — dsh-mneme's memory pipeline did — which made this adapter, the first
3045
- * to actually read the field, the first to crash on such a message with
3046
- * `TypeError: Cannot read properties of undefined (reading 'kind')` in the
3047
- * serialization phase, 0–8 ms in, before any request left the machine and with
3048
- * nothing in the error naming the message or the producer (issue #47).
3049
- *
3050
- * Two rules keep an untagged message from costing the turn:
3051
- *
3052
- * - A PRESENT tag is authoritative. `kind: 'user'`, `'model'`, `'plugin'` or
3053
- * any producer-added kind means "not a tool result", whatever the content
3054
- * looks like — the tag answers *who produced this*.
3055
- * - An ABSENT tag falls back to the content shape. A `user` message whose
3056
- * first block is a `tool-result` is a tool result by the harness's own
3057
- * definition (`ToolResultMessage` is exactly that shape with a
3058
- * `ToolMessageSource`), and the fallback is not cosmetic: `pairedToolCalls()`
3059
- * counts a `tool-result` block from ANY message, so classifying such a
3060
- * message as a plain user message would drop it at emission while its call
3061
- * still counted as paired — leaving the assistant's tool call unanswered on
3062
- * the wire, which the gateway rejects outright. FIRST block, not "any block":
3063
- * the tool-result branch reads `content[0]` and skips anything else, so
3064
- * claiming a mixed message as a tool result would discard its other blocks
3065
- * instead of sending them.
3066
- */
3067
- function isToolResultMessage(message) {
3068
- if (message.role !== "user") return false;
3378
+ * >=0.1.7 puts toolCallId/isError on a role:'tool' message and carries raw
3379
+ * content blocks. Older engines wrap those fields in the first tool-result
3380
+ * block of a role:'user' message. Read structurally so the bundle still builds
3381
+ * and loads against older peers. Pairing and emission MUST use this same view:
3382
+ * otherwise a dropped result either drops its call too (making the model
3383
+ * forget completed work), or leaves an unanswered call on the wire.
3384
+ *
3385
+ * For the legacy envelope a present source tag remains authoritative; an
3386
+ * absent tag falls back to the first block (issue #47). The modern role is
3387
+ * itself the discriminator, as it is for the Harness's own serializers.
3388
+ */
3389
+ function toolResultOf(message) {
3390
+ const current = message;
3391
+ if (current.role === "tool") {
3392
+ if (typeof current.toolCallId !== "string" || current.toolCallId === "") return void 0;
3393
+ return {
3394
+ toolCallId: current.toolCallId,
3395
+ isError: current.isError ?? false,
3396
+ content: current.content
3397
+ };
3398
+ }
3399
+ if (message.role !== "user") return void 0;
3069
3400
  const kind = message.source?.kind;
3070
- if (kind !== void 0) return kind === "tool";
3071
- return message.content?.[0]?.type === "tool-result";
3401
+ if (kind !== void 0 && kind !== "tool") return void 0;
3402
+ const block = message.content[0];
3403
+ return block?.type === "tool-result" ? block : void 0;
3072
3404
  }
3073
3405
  /**
3074
3406
  * Collect the tool calls that have a paired tool result, plus each call's
@@ -3081,12 +3413,13 @@ function pairedToolCalls(messages) {
3081
3413
  const callIds = /* @__PURE__ */ new Set();
3082
3414
  const names = /* @__PURE__ */ new Map();
3083
3415
  const resultIds = /* @__PURE__ */ new Set();
3084
- for (const message of messages) for (const block of message.content) {
3085
- if (message.role === "assistant" && block.type === "tool-call") {
3416
+ for (const message of messages) {
3417
+ const result = toolResultOf(message);
3418
+ if (result) resultIds.add(result.toolCallId);
3419
+ for (const block of message.content) if (message.role === "assistant" && block.type === "tool-call") {
3086
3420
  callIds.add(block.id);
3087
3421
  names.set(block.id, block.name);
3088
3422
  }
3089
- if (block.type === "tool-result") resultIds.add(block.toolCallId);
3090
3423
  }
3091
3424
  return {
3092
3425
  ids: new Set([...callIds].filter((id) => resultIds.has(id))),
@@ -3394,7 +3727,8 @@ async function messagesToCC(messages, readImage) {
3394
3727
  };
3395
3728
  for (const message of messages) {
3396
3729
  if (message.role === "system") continue;
3397
- if (message.role === "user" && !isToolResultMessage(message)) {
3730
+ const result = toolResultOf(message);
3731
+ if (message.role === "user" && !result) {
3398
3732
  flushPendingImages();
3399
3733
  const parts = [];
3400
3734
  for (const block of message.content) {
@@ -3437,18 +3771,17 @@ async function messagesToCC(messages, readImage) {
3437
3771
  });
3438
3772
  continue;
3439
3773
  }
3440
- if (isToolResultMessage(message)) {
3441
- const block = message.content[0];
3442
- if (!block || block.type !== "tool-result" || !paired.has(block.toolCallId)) continue;
3443
- const media = toolResultMedia(block);
3444
- const wireToolCallId = wireIds.get(block.toolCallId) ?? block.toolCallId;
3774
+ if (result) {
3775
+ if (!paired.has(result.toolCallId)) continue;
3776
+ const media = toolResultMedia(result);
3777
+ const wireToolCallId = wireIds.get(result.toolCallId) ?? result.toolCallId;
3445
3778
  out.push({
3446
3779
  role: "tool",
3447
3780
  content: [{
3448
3781
  type: "tool-result",
3449
3782
  toolCallId: wireToolCallId,
3450
- toolName: toolNames.get(block.toolCallId) || "unknown",
3451
- output: block.isError ? {
3783
+ toolName: toolNames.get(result.toolCallId) || "unknown",
3784
+ output: result.isError ? {
3452
3785
  type: "error-text",
3453
3786
  value: toolResultTextForWire(media)
3454
3787
  } : {
@@ -3506,7 +3839,8 @@ async function messagesToOpenAI(messages, readImage) {
3506
3839
  };
3507
3840
  for (const message of messages) {
3508
3841
  if (message.role === "system") continue;
3509
- if (message.role === "user" && !isToolResultMessage(message)) {
3842
+ const result = toolResultOf(message);
3843
+ if (message.role === "user" && !result) {
3510
3844
  flushPendingImages();
3511
3845
  const parts = [];
3512
3846
  for (const block of message.content) {
@@ -3552,11 +3886,10 @@ async function messagesToOpenAI(messages, readImage) {
3552
3886
  out.push(assistant);
3553
3887
  continue;
3554
3888
  }
3555
- if (isToolResultMessage(message)) {
3556
- const block = message.content[0];
3557
- if (!block || block.type !== "tool-result" || !paired.has(block.toolCallId)) continue;
3558
- const media = toolResultMedia(block);
3559
- const wireToolCallId = wireIds.get(block.toolCallId) ?? block.toolCallId;
3889
+ if (result) {
3890
+ if (!paired.has(result.toolCallId)) continue;
3891
+ const media = toolResultMedia(result);
3892
+ const wireToolCallId = wireIds.get(result.toolCallId) ?? result.toolCallId;
3560
3893
  out.push({
3561
3894
  role: "tool",
3562
3895
  tool_call_id: wireToolCallId,
@@ -3761,6 +4094,7 @@ async function connectGenerate(deps, key, protocol, body) {
3761
4094
  if (options.signal) options.signal.removeEventListener("abort", onCallerAbort);
3762
4095
  };
3763
4096
  let response;
4097
+ const zdr = connection.zdr === true;
3764
4098
  const headers = protocol === "cli" ? {
3765
4099
  "Content-Type": "application/json",
3766
4100
  Authorization: `Bearer ${key}`,
@@ -3769,11 +4103,13 @@ async function connectGenerate(deps, key, protocol, body) {
3769
4103
  "x-project-slug": projectSlugFromPath(connection.workingDir),
3770
4104
  "x-taste-learning": "true",
3771
4105
  "x-co-flag": "false",
4106
+ ...zdr ? { "x-cmd-zdr": "1" } : {},
3772
4107
  ...attributionHeaders()
3773
4108
  } : {
3774
4109
  "Content-Type": "application/json",
3775
4110
  Authorization: `Bearer ${key}`,
3776
4111
  Accept: "text/event-stream",
4112
+ ...zdr ? { "x-cmd-zdr": "1" } : {},
3777
4113
  ...attributionHeaders()
3778
4114
  };
3779
4115
  try {
@@ -3817,6 +4153,7 @@ function createBlockAssembler() {
3817
4153
  reasoningIndex: -1,
3818
4154
  reasoningContent: "",
3819
4155
  sawContent: false,
4156
+ finishReason: void 0,
3820
4157
  openAiToolCalls: []
3821
4158
  };
3822
4159
  }
@@ -4036,8 +4373,10 @@ var CommandCodeAdapter = class extends LlmAdapter {
4036
4373
  /** The headers every authenticated account endpoint shares. */
4037
4374
  async accountHeaders(apiKey) {
4038
4375
  const connection = this.deps.options();
4376
+ const raw = apiKey ?? await this.deps.resolveApiKey(connection);
4377
+ const named = connection.apiKeyEnv;
4039
4378
  return {
4040
- Authorization: `Bearer ${apiKey ?? await this.deps.resolveApiKey(connection)}`,
4379
+ Authorization: `Bearer ${assertUsableApiKey(raw, "llm-commandcode", typeof named === "string" && named !== "" ? named : "the stored credential")}`,
4041
4380
  "x-command-code-version": COMMAND_CODE_CLI_VERSION,
4042
4381
  "x-cli-environment": "production",
4043
4382
  ...attributionHeaders()
@@ -4049,11 +4388,16 @@ var CommandCodeAdapter = class extends LlmAdapter {
4049
4388
  * failure accounting: the billing probe fails open silently, the usage
4050
4389
  * report books failures per endpoint. Non-2xx and non-record bodies come
4051
4390
  * back without a record; only a transport throw propagates to the caller.
4391
+ *
4392
+ * `timeoutMs` is the caller's budget for this one request. The default is the
4393
+ * CATALOG probe's short cap, which fits the picker's fail-open reads; the
4394
+ * usage report deliberately passes the connection's own request budget so an
4395
+ * account query is not held to a stricter (and invisible) limit than chat.
4052
4396
  */
4053
- async fetchEndpointJson(url, headers) {
4397
+ async fetchEndpointJson(url, headers, timeoutMs = MODELS_TIMEOUT_MS) {
4054
4398
  const response = await this.fetchImpl(url, {
4055
4399
  headers,
4056
- signal: AbortSignal.timeout(MODELS_TIMEOUT_MS)
4400
+ signal: AbortSignal.timeout(timeoutMs)
4057
4401
  });
4058
4402
  if (!response.ok) return { status: response.status };
4059
4403
  const parsed = await response.json();
@@ -4211,15 +4555,39 @@ var CommandCodeAdapter = class extends LlmAdapter {
4211
4555
  * view. Requires a usable API key (throws `MISSING_CREDENTIAL` otherwise).
4212
4556
  * Pass `apiKey` to report on a specific account of a multi-account pool;
4213
4557
  * the default resolves the currently active account.
4558
+ *
4559
+ * Two failure modes are NOT "the network is down", and the report must say
4560
+ * so rather than let {@link classifyTotalFailure} blame the connection:
4561
+ *
4562
+ * - A key that no HTTP header can carry (a stray newline from a paste, a
4563
+ * full-width character) makes `fetch` throw a `TypeError` **before any
4564
+ * I/O**, for every endpoint at once. The chat path refuses such a key
4565
+ * through `assertUsableApiKey`; this path must too, and it answers
4566
+ * `blocked: 'invalid-key'` instead of four phantom transport failures.
4567
+ * - The four endpoints get the SAME per-request budget as a chat call
4568
+ * (`connection.requestTimeoutMs`, default 60 s), not the catalog's 10 s
4569
+ * probe budget: on a slow link a 10 s cap made every account query time
4570
+ * out while chat kept working, which is exactly a "check your network"
4571
+ * banner that never clears.
4214
4572
  */
4215
4573
  async getUsage(apiKey) {
4216
- const base = this.deps.options().apiBase;
4217
- const headers = await this.accountHeaders(apiKey);
4574
+ const connection = this.deps.options();
4575
+ const base = connection.apiBase;
4576
+ let headers;
4577
+ try {
4578
+ headers = await this.accountHeaders(apiKey);
4579
+ } catch (error) {
4580
+ if (error instanceof LlmError && error.code === "INVALID_CREDENTIAL") return {
4581
+ failures: [error.message],
4582
+ blocked: "invalid-key"
4583
+ };
4584
+ throw error;
4585
+ }
4218
4586
  const failures = [];
4219
4587
  const failedStatuses = [];
4220
4588
  const getJson = async (path) => {
4221
4589
  try {
4222
- const { status, record } = await this.fetchEndpointJson(`${base}${path}`, headers);
4590
+ const { status, record } = await this.fetchEndpointJson(`${base}${path}`, headers, connection.requestTimeoutMs);
4223
4591
  if (record === void 0) {
4224
4592
  failures.push(`${path}: HTTP ${status}`);
4225
4593
  failedStatuses.push(status);
@@ -4305,7 +4673,7 @@ var CommandCodeAdapter = class extends LlmAdapter {
4305
4673
  reported = true;
4306
4674
  if (!window.exceeded) continue;
4307
4675
  exceeded = true;
4308
- resetAt = Math.max(resetAt, window.resetAt);
4676
+ if (window.resetAt > 0 && window.resetAt - Date.now() <= MAX_TRUSTED_RESET_MS) resetAt = Math.max(resetAt, window.resetAt);
4309
4677
  }
4310
4678
  if (!reported) return void 0;
4311
4679
  return {
@@ -4413,6 +4781,19 @@ var CommandCodeAdapter = class extends LlmAdapter {
4413
4781
  const reader = response.body.getReader();
4414
4782
  const decoder = new TextDecoder();
4415
4783
  let buffer = "";
4784
+ const trace = openStreamTrace(`${options.provider}/${options.model}`);
4785
+ const streamStartedAt = Date.now();
4786
+ let chunkCount = 0;
4787
+ let totalBytes = 0;
4788
+ let lastChunkAt = streamStartedAt;
4789
+ trace.record("response", {
4790
+ protocol,
4791
+ endpoint: protocol === "cli" ? `${connection.apiBase}/alpha/generate` : `${connection.apiBase}/provider/v1/chat/completions`,
4792
+ status: response.status,
4793
+ attempts: tried.size,
4794
+ maxTokens,
4795
+ ...reasoningEffort === void 0 ? {} : { reasoningEffort }
4796
+ });
4416
4797
  let idleTimer;
4417
4798
  let idleFired = false;
4418
4799
  const armIdle = () => {
@@ -4429,9 +4810,16 @@ var CommandCodeAdapter = class extends LlmAdapter {
4429
4810
  }
4430
4811
  };
4431
4812
  const asm = createBlockAssembler();
4432
- const handle = (event) => handleEvent(asm, protocol, event);
4813
+ let finish;
4814
+ let usage;
4815
+ let usageEmitted = false;
4816
+ let endRecorded = false;
4817
+ function* handle(event) {
4818
+ for (const chunk of handleEvent(asm, protocol, event)) if (chunk.type === "finish") finish = chunk;
4819
+ else if (chunk.type === "usage") usage = chunk.usage;
4820
+ else yield chunk;
4821
+ }
4433
4822
  try {
4434
- let finished = false;
4435
4823
  for (;;) {
4436
4824
  let read;
4437
4825
  armIdle();
@@ -4439,44 +4827,104 @@ var CommandCodeAdapter = class extends LlmAdapter {
4439
4827
  read = await reader.read();
4440
4828
  } catch (error) {
4441
4829
  if (options.signal?.aborted) throw error;
4830
+ trace.record("read-error", {
4831
+ chunks: chunkCount,
4832
+ bytes: totalBytes,
4833
+ silentMs: Date.now() - lastChunkAt,
4834
+ message: errorChain(error)
4835
+ });
4442
4836
  throw new LlmError(`Command Code API stream from ${connection.apiBase} failed while reading: ${errorChain(error)};Command Code API 流式响应中途断开——网络波动所致,重试通常可恢复`, "TRANSPORT", { cause: error });
4443
4837
  } finally {
4444
4838
  clearIdle();
4445
4839
  }
4446
4840
  const { done, value } = read;
4447
4841
  if (done) {
4842
+ trace.record("eof", {
4843
+ chunks: chunkCount,
4844
+ bytes: totalBytes,
4845
+ silentMs: Date.now() - lastChunkAt,
4846
+ totalMs: Date.now() - streamStartedAt,
4847
+ finishSeen: finish !== void 0,
4848
+ idleFired,
4849
+ buffered: buffer.length
4850
+ });
4448
4851
  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");
4449
- if (buffer.trim()) for (const chunk of handle(parseStreamEventLine(buffer))) {
4450
- yield chunk;
4451
- if (chunk.type === "finish") finished = true;
4452
- }
4852
+ if (buffer.trim()) yield* handle(parseStreamEventLine(buffer));
4453
4853
  break;
4454
4854
  }
4455
- buffer += decoder.decode(value, { stream: true });
4855
+ const text = decoder.decode(value, { stream: true });
4856
+ buffer += text;
4857
+ chunkCount += 1;
4858
+ totalBytes += value.byteLength;
4859
+ lastChunkAt = Date.now();
4860
+ trace.record("chunk", {
4861
+ n: chunkCount,
4862
+ bytes: value.byteLength,
4863
+ text: boundTraceText(text)
4864
+ });
4456
4865
  const lines = buffer.split("\n");
4457
4866
  buffer = lines.pop() ?? "";
4458
- for (const line of lines) {
4459
- const chunks = handle(parseStreamEventLine(line));
4460
- for (const chunk of chunks) {
4461
- yield chunk;
4462
- if (chunk.type === "finish") finished = true;
4463
- }
4464
- }
4465
- if (finished) break;
4867
+ for (const line of lines) yield* handle(parseStreamEventLine(line));
4868
+ if (finish !== void 0) break;
4466
4869
  }
4467
- if (!finished) {
4468
- yield* closeText(asm);
4469
- yield* closeReasoning(asm);
4470
- if (protocol === "openai") yield* emitOpenAiToolCalls(asm);
4471
- if (!asm.sawContent) throw new LlmError("Command Code returned an empty response;Command Code 返回了空响应,重试通常可恢复", "EMPTY_RESPONSE");
4870
+ if (usage !== void 0) {
4871
+ usageEmitted = true;
4472
4872
  yield {
4473
- type: "finish",
4474
- reason: { kind: "stop" }
4873
+ type: "usage",
4874
+ usage
4475
4875
  };
4476
4876
  }
4877
+ if (finish !== void 0) {
4878
+ const failure = asm.sawContent ? void 0 : emptyCompletionError(asm.finishReason, maxTokens, usage);
4879
+ trace.record("end", {
4880
+ outcome: failure === void 0 ? "finished" : failure.code === "OUTPUT_TOKEN_LIMIT" ? "output-token-limit" : "empty",
4881
+ finishReason: asm.finishReason,
4882
+ ...failure === void 0 ? {} : { code: failure.code },
4883
+ maxTokens,
4884
+ ...usage === void 0 ? {} : { usage },
4885
+ chunks: chunkCount,
4886
+ bytes: totalBytes,
4887
+ totalMs: Date.now() - streamStartedAt,
4888
+ sawContent: asm.sawContent
4889
+ });
4890
+ endRecorded = true;
4891
+ if (failure !== void 0) throw failure;
4892
+ yield finish;
4893
+ } else {
4894
+ const elapsed = Date.now() - streamStartedAt;
4895
+ const detail = `${chunkCount} chunk(s), ${totalBytes} bytes, ${elapsed}ms`;
4896
+ trace.record("end", {
4897
+ outcome: asm.sawContent ? "stream-closed" : "empty",
4898
+ chunks: chunkCount,
4899
+ bytes: totalBytes,
4900
+ totalMs: elapsed,
4901
+ sawContent: asm.sawContent
4902
+ });
4903
+ endRecorded = true;
4904
+ if (!asm.sawContent) throw new LlmError("Command Code returned an empty response;Command Code 返回了空响应,重试通常可恢复", "EMPTY_RESPONSE");
4905
+ 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");
4906
+ }
4907
+ } catch (error) {
4908
+ if (!usageEmitted && usage !== void 0) yield {
4909
+ type: "usage",
4910
+ usage
4911
+ };
4912
+ if (!endRecorded) trace.record("end", {
4913
+ outcome: options.signal?.aborted ? "aborted" : "error",
4914
+ code: error instanceof LlmError ? error.code : "unknown",
4915
+ finishReason: asm.finishReason,
4916
+ maxTokens,
4917
+ ...usage === void 0 ? {} : { usage },
4918
+ sawContent: asm.sawContent,
4919
+ chunks: chunkCount,
4920
+ bytes: totalBytes,
4921
+ totalMs: Date.now() - streamStartedAt
4922
+ });
4923
+ throw error;
4477
4924
  } finally {
4478
4925
  clearIdle();
4479
4926
  cleanup();
4927
+ trace.close();
4480
4928
  await reader.cancel().catch(() => void 0);
4481
4929
  reader.releaseLock();
4482
4930
  }
@@ -4517,6 +4965,7 @@ function generateHttpError(status, errText, retryAfterMs) {
4517
4965
  const detail = providerCode ?? `HTTP ${status}`;
4518
4966
  const overflowDetail = providerDetail !== "" ? providerDetail : errText.slice(0, 500);
4519
4967
  if (status < 500 && isContextOverflowDetail(overflowDetail)) return new LlmError(`Command Code API error ${status}: the request exceeds the model's context window — this session is being compacted and the request retried;Command Code API 返回 ${status}:请求内容超出模型上下文窗口——正在压缩上下文后重试;如仍失败,请新建会话或减少上下文`, CONTEXT_WINDOW_EXCEEDED_CODE, { status });
4968
+ if (status === 422 && isZdrNoProviders(providerCode, providerDetail, errText)) return new LlmError(`Command Code API error 422 (${detail}): zero data retention is enforced but no ZDR-capable upstream is available for this model — turn the ZDR setting off (or use another model);Command Code API 返回 422:已开启零数据保留(ZDR),但该模型当前没有可用的 ZDR 上游——请关闭 ZDR 开关,或改用其他模型`, "PROVIDER_HTTP_ERROR", { status: 422 });
4520
4969
  if (status === 401) return new LlmError(`Command Code API error 401 (${detail}): the API key is missing or invalid — check the key stored for COMMANDCODE_API_KEY (Models page) or the auth file;Command Code API 返回 401:API 密钥缺失或无效——请在设置页检查 COMMANDCODE_API_KEY 存储的密钥,或检查 auth 文件`, "INVALID_CREDENTIAL", { status: 401 });
4521
4970
  if (status === 413) return new LlmError("Command Code API error 413: the request body exceeds the provider's size limit (the cap is undocumented, measured at about 50 MB) — the session history is too large to send, usually because of accumulated image attachments. Start a new session, or drop the image-heavy part of this one, and retry;Command Code API 返回 413:请求体超过服务端的体积上限(官方未公开,实测约 50 MB)——会话历史过大,通常是历史图片累积所致。请新建会话,或移除本会话中图片较多的部分后重试", "PROVIDER_HTTP_ERROR", { status: 413 });
4522
4971
  return new LlmError(`Command Code API error ${status}${detail === `HTTP ${status}` ? "" : ` (${detail})`}: ${errText.slice(0, 500)}`, httpErrorCode(status), {
@@ -4567,7 +5016,7 @@ function handleCliEvent(asm, event) {
4567
5016
  }
4568
5017
  const delta = stringValue(event.text) ?? "";
4569
5018
  asm.textContent += delta;
4570
- asm.sawContent = true;
5019
+ if (delta.trim() !== "") asm.sawContent = true;
4571
5020
  chunks.push({
4572
5021
  type: "text-delta",
4573
5022
  index: asm.textIndex,
@@ -4630,6 +5079,7 @@ function handleCliEvent(asm, event) {
4630
5079
  break;
4631
5080
  }
4632
5081
  case "finish": {
5082
+ asm.finishReason = stringValue(event.finishReason);
4633
5083
  chunks.push(...closeText(asm), ...closeReasoning(asm));
4634
5084
  const usage = isRecord(event.totalUsage) ? event.totalUsage : void 0;
4635
5085
  if (usage) {
@@ -4643,6 +5093,8 @@ function handleCliEvent(asm, event) {
4643
5093
  cacheReadTokens: cacheRead,
4644
5094
  cacheWriteTokens: cacheWrite
4645
5095
  };
5096
+ const reasoningTokens = numberValue((isRecord(usage.outputTokenDetails) ? usage.outputTokenDetails : void 0)?.reasoningTokens) ?? numberValue(usage.reasoningTokens);
5097
+ if (reasoningTokens !== void 0) tokenUsage.reasoningTokens = reasoningTokens;
4646
5098
  chunks.push({
4647
5099
  type: "usage",
4648
5100
  usage: tokenUsage
@@ -4707,39 +5159,38 @@ function handleOpenAIEvent(asm, event) {
4707
5159
  });
4708
5160
  }
4709
5161
  asm.textContent += contentDelta;
4710
- asm.sawContent = true;
5162
+ if (contentDelta.trim() !== "") asm.sawContent = true;
4711
5163
  chunks.push({
4712
5164
  type: "text-delta",
4713
5165
  index: asm.textIndex,
4714
5166
  text: contentDelta
4715
5167
  });
4716
5168
  }
4717
- if (Array.isArray(delta.tool_calls)) {
5169
+ if (Array.isArray(delta.tool_calls)) for (const rawCall of delta.tool_calls) {
5170
+ if (!isRecord(rawCall)) continue;
4718
5171
  asm.sawContent = true;
4719
- for (const rawCall of delta.tool_calls) {
4720
- if (!isRecord(rawCall)) continue;
4721
- const callIndex = numberValue(rawCall.index) ?? 0;
4722
- let existing = asm.openAiToolCalls.find((call) => call.index === callIndex);
4723
- const fn = isRecord(rawCall.function) ? rawCall.function : void 0;
4724
- const id = stringValue(rawCall.id);
4725
- const name = fn === void 0 ? void 0 : stringValue(fn.name);
4726
- const argDelta = fn === void 0 ? void 0 : stringValue(fn.arguments) ?? (fn.arguments === void 0 ? "" : JSON.stringify(fn.arguments));
4727
- if (!existing) {
4728
- existing = {
4729
- index: callIndex,
4730
- name: name ?? "",
4731
- arguments: argDelta ?? ""
4732
- };
4733
- if (id !== void 0) existing.id = id;
4734
- asm.openAiToolCalls.push(existing);
4735
- } else {
4736
- if (id !== void 0 && existing.id === void 0) existing.id = id;
4737
- if (name !== void 0 && existing.name === "") existing.name = name;
4738
- if (argDelta !== void 0) existing.arguments += argDelta;
4739
- }
5172
+ const callIndex = numberValue(rawCall.index) ?? 0;
5173
+ let existing = asm.openAiToolCalls.find((call) => call.index === callIndex);
5174
+ const fn = isRecord(rawCall.function) ? rawCall.function : void 0;
5175
+ const id = stringValue(rawCall.id);
5176
+ const name = fn === void 0 ? void 0 : stringValue(fn.name);
5177
+ const argDelta = fn === void 0 ? void 0 : stringValue(fn.arguments) ?? (fn.arguments === void 0 ? "" : JSON.stringify(fn.arguments));
5178
+ if (!existing) {
5179
+ existing = {
5180
+ index: callIndex,
5181
+ name: name ?? "",
5182
+ arguments: argDelta ?? ""
5183
+ };
5184
+ if (id !== void 0) existing.id = id;
5185
+ asm.openAiToolCalls.push(existing);
5186
+ } else {
5187
+ if (id !== void 0 && existing.id === void 0) existing.id = id;
5188
+ if (name !== void 0 && existing.name === "") existing.name = name;
5189
+ if (argDelta !== void 0) existing.arguments += argDelta;
4740
5190
  }
4741
5191
  }
4742
5192
  if (choice.finish_reason !== void 0 && choice.finish_reason !== null) {
5193
+ asm.finishReason = stringValue(choice.finish_reason);
4743
5194
  chunks.push(...closeText(asm), ...closeReasoning(asm), ...emitOpenAiToolCalls(asm));
4744
5195
  if (event.usage !== void 0) chunks.push({
4745
5196
  type: "usage",
@@ -4787,6 +5238,13 @@ function mapFinishReason(reason) {
4787
5238
  if (reason === "length" || reason === "max_tokens" || reason === "max-tokens" || reason === "max_output_tokens") return { kind: "max-tokens" };
4788
5239
  return { kind: "stop" };
4789
5240
  }
5241
+ /** A terminal marker without text or tools is a failed attempt, not a completed turn. */
5242
+ function emptyCompletionError(reason, maxTokens, usage) {
5243
+ const detail = `finish_reason=${reason ?? "unknown"}, max_tokens=${maxTokens}, outputTokens=${usage?.outputTokens ?? "unknown"}, reasoningTokens=${usage?.reasoningTokens ?? "unknown"}`;
5244
+ 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");
5245
+ if (reason === "content_filter" || reason === "content-filter") return new LlmError(`Command Code filtered the response (${detail});Command Code 拦截了响应,未生成正文或工具调用`, "CONTENT_FILTER");
5246
+ return new LlmError(`Command Code finished without producing answer text or a tool call (${detail});Command Code 返回了空响应(可能只有思考内容),重试通常可恢复`, "EMPTY_RESPONSE");
5247
+ }
4790
5248
  /**
4791
5249
  * True when a Provider API pre-stream rejection is Command Code's Go-plan
4792
5250
  * gate (`upgrade_required`). Only this exact class of rejection should fall
@@ -4831,8 +5289,25 @@ function mapFinishReason(reason) {
4831
5289
  * or a model outside its plan (`MODEL_NOT_IN_PLAN`). Rotating must NOT mark
4832
5290
  * the account: the fact is about the model or the balance, it can change
4833
5291
  * without the key changing, and a `:free` model can still be served by an
4834
- * account with an empty balance.
5292
+ * account with an empty balance. These codes are recognized on EVERY
5293
+ * status — a 5xx that proxies the provider's own credits/plan body is
5294
+ * still an account fact, and only the prose scan is confined to 4xx.
4835
5295
  */
5296
+ /**
5297
+ * The provider's structured "this account cannot serve this request" codes.
5298
+ *
5299
+ * Status-independent on purpose, mirroring the CLI's classifier, which reads
5300
+ * the code before any status guard. A 5xx that carries one of these must not
5301
+ * be retried as a transient provider failure: the same request against the
5302
+ * same account cannot succeed, so the caller rotates past it (without marking
5303
+ * the key) instead of replaying it on the harness's retry cadence.
5304
+ */
5305
+ const ACCOUNT_UNAVAILABLE_CODES = /* @__PURE__ */ new Set([
5306
+ "USAGE_EXCEEDED",
5307
+ "INSUFFICIENT_CREDITS",
5308
+ "PREMIUM_CREDITS_EXHAUSTED",
5309
+ "MODEL_NOT_IN_PLAN"
5310
+ ]);
4836
5311
  function classifyAccountRejection(status, errText) {
4837
5312
  if (status === 429 || readProviderErrorCode(errText)?.toUpperCase() === "RATE_LIMITED") {
4838
5313
  const evidence = readWindowLimitEvidence(errText);
@@ -4843,10 +5318,11 @@ function classifyAccountRejection(status, errText) {
4843
5318
  };
4844
5319
  }
4845
5320
  if (status === 401) return { reason: "invalid-credential" };
5321
+ const code = readProviderErrorCode(errText)?.toUpperCase();
5322
+ if (code !== void 0 && ACCOUNT_UNAVAILABLE_CODES.has(code)) return { reason: "unavailable" };
4846
5323
  if (status < 400 || status >= 500) return void 0;
4847
5324
  const lower = errText.toLowerCase().replaceAll("_", " ");
4848
- const code = readProviderErrorCode(errText)?.toUpperCase();
4849
- 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" };
5325
+ 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" };
4850
5326
  }
4851
5327
  /** The machine-readable `error.code`/`error.type` of a pre-stream failure body. */
4852
5328
  function readProviderErrorCode(errText) {
@@ -4860,6 +5336,16 @@ function readProviderErrorCode(errText) {
4860
5336
  }
4861
5337
  }
4862
5338
  /**
5339
+ * How far ahead a provider-published reset may be and still be trusted.
5340
+ *
5341
+ * Generous on purpose: the weekly window is the longest this provider meters,
5342
+ * and the value is a plausibility bound rather than a policy. Beyond it the
5343
+ * reset is dropped rather than clamped, which leaves the account's mark
5344
+ * `unknown` — the state the pool's probe pass re-checks — instead of a
5345
+ * cooldown that never expires.
5346
+ */
5347
+ const MAX_TRUSTED_RESET_MS = 2592e6;
5348
+ /**
4863
5349
  * The usage-window evidence carried by a rejected request, mirroring the CLI's
4864
5350
  * `parseWindowLimitError` → `resolveWindowLabel`/`extractResetAtMs` pair.
4865
5351
  *
@@ -4893,7 +5379,8 @@ function readWindowLimitEvidence(errText) {
4893
5379
  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;
4894
5380
  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];
4895
5381
  const stamped = stamp === void 0 ? void 0 : Date.parse(stamp);
4896
- const resetAtMs = seconds !== void 0 && seconds > 0 ? Math.round(seconds * 1e3) : stamped !== void 0 && !Number.isNaN(stamped) ? stamped : void 0;
5382
+ const published = seconds !== void 0 && seconds > 0 ? Math.round(seconds * 1e3) : stamped !== void 0 && !Number.isNaN(stamped) ? stamped : void 0;
5383
+ const resetAtMs = published !== void 0 && Number.isFinite(published) && published - Date.now() <= MAX_TRUSTED_RESET_MS ? published : void 0;
4897
5384
  return {
4898
5385
  ...window === void 0 ? {} : { window },
4899
5386
  ...resetAtMs === void 0 ? {} : { resetAtMs }
@@ -5693,7 +6180,10 @@ function isCallbackCredentials(value) {
5693
6180
  /**
5694
6181
  * One browser-login attempt machine. Single-flight by design: `begin()` while
5695
6182
  * waiting returns the live attempt's status instead of starting a second one;
5696
- * a terminal state makes the next `begin()` start fresh.
6183
+ * a start that is still binding its port is REJOINED through
6184
+ * {@link CommandCodeLoginFlow.begin}'s in-flight promise, because the status
6185
+ * face cannot say `waiting` until that bind settles; a terminal state makes the
6186
+ * next `begin()` start fresh.
5697
6187
  */
5698
6188
  var CommandCodeLoginFlow = class {
5699
6189
  deps;
@@ -5711,6 +6201,17 @@ var CommandCodeLoginFlow = class {
5711
6201
  * the user cancelled and flipping the page back to success.
5712
6202
  */
5713
6203
  attemptSeq = 0;
6204
+ /**
6205
+ * The start currently binding a port, if any. Every `begin()` that arrives
6206
+ * before it settles joins it: two independent starts would each bind their
6207
+ * own loopback server, and only the LAST one is reachable by
6208
+ * {@link CommandCodeLoginFlow.teardown} — the orphan keeps listening and
6209
+ * answering `/callback` for the process's lifetime, and ten of them exhaust
6210
+ * the port window so browser login dies until the Host restarts.
6211
+ */
6212
+ starting;
6213
+ /** A `cancel()` that arrived while a start was still binding (see {@link CommandCodeLoginFlow.cancel}). */
6214
+ cancelPending = false;
5714
6215
  disposed = false;
5715
6216
  constructor(deps) {
5716
6217
  this.deps = deps;
@@ -5732,6 +6233,22 @@ var CommandCodeLoginFlow = class {
5732
6233
  async begin() {
5733
6234
  if (this.disposed) throw new Error("login flow has been disposed");
5734
6235
  if (this.statusValue.state === "waiting" && this.server !== void 0) return this.statusValue;
6236
+ if (this.starting !== void 0) return this.starting;
6237
+ this.cancelPending = false;
6238
+ const starting = this.startAttempt();
6239
+ this.starting = starting;
6240
+ try {
6241
+ return await starting;
6242
+ } finally {
6243
+ if (this.starting === starting) this.starting = void 0;
6244
+ }
6245
+ }
6246
+ /**
6247
+ * The binding half of {@link CommandCodeLoginFlow.begin}: one attempt, one
6248
+ * server, one published `waiting` status. Callers reach it only through
6249
+ * `begin()`'s single-flight fence.
6250
+ */
6251
+ async startAttempt() {
5735
6252
  this.teardown();
5736
6253
  const attempt = ++this.attemptSeq;
5737
6254
  const port = await this.findPort();
@@ -5743,6 +6260,16 @@ var CommandCodeLoginFlow = class {
5743
6260
  };
5744
6261
  });
5745
6262
  await this.bindServer(port, expectedState);
6263
+ if (this.disposed || this.cancelPending) {
6264
+ const cancelled = !this.disposed;
6265
+ this.cancelPending = false;
6266
+ this.teardown();
6267
+ if (cancelled) this.setStatus({
6268
+ state: "failed",
6269
+ reason: "cancelled"
6270
+ });
6271
+ return this.statusValue;
6272
+ }
5746
6273
  const apiBase = this.readApiBase();
5747
6274
  this.setStatus({
5748
6275
  state: "waiting",
@@ -5764,19 +6291,29 @@ var CommandCodeLoginFlow = class {
5764
6291
  settled.then((credentials) => this.complete(attempt, credentials), (failure) => this.failFrom(attempt, failure));
5765
6292
  return this.statusValue;
5766
6293
  }
5767
- /** Cancel a waiting attempt; terminal states are untouched. */
6294
+ /**
6295
+ * Cancel a waiting attempt; terminal states are untouched. A start that is
6296
+ * still binding a port is flagged instead of ignored — it cannot be torn
6297
+ * down from here (its server does not exist yet), so `startAttempt` observes
6298
+ * the flag once the bind settles and retires it.
6299
+ */
5768
6300
  cancel() {
5769
- if (this.disposed || this.statusValue.state !== "waiting") return;
5770
- this.teardown();
5771
- this.setStatus({
5772
- state: "failed",
5773
- reason: "cancelled"
5774
- });
6301
+ if (this.disposed) return;
6302
+ if (this.statusValue.state === "waiting") {
6303
+ this.teardown();
6304
+ this.setStatus({
6305
+ state: "failed",
6306
+ reason: "cancelled"
6307
+ });
6308
+ return;
6309
+ }
6310
+ if (this.starting !== void 0) this.cancelPending = true;
5775
6311
  }
5776
6312
  /** Stop everything; a waiting attempt ends cancelled. Idempotent. */
5777
6313
  dispose() {
5778
6314
  if (this.disposed) return;
5779
6315
  this.disposed = true;
6316
+ this.cancelPending = false;
5780
6317
  const wasWaiting = this.statusValue.state === "waiting";
5781
6318
  this.teardown();
5782
6319
  if (wasWaiting) this.setStatus({
@@ -5812,8 +6349,12 @@ var CommandCodeLoginFlow = class {
5812
6349
  const server = createServer((request, response) => this.handleCallback(request, response, expectedState));
5813
6350
  this.server = server;
5814
6351
  server.once("error", (error) => {
5815
- if (this.server !== server) return;
6352
+ if (this.server !== server) {
6353
+ closeServer(server);
6354
+ return;
6355
+ }
5816
6356
  this.server = void 0;
6357
+ closeServer(server);
5817
6358
  const tagged = new LoginSettleError("error", `Could not bind the login callback server on port ${port}: ${error.code ?? error.message}`);
5818
6359
  if (binding) {
5819
6360
  binding = false;
@@ -5982,11 +6523,17 @@ var CommandCodeLoginFlow = class {
5982
6523
  /** Close the server and watchdog without touching the published status. */
5983
6524
  teardown() {
5984
6525
  this.clearTimer();
5985
- this.server?.close();
6526
+ closeServer(this.server);
5986
6527
  this.server = void 0;
5987
6528
  this.settle = void 0;
5988
6529
  }
5989
6530
  };
6531
+ /** Close one loopback server without letting "not running"/"already closed" escape. */
6532
+ function closeServer(server) {
6533
+ try {
6534
+ server?.close();
6535
+ } catch {}
6536
+ }
5990
6537
  /** A tagged settle failure carrying the stable copy reason. */
5991
6538
  var LoginSettleError = class extends Error {
5992
6539
  reason;
@@ -6060,7 +6607,8 @@ function selectCommandCodeSearchProvider(web, enable) {
6060
6607
  function commandCodeSearchSelection() {
6061
6608
  return {
6062
6609
  owner: false,
6063
- displaced: void 0
6610
+ displaced: void 0,
6611
+ preexisting: false
6064
6612
  };
6065
6613
  }
6066
6614
  /**
@@ -6078,8 +6626,14 @@ function commandCodeSearchSelection() {
6078
6626
  * auto-select — already says what the user wants.
6079
6627
  * - When the field already reads `commandcode` at first touch (e.g. a
6080
6628
  * surviving runtime the plugin did not set, or a manual
6081
- * `searchProvider: commandcode` pin), `displaced` stays undefined so the
6082
- * later disable is a no-op rather than a guess at the factory default.
6629
+ * `searchProvider: commandcode` pin), `displaced` stays undefined and
6630
+ * `preexisting` is set, so the later disable is a no-op rather than a guess
6631
+ * at the factory default. A "no-op" means the field is left ALONE: writing
6632
+ * that `undefined` back would destroy the user's own pin, and dsh-web reads
6633
+ * a cleared `searchProviderId` as auto-select — where a second usable
6634
+ * provider (the shipped `deepseek-official` is usable whenever a DeepSeek
6635
+ * key resolves) makes EVERY later search throw
6636
+ * `WEB_PROVIDER_AMBIGUOUS`.
6083
6637
  *
6084
6638
  * Never throws: like the low-level rewrite, a hardened runtime shape degrades
6085
6639
  * to registered-but-unselected.
@@ -6093,14 +6647,16 @@ function applyCommandCodeSearchSelection(web, state, enable) {
6093
6647
  return;
6094
6648
  }
6095
6649
  const prior = field.searchProviderId;
6096
- state.displaced = prior === "commandcode" ? void 0 : prior;
6650
+ state.preexisting = prior === COMMANDCODE_SEARCH_PROVIDER_ID;
6651
+ state.displaced = state.preexisting ? void 0 : prior;
6097
6652
  field.searchProviderId = COMMANDCODE_SEARCH_PROVIDER_ID;
6098
6653
  state.owner = true;
6099
6654
  return;
6100
6655
  }
6101
6656
  if (state.owner) {
6102
6657
  state.owner = false;
6103
- field.searchProviderId = state.displaced;
6658
+ if (!state.preexisting) field.searchProviderId = state.displaced;
6659
+ state.preexisting = false;
6104
6660
  return;
6105
6661
  }
6106
6662
  } catch {}
@@ -6131,7 +6687,7 @@ function toSource(result) {
6131
6687
  ...snippet !== void 0 && snippet.length > 0 ? { snippet } : {}
6132
6688
  };
6133
6689
  }
6134
- function isAbortError(error) {
6690
+ function isAbortError$1(error) {
6135
6691
  return error instanceof DOMException && error.name === "AbortError";
6136
6692
  }
6137
6693
  /** Build the provider's stable cancellation error while retaining the caller's reason. */
@@ -6185,7 +6741,7 @@ var CommandCodeSearchProvider = class {
6185
6741
  ...signal !== void 0 ? { signal } : {}
6186
6742
  });
6187
6743
  } catch (error) {
6188
- if (signal?.aborted === true || isAbortError(error)) throw searchAborted(signal, error);
6744
+ if (signal?.aborted === true || isAbortError$1(error)) throw searchAborted(signal, error);
6189
6745
  throw new WebError(`Command Code web search request failed: ${error instanceof Error ? error.message : String(error)}`, "WEB_PROVIDER_ERROR", { cause: error });
6190
6746
  }
6191
6747
  if (!response.ok) {
@@ -6200,7 +6756,7 @@ var CommandCodeSearchProvider = class {
6200
6756
  if (typeof code === "string" || typeof inner === "string") message += `: ${typeof code === "string" ? code : ""}${typeof code === "string" && typeof inner === "string" ? " — " : ""}${typeof inner === "string" ? inner : ""}`;
6201
6757
  }
6202
6758
  } catch (error) {
6203
- if (signal?.aborted === true || isAbortError(error)) throw searchAborted(signal, error);
6759
+ if (signal?.aborted === true || isAbortError$1(error)) throw searchAborted(signal, error);
6204
6760
  const slice = (await response.text().catch(() => "")).trim().slice(0, 200);
6205
6761
  if (slice !== "") message += `: ${slice}`;
6206
6762
  }
@@ -6210,7 +6766,7 @@ var CommandCodeSearchProvider = class {
6210
6766
  try {
6211
6767
  payload = await response.json();
6212
6768
  } catch (error) {
6213
- if (signal?.aborted === true || isAbortError(error)) throw searchAborted(signal, error);
6769
+ if (signal?.aborted === true || isAbortError$1(error)) throw searchAborted(signal, error);
6214
6770
  throw new WebError("Command Code web search returned an unparseable response body", "WEB_PROVIDER_ERROR", { cause: error });
6215
6771
  }
6216
6772
  const results = payload?.results;
@@ -6234,7 +6790,7 @@ var CommandCodeSearchProvider = class {
6234
6790
  try {
6235
6791
  key = await this.deps.resolveKey();
6236
6792
  } catch (error) {
6237
- if (signal?.aborted === true || isAbortError(error)) throw searchAborted(signal, error);
6793
+ if (signal?.aborted === true || isAbortError$1(error)) throw searchAborted(signal, error);
6238
6794
  if (error instanceof Error && typeof error.code === "string") {
6239
6795
  const code = error.code;
6240
6796
  if (code === "MISSING_CREDENTIAL") throw new WebError(error.message, "WEB_PROVIDER_CREDENTIAL_MISSING", { cause: error });
@@ -6402,8 +6958,8 @@ function buildCommandCodeTuiSection(deps) {
6402
6958
  label: "API key",
6403
6959
  descriptions: { zh: "API 密钥" },
6404
6960
  secret: { ref },
6405
- hint: `Stored in the credential store as ${ref}, never in settings.yaml.`,
6406
- hintDescriptions: { zh: `保存在凭据库(${ref}),不会写入 settings.yaml。` }
6961
+ hint: `Stored in the credential store as ${ref}, never in configuration files.`,
6962
+ hintDescriptions: { zh: `保存在凭据库(${ref}),不会写入配置文件。` }
6407
6963
  },
6408
6964
  {
6409
6965
  path: ["apiBase"],
@@ -6422,6 +6978,70 @@ function buildCommandCodeTuiSection(deps) {
6422
6978
  };
6423
6979
  }
6424
6980
  },
6981
+ {
6982
+ path: ["commandGuard"],
6983
+ group: "advanced",
6984
+ kind: "boolean",
6985
+ label: "AI command guard",
6986
+ descriptions: { zh: "AI 命令安全预判" },
6987
+ hint: "typesafe/jev judges whether a shell command is safe and auto-approves it; everything else asks as usual. Off by default.",
6988
+ hintDescriptions: { zh: "由 typesafe/jev 判断 shell 命令是否安全,安全则自动放行,其余照常弹窗。默认关闭。" },
6989
+ format: (value) => value === true ? "true" : "false",
6990
+ parse: (text) => ({
6991
+ kind: "set",
6992
+ value: text.trim() === "true"
6993
+ })
6994
+ },
6995
+ {
6996
+ path: ["commandGuardThreshold"],
6997
+ group: "advanced",
6998
+ kind: "number",
6999
+ label: "Auto-approve threshold (0.5-1)",
7000
+ descriptions: { zh: "自动放行阈值(0.5-1)" },
7001
+ hint: "Approve when typesafe/jev rates the command \"safe\" at least this likely; default 0.9.",
7002
+ hintDescriptions: { zh: "typesafe/jev 判定\"安全\"的概率达到该值即放行,默认 0.9。" },
7003
+ parse: (text) => {
7004
+ const trimmed = text.trim();
7005
+ if (trimmed === "") return { kind: "clear" };
7006
+ const parsed = Number(trimmed);
7007
+ return Number.isFinite(parsed) && parsed >= .5 && parsed <= 1 ? {
7008
+ kind: "set",
7009
+ value: parsed
7010
+ } : void 0;
7011
+ }
7012
+ },
7013
+ {
7014
+ path: ["commandGuardTimeoutMs"],
7015
+ group: "advanced",
7016
+ kind: "number",
7017
+ label: "Judgement timeout (ms, 200-10000)",
7018
+ descriptions: { zh: "判定超时(毫秒,200-10000)" },
7019
+ hint: "If typesafe/jev does not answer in time, the normal prompt appears; default 1500.",
7020
+ hintDescriptions: { zh: "typesafe/jev 超过该时间未返回就照常弹窗,默认 1500。" },
7021
+ parse: (text) => {
7022
+ const trimmed = text.trim();
7023
+ if (trimmed === "") return { kind: "clear" };
7024
+ const parsed = Number(trimmed);
7025
+ return Number.isFinite(parsed) && parsed >= 200 && parsed <= 1e4 ? {
7026
+ kind: "set",
7027
+ value: parsed
7028
+ } : void 0;
7029
+ }
7030
+ },
7031
+ {
7032
+ path: ["zdr"],
7033
+ group: "advanced",
7034
+ kind: "boolean",
7035
+ label: "Zero data retention (ZDR)",
7036
+ descriptions: { zh: "零数据保留(ZDR)" },
7037
+ hint: "Route requests only through upstreams that retain nothing and never train on prompts (the CLI's CMD_ZDR=1). Models without an available ZDR upstream fail instead of losing protection; ZDR is usually billed at higher pass-through rates. Off by default.",
7038
+ hintDescriptions: { zh: "请求只经由不留存数据、也不用于训练的上游(等价于 CLI 的 CMD_ZDR=1)。没有可用 ZDR 上游的模型会报错,不会降级为非 ZDR 请求;ZDR 通常按更高的透传价计费。默认关闭。" },
7039
+ format: (value) => value === true ? "true" : "false",
7040
+ parse: (text) => ({
7041
+ kind: "set",
7042
+ value: text.trim() === "true"
7043
+ })
7044
+ },
6425
7045
  {
6426
7046
  path: ["filterModelsByPlan"],
6427
7047
  group: "models",
@@ -6681,18 +7301,810 @@ function transportBudgetMessage(failureMessage, maxRetries) {
6681
7301
  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`;
6682
7302
  }
6683
7303
  //#endregion
6684
- //#region src/index.ts
7304
+ //#region src/systemone.ts
6685
7305
  /**
6686
- * dsh-commandcode-provider — DeepSeek Harness LLM provider plugin for Command
6687
- * Code (unofficial; ported from pi-commandcode-provider@0.5.1).
7306
+ * Host-side client for Command Code's System One decision endpoint
7307
+ * (`POST {apiBase}/provider/v1/systemone`).
6688
7308
  *
6689
- * Registers the `commandcode` provider route on `ctx.llm` and declares it in
6690
- * the configurable-provider directory, so the web Models page shows a
6691
- * "Command Code" card with an API-key field and the model picker lists the
7309
+ * `typesafe/jev` is **not a chat model**: it takes a `state` plus typed
7310
+ * questions and answers with probabilities, never text, and it never streams.
7311
+ * It is therefore absent from `/provider/v1/models` (so the picker can never
7312
+ * offer it) and must never be registered as a model route — this plugin uses it
7313
+ * as an internal advisor instead: the command guard in `./command-guard.ts`
7314
+ * asks it whether a shell command is safe to auto-approve.
7315
+ *
7316
+ * The three question types are the whole vocabulary (command-code@1.64.0
7317
+ * `systemOneRequestSchema`):
7318
+ *
7319
+ * - `noul` — "is this true?", optional `criteria: { true, false }` describing
7320
+ * each verdict. Answers with `noul`, the probability of *yes*.
7321
+ * - `choice` — "which one?", `criteria` maps each option key to its
7322
+ * description. Answers with `choice`, plus optional `confidence` and
7323
+ * `probabilities`.
7324
+ * - `score` — "where on this rubric?", `criteria` is an array of at least two
7325
+ * levels, lowest first. Answers with `score`, `legend` and `probabilities`.
7326
+ *
7327
+ * A request carries at most 20 questions and every answer is keyed by the
7328
+ * question id it belongs to. `usage` is reported per request, in tokens.
7329
+ *
7330
+ * Billing: free through 2026-09-24T23:59:59Z, then $0.042 per 1M input tokens
7331
+ * with output free. The model has **no ZDR-capable upstream**, so a request
7332
+ * carrying `x-cmd-zdr: 1` is refused with HTTP 422 `cmd_zdr_no_providers` —
7333
+ * this client never sends that header, and it must stay that way even though
7334
+ * the plugin now ships a ZDR switch for chat (`Config.zdr` → the `x-cmd-zdr: 1`
7335
+ * header per model, gated on `KNOWN_NON_ZDR_MODELS`): the decision endpoint is
7336
+ * reached through THIS module's own request path, never through the adapter's
7337
+ * chat connection, so the two cannot drift — and a deployment that later widens
7338
+ * ZDR must exclude this endpoint rather than route it through the switch.
7339
+ *
7340
+ * Auth is the plugin's ordinary chain (the multi-account pool, the credentials
7341
+ * seam, the CLI auth file) through the same `Authorization: Bearer <key>` and
7342
+ * `x-command-code-version` headers every other endpoint uses.
7343
+ *
7344
+ * @module dsh-commandcode-provider/systemone
7345
+ */
7346
+ /** Path of the decision endpoint, relative to `apiBase`. */
7347
+ const SYSTEMONE_ROUTE = "/provider/v1/systemone";
7348
+ /** Milliseconds a decision may take before the caller gives up on it. */
7349
+ const SYSTEMONE_DEFAULT_TIMEOUT_MS = 1500;
7350
+ /** The one error type this client throws. Callers fail closed on it. */
7351
+ var SystemOneError = class extends Error {
7352
+ kind;
7353
+ status;
7354
+ constructor(message, kind, status, options) {
7355
+ super(message, options);
7356
+ this.kind = kind;
7357
+ this.status = status;
7358
+ this.name = "SystemOneError";
7359
+ }
7360
+ };
7361
+ /**
7362
+ * Combine the caller's signal with a timeout, keeping the two distinguishable:
7363
+ * a timeout is this client's own budget expiring (the guard delegates to the
7364
+ * user) while a caller abort means the approval was already withdrawn (answer
7365
+ * nothing) — both fail closed, but the diagnosis must not lie about which.
7366
+ *
7367
+ * The timer is a plain `setTimeout` rather than `AbortSignal.timeout()`, whose
7368
+ * timer Node unrefs: with nothing else pending (a test double, a socket that
7369
+ * never opened) an unref'd timer lets the process exit while the promise is
7370
+ * still pending, which reads as a hang rather than as the timeout it is.
7371
+ */
7372
+ function deadlineOf(signal, timeoutMs) {
7373
+ const controller = new AbortController();
7374
+ let timedOut = false;
7375
+ const timer = setTimeout(() => {
7376
+ timedOut = true;
7377
+ controller.abort();
7378
+ }, timeoutMs);
7379
+ const onCallerAbort = () => controller.abort();
7380
+ if (signal !== void 0) {
7381
+ if (signal.aborted) controller.abort();
7382
+ else signal.addEventListener("abort", onCallerAbort, { once: true });
7383
+ }
7384
+ return {
7385
+ signal: controller.signal,
7386
+ callerAborted: () => signal?.aborted === true,
7387
+ timedOut: () => timedOut,
7388
+ dispose: () => {
7389
+ clearTimeout(timer);
7390
+ signal?.removeEventListener("abort", onCallerAbort);
7391
+ }
7392
+ };
7393
+ }
7394
+ /** Whether a caught value is an abort-shaped failure. */
7395
+ function isAbortError(error) {
7396
+ return error instanceof Error && (error.name === "AbortError" || error.name === "TimeoutError");
7397
+ }
7398
+ /** Stop waiting for an operation that does not accept an AbortSignal. */
7399
+ function withinDeadline(operation, deadline, timeoutMs) {
7400
+ return new Promise((resolve, reject) => {
7401
+ const onAbort = () => {
7402
+ reject(deadline.callerAborted() ? new SystemOneError("decision request aborted", "aborted") : new SystemOneError(`decision request timed out after ${timeoutMs}ms`, "timeout"));
7403
+ };
7404
+ deadline.signal.addEventListener("abort", onAbort, { once: true });
7405
+ operation.then((value) => {
7406
+ deadline.signal.removeEventListener("abort", onAbort);
7407
+ resolve(value);
7408
+ }, (error) => {
7409
+ deadline.signal.removeEventListener("abort", onAbort);
7410
+ reject(error);
7411
+ });
7412
+ if (deadline.signal.aborted) onAbort();
7413
+ });
7414
+ }
7415
+ /** A finite number in `[0, 1]`, or undefined. */
7416
+ function probability(value) {
7417
+ return typeof value === "number" && Number.isFinite(value) && value >= 0 && value <= 1 ? value : void 0;
7418
+ }
7419
+ /** A finite number, or undefined. */
7420
+ function finite(value) {
7421
+ return typeof value === "number" && Number.isFinite(value) ? value : void 0;
7422
+ }
7423
+ /** A record of finite numbers (probabilities), or undefined when the shape is off. */
7424
+ function numberRecord(value) {
7425
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return void 0;
7426
+ const out = {};
7427
+ for (const [key, entry] of Object.entries(value)) {
7428
+ const parsed = finite(entry);
7429
+ if (parsed === void 0) return void 0;
7430
+ out[key] = parsed;
7431
+ }
7432
+ return out;
7433
+ }
7434
+ /**
7435
+ * Parse one answer entry strictly by its own `type`. A body whose answer does
7436
+ * not carry the member its type promises is rejected rather than defaulted:
7437
+ * an absent probability is exactly the case the caller must not read as "safe".
7438
+ *
7439
+ * @param value - one raw entry from the response's `answers` object.
7440
+ * @returns the parsed answer, or undefined when the entry is malformed.
7441
+ */
7442
+ function parseSystemOneAnswer(value) {
7443
+ if (typeof value !== "object" || value === null) return void 0;
7444
+ const raw = value;
7445
+ if (raw.type === "noul") {
7446
+ const noul = probability(raw.noul);
7447
+ return noul === void 0 ? void 0 : {
7448
+ type: "noul",
7449
+ noul
7450
+ };
7451
+ }
7452
+ if (raw.type === "choice") {
7453
+ if (typeof raw.choice !== "string" || raw.choice === "") return void 0;
7454
+ const confidence = raw.confidence === void 0 ? void 0 : probability(raw.confidence);
7455
+ if (raw.confidence !== void 0 && confidence === void 0) return void 0;
7456
+ const probabilities = raw.probabilities === void 0 ? void 0 : numberRecord(raw.probabilities);
7457
+ if (raw.probabilities !== void 0 && probabilities === void 0) return void 0;
7458
+ return {
7459
+ type: "choice",
7460
+ choice: raw.choice,
7461
+ ...confidence === void 0 ? {} : { confidence },
7462
+ ...probabilities === void 0 ? {} : { probabilities }
7463
+ };
7464
+ }
7465
+ if (raw.type === "score") {
7466
+ const score = finite(raw.score);
7467
+ if (score === void 0) return void 0;
7468
+ const confidence = raw.confidence === void 0 ? void 0 : probability(raw.confidence);
7469
+ if (raw.confidence !== void 0 && confidence === void 0) return void 0;
7470
+ const probabilities = raw.probabilities === void 0 ? void 0 : numberRecord(raw.probabilities);
7471
+ if (raw.probabilities !== void 0 && probabilities === void 0) return void 0;
7472
+ return {
7473
+ type: "score",
7474
+ score,
7475
+ ...confidence === void 0 ? {} : { confidence },
7476
+ ...probabilities === void 0 ? {} : { probabilities }
7477
+ };
7478
+ }
7479
+ }
7480
+ /**
7481
+ * Parse a decision response body. Every answer entry must be well formed — one
7482
+ * malformed entry fails the whole response, because a partially understood
7483
+ * decision is not a decision. Unknown extra members are ignored so a server-side
7484
+ * addition cannot break the guard.
7485
+ *
7486
+ * @param value - the decoded JSON body.
7487
+ * @returns the parsed response, or undefined when the body is not one.
7488
+ */
7489
+ function parseSystemOneResponse(value) {
7490
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return void 0;
7491
+ const raw = value;
7492
+ const rawAnswers = raw.answers;
7493
+ if (typeof rawAnswers !== "object" || rawAnswers === null || Array.isArray(rawAnswers)) return void 0;
7494
+ const entries = Object.entries(rawAnswers);
7495
+ if (entries.length === 0) return void 0;
7496
+ const answers = {};
7497
+ for (const [id, entry] of entries) {
7498
+ const answer = parseSystemOneAnswer(entry);
7499
+ if (answer === void 0) return void 0;
7500
+ answers[id] = answer;
7501
+ }
7502
+ const usage = raw.usage === void 0 ? void 0 : parseUsage(raw.usage);
7503
+ if (raw.usage !== void 0 && usage === void 0) return void 0;
7504
+ return {
7505
+ ...typeof raw.model === "string" ? { model: raw.model } : {},
7506
+ answers,
7507
+ ...usage === void 0 ? {} : { usage }
7508
+ };
7509
+ }
7510
+ /** Parse the optional `usage` member; an unreadable one is a malformed body. */
7511
+ function parseUsage(value) {
7512
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return void 0;
7513
+ const raw = value;
7514
+ const inputTokens = raw.input_tokens === void 0 ? void 0 : finite(raw.input_tokens);
7515
+ if (raw.input_tokens !== void 0 && inputTokens === void 0) return void 0;
7516
+ const outputTokens = raw.output_tokens === void 0 ? void 0 : finite(raw.output_tokens);
7517
+ if (raw.output_tokens !== void 0 && outputTokens === void 0) return void 0;
7518
+ return {
7519
+ ...inputTokens === void 0 ? {} : { inputTokens },
7520
+ ...outputTokens === void 0 ? {} : { outputTokens }
7521
+ };
7522
+ }
7523
+ /** Validate the outgoing request where the server would only reject it with a 400. */
7524
+ function assertRequest(request) {
7525
+ const ids = Object.keys(request.questions);
7526
+ if (ids.length === 0) throw new SystemOneError("a decision request needs at least one question", "invalid-request");
7527
+ if (ids.length > 20) throw new SystemOneError(`a decision request carries at most 20 questions (got ${ids.length})`, "invalid-request");
7528
+ for (const id of ids) if (id.trim() === "") throw new SystemOneError("every decision question needs a non-empty id", "invalid-request");
7529
+ }
7530
+ /** Read the endpoint's error body into a one-line diagnosis. */
7531
+ async function httpFailure(response) {
7532
+ let detail = "";
7533
+ try {
7534
+ const slice = (await response.text()).trim().slice(0, 200);
7535
+ if (slice !== "") detail = `: ${slice}`;
7536
+ } catch {}
7537
+ return new SystemOneError(`Command Code decision request failed (HTTP ${response.status})${detail}`, "http", response.status);
7538
+ }
7539
+ /**
7540
+ * Run one decision request.
7541
+ *
7542
+ * @param deps - connection facts, credential chain and fetch seam.
7543
+ * @param request - the state and typed questions.
7544
+ * @param options - caller cancellation and the per-call budget.
7545
+ * @returns the parsed decision.
7546
+ * @throws {SystemOneError} on every failure path; callers fail closed.
7547
+ */
7548
+ async function runSystemOne(deps, request, options = {}) {
7549
+ assertRequest(request);
7550
+ const apiBase = deps.apiBase();
7551
+ if (!URL.canParse(apiBase)) throw new SystemOneError(`Command Code decision request is misconfigured: apiBase ${JSON.stringify(apiBase)} is not a valid URL`, "invalid-request");
7552
+ if (options.signal?.aborted === true) throw new SystemOneError("decision request aborted", "aborted");
7553
+ const timeoutMs = options.timeoutMs ?? 1500;
7554
+ const deadline = deadlineOf(options.signal, timeoutMs);
7555
+ try {
7556
+ let key;
7557
+ try {
7558
+ key = await withinDeadline(Promise.resolve().then(() => deps.resolveApiKey()), deadline, timeoutMs);
7559
+ } catch (error) {
7560
+ if (error instanceof SystemOneError && (error.kind === "aborted" || error.kind === "timeout")) throw error;
7561
+ throw new SystemOneError(`Command Code decision request has no usable credential: ${error instanceof Error ? error.message : String(error)}`, "credential", void 0, { cause: error });
7562
+ }
7563
+ const body = {
7564
+ model: request.model ?? "typesafe/jev",
7565
+ state: request.state,
7566
+ questions: request.questions
7567
+ };
7568
+ if (deadline.signal.aborted) throw deadline.callerAborted() ? new SystemOneError("decision request aborted", "aborted") : new SystemOneError(`decision request timed out after ${timeoutMs}ms`, "timeout");
7569
+ let response;
7570
+ try {
7571
+ response = await (deps.fetchImpl ?? fetch)(`${apiBase.replace(/\/$/, "")}${SYSTEMONE_ROUTE}`, {
7572
+ method: "POST",
7573
+ headers: {
7574
+ "Content-Type": "application/json",
7575
+ Authorization: `Bearer ${key}`,
7576
+ "x-command-code-version": COMMAND_CODE_CLI_VERSION,
7577
+ ...attributionHeaders()
7578
+ },
7579
+ body: JSON.stringify(body),
7580
+ signal: deadline.signal
7581
+ });
7582
+ } catch (error) {
7583
+ if (deadline.callerAborted()) throw new SystemOneError("decision request aborted", "aborted", void 0, { cause: error });
7584
+ if (deadline.timedOut() || isAbortError(error)) throw new SystemOneError(`decision request timed out after ${timeoutMs}ms`, "timeout", void 0, { cause: error });
7585
+ throw new SystemOneError(`Command Code decision request failed: ${error instanceof Error ? error.message : String(error)}`, "network", void 0, { cause: error });
7586
+ }
7587
+ if (!response.ok) throw await httpFailure(response);
7588
+ let payload;
7589
+ try {
7590
+ payload = await response.json();
7591
+ } catch (error) {
7592
+ if (deadline.callerAborted()) throw new SystemOneError("decision request aborted", "aborted", void 0, { cause: error });
7593
+ throw new SystemOneError("Command Code returned an unparseable decision body", "invalid-response", void 0, { cause: error });
7594
+ }
7595
+ const parsed = parseSystemOneResponse(payload);
7596
+ if (parsed === void 0) throw new SystemOneError("Command Code returned a decision body this plugin cannot read", "invalid-response");
7597
+ return parsed;
7598
+ } finally {
7599
+ deadline.dispose();
7600
+ }
7601
+ }
7602
+ //#endregion
7603
+ //#region src/command-guard.ts
7604
+ /** Tool names whose `arguments.command` this guard judges. */
7605
+ const COMMAND_GUARD_SHELL_TOOLS = ["bash", "pwsh"];
7606
+ /** Question id the safety verdict is keyed by. */
7607
+ const COMMAND_GUARD_QUESTION_ID = "safe";
7608
+ /** Decision budget; the approval prompt must not wait longer than this. */
7609
+ const COMMAND_GUARD_DEFAULT_TIMEOUT_MS = SYSTEMONE_DEFAULT_TIMEOUT_MS;
7610
+ /** Bounds of the configurable probability, mirrored by the settings schemas. */
7611
+ const COMMAND_GUARD_MIN_THRESHOLD = .5;
7612
+ const COMMAND_GUARD_MAX_TIMEOUT_MS = 1e4;
7613
+ /**
7614
+ * Longest command the guard will send for judgement. A longer one is delegated
7615
+ * instead of truncated: a verdict on the first N characters of a command is a
7616
+ * verdict on a different command.
7617
+ */
7618
+ const COMMAND_GUARD_MAX_COMMAND_CHARS = 6e3;
7619
+ /**
7620
+ * Hard denylist: commands the guard never auto-approves, and never even sends
7621
+ * for judgement. Each entry is a deliberate, narrow reading of one destructive
7622
+ * family — the goal is not to classify every dangerous command (that is the
7623
+ * model's job) but to make sure the classics cannot be talked into a grant by a
7624
+ * persuasive-looking command string.
7625
+ *
7626
+ * Every pattern is allowed to OVER-match: a false positive costs one ordinary
7627
+ * human prompt, while a false negative would hand a destructive command to a
7628
+ * model that has to guess. That asymmetry is why `sudo` is matched anywhere in
7629
+ * the line rather than only in command position, and why `fdisk` is listed even
7630
+ * though `fdisk -l` only lists partitions.
7631
+ */
7632
+ const COMMAND_GUARD_DENY_LIST = [
7633
+ {
7634
+ id: "rm-root",
7635
+ pattern: /\brm\s+(?:-[a-zA-Z]*[rR][a-zA-Z]*f[a-zA-Z]*|-[a-zA-Z]*f[a-zA-Z]*[rR][a-zA-Z]*|--recursive[^\n]*--force|--force[^\n]*--recursive)\s+(?:\/|\/\*|~|~\/\*|\$HOME(?:\/\*)?|\*|\.\/\*|\.)(?:\s|$)/
7636
+ },
7637
+ {
7638
+ id: "rm-home",
7639
+ pattern: /\brm\s+-[a-zA-Z]*[rR][a-zA-Z]*\s+(?:~|\$HOME)\//
7640
+ },
7641
+ {
7642
+ id: "filesystem-write",
7643
+ pattern: /\b(?:mkfs(?:\.\w+)?|wipefs|fdisk|parted|shred)\b/
7644
+ },
7645
+ {
7646
+ id: "raw-device-write",
7647
+ pattern: /\bdd\b[^\n]*\bof=\/dev\//
7648
+ },
7649
+ {
7650
+ id: "device-redirect",
7651
+ pattern: /(?:^|[^0-9\w])>{1,2}\s*\/dev\/(?:sd|disk|nvme|hd)/
7652
+ },
7653
+ {
7654
+ id: "fork-bomb",
7655
+ pattern: /:\s*\(\s*\)\s*\{\s*:\s*\|\s*:\s*&\s*\}\s*;\s*:/
7656
+ },
7657
+ {
7658
+ id: "privilege-escalation",
7659
+ pattern: /\b(?:sudo|doas)\b/
7660
+ },
7661
+ {
7662
+ id: "su",
7663
+ pattern: /\bsu\s/
7664
+ },
7665
+ {
7666
+ id: "chmod-world-write",
7667
+ pattern: /\bchmod\b[^\n]*\b(?:777|a\+w|o\+w)\b/
7668
+ },
7669
+ {
7670
+ id: "chown-root",
7671
+ pattern: /\bchown\b[^\n]*\b(?:root|0):/
7672
+ },
7673
+ {
7674
+ id: "git-push",
7675
+ pattern: /\bgit\s+push\b/
7676
+ },
7677
+ {
7678
+ id: "git-history-rewrite",
7679
+ pattern: /\bgit\s+(?:filter-branch|filter-repo)\b/
7680
+ },
7681
+ {
7682
+ id: "git-hard-reset",
7683
+ pattern: /\bgit\s+reset\s+--hard\b/
7684
+ },
7685
+ {
7686
+ id: "git-clean-force",
7687
+ pattern: /\bgit\s+clean\b[^\n]*\s-[a-zA-Z]*f/
7688
+ },
7689
+ {
7690
+ id: "git-discard-worktree",
7691
+ pattern: /\bgit\s+(?:checkout|restore)\s+(?:--\s+)?\.(?:\s|$)/
7692
+ },
7693
+ {
7694
+ id: "git-stash-drop",
7695
+ pattern: /\bgit\s+stash\s+(?:drop|clear)\b/
7696
+ },
7697
+ {
7698
+ id: "publish",
7699
+ pattern: /\b(?:npm|pnpm|yarn|bun)\s+publish\b/
7700
+ },
7701
+ {
7702
+ id: "release-upload",
7703
+ pattern: /\b(?:gh\s+release|twine\s+upload)\b/
7704
+ },
7705
+ {
7706
+ id: "docker-push",
7707
+ pattern: /\bdocker\s+push\b/
7708
+ },
7709
+ {
7710
+ id: "cloud-upload",
7711
+ pattern: /\b(?:aws\s+s3|gsutil|gcloud\s+storage|az\s+storage)\b[^\n]*\b(?:cp|sync|mv|rm|rb)\b/
7712
+ },
7713
+ {
7714
+ id: "remote-copy",
7715
+ pattern: /\b(?:scp|rsync)\b[^\n]*(?::|@)/
7716
+ },
7717
+ {
7718
+ id: "pipe-to-shell",
7719
+ pattern: /\b(?:curl|wget|fetch)\b[^\n|]*\|\s*(?:sudo\s+)?(?:sh|bash|zsh|dash|ksh|python\d*|node|ruby|perl)\b/
7720
+ },
7721
+ {
7722
+ id: "power",
7723
+ pattern: /\b(?:shutdown|reboot|halt|poweroff)\b/
7724
+ },
7725
+ {
7726
+ id: "kill-everything",
7727
+ pattern: /\bkill\s+(?:-9\s+)?-1\b|\b(?:killall|pkill)\s+-9\b/
7728
+ },
7729
+ {
7730
+ id: "credential-write",
7731
+ pattern: /(?:>{1,2}|tee|cp|mv|rm|chmod|chown)\s[^\n]*(?:\.ssh\b|\.aws\b|\.gnupg\b|\.commandcode\/auth\.json|id_rsa|id_ed25519)/
7732
+ },
7733
+ {
7734
+ id: "history-clear",
7735
+ pattern: /\bhistory\s+-c\b|\bunset\s+HISTFILE\b/
7736
+ },
7737
+ {
7738
+ id: "global-install",
7739
+ pattern: /\b(?:npm|pnpm|yarn|bun)\s+(?:i|install|add)\s+(?:-g\b|--global\b)/
7740
+ },
7741
+ {
7742
+ id: "system-install",
7743
+ pattern: /\b(?:apt|apt-get|dnf|yum|zypper|apk|pacman|brew)\s+(?:install|remove|purge|uninstall|upgrade)\b/
7744
+ },
7745
+ {
7746
+ id: "language-install",
7747
+ pattern: /\b(?:pip3?|pipx|gem|cargo)\s+(?:install|add)\b/
7748
+ }
7749
+ ];
7750
+ /**
7751
+ * Why the guard refuses to judge a command, when it does.
7752
+ *
7753
+ * @param command - the raw command text.
7754
+ * @returns the matching denylist id, or undefined.
7755
+ */
7756
+ function denyReasonOf(command) {
7757
+ for (const entry of COMMAND_GUARD_DENY_LIST) if (entry.pattern.test(command)) return entry.id;
7758
+ }
7759
+ /** A non-empty string member, or undefined. */
7760
+ function stringMember(value, key) {
7761
+ if (typeof value !== "object" || value === null) return void 0;
7762
+ const member = value[key];
7763
+ return typeof member === "string" && member.trim() !== "" ? member : void 0;
7764
+ }
7765
+ /**
7766
+ * Read a shell command out of a tool execution, or undefined when this call is
7767
+ * not one the guard judges (a different tool, or arguments without a command).
7768
+ *
7769
+ * @param name - the executed tool's name.
7770
+ * @param args - the execution's parsed `arguments`.
7771
+ * @returns the fact to judge, without the asker's reason (that arrives later).
7772
+ */
7773
+ function shellCommandOf(name, args) {
7774
+ if (!COMMAND_GUARD_SHELL_TOOLS.includes(name)) return void 0;
7775
+ const command = stringMember(args, "command");
7776
+ if (command === void 0) return void 0;
7777
+ const description = stringMember(args, "description");
7778
+ const workdir = stringMember(args, "workdir");
7779
+ const sandboxPermissions = stringMember(args, "sandbox_permissions");
7780
+ return {
7781
+ tool: name,
7782
+ command,
7783
+ ...description === void 0 ? {} : { description },
7784
+ ...workdir === void 0 ? {} : { workdir },
7785
+ ...sandboxPermissions === void 0 ? {} : { sandboxPermissions }
7786
+ };
7787
+ }
7788
+ /**
7789
+ * Build the decision request for one command.
7790
+ *
7791
+ * The question is deliberately one `noul`: a single probability is a decision
7792
+ * rule that can be explained, thresholded and logged in one line, and the
7793
+ * criteria carry the whole distinction between "reads, or writes reversibly
7794
+ * inside the project" and "everything else". The agent's own `description` is
7795
+ * included as context, but the criteria say the command text itself decides — a
7796
+ * model-written description is a claim, not evidence.
7797
+ *
7798
+ * @param fact - the command and its context.
7799
+ * @returns the request to send.
7800
+ */
7801
+ function buildSafetyRequest(fact) {
7802
+ const lines = [
7803
+ `Tool: ${fact.tool}`,
7804
+ "Command:",
7805
+ fact.command,
7806
+ ...fact.description === void 0 ? [] : ["", `The agent describes this command as: ${fact.description}`],
7807
+ ...fact.workdir === void 0 ? [] : ["", `Working directory: ${fact.workdir}`],
7808
+ ...fact.sandboxPermissions === void 0 ? [] : ["", `The harness is asking because this call requests a wider sandbox mode (${fact.sandboxPermissions}) than the one currently in force.`],
7809
+ ...fact.reason === void 0 ? [] : ["", `The asker's reason for needing approval: ${fact.reason}`]
7810
+ ];
7811
+ const question = {
7812
+ type: "noul",
7813
+ instructions: "Is it safe to run this command right now WITHOUT asking the user first? Judge the command text itself; a description is only a claim about it.",
7814
+ criteria: {
7815
+ true: "Read-only, or its effects stay inside the current project and are trivially reversible: no data leaves the machine, nothing outside the project is modified, and no credentials, git history or remote state are touched.",
7816
+ false: "Anything else. Deleting or overwriting files outside the project, rewriting or publishing git history, uploading data anywhere, installing system or global packages, changing permissions, ownership or credentials, killing processes, running as another user or as root, piping a download into a shell, or a command obfuscated enough that its effect cannot be read."
7817
+ }
7818
+ };
7819
+ return {
7820
+ state: lines.join("\n"),
7821
+ questions: { [COMMAND_GUARD_QUESTION_ID]: question }
7822
+ };
7823
+ }
7824
+ /**
7825
+ * Read the safety probability out of a decision response.
7826
+ *
7827
+ * @param response - the parsed response.
7828
+ * @param questionId - the question id to read (defaults to the guard's own).
7829
+ * @returns the probability of "safe", or undefined when the answer is missing
7830
+ * or is not a `noul` verdict.
7831
+ */
7832
+ function safeProbabilityOf(response, questionId = COMMAND_GUARD_QUESTION_ID) {
7833
+ const answer = response.answers[questionId];
7834
+ return answer !== void 0 && answer.type === "noul" ? answer.noul : void 0;
7835
+ }
7836
+ /**
7837
+ * One-line, length-bounded excerpt of a command for the log. The full text is
7838
+ * already in the session's tool call and was already sent to the decision
7839
+ * endpoint, so this adds no exposure — it is what makes "why did this run
7840
+ * without asking?" answerable from the log alone.
7841
+ */
7842
+ function commandExcerpt(command, limit = 160) {
7843
+ const flat = command.replace(/\s+/g, " ").trim();
7844
+ return flat.length <= limit ? flat : `${flat.slice(0, limit - 1)}…`;
7845
+ }
7846
+ /**
7847
+ * The guard's stateful half: what was executed (by call id) and what has
7848
+ * already been decided (by agent and complete decision context), both bounded.
7849
+ *
7850
+ * The execution cache is the bridge between the two seams — `tools/pre-execute`
7851
+ * knows the arguments, `approval/request` knows the call id — and it is keyed by
7852
+ * call id with an agent-identity check when read: an entry for an execution
7853
+ * that never asked is never read, so there is nothing to clean up on the result
7854
+ * side (and the ask happens before the result anyway).
7855
+ */
7856
+ var CommandGuard = class {
7857
+ deps;
7858
+ executions = /* @__PURE__ */ new Map();
7859
+ decisions = /* @__PURE__ */ new WeakMap();
7860
+ constructor(deps) {
7861
+ this.deps = deps;
7862
+ }
7863
+ /**
7864
+ * Remember one execution's command. Never throws, never changes a decision.
7865
+ *
7866
+ * @param execution - the execution about to run.
7867
+ */
7868
+ noteExecution(execution) {
7869
+ const callId = execution?.callId;
7870
+ if (typeof callId !== "string" || callId === "") return;
7871
+ const fact = shellCommandOf(execution.name, execution.arguments);
7872
+ if (fact === void 0) return;
7873
+ this.executions.delete(callId);
7874
+ this.executions.set(callId, {
7875
+ fact,
7876
+ agent: execution.agent
7877
+ });
7878
+ while (this.executions.size > 64) {
7879
+ const oldest = this.executions.keys().next();
7880
+ if (oldest.done === true) break;
7881
+ this.executions.delete(oldest.value);
7882
+ }
7883
+ }
7884
+ /** Forget every remembered execution and decision (disposal, tests). */
7885
+ clear() {
7886
+ this.executions.clear();
7887
+ this.decisions = /* @__PURE__ */ new WeakMap();
7888
+ }
7889
+ /**
7890
+ * Judge one approval request.
7891
+ *
7892
+ * @param request - the pending approval.
7893
+ * @returns the verdict; `approve` is the only outcome that grants anything.
7894
+ */
7895
+ async judge(request) {
7896
+ const settings = this.deps.settings();
7897
+ if (!settings.enabled) return {
7898
+ kind: "delegate",
7899
+ reason: "guard disabled"
7900
+ };
7901
+ const callId = request.callId;
7902
+ if (callId === void 0) return {
7903
+ kind: "delegate",
7904
+ reason: "approval carries no call id"
7905
+ };
7906
+ const observed = this.executions.get(callId);
7907
+ if (observed === void 0 || observed.agent !== request.agent) return {
7908
+ kind: "delegate",
7909
+ reason: "the guard never saw this call for this agent"
7910
+ };
7911
+ const fact = observed.fact;
7912
+ const excerpt = commandExcerpt(fact.command);
7913
+ if (!COMMAND_GUARD_SHELL_TOOLS.includes(request.toolName)) return {
7914
+ kind: "delegate",
7915
+ reason: `not a shell tool (${request.toolName})`
7916
+ };
7917
+ if (fact.command.length > 6e3) return {
7918
+ kind: "delegate",
7919
+ reason: `command longer than ${COMMAND_GUARD_MAX_COMMAND_CHARS} characters`,
7920
+ command: excerpt
7921
+ };
7922
+ const denied = denyReasonOf(fact.command);
7923
+ if (denied !== void 0) return {
7924
+ kind: "delegate",
7925
+ reason: `denylisted command (${denied})`,
7926
+ command: excerpt
7927
+ };
7928
+ const full = {
7929
+ ...fact,
7930
+ ...request.reason === void 0 ? {} : { reason: request.reason }
7931
+ };
7932
+ const memoKey = JSON.stringify(full);
7933
+ const now = this.deps.now?.() ?? Date.now();
7934
+ const agentDecisions = request.agent === void 0 ? void 0 : this.decisions.get(request.agent);
7935
+ const memo = agentDecisions?.get(memoKey);
7936
+ if (memo !== void 0) {
7937
+ if (now - memo.at < 6e5) return this.verdict(memo.probability, settings.threshold, "memo", excerpt);
7938
+ agentDecisions?.delete(memoKey);
7939
+ }
7940
+ let response;
7941
+ try {
7942
+ response = await this.deps.decide(buildSafetyRequest(full), {
7943
+ ...request.signal === void 0 ? {} : { signal: request.signal },
7944
+ timeoutMs: settings.timeoutMs
7945
+ });
7946
+ } catch (error) {
7947
+ return {
7948
+ kind: "delegate",
7949
+ reason: `decision unavailable: ${error instanceof Error ? error.message : String(error)}`,
7950
+ command: excerpt
7951
+ };
7952
+ }
7953
+ const probability = safeProbabilityOf(response);
7954
+ if (probability === void 0) return {
7955
+ kind: "delegate",
7956
+ reason: "the decision carried no safe verdict",
7957
+ command: excerpt
7958
+ };
7959
+ if (request.agent !== void 0) {
7960
+ const decisions = agentDecisions ?? /* @__PURE__ */ new Map();
7961
+ decisions.set(memoKey, {
7962
+ probability,
7963
+ at: now
7964
+ });
7965
+ while (decisions.size > 64) {
7966
+ const oldest = decisions.keys().next();
7967
+ if (oldest.done === true) break;
7968
+ decisions.delete(oldest.value);
7969
+ }
7970
+ this.decisions.set(request.agent, decisions);
7971
+ }
7972
+ return this.verdict(probability, settings.threshold, "model", excerpt);
7973
+ }
7974
+ /** Apply the threshold to one probability. */
7975
+ verdict(probability, threshold, source, command) {
7976
+ return probability >= threshold ? {
7977
+ kind: "approve",
7978
+ probability,
7979
+ source,
7980
+ command
7981
+ } : {
7982
+ kind: "delegate",
7983
+ reason: `safe probability ${probability.toFixed(3)} below ${threshold}`,
7984
+ probability,
7985
+ command
7986
+ };
7987
+ }
7988
+ };
7989
+ /**
7990
+ * Register the guard on a context that has the approval seam.
7991
+ *
7992
+ * Call this from inside `ctx.inject(['approval'], …)`: the seam's presence is
7993
+ * what makes the feature meaningful (no approval service means no asks), and
7994
+ * tying the listeners to that injected fiber is what makes them go away with it.
7995
+ *
7996
+ * @param ctx - the injected context (or a test double with `on`).
7997
+ * @param deps - settings, the decision client, clock and logger.
7998
+ * @returns a disposer for both listeners.
7999
+ */
8000
+ function applyCommandGuard(ctx, deps) {
8001
+ const guard = new CommandGuard(deps);
8002
+ const events = ctx;
8003
+ const offPreExecute = events.on("tools/pre-execute", (execution, next) => {
8004
+ guard.noteExecution(execution);
8005
+ return next();
8006
+ }, { prepend: true });
8007
+ const offApproval = events.on("approval/request", async (request, next) => {
8008
+ try {
8009
+ if (!deps.settings().enabled) return next();
8010
+ } catch (error) {
8011
+ deps.log?.(`command guard failed: ${error instanceof Error ? error.message : String(error)}`);
8012
+ return next();
8013
+ }
8014
+ let verdict;
8015
+ try {
8016
+ verdict = await guard.judge(request);
8017
+ } catch (error) {
8018
+ deps.log?.(`command guard failed: ${error instanceof Error ? error.message : String(error)}`);
8019
+ return next();
8020
+ }
8021
+ if (verdict.kind === "approve") {
8022
+ deps.log?.(`command guard auto-approved a ${request.toolName} call (safe probability ${verdict.probability.toFixed(3)}, ${verdict.source}): ${verdict.command ?? "(command unknown)"}`);
8023
+ return "allowed-once";
8024
+ }
8025
+ deps.log?.(`command guard delegated a ${request.toolName} call: ${verdict.reason}${verdict.command === void 0 ? "" : ` — ${verdict.command}`}`);
8026
+ return next();
8027
+ }, { prepend: true });
8028
+ return () => {
8029
+ guard.clear();
8030
+ for (const off of [offPreExecute, offApproval]) if (typeof off === "function") off();
8031
+ };
8032
+ }
8033
+ //#endregion
8034
+ //#region src/config-volatile.ts
8035
+ /**
8036
+ * Mark one schema field volatile when the installed schemastery supports it.
8037
+ * @param schema - The field schema (any generation).
8038
+ * @returns The schema carrying `meta.volatile`, or the schema itself on an
8039
+ * engine whose schemastery predates `.volatile()`.
8040
+ */
8041
+ function markVolatile(schema) {
8042
+ const candidate = schema;
8043
+ if (typeof candidate.volatile !== "function") return schema;
8044
+ return candidate.volatile();
8045
+ }
8046
+ /**
8047
+ * Mark every field of a Config field dict volatile, except the named
8048
+ * composition-only secrets (`apiKey` here, which no settings surface writes).
8049
+ *
8050
+ * The result is a NEW dict of schema instances: callers that also need an
8051
+ * UNMARKED schema for the legacy settings generation must build the fields
8052
+ * twice (`configFields()`), never share one dict — `.volatile()` clones per
8053
+ * call, and a shared instance would carry the mark into the legacy schema.
8054
+ *
8055
+ * @param fields - Freshly built field schemas.
8056
+ * @param skip - Field names that must stay unmarked.
8057
+ * @returns The field dict with every non-skipped field marked.
8058
+ */
8059
+ function markVolatileFields(fields, skip = []) {
8060
+ return Object.fromEntries(Object.entries(fields).map(([name, schema]) => [name, skip.includes(name) ? schema : markVolatile(schema)]));
8061
+ }
8062
+ /**
8063
+ * Whether a resolved config value is a volatile reference.
8064
+ *
8065
+ * Duck-typed on the two properties `createVolatile` produces (frozen object
8066
+ * with a `get` method) rather than cosmokit's private `write` symbol, because
8067
+ * cosmokit is not a peer of this bundle. Config values are plain parsed JSON
8068
+ * (or `!!jsExpr` wrappers), which are never frozen, so the test cannot
8069
+ * misfire on an ordinary field.
8070
+ */
8071
+ function isVolatileRef(value) {
8072
+ return typeof value === "object" && value !== null && !Array.isArray(value) && Object.isFrozen(value) && typeof value.get === "function";
8073
+ }
8074
+ /**
8075
+ * Read one config object as PLAIN values across generations.
8076
+ *
8077
+ * Each top-level field found to be a volatile reference is read through
8078
+ * `.get()` — freshly on every call, so a live update lands on the very next
8079
+ * read. When no field is a reference the input object is returned as-is,
8080
+ * keeping reference identity stable for identity-keyed memos on engines that
8081
+ * hand `apply()` plain config.
8082
+ */
8083
+ function unwrapVolatileConfig(config) {
8084
+ const source = config;
8085
+ if (typeof source !== "object" || source === null) return config;
8086
+ let found = false;
8087
+ const plain = {};
8088
+ for (const [field, value] of Object.entries(source)) if (isVolatileRef(value)) {
8089
+ plain[field] = value.get();
8090
+ found = true;
8091
+ } else plain[field] = value;
8092
+ return found ? plain : config;
8093
+ }
8094
+ //#endregion
8095
+ //#region src/index.ts
8096
+ /**
8097
+ * dsh-commandcode-provider — DeepSeek Harness LLM provider plugin for Command
8098
+ * Code (unofficial; ported from pi-commandcode-provider@0.5.1).
8099
+ *
8100
+ * Registers the `commandcode` provider route on `ctx.llm` and declares it in
8101
+ * the configurable-provider directory, so the web Models page shows a
8102
+ * "Command Code" card with an API-key field and the model picker lists the
6692
8103
  * live Command Code model catalog. Connection facts resolve per request over
6693
- * the optional `llm-commandcode` user-settings section and the credential
6694
- * seam, so a changed key, endpoint, or cache path reaches the next request
6695
- * without a restart.
8104
+ * the plugin's live config — the `llm-commandcode` settings section through
8105
+ * dsh 0.1.6, profile Config (volatile fields, unwrapped per read) from 0.1.7
8106
+ * — and the credential seam, so a changed key, endpoint, or cache path
8107
+ * reaches the next request without a restart.
6696
8108
  *
6697
8109
  * ```yaml
6698
8110
  * - id: llm-commandcode
@@ -6717,41 +8129,88 @@ const DEFAULT_API_KEY_ENV = "COMMANDCODE_API_KEY";
6717
8129
  const PROVIDER = "commandcode";
6718
8130
  /** Default models cache path (mirrors the pi plugin's on-disk cache). */
6719
8131
  const DEFAULT_MODELS_CACHE_PATH = join(homedir(), ".commandcode", "models-cache.json");
6720
- const Config = z.object({
6721
- apiKeyEnv: z.string().role("credential-ref").default(DEFAULT_API_KEY_ENV),
6722
- apiKey: z.string().role("secret"),
6723
- apiBase: z.string(),
6724
- workingDir: z.string(),
6725
- modelsCachePath: z.string(),
6726
- requestTimeoutMs: z.number().min(1).max(MAX_TIMER_DELAY_MS),
6727
- streamIdleTimeoutMs: z.number().min(1).max(MAX_TIMER_DELAY_MS),
6728
- transportMaxRetries: z.number().min(0).max(50),
6729
- filterModelsByPlan: z.boolean(),
6730
- visibleModels: z.array(z.string()),
6731
- /**
6732
- * Per-model visibility overrides for the terminal settings page's checkbox
6733
- * list, keyed by catalog id. dsh-TUI addresses a staged edit by its field
6734
- * PATH, so two checkboxes sharing one path would overwrite each other's
6735
- * draft and the section's last model would decide every write; a map gives
6736
- * each checkbox a path of its own. An id listed here wins over
6737
- * {@link visibleModels}; ids absent here keep following it.
6738
- */
6739
- modelVisibility: z.dict(z.boolean()),
6740
- webSearch: z.boolean().default(true),
6741
- showSidebarQuota: z.boolean().default(false),
6742
- accounts: z.array(z.object({
6743
- label: z.string(),
6744
- apiKeyEnv: z.string().role("credential-ref"),
6745
- /** Literal key for one extra slot; see the top-level `apiKey` secret note. */
6746
- apiKey: z.string().role("secret")
6747
- })),
6748
- activeAccount: z.string(),
6749
- modelAccountRules: z.array(z.object({
6750
- models: z.array(z.string()),
6751
- account: z.string()
6752
- })),
6753
- lang: z.string().pattern(/^(zh|en)$/).default("zh")
6754
- });
8132
+ /**
8133
+ * The Config schema's fields, freshly built on every call.
8134
+ *
8135
+ * A FACTORY, not a shared dict, and that is load-bearing: the exported
8136
+ * {@link Config} marks every field volatile while {@link LegacySettingsSchema}
8137
+ * must carry no mark at all, and schemastery's schema instances are
8138
+ * single-purpose objects — sharing one dict would leak the marks into the
8139
+ * legacy registration (see `config-volatile.ts` for why that is a boot-time
8140
+ * `ValidationError` on ≤0.1.6 engines whose schemastery resolved to ≥3.18.3).
8141
+ *
8142
+ * @returns One fresh schema instance per Config field.
8143
+ */
8144
+ function configFields() {
8145
+ return {
8146
+ apiKeyEnv: z.string().role("credential-ref").default(DEFAULT_API_KEY_ENV),
8147
+ apiKey: z.string().role("secret"),
8148
+ apiBase: z.string(),
8149
+ workingDir: z.string(),
8150
+ modelsCachePath: z.string(),
8151
+ requestTimeoutMs: z.number().min(1).max(MAX_TIMER_DELAY_MS),
8152
+ streamIdleTimeoutMs: z.number().min(1).max(MAX_TIMER_DELAY_MS),
8153
+ transportMaxRetries: z.number().min(0).max(50),
8154
+ filterModelsByPlan: z.boolean(),
8155
+ visibleModels: z.array(z.string()),
8156
+ /**
8157
+ * Per-model visibility overrides for the terminal settings page's checkbox
8158
+ * list, keyed by catalog id. dsh-TUI addresses a staged edit by its field
8159
+ * PATH, so two checkboxes sharing one path would overwrite each other's
8160
+ * draft and the section's last model would decide every write; a map gives
8161
+ * each checkbox a path of its own. An id listed here wins over
8162
+ * {@link Config.visibleModels}; ids absent here keep following it.
8163
+ */
8164
+ modelVisibility: z.dict(z.boolean()),
8165
+ webSearch: z.boolean().default(true),
8166
+ showSidebarQuota: z.boolean().default(false),
8167
+ accounts: z.array(z.object({
8168
+ label: z.string(),
8169
+ apiKeyEnv: z.string().role("credential-ref"),
8170
+ /** Literal key for one extra slot; see the top-level `apiKey` secret note. */
8171
+ apiKey: z.string().role("secret")
8172
+ })),
8173
+ activeAccount: z.string(),
8174
+ modelAccountRules: z.array(z.object({
8175
+ models: z.array(z.string()),
8176
+ account: z.string()
8177
+ })),
8178
+ commandGuard: z.boolean().default(false),
8179
+ commandGuardThreshold: z.number().min(COMMAND_GUARD_MIN_THRESHOLD).max(1),
8180
+ commandGuardTimeoutMs: z.number().min(200).max(COMMAND_GUARD_MAX_TIMEOUT_MS),
8181
+ zdr: z.boolean().default(false),
8182
+ lang: z.string().pattern(/^(zh|en)$/).default("zh")
8183
+ };
8184
+ }
8185
+ /**
8186
+ * The 0.1.7-generation schema: every field volatile except the
8187
+ * composition-only `apiKey` secret.
8188
+ *
8189
+ * dsh 0.1.7's settings forms are projected from the schema's `meta.volatile`
8190
+ * nodes, so an unmarked field would be invisible to AND unwritable from the
8191
+ * settings page and refused by form-edit path validation — and on that
8192
+ * generation the loader hands `apply()` a live reference per marked field,
8193
+ * committing later writes without remounting this fiber (the
8194
+ * `loader/volatile-update` listener at the bottom of `apply` covers the facts
8195
+ * that are not re-derived per read). The mark is inert on engines whose
8196
+ * schemastery predates `.volatile()`.
8197
+ */
8198
+ const Config = z.object(markVolatileFields(configFields(), ["apiKey"]));
8199
+ /**
8200
+ * The ≤0.1.6-generation registration schema: the same fields, NO volatile
8201
+ * marks.
8202
+ *
8203
+ * The legacy `installSection(owner, ns, schema, entry, hooks)` re-validates the
8204
+ * `entry` it is handed (`register()` → `resolve()` → `schema(mergeLayers(base,
8205
+ * section))`) and its `describe()` structuredClones that same `entry`. Since
8206
+ * schemastery ≥3.18.3 creates references at parse time on EVERY generation —
8207
+ * `dsh-settings` through 0.1.6 declares `schemastery: ^3.18.2`, so old engines
8208
+ * freshly installed today resolve 3.18.3 — a marked schema plus the raw
8209
+ * reference-carrying `config` is a boot-time `ValidationError` there. This
8210
+ * schema is therefore unmarked, and `apply` pairs it with
8211
+ * `unwrapVolatileConfig(config)`.
8212
+ */
8213
+ const LegacySettingsSchema = z.object(configFields());
6755
8214
  /**
6756
8215
  * The one explicit resolve step from raw config to validated connection
6757
8216
  * facts. Programmatic construction may bypass Schemastery normalization, so
@@ -6768,7 +8227,8 @@ function resolveAdapterOptions(config) {
6768
8227
  streamIdleTimeoutMs: config.streamIdleTimeoutMs ?? 3e5,
6769
8228
  filterModelsByPlan: config.filterModelsByPlan ?? true,
6770
8229
  visibleModels: Array.isArray(config.visibleModels) ? config.visibleModels.filter((id) => typeof id === "string" && id !== "") : void 0,
6771
- modelVisibility: readModelVisibility(config.modelVisibility)
8230
+ modelVisibility: readModelVisibility(config.modelVisibility),
8231
+ zdr: config.zdr === true
6772
8232
  };
6773
8233
  }
6774
8234
  /**
@@ -6785,7 +8245,8 @@ function readModelVisibility(raw) {
6785
8245
  }
6786
8246
  function apply(ctx, config) {
6787
8247
  installCostProjection(ctx);
6788
- let current = () => config;
8248
+ let source = () => config;
8249
+ const current = () => unwrapVolatileConfig(source());
6789
8250
  let lastRaw;
6790
8251
  let lastGood;
6791
8252
  const options = () => {
@@ -6981,6 +8442,26 @@ function apply(ctx, config) {
6981
8442
  applyCommandCodeSearchSelection(webCtx.web, searchSelection, false);
6982
8443
  }, "dsh-commandcode-provider: web search selection");
6983
8444
  });
8445
+ const commandGuardSettings = () => {
8446
+ const raw = current();
8447
+ return {
8448
+ enabled: raw.commandGuard === true,
8449
+ threshold: raw.commandGuardThreshold ?? .9,
8450
+ timeoutMs: raw.commandGuardTimeoutMs ?? COMMAND_GUARD_DEFAULT_TIMEOUT_MS
8451
+ };
8452
+ };
8453
+ ctx.inject(["approval"], (approvalCtx) => {
8454
+ applyCommandGuard(approvalCtx, {
8455
+ settings: commandGuardSettings,
8456
+ decide: (request, decideOptions) => runSystemOne({
8457
+ apiBase: () => options().apiBase,
8458
+ resolveApiKey: () => resolveApiKey(options())
8459
+ }, request, decideOptions),
8460
+ log: (message) => {
8461
+ ctx.logger.info(`llm-commandcode: ${message}`);
8462
+ }
8463
+ });
8464
+ });
6984
8465
  let refreshTuiSettings;
6985
8466
  ctx.inject(["tuiSettingsSections"], (tuiCtx) => {
6986
8467
  refreshTuiSettings = applyCommandCodeTuiSettings(tuiCtx, {
@@ -6998,18 +8479,38 @@ function apply(ctx, config) {
6998
8479
  }, "dsh-commandcode-provider: tui settings handle");
6999
8480
  });
7000
8481
  ctx.inject(["settings"], (settingsCtx) => {
7001
- settingsCtx.settings.installSection(ctx, NS, Config, config, {
7002
- setSource: (source) => {
7003
- current = source;
7004
- },
7005
- onChange: () => {
7006
- applySearchSelection(current().webSearch ?? true);
7007
- refreshTuiSettings?.();
7008
- }
7009
- });
8482
+ const service = settingsCtx.settings;
8483
+ if (typeof service.installSection === "function") {
8484
+ service.installSection(ctx, NS, LegacySettingsSchema, unwrapVolatileConfig(config), {
8485
+ setSource: (next) => {
8486
+ source = next;
8487
+ },
8488
+ onChange: () => {
8489
+ applySearchSelection(current().webSearch ?? true);
8490
+ refreshTuiSettings?.();
8491
+ }
8492
+ });
8493
+ return;
8494
+ }
8495
+ if (typeof service.configure === "function") settingsCtx.effect(() => {
8496
+ const dispose = service.configure?.({ auto: false }, ctx.fiber);
8497
+ return () => {
8498
+ dispose?.();
8499
+ };
8500
+ }, "dsh-commandcode-provider: settings auto-form policy");
7010
8501
  });
8502
+ const loaderEvents = ctx;
8503
+ ctx.effect(() => {
8504
+ const off = loaderEvents.on("loader/volatile-update", () => {
8505
+ applySearchSelection(current().webSearch ?? true);
8506
+ refreshTuiSettings?.();
8507
+ });
8508
+ return () => {
8509
+ if (typeof off === "function") off();
8510
+ };
8511
+ }, "dsh-commandcode-provider: volatile config updates");
7011
8512
  }
7012
8513
  //#endregion
7013
- 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 };
8514
+ 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_NON_ZDR_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, supportsZeroDataRetention, usageReportSchema, validateCommandApiKey };
7014
8515
 
7015
8516
  //# sourceMappingURL=index.js.map