@khorsheed/dsh-local-agent-codex 0.2.0-rc.1 → 0.2.0

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,12 +1,12 @@
1
1
  import z from "@deepseek-ai/schemastery";
2
- import { LIVE_FLUSH_INTERVAL_MS, LiveFlush, LiveStreamPublisher, ModelDirectoryCache, assertResumeCwdUnchanged, assertResumeScopeUnchanged, containerExecSpawn, containerScopedHome, delegationEnv, establishSubagentCatalogChild, extendModelDirectory, modelDirectoryContextKey, persistChildSession, probeCliVersion, resolveChildCwd, resolveRoundModel, settingsFace, subagentDelegationLabel, vol } from "@khorsheed/dsh-local-agent";
2
+ import { LIVE_FLUSH_INTERVAL_MS, LiveFlush, LiveStreamPublisher, ModelDirectoryCache, assertResumeCwdUnchanged, assertResumeScopeUnchanged, containerExecSpawn, containerScopedHome, delegationEnv, establishSubagentCatalogChild, extendModelDirectory, firstModelOf, modelDirectoryContextKey, persistChildSession, probeCliVersion, readModelList, readModelListSync, resolveChildCwd, resolveRoundModel, settingsFace, subagentDelegationLabel, vol, writeModelList } from "@khorsheed/dsh-local-agent";
3
3
  import { MEMBER_BRIDGE_SOCKET_ENV, MEMBER_BRIDGE_TOKEN_ENV, endpointHost } from "@khorsheed/dsh-local-agent/types";
4
4
  import { randomUUID } from "node:crypto";
5
5
  import { createAssistantMessage, createToolResultMessage, createUserMessage } from "@deepseek-ai/dsh-llm";
6
6
  import { SessionId } from "@deepseek-ai/dsh-session";
7
- import { NO_START_CAPABILITIES, settleRunResult, subprocessRunHandle } from "@deepseek-ai/dsh-subagent";
7
+ import { NO_START_CAPABILITIES, SUBAGENT_DESCRIPTOR_VERSION, settleRunResult, subprocessRunHandle } from "@deepseek-ai/dsh-subagent";
8
8
  import { StringDecoder } from "node:string_decoder";
