@linzumi/cli 0.0.4-beta → 0.0.6-beta

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/runner.ts CHANGED
@@ -30,6 +30,28 @@
30
30
  Relationship: Leaves channel-scoped approval controls to `channelSession.ts`
31
31
  so the process runner remains lifecycle-only while Kandan safely resolves
32
32
  Codex app-server approval requests from the thread UI.
33
+
34
+ - Date: 2026-04-26
35
+ Spec: plans/2026_04_26_linzumi_cli_review_followups_note.md
36
+ Relationship: Rejects stale Kandan controls for previous local runner
37
+ instances before they can mutate the current Codex app-server session.
38
+
39
+ - Date: 2026-04-26
40
+ Spec: plans/2026-04-26-local-codex-driver-worldclass-spec.md
41
+ Relationship: Enforces local machine cwd capabilities for Kandan-requested
42
+ Codex session starts and advertises cwd plus local preview-port capabilities
43
+ to the server on the runner join payload.
44
+
45
+ - Date: 2026-04-26
46
+ Spec: plans/2026-04-26-local-runner-forwarding-and-editor-plan.md
47
+ Relationship: Routes Kandan-authenticated local preview forwarding controls
48
+ to the local loopback HTTP fetcher after verifying the control targets this
49
+ runner instance.
50
+
51
+ - Date: 2026-04-26
52
+ Spec: plans/2026-04-26-local-runner-port-forward-approval.md
53
+ Relationship: Lets channel-session approval add descendant listener ports to
54
+ the runner's live forwarding capability before forwarding requests use them.
33
55
  */
34
56
  import { spawn, type ChildProcess } from "node:child_process";
35
57
  import { randomUUID } from "node:crypto";
@@ -38,6 +60,25 @@ import { join } from "node:path";
38
60
  import { attachChannelSession } from "./channelSession";
39
61
  import { connectCodexAppServer, startCodexAppServer } from "./codexAppServer";
40
62
  import { arrayValue, integerValue, objectValue, stringValue } from "./json";
63
+ import { connectForwardTunnel } from "./forwardTunnel";
64
+ import { resolveAllowedCwd } from "./localCapabilities";
65
+ import {
66
+ createForwardWebSocketManager,
67
+ handleForwardHttpRequest,
68
+ isForwardHttpRequestControl,
69
+ isForwardWebSocketControl,
70
+ } from "./localForwarding";
71
+ import {
72
+ isStartLocalEditorControl,
73
+ localEditorCapabilities,
74
+ startLocalEditor,
75
+ type LocalEditorState,
76
+ } from "./localEditor";
77
+ import type { InstalledEditorRuntime } from "./localEditorRuntime";
78
+ import {
79
+ dependencyStatusPayload,
80
+ type RunnerDependencyStatus,
81
+ } from "./dependencyStatus";
41
82
  import { connectPhoenixClient } from "./phoenix";
