@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.
@@ -33,6 +33,63 @@
33
33
  Relationship: Surfaces non-auto-accepted Codex app-server approval requests
34
34
  as Kandan message state, then resolves the blocked app-server request only
35
35
  after a scoped Kandan approval control arrives.
36
+
37
+ - Date: 2026-04-26
38
+ Spec: plans/2026-04-26-local-codex-driver-worldclass-spec.md
39
+ Relationship: Batches live Codex streaming deltas at the runner boundary so
40
+ one logical Codex item stays one Kandan row without per-token persistence
41
+ churn, while turn completion still flushes all pending live output before
42
+ final reconciliation.
43
+
44
+ - Date: 2026-04-26
45
+ Spec: plans/2026-04-26-local-codex-driver-worldclass-spec.md
46
+ Relationship: Stores stream and turn registries in bounded keyed caches so
47
+ long local Codex turns avoid repeated array replacement while preserving the
48
+ deterministic reconciliation order required by the transcript state machine.
49
+
50
+ - Date: 2026-04-26
51
+ Spec: plans/2026-04-26-local-codex-driver-worldclass-spec.md
52
+ Relationship: Keeps approval requests, TUI input mirrors, and turn membership
53
+ registries keyed by their stable logical ids, so approval resolution,
54
+ duplicate-output guards, and local-TUI mirroring remain constant-time under
55
+ long-running shared Codex sessions.
56
+
57
+ - Date: 2026-04-26
58
+ Spec: plans/2026-04-26-local-codex-driver-worldclass-spec.md
59
+ Relationship: Delegates shared Codex stream timer/flush/forward-chain
60
+ behavior to `streamDeltaQueue.ts`, leaving this module to map each concrete
61
+ Codex event kind into Kandan transcript semantics.
62
+
63
+ - Date: 2026-04-26
64
+ Spec: plans/2026-04-26-local-codex-driver-worldclass-spec.md
65
+ Relationship: Uses `pendingKandanMessageQueue.ts` for accepted human replies
66
+ so high-volume shared Codex threads drain pending work without repeated
67
+ `Array.shift()` reindexing, while preserving interrupt fusion and recovery
68
+ requeue semantics at this orchestration seam.
69
+
70
+ - Date: 2026-04-26
71
+ Spec: plans/2026-04-26-local-codex-driver-worldclass-spec.md
72
+ Relationship: Delegates app-server runtime option construction to
73
+ `codexRuntimeOptions.ts` so this orchestrator applies a single tested mapping
74
+ for model, effort, fast tier, approval policy, and sandbox settings.
75
+
76
+ - Date: 2026-04-26
77
+ Spec: plans/2026-04-26-local-codex-driver-worldclass-spec.md
78
+ Relationship: Delegates user-visible local Codex message state labels,
79
+ approval metadata extraction, and Codex notification-to-processing-reason
80
+ mapping to `localCodexMessageState.ts`.
81
+
82
+ - Date: 2026-04-26
83
+ Spec: plans/2026-04-26-local-codex-driver-worldclass-spec.md
84
+ Relationship: Delegates pure Codex turn lifecycle queries to
85
+ `localCodexTurnState.ts` so interruption, completion, and failure paths share
86
+ one tested interpretation of idle/starting/active/completing states.
87
+
88
+ - Date: 2026-04-26
89
+ Spec: plans/2026-04-26-local-runner-port-forward-approval.md
90
+ Relationship: Watches descendant listener ports, prompts the authorized
91
+ Kandan listener user for approval, and resolves the approval into a dynamic
92
+ runner forwarding capability before publishing the same-origin preview URL.
36
93
  */
