@linzumi/cli 0.0.12-beta → 0.0.14-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.
@@ -90,6 +90,11 @@
90
90
  Relationship: Watches descendant listener ports, prompts the authorized
91
91
  Kandan listener user for approval, and resolves the approval into a dynamic
92
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.
93
98
  */
94
99
  import {
95
100
  availabilityMessage,
@@ -104,6 +109,8 @@ import {
104
109
  type RunnerPayloadContext,
105
110
  } from "./channelSessionSupport";
106
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";
107
114
  import { type CodexAppServerClient } from "./codexAppServer";
108
115
  import {
109
116
  codexThreadRuntimeOverrides,
@@ -223,12 +230,14 @@ export type ChannelSessionRuntime = {
223
230
  ) => void;
224
231
  readonly handleControl: (control: KandanControl) => Promise<JsonObject | undefined>;
225
232
  readonly handleKandanReconnect: () => Promise<void>;
233
+ readonly currentRuntimeSettings: () => LocalCodexRuntimeSettings;
226
234
  readonly currentCodexThreadId: () => string | undefined;
227
235
  readonly currentKandanThreadId: () => string | undefined;
228
236
  readonly close: () => Promise<void>;
229
237
  };
230
238
 
231
239
  export type ChannelSessionRunnerOptions = {
240
+ readonly kandanUrl?: string | undefined;
232
241
  readonly token: string;
233
242
  readonly runnerId: string;
234
243
  readonly cwd: string;
@@ -294,12 +303,21 @@ type ChannelSessionState = {
294
303
  webSearchProgressForwardChain: Promise<void>;
295
304
  typingHeartbeat: ReturnType<typeof setInterval> | undefined;
296
305
  typingHeartbeatInFlight: boolean;
306
+ runtimeSettings: LocalCodexRuntimeSettings;
297
307
  };
298
308
 
299
309
  const codexTypingHeartbeatMs = 5_000;
300
310
  const defaultStreamFlushIntervalMs = 150;
301
311
  const maxForwardedTurnIds = 64;
302
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
+
303
321
  type StreamingAssistantOutput = {
304
322
  readonly itemKey: string;
305
323
  readonly turnId: string | undefined;
@@ -362,7 +380,11 @@ export async function attachChannelSession(
362
380
  ): Promise<ChannelSessionRuntime> {
363
381
  const session = args.options.channelSession;
364
382
  const chatTopic = `chat:${session.workspaceSlug}:${session.channelSlug}`;
365
- const state = initialChannelSessionState(0, session.kandanThreadId);
383
+ const state = initialChannelSessionState(
384
+ 0,
385
+ session.kandanThreadId,
386
+ args.options,
387
+ );
366
388
  const joined = await args.kandan.join(chatTopic, { last_seq: 0 }, {
367
389
  rejoinPayload: () => ({ last_seq: state.minSeq }),
368
390
  });
@@ -413,10 +435,7 @@ export async function attachChannelSession(
413
435
  const turnId = codexNotificationTurnId(params);
414
436
  const threadId = codexNotificationThreadId(params);
415
437
 
416
- if (
417
- threadId !== undefined &&
418
- state.codexThreadId === threadId
419
- ) {
438
+ if (codexNotificationBelongsToSession(state, threadId, turnId)) {
420
439
  const processingReason = processingReasonForCodexNotification(method, params);
421
440
  if (turnId !== undefined && processingReason !== undefined) {
422
441
  void refreshActiveProcessingState(args, state, turnId, processingReason).catch(error => {
@@ -504,6 +523,7 @@ export async function attachChannelSession(
504
523
  }
505
524
  },
506
525
  handleControl: control => handleChannelSessionControl(args, state, payloadContext, control),
526
+ currentRuntimeSettings: () => state.runtimeSettings,
507
527
  currentCodexThreadId: () => state.codexThreadId,
508
528
  currentKandanThreadId: () => state.kandanThreadId,
509
529
  handleKandanReconnect: async () => {
@@ -519,6 +539,7 @@ export async function attachChannelSession(
519
539
  await bindCurrentCodexThread(args, state);
520
540
  if (state.kandanThreadId !== undefined && state.turn.status !== "idle") {
521
541
  startCodexTypingHeartbeat(args, state, state.kandanThreadId);
542
+ await refreshActiveProcessingHeartbeat(args, state);
522
543
  }
523
544
  await drainKandanMessageQueue(args, state, payloadContext);
524
545
  },
@@ -554,6 +575,7 @@ async function bindCurrentCodexThread(
554
575
  function initialChannelSessionState(
555
576
  cursor: number,
556
577
  kandanThreadId: string | undefined,
578
+ options: ChannelSessionRunnerOptions,
557
579
  ): ChannelSessionState {
558
580
  return {
559
581
  rootSeq: undefined,
@@ -591,6 +613,7 @@ function initialChannelSessionState(
591
613
  webSearchProgressForwardChain: Promise.resolve(),
592
614
  typingHeartbeat: undefined,
593
615
  typingHeartbeatInFlight: false,
616
+ runtimeSettings: runtimeSettingsFromOptions(options),
594
617
  };
595
618
  }
596
619
 
@@ -679,6 +702,10 @@ async function handleChannelSessionControl(
679
702
  payloadContext: RunnerPayloadContext,
680
703
  control: KandanControl,
681
704
  ): Promise<JsonObject | undefined> {
705
+ if (control.type === "update_session_settings") {
706
+ return updateSessionSettings(args, state, control);
707
+ }
708
+
682
709
  if (control.type === "resolve_codex_approval_request") {
683
710
  return resolvePendingCodexApprovalRequest(args, state, control);
684
711
  }
@@ -753,6 +780,43 @@ async function handleChannelSessionControl(
753
780
  };
754
781
  }
755
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
+
756
820
  async function resolvePendingCodexApprovalRequest(
757
821
  args: ChannelSessionContext,
758
822
  state: ChannelSessionState,
@@ -1136,7 +1200,7 @@ async function handleKandanChatEvent(
1136
1200
  if (
1137
1201
  event.type !== "thread.message" ||
1138
1202
  event.threadId === undefined ||
1139
- event.body.trim() === ""
1203
+ (event.body.trim() === "" && event.attachments.length === 0)
1140
1204
  ) {
1141
1205
  args.log("kandan.message_ignored", {
1142
1206
  seq: event.seq,
@@ -1248,6 +1312,7 @@ async function handleKandanChatEvent(
1248
1312
  actorSlug: event.actorSlug,
1249
1313
  actorUserId: event.actorUserId,
1250
1314
  body: event.body,
1315
+ attachments: event.attachments,
1251
1316
  });
1252
1317
  args.log("kandan.message_queued", {
1253
1318
  seq: event.seq,
@@ -1357,7 +1422,11 @@ async function drainKandanMessageQueue(
1357
1422
  state: ChannelSessionState,
1358
1423
  payloadContext: RunnerPayloadContext,
1359
1424
  ): Promise<void> {
1360
- if (state.closed || state.turn.status !== "idle") {
1425
+ if (
1426
+ state.closed ||
1427
+ state.turn.status !== "idle" ||
1428
+ localTuiTurnIsActive(state)
1429
+ ) {
1361
1430
  return;
1362
1431
  }
1363
1432
 
@@ -1394,8 +1463,8 @@ async function drainKandanMessageQueue(
1394
1463
 
1395
1464
  const started = await args.codex.request("turn/start", {
1396
1465
  threadId: codexThreadId,
1397
- input: [{ type: "text", text: codexInputForQueuedKandanMessage(next) }],
1398
- ...codexTurnRuntimeOverrides(args.options),
1466
+ input: await codexInputItemsForQueuedKandanMessage(args, next),
1467
+ ...codexTurnRuntimeOverrides(runtimeOptionsForSettings(args.options, state.runtimeSettings)),
1399
1468
  });
1400
1469
  const turnId = extractTurnIdFromResponse(started);
1401
1470
  const interruptAfterStart =
@@ -1478,7 +1547,7 @@ async function handleCodexServerRequest(
1478
1547
  const params = objectValue(request.params) ?? {};
1479
1548
  const turnId = stringValue(params.turnId);
1480
1549
 
1481
- if (codexApprovalRequestCanAutoAccept(args.options, request.method)) {
1550
+ if (codexApprovalRequestCanAutoAccept(state.runtimeSettings, request.method)) {
1482
1551
  args.log("codex.server_request_auto_accepted", {
1483
1552
  method: request.method,
1484
1553
  turn_id: turnId ?? null,
@@ -1509,11 +1578,11 @@ async function handleCodexServerRequest(
1509
1578
  }
1510
1579
 
1511
1580
  function codexApprovalRequestCanAutoAccept(
1512
- options: ChannelSessionRunnerOptions,
1581
+ settings: LocalCodexRuntimeSettings,
1513
1582
  method: string,
1514
1583
  ): boolean {
1515
1584
  return (
1516
- options.channelSession.approvalPolicy === "never" &&
1585
+ settings.approvalPolicy === "never" &&
1517
1586
  (
1518
1587
  method === "item/commandExecution/requestApproval" ||
1519
1588
  method === "item/fileChange/requestApproval"
@@ -1624,7 +1693,6 @@ async function forwardCompletedCodexTurn(
1624
1693
  forgetRetryableTurnId(state, turnId);
1625
1694
 
1626
1695
  try {
1627
- const session = args.options.channelSession;
1628
1696
  const read = await args.codex.request("thread/read", {
1629
1697
  threadId: state.codexThreadId,
1630
1698
  includeTurns: true,
@@ -1702,24 +1770,11 @@ async function forwardCompletedCodexTurn(
1702
1770
 
1703
1771
  switch (streamed.status) {
1704
1772
  case "none":
1705
- await pushOk(args.kandan, args.topic, "session:post_thread_message", {
1706
- workspace: session.workspaceSlug,
1707
- channel: session.channelSlug,
1708
- thread_id: state.kandanThreadId,
1709
- body: message.body,
1710
- payload: {
1711
- ...localRunnerPayload(
1712
- args.options,
1713
- args.instanceId,
1714
- "codex_output",
1715
- state.codexThreadId,
1716
- payloadContext,
1717
- sourceMessageSeq,
1718
- ),
1719
- ...(rootSeq === undefined ? {} : { reply_to_seq: rootSeq }),
1720
- structured: message.structured,
1721
- },
1722
- client_message_id: `local-codex-${args.instanceId}-${turnId}-${message.itemKey}`,
1773
+ await streamCompletedCodexOutput(args, state, payloadContext, {
1774
+ turnId,
1775
+ sourceMessageSeq,
1776
+ rootSeq,
1777
+ message,
1723
1778
  });
1724
1779
  break;
1725
1780
  case "matched":
@@ -1788,6 +1843,7 @@ async function forwardCompletedCodexTurn(
1788
1843
  }
1789
1844
  if (completingLocalTuiTurn && !completingActiveTurn) {
1790
1845
  await stopCodexTyping(args, state);
1846
+ await drainKandanMessageQueue(args, state, payloadContext);
1791
1847
  }
1792
1848
  }
1793
1849
  }
@@ -2675,6 +2731,80 @@ function commandOutputBody(command: string, output: string): string {
2675
2731
  return [`$ ${command}`, output].filter(part => part.trim() !== "").join("\n\n");
2676
2732
  }
2677
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
+
2678
2808
  async function editStreamedCodexOutput(
2679
2809
  args: ChannelSessionContext,
2680
2810
  state: ChannelSessionState,
@@ -3120,6 +3250,22 @@ function codexNotificationThreadId(params: JsonObject): string | undefined {
3120
3250
  );
3121
3251
  }
3122
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
+
3123
3269
  function rememberLocalTuiTurnIfNeeded(
3124
3270
  args: ChannelSessionContext,
3125
3271
  state: ChannelSessionState,
@@ -3145,6 +3291,10 @@ function isLocalTuiTurn(state: ChannelSessionState, turnId: string): boolean {
3145
3291
  return state.localTuiTurnIds.has(turnId);
3146
3292
  }
3147
3293
 
3294
+ function localTuiTurnIsActive(state: ChannelSessionState): boolean {
3295
+ return state.localTuiTurnIds.size > 0;
3296
+ }
3297
+
3148
3298
  function ensureKandanThreadForLocalTuiTurn(
3149
3299
  state: ChannelSessionState,
3150
3300
  ): void {
@@ -3630,6 +3780,441 @@ function clearActiveProcessingState(state: ChannelSessionState, seq: number): vo
3630
3780
  }
3631
3781
  }
3632
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
+
3633
4218
 
3634
4219
  function extractThreadIdFromResponse(response: JsonRpcResponse): string {
3635
4220
  if ("error" in response) {