@khorsheed/dsh-local-agent-kimi 0.2.0-rc.3 → 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.
@@ -16,11 +16,14 @@
16
16
  * throttled `mirrorKimiDelta` passes and the settle pass stays authoritative
17
17
  * (one fold, one offset, zero divergence between the two drivers). Every
18
18
  * transcript item folds 1:1 into the child session log.
19
- * Live output also mirrors the in-flight item: chunk
20
- * deltas accumulate per streaming item and append throttled snapshot
21
- * assistant/messages at the item's reserved (turn, step) — the host folds
22
- * repeated settles at one coordinate into one live-updating chat node — and
23
- * the wire line completing the item folds at the same step, finalizing it.
19
+ * Live output rides the `local-agent/stream` channel: chunk deltas
20
+ * accumulate per streaming item and publish throttled updates at the item's
21
+ * reserved (turn, step) — the member live node renders them as-is — while
22
+ * the item's durable content lands as ONE atomic step/start→…→step/end
23
+ * triple at that step, written by the completing wire line's fold or at
24
+ * settle. Durable steps stay dense and strictly sequential (the released-log
25
+ * reader validates exactly that on every cold open); snapshots never hold a
26
+ * step open across appends.
24
27
  *
25
28
  * Lifecycle mirrors the dsh/codex live drivers' discipline: lazy spawn with an
