@linzumi/cli 0.0.5-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
@@ -35,6 +35,23 @@
35
35
  Spec: plans/2026_04_26_linzumi_cli_review_followups_note.md
36
36
  Relationship: Rejects stale Kandan controls for previous local runner
37
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.
38
55
  */
39
56
  import { spawn, type ChildProcess } from "node:child_process";
40
57
  import { randomUUID } from "node:crypto";
@@ -43,6 +60,25 @@ import { join } from "node:path";
43
60
  import { attachChannelSession } from "./channelSession";
44
61
  import { connectCodexAppServer, startCodexAppServer } from "./codexAppServer";
45
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";
46
82
  import { connectPhoenixClient } from "./phoenix";
47
83
  import {
48
84
  type JsonObject,
@@ -65,6 +101,12 @@ export type RunnerOptions = {
65
101
  readonly launchTui: boolean;
66
102
  readonly fast?: boolean | undefined;
67
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;
68
110
  readonly channelSession: KandanChannelSessionOptions | undefined;
69
111
  };
70
112
 
@@ -114,17 +156,71 @@ async function openLocalCodexRunner(
114
156
  cleanup: CleanupStack,
115
157
  close: () => Promise<void>,
116
158
  ): Promise<LocalCodexRunnerHandle> {
117
- 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
+ );
118
207
  cleanup.actions.push(() => kandan.close());
119
208
  const topic = `local_runner:${options.runnerId}`;
120
- await kandan.join(topic, {
209
+ const joinPayload = (): JsonObject => ({
121
210
  clientName: "kandan-local-codex-runner",
122
211
  version: "0.0.1",
123
- capabilities: {
124
- codexAppServer: true,
125
- codexRemoteTui: true,
126
- },
212
+ capabilities: capabilitiesPayload(),
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,
127
222
  });
223
+ cleanup.actions.push(() => forwardTunnel.close());
128
224
 
129
225
  const started =
130
226
  options.codexUrl === undefined
@@ -148,6 +244,58 @@ async function openLocalCodexRunner(
148
244
  }
149
245
 
150
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
+ };
151
299
  const codex = await connectCodexAppServer(codexUrl);
152
300
  cleanup.actions.push(() => codex.close());
153
301
 
@@ -188,6 +336,17 @@ async function openLocalCodexRunner(
188
336
  codexBin: options.codexBin,
189
337
  fast: options.fast,
190
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
+ },
191
350
  channelSession: options.channelSession,
192
351
  },
193
352
  log,
@@ -210,9 +369,10 @@ async function openLocalCodexRunner(
210
369
  model: options.channelSession?.model ?? null,
211
370
  reasoningEffort: options.channelSession?.reasoningEffort ?? null,
212
371
  fast: options.fast ?? false,
372
+ capabilities: capabilitiesPayload(),
213
373
  });
214
374
  const pushHeartbeat = () =>