42
83
  import {
43
84
  type JsonObject,
@@ -60,6 +101,12 @@ export type RunnerOptions = {
60
101
  readonly launchTui: boolean;
61
102
  readonly fast?: boolean | undefined;
62
103
  readonly logFile?: string | undefined;
104
+ readonly allowedCwds: readonly string[];
105
+ readonly allowedForwardPorts?: readonly number[] | undefined;
106
+ readonly codeServerBin?: string | undefined;
107
+ readonly editorRuntime?: InstalledEditorRuntime | undefined;
108
+ readonly dependencyStatus?: RunnerDependencyStatus | undefined;
109
+ readonly socketFactory?: ((url: string) => WebSocket) | undefined;
63
110
  readonly channelSession: KandanChannelSessionOptions | undefined;
64
111
  };
65
112
 
@@ -109,17 +156,71 @@ async function openLocalCodexRunner(
109
156
  cleanup: CleanupStack,
110
157
  close: () => Promise<void>,
111
158
  ): Promise<LocalCodexRunnerHandle> {
112
- const kandan = await connectPhoenixClient(options.kandanUrl, options.token);
159
+ const allowedForwardPorts = options.allowedForwardPorts ?? [];
160
+ const liveForwardPorts = new Set<number>(allowedForwardPorts);
161
+ const managedForwardPorts = new Set<number>();
162
+ const allowedCwds = { value: [...options.allowedCwds] };
163
+ const localEditorState: { value: LocalEditorState } = {
164
+ value: { status: "disabled" },
165
+ };
166
+ const localEditorGeneration = { value: 0 };
167
+ cleanup.actions.push(() => {
168
+ if (localEditorState.value.status === "running") {
169
+ localEditorState.value.process.kill("SIGINT");
170
+ localEditorState.value.collaboration?.process.kill("SIGINT");
171
+ }
172
+ });
173
+ const capabilitiesPayload = (): JsonObject => ({
174
+ codexAppServer: true,
175
+ codexRemoteTui: true,
176
+ startInstance: allowedCwds.value.length > 0,
177
+ allowedCwds: allowedCwds.value,
178
+ allowedCwdSuggestions: allowedCwdSuggestions(
179
+ options.cwd,
180
+ allowedCwds.value,
181
+ ),
182
+ portForwarding: liveForwardPorts.size > 0,
183
+ allowedPorts: Array.from(liveForwardPorts).sort(
184
+ (left, right) => left - right,
185
+ ),
186
+ streamingForwarding: true,
187
+ streamingForwardingVersion: 1,
188
+ toolStatus:
189
+ options.dependencyStatus === undefined
190
+ ? null
191
+ : dependencyStatusPayload(options.dependencyStatus),
192
+ editorRuntime:
193
+ options.dependencyStatus?.editorRuntime === undefined
194
+ ? null
195
+ : dependencyStatusPayload(options.dependencyStatus).editorRuntime,
196
+ ...localEditorCapabilities(
197
+ options.editorRuntime,
198
+ allowedCwds.value,
199
+ localEditorState.value,
200
+ ),
201
+ });
202
+ const kandan = await connectPhoenixClient(
203
+ options.kandanUrl,
204
+ options.token,
205
+ options.socketFactory,
206
+ );
113
207
  cleanup.actions.push(() => kandan.close());
114
208
  const topic = `local_runner:${options.runnerId}`;
115
- await kandan.join(topic, {
209
+ const joinPayload = (): JsonObject => ({
116
210
  clientName: "kandan-local-codex-runner",
117
211
  version: "0.0.1",
118
- capabilities: {
119
- codexAppServer: true,
120
- codexRemoteTui: true,
121
- },
212
+ capabilities: capabilitiesPayload(),
122
213
  });
214
+ await kandan.join(topic, joinPayload(), { rejoinPayload: joinPayload });
215
+ const forwardTunnel = await connectForwardTunnel({
216
+ kandanUrl: options.kandanUrl,
217
+ token: options.token,
218
+ runnerId: options.runnerId,
219
+ allowedPorts: () => Array.from(liveForwardPorts),
220
+ log,
221
+ socketFactory: options.socketFactory,
222
+ });
223
+ cleanup.actions.push(() => forwardTunnel.close());
123
224
 
124
225
  const started =
125
226
  options.codexUrl === undefined
@@ -143,6 +244,58 @@ async function openLocalCodexRunner(
143
244
  }
144
245
 
145
246
  const instanceId = `codex-${randomUUID()}`;
247
+ const publishLocalEditorStatus = (payload: JsonObject): void => {
248
+ void kandan.push(topic, "local_editor_status", payload).catch((error) => {
249
+ log("kandan.local_editor_status_push_failed", {
250
+ message: error instanceof Error ? error.message : String(error),
251
+ });
252
+ });
253
+ };
254
+ const watchLocalEditorExit = (
255
+ state: Extract<LocalEditorState, { status: "running" }>,
256
+ generation: number,
257
+ initialStatusPushed: Promise<unknown>,
258
+ ): void => {
259
+ const handleExit = () => {
260
+ if (
261
+ localEditorGeneration.value !== generation ||
262
+ localEditorState.value.status !== "running" ||
263
+ localEditorState.value.process !== state.process
264
+ ) {
265
+ return;
266
+ }
267
+
268
+ localEditorState.value = { status: "disabled" };
269
+ liveForwardPorts.delete(state.port);
270
+ managedForwardPorts.delete(state.port);
271
+ if (state.collaboration !== undefined) {
272
+ liveForwardPorts.delete(state.collaboration.serverPort);
273
+ managedForwardPorts.delete(state.collaboration.serverPort);
274
+ }
275
+ publishLocalEditorStatus({
276
+ instanceId,
277
+ ok: true,
278
+ cwd: state.cwd,
279
+ capabilities: {
280
+ ...capabilitiesPayload(),
281
+ revokedPorts:
282
+ state.collaboration === undefined
283
+ ? [state.port]
284
+ : [state.port, state.collaboration.serverPort],
285
+ },
286
+ });
287
+ };
288
+
289
+ const handleExitAfterInitialStatus = () => {
290
+ void initialStatusPushed.then(handleExit).catch((error) => {
291
+ log("kandan.local_editor_initial_status_failed", {
292
+ message: error instanceof Error ? error.message : String(error),
293
+ });
294
+ });
295
+ };
296
+
297
+ void state.exited.then(handleExitAfterInitialStatus);
298
+ };
146
299
  const codex = await connectCodexAppServer(codexUrl);
147
300
  cleanup.actions.push(() => codex.close());
148
301
 
@@ -183,6 +336,17 @@ async function openLocalCodexRunner(
183
336
  codexBin: options.codexBin,
184
337
  fast: options.fast,
185
338
  launchTui: options.launchTui,
339
+ enablePortForwardWatch: true,
340
+ initialForwardPorts: allowedForwardPorts,
341
+ suppressedForwardPorts: () => Array.from(managedForwardPorts),
342
+ onForwardPortApproved: (port) => {
343
+ liveForwardPorts.add(port);
344
+ return capabilitiesPayload();
345
+ },
346
+ onForwardPortRevoked: (port) => {
347
+ liveForwardPorts.delete(port);
348
+ return capabilitiesPayload();
349
+ },
186
350
  channelSession: options.channelSession,
187
351
  },
188
352
  log,
@@ -205,9 +369,10 @@ async function openLocalCodexRunner(
205
369
  model: options.channelSession?.model ?? null,
206
370
  reasoningEffort: options.channelSession?.reasoningEffort ?? null,
207
371
  fast: options.fast ?? false,
372
+ capabilities: capabilitiesPayload(),
208
373
  });
209
374
  const pushHeartbeat = () =>
210
- kandan.push(topic, "heartbeat", heartbeatPayload()).catch(error => {
375
+ kandan.push(topic, "heartbeat", heartbeatPayload()).catch((error) => {
211
376
  log("kandan.heartbeat_push_failed", {
212
377
  message: error instanceof Error ? error.message : String(error),
213
378
  });
@@ -219,6 +384,11 @@ async function openLocalCodexRunner(
219
384
  kandan.onReconnect(() => pushHeartbeat().then(() => undefined));
220
385
  void pushHeartbeat();
221
386
 
387
+ const forwardWebSockets = createForwardWebSocketManager(kandan, topic, () =>
388
+ Array.from(liveForwardPorts),
389
+ );
390
+ cleanup.actions.push(() => forwardWebSockets.close());
391
+
222
392
  const channelCodexThreadId = channelSession?.currentCodexThreadId();
223
393
  if (options.launchTui && channelCodexThreadId !== undefined) {
224
394
  await prepareCodexThreadForTuiResume(codex, channelCodexThreadId);
@@ -241,24 +411,26 @@ async function openLocalCodexRunner(
241
411
  });
242
412
  }
243
413
 
244
- codex.onNotification(notification => {
414
+ codex.onNotification((notification) => {
245
415
  seq.value += 1;
246
416
  const params = (notification.params ?? {}) as JsonObject;
247
417
  const metadata = extractCodexIds(params);
248
418
 
249
419
  if (channelSession === undefined) {
250
- void kandan.push(topic, "codex_notification", {
251
- instanceId,
252
- seq: seq.value,
253
- method: notification.method,
254
- params,
255
- metadata,
256
- receivedAt: new Date().toISOString(),
257
- }).catch(error => {
258
- log("kandan.codex_notification_push_failed", {
259
- message: error instanceof Error ? error.message : String(error),
420
+ void kandan
421
+ .push(topic, "codex_notification", {
422
+ instanceId,
423
+ seq: seq.value,
424
+ method: notification.method,
425
+ params,
426
+ metadata,
427
+ receivedAt: new Date().toISOString(),
428
+ })
429
+ .catch((error) => {
430
+ log("kandan.codex_notification_push_failed", {
431
+ message: error instanceof Error ? error.message : String(error),
432
+ });
260
433
  });
261
- });
262
434
  }
263
435
 
264
436
  log("codex.notification", {
@@ -268,30 +440,171 @@ async function openLocalCodexRunner(
268
440
  channelSession?.handleCodexNotification(notification.method, params);
269
441
  });
270
442
 
271
- kandan.onControl(control => {
443
+ kandan.onControl((control) => {
272
444
  log("kandan.control", { control });
445
+ if (!controlTargetsInstance(control, instanceId)) {
446
+ log("kandan.control_ignored", {
447
+ reason: "instance_id_mismatch",
448
+ instanceId,
449
+ controlInstanceId: control.instanceId,
450
+ controlType: control.type,
451
+ });
452
+ return;
453
+ }
454
+
455
+ if (isForwardHttpRequestControl(control)) {
456
+ void handleForwardHttpRequest(control, Array.from(liveForwardPorts))
457
+ .then((response) =>
458
+ kandan.push(topic, "forward:http_response", response),
459
+ )
460
+ .catch((error) =>
461
+ kandan.push(topic, "forward:http_response", {
462
+ requestId: control.requestId,
463
+ ok: false,
464
+ error: error instanceof Error ? error.message : String(error),
465
+ }),
466
+ )
467
+ .catch((error) => {
468
+ log("kandan.forward_response_push_failed", {
469
+ message: error instanceof Error ? error.message : String(error),
470
+ });
471
+ });
472
+ return;
473
+ }
474
+
475
+ if (isForwardWebSocketControl(control)) {
476
+ forwardWebSockets.handle(control);
477
+ return;
478
+ }
479
+
480
+ if (isStartLocalEditorControl(control)) {
481
+ void startLocalEditor(control, {
482
+ codeServerBin: options.codeServerBin,
483
+ editorRuntime: options.editorRuntime,
484
+ allowedCwds: allowedCwds.value,
485
+ currentState: localEditorState.value,
486
+ browserBaseUrl:
487
+ control.browserBaseUrl ?? process.env.KANDAN_LOCAL_RUNNER_PUBLIC_BASE_URL,
488
+ runnerId: options.runnerId,
489
+ })
490
+ .then((result) => {
491
+ if (result.ok) {
492
+ localEditorGeneration.value += 1;
493
+ const editorGeneration = localEditorGeneration.value;
494
+ if (localEditorState.value.status === "running") {
495
+ liveForwardPorts.delete(localEditorState.value.port);
496
+ managedForwardPorts.delete(localEditorState.value.port);
497
+ if (localEditorState.value.collaboration !== undefined) {
498
+ liveForwardPorts.delete(
499
+ localEditorState.value.collaboration.serverPort,
500
+ );
501
+ managedForwardPorts.delete(
502
+ localEditorState.value.collaboration.serverPort,
503
+ );
504
+ }
505
+ }
506
+ localEditorState.value = result.state;
507
+ liveForwardPorts.add(result.event.port);
508
+ managedForwardPorts.add(result.event.port);
509
+ if (result.event.collaboration !== undefined) {
510
+ liveForwardPorts.add(result.event.collaboration.serverPort);
511
+ managedForwardPorts.add(result.event.collaboration.serverPort);
512
+ }
513
+ const initialStatusPushed = kandan.push(
514
+ topic,
515
+ "local_editor_status",
516
+ {
517
+ instanceId,
518
+ requestId: control.requestId,
519
+ ok: true,
520
+ capabilities: capabilitiesPayload(),
521
+ ...result.event,
522
+ },
523
+ );
524
+
525
+ if (result.state.status === "running") {
526
+ watchLocalEditorExit(
527
+ result.state,
528
+ editorGeneration,
529
+ initialStatusPushed,
530
+ );
531
+ }
532
+
533
+ return initialStatusPushed;
534
+ }
535
+
536
+ localEditorState.value = result.state;
537
+ return kandan.push(topic, "local_editor_status", {
538
+ instanceId,
539
+ requestId: control.requestId,
540
+ ok: false,
541
+ reason: result.reason,
542
+ capabilities: capabilitiesPayload(),
543
+ });
544
+ })
545
+ .catch((error) =>
546
+ kandan.push(topic, "local_editor_status", {
547
+ instanceId,
548
+ requestId: control.requestId,
549
+ ok: false,
550
+ reason: error instanceof Error ? error.message : String(error),
551
+ capabilities: capabilitiesPayload(),
552
+ }),
553
+ )
554
+ .catch((error) => {
555
+ log("kandan.local_editor_status_push_failed", {
556
+ message: error instanceof Error ? error.message : String(error),
557
+ });
558
+ });
559
+ return;
560
+ }
561
+
562
+ if (isUpdateRunnerConfigControl(control)) {
563
+ allowedCwds.value = normalizeAllowedCwds(control.allowedCwds);
564
+ void pushHeartbeat();
565
+ return;
566
+ }
567
+
273
568
  void (channelSession?.handleControl(control) ?? Promise.resolve(undefined))
274
- .then(handled => {
569
+ .then((handled) => {
275
570
  if (handled !== undefined) {
276
571
  return handled;
277
572
  }
278
573
 
279
- return applyControl(codex, instanceId, control);
574
+ return applyControl(
575
+ codex,
576
+ instanceId,
577
+ options,
578
+ allowedCwds.value,
579
+ control,
580
+ );
280
581
  })
281
- .then(response => {
582
+ .then((response) => {
282
583
  return kandan.push(topic, "codex_response", response);
283
584
  })
284
- .catch(error => {
585
+ .catch((error) => {
285
586
  return kandan.push(topic, "codex_error", {
286
587
  instanceId,
287
588
  message: error instanceof Error ? error.message : String(error),
288
589
  });
590
+ })
591
+ .catch((error) => {
592
+ log("kandan.control_response_push_failed", {
593
+ message: error instanceof Error ? error.message : String(error),
594
+ });
289
595
  });
290
596
  });
291
597
 
292
598
  return { instanceId, codexUrl, close };
293
599
  }
294
600
 
601
+ function controlTargetsInstance(
602
+ control: KandanControl,
603
+ instanceId: string,
604
+ ): boolean {
605
+ return control.instanceId === undefined || control.instanceId === instanceId;
606
+ }
607
+
295
608
  async function closeCleanupStack(cleanup: CleanupStack): Promise<void> {
296
609
  if (cleanup.closePromise !== undefined) {
297
610
  return cleanup.closePromise;
@@ -337,7 +650,7 @@ async function discoverCodexThreads(
337
650
  ? []
338
651
  : data
339
652
  .filter(isJsonObject)
340
- .map(thread => ({
653
+ .map((thread) => ({
341
654
  id: stringValue(thread.id) ?? "",
342
655
  preview: stringValue(thread.preview) ?? "",
343
656
  cwd: stringValue(thread.cwd) ?? "",
@@ -345,7 +658,7 @@ async function discoverCodexThreads(
345
658
  updatedAt: integerValue(thread.updatedAt) ?? null,
346
659
  status: objectValue(thread.status) ?? null,
347
660
  }))
348
- .filter(thread => thread.id !== "");
661
+ .filter((thread) => thread.id !== "");
349
662
  }
350
663
 
351
664
  function makeRunnerLogger(options: RunnerOptions): RunnerLogger {
@@ -357,7 +670,9 @@ function makeRunnerLogger(options: RunnerOptions): RunnerLogger {
357
670
 
358
671
  function installCleanupHandlers(close: () => Promise<void>): () => void {
359
672
  const closeAndExit = () => {
360
- void close().catch(() => undefined).finally(() => process.exit(0));
673
+ void close()
674
+ .catch(() => undefined)
675
+ .finally(() => process.exit(0));
361
676
  };
362
677
  const closeOnExit = () => {
363
678
  void close().catch(() => undefined);
@@ -462,9 +777,47 @@ export async function prepareCodexThreadForTuiResume(
462
777
  async function applyControl(
463
778
  codex: Awaited<ReturnType<typeof connectCodexAppServer>>,
464
779
  instanceId: string,
780
+ options: RunnerOptions,
781
+ allowedCwds: readonly string[],
465
782
  control: KandanControl,
466
783
  ): Promise<JsonObject> {
467
784
  switch (control.type) {
785
+ case "start_instance": {
786
+ const cwd = resolveAllowedCwd(control.cwd, allowedCwds);
787
+
788
+ if (!cwd.ok) {
789
+ return {
790
+ instanceId,
791
+ controlType: control.type,
792
+ ok: false,
793
+ error: cwd.reason,
794
+ };
795
+ }
796
+
797
+ const response = await codex.request("thread/start", {
798
+ cwd: cwd.cwd,
799
+ serviceName: "kandan-local-runner",
800
+ personality: "pragmatic",
801
+ ...(control.model === undefined ? {} : { model: control.model }),
802
+ ...(control.reasoningEffort === undefined
803
+ ? {}
804
+ : { reasoningEffort: control.reasoningEffort }),
805
+ ...(control.approvalPolicy === undefined
806
+ ? {}
807
+ : { approvalPolicy: control.approvalPolicy }),
808
+ ...(control.sandbox === undefined ? {} : { sandbox: control.sandbox }),
809
+ ...(control.fast === true ? { serviceTier: "fast" } : {}),
810
+ });
811
+
812
+ return {
813
+ instanceId,
814
+ controlType: control.type,
815
+ cwd: cwd.cwd,
816
+ matchedRoot: cwd.matchedRoot,
817
+ response: response as JsonObject,
818
+ };
819
+ }
820
+
468
821
  case "start_turn": {
469
822
  const response = await codex.request("turn/start", {
470
823
  threadId: control.threadId,
@@ -516,9 +869,41 @@ async function applyControl(
516
869
 
517
870
  case "stop_instance":
518
871
  case "kill_instance":
519
- case "start_instance":
520
872
  case "interrupt_queued_messages":
521
873
  case "resolve_codex_approval_request":
874
+ case "forward_http_request":
875
+ case "forward_websocket_open":
876
+ case "forward_websocket_send":
877
+ case "forward_websocket_close":
878
+ case "start_local_editor":
879
+ case "update_runner_config":
522
880
  return { instanceId, controlType: control.type, skipped: true };
523
881
  }
524
882
  }
883
+
884
+ function isUpdateRunnerConfigControl(
885
+ control: KandanControl,
886
+ ): control is Extract<
887
+ KandanControl,
888
+ { readonly type: "update_runner_config" }
889
+ > {
890
+ return control.type === "update_runner_config";
891
+ }
892
+
893
+ function normalizeAllowedCwds(values: readonly string[]): string[] {
894
+ return Array.from(
895
+ new Set(
896
+ values.flatMap((value) => {
897
+ const normalized = value.trim();
898
+ return normalized === "" ? [] : [normalized];
899
+ }),
900
+ ),
901
+ );
902
+ }
903
+
904
+ function allowedCwdSuggestions(
905
+ cwd: string,
906
+ allowedCwds: readonly string[],
907
+ ): string[] {
908
+ return normalizeAllowedCwds([cwd, ...allowedCwds]);
909
+ }
@@ -0,0 +1,129 @@
1
+ /*
2
+ - Date: 2026-04-26
3
+ Spec: plans/2026-04-26-local-codex-driver-worldclass-spec.md
4
+ Relationship: Pure coalescing helpers for runner-batched Codex stream
5
+ updates, keeping one logical Codex item mapped to one Kandan row while
6
+ avoiding per-delta state churn in the session orchestrator.
7
+ */
8
+ import type {
9
+ CodexAssistantDelta,
10
+ CodexCommandOutputDelta,
11
+ CodexFileChangeDelta,
12
+ CodexReasoningDelta,
13
+ } from "./codexOutput";
14
+
15
+ type StreamDelta = {
16
+ readonly itemKey: string;
17
+ readonly turnId: string | undefined;
18
+ };
19
+
20
+ type TextDelta = StreamDelta & {
21
+ readonly delta?: string | undefined;
22
+ readonly patchText?: string | undefined;
23
+ };
24
+
25
+ export function coalesceAssistantDeltas(
26
+ deltas: readonly CodexAssistantDelta[],
27
+ ): CodexAssistantDelta[] {
28
+ return coalesceTextDeltas(deltas, (delta) => ({
29
+ itemKey: delta.itemKey,
30
+ turnId: delta.turnId,
31
+ delta: delta.delta,
32
+ }));
33
+ }
34
+
35
+ export function coalesceReasoningDeltas(
36
+ deltas: readonly CodexReasoningDelta[],
37
+ ): CodexReasoningDelta[] {
38
+ return coalesceTextDeltas(deltas, (delta) => ({
39
+ itemKey: delta.itemKey,
40
+ turnId: delta.turnId,
41
+ delta: delta.delta,
42
+ }));
43
+ }
44
+
45
+ export function coalesceFileChangeDeltas(
46
+ deltas: readonly CodexFileChangeDelta[],
47
+ ): CodexFileChangeDelta[] {
48
+ return coalesceTextDeltas(deltas, (delta) => ({
49
+ itemKey: delta.itemKey,
50
+ turnId: delta.turnId,
51
+ patchText: delta.patchText,
52
+ }));
53
+ }
54
+
55
+ export function coalesceCommandOutputDeltas(
56
+ deltas: readonly CodexCommandOutputDelta[],
57
+ ): CodexCommandOutputDelta[] {
58
+ const coalesced = new Map<string, CodexCommandOutputDelta>();
59
+
60
+ for (const delta of deltas) {
61
+ const key = commandOutputStreamingKey(delta);
62
+ const existing = coalesced.get(key);
63
+
64
+ if (existing === undefined) {
65
+ coalesced.set(key, delta);
66
+ continue;
67
+ }
68
+
69
+ coalesced.set(key, {
70
+ ...delta,
71
+ processId: delta.processId ?? existing.processId,
72
+ delta: `${existing.delta}${delta.delta}`,
73
+ });
74
+ }
75
+
76
+ return [...coalesced.values()];
77
+ }
78
+
79
+ export function firstDeltaTurnId(
80
+ deltas: readonly { readonly turnId: string | undefined }[],
81
+ ): string | undefined {
82
+ return deltas.find((delta) => delta.turnId !== undefined)?.turnId;
83
+ }
84
+
85
+ function coalesceTextDeltas<TDelta extends TextDelta>(
86
+ deltas: readonly TDelta[],
87
+ clone: (delta: TDelta) => TDelta,
88
+ ): TDelta[] {
89
+ const coalesced = new Map<string, TDelta>();
90
+
91
+ for (const delta of deltas) {
92
+ const key = streamingItemKey(delta);
93
+ const existing = coalesced.get(key);
94
+
95
+ if (existing === undefined) {
96
+ coalesced.set(key, clone(delta));
97
+ continue;
98
+ }
99
+
100
+ coalesced.set(key, mergeTextDelta(existing, delta));
101
+ }
102
+
103
+ return [...coalesced.values()];
104
+ }
105
+
106
+ function mergeTextDelta<TDelta extends TextDelta>(
107
+ existing: TDelta,
108
+ incoming: TDelta,
109
+ ): TDelta {
110
+ if (existing.delta !== undefined || incoming.delta !== undefined) {
111
+ return {
112
+ ...incoming,
113
+ delta: `${existing.delta ?? ""}${incoming.delta ?? ""}`,
114
+ };
115
+ }
116
+
117
+ return {
118
+ ...incoming,
119
+ patchText: `${existing.patchText ?? ""}${incoming.patchText ?? ""}`,
120
+ };
121
+ }
122
+
123
+ function streamingItemKey(delta: StreamDelta): string {
124
+ return `${delta.turnId ?? "turn"}:${delta.itemKey}`;
125
+ }
126
+
127
+ function commandOutputStreamingKey(delta: CodexCommandOutputDelta): string {
128
+ return `${streamingItemKey(delta)}:${delta.stream}`;
129
+ }