@linzumi/cli 0.0.4-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 +25 -1
- package/src/portForwardApproval.ts +181 -0
- package/src/portForwardWatcher.ts +404 -0
- package/src/protocol.ts +97 -3
- package/src/runner.ts +413 -28
- package/src/streamDeltaCoalescing.ts +129 -0
- package/src/streamDeltaQueue.ts +102 -0
package/src/runner.ts
CHANGED
|
@@ -30,6 +30,28 @@
|
|
|
30
30
|
Relationship: Leaves channel-scoped approval controls to `channelSession.ts`
|
|
31
31
|
so the process runner remains lifecycle-only while Kandan safely resolves
|
|
32
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.
|
|
33
55
|
*/
|
|
34
56
|
import { spawn, type ChildProcess } from "node:child_process";
|
|
35
57
|
import { randomUUID } from "node:crypto";
|
|
@@ -38,6 +60,25 @@ import { join } from "node:path";
|
|
|
38
60
|
import { attachChannelSession } from "./channelSession";
|
|
39
61
|
import { connectCodexAppServer, startCodexAppServer } from "./codexAppServer";
|
|
40
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";
|
|
41
82
|
import { connectPhoenixClient } from "./phoenix";
|
|
42
83
|
import {
|
|
43
84
|
type JsonObject,
|
|
@@ -60,6 +101,12 @@ export type RunnerOptions = {
|
|
|
60
101
|
readonly launchTui: boolean;
|
|
61
102
|
readonly fast?: boolean | undefined;
|
|
62
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;
|
|
63
110
|
readonly channelSession: KandanChannelSessionOptions | undefined;
|
|
64
111
|
};
|
|
65
112
|
|
|
@@ -109,17 +156,71 @@ async function openLocalCodexRunner(
|
|
|
109
156
|
cleanup: CleanupStack,
|
|
110
157
|
close: () => Promise<void>,
|
|
111
158
|
): Promise<LocalCodexRunnerHandle> {
|
|
112
|
-
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
|
+
);
|
|
113
207
|
cleanup.actions.push(() => kandan.close());
|
|
114
208
|
const topic = `local_runner:${options.runnerId}`;
|
|
115
|
-
|
|
209
|
+
const joinPayload = (): JsonObject => ({
|
|
116
210
|
clientName: "kandan-local-codex-runner",
|
|
117
211
|
version: "0.0.1",
|
|
118
|
-
capabilities:
|
|
119
|
-
codexAppServer: true,
|
|
120
|
-
codexRemoteTui: true,
|
|
121
|
-
},
|
|
212
|
+
capabilities: capabilitiesPayload(),
|
|
122
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,
|
|
222
|
+
});
|
|
223
|
+
cleanup.actions.push(() => forwardTunnel.close());
|
|
123
224
|
|
|
124
225
|
const started =
|
|
125
226
|
options.codexUrl === undefined
|
|
@@ -143,6 +244,58 @@ async function openLocalCodexRunner(
|
|
|
143
244
|
}
|
|
144
245
|
|
|
145
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
|
+
};
|
|
146
299
|
const codex = await connectCodexAppServer(codexUrl);
|
|
147
300
|
cleanup.actions.push(() => codex.close());
|
|
148
301
|
|
|
@@ -183,6 +336,17 @@ async function openLocalCodexRunner(
|
|
|
183
336
|
codexBin: options.codexBin,
|
|
184
337
|
fast: options.fast,
|
|
185
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
|
+
},
|
|
186
350
|
channelSession: options.channelSession,
|
|
187
351
|
},
|
|
188
352
|
log,
|
|
@@ -205,9 +369,10 @@ async function openLocalCodexRunner(
|
|
|
205
369
|
model: options.channelSession?.model ?? null,
|
|
206
370
|
reasoningEffort: options.channelSession?.reasoningEffort ?? null,
|
|
207
371
|
fast: options.fast ?? false,
|
|
372
|
+
capabilities: capabilitiesPayload(),
|
|
208
373
|
});
|
|
209
374
|
const pushHeartbeat = () =>
|
|
210
|
-
kandan.push(topic, "heartbeat", heartbeatPayload()).catch(error => {
|
|
375
|
+
kandan.push(topic, "heartbeat", heartbeatPayload()).catch((error) => {
|
|
211
376
|
log("kandan.heartbeat_push_failed", {
|
|
212
377
|
message: error instanceof Error ? error.message : String(error),
|
|
213
378
|
});
|
|
@@ -219,6 +384,11 @@ async function openLocalCodexRunner(
|
|
|
219
384
|
kandan.onReconnect(() => pushHeartbeat().then(() => undefined));
|
|
220
385
|
void pushHeartbeat();
|
|
221
386
|
|
|
387
|
+
const forwardWebSockets = createForwardWebSocketManager(kandan, topic, () =>
|
|
388
|
+
Array.from(liveForwardPorts),
|
|
389
|
+
);
|
|
390
|
+
cleanup.actions.push(() => forwardWebSockets.close());
|
|
391
|
+
|
|
222
392
|
const channelCodexThreadId = channelSession?.currentCodexThreadId();
|
|
223
393
|
if (options.launchTui && channelCodexThreadId !== undefined) {
|
|
224
394
|
await prepareCodexThreadForTuiResume(codex, channelCodexThreadId);
|
|
@@ -241,24 +411,26 @@ async function openLocalCodexRunner(
|
|
|
241
411
|
});
|
|
242
412
|
}
|
|
243
413
|
|
|
244
|
-
codex.onNotification(notification => {
|
|
414
|
+
codex.onNotification((notification) => {
|
|
245
415
|
seq.value += 1;
|
|
246
416
|
const params = (notification.params ?? {}) as JsonObject;
|
|
247
417
|
const metadata = extractCodexIds(params);
|
|
248
418
|
|
|
249
419
|
if (channelSession === undefined) {
|
|
250
|
-
void kandan
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
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
|
+
});
|
|
260
433
|
});
|
|
261
|
-
});
|
|
262
434
|
}
|
|
263
435
|
|
|
264
436
|
log("codex.notification", {
|
|
@@ -268,30 +440,171 @@ async function openLocalCodexRunner(
|
|
|
268
440
|
channelSession?.handleCodexNotification(notification.method, params);
|
|
269
441
|
});
|
|
270
442
|
|
|
271
|
-
kandan.onControl(control => {
|
|
443
|
+
kandan.onControl((control) => {
|
|
272
444
|
log("kandan.control", { control });
|
|
445
|
+
if (!controlTargetsInstance(control, instanceId)) {
|
|
446
|
+
log("kandan.control_ignored", {
|
|
447
|
+
reason: "instance_id_mismatch",
|
|
448
|
+
instanceId,
|
|
449
|
+
controlInstanceId: control.instanceId,
|
|
450
|
+
controlType: control.type,
|
|
451
|
+
});
|
|
452
|
+
return;
|
|
453
|
+
}
|
|
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
|
+
|
|
273
568
|
void (channelSession?.handleControl(control) ?? Promise.resolve(undefined))
|
|
274
|
-
.then(handled => {
|
|
569
|
+
.then((handled) => {
|
|
275
570
|
if (handled !== undefined) {
|
|
276
571
|
return handled;
|
|
277
572
|
}
|
|
278
573
|
|
|
279
|
-
return applyControl(
|
|
574
|
+
return applyControl(
|
|
575
|
+
codex,
|
|
576
|
+
instanceId,
|
|
577
|
+
options,
|
|
578
|
+
allowedCwds.value,
|
|
579
|
+
control,
|
|
580
|
+
);
|
|
280
581
|
})
|
|
281
|
-
.then(response => {
|
|
582
|
+
.then((response) => {
|
|
282
583
|
return kandan.push(topic, "codex_response", response);
|
|
283
584
|
})
|
|
284
|
-
.catch(error => {
|
|
585
|
+
.catch((error) => {
|
|
285
586
|
return kandan.push(topic, "codex_error", {
|
|
286
587
|
instanceId,
|
|
287
588
|
message: error instanceof Error ? error.message : String(error),
|
|
288
589
|
});
|
|
590
|
+
})
|
|
591
|
+
.catch((error) => {
|
|
592
|
+
log("kandan.control_response_push_failed", {
|
|
593
|
+
message: error instanceof Error ? error.message : String(error),
|
|
594
|
+
});
|
|
289
595
|
});
|
|
290
596
|
});
|
|
291
597
|
|
|
292
598
|
return { instanceId, codexUrl, close };
|
|
293
599
|
}
|
|
294
600
|
|
|
601
|
+
function controlTargetsInstance(
|
|
602
|
+
control: KandanControl,
|
|
603
|
+
instanceId: string,
|
|
604
|
+
): boolean {
|
|
605
|
+
return control.instanceId === undefined || control.instanceId === instanceId;
|
|
606
|
+
}
|
|
607
|
+
|
|
295
608
|
async function closeCleanupStack(cleanup: CleanupStack): Promise<void> {
|
|
296
609
|
if (cleanup.closePromise !== undefined) {
|
|
297
610
|
return cleanup.closePromise;
|
|
@@ -337,7 +650,7 @@ async function discoverCodexThreads(
|
|
|
337
650
|
? []
|
|
338
651
|
: data
|
|
339
652
|
.filter(isJsonObject)
|
|
340
|
-
.map(thread => ({
|
|
653
|
+
.map((thread) => ({
|
|
341
654
|
id: stringValue(thread.id) ?? "",
|
|
342
655
|
preview: stringValue(thread.preview) ?? "",
|
|
343
656
|
cwd: stringValue(thread.cwd) ?? "",
|
|
@@ -345,7 +658,7 @@ async function discoverCodexThreads(
|
|
|
345
658
|
updatedAt: integerValue(thread.updatedAt) ?? null,
|
|
346
659
|
status: objectValue(thread.status) ?? null,
|
|
347
660
|
}))
|
|
348
|
-
.filter(thread => thread.id !== "");
|
|
661
|
+
.filter((thread) => thread.id !== "");
|
|
349
662
|
}
|
|
350
663
|
|
|
351
664
|
function makeRunnerLogger(options: RunnerOptions): RunnerLogger {
|
|
@@ -357,7 +670,9 @@ function makeRunnerLogger(options: RunnerOptions): RunnerLogger {
|
|
|
357
670
|
|
|
358
671
|
function installCleanupHandlers(close: () => Promise<void>): () => void {
|
|
359
672
|
const closeAndExit = () => {
|
|
360
|
-
void close()
|
|
673
|
+
void close()
|
|
674
|
+
.catch(() => undefined)
|
|
675
|
+
.finally(() => process.exit(0));
|
|
361
676
|
};
|
|
362
677
|
const closeOnExit = () => {
|
|
363
678
|
void close().catch(() => undefined);
|
|
@@ -462,9 +777,47 @@ export async function prepareCodexThreadForTuiResume(
|
|
|
462
777
|
async function applyControl(
|
|
463
778
|
codex: Awaited<ReturnType<typeof connectCodexAppServer>>,
|
|
464
779
|
instanceId: string,
|
|
780
|
+
options: RunnerOptions,
|
|
781
|
+
allowedCwds: readonly string[],
|
|
465
782
|
control: KandanControl,
|
|
466
783
|
): Promise<JsonObject> {
|
|
467
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
|
+
|
|
468
821
|
case "start_turn": {
|
|
469
822
|
const response = await codex.request("turn/start", {
|
|
470
823
|
threadId: control.threadId,
|
|
@@ -516,9 +869,41 @@ async function applyControl(
|
|
|
516
869
|
|
|
517
870
|
case "stop_instance":
|
|
518
871
|
case "kill_instance":
|
|
519
|
-
case "start_instance":
|
|
520
872
|
case "interrupt_queued_messages":
|
|
521
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":
|
|
522
880
|
return { instanceId, controlType: control.type, skipped: true };
|
|
523
881
|
}
|
|
524
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
|
+
}
|