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

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.
@@ -27,7 +27,7 @@ import { createUserMessage } from '@deepseek-ai/dsh-llm';
27
27
  import { SessionId } from '@deepseek-ai/dsh-session';
28
28
  import { settleRunResult, subprocessRunHandle, } from '@deepseek-ai/dsh-subagent';
29
29
  import { delegationEnv, persistChildSession, LiveStreamPublisher, LiveFlush, LIVE_FLUSH_INTERVAL_MS } from '@khorsheed/dsh-local-agent';
30
- 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';
31
31
  import { codexRolloutRoundFacts } from './records.js';
32
32
  import { endpointAuthEnv, endpointConfigOverrides } from './endpoint-auth.js';
33
33
  /** Default idle lifetime of an unused resident runtime before reclaim. */
@@ -46,11 +46,6 @@ const RECLAIM_EOF_GRACE_MS = 1_000;
46
46
  export const DEFAULT_SNAPSHOT_MIN_INTERVAL_MS = LIVE_FLUSH_INTERVAL_MS;
47
47
  /** Default minimum text growth between one streaming item's snapshot messages. */
48
48
  export const DEFAULT_SNAPSHOT_MIN_CHARS = 0;
49
- /**
50
- * The app-server channel could not come up (spawn failure or handshake
51
- * timeout/mismatch). The provider catches exactly this and falls back to the
52
- * exec one-shot; any other error is a real round failure.
53
- */
54
49
  export class LiveChannelUnavailableError extends Error {
55
50
  constructor(message) {
56
51
  super(message);
@@ -139,16 +134,27 @@ export function codexAppServerItemToLine(item) {
139
134
  };
140
135
  }
141
136
  case 'dynamicToolCall': {
142
- // 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.
143
140
  const tool = typeof item['tool'] === 'string' ? item.tool : 'dynamic';
144
141
  const args = item['arguments'] === undefined || item['arguments'] === null
145
142
  ? undefined
146
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;
147
152
  return {
148
153
  kind: 'tool',
149
154
  id: itemId ?? `codex-live-${randomUUID()}`,
150
155
  name: tool,
151
156
  ...args === undefined || args === '' ? {} : { args },
157
+ ...result === undefined || result === '' ? {} : { result },
152
158
  };
153
159
  }
154
160
  case 'collabAgentToolCall': {
@@ -176,15 +182,22 @@ export function codexAppServerItemToLine(item) {
176
182
  function usageFromAppServer(breakdown) {
177
183
  const input = Number(breakdown['inputTokens']);
178
184
  const cached = Number(breakdown['cachedInputTokens']);
185
+ const cacheWrite = Number(breakdown['cacheWriteInputTokens']);
179
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.
180
189
  const usage = {
181
190
  inputTokens: Number.isFinite(input) && Number.isFinite(cached)
182
- ? Math.max(0, input - cached)
191
+ ? Math.max(0, input - cached - (Number.isFinite(cacheWrite) ? cacheWrite : 0))
183
192
  : Number.isFinite(input) ? input : 0,
184
193
  outputTokens: Number.isFinite(output) ? output : 0,
185
194
  };
186
- 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))
187
198
  usage.cacheReadTokens = cached;
199
+ if (Number.isFinite(cacheWrite) && cacheWrite > 0)
200
+ usage.cacheWriteTokens = cacheWrite;
188
201
  return usage;
189
202
  }
190
203
  /**
@@ -782,27 +795,31 @@ export class CodexLiveDriver {
782
795
  const lines = [];
783
796
  let mirrored = 0;
784
797
  let usage;
785
- /** Whether a folded line already carries the round's usage (no backfill event exists). */
786
- let usageCarried = false;
798
+ /** The cumulative turn total the wire's `tokenUsage.total` names, for the settled report. */
799
+ let totalUsage;
787
800
  /**
788
- * The step ledger: `reservedSteps` holds every step a stream reservation
789
- * consumed (in increasing order — permanently, whether the stream later
790
- * completes or stays an orphan snapshot), and a sequential line folds at
791
- * its index shifted past every reservation before it. A tool completed
792
- * mid-stream therefore folds ABOVE the stream's step and the projection
793
- * 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.
794
805
  */
795
- 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;
796
809
  /**
797
- * The streaming items seen this round by item id:
798
- * deltas accumulate into throttled snapshot assistant/messages appended
799
- * at the item's reserved (turn, step) — the host folds repeated settles
800
- * at one coordinate into one live-updating chat node, which is the only
801
- * streaming channel left after 0.1.5 retired the durable per-chunk event.
802
- * The item's completion folds at the same step and finalizes it; an entry
803
- * 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.
804
817
  */
805
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();
806
823
  /** The item deltas currently accumulate into (items stream sequentially). */
807
824
  let activeStream;
808
825
  /** Items/completions that arrived before the turn id was known. */
@@ -850,6 +867,29 @@ export class CodexLiveDriver {
850
867
  const streamPublisher = localAgent?.liveStreams === undefined ? undefined
851
868
  : new LiveStreamPublisher(localAgent.liveStreams, childSession, turn, persist, error => this.ctx.logger.warn(`live checkpoint failed: ${String(error)}`));
852
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;
853
893
  const pendingStreamArrival = new Map();
854
894
  const appendStreamSnapshot = (stream, force, interrupted, withUsage = false, final = false) => {
855
895
  if (stream.text.trim() === '')
@@ -864,31 +904,39 @@ export class CodexLiveDriver {
864
904
  liveFlush.cancel(stream.step);
865
905
  const receivedAt = pendingStreamArrival.get(stream.step) ?? now;
866
906
  pendingStreamArrival.delete(stream.step);
867
- if (streamPublisher !== undefined && !final) {
868
- if (!stream.opened) {
869
- childSession.append('step/start', { turn, step: stream.step });
870
- 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;
871
914
  }
872
- 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 });
873
923
  stream.lastSnapshotAt = now;
874
924
  stream.lastSnapshotLen = stream.text.length;
875
925
  return;
876
926
  }
877
- if (!stream.opened) {
878
- childSession.append('step/start', { turn, step: stream.step });
879
- stream.opened = true;
880
- }
881
- 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;
882
930
  childSession.append('assistant/message', {
883
931
  turn,
884
- step: stream.step,
932
+ step: stream.durable,
885
933
  message: codexAssistantEvent([
886
934
  stream.kind === 'think'
887
935
  ? { type: 'reasoning', text: stream.text }
888
936
  : { type: 'text', text: stream.text },
889
937
  ]),
890
938
  stream: [],
891
- ...withUsage && !usageCarried && usage !== undefined ? { usage } : {},
939
+ ...withUsage && !usageCarried && usageByLine.size === 0 && usage !== undefined ? { usage } : {},
892
940
  ...interrupted ? { interrupted: true } : {},
893
941
  }, { surfaceOp: 'append' });
894
942
  if (attachUsage)
@@ -897,11 +945,21 @@ export class CodexLiveDriver {
897
945
  stream.lastSnapshotLen = stream.text.length;
898
946
  persist();
899
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
+ };
900
958
  /**
901
- * Reserve the step one new streaming item will occupy — past every
902
- * completed line (folded or held back) and every earlier reservation. A
903
- * different item's deltas force one final snapshot of the current stream
904
- * (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.
905
963
  */
906
964
  const reserveStream = (itemId, kind) => {
907
965
  if (activeStream !== undefined && activeStream !== itemId) {
@@ -915,75 +973,96 @@ export class CodexLiveDriver {
915
973
  }
916
974
  let stream = streams.get(itemId);
917
975
  if (stream === undefined) {
918
- const step = lines.length + reservedSteps.length + 1;
919
- reservedSteps.push(step);
920
- 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 };
921
978
  streams.set(itemId, stream);
922
979
  }
923
980
  activeStream = itemId;
924
981
  };
925
- /** The sequential fold step for `lines[index]`: its position shifted past every reservation before it. */
926
- const foldStep = (index) => {
927
- let step = index + 1;
928
- for (const reserved of reservedSteps) {
929
- if (reserved <= step)
930
- step += 1;
931
- else
932
- 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);
933
1012
  }
934
- return step;
1013
+ folded.add(index);
1014
+ localAgent?.reportRunProgress(childSession.id, { kind: 'delta', text: codexLineText(line) });
935
1015
  };
936
- /** 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
+ */
937
1022
  const mirrorUpTo = (upto, withUsage) => {
938
- // The usage rides the last NON-tool line (tool events carry no usage
939
- // slot, and a kill mid-command ends the transcript with a tool line).
940
- // A carrier mirrored in an earlier flush (before the usage was knowable)
941
- // 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.
942
1030
  let usageIndex = -1;
943
- if (withUsage) {
1031
+ if (withUsage && usageByLine.size === 0) {
944
1032
  for (let index = 0; index < lines.length; index += 1) {
945
1033
  if (lines[index]?.kind !== 'tool')
946
1034
  usageIndex = index;
947
1035
  }
948
1036
  }
949
- for (let index = mirrored; index < upto; index += 1) {
950
- const line = lines[index];
951
- if (line === undefined)
952
- continue;
1037
+ const usageFor = (index) => {
1038
+ const own = usageByLine.get(index);
1039
+ if (own !== undefined) {
1040
+ usageCarried = true;
1041
+ return own;
1042
+ }
953
1043
  const lineUsage = withUsage && index === usageIndex ? usage : undefined;
954
1044
  if (lineUsage !== undefined)
955
1045
  usageCarried = true;
956
- const itemId = line.kind === 'tool' ? undefined : line.itemId;
957
- const stream = itemId === undefined ? undefined : streams.get(itemId);
958
- if (itemId !== undefined && stream !== undefined) {
959
- // A streamed item folds at its reserved step, finalizing the
960
- // snapshots: the step opens only if no snapshot ever landed (a
961
- // fast item that stayed under the throttle), and closes here.
962
- liveFlush.cancel(stream.step);
963
- streams.delete(itemId);
964
- if (activeStream === itemId)
965
- activeStream = undefined;
966
- if (!stream.opened)
967
- childSession.append('step/start', { turn, step: stream.step });
968
- const blocks = stream.kind === 'think'
969
- ? [{ type: 'reasoning', text: line.kind === 'tool' ? '' : line.text }]
970
- : [{ type: 'text', text: line.kind === 'tool' ? '' : line.text }];
971
- childSession.append('assistant/message', {
972
- turn,
973
- step: stream.step,
974
- message: codexAssistantEvent(blocks),
975
- stream: [],
976
- ...lineUsage === undefined ? {} : { usage: lineUsage },
977
- }, { surfaceOp: 'append' });
978
- streamPublisher?.finish(stream.step);
979
- childSession.append('step/end', { turn, step: stream.step });
980
- }
981
- else {
982
- 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));
983
1061
  }
984
- mirrored = index + 1;
985
- localAgent?.reportRunProgress(childSession.id, { kind: 'delta', text: codexLineText(line) });
1062
+ foldLine(index, usageFor(index));
986
1063
  }
1064
+ while (folded.has(mirrored))
1065
+ mirrored += 1;
987
1066
  if (upto > 0)
988
1067
  persist();
989
1068
  };
@@ -993,6 +1072,17 @@ export class CodexLiveDriver {
993
1072
  // The boundary opens after the accept ack (a definitive reject leaves no
994
1073
  // dangling turn/start); the prompt and any early items flush inside it.
995
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
+ }
996
1086
  childSession.append('user/message', createUserMessage({
997
1087
  content: [{ type: 'text', text: task }],
998
1088
  source: { kind: 'user' },
@@ -1020,8 +1110,9 @@ export class CodexLiveDriver {
1020
1110
  if (line === undefined)
1021
1111
  return;
1022
1112
  lines.push(line);
1023
- // Hold back the volatile last line until turn/completed (it is the
1024
- // 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.
1025
1116
  mirrorUpTo(lines.length - 1, false);
1026
1117
  return;
1027
1118
  }
@@ -1050,19 +1141,41 @@ export class CodexLiveDriver {
1050
1141
  if (method === 'thread/tokenUsage/updated') {
1051
1142
  const tokenUsage = params['tokenUsage'];
1052
1143
  const last = tokenUsage?.['last'];
1053
- 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) {
1054
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
+ }
1055
1162
  return;
1056
1163
  }
1057
1164
  if (method === 'turn/completed') {
1058
1165
  const turnData = params['turn'];
1059
- mirrorUpTo(lines.length, true);
1060
- resolveCompletion({
1061
- status: String(turnData['status']),
1062
- ...(turnData['error'] === null || turnData['error'] === undefined
1063
- ? {}
1064
- : { error: turnData['error'] }),
1065
- });
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
+ }
1066
1179
  }
1067
1180
  };
1068
1181
  /** Route one wire notification: round-scoped, turn-id tagged, early ones buffered. */
@@ -1184,6 +1297,15 @@ export class CodexLiveDriver {
1184
1297
  // flush it now (the exec settle-mirror's partial-work contract).
1185
1298
  if (mirrored < lines.length)
1186
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
+ }
1187
1309
  // Streams whose item/completed never arrived (abort, or the server
1188
1310
  // ended the turn mid-item) still finalize INSIDE the turn window:
1189
1311
  // one forced snapshot each — interrupted on a non-completed round,
@@ -1193,9 +1315,8 @@ export class CodexLiveDriver {
1193
1315
  const remaining = [...streams.values()];
1194
1316
  for (const [position, stream] of remaining.entries()) {
1195
1317
  appendStreamSnapshot(stream, true, settled.stopReason !== 'completed', position === remaining.length - 1, true);
1196
- streamPublisher?.finish(stream.step);
1197
- if (stream.opened)
1198
- childSession.append('step/end', { turn, step: stream.step });
1318
+ finishStream(stream);
1319
+ closeOpenStep();
1199
1320
  }
1200
1321
  streams.clear();
1201
1322
  activeStream = undefined;
@@ -1248,7 +1369,11 @@ export class CodexLiveDriver {
1248
1369
  windowStart: startedAtMs,
1249
1370
  cwd: spec.cwd,
1250
1371
  });
1251
- 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;
1252
1377
  const round = {
1253
1378
  ...facts.model === undefined ? {} : { observedModel: facts.model },
1254
1379
  ...facts.cliVersion === undefined ? {} : { cliVersion: facts.cliVersion },
@@ -39,6 +39,42 @@ function dedupeChoices(layers) {
39
39
  }
40
40
  return choices;
41
41
  }
42
+ /**
43
+ * Scope the served directory to the surface asking for it (the same helper
44
+ * lives in each provider's broker — fold into the family core when the
45
+ * directory faces next evolve):
46
+ *
47
+ * - memberless (the card's add-model dialog, the invite picker): the
48
+ * endpoint's served catalog only — the configuration/history extras the
49
+ * broker's info extension adds for STATUS display must never become add
50
+ * candidates;
51
+ * - a member whose harness has a curated list: the list IS the vocabulary,
52
+ * in the list's own order (the same rule `choices` already follows) — a
53
+ * curated value the endpoint no longer serves stays as a bare row (the
54
+ * card shows it the same way), and the member's current effective model is
55
+ * kept visible as a bare entry when it sits outside the list, so a pre-list
56
+ * binding never renders blank;
57
+ * - a member without a curated list: the legacy extended vocabulary.
58
+ */
59
+ function scopeServedDirectory(directory, opts) {
60
+ if (opts.member && opts.curated.length === 0)
61
+ return directory;
62
+ const served = directory.entries.filter(entry => entry.source === 'native');
63
+ if (!opts.member)
64
+ return { ...directory, entries: served };
65
+ const byValue = new Map(served.flatMap(entry => entry.resolvedModel === undefined
66
+ ? [[entry.value, entry]]
67
+ : [[entry.value, entry], [entry.resolvedModel, entry]]));
68
+ // Curated ORDER leads (the card's first row is the default); a curated value
69
+ // the endpoint no longer serves stays listed as a bare row, exactly like the
70
+ // card shows it.
71
+ const entries = opts.curated.map(value => byValue.get(value) ?? { value, label: value, source: 'configuration' });
72
+ const current = opts.current?.trim();
73
+ if (current !== undefined && current !== '' && !entries.some(entry => entry.value === current || entry.resolvedModel === current)) {
74
+ entries.push({ value: current, label: current, source: 'configuration' });
75
+ }
76
+ return { ...directory, entries };
77
+ }
42
78
  export class CodexModelBroker {
43
79
  deps;
44
80
  constructor(deps) {
@@ -88,7 +124,12 @@ export class CodexModelBroker {
88
124
  if (refresh)
89
125
  await this.deps.refreshDirectory?.(home, this.deps.cwd?.(childSessionId));
90
126
  const info = await this.modelInfo(childSessionId, childSessionId === undefined ? undefined : this.deps.ctx.localAgent.getDelegation(childSessionId)?.model);
91
- return info.directory ?? { entries: [], complete: false, customInput: true, status: 'unsupported', refreshing: false, revision: 0 };
127
+ const directory = info.directory ?? { entries: [], complete: false, customInput: true, status: 'unsupported', refreshing: false, revision: 0 };
128
+ return scopeServedDirectory(directory, {
129
+ member: childSessionId !== undefined,
130
+ curated: this.deps.modelList?.(home) ?? [],
131
+ current: info.effective,
132
+ });
92
133
  }
93
134
  async *followModelDirectory(childSessionId, signal) {
94
135
  if (this.deps.followDirectory === undefined) {
@@ -35,10 +35,12 @@ export declare function listCodexSessions(homeDir: string): Promise<readonly Loc
35
35
  * `input_tokens` is the TOTAL input including cache hits (OpenAI-style
36
36
  * accounting, confirmed against `total_tokens` in the rollout token_count),
37
37
  * and `cached_input_tokens` is the cache-read subset — so the uncached
38
- * bucket subtracts the cached portion to avoid double counting. There is no
39
- * cache-write concept, so that bucket is omitted (the shared four-bucket
40
- * contract: uncached input, output, cache read, cache write — codex fills
41
- * the first three).
38
+ * bucket subtracts the cached portion to avoid double counting.
39
+ * `cached_input_tokens` rides every payload (zero included), so
40
+ * `cacheReadTokens` is written whenever the field is present — a reader can
41
+ * tell "no hits" from "not reported". Codex also reports cache creation
42
+ * (`cache_write_input_tokens`), mapped to `cacheWriteTokens` (the shared
43
+ * four-bucket contract: uncached input, output, cache read, cache write).
42
44
  * @param usage - the raw codex usage object (`turn.completed`'s `usage` or a
43
45
  * rollout `token_count` `info.last_token_usage`/`total_token_usage`).
44
46
  * @returns the shared usage record.
@@ -149,10 +149,12 @@ export async function listCodexSessions(homeDir) {
149
149
  * `input_tokens` is the TOTAL input including cache hits (OpenAI-style
150
150
  * accounting, confirmed against `total_tokens` in the rollout token_count),
151
151
  * and `cached_input_tokens` is the cache-read subset — so the uncached
152
- * bucket subtracts the cached portion to avoid double counting. There is no
153
- * cache-write concept, so that bucket is omitted (the shared four-bucket
154
- * contract: uncached input, output, cache read, cache write — codex fills
155
- * the first three).
152
+ * bucket subtracts the cached portion to avoid double counting.
153
+ * `cached_input_tokens` rides every payload (zero included), so
154
+ * `cacheReadTokens` is written whenever the field is present — a reader can
155
+ * tell "no hits" from "not reported". Codex also reports cache creation
156
+ * (`cache_write_input_tokens`), mapped to `cacheWriteTokens` (the shared
157
+ * four-bucket contract: uncached input, output, cache read, cache write).
156
158
  * @param usage - the raw codex usage object (`turn.completed`'s `usage` or a
157
159
  * rollout `token_count` `info.last_token_usage`/`total_token_usage`).
158
160
  * @returns the shared usage record.
@@ -161,9 +163,12 @@ export function usageFromCodex(usage) {
161
163
  const raw = usage;
162
164
  const input = Number(raw.input_tokens);
163
165
  const cached = Number(raw.cached_input_tokens);
166
+ const cacheWrite = Number(raw.cache_write_input_tokens);
164
167
  const output = Number(raw.output_tokens);
168
+ // `input_tokens` already contains both cache buckets (total = input +
169
+ // output); the shared contract keeps the three input buckets disjoint.
165
170
  const uncached = Number.isFinite(input) && Number.isFinite(cached)
166
- ? Math.max(0, input - cached)
171
+ ? Math.max(0, input - cached - (Number.isFinite(cacheWrite) ? cacheWrite : 0))
167
172
  : Number.isFinite(input)
168
173
  ? input
169
174
  : 0;
@@ -171,8 +176,10 @@ export function usageFromCodex(usage) {
171
176
  inputTokens: uncached,
172
177
  outputTokens: Number.isFinite(output) ? output : 0,
173
178
  };
174
- if (Number.isFinite(cached) && cached > 0)
179
+ if (Number.isFinite(cached))
175
180
  usageRecord.cacheReadTokens = cached;
181
+ if (Number.isFinite(cacheWrite) && cacheWrite > 0)
182
+ usageRecord.cacheWriteTokens = cacheWrite;
176
183
  return usageRecord;
177
184
  }
178
185
  /**
package/package.json CHANGED
@@ -8,7 +8,7 @@
8
8
  "local-agent",
9
9
  "codex"
10
10
  ],
11
- "version": "0.2.0-rc.3",
11
+ "version": "0.2.1",
12
12
  "type": "module",
13
13
  "main": "lib/index.js",
14
14
  "types": "lib/types/index.d.ts",
@@ -69,7 +69,7 @@
69
69
  "dsh": ">=0.1.5-rc.1 <0.3.0-0"
70
70
  },
71
71
  "dependencies": {
72
- "@khorsheed/dsh-local-agent": "^0.2.0-rc.3"
72
+ "@khorsheed/dsh-local-agent": "^0.2.1"
73
73
  },
74
74
  "peerDependencies": {
75
75
  "@deepseek-ai/cordis": "^4.0.1",