@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.
- package/README.md +65 -62
- package/bin/linzumi.js +10 -18
- package/dist/assets/linzumi-logo.svg +1 -0
- package/dist/index.js +9229 -0
- package/package.json +9 -4
- package/src/agentBootstrap.ts +0 -872
- package/src/authCache.ts +0 -157
- package/src/authResolution.ts +0 -77
- package/src/boundedCache.ts +0 -57
- package/src/channelSession.ts +0 -4301
- package/src/channelSessionSupport.ts +0 -308
- package/src/codexAppServer.ts +0 -380
- package/src/codexOutput.ts +0 -846
- package/src/codexRuntimeOptions.ts +0 -80
- package/src/dependencyStatus.ts +0 -198
- package/src/forwardTunnel.ts +0 -859
- package/src/forwardTunnelProtocol.ts +0 -324
- package/src/index.ts +0 -1080
- package/src/json.ts +0 -49
- package/src/kandanQueue.ts +0 -113
- package/src/kandanTls.ts +0 -86
- package/src/localCapabilities.ts +0 -143
- package/src/localCodexMessageState.ts +0 -135
- package/src/localCodexTurnState.ts +0 -108
- package/src/localConfig.ts +0 -99
- package/src/localEditor.ts +0 -1061
- package/src/localEditorRuntime.ts +0 -717
- package/src/localForwarding.ts +0 -523
- package/src/oauth.ts +0 -425
- package/src/pendingKandanMessageQueue.ts +0 -109
- package/src/phoenix.ts +0 -359
- package/src/portForwardApproval.ts +0 -181
- package/src/portForwardWatcher.ts +0 -404
- package/src/protocol.ts +0 -321
- package/src/runner.ts +0 -943
- package/src/runnerConsoleReporter.ts +0 -142
- package/src/runnerLogger.ts +0 -50
- package/src/streamDeltaCoalescing.ts +0 -129
- package/src/streamDeltaQueue.ts +0 -102
package/src/channelSession.ts
DELETED
|
@@ -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
|
-
}
|