@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.
@@ -24,10 +24,12 @@
24
24
  import { randomUUID } from 'node:crypto';
25
25
  import { StringDecoder } from 'node:string_decoder';
26
26
  import { createUserMessage } from '@deepseek-ai/dsh-llm';
27
+ import { SessionId } from '@deepseek-ai/dsh-session';
27
28
  import { settleRunResult, subprocessRunHandle, } from '@deepseek-ai/dsh-subagent';
28
29
  import { delegationEnv, persistChildSession, LiveStreamPublisher, LiveFlush, LIVE_FLUSH_INTERVAL_MS } from '@khorsheed/dsh-local-agent';
29
- import { appendCodexTranscriptLine, codexAssistantEvent, codexLineText, DEFAULT_DISPOSE_GRACE_MS, registerCodexMemberRun, textTask, } from './codex-cli-provider.js';
30
+ import { appendCodexRequestHeader, appendCodexTranscriptLine, codexAssistantEvent, codexLineText, DEFAULT_DISPOSE_GRACE_MS, registerCodexMemberRun, textTask, } from './codex-cli-provider.js';
30
31
  import { codexRolloutRoundFacts } from './records.js';
32
+ import { endpointAuthEnv, endpointConfigOverrides } from './endpoint-auth.js';
31
33
  /** Default idle lifetime of an unused resident runtime before reclaim. */
32
34
  export const DEFAULT_LIVE_IDLE_MS = 30 * 60_000;
33
35
  /** Default timeout for a wire request acknowledgement. */
@@ -44,11 +46,6 @@ const RECLAIM_EOF_GRACE_MS = 1_000;
44
46
  export const DEFAULT_SNAPSHOT_MIN_INTERVAL_MS = LIVE_FLUSH_INTERVAL_MS;
45
47
  /** Default minimum text growth between one streaming item's snapshot messages. */
46
48
  export const DEFAULT_SNAPSHOT_MIN_CHARS = 0;