37
94
  import {
38
95
  availabilityMessage,
@@ -48,6 +105,10 @@ import {
48
105
  } from "./channelSessionSupport";
49
106
  import { createHash, randomUUID } from "node:crypto";
50
107
  import { type CodexAppServerClient } from "./codexAppServer";
108
+ import {
109
+ codexThreadRuntimeOverrides,
110
+ codexTurnRuntimeOverrides,
111
+ } from "./codexRuntimeOptions";
51
112
  import {
52
113
  codexAssistantDeltaFromNotification,
53
114
  codexAssistantStructuredMessage,
@@ -64,14 +125,46 @@ import {
64
125
  webSearchProgressBody,
65
126
  codexUserInputMessageFromNotification,
66
127
  codexUserInputMessagesForTurn,
128
+ type CodexAssistantDelta,
129
+ type CodexCommandOutputDelta,
130
+ type CodexFileChangeDelta,
131
+ type CodexReasoningDelta,
67
132
  type CodexUserInputMessage,
68
133
  } from "./codexOutput";
69
134
  import { arrayValue, integerValue, objectValue, stringValue } from "./json";
70
135
  import {
71
136
  codexInputForQueuedKandanMessage,
72
- interruptQueuedMessages,
73
137
  type QueuedKandanMessage,
74
138
  } from "./kandanQueue";
139
+ import {
140
+ approvalRequestKey,
141
+ codexApprovalMessageState,
142
+ processingMessageStateFromActive,
143
+ processingReasonForCodexNotification,
144
+ type ActiveProcessingState,
145
+ type CodexApprovalMessageState,
146
+ type LocalCodexMessageState,
147
+ type LocalCodexProcessingReason,
148
+ } from "./localCodexMessageState";
149
+ import {
150
+ activeQueuedSeqForTurn,
151
+ activeTurnId,
152
+ completingQueuedSeqForTurn,
153
+ interruptibleQueuedSeq,
154
+ markInterruptAfterStart,
155
+ shouldClearTurnAfterFailure,
156
+ turnCanForwardByState,
157
+ type TurnState,
158
+ } from "./localCodexTurnState";
159
+ import {
160
+ createPendingKandanMessageQueue,
161
+ dequeuePendingKandanMessage,
162
+ enqueuePendingKandanMessage,
163
+ interruptPendingKandanMessages,
164
+ pendingKandanMessageQueueLength,
165
+ requeuePendingKandanMessageFront,
166
+ type PendingKandanMessageQueue,
167
+ } from "./pendingKandanMessageQueue";
75
168
  import { type PhoenixClient } from "./phoenix";
76
169
  import {
77
170
  type JsonObject,
@@ -82,6 +175,46 @@ import {
82
175
  isJsonObject,
83
176
  } from "./protocol";
84
177
  import { type RunnerLogger } from "./runnerLogger";
178
+ import {
179
+ boundedCacheValues,
180
+ createBoundedCache,
181
+ forgetBoundedCacheValue,
182
+ getBoundedCacheValue,
183
+ rememberBoundedCacheValue,
184
+ type BoundedCache,
185
+ } from "./boundedCache";
186
+ import {
187
+ coalesceAssistantDeltas,
188
+ coalesceCommandOutputDeltas,
189
+ coalesceFileChangeDeltas,
190
+ coalesceReasoningDeltas,
191
+ firstDeltaTurnId,
192
+ } from "./streamDeltaCoalescing";
193
+ import {
194
+ clearStreamDeltaFlushTimer,
195
+ createStreamDeltaQueue,
196
+ enqueueStreamDelta,
197
+ flushStreamDeltaQueue,
198
+ type StreamDeltaQueue,
199
+ type StreamDeltaQueueRuntime,
200
+ } from "./streamDeltaQueue";
201
+ import {
202
+ startPortForwardWatcher,
203
+ type PortForwardCandidate,
204
+ type PortForwardWatcher,
205
+ type PortForwardWatcherOptions,
206
+ } from "./portForwardWatcher";
207
+ import {
208
+ approvedTargetFromRequest,
209
+ forwardPreviewPath,
210
+ pendingRequestFromCandidate,
211
+ portForwardPromptBody,
212
+ portForwardPromptLabel,
213
+ portForwardPromptReason,
214
+ reviewPortForwardCandidate,
215
+ revocationCapabilities,
216
+ type PendingPortForwardRequest,
217
+ } from "./portForwardApproval";
85
218
 
86
219
  export type ChannelSessionRuntime = {
87
220
  readonly handleCodexNotification: (
@@ -102,9 +235,20 @@ export type ChannelSessionRunnerOptions = {
102
235
  readonly codexBin: string;
103
236
  readonly fast?: boolean | undefined;
104
237
  readonly launchTui?: boolean | undefined;
238
+ readonly enablePortForwardWatch?: boolean | undefined;
239
+ readonly initialForwardPorts?: readonly number[] | undefined;
240
+ readonly suppressedForwardPorts?: (() => readonly number[]) | undefined;
241
+ readonly portForwardWatcher?: PortForwardWatchRuntimeOptions | undefined;
242
+ readonly onForwardPortApproved?: ((port: number) => JsonObject | undefined) | undefined;
243
+ readonly onForwardPortRevoked?: ((port: number) => JsonObject | undefined) | undefined;
105
244
  readonly channelSession: KandanChannelSessionOptions;
106
245
  };
107
246
 
247
+ type PortForwardWatchRuntimeOptions =
248
+ Partial<Omit<PortForwardWatcherOptions, "onCandidate">> & {
249
+ readonly start?: ((options: PortForwardWatcherOptions) => PortForwardWatcher) | undefined;
250
+ };
251
+
108
252
  type ChannelSessionContext = {
109
253
  readonly kandan: PhoenixClient;
110
254
  readonly codex: CodexAppServerClient;
@@ -121,39 +265,39 @@ type ChannelSessionState = {
121
265
  turn: TurnState;
122
266
  closed: boolean;
123
267
  minSeq: number;
124
- queue: QueuedKandanMessage[];
125
- forwardedTurnIds: string[];
126
- forwardingTurnIds: string[];
127
- retryableTurnIds: string[];
128
- localTuiTurnIds: string[];
129
- mirroredTuiInputProjections: MirroredTuiInputProjection[];
130
- pendingTuiInputMirrors: PendingTuiInputMirror[];
131
- turnReplyTargets: TurnReplyTarget[];
132
- streamingAssistantOutputs: StreamingAssistantOutput[];
133
- streamingReasoningOutputs: StreamingReasoningOutput[];
134
- streamingCommandOutputs: StreamingCommandOutput[];
135
- streamingFileChangeOutputs: StreamingFileChangeOutput[];
136
- forwardedTerminalInputKeys: string[];
137
- webSearchProgressOutputs: WebSearchProgressOutput[];
138
- pendingApprovalRequests: PendingCodexApprovalRequest[];
268
+ queue: PendingKandanMessageQueue;
269
+ forwardedTurnIds: Set<string>;
270
+ forwardingTurnIds: Set<string>;
271
+ retryableTurnIds: Set<string>;
272
+ localTuiTurnIds: Set<string>;
273
+ mirroredTuiInputProjections: BoundedCache<MirroredTuiInputProjection>;
274
+ pendingTuiInputMirrors: Map<string, Promise<void>>;
275
+ turnReplyTargets: BoundedCache<TurnReplyTarget>;
276
+ streamingAssistantOutputs: BoundedCache<StreamingAssistantOutput>;
277
+ streamingReasoningOutputs: BoundedCache<StreamingReasoningOutput>;
278
+ streamingCommandOutputs: BoundedCache<StreamingCommandOutput>;
279
+ streamingFileChangeOutputs: BoundedCache<StreamingFileChangeOutput>;
280
+ assistantDeltaQueue: StreamDeltaQueue<CodexAssistantDelta>;
281
+ reasoningDeltaQueue: StreamDeltaQueue<CodexReasoningDelta>;
282
+ commandOutputQueue: StreamDeltaQueue<CodexCommandOutputDelta>;
283
+ fileChangeQueue: StreamDeltaQueue<CodexFileChangeDelta>;
284
+ forwardedTerminalInputKeys: Set<string>;
285
+ webSearchProgressOutputs: BoundedCache<WebSearchProgressOutput>;
286
+ pendingApprovalRequests: Map<string, PendingCodexApprovalRequest>;
287
+ pendingPortForwardRequests: Map<string, PendingPortForwardRequest>;
288
+ approvedForwardPorts: Set<number>;
289
+ approvedForwardTargets: Map<number, PortForwardCandidate>;
290
+ dismissedForwardTargets: Map<number, PortForwardCandidate>;
291
+ portForwardWatcher: PortForwardWatcher | undefined;
139
292
  activeProcessingState: ActiveProcessingState | undefined;
140
- assistantDeltaForwardChain: Promise<void>;
141
- reasoningDeltaForwardChain: Promise<void>;
142
- commandOutputForwardChain: Promise<void>;
143
- fileChangeForwardChain: Promise<void>;
144
293
  terminalInputForwardChain: Promise<void>;
145
294
  webSearchProgressForwardChain: Promise<void>;
146
295
  typingHeartbeat: ReturnType<typeof setInterval> | undefined;
147
296
  typingHeartbeatInFlight: boolean;
148
297
  };
149
298
 
150
- type TurnState =
151
- | { readonly status: "idle" }
152
- | { readonly status: "starting"; readonly queuedSeq: number; readonly interruptAfterStart: boolean }
153
- | { readonly status: "active"; readonly turnId: string; readonly queuedSeq: number }
154
- | { readonly status: "completing"; readonly turnId: string; readonly queuedSeq: number };
155
-
156
299
  const codexTypingHeartbeatMs = 5_000;
300
+ const defaultStreamFlushIntervalMs = 150;
157
301
  const maxForwardedTurnIds = 64;
158
302
 
159
303
  type StreamingAssistantOutput = {
@@ -200,27 +344,11 @@ type MirroredTuiInputProjection = {
200
344
  readonly itemKeys: string[];
201
345
  };
202
346
 
203
- type PendingTuiInputMirror = {
204
- readonly turnId: string;
205
- readonly promise: Promise<void>;
206
- };
207
-
208
347
  type TurnReplyTarget = {
209
348
  readonly turnId: string;
210
349
  readonly replyToSeq: number;
211
350
  };
212
351
 
213
- type ActiveProcessingState =
214
- | {
215
- readonly seq: number;
216
- readonly reason: Exclude<LocalCodexProcessingReason, "awaiting approval">;
217
- }
218
- | {
219
- readonly seq: number;
220
- readonly reason: "awaiting approval";
221
- readonly approval: CodexApprovalMessageState;
222
- };
223
-
224
352
  type PendingCodexApprovalRequest = {
225
353
  readonly requestId: string;
226
354
  readonly sourceSeq: number;
@@ -229,12 +357,6 @@ type PendingCodexApprovalRequest = {
229
357
  readonly reject: (error: Error) => void;
230
358
  };
231
359
 
232
- type CodexApprovalMessageState = {
233
- readonly requestId: string;
234
- readonly kind: string;
235
- readonly summary: string;
236
- };
237
-
238
360
  export async function attachChannelSession(
239
361
  args: ChannelSessionContext,
240
362
  ): Promise<ChannelSessionRuntime> {
@@ -283,6 +405,7 @@ export async function attachChannelSession(
283
405
  await bindChannelSession(args, state, payloadContext);
284
406
  await processBufferedKandanEvents(args, state, runnerIdentity, payloadContext, eventBuffer.pending);
285
407
  eventBuffer.ready = true;
408
+ startPortForwardWatchIfEnabled(args, state, payloadContext);
286
409
  await drainKandanMessageQueue(args, state, payloadContext);
287
410
 
288
411
  return {
@@ -401,6 +524,9 @@ export async function attachChannelSession(
401
524
  },
402
525
  close: async () => {
403
526
  state.closed = true;
527
+ state.portForwardWatcher?.close();
528
+ state.portForwardWatcher = undefined;
529
+ clearPendingStreamFlushTimers(state);
404
530
  rejectPendingApprovalRequests(state, new Error("runner closed"));
405
531
  await stopCodexTyping(args, state);
406
532
  },
@@ -436,26 +562,31 @@ function initialChannelSessionState(
436
562
  turn: { status: "idle" },
437
563
  closed: false,
438
564
  minSeq: cursor,
439
- queue: [],
440
- forwardedTurnIds: [],
441
- forwardingTurnIds: [],
442
- retryableTurnIds: [],
443
- localTuiTurnIds: [],
444
- mirroredTuiInputProjections: [],
445
- pendingTuiInputMirrors: [],
446
- turnReplyTargets: [],
447
- streamingAssistantOutputs: [],
448
- streamingReasoningOutputs: [],
449
- streamingCommandOutputs: [],
450
- streamingFileChangeOutputs: [],
451
- forwardedTerminalInputKeys: [],
452
- webSearchProgressOutputs: [],
453
- pendingApprovalRequests: [],
565
+ queue: createPendingKandanMessageQueue(),
566
+ forwardedTurnIds: new Set<string>(),
567
+ forwardingTurnIds: new Set<string>(),
568
+ retryableTurnIds: new Set<string>(),
569
+ localTuiTurnIds: new Set<string>(),
570
+ mirroredTuiInputProjections: createBoundedCache(maxForwardedTurnIds),
571
+ pendingTuiInputMirrors: new Map<string, Promise<void>>(),
572
+ turnReplyTargets: createBoundedCache(maxForwardedTurnIds),
573
+ streamingAssistantOutputs: createBoundedCache(maxForwardedTurnIds),
574
+ streamingReasoningOutputs: createBoundedCache(maxForwardedTurnIds),
575
+ streamingCommandOutputs: createBoundedCache(maxForwardedTurnIds),
576
+ streamingFileChangeOutputs: createBoundedCache(maxForwardedTurnIds),
577
+ assistantDeltaQueue: createStreamDeltaQueue(),
578
+ reasoningDeltaQueue: createStreamDeltaQueue(),
579
+ commandOutputQueue: createStreamDeltaQueue(),
580
+ fileChangeQueue: createStreamDeltaQueue(),
581
+ forwardedTerminalInputKeys: new Set<string>(),
582
+ webSearchProgressOutputs: createBoundedCache(maxForwardedTurnIds),
583
+ pendingApprovalRequests: new Map<string, PendingCodexApprovalRequest>(),
584
+ pendingPortForwardRequests: new Map<string, PendingPortForwardRequest>(),
585
+ approvedForwardPorts: new Set<number>(),
586
+ approvedForwardTargets: new Map<number, PortForwardCandidate>(),
587
+ dismissedForwardTargets: new Map<number, PortForwardCandidate>(),
588
+ portForwardWatcher: undefined,
454
589
  activeProcessingState: undefined,
455
- assistantDeltaForwardChain: Promise.resolve(),
456
- reasoningDeltaForwardChain: Promise.resolve(),
457
- commandOutputForwardChain: Promise.resolve(),
458
- fileChangeForwardChain: Promise.resolve(),
459
590
  terminalInputForwardChain: Promise.resolve(),
460
591
  webSearchProgressForwardChain: Promise.resolve(),
461
592
  typingHeartbeat: undefined,
@@ -463,6 +594,27 @@ function initialChannelSessionState(
463
594
  };
464
595
  }
465
596
 
597
+ function startPortForwardWatchIfEnabled(
598
+ args: ChannelSessionContext,
599
+ state: ChannelSessionState,
600
+ payloadContext: RunnerPayloadContext,
601
+ ): void {
602
+ if (args.options.enablePortForwardWatch !== true || state.portForwardWatcher !== undefined) {
603
+ return;
604
+ }
605
+
606
+ const { start: configuredStart, ...watchOptions } = args.options.portForwardWatcher ?? {};
607
+ const start = configuredStart ?? startPortForwardWatcher;
608
+ for (const port of args.options.initialForwardPorts ?? []) {
609
+ state.approvedForwardPorts.add(port);
610
+ }
611
+ state.portForwardWatcher = start({
612
+ ...watchOptions,
613
+ onCandidate: candidate => publishPortForwardPrompt(args, state, payloadContext, candidate),
614
+ onError: error => args.log("port_forward.watch_failed", { message: error.message }),
615
+ });
616
+ }
617
+
466
618
  async function bindChannelSession(
467
619
  args: ChannelSessionContext,
468
620
  state: ChannelSessionState,
@@ -531,6 +683,10 @@ async function handleChannelSessionControl(
531
683
  return resolvePendingCodexApprovalRequest(args, state, control);
532
684
  }
533
685
 
686
+ if (control.type === "resolve_port_forward_request") {
687
+ return resolvePendingPortForwardRequest(args, state, payloadContext, control);
688
+ }
689
+
534
690
  if (control.type !== "interrupt_queued_messages") {
535
691
  return undefined;
536
692
  }
@@ -543,11 +699,8 @@ async function handleChannelSessionControl(
543
699
  return { instanceId: args.instanceId, ok: false, error: "thread_not_bound" };
544
700
  }
545
701
 
546
- const interrupted = interruptQueuedMessages(state.queue, control.throughSeq);
547
- const activeQueuedSeq =
548
- state.turn.status === "active" || state.turn.status === "starting"
549
- ? state.turn.queuedSeq
550
- : undefined;
702
+ const interrupted = interruptPendingKandanMessages(state.queue, control.throughSeq);
703
+ const activeQueuedSeq = interruptibleQueuedSeq(state.turn);
551
704
  const targetsActiveTurn =
552
705
  control.throughSeq !== undefined && activeQueuedSeq === control.throughSeq;
553
706
 
@@ -563,7 +716,6 @@ async function handleChannelSessionControl(
563
716
  turnId: state.turn.turnId,
564
717
  });
565
718
 
566
- state.queue = interrupted.ok ? interrupted.queue : state.queue;
567
719
  state.turn = { status: "idle" };
568
720
  clearActiveProcessingState(state, interruptedActiveSeq);
569
721
  await publishMessageState(args, state.kandanThreadId, interruptedActiveSeq, {
@@ -571,23 +723,19 @@ async function handleChannelSessionControl(
571
723
  });
572
724
  break;
573
725
  case "starting":
574
- state.queue = interrupted.ok ? interrupted.queue : state.queue;
575
- state.turn = {
576
- status: "starting",
577
- queuedSeq: state.turn.queuedSeq,
578
- interruptAfterStart: true,
579
- };
726
+ state.turn = markInterruptAfterStart(state.turn);
580
727
  break;
581
728
  case "idle":
582
729
  case "completing":
583
- state.queue = interrupted.ok ? interrupted.queue : state.queue;
584
730
  break;
585
731
  }
586
732
 
587
733
  args.log("codex.queued_messages_interrupted", {
588
734
  through_seq: control.throughSeq ?? null,
589
735
  selected_count: interrupted.ok ? interrupted.selectedCount : 0,
590
- remaining_count: interrupted.ok ? interrupted.remainingCount : state.queue.length,
736
+ remaining_count: interrupted.ok
737
+ ? interrupted.remainingCount
738
+ : pendingKandanMessageQueueLength(state.queue),
591
739
  });
592
740
  if (interrupted.ok) {
593
741
  for (const seq of interrupted.selectedSeqs) {
@@ -618,19 +766,15 @@ async function resolvePendingCodexApprovalRequest(
618
766
  return { instanceId: args.instanceId, ok: false, error: "thread_not_bound" };
619
767
  }
620
768
 
621
- const approval = state.pendingApprovalRequests.find(
622
- pending =>
623
- pending.requestId === control.requestId &&
624
- pending.sourceSeq === control.sourceSeq,
769
+ const approval = state.pendingApprovalRequests.get(
770
+ approvalRequestKey(control.requestId, control.sourceSeq),
625
771
  );
626
772
 
627
773
  if (approval === undefined) {
628
774
  return { instanceId: args.instanceId, ok: false, error: "approval_request_not_found" };
629
775
  }
630
776
 
631
- state.pendingApprovalRequests = state.pendingApprovalRequests.filter(
632
- pending => pending !== approval,
633
- );
777
+ state.pendingApprovalRequests.delete(approvalRequestKey(control.requestId, control.sourceSeq));
634
778
 
635
779
  const codexDecision = control.decision === "approve" ? "accept" : "decline";
636
780
  approval.resolve({ decision: codexDecision });
@@ -651,6 +795,335 @@ async function resolvePendingCodexApprovalRequest(
651
795
  return { instanceId: args.instanceId, ok: true };
652
796
  }
653
797
 
798
+ async function resolvePendingPortForwardRequest(
799
+ args: ChannelSessionContext,
800
+ state: ChannelSessionState,
801
+ payloadContext: RunnerPayloadContext,
802
+ control: Extract<KandanControl, { readonly type: "resolve_port_forward_request" }>,
803
+ ): Promise<JsonObject> {
804
+ if (!portForwardControlSenderAllowed(args, payloadContext, control)) {
805
+ args.log("port_forward.request_resolution_ignored", {
806
+ request_id: control.requestId,
807
+ actor_slug: control.actorSlug ?? null,
808
+ actor_user_id: control.actorUserId ?? null,
809
+ reason: "sender_not_allowed",
810
+ });
811
+ return { instanceId: args.instanceId, ok: false, error: "sender_not_allowed" };
812
+ }
813
+
814
+ const request = state.pendingPortForwardRequests.get(control.requestId);
815
+
816
+ if (request === undefined) {
817
+ return { instanceId: args.instanceId, ok: false, error: "port_forward_request_not_found" };
818
+ }
819
+
820
+ state.pendingPortForwardRequests.delete(control.requestId);
821
+
822
+ if (control.decision === "deny") {
823
+ state.dismissedForwardTargets.set(request.port, approvedTargetFromRequest(request));
824
+ await publishMessageStateForPortForwardResult(args, state, request, "failed");
825
+ args.log("port_forward.request_denied", {
826
+ request_id: control.requestId,
827
+ port: request.port,
828
+ pid: request.pid,
829
+ });
830
+ return { instanceId: args.instanceId, ok: true };
831
+ }
832
+
833
+ state.approvedForwardPorts.add(request.port);
834
+ state.approvedForwardTargets.set(request.port, approvedTargetFromRequest(request));
835
+ const capabilities = args.options.onForwardPortApproved?.(request.port);
836
+ await publishForwardPortResolvedEvent(args, request, capabilities);
837
+ await publishMessageStateForPortForwardResult(args, state, request, "processed");
838
+ await publishPortForwardReadyMessage(args, state, payloadContext, request);
839
+
840
+ args.log("port_forward.request_approved", {
841
+ request_id: control.requestId,
842
+ port: request.port,
843
+ pid: request.pid,
844
+ });
845
+
846
+ return { instanceId: args.instanceId, ok: true, port: request.port };
847
+ }
848
+
849
+ function portForwardControlSenderAllowed(
850
+ args: ChannelSessionContext,
851
+ payloadContext: RunnerPayloadContext,
852
+ control: Extract<KandanControl, { readonly type: "resolve_port_forward_request" }>,
853
+ ): boolean {
854
+ if (control.actorSlug === undefined && control.actorUserId === undefined) {
855
+ return false;
856
+ }
857
+
858
+ return senderAllowed(
859
+ args.options.channelSession.listenUser,
860
+ {
861
+ actorSlug: control.actorSlug,
862
+ actorUserId: control.actorUserId,
863
+ },
864
+ payloadContext.runnerIdentity,
865
+ );
866
+ }
867
+
868
+ async function publishPortForwardPrompt(
869
+ args: ChannelSessionContext,
870
+ state: ChannelSessionState,
871
+ payloadContext: RunnerPayloadContext,
872
+ candidate: PortForwardCandidate,
873
+ ): Promise<void> {
874
+ if (state.kandanThreadId === undefined || state.codexThreadId === undefined) {
875
+ args.log("port_forward.prompt_skipped", {
876
+ port: candidate.port,
877
+ pid: candidate.pid,
878
+ reason: "thread_not_bound",
879
+ });
880
+ return;
881
+ }
882
+
883
+ const review = reviewPortForwardCandidate({
884
+ candidate,
885
+ threadBound: true,
886
+ suppressedPorts: new Set(args.options.suppressedForwardPorts?.() ?? []),
887
+ approvedPorts: state.approvedForwardPorts,
888
+ approvedTargets: state.approvedForwardTargets,
889
+ dismissedTargets: state.dismissedForwardTargets,
890
+ pendingRequests: Array.from(state.pendingPortForwardRequests.values()),
891
+ });
892
+
893
+ switch (review.type) {
894
+ case "skip":
895
+ return;
896
+ case "remember_approved_target":
897
+ state.approvedForwardTargets.set(review.target.port, review.target);
898
+ return;
899
+ case "revoke_and_prompt":
900
+ await revokeApprovedForwardPort(args, state, review.revoked, review.reason);
901
+ break;
902
+ case "prompt":
903
+ break;
904
+ }
905
+
906
+ const requestId = `port-forward-${randomUUID()}`;
907
+ const label = portForwardPromptLabel(candidate);
908
+ const body = portForwardPromptBody(candidate, requestId);
909
+ const payload = {
910
+ ...localRunnerPayload(
911
+ args.options,
912
+ args.instanceId,
913
+ "port_forward_request",
914
+ state.codexThreadId,
915
+ payloadContext,
916
+ ),
917
+ reply_to_seq: state.rootSeq ?? null,
918
+ structured: {
919
+ kind: "local_runner_port_forward_request",
920
+ request_id: requestId,
921
+ port: candidate.port,
922
+ pid: candidate.pid,
923
+ command: candidate.command,
924
+ ...(candidate.cwd === undefined ? {} : { cwd: candidate.cwd }),
925
+ command_label: label,
926
+ },
927
+ };
928
+
929
+ const reply = await pushOk(args.kandan, args.topic, "session:post_thread_message", {
930
+ workspace: args.options.channelSession.workspaceSlug,
931
+ channel: args.options.channelSession.channelSlug,
932
+ thread_id: state.kandanThreadId,
933
+ body,
934
+ payload,
935
+ });
936
+ const sourceSeq = integerValue(reply.seq);
937
+
938
+ if (sourceSeq === undefined) {
939
+ throw new Error("port forward prompt did not return a Kandan message seq");
940
+ }
941
+
942
+ const request = pendingRequestFromCandidate({
943
+ requestId,
944
+ sourceSeq,
945
+ candidate,
946
+ });
947
+ state.pendingPortForwardRequests.set(requestId, request);
948
+
949
+ await publishForwardPortRequestedEvent(args, request);
950
+ await publishMessageState(args, state.kandanThreadId, sourceSeq, {
951
+ status: "processing",
952
+ reason: "awaiting approval",
953
+ approval: {
954
+ requestId,
955
+ kind: "local_runner_port_forward",
956
+ summary: `Open runner port ${candidate.port} from ${label}`,
957
+ reason: portForwardPromptReason(candidate),
958
+ choices: [
959
+ {
960
+ decision: "approve",
961
+ label: "Open preview",
962
+ description: `Allow Kandan to proxy HTTP, HTTPS, and WebSocket traffic for runner port ${candidate.port}.`,
963
+ },
964
+ {
965
+ decision: "deny",
966
+ label: "Deny",
967
+ description: `Keep runner port ${candidate.port} private for this runner session.`,
968
+ },
969
+ ],
970
+ allowedActorSlug: args.options.channelSession.listenUser,
971
+ allowedActorUserId:
972
+ args.options.channelSession.listenUser.toLowerCase() ===
973
+ (payloadContext.runnerIdentity.actorUsername ?? "").toLowerCase()
974
+ ? payloadContext.runnerIdentity.actorUserId
975
+ : undefined,
976
+ },
977
+ });
978
+
979
+ args.log("port_forward.request_pending", {
980
+ request_id: requestId,
981
+ port: candidate.port,
982
+ pid: candidate.pid,
983
+ });
984
+ }
985
+
986
+ async function revokeApprovedForwardPort(
987
+ args: ChannelSessionContext,
988
+ state: ChannelSessionState,
989
+ target: PortForwardCandidate,
990
+ reason: "listener_changed",
991
+ ): Promise<void> {
992
+ state.approvedForwardPorts.delete(target.port);
993
+ state.approvedForwardTargets.delete(target.port);
994
+ const capabilities = args.options.onForwardPortRevoked?.(target.port);
995
+
996
+ await pushOptional(args.kandan, args.topic, "forward_port_revoked", {
997
+ instanceId: args.instanceId,
998
+ port: target.port,
999
+ pid: target.pid,
1000
+ command: target.command,
1001
+ ...(target.cwd === undefined ? {} : { cwd: target.cwd }),
1002
+ reason,
1003
+ capabilities: revocationCapabilities(capabilities, target.port),
1004
+ }, args.log);
1005
+
1006
+ args.log("port_forward.approved_port_revoked", {
1007
+ port: target.port,
1008
+ pid: target.pid,
1009
+ reason,
1010
+ });
1011
+ }
1012
+
1013
+ async function publishPortForwardReadyMessage(
1014
+ args: ChannelSessionContext,
1015
+ state: ChannelSessionState,
1016
+ payloadContext: RunnerPayloadContext,
1017
+ request: PendingPortForwardRequest,
1018
+ ): Promise<void> {
1019
+ if (state.kandanThreadId === undefined || state.codexThreadId === undefined) {
1020
+ return;
1021
+ }
1022
+
1023
+ const path = forwardPreviewPath(args.options.runnerId, request.port);
1024
+ await pushOptional(args.kandan, args.topic, "session:post_thread_message", {
1025
+ workspace: args.options.channelSession.workspaceSlug,
1026
+ channel: args.options.channelSession.channelSlug,
1027
+ thread_id: state.kandanThreadId,
1028
+ body: `Runner port ${request.port} is open in Kandan: [Open preview](${path})`,
1029
+ payload: {
1030
+ ...localRunnerPayload(
1031
+ args.options,
1032
+ args.instanceId,
1033
+ "port_forward_ready",
1034
+ state.codexThreadId,
1035
+ payloadContext,
1036
+ ),
1037
+ ...(state.rootSeq === undefined ? {} : { reply_to_seq: state.rootSeq }),
1038
+ structured: {
1039
+ kind: "local_runner_port_forward_ready",
1040
+ status: "ready",
1041
+ summary: `Runner port ${request.port} is open in Kandan.`,
1042
+ next_action: "Open HTTP/HTTPS/WebSocket preview",
1043
+ source_path: path,
1044
+ link_label: "Open preview",
1045
+ request_id: request.requestId,
1046
+ port: request.port,
1047
+ pid: request.pid,
1048
+ command: request.command,
1049
+ ...(request.cwd === undefined ? {} : { cwd: request.cwd }),
1050
+ url: path,
1051
+ },
1052
+ },
1053
+ }, args.log);
1054
+ }
1055
+
1056
+ async function publishForwardPortRequestedEvent(
1057
+ args: ChannelSessionContext,
1058
+ request: PendingPortForwardRequest,
1059
+ ): Promise<void> {
1060
+ await pushOptional(args.kandan, args.topic, "forward_port_requested", {
1061
+ instanceId: args.instanceId,
1062
+ requestId: request.requestId,
1063
+ port: request.port,
1064
+ pid: request.pid,
1065
+ command: request.command,
1066
+ ...(request.cwd === undefined ? {} : { cwd: request.cwd }),
1067
+ }, args.log);
1068
+ }
1069
+
1070
+ async function publishForwardPortResolvedEvent(
1071
+ args: ChannelSessionContext,
1072
+ request: PendingPortForwardRequest,
1073
+ capabilities: JsonObject | undefined,
1074
+ ): Promise<void> {
1075
+ await pushOptional(args.kandan, args.topic, "forward_port_resolved", {
1076
+ instanceId: args.instanceId,
1077
+ requestId: request.requestId,
1078
+ port: request.port,
1079
+ pid: request.pid,
1080
+ command: request.command,
1081
+ ...(request.cwd === undefined ? {} : { cwd: request.cwd }),
1082
+ decision: "approve",
1083
+ ...(capabilities === undefined ? {} : { capabilities }),
1084
+ }, args.log);
1085
+ }
1086
+
1087
+ async function publishMessageStateForPortForwardResult(
1088
+ args: ChannelSessionContext,
1089
+ state: ChannelSessionState,
1090
+ request: PendingPortForwardRequest,
1091
+ status: "processed" | "failed",
1092
+ ): Promise<void> {
1093
+ if (state.kandanThreadId === undefined) {
1094
+ return;
1095
+ }
1096
+
1097
+ switch (status) {
1098
+ case "processed":
1099
+ await publishMessageState(args, state.kandanThreadId, request.sourceSeq, {
1100
+ status: "processed",
1101
+ });
1102
+ break;
1103
+ case "failed":
1104
+ await publishMessageState(args, state.kandanThreadId, request.sourceSeq, {
1105
+ status: "failed",
1106
+ reason: "port_forward_denied",
1107
+ });
1108
+ break;
1109
+ }
1110
+ }
1111
+
1112
+ function parsePortForwardDecision(
1113
+ body: string,
1114
+ ): { readonly decision: "approve" | "deny"; readonly requestId: string } | undefined {
1115
+ const match = body.trim().match(/^\/kandan\s+(approve|deny)-port-forward\s+(\S+)$/i);
1116
+
1117
+ if (match === null) {
1118
+ return undefined;
1119
+ }
1120
+
1121
+ return {
1122
+ decision: match[1]?.toLocaleLowerCase() === "approve" ? "approve" : "deny",
1123
+ requestId: match[2] ?? "",
1124
+ };
1125
+ }
1126
+
654
1127
  async function handleKandanChatEvent(
655
1128
  args: ChannelSessionContext,
656
1129
  state: ChannelSessionState,
@@ -698,6 +1171,28 @@ async function handleKandanChatEvent(
698
1171
  return;
699
1172
  }
700
1173
 
1174
+ const portForwardDecision = parsePortForwardDecision(event.body);
1175
+ if (portForwardDecision !== undefined) {
1176
+ const result = await resolvePendingPortForwardRequest(args, state, payloadContext, {
1177
+ type: "resolve_port_forward_request",
1178
+ instanceId: args.instanceId,
1179
+ requestId: portForwardDecision.requestId,
1180
+ decision: portForwardDecision.decision,
1181
+ actorUserId: event.actorUserId,
1182
+ actorSlug: event.actorSlug,
1183
+ });
1184
+
1185
+ if (result.ok === true) {
1186
+ await publishKandanMessageState(args, event, { status: "processed" });
1187
+ } else {
1188
+ await publishKandanMessageState(args, event, {
1189
+ status: "failed",
1190
+ reason: stringValue(result.error) ?? "port_forward_decision_failed",
1191
+ });
1192
+ }
1193
+ return;
1194
+ }
1195
+
701
1196
  if (state.kandanThreadId === undefined) {
702
1197
  if (state.rootSeq === undefined || event.replyToSeq !== state.rootSeq) {
703
1198
  const bound = await bindUnboundHistoricalThread(args, state, event);
@@ -734,7 +1229,7 @@ async function handleKandanChatEvent(
734
1229
  return;
735
1230
  }
736
1231
 
737
- state.queue.push({
1232
+ enqueuePendingKandanMessage(state.queue, {
738
1233
  seq: event.seq,
739
1234
  actorSlug: event.actorSlug,
740
1235
  actorUserId: event.actorUserId,
@@ -744,7 +1239,7 @@ async function handleKandanChatEvent(
744
1239
  seq: event.seq,
745
1240
  actor_slug: event.actorSlug ?? null,
746
1241
  actor_user_id: event.actorUserId ?? null,
747
- queue_depth: state.queue.length,
1242
+ queue_depth: pendingKandanMessageQueueLength(state.queue),
748
1243
  });
749
1244
  await publishKandanMessageState(args, event, { status: "queued" });
750
1245
  await drainKandanMessageQueue(args, state, payloadContext);
@@ -852,7 +1347,7 @@ async function drainKandanMessageQueue(
852
1347
  return;
853
1348
  }
854
1349
 
855
- const next = state.queue.shift();
1350
+ const next = dequeuePendingKandanMessage(state.queue);
856
1351
 
857
1352
  if (next === undefined) {
858
1353
  return;
@@ -865,7 +1360,7 @@ async function drainKandanMessageQueue(
865
1360
  actor_slug: next.actorSlug ?? null,
866
1361
  actor_user_id: next.actorUserId ?? null,
867
1362
  codex_thread_id: state.codexThreadId ?? null,
868
- queue_depth: state.queue.length,
1363
+ queue_depth: pendingKandanMessageQueueLength(state.queue),
869
1364
  });
870
1365
  await publishQueuedMessageState(args, state, next, {
871
1366
  status: "processing",
@@ -928,7 +1423,7 @@ async function drainKandanMessageQueue(
928
1423
  oldCodexThreadId,
929
1424
  newCodexThreadId,
930
1425
  );
931
- state.queue = [next, ...state.queue];
1426
+ requeuePendingKandanMessageFront(state.queue, next);
932
1427
  state.turn = { status: "idle" };
933
1428
  await drainKandanMessageQueue(args, state, payloadContext);
934
1429
  return;
@@ -1026,7 +1521,7 @@ async function requestKandanApproval(
1026
1521
  turnId: string,
1027
1522
  payloadContext: RunnerPayloadContext,
1028
1523
  ): Promise<JsonObject> {
1029
- const sourceSeq = activeQueuedSeqForTurn(state, turnId);
1524
+ const sourceSeq = activeQueuedSeqForTurn(state.turn, turnId);
1030
1525
 
1031
1526
  if (sourceSeq === undefined || state.kandanThreadId === undefined) {
1032
1527
  const message = `Codex approval request has no active Kandan source message: ${request.method}`;
@@ -1050,43 +1545,23 @@ async function requestKandanApproval(
1050
1545
  });
1051
1546
 
1052
1547
  return new Promise<JsonObject>((resolve, reject) => {
1053
- state.pendingApprovalRequests = [
1054
- ...state.pendingApprovalRequests,
1055
- {
1056
- requestId: approval.requestId,
1057
- sourceSeq,
1058
- turnId,
1059
- resolve,
1060
- reject,
1061
- },
1062
- ];
1548
+ const request = {
1549
+ requestId: approval.requestId,
1550
+ sourceSeq,
1551
+ turnId,
1552
+ resolve,
1553
+ reject,
1554
+ };
1555
+ state.pendingApprovalRequests.set(
1556
+ approvalRequestKey(request.requestId, request.sourceSeq),
1557
+ request,
1558
+ );
1063
1559
  });
1064
1560
  }
1065
1561
 
1066
- function codexApprovalMessageState(request: JsonRpcRequest): CodexApprovalMessageState {
1067
- const params = objectValue(request.params) ?? {};
1068
- const command = stringValue(params.command) ?? stringValue(params.cmd);
1069
- const filePath =
1070
- stringValue(params.path) ??
1071
- stringValue(params.filePath) ??
1072
- stringValue(params.file);
1073
- const summary =
1074
- command ??
1075
- filePath ??
1076
- stringValue(params.reason) ??
1077
- stringValue(params.summary) ??
1078
- request.method;
1079
-
1080
- return {
1081
- requestId: String(request.id),
1082
- kind: request.method,
1083
- summary,
1084
- };
1085
- }
1086
-
1087
1562
  function rejectPendingApprovalRequests(state: ChannelSessionState, error: Error): void {
1088
- const pendingApprovals = state.pendingApprovalRequests;
1089
- state.pendingApprovalRequests = [];
1563
+ const pendingApprovals = [...state.pendingApprovalRequests.values()];
1564
+ state.pendingApprovalRequests.clear();
1090
1565
  pendingApprovals.forEach(approval => approval.reject(error));
1091
1566
  }
1092
1567
 
@@ -1095,11 +1570,12 @@ function rejectPendingApprovalRequestsForTurn(
1095
1570
  turnId: string,
1096
1571
  error: Error,
1097
1572
  ): void {
1098
- const pendingApprovals = state.pendingApprovalRequests;
1099
- state.pendingApprovalRequests = pendingApprovals.filter(approval => approval.turnId !== turnId);
1100
- pendingApprovals
1101
- .filter(approval => approval.turnId === turnId)
1102
- .forEach(approval => approval.reject(error));
1573
+ for (const [key, approval] of state.pendingApprovalRequests) {
1574
+ if (approval.turnId === turnId) {
1575
+ state.pendingApprovalRequests.delete(key);
1576
+ approval.reject(error);
1577
+ }
1578
+ }
1103
1579
  }
1104
1580
 
1105
1581
  async function forwardCompletedCodexTurn(
@@ -1115,24 +1591,21 @@ async function forwardCompletedCodexTurn(
1115
1591
  if (
1116
1592
  state.kandanThreadId === undefined ||
1117
1593
  state.codexThreadId === undefined ||
1118
- state.forwardedTurnIds.includes(turnId) ||
1119
- state.forwardingTurnIds.includes(turnId) ||
1594
+ state.forwardedTurnIds.has(turnId) ||
1595
+ state.forwardingTurnIds.has(turnId) ||
1120
1596
  !turnCanForward(state, turnId)
1121
1597
  ) {
1122
1598
  return;
1123
1599
  }
1124
1600
 
1125
- const completingQueuedSeq =
1126
- state.turn.status === "active" && state.turn.turnId === turnId
1127
- ? state.turn.queuedSeq
1128
- : undefined;
1601
+ const completingQueuedSeq = completingQueuedSeqForTurn(state.turn, turnId);
1129
1602
  const completingActiveTurn = completingQueuedSeq !== undefined;
1130
1603
  const completingLocalTuiTurn = isLocalTuiTurn(state, turnId);
1131
1604
  if (completingQueuedSeq !== undefined) {
1132
1605
  state.turn = { status: "completing", turnId, queuedSeq: completingQueuedSeq };
1133
1606
  }
1134
1607
  await waitForPendingTuiInputMirror(state, turnId);
1135
- await waitForStreamingForwardChains(state);
1608
+ await waitForStreamingForwardChains(args, state, payloadContext);
1136
1609
  rememberForwardingTurnId(state, turnId);
1137
1610
  forgetRetryableTurnId(state, turnId);
1138
1611
 
@@ -1200,7 +1673,7 @@ async function forwardCompletedCodexTurn(
1200
1673
 
1201
1674
  if (
1202
1675
  stringValue(message.structured.kind) === "codex_terminal_input" &&
1203
- state.forwardedTerminalInputKeys.includes(message.itemKey)
1676
+ state.forwardedTerminalInputKeys.has(message.itemKey)
1204
1677
  ) {
1205
1678
  continue;
1206
1679
  }
@@ -1311,13 +1784,22 @@ async function forwardAssistantDelta(
1311
1784
  params: JsonObject,
1312
1785
  payloadContext: RunnerPayloadContext,
1313
1786
  ): Promise<void> {
1314
- if (state.kandanThreadId === undefined || state.codexThreadId === undefined) {
1787
+ const delta = codexAssistantDeltaFromNotification(params);
1788
+
1789
+ if (delta === undefined) {
1315
1790
  return;
1316
1791
  }
1317
1792
 
1318
- const delta = codexAssistantDeltaFromNotification(params);
1793
+ await forwardAssistantDeltaPayload(args, state, delta, payloadContext);
1794
+ }
1319
1795
 
1320
- if (delta === undefined) {
1796
+ async function forwardAssistantDeltaPayload(
1797
+ args: ChannelSessionContext,
1798
+ state: ChannelSessionState,
1799
+ delta: CodexAssistantDelta,
1800
+ payloadContext: RunnerPayloadContext,
1801
+ ): Promise<void> {
1802
+ if (state.kandanThreadId === undefined || state.codexThreadId === undefined) {
1321
1803
  return;
1322
1804
  }
1323
1805
 
@@ -1421,17 +1903,17 @@ function enqueueAssistantDelta(
1421
1903
  params: JsonObject,
1422
1904
  payloadContext: RunnerPayloadContext,
1423
1905
  ): void {
1424
- const previous = state.assistantDeltaForwardChain;
1425
- const next = previous
1426
- .catch(() => undefined)
1427
- .then(() => forwardAssistantDelta(args, state, params, payloadContext));
1906
+ const delta = codexAssistantDeltaFromNotification(params);
1428
1907
 
1429
- state.assistantDeltaForwardChain = next.catch(error => {
1430
- args.log("codex.delta_forward_failed", {
1431
- turn_id: codexNotificationTurnId(params) ?? null,
1432
- message: error instanceof Error ? error.message : String(error),
1433
- });
1434
- });
1908
+ if (delta === undefined) {
1909
+ return;
1910
+ }
1911
+
1912
+ enqueueStreamDelta(
1913
+ state.assistantDeltaQueue,
1914
+ delta,
1915
+ assistantDeltaQueueRuntime(args, state, payloadContext),
1916
+ );
1435
1917
  }
1436
1918
 
1437
1919
  function enqueueReasoningDelta(
@@ -1440,17 +1922,17 @@ function enqueueReasoningDelta(
1440
1922
  params: JsonObject,
1441
1923
  payloadContext: RunnerPayloadContext,
1442
1924
  ): void {
1443
- const previous = state.reasoningDeltaForwardChain;
1444
- const next = previous
1445
- .catch(() => undefined)
1446
- .then(() => forwardReasoningDelta(args, state, params, payloadContext));
1925
+ const delta = codexReasoningDeltaFromNotification(params);
1447
1926
 
1448
- state.reasoningDeltaForwardChain = next.catch(error => {
1449
- args.log("codex.reasoning_delta_forward_failed", {
1450
- turn_id: codexNotificationTurnId(params) ?? null,
1451
- message: error instanceof Error ? error.message : String(error),
1452
- });
1453
- });
1927
+ if (delta === undefined) {
1928
+ return;
1929
+ }
1930
+
1931
+ enqueueStreamDelta(
1932
+ state.reasoningDeltaQueue,
1933
+ delta,
1934
+ reasoningDeltaQueueRuntime(args, state, payloadContext),
1935
+ );
1454
1936
  }
1455
1937
 
1456
1938
  function enqueueCommandOutputDelta(
@@ -1459,17 +1941,17 @@ function enqueueCommandOutputDelta(
1459
1941
  params: JsonObject,
1460
1942
  payloadContext: RunnerPayloadContext,
1461
1943
  ): void {
1462
- const previous = state.commandOutputForwardChain;
1463
- const next = previous
1464
- .catch(() => undefined)
1465
- .then(() => forwardCommandOutputDelta(args, state, params, payloadContext));
1944
+ const delta = codexCommandOutputDeltaFromNotification(params);
1466
1945
 
1467
- state.commandOutputForwardChain = next.catch(error => {
1468
- args.log("codex.command_output_forward_failed", {
1469
- turn_id: codexNotificationTurnId(params) ?? null,
1470
- message: error instanceof Error ? error.message : String(error),
1471
- });
1472
- });
1946
+ if (delta === undefined) {
1947
+ return;
1948
+ }
1949
+
1950
+ enqueueStreamDelta(
1951
+ state.commandOutputQueue,
1952
+ delta,
1953
+ commandOutputQueueRuntime(args, state, payloadContext),
1954
+ );
1473
1955
  }
1474
1956
 
1475
1957
  function enqueueFileChangeDelta(
@@ -1478,17 +1960,97 @@ function enqueueFileChangeDelta(
1478
1960
  params: JsonObject,
1479
1961
  payloadContext: RunnerPayloadContext,
1480
1962
  ): void {
1481
- const previous = state.fileChangeForwardChain;
1482
- const next = previous
1483
- .catch(() => undefined)
1484
- .then(() => forwardFileChangeDelta(args, state, params, payloadContext));
1963
+ const delta = codexFileChangeDeltaFromNotification(params);
1485
1964
 
1486
- state.fileChangeForwardChain = next.catch(error => {
1487
- args.log("codex.file_change_forward_failed", {
1488
- turn_id: codexNotificationTurnId(params) ?? null,
1489
- message: error instanceof Error ? error.message : String(error),
1490
- });
1491
- });
1965
+ if (delta === undefined) {
1966
+ return;
1967
+ }
1968
+
1969
+ enqueueStreamDelta(
1970
+ state.fileChangeQueue,
1971
+ delta,
1972
+ fileChangeQueueRuntime(args, state, payloadContext),
1973
+ );
1974
+ }
1975
+
1976
+ function assistantDeltaQueueRuntime(
1977
+ args: ChannelSessionContext,
1978
+ state: ChannelSessionState,
1979
+ payloadContext: RunnerPayloadContext,
1980
+ ): StreamDeltaQueueRuntime<CodexAssistantDelta> {
1981
+ return {
1982
+ flushIntervalMs: streamFlushIntervalMs(args),
1983
+ coalesce: coalesceAssistantDeltas,
1984
+ firstTurnId: firstDeltaTurnId,
1985
+ forward: delta => forwardAssistantDeltaPayload(args, state, delta, payloadContext),
1986
+ logFailure: (turnId, error) => {
1987
+ args.log("codex.delta_forward_failed", {
1988
+ turn_id: turnId ?? null,
1989
+ message: error instanceof Error ? error.message : String(error),
1990
+ });
1991
+ },
1992
+ };
1993
+ }
1994
+
1995
+ function reasoningDeltaQueueRuntime(
1996
+ args: ChannelSessionContext,
1997
+ state: ChannelSessionState,
1998
+ payloadContext: RunnerPayloadContext,
1999
+ ): StreamDeltaQueueRuntime<CodexReasoningDelta> {
2000
+ return {
2001
+ flushIntervalMs: streamFlushIntervalMs(args),
2002
+ coalesce: coalesceReasoningDeltas,
2003
+ firstTurnId: firstDeltaTurnId,
2004
+ forward: delta => forwardReasoningDeltaPayload(args, state, delta, payloadContext),
2005
+ logFailure: (turnId, error) => {
2006
+ args.log("codex.reasoning_delta_forward_failed", {
2007
+ turn_id: turnId ?? null,
2008
+ message: error instanceof Error ? error.message : String(error),
2009
+ });
2010
+ },
2011
+ };
2012
+ }
2013
+
2014
+ function commandOutputQueueRuntime(
2015
+ args: ChannelSessionContext,
2016
+ state: ChannelSessionState,
2017
+ payloadContext: RunnerPayloadContext,
2018
+ ): StreamDeltaQueueRuntime<CodexCommandOutputDelta> {
2019
+ return {
2020
+ flushIntervalMs: streamFlushIntervalMs(args),
2021
+ coalesce: coalesceCommandOutputDeltas,
2022
+ firstTurnId: firstDeltaTurnId,
2023
+ forward: delta => forwardCommandOutputDeltaPayload(args, state, delta, payloadContext),
2024
+ logFailure: (turnId, error) => {
2025
+ args.log("codex.command_output_forward_failed", {
2026
+ turn_id: turnId ?? null,
2027
+ message: error instanceof Error ? error.message : String(error),
2028
+ });
2029
+ },
2030
+ };
2031
+ }
2032
+
2033
+ function fileChangeQueueRuntime(
2034
+ args: ChannelSessionContext,
2035
+ state: ChannelSessionState,
2036
+ payloadContext: RunnerPayloadContext,
2037
+ ): StreamDeltaQueueRuntime<CodexFileChangeDelta> {
2038
+ return {
2039
+ flushIntervalMs: streamFlushIntervalMs(args),
2040
+ coalesce: coalesceFileChangeDeltas,
2041
+ firstTurnId: firstDeltaTurnId,
2042
+ forward: delta => forwardFileChangeDeltaPayload(args, state, delta, payloadContext),
2043
+ logFailure: (turnId, error) => {
2044
+ args.log("codex.file_change_forward_failed", {
2045
+ turn_id: turnId ?? null,
2046
+ message: error instanceof Error ? error.message : String(error),
2047
+ });
2048
+ },
2049
+ };
2050
+ }
2051
+
2052
+ function streamFlushIntervalMs(args: ChannelSessionContext): number {
2053
+ return args.options.channelSession.streamFlushMs ?? defaultStreamFlushIntervalMs;
1492
2054
  }
1493
2055
 
1494
2056
  function enqueueTerminalInput(
@@ -1552,12 +2114,12 @@ function enqueueFileChangeCompletion(
1552
2114
  state: ChannelSessionState,
1553
2115
  turnId: string,
1554
2116
  ): void {
1555
- const previous = state.fileChangeForwardChain;
2117
+ const previous = state.fileChangeQueue.chain;
1556
2118
  const next = previous
1557
2119
  .catch(() => undefined)
1558
2120
  .then(() => completeFileChangeOutputs(args, state, turnId));
1559
2121
 
1560
- state.fileChangeForwardChain = next.catch(error => {
2122
+ state.fileChangeQueue.chain = next.catch(error => {
1561
2123
  args.log("codex.file_change_completion_failed", {
1562
2124
  turn_id: turnId,
1563
2125
  message: error instanceof Error ? error.message : String(error),
@@ -1571,17 +2133,26 @@ async function forwardReasoningDelta(
1571
2133
  params: JsonObject,
1572
2134
  payloadContext: RunnerPayloadContext,
1573
2135
  ): Promise<void> {
1574
- if (state.kandanThreadId === undefined || state.codexThreadId === undefined) {
2136
+ const delta = codexReasoningDeltaFromNotification(params);
2137
+
2138
+ if (delta === undefined) {
1575
2139
  return;
1576
2140
  }
1577
2141
 
1578
- const delta = codexReasoningDeltaFromNotification(params);
2142
+ await forwardReasoningDeltaPayload(args, state, delta, payloadContext);
2143
+ }
1579
2144
 
1580
- if (delta === undefined) {
2145
+ async function forwardReasoningDeltaPayload(
2146
+ args: ChannelSessionContext,
2147
+ state: ChannelSessionState,
2148
+ delta: CodexReasoningDelta,
2149
+ payloadContext: RunnerPayloadContext,
2150
+ ): Promise<void> {
2151
+ if (state.kandanThreadId === undefined || state.codexThreadId === undefined) {
1581
2152
  return;
1582
2153
  }
1583
2154
 
1584
- const turnId = delta.turnId ?? activeTurnId(state);
2155
+ const turnId = delta.turnId ?? activeTurnId(state.turn);
1585
2156
 
1586
2157
  if (turnId === undefined || turnIsFinalizingOrForwarded(state, turnId)) {
1587
2158
  return;
@@ -1662,17 +2233,26 @@ async function forwardCommandOutputDelta(
1662
2233
  params: JsonObject,
1663
2234
  payloadContext: RunnerPayloadContext,
1664
2235
  ): Promise<void> {
1665
- if (state.kandanThreadId === undefined || state.codexThreadId === undefined) {
2236
+ const delta = codexCommandOutputDeltaFromNotification(params);
2237
+
2238
+ if (delta === undefined) {
1666
2239
  return;
1667
2240
  }
1668
2241
 
1669
- const delta = codexCommandOutputDeltaFromNotification(params);
2242
+ await forwardCommandOutputDeltaPayload(args, state, delta, payloadContext);
2243
+ }
1670
2244
 
1671
- if (delta === undefined) {
2245
+ async function forwardCommandOutputDeltaPayload(
2246
+ args: ChannelSessionContext,
2247
+ state: ChannelSessionState,
2248
+ delta: CodexCommandOutputDelta,
2249
+ payloadContext: RunnerPayloadContext,
2250
+ ): Promise<void> {
2251
+ if (state.kandanThreadId === undefined || state.codexThreadId === undefined) {
1672
2252
  return;
1673
2253
  }
1674
2254
 
1675
- const turnId = delta.turnId ?? activeTurnId(state);
2255
+ const turnId = delta.turnId ?? activeTurnId(state.turn);
1676
2256
 
1677
2257
  if (turnId === undefined || turnIsFinalizingOrForwarded(state, turnId)) {
1678
2258
  return;
@@ -1763,17 +2343,26 @@ async function forwardFileChangeDelta(
1763
2343
  params: JsonObject,
1764
2344
  payloadContext: RunnerPayloadContext,
1765
2345
  ): Promise<void> {
1766
- if (state.kandanThreadId === undefined || state.codexThreadId === undefined) {
2346
+ const delta = codexFileChangeDeltaFromNotification(params);
2347
+
2348
+ if (delta === undefined) {
1767
2349
  return;
1768
2350
  }
1769
2351
 
1770
- const delta = codexFileChangeDeltaFromNotification(params);
2352
+ await forwardFileChangeDeltaPayload(args, state, delta, payloadContext);
2353
+ }
1771
2354
 
1772
- if (delta === undefined) {
2355
+ async function forwardFileChangeDeltaPayload(
2356
+ args: ChannelSessionContext,
2357
+ state: ChannelSessionState,
2358
+ delta: CodexFileChangeDelta,
2359
+ payloadContext: RunnerPayloadContext,
2360
+ ): Promise<void> {
2361
+ if (state.kandanThreadId === undefined || state.codexThreadId === undefined) {
1773
2362
  return;
1774
2363
  }
1775
2364
 
1776
- const turnId = delta.turnId ?? activeTurnId(state);
2365
+ const turnId = delta.turnId ?? activeTurnId(state.turn);
1777
2366
 
1778
2367
  if (turnId === undefined || turnIsFinalizingOrForwarded(state, turnId)) {
1779
2368
  return;
@@ -1859,11 +2448,11 @@ async function forwardTerminalInput(
1859
2448
 
1860
2449
  const terminal = codexTerminalInputFromNotification(params);
1861
2450
 
1862
- if (terminal === undefined || state.forwardedTerminalInputKeys.includes(terminal.itemKey)) {
2451
+ if (terminal === undefined || state.forwardedTerminalInputKeys.has(terminal.itemKey)) {
1863
2452
  return;
1864
2453
  }
1865
2454
 
1866
- const turnId = terminal.turnId ?? activeTurnId(state);
2455
+ const turnId = terminal.turnId ?? activeTurnId(state.turn);
1867
2456
 
1868
2457
  if (turnId === undefined || turnIsFinalizingOrForwarded(state, turnId)) {
1869
2458
  return;
@@ -2023,7 +2612,7 @@ async function completeFileChangeOutputs(
2023
2612
  state: ChannelSessionState,
2024
2613
  turnId: string,
2025
2614
  ): Promise<void> {
2026
- const outputs = state.streamingFileChangeOutputs.filter(
2615
+ const outputs = boundedCacheValues(state.streamingFileChangeOutputs).filter(
2027
2616
  output => output.turnId === turnId,
2028
2617
  );
2029
2618
 
@@ -2215,21 +2804,18 @@ function findMirroredTuiInputProjection(
2215
2804
  state: ChannelSessionState,
2216
2805
  logicalKey: string,
2217
2806
  ): MirroredTuiInputProjection | undefined {
2218
- return state.mirroredTuiInputProjections.find(
2219
- projection => projection.logicalKey === logicalKey,
2220
- );
2807
+ return getBoundedCacheValue(state.mirroredTuiInputProjections, logicalKey);
2221
2808
  }
2222
2809
 
2223
2810
  function rememberMirroredTuiInputProjection(
2224
2811
  state: ChannelSessionState,
2225
2812
  projection: MirroredTuiInputProjection,
2226
2813
  ): void {
2227
- state.mirroredTuiInputProjections = [
2228
- ...state.mirroredTuiInputProjections.filter(
2229
- existing => existing.logicalKey !== projection.logicalKey,
2230
- ),
2814
+ rememberBoundedCacheValue(
2815
+ state.mirroredTuiInputProjections,
2816
+ projection.logicalKey,
2231
2817
  projection,
2232
- ].slice(-maxForwardedTurnIds);
2818
+ );
2233
2819
  }
2234
2820
 
2235
2821
  function rememberMirroredTuiInputItemKey(
@@ -2253,28 +2839,28 @@ function findStreamingAssistantOutput(
2253
2839
  state: ChannelSessionState,
2254
2840
  itemKey: string,
2255
2841
  ): StreamingAssistantOutput | undefined {
2256
- return state.streamingAssistantOutputs.find(output => output.itemKey === itemKey);
2842
+ return getBoundedCacheValue(state.streamingAssistantOutputs, itemKey);
2257
2843
  }
2258
2844
 
2259
2845
  function findStreamingReasoningOutput(
2260
2846
  state: ChannelSessionState,
2261
2847
  itemKey: string,
2262
2848
  ): StreamingReasoningOutput | undefined {
2263
- return state.streamingReasoningOutputs.find(output => output.itemKey === itemKey);
2849
+ return getBoundedCacheValue(state.streamingReasoningOutputs, itemKey);
2264
2850
  }
2265
2851
 
2266
2852
  function findStreamingCommandOutput(
2267
2853
  state: ChannelSessionState,
2268
2854
  itemKey: string,
2269
2855
  ): StreamingCommandOutput | undefined {
2270
- return state.streamingCommandOutputs.find(output => output.itemKey === itemKey);
2856
+ return getBoundedCacheValue(state.streamingCommandOutputs, itemKey);
2271
2857
  }
2272
2858
 
2273
2859
  function findStreamingFileChangeOutput(
2274
2860
  state: ChannelSessionState,
2275
2861
  itemKey: string,
2276
2862
  ): StreamingFileChangeOutput | undefined {
2277
- return state.streamingFileChangeOutputs.find(output => output.itemKey === itemKey);
2863
+ return getBoundedCacheValue(state.streamingFileChangeOutputs, itemKey);
2278
2864
  }
2279
2865
 
2280
2866
  type StreamedStructuredOutput = {
@@ -2352,7 +2938,7 @@ function resolveStreamingAssistantOutputForCompletedMessage(
2352
2938
  return { status: "none" };
2353
2939
  }
2354
2940
 
2355
- const candidates = state.streamingAssistantOutputs.filter(
2941
+ const candidates = boundedCacheValues(state.streamingAssistantOutputs).filter(
2356
2942
  output => output.turnId === turnId,
2357
2943
  );
2358
2944
 
@@ -2391,8 +2977,8 @@ function turnIsFinalizingOrForwarded(
2391
2977
  turnId: string,
2392
2978
  ): boolean {
2393
2979
  return (
2394
- state.forwardingTurnIds.includes(turnId) ||
2395
- state.forwardedTurnIds.includes(turnId)
2980
+ state.forwardingTurnIds.has(turnId) ||
2981
+ state.forwardedTurnIds.has(turnId)
2396
2982
  );
2397
2983
  }
2398
2984
 
@@ -2400,114 +2986,88 @@ function rememberStreamingAssistantOutput(
2400
2986
  state: ChannelSessionState,
2401
2987
  output: StreamingAssistantOutput,
2402
2988
  ): void {
2403
- state.streamingAssistantOutputs = [
2404
- ...state.streamingAssistantOutputs.filter(existing => existing.itemKey !== output.itemKey),
2405
- output,
2406
- ].slice(-maxForwardedTurnIds);
2989
+ rememberBoundedCacheValue(state.streamingAssistantOutputs, output.itemKey, output);
2407
2990
  }
2408
2991
 
2409
2992
  function forgetStreamingAssistantOutput(
2410
2993
  state: ChannelSessionState,
2411
2994
  itemKey: string,
2412
2995
  ): void {
2413
- state.streamingAssistantOutputs = state.streamingAssistantOutputs.filter(
2414
- output => output.itemKey !== itemKey,
2415
- );
2996
+ forgetBoundedCacheValue(state.streamingAssistantOutputs, itemKey);
2416
2997
  }
2417
2998
 
2418
2999
  function rememberStreamingReasoningOutput(
2419
3000
  state: ChannelSessionState,
2420
3001
  output: StreamingReasoningOutput,
2421
3002
  ): void {
2422
- state.streamingReasoningOutputs = [
2423
- ...state.streamingReasoningOutputs.filter(existing => existing.itemKey !== output.itemKey),
2424
- output,
2425
- ].slice(-maxForwardedTurnIds);
3003
+ rememberBoundedCacheValue(state.streamingReasoningOutputs, output.itemKey, output);
2426
3004
  }
2427
3005
 
2428
3006
  function forgetStreamingReasoningOutput(
2429
3007
  state: ChannelSessionState,
2430
3008
  itemKey: string,
2431
3009
  ): void {
2432
- state.streamingReasoningOutputs = state.streamingReasoningOutputs.filter(
2433
- output => output.itemKey !== itemKey,
2434
- );
3010
+ forgetBoundedCacheValue(state.streamingReasoningOutputs, itemKey);
2435
3011
  }
2436
3012
 
2437
3013
  function rememberStreamingCommandOutput(
2438
3014
  state: ChannelSessionState,
2439
3015
  output: StreamingCommandOutput,
2440
3016
  ): void {
2441
- state.streamingCommandOutputs = [
2442
- ...state.streamingCommandOutputs.filter(existing => existing.itemKey !== output.itemKey),
2443
- output,
2444
- ].slice(-maxForwardedTurnIds);
3017
+ rememberBoundedCacheValue(state.streamingCommandOutputs, output.itemKey, output);
2445
3018
  }
2446
3019
 
2447
3020
  function forgetStreamingCommandOutput(
2448
3021
  state: ChannelSessionState,
2449
3022
  itemKey: string,
2450
3023
  ): void {
2451
- state.streamingCommandOutputs = state.streamingCommandOutputs.filter(
2452
- output => output.itemKey !== itemKey,
2453
- );
3024
+ forgetBoundedCacheValue(state.streamingCommandOutputs, itemKey);
2454
3025
  }
2455
3026
 
2456
3027
  function rememberStreamingFileChangeOutput(
2457
3028
  state: ChannelSessionState,
2458
3029
  output: StreamingFileChangeOutput,
2459
3030
  ): void {
2460
- state.streamingFileChangeOutputs = [
2461
- ...state.streamingFileChangeOutputs.filter(existing => existing.itemKey !== output.itemKey),
2462
- output,
2463
- ].slice(-maxForwardedTurnIds);
3031
+ rememberBoundedCacheValue(state.streamingFileChangeOutputs, output.itemKey, output);
2464
3032
  }
2465
3033
 
2466
3034
  function forgetStreamingFileChangeOutput(
2467
3035
  state: ChannelSessionState,
2468
3036
  itemKey: string,
2469
3037
  ): void {
2470
- state.streamingFileChangeOutputs = state.streamingFileChangeOutputs.filter(
2471
- output => output.itemKey !== itemKey,
2472
- );
3038
+ forgetBoundedCacheValue(state.streamingFileChangeOutputs, itemKey);
2473
3039
  }
2474
3040
 
2475
3041
  function rememberForwardedTerminalInputKey(
2476
3042
  state: ChannelSessionState,
2477
3043
  itemKey: string,
2478
3044
  ): void {
2479
- if (state.forwardedTerminalInputKeys.includes(itemKey)) {
3045
+ if (state.forwardedTerminalInputKeys.has(itemKey)) {
2480
3046
  return;
2481
3047
  }
2482
3048
 
2483
- state.forwardedTerminalInputKeys = [...state.forwardedTerminalInputKeys, itemKey]
2484
- .slice(-maxForwardedTurnIds);
3049
+ rememberBoundedStringSet(state.forwardedTerminalInputKeys, itemKey, maxForwardedTurnIds);
2485
3050
  }
2486
3051
 
2487
3052
  function findWebSearchProgressOutput(
2488
3053
  state: ChannelSessionState,
2489
3054
  turnId: string,
2490
3055
  ): WebSearchProgressOutput | undefined {
2491
- return state.webSearchProgressOutputs.find(output => output.turnId === turnId);
3056
+ return getBoundedCacheValue(state.webSearchProgressOutputs, turnId);
2492
3057
  }
2493
3058
 
2494
3059
  function rememberWebSearchProgressOutput(
2495
3060
  state: ChannelSessionState,
2496
3061
  output: WebSearchProgressOutput,
2497
3062
  ): void {
2498
- state.webSearchProgressOutputs = [
2499
- ...state.webSearchProgressOutputs.filter(existing => existing.turnId !== output.turnId),
2500
- output,
2501
- ].slice(-maxForwardedTurnIds);
3063
+ rememberBoundedCacheValue(state.webSearchProgressOutputs, output.turnId, output);
2502
3064
  }
2503
3065
 
2504
3066
  function forgetWebSearchProgressOutput(
2505
3067
  state: ChannelSessionState,
2506
3068
  turnId: string,
2507
3069
  ): void {
2508
- state.webSearchProgressOutputs = state.webSearchProgressOutputs.filter(
2509
- output => output.turnId !== turnId,
2510
- );
3070
+ forgetBoundedCacheValue(state.webSearchProgressOutputs, turnId);
2511
3071
  }
2512
3072
 
2513
3073
  function mergeWebSearchQueries(
@@ -2560,7 +3120,7 @@ function rememberLocalTuiTurnIfNeeded(
2560
3120
  return;
2561
3121
  }
2562
3122
 
2563
- state.localTuiTurnIds = rememberTurnId(state.localTuiTurnIds, turnId);
3123
+ rememberBoundedStringSet(state.localTuiTurnIds, turnId, maxForwardedTurnIds);
2564
3124
  if (state.kandanThreadId !== undefined) {
2565
3125
  startCodexTypingHeartbeat(args, state, state.kandanThreadId);
2566
3126
  }
@@ -2568,7 +3128,7 @@ function rememberLocalTuiTurnIfNeeded(
2568
3128
  }
2569
3129
 
2570
3130
  function isLocalTuiTurn(state: ChannelSessionState, turnId: string): boolean {
2571
- return state.localTuiTurnIds.includes(turnId);
3131
+ return state.localTuiTurnIds.has(turnId);
2572
3132
  }
2573
3133
 
2574
3134
  function ensureKandanThreadForLocalTuiTurn(
@@ -2585,7 +3145,7 @@ function forgetLocalTuiTurnId(
2585
3145
  state: ChannelSessionState,
2586
3146
  turnId: string,
2587
3147
  ): void {
2588
- state.localTuiTurnIds = state.localTuiTurnIds.filter(id => id !== turnId);
3148
+ state.localTuiTurnIds.delete(turnId);
2589
3149
  }
2590
3150
 
2591
3151
  function rememberPendingTuiInputMirror(
@@ -2593,63 +3153,88 @@ function rememberPendingTuiInputMirror(
2593
3153
  turnId: string,
2594
3154
  promise: Promise<void>,
2595
3155
  ): void {
2596
- state.pendingTuiInputMirrors = [
2597
- ...state.pendingTuiInputMirrors.filter(pending => pending.turnId !== turnId),
2598
- { turnId, promise },
2599
- ].slice(-maxForwardedTurnIds);
3156
+ state.pendingTuiInputMirrors.delete(turnId);
3157
+ state.pendingTuiInputMirrors.set(turnId, promise);
3158
+ trimBoundedMap(state.pendingTuiInputMirrors, maxForwardedTurnIds);
2600
3159
  }
2601
3160
 
2602
3161
  function forgetPendingTuiInputMirror(
2603
3162
  state: ChannelSessionState,
2604
3163
  turnId: string,
2605
3164
  ): void {
2606
- state.pendingTuiInputMirrors = state.pendingTuiInputMirrors.filter(
2607
- pending => pending.turnId !== turnId,
2608
- );
3165
+ state.pendingTuiInputMirrors.delete(turnId);
2609
3166
  }
2610
3167
 
2611
3168
  async function waitForPendingTuiInputMirror(
2612
3169
  state: ChannelSessionState,
2613
3170
  turnId: string,
2614
3171
  ): Promise<void> {
2615
- const pending = state.pendingTuiInputMirrors.find(
2616
- mirror => mirror.turnId === turnId,
2617
- );
3172
+ const pending = state.pendingTuiInputMirrors.get(turnId);
2618
3173
 
2619
3174
  if (pending !== undefined) {
2620
- await pending.promise;
3175
+ await pending;
2621
3176
  }
2622
3177
  }
2623
3178
 
2624
3179
  async function waitForStreamingForwardChains(
3180
+ args: ChannelSessionContext,
2625
3181
  state: ChannelSessionState,
3182
+ payloadContext: RunnerPayloadContext,
2626
3183
  ): Promise<void> {
3184
+ flushPendingStreamingDeltas(args, state, payloadContext);
2627
3185
  await Promise.all([
2628
- state.assistantDeltaForwardChain.catch(() => undefined),
2629
- state.reasoningDeltaForwardChain.catch(() => undefined),
2630
- state.commandOutputForwardChain.catch(() => undefined),
2631
- state.fileChangeForwardChain.catch(() => undefined),
3186
+ state.assistantDeltaQueue.chain.catch(() => undefined),
3187
+ state.reasoningDeltaQueue.chain.catch(() => undefined),
3188
+ state.commandOutputQueue.chain.catch(() => undefined),
3189
+ state.fileChangeQueue.chain.catch(() => undefined),
2632
3190
  state.terminalInputForwardChain.catch(() => undefined),
2633
3191
  state.webSearchProgressForwardChain.catch(() => undefined),
2634
3192
  ]);
2635
3193
  }
2636
3194
 
3195
+ function flushPendingStreamingDeltas(
3196
+ args: ChannelSessionContext,
3197
+ state: ChannelSessionState,
3198
+ payloadContext: RunnerPayloadContext,
3199
+ ): void {
3200
+ flushStreamDeltaQueue(
3201
+ state.assistantDeltaQueue,
3202
+ assistantDeltaQueueRuntime(args, state, payloadContext),
3203
+ );
3204
+ flushStreamDeltaQueue(
3205
+ state.reasoningDeltaQueue,
3206
+ reasoningDeltaQueueRuntime(args, state, payloadContext),
3207
+ );
3208
+ flushStreamDeltaQueue(
3209
+ state.commandOutputQueue,
3210
+ commandOutputQueueRuntime(args, state, payloadContext),
3211
+ );
3212
+ flushStreamDeltaQueue(
3213
+ state.fileChangeQueue,
3214
+ fileChangeQueueRuntime(args, state, payloadContext),
3215
+ );
3216
+ }
3217
+
3218
+ function clearPendingStreamFlushTimers(state: ChannelSessionState): void {
3219
+ clearStreamDeltaFlushTimer(state.assistantDeltaQueue);
3220
+ clearStreamDeltaFlushTimer(state.reasoningDeltaQueue);
3221
+ clearStreamDeltaFlushTimer(state.commandOutputQueue);
3222
+ clearStreamDeltaFlushTimer(state.fileChangeQueue);
3223
+ }
3224
+
2637
3225
  function rememberTurnReplyTarget(
2638
3226
  state: ChannelSessionState,
2639
3227
  turnId: string,
2640
3228
  replyToSeq: number,
2641
3229
  ): void {
2642
- state.turnReplyTargets = [
2643
- ...state.turnReplyTargets.filter(target => target.turnId !== turnId),
2644
- { turnId, replyToSeq },
2645
- ].slice(-maxForwardedTurnIds);
3230
+ rememberBoundedCacheValue(state.turnReplyTargets, turnId, { turnId, replyToSeq });
2646
3231
  }
2647
3232
 
2648
3233
  function sourceMessageSeqForTurn(
2649
3234
  state: ChannelSessionState,
2650
3235
  turnId: string,
2651
3236
  ): number | undefined {
2652
- return state.turnReplyTargets.find(target => target.turnId === turnId)?.replyToSeq;
3237
+ return getBoundedCacheValue(state.turnReplyTargets, turnId)?.replyToSeq;
2653
3238
  }
2654
3239
 
2655
3240
  function fileChangePaths(structured: JsonObject): string[] {
@@ -2710,7 +3295,7 @@ function isRecoverableCodexThreadError(error: unknown): boolean {
2710
3295
  }
2711
3296
 
2712
3297
  function turnCanForward(state: ChannelSessionState, turnId: string): boolean {
2713
- if (state.retryableTurnIds.includes(turnId)) {
3298
+ if (state.retryableTurnIds.has(turnId)) {
2714
3299
  return true;
2715
3300
  }
2716
3301
 
@@ -2718,26 +3303,18 @@ function turnCanForward(state: ChannelSessionState, turnId: string): boolean {
2718
3303
  return true;
2719
3304
  }
2720
3305
 
2721
- switch (state.turn.status) {
2722
- case "active":
2723
- return state.turn.turnId === turnId;
2724
- case "completing":
2725
- return state.turn.turnId === turnId;
2726
- case "idle":
2727
- case "starting":
2728
- return false;
2729
- }
3306
+ return turnCanForwardByState(state.turn, turnId);
2730
3307
  }
2731
3308
 
2732
3309
  function rememberForwardedTurnId(
2733
3310
  state: ChannelSessionState,
2734
3311
  turnId: string,
2735
3312
  ): void {
2736
- if (state.forwardedTurnIds.includes(turnId)) {
3313
+ if (state.forwardedTurnIds.has(turnId)) {
2737
3314
  return;
2738
3315
  }
2739
3316
 
2740
- state.forwardedTurnIds = rememberTurnId(state.forwardedTurnIds, turnId);
3317
+ rememberBoundedStringSet(state.forwardedTurnIds, turnId, maxForwardedTurnIds);
2741
3318
  forgetRetryableTurnId(state, turnId);
2742
3319
  }
2743
3320
 
@@ -2745,39 +3322,54 @@ function rememberForwardingTurnId(
2745
3322
  state: ChannelSessionState,
2746
3323
  turnId: string,
2747
3324
  ): void {
2748
- state.forwardingTurnIds = rememberTurnId(state.forwardingTurnIds, turnId);
3325
+ rememberBoundedStringSet(state.forwardingTurnIds, turnId, maxForwardedTurnIds);
2749
3326
  }
2750
3327
 
2751
3328
  function forgetForwardingTurnId(
2752
3329
  state: ChannelSessionState,
2753
3330
  turnId: string,
2754
3331
  ): void {
2755
- state.forwardingTurnIds = state.forwardingTurnIds.filter(id => id !== turnId);
3332
+ state.forwardingTurnIds.delete(turnId);
2756
3333
  }
2757
3334
 
2758
3335
  function rememberRetryableTurnId(
2759
3336
  state: ChannelSessionState,
2760
3337
  turnId: string,
2761
3338
  ): void {
2762
- state.retryableTurnIds = rememberTurnId(state.retryableTurnIds, turnId);
3339
+ rememberBoundedStringSet(state.retryableTurnIds, turnId, maxForwardedTurnIds);
2763
3340
  }
2764
3341
 
2765
3342
  function forgetRetryableTurnId(
2766
3343
  state: ChannelSessionState,
2767
3344
  turnId: string,
2768
3345
  ): void {
2769
- state.retryableTurnIds = state.retryableTurnIds.filter(id => id !== turnId);
3346
+ state.retryableTurnIds.delete(turnId);
2770
3347
  }
2771
3348
 
2772
- function rememberTurnId(
2773
- values: readonly string[],
2774
- turnId: string,
2775
- ): string[] {
2776
- if (values.includes(turnId)) {
2777
- return [...values];
3349
+ function rememberBoundedStringSet(values: Set<string>, value: string, maxSize: number): void {
3350
+ values.delete(value);
3351
+ values.add(value);
3352
+ trimBoundedSet(values, maxSize);
3353
+ }
3354
+
3355
+ function trimBoundedSet(values: Set<string>, maxSize: number): void {
3356
+ while (values.size > maxSize) {
3357
+ const oldest = values.values().next().value;
3358
+ if (oldest === undefined) {
3359
+ return;
3360
+ }
3361
+ values.delete(oldest);
2778
3362
  }
3363
+ }
2779
3364
 
2780
- return [...values, turnId].slice(-maxForwardedTurnIds);
3365
+ function trimBoundedMap<V>(values: Map<string, V>, maxSize: number): void {
3366
+ while (values.size > maxSize) {
3367
+ const oldest = values.keys().next().value;
3368
+ if (oldest === undefined) {
3369
+ return;
3370
+ }
3371
+ values.delete(oldest);
3372
+ }
2781
3373
  }
2782
3374
 
2783
3375
  async function stopCodexTyping(
@@ -2852,24 +3444,6 @@ function stopCodexTypingHeartbeat(state: ChannelSessionState): void {
2852
3444
  }
2853
3445
  }
2854
3446
 
2855
- type LocalCodexProcessingReason =
2856
- | "starting turn"
2857
- | "streaming response"
2858
- | "running terminal command"
2859
- | "interrupt requested"
2860
- | "awaiting approval";
2861
-
2862
- type LocalCodexMessageState =
2863
- | { readonly status: "queued" }
2864
- | { readonly status: "processed" }
2865
- | {
2866
- readonly status: "processing";
2867
- readonly reason: LocalCodexProcessingReason;
2868
- readonly approval?: CodexApprovalMessageState | undefined;
2869
- }
2870
- | { readonly status: "ignored"; readonly reason: string }
2871
- | { readonly status: "failed"; readonly reason: string };
2872
-
2873
3447
  async function publishKandanMessageState(
2874
3448
  args: ChannelSessionContext,
2875
3449
  event: KandanChatEvent,
@@ -2930,6 +3504,18 @@ async function publishMessageState(
2930
3504
  approval_request_id: state.approval.requestId,
2931
3505
  approval_kind: state.approval.kind,
2932
3506
  approval_summary: state.approval.summary,
3507
+ ...(state.approval.reason === undefined
3508
+ ? {}
3509
+ : { approval_reason: state.approval.reason }),
3510
+ ...(state.approval.choices === undefined
3511
+ ? {}
3512
+ : { approval_choices: state.approval.choices }),
3513
+ ...(state.approval.allowedActorSlug === undefined
3514
+ ? {}
3515
+ : { approval_allowed_actor_slug: state.approval.allowedActorSlug }),
3516
+ ...(state.approval.allowedActorUserId === undefined
3517
+ ? {}
3518
+ : { approval_allowed_actor_user_id: state.approval.allowedActorUserId }),
2933
3519
  }
2934
3520
  : {}),
2935
3521
  ...(actorSlug === undefined ? {} : { actor_slug: actorSlug }),
@@ -2939,51 +3525,6 @@ async function publishMessageState(
2939
3525
  await pushOptional(args.kandan, args.topic, "message_state", payload, args.log);
2940
3526
  }
2941
3527
 
2942
- function processingReasonForCodexNotification(
2943
- method: string,
2944
- params: JsonObject,
2945
- ): Exclude<LocalCodexProcessingReason, "awaiting approval"> | undefined {
2946
- if (method === "item/agentMessage/delta" || method === "item/reasoning/textDelta") {
2947
- return "streaming response";
2948
- }
2949
-
2950
- if (
2951
- method.startsWith("item/commandExecution/") ||
2952
- method === "command/exec/outputDelta"
2953
- ) {
2954
- return "running terminal command";
2955
- }
2956
-
2957
- const item = objectValue(params.item) ?? params;
2958
- const itemType = stringValue(item.type);
2959
-
2960
- switch (itemType) {
2961
- case "commandExecution":
2962
- case "terminalInput":
2963
- return "running terminal command";
2964
- case "agentMessage":
2965
- case "reasoning":
2966
- case "fileChange":
2967
- case "web_search_call":
2968
- case "webSearchCall":
2969
- case "webSearch":
2970
- return "streaming response";
2971
- default:
2972
- return undefined;
2973
- }
2974
- }
2975
-
2976
- function activeTurnId(state: ChannelSessionState): string | undefined {
2977
- switch (state.turn.status) {
2978
- case "active":
2979
- case "completing":
2980
- return state.turn.turnId;
2981
- case "idle":
2982
- case "starting":
2983
- return undefined;
2984
- }
2985
- }
2986
-
2987
3528
  function abortReason(params: JsonObject): string {
2988
3529
  return (
2989
3530
  stringValue(params.reason) ??
@@ -3001,7 +3542,7 @@ async function failActiveCodexTurn(
3001
3542
  payloadContext: RunnerPayloadContext,
3002
3543
  ): Promise<void> {
3003
3544
  rejectPendingApprovalRequestsForTurn(state, turnId, new Error(reason));
3004
- const seq = activeQueuedSeqForTurn(state, turnId);
3545
+ const seq = activeQueuedSeqForTurn(state.turn, turnId);
3005
3546
 
3006
3547
  if (seq !== undefined && state.kandanThreadId !== undefined) {
3007
3548
  await publishMessageState(args, state.kandanThreadId, seq, {
@@ -3014,10 +3555,7 @@ async function failActiveCodexTurn(
3014
3555
  forgetLocalTuiTurnId(state, turnId);
3015
3556
  rememberForwardedTurnId(state, turnId);
3016
3557
 
3017
- if (
3018
- state.turn.status !== "idle" &&
3019
- (state.turn.status === "starting" || state.turn.turnId === turnId)
3020
- ) {
3558
+ if (shouldClearTurnAfterFailure(state.turn, turnId)) {
3021
3559
  state.turn = { status: "idle" };
3022
3560
  }
3023
3561
 
@@ -3025,27 +3563,13 @@ async function failActiveCodexTurn(
3025
3563
  await drainKandanMessageQueue(args, state, payloadContext);
3026
3564
  }
3027
3565
 
3028
- function activeQueuedSeqForTurn(
3029
- state: ChannelSessionState,
3030
- turnId: string,
3031
- ): number | undefined {
3032
- switch (state.turn.status) {
3033
- case "active":
3034
- case "completing":
3035
- return state.turn.turnId === turnId ? state.turn.queuedSeq : undefined;
3036
- case "idle":
3037
- case "starting":
3038
- return undefined;
3039
- }
3040
- }
3041
-
3042
3566
  async function refreshActiveProcessingState(
3043
3567
  args: ChannelSessionContext,
3044
3568
  state: ChannelSessionState,
3045
3569
  turnId: string,
3046
3570
  reason: Exclude<LocalCodexProcessingReason, "awaiting approval">,
3047
3571
  ): Promise<void> {
3048
- const seq = activeQueuedSeqForTurn(state, turnId);
3572
+ const seq = activeQueuedSeqForTurn(state.turn, turnId);
3049
3573
 
3050
3574
  if (seq === undefined || state.kandanThreadId === undefined) {
3051
3575
  return;
@@ -3092,19 +3616,6 @@ function clearActiveProcessingState(state: ChannelSessionState, seq: number): vo
3092
3616
  }
3093
3617
  }
3094
3618
 
3095
- function processingMessageStateFromActive(
3096
- state: ActiveProcessingState,
3097
- ): Extract<LocalCodexMessageState, { readonly status: "processing" }> {
3098
- switch (state.reason) {
3099
- case "awaiting approval":
3100
- return { status: "processing", reason: state.reason, approval: state.approval };
3101
- case "starting turn":
3102
- case "streaming response":
3103
- case "running terminal command":
3104
- case "interrupt requested":
3105
- return { status: "processing", reason: state.reason };
3106
- }
3107
- }
3108
3619
 
3109
3620
  function extractThreadIdFromResponse(response: JsonRpcResponse): string {
3110
3621
  if ("error" in response) {
@@ -3152,63 +3663,6 @@ async function startCodexThread(
3152
3663
  return extractThreadIdFromResponse(start);
3153
3664
  }
3154
3665
 
3155
- function codexThreadRuntimeOverrides(options: ChannelSessionRunnerOptions): JsonObject {
3156
- const session = options.channelSession;
3157
-
3158
- return {
3159
- ...(session.model === undefined ? {} : { model: session.model }),
3160
- ...(session.reasoningEffort === undefined
3161
- ? {}
3162
- : { reasoningEffort: session.reasoningEffort }),
3163
- ...(options.fast === true ? { serviceTier: "fast" } : {}),
3164
- ...(session.approvalPolicy === undefined
3165
- ? {}
3166
- : { approvalPolicy: session.approvalPolicy }),
3167
- ...(session.sandbox === undefined ? {} : { sandbox: session.sandbox }),
3168
- };
3169
- }
3170
-
3171
- function codexTurnRuntimeOverrides(options: ChannelSessionRunnerOptions): JsonObject {
3172
- const session = options.channelSession;
3173
-
3174
- return {
3175
- cwd: options.cwd,
3176
- ...(session.model === undefined ? {} : { model: session.model }),
3177
- ...(session.reasoningEffort === undefined ? {} : { effort: session.reasoningEffort }),
3178
- ...(options.fast === true ? { serviceTier: "fast" } : {}),
3179
- ...(session.approvalPolicy === undefined
3180
- ? {}
3181
- : { approvalPolicy: session.approvalPolicy }),
3182
- ...(session.sandbox === undefined
3183
- ? {}
3184
- : { sandboxPolicy: codexSandboxPolicy(session.sandbox, options.cwd) }),
3185
- };
3186
- }
3187
-
3188
- function codexSandboxPolicy(sandbox: string, cwd: string): JsonObject {
3189
- switch (sandbox) {
3190
- case "danger-full-access":
3191
- return { type: "dangerFullAccess" };
3192
- case "read-only":
3193
- return {
3194
- type: "readOnly",
3195
- access: { type: "fullAccess" },
3196
- networkAccess: false,
3197
- };
3198
- case "workspace-write":
3199
- return {
3200
- type: "workspaceWrite",
3201
- writableRoots: [cwd],
3202
- readOnlyAccess: { type: "fullAccess" },
3203
- networkAccess: false,
3204
- excludeTmpdirEnvVar: false,
3205
- excludeSlashTmp: false,
3206
- };
3207
- default:
3208
- throw new Error(`unsupported Codex sandbox mode: ${sandbox}`);
3209
- }
3210
- }
3211
-
3212
3666
  async function pushOk(
3213
3667
  kandan: PhoenixClient,
3214
3668
  topic: string,