@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.
- package/README.md +208 -229
- package/package.json +2 -2
- package/src/agentBootstrap.ts +806 -0
- package/src/channelSession.ts +630 -31
- package/src/channelSessionSupport.ts +54 -1
- package/src/forwardTunnel.ts +32 -7
- package/src/index.ts +373 -55
- package/src/kandanQueue.ts +11 -0
- package/src/localCapabilities.ts +14 -1
- package/src/localConfig.ts +99 -0
- package/src/localEditor.ts +1 -1
- package/src/localForwarding.ts +31 -8
- package/src/protocol.ts +16 -0
- package/src/runner.ts +49 -15
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,
|
|
@@ -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 (
|
|
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:
|
|
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(
|
|
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
|
-
|
|
1581
|
+
settings: LocalCodexRuntimeSettings,
|
|
1499
1582
|
method: string,
|
|
1500
1583
|
): boolean {
|
|
1501
1584
|
return (
|
|
1502
|
-
|
|
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
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
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) {
|