47
- /**
48
- * The app-server channel could not come up (spawn failure or handshake
49
- * timeout/mismatch). The provider catches exactly this and falls back to the
50
- * exec one-shot; any other error is a real round failure.
51
- */
52
49
  export class LiveChannelUnavailableError extends Error {
53
50
  constructor(message) {
54
51
  super(message);
@@ -137,16 +134,27 @@ export function codexAppServerItemToLine(item) {
137
134
  };
138
135
  }
139
136
  case 'dynamicToolCall': {
140
- // Non-shell tools (e.g. the multi-agent `wait`): {id, tool, arguments, status}.
137
+ // Non-shell tools (e.g. the multi-agent `wait`, the deferred `exec`
138
+ // discovery tool): {id, tool, arguments, status, contentItems, success}
139
+ // — the app-server carries the result INLINE on the completed item.
141
140
  const tool = typeof item['tool'] === 'string' ? item.tool : 'dynamic';
142
141
  const args = item['arguments'] === undefined || item['arguments'] === null
143
142
  ? undefined
144
143
  : typeof item['arguments'] === 'string' ? item['arguments'] : JSON.stringify(item['arguments']);
144
+ const content = item['contentItems'];
145
+ const result = Array.isArray(content)
146
+ ? content
147
+ .filter((block) => typeof block === 'object' && block !== null)
148
+ .map(block => typeof block['text'] === 'string' ? block['text'] : undefined)
149
+ .filter((text) => text !== undefined && text !== '')
150
+ .join('\n')
151
+ : undefined;
145
152
  return {
146
153
  kind: 'tool',
147
154
  id: itemId ?? `codex-live-${randomUUID()}`,
148
155
  name: tool,
149
156
  ...args === undefined || args === '' ? {} : { args },
157
+ ...result === undefined || result === '' ? {} : { result },
150
158
  };
151
159
  }
152
160
  case 'collabAgentToolCall': {
@@ -174,15 +182,22 @@ export function codexAppServerItemToLine(item) {
174
182
  function usageFromAppServer(breakdown) {
175
183
  const input = Number(breakdown['inputTokens']);
176
184
  const cached = Number(breakdown['cachedInputTokens']);
185
+ const cacheWrite = Number(breakdown['cacheWriteInputTokens']);
177
186
  const output = Number(breakdown['outputTokens']);
187
+ // Codex's input total already contains both cache buckets (total = input +
188
+ // output); the shared contract keeps the three input buckets disjoint.
178
189
  const usage = {
179
190
  inputTokens: Number.isFinite(input) && Number.isFinite(cached)
180
- ? Math.max(0, input - cached)
191
+ ? Math.max(0, input - cached - (Number.isFinite(cacheWrite) ? cacheWrite : 0))
181
192
  : Number.isFinite(input) ? input : 0,
182
193
  outputTokens: Number.isFinite(output) ? output : 0,
183
194
  };
184
- if (Number.isFinite(cached) && cached > 0)
195
+ // The field rides every payload (zero included): write it whenever present,
196
+ // so "no hits" never reads as "not reported".
197
+ if (Number.isFinite(cached))
185
198
  usage.cacheReadTokens = cached;
199
+ if (Number.isFinite(cacheWrite) && cacheWrite > 0)
200
+ usage.cacheWriteTokens = cacheWrite;
186
201
  return usage;
187
202
  }
188
203
  /**
@@ -552,10 +567,18 @@ export class CodexLiveDriver {
552
567
  const startModel = spec.startModel?.trim();
553
568
  const model = spec.configuration !== undefined ? spec.configuration.model
554
569
  : startModel !== undefined && startModel !== '' ? startModel : this.config.model?.(key)?.trim();
570
+ // The endpoint credential routes the resident process too: with a key the
571
+ // app-server spawns onto the relay's custom provider (the same `-c`
572
+ // overrides the exec path carries), so a live round never consults
573
+ // auth.json either. Read per spawn — a card write reaches the next respawn.
574
+ const endpoint = this.config.endpoint === undefined
575
+ ? undefined
576
+ : await this.config.endpoint(spec.homeDir).catch(() => undefined);
555
577
  const spawnSpec = {
556
578
  argv: [
557
579
  'codex', 'app-server',
558
580
  ...member === undefined ? [] : ['-c', member.configOverride],
581
+ ...endpointConfigOverrides(endpoint),
559
582
  ...model === undefined || model === '' ? [] : ['-c', `model=${JSON.stringify(model)}`],
560
583
  ...spec.configuration?.effort === undefined ? [] : ['-c', `model_reasoning_effort=${JSON.stringify(spec.configuration.effort)}`],
561
584
  '--stdio',
@@ -563,7 +586,7 @@ export class CodexLiveDriver {
563
586
  cwd: spec.cwd,
564
587
  stdio: { stdin: 'pipe', stdout: 'pipe', stderr: 'pipe' },
565
588
  graceMs: DEFAULT_DISPOSE_GRACE_MS,
566
- env: delegationEnv({ CODEX_HOME: spec.homeDir }),
589
+ env: delegationEnv({ CODEX_HOME: spec.homeDir, ...endpointAuthEnv(endpoint) }),
567
590
  };
568
591
  let child;
569
592
  try {
@@ -772,27 +795,31 @@ export class CodexLiveDriver {
772
795
  const lines = [];
773
796
  let mirrored = 0;
774
797
  let usage;
775
- /** Whether a folded line already carries the round's usage (no backfill event exists). */
776
- let usageCarried = false;
798
+ /** The cumulative turn total the wire's `tokenUsage.total` names, for the settled report. */
799
+ let totalUsage;
777
800
  /**
778
- * The step ledger: `reservedSteps` holds every step a stream reservation
779
- * consumed (in increasing order — permanently, whether the stream later
780
- * completes or stays an orphan snapshot), and a sequential line folds at
781
- * its index shifted past every reservation before it. A tool completed
782
- * mid-stream therefore folds ABOVE the stream's step and the projection
783
- * renders the round's chronological order.
801
+ * Per-call usage by transcript line: each `thread/tokenUsage/updated`
802
+ * notification closes one API call (its items have all completed), and
803
+ * the call's LAST line carries its `tokenUsage.last`. The map is
804
+ * authoritative per line, independent of the flush's withUsage flag.
784
805
  */
785
- const reservedSteps = [];
806
+ const usageByLine = new Map();
807
+ /** Whether a folded line already carries usage (per-call, or the round's fallback — no backfill event exists). */
808
+ let usageCarried = false;
786
809
  /**
787
- * The streaming items seen this round by item id:
788
- * deltas accumulate into throttled snapshot assistant/messages appended
789
- * at the item's reserved (turn, step) — the host folds repeated settles
790
- * at one coordinate into one live-updating chat node, which is the only
791
- * streaming channel left after 0.1.5 retired the durable per-chunk event.
792
- * The item's completion folds at the same step and finalizes it; an entry
793
- * whose completion never arrives is force-finalized at settle.
810
+ * The streaming items seen this round by item id: deltas accumulate into
811
+ * throttled snapshots at the item's step once it may open in the log
812
+ * (previewed live before that) — the host folds repeated settles at one
813
+ * coordinate into one live-updating chat node. The item's completion
814
+ * folds at the same step and finalizes it; an entry whose completion
815
+ * never arrives is force-finalized at settle. A tool that completes
816
+ * mid-stream folds after the stream, keeping the round's chronology.
794
817
  */
795
818
  const streams = new Map();
819
+ /** Provisional stream keys (live flush and preview identity), never log steps. */
820
+ let streamKeys = 0;
821
+ /** Line indexes already written (a line may fold ahead of an earlier one it chronologically precedes). */
822
+ const folded = new Set();
796
823
  /** The item deltas currently accumulate into (items stream sequentially). */
797
824
  let activeStream;
798
825
  /** Items/completions that arrived before the turn id was known. */
@@ -840,6 +867,29 @@ export class CodexLiveDriver {
840
867
  const streamPublisher = localAgent?.liveStreams === undefined ? undefined
841
868
  : new LiveStreamPublisher(localAgent.liveStreams, childSession, turn, persist, error => this.ctx.logger.warn(`live checkpoint failed: ${String(error)}`));
842
869
  const liveFlush = new LiveFlush(error => this.ctx.logger.warn(`live mirror flush failed: ${String(error)}`), this.config.snapshotMinIntervalMs ?? DEFAULT_SNAPSHOT_MIN_INTERVAL_MS);
870
+ // The step ledger. The log admits steps strictly in sequence — one open
871
+ // at a time, numbered when it is WRITTEN (`lastStep`) — while the round's
872
+ // chronology is kept by start order: a stream sorts before every line
873
+ // that completed after its first delta (`after` = lines completed before
874
+ // it began). A stream's step opens in the log only once it is the oldest
875
+ // unfinished stream and everything before it is written; until then the
876
+ // browser previews it (`preview`), and its fold writes the text anyway.
877
+ let lastStep = 0;
878
+ let openStep;
879
+ const openStreamStep = (stream) => {
880
+ lastStep += 1;
881
+ childSession.append('step/start', { turn, step: lastStep });
882
+ stream.durable = lastStep;
883
+ stream.opened = true;
884
+ openStep = lastStep;
885
+ };
886
+ const closeOpenStep = () => {
887
+ if (openStep === undefined)
888
+ return;
889
+ childSession.append('step/end', { turn, step: openStep });
890
+ openStep = undefined;
891
+ };
892
+ const canOpenStream = (stream) => openStep === undefined && mirrored >= stream.after && streams.keys().next().value === stream.itemId;
843
893
  const pendingStreamArrival = new Map();
844
894
  const appendStreamSnapshot = (stream, force, interrupted, withUsage = false, final = false) => {
845
895
  if (stream.text.trim() === '')
@@ -854,31 +904,39 @@ export class CodexLiveDriver {
854
904
  liveFlush.cancel(stream.step);
855
905
  const receivedAt = pendingStreamArrival.get(stream.step) ?? now;
856
906
  pendingStreamArrival.delete(stream.step);
857
- if (streamPublisher !== undefined && !final) {
858
- if (!stream.opened) {
859
- childSession.append('step/start', { turn, step: stream.step });
860
- stream.opened = true;
907
+ if (!stream.opened) {
908
+ if (canOpenStream(stream))
909
+ openStreamStep(stream);
910
+ else if (!final) {
911
+ // Not its turn in the log yet: show it live, write it later.
912
+ streamPublisher?.preview(stream.step, stream.kind, stream.text, { itemId: stream.itemId, receivedAt });
913
+ return;
861
914
  }
862
- streamPublisher.update(stream.step, stream.kind, stream.text, { receivedAt });
915
+ else {
916
+ // Settle: every line has folded; take the next step.
917
+ closeOpenStep();
918
+ openStreamStep(stream);
919
+ }
920
+ }
921
+ if (streamPublisher !== undefined && !final) {
922
+ streamPublisher.update(stream.durable, stream.kind, stream.text, { receivedAt });
863
923
  stream.lastSnapshotAt = now;
864
924
  stream.lastSnapshotLen = stream.text.length;
865
925
  return;
866
926
  }
867
- if (!stream.opened) {
868
- childSession.append('step/start', { turn, step: stream.step });
869
- stream.opened = true;
870
- }
871
- const attachUsage = withUsage && !usageCarried && usage !== undefined;
927
+ // Per-call folds carry the accounting; a final snapshot only falls back
928
+ // to the last-call read when the wire named no per-call usage at all.
929
+ const attachUsage = withUsage && !usageCarried && usageByLine.size === 0 && usage !== undefined;
872
930
  childSession.append('assistant/message', {
873
931
  turn,
874
- step: stream.step,
932
+ step: stream.durable,
875
933
  message: codexAssistantEvent([
876
934
  stream.kind === 'think'
877
935
  ? { type: 'reasoning', text: stream.text }
878
936
  : { type: 'text', text: stream.text },
879
937
  ]),
880
938
  stream: [],
881
- ...withUsage && !usageCarried && usage !== undefined ? { usage } : {},
939
+ ...withUsage && !usageCarried && usageByLine.size === 0 && usage !== undefined ? { usage } : {},
882
940
  ...interrupted ? { interrupted: true } : {},
883
941
  }, { surfaceOp: 'append' });
884
942
  if (attachUsage)
@@ -887,11 +945,21 @@ export class CodexLiveDriver {
887
945
  stream.lastSnapshotLen = stream.text.length;
888
946
  persist();
889
947
  };
948
+ /** Close a stream's live presentation once its final text is in the log. */
949
+ const finishStream = (stream) => {
950
+ liveFlush.cancel(stream.step);
951
+ streams.delete(stream.itemId);
952
+ if (activeStream === stream.itemId)
953
+ activeStream = undefined;
954
+ streamPublisher?.finish(stream.step);
955
+ if (stream.durable !== undefined)
956
+ streamPublisher?.finish(stream.durable);
957
+ };
890
958
  /**
891
- * Reserve the step one new streaming item will occupy — past every
892
- * completed line (folded or held back) and every earlier reservation. A
893
- * different item's deltas force one final snapshot of the current stream
894
- * (its completion still folds at its own reserved step when it lands).
959
+ * Track one new streaming item: its provisional key (live flush and
960
+ * preview identity) and how many lines had completed when it began. A
961
+ * different item's deltas force one freshness snapshot of the current
962
+ * stream.
895
963
  */
896
964
  const reserveStream = (itemId, kind) => {
897
965
  if (activeStream !== undefined && activeStream !== itemId) {
@@ -905,75 +973,96 @@ export class CodexLiveDriver {
905
973
  }
906
974
  let stream = streams.get(itemId);
907
975
  if (stream === undefined) {
908
- const step = lines.length + reservedSteps.length + 1;
909
- reservedSteps.push(step);
910
- stream = { itemId, kind, step, text: '', lastSnapshotAt: 0, lastSnapshotLen: 0, opened: false };
976
+ streamKeys += 1;
977
+ stream = { itemId, kind, step: streamKeys, after: lines.length, text: '', lastSnapshotAt: 0, lastSnapshotLen: 0, opened: false };
911
978
  streams.set(itemId, stream);
912
979
  }
913
980
  activeStream = itemId;
914
981
  };
915
- /** The sequential fold step for `lines[index]`: its position shifted past every reservation before it. */
916
- const foldStep = (index) => {
917
- let step = index + 1;
918
- for (const reserved of reservedSteps) {
919
- if (reserved <= step)
920
- step += 1;
921
- else
922
- break;
982
+ /** The stream a completed line finalizes, if it was streamed. */
983
+ const streamOfLine = (line) => line === undefined || line.kind === 'tool' ? undefined : streams.get(line.itemId ?? '');
984
+ /** Write lines[index] at the next step(s). */
985
+ const foldLine = (index, lineUsage) => {
986
+ const line = lines[index];
987
+ const stream = streamOfLine(line);
988
+ if (stream !== undefined) {
989
+ // A streamed item finalizes its own step (opening it now if no
990
+ // snapshot ever could) and closes it.
991
+ if (!stream.opened) {
992
+ closeOpenStep();
993
+ openStreamStep(stream);
994
+ }
995
+ const blocks = stream.kind === 'think'
996
+ ? [{ type: 'reasoning', text: line.kind === 'tool' ? '' : line.text }]
997
+ : [{ type: 'text', text: line.kind === 'tool' ? '' : line.text }];
998
+ childSession.append('assistant/message', {
999
+ turn,
1000
+ step: stream.durable,
1001
+ message: codexAssistantEvent(blocks),
1002
+ stream: [],
1003
+ ...lineUsage === undefined ? {} : { usage: lineUsage },
1004
+ }, { surfaceOp: 'append' });
1005
+ finishStream(stream);
1006
+ closeOpenStep();
1007
+ }
1008
+ else {
1009
+ closeOpenStep();
1010
+ lastStep += 1;
1011
+ appendCodexTranscriptLine(childSession, turn, lastStep, line, lineUsage);
923
1012
  }
924
- return step;
1013
+ folded.add(index);
1014
+ localAgent?.reportRunProgress(childSession.id, { kind: 'delta', text: codexLineText(line) });
925
1015
  };
926
- /** Mirror folded lines [mirrored, upto); the last line is held back until completion. */
1016
+ /**
1017
+ * Mirror the completed lines before `upto` in chronological order; the
1018
+ * last line is held back until completion. A line waits behind every
1019
+ * stream that began before it completed: that stream's line folds first,
1020
+ * or — while it is still streaming — the mirror stops until it lands.
1021
+ */
927
1022
  const mirrorUpTo = (upto, withUsage) => {
928
- // The usage rides the last NON-tool line (tool events carry no usage
929
- // slot, and a kill mid-command ends the transcript with a tool line).
930
- // A carrier mirrored in an earlier flush (before the usage was knowable)
931
- // loses the accounting — host 0.1.5 has no usage-backfill event.
1023
+ // Per-call usage rides the line its notification stamped, whenever that
1024
+ // line folds. The fallback (a wire that never named per-call usage)
1025
+ // rides the last NON-tool line once — tool/call and tool/result events
1026
+ // carry no usage slot, and a kill mid-command ends the transcript with
1027
+ // a tool line. A carrier mirrored in an earlier flush (before the usage
1028
+ // was knowable) loses the accounting — host 0.1.5 has no usage-backfill
1029
+ // event.
932
1030
  let usageIndex = -1;
933
- if (withUsage) {
1031
+ if (withUsage && usageByLine.size === 0) {
934
1032
  for (let index = 0; index < lines.length; index += 1) {
935
1033
  if (lines[index]?.kind !== 'tool')
936
1034
  usageIndex = index;
937
1035
  }
938
1036
  }
939
- for (let index = mirrored; index < upto; index += 1) {
940
- const line = lines[index];
941
- if (line === undefined)
942
- continue;
1037
+ const usageFor = (index) => {
1038
+ const own = usageByLine.get(index);
1039
+ if (own !== undefined) {
1040
+ usageCarried = true;
1041
+ return own;
1042
+ }
943
1043
  const lineUsage = withUsage && index === usageIndex ? usage : undefined;
944
1044
  if (lineUsage !== undefined)
945
1045
  usageCarried = true;
946
- const itemId = line.kind === 'tool' ? undefined : line.itemId;
947
- const stream = itemId === undefined ? undefined : streams.get(itemId);
948
- if (itemId !== undefined && stream !== undefined) {
949
- // A streamed item folds at its reserved step, finalizing the
950
- // snapshots: the step opens only if no snapshot ever landed (a
951
- // fast item that stayed under the throttle), and closes here.
952
- liveFlush.cancel(stream.step);
953
- streams.delete(itemId);
954
- if (activeStream === itemId)
955
- activeStream = undefined;
956
- if (!stream.opened)
957
- childSession.append('step/start', { turn, step: stream.step });
958
- const blocks = stream.kind === 'think'
959
- ? [{ type: 'reasoning', text: line.kind === 'tool' ? '' : line.text }]
960
- : [{ type: 'text', text: line.kind === 'tool' ? '' : line.text }];
961
- childSession.append('assistant/message', {
962
- turn,
963
- step: stream.step,
964
- message: codexAssistantEvent(blocks),
965
- stream: [],
966
- ...lineUsage === undefined ? {} : { usage: lineUsage },
967
- }, { surfaceOp: 'append' });
968
- streamPublisher?.finish(stream.step);
969
- childSession.append('step/end', { turn, step: stream.step });
970
- }
971
- else {
972
- appendCodexTranscriptLine(childSession, turn, foldStep(index), line, lineUsage);
1046
+ return lineUsage;
1047
+ };
1048
+ outer: for (let index = mirrored; index < upto; index += 1) {
1049
+ if (folded.has(index))
1050
+ continue;
1051
+ const own = streamOfLine(lines[index]);
1052
+ // Streams order among themselves by start; a plain line follows every
1053
+ // stream that began before it completed.
1054
+ const all = [...streams.values()];
1055
+ const before = own === undefined ? all.filter(stream => stream.after <= index) : all.slice(0, all.indexOf(own));
1056
+ for (const earlier of before) {
1057
+ const at = lines.findIndex((line, position) => position > index && position < upto && !folded.has(position) && streamOfLine(line) === earlier);
1058
+ if (at < 0)
1059
+ break outer;
1060
+ foldLine(at, usageFor(at));
973
1061
  }
974
- mirrored = index + 1;
975
- localAgent?.reportRunProgress(childSession.id, { kind: 'delta', text: codexLineText(line) });
1062
+ foldLine(index, usageFor(index));
976
1063
  }
1064
+ while (folded.has(mirrored))
1065
+ mirrored += 1;
977
1066
  if (upto > 0)
978
1067
  persist();
979
1068
  };
@@ -983,6 +1072,17 @@ export class CodexLiveDriver {
983
1072
  // The boundary opens after the accept ack (a definitive reject leaves no
984
1073
  // dangling turn/start); the prompt and any early items flush inside it.
985
1074
  childSession.append('turn/start', { turn });
1075
+ // The provider's own launch facts are the child log's request config —
1076
+ // codex's notifications never carry model/effort, so the turn/start
1077
+ // request parameters the provider sends are the authoritative source.
1078
+ // The header contract requires a model: without one nothing is recorded.
1079
+ if (spec.configuration?.model !== undefined) {
1080
+ appendCodexRequestHeader(childSession, {
1081
+ provider: 'codex-local',
1082
+ model: spec.configuration.model,
1083
+ ...spec.configuration.effort === undefined ? {} : { reasoningEffort: spec.configuration.effort },
1084
+ });
1085
+ }
986
1086
  childSession.append('user/message', createUserMessage({
987
1087
  content: [{ type: 'text', text: task }],
988
1088
  source: { kind: 'user' },
@@ -1010,8 +1110,9 @@ export class CodexLiveDriver {
1010
1110
  if (line === undefined)
1011
1111
  return;
1012
1112
  lines.push(line);
1013
- // Hold back the volatile last line until turn/completed (it is the
1014
- // round's usage carrier) — the exec live mirror's exact rule.
1113
+ // Hold back the volatile last line (a tool result may still merge
1114
+ // into it) — per-call usage arrives on thread/tokenUsage/updated and
1115
+ // folds with the line whenever it lands.
1015
1116
  mirrorUpTo(lines.length - 1, false);
1016
1117
  return;
1017
1118
  }
@@ -1040,19 +1141,41 @@ export class CodexLiveDriver {
1040
1141
  if (method === 'thread/tokenUsage/updated') {
1041
1142
  const tokenUsage = params['tokenUsage'];
1042
1143
  const last = tokenUsage?.['last'];
1043
- if (last !== undefined)
1144
+ const total = tokenUsage?.['total'];
1145
+ // A total with no numbers is "not reported" (never override the
1146
+ // per-call read with an empty object).
1147
+ if (total !== undefined && (Number.isFinite(Number(total['inputTokens'])) || Number.isFinite(Number(total['outputTokens'])))) {
1148
+ totalUsage = usageFromAppServer(total);
1149
+ }
1150
+ if (last !== undefined) {
1044
1151
  usage = usageFromAppServer(last);
1152
+ // One notification closes one API call (the rollout's token_count
1153
+ // position: right after the call's last item). Stamp the call's own
1154
+ // usage on its last line and flush the completed prefix with it;
1155
+ // the volatile last line keeps its hold (a tool result may still
1156
+ // merge into it) and folds with its mapped usage whenever it lands.
1157
+ if (lines.length > 0) {
1158
+ usageByLine.set(lines.length - 1, usage);
1159
+ mirrorUpTo(lines.length - 1, true);
1160
+ }
1161
+ }
1045
1162
  return;
1046
1163
  }
1047
1164
  if (method === 'turn/completed') {
1048
1165
  const turnData = params['turn'];
1049
- mirrorUpTo(lines.length, true);
1050
- resolveCompletion({
1051
- status: String(turnData['status']),
1052
- ...(turnData['error'] === null || turnData['error'] === undefined
1053
- ? {}
1054
- : { error: turnData['error'] }),
1055
- });
1166
+ // A fold failure must never skip the completion: the round settles
1167
+ // with its mirrored prefix instead of hanging forever.
1168
+ try {
1169
+ mirrorUpTo(lines.length, true);
1170
+ }
1171
+ finally {
1172
+ resolveCompletion({
1173
+ status: String(turnData['status']),
1174
+ ...(turnData['error'] === null || turnData['error'] === undefined
1175
+ ? {}
1176
+ : { error: turnData['error'] }),
1177
+ });
1178
+ }
1056
1179
  }
1057
1180
  };
1058
1181
  /** Route one wire notification: round-scoped, turn-id tagged, early ones buffered. */
@@ -1174,6 +1297,15 @@ export class CodexLiveDriver {
1174
1297
  // flush it now (the exec settle-mirror's partial-work contract).
1175
1298
  if (mirrored < lines.length)
1176
1299
  mirrorUpTo(lines.length, true);
1300
+ // A line still waiting behind a stream whose completion never came:
1301
+ // finalize that stream from its last snapshot, then keep folding.
1302
+ while (mirrored < lines.length && streams.size > 0) {
1303
+ const oldest = streams.values().next().value;
1304
+ appendStreamSnapshot(oldest, true, settled.stopReason !== 'completed', false, true);
1305
+ finishStream(oldest);
1306
+ closeOpenStep();
1307
+ mirrorUpTo(lines.length, true);
1308
+ }
1177
1309
  // Streams whose item/completed never arrived (abort, or the server
1178
1310
  // ended the turn mid-item) still finalize INSIDE the turn window:
1179
1311
  // one forced snapshot each — interrupted on a non-completed round,
@@ -1183,9 +1315,8 @@ export class CodexLiveDriver {
1183
1315
  const remaining = [...streams.values()];
1184
1316
  for (const [position, stream] of remaining.entries()) {
1185
1317
  appendStreamSnapshot(stream, true, settled.stopReason !== 'completed', position === remaining.length - 1, true);
1186
- streamPublisher?.finish(stream.step);
1187
- if (stream.opened)
1188
- childSession.append('step/end', { turn, step: stream.step });
1318
+ finishStream(stream);
1319
+ closeOpenStep();
1189
1320
  }
1190
1321
  streams.clear();
1191
1322
  activeStream = undefined;
@@ -1238,7 +1369,11 @@ export class CodexLiveDriver {
1238
1369
  windowStart: startedAtMs,
1239
1370
  cwd: spec.cwd,
1240
1371
  });
1241
- const settledUsage = usage ?? facts.usage;
1372
+ // The settled report wants the round TOTAL: prefer the wire's
1373
+ // cumulative tokenUsage.total (the per-call `last` under-reports a
1374
+ // multi-call round); the rollout read is the non-completed turn's
1375
+ // fallback, as before.
1376
+ const settledUsage = totalUsage ?? usage ?? facts.usage;
1242
1377
  const round = {
1243
1378
  ...facts.model === undefined ? {} : { observedModel: facts.model },
1244
1379
  ...facts.cliVersion === undefined ? {} : { cliVersion: facts.cliVersion },
@@ -1261,7 +1396,11 @@ export class CodexLiveDriver {
1261
1396
  // hostage to the slow windows — a cancel returns the run immediately.
1262
1397
  await Promise.race([accepted, abortBranch.catch(() => undefined)]);
1263
1398
  return subprocessRunHandle({
1264
- id: childSession.id,
1399
+ // Host 0.2.1: a fresh PER-EXECUTION run id — the activation manager
1400
+ // rejects a run id naming a live Session (this child session is live),
1401
+ // and resume rounds reuse the child session id. The family's durable
1402
+ // tracking key (the child session id) rides the staged intent instead.
1403
+ id: SessionId(randomUUID()),
1265
1404
  result,
1266
1405
  signal: request.signal,
1267
1406
  onAbort,
@@ -13,6 +13,7 @@
13
13
  import type { Context } from '@deepseek-ai/cordis';
14
14
  import type { SettingsFace } from '@khorsheed/dsh-local-agent';
15
15
  import type { Config } from './index.ts';
16
+ import type { CodexEndpointAuth } from './endpoint-auth.ts';
16
17
  import { CodexLiveDriver } from './live-driver.ts';
17
18
  import type { CodexLiveMirrorGranularity } from './live-driver.ts';
18
19
  /** The resolved live settings (schema defaults ← YAML base ← user layer). */
@@ -41,6 +42,12 @@ export declare class LiveDriverSwitch {
41
42
  * outrank the settings value.
42
43
  */
43
44
  model?: (childSessionId: string) => string | undefined;
45
+ /**
46
+ * Per-spawn endpoint credential resolver, handed to every driver
47
+ * generation: a resolution carrying a key routes the app-server at the
48
+ * relay (see the driver's own contract).
49
+ */
50
+ endpoint?: (scopeHome: string) => Promise<CodexEndpointAuth>;
44
51
  });
45
52
  /**
46
53
  * Resolve the live driver for one round's member (the provider's call
@@ -69,6 +69,7 @@ export class LiveDriverSwitch {
69
69
  ...this.options.sandbox === undefined ? {} : { sandbox: this.options.sandbox },
70
70
  ...this.options.liveIdleMs === undefined ? {} : { liveIdleMs: this.options.liveIdleMs },
71
71
  ...this.options.model === undefined ? {} : { model: this.options.model },
72
+ ...this.options.endpoint === undefined ? {} : { endpoint: this.options.endpoint },
72
73
  })
73
74
  : undefined;
74
75
  if (retiring !== undefined) {
@@ -31,8 +31,18 @@ import type { LocalAgentModelBroker, LocalAgentModelInfo, LocalAgentModelDirecto
31
31
  export interface CodexModelBrokerDeps {
32
32
  /** Host context carrying the family registry (in-flight reads, delegation records). */
33
33
  readonly ctx: Context;
34
- /** The plugin-config `model` resolver — the settings layer, blank reads unset. */
35
- readonly settingsModel: () => string | undefined;
34
+ /**
35
+ * The settings-layer model for one scoped home: the curated model list's
36
+ * FIRST entry (models.json, edited on the card), then the plugin-config
37
+ * `model` key — blank reads unset.
38
+ */
39
+ readonly settingsModel: (homeDir: string) => string | undefined;
40
+ /**
41
+ * The scope's curated model list (models.json) for the choice vocabulary:
42
+ * non-empty it is the COMPLETE vocabulary (nothing outside the endpoint's
43
+ * served catalog sneaks in); empty falls back to the pre-list chain.
44
+ */
45
+ readonly modelList?: (homeDir: string) => readonly string[];
36
46
  /** The card's recent-model memory (the suggestion vocabulary's tail). */
37
47
  readonly recentModels: () => readonly string[];
38
48
  /**