215
- kandan.push(topic, "heartbeat", heartbeatPayload()).catch(error => {
375
+ kandan.push(topic, "heartbeat", heartbeatPayload()).catch((error) => {
216
376
  log("kandan.heartbeat_push_failed", {
217
377
  message: error instanceof Error ? error.message : String(error),
218
378
  });
@@ -224,6 +384,11 @@ async function openLocalCodexRunner(
224
384
  kandan.onReconnect(() => pushHeartbeat().then(() => undefined));
225
385
  void pushHeartbeat();
226
386
 
387
+ const forwardWebSockets = createForwardWebSocketManager(kandan, topic, () =>
388
+ Array.from(liveForwardPorts),
389
+ );
390
+ cleanup.actions.push(() => forwardWebSockets.close());
391
+
227
392
  const channelCodexThreadId = channelSession?.currentCodexThreadId();
228
393
  if (options.launchTui && channelCodexThreadId !== undefined) {
229
394
  await prepareCodexThreadForTuiResume(codex, channelCodexThreadId);
@@ -246,24 +411,26 @@ async function openLocalCodexRunner(
246
411
  });
247
412
  }
248
413
 
249
- codex.onNotification(notification => {
414
+ codex.onNotification((notification) => {
250
415
  seq.value += 1;
251
416
  const params = (notification.params ?? {}) as JsonObject;
252
417
  const metadata = extractCodexIds(params);
253
418
 
254
419
  if (channelSession === undefined) {
255
- void kandan.push(topic, "codex_notification", {
256
- instanceId,
257
- seq: seq.value,
258
- method: notification.method,
259
- params,
260
- metadata,
261
- receivedAt: new Date().toISOString(),
262
- }).catch(error => {
263
- log("kandan.codex_notification_push_failed", {
264
- 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
+ });
265
433
  });
266
- });
267
434
  }
268
435
 
269
436
  log("codex.notification", {
@@ -273,7 +440,7 @@ async function openLocalCodexRunner(
273
440
  channelSession?.handleCodexNotification(notification.method, params);
274
441
  });
275
442
 
276
- kandan.onControl(control => {
443
+ kandan.onControl((control) => {
277
444
  log("kandan.control", { control });
278
445
  if (!controlTargetsInstance(control, instanceId)) {
279
446
  log("kandan.control_ignored", {
@@ -285,24 +452,143 @@ async function openLocalCodexRunner(
285
452
  return;
286
453
  }
287
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
+
288
568
  void (channelSession?.handleControl(control) ?? Promise.resolve(undefined))
289
- .then(handled => {
569
+ .then((handled) => {
290
570
  if (handled !== undefined) {
291
571
  return handled;
292
572
  }
293
573
 
294
- return applyControl(codex, instanceId, control);
574
+ return applyControl(
575
+ codex,
576
+ instanceId,
577
+ options,
578
+ allowedCwds.value,
579
+ control,
580
+ );
295
581
  })
296
- .then(response => {
582
+ .then((response) => {
297
583
  return kandan.push(topic, "codex_response", response);
298
584
  })
299
- .catch(error => {
585
+ .catch((error) => {
300
586
  return kandan.push(topic, "codex_error", {
301
587
  instanceId,
302
588
  message: error instanceof Error ? error.message : String(error),
303
589
  });
304
590
  })
305
- .catch(error => {
591
+ .catch((error) => {
306
592
  log("kandan.control_response_push_failed", {
307
593
  message: error instanceof Error ? error.message : String(error),
308
594
  });
@@ -312,7 +598,10 @@ async function openLocalCodexRunner(
312
598
  return { instanceId, codexUrl, close };
313
599
  }
314
600
 
315
- function controlTargetsInstance(control: KandanControl, instanceId: string): boolean {
601
+ function controlTargetsInstance(
602
+ control: KandanControl,
603
+ instanceId: string,
604
+ ): boolean {
316
605
  return control.instanceId === undefined || control.instanceId === instanceId;
317
606
  }
318
607
 
@@ -361,7 +650,7 @@ async function discoverCodexThreads(
361
650
  ? []
362
651
  : data
363
652
  .filter(isJsonObject)
364
- .map(thread => ({
653
+ .map((thread) => ({
365
654
  id: stringValue(thread.id) ?? "",
366
655
  preview: stringValue(thread.preview) ?? "",
367
656
  cwd: stringValue(thread.cwd) ?? "",
@@ -369,7 +658,7 @@ async function discoverCodexThreads(
369
658
  updatedAt: integerValue(thread.updatedAt) ?? null,
370
659
  status: objectValue(thread.status) ?? null,
371
660
  }))
372
- .filter(thread => thread.id !== "");
661
+ .filter((thread) => thread.id !== "");
373
662
  }
374
663
 
375
664
  function makeRunnerLogger(options: RunnerOptions): RunnerLogger {
@@ -381,7 +670,9 @@ function makeRunnerLogger(options: RunnerOptions): RunnerLogger {
381
670
 
382
671
  function installCleanupHandlers(close: () => Promise<void>): () => void {
383
672
  const closeAndExit = () => {
384
- void close().catch(() => undefined).finally(() => process.exit(0));
673
+ void close()
674
+ .catch(() => undefined)
675
+ .finally(() => process.exit(0));
385
676
  };
386
677
  const closeOnExit = () => {
387
678
  void close().catch(() => undefined);
@@ -486,9 +777,47 @@ export async function prepareCodexThreadForTuiResume(
486
777
  async function applyControl(
487
778
  codex: Awaited<ReturnType<typeof connectCodexAppServer>>,
488
779
  instanceId: string,
780
+ options: RunnerOptions,
781
+ allowedCwds: readonly string[],
489
782
  control: KandanControl,
490
783
  ): Promise<JsonObject> {
491
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
+
492
821
  case "start_turn": {
493
822
  const response = await codex.request("turn/start", {
494
823
  threadId: control.threadId,
@@ -540,9 +869,41 @@ async function applyControl(
540
869
 
541
870
  case "stop_instance":
542
871
  case "kill_instance":
543
- case "start_instance":
544
872
  case "interrupt_queued_messages":
545
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":
546
880
  return { instanceId, controlType: control.type, skipped: true };
547
881
  }
548
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
+ }