@linzumi/cli 0.0.19-beta → 0.0.22-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/src/runner.ts DELETED
@@ -1,943 +0,0 @@
1
- /*
2
- - Date: 2026-04-24
3
- Spec: plans/2026-04-24-local-codex-runner-plan.md
4
- Relationship: Bridges the spec's desired local instance lifecycle between
5
- Kandan controls, Codex app-server JSON-RPC, and optional remote TUI launch.
6
-
7
- - Date: 2026-04-24
8
- Spec: plans/2026-04-24-local-codex-channel-thread-binding-spec.md
9
- Relationship: Hosts the process lifecycle used by the channel-bound local
10
- Codex product flow while delegating session behavior to `channelSession.ts`.
11
-
12
- - Date: 2026-04-24
13
- Spec: plans/2026-04-24-local-codex-runner-quality-pass-spec.md
14
- Relationship: Keeps the runner focused on transport lifecycle and Codex
15
- process orchestration instead of embedding pure channel-session policy.
16
-
17
- - Date: 2026-04-24
18
- Spec: plans/2026-04-24-local-codex-channel-session-module-spec.md
19
- Relationship: Delegates channel-bound Kandan/Codex session orchestration to
20
- the dedicated channel session module.
21
-
22
- - Date: 2026-04-24
23
- Spec: plans/2026-04-24-local-codex-runner-deep-quality-spec.md
24
- Relationship: Owns channel-mode startup cost and process-level cleanup
25
- requirements for the deep quality pass, including cleanup of partially
26
- opened resources when startup fails.
27
-
28
- - Date: 2026-04-25
29
- Spec: plans/2026-04-24-local-codex-runner-deep-quality-spec.md
30
- Relationship: Leaves channel-scoped approval controls to `channelSession.ts`
31
- so the process runner remains lifecycle-only while Kandan safely resolves
32
- Codex app-server approval requests from the thread UI.
33
-
34
- - Date: 2026-04-26
35
- Spec: plans/2026_04_26_linzumi_cli_review_followups_note.md
36
- Relationship: Rejects stale Kandan controls for previous local runner
37
- instances before they can mutate the current Codex app-server session.
38
-
39
- - Date: 2026-04-26
40
- Spec: plans/2026-04-26-local-codex-driver-worldclass-spec.md
41
- Relationship: Enforces local machine cwd capabilities for Kandan-requested
42
- Codex session starts and advertises cwd plus local preview-port capabilities
43
- to the server on the runner join payload.
44
-
45
- - Date: 2026-04-26
46
- Spec: plans/2026-04-26-local-runner-forwarding-and-editor-plan.md
47
- Relationship: Routes Kandan-authenticated local preview forwarding controls
48
- to the local loopback HTTP fetcher after verifying the control targets this
49
- runner instance.
50
-
51
- - Date: 2026-04-26
52
- Spec: plans/2026-04-26-local-runner-port-forward-approval.md
53
- Relationship: Lets channel-session approval add descendant listener ports to
54
- the runner's live forwarding capability before forwarding requests use them.
55
- */
56
- import { spawn, type ChildProcess } from "node:child_process";
57
- import { randomUUID } from "node:crypto";
58
- import { hostname } from "node:os";
59
- import { join } from "node:path";
60
- import { attachChannelSession } from "./channelSession";
61
- import { connectCodexAppServer, startCodexAppServer } from "./codexAppServer";
62
- import { arrayValue, integerValue, objectValue, stringValue } from "./json";
63
- import { resolveAllowedCwd } from "./localCapabilities";
64
- import {
65
- createForwardWebSocketManager,
66
- handleForwardHttpRequest,
67
- isForwardHttpRequestControl,
68
- isForwardWebSocketControl,
69
- } from "./localForwarding";
70
- import {
71
- isStartLocalEditorControl,
72
- localEditorCapabilities,
73
- startLocalEditor,
74
- type LocalEditorState,
75
- } from "./localEditor";
76
- import type { InstalledEditorRuntime } from "./localEditorRuntime";
77
- import {
78
- dependencyStatusPayload,
79
- type RunnerDependencyStatus,
80
- } from "./dependencyStatus";
81
- import { connectPhoenixClient } from "./phoenix";
82
- import {
83
- type JsonObject,
84
- type JsonRpcResponse,
85
- type JsonValue,
86
- type KandanChannelSessionOptions,
87
- type KandanControl,
88
- extractCodexIds,
89
- isJsonObject,
90
- } from "./protocol";
91
- import { createRunnerLogger, type RunnerLogger } from "./runnerLogger";
92
- import { reportRunnerConsoleEvent } from "./runnerConsoleReporter";
93
-
94
- export type RunnerOptions = {
95
- readonly kandanUrl: string;
96
- readonly token: string;
97
- readonly runnerId: string;
98
- readonly cwd: string;
99
- readonly codexBin: string;
100
- readonly codexUrl: string | undefined;
101
- readonly launchTui: boolean;
102
- readonly fast?: boolean | undefined;
103
- readonly logFile?: string | undefined;
104
- readonly allowedCwds: readonly string[];
105
- readonly allowedForwardPorts?: readonly number[] | undefined;
106
- readonly codeServerBin?: string | undefined;
107
- readonly editorRuntime?: InstalledEditorRuntime | undefined;
108
- readonly dependencyStatus?: RunnerDependencyStatus | undefined;
109
- readonly socketFactory?: ((url: string) => WebSocket) | undefined;
110
- readonly channelSession: KandanChannelSessionOptions | undefined;
111
- };
112
-
113
- export type LocalCodexRunnerHandle = {
114
- readonly instanceId: string;
115
- readonly codexUrl: string;
116
- readonly close: () => Promise<void>;
117
- };
118
-
119
- type CleanupAction = () => void | Promise<void>;
120
-
121
- type CleanupStack = {
122
- readonly actions: CleanupAction[];
123
- closePromise: Promise<void> | undefined;
124
- removeHandlers: (() => void) | undefined;
125
- };
126
-
127
- export async function runLocalCodexRunner(
128
- options: RunnerOptions,
129
- ): Promise<LocalCodexRunnerHandle> {
130
- const log = makeRunnerLogger(options);
131
- const cleanup: CleanupStack = {
132
- actions: [() => log.close()],
133
- closePromise: undefined,
134
- removeHandlers: undefined,
135
- };
136
- const close = () => closeCleanupStack(cleanup);
137
- cleanup.removeHandlers = installCleanupHandlers(close);
138
-
139
- log("runner.starting", {
140
- runnerId: options.runnerId,
141
- cwd: options.cwd,
142
- kandanUrl: options.kandanUrl,
143
- });
144
-
145
- try {
146
- return await openLocalCodexRunner(options, log, cleanup, close);
147
- } catch (error) {
148
- await close().catch(() => undefined);
149
- throw error;
150
- }
151
- }
152
-
153
- async function openLocalCodexRunner(
154
- options: RunnerOptions,
155
- log: RunnerLogger,
156
- cleanup: CleanupStack,
157
- close: () => Promise<void>,
158
- ): Promise<LocalCodexRunnerHandle> {
159
- const allowedForwardPorts = options.allowedForwardPorts ?? [];
160
- const liveForwardPorts = new Set<number>(allowedForwardPorts);
161
- const managedForwardPorts = new Set<number>();
162
- const allowedCwds = { value: [...options.allowedCwds] };
163
- const localEditorState: { value: LocalEditorState } = {
164
- value: { status: "disabled" },
165
- };
166
- const localEditorGeneration = { value: 0 };
167
- cleanup.actions.push(() => {
168
- if (localEditorState.value.status === "running") {
169
- localEditorState.value.process.kill("SIGINT");
170
- localEditorState.value.collaboration?.process.kill("SIGINT");
171
- }
172
- });
173
- const capabilitiesPayload = (): JsonObject => ({
174
- codexAppServer: true,
175
- codexRemoteTui: true,
176
- startInstance: allowedCwds.value.length > 0,
177
- allowedCwds: allowedCwds.value,
178
- allowedCwdSuggestions: allowedCwdSuggestions(
179
- options.cwd,
180
- allowedCwds.value,
181
- ),
182
- portForwarding: liveForwardPorts.size > 0,
183
- allowedPorts: Array.from(liveForwardPorts).sort(
184
- (left, right) => left - right,
185
- ),
186
- toolStatus:
187
- options.dependencyStatus === undefined
188
- ? null
189
- : dependencyStatusPayload(options.dependencyStatus),
190
- editorRuntime:
191
- options.dependencyStatus?.editorRuntime === undefined
192
- ? null
193
- : dependencyStatusPayload(options.dependencyStatus).editorRuntime,
194
- ...localEditorCapabilities(
195
- options.editorRuntime,
196
- allowedCwds.value,
197
- localEditorState.value,
198
- ),
199
- });
200
- const kandan = await connectPhoenixClient(
201
- options.kandanUrl,
202
- options.token,
203
- options.socketFactory,
204
- );
205
- cleanup.actions.push(() => kandan.close());
206
- const topic = `local_runner:${options.runnerId}`;
207
- const joinPayload = (): JsonObject => ({
208
- clientName: "kandan-local-codex-runner",
209
- version: "0.0.1",
210
- workspace: options.channelSession?.workspaceSlug ?? null,
211
- channel: options.channelSession?.channelSlug ?? null,
212
- capabilities: capabilitiesPayload(),
213
- });
214
-
215
- const pendingControls: KandanControl[] = [];
216
- const controlDispatcher: {
217
- value: ((control: KandanControl) => void) | undefined;
218
- } = { value: undefined };
219
- kandan.onControl((control) => {
220
- const dispatcher = controlDispatcher.value;
221
- if (dispatcher === undefined) {
222
- pendingControls.push(control);
223
- return;
224
- }
225
-
226
- dispatcher(control);
227
- });
228
-
229
- await kandan.join(topic, joinPayload(), { rejoinPayload: joinPayload });
230
-
231
- const started =
232
- options.codexUrl === undefined
233
- ? await startCodexAppServer(options.codexBin, options.cwd, {
234
- model: options.channelSession?.model,
235
- reasoningEffort: options.channelSession?.reasoningEffort,
236
- fast: options.fast,
237
- })
238
- : undefined;
239
-
240
- if (started !== undefined) {
241
- cleanup.actions.push(() => {
242
- started.process.kill("SIGINT");
243
- });
244
- }
245
-
246
- const codexUrl = options.codexUrl ?? started?.url;
247
-
248
- if (codexUrl === undefined) {
249
- throw new Error("missing codex app-server websocket URL");
250
- }
251
-
252
- const instanceId = `codex-${randomUUID()}`;
253
- const publishLocalEditorStatus = (payload: JsonObject): void => {
254
- void kandan.push(topic, "local_editor_status", payload).catch((error) => {
255
- log("kandan.local_editor_status_push_failed", {
256
- message: error instanceof Error ? error.message : String(error),
257
- });
258
- });
259
- };
260
- const watchLocalEditorExit = (
261
- state: Extract<LocalEditorState, { status: "running" }>,
262
- generation: number,
263
- initialStatusPushed: Promise<unknown>,
264
- ): void => {
265
- const handleExit = () => {
266
- if (
267
- localEditorGeneration.value !== generation ||
268
- localEditorState.value.status !== "running" ||
269
- localEditorState.value.process !== state.process
270
- ) {
271
- return;
272
- }
273
-
274
- localEditorState.value = { status: "disabled" };
275
- liveForwardPorts.delete(state.port);
276
- managedForwardPorts.delete(state.port);
277
- if (state.collaboration !== undefined) {
278
- liveForwardPorts.delete(state.collaboration.serverPort);
279
- managedForwardPorts.delete(state.collaboration.serverPort);
280
- }
281
- publishLocalEditorStatus({
282
- instanceId,
283
- ok: true,
284
- cwd: state.cwd,
285
- capabilities: {
286
- ...capabilitiesPayload(),
287
- revokedPorts:
288
- state.collaboration === undefined
289
- ? [state.port]
290
- : [state.port, state.collaboration.serverPort],
291
- },
292
- });
293
- };
294
-
295
- const handleExitAfterInitialStatus = () => {
296
- void initialStatusPushed.then(handleExit).catch((error) => {
297
- log("kandan.local_editor_initial_status_failed", {
298
- message: error instanceof Error ? error.message : String(error),
299
- });
300
- });
301
- };
302
-
303
- void state.exited.then(handleExitAfterInitialStatus);
304
- };
305
- const codex = await connectCodexAppServer(codexUrl);
306
- cleanup.actions.push(() => codex.close());
307
-
308
- const seq = { value: 0 };
309
- const codexThreads =
310
- options.channelSession === undefined
311
- ? await discoverCodexThreads(codex, options.cwd)
312
- : [];
313
-
314
- const runnerHost = hostname();
315
- const instancePayload = {
316
- instanceId,
317
- codexUrl,
318
- tuiLaunched: options.launchTui,
319
- cwd: options.cwd,
320
- hostname: runnerHost,
321
- codexThreads,
322
- model: options.channelSession?.model ?? null,
323
- reasoningEffort: options.channelSession?.reasoningEffort ?? null,
324
- fast: options.fast ?? false,
325
- };
326
-
327
- await kandan.push(topic, "instance_started", instancePayload);
328
- log("runner.instance_started", { instanceId, codexUrl });
329
-
330
- const channelSession =
331
- options.channelSession === undefined
332
- ? undefined
333
- : await attachChannelSession({
334
- kandan,
335
- codex,
336
- topic,
337
- instanceId,
338
- options: {
339
- token: options.token,
340
- runnerId: options.runnerId,
341
- cwd: options.cwd,
342
- codexBin: options.codexBin,
343
- fast: options.fast,
344
- launchTui: options.launchTui,
345
- enablePortForwardWatch: true,
346
- initialForwardPorts: allowedForwardPorts,
347
- suppressedForwardPorts: () => Array.from(managedForwardPorts),
348
- onForwardPortApproved: (port) => {
349
- liveForwardPorts.add(port);
350
- return capabilitiesPayload();
351
- },
352
- onForwardPortRevoked: (port) => {
353
- liveForwardPorts.delete(port);
354
- return capabilitiesPayload();
355
- },
356
- channelSession: options.channelSession,
357
- },
358
- log,
359
- });
360
-
361
- if (channelSession !== undefined) {
362
- cleanup.actions.push(() => channelSession.close());
363
- kandan.onReconnect(() => channelSession.handleKandanReconnect());
364
- }
365
-
366
- const heartbeatPayload = (): JsonObject => ({
367
- instanceId,
368
- codexUrl,
369
- cwd: options.cwd,
370
- hostname: runnerHost,
371
- workspace: options.channelSession?.workspaceSlug ?? null,
372
- channel: options.channelSession?.channelSlug ?? null,
373
- threadId: channelSession?.currentKandanThreadId() ?? null,
374
- codexThreadId: channelSession?.currentCodexThreadId() ?? null,
375
- model: options.channelSession?.model ?? null,
376
- reasoningEffort: options.channelSession?.reasoningEffort ?? null,
377
- fast: options.fast ?? false,
378
- capabilities: capabilitiesPayload(),
379
- });
380
- const pushHeartbeat = () =>
381
- kandan.push(topic, "heartbeat", heartbeatPayload()).catch((error) => {
382
- log("kandan.heartbeat_push_failed", {
383
- message: error instanceof Error ? error.message : String(error),
384
- });
385
- });
386
- const heartbeatInterval = setInterval(() => {
387
- void pushHeartbeat();
388
- }, 15_000);
389
- cleanup.actions.push(() => clearInterval(heartbeatInterval));
390
- kandan.onReconnect(() => pushHeartbeat().then(() => undefined));
391
- void pushHeartbeat();
392
-
393
- const forwardWebSockets = createForwardWebSocketManager(kandan, topic, () =>
394
- Array.from(liveForwardPorts),
395
- );
396
- cleanup.actions.push(() => forwardWebSockets.close());
397
-
398
- const channelCodexThreadId = channelSession?.currentCodexThreadId();
399
- if (options.launchTui && channelCodexThreadId !== undefined) {
400
- await prepareCodexThreadForTuiResume(codex, channelCodexThreadId);
401
- }
402
-
403
- const tui = options.launchTui
404
- ? launchCodexTui(
405
- options.codexBin,
406
- codexUrl,
407
- options.cwd,
408
- channelCodexThreadId,
409
- options.channelSession,
410
- options.fast,
411
- )
412
- : undefined;
413
-
414
- if (tui !== undefined) {
415
- cleanup.actions.push(() => {
416
- tui.kill("SIGINT");
417
- });
418
- }
419
-
420
- codex.onNotification((notification) => {
421
- seq.value += 1;
422
- const params = (notification.params ?? {}) as JsonObject;
423
- const metadata = extractCodexIds(params);
424
-
425
- if (channelSession === undefined) {
426
- void kandan
427
- .push(topic, "codex_notification", {
428
- instanceId,
429
- seq: seq.value,
430
- method: notification.method,
431
- params,
432
- metadata,
433
- receivedAt: new Date().toISOString(),
434
- })
435
- .catch((error) => {
436
- log("kandan.codex_notification_push_failed", {
437
- message: error instanceof Error ? error.message : String(error),
438
- });
439
- });
440
- }
441
-
442
- log("codex.notification", {
443
- method: notification.method,
444
- metadata,
445
- });
446
- channelSession?.handleCodexNotification(notification.method, params);
447
- });
448
-
449
- const handleControl = (control: KandanControl) => {
450
- log("kandan.control", { control });
451
- if (!controlTargetsInstance(control, instanceId)) {
452
- log("kandan.control_ignored", {
453
- reason: "instance_id_mismatch",
454
- instanceId,
455
- controlInstanceId: control.instanceId,
456
- controlType: control.type,
457
- });
458
- return;
459
- }
460
-
461
- if (isForwardHttpRequestControl(control)) {
462
- void handleForwardHttpRequest(control, Array.from(liveForwardPorts))
463
- .then((response) =>
464
- kandan.push(topic, "forward:http_response", response),
465
- )
466
- .catch((error) =>
467
- kandan.push(topic, "forward:http_response", {
468
- requestId: control.requestId,
469
- ok: false,
470
- error: error instanceof Error ? error.message : String(error),
471
- }),
472
- )
473
- .catch((error) => {
474
- log("kandan.forward_response_push_failed", {
475
- message: error instanceof Error ? error.message : String(error),
476
- });
477
- });
478
- return;
479
- }
480
-
481
- if (isForwardWebSocketControl(control)) {
482
- forwardWebSockets.handle(control);
483
- return;
484
- }
485
-
486
- if (isStartLocalEditorControl(control)) {
487
- void startLocalEditor(control, {
488
- codeServerBin: options.codeServerBin,
489
- editorRuntime: options.editorRuntime,
490
- allowedCwds: allowedCwds.value,
491
- currentState: localEditorState.value,
492
- browserBaseUrl:
493
- control.browserBaseUrl ?? process.env.KANDAN_LOCAL_RUNNER_PUBLIC_BASE_URL,
494
- runnerId: options.runnerId,
495
- })
496
- .then((result) => {
497
- if (result.ok) {
498
- localEditorGeneration.value += 1;
499
- const editorGeneration = localEditorGeneration.value;
500
- if (localEditorState.value.status === "running") {
501
- liveForwardPorts.delete(localEditorState.value.port);
502
- managedForwardPorts.delete(localEditorState.value.port);
503
- if (localEditorState.value.collaboration !== undefined) {
504
- liveForwardPorts.delete(
505
- localEditorState.value.collaboration.serverPort,
506
- );
507
- managedForwardPorts.delete(
508
- localEditorState.value.collaboration.serverPort,
509
- );
510
- }
511
- }
512
- localEditorState.value = result.state;
513
- liveForwardPorts.add(result.event.port);
514
- managedForwardPorts.add(result.event.port);
515
- if (result.event.collaboration !== undefined) {
516
- liveForwardPorts.add(result.event.collaboration.serverPort);
517
- managedForwardPorts.add(result.event.collaboration.serverPort);
518
- }
519
- const initialStatusPushed = kandan.push(
520
- topic,
521
- "local_editor_status",
522
- {
523
- instanceId,
524
- requestId: control.requestId,
525
- ok: true,
526
- capabilities: capabilitiesPayload(),
527
- ...result.event,
528
- },
529
- );
530
-
531
- if (result.state.status === "running") {
532
- watchLocalEditorExit(
533
- result.state,
534
- editorGeneration,
535
- initialStatusPushed,
536
- );
537
- }
538
-
539
- return initialStatusPushed;
540
- }
541
-
542
- localEditorState.value = result.state;
543
- return kandan.push(topic, "local_editor_status", {
544
- instanceId,
545
- requestId: control.requestId,
546
- ok: false,
547
- reason: result.reason,
548
- capabilities: capabilitiesPayload(),
549
- });
550
- })
551
- .catch((error) =>
552
- kandan.push(topic, "local_editor_status", {
553
- instanceId,
554
- requestId: control.requestId,
555
- ok: false,
556
- reason: error instanceof Error ? error.message : String(error),
557
- capabilities: capabilitiesPayload(),
558
- }),
559
- )
560
- .catch((error) => {
561
- log("kandan.local_editor_status_push_failed", {
562
- message: error instanceof Error ? error.message : String(error),
563
- });
564
- });
565
- return;
566
- }
567
-
568
- if (isUpdateRunnerConfigControl(control)) {
569
- allowedCwds.value = normalizeAllowedCwds(control.allowedCwds);
570
- void pushHeartbeat();
571
- return;
572
- }
573
-
574
- void (channelSession?.handleControl(control) ?? Promise.resolve(undefined))
575
- .then((handled) => {
576
- if (handled !== undefined) {
577
- return handled;
578
- }
579
-
580
- return applyControl(
581
- codex,
582
- instanceId,
583
- options,
584
- allowedCwds.value,
585
- control,
586
- );
587
- })
588
- .then((response) => {
589
- return kandan.push(topic, "codex_response", response);
590
- })
591
- .catch((error) => {
592
- return kandan.push(topic, "codex_error", {
593
- instanceId,
594
- message: error instanceof Error ? error.message : String(error),
595
- });
596
- })
597
- .catch((error) => {
598
- log("kandan.control_response_push_failed", {
599
- message: error instanceof Error ? error.message : String(error),
600
- });
601
- });
602
- };
603
-
604
- controlDispatcher.value = handleControl;
605
- pendingControls.splice(0).forEach(handleControl);
606
-
607
- return { instanceId, codexUrl, close };
608
- }
609
-
610
- function controlTargetsInstance(
611
- control: KandanControl,
612
- instanceId: string,
613
- ): boolean {
614
- return control.instanceId === undefined || control.instanceId === instanceId;
615
- }
616
-
617
- async function closeCleanupStack(cleanup: CleanupStack): Promise<void> {
618
- if (cleanup.closePromise !== undefined) {
619
- return cleanup.closePromise;
620
- }
621
-
622
- cleanup.closePromise = (async () => {
623
- const errors: Error[] = [];
624
- cleanup.removeHandlers?.();
625
- cleanup.removeHandlers = undefined;
626
-
627
- for (const action of [...cleanup.actions].reverse()) {
628
- try {
629
- await action();
630
- } catch (error) {
631
- errors.push(error instanceof Error ? error : new Error(String(error)));
632
- }
633
- }
634
-
635
- cleanup.actions.splice(0);
636
-
637
- if (errors[0] !== undefined) {
638
- throw errors[0];
639
- }
640
- })();
641
-
642
- return cleanup.closePromise;
643
- }
644
-
645
- async function discoverCodexThreads(
646
- codex: Awaited<ReturnType<typeof connectCodexAppServer>>,
647
- cwd: string,
648
- ): Promise<JsonValue[]> {
649
- const response = await codex.request("thread/list", { cwd });
650
-
651
- if ("error" in response) {
652
- return [];
653
- }
654
-
655
- const result = objectValue(response.result);
656
- const data = arrayValue(result?.data);
657
-
658
- return data === undefined
659
- ? []
660
- : data
661
- .filter(isJsonObject)
662
- .map((thread) => ({
663
- id: stringValue(thread.id) ?? "",
664
- preview: stringValue(thread.preview) ?? "",
665
- cwd: stringValue(thread.cwd) ?? "",
666
- source: stringValue(thread.source) ?? "",
667
- updatedAt: integerValue(thread.updatedAt) ?? null,
668
- status: objectValue(thread.status) ?? null,
669
- }))
670
- .filter((thread) => thread.id !== "");
671
- }
672
-
673
- function extractStartedThreadId(response: JsonRpcResponse): string | undefined {
674
- if ("error" in response) {
675
- return undefined;
676
- }
677
-
678
- return stringValue(objectValue(objectValue(response.result)?.thread)?.id);
679
- }
680
-
681
- function normalizedWorkDescription(value: string | undefined): string | undefined {
682
- const normalized = value?.trim();
683
-
684
- return normalized === undefined || normalized === ""
685
- ? undefined
686
- : normalized;
687
- }
688
-
689
- function makeRunnerLogger(options: RunnerOptions): RunnerLogger {
690
- return createRunnerLogger(
691
- options.logFile ?? join(options.cwd, ".linzumi-runner.log"),
692
- options.launchTui ? undefined : reportRunnerConsoleEvent,
693
- );
694
- }
695
-
696
- function installCleanupHandlers(close: () => Promise<void>): () => void {
697
- const closeAndExit = () => {
698
- void close()
699
- .catch(() => undefined)
700
- .finally(() => process.exit(0));
701
- };
702
- const closeOnExit = () => {
703
- void close().catch(() => undefined);
704
- };
705
-
706
- process.once("SIGINT", closeAndExit);
707
- process.once("SIGTERM", closeAndExit);
708
- process.once("SIGHUP", closeAndExit);
709
- process.once("exit", closeOnExit);
710
-
711
- return () => {
712
- process.off("SIGINT", closeAndExit);
713
- process.off("SIGTERM", closeAndExit);
714
- process.off("SIGHUP", closeAndExit);
715
- process.off("exit", closeOnExit);
716
- };
717
- }
718
-
719
- function launchCodexTui(
720
- codexBin: string,
721
- codexUrl: string,
722
- cwd: string,
723
- codexThreadId: string | undefined,
724
- session: KandanChannelSessionOptions | undefined,
725
- fast: boolean | undefined,
726
- ): ChildProcess {
727
- return spawn(codexBin, codexTuiArgs(codexUrl, codexThreadId, session, fast), {
728
- cwd,
729
- env: process.env,
730
- stdio: "inherit",
731
- });
732
- }
733
-
734
- export function codexTuiArgs(
735
- codexUrl: string,
736
- codexThreadId: string | undefined,
737
- session?: KandanChannelSessionOptions | undefined,
738
- fast?: boolean | undefined,
739
- ): string[] {
740
- const overrides = codexTuiConfigArgs(session, fast);
741
-
742
- return codexThreadId === undefined
743
- ? ["--remote", codexUrl, ...overrides]
744
- : ["resume", "--remote", codexUrl, ...overrides, codexThreadId];
745
- }
746
-
747
- function codexTuiConfigArgs(
748
- session: KandanChannelSessionOptions | undefined,
749
- fast: boolean | undefined,
750
- ): string[] {
751
- const modelArgs =
752
- session?.model === undefined ? [] : ["--model", session.model];
753
- const reasoningArgs =
754
- session?.reasoningEffort === undefined
755
- ? []
756
- : ["-c", `model_reasoning_effort="${session.reasoningEffort}"`];
757
- const tierArgs = fast === true ? ["-c", 'service_tier="fast"'] : [];
758
-
759
- return [...modelArgs, ...reasoningArgs, ...tierArgs];
760
- }
761
-
762
- export async function prepareCodexThreadForTuiResume(
763
- codex: Pick<Awaited<ReturnType<typeof connectCodexAppServer>>, "request">,
764
- codexThreadId: string,
765
- ): Promise<void> {
766
- const resume = await codex.request("thread/resume", {
767
- threadId: codexThreadId,
768
- });
769
-
770
- if (!("error" in resume)) {
771
- return;
772
- }
773
-
774
- if (!resume.error.message.includes("no rollout found")) {
775
- throw new Error(
776
- `failed to prepare Codex TUI resume: ${resume.error.message}`,
777
- );
778
- }
779
-
780
- const injected = await codex.request("thread/inject_items", {
781
- threadId: codexThreadId,
782
- items: [{ type: "agentMessage", text: "" }],
783
- });
784
-
785
- if ("error" in injected) {
786
- throw new Error(
787
- `failed to prepare Codex TUI resume: ${injected.error.message}`,
788
- );
789
- }
790
-
791
- const verified = await codex.request("thread/resume", {
792
- threadId: codexThreadId,
793
- });
794
-
795
- if ("error" in verified) {
796
- throw new Error(
797
- `failed to verify Codex TUI resume: ${verified.error.message}`,
798
- );
799
- }
800
- }
801
-
802
- async function applyControl(
803
- codex: Awaited<ReturnType<typeof connectCodexAppServer>>,
804
- instanceId: string,
805
- options: RunnerOptions,
806
- allowedCwds: readonly string[],
807
- control: KandanControl,
808
- ): Promise<JsonObject> {
809
- switch (control.type) {
810
- case "start_instance": {
811
- const cwd = resolveAllowedCwd(control.cwd, allowedCwds);
812
-
813
- if (!cwd.ok) {
814
- return {
815
- instanceId,
816
- controlType: control.type,
817
- ok: false,
818
- error: cwd.reason,
819
- };
820
- }
821
-
822
- const response = await codex.request("thread/start", {
823
- cwd: cwd.cwd,
824
- serviceName: "kandan-local-runner",
825
- personality: "pragmatic",
826
- ...(control.model === undefined ? {} : { model: control.model }),
827
- ...(control.reasoningEffort === undefined
828
- ? {}
829
- : { reasoningEffort: control.reasoningEffort }),
830
- ...(control.approvalPolicy === undefined
831
- ? {}
832
- : { approvalPolicy: control.approvalPolicy }),
833
- ...(control.sandbox === undefined ? {} : { sandbox: control.sandbox }),
834
- ...(control.fast === true ? { serviceTier: "fast" } : {}),
835
- });
836
- const codexThreadId = extractStartedThreadId(response);
837
- const workDescription = normalizedWorkDescription(control.workDescription);
838
-
839
- if (codexThreadId !== undefined && workDescription !== undefined) {
840
- await codex.request("turn/start", {
841
- threadId: codexThreadId,
842
- input: [{ type: "text", text: workDescription }],
843
- });
844
- }
845
-
846
- return {
847
- instanceId,
848
- controlType: control.type,
849
- cwd: cwd.cwd,
850
- matchedRoot: cwd.matchedRoot,
851
- response: response as JsonObject,
852
- };
853
- }
854
-
855
- case "start_turn": {
856
- const response = await codex.request("turn/start", {
857
- threadId: control.threadId,
858
- input: control.input,
859
- });
860
- return {
861
- instanceId,
862
- controlType: control.type,
863
- response: response as JsonObject,
864
- };
865
- }
866
-
867
- case "steer_turn": {
868
- const response = await codex.request("turn/steer", {
869
- threadId: control.threadId,
870
- turnId: control.turnId,
871
- input: control.input,
872
- });
873
- return {
874
- instanceId,
875
- controlType: control.type,
876
- response: response as JsonObject,
877
- };
878
- }
879
-
880
- case "interrupt_turn": {
881
- const response = await codex.request("turn/interrupt", {
882
- threadId: control.threadId,
883
- turnId: control.turnId ?? null,
884
- });
885
- return {
886
- instanceId,
887
- controlType: control.type,
888
- response: response as JsonObject,
889
- };
890
- }
891
-
892
- case "read_thread": {
893
- const response = await codex.request("thread/read", {
894
- threadId: control.threadId,
895
- includeTurns: control.includeTurns ?? true,
896
- });
897
- return {
898
- instanceId,
899
- controlType: control.type,
900
- response: response as JsonObject,
901
- };
902
- }
903
-
904
- case "stop_instance":
905
- case "kill_instance":
906
- case "interrupt_queued_messages":
907
- case "resolve_codex_approval_request":
908
- case "forward_http_request":
909
- case "forward_websocket_open":
910
- case "forward_websocket_send":
911
- case "forward_websocket_close":
912
- case "start_local_editor":
913
- case "update_runner_config":
914
- return { instanceId, controlType: control.type, skipped: true };
915
- }
916
- }
917
-
918
- function isUpdateRunnerConfigControl(
919
- control: KandanControl,
920
- ): control is Extract<
921
- KandanControl,
922
- { readonly type: "update_runner_config" }
923
- > {
924
- return control.type === "update_runner_config";
925
- }
926
-
927
- function normalizeAllowedCwds(values: readonly string[]): string[] {
928
- return Array.from(
929
- new Set(
930
- values.flatMap((value) => {
931
- const normalized = value.trim();
932
- return normalized === "" ? [] : [normalized];
933
- }),
934
- ),
935
- );
936
- }
937
-
938
- function allowedCwdSuggestions(
939
- cwd: string,
940
- allowedCwds: readonly string[],
941
- ): string[] {
942
- return normalizeAllowedCwds([cwd, ...allowedCwds]);
943
- }