@linzumi/cli 0.0.20-beta → 0.0.23-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.
@@ -1,4301 +0,0 @@
1
- /*
2
- - Date: 2026-04-24
3
- Spec: plans/2026-04-24-local-codex-channel-session-module-spec.md
4
- Relationship: Owns one channel-bound local Codex session: Kandan thread
5
- binding, sender queueing, Codex turn lifecycle, typing heartbeat, queued
6
- interrupt handling, and Codex output forwarding.
7
-
8
- - Date: 2026-04-24
9
- Spec: plans/2026-04-24-local-codex-runner-deep-quality-spec.md
10
- Relationship: Keeps channel event dedupe bounded and represents Codex turn
11
- lifecycle as an explicit state machine so queued-message interrupts cannot
12
- race in-flight turn starts, malformed stored Codex thread ids can rebound,
13
- and non-recoverable delivery failures become terminal failed input states.
14
-
15
- - Date: 2026-04-25
16
- Spec: plans/2026-04-24-local-codex-runner-deep-quality-spec.md
17
- Relationship: Makes bare `processing` impossible to publish from the runner
18
- by requiring each processing message state to carry the specific active
19
- Codex work reason at the channel-session boundary.
20
-
21
- - Date: 2026-04-25
22
- Spec: plans/2026-04-24-local-codex-runner-deep-quality-spec.md
23
- Relationship: Reduces raw Codex notifications and thread snapshots into
24
- logical transcript units before Kandan projection, so TUI input projection
25
- failures and ambiguous assistant-output reconciliation fail as state-machine
26
- errors instead of creating source-less or duplicate-looking transcript rows,
27
- and so live reasoning, command-output, terminal-interaction, search, abort,
28
- live file-change, and completed tool-call observations update Kandan using
29
- existing Codex output kinds.
30
-
31
- - Date: 2026-04-25
32
- Spec: plans/2026-04-24-local-codex-runner-deep-quality-spec.md
33
- Relationship: Surfaces non-auto-accepted Codex app-server approval requests
34
- as Kandan message state, then resolves the blocked app-server request only
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.
93
-
94
- - Date: 2026-05-02
95
- Spec: plans/2026-05-02-agent-first-zero-to-codex-launch-plan.md
96
- Relationship: Closes npm CLI runner parity gaps for attachment-backed
97
- Kandan replies, runtime settings updates, and local TUI turn exclusivity.
98
- */
99
- import {
100
- availabilityMessage,
101
- detectCodexVersion,
102
- identityFromAccessToken,
103
- isCodexAuthoredEvent,
104
- localRunnerPayload,
105
- parseKandanChatEvent,
106
- senderAllowed,
107
- type KandanChatEvent,
108
- type RunnerIdentity,
109
- type RunnerPayloadContext,
110
- } from "./channelSessionSupport";
111
- import { createHash, randomUUID } from "node:crypto";
112
- import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
113
- import { basename, isAbsolute, join, relative, resolve } from "node:path";
114
- import { type CodexAppServerClient } from "./codexAppServer";
115
- import {
116
- codexThreadRuntimeOverrides,
117
- codexTurnRuntimeOverrides,
118
- } from "./codexRuntimeOptions";
119
- import {
120
- codexAssistantDeltaFromNotification,
121
- codexAssistantStructuredMessage,
122
- codexCommandExecutionStructuredMessage,
123
- codexCommandOutputDeltaFromNotification,
124
- codexFileChangeDeltaFromNotification,
125
- codexFileChangeStructuredMessage,
126
- codexOutputMessagesForTurn,
127
- codexReasoningDeltaFromNotification,
128
- codexReasoningStructuredMessage,
129
- codexTerminalInputFromNotification,
130
- codexWebSearchProgressFromNotification,
131
- codexWebSearchStructuredMessage,
132
- webSearchProgressBody,
133
- codexUserInputMessageFromNotification,
134
- codexUserInputMessagesForTurn,
135
- type CodexAssistantDelta,
136
- type CodexCommandOutputDelta,
137
- type CodexFileChangeDelta,
138
- type CodexReasoningDelta,
139
- type CodexUserInputMessage,
140
- } from "./codexOutput";
141
- import { arrayValue, integerValue, objectValue, stringValue } from "./json";
142
- import {
143
- codexInputForQueuedKandanMessage,
144
- type QueuedKandanMessage,
145
- } from "./kandanQueue";
146
- import {
147
- approvalRequestKey,
148
- codexApprovalMessageState,
149
- processingMessageStateFromActive,
150
- processingReasonForCodexNotification,
151
- type ActiveProcessingState,
152
- type CodexApprovalMessageState,
153
- type LocalCodexMessageState,
154
- type LocalCodexProcessingReason,
155
- } from "./localCodexMessageState";
156
- import {
157
- activeQueuedSeqForTurn,
158
- activeTurnId,
159
- completingQueuedSeqForTurn,
160
- interruptibleQueuedSeq,
161
- markInterruptAfterStart,
162
- shouldClearTurnAfterFailure,
163
- turnCanForwardByState,
164
- type TurnState,
165
- } from "./localCodexTurnState";
166
- import {
167
- createPendingKandanMessageQueue,
168
- dequeuePendingKandanMessage,
169
- enqueuePendingKandanMessage,
170
- interruptPendingKandanMessages,
171
- pendingKandanMessageQueueLength,
172
- requeuePendingKandanMessageFront,
173
- type PendingKandanMessageQueue,
174
- } from "./pendingKandanMessageQueue";
175
- import { type PhoenixClient } from "./phoenix";
176
- import {
177
- type JsonObject,
178
- type JsonRpcRequest,
179
- type JsonRpcResponse,
180
- type KandanChannelSessionOptions,
181
- type KandanControl,
182
- isJsonObject,
183
- } from "./protocol";
184
- import { type RunnerLogger } from "./runnerLogger";
185
- import {
186
- boundedCacheValues,
187
- createBoundedCache,
188
- forgetBoundedCacheValue,
189
- getBoundedCacheValue,
190
- rememberBoundedCacheValue,
191
- type BoundedCache,
192
- } from "./boundedCache";
193
- import {
194
- coalesceAssistantDeltas,
195
- coalesceCommandOutputDeltas,
196
- coalesceFileChangeDeltas,
197
- coalesceReasoningDeltas,
198
- firstDeltaTurnId,
199
- } from "./streamDeltaCoalescing";
200
- import {
201
- clearStreamDeltaFlushTimer,
202
- createStreamDeltaQueue,
203
- enqueueStreamDelta,
204
- flushStreamDeltaQueue,
205
- type StreamDeltaQueue,
206
- type StreamDeltaQueueRuntime,
207
- } from "./streamDeltaQueue";
208
- import {
209
- startPortForwardWatcher,
210
- type PortForwardCandidate,
211
- type PortForwardWatcher,
212
- type PortForwardWatcherOptions,
213
- } from "./portForwardWatcher";
214
- import {
215
- approvedTargetFromRequest,
216
- forwardPreviewPath,
217
- pendingRequestFromCandidate,
218
- portForwardPromptBody,
219
- portForwardPromptLabel,
220
- portForwardPromptReason,
221
- reviewPortForwardCandidate,
222
- revocationCapabilities,
223
- type PendingPortForwardRequest,
224
- } from "./portForwardApproval";
225
-
226
- export type ChannelSessionRuntime = {
227
- readonly handleCodexNotification: (
228
- method: string,
229
- params: JsonObject,
230
- ) => void;
231
- readonly handleControl: (control: KandanControl) => Promise<JsonObject | undefined>;
232
- readonly handleKandanReconnect: () => Promise<void>;
233
- readonly currentRuntimeSettings: () => LocalCodexRuntimeSettings;
234
- readonly currentCodexThreadId: () => string | undefined;
235
- readonly currentKandanThreadId: () => string | undefined;
236
- readonly close: () => Promise<void>;
237
- };
238
-
239
- export type ChannelSessionRunnerOptions = {
240
- readonly kandanUrl?: string | undefined;
241
- readonly token: string;
242
- readonly runnerId: string;
243
- readonly cwd: string;
244
- readonly codexBin: string;
245
- readonly fast?: boolean | undefined;
246
- readonly launchTui?: boolean | undefined;
247
- readonly enablePortForwardWatch?: boolean | undefined;
248
- readonly initialForwardPorts?: readonly number[] | undefined;
249
- readonly suppressedForwardPorts?: (() => readonly number[]) | undefined;
250
- readonly portForwardWatcher?: PortForwardWatchRuntimeOptions | undefined;
251
- readonly onForwardPortApproved?: ((port: number) => JsonObject | undefined) | undefined;
252
- readonly onForwardPortRevoked?: ((port: number) => JsonObject | undefined) | undefined;
253
- readonly channelSession: KandanChannelSessionOptions;
254
- };
255
-
256
- type PortForwardWatchRuntimeOptions =
257
- Partial<Omit<PortForwardWatcherOptions, "onCandidate">> & {
258
- readonly start?: ((options: PortForwardWatcherOptions) => PortForwardWatcher) | undefined;
259
- };
260
-
261
- type ChannelSessionContext = {
262
- readonly kandan: PhoenixClient;
263
- readonly codex: CodexAppServerClient;
264
- readonly topic: string;
265
- readonly instanceId: string;
266
- readonly options: ChannelSessionRunnerOptions;
267
- readonly log: RunnerLogger;
268
- };
269
-
270
- type ChannelSessionState = {
271
- rootSeq: number | undefined;
272
- kandanThreadId: string | undefined;
273
- codexThreadId: string | undefined;
274
- turn: TurnState;
275
- closed: boolean;
276
- minSeq: number;
277
- queue: PendingKandanMessageQueue;
278
- forwardedTurnIds: Set<string>;
279
- forwardingTurnIds: Set<string>;
280
- retryableTurnIds: Set<string>;
281
- localTuiTurnIds: Set<string>;
282
- mirroredTuiInputProjections: BoundedCache<MirroredTuiInputProjection>;
283
- pendingTuiInputMirrors: Map<string, Promise<void>>;
284
- turnReplyTargets: BoundedCache<TurnReplyTarget>;
285
- streamingAssistantOutputs: BoundedCache<StreamingAssistantOutput>;
286
- streamingReasoningOutputs: BoundedCache<StreamingReasoningOutput>;
287
- streamingCommandOutputs: BoundedCache<StreamingCommandOutput>;
288
- streamingFileChangeOutputs: BoundedCache<StreamingFileChangeOutput>;
289
- assistantDeltaQueue: StreamDeltaQueue<CodexAssistantDelta>;
290
- reasoningDeltaQueue: StreamDeltaQueue<CodexReasoningDelta>;
291
- commandOutputQueue: StreamDeltaQueue<CodexCommandOutputDelta>;
292
- fileChangeQueue: StreamDeltaQueue<CodexFileChangeDelta>;
293
- forwardedTerminalInputKeys: Set<string>;
294
- webSearchProgressOutputs: BoundedCache<WebSearchProgressOutput>;
295
- pendingApprovalRequests: Map<string, PendingCodexApprovalRequest>;
296
- pendingPortForwardRequests: Map<string, PendingPortForwardRequest>;
297
- approvedForwardPorts: Set<number>;
298
- approvedForwardTargets: Map<number, PortForwardCandidate>;
299
- dismissedForwardTargets: Map<number, PortForwardCandidate>;
300
- portForwardWatcher: PortForwardWatcher | undefined;
301
- activeProcessingState: ActiveProcessingState | undefined;
302
- terminalInputForwardChain: Promise<void>;
303
- webSearchProgressForwardChain: Promise<void>;
304
- typingHeartbeat: ReturnType<typeof setInterval> | undefined;
305
- typingHeartbeatInFlight: boolean;
306
- runtimeSettings: LocalCodexRuntimeSettings;
307
- };
308
-
309
- const codexTypingHeartbeatMs = 5_000;
310
- const defaultStreamFlushIntervalMs = 150;
311
- const maxForwardedTurnIds = 64;
312
-
313
- type LocalCodexRuntimeSettings = {
314
- readonly model: string | undefined;
315
- readonly reasoningEffort: string | undefined;
316
- readonly approvalPolicy: string | undefined;
317
- readonly sandbox: string | undefined;
318
- readonly fast: boolean | undefined;
319
- };
320
-
321
- type StreamingAssistantOutput = {
322
- readonly itemKey: string;
323
- readonly turnId: string | undefined;
324
- readonly seq: number;
325
- readonly content: string;
326
- };
327
-
328
- type StreamingReasoningOutput = {
329
- readonly itemKey: string;
330
- readonly turnId: string | undefined;
331
- readonly seq: number;
332
- readonly content: string;
333
- };
334
-
335
- type StreamingCommandOutput = {
336
- readonly itemKey: string;
337
- readonly turnId: string | undefined;
338
- readonly seq: number;
339
- readonly output: string;
340
- readonly processId: string | undefined;
341
- readonly stream: string;
342
- };
343
-
344
- type StreamingFileChangeOutput = {
345
- readonly itemKey: string;
346
- readonly turnId: string | undefined;
347
- readonly seq: number;
348
- readonly patchText: string;
349
- };
350
-
351
- type WebSearchProgressOutput = {
352
- readonly turnId: string;
353
- readonly itemKey: string;
354
- readonly seq: number;
355
- readonly queries: string[];
356
- };
357
-
358
- type MirroredTuiInputProjection = {
359
- readonly logicalKey: string;
360
- readonly turnId: string;
361
- readonly seq: number;
362
- readonly itemKeys: string[];
363
- };
364
-
365
- type TurnReplyTarget = {
366
- readonly turnId: string;
367
- readonly replyToSeq: number;
368
- };
369
-
370
- type PendingCodexApprovalRequest = {
371
- readonly requestId: string;
372
- readonly sourceSeq: number;
373
- readonly turnId: string;
374
- readonly resolve: (response: JsonObject) => void;
375
- readonly reject: (error: Error) => void;
376
- };
377
-
378
- export async function attachChannelSession(
379
- args: ChannelSessionContext,
380
- ): Promise<ChannelSessionRuntime> {
381
- const session = args.options.channelSession;
382
- const chatTopic = `chat:${session.workspaceSlug}:${session.channelSlug}`;
383
- const state = initialChannelSessionState(
384
- 0,
385
- session.kandanThreadId,
386
- args.options,
387
- );
388
- const joined = await args.kandan.join(chatTopic, { last_seq: 0 }, {
389
- rejoinPayload: () => ({ last_seq: state.minSeq }),
390
- });
391
- const cursor = integerValue(joined.cursor) ?? 0;
392
- const runnerIdentity = identityFromAccessToken(args.options.token);
393
- const codexVersion = detectCodexVersion(args.options.codexBin, args.options.cwd);
394
- state.minSeq = cursor;
395
- const payloadContext = {
396
- runnerIdentity,
397
- codexVersion,
398
- };
399
- const eventBuffer = {
400
- ready: false,
401
- pending: [] as KandanChatEvent[],
402
- };
403
-
404
- args.codex.onRequest?.(request =>
405
- handleCodexServerRequest(args, state, payloadContext, request)
406
- );
407
-
408
- args.kandan.onEvent((topic, event, payload) => {
409
- if (topic !== chatTopic || event !== "event") {
410
- return;
411
- }
412
-
413
- const parsed = parseKandanChatEvent(payload);
414
-
415
- if (parsed === undefined) {
416
- return;
417
- }
418
-
419
- if (!eventBuffer.ready) {
420
- eventBuffer.pending.push(parsed);
421
- return;
422
- }
423
-
424
- void processKandanChatEvent(args, state, runnerIdentity, parsed, payloadContext);
425
- });
426
-
427
- await bindChannelSession(args, state, payloadContext);
428
- await processBufferedKandanEvents(args, state, runnerIdentity, payloadContext, eventBuffer.pending);
429
- eventBuffer.ready = true;
430
- startPortForwardWatchIfEnabled(args, state, payloadContext);
431
- await drainKandanMessageQueue(args, state, payloadContext);
432
-
433
- return {
434
- handleCodexNotification: (method, params) => {
435
- const turnId = codexNotificationTurnId(params);
436
- const threadId = codexNotificationThreadId(params);
437
-
438
- if (codexNotificationBelongsToSession(state, threadId, turnId)) {
439
- const processingReason = processingReasonForCodexNotification(method, params);
440
- if (turnId !== undefined && processingReason !== undefined) {
441
- void refreshActiveProcessingState(args, state, turnId, processingReason).catch(error => {
442
- args.log("kandan.message_state_refresh_failed", {
443
- turn_id: turnId,
444
- reason: processingReason,
445
- message: error instanceof Error ? error.message : String(error),
446
- });
447
- });
448
- }
449
-
450
- switch (method) {
451
- case "turn/started":
452
- if (turnId !== undefined) {
453
- rememberLocalTuiTurnIfNeeded(args, state, threadId, turnId);
454
- }
455
- break;
456
- case "turn/aborted":
457
- case "turn/canceled":
458
- case "turn/cancelled":
459
- case "turn/failed":
460
- if (turnId !== undefined) {
461
- void failActiveCodexTurn(args, state, turnId, abortReason(params), payloadContext)
462
- .catch(error => {
463
- args.log("codex.turn_abort_handling_failed", {
464
- turn_id: turnId,
465
- message: error instanceof Error ? error.message : String(error),
466
- });
467
- });
468
- }
469
- break;
470
- case "turn/completed":
471
- if (turnId !== undefined) {
472
- enqueueWebSearchProgressCompletion(args, state, turnId);
473
- enqueueFileChangeCompletion(args, state, turnId);
474
- void forwardCompletedCodexTurn(args, state, turnId, payloadContext).catch(error => {
475
- args.log("codex.turn_forward_failed", {
476
- turn_id: turnId,
477
- message: error instanceof Error ? error.message : String(error),
478
- });
479
- });
480
- }
481
- break;
482
- case "item/agentMessage/delta":
483
- enqueueAssistantDelta(args, state, params, payloadContext);
484
- break;
485
- case "item/reasoning/textDelta":
486
- enqueueReasoningDelta(args, state, params, payloadContext);
487
- break;
488
- case "item/commandExecution/outputDelta":
489
- case "command/exec/outputDelta":
490
- enqueueCommandOutputDelta(args, state, params, payloadContext);
491
- break;
492
- case "item/fileChange/outputDelta":
493
- enqueueFileChangeDelta(args, state, params, payloadContext);
494
- break;
495
- case "item/commandExecution/terminalInteraction":
496
- enqueueTerminalInput(args, state, params, payloadContext);
497
- break;
498
- case "item/started":
499
- enqueueWebSearchProgress(args, state, params, payloadContext);
500
- break;
501
- case "item/completed":
502
- enqueueWebSearchProgress(args, state, params, payloadContext);
503
- if (turnId !== undefined) {
504
- const promise = mirrorLocalTuiInputFromNotification(
505
- args,
506
- state,
507
- turnId,
508
- params,
509
- payloadContext,
510
- );
511
- rememberPendingTuiInputMirror(state, turnId, promise);
512
- void promise
513
- .catch(error => {
514
- args.log("codex.tui_input_mirror_failed", {
515
- turn_id: turnId,
516
- message: error instanceof Error ? error.message : String(error),
517
- });
518
- })
519
- .finally(() => forgetPendingTuiInputMirror(state, turnId));
520
- }
521
- break;
522
- }
523
- }
524
- },
525
- handleControl: control => handleChannelSessionControl(args, state, payloadContext, control),
526
- currentRuntimeSettings: () => state.runtimeSettings,
527
- currentCodexThreadId: () => state.codexThreadId,
528
- currentKandanThreadId: () => state.kandanThreadId,
529
- handleKandanReconnect: async () => {
530
- if (state.closed) {
531
- return;
532
- }
533
-
534
- args.log("kandan.reconnected", {
535
- kandan_thread_id: state.kandanThreadId ?? null,
536
- codex_thread_id: state.codexThreadId ?? null,
537
- min_seq: state.minSeq,
538
- });
539
- await bindCurrentCodexThread(args, state);
540
- if (state.kandanThreadId !== undefined && state.turn.status !== "idle") {
541
- startCodexTypingHeartbeat(args, state, state.kandanThreadId);
542
- await refreshActiveProcessingHeartbeat(args, state);
543
- }
544
- await drainKandanMessageQueue(args, state, payloadContext);
545
- },
546
- close: async () => {
547
- state.closed = true;
548
- state.portForwardWatcher?.close();
549
- state.portForwardWatcher = undefined;
550
- clearPendingStreamFlushTimers(state);
551
- rejectPendingApprovalRequests(state, new Error("runner closed"));
552
- await stopCodexTyping(args, state);
553
- },
554
- };
555
- }
556
-
557
- async function bindCurrentCodexThread(
558
- args: ChannelSessionContext,
559
- state: ChannelSessionState,
560
- ): Promise<void> {
561
- if (state.codexThreadId === undefined) {
562
- return;
563
- }
564
-
565
- const session = args.options.channelSession;
566
- await pushOk(args.kandan, args.topic, "session:bind", {
567
- workspace: session.workspaceSlug,
568
- channel: session.channelSlug,
569
- thread_id: state.kandanThreadId ?? null,
570
- codex_thread_id: state.codexThreadId,
571
- instance_id: args.instanceId,
572
- });
573
- }
574
-
575
- function initialChannelSessionState(
576
- cursor: number,
577
- kandanThreadId: string | undefined,
578
- options: ChannelSessionRunnerOptions,
579
- ): ChannelSessionState {
580
- return {
581
- rootSeq: undefined,
582
- kandanThreadId,
583
- codexThreadId: undefined,
584
- turn: { status: "idle" },
585
- closed: false,
586
- minSeq: cursor,
587
- queue: createPendingKandanMessageQueue(),
588
- forwardedTurnIds: new Set<string>(),
589
- forwardingTurnIds: new Set<string>(),
590
- retryableTurnIds: new Set<string>(),
591
- localTuiTurnIds: new Set<string>(),
592
- mirroredTuiInputProjections: createBoundedCache(maxForwardedTurnIds),
593
- pendingTuiInputMirrors: new Map<string, Promise<void>>(),
594
- turnReplyTargets: createBoundedCache(maxForwardedTurnIds),
595
- streamingAssistantOutputs: createBoundedCache(maxForwardedTurnIds),
596
- streamingReasoningOutputs: createBoundedCache(maxForwardedTurnIds),
597
- streamingCommandOutputs: createBoundedCache(maxForwardedTurnIds),
598
- streamingFileChangeOutputs: createBoundedCache(maxForwardedTurnIds),
599
- assistantDeltaQueue: createStreamDeltaQueue(),
600
- reasoningDeltaQueue: createStreamDeltaQueue(),
601
- commandOutputQueue: createStreamDeltaQueue(),
602
- fileChangeQueue: createStreamDeltaQueue(),
603
- forwardedTerminalInputKeys: new Set<string>(),
604
- webSearchProgressOutputs: createBoundedCache(maxForwardedTurnIds),
605
- pendingApprovalRequests: new Map<string, PendingCodexApprovalRequest>(),
606
- pendingPortForwardRequests: new Map<string, PendingPortForwardRequest>(),
607
- approvedForwardPorts: new Set<number>(),
608
- approvedForwardTargets: new Map<number, PortForwardCandidate>(),
609
- dismissedForwardTargets: new Map<number, PortForwardCandidate>(),
610
- portForwardWatcher: undefined,
611
- activeProcessingState: undefined,
612
- terminalInputForwardChain: Promise.resolve(),
613
- webSearchProgressForwardChain: Promise.resolve(),
614
- typingHeartbeat: undefined,
615
- typingHeartbeatInFlight: false,
616
- runtimeSettings: runtimeSettingsFromOptions(options),
617
- };
618
- }
619
-
620
- function startPortForwardWatchIfEnabled(
621
- args: ChannelSessionContext,
622
- state: ChannelSessionState,
623
- payloadContext: RunnerPayloadContext,
624
- ): void {
625
- if (args.options.enablePortForwardWatch !== true || state.portForwardWatcher !== undefined) {
626
- return;
627
- }
628
-
629
- const { start: configuredStart, ...watchOptions } = args.options.portForwardWatcher ?? {};
630
- const start = configuredStart ?? startPortForwardWatcher;
631
- for (const port of args.options.initialForwardPorts ?? []) {
632
- state.approvedForwardPorts.add(port);
633
- }
634
- state.portForwardWatcher = start({
635
- ...watchOptions,
636
- onCandidate: candidate => publishPortForwardPrompt(args, state, payloadContext, candidate),
637
- onError: error => args.log("port_forward.watch_failed", { message: error.message }),
638
- });
639
- }
640
-
641
- async function bindChannelSession(
642
- args: ChannelSessionContext,
643
- state: ChannelSessionState,
644
- payloadContext: RunnerPayloadContext,
645
- ): Promise<void> {
646
- const session = args.options.channelSession;
647
- const codexVersion = payloadContext.codexVersion;
648
-
649
- if (state.kandanThreadId === undefined) {
650
- state.codexThreadId = await startCodexThread(args.codex, args.options);
651
- const reply = await pushOk(args.kandan, args.topic, "session:announce", {
652
- workspace: session.workspaceSlug,
653
- channel: session.channelSlug,
654
- body: availabilityMessage(args.options, codexVersion, state.codexThreadId),
655
- payload: localRunnerPayload(
656
- args.options,
657
- args.instanceId,
658
- "availability",
659
- state.codexThreadId,
660
- payloadContext,
661
- ),
662
- client_message_id: `local-codex-root-${args.instanceId}`,
663
- });
664
- state.rootSeq = integerValue(reply.seq);
665
- if (state.rootSeq !== undefined) {
666
- state.minSeq = Math.max(state.minSeq, state.rootSeq);
667
- }
668
- } else {
669
- const resolved = await pushOk(args.kandan, args.topic, "session:resolve_thread_session", {
670
- workspace: session.workspaceSlug,
671
- channel: session.channelSlug,
672
- thread_id: state.kandanThreadId,
673
- });
674
- state.rootSeq = integerValue(resolved.seq);
675
- state.codexThreadId = stringValue(resolved.codex_thread_id);
676
-
677
- if (state.codexThreadId === undefined) {
678
- throw new Error(
679
- "Kandan thread root metadata did not include a Codex thread id",
680
- );
681
- }
682
-
683
- await pushOk(args.kandan, args.topic, "session:post_thread_message", {
684
- workspace: session.workspaceSlug,
685
- channel: session.channelSlug,
686
- thread_id: state.kandanThreadId,
687
- body: availabilityMessage(args.options, codexVersion, state.codexThreadId),
688
- payload: localRunnerPayload(
689
- args.options,
690
- args.instanceId,
691
- "availability",
692
- state.codexThreadId,
693
- payloadContext,
694
- ),
695
- });
696
- }
697
- }
698
-
699
- async function handleChannelSessionControl(
700
- args: ChannelSessionContext,
701
- state: ChannelSessionState,
702
- payloadContext: RunnerPayloadContext,
703
- control: KandanControl,
704
- ): Promise<JsonObject | undefined> {
705
- if (control.type === "update_session_settings") {
706
- return updateSessionSettings(args, state, control);
707
- }
708
-
709
- if (control.type === "resolve_codex_approval_request") {
710
- return resolvePendingCodexApprovalRequest(args, state, control);
711
- }
712
-
713
- if (control.type === "resolve_port_forward_request") {
714
- return resolvePendingPortForwardRequest(args, state, payloadContext, control);
715
- }
716
-
717
- if (control.type !== "interrupt_queued_messages") {
718
- return undefined;
719
- }
720
-
721
- if (
722
- state.codexThreadId === undefined ||
723
- state.kandanThreadId === undefined ||
724
- control.threadId !== state.codexThreadId
725
- ) {
726
- return { instanceId: args.instanceId, ok: false, error: "thread_not_bound" };
727
- }
728
-
729
- const interrupted = interruptPendingKandanMessages(state.queue, control.throughSeq);
730
- const activeQueuedSeq = interruptibleQueuedSeq(state.turn);
731
- const targetsActiveTurn =
732
- control.throughSeq !== undefined && activeQueuedSeq === control.throughSeq;
733
-
734
- if (!interrupted.ok && !targetsActiveTurn) {
735
- return { instanceId: args.instanceId, ok: false, error: "queue_empty" };
736
- }
737
-
738
- switch (state.turn.status) {
739
- case "active":
740
- const interruptedActiveSeq = state.turn.queuedSeq;
741
- await args.codex.request("turn/interrupt", {
742
- threadId: state.codexThreadId,
743
- turnId: state.turn.turnId,
744
- });
745
-
746
- state.turn = { status: "idle" };
747
- clearActiveProcessingState(state, interruptedActiveSeq);
748
- await publishMessageState(args, state.kandanThreadId, interruptedActiveSeq, {
749
- status: "processed",
750
- });
751
- break;
752
- case "starting":
753
- state.turn = markInterruptAfterStart(state.turn);
754
- break;
755
- case "idle":
756
- case "completing":
757
- break;
758
- }
759
-
760
- args.log("codex.queued_messages_interrupted", {
761
- through_seq: control.throughSeq ?? null,
762
- selected_count: interrupted.ok ? interrupted.selectedCount : 0,
763
- remaining_count: interrupted.ok
764
- ? interrupted.remainingCount
765
- : pendingKandanMessageQueueLength(state.queue),
766
- });
767
- if (interrupted.ok) {
768
- for (const seq of interrupted.selectedSeqs) {
769
- await publishMessageState(args, state.kandanThreadId, seq, {
770
- status: "processing",
771
- reason: "interrupt requested",
772
- });
773
- }
774
- }
775
- await drainKandanMessageQueue(args, state, payloadContext);
776
- return {
777
- instanceId: args.instanceId,
778
- ok: true,
779
- interruptedQueuedMessages: interrupted.ok ? interrupted.selectedCount : 0,
780
- };
781
- }
782
-
783
- function updateSessionSettings(
784
- args: ChannelSessionContext,
785
- state: ChannelSessionState,
786
- control: Extract<KandanControl, { readonly type: "update_session_settings" }>,
787
- ): JsonObject {
788
- if (
789
- state.codexThreadId === undefined ||
790
- control.threadId !== state.codexThreadId
791
- ) {
792
- return { instanceId: args.instanceId, ok: false, error: "thread_not_bound" };
793
- }
794
-
795
- state.runtimeSettings = mergeRuntimeSettings(state.runtimeSettings, control);
796
- void publishRuntimeSettings(args, state).catch(error => {
797
- args.log("kandan.session_settings_publish_failed", {
798
- message: error instanceof Error ? error.message : String(error),
799
- });
800
- });
801
- args.log("codex.session_settings_updated", {
802
- model: state.runtimeSettings.model ?? null,
803
- reasoning_effort: state.runtimeSettings.reasoningEffort ?? null,
804
- approval_policy: state.runtimeSettings.approvalPolicy ?? null,
805
- sandbox: state.runtimeSettings.sandbox ?? null,
806
- fast: state.runtimeSettings.fast ?? null,
807
- });
808
-
809
- return {
810
- instanceId: args.instanceId,
811
- ok: true,
812
- model: state.runtimeSettings.model ?? null,
813
- reasoningEffort: state.runtimeSettings.reasoningEffort ?? null,
814
- approvalPolicy: state.runtimeSettings.approvalPolicy ?? null,
815
- sandbox: state.runtimeSettings.sandbox ?? null,
816
- fast: state.runtimeSettings.fast ?? null,
817
- };
818
- }
819
-
820
- async function resolvePendingCodexApprovalRequest(
821
- args: ChannelSessionContext,
822
- state: ChannelSessionState,
823
- control: Extract<KandanControl, { readonly type: "resolve_codex_approval_request" }>,
824
- ): Promise<JsonObject> {
825
- if (
826
- state.codexThreadId === undefined ||
827
- state.kandanThreadId === undefined ||
828
- control.threadId !== state.codexThreadId
829
- ) {
830
- return { instanceId: args.instanceId, ok: false, error: "thread_not_bound" };
831
- }
832
-
833
- const approval = state.pendingApprovalRequests.get(
834
- approvalRequestKey(control.requestId, control.sourceSeq),
835
- );
836
-
837
- if (approval === undefined) {
838
- return { instanceId: args.instanceId, ok: false, error: "approval_request_not_found" };
839
- }
840
-
841
- state.pendingApprovalRequests.delete(approvalRequestKey(control.requestId, control.sourceSeq));
842
-
843
- const codexDecision = control.decision === "approve" ? "accept" : "decline";
844
- approval.resolve({ decision: codexDecision });
845
- state.activeProcessingState = { seq: approval.sourceSeq, reason: "streaming response" };
846
-
847
- await publishMessageState(args, state.kandanThreadId, approval.sourceSeq, {
848
- status: "processing",
849
- reason: "streaming response",
850
- });
851
-
852
- args.log("codex.approval_request_resolved", {
853
- request_id: control.requestId,
854
- source_seq: control.sourceSeq,
855
- decision: control.decision,
856
- codex_decision: codexDecision,
857
- });
858
-
859
- return { instanceId: args.instanceId, ok: true };
860
- }
861
-
862
- async function resolvePendingPortForwardRequest(
863
- args: ChannelSessionContext,
864
- state: ChannelSessionState,
865
- payloadContext: RunnerPayloadContext,
866
- control: Extract<KandanControl, { readonly type: "resolve_port_forward_request" }>,
867
- ): Promise<JsonObject> {
868
- if (!portForwardControlSenderAllowed(args, payloadContext, control)) {
869
- args.log("port_forward.request_resolution_ignored", {
870
- request_id: control.requestId,
871
- actor_slug: control.actorSlug ?? null,
872
- actor_user_id: control.actorUserId ?? null,
873
- reason: "sender_not_allowed",
874
- });
875
- return { instanceId: args.instanceId, ok: false, error: "sender_not_allowed" };
876
- }
877
-
878
- const request = state.pendingPortForwardRequests.get(control.requestId);
879
-
880
- if (request === undefined) {
881
- return { instanceId: args.instanceId, ok: false, error: "port_forward_request_not_found" };
882
- }
883
-
884
- state.pendingPortForwardRequests.delete(control.requestId);
885
-
886
- if (control.decision === "deny") {
887
- state.dismissedForwardTargets.set(request.port, approvedTargetFromRequest(request));
888
- await publishMessageStateForPortForwardResult(args, state, request, "failed");
889
- args.log("port_forward.request_denied", {
890
- request_id: control.requestId,
891
- port: request.port,
892
- pid: request.pid,
893
- });
894
- return { instanceId: args.instanceId, ok: true };
895
- }
896
-
897
- state.approvedForwardPorts.add(request.port);
898
- state.approvedForwardTargets.set(request.port, approvedTargetFromRequest(request));
899
- const capabilities = args.options.onForwardPortApproved?.(request.port);
900
- await publishForwardPortResolvedEvent(args, request, capabilities);
901
- await publishMessageStateForPortForwardResult(args, state, request, "processed");
902
- await publishPortForwardReadyMessage(args, state, payloadContext, request);
903
-
904
- args.log("port_forward.request_approved", {
905
- request_id: control.requestId,
906
- port: request.port,
907
- pid: request.pid,
908
- });
909
-
910
- return { instanceId: args.instanceId, ok: true, port: request.port };
911
- }
912
-
913
- function portForwardControlSenderAllowed(
914
- args: ChannelSessionContext,
915
- payloadContext: RunnerPayloadContext,
916
- control: Extract<KandanControl, { readonly type: "resolve_port_forward_request" }>,
917
- ): boolean {
918
- if (control.actorSlug === undefined && control.actorUserId === undefined) {
919
- return false;
920
- }
921
-
922
- return senderAllowed(
923
- args.options.channelSession.listenUser,
924
- {
925
- actorSlug: control.actorSlug,
926
- actorUserId: control.actorUserId,
927
- },
928
- payloadContext.runnerIdentity,
929
- );
930
- }
931
-
932
- async function publishPortForwardPrompt(
933
- args: ChannelSessionContext,
934
- state: ChannelSessionState,
935
- payloadContext: RunnerPayloadContext,
936
- candidate: PortForwardCandidate,
937
- ): Promise<void> {
938
- if (state.kandanThreadId === undefined || state.codexThreadId === undefined) {
939
- args.log("port_forward.prompt_skipped", {
940
- port: candidate.port,
941
- pid: candidate.pid,
942
- reason: "thread_not_bound",
943
- });
944
- return;
945
- }
946
-
947
- const review = reviewPortForwardCandidate({
948
- candidate,
949
- threadBound: true,
950
- suppressedPorts: new Set(args.options.suppressedForwardPorts?.() ?? []),
951
- approvedPorts: state.approvedForwardPorts,
952
- approvedTargets: state.approvedForwardTargets,
953
- dismissedTargets: state.dismissedForwardTargets,
954
- pendingRequests: Array.from(state.pendingPortForwardRequests.values()),
955
- });
956
-
957
- switch (review.type) {
958
- case "skip":
959
- return;
960
- case "remember_approved_target":
961
- state.approvedForwardTargets.set(review.target.port, review.target);
962
- return;
963
- case "revoke_and_prompt":
964
- await revokeApprovedForwardPort(args, state, review.revoked, review.reason);
965
- break;
966
- case "prompt":
967
- break;
968
- }
969
-
970
- const requestId = `port-forward-${randomUUID()}`;
971
- const label = portForwardPromptLabel(candidate);
972
- const body = portForwardPromptBody(candidate, requestId);
973
- const payload = {
974
- ...localRunnerPayload(
975
- args.options,
976
- args.instanceId,
977
- "port_forward_request",
978
- state.codexThreadId,
979
- payloadContext,
980
- ),
981
- reply_to_seq: state.rootSeq ?? null,
982
- structured: {
983
- kind: "local_runner_port_forward_request",
984
- request_id: requestId,
985
- port: candidate.port,
986
- pid: candidate.pid,
987
- command: candidate.command,
988
- ...(candidate.cwd === undefined ? {} : { cwd: candidate.cwd }),
989
- command_label: label,
990
- },
991
- };
992
-
993
- const reply = await pushOk(args.kandan, args.topic, "session:post_thread_message", {
994
- workspace: args.options.channelSession.workspaceSlug,
995
- channel: args.options.channelSession.channelSlug,
996
- thread_id: state.kandanThreadId,
997
- body,
998
- payload,
999
- });
1000
- const sourceSeq = integerValue(reply.seq);
1001
-
1002
- if (sourceSeq === undefined) {
1003
- throw new Error("port forward prompt did not return a Kandan message seq");
1004
- }
1005
-
1006
- const request = pendingRequestFromCandidate({
1007
- requestId,
1008
- sourceSeq,
1009
- candidate,
1010
- });
1011
- state.pendingPortForwardRequests.set(requestId, request);
1012
-
1013
- await publishForwardPortRequestedEvent(args, request);
1014
- await publishMessageState(args, state.kandanThreadId, sourceSeq, {
1015
- status: "processing",
1016
- reason: "awaiting approval",
1017
- approval: {
1018
- requestId,
1019
- kind: "local_runner_port_forward",
1020
- summary: `Open runner port ${candidate.port} from ${label}`,
1021
- reason: portForwardPromptReason(candidate),
1022
- choices: [
1023
- {
1024
- decision: "approve",
1025
- label: "Open preview",
1026
- description: `Allow Kandan to proxy HTTP, HTTPS, and WebSocket traffic for runner port ${candidate.port}.`,
1027
- },
1028
- {
1029
- decision: "deny",
1030
- label: "Deny",
1031
- description: `Keep runner port ${candidate.port} private for this runner session.`,
1032
- },
1033
- ],
1034
- allowedActorSlug: args.options.channelSession.listenUser,
1035
- allowedActorUserId:
1036
- args.options.channelSession.listenUser.toLowerCase() ===
1037
- (payloadContext.runnerIdentity.actorUsername ?? "").toLowerCase()
1038
- ? payloadContext.runnerIdentity.actorUserId
1039
- : undefined,
1040
- },
1041
- });
1042
-
1043
- args.log("port_forward.request_pending", {
1044
- request_id: requestId,
1045
- port: candidate.port,
1046
- pid: candidate.pid,
1047
- });
1048
- }
1049
-
1050
- async function revokeApprovedForwardPort(
1051
- args: ChannelSessionContext,
1052
- state: ChannelSessionState,
1053
- target: PortForwardCandidate,
1054
- reason: "listener_changed",
1055
- ): Promise<void> {
1056
- state.approvedForwardPorts.delete(target.port);
1057
- state.approvedForwardTargets.delete(target.port);
1058
- const capabilities = args.options.onForwardPortRevoked?.(target.port);
1059
-
1060
- await pushOptional(args.kandan, args.topic, "forward_port_revoked", {
1061
- instanceId: args.instanceId,
1062
- port: target.port,
1063
- pid: target.pid,
1064
- command: target.command,
1065
- ...(target.cwd === undefined ? {} : { cwd: target.cwd }),
1066
- reason,
1067
- capabilities: revocationCapabilities(capabilities, target.port),
1068
- }, args.log);
1069
-
1070
- args.log("port_forward.approved_port_revoked", {
1071
- port: target.port,
1072
- pid: target.pid,
1073
- reason,
1074
- });
1075
- }
1076
-
1077
- async function publishPortForwardReadyMessage(
1078
- args: ChannelSessionContext,
1079
- state: ChannelSessionState,
1080
- payloadContext: RunnerPayloadContext,
1081
- request: PendingPortForwardRequest,
1082
- ): Promise<void> {
1083
- if (state.kandanThreadId === undefined || state.codexThreadId === undefined) {
1084
- return;
1085
- }
1086
-
1087
- const path = forwardPreviewPath(args.options.runnerId, request.port);
1088
- await pushOptional(args.kandan, args.topic, "session:post_thread_message", {
1089
- workspace: args.options.channelSession.workspaceSlug,
1090
- channel: args.options.channelSession.channelSlug,
1091
- thread_id: state.kandanThreadId,
1092
- body: `Runner port ${request.port} is open in Kandan: [Open preview](${path})`,
1093
- payload: {
1094
- ...localRunnerPayload(
1095
- args.options,
1096
- args.instanceId,
1097
- "port_forward_ready",
1098
- state.codexThreadId,
1099
- payloadContext,
1100
- ),
1101
- ...(state.rootSeq === undefined ? {} : { reply_to_seq: state.rootSeq }),
1102
- structured: {
1103
- kind: "local_runner_port_forward_ready",
1104
- status: "ready",
1105
- summary: `Runner port ${request.port} is open in Kandan.`,
1106
- next_action: "Open HTTP/HTTPS/WebSocket preview",
1107
- source_path: path,
1108
- link_label: "Open preview",
1109
- request_id: request.requestId,
1110
- port: request.port,
1111
- pid: request.pid,
1112
- command: request.command,
1113
- ...(request.cwd === undefined ? {} : { cwd: request.cwd }),
1114
- url: path,
1115
- },
1116
- },
1117
- }, args.log);
1118
- }
1119
-
1120
- async function publishForwardPortRequestedEvent(
1121
- args: ChannelSessionContext,
1122
- request: PendingPortForwardRequest,
1123
- ): Promise<void> {
1124
- await pushOptional(args.kandan, args.topic, "forward_port_requested", {
1125
- instanceId: args.instanceId,
1126
- requestId: request.requestId,
1127
- port: request.port,
1128
- pid: request.pid,
1129
- command: request.command,
1130
- ...(request.cwd === undefined ? {} : { cwd: request.cwd }),
1131
- }, args.log);
1132
- }
1133
-
1134
- async function publishForwardPortResolvedEvent(
1135
- args: ChannelSessionContext,
1136
- request: PendingPortForwardRequest,
1137
- capabilities: JsonObject | undefined,
1138
- ): Promise<void> {
1139
- await pushOptional(args.kandan, args.topic, "forward_port_resolved", {
1140
- instanceId: args.instanceId,
1141
- requestId: request.requestId,
1142
- port: request.port,
1143
- pid: request.pid,
1144
- command: request.command,
1145
- ...(request.cwd === undefined ? {} : { cwd: request.cwd }),
1146
- decision: "approve",
1147
- ...(capabilities === undefined ? {} : { capabilities }),
1148
- }, args.log);
1149
- }
1150
-
1151
- async function publishMessageStateForPortForwardResult(
1152
- args: ChannelSessionContext,
1153
- state: ChannelSessionState,
1154
- request: PendingPortForwardRequest,
1155
- status: "processed" | "failed",
1156
- ): Promise<void> {
1157
- if (state.kandanThreadId === undefined) {
1158
- return;
1159
- }
1160
-
1161
- switch (status) {
1162
- case "processed":
1163
- await publishMessageState(args, state.kandanThreadId, request.sourceSeq, {
1164
- status: "processed",
1165
- });
1166
- break;
1167
- case "failed":
1168
- await publishMessageState(args, state.kandanThreadId, request.sourceSeq, {
1169
- status: "failed",
1170
- reason: "port_forward_denied",
1171
- });
1172
- break;
1173
- }
1174
- }
1175
-
1176
- function parsePortForwardDecision(
1177
- body: string,
1178
- ): { readonly decision: "approve" | "deny"; readonly requestId: string } | undefined {
1179
- const match = body.trim().match(/^\/kandan\s+(approve|deny)-port-forward\s+(\S+)$/i);
1180
-
1181
- if (match === null) {
1182
- return undefined;
1183
- }
1184
-
1185
- return {
1186
- decision: match[1]?.toLocaleLowerCase() === "approve" ? "approve" : "deny",
1187
- requestId: match[2] ?? "",
1188
- };
1189
- }
1190
-
1191
- async function handleKandanChatEvent(
1192
- args: ChannelSessionContext,
1193
- state: ChannelSessionState,
1194
- runnerIdentity: RunnerIdentity,
1195
- payloadContext: RunnerPayloadContext,
1196
- event: KandanChatEvent,
1197
- ): Promise<void> {
1198
- const session = args.options.channelSession;
1199
-
1200
- if (
1201
- event.type !== "thread.message" ||
1202
- event.threadId === undefined ||
1203
- (event.body.trim() === "" && event.attachments.length === 0)
1204
- ) {
1205
- args.log("kandan.message_ignored", {
1206
- seq: event.seq,
1207
- actor_slug: event.actorSlug ?? null,
1208
- actor_user_id: event.actorUserId ?? null,
1209
- reason: "not_thread_message_or_empty",
1210
- });
1211
- return;
1212
- }
1213
-
1214
- if (isCodexAuthoredEvent(event)) {
1215
- args.log("kandan.message_ignored", {
1216
- seq: event.seq,
1217
- actor_slug: event.actorSlug ?? null,
1218
- actor_user_id: event.actorUserId ?? null,
1219
- reason: "codex_authored",
1220
- });
1221
- return;
1222
- }
1223
-
1224
- if (!senderAllowed(session.listenUser, event, runnerIdentity)) {
1225
- args.log("kandan.message_ignored", {
1226
- seq: event.seq,
1227
- actor_slug: event.actorSlug ?? null,
1228
- actor_user_id: event.actorUserId ?? null,
1229
- reason: "sender_not_allowed",
1230
- });
1231
- await publishKandanMessageState(args, event, {
1232
- status: "ignored",
1233
- reason: "sender_not_allowed",
1234
- });
1235
- return;
1236
- }
1237
-
1238
- if (event.body.trimStart().startsWith("&")) {
1239
- args.log("kandan.message_ignored", {
1240
- seq: event.seq,
1241
- actor_slug: event.actorSlug ?? null,
1242
- actor_user_id: event.actorUserId ?? null,
1243
- reason: "human_only",
1244
- });
1245
- await publishKandanMessageState(args, event, {
1246
- status: "ignored",
1247
- reason: "human_only",
1248
- });
1249
- return;
1250
- }
1251
-
1252
- const portForwardDecision = parsePortForwardDecision(event.body);
1253
- if (portForwardDecision !== undefined) {
1254
- const result = await resolvePendingPortForwardRequest(args, state, payloadContext, {
1255
- type: "resolve_port_forward_request",
1256
- instanceId: args.instanceId,
1257
- requestId: portForwardDecision.requestId,
1258
- decision: portForwardDecision.decision,
1259
- actorUserId: event.actorUserId,
1260
- actorSlug: event.actorSlug,
1261
- });
1262
-
1263
- if (result.ok === true) {
1264
- await publishKandanMessageState(args, event, { status: "processed" });
1265
- } else {
1266
- await publishKandanMessageState(args, event, {
1267
- status: "failed",
1268
- reason: stringValue(result.error) ?? "port_forward_decision_failed",
1269
- });
1270
- }
1271
- return;
1272
- }
1273
-
1274
- if (state.kandanThreadId === undefined) {
1275
- if (state.rootSeq === undefined || event.replyToSeq !== state.rootSeq) {
1276
- const bound = await bindUnboundHistoricalThread(args, state, event);
1277
-
1278
- if (!bound) {
1279
- args.log("kandan.message_ignored", {
1280
- seq: event.seq,
1281
- actor_slug: event.actorSlug ?? null,
1282
- actor_user_id: event.actorUserId ?? null,
1283
- reason: "not_root_reply",
1284
- });
1285
- await publishKandanMessageState(args, event, {
1286
- status: "ignored",
1287
- reason: "not_root_reply",
1288
- });
1289
- return;
1290
- }
1291
- } else {
1292
- state.kandanThreadId = event.threadId;
1293
- }
1294
- }
1295
-
1296
- if (event.threadId !== state.kandanThreadId) {
1297
- args.log("kandan.message_ignored", {
1298
- seq: event.seq,
1299
- actor_slug: event.actorSlug ?? null,
1300
- actor_user_id: event.actorUserId ?? null,
1301
- reason: "different_thread",
1302
- });
1303
- await publishKandanMessageState(args, event, {
1304
- status: "ignored",
1305
- reason: "different_thread",
1306
- });
1307
- return;
1308
- }
1309
-
1310
- enqueuePendingKandanMessage(state.queue, {
1311
- seq: event.seq,
1312
- actorSlug: event.actorSlug,
1313
- actorUserId: event.actorUserId,
1314
- body: event.body,
1315
- attachments: event.attachments,
1316
- });
1317
- args.log("kandan.message_queued", {
1318
- seq: event.seq,
1319
- actor_slug: event.actorSlug ?? null,
1320
- actor_user_id: event.actorUserId ?? null,
1321
- queue_depth: pendingKandanMessageQueueLength(state.queue),
1322
- });
1323
- await publishKandanMessageState(args, event, { status: "queued" });
1324
- await drainKandanMessageQueue(args, state, payloadContext);
1325
- }
1326
-
1327
- async function bindUnboundHistoricalThread(
1328
- args: ChannelSessionContext,
1329
- state: ChannelSessionState,
1330
- event: KandanChatEvent,
1331
- ): Promise<boolean> {
1332
- if (event.threadId === undefined || event.replyToSeq === undefined) {
1333
- return false;
1334
- }
1335
-
1336
- const session = args.options.channelSession;
1337
-
1338
- try {
1339
- const resolved = await pushOk(args.kandan, args.topic, "session:resolve_thread_session", {
1340
- workspace: session.workspaceSlug,
1341
- channel: session.channelSlug,
1342
- thread_id: event.threadId,
1343
- });
1344
- const rootSeq = integerValue(resolved.seq);
1345
- const codexThreadId = stringValue(resolved.codex_thread_id);
1346
-
1347
- if (rootSeq !== event.replyToSeq || codexThreadId === undefined) {
1348
- return false;
1349
- }
1350
-
1351
- state.rootSeq = rootSeq;
1352
- state.kandanThreadId = event.threadId;
1353
- state.codexThreadId = codexThreadId;
1354
- await bindCurrentCodexThread(args, state);
1355
- args.log("kandan.thread_bound_from_historical_reply", {
1356
- seq: event.seq,
1357
- kandan_thread_id: event.threadId,
1358
- root_seq: rootSeq,
1359
- codex_thread_id: codexThreadId,
1360
- });
1361
- return true;
1362
- } catch (error) {
1363
- args.log("kandan.thread_bind_from_historical_reply_failed", {
1364
- seq: event.seq,
1365
- kandan_thread_id: event.threadId,
1366
- message: error instanceof Error ? error.message : String(error),
1367
- });
1368
- return false;
1369
- }
1370
- }
1371
-
1372
- async function processBufferedKandanEvents(
1373
- args: ChannelSessionContext,
1374
- state: ChannelSessionState,
1375
- runnerIdentity: RunnerIdentity,
1376
- payloadContext: RunnerPayloadContext,
1377
- pendingEvents: readonly KandanChatEvent[],
1378
- ): Promise<void> {
1379
- const events = [...pendingEvents].sort(
1380
- (left, right) => left.seq - right.seq,
1381
- );
1382
-
1383
- for (const event of events) {
1384
- await processKandanChatEvent(args, state, runnerIdentity, event, payloadContext);
1385
- }
1386
- }
1387
-
1388
- async function processKandanChatEvent(
1389
- args: ChannelSessionContext,
1390
- state: ChannelSessionState,
1391
- runnerIdentity: RunnerIdentity,
1392
- event: KandanChatEvent,
1393
- payloadContext: RunnerPayloadContext,
1394
- ): Promise<void> {
1395
- if (event.seq <= state.minSeq) {
1396
- return;
1397
- }
1398
-
1399
- state.minSeq = event.seq;
1400
- args.log("kandan.chat_event", {
1401
- seq: event.seq,
1402
- type: event.type,
1403
- actor_slug: event.actorSlug ?? null,
1404
- actor_user_id: event.actorUserId ?? null,
1405
- thread_id: event.threadId ?? null,
1406
- reply_to_seq: event.replyToSeq ?? null,
1407
- local_runner_event_type: event.localRunnerEventType ?? null,
1408
- });
1409
-
1410
- try {
1411
- await handleKandanChatEvent(args, state, runnerIdentity, payloadContext, event);
1412
- } catch (error) {
1413
- args.log("kandan.chat_event_failed", {
1414
- seq: event.seq,
1415
- message: error instanceof Error ? error.message : String(error),
1416
- });
1417
- }
1418
- }
1419
-
1420
- async function drainKandanMessageQueue(
1421
- args: ChannelSessionContext,
1422
- state: ChannelSessionState,
1423
- payloadContext: RunnerPayloadContext,
1424
- ): Promise<void> {
1425
- if (
1426
- state.closed ||
1427
- state.turn.status !== "idle" ||
1428
- localTuiTurnIsActive(state)
1429
- ) {
1430
- return;
1431
- }
1432
-
1433
- const next = dequeuePendingKandanMessage(state.queue);
1434
-
1435
- if (next === undefined) {
1436
- return;
1437
- }
1438
-
1439
- state.turn = { status: "starting", queuedSeq: next.seq, interruptAfterStart: false };
1440
- state.activeProcessingState = { seq: next.seq, reason: "starting turn" };
1441
- args.log("codex.turn_starting", {
1442
- queued_seq: next.seq,
1443
- actor_slug: next.actorSlug ?? null,
1444
- actor_user_id: next.actorUserId ?? null,
1445
- codex_thread_id: state.codexThreadId ?? null,
1446
- queue_depth: pendingKandanMessageQueueLength(state.queue),
1447
- });
1448
- await publishQueuedMessageState(args, state, next, {
1449
- status: "processing",
1450
- reason: "starting turn",
1451
- });
1452
-
1453
- try {
1454
- const codexThreadId = state.codexThreadId;
1455
-
1456
- if (codexThreadId === undefined) {
1457
- throw new Error("Codex thread is not bound");
1458
- }
1459
-
1460
- if (state.kandanThreadId !== undefined) {
1461
- startCodexTypingHeartbeat(args, state, state.kandanThreadId);
1462
- }
1463
-
1464
- const started = await args.codex.request("turn/start", {
1465
- threadId: codexThreadId,
1466
- input: await codexInputItemsForQueuedKandanMessage(args, next),
1467
- ...codexTurnRuntimeOverrides(runtimeOptionsForSettings(args.options, state.runtimeSettings)),
1468
- });
1469
- const turnId = extractTurnIdFromResponse(started);
1470
- const interruptAfterStart =
1471
- state.turn.status === "starting" && state.turn.interruptAfterStart;
1472
- state.turn = { status: "active", turnId, queuedSeq: next.seq };
1473
- rememberTurnReplyTarget(state, turnId, next.seq);
1474
- args.log("codex.turn_started", { turn_id: turnId });
1475
-
1476
- if (interruptAfterStart) {
1477
- await args.codex.request("turn/interrupt", {
1478
- threadId: codexThreadId,
1479
- turnId,
1480
- });
1481
- state.turn = { status: "idle" };
1482
- clearActiveProcessingState(state, next.seq);
1483
- if (state.kandanThreadId !== undefined) {
1484
- await publishMessageState(args, state.kandanThreadId, next.seq, {
1485
- status: "processed",
1486
- });
1487
- }
1488
- await drainKandanMessageQueue(args, state, payloadContext);
1489
- }
1490
- } catch (error) {
1491
- if (isRecoverableCodexThreadError(error) && state.kandanThreadId !== undefined) {
1492
- const oldCodexThreadId = state.codexThreadId;
1493
- try {
1494
- await stopCodexTyping(args, state);
1495
- const newCodexThreadId = await startCodexThread(args.codex, args.options);
1496
- state.codexThreadId = newCodexThreadId;
1497
- args.log("codex.thread_rebound", {
1498
- kandan_thread_id: state.kandanThreadId,
1499
- old_codex_thread_id: oldCodexThreadId ?? null,
1500
- new_codex_thread_id: newCodexThreadId,
1501
- });
1502
- await postCodexThreadReboundMessage(
1503
- args,
1504
- state,
1505
- payloadContext,
1506
- oldCodexThreadId,
1507
- newCodexThreadId,
1508
- );
1509
- requeuePendingKandanMessageFront(state.queue, next);
1510
- state.turn = { status: "idle" };
1511
- await drainKandanMessageQueue(args, state, payloadContext);
1512
- return;
1513
- } catch (recoveryError) {
1514
- args.log("codex.thread_rebind_failed", {
1515
- kandan_thread_id: state.kandanThreadId,
1516
- old_codex_thread_id: oldCodexThreadId ?? null,
1517
- message: recoveryError instanceof Error ? recoveryError.message : String(recoveryError),
1518
- });
1519
- }
1520
- }
1521
-
1522
- state.turn = { status: "idle" };
1523
- clearActiveProcessingState(state, next.seq);
1524
- await stopCodexTyping(args, state);
1525
- await publishQueuedMessageState(
1526
- args,
1527
- state,
1528
- next,
1529
- {
1530
- status: "failed",
1531
- reason: error instanceof Error ? error.message : String(error),
1532
- },
1533
- );
1534
- args.log("codex.turn_start_failed", {
1535
- queued_seq: next.seq,
1536
- message: error instanceof Error ? error.message : String(error),
1537
- });
1538
- }
1539
- }
1540
-
1541
- async function handleCodexServerRequest(
1542
- args: ChannelSessionContext,
1543
- state: ChannelSessionState,
1544
- payloadContext: RunnerPayloadContext,
1545
- request: JsonRpcRequest,
1546
- ): Promise<JsonObject> {
1547
- const params = objectValue(request.params) ?? {};
1548
- const turnId = stringValue(params.turnId);
1549
-
1550
- if (codexApprovalRequestCanAutoAccept(state.runtimeSettings, request.method)) {
1551
- args.log("codex.server_request_auto_accepted", {
1552
- method: request.method,
1553
- turn_id: turnId ?? null,
1554
- });
1555
- return { decision: "accept" };
1556
- }
1557
-
1558
- if (codexApprovalRequestCanSurface(request.method)) {
1559
- if (turnId === undefined) {
1560
- throw new Error(`Codex approval request missing turn id: ${request.method}`);
1561
- }
1562
-
1563
- return requestKandanApproval(args, state, request, turnId, payloadContext);
1564
- }
1565
-
1566
- const message = `unhandled Codex app-server request: ${request.method}`;
1567
-
1568
- args.log("codex.server_request_unhandled", {
1569
- method: request.method,
1570
- turn_id: turnId ?? null,
1571
- });
1572
-
1573
- if (turnId !== undefined) {
1574
- await failActiveCodexTurn(args, state, turnId, message, payloadContext);
1575
- }
1576
-
1577
- throw new Error(message);
1578
- }
1579
-
1580
- function codexApprovalRequestCanAutoAccept(
1581
- settings: LocalCodexRuntimeSettings,
1582
- method: string,
1583
- ): boolean {
1584
- return (
1585
- settings.approvalPolicy === "never" &&
1586
- (
1587
- method === "item/commandExecution/requestApproval" ||
1588
- method === "item/fileChange/requestApproval"
1589
- )
1590
- );
1591
- }
1592
-
1593
- function codexApprovalRequestCanSurface(method: string): boolean {
1594
- return (
1595
- method === "item/commandExecution/requestApproval" ||
1596
- method === "item/fileChange/requestApproval"
1597
- );
1598
- }
1599
-
1600
- async function requestKandanApproval(
1601
- args: ChannelSessionContext,
1602
- state: ChannelSessionState,
1603
- request: JsonRpcRequest,
1604
- turnId: string,
1605
- payloadContext: RunnerPayloadContext,
1606
- ): Promise<JsonObject> {
1607
- const sourceSeq = activeQueuedSeqForTurn(state.turn, turnId);
1608
-
1609
- if (sourceSeq === undefined || state.kandanThreadId === undefined) {
1610
- const message = `Codex approval request has no active Kandan source message: ${request.method}`;
1611
- await failActiveCodexTurn(args, state, turnId, message, payloadContext);
1612
- throw new Error(message);
1613
- }
1614
-
1615
- const approval = codexApprovalMessageState(request);
1616
- state.activeProcessingState = { seq: sourceSeq, reason: "awaiting approval", approval };
1617
- await publishMessageState(args, state.kandanThreadId, sourceSeq, {
1618
- status: "processing",
1619
- reason: "awaiting approval",
1620
- approval,
1621
- });
1622
-
1623
- args.log("codex.approval_request_pending", {
1624
- request_id: approval.requestId,
1625
- source_seq: sourceSeq,
1626
- turn_id: turnId,
1627
- method: request.method,
1628
- });
1629
-
1630
- return new Promise<JsonObject>((resolve, reject) => {
1631
- const request = {
1632
- requestId: approval.requestId,
1633
- sourceSeq,
1634
- turnId,
1635
- resolve,
1636
- reject,
1637
- };
1638
- state.pendingApprovalRequests.set(
1639
- approvalRequestKey(request.requestId, request.sourceSeq),
1640
- request,
1641
- );
1642
- });
1643
- }
1644
-
1645
- function rejectPendingApprovalRequests(state: ChannelSessionState, error: Error): void {
1646
- const pendingApprovals = [...state.pendingApprovalRequests.values()];
1647
- state.pendingApprovalRequests.clear();
1648
- pendingApprovals.forEach(approval => approval.reject(error));
1649
- }
1650
-
1651
- function rejectPendingApprovalRequestsForTurn(
1652
- state: ChannelSessionState,
1653
- turnId: string,
1654
- error: Error,
1655
- ): void {
1656
- for (const [key, approval] of state.pendingApprovalRequests) {
1657
- if (approval.turnId === turnId) {
1658
- state.pendingApprovalRequests.delete(key);
1659
- approval.reject(error);
1660
- }
1661
- }
1662
- }
1663
-
1664
- async function forwardCompletedCodexTurn(
1665
- args: ChannelSessionContext,
1666
- state: ChannelSessionState,
1667
- turnId: string,
1668
- payloadContext: RunnerPayloadContext,
1669
- ): Promise<void> {
1670
- if (isLocalTuiTurn(state, turnId)) {
1671
- ensureKandanThreadForLocalTuiTurn(state);
1672
- }
1673
-
1674
- if (
1675
- state.kandanThreadId === undefined ||
1676
- state.codexThreadId === undefined ||
1677
- state.forwardedTurnIds.has(turnId) ||
1678
- state.forwardingTurnIds.has(turnId) ||
1679
- !turnCanForward(state, turnId)
1680
- ) {
1681
- return;
1682
- }
1683
-
1684
- const completingQueuedSeq = completingQueuedSeqForTurn(state.turn, turnId);
1685
- const completingActiveTurn = completingQueuedSeq !== undefined;
1686
- const completingLocalTuiTurn = isLocalTuiTurn(state, turnId);
1687
- if (completingQueuedSeq !== undefined) {
1688
- state.turn = { status: "completing", turnId, queuedSeq: completingQueuedSeq };
1689
- }
1690
- await waitForPendingTuiInputMirror(state, turnId);
1691
- await waitForStreamingForwardChains(args, state, payloadContext);
1692
- rememberForwardingTurnId(state, turnId);
1693
- forgetRetryableTurnId(state, turnId);
1694
-
1695
- try {
1696
- const read = await args.codex.request("thread/read", {
1697
- threadId: state.codexThreadId,
1698
- includeTurns: true,
1699
- });
1700
- const tuiInputMessages =
1701
- isLocalTuiTurn(state, turnId)
1702
- ? codexUserInputMessagesForTurn(read, turnId)
1703
- : [];
1704
- const messages = codexOutputMessagesForTurn(read, turnId);
1705
- args.log("codex.turn_completed", {
1706
- turn_id: turnId,
1707
- tui_input_count: tuiInputMessages.length,
1708
- output_count: messages.length,
1709
- });
1710
-
1711
- if (isLocalTuiTurn(state, turnId)) {
1712
- ensureKandanThreadForLocalTuiTurn(state);
1713
- }
1714
-
1715
- for (const message of tuiInputMessages) {
1716
- await mirrorLocalTuiInputMessage(args, state, turnId, message, payloadContext);
1717
- }
1718
-
1719
- if (
1720
- completingLocalTuiTurn &&
1721
- sourceMessageSeqForTurn(state, turnId) === undefined
1722
- ) {
1723
- throw new LogicalProjectionError(
1724
- `Cannot forward Codex output for local TUI turn ${turnId} before mirroring its human input`,
1725
- );
1726
- }
1727
-
1728
- for (const message of messages) {
1729
- const sourceMessageSeq = sourceMessageSeqForTurn(state, turnId);
1730
- const rootSeq = state.rootSeq;
1731
- const streamedStructured = resolveStreamingStructuredOutputForCompletedMessage(
1732
- state,
1733
- message.itemKey,
1734
- message.structured,
1735
- );
1736
-
1737
- if (streamedStructured !== undefined) {
1738
- await editCodexStructuredOutput(
1739
- args,
1740
- state,
1741
- streamedStructured.seq,
1742
- message.body,
1743
- message.structured,
1744
- );
1745
- forgetStreamingStructuredOutput(state, message.itemKey, message.structured);
1746
- args.log("kandan.codex_output_forwarded", {
1747
- turn_id: turnId,
1748
- item_key: message.itemKey,
1749
- structured_kind: stringValue(message.structured.kind) ?? null,
1750
- command: stringValue(message.structured.command) ?? null,
1751
- file_paths: fileChangePaths(message.structured),
1752
- });
1753
- continue;
1754
- }
1755
-
1756
- if (
1757
- stringValue(message.structured.kind) === "codex_terminal_input" &&
1758
- state.forwardedTerminalInputKeys.has(message.itemKey)
1759
- ) {
1760
- continue;
1761
- }
1762
-
1763
- const streamed = resolveStreamingAssistantOutputForCompletedMessage(
1764
- state,
1765
- turnId,
1766
- message.itemKey,
1767
- message.body,
1768
- message.structured,
1769
- );
1770
-
1771
- switch (streamed.status) {
1772
- case "none":
1773
- await streamCompletedCodexOutput(args, state, payloadContext, {
1774
- turnId,
1775
- sourceMessageSeq,
1776
- rootSeq,
1777
- message,
1778
- });
1779
- break;
1780
- case "matched":
1781
- await editStreamedCodexOutput(
1782
- args,
1783
- state,
1784
- streamed.output.seq,
1785
- message.itemKey,
1786
- message.body,
1787
- "completed",
1788
- );
1789
- forgetStreamingAssistantOutput(state, streamed.output.itemKey);
1790
- break;
1791
- case "ambiguous":
1792
- throw new LogicalProjectionError(
1793
- `Cannot reconcile completed assistant item ${message.itemKey} for turn ${turnId}; ${streamed.candidateCount} active streamed assistant outputs exist`,
1794
- );
1795
- }
1796
- args.log("kandan.codex_output_forwarded", {
1797
- turn_id: turnId,
1798
- item_key: message.itemKey,
1799
- structured_kind: stringValue(message.structured.kind) ?? null,
1800
- command: stringValue(message.structured.command) ?? null,
1801
- file_paths: fileChangePaths(message.structured),
1802
- });
1803
- }
1804
-
1805
- rememberForwardedTurnId(state, turnId);
1806
- forgetLocalTuiTurnId(state, turnId);
1807
- if (completingQueuedSeq !== undefined) {
1808
- await publishMessageState(args, state.kandanThreadId, completingQueuedSeq, {
1809
- status: "processed",
1810
- });
1811
- clearActiveProcessingState(state, completingQueuedSeq);
1812
- }
1813
- } catch (error) {
1814
- if (error instanceof LogicalProjectionError) {
1815
- if (completingQueuedSeq !== undefined && state.kandanThreadId !== undefined) {
1816
- await publishMessageState(args, state.kandanThreadId, completingQueuedSeq, {
1817
- status: "failed",
1818
- reason: error.message,
1819
- });
1820
- clearActiveProcessingState(state, completingQueuedSeq);
1821
- }
1822
- forgetLocalTuiTurnId(state, turnId);
1823
- rememberForwardedTurnId(state, turnId);
1824
- args.log("codex.logical_projection_failed", {
1825
- turn_id: turnId,
1826
- message: error.message,
1827
- });
1828
- return;
1829
- }
1830
-
1831
- rememberRetryableTurnId(state, turnId);
1832
- throw error;
1833
- } finally {
1834
- forgetForwardingTurnId(state, turnId);
1835
- if (
1836
- completingActiveTurn &&
1837
- state.turn.status === "completing" &&
1838
- state.turn.turnId === turnId
1839
- ) {
1840
- state.turn = { status: "idle" };
1841
- await stopCodexTyping(args, state);
1842
- await drainKandanMessageQueue(args, state, payloadContext);
1843
- }
1844
- if (completingLocalTuiTurn && !completingActiveTurn) {
1845
- await stopCodexTyping(args, state);
1846
- await drainKandanMessageQueue(args, state, payloadContext);
1847
- }
1848
- }
1849
- }
1850
-
1851
- async function forwardAssistantDelta(
1852
- args: ChannelSessionContext,
1853
- state: ChannelSessionState,
1854
- params: JsonObject,
1855
- payloadContext: RunnerPayloadContext,
1856
- ): Promise<void> {
1857
- const delta = codexAssistantDeltaFromNotification(params);
1858
-
1859
- if (delta === undefined) {
1860
- return;
1861
- }
1862
-
1863
- await forwardAssistantDeltaPayload(args, state, delta, payloadContext);
1864
- }
1865
-
1866
- async function forwardAssistantDeltaPayload(
1867
- args: ChannelSessionContext,
1868
- state: ChannelSessionState,
1869
- delta: CodexAssistantDelta,
1870
- payloadContext: RunnerPayloadContext,
1871
- ): Promise<void> {
1872
- if (state.kandanThreadId === undefined || state.codexThreadId === undefined) {
1873
- return;
1874
- }
1875
-
1876
- if (
1877
- delta.turnId !== undefined &&
1878
- turnIsFinalizingOrForwarded(state, delta.turnId)
1879
- ) {
1880
- return;
1881
- }
1882
-
1883
- if (delta.turnId !== undefined) {
1884
- await waitForPendingTuiInputMirror(state, delta.turnId);
1885
- }
1886
-
1887
- if (
1888
- delta.turnId !== undefined &&
1889
- isLocalTuiTurn(state, delta.turnId) &&
1890
- sourceMessageSeqForTurn(state, delta.turnId) === undefined
1891
- ) {
1892
- args.log("codex.delta_waiting_for_tui_input_projection", {
1893
- turn_id: delta.turnId,
1894
- item_key: delta.itemKey,
1895
- });
1896
- return;
1897
- }
1898
-
1899
- const existing = findStreamingAssistantOutput(state, delta.itemKey);
1900
- const nextContent = `${existing?.content ?? ""}${delta.delta}`;
1901
- const sourceMessageSeq =
1902
- delta.turnId === undefined ? undefined : sourceMessageSeqForTurn(state, delta.turnId);
1903
- const rootSeq = state.rootSeq;
1904
-
1905
- if (delta.turnId !== undefined && sourceMessageSeq === undefined) {
1906
- args.log("codex.delta_without_source_message", {
1907
- turn_id: delta.turnId,
1908
- item_key: delta.itemKey,
1909
- });
1910
- return;
1911
- }
1912
-
1913
- if (existing === undefined && nextContent.trim() === "") {
1914
- return;
1915
- }
1916
-
1917
- if (existing === undefined) {
1918
- const session = args.options.channelSession;
1919
- const reply = await pushOk(args.kandan, args.topic, "session:post_thread_message", {
1920
- workspace: session.workspaceSlug,
1921
- channel: session.channelSlug,
1922
- thread_id: state.kandanThreadId,
1923
- body: nextContent,
1924
- payload: {
1925
- ...localRunnerPayload(
1926
- args.options,
1927
- args.instanceId,
1928
- "codex_output",
1929
- state.codexThreadId,
1930
- payloadContext,
1931
- sourceMessageSeq,
1932
- ),
1933
- ...(rootSeq === undefined ? {} : { reply_to_seq: rootSeq }),
1934
- structured: codexAssistantStructuredMessage(delta.itemKey, nextContent, "streaming"),
1935
- },
1936
- client_message_id: streamingClientMessageId(args.instanceId, delta),
1937
- });
1938
- const seq = integerValue(reply.seq);
1939
-
1940
- if (seq !== undefined) {
1941
- rememberStreamingAssistantOutput(state, {
1942
- itemKey: delta.itemKey,
1943
- turnId: delta.turnId,
1944
- seq,
1945
- content: nextContent,
1946
- });
1947
- }
1948
- } else {
1949
- await editStreamedCodexOutput(
1950
- args,
1951
- state,
1952
- existing.seq,
1953
- delta.itemKey,
1954
- nextContent,
1955
- "streaming",
1956
- );
1957
- rememberStreamingAssistantOutput(state, {
1958
- ...existing,
1959
- content: nextContent,
1960
- });
1961
- }
1962
-
1963
- args.log("kandan.codex_delta_forwarded", {
1964
- item_key: delta.itemKey,
1965
- turn_id: delta.turnId ?? null,
1966
- content_length: nextContent.length,
1967
- });
1968
- }
1969
-
1970
- function enqueueAssistantDelta(
1971
- args: ChannelSessionContext,
1972
- state: ChannelSessionState,
1973
- params: JsonObject,
1974
- payloadContext: RunnerPayloadContext,
1975
- ): void {
1976
- const delta = codexAssistantDeltaFromNotification(params);
1977
-
1978
- if (delta === undefined) {
1979
- return;
1980
- }
1981
-
1982
- enqueueStreamDelta(
1983
- state.assistantDeltaQueue,
1984
- delta,
1985
- assistantDeltaQueueRuntime(args, state, payloadContext),
1986
- );
1987
- }
1988
-
1989
- function enqueueReasoningDelta(
1990
- args: ChannelSessionContext,
1991
- state: ChannelSessionState,
1992
- params: JsonObject,
1993
- payloadContext: RunnerPayloadContext,
1994
- ): void {
1995
- const delta = codexReasoningDeltaFromNotification(params);
1996
-
1997
- if (delta === undefined) {
1998
- return;
1999
- }
2000
-
2001
- enqueueStreamDelta(
2002
- state.reasoningDeltaQueue,
2003
- delta,
2004
- reasoningDeltaQueueRuntime(args, state, payloadContext),
2005
- );
2006
- }
2007
-
2008
- function enqueueCommandOutputDelta(
2009
- args: ChannelSessionContext,
2010
- state: ChannelSessionState,
2011
- params: JsonObject,
2012
- payloadContext: RunnerPayloadContext,
2013
- ): void {
2014
- const delta = codexCommandOutputDeltaFromNotification(params);
2015
-
2016
- if (delta === undefined) {
2017
- return;
2018
- }
2019
-
2020
- enqueueStreamDelta(
2021
- state.commandOutputQueue,
2022
- delta,
2023
- commandOutputQueueRuntime(args, state, payloadContext),
2024
- );
2025
- }
2026
-
2027
- function enqueueFileChangeDelta(
2028
- args: ChannelSessionContext,
2029
- state: ChannelSessionState,
2030
- params: JsonObject,
2031
- payloadContext: RunnerPayloadContext,
2032
- ): void {
2033
- const delta = codexFileChangeDeltaFromNotification(params);
2034
-
2035
- if (delta === undefined) {
2036
- return;
2037
- }
2038
-
2039
- enqueueStreamDelta(
2040
- state.fileChangeQueue,
2041
- delta,
2042
- fileChangeQueueRuntime(args, state, payloadContext),
2043
- );
2044
- }
2045
-
2046
- function assistantDeltaQueueRuntime(
2047
- args: ChannelSessionContext,
2048
- state: ChannelSessionState,
2049
- payloadContext: RunnerPayloadContext,
2050
- ): StreamDeltaQueueRuntime<CodexAssistantDelta> {
2051
- return {
2052
- flushIntervalMs: streamFlushIntervalMs(args),
2053
- coalesce: coalesceAssistantDeltas,
2054
- firstTurnId: firstDeltaTurnId,
2055
- forward: delta => forwardAssistantDeltaPayload(args, state, delta, payloadContext),
2056
- logFailure: (turnId, error) => {
2057
- args.log("codex.delta_forward_failed", {
2058
- turn_id: turnId ?? null,
2059
- message: error instanceof Error ? error.message : String(error),
2060
- });
2061
- },
2062
- };
2063
- }
2064
-
2065
- function reasoningDeltaQueueRuntime(
2066
- args: ChannelSessionContext,
2067
- state: ChannelSessionState,
2068
- payloadContext: RunnerPayloadContext,
2069
- ): StreamDeltaQueueRuntime<CodexReasoningDelta> {
2070
- return {
2071
- flushIntervalMs: streamFlushIntervalMs(args),
2072
- coalesce: coalesceReasoningDeltas,
2073
- firstTurnId: firstDeltaTurnId,
2074
- forward: delta => forwardReasoningDeltaPayload(args, state, delta, payloadContext),
2075
- logFailure: (turnId, error) => {
2076
- args.log("codex.reasoning_delta_forward_failed", {
2077
- turn_id: turnId ?? null,
2078
- message: error instanceof Error ? error.message : String(error),
2079
- });
2080
- },
2081
- };
2082
- }
2083
-
2084
- function commandOutputQueueRuntime(
2085
- args: ChannelSessionContext,
2086
- state: ChannelSessionState,
2087
- payloadContext: RunnerPayloadContext,
2088
- ): StreamDeltaQueueRuntime<CodexCommandOutputDelta> {
2089
- return {
2090
- flushIntervalMs: streamFlushIntervalMs(args),
2091
- coalesce: coalesceCommandOutputDeltas,
2092
- firstTurnId: firstDeltaTurnId,
2093
- forward: delta => forwardCommandOutputDeltaPayload(args, state, delta, payloadContext),
2094
- logFailure: (turnId, error) => {
2095
- args.log("codex.command_output_forward_failed", {
2096
- turn_id: turnId ?? null,
2097
- message: error instanceof Error ? error.message : String(error),
2098
- });
2099
- },
2100
- };
2101
- }
2102
-
2103
- function fileChangeQueueRuntime(
2104
- args: ChannelSessionContext,
2105
- state: ChannelSessionState,
2106
- payloadContext: RunnerPayloadContext,
2107
- ): StreamDeltaQueueRuntime<CodexFileChangeDelta> {
2108
- return {
2109
- flushIntervalMs: streamFlushIntervalMs(args),
2110
- coalesce: coalesceFileChangeDeltas,
2111
- firstTurnId: firstDeltaTurnId,
2112
- forward: delta => forwardFileChangeDeltaPayload(args, state, delta, payloadContext),
2113
- logFailure: (turnId, error) => {
2114
- args.log("codex.file_change_forward_failed", {
2115
- turn_id: turnId ?? null,
2116
- message: error instanceof Error ? error.message : String(error),
2117
- });
2118
- },
2119
- };
2120
- }
2121
-
2122
- function streamFlushIntervalMs(args: ChannelSessionContext): number {
2123
- return args.options.channelSession.streamFlushMs ?? defaultStreamFlushIntervalMs;
2124
- }
2125
-
2126
- function enqueueTerminalInput(
2127
- args: ChannelSessionContext,
2128
- state: ChannelSessionState,
2129
- params: JsonObject,
2130
- payloadContext: RunnerPayloadContext,
2131
- ): void {
2132
- const previous = state.terminalInputForwardChain;
2133
- const next = previous
2134
- .catch(() => undefined)
2135
- .then(() => forwardTerminalInput(args, state, params, payloadContext));
2136
-
2137
- state.terminalInputForwardChain = next.catch(error => {
2138
- args.log("codex.terminal_input_forward_failed", {
2139
- turn_id: codexNotificationTurnId(params) ?? null,
2140
- message: error instanceof Error ? error.message : String(error),
2141
- });
2142
- });
2143
- }
2144
-
2145
- function enqueueWebSearchProgress(
2146
- args: ChannelSessionContext,
2147
- state: ChannelSessionState,
2148
- params: JsonObject,
2149
- payloadContext: RunnerPayloadContext,
2150
- ): void {
2151
- const previous = state.webSearchProgressForwardChain;
2152
- const next = previous
2153
- .catch(() => undefined)
2154
- .then(() => forwardWebSearchProgress(args, state, params, payloadContext));
2155
-
2156
- state.webSearchProgressForwardChain = next.catch(error => {
2157
- args.log("codex.web_search_progress_forward_failed", {
2158
- turn_id: codexNotificationTurnId(params) ?? null,
2159
- message: error instanceof Error ? error.message : String(error),
2160
- });
2161
- });
2162
- }
2163
-
2164
- function enqueueWebSearchProgressCompletion(
2165
- args: ChannelSessionContext,
2166
- state: ChannelSessionState,
2167
- turnId: string,
2168
- ): void {
2169
- const previous = state.webSearchProgressForwardChain;
2170
- const next = previous
2171
- .catch(() => undefined)
2172
- .then(() => completeWebSearchProgress(args, state, turnId));
2173
-
2174
- state.webSearchProgressForwardChain = next.catch(error => {
2175
- args.log("codex.web_search_progress_completion_failed", {
2176
- turn_id: turnId,
2177
- message: error instanceof Error ? error.message : String(error),
2178
- });
2179
- });
2180
- }
2181
-
2182
- function enqueueFileChangeCompletion(
2183
- args: ChannelSessionContext,
2184
- state: ChannelSessionState,
2185
- turnId: string,
2186
- ): void {
2187
- const previous = state.fileChangeQueue.chain;
2188
- const next = previous
2189
- .catch(() => undefined)
2190
- .then(() => completeFileChangeOutputs(args, state, turnId));
2191
-
2192
- state.fileChangeQueue.chain = next.catch(error => {
2193
- args.log("codex.file_change_completion_failed", {
2194
- turn_id: turnId,
2195
- message: error instanceof Error ? error.message : String(error),
2196
- });
2197
- });
2198
- }
2199
-
2200
- async function forwardReasoningDelta(
2201
- args: ChannelSessionContext,
2202
- state: ChannelSessionState,
2203
- params: JsonObject,
2204
- payloadContext: RunnerPayloadContext,
2205
- ): Promise<void> {
2206
- const delta = codexReasoningDeltaFromNotification(params);
2207
-
2208
- if (delta === undefined) {
2209
- return;
2210
- }
2211
-
2212
- await forwardReasoningDeltaPayload(args, state, delta, payloadContext);
2213
- }
2214
-
2215
- async function forwardReasoningDeltaPayload(
2216
- args: ChannelSessionContext,
2217
- state: ChannelSessionState,
2218
- delta: CodexReasoningDelta,
2219
- payloadContext: RunnerPayloadContext,
2220
- ): Promise<void> {
2221
- if (state.kandanThreadId === undefined || state.codexThreadId === undefined) {
2222
- return;
2223
- }
2224
-
2225
- const turnId = delta.turnId ?? activeTurnId(state.turn);
2226
-
2227
- if (turnId === undefined || turnIsFinalizingOrForwarded(state, turnId)) {
2228
- return;
2229
- }
2230
-
2231
- const sourceMessageSeq = sourceMessageSeqForTurn(state, turnId);
2232
-
2233
- if (sourceMessageSeq === undefined) {
2234
- args.log("codex.reasoning_without_source_message", {
2235
- turn_id: turnId,
2236
- item_key: delta.itemKey,
2237
- });
2238
- return;
2239
- }
2240
-
2241
- const existing = findStreamingReasoningOutput(state, delta.itemKey);
2242
- const nextContent = `${existing?.content ?? ""}${delta.delta}`;
2243
-
2244
- if (existing === undefined && nextContent.trim() === "") {
2245
- return;
2246
- }
2247
-
2248
- if (existing === undefined) {
2249
- const session = args.options.channelSession;
2250
- const reply = await pushOk(args.kandan, args.topic, "session:post_thread_message", {
2251
- workspace: session.workspaceSlug,
2252
- channel: session.channelSlug,
2253
- thread_id: state.kandanThreadId,
2254
- body: nextContent,
2255
- payload: {
2256
- ...localRunnerPayload(
2257
- args.options,
2258
- args.instanceId,
2259
- "codex_output",
2260
- state.codexThreadId,
2261
- payloadContext,
2262
- sourceMessageSeq,
2263
- ),
2264
- ...(state.rootSeq === undefined ? {} : { reply_to_seq: state.rootSeq }),
2265
- structured: codexReasoningStructuredMessage(delta.itemKey, nextContent, "streaming"),
2266
- },
2267
- client_message_id: streamingClientMessageId(args.instanceId, {
2268
- itemKey: `reasoning:${delta.itemKey}`,
2269
- turnId,
2270
- }),
2271
- });
2272
- const seq = integerValue(reply.seq);
2273
-
2274
- if (seq !== undefined) {
2275
- rememberStreamingReasoningOutput(state, {
2276
- itemKey: delta.itemKey,
2277
- turnId,
2278
- seq,
2279
- content: nextContent,
2280
- });
2281
- }
2282
- } else {
2283
- await editCodexStructuredOutput(
2284
- args,
2285
- state,
2286
- existing.seq,
2287
- nextContent,
2288
- codexReasoningStructuredMessage(delta.itemKey, nextContent, "streaming"),
2289
- );
2290
- rememberStreamingReasoningOutput(state, { ...existing, content: nextContent });
2291
- }
2292
-
2293
- args.log("kandan.codex_reasoning_delta_forwarded", {
2294
- item_key: delta.itemKey,
2295
- turn_id: turnId,
2296
- content_length: nextContent.length,
2297
- });
2298
- }
2299
-
2300
- async function forwardCommandOutputDelta(
2301
- args: ChannelSessionContext,
2302
- state: ChannelSessionState,
2303
- params: JsonObject,
2304
- payloadContext: RunnerPayloadContext,
2305
- ): Promise<void> {
2306
- const delta = codexCommandOutputDeltaFromNotification(params);
2307
-
2308
- if (delta === undefined) {
2309
- return;
2310
- }
2311
-
2312
- await forwardCommandOutputDeltaPayload(args, state, delta, payloadContext);
2313
- }
2314
-
2315
- async function forwardCommandOutputDeltaPayload(
2316
- args: ChannelSessionContext,
2317
- state: ChannelSessionState,
2318
- delta: CodexCommandOutputDelta,
2319
- payloadContext: RunnerPayloadContext,
2320
- ): Promise<void> {
2321
- if (state.kandanThreadId === undefined || state.codexThreadId === undefined) {
2322
- return;
2323
- }
2324
-
2325
- const turnId = delta.turnId ?? activeTurnId(state.turn);
2326
-
2327
- if (turnId === undefined || turnIsFinalizingOrForwarded(state, turnId)) {
2328
- return;
2329
- }
2330
-
2331
- const sourceMessageSeq = sourceMessageSeqForTurn(state, turnId);
2332
-
2333
- if (sourceMessageSeq === undefined) {
2334
- args.log("codex.command_output_without_source_message", {
2335
- turn_id: turnId,
2336
- item_key: delta.itemKey,
2337
- });
2338
- return;
2339
- }
2340
-
2341
- const existing = findStreamingCommandOutput(state, delta.itemKey);
2342
- const output = `${existing?.output ?? ""}${delta.delta}`;
2343
- const body = commandOutputBody("command output", output);
2344
- const structured = codexCommandExecutionStructuredMessage(
2345
- delta.itemKey,
2346
- {
2347
- command: "command output",
2348
- output,
2349
- processId: delta.processId,
2350
- stream: delta.stream,
2351
- },
2352
- "streaming",
2353
- );
2354
-
2355
- if (existing === undefined) {
2356
- const session = args.options.channelSession;
2357
- const reply = await pushOk(args.kandan, args.topic, "session:post_thread_message", {
2358
- workspace: session.workspaceSlug,
2359
- channel: session.channelSlug,
2360
- thread_id: state.kandanThreadId,
2361
- body,
2362
- payload: {
2363
- ...localRunnerPayload(
2364
- args.options,
2365
- args.instanceId,
2366
- "codex_output",
2367
- state.codexThreadId,
2368
- payloadContext,
2369
- sourceMessageSeq,
2370
- ),
2371
- ...(state.rootSeq === undefined ? {} : { reply_to_seq: state.rootSeq }),
2372
- structured,
2373
- },
2374
- client_message_id: streamingClientMessageId(args.instanceId, {
2375
- itemKey: `command:${delta.itemKey}`,
2376
- turnId,
2377
- }),
2378
- });
2379
- const seq = integerValue(reply.seq);
2380
-
2381
- if (seq !== undefined) {
2382
- rememberStreamingCommandOutput(state, {
2383
- itemKey: delta.itemKey,
2384
- turnId,
2385
- seq,
2386
- output,
2387
- processId: delta.processId,
2388
- stream: delta.stream,
2389
- });
2390
- }
2391
- } else {
2392
- await editCodexStructuredOutput(args, state, existing.seq, body, structured);
2393
- rememberStreamingCommandOutput(state, {
2394
- ...existing,
2395
- output,
2396
- processId: delta.processId ?? existing.processId,
2397
- stream: delta.stream,
2398
- });
2399
- }
2400
-
2401
- args.log("kandan.codex_command_output_forwarded", {
2402
- item_key: delta.itemKey,
2403
- turn_id: turnId,
2404
- process_id: delta.processId ?? null,
2405
- stream: delta.stream,
2406
- output_length: output.length,
2407
- });
2408
- }
2409
-
2410
- async function forwardFileChangeDelta(
2411
- args: ChannelSessionContext,
2412
- state: ChannelSessionState,
2413
- params: JsonObject,
2414
- payloadContext: RunnerPayloadContext,
2415
- ): Promise<void> {
2416
- const delta = codexFileChangeDeltaFromNotification(params);
2417
-
2418
- if (delta === undefined) {
2419
- return;
2420
- }
2421
-
2422
- await forwardFileChangeDeltaPayload(args, state, delta, payloadContext);
2423
- }
2424
-
2425
- async function forwardFileChangeDeltaPayload(
2426
- args: ChannelSessionContext,
2427
- state: ChannelSessionState,
2428
- delta: CodexFileChangeDelta,
2429
- payloadContext: RunnerPayloadContext,
2430
- ): Promise<void> {
2431
- if (state.kandanThreadId === undefined || state.codexThreadId === undefined) {
2432
- return;
2433
- }
2434
-
2435
- const turnId = delta.turnId ?? activeTurnId(state.turn);
2436
-
2437
- if (turnId === undefined || turnIsFinalizingOrForwarded(state, turnId)) {
2438
- return;
2439
- }
2440
-
2441
- const sourceMessageSeq = sourceMessageSeqForTurn(state, turnId);
2442
-
2443
- if (sourceMessageSeq === undefined) {
2444
- args.log("codex.file_change_without_source_message", {
2445
- turn_id: turnId,
2446
- item_key: delta.itemKey,
2447
- });
2448
- return;
2449
- }
2450
-
2451
- const existing = findStreamingFileChangeOutput(state, delta.itemKey);
2452
- const patchText = `${existing?.patchText ?? ""}${delta.patchText}`;
2453
- const structured = codexFileChangeStructuredMessage(
2454
- delta.itemKey,
2455
- patchText,
2456
- "streaming",
2457
- "started",
2458
- );
2459
-
2460
- if (existing === undefined) {
2461
- const session = args.options.channelSession;
2462
- const reply = await pushOk(args.kandan, args.topic, "session:post_thread_message", {
2463
- workspace: session.workspaceSlug,
2464
- channel: session.channelSlug,
2465
- thread_id: state.kandanThreadId,
2466
- body: patchText,
2467
- payload: {
2468
- ...localRunnerPayload(
2469
- args.options,
2470
- args.instanceId,
2471
- "codex_output",
2472
- state.codexThreadId,
2473
- payloadContext,
2474
- sourceMessageSeq,
2475
- ),
2476
- ...(state.rootSeq === undefined ? {} : { reply_to_seq: state.rootSeq }),
2477
- structured,
2478
- },
2479
- client_message_id: streamingClientMessageId(args.instanceId, {
2480
- itemKey: `file-change:${delta.itemKey}`,
2481
- turnId,
2482
- }),
2483
- });
2484
- const seq = integerValue(reply.seq);
2485
-
2486
- if (seq !== undefined) {
2487
- rememberStreamingFileChangeOutput(state, {
2488
- itemKey: delta.itemKey,
2489
- turnId,
2490
- seq,
2491
- patchText,
2492
- });
2493
- }
2494
- } else {
2495
- await editCodexStructuredOutput(args, state, existing.seq, patchText, structured);
2496
- rememberStreamingFileChangeOutput(state, {
2497
- ...existing,
2498
- patchText,
2499
- });
2500
- }
2501
-
2502
- args.log("kandan.codex_file_change_forwarded", {
2503
- item_key: delta.itemKey,
2504
- turn_id: turnId,
2505
- patch_length: patchText.length,
2506
- });
2507
- }
2508
-
2509
- async function forwardTerminalInput(
2510
- args: ChannelSessionContext,
2511
- state: ChannelSessionState,
2512
- params: JsonObject,
2513
- payloadContext: RunnerPayloadContext,
2514
- ): Promise<void> {
2515
- if (state.kandanThreadId === undefined || state.codexThreadId === undefined) {
2516
- return;
2517
- }
2518
-
2519
- const terminal = codexTerminalInputFromNotification(params);
2520
-
2521
- if (terminal === undefined || state.forwardedTerminalInputKeys.has(terminal.itemKey)) {
2522
- return;
2523
- }
2524
-
2525
- const turnId = terminal.turnId ?? activeTurnId(state.turn);
2526
-
2527
- if (turnId === undefined || turnIsFinalizingOrForwarded(state, turnId)) {
2528
- return;
2529
- }
2530
-
2531
- const sourceMessageSeq = sourceMessageSeqForTurn(state, turnId);
2532
-
2533
- if (sourceMessageSeq === undefined) {
2534
- args.log("codex.terminal_input_without_source_message", {
2535
- turn_id: turnId,
2536
- item_key: terminal.itemKey,
2537
- });
2538
- return;
2539
- }
2540
-
2541
- const session = args.options.channelSession;
2542
- await pushOk(args.kandan, args.topic, "session:post_thread_message", {
2543
- workspace: session.workspaceSlug,
2544
- channel: session.channelSlug,
2545
- thread_id: state.kandanThreadId,
2546
- body: terminal.inputText,
2547
- payload: {
2548
- ...localRunnerPayload(
2549
- args.options,
2550
- args.instanceId,
2551
- "codex_output",
2552
- state.codexThreadId,
2553
- payloadContext,
2554
- sourceMessageSeq,
2555
- ),
2556
- ...(state.rootSeq === undefined ? {} : { reply_to_seq: state.rootSeq }),
2557
- structured: {
2558
- kind: "codex_terminal_input",
2559
- item_id: terminal.itemKey,
2560
- transcript_unit_id: `codex_terminal_input:${terminal.itemKey}`,
2561
- stream_state: "completed",
2562
- process_id: terminal.processId ?? "",
2563
- input_text: terminal.inputText,
2564
- },
2565
- },
2566
- client_message_id: streamingClientMessageId(args.instanceId, {
2567
- itemKey: `terminal:${terminal.itemKey}`,
2568
- turnId,
2569
- }),
2570
- });
2571
- rememberForwardedTerminalInputKey(state, terminal.itemKey);
2572
- args.log("kandan.codex_output_forwarded", {
2573
- turn_id: turnId,
2574
- item_key: terminal.itemKey,
2575
- structured_kind: "codex_terminal_input",
2576
- command: null,
2577
- file_paths: [],
2578
- });
2579
- }
2580
-
2581
- async function forwardWebSearchProgress(
2582
- args: ChannelSessionContext,
2583
- state: ChannelSessionState,
2584
- params: JsonObject,
2585
- payloadContext: RunnerPayloadContext,
2586
- ): Promise<void> {
2587
- if (state.kandanThreadId === undefined || state.codexThreadId === undefined) {
2588
- return;
2589
- }
2590
-
2591
- const progress = codexWebSearchProgressFromNotification(params);
2592
-
2593
- if (progress === undefined || progress.turnId === undefined) {
2594
- return;
2595
- }
2596
-
2597
- const sourceMessageSeq = sourceMessageSeqForTurn(state, progress.turnId);
2598
-
2599
- if (sourceMessageSeq === undefined) {
2600
- args.log("codex.web_search_without_source_message", {
2601
- turn_id: progress.turnId,
2602
- item_key: progress.itemKey,
2603
- });
2604
- return;
2605
- }
2606
-
2607
- const itemKey = webSearchProgressItemKey(progress.turnId);
2608
- const existing = findWebSearchProgressOutput(state, progress.turnId);
2609
- const queries = mergeWebSearchQueries(existing?.queries ?? [], progress.queries);
2610
- const body = webSearchProgressBody(queries);
2611
- const structured = codexWebSearchStructuredMessage(itemKey, queries, "streaming");
2612
-
2613
- if (existing === undefined) {
2614
- const session = args.options.channelSession;
2615
- const reply = await pushOk(args.kandan, args.topic, "session:post_thread_message", {
2616
- workspace: session.workspaceSlug,
2617
- channel: session.channelSlug,
2618
- thread_id: state.kandanThreadId,
2619
- body,
2620
- payload: {
2621
- ...localRunnerPayload(
2622
- args.options,
2623
- args.instanceId,
2624
- "codex_output",
2625
- state.codexThreadId,
2626
- payloadContext,
2627
- sourceMessageSeq,
2628
- ),
2629
- ...(state.rootSeq === undefined ? {} : { reply_to_seq: state.rootSeq }),
2630
- structured,
2631
- },
2632
- client_message_id: webSearchProgressClientMessageId(args.instanceId, progress.turnId),
2633
- });
2634
- const seq = integerValue(reply.seq);
2635
-
2636
- if (seq !== undefined) {
2637
- rememberWebSearchProgressOutput(state, {
2638
- turnId: progress.turnId,
2639
- itemKey,
2640
- seq,
2641
- queries,
2642
- });
2643
- }
2644
- } else if (queries.length !== existing.queries.length) {
2645
- await editCodexStructuredOutput(args, state, existing.seq, body, structured);
2646
- rememberWebSearchProgressOutput(state, {
2647
- ...existing,
2648
- queries,
2649
- });
2650
- }
2651
-
2652
- args.log("kandan.codex_web_search_progress_forwarded", {
2653
- turn_id: progress.turnId,
2654
- item_key: progress.itemKey,
2655
- query_count: queries.length,
2656
- });
2657
- }
2658
-
2659
- async function completeWebSearchProgress(
2660
- args: ChannelSessionContext,
2661
- state: ChannelSessionState,
2662
- turnId: string,
2663
- ): Promise<void> {
2664
- const existing = findWebSearchProgressOutput(state, turnId);
2665
-
2666
- if (existing === undefined) {
2667
- return;
2668
- }
2669
-
2670
- await editCodexStructuredOutput(
2671
- args,
2672
- state,
2673
- existing.seq,
2674
- webSearchProgressBody(existing.queries),
2675
- codexWebSearchStructuredMessage(existing.itemKey, existing.queries, "completed"),
2676
- );
2677
- forgetWebSearchProgressOutput(state, turnId);
2678
- }
2679
-
2680
- async function completeFileChangeOutputs(
2681
- args: ChannelSessionContext,
2682
- state: ChannelSessionState,
2683
- turnId: string,
2684
- ): Promise<void> {
2685
- const outputs = boundedCacheValues(state.streamingFileChangeOutputs).filter(
2686
- output => output.turnId === turnId,
2687
- );
2688
-
2689
- for (const output of outputs) {
2690
- await editCodexStructuredOutput(
2691
- args,
2692
- state,
2693
- output.seq,
2694
- output.patchText,
2695
- codexFileChangeStructuredMessage(
2696
- output.itemKey,
2697
- output.patchText,
2698
- "completed",
2699
- "completed",
2700
- ),
2701
- );
2702
- }
2703
- }
2704
-
2705
- function streamingClientMessageId(
2706
- instanceId: string,
2707
- delta: { readonly itemKey: string; readonly turnId: string | undefined },
2708
- ): string {
2709
- const digest = createHash("sha256")
2710
- .update(`${instanceId}:${delta.turnId ?? "turn"}:${delta.itemKey}`)
2711
- .digest("hex")
2712
- .slice(0, 32);
2713
-
2714
- return `local-codex-stream-${digest}`;
2715
- }
2716
-
2717
- function webSearchProgressClientMessageId(instanceId: string, turnId: string): string {
2718
- const digest = createHash("sha256")
2719
- .update(`${instanceId}:${turnId}:web-search`)
2720
- .digest("hex")
2721
- .slice(0, 32);
2722
-
2723
- return `local-codex-search-${digest}`;
2724
- }
2725
-
2726
- function webSearchProgressItemKey(turnId: string): string {
2727
- return `web-search:${turnId}`;
2728
- }
2729
-
2730
- function commandOutputBody(command: string, output: string): string {
2731
- return [`$ ${command}`, output].filter(part => part.trim() !== "").join("\n\n");
2732
- }
2733
-
2734
- async function streamCompletedCodexOutput(
2735
- args: ChannelSessionContext,
2736
- state: ChannelSessionState,
2737
- payloadContext: RunnerPayloadContext,
2738
- params: {
2739
- readonly turnId: string;
2740
- readonly sourceMessageSeq: number | undefined;
2741
- readonly rootSeq: number | undefined;
2742
- readonly message: {
2743
- readonly itemKey: string;
2744
- readonly body: string;
2745
- readonly structured: JsonObject;
2746
- };
2747
- },
2748
- ): Promise<void> {
2749
- if (state.kandanThreadId === undefined || state.codexThreadId === undefined) {
2750
- return;
2751
- }
2752
-
2753
- const session = args.options.channelSession;
2754
- const streamKey = streamingClientMessageId(args.instanceId, {
2755
- itemKey: params.message.itemKey,
2756
- turnId: params.turnId,
2757
- });
2758
- const streamMetadata: JsonObject = {
2759
- turn_id: params.turnId,
2760
- stream_key: streamKey,
2761
- };
2762
- const structured = structuredWithStreamKey(params.message.structured, streamKey);
2763
- const uploadedFileIds = await uploadedFileIdsForCodexOutput(
2764
- args,
2765
- params.message.body,
2766
- structured,
2767
- );
2768
-
2769
- await pushOk(args.kandan, args.topic, "session:stream_thread_message", {
2770
- workspace: session.workspaceSlug,
2771
- channel: session.channelSlug,
2772
- thread_id: state.kandanThreadId,
2773
- stream_key: streamKey,
2774
- body: params.message.body,
2775
- payload: {
2776
- ...localRunnerPayload(
2777
- args.options,
2778
- args.instanceId,
2779
- "codex_output",
2780
- state.codexThreadId,
2781
- payloadContext,
2782
- params.sourceMessageSeq,
2783
- streamMetadata,
2784
- ),
2785
- ...(params.rootSeq === undefined ? {} : { reply_to_seq: params.rootSeq }),
2786
- ...(params.sourceMessageSeq === undefined
2787
- ? {}
2788
- : { source_message_seq: params.sourceMessageSeq }),
2789
- ...streamMetadata,
2790
- structured,
2791
- },
2792
- ...(uploadedFileIds.length === 0
2793
- ? {}
2794
- : { uploaded_file_ids: uploadedFileIds }),
2795
- client_message_id: streamKey,
2796
- });
2797
- }
2798
-
2799
- function structuredWithStreamKey(
2800
- structured: JsonObject,
2801
- streamKey: string | undefined,
2802
- ): JsonObject {
2803
- return streamKey === undefined
2804
- ? structured
2805
- : { ...structured, stream_key: streamKey };
2806
- }
2807
-
2808
- async function editStreamedCodexOutput(
2809
- args: ChannelSessionContext,
2810
- state: ChannelSessionState,
2811
- targetSeq: number,
2812
- itemKey: string,
2813
- content: string,
2814
- streamState: "streaming" | "completed",
2815
- ): Promise<void> {
2816
- await editCodexStructuredOutput(
2817
- args,
2818
- state,
2819
- targetSeq,
2820
- content,
2821
- codexAssistantStructuredMessage(itemKey, content, streamState),
2822
- );
2823
- }
2824
-
2825
- async function editCodexStructuredOutput(
2826
- args: ChannelSessionContext,
2827
- state: ChannelSessionState,
2828
- targetSeq: number,
2829
- content: string,
2830
- structured: JsonObject,
2831
- ): Promise<void> {
2832
- if (state.kandanThreadId === undefined) {
2833
- return;
2834
- }
2835
-
2836
- const session = args.options.channelSession;
2837
- await pushOk(args.kandan, args.topic, "session:edit_thread_message", {
2838
- workspace: session.workspaceSlug,
2839
- channel: session.channelSlug,
2840
- thread_id: state.kandanThreadId,
2841
- target_seq: targetSeq,
2842
- body: content,
2843
- structured,
2844
- });
2845
- }
2846
-
2847
- async function mirrorLocalTuiInputFromNotification(
2848
- args: ChannelSessionContext,
2849
- state: ChannelSessionState,
2850
- turnId: string,
2851
- params: JsonObject,
2852
- payloadContext: RunnerPayloadContext,
2853
- ): Promise<void> {
2854
- if (!isLocalTuiTurn(state, turnId)) {
2855
- return;
2856
- }
2857
-
2858
- const message = codexUserInputMessageFromNotification(params);
2859
-
2860
- if (message === undefined) {
2861
- return;
2862
- }
2863
-
2864
- await mirrorLocalTuiInputMessage(args, state, turnId, message, payloadContext);
2865
- }
2866
-
2867
- async function mirrorLocalTuiInputMessage(
2868
- args: ChannelSessionContext,
2869
- state: ChannelSessionState,
2870
- turnId: string,
2871
- message: CodexUserInputMessage,
2872
- payloadContext: RunnerPayloadContext,
2873
- ): Promise<void> {
2874
- const logicalKey = tuiInputLogicalKey(turnId, message.body);
2875
-
2876
- if (findMirroredTuiInputProjection(state, logicalKey) !== undefined) {
2877
- rememberMirroredTuiInputItemKey(state, logicalKey, message.itemKey);
2878
- return;
2879
- }
2880
-
2881
- ensureKandanThreadForLocalTuiTurn(state);
2882
- const threadId = state.kandanThreadId;
2883
- const codexThreadId = state.codexThreadId;
2884
-
2885
- if (threadId === undefined || codexThreadId === undefined) {
2886
- return;
2887
- }
2888
-
2889
- const session = args.options.channelSession;
2890
- const reply = await pushOk(args.kandan, args.topic, "session:post_thread_user_message", {
2891
- workspace: session.workspaceSlug,
2892
- channel: session.channelSlug,
2893
- thread_id: threadId,
2894
- body: message.body,
2895
- payload: {
2896
- ...localRunnerPayload(
2897
- args.options,
2898
- args.instanceId,
2899
- "tui_input",
2900
- codexThreadId,
2901
- payloadContext,
2902
- ),
2903
- ...(state.rootSeq === undefined ? {} : { reply_to_seq: state.rootSeq }),
2904
- },
2905
- client_message_id: tuiInputClientMessageId(args.instanceId, turnId, message.itemKey),
2906
- });
2907
- const seq = integerValue(reply.seq);
2908
- if (seq !== undefined) {
2909
- rememberTurnReplyTarget(state, turnId, seq);
2910
- rememberMirroredTuiInputProjection(state, {
2911
- logicalKey,
2912
- turnId,
2913
- seq,
2914
- itemKeys: [message.itemKey],
2915
- });
2916
- }
2917
- args.log("kandan.tui_input_mirrored", {
2918
- turn_id: turnId,
2919
- item_key: message.itemKey,
2920
- actor_user_id: payloadContext.runnerIdentity.actorUserId ?? null,
2921
- actor_slug: payloadContext.runnerIdentity.actorUsername ?? null,
2922
- });
2923
- }
2924
-
2925
- function tuiInputClientMessageId(
2926
- instanceId: string,
2927
- turnId: string,
2928
- itemKey: string,
2929
- ): string {
2930
- const digest = createHash("sha256")
2931
- .update(`${instanceId}:${turnId}:${itemKey}`)
2932
- .digest("hex")
2933
- .slice(0, 32);
2934
-
2935
- return `local-codex-tui-${digest}`;
2936
- }
2937
-
2938
- function tuiInputLogicalKey(turnId: string, body: string): string {
2939
- const digest = createHash("sha256")
2940
- .update(`${turnId}:${body.trim()}`)
2941
- .digest("hex")
2942
- .slice(0, 32);
2943
-
2944
- return `local-codex-tui-input:${digest}`;
2945
- }
2946
-
2947
- function findMirroredTuiInputProjection(
2948
- state: ChannelSessionState,
2949
- logicalKey: string,
2950
- ): MirroredTuiInputProjection | undefined {
2951
- return getBoundedCacheValue(state.mirroredTuiInputProjections, logicalKey);
2952
- }
2953
-
2954
- function rememberMirroredTuiInputProjection(
2955
- state: ChannelSessionState,
2956
- projection: MirroredTuiInputProjection,
2957
- ): void {
2958
- rememberBoundedCacheValue(
2959
- state.mirroredTuiInputProjections,
2960
- projection.logicalKey,
2961
- projection,
2962
- );
2963
- }
2964
-
2965
- function rememberMirroredTuiInputItemKey(
2966
- state: ChannelSessionState,
2967
- logicalKey: string,
2968
- itemKey: string,
2969
- ): void {
2970
- const existing = findMirroredTuiInputProjection(state, logicalKey);
2971
-
2972
- if (existing === undefined || existing.itemKeys.includes(itemKey)) {
2973
- return;
2974
- }
2975
-
2976
- rememberMirroredTuiInputProjection(state, {
2977
- ...existing,
2978
- itemKeys: [...existing.itemKeys, itemKey],
2979
- });
2980
- }
2981
-
2982
- function findStreamingAssistantOutput(
2983
- state: ChannelSessionState,
2984
- itemKey: string,
2985
- ): StreamingAssistantOutput | undefined {
2986
- return getBoundedCacheValue(state.streamingAssistantOutputs, itemKey);
2987
- }
2988
-
2989
- function findStreamingReasoningOutput(
2990
- state: ChannelSessionState,
2991
- itemKey: string,
2992
- ): StreamingReasoningOutput | undefined {
2993
- return getBoundedCacheValue(state.streamingReasoningOutputs, itemKey);
2994
- }
2995
-
2996
- function findStreamingCommandOutput(
2997
- state: ChannelSessionState,
2998
- itemKey: string,
2999
- ): StreamingCommandOutput | undefined {
3000
- return getBoundedCacheValue(state.streamingCommandOutputs, itemKey);
3001
- }
3002
-
3003
- function findStreamingFileChangeOutput(
3004
- state: ChannelSessionState,
3005
- itemKey: string,
3006
- ): StreamingFileChangeOutput | undefined {
3007
- return getBoundedCacheValue(state.streamingFileChangeOutputs, itemKey);
3008
- }
3009
-
3010
- type StreamedStructuredOutput = {
3011
- readonly itemKey: string;
3012
- readonly seq: number;
3013
- };
3014
-
3015
- function resolveStreamingStructuredOutputForCompletedMessage(
3016
- state: ChannelSessionState,
3017
- itemKey: string,
3018
- structured: JsonObject,
3019
- ): StreamedStructuredOutput | undefined {
3020
- switch (stringValue(structured.kind)) {
3021
- case "codex_reasoning": {
3022
- const output = findStreamingReasoningOutput(state, itemKey);
3023
- return output === undefined ? undefined : { itemKey: output.itemKey, seq: output.seq };
3024
- }
3025
- case "codex_command_execution": {
3026
- const output = findStreamingCommandOutput(state, itemKey);
3027
- return output === undefined ? undefined : { itemKey: output.itemKey, seq: output.seq };
3028
- }
3029
- case "codex_file_change": {
3030
- const output = findStreamingFileChangeOutput(state, itemKey);
3031
- return output === undefined ? undefined : { itemKey: output.itemKey, seq: output.seq };
3032
- }
3033
- default:
3034
- return undefined;
3035
- }
3036
- }
3037
-
3038
- function forgetStreamingStructuredOutput(
3039
- state: ChannelSessionState,
3040
- itemKey: string,
3041
- structured: JsonObject,
3042
- ): void {
3043
- switch (stringValue(structured.kind)) {
3044
- case "codex_reasoning":
3045
- forgetStreamingReasoningOutput(state, itemKey);
3046
- break;
3047
- case "codex_command_execution":
3048
- forgetStreamingCommandOutput(state, itemKey);
3049
- break;
3050
- case "codex_file_change":
3051
- forgetStreamingFileChangeOutput(state, itemKey);
3052
- break;
3053
- }
3054
- }
3055
-
3056
- type StreamingAssistantOutputResolution =
3057
- | { readonly status: "matched"; readonly output: StreamingAssistantOutput }
3058
- | { readonly status: "none" }
3059
- | { readonly status: "ambiguous"; readonly candidateCount: number };
3060
-
3061
- class LogicalProjectionError extends Error {
3062
- constructor(message: string) {
3063
- super(message);
3064
- this.name = "LogicalProjectionError";
3065
- }
3066
- }
3067
-
3068
- function resolveStreamingAssistantOutputForCompletedMessage(
3069
- state: ChannelSessionState,
3070
- turnId: string,
3071
- itemKey: string,
3072
- body: string,
3073
- structured: JsonObject,
3074
- ): StreamingAssistantOutputResolution {
3075
- const exact = findStreamingAssistantOutput(state, itemKey);
3076
-
3077
- if (exact !== undefined) {
3078
- return { status: "matched", output: exact };
3079
- }
3080
-
3081
- if (stringValue(structured.kind) !== "codex_assistant_message") {
3082
- return { status: "none" };
3083
- }
3084
-
3085
- const candidates = boundedCacheValues(state.streamingAssistantOutputs).filter(
3086
- output => output.turnId === turnId,
3087
- );
3088
-
3089
- switch (candidates.length) {
3090
- case 0:
3091
- return { status: "none" };
3092
- case 1: {
3093
- const [output] = candidates;
3094
- return output === undefined
3095
- ? { status: "none" }
3096
- : { status: "matched", output };
3097
- }
3098
- default: {
3099
- const contentMatched = candidates.filter(
3100
- output => normalizedTranscriptText(output.content) === normalizedTranscriptText(body),
3101
- );
3102
-
3103
- if (contentMatched.length === 1) {
3104
- const [output] = contentMatched;
3105
- return output === undefined
3106
- ? { status: "ambiguous", candidateCount: candidates.length }
3107
- : { status: "matched", output };
3108
- }
3109
-
3110
- return { status: "ambiguous", candidateCount: candidates.length };
3111
- }
3112
- }
3113
- }
3114
-
3115
- function normalizedTranscriptText(value: string): string {
3116
- return value.trim().replace(/\s+/g, " ");
3117
- }
3118
-
3119
- function turnIsFinalizingOrForwarded(
3120
- state: ChannelSessionState,
3121
- turnId: string,
3122
- ): boolean {
3123
- return (
3124
- state.forwardingTurnIds.has(turnId) ||
3125
- state.forwardedTurnIds.has(turnId)
3126
- );
3127
- }
3128
-
3129
- function rememberStreamingAssistantOutput(
3130
- state: ChannelSessionState,
3131
- output: StreamingAssistantOutput,
3132
- ): void {
3133
- rememberBoundedCacheValue(state.streamingAssistantOutputs, output.itemKey, output);
3134
- }
3135
-
3136
- function forgetStreamingAssistantOutput(
3137
- state: ChannelSessionState,
3138
- itemKey: string,
3139
- ): void {
3140
- forgetBoundedCacheValue(state.streamingAssistantOutputs, itemKey);
3141
- }
3142
-
3143
- function rememberStreamingReasoningOutput(
3144
- state: ChannelSessionState,
3145
- output: StreamingReasoningOutput,
3146
- ): void {
3147
- rememberBoundedCacheValue(state.streamingReasoningOutputs, output.itemKey, output);
3148
- }
3149
-
3150
- function forgetStreamingReasoningOutput(
3151
- state: ChannelSessionState,
3152
- itemKey: string,
3153
- ): void {
3154
- forgetBoundedCacheValue(state.streamingReasoningOutputs, itemKey);
3155
- }
3156
-
3157
- function rememberStreamingCommandOutput(
3158
- state: ChannelSessionState,
3159
- output: StreamingCommandOutput,
3160
- ): void {
3161
- rememberBoundedCacheValue(state.streamingCommandOutputs, output.itemKey, output);
3162
- }
3163
-
3164
- function forgetStreamingCommandOutput(
3165
- state: ChannelSessionState,
3166
- itemKey: string,
3167
- ): void {
3168
- forgetBoundedCacheValue(state.streamingCommandOutputs, itemKey);
3169
- }
3170
-
3171
- function rememberStreamingFileChangeOutput(
3172
- state: ChannelSessionState,
3173
- output: StreamingFileChangeOutput,
3174
- ): void {
3175
- rememberBoundedCacheValue(state.streamingFileChangeOutputs, output.itemKey, output);
3176
- }
3177
-
3178
- function forgetStreamingFileChangeOutput(
3179
- state: ChannelSessionState,
3180
- itemKey: string,
3181
- ): void {
3182
- forgetBoundedCacheValue(state.streamingFileChangeOutputs, itemKey);
3183
- }
3184
-
3185
- function rememberForwardedTerminalInputKey(
3186
- state: ChannelSessionState,
3187
- itemKey: string,
3188
- ): void {
3189
- if (state.forwardedTerminalInputKeys.has(itemKey)) {
3190
- return;
3191
- }
3192
-
3193
- rememberBoundedStringSet(state.forwardedTerminalInputKeys, itemKey, maxForwardedTurnIds);
3194
- }
3195
-
3196
- function findWebSearchProgressOutput(
3197
- state: ChannelSessionState,
3198
- turnId: string,
3199
- ): WebSearchProgressOutput | undefined {
3200
- return getBoundedCacheValue(state.webSearchProgressOutputs, turnId);
3201
- }
3202
-
3203
- function rememberWebSearchProgressOutput(
3204
- state: ChannelSessionState,
3205
- output: WebSearchProgressOutput,
3206
- ): void {
3207
- rememberBoundedCacheValue(state.webSearchProgressOutputs, output.turnId, output);
3208
- }
3209
-
3210
- function forgetWebSearchProgressOutput(
3211
- state: ChannelSessionState,
3212
- turnId: string,
3213
- ): void {
3214
- forgetBoundedCacheValue(state.webSearchProgressOutputs, turnId);
3215
- }
3216
-
3217
- function mergeWebSearchQueries(
3218
- existing: readonly string[],
3219
- incoming: readonly string[],
3220
- ): string[] {
3221
- return [...existing, ...incoming].reduce<string[]>((acc, query) => {
3222
- if (acc.includes(query)) {
3223
- return acc;
3224
- }
3225
-
3226
- return [...acc, query];
3227
- }, []);
3228
- }
3229
-
3230
- function codexNotificationTurnId(params: JsonObject): string | undefined {
3231
- const turn = objectValue(params.turn);
3232
-
3233
- return (
3234
- stringValue(turn?.id) ??
3235
- stringValue(params.turnId) ??
3236
- stringValue(params.turn_id)
3237
- );
3238
- }
3239
-
3240
- function codexNotificationThreadId(params: JsonObject): string | undefined {
3241
- const thread = objectValue(params.thread);
3242
- const turn = objectValue(params.turn);
3243
-
3244
- return (
3245
- stringValue(params.threadId) ??
3246
- stringValue(params.thread_id) ??
3247
- stringValue(thread?.id) ??
3248
- stringValue(turn?.threadId) ??
3249
- stringValue(turn?.thread_id)
3250
- );
3251
- }
3252
-
3253
- function codexNotificationBelongsToSession(
3254
- state: ChannelSessionState,
3255
- threadId: string | undefined,
3256
- turnId: string | undefined,
3257
- ): boolean {
3258
- if (threadId !== undefined) {
3259
- return state.codexThreadId === threadId;
3260
- }
3261
-
3262
- if (turnId === undefined) {
3263
- return false;
3264
- }
3265
-
3266
- return activeTurnId(state.turn) === turnId || isLocalTuiTurn(state, turnId);
3267
- }
3268
-
3269
- function rememberLocalTuiTurnIfNeeded(
3270
- args: ChannelSessionContext,
3271
- state: ChannelSessionState,
3272
- threadId: string,
3273
- turnId: string,
3274
- ): void {
3275
- if (
3276
- args.options.launchTui !== true ||
3277
- state.codexThreadId !== threadId ||
3278
- state.turn.status !== "idle"
3279
- ) {
3280
- return;
3281
- }
3282
-
3283
- rememberBoundedStringSet(state.localTuiTurnIds, turnId, maxForwardedTurnIds);
3284
- if (state.kandanThreadId !== undefined) {
3285
- startCodexTypingHeartbeat(args, state, state.kandanThreadId);
3286
- }
3287
- args.log("codex.tui_turn_started", { turn_id: turnId, codex_thread_id: threadId });
3288
- }
3289
-
3290
- function isLocalTuiTurn(state: ChannelSessionState, turnId: string): boolean {
3291
- return state.localTuiTurnIds.has(turnId);
3292
- }
3293
-
3294
- function localTuiTurnIsActive(state: ChannelSessionState): boolean {
3295
- return state.localTuiTurnIds.size > 0;
3296
- }
3297
-
3298
- function ensureKandanThreadForLocalTuiTurn(
3299
- state: ChannelSessionState,
3300
- ): void {
3301
- if (state.kandanThreadId !== undefined || state.rootSeq === undefined) {
3302
- return;
3303
- }
3304
-
3305
- state.kandanThreadId = randomUUID();
3306
- }
3307
-
3308
- function forgetLocalTuiTurnId(
3309
- state: ChannelSessionState,
3310
- turnId: string,
3311
- ): void {
3312
- state.localTuiTurnIds.delete(turnId);
3313
- }
3314
-
3315
- function rememberPendingTuiInputMirror(
3316
- state: ChannelSessionState,
3317
- turnId: string,
3318
- promise: Promise<void>,
3319
- ): void {
3320
- state.pendingTuiInputMirrors.delete(turnId);
3321
- state.pendingTuiInputMirrors.set(turnId, promise);
3322
- trimBoundedMap(state.pendingTuiInputMirrors, maxForwardedTurnIds);
3323
- }
3324
-
3325
- function forgetPendingTuiInputMirror(
3326
- state: ChannelSessionState,
3327
- turnId: string,
3328
- ): void {
3329
- state.pendingTuiInputMirrors.delete(turnId);
3330
- }
3331
-
3332
- async function waitForPendingTuiInputMirror(
3333
- state: ChannelSessionState,
3334
- turnId: string,
3335
- ): Promise<void> {
3336
- const pending = state.pendingTuiInputMirrors.get(turnId);
3337
-
3338
- if (pending !== undefined) {
3339
- await pending;
3340
- }
3341
- }
3342
-
3343
- async function waitForStreamingForwardChains(
3344
- args: ChannelSessionContext,
3345
- state: ChannelSessionState,
3346
- payloadContext: RunnerPayloadContext,
3347
- ): Promise<void> {
3348
- flushPendingStreamingDeltas(args, state, payloadContext);
3349
- await Promise.all([
3350
- state.assistantDeltaQueue.chain.catch(() => undefined),
3351
- state.reasoningDeltaQueue.chain.catch(() => undefined),
3352
- state.commandOutputQueue.chain.catch(() => undefined),
3353
- state.fileChangeQueue.chain.catch(() => undefined),
3354
- state.terminalInputForwardChain.catch(() => undefined),
3355
- state.webSearchProgressForwardChain.catch(() => undefined),
3356
- ]);
3357
- }
3358
-
3359
- function flushPendingStreamingDeltas(
3360
- args: ChannelSessionContext,
3361
- state: ChannelSessionState,
3362
- payloadContext: RunnerPayloadContext,
3363
- ): void {
3364
- flushStreamDeltaQueue(
3365
- state.assistantDeltaQueue,
3366
- assistantDeltaQueueRuntime(args, state, payloadContext),
3367
- );
3368
- flushStreamDeltaQueue(
3369
- state.reasoningDeltaQueue,
3370
- reasoningDeltaQueueRuntime(args, state, payloadContext),
3371
- );
3372
- flushStreamDeltaQueue(
3373
- state.commandOutputQueue,
3374
- commandOutputQueueRuntime(args, state, payloadContext),
3375
- );
3376
- flushStreamDeltaQueue(
3377
- state.fileChangeQueue,
3378
- fileChangeQueueRuntime(args, state, payloadContext),
3379
- );
3380
- }
3381
-
3382
- function clearPendingStreamFlushTimers(state: ChannelSessionState): void {
3383
- clearStreamDeltaFlushTimer(state.assistantDeltaQueue);
3384
- clearStreamDeltaFlushTimer(state.reasoningDeltaQueue);
3385
- clearStreamDeltaFlushTimer(state.commandOutputQueue);
3386
- clearStreamDeltaFlushTimer(state.fileChangeQueue);
3387
- }
3388
-
3389
- function rememberTurnReplyTarget(
3390
- state: ChannelSessionState,
3391
- turnId: string,
3392
- replyToSeq: number,
3393
- ): void {
3394
- rememberBoundedCacheValue(state.turnReplyTargets, turnId, { turnId, replyToSeq });
3395
- }
3396
-
3397
- function sourceMessageSeqForTurn(
3398
- state: ChannelSessionState,
3399
- turnId: string,
3400
- ): number | undefined {
3401
- return getBoundedCacheValue(state.turnReplyTargets, turnId)?.replyToSeq;
3402
- }
3403
-
3404
- function fileChangePaths(structured: JsonObject): string[] {
3405
- const changes = arrayValue(structured.changes) ?? [];
3406
-
3407
- return changes
3408
- .filter(isJsonObject)
3409
- .map(change => stringValue(change.path) ?? "")
3410
- .filter(path => path.trim() !== "");
3411
- }
3412
-
3413
- async function postCodexThreadReboundMessage(
3414
- args: ChannelSessionContext,
3415
- state: ChannelSessionState,
3416
- payloadContext: RunnerPayloadContext,
3417
- oldCodexThreadId: string | undefined,
3418
- newCodexThreadId: string,
3419
- ): Promise<void> {
3420
- if (state.kandanThreadId === undefined) {
3421
- return;
3422
- }
3423
-
3424
- const session = args.options.channelSession;
3425
- const body = [
3426
- "Codex reconnected.",
3427
- "",
3428
- "The previous local Codex app-server thread was not available in this process, so this runner started a new local Codex thread for this Kandan thread.",
3429
- "",
3430
- `Previous Codex thread: ${oldCodexThreadId ?? "unknown"}`,
3431
- `New Codex thread: ${newCodexThreadId}`,
3432
- ].join("\n");
3433
-
3434
- await pushOk(args.kandan, args.topic, "session:post_thread_message", {
3435
- workspace: session.workspaceSlug,
3436
- channel: session.channelSlug,
3437
- thread_id: state.kandanThreadId,
3438
- body,
3439
- payload: localRunnerPayload(
3440
- args.options,
3441
- args.instanceId,
3442
- "availability",
3443
- newCodexThreadId,
3444
- payloadContext,
3445
- ),
3446
- client_message_id: `local-codex-rebound-${args.instanceId}-${newCodexThreadId}`,
3447
- });
3448
- }
3449
-
3450
- function isRecoverableCodexThreadError(error: unknown): boolean {
3451
- if (!(error instanceof Error)) {
3452
- return false;
3453
- }
3454
-
3455
- return (
3456
- error.message.includes("turn/start failed: thread not found") ||
3457
- error.message.includes("turn/start failed: invalid thread id")
3458
- );
3459
- }
3460
-
3461
- function turnCanForward(state: ChannelSessionState, turnId: string): boolean {
3462
- if (state.retryableTurnIds.has(turnId)) {
3463
- return true;
3464
- }
3465
-
3466
- if (isLocalTuiTurn(state, turnId)) {
3467
- return true;
3468
- }
3469
-
3470
- return turnCanForwardByState(state.turn, turnId);
3471
- }
3472
-
3473
- function rememberForwardedTurnId(
3474
- state: ChannelSessionState,
3475
- turnId: string,
3476
- ): void {
3477
- if (state.forwardedTurnIds.has(turnId)) {
3478
- return;
3479
- }
3480
-
3481
- rememberBoundedStringSet(state.forwardedTurnIds, turnId, maxForwardedTurnIds);
3482
- forgetRetryableTurnId(state, turnId);
3483
- }
3484
-
3485
- function rememberForwardingTurnId(
3486
- state: ChannelSessionState,
3487
- turnId: string,
3488
- ): void {
3489
- rememberBoundedStringSet(state.forwardingTurnIds, turnId, maxForwardedTurnIds);
3490
- }
3491
-
3492
- function forgetForwardingTurnId(
3493
- state: ChannelSessionState,
3494
- turnId: string,
3495
- ): void {
3496
- state.forwardingTurnIds.delete(turnId);
3497
- }
3498
-
3499
- function rememberRetryableTurnId(
3500
- state: ChannelSessionState,
3501
- turnId: string,
3502
- ): void {
3503
- rememberBoundedStringSet(state.retryableTurnIds, turnId, maxForwardedTurnIds);
3504
- }
3505
-
3506
- function forgetRetryableTurnId(
3507
- state: ChannelSessionState,
3508
- turnId: string,
3509
- ): void {
3510
- state.retryableTurnIds.delete(turnId);
3511
- }
3512
-
3513
- function rememberBoundedStringSet(values: Set<string>, value: string, maxSize: number): void {
3514
- values.delete(value);
3515
- values.add(value);
3516
- trimBoundedSet(values, maxSize);
3517
- }
3518
-
3519
- function trimBoundedSet(values: Set<string>, maxSize: number): void {
3520
- while (values.size > maxSize) {
3521
- const oldest = values.values().next().value;
3522
- if (oldest === undefined) {
3523
- return;
3524
- }
3525
- values.delete(oldest);
3526
- }
3527
- }
3528
-
3529
- function trimBoundedMap<V>(values: Map<string, V>, maxSize: number): void {
3530
- while (values.size > maxSize) {
3531
- const oldest = values.keys().next().value;
3532
- if (oldest === undefined) {
3533
- return;
3534
- }
3535
- values.delete(oldest);
3536
- }
3537
- }
3538
-
3539
- async function stopCodexTyping(
3540
- args: ChannelSessionContext,
3541
- state: ChannelSessionState,
3542
- ): Promise<void> {
3543
- stopCodexTypingHeartbeat(state);
3544
-
3545
- if (state.kandanThreadId === undefined) {
3546
- return;
3547
- }
3548
-
3549
- const session = args.options.channelSession;
3550
- await pushOptional(
3551
- args.kandan,
3552
- args.topic,
3553
- "session:typing_stop",
3554
- {
3555
- workspace: session.workspaceSlug,
3556
- channel: session.channelSlug,
3557
- thread_id: state.kandanThreadId,
3558
- },
3559
- args.log,
3560
- );
3561
- }
3562
-
3563
- function startCodexTypingHeartbeat(
3564
- args: ChannelSessionContext,
3565
- state: ChannelSessionState,
3566
- threadId: string,
3567
- ): void {
3568
- const send = () => {
3569
- if (state.typingHeartbeatInFlight) {
3570
- return;
3571
- }
3572
-
3573
- const session = args.options.channelSession;
3574
- state.typingHeartbeatInFlight = true;
3575
- void pushOptional(
3576
- args.kandan,
3577
- args.topic,
3578
- "session:typing_start",
3579
- {
3580
- workspace: session.workspaceSlug,
3581
- channel: session.channelSlug,
3582
- thread_id: threadId,
3583
- },
3584
- args.log,
3585
- )
3586
- .then(() => refreshActiveProcessingHeartbeat(args, state))
3587
- .catch(error => {
3588
- args.log("kandan.typing_heartbeat_failed", {
3589
- message: error instanceof Error ? error.message : String(error),
3590
- });
3591
- })
3592
- .finally(() => {
3593
- state.typingHeartbeatInFlight = false;
3594
- });
3595
- };
3596
-
3597
- send();
3598
-
3599
- if (state.typingHeartbeat === undefined) {
3600
- state.typingHeartbeat = setInterval(send, codexTypingHeartbeatMs);
3601
- }
3602
- }
3603
-
3604
- function stopCodexTypingHeartbeat(state: ChannelSessionState): void {
3605
- if (state.typingHeartbeat !== undefined) {
3606
- clearInterval(state.typingHeartbeat);
3607
- state.typingHeartbeat = undefined;
3608
- }
3609
- }
3610
-
3611
- async function publishKandanMessageState(
3612
- args: ChannelSessionContext,
3613
- event: KandanChatEvent,
3614
- state: LocalCodexMessageState,
3615
- ): Promise<void> {
3616
- if (event.threadId === undefined) {
3617
- return;
3618
- }
3619
-
3620
- await publishMessageState(
3621
- args,
3622
- event.threadId,
3623
- event.seq,
3624
- state,
3625
- event.actorSlug,
3626
- event.actorUserId,
3627
- );
3628
- }
3629
-
3630
- async function publishQueuedMessageState(
3631
- args: ChannelSessionContext,
3632
- state: ChannelSessionState,
3633
- message: QueuedKandanMessage,
3634
- messageState: LocalCodexMessageState,
3635
- ): Promise<void> {
3636
- if (state.kandanThreadId === undefined) {
3637
- return;
3638
- }
3639
-
3640
- await publishMessageState(
3641
- args,
3642
- state.kandanThreadId,
3643
- message.seq,
3644
- messageState,
3645
- message.actorSlug,
3646
- message.actorUserId,
3647
- );
3648
- }
3649
-
3650
- async function publishMessageState(
3651
- args: ChannelSessionContext,
3652
- threadId: string,
3653
- seq: number,
3654
- state: LocalCodexMessageState,
3655
- actorSlug?: string,
3656
- actorUserId?: number,
3657
- ): Promise<void> {
3658
- const session = args.options.channelSession;
3659
- const payload = {
3660
- workspace: session.workspaceSlug,
3661
- channel: session.channelSlug,
3662
- thread_id: threadId,
3663
- seq,
3664
- status: state.status,
3665
- ...("reason" in state ? { reason: state.reason } : {}),
3666
- ...(state.status === "processing" && state.approval !== undefined
3667
- ? {
3668
- approval_request_id: state.approval.requestId,
3669
- approval_kind: state.approval.kind,
3670
- approval_summary: state.approval.summary,
3671
- ...(state.approval.reason === undefined
3672
- ? {}
3673
- : { approval_reason: state.approval.reason }),
3674
- ...(state.approval.choices === undefined
3675
- ? {}
3676
- : { approval_choices: state.approval.choices }),
3677
- ...(state.approval.allowedActorSlug === undefined
3678
- ? {}
3679
- : { approval_allowed_actor_slug: state.approval.allowedActorSlug }),
3680
- ...(state.approval.allowedActorUserId === undefined
3681
- ? {}
3682
- : { approval_allowed_actor_user_id: state.approval.allowedActorUserId }),
3683
- }
3684
- : {}),
3685
- ...(actorSlug === undefined ? {} : { actor_slug: actorSlug }),
3686
- ...(actorUserId === undefined ? {} : { actor_user_id: actorUserId }),
3687
- };
3688
-
3689
- await pushOptional(args.kandan, args.topic, "message_state", payload, args.log);
3690
- }
3691
-
3692
- function abortReason(params: JsonObject): string {
3693
- return (
3694
- stringValue(params.reason) ??
3695
- stringValue(params.message) ??
3696
- stringValue(params.error) ??
3697
- "codex_turn_aborted"
3698
- );
3699
- }
3700
-
3701
- async function failActiveCodexTurn(
3702
- args: ChannelSessionContext,
3703
- state: ChannelSessionState,
3704
- turnId: string,
3705
- reason: string,
3706
- payloadContext: RunnerPayloadContext,
3707
- ): Promise<void> {
3708
- rejectPendingApprovalRequestsForTurn(state, turnId, new Error(reason));
3709
- const seq = activeQueuedSeqForTurn(state.turn, turnId);
3710
-
3711
- if (seq !== undefined && state.kandanThreadId !== undefined) {
3712
- await publishMessageState(args, state.kandanThreadId, seq, {
3713
- status: "failed",
3714
- reason,
3715
- });
3716
- clearActiveProcessingState(state, seq);
3717
- }
3718
-
3719
- forgetLocalTuiTurnId(state, turnId);
3720
- rememberForwardedTurnId(state, turnId);
3721
-
3722
- if (shouldClearTurnAfterFailure(state.turn, turnId)) {
3723
- state.turn = { status: "idle" };
3724
- }
3725
-
3726
- await stopCodexTyping(args, state);
3727
- await drainKandanMessageQueue(args, state, payloadContext);
3728
- }
3729
-
3730
- async function refreshActiveProcessingState(
3731
- args: ChannelSessionContext,
3732
- state: ChannelSessionState,
3733
- turnId: string,
3734
- reason: Exclude<LocalCodexProcessingReason, "awaiting approval">,
3735
- ): Promise<void> {
3736
- const seq = activeQueuedSeqForTurn(state.turn, turnId);
3737
-
3738
- if (seq === undefined || state.kandanThreadId === undefined) {
3739
- return;
3740
- }
3741
-
3742
- if (
3743
- state.activeProcessingState?.seq === seq &&
3744
- state.activeProcessingState.reason === reason
3745
- ) {
3746
- return;
3747
- }
3748
-
3749
- state.activeProcessingState = { seq, reason };
3750
- await publishMessageState(args, state.kandanThreadId, seq, {
3751
- status: "processing",
3752
- reason,
3753
- });
3754
- }
3755
-
3756
- async function refreshActiveProcessingHeartbeat(
3757
- args: ChannelSessionContext,
3758
- state: ChannelSessionState,
3759
- ): Promise<void> {
3760
- const activeProcessingState = state.activeProcessingState;
3761
-
3762
- if (
3763
- activeProcessingState === undefined ||
3764
- state.kandanThreadId === undefined
3765
- ) {
3766
- return;
3767
- }
3768
-
3769
- await publishMessageState(
3770
- args,
3771
- state.kandanThreadId,
3772
- activeProcessingState.seq,
3773
- processingMessageStateFromActive(activeProcessingState),
3774
- );
3775
- }
3776
-
3777
- function clearActiveProcessingState(state: ChannelSessionState, seq: number): void {
3778
- if (state.activeProcessingState?.seq === seq) {
3779
- state.activeProcessingState = undefined;
3780
- }
3781
- }
3782
-
3783
- type DownloadedKandanAttachment = {
3784
- readonly fileName: string;
3785
- readonly contentType: string | undefined;
3786
- readonly sizeBytes: number | undefined;
3787
- readonly path: string;
3788
- readonly isImage: boolean;
3789
- };
3790
-
3791
- async function codexInputItemsForQueuedKandanMessage(
3792
- args: ChannelSessionContext,
3793
- message: QueuedKandanMessage,
3794
- ): Promise<JsonObject[]> {
3795
- const attachments = await downloadQueuedKandanAttachments(args, message);
3796
- const text = appendDownloadedAttachmentContext(
3797
- codexInputForQueuedKandanMessage(message),
3798
- attachments,
3799
- );
3800
- const imageItems = attachments.flatMap(attachment =>
3801
- attachment.isImage ? [{ type: "localImage", path: attachment.path }] : [],
3802
- );
3803
-
3804
- return [{ type: "text", text }, ...imageItems];
3805
- }
3806
-
3807
- async function downloadQueuedKandanAttachments(
3808
- args: ChannelSessionContext,
3809
- message: QueuedKandanMessage,
3810
- ): Promise<DownloadedKandanAttachment[]> {
3811
- if (message.attachments.length === 0) {
3812
- return [];
3813
- }
3814
-
3815
- const directory = join(
3816
- args.options.cwd,
3817
- ".kandan",
3818
- "local-codex-attachments",
3819
- `message-${message.seq}`,
3820
- );
3821
- await mkdir(directory, { recursive: true });
3822
-
3823
- const downloaded: DownloadedKandanAttachment[] = [];
3824
-
3825
- for (const [index, attachment] of message.attachments.entries()) {
3826
- const url = attachment.url;
3827
-
3828
- if (url === undefined) {
3829
- throw new Error(
3830
- `attachment ${index + 1} for message ${message.seq} is missing a URL`,
3831
- );
3832
- }
3833
-
3834
- const fileName = safeAttachmentFileName(
3835
- attachment.fileName ?? attachment.id ?? `attachment-${index + 1}`,
3836
- index,
3837
- );
3838
- const localPath = join(directory, fileName);
3839
- const response = await fetch(resolveKandanAttachmentUrl(args.options.kandanUrl, url), {
3840
- headers: {
3841
- authorization: `Bearer ${args.options.token}`,
3842
- },
3843
- });
3844
-
3845
- if (!response.ok) {
3846
- throw new Error(
3847
- `attachment ${fileName} download failed: ${response.status} ${response.statusText}`,
3848
- );
3849
- }
3850
-
3851
- const bytes = Buffer.from(await response.arrayBuffer());
3852
- await writeFile(localPath, bytes);
3853
-
3854
- downloaded.push({
3855
- fileName,
3856
- contentType: attachment.contentType,
3857
- sizeBytes: attachment.sizeBytes ?? bytes.byteLength,
3858
- path: localPath,
3859
- isImage: isImageAttachment(attachment.contentType, attachment.kind),
3860
- });
3861
- }
3862
-
3863
- return downloaded;
3864
- }
3865
-
3866
- function appendDownloadedAttachmentContext(
3867
- input: string,
3868
- attachments: readonly DownloadedKandanAttachment[],
3869
- ): string {
3870
- if (input.trimStart().startsWith("!")) {
3871
- return input;
3872
- }
3873
-
3874
- const attachmentContext =
3875
- attachments.length === 0
3876
- ? []
3877
- : [
3878
- "",
3879
- "Kandan attachments downloaded for this message:",
3880
- ...attachments.map(attachment => {
3881
- const details = [
3882
- attachment.contentType,
3883
- attachment.sizeBytes === undefined
3884
- ? undefined
3885
- : `${attachment.sizeBytes} bytes`,
3886
- ].flatMap(value => (value === undefined ? [] : [value]));
3887
- const suffix = details.length === 0 ? "" : ` (${details.join(", ")})`;
3888
-
3889
- return `- ${attachment.fileName}${suffix}: ${attachment.path}`;
3890
- }),
3891
- ];
3892
-
3893
- return [
3894
- input,
3895
- ...attachmentContext,
3896
- "",
3897
- "If you create files that should be attached back to Kandan, end your assistant response with:",
3898
- "Kandan attachments:",
3899
- "- relative/or/absolute/path/to/file",
3900
- ].join("\n");
3901
- }
3902
-
3903
- function resolveKandanAttachmentUrl(
3904
- kandanUrl: string | undefined,
3905
- url: string,
3906
- ): string {
3907
- const absolute = tryAbsoluteUrl(url);
3908
-
3909
- if (absolute !== undefined) {
3910
- return absolute;
3911
- }
3912
-
3913
- if (kandanUrl === undefined) {
3914
- throw new Error(
3915
- `relative attachment URL cannot be resolved without Kandan URL: ${url}`,
3916
- );
3917
- }
3918
-
3919
- const httpBase = kandanUrl
3920
- .replace(/^wss:\/\//, "https://")
3921
- .replace(/^ws:\/\//, "http://");
3922
-
3923
- return new URL(url, httpBase).toString();
3924
- }
3925
-
3926
- function tryAbsoluteUrl(url: string): string | undefined {
3927
- try {
3928
- return new URL(url).toString();
3929
- } catch (_error) {
3930
- return undefined;
3931
- }
3932
- }
3933
-
3934
- function safeAttachmentFileName(fileName: string, index: number): string {
3935
- const normalized = basename(fileName)
3936
- .replaceAll(/[^\w.\- ]/g, "_")
3937
- .trim();
3938
- const safeName =
3939
- normalized === "" || normalized === "." || normalized === ".."
3940
- ? `attachment-${index + 1}`
3941
- : normalized;
3942
-
3943
- return `${index + 1}-${safeName}`;
3944
- }
3945
-
3946
- function isImageAttachment(
3947
- contentType: string | undefined,
3948
- kind: string | undefined,
3949
- ): boolean {
3950
- if (
3951
- contentType !== undefined &&
3952
- contentType.toLowerCase().startsWith("image/")
3953
- ) {
3954
- return true;
3955
- }
3956
-
3957
- return kind?.toLowerCase() === "image";
3958
- }
3959
-
3960
- async function uploadedFileIdsForCodexOutput(
3961
- args: ChannelSessionContext,
3962
- body: string,
3963
- structured: JsonObject,
3964
- ): Promise<string[]> {
3965
- if (stringValue(structured.kind) !== "codex_assistant_message") {
3966
- return [];
3967
- }
3968
-
3969
- const paths = extractKandanAttachmentPaths(body, args.options.cwd);
3970
-
3971
- if (paths.length === 0) {
3972
- return [];
3973
- }
3974
-
3975
- const files = await Promise.all(
3976
- paths.map(async path => {
3977
- const info = await stat(path);
3978
-
3979
- if (!info.isFile()) {
3980
- throw new Error(`Kandan attachment path is not a file: ${path}`);
3981
- }
3982
-
3983
- return {
3984
- path,
3985
- fileName: basename(path),
3986
- contentType: contentTypeForFileName(path),
3987
- sizeBytes: info.size,
3988
- };
3989
- }),
3990
- );
3991
- const session = args.options.channelSession;
3992
- const prepare = await pushOk(args.kandan, args.topic, "session:prepare_message_uploads", {
3993
- workspace: session.workspaceSlug,
3994
- channel: session.channelSlug,
3995
- files: files.map(file => ({
3996
- file_name: file.fileName,
3997
- content_type: file.contentType,
3998
- size_bytes: file.sizeBytes,
3999
- })),
4000
- });
4001
- const uploads = arrayValue(prepare.uploads) ?? [];
4002
-
4003
- if (uploads.length !== files.length) {
4004
- throw new Error("Kandan attachment prepare response count mismatch");
4005
- }
4006
-
4007
- await Promise.all(
4008
- files.map(async (file, index) => {
4009
- const upload = objectValue(uploads[index]);
4010
- const uploadUrl = stringValue(upload?.upload_url);
4011
- const uploadMethod = stringValue(upload?.upload_method) ?? "PUT";
4012
-
4013
- if (uploadUrl === undefined) {
4014
- throw new Error("Kandan attachment prepare response missing upload_url");
4015
- }
4016
-
4017
- const bytes = await readFile(file.path);
4018
- const response = await fetch(
4019
- resolveKandanAttachmentUrl(args.options.kandanUrl, uploadUrl),
4020
- {
4021
- method: uploadMethod,
4022
- headers: { "content-type": file.contentType },
4023
- body: bytes,
4024
- },
4025
- );
4026
-
4027
- if (!response.ok) {
4028
- throw new Error(
4029
- `Kandan attachment upload failed for ${file.fileName}: ${response.status} ${response.statusText}`,
4030
- );
4031
- }
4032
- }),
4033
- );
4034
-
4035
- return uploads.map(upload => {
4036
- const fileId = stringValue(objectValue(upload)?.file_id);
4037
-
4038
- if (fileId === undefined) {
4039
- throw new Error("Kandan attachment prepare response missing file_id");
4040
- }
4041
-
4042
- return fileId;
4043
- });
4044
- }
4045
-
4046
- function extractKandanAttachmentPaths(body: string, cwd: string): string[] {
4047
- const lines = body.split(/\r?\n/);
4048
- const headingIndex = lines.findIndex(
4049
- line => line.trim().toLowerCase() === "kandan attachments:",
4050
- );
4051
-
4052
- if (headingIndex === -1) {
4053
- return [];
4054
- }
4055
-
4056
- const seen = new Set<string>();
4057
- return lines.slice(headingIndex + 1).flatMap(line => {
4058
- const trimmed = line.trim();
4059
-
4060
- if (!trimmed.startsWith("- ")) {
4061
- return [];
4062
- }
4063
-
4064
- const rawPath = trimmed.slice(2).trim().replace(/^`|`$/g, "");
4065
- const resolvedPath = resolveCodexAttachmentPath(cwd, rawPath);
4066
-
4067
- if (seen.has(resolvedPath)) {
4068
- return [];
4069
- }
4070
-
4071
- seen.add(resolvedPath);
4072
- return [resolvedPath];
4073
- });
4074
- }
4075
-
4076
- function resolveCodexAttachmentPath(cwd: string, rawPath: string): string {
4077
- if (rawPath === "") {
4078
- throw new Error("Kandan attachment path must not be empty");
4079
- }
4080
-
4081
- const cwdPath = resolve(cwd);
4082
- const path = isAbsolute(rawPath) ? resolve(rawPath) : resolve(cwdPath, rawPath);
4083
- const relativePath = relative(cwdPath, path);
4084
-
4085
- if (relativePath.startsWith("..") || isAbsolute(relativePath)) {
4086
- throw new Error(
4087
- `Kandan attachment path must be inside the runner cwd: ${rawPath}`,
4088
- );
4089
- }
4090
-
4091
- return path;
4092
- }
4093
-
4094
- function contentTypeForFileName(fileName: string): string {
4095
- const lower = fileName.toLowerCase();
4096
-
4097
- if (lower.endsWith(".png")) {
4098
- return "image/png";
4099
- }
4100
-
4101
- if (lower.endsWith(".jpg") || lower.endsWith(".jpeg")) {
4102
- return "image/jpeg";
4103
- }
4104
-
4105
- if (lower.endsWith(".gif")) {
4106
- return "image/gif";
4107
- }
4108
-
4109
- if (lower.endsWith(".webp")) {
4110
- return "image/webp";
4111
- }
4112
-
4113
- if (lower.endsWith(".csv")) {
4114
- return "text/csv";
4115
- }
4116
-
4117
- if (lower.endsWith(".json")) {
4118
- return "application/json";
4119
- }
4120
-
4121
- if (
4122
- lower.endsWith(".md") ||
4123
- lower.endsWith(".txt") ||
4124
- lower.endsWith(".log")
4125
- ) {
4126
- return "text/plain";
4127
- }
4128
-
4129
- return "application/octet-stream";
4130
- }
4131
-
4132
- function runtimeSettingsFromOptions(
4133
- options: ChannelSessionRunnerOptions,
4134
- ): LocalCodexRuntimeSettings {
4135
- return {
4136
- model: options.channelSession.model,
4137
- reasoningEffort: options.channelSession.reasoningEffort,
4138
- approvalPolicy: options.channelSession.approvalPolicy,
4139
- sandbox: options.channelSession.sandbox,
4140
- fast: options.fast,
4141
- };
4142
- }
4143
-
4144
- function mergeRuntimeSettings(
4145
- current: LocalCodexRuntimeSettings,
4146
- update: Extract<KandanControl, { readonly type: "update_session_settings" }>,
4147
- ): LocalCodexRuntimeSettings {
4148
- return {
4149
- model: mergeOptionalStringRuntimeSetting(current.model, update, "model"),
4150
- reasoningEffort: mergeOptionalStringRuntimeSetting(
4151
- current.reasoningEffort,
4152
- update,
4153
- "reasoningEffort",
4154
- ),
4155
- approvalPolicy: mergeOptionalStringRuntimeSetting(
4156
- current.approvalPolicy,
4157
- update,
4158
- "approvalPolicy",
4159
- ),
4160
- sandbox: mergeOptionalStringRuntimeSetting(current.sandbox, update, "sandbox"),
4161
- fast: update.fast ?? current.fast,
4162
- };
4163
- }
4164
-
4165
- function mergeOptionalStringRuntimeSetting(
4166
- current: string | undefined,
4167
- update: Extract<KandanControl, { readonly type: "update_session_settings" }>,
4168
- key: "model" | "reasoningEffort" | "approvalPolicy" | "sandbox",
4169
- ): string | undefined {
4170
- if (Object.prototype.hasOwnProperty.call(update, key)) {
4171
- return update[key] ?? undefined;
4172
- }
4173
-
4174
- return current;
4175
- }
4176
-
4177
- function runtimeOptionsForSettings(
4178
- options: ChannelSessionRunnerOptions,
4179
- settings: LocalCodexRuntimeSettings,
4180
- ): ChannelSessionRunnerOptions {
4181
- return {
4182
- ...options,
4183
- fast: settings.fast,
4184
- channelSession: {
4185
- ...options.channelSession,
4186
- model: settings.model,
4187
- reasoningEffort: settings.reasoningEffort,
4188
- approvalPolicy: settings.approvalPolicy,
4189
- sandbox: settings.sandbox,
4190
- },
4191
- };
4192
- }
4193
-
4194
- async function publishRuntimeSettings(
4195
- args: ChannelSessionContext,
4196
- state: ChannelSessionState,
4197
- ): Promise<void> {
4198
- const session = args.options.channelSession;
4199
-
4200
- if (state.kandanThreadId === undefined || state.codexThreadId === undefined) {
4201
- throw new Error("cannot publish local Codex settings before thread binding");
4202
- }
4203
-
4204
- await pushOk(args.kandan, args.topic, "session:settings", {
4205
- workspace: session.workspaceSlug,
4206
- channel: session.channelSlug,
4207
- thread_id: state.kandanThreadId,
4208
- codex_thread_id: state.codexThreadId,
4209
- model: state.runtimeSettings.model ?? null,
4210
- reasoningEffort: state.runtimeSettings.reasoningEffort ?? null,
4211
- approvalPolicy: state.runtimeSettings.approvalPolicy ?? null,
4212
- sandbox: state.runtimeSettings.sandbox ?? null,
4213
- fast: state.runtimeSettings.fast ?? null,
4214
- updated_at: new Date().toISOString(),
4215
- });
4216
- }
4217
-
4218
-
4219
- function extractThreadIdFromResponse(response: JsonRpcResponse): string {
4220
- if ("error" in response) {
4221
- throw new Error(`thread/start failed: ${response.error.message}`);
4222
- }
4223
-
4224
- const threadId = stringValue(
4225
- objectValue(objectValue(response.result)?.thread)?.id,
4226
- );
4227
-
4228
- if (threadId === undefined) {
4229
- throw new Error("thread/start response did not include thread.id");
4230
- }
4231
-
4232
- return threadId;
4233
- }
4234
-
4235
- function extractTurnIdFromResponse(response: JsonRpcResponse): string {
4236
- if ("error" in response) {
4237
- throw new Error(`turn/start failed: ${response.error.message}`);
4238
- }
4239
-
4240
- const turnId = stringValue(
4241
- objectValue(objectValue(response.result)?.turn)?.id,
4242
- );
4243
-
4244
- if (turnId === undefined) {
4245
- throw new Error("turn/start response did not include turn.id");
4246
- }
4247
-
4248
- return turnId;
4249
- }
4250
-
4251
- async function startCodexThread(
4252
- codex: CodexAppServerClient,
4253
- options: ChannelSessionRunnerOptions,
4254
- ): Promise<string> {
4255
- const start = await codex.request("thread/start", {
4256
- cwd: options.cwd,
4257
- serviceName: "kandan-local-runner",
4258
- personality: "pragmatic",
4259
- ...codexThreadRuntimeOverrides(options),
4260
- });
4261
-
4262
- return extractThreadIdFromResponse(start);
4263
- }
4264
-
4265
- async function pushOk(
4266
- kandan: PhoenixClient,
4267
- topic: string,
4268
- event: string,
4269
- payload: JsonObject,
4270
- ): Promise<JsonObject> {
4271
- const reply = await kandan.push(topic, event, payload);
4272
-
4273
- if (
4274
- isJsonObject(reply) &&
4275
- reply.status === "ok" &&
4276
- isJsonObject(reply.response)
4277
- ) {
4278
- return reply.response;
4279
- }
4280
-
4281
- throw new Error(
4282
- `kandan push failed: ${event}: ${JSON.stringify(reply).slice(0, 500)}`,
4283
- );
4284
- }
4285
-
4286
- async function pushOptional(
4287
- kandan: PhoenixClient,
4288
- topic: string,
4289
- event: string,
4290
- payload: JsonObject,
4291
- log: RunnerLogger,
4292
- ): Promise<void> {
4293
- try {
4294
- await pushOk(kandan, topic, event, payload);
4295
- } catch (error) {
4296
- log("kandan.optional_push_failed", {
4297
- event,
4298
- message: error instanceof Error ? error.message : String(error),
4299
- });
4300
- }
4301
- }