@linzumi/cli 0.0.5-beta → 0.0.6-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 +197 -85
- package/package.json +17 -11
- package/src/authResolution.ts +2 -0
- package/src/boundedCache.ts +57 -0
- package/src/channelSession.ts +907 -453
- package/src/codexRuntimeOptions.ts +80 -0
- package/src/dependencyStatus.ts +198 -0
- package/src/forwardTunnel.ts +834 -0
- package/src/forwardTunnelProtocol.ts +324 -0
- package/src/index.ts +414 -30
- package/src/kandanTls.ts +86 -0
- package/src/localCapabilities.ts +130 -0
- package/src/localCodexMessageState.ts +135 -0
- package/src/localCodexTurnState.ts +108 -0
- package/src/localEditor.ts +963 -0
- package/src/localEditorRuntime.ts +603 -0
- package/src/localForwarding.ts +500 -0
- package/src/oauth.ts +135 -4
- package/src/pendingKandanMessageQueue.ts +109 -0
- package/src/phoenix.ts +8 -0
- package/src/portForwardApproval.ts +181 -0
- package/src/portForwardWatcher.ts +404 -0
- package/src/protocol.ts +97 -3
- package/src/runner.ts +391 -30
- package/src/streamDeltaCoalescing.ts +129 -0
- package/src/streamDeltaQueue.ts +102 -0
package/src/runner.ts
CHANGED
|
@@ -35,6 +35,23 @@
|
|
|
35
35
|
Spec: plans/2026_04_26_linzumi_cli_review_followups_note.md
|
|
36
36
|
Relationship: Rejects stale Kandan controls for previous local runner
|
|
37
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.
|
|
38
55
|
*/
|
|
39
56
|
import { spawn, type ChildProcess } from "node:child_process";
|
|
40
57
|
import { randomUUID } from "node:crypto";
|
|
@@ -43,6 +60,25 @@ import { join } from "node:path";
|
|
|
43
60
|
import { attachChannelSession } from "./channelSession";
|
|
44
61
|
import { connectCodexAppServer, startCodexAppServer } from "./codexAppServer";
|
|
45
62
|
import { arrayValue, integerValue, objectValue, stringValue } from "./json";
|
|
63
|
+
import { connectForwardTunnel } from "./forwardTunnel";
|
|
64
|
+
import { resolveAllowedCwd } from "./localCapabilities";
|
|
65
|
+
import {
|
|
66
|
+
createForwardWebSocketManager,
|
|
67
|
+
handleForwardHttpRequest,
|
|
68
|
+
isForwardHttpRequestControl,
|
|
69
|
+
isForwardWebSocketControl,
|
|
70
|
+
} from "./localForwarding";
|
|
71
|
+
import {
|
|
72
|
+
isStartLocalEditorControl,
|
|
73
|
+
localEditorCapabilities,
|
|
74
|
+
startLocalEditor,
|
|
75
|
+
type LocalEditorState,
|
|
76
|
+
} from "./localEditor";
|
|
77
|
+
import type { InstalledEditorRuntime } from "./localEditorRuntime";
|
|
78
|
+
import {
|
|
79
|
+
dependencyStatusPayload,
|
|
80
|
+
type RunnerDependencyStatus,
|
|
81
|
+
} from "./dependencyStatus";
|
|
46
82
|
import { connectPhoenixClient } from "./phoenix";
|
|
47
83
|
import {
|
|
48
84
|
type JsonObject,
|
|
@@ -65,6 +101,12 @@ export type RunnerOptions = {
|
|
|
65
101
|
readonly launchTui: boolean;
|
|
66
102
|
readonly fast?: boolean | undefined;
|
|
67
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;
|
|
68
110
|
readonly channelSession: KandanChannelSessionOptions | undefined;
|
|
69
111
|
};
|
|
70
112
|
|
|
@@ -114,17 +156,71 @@ async function openLocalCodexRunner(
|
|
|
114
156
|
cleanup: CleanupStack,
|
|
115
157
|
close: () => Promise<void>,
|
|
116
158
|
): Promise<LocalCodexRunnerHandle> {
|
|
117
|
-
const
|
|
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
|
+
streamingForwarding: true,
|
|
187
|
+
streamingForwardingVersion: 1,
|
|
188
|
+
toolStatus:
|
|
189
|
+
options.dependencyStatus === undefined
|
|
190
|
+
? null
|
|
191
|
+
: dependencyStatusPayload(options.dependencyStatus),
|
|
192
|
+
editorRuntime:
|
|
193
|
+
options.dependencyStatus?.editorRuntime === undefined
|
|
194
|
+
? null
|
|
195
|
+
: dependencyStatusPayload(options.dependencyStatus).editorRuntime,
|
|
196
|
+
...localEditorCapabilities(
|
|
197
|
+
options.editorRuntime,
|
|
198
|
+
allowedCwds.value,
|
|
199
|
+
localEditorState.value,
|
|
200
|
+
),
|
|
201
|
+
});
|
|
202
|
+
const kandan = await connectPhoenixClient(
|
|
203
|
+
options.kandanUrl,
|
|
204
|
+
options.token,
|
|
205
|
+
options.socketFactory,
|
|
206
|
+
);
|
|
118
207
|
cleanup.actions.push(() => kandan.close());
|
|
119
208
|
const topic = `local_runner:${options.runnerId}`;
|
|
120
|
-
|
|
209
|
+
const joinPayload = (): JsonObject => ({
|
|
121
210
|
clientName: "kandan-local-codex-runner",
|
|
122
211
|
version: "0.0.1",
|
|
123
|
-
capabilities:
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
212
|
+
capabilities: capabilitiesPayload(),
|
|
213
|
+
});
|
|
214
|
+
await kandan.join(topic, joinPayload(), { rejoinPayload: joinPayload });
|
|
215
|
+
const forwardTunnel = await connectForwardTunnel({
|
|
216
|
+
kandanUrl: options.kandanUrl,
|
|
217
|
+
token: options.token,
|
|
218
|
+
runnerId: options.runnerId,
|
|
219
|
+
allowedPorts: () => Array.from(liveForwardPorts),
|
|
220
|
+
log,
|
|
221
|
+
socketFactory: options.socketFactory,
|
|
127
222
|
});
|
|
223
|
+
cleanup.actions.push(() => forwardTunnel.close());
|
|
128
224
|
|
|
129
225
|
const started =
|
|
130
226
|
options.codexUrl === undefined
|
|
@@ -148,6 +244,58 @@ async function openLocalCodexRunner(
|
|
|
148
244
|
}
|
|
149
245
|
|
|
150
246
|
const instanceId = `codex-${randomUUID()}`;
|
|
247
|
+
const publishLocalEditorStatus = (payload: JsonObject): void => {
|
|
248
|
+
void kandan.push(topic, "local_editor_status", payload).catch((error) => {
|
|
249
|
+
log("kandan.local_editor_status_push_failed", {
|
|
250
|
+
message: error instanceof Error ? error.message : String(error),
|
|
251
|
+
});
|
|
252
|
+
});
|
|
253
|
+
};
|
|
254
|
+
const watchLocalEditorExit = (
|
|
255
|
+
state: Extract<LocalEditorState, { status: "running" }>,
|
|
256
|
+
generation: number,
|
|
257
|
+
initialStatusPushed: Promise<unknown>,
|
|
258
|
+
): void => {
|
|
259
|
+
const handleExit = () => {
|
|
260
|
+
if (
|
|
261
|
+
localEditorGeneration.value !== generation ||
|
|
262
|
+
localEditorState.value.status !== "running" ||
|
|
263
|
+
localEditorState.value.process !== state.process
|
|
264
|
+
) {
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
localEditorState.value = { status: "disabled" };
|
|
269
|
+
liveForwardPorts.delete(state.port);
|
|
270
|
+
managedForwardPorts.delete(state.port);
|
|
271
|
+
if (state.collaboration !== undefined) {
|
|
272
|
+
liveForwardPorts.delete(state.collaboration.serverPort);
|
|
273
|
+
managedForwardPorts.delete(state.collaboration.serverPort);
|
|
274
|
+
}
|
|
275
|
+
publishLocalEditorStatus({
|
|
276
|
+
instanceId,
|
|
277
|
+
ok: true,
|
|
278
|
+
cwd: state.cwd,
|
|
279
|
+
capabilities: {
|
|
280
|
+
...capabilitiesPayload(),
|
|
281
|
+
revokedPorts:
|
|
282
|
+
state.collaboration === undefined
|
|
283
|
+
? [state.port]
|
|
284
|
+
: [state.port, state.collaboration.serverPort],
|
|
285
|
+
},
|
|
286
|
+
});
|
|
287
|
+
};
|
|
288
|
+
|
|
289
|
+
const handleExitAfterInitialStatus = () => {
|
|
290
|
+
void initialStatusPushed.then(handleExit).catch((error) => {
|
|
291
|
+
log("kandan.local_editor_initial_status_failed", {
|
|
292
|
+
message: error instanceof Error ? error.message : String(error),
|
|
293
|
+
});
|
|
294
|
+
});
|
|
295
|
+
};
|
|
296
|
+
|
|
297
|
+
void state.exited.then(handleExitAfterInitialStatus);
|
|
298
|
+
};
|
|
151
299
|
const codex = await connectCodexAppServer(codexUrl);
|
|
152
300
|
cleanup.actions.push(() => codex.close());
|
|
153
301
|
|
|
@@ -188,6 +336,17 @@ async function openLocalCodexRunner(
|
|
|
188
336
|
codexBin: options.codexBin,
|
|
189
337
|
fast: options.fast,
|
|
190
338
|
launchTui: options.launchTui,
|
|
339
|
+
enablePortForwardWatch: true,
|
|
340
|
+
initialForwardPorts: allowedForwardPorts,
|
|
341
|
+
suppressedForwardPorts: () => Array.from(managedForwardPorts),
|
|
342
|
+
onForwardPortApproved: (port) => {
|
|
343
|
+
liveForwardPorts.add(port);
|
|
344
|
+
return capabilitiesPayload();
|
|
345
|
+
},
|
|
346
|
+
onForwardPortRevoked: (port) => {
|
|
347
|
+
liveForwardPorts.delete(port);
|
|
348
|
+
return capabilitiesPayload();
|
|
349
|
+
},
|
|
191
350
|
channelSession: options.channelSession,
|
|
192
351
|
},
|
|
193
352
|
log,
|
|
@@ -210,9 +369,10 @@ async function openLocalCodexRunner(
|
|
|
210
369
|
model: options.channelSession?.model ?? null,
|
|
211
370
|
reasoningEffort: options.channelSession?.reasoningEffort ?? null,
|
|
212
371
|
fast: options.fast ?? false,
|
|
372
|
+
capabilities: capabilitiesPayload(),
|
|
213
373
|
});
|
|
214
374
|
const pushHeartbeat = () =>
|
|
215
|
-
kandan.push(topic, "heartbeat", heartbeatPayload()).catch(error => {
|
|
375
|
+
kandan.push(topic, "heartbeat", heartbeatPayload()).catch((error) => {
|
|
216
376
|
log("kandan.heartbeat_push_failed", {
|
|
217
377
|
message: error instanceof Error ? error.message : String(error),
|
|
218
378
|
});
|
|
@@ -224,6 +384,11 @@ async function openLocalCodexRunner(
|
|
|
224
384
|
kandan.onReconnect(() => pushHeartbeat().then(() => undefined));
|
|
225
385
|
void pushHeartbeat();
|
|
226
386
|
|
|
387
|
+
const forwardWebSockets = createForwardWebSocketManager(kandan, topic, () =>
|
|
388
|
+
Array.from(liveForwardPorts),
|
|
389
|
+
);
|
|
390
|
+
cleanup.actions.push(() => forwardWebSockets.close());
|
|
391
|
+
|
|
227
392
|
const channelCodexThreadId = channelSession?.currentCodexThreadId();
|
|
228
393
|
if (options.launchTui && channelCodexThreadId !== undefined) {
|
|
229
394
|
await prepareCodexThreadForTuiResume(codex, channelCodexThreadId);
|
|
@@ -246,24 +411,26 @@ async function openLocalCodexRunner(
|
|
|
246
411
|
});
|
|
247
412
|
}
|
|
248
413
|
|
|
249
|
-
codex.onNotification(notification => {
|
|
414
|
+
codex.onNotification((notification) => {
|
|
250
415
|
seq.value += 1;
|
|
251
416
|
const params = (notification.params ?? {}) as JsonObject;
|
|
252
417
|
const metadata = extractCodexIds(params);
|
|
253
418
|
|
|
254
419
|
if (channelSession === undefined) {
|
|
255
|
-
void kandan
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
420
|
+
void kandan
|
|
421
|
+
.push(topic, "codex_notification", {
|
|
422
|
+
instanceId,
|
|
423
|
+
seq: seq.value,
|
|
424
|
+
method: notification.method,
|
|
425
|
+
params,
|
|
426
|
+
metadata,
|
|
427
|
+
receivedAt: new Date().toISOString(),
|
|
428
|
+
})
|
|
429
|
+
.catch((error) => {
|
|
430
|
+
log("kandan.codex_notification_push_failed", {
|
|
431
|
+
message: error instanceof Error ? error.message : String(error),
|
|
432
|
+
});
|
|
265
433
|
});
|
|
266
|
-
});
|
|
267
434
|
}
|
|
268
435
|
|
|
269
436
|
log("codex.notification", {
|
|
@@ -273,7 +440,7 @@ async function openLocalCodexRunner(
|
|
|
273
440
|
channelSession?.handleCodexNotification(notification.method, params);
|
|
274
441
|
});
|
|
275
442
|
|
|
276
|
-
kandan.onControl(control => {
|
|
443
|
+
kandan.onControl((control) => {
|
|
277
444
|
log("kandan.control", { control });
|
|
278
445
|
if (!controlTargetsInstance(control, instanceId)) {
|
|
279
446
|
log("kandan.control_ignored", {
|
|
@@ -285,24 +452,143 @@ async function openLocalCodexRunner(
|
|
|
285
452
|
return;
|
|
286
453
|
}
|
|
287
454
|
|
|
455
|
+
if (isForwardHttpRequestControl(control)) {
|
|
456
|
+
void handleForwardHttpRequest(control, Array.from(liveForwardPorts))
|
|
457
|
+
.then((response) =>
|
|
458
|
+
kandan.push(topic, "forward:http_response", response),
|
|
459
|
+
)
|
|
460
|
+
.catch((error) =>
|
|
461
|
+
kandan.push(topic, "forward:http_response", {
|
|
462
|
+
requestId: control.requestId,
|
|
463
|
+
ok: false,
|
|
464
|
+
error: error instanceof Error ? error.message : String(error),
|
|
465
|
+
}),
|
|
466
|
+
)
|
|
467
|
+
.catch((error) => {
|
|
468
|
+
log("kandan.forward_response_push_failed", {
|
|
469
|
+
message: error instanceof Error ? error.message : String(error),
|
|
470
|
+
});
|
|
471
|
+
});
|
|
472
|
+
return;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
if (isForwardWebSocketControl(control)) {
|
|
476
|
+
forwardWebSockets.handle(control);
|
|
477
|
+
return;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
if (isStartLocalEditorControl(control)) {
|
|
481
|
+
void startLocalEditor(control, {
|
|
482
|
+
codeServerBin: options.codeServerBin,
|
|
483
|
+
editorRuntime: options.editorRuntime,
|
|
484
|
+
allowedCwds: allowedCwds.value,
|
|
485
|
+
currentState: localEditorState.value,
|
|
486
|
+
browserBaseUrl:
|
|
487
|
+
control.browserBaseUrl ?? process.env.KANDAN_LOCAL_RUNNER_PUBLIC_BASE_URL,
|
|
488
|
+
runnerId: options.runnerId,
|
|
489
|
+
})
|
|
490
|
+
.then((result) => {
|
|
491
|
+
if (result.ok) {
|
|
492
|
+
localEditorGeneration.value += 1;
|
|
493
|
+
const editorGeneration = localEditorGeneration.value;
|
|
494
|
+
if (localEditorState.value.status === "running") {
|
|
495
|
+
liveForwardPorts.delete(localEditorState.value.port);
|
|
496
|
+
managedForwardPorts.delete(localEditorState.value.port);
|
|
497
|
+
if (localEditorState.value.collaboration !== undefined) {
|
|
498
|
+
liveForwardPorts.delete(
|
|
499
|
+
localEditorState.value.collaboration.serverPort,
|
|
500
|
+
);
|
|
501
|
+
managedForwardPorts.delete(
|
|
502
|
+
localEditorState.value.collaboration.serverPort,
|
|
503
|
+
);
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
localEditorState.value = result.state;
|
|
507
|
+
liveForwardPorts.add(result.event.port);
|
|
508
|
+
managedForwardPorts.add(result.event.port);
|
|
509
|
+
if (result.event.collaboration !== undefined) {
|
|
510
|
+
liveForwardPorts.add(result.event.collaboration.serverPort);
|
|
511
|
+
managedForwardPorts.add(result.event.collaboration.serverPort);
|
|
512
|
+
}
|
|
513
|
+
const initialStatusPushed = kandan.push(
|
|
514
|
+
topic,
|
|
515
|
+
"local_editor_status",
|
|
516
|
+
{
|
|
517
|
+
instanceId,
|
|
518
|
+
requestId: control.requestId,
|
|
519
|
+
ok: true,
|
|
520
|
+
capabilities: capabilitiesPayload(),
|
|
521
|
+
...result.event,
|
|
522
|
+
},
|
|
523
|
+
);
|
|
524
|
+
|
|
525
|
+
if (result.state.status === "running") {
|
|
526
|
+
watchLocalEditorExit(
|
|
527
|
+
result.state,
|
|
528
|
+
editorGeneration,
|
|
529
|
+
initialStatusPushed,
|
|
530
|
+
);
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
return initialStatusPushed;
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
localEditorState.value = result.state;
|
|
537
|
+
return kandan.push(topic, "local_editor_status", {
|
|
538
|
+
instanceId,
|
|
539
|
+
requestId: control.requestId,
|
|
540
|
+
ok: false,
|
|
541
|
+
reason: result.reason,
|
|
542
|
+
capabilities: capabilitiesPayload(),
|
|
543
|
+
});
|
|
544
|
+
})
|
|
545
|
+
.catch((error) =>
|
|
546
|
+
kandan.push(topic, "local_editor_status", {
|
|
547
|
+
instanceId,
|
|
548
|
+
requestId: control.requestId,
|
|
549
|
+
ok: false,
|
|
550
|
+
reason: error instanceof Error ? error.message : String(error),
|
|
551
|
+
capabilities: capabilitiesPayload(),
|
|
552
|
+
}),
|
|
553
|
+
)
|
|
554
|
+
.catch((error) => {
|
|
555
|
+
log("kandan.local_editor_status_push_failed", {
|
|
556
|
+
message: error instanceof Error ? error.message : String(error),
|
|
557
|
+
});
|
|
558
|
+
});
|
|
559
|
+
return;
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
if (isUpdateRunnerConfigControl(control)) {
|
|
563
|
+
allowedCwds.value = normalizeAllowedCwds(control.allowedCwds);
|
|
564
|
+
void pushHeartbeat();
|
|
565
|
+
return;
|
|
566
|
+
}
|
|
567
|
+
|
|
288
568
|
void (channelSession?.handleControl(control) ?? Promise.resolve(undefined))
|
|
289
|
-
.then(handled => {
|
|
569
|
+
.then((handled) => {
|
|
290
570
|
if (handled !== undefined) {
|
|
291
571
|
return handled;
|
|
292
572
|
}
|
|
293
573
|
|
|
294
|
-
return applyControl(
|
|
574
|
+
return applyControl(
|
|
575
|
+
codex,
|
|
576
|
+
instanceId,
|
|
577
|
+
options,
|
|
578
|
+
allowedCwds.value,
|
|
579
|
+
control,
|
|
580
|
+
);
|
|
295
581
|
})
|
|
296
|
-
.then(response => {
|
|
582
|
+
.then((response) => {
|
|
297
583
|
return kandan.push(topic, "codex_response", response);
|
|
298
584
|
})
|
|
299
|
-
.catch(error => {
|
|
585
|
+
.catch((error) => {
|
|
300
586
|
return kandan.push(topic, "codex_error", {
|
|
301
587
|
instanceId,
|
|
302
588
|
message: error instanceof Error ? error.message : String(error),
|
|
303
589
|
});
|
|
304
590
|
})
|
|
305
|
-
.catch(error => {
|
|
591
|
+
.catch((error) => {
|
|
306
592
|
log("kandan.control_response_push_failed", {
|
|
307
593
|
message: error instanceof Error ? error.message : String(error),
|
|
308
594
|
});
|
|
@@ -312,7 +598,10 @@ async function openLocalCodexRunner(
|
|
|
312
598
|
return { instanceId, codexUrl, close };
|
|
313
599
|
}
|
|
314
600
|
|
|
315
|
-
function controlTargetsInstance(
|
|
601
|
+
function controlTargetsInstance(
|
|
602
|
+
control: KandanControl,
|
|
603
|
+
instanceId: string,
|
|
604
|
+
): boolean {
|
|
316
605
|
return control.instanceId === undefined || control.instanceId === instanceId;
|
|
317
606
|
}
|
|
318
607
|
|
|
@@ -361,7 +650,7 @@ async function discoverCodexThreads(
|
|
|
361
650
|
? []
|
|
362
651
|
: data
|
|
363
652
|
.filter(isJsonObject)
|
|
364
|
-
.map(thread => ({
|
|
653
|
+
.map((thread) => ({
|
|
365
654
|
id: stringValue(thread.id) ?? "",
|
|
366
655
|
preview: stringValue(thread.preview) ?? "",
|
|
367
656
|
cwd: stringValue(thread.cwd) ?? "",
|
|
@@ -369,7 +658,7 @@ async function discoverCodexThreads(
|
|
|
369
658
|
updatedAt: integerValue(thread.updatedAt) ?? null,
|
|
370
659
|
status: objectValue(thread.status) ?? null,
|
|
371
660
|
}))
|
|
372
|
-
.filter(thread => thread.id !== "");
|
|
661
|
+
.filter((thread) => thread.id !== "");
|
|
373
662
|
}
|
|
374
663
|
|
|
375
664
|
function makeRunnerLogger(options: RunnerOptions): RunnerLogger {
|
|
@@ -381,7 +670,9 @@ function makeRunnerLogger(options: RunnerOptions): RunnerLogger {
|
|
|
381
670
|
|
|
382
671
|
function installCleanupHandlers(close: () => Promise<void>): () => void {
|
|
383
672
|
const closeAndExit = () => {
|
|
384
|
-
void close()
|
|
673
|
+
void close()
|
|
674
|
+
.catch(() => undefined)
|
|
675
|
+
.finally(() => process.exit(0));
|
|
385
676
|
};
|
|
386
677
|
const closeOnExit = () => {
|
|
387
678
|
void close().catch(() => undefined);
|
|
@@ -486,9 +777,47 @@ export async function prepareCodexThreadForTuiResume(
|
|
|
486
777
|
async function applyControl(
|
|
487
778
|
codex: Awaited<ReturnType<typeof connectCodexAppServer>>,
|
|
488
779
|
instanceId: string,
|
|
780
|
+
options: RunnerOptions,
|
|
781
|
+
allowedCwds: readonly string[],
|
|
489
782
|
control: KandanControl,
|
|
490
783
|
): Promise<JsonObject> {
|
|
491
784
|
switch (control.type) {
|
|
785
|
+
case "start_instance": {
|
|
786
|
+
const cwd = resolveAllowedCwd(control.cwd, allowedCwds);
|
|
787
|
+
|
|
788
|
+
if (!cwd.ok) {
|
|
789
|
+
return {
|
|
790
|
+
instanceId,
|
|
791
|
+
controlType: control.type,
|
|
792
|
+
ok: false,
|
|
793
|
+
error: cwd.reason,
|
|
794
|
+
};
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
const response = await codex.request("thread/start", {
|
|
798
|
+
cwd: cwd.cwd,
|
|
799
|
+
serviceName: "kandan-local-runner",
|
|
800
|
+
personality: "pragmatic",
|
|
801
|
+
...(control.model === undefined ? {} : { model: control.model }),
|
|
802
|
+
...(control.reasoningEffort === undefined
|
|
803
|
+
? {}
|
|
804
|
+
: { reasoningEffort: control.reasoningEffort }),
|
|
805
|
+
...(control.approvalPolicy === undefined
|
|
806
|
+
? {}
|
|
807
|
+
: { approvalPolicy: control.approvalPolicy }),
|
|
808
|
+
...(control.sandbox === undefined ? {} : { sandbox: control.sandbox }),
|
|
809
|
+
...(control.fast === true ? { serviceTier: "fast" } : {}),
|
|
810
|
+
});
|
|
811
|
+
|
|
812
|
+
return {
|
|
813
|
+
instanceId,
|
|
814
|
+
controlType: control.type,
|
|
815
|
+
cwd: cwd.cwd,
|
|
816
|
+
matchedRoot: cwd.matchedRoot,
|
|
817
|
+
response: response as JsonObject,
|
|
818
|
+
};
|
|
819
|
+
}
|
|
820
|
+
|
|
492
821
|
case "start_turn": {
|
|
493
822
|
const response = await codex.request("turn/start", {
|
|
494
823
|
threadId: control.threadId,
|
|
@@ -540,9 +869,41 @@ async function applyControl(
|
|
|
540
869
|
|
|
541
870
|
case "stop_instance":
|
|
542
871
|
case "kill_instance":
|
|
543
|
-
case "start_instance":
|
|
544
872
|
case "interrupt_queued_messages":
|
|
545
873
|
case "resolve_codex_approval_request":
|
|
874
|
+
case "forward_http_request":
|
|
875
|
+
case "forward_websocket_open":
|
|
876
|
+
case "forward_websocket_send":
|
|
877
|
+
case "forward_websocket_close":
|
|
878
|
+
case "start_local_editor":
|
|
879
|
+
case "update_runner_config":
|
|
546
880
|
return { instanceId, controlType: control.type, skipped: true };
|
|
547
881
|
}
|
|
548
882
|
}
|
|
883
|
+
|
|
884
|
+
function isUpdateRunnerConfigControl(
|
|
885
|
+
control: KandanControl,
|
|
886
|
+
): control is Extract<
|
|
887
|
+
KandanControl,
|
|
888
|
+
{ readonly type: "update_runner_config" }
|
|
889
|
+
> {
|
|
890
|
+
return control.type === "update_runner_config";
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
function normalizeAllowedCwds(values: readonly string[]): string[] {
|
|
894
|
+
return Array.from(
|
|
895
|
+
new Set(
|
|
896
|
+
values.flatMap((value) => {
|
|
897
|
+
const normalized = value.trim();
|
|
898
|
+
return normalized === "" ? [] : [normalized];
|
|
899
|
+
}),
|
|
900
|
+
),
|
|
901
|
+
);
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
function allowedCwdSuggestions(
|
|
905
|
+
cwd: string,
|
|
906
|
+
allowedCwds: readonly string[],
|
|
907
|
+
): string[] {
|
|
908
|
+
return normalizeAllowedCwds([cwd, ...allowedCwds]);
|
|
909
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/*
|
|
2
|
+
- Date: 2026-04-26
|
|
3
|
+
Spec: plans/2026-04-26-local-codex-driver-worldclass-spec.md
|
|
4
|
+
Relationship: Pure coalescing helpers for runner-batched Codex stream
|
|
5
|
+
updates, keeping one logical Codex item mapped to one Kandan row while
|
|
6
|
+
avoiding per-delta state churn in the session orchestrator.
|
|
7
|
+
*/
|
|
8
|
+
import type {
|
|
9
|
+
CodexAssistantDelta,
|
|
10
|
+
CodexCommandOutputDelta,
|
|
11
|
+
CodexFileChangeDelta,
|
|
12
|
+
CodexReasoningDelta,
|
|
13
|
+
} from "./codexOutput";
|
|
14
|
+
|
|
15
|
+
type StreamDelta = {
|
|
16
|
+
readonly itemKey: string;
|
|
17
|
+
readonly turnId: string | undefined;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
type TextDelta = StreamDelta & {
|
|
21
|
+
readonly delta?: string | undefined;
|
|
22
|
+
readonly patchText?: string | undefined;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export function coalesceAssistantDeltas(
|
|
26
|
+
deltas: readonly CodexAssistantDelta[],
|
|
27
|
+
): CodexAssistantDelta[] {
|
|
28
|
+
return coalesceTextDeltas(deltas, (delta) => ({
|
|
29
|
+
itemKey: delta.itemKey,
|
|
30
|
+
turnId: delta.turnId,
|
|
31
|
+
delta: delta.delta,
|
|
32
|
+
}));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function coalesceReasoningDeltas(
|
|
36
|
+
deltas: readonly CodexReasoningDelta[],
|
|
37
|
+
): CodexReasoningDelta[] {
|
|
38
|
+
return coalesceTextDeltas(deltas, (delta) => ({
|
|
39
|
+
itemKey: delta.itemKey,
|
|
40
|
+
turnId: delta.turnId,
|
|
41
|
+
delta: delta.delta,
|
|
42
|
+
}));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function coalesceFileChangeDeltas(
|
|
46
|
+
deltas: readonly CodexFileChangeDelta[],
|
|
47
|
+
): CodexFileChangeDelta[] {
|
|
48
|
+
return coalesceTextDeltas(deltas, (delta) => ({
|
|
49
|
+
itemKey: delta.itemKey,
|
|
50
|
+
turnId: delta.turnId,
|
|
51
|
+
patchText: delta.patchText,
|
|
52
|
+
}));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function coalesceCommandOutputDeltas(
|
|
56
|
+
deltas: readonly CodexCommandOutputDelta[],
|
|
57
|
+
): CodexCommandOutputDelta[] {
|
|
58
|
+
const coalesced = new Map<string, CodexCommandOutputDelta>();
|
|
59
|
+
|
|
60
|
+
for (const delta of deltas) {
|
|
61
|
+
const key = commandOutputStreamingKey(delta);
|
|
62
|
+
const existing = coalesced.get(key);
|
|
63
|
+
|
|
64
|
+
if (existing === undefined) {
|
|
65
|
+
coalesced.set(key, delta);
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
coalesced.set(key, {
|
|
70
|
+
...delta,
|
|
71
|
+
processId: delta.processId ?? existing.processId,
|
|
72
|
+
delta: `${existing.delta}${delta.delta}`,
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return [...coalesced.values()];
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function firstDeltaTurnId(
|
|
80
|
+
deltas: readonly { readonly turnId: string | undefined }[],
|
|
81
|
+
): string | undefined {
|
|
82
|
+
return deltas.find((delta) => delta.turnId !== undefined)?.turnId;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function coalesceTextDeltas<TDelta extends TextDelta>(
|
|
86
|
+
deltas: readonly TDelta[],
|
|
87
|
+
clone: (delta: TDelta) => TDelta,
|
|
88
|
+
): TDelta[] {
|
|
89
|
+
const coalesced = new Map<string, TDelta>();
|
|
90
|
+
|
|
91
|
+
for (const delta of deltas) {
|
|
92
|
+
const key = streamingItemKey(delta);
|
|
93
|
+
const existing = coalesced.get(key);
|
|
94
|
+
|
|
95
|
+
if (existing === undefined) {
|
|
96
|
+
coalesced.set(key, clone(delta));
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
coalesced.set(key, mergeTextDelta(existing, delta));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return [...coalesced.values()];
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function mergeTextDelta<TDelta extends TextDelta>(
|
|
107
|
+
existing: TDelta,
|
|
108
|
+
incoming: TDelta,
|
|
109
|
+
): TDelta {
|
|
110
|
+
if (existing.delta !== undefined || incoming.delta !== undefined) {
|
|
111
|
+
return {
|
|
112
|
+
...incoming,
|
|
113
|
+
delta: `${existing.delta ?? ""}${incoming.delta ?? ""}`,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return {
|
|
118
|
+
...incoming,
|
|
119
|
+
patchText: `${existing.patchText ?? ""}${incoming.patchText ?? ""}`,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function streamingItemKey(delta: StreamDelta): string {
|
|
124
|
+
return `${delta.turnId ?? "turn"}:${delta.itemKey}`;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function commandOutputStreamingKey(delta: CodexCommandOutputDelta): string {
|
|
128
|
+
return `${streamingItemKey(delta)}:${delta.stream}`;
|
|
129
|
+
}
|