9
- import { open, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
9
+ import { mkdir, open, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
10
10
  import { dirname, join, resolve } from "node:path";
11
11
  //#region lib/types/records.js
12
12
  /**
@@ -38,21 +38,6 @@ const ROLLOUT_WINDOW_SLACK_MS = 3e5;
38
38
  /** Extra future slack for the window end (file timestamps may skew ahead). */
39
39
  const ROLLOUT_WINDOW_FUTURE_MS = 6e4;
40
40
  /**
41
- * Whether the scoped home holds usable Codex credentials: the `auth.json`
42
- * file exists. Absent means the device-code flow has not completed.
43
- * @param homeDir - the `codex` harness's scoped home.
44
- * @returns true when credentials are present.
45
- */
46
- async function codexAuthenticated(homeDir) {
47
- try {
48
- await readFile(join(homeDir, "auth.json"));
49
- return true;
50
- } catch (error) {
51
- if (error.code === "ENOENT") return false;
52
- throw error;
53
- }
54
- }
55
- /**
56
41
  * Parse the first line of a rollout file for the listing fields. Unknown or
57
42
  * malformed values yield an undefined record rather than failing the list.
58
43
  * @param text - the raw rollout file content (only the head is read).
@@ -150,10 +135,12 @@ async function listCodexSessions(homeDir) {
150
135
  * `input_tokens` is the TOTAL input including cache hits (OpenAI-style
151
136
  * accounting, confirmed against `total_tokens` in the rollout token_count),
152
137
  * and `cached_input_tokens` is the cache-read subset — so the uncached
153
- * bucket subtracts the cached portion to avoid double counting. There is no
154
- * cache-write concept, so that bucket is omitted (the shared four-bucket
155
- * contract: uncached input, output, cache read, cache write — codex fills
156
- * the first three).
138
+ * bucket subtracts the cached portion to avoid double counting.
139
+ * `cached_input_tokens` rides every payload (zero included), so
140
+ * `cacheReadTokens` is written whenever the field is present — a reader can
141
+ * tell "no hits" from "not reported". Codex also reports cache creation
142
+ * (`cache_write_input_tokens`), mapped to `cacheWriteTokens` (the shared
143
+ * four-bucket contract: uncached input, output, cache read, cache write).
157
144
  * @param usage - the raw codex usage object (`turn.completed`'s `usage` or a
158
145
  * rollout `token_count` `info.last_token_usage`/`total_token_usage`).
159
146
  * @returns the shared usage record.
@@ -162,12 +149,14 @@ function usageFromCodex(usage) {
162
149
  const raw = usage;
163
150
  const input = Number(raw.input_tokens);
164
151
  const cached = Number(raw.cached_input_tokens);
152
+ const cacheWrite = Number(raw.cache_write_input_tokens);
165
153
  const output = Number(raw.output_tokens);
166
154
  const usageRecord = {
167
- inputTokens: Number.isFinite(input) && Number.isFinite(cached) ? Math.max(0, input - cached) : Number.isFinite(input) ? input : 0,
155
+ inputTokens: Number.isFinite(input) && Number.isFinite(cached) ? Math.max(0, input - cached - (Number.isFinite(cacheWrite) ? cacheWrite : 0)) : Number.isFinite(input) ? input : 0,
168
156
  outputTokens: Number.isFinite(output) ? output : 0
169
157
  };
170
- if (Number.isFinite(cached) && cached > 0) usageRecord.cacheReadTokens = cached;
158
+ if (Number.isFinite(cached)) usageRecord.cacheReadTokens = cached;
159
+ if (Number.isFinite(cacheWrite) && cacheWrite > 0) usageRecord.cacheWriteTokens = cacheWrite;
171
160
  return usageRecord;
172
161
  }
173
162
  /**
@@ -430,6 +419,293 @@ async function codexRolloutRoundFacts(homeDir, locator) {
430
419
  };
431
420
  }
432
421
  //#endregion
422
+ //#region lib/types/endpoint-auth.js
423
+ /**
424
+ * Endpoint-key authentication — the codex harness's ONLY auth mode. There is
425
+ * no web login anywhere in this package: a delegation round reaches its
426
+ * endpoint with a custom `model_providers` entry on the argv pointing at the
427
+ * relay and the key traveling as `OPENAI_API_KEY` in the process environment
428
+ * (verified against codex-cli 0.163.0: a fresh scoped home with only these
429
+ * runs `codex exec` with no login file at all). With no credential the spawn
430
+ * carries no auth at all — it fails loudly at the endpoint, exactly the
431
+ * honest reading the status shows.
432
+ *
433
+ * Verified against codex-cli 0.163.0 (2026-10-20): a fresh scoped home with
434
+ * only `OPENAI_API_KEY` plus the two `-c` overrides below runs
435
+ * `codex exec -m <model>` end-to-end through an OpenAI-compatible relay
436
+ * (openproxy, `wire_api = "responses"`) — no login, no auth.json, no
437
+ * onboarding gate.
438
+ *
439
+ * Two layers, merged per field, file over config:
440
+ *
441
+ * - `<scopedHome>/endpoint-auth.json` (mode 0600) — the per-scope credential.
442
+ * Its name deliberately matches the eval contract hash's credential denylist
443
+ * (`auth`), so the key material never enters an evaluation's condition
444
+ * fingerprint; the file's mtime serves as the credential stamp, which makes
445
+ * "edit the file" the recovery gesture for a `rejected` grade.
446
+ * - The plugin config's `baseUrl` / `apiKey` — the deployment default every
447
+ * scope without a file shares. Read PER ROUND, so a profile edit applies to
448
+ * the next round (and a host restart clears the in-memory `rejected` mark).
449
+ *
450
+ * `wireApi` is a file-only escape hatch: the settings card never edits it,
451
+ * the default `responses` is codex's native wire (relay-verified), and a
452
+ * hand-edit to `"chat"` survives the card's whole-file writes because the
453
+ * read merges it back. The env layer rides the family's existing plumbing:
454
+ * `delegationEnv` on the host, `containerExecSpawn`'s name-only `-e`
455
+ * forwarding into a container — a containerized round authenticates with the
456
+ * same credential as the host, nothing is authorized inside the unit.
457
+ * @module @khorsheed/dsh-local-agent-codex/endpoint-auth
458
+ */
459
+ /**
460
+ * The scoped-home credential file's name. Chosen to match the eval contract
461
+ * hash's credential-shaped denylist (`/token|key|credential|secret|password|auth|\.env/i`),
462
+ * so the file is excluded from condition fingerprints by name.
463
+ */
464
+ const ENDPOINT_AUTH_FILE = "endpoint-auth.json";
465
+ /** The custom provider's TOML key in the `-c` overrides (bare-key safe). */
466
+ const ENDPOINT_PROVIDER_NAME = "dsh-endpoint";
467
+ /** The env var the custom provider reads the key from (`env_key`). */
468
+ const ENDPOINT_ENV_KEY = "OPENAI_API_KEY";
469
+ /** Trim and normalize one credential field; blank reads as absent. */
470
+ function field(value) {
471
+ if (typeof value !== "string") return void 0;
472
+ const trimmed = value.trim();
473
+ return trimmed === "" ? void 0 : trimmed;
474
+ }
475
+ /** Whether a resolved endpoint carries a usable credential. */
476
+ function hasEndpointCredential(auth) {
477
+ return auth !== void 0 && field(auth.apiKey) !== void 0;
478
+ }
479
+ /**
480
+ * Read the scope's endpoint-auth.json. Absent or unreadable yields undefined
481
+ * (the caller falls back to the plugin config); malformed JSON or a non-object
482
+ * payload also yields undefined — a torn file must not fail a status read.
483
+ * Non-string fields are dropped, never carried through.
484
+ * @param homeDir - the `codex` harness's scoped home.
485
+ * @returns the file's endpoint credential, or undefined.
486
+ */
487
+ async function readEndpointAuthFile(homeDir) {
488
+ let text;
489
+ try {
490
+ text = await readFile(join(homeDir, ENDPOINT_AUTH_FILE), "utf8");
491
+ } catch {
492
+ return;
493
+ }
494
+ try {
495
+ const parsed = JSON.parse(text);
496
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return void 0;
497
+ const baseUrl = field(parsed["baseUrl"]);
498
+ const apiKey = field(parsed["apiKey"]);
499
+ const wireApi = field(parsed["wireApi"]);
500
+ return {
501
+ ...baseUrl === void 0 ? {} : { baseUrl },
502
+ ...apiKey === void 0 ? {} : { apiKey },
503
+ ...wireApi === void 0 ? {} : { wireApi }
504
+ };
505
+ } catch {
506
+ return;
507
+ }
508
+ }
509
+ /**
510
+ * Resolve one scope's effective endpoint credential: the scope's
511
+ * endpoint-auth.json merged FIELD BY FIELD over the plugin config's
512
+ * `baseUrl` / `apiKey` (a file that names only a key rotates the key while
513
+ * keeping the deployment's relay URL). The result may carry no credential at
514
+ * all — {@link hasEndpointCredential} is the judgment, kept separate so the
515
+ * caller can still route/log the resolved baseUrl.
516
+ * @param homeDir - the scope's scoped home.
517
+ * @param config - the plugin config's deployment-level endpoint pair.
518
+ * @returns the merged endpoint auth.
519
+ */
520
+ async function resolveEndpointAuth(homeDir, config) {
521
+ const file = await readEndpointAuthFile(homeDir);
522
+ const configBaseUrl = field(config.baseUrl);
523
+ const configApiKey = field(config.apiKey);
524
+ return {
525
+ ...configBaseUrl === void 0 ? {} : { baseUrl: configBaseUrl },
526
+ ...configApiKey === void 0 ? {} : { apiKey: configApiKey },
527
+ ...file
528
+ };
529
+ }
530
+ /**
531
+ * The spawn-env layer for a resolved endpoint: the key under the custom
532
+ * provider's `env_key` name, nothing else. A credential-less resolution
533
+ * yields an empty layer — the spread then adds no keys at all, byte-identical
534
+ * to the pre-endpoint env. With no baseUrl the CLI's built-in OpenAI provider
535
+ * reads the same variable, so an official platform key needs no `-c`
536
+ * override either.
537
+ * @param auth - the resolved endpoint auth, or undefined.
538
+ * @returns the explicit env entries for the delegation spawn.
539
+ */
540
+ function endpointAuthEnv(auth) {
541
+ const apiKey = field(auth?.apiKey);
542
+ return apiKey === void 0 ? {} : { [ENDPOINT_ENV_KEY]: apiKey };
543
+ }
544
+ /** Quote one TOML basic string for the `-c` config override. */
545
+ function tomlString$1(value) {
546
+ return `"${value.replace(/\\/g, "\\\\").replace(/"/g, "\\\"")}"`;
547
+ }
548
+ /**
549
+ * The `-c` config overrides routing the CLI at the resolved relay: a custom
550
+ * `model_providers` entry plus the `model_provider` selection, as a flat argv
551
+ * fragment (`-c <override>` pairs). Empty unless BOTH a key and a baseUrl are
552
+ * present — a key without a relay URL is served by the built-in provider
553
+ * through the env layer alone, and a relay URL without a key would route
554
+ * every round into a 401. Verified end-to-end against codex-cli 0.163.0.
555
+ * @param auth - the resolved endpoint auth, or undefined.
556
+ * @returns the argv fragment, possibly empty.
557
+ */
558
+ function endpointConfigOverrides(auth) {
559
+ const baseUrl = field(auth?.baseUrl);
560
+ const apiKey = field(auth?.apiKey);
561
+ if (baseUrl === void 0 || apiKey === void 0) return [];
562
+ const wireApi = field(auth?.wireApi) ?? "responses";
563
+ return [
564
+ "-c",
565
+ `model_provider=${tomlString$1(ENDPOINT_PROVIDER_NAME)}`,
566
+ "-c",
567
+ `model_providers.${ENDPOINT_PROVIDER_NAME}={name=${tomlString$1(ENDPOINT_PROVIDER_NAME)},base_url=${tomlString$1(baseUrl)},env_key=${tomlString$1(ENDPOINT_ENV_KEY)},wire_api=${tomlString$1(wireApi)}}`
568
+ ];
569
+ }
570
+ /**
571
+ * The credential marker's modification stamp (epoch ms): the endpoint file's
572
+ * mtime, or undefined when the scope has no file (a config-sourced credential
573
+ * has no marker — rotating it pairs with the profile edit and instance
574
+ * restart that clear the in-memory grade marks). A fresh file write is a
575
+ * fresh stamp, which is how an edited-in key recovers a `rejected` grade.
576
+ * @param homeDir - the scope's scoped home.
577
+ * @returns the stamp, or undefined.
578
+ */
579
+ async function endpointAuthStamp(homeDir) {
580
+ try {
581
+ return (await stat(join(homeDir, ENDPOINT_AUTH_FILE))).mtimeMs;
582
+ } catch {
583
+ return;
584
+ }
585
+ }
586
+ /** Whether the scope carries its own endpoint file at all. */
587
+ async function endpointAuthFileExists(homeDir) {
588
+ return await endpointAuthStamp(homeDir) !== void 0;
589
+ }
590
+ /**
591
+ * Write the scope's endpoint-auth.json (mode 0600, mkdir -p first). The
592
+ * payload preserves the file-only `wireApi` across card writes: callers pass
593
+ * the merged object (current file + edits), never a bare card payload.
594
+ * @param homeDir - the scope's scoped home.
595
+ * @param auth - the endpoint credential to persist.
596
+ */
597
+ async function writeEndpointAuth(homeDir, auth) {
598
+ await mkdir(homeDir, { recursive: true });
599
+ const baseUrl = field(auth.baseUrl);
600
+ const apiKey = field(auth.apiKey);
601
+ const wireApi = field(auth.wireApi);
602
+ const payload = {
603
+ ...baseUrl === void 0 ? {} : { baseUrl },
604
+ ...apiKey === void 0 ? {} : { apiKey },
605
+ ...wireApi === void 0 ? {} : { wireApi }
606
+ };
607
+ await writeFile(join(homeDir, ENDPOINT_AUTH_FILE), JSON.stringify(payload, void 0, 2) + "\n", { mode: 384 });
608
+ }
609
+ /**
610
+ * Clear the scope's OWN endpoint credential (the endpoint-auth.json file).
611
+ * A deployment-level credential from the plugin config is untouched — the
612
+ * core's logout reply then names the profile row as the place to edit.
613
+ * @param homeDir - the scope's scoped home.
614
+ */
615
+ async function clearEndpointAuth(homeDir) {
616
+ await rm(join(homeDir, ENDPOINT_AUTH_FILE), { force: true });
617
+ }
618
+ /** How many served model ids the check reply lists before truncating. */
619
+ const CHECK_MODEL_LIST_LIMIT = 8;
620
+ /** Bound on the error-body excerpt a failed check reports. */
621
+ const CHECK_ERROR_BODY_LIMIT = 200;
622
+ /**
623
+ * `GET <baseUrl>/v1/models` with `Authorization: Bearer <key>` — the
624
+ * token-free endpoint probe shared by the `/codex check` verdict and any
625
+ * future catalog. OpenAI's own API serves this route with the platform key;
626
+ * relays (openproxy verified 2026-10-20, 139 models) proxy it and answer the
627
+ * same OpenAI-list shape. Never throws: every failure mode folds into the
628
+ * result.
629
+ * @param endpoint - the resolved endpoint auth with a decided baseUrl.
630
+ * @param fetchImpl - the HTTP fetch; default the host's global fetch.
631
+ * @param signal - abort for the whole probe (timeout bundled by the caller).
632
+ * @returns the folded result.
633
+ */
634
+ async function fetchEndpointModels(endpoint, fetchImpl, signal) {
635
+ const headers = {};
636
+ if (endpoint.apiKey !== void 0) headers["authorization"] = `Bearer ${endpoint.apiKey}`;
637
+ const fetcher = fetchImpl ?? globalThis.fetch;
638
+ const root = endpoint.baseUrl.replace(/\/+$/, "");
639
+ const url = `${root}${root.endsWith("/v1") ? "" : "/v1"}/models`;
640
+ let response;
641
+ try {
642
+ response = await fetcher(url, {
643
+ headers,
644
+ signal: signal ?? AbortSignal.timeout(8e3)
645
+ });
646
+ } catch (error) {
647
+ return {
648
+ kind: "unreachable",
649
+ message: error instanceof Error ? error.message : String(error)
650
+ };
651
+ }
652
+ if (!response.ok) return {
653
+ kind: "http",
654
+ status: response.status,
655
+ body: (await response.text().catch(() => "")).slice(0, CHECK_ERROR_BODY_LIMIT)
656
+ };
657
+ let data;
658
+ try {
659
+ data = JSON.parse(await response.text()).data;
660
+ } catch {
661
+ return { kind: "malformed" };
662
+ }
663
+ if (!Array.isArray(data)) return { kind: "malformed" };
664
+ const models = [];
665
+ for (const entry of data) {
666
+ if (typeof entry !== "object" || entry === null) continue;
667
+ const id = entry.id;
668
+ if (typeof id === "string" && id !== "") models.push(id);
669
+ }
670
+ return {
671
+ kind: "models",
672
+ models
673
+ };
674
+ }
675
+ /**
676
+ * Probe the resolved endpoint with a cheap, token-free round-trip: `GET
677
+ * <baseUrl>/v1/models` with the resolved credential's Bearer header. A 200
678
+ * proves both reachability and credential validity — the exact evidence a
679
+ * "verify" button needs — and the served model list rides the reply. One
680
+ * transport-level retry rides along: a local proxy restart (a Surge/clash
681
+ * reload) stalls the first attempt into the timeout and answers the retry
682
+ * immediately. Never throws: every failure mode folds into the reply text,
683
+ * since the reply IS the diagnostic.
684
+ * @param endpoint - the resolved endpoint auth (scope file over settings).
685
+ * @param fetchImpl - the HTTP fetch; default the host's global fetch.
686
+ * @param timeoutMs - one attempt's deadline (two attempts at most).
687
+ * @returns the human-readable verdict for the check reply.
688
+ */
689
+ async function checkCodexEndpoint(endpoint, fetchImpl, timeoutMs = 2e4) {
690
+ if (!hasEndpointCredential(endpoint)) return "No endpoint credential configured — fill Base URL and API Key on the settings card (or the profile row) first.";
691
+ const baseUrl = (endpoint.baseUrl ?? "https://api.openai.com").replace(/\/+$/, "");
692
+ let result = await fetchEndpointModels({
693
+ ...endpoint,
694
+ baseUrl
695
+ }, fetchImpl, AbortSignal.timeout(timeoutMs));
696
+ if (result.kind === "unreachable") result = await fetchEndpointModels({
697
+ ...endpoint,
698
+ baseUrl
699
+ }, fetchImpl, AbortSignal.timeout(timeoutMs));
700
+ if (result.kind === "unreachable") return `Endpoint unreachable (${baseUrl}): ${result.message}`;
701
+ if (result.kind === "http") return `Endpoint answered HTTP ${String(result.status)} (${baseUrl})${result.body === "" ? "" : `: ${result.body}`}`;
702
+ if (result.kind === "malformed") return `Endpoint reachable (${baseUrl}) but the model list did not parse — the credential worked, the reply was not /v1/models-shaped.`;
703
+ if (result.models.length === 0) return `Endpoint reachable (${baseUrl}); it serves no models.`;
704
+ const listed = result.models.slice(0, CHECK_MODEL_LIST_LIMIT).join(", ");
705
+ const more = result.models.length > CHECK_MODEL_LIST_LIMIT ? `, … +${String(result.models.length - CHECK_MODEL_LIST_LIMIT)} more` : "";
706
+ return `Endpoint reachable (${baseUrl}): ${String(result.models.length)} model(s) served — ${listed}${more}`;
707
+ }
708
+ //#endregion
433
709
  //#region lib/types/live-driver.js
434
710
  /**
435
711
  * The codex provider's live driver: one resident `codex app-server --stdio`
@@ -468,11 +744,6 @@ const DEFAULT_LIVE_CHANNEL_RETRY_MS = 3e5;
468
744
  const RECLAIM_EOF_GRACE_MS = 1e3;
469
745
  /** Default minimum interval between one streaming item's snapshot messages. */
470
746
  const DEFAULT_SNAPSHOT_MIN_INTERVAL_MS = LIVE_FLUSH_INTERVAL_MS;
471
- /**
472
- * The app-server channel could not come up (spawn failure or handshake
473
- * timeout/mismatch). The provider catches exactly this and falls back to the
474
- * exec one-shot; any other error is a real round failure.
475
- */
476
747
  var LiveChannelUnavailableError = class extends Error {
477
748
  constructor(message) {
478
749
  super(message);
@@ -554,11 +825,14 @@ function codexAppServerItemToLine(item) {
554
825
  case "dynamicToolCall": {
555
826
  const tool = typeof item["tool"] === "string" ? item.tool : "dynamic";
556
827
  const args = item["arguments"] === void 0 || item["arguments"] === null ? void 0 : typeof item["arguments"] === "string" ? item["arguments"] : JSON.stringify(item["arguments"]);
828
+ const content = item["contentItems"];
829
+ const result = Array.isArray(content) ? content.filter((block) => typeof block === "object" && block !== null).map((block) => typeof block["text"] === "string" ? block["text"] : void 0).filter((text) => text !== void 0 && text !== "").join("\n") : void 0;
557
830
  return {
558
831
  kind: "tool",
559
832
  id: itemId ?? `codex-live-${randomUUID()}`,
560
833
  name: tool,
561
- ...args === void 0 || args === "" ? {} : { args }
834
+ ...args === void 0 || args === "" ? {} : { args },
835
+ ...result === void 0 || result === "" ? {} : { result }
562
836
  };
563
837
  }
564
838
  case "collabAgentToolCall": {
@@ -589,12 +863,14 @@ function codexAppServerItemToLine(item) {
589
863
  function usageFromAppServer(breakdown) {
590
864
  const input = Number(breakdown["inputTokens"]);
591
865
  const cached = Number(breakdown["cachedInputTokens"]);
866
+ const cacheWrite = Number(breakdown["cacheWriteInputTokens"]);
592
867
  const output = Number(breakdown["outputTokens"]);
593
868
  const usage = {
594
- inputTokens: Number.isFinite(input) && Number.isFinite(cached) ? Math.max(0, input - cached) : Number.isFinite(input) ? input : 0,
869
+ inputTokens: Number.isFinite(input) && Number.isFinite(cached) ? Math.max(0, input - cached - (Number.isFinite(cacheWrite) ? cacheWrite : 0)) : Number.isFinite(input) ? input : 0,
595
870
  outputTokens: Number.isFinite(output) ? output : 0
596
871
  };
597
- if (Number.isFinite(cached) && cached > 0) usage.cacheReadTokens = cached;
872
+ if (Number.isFinite(cached)) usage.cacheReadTokens = cached;
873
+ if (Number.isFinite(cacheWrite) && cacheWrite > 0) usage.cacheWriteTokens = cacheWrite;
598
874
  return usage;
599
875
  }
600
876
  /**
@@ -948,11 +1224,13 @@ var CodexLiveDriver = class {
948
1224
  const member = registerCodexMemberRun(this.ctx, String(spec.childSession.id), spec.parentSessionId);
949
1225
  const startModel = spec.startModel?.trim();
950
1226
  const model = spec.configuration !== void 0 ? spec.configuration.model : startModel !== void 0 && startModel !== "" ? startModel : this.config.model?.(key)?.trim();
1227
+ const endpoint = this.config.endpoint === void 0 ? void 0 : await this.config.endpoint(spec.homeDir).catch(() => void 0);
951
1228
  const spawnSpec = {
952
1229
  argv: [
953
1230
  "codex",
954
1231
  "app-server",
955
1232
  ...member === void 0 ? [] : ["-c", member.configOverride],
1233
+ ...endpointConfigOverrides(endpoint),
956
1234
  ...model === void 0 || model === "" ? [] : ["-c", `model=${JSON.stringify(model)}`],
957
1235
  ...spec.configuration?.effort === void 0 ? [] : ["-c", `model_reasoning_effort=${JSON.stringify(spec.configuration.effort)}`],
958
1236
  "--stdio"
@@ -964,7 +1242,10 @@ var CodexLiveDriver = class {
964
1242
  stderr: "pipe"
965
1243
  },
966
1244
  graceMs: DEFAULT_DISPOSE_GRACE_MS,
967
- env: delegationEnv({ CODEX_HOME: spec.homeDir })
1245
+ env: delegationEnv({
1246
+ CODEX_HOME: spec.homeDir,
1247
+ ...endpointAuthEnv(endpoint)
1248
+ })
968
1249
  };
969
1250
  let child;
970
1251
  try {
@@ -1151,27 +1432,31 @@ var CodexLiveDriver = class {
1151
1432
  const lines = [];
1152
1433
  let mirrored = 0;
1153
1434
  let usage;
1154
- /** Whether a folded line already carries the round's usage (no backfill event exists). */
1155
- let usageCarried = false;
1435
+ /** The cumulative turn total the wire's `tokenUsage.total` names, for the settled report. */
1436
+ let totalUsage;
1156
1437
  /**
1157
- * The step ledger: `reservedSteps` holds every step a stream reservation
1158
- * consumed (in increasing order — permanently, whether the stream later
1159
- * completes or stays an orphan snapshot), and a sequential line folds at
1160
- * its index shifted past every reservation before it. A tool completed
1161
- * mid-stream therefore folds ABOVE the stream's step and the projection
1162
- * renders the round's chronological order.
1438
+ * Per-call usage by transcript line: each `thread/tokenUsage/updated`
1439
+ * notification closes one API call (its items have all completed), and
1440
+ * the call's LAST line carries its `tokenUsage.last`. The map is
1441
+ * authoritative per line, independent of the flush's withUsage flag.
1163
1442
  */
1164
- const reservedSteps = [];
1443
+ const usageByLine = /* @__PURE__ */ new Map();
1444
+ /** Whether a folded line already carries usage (per-call, or the round's fallback — no backfill event exists). */
1445
+ let usageCarried = false;
1165
1446
  /**
1166
- * The streaming items seen this round by item id:
1167
- * deltas accumulate into throttled snapshot assistant/messages appended
1168
- * at the item's reserved (turn, step) — the host folds repeated settles
1169
- * at one coordinate into one live-updating chat node, which is the only
1170
- * streaming channel left after 0.1.5 retired the durable per-chunk event.
1171
- * The item's completion folds at the same step and finalizes it; an entry
1172
- * whose completion never arrives is force-finalized at settle.
1447
+ * The streaming items seen this round by item id: deltas accumulate into
1448
+ * throttled snapshots at the item's step once it may open in the log
1449
+ * (previewed live before that) — the host folds repeated settles at one
1450
+ * coordinate into one live-updating chat node. The item's completion
1451
+ * folds at the same step and finalizes it; an entry whose completion
1452
+ * never arrives is force-finalized at settle. A tool that completes
1453
+ * mid-stream folds after the stream, keeping the round's chronology.
1173
1454
  */
1174
1455
  const streams = /* @__PURE__ */ new Map();
1456
+ /** Provisional stream keys (live flush and preview identity), never log steps. */
1457
+ let streamKeys = 0;
1458
+ /** Line indexes already written (a line may fold ahead of an earlier one it chronologically precedes). */
1459
+ const folded = /* @__PURE__ */ new Set();
1175
1460
  /** The item deltas currently accumulate into (items stream sequentially). */
1176
1461
  let activeStream;
1177
1462
  /** Items/completions that arrived before the turn id was known. */
@@ -1215,6 +1500,27 @@ var CodexLiveDriver = class {
1215
1500
  */
1216
1501
  const streamPublisher = localAgent?.liveStreams === void 0 ? void 0 : new LiveStreamPublisher(localAgent.liveStreams, childSession, turn, persist, (error) => this.ctx.logger.warn(`live checkpoint failed: ${String(error)}`));
1217
1502
  const liveFlush = new LiveFlush((error) => this.ctx.logger.warn(`live mirror flush failed: ${String(error)}`), this.config.snapshotMinIntervalMs ?? DEFAULT_SNAPSHOT_MIN_INTERVAL_MS);
1503
+ let lastStep = 0;
1504
+ let openStep;
1505
+ const openStreamStep = (stream) => {
1506
+ lastStep += 1;
1507
+ childSession.append("step/start", {
1508
+ turn,
1509
+ step: lastStep
1510
+ });
1511
+ stream.durable = lastStep;
1512
+ stream.opened = true;
1513
+ openStep = lastStep;
1514
+ };
1515
+ const closeOpenStep = () => {
1516
+ if (openStep === void 0) return;
1517
+ childSession.append("step/end", {
1518
+ turn,
1519
+ step: openStep
1520
+ });
1521
+ openStep = void 0;
1522
+ };
1523
+ const canOpenStream = (stream) => openStep === void 0 && mirrored >= stream.after && streams.keys().next().value === stream.itemId;
1218
1524
  const pendingStreamArrival = /* @__PURE__ */ new Map();
1219
1525
  const appendStreamSnapshot = (stream, force, interrupted, withUsage = false, final = false) => {
1220
1526
  if (stream.text.trim() === "") return;
@@ -1227,30 +1533,29 @@ var CodexLiveDriver = class {
1227
1533
  liveFlush.cancel(stream.step);
1228
1534
  const receivedAt = pendingStreamArrival.get(stream.step) ?? now;
1229
1535
  pendingStreamArrival.delete(stream.step);
1230
- if (streamPublisher !== void 0 && !final) {
1231
- if (!stream.opened) {
1232
- childSession.append("step/start", {
1233
- turn,
1234
- step: stream.step
1536
+ if (!stream.opened) {
1537
+ if (canOpenStream(stream)) openStreamStep(stream);
1538
+ else if (!final) {
1539
+ streamPublisher?.preview(stream.step, stream.kind, stream.text, {
1540
+ itemId: stream.itemId,
1541
+ receivedAt
1235
1542
  });
1236
- stream.opened = true;
1543
+ return;
1544
+ } else {
1545
+ closeOpenStep();
1546
+ openStreamStep(stream);
1237
1547
  }
1238
- streamPublisher.update(stream.step, stream.kind, stream.text, { receivedAt });
1548
+ }
1549
+ if (streamPublisher !== void 0 && !final) {
1550
+ streamPublisher.update(stream.durable, stream.kind, stream.text, { receivedAt });
1239
1551
  stream.lastSnapshotAt = now;
1240
1552
  stream.lastSnapshotLen = stream.text.length;
1241
1553
  return;
1242
1554
  }
1243
- if (!stream.opened) {
1244
- childSession.append("step/start", {
1245
- turn,
1246
- step: stream.step
1247
- });
1248
- stream.opened = true;
1249
- }
1250
- const attachUsage = withUsage && !usageCarried && usage !== void 0;
1555
+ const attachUsage = withUsage && !usageCarried && usageByLine.size === 0 && usage !== void 0;
1251
1556
  childSession.append("assistant/message", {
1252
1557
  turn,
1253
- step: stream.step,
1558
+ step: stream.durable,
1254
1559
  message: codexAssistantEvent([stream.kind === "think" ? {
1255
1560
  type: "reasoning",
1256
1561
  text: stream.text
@@ -1259,7 +1564,7 @@ var CodexLiveDriver = class {
1259
1564
  text: stream.text
1260
1565
  }]),
1261
1566
  stream: [],
1262
- ...withUsage && !usageCarried && usage !== void 0 ? { usage } : {},
1567
+ ...withUsage && !usageCarried && usageByLine.size === 0 && usage !== void 0 ? { usage } : {},
1263
1568
  ...interrupted ? { interrupted: true } : {}
1264
1569
  }, { surfaceOp: "append" });
1265
1570
  if (attachUsage) usageCarried = true;
@@ -1267,11 +1572,19 @@ var CodexLiveDriver = class {
1267
1572
  stream.lastSnapshotLen = stream.text.length;
1268
1573
  persist();
1269
1574
  };
1575
+ /** Close a stream's live presentation once its final text is in the log. */
1576
+ const finishStream = (stream) => {
1577
+ liveFlush.cancel(stream.step);
1578
+ streams.delete(stream.itemId);
1579
+ if (activeStream === stream.itemId) activeStream = void 0;
1580
+ streamPublisher?.finish(stream.step);
1581
+ if (stream.durable !== void 0) streamPublisher?.finish(stream.durable);
1582
+ };
1270
1583
  /**
1271
- * Reserve the step one new streaming item will occupy — past every
1272
- * completed line (folded or held back) and every earlier reservation. A
1273
- * different item's deltas force one final snapshot of the current stream
1274
- * (its completion still folds at its own reserved step when it lands).
1584
+ * Track one new streaming item: its provisional key (live flush and
1585
+ * preview identity) and how many lines had completed when it began. A
1586
+ * different item's deltas force one freshness snapshot of the current
1587
+ * stream.
1275
1588
  */
1276
1589
  const reserveStream = (itemId, kind) => {
1277
1590
  if (activeStream !== void 0 && activeStream !== itemId) {
@@ -1281,12 +1594,12 @@ var CodexLiveDriver = class {
1281
1594
  }
1282
1595
  let stream = streams.get(itemId);
1283
1596
  if (stream === void 0) {
1284
- const step = lines.length + reservedSteps.length + 1;
1285
- reservedSteps.push(step);
1597
+ streamKeys += 1;
1286
1598
  stream = {
1287
1599
  itemId,
1288
1600
  kind,
1289
- step,
1601
+ step: streamKeys,
1602
+ after: lines.length,
1290
1603
  text: "",
1291
1604
  lastSnapshotAt: 0,
1292
1605
  lastSnapshotLen: 0,
@@ -1296,60 +1609,78 @@ var CodexLiveDriver = class {
1296
1609
  }
1297
1610
  activeStream = itemId;
1298
1611
  };
1299
- /** The sequential fold step for `lines[index]`: its position shifted past every reservation before it. */
1300
- const foldStep = (index) => {
1301
- let step = index + 1;
1302
- for (const reserved of reservedSteps) if (reserved <= step) step += 1;
1303
- else break;
1304
- return step;
1612
+ /** The stream a completed line finalizes, if it was streamed. */
1613
+ const streamOfLine = (line) => line === void 0 || line.kind === "tool" ? void 0 : streams.get(line.itemId ?? "");
1614
+ /** Write lines[index] at the next step(s). */
1615
+ const foldLine = (index, lineUsage) => {
1616
+ const line = lines[index];
1617
+ const stream = streamOfLine(line);
1618
+ if (stream !== void 0) {
1619
+ if (!stream.opened) {
1620
+ closeOpenStep();
1621
+ openStreamStep(stream);
1622
+ }
1623
+ const blocks = stream.kind === "think" ? [{
1624
+ type: "reasoning",
1625
+ text: line.kind === "tool" ? "" : line.text
1626
+ }] : [{
1627
+ type: "text",
1628
+ text: line.kind === "tool" ? "" : line.text
1629
+ }];
1630
+ childSession.append("assistant/message", {
1631
+ turn,
1632
+ step: stream.durable,
1633
+ message: codexAssistantEvent(blocks),
1634
+ stream: [],
1635
+ ...lineUsage === void 0 ? {} : { usage: lineUsage }
1636
+ }, { surfaceOp: "append" });
1637
+ finishStream(stream);
1638
+ closeOpenStep();
1639
+ } else {
1640
+ closeOpenStep();
1641
+ lastStep += 1;
1642
+ appendCodexTranscriptLine(childSession, turn, lastStep, line, lineUsage);
1643
+ }
1644
+ folded.add(index);
1645
+ localAgent?.reportRunProgress(childSession.id, {
1646
+ kind: "delta",
1647
+ text: codexLineText(line)
1648
+ });
1305
1649
  };
1306
- /** Mirror folded lines [mirrored, upto); the last line is held back until completion. */
1650
+ /**
1651
+ * Mirror the completed lines before `upto` in chronological order; the
1652
+ * last line is held back until completion. A line waits behind every
1653
+ * stream that began before it completed: that stream's line folds first,
1654
+ * or — while it is still streaming — the mirror stops until it lands.
1655
+ */
1307
1656
  const mirrorUpTo = (upto, withUsage) => {
1308
1657
  let usageIndex = -1;
1309
- if (withUsage) {
1658
+ if (withUsage && usageByLine.size === 0) {
1310
1659
  for (let index = 0; index < lines.length; index += 1) if (lines[index]?.kind !== "tool") usageIndex = index;
1311
1660
  }
1312
- for (let index = mirrored; index < upto; index += 1) {
1313
- const line = lines[index];
1314
- if (line === void 0) continue;
1661
+ const usageFor = (index) => {
1662
+ const own = usageByLine.get(index);
1663
+ if (own !== void 0) {
1664
+ usageCarried = true;
1665
+ return own;
1666
+ }
1315
1667
  const lineUsage = withUsage && index === usageIndex ? usage : void 0;
1316
1668
  if (lineUsage !== void 0) usageCarried = true;
1317
- const itemId = line.kind === "tool" ? void 0 : line.itemId;
1318
- const stream = itemId === void 0 ? void 0 : streams.get(itemId);
1319
- if (itemId !== void 0 && stream !== void 0) {
1320
- liveFlush.cancel(stream.step);
1321
- streams.delete(itemId);
1322
- if (activeStream === itemId) activeStream = void 0;
1323
- if (!stream.opened) childSession.append("step/start", {
1324
- turn,
1325
- step: stream.step
1326
- });
1327
- const blocks = stream.kind === "think" ? [{
1328
- type: "reasoning",
1329
- text: line.kind === "tool" ? "" : line.text
1330
- }] : [{
1331
- type: "text",
1332
- text: line.kind === "tool" ? "" : line.text
1333
- }];
1334
- childSession.append("assistant/message", {
1335
- turn,
1336
- step: stream.step,
1337
- message: codexAssistantEvent(blocks),
1338
- stream: [],
1339
- ...lineUsage === void 0 ? {} : { usage: lineUsage }
1340
- }, { surfaceOp: "append" });
1341
- streamPublisher?.finish(stream.step);
1342
- childSession.append("step/end", {
1343
- turn,
1344
- step: stream.step
1345
- });
1346
- } else appendCodexTranscriptLine(childSession, turn, foldStep(index), line, lineUsage);
1347
- mirrored = index + 1;
1348
- localAgent?.reportRunProgress(childSession.id, {
1349
- kind: "delta",
1350
- text: codexLineText(line)
1351
- });
1669
+ return lineUsage;
1670
+ };
1671
+ outer: for (let index = mirrored; index < upto; index += 1) {
1672
+ if (folded.has(index)) continue;
1673
+ const own = streamOfLine(lines[index]);
1674
+ const all = [...streams.values()];
1675
+ const before = own === void 0 ? all.filter((stream) => stream.after <= index) : all.slice(0, all.indexOf(own));
1676
+ for (const earlier of before) {
1677
+ const at = lines.findIndex((line, position) => position > index && position < upto && !folded.has(position) && streamOfLine(line) === earlier);
1678
+ if (at < 0) break outer;
1679
+ foldLine(at, usageFor(at));
1680
+ }
1681
+ foldLine(index, usageFor(index));
1352
1682
  }
1683
+ while (folded.has(mirrored)) mirrored += 1;
1353
1684
  if (upto > 0) persist();
1354
1685
  };
1355
1686
  let resolveCompletion;
@@ -1358,6 +1689,11 @@ var CodexLiveDriver = class {
1358
1689
  });
1359
1690
  const openTurn = () => {
1360
1691
  childSession.append("turn/start", { turn });
1692
+ if (spec.configuration?.model !== void 0) appendCodexRequestHeader(childSession, {
1693
+ provider: "codex-local",
1694
+ model: spec.configuration.model,
1695
+ ...spec.configuration.effort === void 0 ? {} : { reasoningEffort: spec.configuration.effort }
1696
+ });
1361
1697
  childSession.append("user/message", createUserMessage({
1362
1698
  content: [{
1363
1699
  type: "text",
@@ -1408,17 +1744,29 @@ var CodexLiveDriver = class {
1408
1744
  return;
1409
1745
  }
1410
1746
  if (method === "thread/tokenUsage/updated") {
1411
- const last = params["tokenUsage"]?.["last"];
1412
- if (last !== void 0) usage = usageFromAppServer(last);
1747
+ const tokenUsage = params["tokenUsage"];
1748
+ const last = tokenUsage?.["last"];
1749
+ const total = tokenUsage?.["total"];
1750
+ if (total !== void 0 && (Number.isFinite(Number(total["inputTokens"])) || Number.isFinite(Number(total["outputTokens"])))) totalUsage = usageFromAppServer(total);
1751
+ if (last !== void 0) {
1752
+ usage = usageFromAppServer(last);
1753
+ if (lines.length > 0) {
1754
+ usageByLine.set(lines.length - 1, usage);
1755
+ mirrorUpTo(lines.length - 1, true);
1756
+ }
1757
+ }
1413
1758
  return;
1414
1759
  }
1415
1760
  if (method === "turn/completed") {
1416
1761
  const turnData = params["turn"];
1417
- mirrorUpTo(lines.length, true);
1418
- resolveCompletion({
1419
- status: String(turnData["status"]),
1420
- ...turnData["error"] === null || turnData["error"] === void 0 ? {} : { error: turnData["error"] }
1421
- });
1762
+ try {
1763
+ mirrorUpTo(lines.length, true);
1764
+ } finally {
1765
+ resolveCompletion({
1766
+ status: String(turnData["status"]),
1767
+ ...turnData["error"] === null || turnData["error"] === void 0 ? {} : { error: turnData["error"] }
1768
+ });
1769
+ }
1422
1770
  }
1423
1771
  };
1424
1772
  /** Route one wire notification: round-scoped, turn-id tagged, early ones buffered. */
@@ -1518,15 +1866,19 @@ var CodexLiveDriver = class {
1518
1866
  liveFlush.dispose();
1519
1867
  if (turnOpened) {
1520
1868
  if (mirrored < lines.length) mirrorUpTo(lines.length, true);
1869
+ while (mirrored < lines.length && streams.size > 0) {
1870
+ const oldest = streams.values().next().value;
1871
+ appendStreamSnapshot(oldest, true, settled.stopReason !== "completed", false, true);
1872
+ finishStream(oldest);
1873
+ closeOpenStep();
1874
+ mirrorUpTo(lines.length, true);
1875
+ }
1521
1876
  if (streams.size > 0) {
1522
1877
  const remaining = [...streams.values()];
1523
1878
  for (const [position, stream] of remaining.entries()) {
1524
1879
  appendStreamSnapshot(stream, true, settled.stopReason !== "completed", position === remaining.length - 1, true);
1525
- streamPublisher?.finish(stream.step);
1526
- if (stream.opened) childSession.append("step/end", {
1527
- turn,
1528
- step: stream.step
1529
- });
1880
+ finishStream(stream);
1881
+ closeOpenStep();
1530
1882
  }
1531
1883
  streams.clear();
1532
1884
  activeStream = void 0;
@@ -1577,7 +1929,7 @@ var CodexLiveDriver = class {
1577
1929
  windowStart: startedAtMs,
1578
1930
  cwd: spec.cwd
1579
1931
  });
1580
- const settledUsage = usage ?? facts.usage;
1932
+ const settledUsage = totalUsage ?? usage ?? facts.usage;
1581
1933
  const round = {
1582
1934
  ...facts.model === void 0 ? {} : { observedModel: facts.model },
1583
1935
  ...facts.cliVersion === void 0 ? {} : { cliVersion: facts.cliVersion },
@@ -1593,7 +1945,7 @@ var CodexLiveDriver = class {
1593
1945
  });
1594
1946
  await Promise.race([accepted, abortBranch.catch(() => void 0)]);
1595
1947
  return subprocessRunHandle({
1596
- id: childSession.id,
1948
+ id: SessionId(randomUUID()),
1597
1949
  result,
1598
1950
  signal: request.signal,
1599
1951
  onAbort,
@@ -1608,31 +1960,33 @@ var CodexLiveDriver = class {
1608
1960
  //#endregion
1609
1961
  //#region lib/types/provision.js
1610
1962
  /**
1611
- * Codex scoped-home provisioning: pin file-based credential storage so the
1612
- * device-code login writes `auth.json` inside the scoped home. Codex's
1613
- * `cli_auth_credentials_store` defaults to `auto` — on macOS that resolves
1614
- * to the keychain, which would both leak credentials outside the scoped
1615
- * home and defeat the `auth.json` presence check this package's records
1616
- * adapter uses for authentication. An existing config is respected
1617
- * untouched; sign-out removes the credential file.
1963
+ * Codex scoped-home provisioning: a working `config.toml` (a person may edit
1964
+ * it — custom providers, model, effort) plus the one-way sweep of the web
1965
+ * login's leftover `auth.json`. The harness is endpoint-key only: a stale
1966
+ * grant sitting next to an endpoint credential would keep authenticating the
1967
+ * built-in provider while the status reads "unauthenticated", so provisioning
1968
+ * deletes it on every boot (claude-code's OAuth-remnant sweep, same idea).
1618
1969
  * @module @khorsheed/dsh-local-agent-codex/provision
1619
1970
  */
1620
- /** Credential file inside the scoped home; its presence marks auth. */
1621
- const AUTH_FILE = "auth.json";
1622
- /** Minimal scoped config: keep credentials in the scoped home. */
1971
+ /** Minimal scoped config: keep any hand-run login's credentials in the scoped home. */
1623
1972
  const SCOPED_CONFIG = `# Written by dsh-local-agent-codex: keep the device-code
1624
1973
  # credentials inside the scoped home (auth.json) instead of the OS keychain.
1625
1974
  cli_auth_credentials_store = "file"
1626
1975
  `;
1976
+ /** The web login's credential file, swept on every boot (endpoint-key only now). */
1977
+ const LEGACY_AUTH_FILE = "auth.json";
1627
1978
  /**
1628
- * Provision `<homeDir>/config.toml` unless one already exists. A present
1629
- * config is respected untouched (a person may have edited it); otherwise a
1630
- * minimal config pinning file-based credential storage is written so login
1631
- * lands in the scoped home's `auth.json`.
1979
+ * Provision the scoped home: ensure a `config.toml` exists (a minimal one —
1980
+ * codex reads it for custom providers, model, and effort; the historical
1981
+ * `cli_auth_credentials_store = "file"` pin stays so a hand-run `codex login`
1982
+ * under the scoped home can never spill into the macOS keychain), and sweep
1983
+ * the web login's leftover `auth.json` (the harness no longer consults it —
1984
+ * endpoint key or nothing).
1632
1985
  * @param homeDir - the `codex` harness's scoped home.
1633
1986
  * @returns true when a config was written, false when one already existed.
1634
1987
  */
1635
1988
  async function provisionCodexConfig(homeDir) {
1989
+ await rm(join(homeDir, LEGACY_AUTH_FILE), { force: true });
1636
1990
  try {
1637
1991
  await readFile(join(homeDir, "config.toml"));
1638
1992
  return false;
@@ -1767,47 +2121,6 @@ async function listCodexConfigModels(homeDir) {
1767
2121
  }
1768
2122
  return found;
1769
2123
  }
1770
- /**
1771
- * The credential file's modification stamp (epoch ms), undefined when absent.
1772
- * A completed device-code login rewrites `auth.json`, so the stamp
1773
- * distinguishes a fresh login from a leftover (possibly revoked) credential.
1774
- * @param homeDir - the `codex` harness's scoped home.
1775
- * @returns the marker's mtime, or undefined when no credential exists.
1776
- */
1777
- async function codexCredentialStamp(homeDir) {
1778
- try {
1779
- return (await stat(join(homeDir, AUTH_FILE))).mtimeMs;
1780
- } catch {
1781
- return;
1782
- }
1783
- }
1784
- /**
1785
- * Sign out of the scoped account: remove the credential file, so `/<name>
1786
- * status` reports not authenticated and the next login authorizes a fresh
1787
- * account. With `cli_auth_credentials_store = "file"` the credentials live
1788
- * solely in `auth.json`, so removing it is the logout path.
1789
- * @param homeDir - the `codex` harness's scoped home.
1790
- */
1791
- async function codexLogout(homeDir) {
1792
- await rm(join(homeDir, AUTH_FILE), { force: true });
1793
- }
1794
- //#endregion
1795
- //#region lib/types/codex-cli-provider.js
1796
- /**
1797
- * One-shot Codex CLI subagent lifecycle: spawn `codex exec "<task>"` through
1798
- * the subprocess seam under the harness's scoped home, capture its printed
1799
- * response as the run output, and dispose to whole-tree quiescence. Mirrors
1800
- * the kimi-cli provider: every accepted run is a fresh process and a fresh
1801
- * codex session; there is no continuation across runs.
1802
- *
1803
- * The provider name `codex-local` avoids colliding with the official
1804
- * `subagent-codex` package's provider name `codex` — a composition that
1805
- * mounts both would fail loud with DUPLICATE_PROVIDER. The tool row in this
1806
- * bundle's patch takes the official model-facing name `subagent_codex`
1807
- * instead: the official preset row ships disabled, and the patch disables
1808
- * it too (a deliberate user re-enable conflicts loud, by design).
1809
- * @module @khorsheed/dsh-local-agent-codex/codex-cli-provider
1810
- */
1811
2124
  /** Quote one TOML basic string for the `-c` config override. */
1812
2125
  function tomlString(value) {
1813
2126
  return `"${value.replace(/\\/g, "\\\\").replace(/"/g, "\\\"")}"`;
@@ -1831,7 +2144,7 @@ function registerCodexMemberRun(ctx, childSessionId, parentSessionId) {
1831
2144
  });
1832
2145
  const serverName = `dsh-member-${token.slice(0, 8)}`;
1833
2146
  const bridge = registry.memberBridgeCommand();
1834
- const configOverride = `mcp_servers.${serverName}={command=${tomlString(bridge.command)},args=[${bridge.args.map(tomlString).join(",")}],env={${MEMBER_BRIDGE_SOCKET_ENV}=${tomlString(registry.memberBridgeSocketPath())},${MEMBER_BRIDGE_TOKEN_ENV}=${tomlString(token)}},default_tools_approval_mode=${tomlString("approve")}}`;
2147
+ const configOverride = `mcp_servers.${serverName}={command=${tomlString(bridge.command)},args=[${bridge.args.map(tomlString).join(",")}],env={${MEMBER_BRIDGE_SOCKET_ENV}=${tomlString(registry.memberBridgeSocketPath())},${MEMBER_BRIDGE_TOKEN_ENV}=${tomlString(token)}},default_tools_approval_mode=${tomlString("approve")},tool_timeout_sec=1800}`;
1835
2148
  let released = false;
1836
2149
  return {
1837
2150
  token,
@@ -1889,6 +2202,7 @@ var CodexCliProvider = class {
1889
2202
  live;
1890
2203
  model;
1891
2204
  overrides;
2205
+ endpoint;
1892
2206
  name = "codex-local";
1893
2207
  capabilities = NO_START_CAPABILITIES;
1894
2208
  inheritsParentContext = false;
@@ -1899,22 +2213,33 @@ var CodexCliProvider = class {
1899
2213
  * while a retiring generation still hosts it).
1900
2214
  * @param model - resolver for the configured model, read PER ROUND so a
1901
2215
  * settings-card write takes effect on the next delegation without a
1902
- * reload. Undefined (the resolver absent, or returning undefined) leaves
1903
- * the argv exactly as it was before the key existed — the scoped
1904
- * config.toml's own `model` then decides, as it always did.
2216
+ * reload; it receives the round's scope (a named scope's curated list
2217
+ * answers first). Undefined (the resolver absent, or returning
2218
+ * undefined) leaves the argv exactly as it was before the key existed —
2219
+ * the scoped config.toml's own `model` then decides, as it always did.
1905
2220
  * @param overrides - resolver for the member's session-level model override
1906
2221
  * (the composer picker, owned by the model broker). It outranks BOTH the
1907
2222
  * delegation's recorded model and the configured one: on the exec path it
1908
2223
  * re-points the next round's `-m`, and on the live path it is the member's
1909
2224
  * start model — a resident runtime bound to a different model is retired
1910
2225
  * so the round respawns onto the override.
2226
+ * @param endpoint - resolver for the scope's endpoint credential, read PER
2227
+ * ROUND so a settings-card write takes effect on the next delegation
2228
+ * without a reload. A resolution carrying a key routes the round at the
2229
+ * relay (a custom `model_providers` entry on the argv, the key in the
2230
+ * env); with no key the spawn carries no credential at all.
1911
2231
  */
1912
- constructor(ctx, sandbox = "workspace-write", live, model, overrides) {
2232
+ constructor(ctx, sandbox = "workspace-write", live, model, overrides, endpoint) {
1913
2233
  this.ctx = ctx;
1914
2234
  this.sandbox = sandbox;
1915
2235
  this.live = live;
1916
2236
  this.model = model;
1917
2237
  this.overrides = overrides;
2238
+ this.endpoint = endpoint;
2239
+ }
2240
+ /** The scope's endpoint credential for this round; absent resolver = never. */
2241
+ resolveEndpoint(homeDir) {
2242
+ return this.endpoint === void 0 ? Promise.resolve(void 0) : this.endpoint(homeDir).catch(() => void 0);
1918
2243
  }
1919
2244
  /** Resolve the live driver for one round's member, if live is on for it. */
1920
2245
  liveDriver(childSessionId) {
@@ -1950,7 +2275,7 @@ var CodexCliProvider = class {
1950
2275
  ...record?.configurationLock === void 0 ? {} : { configurationLock: record.configurationLock }
1951
2276
  }, (configuration) => this.startCodexResume(request, intent, cwd, homeDir, exec, scope, record?.model, configuration), request.signal, intent?.onAdmitted);
1952
2277
  }
1953
- const childSessionId = SessionId(intent?.preparedMemberId ?? randomUUID());
2278
+ const childSessionId = SessionId(intent?.preparedMemberId ?? intent?.childSessionId ?? randomUUID());
1954
2279
  if (typeof this.ctx.localAgent.withMemberConfigurationRound !== "function") {
1955
2280
  if (intent?.effort !== void 0) throw new Error("Codex effort requires the configuration admission core");
1956
2281
  return this.startCodexFresh(request, cwd, homeDir, exec, scope, intent?.model, childSessionId);
@@ -1979,16 +2304,19 @@ var CodexCliProvider = class {
1979
2304
  delegationDepth: (request.parent.session.header.delegationDepth ?? 0) + 1
1980
2305
  } });
1981
2306
  const harness = this.ctx.localAgent.get("codex");
1982
- const label = subagentDelegationLabel(harness?.displayName ?? "codex", request.descriptor.label);
2307
+ const label = subagentDelegationLabel(harness?.displayName ?? "codex", request.label || void 0);
1983
2308
  childSession.append("subagent/descriptor", {
1984
- ...request.descriptor,
2309
+ version: SUBAGENT_DESCRIPTOR_VERSION,
2310
+ mode: "one-shot",
2311
+ provider: this.name,
1985
2312
  label
1986
2313
  });
1987
2314
  establishSubagentCatalogChild(request.parent.session, childSession.header, label);
1988
2315
  } catch (error) {
1989
2316
  this.ctx.logger.warn(`subagent-codex: subagent session record failed: ${error instanceof Error ? error.message : String(error)}`);
1990
2317
  }
1991
- const baseUrl = await readCodexBaseUrl(homeDir).catch(() => void 0);
2318
+ const endpoint = await this.resolveEndpoint(homeDir);
2319
+ const baseUrl = hasEndpointCredential(endpoint) ? endpoint.baseUrl : await readCodexBaseUrl(homeDir).catch(() => void 0);
1992
2320
  this.ctx.logger.info(`subagent-codex: delegating via ${baseUrl ?? "codex default endpoint"}`);
1993
2321
  const live = exec === void 0 ? this.liveDriver(runId) : void 0;
1994
2322
  if (live !== void 0 && childSession !== void 0 && !live.disabled) {
@@ -2022,11 +2350,15 @@ var CodexCliProvider = class {
2022
2350
  try {
2023
2351
  const run = await startCodexCliRun(request, {
2024
2352
  cwd,
2025
- env: delegationEnv({ CODEX_HOME: homeDir }),
2353
+ env: delegationEnv({
2354
+ CODEX_HOME: homeDir,
2355
+ ...endpointAuthEnv(endpoint)
2356
+ }),
2026
2357
  ...exec === void 0 ? {} : { exec },
2027
2358
  endpointLabel: baseUrl,
2359
+ endpointArgv: endpointConfigOverrides(endpoint),
2028
2360
  sandbox: this.sandbox,
2029
- ...configuration?.resolved ?? resolveRoundModel(this.overrides?.(runId) ?? requestedModel, this.model),
2361
+ ...configuration?.resolved ?? resolveRoundModel(this.overrides?.(runId) ?? requestedModel, () => this.model?.(scope)),
2030
2362
  disposeGraceMs: DEFAULT_DISPOSE_GRACE_MS,
2031
2363
  spawn: (spec) => this.ctx.subprocess.spawn(spec),
2032
2364
  onError: (error, stopReason) => {
@@ -2072,7 +2404,8 @@ var CodexCliProvider = class {
2072
2404
  const childSession = this.ctx.get("sessions")?.get(SessionId(intent.childSessionId));
2073
2405
  if (childSession === void 0) throw new Error(`subagent-codex: resume target child session ${intent.childSessionId} is not live — start a fresh delegation instead`);
2074
2406
  const nextTurn = childSession.snapshotEvents().filter((event) => event.type === "turn/start").length + 1;
2075
- const baseUrl = await readCodexBaseUrl(homeDir).catch(() => void 0);
2407
+ const endpoint = await this.resolveEndpoint(homeDir);
2408
+ const baseUrl = hasEndpointCredential(endpoint) ? endpoint.baseUrl : await readCodexBaseUrl(homeDir).catch(() => void 0);
2076
2409
  this.ctx.logger.info(`subagent-codex: resuming via ${baseUrl ?? "codex default endpoint"}`);
2077
2410
  const live = exec === void 0 ? this.liveDriver(intent.childSessionId) : void 0;
2078
2411
  if (live !== void 0 && !live.disabled) {
@@ -2106,11 +2439,15 @@ var CodexCliProvider = class {
2106
2439
  try {
2107
2440
  run = await startCodexCliRun(request, {
2108
2441
  cwd,
2109
- env: delegationEnv({ CODEX_HOME: homeDir }),
2442
+ env: delegationEnv({
2443
+ CODEX_HOME: homeDir,
2444
+ ...endpointAuthEnv(endpoint)
2445
+ }),
2110
2446
  ...exec === void 0 ? {} : { exec },
2111
2447
  endpointLabel: baseUrl,
2448
+ endpointArgv: endpointConfigOverrides(endpoint),
2112
2449
  sandbox: this.sandbox,
2113
- ...configuration?.resolved ?? resolveRoundModel(this.overrides?.(intent.childSessionId) ?? requestedModel, this.model),
2450
+ ...configuration?.resolved ?? resolveRoundModel(this.overrides?.(intent.childSessionId) ?? requestedModel, () => this.model?.(scope)),
2114
2451
  disposeGraceMs: DEFAULT_DISPOSE_GRACE_MS,
2115
2452
  spawn: (spec) => this.ctx.subprocess.spawn(spec),
2116
2453
  onError: (error, stopReason) => {
@@ -2278,6 +2615,23 @@ function foldCodexStreamLine(state, raw) {
2278
2615
  result: last.result === void 0 ? output : `${last.result}\n${output}`
2279
2616
  };
2280
2617
  }
2618
+ } else if (item.type === "custom_tool_call") {
2619
+ countToolCall(state, "custom_tool_call");
2620
+ state.lines.push({
2621
+ kind: "tool",
2622
+ id: typeof item.id === "string" ? item.id : `codex-tool-${state.lines.length}`,
2623
+ name: item.name ?? "custom",
2624
+ ...typeof item.input === "string" && item.input !== "" ? { args: item.input } : {}
2625
+ });
2626
+ } else if (item.type === "custom_tool_call_output") {
2627
+ const output = typeof item.output === "string" ? item.output : Array.isArray(item.output) ? item.output.filter((block) => typeof block === "object" && block !== null && typeof block.text === "string").map((block) => block.text).join("\n") : void 0;
2628
+ if (output !== void 0 && output.trim() !== "") {
2629
+ const last = state.lines[state.lines.length - 1];
2630
+ if (last !== void 0 && last.kind === "tool") state.lines[state.lines.length - 1] = {
2631
+ ...last,
2632
+ result: last.result === void 0 ? output : `${last.result}\n${output}`
2633
+ };
2634
+ }
2281
2635
  }
2282
2636
  }
2283
2637
  /**
@@ -2312,7 +2666,8 @@ var CodexStreamParser = class {
2312
2666
  * not put one on the wire; the settle path then reads the rollout's
2313
2667
  * turn_context). Item types covered: `reasoning` (thinking), `agent_message`
2314
2668
  * (reply text), `command_execution` (shell command with aggregated output),
2315
- * `web_search_call`, and `function_call_output`. The last `agent_message`
2669
+ * `web_search_call`, `custom_tool_call`/`custom_tool_call_output` (deferred
2670
+ * and dynamic tools), and `function_call_output`. The last `agent_message`
2316
2671
  * wins as the run output (the final answer), and `turn.completed` carries the
2317
2672
  * turn's usage. Malformed lines are skipped.
2318
2673
  * @param stream - the collected stdout NDJSON text.
@@ -2357,11 +2712,13 @@ function startCodexCliRun(request, spec) {
2357
2712
  if (request.signal.aborted) throw new Error("subagent-codex: request was aborted before the CLI started");
2358
2713
  const turn = spec.resume?.turn ?? 1;
2359
2714
  const memberArgv = spec.member === void 0 ? [] : ["-c", spec.member.configOverride];
2715
+ const endpointArgv = spec.endpointArgv ?? [];
2360
2716
  const modelArgv = [...spec.model === void 0 ? [] : ["-m", spec.model], ...spec.effort === void 0 ? [] : ["-c", `model_reasoning_effort=${JSON.stringify(spec.effort)}`]];
2361
2717
  const argv = spec.resume === void 0 ? [
2362
2718
  "codex",
2363
2719
  "exec",
2364
2720
  ...memberArgv,
2721
+ ...endpointArgv,
2365
2722
  ...modelArgv,
2366
2723
  "--sandbox",
2367
2724
  spec.sandbox,
@@ -2372,6 +2729,7 @@ function startCodexCliRun(request, spec) {
2372
2729
  "codex",
2373
2730
  "exec",
2374
2731
  ...memberArgv,
2732
+ ...endpointArgv,
2375
2733
  ...modelArgv,
2376
2734
  "--sandbox",
2377
2735
  spec.sandbox,
@@ -2401,6 +2759,11 @@ function startCodexCliRun(request, spec) {
2401
2759
  });
2402
2760
  const startedAtMs = Date.now();
2403
2761
  spec.childSession?.append("turn/start", { turn });
2762
+ if (spec.model !== void 0 && spec.childSession !== void 0) appendCodexRequestHeader(spec.childSession, {
2763
+ provider: "codex-local",
2764
+ model: spec.model,
2765
+ ...spec.effort === void 0 ? {} : { reasoningEffort: spec.effort }
2766
+ });
2404
2767
  const liveMirror = spec.childSession !== void 0 && spec.ctx !== void 0 ? createCodexLiveMirror(spec, task, turn) : void 0;
2405
2768
  let output = "";
2406
2769
  child.stdout?.on("data", (chunk) => {
@@ -2488,7 +2851,16 @@ function startCodexCliRun(request, spec) {
2488
2851
  onAbort
2489
2852
  }).then((settled) => {
2490
2853
  if (settled.stopReason === "completed") spec.onAuthSuccess?.();
2854
+ return settled;
2855
+ });
2856
+ result.then(() => child.done).then(async () => {
2857
+ if (spec.onAuthFailure !== void 0) {
2858
+ const authDetail = `${child.collected.stderr?.readFrom(0).text || stderr}\n${child.collected.stdout?.readFrom(0).text || output}`;
2859
+ if (CODEX_AUTH_FAILURE.test(authDetail)) spec.onAuthFailure(authDetail.split("\n").find((line) => line.trim() !== "") ?? "auth failure");
2860
+ }
2861
+ await mirrorCodexAfterExit(spec, task, turn, output, liveMirror, startedAtMs);
2491
2862
  if (spec.childSession !== void 0) {
2863
+ const settled = await result;
2492
2864
  if (settled.stopReason === "completed") spec.childSession.append("turn/end", {
2493
2865
  turn,
2494
2866
  reason: { kind: "completed" }
@@ -2510,18 +2882,13 @@ function startCodexCliRun(request, spec) {
2510
2882
  }
2511
2883
  }
2512
2884
  });
2885
+ if (spec.ctx !== void 0) await persistChildSession(spec.ctx, spec.childSession).catch((error) => {
2886
+ spec.ctx?.logger.warn(`subagent-codex: turn/end persistence failed: ${thrown(error).message}`);
2887
+ });
2513
2888
  }
2514
- return settled;
2515
- });
2516
- result.then(() => child.done).then(() => {
2517
- if (spec.onAuthFailure !== void 0) {
2518
- const authDetail = `${child.collected.stderr?.readFrom(0).text || stderr}\n${child.collected.stdout?.readFrom(0).text || output}`;
2519
- if (CODEX_AUTH_FAILURE.test(authDetail)) spec.onAuthFailure(authDetail.split("\n").find((line) => line.trim() !== "") ?? "auth failure");
2520
- }
2521
- return mirrorCodexAfterExit(spec, task, turn, output, liveMirror, startedAtMs);
2522
2889
  }, () => {});
2523
2890
  return Promise.resolve(subprocessRunHandle({
2524
- id: spec.childSession?.id ?? SessionId(randomUUID()),
2891
+ id: SessionId(randomUUID()),
2525
2892
  result,
2526
2893
  signal: request.signal,
2527
2894
  onAbort,
@@ -2529,6 +2896,22 @@ function startCodexCliRun(request, spec) {
2529
2896
  teardown: disposeProcess
2530
2897
  }));
2531
2898
  }
2899
+ /**
2900
+ * Write the round's request/header when the config differs from the child
2901
+ * log's last one (the host logs changed snapshots, not per-call duplicates):
2902
+ * the first write is `initial`, a differing one `change`, an identical one
2903
+ * skipped. Context Lens reads the child log's config from these headers.
2904
+ */
2905
+ function appendCodexRequestHeader(childSession, config) {
2906
+ const serialized = JSON.stringify(config);
2907
+ let last;
2908
+ for (const event of childSession.snapshotEvents()) if (event.type === "request/header") last = JSON.stringify(event.data.header?.config);
2909
+ if (last === serialized) return;
2910
+ childSession.append("request/header", {
2911
+ header: { config },
2912
+ reason: last === void 0 ? "initial" : "change"
2913
+ });
2914
+ }
2532
2915
  /** One assistant-role message event, attributed to the codex route. */
2533
2916
  function codexAssistantEvent(blocks) {
2534
2917
  return createAssistantMessage({
@@ -2545,8 +2928,17 @@ function codexAssistantEvent(blocks) {
2545
2928
  * assembler requires: without a boundary the step never enters the location
2546
2929
  * index, the line's assistant message resolves to a turn-level location, and
2547
2930
  * the real-time view renders nothing (only a full rebuild, which mints step
2548
- * drafts from the explicit coordinates, recovers it). Tool cards escape that
2549
- * fate only because their definition does not consult the step location.
2931
+ * drafts from the explicit coordinates, recovers it).
2932
+ *
2933
+ * A tool line folds the NATIVE lifecycle inside its one step: an assistant
2934
+ * message advertising the tool-call block, the `tool/call`, then its
2935
+ * `tool/result` — the released-log reader pairs call and result against the
2936
+ * advertisement and resolves every advertised call before the step closes.
2937
+ * Every fold sees a COMPLETED item (the exec live mirror holds the volatile
2938
+ * last line; the live driver folds at item/completed), so a result the
2939
+ * stream has not recorded by fold time will never arrive — the call folds
2940
+ * with the synthesized interrupted result instead of leaving the step
2941
+ * unresolvable.
2550
2942
  * @returns whether the line folded (false when the options skipped it).
2551
2943
  */
2552
2944
  function appendCodexTranscriptLine(childSession, turn, step, line, usage) {
@@ -2555,23 +2947,36 @@ function appendCodexTranscriptLine(childSession, turn, step, line, usage) {
2555
2947
  step
2556
2948
  });
2557
2949
  if (line.kind === "tool") {
2950
+ const args = line.args ?? "";
2951
+ childSession.append("assistant/message", {
2952
+ turn,
2953
+ step,
2954
+ message: codexAssistantEvent([{
2955
+ type: "tool-call",
2956
+ id: line.id,
2957
+ name: line.name,
2958
+ arguments: args
2959
+ }]),
2960
+ stream: [],
2961
+ ...usage === void 0 ? {} : { usage }
2962
+ }, { surfaceOp: "append" });
2558
2963
  const call = childSession.append("tool/call", {
2559
2964
  turn,
2560
2965
  step,
2561
2966
  callId: line.id,
2562
2967
  name: line.name,
2563
- arguments: line.args ?? ""
2968
+ arguments: args
2564
2969
  });
2565
- if (line.result !== void 0) childSession.append("tool/result", {
2970
+ childSession.append("tool/result", {
2566
2971
  turn,
2567
2972
  step,
2568
2973
  message: createToolResultMessage({
2569
2974
  callId: line.id,
2570
2975
  content: [{
2571
2976
  type: "text",
2572
- text: line.result
2977
+ text: line.result ?? "(工具调用被中断,未能记录返回结果)"
2573
2978
  }],
2574
- isError: false
2979
+ isError: line.result === void 0
2575
2980
  })
2576
2981
  }, {
2577
2982
  surfaceOp: "append",
@@ -2881,7 +3286,8 @@ var LiveDriverSwitch = class {
2881
3286
  this.active = next.live ? new CodexLiveDriver(this.ctx, {
2882
3287
  ...this.options.sandbox === void 0 ? {} : { sandbox: this.options.sandbox },
2883
3288
  ...this.options.liveIdleMs === void 0 ? {} : { liveIdleMs: this.options.liveIdleMs },
2884
- ...this.options.model === void 0 ? {} : { model: this.options.model }
3289
+ ...this.options.model === void 0 ? {} : { model: this.options.model },
3290
+ ...this.options.endpoint === void 0 ? {} : { endpoint: this.options.endpoint }
2885
3291
  }) : void 0;
2886
3292
  if (retiring !== void 0) {
2887
3293
  this.draining.add(retiring);
@@ -2941,6 +3347,47 @@ function dedupeChoices(layers) {
2941
3347
  }
2942
3348
  return choices;
2943
3349
  }
3350
+ /**
3351
+ * Scope the served directory to the surface asking for it (the same helper
3352
+ * lives in each provider's broker — fold into the family core when the
3353
+ * directory faces next evolve):
3354
+ *
3355
+ * - memberless (the card's add-model dialog, the invite picker): the
3356
+ * endpoint's served catalog only — the configuration/history extras the
3357
+ * broker's info extension adds for STATUS display must never become add
3358
+ * candidates;
3359
+ * - a member whose harness has a curated list: the list IS the vocabulary,
3360
+ * in the list's own order (the same rule `choices` already follows) — a
3361
+ * curated value the endpoint no longer serves stays as a bare row (the
3362
+ * card shows it the same way), and the member's current effective model is
3363
+ * kept visible as a bare entry when it sits outside the list, so a pre-list
3364
+ * binding never renders blank;
3365
+ * - a member without a curated list: the legacy extended vocabulary.
3366
+ */
3367
+ function scopeServedDirectory(directory, opts) {
3368
+ if (opts.member && opts.curated.length === 0) return directory;
3369
+ const served = directory.entries.filter((entry) => entry.source === "native");
3370
+ if (!opts.member) return {
3371
+ ...directory,
3372
+ entries: served
3373
+ };
3374
+ const byValue = new Map(served.flatMap((entry) => entry.resolvedModel === void 0 ? [[entry.value, entry]] : [[entry.value, entry], [entry.resolvedModel, entry]]));
3375
+ const entries = opts.curated.map((value) => byValue.get(value) ?? {
3376
+ value,
3377
+ label: value,
3378
+ source: "configuration"
3379
+ });
3380
+ const current = opts.current?.trim();
3381
+ if (current !== void 0 && current !== "" && !entries.some((entry) => entry.value === current || entry.resolvedModel === current)) entries.push({
3382
+ value: current,
3383
+ label: current,
3384
+ source: "configuration"
3385
+ });
3386
+ return {
3387
+ ...directory,
3388
+ entries
3389
+ };
3390
+ }
2944
3391
  var CodexModelBroker = class {
2945
3392
  deps;
2946
3393
  constructor(deps) {
@@ -2954,7 +3401,7 @@ var CodexModelBroker = class {
2954
3401
  await this.deps.refreshDirectory?.(home, binding.cwd);
2955
3402
  directory = this.deps.directory?.(home, binding.cwd);
2956
3403
  }
2957
- const model = selection.model.mode === "value" ? selection.model.value : (selection.model.mode === "inherit" ? binding.model : void 0) ?? this.deps.settingsModel() ?? await readCodexModel(home) ?? this.deps.catalogDefault(home, binding.cwd);
3404
+ const model = selection.model.mode === "value" ? selection.model.value : (selection.model.mode === "inherit" ? binding.model : void 0) ?? this.deps.settingsModel(home) ?? await readCodexModel(home) ?? this.deps.catalogDefault(home, binding.cwd);
2958
3405
  const entry = directory?.entries.find((entry) => entry.value === model);
2959
3406
  const explicitEffort = selection.effort.mode === "value" ? selection.effort.value : selection.effort.mode === "inherit" ? binding.effort : void 0;
2960
3407
  const effort = explicitEffort ?? await readCodexReasoningEffort(home) ?? entry?.reasoning?.default;
@@ -2992,14 +3439,19 @@ var CodexModelBroker = class {
2992
3439
  async modelDirectory(childSessionId, refresh = false) {
2993
3440
  const home = this.deps.homeDir(childSessionId);
2994
3441
  if (refresh) await this.deps.refreshDirectory?.(home, this.deps.cwd?.(childSessionId));
2995
- return (await this.modelInfo(childSessionId, childSessionId === void 0 ? void 0 : this.deps.ctx.localAgent.getDelegation(childSessionId)?.model)).directory ?? {
3442
+ const info = await this.modelInfo(childSessionId, childSessionId === void 0 ? void 0 : this.deps.ctx.localAgent.getDelegation(childSessionId)?.model);
3443
+ return scopeServedDirectory(info.directory ?? {
2996
3444
  entries: [],
2997
3445
  complete: false,
2998
3446
  customInput: true,
2999
3447
  status: "unsupported",
3000
3448
  refreshing: false,
3001
3449
  revision: 0
3002
- };
3450
+ }, {
3451
+ member: childSessionId !== void 0,
3452
+ curated: this.deps.modelList?.(home) ?? [],
3453
+ current: info.effective
3454
+ });
3003
3455
  }
3004
3456
  async *followModelDirectory(childSessionId, signal) {
3005
3457
  if (this.deps.followDirectory === void 0) {
@@ -3019,7 +3471,7 @@ var CodexModelBroker = class {
3019
3471
  if (override !== void 0) return override;
3020
3472
  const recorded = this.deps.ctx.localAgent.getDelegation(childSessionId)?.model?.trim();
3021
3473
  if (recorded !== void 0 && recorded !== "") return recorded;
3022
- return this.deps.settingsModel();
3474
+ return this.deps.settingsModel(this.deps.homeDir(childSessionId));
3023
3475
  }
3024
3476
  /**
3025
3477
  * Read the model surface. The cliDefault and the discovered identifiers come
@@ -3030,8 +3482,8 @@ var CodexModelBroker = class {
3030
3482
  * re-fetches on open, so it never blocks on a CLI boot).
3031
3483
  */
3032
3484
  async modelInfo(childSessionId, delegationModel) {
3033
- const settings = this.deps.settingsModel();
3034
3485
  const homeDir = this.deps.homeDir(childSessionId);
3486
+ const settings = this.deps.settingsModel(homeDir);
3035
3487
  const [cliDefault, discovered] = await Promise.all([readCodexModel(homeDir).catch(() => void 0), listCodexConfigModels(homeDir).catch(() => [])]);
3036
3488
  const override = childSessionId === void 0 ? void 0 : this.deps.overrides.get(childSessionId);
3037
3489
  const delegation = delegationModel?.trim() === "" ? void 0 : delegationModel;
@@ -3039,6 +3491,7 @@ var CodexModelBroker = class {
3039
3491
  const effective = override ?? delegation ?? settings ?? cliDefault ?? (catalogDefaultRaw === void 0 || catalogDefaultRaw === "" ? void 0 : catalogDefaultRaw);
3040
3492
  const source = override !== void 0 ? "override" : delegation !== void 0 ? "delegation" : settings !== void 0 ? "settings" : cliDefault !== void 0 ? "cli-config" : "cli-builtin";
3041
3493
  const inFlight = childSessionId !== void 0 && this.activeDelegations().includes(childSessionId);
3494
+ const curated = this.deps.modelList?.(homeDir) ?? [];
3042
3495
  return {
3043
3496
  ...effective === void 0 ? {} : { effective },
3044
3497
  source,
@@ -3050,8 +3503,8 @@ var CodexModelBroker = class {
3050
3503
  settings,
3051
3504
  cliDefault,
3052
3505
  ...discovered
3053
- ], this.deps.recentModels()) },
3054
- choices: dedupeChoices([
3506
+ ], curated.length > 0 ? [] : this.deps.recentModels()) },
3507
+ choices: curated.length > 0 ? [...curated] : dedupeChoices([
3055
3508
  settings,
3056
3509
  cliDefault,
3057
3510
  ...discovered,
@@ -3083,6 +3536,16 @@ var CodexModelBroker = class {
3083
3536
  };
3084
3537
  /** Grace between stdin EOF and terminate when the probe tears the process down. */
3085
3538
  const PROBE_EOF_GRACE_MS = 1e3;
3539
+ /** One directory answer that names why it is empty. */
3540
+ function unsupported(reason) {
3541
+ return {
3542
+ entries: [],
3543
+ complete: false,
3544
+ customInput: true,
3545
+ unsupported: true,
3546
+ reason
3547
+ };
3548
+ }
3086
3549
  /** Preserve native metadata, including hidden candidates; reject malformed pages. */
3087
3550
  function catalogEntries(result) {
3088
3551
  const page = result;
@@ -3138,7 +3601,7 @@ var CodexModelCatalog = class {
3138
3601
  this.cache = new ModelDirectoryCache({
3139
3602
  load: (key, signal) => {
3140
3603
  const context = JSON.parse(key);
3141
- return this.probe(context.homeDir, context.cwd, signal);
3604
+ return this.load(context.homeDir, context.cwd, signal);
3142
3605
  },
3143
3606
  ttlMs: deps.ttlMs ?? 3e5,
3144
3607
  ...deps.now === void 0 ? {} : { now: deps.now },
@@ -3146,7 +3609,11 @@ var CodexModelCatalog = class {
3146
3609
  });
3147
3610
  }
3148
3611
  key(homeDir, cwd = homeDir) {
3149
- const files = [join(homeDir, "config.toml"), join(homeDir, "auth.json")];
3612
+ const files = [
3613
+ join(homeDir, "config.toml"),
3614
+ join(homeDir, "auth.json"),
3615
+ join(homeDir, ENDPOINT_AUTH_FILE)
3616
+ ];
3150
3617
  for (let directory = cwd;; directory = dirname(directory)) {
3151
3618
  files.push(join(directory, ".codex", "config.toml"));
3152
3619
  if (dirname(directory) === directory) break;
@@ -3181,16 +3648,69 @@ var CodexModelCatalog = class {
3181
3648
  return (await this.cache.refresh(this.key(homeDir, cwd))).entries.filter((entry) => !entry.hidden).map((entry) => entry.value);
3182
3649
  }
3183
3650
  /**
3184
- * One bounded probe: spawn the app-server against the scoped home, answer
3185
- * the initialize handshake exactly as the live driver shapes it, ask
3186
- * `model/list`, then tear the process down (stdin EOF → grace →
3187
- * terminate). The overall timeout kills the process on expiry. Failures propagate to the shared cache, which preserves prior data.
3651
+ * One directory load: the endpoint's served id list is the vocabulary (the
3652
+ * card's add dialog can only offer what the endpoint answers), folded
3653
+ * CONCURRENTLY with the native app-server probe's metadata. The endpoint
3654
+ * answers are deliberate:
3655
+ *
3656
+ * - no credential resolved → empty with a "save a key first" reason;
3657
+ * - 401/403 → empty with the rejection (definitive — cached as data);
3658
+ * - another HTTP status → empty with it (the relay serves no model route);
3659
+ * - a transport failure THROWS, so the cache keeps the last good list and
3660
+ * retries on schedule instead of blanking a momentarily-down endpoint
3661
+ * (the openproxy relay once 500'd and recovered — 2026-10-22).
3662
+ *
3663
+ * The native probe's failure never fails the load: endpoint data alone is
3664
+ * complete (it warns once via the deps sink). The endpoint fetch gets a
3665
+ * 20s ceiling by default (mirrors claude's catalog timeout); the native
3666
+ * probe keeps its own tighter bound internally.
3188
3667
  */
3189
- async probe(homeDir, cwd, signal) {
3668
+ async load(homeDir, cwd, signal) {
3669
+ const endpoint = this.deps.endpoint === void 0 ? void 0 : await this.deps.endpoint(homeDir).catch(() => void 0);
3670
+ if (!hasEndpointCredential(endpoint)) return unsupported("No endpoint credential configured — the model list loads from the endpoint once an API Key is saved");
3671
+ const combined = AbortSignal.any([signal, AbortSignal.timeout(this.deps.timeoutMs ?? 2e4)]);
3672
+ const [result, native] = await Promise.all([fetchEndpointModels({
3673
+ ...endpoint,
3674
+ baseUrl: endpoint.baseUrl ?? "https://api.openai.com"
3675
+ }, this.deps.fetchImpl, combined), this.probeNative(homeDir, cwd, combined).catch((error) => {
3676
+ this.deps.warn?.(`local-agent-codex: the native model/list probe failed (metadata only — the endpoint's list stands): ${error instanceof Error ? error.message : String(error)}`);
3677
+ })]);
3678
+ if (result.kind === "unreachable") throw new Error(`endpoint unreachable: ${result.message}`);
3679
+ if (result.kind === "malformed") return unsupported("The endpoint answered but the model list did not parse (not /v1/models-shaped)");
3680
+ if (result.kind === "http") {
3681
+ if (result.status === 401 || result.status === 403) return unsupported(`The endpoint rejected the credential (HTTP ${String(result.status)}) — the list loads once a valid key is saved`);
3682
+ return unsupported(`The endpoint does not serve a model list (HTTP ${String(result.status)})`);
3683
+ }
3684
+ if (result.models.length === 0) return unsupported("The endpoint serves no models");
3685
+ const nativeByValue = new Map((native?.entries ?? []).map((entry) => [entry.value, entry]));
3686
+ const entries = result.models.map((id) => nativeByValue.get(id) ?? {
3687
+ value: id,
3688
+ label: id,
3689
+ source: "native"
3690
+ });
3691
+ const defaultModel = native?.defaultModel;
3692
+ return {
3693
+ entries,
3694
+ complete: true,
3695
+ customInput: true,
3696
+ ...defaultModel === void 0 ? {} : { defaultModel }
3697
+ };
3698
+ }
3699
+ /**
3700
+ * One bounded NATIVE probe — the metadata-enrichment side of {@link load}:
3701
+ * spawn the app-server against the scoped home, answer the initialize
3702
+ * handshake exactly as the live driver shapes it, ask `model/list`, then
3703
+ * tear the process down (stdin EOF → grace → terminate). The overall
3704
+ * timeout kills the process on expiry. Failures propagate to the caller
3705
+ * (load demotes them to a warn).
3706
+ */
3707
+ async probeNative(homeDir, cwd, signal) {
3708
+ const endpoint = this.deps.endpoint === void 0 ? void 0 : await this.deps.endpoint(homeDir).catch(() => void 0);
3190
3709
  const spec = {
3191
3710
  argv: [
3192
3711
  "codex",
3193
3712
  "app-server",
3713
+ ...endpointConfigOverrides(endpoint),
3194
3714
  "--stdio"
3195
3715
  ],
3196
3716
  cwd,
@@ -3200,7 +3720,10 @@ var CodexModelCatalog = class {
3200
3720
  stderr: "pipe"
3201
3721
  },
3202
3722
  graceMs: DEFAULT_DISPOSE_GRACE_MS,
3203
- env: delegationEnv({ CODEX_HOME: homeDir })
3723
+ env: delegationEnv({
3724
+ CODEX_HOME: homeDir,
3725
+ ...endpointAuthEnv(endpoint)
3726
+ })
3204
3727
  };
3205
3728
  const child = this.deps.spawn(spec);
3206
3729
  const decoder = new StringDecoder("utf8");
@@ -3337,8 +3860,8 @@ var CodexModelCatalog = class {
3337
3860
  /**
3338
3861
  * Codex harness instantiation of the local-agent family. Registers the
3339
3862
  * `codex` harness into the core registry: scoped home under the shared homes
3340
- * root (`CODEX_HOME`), device-code login through `codex login --device-auth`
3341
- * (prompt captured from stdout), and rollout-file session records.
3863
+ * root (`CODEX_HOME`), endpoint-key authentication (endpoint-auth.ts — no web
3864
+ * login anywhere), and rollout-file session records.
3342
3865
  * Delegation mounts through the `codex-local` provider (one-shot
3343
3866
  * `codex exec` under the scoped home); the bundle patch's
3344
3867
  * `tool-subagent-codex-local` row puts the tool on the profile root, so
@@ -3350,7 +3873,8 @@ var CodexModelCatalog = class {
3350
3873
  const name = "local-agent-codex";
3351
3874
  /**
3352
3875
  * Boot delay before the model catalog's eager warmup probe fires. The probe
3353
- * spawns a `codex app-server` — cheap, but not free — so it waits out the
3876
+ * fetches the endpoint's served model list AND spawns a `codex app-server`
3877
+ * (the native metadata side) — cheap, but not free — so it waits out the
3354
3878
  * boot storm instead of competing with it, and the FIRST settings-card open
3355
3879
  * usually hits a warm cache. A card opened before the timer fires simply
3356
3880
  * triggers the probe itself (the read path's lazy kick), so the delay never
@@ -3393,6 +3917,8 @@ const Config = z.object({
3393
3917
  z.const("workspace-write"),
3394
3918
  z.const("danger-full-access")
3395
3919
  ]),
3920
+ baseUrl: z.string(),
3921
+ apiKey: z.string(),
3396
3922
  model: volatilize(SETTINGS_FIELDS.model),
3397
3923
  live: volatilize(SETTINGS_FIELDS.live),
3398
3924
  liveIdleMs: z.number().default(DEFAULT_LIVE_IDLE_MS),
@@ -3449,14 +3975,22 @@ function apply(ctx, config) {
3449
3975
  legacyBase,
3450
3976
  read: readSettings
3451
3977
  });
3452
- const resolveModel = () => {
3978
+ const resolveModelKey = () => {
3453
3979
  const model = face.get().model?.trim();
3454
3980
  return model === void 0 || model === "" ? void 0 : model;
3455
3981
  };
3982
+ const resolveModelFor = (homeDir) => firstModelOf(readModelListSync(homeDir)) ?? resolveModelKey();
3983
+ const resolveModel = (scope) => resolveModelFor(ctx.localAgent.homeDir("codex", scope));
3456
3984
  const memberModelOverrides = /* @__PURE__ */ new Map();
3985
+ const endpointConfig = {
3986
+ ...config.baseUrl === void 0 ? {} : { baseUrl: config.baseUrl },
3987
+ ...config.apiKey === void 0 ? {} : { apiKey: config.apiKey }
3988
+ };
3989
+ const resolveEndpoint = (scopeHome) => resolveEndpointAuth(scopeHome, endpointConfig);
3457
3990
  const modelCatalog = new CodexModelCatalog({
3458
3991
  spawn: (spec) => ctx.subprocess.spawn(spec),
3459
- warn: (message) => ctx.logger.warn(message)
3992
+ warn: (message) => ctx.logger.warn(message),
3993
+ endpoint: resolveEndpoint
3460
3994
  });
3461
3995
  const warmup = setTimeout(() => {
3462
3996
  modelCatalog.read(homeDir);
@@ -3464,12 +3998,14 @@ function apply(ctx, config) {
3464
3998
  warmup.unref();
3465
3999
  const liveSwitch = new LiveDriverSwitch(ctx, face, {
3466
4000
  sandbox,
3467
- model: (childSessionId) => memberModelOverrides.get(childSessionId) ?? resolveModel(),
4001
+ model: (childSessionId) => memberModelOverrides.get(childSessionId) ?? resolveModel((ctx.localAgent.memberBinding?.(childSessionId) ?? ctx.localAgent.getDelegation(childSessionId))?.scope),
4002
+ endpoint: resolveEndpoint,
3468
4003
  ...config.liveIdleMs === void 0 ? {} : { liveIdleMs: config.liveIdleMs }
3469
4004
  });
3470
4005
  const modelBroker = new CodexModelBroker({
3471
4006
  ctx,
3472
- settingsModel: resolveModel,
4007
+ settingsModel: resolveModelFor,
4008
+ modelList: (homeDir) => readModelListSync(homeDir),
3473
4009
  recentModels: () => face.get().recentModels ?? [],
3474
4010
  homeDir: (childSessionId) => ctx.localAgent.homeDir("codex", childSessionId === void 0 ? void 0 : (ctx.localAgent.memberBinding?.(childSessionId) ?? ctx.localAgent.getDelegation(childSessionId))?.scope),
3475
4011
  cwd: (childSessionId) => childSessionId === void 0 ? void 0 : (ctx.localAgent.memberBinding?.(childSessionId) ?? ctx.localAgent.getDelegation(childSessionId))?.cwd,
@@ -3483,7 +4019,7 @@ function apply(ctx, config) {
3483
4019
  catalog: (scopedHome, cwd) => modelCatalog.read(scopedHome, cwd),
3484
4020
  catalogDefault: (scopedHome, cwd) => modelCatalog.readDefault(scopedHome, cwd)
3485
4021
  });
3486
- const disposeProvider = ctx.subagents.registerProvider(new CodexCliProvider(ctx, sandbox, liveSwitch.resolve, resolveModel, (childSessionId) => memberModelOverrides.get(childSessionId)));
4022
+ const disposeProvider = ctx.subagents.registerProvider(new CodexCliProvider(ctx, sandbox, liveSwitch.resolve, resolveModel, (childSessionId) => memberModelOverrides.get(childSessionId), resolveEndpoint));
3487
4023
  const disposeHarness = ctx.localAgent.register({
3488
4024
  prepareMember: async ({ binding, childSession, configuration, signal }) => {
3489
4025
  const driver = liveSwitch.resolve(binding.childSessionId);
@@ -3501,23 +4037,53 @@ function apply(ctx, config) {
3501
4037
  homeEnvVar: "CODEX_HOME",
3502
4038
  delegationProvider: "codex-local",
3503
4039
  modelBroker,
3504
- login: { pty: {
3505
- command: "codex",
3506
- args: ["login"]
3507
- } },
4040
+ modelList: {
4041
+ read: (home) => readModelList(home),
4042
+ write: (home, models) => writeModelList(home, models)
4043
+ },
3508
4044
  records: { listSessions: (homeDir) => listCodexSessions(homeDir) },
3509
4045
  provision: (scopedHome) => provisionCodexConfig(scopedHome).then(() => {}),
3510
- isAuthenticated: codexAuthenticated,
3511
- credentialStamp: codexCredentialStamp,
3512
- logout: codexLogout,
4046
+ isAuthenticated: async (home) => hasEndpointCredential(await resolveEndpoint(home)),
4047
+ checkEndpoint: (home) => resolveEndpoint(home).then((endpoint) => checkCodexEndpoint(endpoint)),
4048
+ endpointAuth: {
4049
+ read: async (home) => {
4050
+ const endpoint = await resolveEndpoint(home);
4051
+ return {
4052
+ ...endpoint.baseUrl === void 0 ? {} : { baseUrl: endpoint.baseUrl },
4053
+ apiKeySet: endpoint.apiKey !== void 0,
4054
+ authTokenSet: false
4055
+ };
4056
+ },
4057
+ write: async (home, fields) => {
4058
+ const next = { ...await readEndpointAuthFile(home) ?? {} };
4059
+ for (const [field, value] of Object.entries(fields)) {
4060
+ if (field !== "baseUrl" && field !== "apiKey") continue;
4061
+ if (value === void 0) continue;
4062
+ const trimmed = typeof value === "string" ? value.trim() : "";
4063
+ if (value === null || trimmed === "") delete next[field];
4064
+ else next[field] = trimmed;
4065
+ }
4066
+ if (Object.keys(next).length === 0) await clearEndpointAuth(home);
4067
+ else await writeEndpointAuth(home, next);
4068
+ }
4069
+ },
4070
+ credentialStamp: endpointAuthStamp,
4071
+ logout: async (home) => {
4072
+ if (await endpointAuthFileExists(home)) {
4073
+ await clearEndpointAuth(home);
4074
+ return;
4075
+ }
4076
+ throw new Error("codex has no scoped endpoint-auth.json to clear — the credential comes from the settings card or the profile row (baseUrl/apiKey); clear it there instead");
4077
+ },
3513
4078
  effectiveSettings: async (scopedHome) => {
3514
- const [reasoningEffort, baseUrl, scopedModel, cliVersion] = await Promise.all([
4079
+ const [reasoningEffort, endpoint, scopedModel, cliVersion] = await Promise.all([
3515
4080
  readCodexReasoningEffort(scopedHome).catch(() => void 0),
3516
- readCodexBaseUrl(scopedHome).catch(() => void 0),
4081
+ resolveEndpoint(scopedHome).catch(() => void 0),
3517
4082
  readCodexModel(scopedHome).catch(() => void 0),
3518
4083
  codexCliVersion(ctx, scopedHome).catch(() => void 0)
3519
4084
  ]);
3520
- const model = resolveModel() ?? scopedModel;
4085
+ const baseUrl = hasEndpointCredential(endpoint) ? endpoint.baseUrl : await readCodexBaseUrl(scopedHome).catch(() => void 0);
4086
+ const model = resolveModelFor(scopedHome) ?? scopedModel;
3521
4087
  const baseUrlHost = baseUrl !== void 0 ? endpointHost(baseUrl) : void 0;
3522
4088
  return {
3523
4089
  drive: face.get().live ? "live" : "exec",