@linzumi/cli 0.0.11-beta → 0.0.13-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,
@@ -1171,6 +1235,20 @@ async function handleKandanChatEvent(
1171
1235
  return;
1172
1236
  }
1173
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
+
1174
1252
  const portForwardDecision = parsePortForwardDecision(event.body);
1175
1253
  if (portForwardDecision !== undefined) {
1176
1254
  const result = await resolvePendingPortForwardRequest(args, state, payloadContext, {
@@ -1234,6 +1312,7 @@ async function handleKandanChatEvent(
1234
1312
  actorSlug: event.actorSlug,
1235
1313
  actorUserId: event.actorUserId,
1236
1314
  body: event.body,
1315
+ attachments: event.attachments,
1237
1316
  });
1238
1317
  args.log("kandan.message_queued", {
1239
1318
  seq: event.seq,
@@ -1343,7 +1422,11 @@ async function drainKandanMessageQueue(
1343
1422
  state: ChannelSessionState,
1344
1423
  payloadContext: RunnerPayloadContext,
1345
1424
  ): Promise<void> {
1346
- if (state.closed || state.turn.status !== "idle") {
1425
+ if (
1426
+ state.closed ||
1427
+ state.turn.status !== "idle" ||
1428
+ localTuiTurnIsActive(state)
1429
+ ) {
1347
1430
  return;
1348
1431
  }
1349
1432
 
@@ -1380,8 +1463,8 @@ async function drainKandanMessageQueue(
1380
1463
 
1381
1464
  const started = await args.codex.request("turn/start", {
1382
1465
  threadId: codexThreadId,
1383
- input: [{ type: "text", text: codexInputForQueuedKandanMessage(next) }],
1384
- ...codexTurnRuntimeOverrides(args.options),
1466
+ input: await codexInputItemsForQueuedKandanMessage(args, next),
1467
+ ...codexTurnRuntimeOverrides(runtimeOptionsForSettings(args.options, state.runtimeSettings)),
1385
1468
  });
1386
1469
  const turnId = extractTurnIdFromResponse(started);
1387
1470
  const interruptAfterStart =
@@ -1464,7 +1547,7 @@ async function handleCodexServerRequest(
1464
1547
  const params = objectValue(request.params) ?? {};
1465
1548
  const turnId = stringValue(params.turnId);
1466
1549
 
1467
- if (codexApprovalRequestCanAutoAccept(args.options, request.method)) {
1550
+ if (codexApprovalRequestCanAutoAccept(state.runtimeSettings, request.method)) {
1468
1551
  args.log("codex.server_request_auto_accepted", {
1469
1552
  method: request.method,
1470
1553
  turn_id: turnId ?? null,
@@ -1495,11 +1578,11 @@ async function handleCodexServerRequest(
1495
1578
  }
1496
1579
 
1497
1580
  function codexApprovalRequestCanAutoAccept(
1498
- options: ChannelSessionRunnerOptions,
1581
+ settings: LocalCodexRuntimeSettings,
1499
1582
  method: string,
1500
1583
  ): boolean {
1501
1584
  return (
1502
- options.channelSession.approvalPolicy === "never" &&
1585
+ settings.approvalPolicy === "never" &&
1503
1586
  (
1504
1587
  method === "item/commandExecution/requestApproval" ||
1505
1588
  method === "item/fileChange/requestApproval"
@@ -1610,7 +1693,6 @@ async function forwardCompletedCodexTurn(
1610
1693
  forgetRetryableTurnId(state, turnId);
1611
1694
 
1612
1695
  try {
1613
- const session = args.options.channelSession;
1614
1696
  const read = await args.codex.request("thread/read", {
1615
1697
  threadId: state.codexThreadId,
1616
1698
  includeTurns: true,
@@ -1688,24 +1770,11 @@ async function forwardCompletedCodexTurn(
1688
1770
 
1689
1771
  switch (streamed.status) {
1690
1772
  case "none":
1691
- await pushOk(args.kandan, args.topic, "session:post_thread_message", {
1692
- workspace: session.workspaceSlug,
1693
- channel: session.channelSlug,
1694
- thread_id: state.kandanThreadId,
1695
- body: message.body,
1696
- payload: {
1697
- ...localRunnerPayload(
1698
- args.options,
1699
- args.instanceId,
1700
- "codex_output",
1701
- state.codexThreadId,
1702
- payloadContext,
1703
- sourceMessageSeq,
1704
- ),
1705
- ...(rootSeq === undefined ? {} : { reply_to_seq: rootSeq }),
1706
- structured: message.structured,
1707
- },
1708
- client_message_id: `local-codex-${args.instanceId}-${turnId}-${message.itemKey}`,
1773
+ await streamCompletedCodexOutput(args, state, payloadContext, {
1774
+ turnId,
1775
+ sourceMessageSeq,
1776
+ rootSeq,
1777
+ message,
1709
1778
  });
1710
1779
  break;
1711
1780
  case "matched":
@@ -1774,6 +1843,7 @@ async function forwardCompletedCodexTurn(
1774
1843
  }
1775
1844
  if (completingLocalTuiTurn && !completingActiveTurn) {
1776
1845
  await stopCodexTyping(args, state);
1846
+ await drainKandanMessageQueue(args, state, payloadContext);
1777
1847
  }
1778
1848
  }
1779
1849
  }
@@ -2661,6 +2731,80 @@ function commandOutputBody(command: string, output: string): string {
2661
2731
  return [`$ ${command}`, output].filter(part => part.trim() !== "").join("\n\n");
2662
2732
  }
2663
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
+
2664
2808
  async function editStreamedCodexOutput(
2665
2809
  args: ChannelSessionContext,
2666
2810
  state: ChannelSessionState,
@@ -3106,6 +3250,22 @@ function codexNotificationThreadId(params: JsonObject): string | undefined {
3106
3250
  );
3107
3251
  }
3108
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
+
3109
3269
  function rememberLocalTuiTurnIfNeeded(
3110
3270
  args: ChannelSessionContext,
3111
3271
  state: ChannelSessionState,
@@ -3131,6 +3291,10 @@ function isLocalTuiTurn(state: ChannelSessionState, turnId: string): boolean {
3131
3291
  return state.localTuiTurnIds.has(turnId);
3132
3292
  }
3133
3293
 
3294
+ function localTuiTurnIsActive(state: ChannelSessionState): boolean {
3295
+ return state.localTuiTurnIds.size > 0;
3296
+ }
3297
+
3134
3298
  function ensureKandanThreadForLocalTuiTurn(
3135
3299
  state: ChannelSessionState,
3136
3300
  ): void {
@@ -3616,6 +3780,441 @@ function clearActiveProcessingState(state: ChannelSessionState, seq: number): vo
3616
3780
  }
3617
3781
  }
3618
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
+
3619
4218
 
3620
4219
  function extractThreadIdFromResponse(response: JsonRpcResponse): string {
3621
4220
  if ("error" in response) {