@linzumi/cli 0.0.12-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.
- package/README.md +200 -95
- package/package.json +2 -2
- package/src/agentBootstrap.ts +806 -0
- package/src/channelSession.ts +616 -31
- package/src/channelSessionSupport.ts +54 -1
- package/src/forwardTunnel.ts +32 -7
- package/src/index.ts +293 -56
- package/src/kandanQueue.ts +11 -0
- package/src/localForwarding.ts +31 -8
- package/src/protocol.ts +15 -0
- package/src/runner.ts +21 -3
package/src/channelSession.ts
CHANGED
|
@@ -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(
|
|
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 (
|
|
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:
|
|
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(
|
|
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
|
-
|
|
1581
|
+
settings: LocalCodexRuntimeSettings,
|
|
1513
1582
|
method: string,
|
|
1514
1583
|
): boolean {
|
|
1515
1584
|
return (
|
|
1516
|
-
|
|
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
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
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) {
|