@linzumi/cli 0.0.1-beta → 0.0.2-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 ADDED
@@ -0,0 +1,524 @@
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
+ import { spawn, type ChildProcess } from "node:child_process";
35
+ import { randomUUID } from "node:crypto";
36
+ import { hostname } from "node:os";
37
+ import { join } from "node:path";
38
+ import { attachChannelSession } from "./channelSession";
39
+ import { connectCodexAppServer, startCodexAppServer } from "./codexAppServer";
40
+ import { arrayValue, integerValue, objectValue, stringValue } from "./json";
41
+ import { connectPhoenixClient } from "./phoenix";
42
+ import {
43
+ type JsonObject,
44
+ type JsonValue,
45
+ type KandanChannelSessionOptions,
46
+ type KandanControl,
47
+ extractCodexIds,
48
+ isJsonObject,
49
+ } from "./protocol";
50
+ import { createRunnerLogger, type RunnerLogger } from "./runnerLogger";
51
+ import { reportRunnerConsoleEvent } from "./runnerConsoleReporter";
52
+
53
+ export type RunnerOptions = {
54
+ readonly kandanUrl: string;
55
+ readonly token: string;
56
+ readonly runnerId: string;
57
+ readonly cwd: string;
58
+ readonly codexBin: string;
59
+ readonly codexUrl: string | undefined;
60
+ readonly launchTui: boolean;
61
+ readonly fast?: boolean | undefined;
62
+ readonly logFile?: string | undefined;
63
+ readonly channelSession: KandanChannelSessionOptions | undefined;
64
+ };
65
+
66
+ export type LocalCodexRunnerHandle = {
67
+ readonly instanceId: string;
68
+ readonly codexUrl: string;
69
+ readonly close: () => Promise<void>;
70
+ };
71
+
72
+ type CleanupAction = () => void | Promise<void>;
73
+
74
+ type CleanupStack = {
75
+ readonly actions: CleanupAction[];
76
+ closePromise: Promise<void> | undefined;
77
+ removeHandlers: (() => void) | undefined;
78
+ };
79
+
80
+ export async function runLocalCodexRunner(
81
+ options: RunnerOptions,
82
+ ): Promise<LocalCodexRunnerHandle> {
83
+ const log = makeRunnerLogger(options);
84
+ const cleanup: CleanupStack = {
85
+ actions: [() => log.close()],
86
+ closePromise: undefined,
87
+ removeHandlers: undefined,
88
+ };
89
+ const close = () => closeCleanupStack(cleanup);
90
+ cleanup.removeHandlers = installCleanupHandlers(close);
91
+
92
+ log("runner.starting", {
93
+ runnerId: options.runnerId,
94
+ cwd: options.cwd,
95
+ kandanUrl: options.kandanUrl,
96
+ });
97
+
98
+ try {
99
+ return await openLocalCodexRunner(options, log, cleanup, close);
100
+ } catch (error) {
101
+ await close().catch(() => undefined);
102
+ throw error;
103
+ }
104
+ }
105
+
106
+ async function openLocalCodexRunner(
107
+ options: RunnerOptions,
108
+ log: RunnerLogger,
109
+ cleanup: CleanupStack,
110
+ close: () => Promise<void>,
111
+ ): Promise<LocalCodexRunnerHandle> {
112
+ const kandan = await connectPhoenixClient(options.kandanUrl, options.token);
113
+ cleanup.actions.push(() => kandan.close());
114
+ const topic = `local_runner:${options.runnerId}`;
115
+ await kandan.join(topic, {
116
+ clientName: "kandan-local-codex-runner",
117
+ version: "0.0.1",
118
+ capabilities: {
119
+ codexAppServer: true,
120
+ codexRemoteTui: true,
121
+ },
122
+ });
123
+
124
+ const started =
125
+ options.codexUrl === undefined
126
+ ? await startCodexAppServer(options.codexBin, options.cwd, {
127
+ model: options.channelSession?.model,
128
+ reasoningEffort: options.channelSession?.reasoningEffort,
129
+ fast: options.fast,
130
+ })
131
+ : undefined;
132
+
133
+ if (started !== undefined) {
134
+ cleanup.actions.push(() => {
135
+ started.process.kill("SIGINT");
136
+ });
137
+ }
138
+
139
+ const codexUrl = options.codexUrl ?? started?.url;
140
+
141
+ if (codexUrl === undefined) {
142
+ throw new Error("missing codex app-server websocket URL");
143
+ }
144
+
145
+ const instanceId = `codex-${randomUUID()}`;
146
+ const codex = await connectCodexAppServer(codexUrl);
147
+ cleanup.actions.push(() => codex.close());
148
+
149
+ const seq = { value: 0 };
150
+ const codexThreads =
151
+ options.channelSession === undefined
152
+ ? await discoverCodexThreads(codex, options.cwd)
153
+ : [];
154
+
155
+ const runnerHost = hostname();
156
+ const instancePayload = {
157
+ instanceId,
158
+ codexUrl,
159
+ tuiLaunched: options.launchTui,
160
+ cwd: options.cwd,
161
+ hostname: runnerHost,
162
+ codexThreads,
163
+ model: options.channelSession?.model ?? null,
164
+ reasoningEffort: options.channelSession?.reasoningEffort ?? null,
165
+ fast: options.fast ?? false,
166
+ };
167
+
168
+ await kandan.push(topic, "instance_started", instancePayload);
169
+ log("runner.instance_started", { instanceId, codexUrl });
170
+
171
+ const channelSession =
172
+ options.channelSession === undefined
173
+ ? undefined
174
+ : await attachChannelSession({
175
+ kandan,
176
+ codex,
177
+ topic,
178
+ instanceId,
179
+ options: {
180
+ token: options.token,
181
+ runnerId: options.runnerId,
182
+ cwd: options.cwd,
183
+ codexBin: options.codexBin,
184
+ fast: options.fast,
185
+ launchTui: options.launchTui,
186
+ channelSession: options.channelSession,
187
+ },
188
+ log,
189
+ });
190
+
191
+ if (channelSession !== undefined) {
192
+ cleanup.actions.push(() => channelSession.close());
193
+ kandan.onReconnect(() => channelSession.handleKandanReconnect());
194
+ }
195
+
196
+ const heartbeatPayload = (): JsonObject => ({
197
+ instanceId,
198
+ codexUrl,
199
+ cwd: options.cwd,
200
+ hostname: runnerHost,
201
+ workspace: options.channelSession?.workspaceSlug ?? null,
202
+ channel: options.channelSession?.channelSlug ?? null,
203
+ threadId: channelSession?.currentKandanThreadId() ?? null,
204
+ codexThreadId: channelSession?.currentCodexThreadId() ?? null,
205
+ model: options.channelSession?.model ?? null,
206
+ reasoningEffort: options.channelSession?.reasoningEffort ?? null,
207
+ fast: options.fast ?? false,
208
+ });
209
+ const pushHeartbeat = () =>
210
+ kandan.push(topic, "heartbeat", heartbeatPayload()).catch(error => {
211
+ log("kandan.heartbeat_push_failed", {
212
+ message: error instanceof Error ? error.message : String(error),
213
+ });
214
+ });
215
+ const heartbeatInterval = setInterval(() => {
216
+ void pushHeartbeat();
217
+ }, 15_000);
218
+ cleanup.actions.push(() => clearInterval(heartbeatInterval));
219
+ kandan.onReconnect(() => pushHeartbeat().then(() => undefined));
220
+ void pushHeartbeat();
221
+
222
+ const channelCodexThreadId = channelSession?.currentCodexThreadId();
223
+ if (options.launchTui && channelCodexThreadId !== undefined) {
224
+ await prepareCodexThreadForTuiResume(codex, channelCodexThreadId);
225
+ }
226
+
227
+ const tui = options.launchTui
228
+ ? launchCodexTui(
229
+ options.codexBin,
230
+ codexUrl,
231
+ options.cwd,
232
+ channelCodexThreadId,
233
+ options.channelSession,
234
+ options.fast,
235
+ )
236
+ : undefined;
237
+
238
+ if (tui !== undefined) {
239
+ cleanup.actions.push(() => {
240
+ tui.kill("SIGINT");
241
+ });
242
+ }
243
+
244
+ codex.onNotification(notification => {
245
+ seq.value += 1;
246
+ const params = (notification.params ?? {}) as JsonObject;
247
+ const metadata = extractCodexIds(params);
248
+
249
+ if (channelSession === undefined) {
250
+ void kandan.push(topic, "codex_notification", {
251
+ instanceId,
252
+ seq: seq.value,
253
+ method: notification.method,
254
+ params,
255
+ metadata,
256
+ receivedAt: new Date().toISOString(),
257
+ }).catch(error => {
258
+ log("kandan.codex_notification_push_failed", {
259
+ message: error instanceof Error ? error.message : String(error),
260
+ });
261
+ });
262
+ }
263
+
264
+ log("codex.notification", {
265
+ method: notification.method,
266
+ metadata,
267
+ });
268
+ channelSession?.handleCodexNotification(notification.method, params);
269
+ });
270
+
271
+ kandan.onControl(control => {
272
+ log("kandan.control", { control });
273
+ void (channelSession?.handleControl(control) ?? Promise.resolve(undefined))
274
+ .then(handled => {
275
+ if (handled !== undefined) {
276
+ return handled;
277
+ }
278
+
279
+ return applyControl(codex, instanceId, control);
280
+ })
281
+ .then(response => {
282
+ return kandan.push(topic, "codex_response", response);
283
+ })
284
+ .catch(error => {
285
+ return kandan.push(topic, "codex_error", {
286
+ instanceId,
287
+ message: error instanceof Error ? error.message : String(error),
288
+ });
289
+ });
290
+ });
291
+
292
+ return { instanceId, codexUrl, close };
293
+ }
294
+
295
+ async function closeCleanupStack(cleanup: CleanupStack): Promise<void> {
296
+ if (cleanup.closePromise !== undefined) {
297
+ return cleanup.closePromise;
298
+ }
299
+
300
+ cleanup.closePromise = (async () => {
301
+ const errors: Error[] = [];
302
+ cleanup.removeHandlers?.();
303
+ cleanup.removeHandlers = undefined;
304
+
305
+ for (const action of [...cleanup.actions].reverse()) {
306
+ try {
307
+ await action();
308
+ } catch (error) {
309
+ errors.push(error instanceof Error ? error : new Error(String(error)));
310
+ }
311
+ }
312
+
313
+ cleanup.actions.splice(0);
314
+
315
+ if (errors[0] !== undefined) {
316
+ throw errors[0];
317
+ }
318
+ })();
319
+
320
+ return cleanup.closePromise;
321
+ }
322
+
323
+ async function discoverCodexThreads(
324
+ codex: Awaited<ReturnType<typeof connectCodexAppServer>>,
325
+ cwd: string,
326
+ ): Promise<JsonValue[]> {
327
+ const response = await codex.request("thread/list", { cwd });
328
+
329
+ if ("error" in response) {
330
+ return [];
331
+ }
332
+
333
+ const result = objectValue(response.result);
334
+ const data = arrayValue(result?.data);
335
+
336
+ return data === undefined
337
+ ? []
338
+ : data
339
+ .filter(isJsonObject)
340
+ .map(thread => ({
341
+ id: stringValue(thread.id) ?? "",
342
+ preview: stringValue(thread.preview) ?? "",
343
+ cwd: stringValue(thread.cwd) ?? "",
344
+ source: stringValue(thread.source) ?? "",
345
+ updatedAt: integerValue(thread.updatedAt) ?? null,
346
+ status: objectValue(thread.status) ?? null,
347
+ }))
348
+ .filter(thread => thread.id !== "");
349
+ }
350
+
351
+ function makeRunnerLogger(options: RunnerOptions): RunnerLogger {
352
+ return createRunnerLogger(
353
+ options.logFile ?? join(options.cwd, ".kandan-local-codex-runner.log"),
354
+ options.launchTui ? undefined : reportRunnerConsoleEvent,
355
+ );
356
+ }
357
+
358
+ function installCleanupHandlers(close: () => Promise<void>): () => void {
359
+ const closeAndExit = () => {
360
+ void close().catch(() => undefined).finally(() => process.exit(0));
361
+ };
362
+ const closeOnExit = () => {
363
+ void close().catch(() => undefined);
364
+ };
365
+
366
+ process.once("SIGINT", closeAndExit);
367
+ process.once("SIGTERM", closeAndExit);
368
+ process.once("SIGHUP", closeAndExit);
369
+ process.once("exit", closeOnExit);
370
+
371
+ return () => {
372
+ process.off("SIGINT", closeAndExit);
373
+ process.off("SIGTERM", closeAndExit);
374
+ process.off("SIGHUP", closeAndExit);
375
+ process.off("exit", closeOnExit);
376
+ };
377
+ }
378
+
379
+ function launchCodexTui(
380
+ codexBin: string,
381
+ codexUrl: string,
382
+ cwd: string,
383
+ codexThreadId: string | undefined,
384
+ session: KandanChannelSessionOptions | undefined,
385
+ fast: boolean | undefined,
386
+ ): ChildProcess {
387
+ return spawn(codexBin, codexTuiArgs(codexUrl, codexThreadId, session, fast), {
388
+ cwd,
389
+ env: process.env,
390
+ stdio: "inherit",
391
+ });
392
+ }
393
+
394
+ export function codexTuiArgs(
395
+ codexUrl: string,
396
+ codexThreadId: string | undefined,
397
+ session?: KandanChannelSessionOptions | undefined,
398
+ fast?: boolean | undefined,
399
+ ): string[] {
400
+ const overrides = codexTuiConfigArgs(session, fast);
401
+
402
+ return codexThreadId === undefined
403
+ ? ["--remote", codexUrl, ...overrides]
404
+ : ["resume", "--remote", codexUrl, ...overrides, codexThreadId];
405
+ }
406
+
407
+ function codexTuiConfigArgs(
408
+ session: KandanChannelSessionOptions | undefined,
409
+ fast: boolean | undefined,
410
+ ): string[] {
411
+ const modelArgs =
412
+ session?.model === undefined ? [] : ["--model", session.model];
413
+ const reasoningArgs =
414
+ session?.reasoningEffort === undefined
415
+ ? []
416
+ : ["-c", `model_reasoning_effort="${session.reasoningEffort}"`];
417
+ const tierArgs = fast === true ? ["-c", 'service_tier="fast"'] : [];
418
+
419
+ return [...modelArgs, ...reasoningArgs, ...tierArgs];
420
+ }
421
+
422
+ export async function prepareCodexThreadForTuiResume(
423
+ codex: Pick<Awaited<ReturnType<typeof connectCodexAppServer>>, "request">,
424
+ codexThreadId: string,
425
+ ): Promise<void> {
426
+ const resume = await codex.request("thread/resume", {
427
+ threadId: codexThreadId,
428
+ });
429
+
430
+ if (!("error" in resume)) {
431
+ return;
432
+ }
433
+
434
+ if (!resume.error.message.includes("no rollout found")) {
435
+ throw new Error(
436
+ `failed to prepare Codex TUI resume: ${resume.error.message}`,
437
+ );
438
+ }
439
+
440
+ const injected = await codex.request("thread/inject_items", {
441
+ threadId: codexThreadId,
442
+ items: [{ type: "agentMessage", text: "" }],
443
+ });
444
+
445
+ if ("error" in injected) {
446
+ throw new Error(
447
+ `failed to prepare Codex TUI resume: ${injected.error.message}`,
448
+ );
449
+ }
450
+
451
+ const verified = await codex.request("thread/resume", {
452
+ threadId: codexThreadId,
453
+ });
454
+
455
+ if ("error" in verified) {
456
+ throw new Error(
457
+ `failed to verify Codex TUI resume: ${verified.error.message}`,
458
+ );
459
+ }
460
+ }
461
+
462
+ async function applyControl(
463
+ codex: Awaited<ReturnType<typeof connectCodexAppServer>>,
464
+ instanceId: string,
465
+ control: KandanControl,
466
+ ): Promise<JsonObject> {
467
+ switch (control.type) {
468
+ case "start_turn": {
469
+ const response = await codex.request("turn/start", {
470
+ threadId: control.threadId,
471
+ input: control.input,
472
+ });
473
+ return {
474
+ instanceId,
475
+ controlType: control.type,
476
+ response: response as JsonObject,
477
+ };
478
+ }
479
+
480
+ case "steer_turn": {
481
+ const response = await codex.request("turn/steer", {
482
+ threadId: control.threadId,
483
+ turnId: control.turnId,
484
+ input: control.input,
485
+ });
486
+ return {
487
+ instanceId,
488
+ controlType: control.type,
489
+ response: response as JsonObject,
490
+ };
491
+ }
492
+
493
+ case "interrupt_turn": {
494
+ const response = await codex.request("turn/interrupt", {
495
+ threadId: control.threadId,
496
+ turnId: control.turnId ?? null,
497
+ });
498
+ return {
499
+ instanceId,
500
+ controlType: control.type,
501
+ response: response as JsonObject,
502
+ };
503
+ }
504
+
505
+ case "read_thread": {
506
+ const response = await codex.request("thread/read", {
507
+ threadId: control.threadId,
508
+ includeTurns: control.includeTurns ?? true,
509
+ });
510
+ return {
511
+ instanceId,
512
+ controlType: control.type,
513
+ response: response as JsonObject,
514
+ };
515
+ }
516
+
517
+ case "stop_instance":
518
+ case "kill_instance":
519
+ case "start_instance":
520
+ case "interrupt_queued_messages":
521
+ case "resolve_codex_approval_request":
522
+ return { instanceId, controlType: control.type, skipped: true };
523
+ }
524
+ }
@@ -0,0 +1,142 @@
1
+ /*
2
+ - Date: 2026-04-24
3
+ Spec: plans/2026-04-24-local-codex-channel-thread-binding-spec.md
4
+ Relationship: Provides the headless local-runner stdout status feed required
5
+ for operators to see accepted, ignored, forwarded, and Codex-output events
6
+ without printing message bodies or raw Codex payload contents.
7
+
8
+ - Date: 2026-04-25
9
+ Spec: plans/2026-04-24-local-codex-runner-deep-quality-spec.md
10
+ Relationship: Surfaces live reasoning, command-output, and search-progress
11
+ projection status in headless runner stdout while keeping transcript content
12
+ out of the console stream.
13
+ */
14
+
15
+ type ConsolePayload = Record<string, unknown>;
16
+
17
+ export function reportRunnerConsoleEvent(event: string, payload: ConsolePayload): void {
18
+ const line = formatRunnerConsoleEvent(event, payload);
19
+
20
+ if (line !== undefined) {
21
+ process.stdout.write(`${line}\n`);
22
+ }
23
+ }
24
+
25
+ export function formatRunnerConsoleEvent(
26
+ event: string,
27
+ payload: ConsolePayload,
28
+ ): string | undefined {
29
+ switch (event) {
30
+ case "runner.instance_started":
31
+ return `Runner connected: instance=${text(payload.instanceId)} codex=${text(payload.codexUrl)}`;
32
+
33
+ case "kandan.message_ignored":
34
+ return `Incoming message from ${sender(payload)}: ignored for reason ${text(payload.reason)}`;
35
+
36
+ case "kandan.message_queued":
37
+ return `Incoming message from ${sender(payload)}: queued seq=${text(payload.seq)} depth=${text(payload.queue_depth)}`;
38
+
39
+ case "kandan.chat_event_failed":
40
+ return `Incoming message handling failed: seq=${text(payload.seq)} reason=${text(payload.message)}`;
41
+
42
+ case "kandan.reconnected":
43
+ return `Kandan reconnected: codex_session=${text(payload.codex_thread_id)} cursor=${text(payload.min_seq)}`;
44
+
45
+ case "codex.turn_starting":
46
+ return `Incoming message from ${sender(payload)}: forwarding to Codex session ${text(payload.codex_thread_id)} seq=${text(payload.queued_seq)}`;
47
+
48
+ case "codex.turn_started":
49
+ return `Codex turn started: id=${text(payload.turn_id)}`;
50
+
51
+ case "codex.notification":
52
+ return `Codex event [id=${codexEventId(payload)}]: ${text(payload.method)}`;
53
+
54
+ case "codex.turn_completed":
55
+ return `Codex turn completed: id=${text(payload.turn_id)} outputs=${text(payload.output_count)}`;
56
+
57
+ case "kandan.codex_output_forwarded":
58
+ return `Codex event [id=${text(payload.item_key)}]: ${codexOutputLabel(payload)}`;
59
+
60
+ case "kandan.codex_reasoning_delta_forwarded":
61
+ return `Codex event [id=${text(payload.item_key)}]: reasoning_delta chars=${text(payload.content_length)}`;
62
+
63
+ case "kandan.codex_command_output_forwarded":
64
+ return `Codex event [id=${text(payload.item_key)}]: command_output ${text(payload.stream)} chars=${text(payload.output_length)}`;
65
+
66
+ case "kandan.codex_web_search_progress_forwarded":
67
+ return `Codex event [id=${text(payload.item_key)}]: search_progress queries=${text(payload.query_count)}`;
68
+
69
+ case "codex.queued_messages_interrupted":
70
+ return `Queued messages interrupted: selected=${text(payload.selected_count)} remaining=${text(payload.remaining_count)}`;
71
+
72
+ case "codex.turn_start_failed":
73
+ return `Codex turn start failed: seq=${text(payload.queued_seq)} reason=${text(payload.message)}`;
74
+
75
+ case "codex.turn_forward_failed":
76
+ return `Codex turn forward failed: id=${text(payload.turn_id)} reason=${text(payload.message)}`;
77
+
78
+ default:
79
+ return undefined;
80
+ }
81
+ }
82
+
83
+ function sender(payload: ConsolePayload): string {
84
+ const slug = stringValue(payload.actor_slug);
85
+ const userId = numberValue(payload.actor_user_id);
86
+
87
+ if (slug !== undefined && userId !== undefined) {
88
+ return `${slug}#${userId}`;
89
+ }
90
+
91
+ return slug ?? (userId === undefined ? "unknown" : `user#${userId}`);
92
+ }
93
+
94
+ function codexOutputLabel(payload: ConsolePayload): string {
95
+ const kind = stringValue(payload.structured_kind) ?? "output";
96
+
97
+ switch (kind) {
98
+ case "codex_assistant_message":
99
+ return "assistant_message";
100
+ case "codex_reasoning":
101
+ return "reasoning";
102
+ case "codex_command_execution":
103
+ return `command ${text(payload.command)}`;
104
+ case "codex_terminal_input":
105
+ return "terminal_input";
106
+ case "codex_file_change":
107
+ return `file_change ${fileChangePaths(payload)}`;
108
+ default:
109
+ return kind;
110
+ }
111
+ }
112
+
113
+ function fileChangePaths(payload: ConsolePayload): string {
114
+ const paths = Array.isArray(payload.file_paths)
115
+ ? payload.file_paths.filter((value): value is string => typeof value === "string")
116
+ : [];
117
+
118
+ return paths.length === 0 ? "(unknown file)" : paths.slice(0, 3).join(", ");
119
+ }
120
+
121
+ function codexEventId(payload: ConsolePayload): string {
122
+ const metadata = payload.metadata;
123
+
124
+ if (typeof metadata === "object" && metadata !== null && !Array.isArray(metadata)) {
125
+ const record = metadata as Record<string, unknown>;
126
+ return stringValue(record.turnId) ?? stringValue(record.itemId) ?? stringValue(record.threadId) ?? "?";
127
+ }
128
+
129
+ return "?";
130
+ }
131
+
132
+ function text(value: unknown): string {
133
+ return stringValue(value) ?? numberValue(value)?.toString() ?? "?";
134
+ }
135
+
136
+ function stringValue(value: unknown): string | undefined {
137
+ return typeof value === "string" && value.trim() !== "" ? value : undefined;
138
+ }
139
+
140
+ function numberValue(value: unknown): number | undefined {
141
+ return typeof value === "number" && Number.isFinite(value) ? value : undefined;
142
+ }
@@ -0,0 +1,50 @@
1
+ /*
2
+ - Date: 2026-04-24
3
+ Spec: plans/2026-04-24-local-codex-channel-thread-binding-spec.md
4
+ Relationship: Writes the runner's local event log without blocking the
5
+ websocket/control loop on synchronous filesystem appends.
6
+ */
7
+ import { openSync } from "node:fs";
8
+ import { createWriteStream, type WriteStream } from "node:fs";
9
+ import { dirname } from "node:path";
10
+ import { mkdirSync } from "node:fs";
11
+
12
+ export type RunnerConsoleReporter = (event: string, payload: Record<string, unknown>) => void;
13
+
14
+ export type RunnerLogger = ((event: string, payload: Record<string, unknown>) => void) & {
15
+ readonly close: () => Promise<void>;
16
+ };
17
+
18
+ export function createRunnerLogger(
19
+ logFile: string,
20
+ consoleReporter?: RunnerConsoleReporter | undefined,
21
+ ): RunnerLogger {
22
+ mkdirSync(dirname(logFile), { recursive: true });
23
+ const fd = openSync(logFile, "a");
24
+ const stream = createWriteStream("", { fd, flags: "a", autoClose: true });
25
+
26
+ const logger = ((event: string, payload: Record<string, unknown>) => {
27
+ stream.write(
28
+ `${JSON.stringify({ ts: new Date().toISOString(), event, ...payload })}\n`,
29
+ "utf8",
30
+ );
31
+ consoleReporter?.(event, payload);
32
+ }) as RunnerLogger;
33
+
34
+ Object.defineProperty(logger, "close", {
35
+ value: () => closeStream(stream),
36
+ });
37
+
38
+ return logger;
39
+ }
40
+
41
+ function closeStream(stream: WriteStream): Promise<void> {
42
+ if (stream.closed || stream.destroyed) {
43
+ return Promise.resolve();
44
+ }
45
+
46
+ return new Promise((resolve, reject) => {
47
+ stream.once("error", reject);
48
+ stream.end(resolve);
49
+ });
50
+ }