26
29
  * initialize handshake (loadSession capability required — crash recovery is
@@ -37,7 +40,7 @@ import { configureKimiSession } from './session-configuration.js';
37
40
  import { kimiNativeConfiguration } from './model-catalog.js';
38
41
  import { StringDecoder } from 'node:string_decoder';
39
42
  import { randomUUID } from 'node:crypto';
40
- import { createUserMessage } from '@deepseek-ai/dsh-llm';
43
+ import { createToolResultMessage, createUserMessage } from '@deepseek-ai/dsh-llm';
41
44
  import { SessionId } from '@deepseek-ai/dsh-session';
42
45
  import { settleRunResult, subprocessRunHandle, } from '@deepseek-ai/dsh-subagent';
43
46
  import { delegationEnv, persistChildSession, LiveStreamPublisher, LiveFlush, LIVE_FLUSH_INTERVAL_MS } from '@khorsheed/dsh-local-agent';
@@ -45,7 +48,7 @@ import { endpointAuthEnv, hasEndpointCredential } from './endpoint-auth.js';
45
48
  import { ensureKimiEndpointProvider, KIMI_MANAGED_BASE_URL } from './provision.js';
46
49
  import { MEMBER_BRIDGE_SOCKET_ENV, MEMBER_BRIDGE_TOKEN_ENV } from '@khorsheed/dsh-local-agent/types';
47
50
  import { DEFAULT_DISPOSE_GRACE_MS, mirrorKimiDelta, textTask, } from './kimi-cli-provider.js';
48
- import { assistantEvent, nextKimiSessionStep } from './session-mirror.js';
51
+ import { assistantEvent, KIMI_INTERRUPTED_TOOL_RESULT, nextKimiSessionStep, nextWrittenKimiStep } from './session-mirror.js';
49
52
  import { addTokenUsage } from './session-view.js';
50
53
  import { writeKimiDefaultModel } from './provision.js';
51
54
  /** Default idle lifetime of an unused resident runtime before reclaim. */
@@ -793,25 +796,17 @@ export class KimiAcpLiveDriver {
793
796
  /** The round's accumulated assistant text (the run output — chunks are the only source). */
794
797
  let roundText = '';
795
798
  /**
796
- * The step ledger: every step a stream reservation consumed this round
797
- * (in increasing order — permanently, whether the stream later completes
798
- * or stays an orphan snapshot). The file fold skips these steps for its
799
- * sequential folds, so a tool card folded mid-stream lands ABOVE the
800
- * stream's step and the projection renders the round's chronological
801
- * order.
799
+ * The step ledger: the steps in-flight reservations currently hold this
800
+ * round. A reservation's durable content lands at the fold that consumes
801
+ * it (possibly carrying several reservations' kinds in one message — the
802
+ * wire step's fold), which RELEASES the other consumed coordinates for
803
+ * later folds; only a reservation kimi never completes (a plan stream, an
804
+ * unwired tool notification) stays held until its settle-time finalize.
805
+ * The file fold skips held steps for its sequential folds, so a tool card
806
+ * folded mid-stream lands ABOVE the stream's step and the projection
807
+ * renders the round's chronological order.
802
808
  */
803
809
  const reservedSteps = [];
804
- /**
805
- * The streaming items seen this round . kimi's ACP
806
- * chunks carry no item id, so the stream key is synthetic per kind per
807
- * round (the codex live driver's fallback shape): thought chunks share
808
- * one stream, message chunks another. Deltas accumulate into throttled
809
- * snapshot assistant/messages appended at the stream's reserved
810
- * (turn, step) — the host folds repeated settles at one coordinate into
811
- * one live-updating chat node. The wire line that completes the stream's
812
- * kind folds at the same step and finalizes it; an entry whose completion
813
- * never folds is force-finalized at settle.
814
- */
815
810
  const streams = new Map();
816
811
  /** The stream the latest delta accumulated into (a kind switch force-snapshots it). */
817
812
  let activeStream;
@@ -839,7 +834,23 @@ export class KimiAcpLiveDriver {
839
834
  };
840
835
  /** A stream's synthetic key: kimi's ACP deltas carry no item id, so kind + turn pairs them. */
841
836
  let generation = 0;
837
+ /**
838
+ * One step reservation per generation, shared by the think and text
839
+ * streams of the same response segment: the host's own live shape is
840
+ * reasoning + text on ONE step, and a per-kind step would leave the
841
+ * consumed reservation's stream frames pointing at a step that never
842
+ * materializes (an empty trailing step breaks the host's answer
843
+ * detection, folding the reply into the collapsed process block).
844
+ */
845
+ const generationSteps = new Map();
842
846
  const seenToolCalls = new Set();
847
+ /**
848
+ * ACP tool_call notifications' chronological reservations (call id →
849
+ * step, plus the notification's display title). No durable event lands at
850
+ * notification time: the native lifecycle (advertisement, call, result in
851
+ * one closed step) is written by the wire line's fold, or at settle with
852
+ * an interrupted placeholder when kimi never logged the call.
853
+ */
843
854
  const toolSteps = new Map();
844
855
  const streamKey = (kind) => `kimi-stream-${kind}-${turn}-${generation}`;
845
856
  /** The plan stream's key: a snapshot stream beside the think/text delta streams. */
@@ -857,7 +868,7 @@ export class KimiAcpLiveDriver {
857
868
  const stream = streams.get(key);
858
869
  if (stream !== undefined) {
859
870
  stream.text += text;
860
- appendStreamSnapshot(stream, false, false);
871
+ publishStreamSnapshot(stream, false);
861
872
  }
862
873
  localAgent?.reportRunProgress(childSession.id, { kind: 'delta', text });
863
874
  };
@@ -881,133 +892,304 @@ export class KimiAcpLiveDriver {
881
892
  if (plan === '')
882
893
  return;
883
894
  // The plan's own snapshot stream (think-styled): every update replaces
884
- // the text and lands a snapshot at the reserved (turn, step); the
885
- // stream force-finalizes at settle (the wire carries no plan line
886
- // that could complete it).
895
+ // the text and publishes at the reserved (turn, step); the stream
896
+ // finalizes at settle (the wire carries no plan line that could
897
+ // complete it) — or early, when a fold needs its step closed.
887
898
  reserveStream(planKey, 'think');
888
899
  const stream = streams.get(planKey);
889
900
  if (stream !== undefined && stream.text !== plan) {
890
901
  stream.text = plan;
891
- appendStreamSnapshot(stream, true, false);
902
+ publishStreamSnapshot(stream, true);
892
903
  }
893
904
  };
894
905
  /**
895
- * Append one snapshot of a streaming item at its reserved (turn, step).
896
- * Batched per stream on a bounded deadline unless `force`; the forced
897
- * final snapshot carries `interrupted` (a cancelled turn reads 已停止
898
- * legitimately) and, when `withUsage`, the round's uncarried usage. An
899
- * empty stream leaves no boundary at all.
906
+ * The live-output channel. Snapshots are browser-only previews (never
907
+ * `local-agent/stream` log events) — durable step content lands once per
908
+ * stream, at finalization, as the fold's atomic triple.
900
909
  */
901
910
  const streamPublisher = localAgent?.liveStreams === undefined ? undefined
902
911
  : new LiveStreamPublisher(localAgent.liveStreams, childSession, turn, persist, error => this.ctx.logger.warn(`live checkpoint failed: ${String(error)}`));
903
912
  const liveFlush = new LiveFlush(error => this.ctx.logger.warn(`live mirror flush failed: ${String(error)}`), this.config.snapshotMinIntervalMs ?? DEFAULT_SNAPSHOT_MIN_INTERVAL_MS);
904
913
  const pendingStreamArrival = new Map();
905
- const appendStreamSnapshot = (stream, force, interrupted, withUsage = false, final = false) => {
906
- if (stream.text.trim() === '')
914
+ /**
915
+ * Publish one snapshot of a streaming item at its reserved (turn, step)
916
+ * through the live channel. Batched per stream on a bounded deadline
917
+ * unless `force`. Never writes durable content.
918
+ */
919
+ const publishStreamSnapshot = (stream, force) => {
920
+ if (stream.text.trim() === '' || stream.sealed === true)
907
921
  return;
908
922
  const now = Date.now();
909
923
  if (!force) {
910
924
  if (!pendingStreamArrival.has(stream.step))
911
925
  pendingStreamArrival.set(stream.step, now);
912
- liveFlush.schedule(stream.step, () => appendStreamSnapshot(stream, true, interrupted, withUsage));
926
+ liveFlush.schedule(stream.step, () => publishStreamSnapshot(stream, true));
913
927
  return;
914
928
  }
915
929
  liveFlush.cancel(stream.step);
916
930
  const receivedAt = pendingStreamArrival.get(stream.step) ?? now;
917
931
  pendingStreamArrival.delete(stream.step);
918
- if (streamPublisher !== undefined && !final) {
919
- if (!stream.opened) {
920
- childSession.append('step/start', { turn, step: stream.step });
921
- stream.opened = true;
932
+ // The kind rides the item id so two kinds sharing one step never collide.
933
+ // Browser-only (preview): the step is reserved, not yet open in the log,
934
+ // and a durable checkpoint there would land before its step/start — a
935
+ // replayed log then orders that step first and loses the turn's final
936
+ // answer under the fold. The durable content is the fold's atomic triple.
937
+ streamPublisher?.preview(stream.step, stream.kind, stream.text, { receivedAt, itemId: stream.kind });
938
+ stream.lastSnapshotLen = stream.text.length;
939
+ };
940
+ /**
941
+ * Write the stream's durable content as ONE atomic
942
+ * step/start→assistant/message→step/end triple at its reserved step and
943
+ * seal the reservation (its completing wire line, when one exists, pairs
944
+ * and drops). `interrupted` marks a non-completed round's partial (a
945
+ * cancelled turn reads 已停止 legitimately); `withUsage` attaches the
946
+ * round's uncarried usage.
947
+ */
948
+ /**
949
+ * Write the durable content of every stream sharing one step as ONE atomic
950
+ * step/start→assistant/message→step/end triple (a generation's think and
951
+ * text merge into a single message — the host's own shape) and seal the
952
+ * reservations. `interrupted` marks a non-completed round's partial (a
953
+ * cancelled turn reads 已停止 legitimately); `withUsage` attaches the
954
+ * round's uncarried usage.
955
+ */
956
+ const finalizeStep = (step, entries, interrupted, withUsage) => {
957
+ liveFlush.cancel(step);
958
+ pendingStreamArrival.delete(step);
959
+ const reasoning = entries.filter(([, stream]) => stream.kind === 'think').map(([, stream]) => stream.text).join('');
960
+ const text = entries.filter(([, stream]) => stream.kind === 'text').map(([, stream]) => stream.text).join('');
961
+ const blocks = [];
962
+ if (reasoning.trim() !== '')
963
+ blocks.push({ type: 'reasoning', text: reasoning });
964
+ if (text.trim() !== '')
965
+ blocks.push({ type: 'text', text });
966
+ if (blocks.length === 0) {
967
+ // Nothing durable to write: release the coordinate instead of pinning
968
+ // it forever — a later fold landing above would otherwise strand a
969
+ // permanent step gap the released-log reader rejects as corruption.
970
+ streamPublisher?.finish(step);
971
+ const keys = new Set(entries.map(([key]) => key));
972
+ const held = new Set([...streams.entries()]
973
+ .filter(([key, stream]) => !keys.has(key) && stream.sealed !== true)
974
+ .map(([, stream]) => stream.step));
975
+ if (!held.has(step)) {
976
+ const index = reservedSteps.indexOf(step);
977
+ if (index >= 0)
978
+ reservedSteps.splice(index, 1);
979
+ }
980
+ for (const [key, stream] of entries) {
981
+ stream.sealed = true;
982
+ generationSteps.delete(stream.generation);
983
+ if (activeStream === key)
984
+ activeStream = undefined;
922
985
  }
923
- streamPublisher.update(stream.step, stream.kind, stream.text, { receivedAt });
924
- stream.lastSnapshotAt = now;
925
- stream.lastSnapshotLen = stream.text.length;
926
986
  return;
927
987
  }
928
- if (!stream.opened) {
929
- childSession.append('step/start', { turn, step: stream.step });
930
- stream.opened = true;
931
- }
988
+ // The reservation keyed the preview; the log takes the next step in write order.
989
+ const durable = nextWrittenKimiStep(childSession, turn);
990
+ childSession.append('step/start', { turn, step: durable });
932
991
  childSession.append('assistant/message', {
933
992
  turn,
934
- step: stream.step,
935
- message: assistantEvent([
936
- stream.kind === 'think'
937
- ? { type: 'reasoning', text: stream.text }
938
- : { type: 'text', text: stream.text },
939
- ], runtime?.modelConfiguration?.currentModel ?? roundModel),
993
+ step: durable,
994
+ message: assistantEvent(blocks, runtime?.modelConfiguration?.currentModel ?? roundModel),
940
995
  stream: [],
941
996
  ...withUsage && roundUsage !== undefined ? { usage: roundUsage } : {},
942
997
  ...interrupted ? { interrupted: true } : {},
943
998
  }, { surfaceOp: 'append' });
944
999
  if (withUsage)
945
1000
  roundUsage = undefined;
946
- stream.lastSnapshotAt = now;
947
- stream.lastSnapshotLen = stream.text.length;
1001
+ streamPublisher?.finish(step);
1002
+ childSession.append('step/end', { turn, step: durable });
1003
+ for (const [key, stream] of entries) {
1004
+ stream.sealed = true;
1005
+ generationSteps.delete(stream.generation);
1006
+ if (activeStream === key)
1007
+ activeStream = undefined;
1008
+ }
948
1009
  persist();
949
1010
  };
1011
+ /**
1012
+ * Write the durable step for an ACP tool call kimi never logged to the
1013
+ * wire (a crash between the notification and the wire flush): the
1014
+ * notification's title stands in for the name and the result is the
1015
+ * shared interrupted placeholder — the native lifecycle in one step.
1016
+ */
1017
+ /** Whether this turn's log already carries the tool call (folded from the wire). */
1018
+ const toolLogged = (id) => childSession.snapshotEvents().some(event => event.type === 'tool/call' && event.data.turn === turn
1019
+ && event.data.callId === id);
1020
+ const finalizeOrphanTool = (id, entry) => {
1021
+ const name = entry.title ?? 'tool';
1022
+ // The reservation keyed the preview; the log takes the next step in write order.
1023
+ const step = nextWrittenKimiStep(childSession, turn);
1024
+ childSession.append('step/start', { turn, step });
1025
+ childSession.append('assistant/message', {
1026
+ turn,
1027
+ step,
1028
+ message: assistantEvent([{ type: 'tool-call', id: id, name, arguments: '' }], runtime?.modelConfiguration?.currentModel ?? roundModel),
1029
+ stream: [],
1030
+ }, { surfaceOp: 'append' });
1031
+ const call = childSession.append('tool/call', {
1032
+ turn,
1033
+ step,
1034
+ callId: id,
1035
+ name,
1036
+ arguments: '',
1037
+ });
1038
+ childSession.append('tool/result', {
1039
+ turn,
1040
+ step,
1041
+ message: createToolResultMessage({
1042
+ callId: id,
1043
+ content: [{ type: 'text', text: KIMI_INTERRUPTED_TOOL_RESULT }],
1044
+ isError: true,
1045
+ }),
1046
+ }, { surfaceOp: 'append', sourceEventSeqs: [call.seq] });
1047
+ childSession.append('step/end', { turn, step });
1048
+ };
1049
+ /**
1050
+ * The fold's gap fill: a fold about to write at `step` first finalizes
1051
+ * every live stream reserved below it (ascending), so the stored log's
1052
+ * steps land dense and strictly sequential. Only streams can sit in the
1053
+ * gap: an unresulted tool line holds its fold instead (session-mirror).
1054
+ */
1055
+ const finalizeBefore = (targetTurn, step) => {
1056
+ if (targetTurn !== turn)
1057
+ return;
1058
+ const pending = [...streams.entries()]
1059
+ .filter(([, stream]) => stream.sealed !== true && stream.step < step)
1060
+ .sort((a, b) => a[1].step - b[1].step);
1061
+ const byStep = new Map();
1062
+ for (const entry of pending) {
1063
+ const group = byStep.get(entry[1].step) ?? [];
1064
+ group.push(entry);
1065
+ byStep.set(entry[1].step, group);
1066
+ }
1067
+ for (const [at, entries] of byStep)
1068
+ finalizeStep(at, entries, false, false);
1069
+ };
950
1070
  /**
951
1071
  * Reserve the step one streaming item will occupy — past every folded
952
1072
  * line (the session's own event ledger: the file fold writes events
953
- * synchronously) and every earlier reservation. A kind switch forces one
954
- * freshness snapshot of the current stream (skipped when its text has not
955
- * grown since the last one); its completion still folds at its own
956
- * reserved step when the wire line lands.
1073
+ * synchronously) and every earlier reservation. A kind switch publishes
1074
+ * one freshness snapshot of the current stream (skipped when its text has
1075
+ * not grown since the last one); its durable triple still lands at its
1076
+ * own reserved step when the wire line folds or the round settles.
957
1077
  */
958
1078
  const reserveStream = (key, kind) => {
959
1079
  if (activeStream !== undefined && activeStream !== key) {
960
1080
  const previous = streams.get(activeStream);
961
1081
  if (previous !== undefined && previous.text.length !== previous.lastSnapshotLen) {
962
- appendStreamSnapshot(previous, true, false);
1082
+ publishStreamSnapshot(previous, true);
963
1083
  }
964
1084
  activeStream = undefined;
965
1085
  }
966
1086
  let stream = streams.get(key);
1087
+ // A sealed entry's durable triple already landed: later deltas (a plan
1088
+ // rewriting itself after a gap fill, a same-generation think resuming)
1089
+ // open a FRESH reservation instead of growing a closed step.
1090
+ if (stream?.sealed === true) {
1091
+ streams.delete(key);
1092
+ stream = undefined;
1093
+ }
967
1094
  if (stream === undefined) {
968
- let step = nextKimiSessionStep(childSession, turn);
969
- for (const reserved of reservedSteps)
970
- step = Math.max(step, reserved + 1);
971
- reservedSteps.push(step);
972
- stream = { kind, step, text: '', lastSnapshotAt: 0, lastSnapshotLen: 0, opened: false };
1095
+ // The think and text streams of one generation share ONE reserved
1096
+ // step; the plan stream keeps its own.
1097
+ let step = key === planKey ? undefined : generationSteps.get(generation);
1098
+ if (step === undefined) {
1099
+ step = nextKimiSessionStep(childSession, turn);
1100
+ while (reservedSteps.includes(step))
1101
+ step += 1;
1102
+ reservedSteps.push(step);
1103
+ if (key !== planKey)
1104
+ generationSteps.set(generation, step);
1105
+ }
1106
+ stream = { kind, step, generation: key === planKey ? -1 : generation, text: '', lastSnapshotLen: 0 };
973
1107
  streams.set(key, stream);
974
1108
  }
975
1109
  activeStream = key;
976
1110
  };
977
1111
  /**
978
- * The fold-pass coordination surface : the file
979
- * fold skips reserved steps for sequential folds and pairs a completed
980
- * think/assistant line with its stream — scoped to THIS round's turn, so
981
- * an earlier round's leftover lines never consume this round's stream.
1112
+ * The fold-pass coordination surface: the file
1113
+ * fold skips reserved steps for sequential folds, pairs one fold with the
1114
+ * round's in-flight streams of the fold's kinds (consuming both — a wire
1115
+ * step's think and text ride ONE durable step), and closes every
1116
+ * reservation below each fold step first (finalizeBefore) — scoped to
1117
+ * THIS round's turn, so an earlier round's leftover lines never consume
1118
+ * this round's stream.
982
1119
  */
983
1120
  const mirrorStreams = {
984
1121
  reservedSteps: t => t === turn ? reservedSteps : [],
985
- completeTool: (line) => {
1122
+ finalizeBefore,
1123
+ releaseTool: (line) => {
986
1124
  if (line.turn !== turn)
987
- return undefined;
988
- const step = toolSteps.get(line.id);
989
- if (step === undefined)
990
- return undefined;
1125
+ return;
1126
+ const entry = toolSteps.get(line.id);
1127
+ if (entry === undefined)
1128
+ return;
991
1129
  toolSteps.delete(line.id);
992
- return { step, opened: true };
1130
+ // The fold's step carries the whole lifecycle; the coordinate frees.
1131
+ const index = reservedSteps.indexOf(entry.step);
1132
+ if (index >= 0)
1133
+ reservedSteps.splice(index, 1);
993
1134
  },
994
- completeStream: (line) => {
995
- if (line.turn !== turn)
1135
+ completeStreams: (t, kinds) => {
1136
+ if (t !== turn)
996
1137
  return undefined;
997
- const kind = line.kind === 'think' ? 'think' : 'text';
998
- const entry = [...streams.entries()].find(([key, stream]) => key !== planKey && stream.kind === kind);
999
- if (entry === undefined)
1138
+ const picked = [];
1139
+ for (const kind of kinds) {
1140
+ const entry = [...streams.entries()].find(([key, stream]) => key !== planKey && stream.kind === kind);
1141
+ if (entry !== undefined)
1142
+ picked.push(entry);
1143
+ }
1144
+ if (picked.length === 0)
1000
1145
  return undefined;
1001
- const [key, stream] = entry;
1002
- liveFlush.cancel(stream.step);
1003
- streamPublisher?.finish(stream.step);
1004
- streams.delete(key);
1005
- if (activeStream === key)
1006
- activeStream = undefined;
1007
- return { step: stream.step, opened: stream.opened };
1146
+ const sealed = [];
1147
+ for (const [key, stream] of picked) {
1148
+ streams.delete(key);
1149
+ generationSteps.delete(stream.generation);
1150
+ if (activeStream === key)
1151
+ activeStream = undefined;
1152
+ // A sealed entry's durable triple already landed: its content stays
1153
+ // out of the fold, and its coordinate keeps its (durable) place.
1154
+ if (stream.sealed === true) {
1155
+ sealed.push(stream.kind);
1156
+ continue;
1157
+ }
1158
+ liveFlush.cancel(stream.step);
1159
+ }
1160
+ const open = picked.filter(([, stream]) => stream.sealed !== true);
1161
+ if (open.length === 0)
1162
+ return { sealed };
1163
+ // The reservations whose content rides this fold free their own —
1164
+ // unless another open stream still holds the same (shared) step.
1165
+ const held = new Set([...streams.values()].filter(stream => stream.sealed !== true).map(stream => stream.step));
1166
+ for (const [, stream] of open) {
1167
+ if (held.has(stream.step))
1168
+ continue;
1169
+ const index = reservedSteps.indexOf(stream.step);
1170
+ if (index >= 0)
1171
+ reservedSteps.splice(index, 1);
1172
+ }
1173
+ for (const step of new Set(open.map(([, stream]) => stream.step)))
1174
+ streamPublisher?.finish(step);
1175
+ // The fold's coordinate: the earliest consumed reservation — or a
1176
+ // lower step an earlier release freed, so the stored log stays dense.
1177
+ // A step whose triple an earlier fold already wrote takes nothing.
1178
+ const written = new Set();
1179
+ for (const event of childSession.snapshotEvents()) {
1180
+ const data = event.data;
1181
+ if (event.type === 'step/start' && data.turn === turn && typeof data.step === 'number')
1182
+ written.add(data.step);
1183
+ }
1184
+ let free = nextKimiSessionStep(childSession, turn);
1185
+ while (reservedSteps.includes(free))
1186
+ free += 1;
1187
+ const candidates = [...new Set(open.map(([, stream]) => stream.step))].filter(step => !written.has(step));
1188
+ const step = Math.min(free, ...candidates);
1189
+ return { step, sealed };
1008
1190
  },
1009
1191
  };
1010
- const mirrorOptions = () => ({ streams: mirrorStreams });
1192
+ const mirrorOptions = (settle = false) => ({ streams: mirrorStreams, ...settle ? { settle: true } : {} });
1011
1193
  /**
1012
1194
  * Observation accounting across mirror passes: a pass whose window
1013
1195
  * attached its usage to a folded message is already carried; anything else
@@ -1016,8 +1198,8 @@ export class KimiAcpLiveDriver {
1016
1198
  * the latest.
1017
1199
  */
1018
1200
  const noteMirrorUsage = (delta) => {
1019
- if (delta.usage !== undefined && delta.usageAttached !== true) {
1020
- roundUsage = addTokenUsage(roundUsage, delta.usage);
1201
+ if (delta.usageUnattached !== undefined) {
1202
+ roundUsage = addTokenUsage(roundUsage, delta.usageUnattached);
1021
1203
  }
1022
1204
  if (delta.model !== undefined)
1023
1205
  roundModel = delta.model;
@@ -1078,22 +1260,32 @@ export class KimiAcpLiveDriver {
1078
1260
  const stream = streams.get(key);
1079
1261
  if (stream !== undefined) {
1080
1262
  stream.text += text;
1081
- appendStreamSnapshot(stream, false, false);
1263
+ publishStreamSnapshot(stream, false);
1082
1264
  }
1083
1265
  }
1084
1266
  }
1085
1267
  else if (kind === 'tool_call') {
1086
1268
  const id = typeof update['toolCallId'] === 'string' ? update['toolCallId'] : undefined;
1087
1269
  if (id === undefined || !seenToolCalls.has(id)) {
1088
- if (id !== undefined) {
1270
+ // The wire flush can win the race: a call already folded into the
1271
+ // log (its notification arriving late) needs no reservation — one
1272
+ // would never be released and settle would write the call twice.
1273
+ if (id !== undefined && toolLogged(id))
1274
+ seenToolCalls.add(id);
1275
+ else if (id !== undefined) {
1089
1276
  seenToolCalls.add(id);
1277
+ // Reserve the chronological step only — nothing durable lands at
1278
+ // notification time. The wire line's fold writes the whole native
1279
+ // lifecycle in one step; a call kimi never logs finalizes as an
1280
+ // interrupted placeholder at settle.
1090
1281
  let step = nextKimiSessionStep(childSession, turn);
1091
- for (const reserved of reservedSteps)
1092
- step = Math.max(step, reserved + 1);
1282
+ while (reservedSteps.includes(step))
1283
+ step += 1;
1093
1284
  reservedSteps.push(step);
1094
- toolSteps.set(id, step);
1095
- childSession.append('step/start', { turn, step });
1096
- persist();
1285
+ toolSteps.set(id, {
1286
+ step,
1287
+ ...typeof update['title'] === 'string' ? { title: update['title'] } : {},
1288
+ });
1097
1289
  }
1098
1290
  // ACP has no text-item ids. A new tool call separates generation
1099
1291
  // segments; late file folds consume same-kind segments in order.
@@ -1191,7 +1383,7 @@ export class KimiAcpLiveDriver {
1191
1383
  let stableReads = 0;
1192
1384
  const deadline = Date.now() + SETTLE_MIRROR_QUIESCE_MS;
1193
1385
  for (;;) {
1194
- const delta = await mirrorKimiDelta(this.ctx, childSession, spec.homeDir, sessionId, mirrorOptions());
1386
+ const delta = await mirrorKimiDelta(this.ctx, childSession, spec.homeDir, sessionId, mirrorOptions(true));
1195
1387
  noteMirrorUsage(delta);
1196
1388
  localAgent?.reportRunProgress(childSession.id, { kind: 'mirror', mirroredLines: delta.total });
1197
1389
  stableReads = delta.total === lastTotal ? stableReads + 1 : 0;
@@ -1222,33 +1414,46 @@ export class KimiAcpLiveDriver {
1222
1414
  if (turnOpened) {
1223
1415
  // Reconcile the file fold INSIDE the turn window
1224
1416
  // (bounded quiescence — the price of the wire flush race; a mirror
1225
- // failure never fails the round), so every streamed item's completion
1226
- // fold lands at its reserved step before turn/end. Streams whose
1227
- // completing wire line never folded (abort, or a flush outlasting the
1228
- // quiescence window) then finalize in place: one forced snapshot each
1229
- // — interrupted on a non-completed round, the LAST one carrying the
1230
- // round's uncarried usage — then step/end.
1417
+ // failure never fails the round), then finalize every reservation
1418
+ // the fold left behind in ASCENDING step order, so the stored log's
1419
+ // steps stay dense and strictly sequential — the contract the
1420
+ // released-log reader validates on every cold open:
1421
+ // - a stream whose completing wire line never folded (abort, or a
1422
+ // flush outlasting the quiescence window) finalizes from its
1423
+ // accumulated text — interrupted on a non-completed round, the
1424
+ // LAST stream carrying the round's uncarried usage;
1425
+ // - a tool reservation kimi never logged to the wire finalizes as
1426
+ // an interrupted placeholder step.
1231
1427
  try {
1232
1428
  await settleMirror();
1233
1429
  }
1234
1430
  catch (error) {
1235
1431
  this.ctx.logger.warn(`subagent-kimi: live settle mirror failed: ${thrown(error).message}`);
1236
1432
  }
1237
- if (streams.size > 0) {
1238
- const remaining = [...streams.values()];
1239
- for (const [position, stream] of remaining.entries()) {
1240
- appendStreamSnapshot(stream, true, settled.stopReason !== 'completed', position === remaining.length - 1, true);
1241
- streamPublisher?.finish(stream.step);
1242
- if (stream.opened)
1243
- childSession.append('step/end', { turn, step: stream.step });
1244
- }
1245
- streams.clear();
1246
- activeStream = undefined;
1247
- persist();
1248
- }
1249
- for (const step of toolSteps.values())
1250
- childSession.append('step/end', { turn, step });
1433
+ const pending = [
1434
+ ...[...[...streams.entries()]
1435
+ .filter(([, stream]) => stream.sealed !== true)
1436
+ .reduce((byStep, entry) => {
1437
+ const group = byStep.get(entry[1].step) ?? [];
1438
+ group.push(entry);
1439
+ byStep.set(entry[1].step, group);
1440
+ return byStep;
1441
+ }, new Map())
1442
+ .entries()].map(([step, entries]) => ({ step, entries })),
1443
+ ...[...toolSteps.entries()].map(([toolId, tool]) => ({ step: tool.step, toolId, tool })),
1444
+ ].sort((a, b) => a.step - b.step);
1445
+ const lastStream = pending.map((entry, index) => 'entries' in entry ? index : -1).filter(index => index >= 0).at(-1);
1446
+ pending.forEach((entry, index) => {
1447
+ if ('entries' in entry)
1448
+ finalizeStep(entry.step, entry.entries, settled.stopReason !== 'completed', index === lastStream);
1449
+ else if (!toolLogged(entry.toolId))
1450
+ finalizeOrphanTool(entry.toolId, entry.tool);
1451
+ });
1452
+ streams.clear();
1251
1453
  toolSteps.clear();
1454
+ activeStream = undefined;
1455
+ if (pending.length > 0)
1456
+ persist();
1252
1457
  if (settled.stopReason === 'completed') {
1253
1458
  childSession.append('turn/end', { turn, reason: { kind: 'completed' } });
1254
1459
  }