@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.
@@ -1,308 +0,0 @@
1
- /*
2
- - Date: 2026-04-24
3
- Spec: plans/2026-04-24-local-codex-runner-quality-pass-spec.md
4
- Relationship: Owns pure channel-session policy so the stateful runner can
5
- stay focused on websocket lifecycle and Codex turn orchestration.
6
-
7
- - Date: 2026-04-24
8
- Spec: plans/2026-04-24-local-codex-runner-deep-quality-spec.md
9
- Relationship: Keeps local Codex version discovery bounded so channel
10
- availability is not blocked indefinitely by a bad executable.
11
-
12
- - Date: 2026-05-02
13
- Spec: plans/2026-05-02-agent-first-zero-to-codex-launch-plan.md
14
- Relationship: Keeps the npm CLI runner in parity with the legacy local
15
- runner by carrying Kandan attachment metadata into queued Codex input.
16
- */
17
- import { spawnSync } from "node:child_process";
18
- import { hostname } from "node:os";
19
- import { arrayValue, integerValue, objectValue, stringValue } from "./json";
20
- import { type JsonObject, type JsonValue, isJsonObject } from "./protocol";
21
-
22
- export type KandanChatAttachment = {
23
- readonly id: string | undefined;
24
- readonly kind: string | undefined;
25
- readonly fileName: string | undefined;
26
- readonly contentType: string | undefined;
27
- readonly sizeBytes: number | undefined;
28
- readonly url: string | undefined;
29
- };
30
-
31
- export type KandanChatEvent = {
32
- readonly seq: number;
33
- readonly type: string;
34
- readonly actorKind: string | undefined;
35
- readonly actorSlug: string | undefined;
36
- readonly actorUserId: number | undefined;
37
- readonly threadId: string | undefined;
38
- readonly replyToSeq: number | undefined;
39
- readonly localRunnerEventType: string | undefined;
40
- readonly body: string;
41
- readonly attachments: readonly KandanChatAttachment[];
42
- };
43
-
44
- export type RunnerIdentity = {
45
- readonly actorUserId: number | undefined;
46
- readonly actorUsername: string | undefined;
47
- };
48
-
49
- export type RunnerPayloadContext = {
50
- readonly runnerIdentity: RunnerIdentity;
51
- readonly codexVersion: string | undefined;
52
- };
53
-
54
- export type ChannelSessionSupportOptions = {
55
- readonly runnerId: string;
56
- readonly cwd: string;
57
- readonly fast?: boolean | undefined;
58
- readonly channelSession:
59
- | {
60
- readonly model?: string | undefined;
61
- readonly reasoningEffort?: string | undefined;
62
- readonly sandbox?: string | undefined;
63
- readonly approvalPolicy?: string | undefined;
64
- }
65
- | undefined;
66
- };
67
-
68
- export function availabilityMessage(
69
- options: ChannelSessionSupportOptions,
70
- codexVersion: string | undefined,
71
- codexThreadId: string,
72
- ): string {
73
- const session = options.channelSession;
74
-
75
- return [
76
- "Codex is ready.",
77
- "",
78
- "| Stat | Value |",
79
- "| --- | --- |",
80
- `| Host | ${markdownTableCell(hostname())} |`,
81
- `| Directory | ${markdownTableCell(options.cwd)} |`,
82
- `| Codex thread | ${markdownTableCell(codexThreadId)} |`,
83
- `| Codex | ${markdownTableCell(codexVersion ?? "unknown")} |`,
84
- `| Model | ${markdownTableCell(session?.model ?? "default")} |`,
85
- `| Reasoning effort | ${markdownTableCell(session?.reasoningEffort ?? "default")} |`,
86
- `| Fast mode | ${options.fast ? "yes" : "no"} |`,
87
- `| Sandbox | ${markdownTableCell(session?.sandbox ?? "default")} |`,
88
- `| Approval policy | ${markdownTableCell(session?.approvalPolicy ?? "default")} |`,
89
- "",
90
- "Reply in this thread to send a message to the local Codex session.",
91
- ].join("\n");
92
- }
93
-
94
- function markdownTableCell(value: string): string {
95
- return value
96
- .replaceAll("\\", "\\\\")
97
- .replaceAll("|", "\\|")
98
- .replaceAll("\n", " ");
99
- }
100
-
101
- export function parseKandanChatEvent(
102
- value: JsonValue,
103
- ): KandanChatEvent | undefined {
104
- if (!isJsonObject(value)) {
105
- return undefined;
106
- }
107
-
108
- const payload = objectValue(value.payload) ?? {};
109
- const actor = objectValue(value.actor);
110
- const seq = integerValue(value.seq);
111
- const type = stringValue(value.type);
112
-
113
- if (seq === undefined || type === undefined) {
114
- return undefined;
115
- }
116
-
117
- return {
118
- seq,
119
- type,
120
- actorKind: stringValue(actor?.kind),
121
- actorSlug: stringValue(actor?.slug),
122
- actorUserId: integerValue(value.actor_user_id),
123
- threadId: stringValue(payload.thread_id),
124
- replyToSeq: integerValue(payload.reply_to_seq),
125
- localRunnerEventType: localRunnerEventType(payload),
126
- body:
127
- stringValue(value.body) ??
128
- stringValue(value.text) ??
129
- stringValue(payload.body) ??
130
- stringValue(payload.text) ??
131
- "",
132
- attachments: parseKandanChatAttachments(
133
- arrayValue(payload.attachments) ?? arrayValue(value.attachments) ?? [],
134
- ),
135
- };
136
- }
137
-
138
- function parseKandanChatAttachments(
139
- attachments: readonly JsonValue[],
140
- ): readonly KandanChatAttachment[] {
141
- return attachments.flatMap(attachment => {
142
- const value = objectValue(attachment);
143
-
144
- if (value === undefined) {
145
- return [];
146
- }
147
-
148
- return [
149
- {
150
- id: stringValue(value.id),
151
- kind: stringValue(value.kind),
152
- fileName:
153
- stringValue(value.file_name) ??
154
- stringValue(value.fileName) ??
155
- stringValue(value.name),
156
- contentType:
157
- stringValue(value.content_type) ??
158
- stringValue(value.contentType) ??
159
- stringValue(value.mime_type) ??
160
- stringValue(value.mimeType),
161
- sizeBytes:
162
- integerValue(value.size_bytes) ??
163
- integerValue(value.sizeBytes) ??
164
- integerValue(value.size),
165
- url: stringValue(value.url),
166
- },
167
- ];
168
- });
169
- }
170
-
171
- export function isCodexAuthoredEvent(
172
- event: Pick<
173
- KandanChatEvent,
174
- "actorKind" | "actorSlug" | "localRunnerEventType"
175
- >,
176
- ): boolean {
177
- if (event.localRunnerEventType !== undefined) {
178
- return true;
179
- }
180
-
181
- if (event.actorKind === "service" && event.actorSlug === "codex") {
182
- return true;
183
- }
184
-
185
- return event.actorSlug === "codex";
186
- }
187
-
188
- export function senderAllowed(
189
- listenUser: string,
190
- event: Pick<KandanChatEvent, "actorSlug" | "actorUserId">,
191
- runnerIdentity: RunnerIdentity,
192
- ): boolean {
193
- const normalized = listenUser.trim().toLocaleLowerCase();
194
-
195
- if (normalized === "all") {
196
- return true;
197
- }
198
-
199
- if (event.actorSlug !== undefined && event.actorSlug.toLocaleLowerCase() === normalized) {
200
- return true;
201
- }
202
-
203
- return (
204
- runnerIdentity.actorUsername?.toLocaleLowerCase() === normalized &&
205
- runnerIdentity.actorUserId !== undefined &&
206
- event.actorUserId === runnerIdentity.actorUserId
207
- );
208
- }
209
-
210
- export function identityFromAccessToken(token: string): RunnerIdentity {
211
- const [, payload] = token.split(".");
212
-
213
- if (payload === undefined) {
214
- return { actorUserId: undefined, actorUsername: undefined };
215
- }
216
-
217
- try {
218
- const decoded = JSON.parse(
219
- Buffer.from(base64UrlToBase64(payload), "base64").toString("utf8"),
220
- );
221
- const actorUserId =
222
- integerValue(decoded.actor_id) ?? integerValue(decoded.sub);
223
-
224
- return {
225
- actorUserId,
226
- actorUsername: stringValue(decoded.actor_username),
227
- };
228
- } catch (_error) {
229
- return { actorUserId: undefined, actorUsername: undefined };
230
- }
231
- }
232
-
233
- export function detectCodexVersion(
234
- codexBin: string,
235
- cwd: string,
236
- ): string | undefined {
237
- const result = spawnSync(codexBin, ["--version"], {
238
- cwd,
239
- encoding: "utf8",
240
- timeout: 1_000,
241
- });
242
-
243
- if (result.error !== undefined || result.status !== 0) {
244
- return undefined;
245
- }
246
-
247
- const version = stringValue(result.stdout) ?? stringValue(result.stderr);
248
- const trimmed = version?.trim();
249
-
250
- return trimmed === "" ? undefined : trimmed;
251
- }
252
-
253
- export function localRunnerPayload(
254
- options: ChannelSessionSupportOptions,
255
- instanceId: string,
256
- eventType: string,
257
- codexThreadId: string,
258
- context: RunnerPayloadContext,
259
- sourceMessageSeq?: number | undefined,
260
- extraLocalRunnerMetadata?: JsonObject | undefined,
261
- ): JsonObject {
262
- return {
263
- metadata: {
264
- local_codex_runner: {
265
- ...(extraLocalRunnerMetadata ?? {}),
266
- runner_id: options.runnerId,
267
- instance_id: instanceId,
268
- event_type: eventType,
269
- codex_thread_id: codexThreadId,
270
- hostname: hostname(),
271
- cwd: options.cwd,
272
- codex_version: context.codexVersion ?? null,
273
- model: options.channelSession?.model ?? null,
274
- reasoning_effort: options.channelSession?.reasoningEffort ?? null,
275
- fast: options.fast ?? false,
276
- sandbox: options.channelSession?.sandbox ?? null,
277
- approval_policy: options.channelSession?.approvalPolicy ?? null,
278
- user_id: context.runnerIdentity.actorUserId ?? null,
279
- ...(sourceMessageSeq === undefined
280
- ? {}
281
- : { source_message_seq: sourceMessageSeq }),
282
- },
283
- },
284
- };
285
- }
286
-
287
- export function localRunnerEventType(payload: JsonObject): string | undefined {
288
- const metadata = objectValue(payload.metadata);
289
- const localRunner = objectValue(metadata?.local_codex_runner);
290
-
291
- return stringValue(localRunner?.event_type);
292
- }
293
-
294
- function base64UrlToBase64(value: string): string {
295
- const normalized = value.replaceAll("-", "+").replaceAll("_", "/");
296
- const padding = normalized.length % 4;
297
-
298
- switch (padding) {
299
- case 0:
300
- return normalized;
301
- case 2:
302
- return `${normalized}==`;
303
- case 3:
304
- return `${normalized}=`;
305
- default:
306
- return normalized;
307
- }
308
- }
@@ -1,380 +0,0 @@
1
- /*
2
- - Date: 2026-04-24
3
- Spec: plans/2026-04-24-local-codex-runner-plan.md
4
- Relationship: Implements the spec's local Codex app-server process/client
5
- seam, including loopback binding and readiness before runner attachment.
6
-
7
- - Date: 2026-04-25
8
- Spec: plans/2026-04-24-local-codex-runner-deep-quality-spec.md
9
- Relationship: Implements the spec requirement that the local runner is a
10
- complete app-server JSON-RPC client: server-initiated requests must receive
11
- a response instead of hanging active Codex turns indefinitely.
12
- */
13
- import { spawn, type ChildProcess } from "node:child_process";
14
- import { createServer } from "node:net";
15
- import {
16
- type JsonObject,
17
- type JsonValue,
18
- type JsonRpcId,
19
- type JsonRpcMessage,
20
- type JsonRpcNotification,
21
- type JsonRpcRequest,
22
- type JsonRpcResponse,
23
- isJsonObject
24
- } from "./protocol";
25
-
26
- type PendingRequest = {
27
- readonly resolve: (value: JsonRpcResponse) => void;
28
- readonly reject: (error: Error) => void;
29
- };
30
-
31
- export type CodexAppServerClient = {
32
- readonly request: (method: string, params?: JsonObject) => Promise<JsonRpcResponse>;
33
- readonly notify: (method: string, params?: JsonObject) => void;
34
- readonly onNotification: (callback: (message: JsonRpcNotification) => void) => void;
35
- readonly onRequest?: (callback: (message: JsonRpcRequest) => JsonValue | Promise<JsonValue>) => void;
36
- readonly close: () => void;
37
- };
38
-
39
- export type StartedCodexAppServer = {
40
- readonly url: string;
41
- readonly process: ChildProcess;
42
- };
43
-
44
- export type StartCodexAppServerOptions = {
45
- readonly model?: string | undefined;
46
- readonly reasoningEffort?: string | undefined;
47
- readonly fast?: boolean | undefined;
48
- };
49
-
50
- export async function chooseLoopbackPort(): Promise<number> {
51
- return new Promise((resolve, reject) => {
52
- const server = createServer();
53
-
54
- server.on("error", error => reject(error));
55
- server.listen(0, "127.0.0.1", () => {
56
- const address = server.address();
57
-
58
- if (typeof address === "object" && address !== null) {
59
- const port = address.port;
60
- server.close(error => {
61
- if (error !== undefined) {
62
- reject(error);
63
- } else {
64
- resolve(port);
65
- }
66
- });
67
- } else {
68
- server.close();
69
- reject(new Error("failed to allocate loopback port"));
70
- }
71
- });
72
- });
73
- }
74
-
75
- export async function startCodexAppServer(
76
- codexBin: string,
77
- cwd: string,
78
- options: StartCodexAppServerOptions = {},
79
- ): Promise<StartedCodexAppServer> {
80
- const port = await chooseLoopbackPort();
81
- const url = `ws://127.0.0.1:${port}`;
82
- const child = spawn(codexBin, codexAppServerArgs(url, options), {
83
- cwd,
84
- env: process.env,
85
- stdio: ["ignore", "inherit", "inherit"]
86
- });
87
-
88
- child.once("exit", code => {
89
- if (code !== 0) {
90
- process.stderr.write(`codex app-server exited with code ${code ?? "signal"}\n`);
91
- }
92
- });
93
-
94
- try {
95
- await waitForReadyz(url, child);
96
- } catch (error) {
97
- child.kill("SIGINT");
98
- throw error;
99
- }
100
-
101
- return { url, process: child };
102
- }
103
-
104
- export function codexAppServerArgs(
105
- listenUrl: string,
106
- options: StartCodexAppServerOptions = {},
107
- ): string[] {
108
- return [
109
- "app-server",
110
- ...codexConfigArgs(options),
111
- "--listen",
112
- listenUrl,
113
- ];
114
- }
115
-
116
- function codexConfigArgs(options: StartCodexAppServerOptions): string[] {
117
- return [
118
- ...(options.model === undefined ? [] : [
119
- "-c",
120
- `model=${JSON.stringify(options.model)}`,
121
- ]),
122
- ...(options.reasoningEffort === undefined ? [] : [
123
- "-c",
124
- `model_reasoning_effort=${JSON.stringify(options.reasoningEffort)}`,
125
- ]),
126
- ...(options.fast === true ? [
127
- "-c",
128
- `service_tier=${JSON.stringify("fast")}`,
129
- ] : []),
130
- ];
131
- }
132
-
133
- export async function connectCodexAppServer(
134
- websocketUrl: string,
135
- socketFactory: (url: string) => WebSocket = url => new WebSocket(url)
136
- ): Promise<CodexAppServerClient> {
137
- const websocket = socketFactory(websocketUrl);
138
- const pending = new Map<JsonRpcId, PendingRequest>();
139
- const notificationCallbacks = new Set<(message: JsonRpcNotification) => void>();
140
- const requestCallbacks = new Set<(message: JsonRpcRequest) => JsonValue | Promise<JsonValue>>();
141
- const pendingNotifications: JsonRpcNotification[] = [];
142
- const state = { nextId: 1 };
143
- const rejectPending = (message: string) => {
144
- const error = new Error(message);
145
- pending.forEach(pendingRequest => pendingRequest.reject(error));
146
- pending.clear();
147
- };
148
-
149
- websocket.addEventListener("close", () => rejectPending("codex app-server websocket closed"));
150
- websocket.addEventListener("error", () => rejectPending("codex app-server websocket error"));
151
-
152
- websocket.addEventListener("message", event => {
153
- const parsed = JSON.parse(String(event.data));
154
-
155
- if (!isJsonObject(parsed)) {
156
- throw new Error("codex app-server sent non-object JSON-RPC message");
157
- }
158
-
159
- const message = parsed as JsonRpcMessage;
160
-
161
- if ("id" in message && ("result" in message || "error" in message)) {
162
- const pendingRequest = pending.get(message.id);
163
-
164
- if (pendingRequest !== undefined) {
165
- pending.delete(message.id);
166
- pendingRequest.resolve(message);
167
- }
168
- return;
169
- }
170
-
171
- if ("id" in message && typeof message.method === "string") {
172
- void respondToServerRequest(websocket, message as JsonRpcRequest, requestCallbacks);
173
- return;
174
- }
175
-
176
- if (!("id" in message) && typeof message.method === "string") {
177
- const notification = message as JsonRpcNotification;
178
-
179
- if (notificationCallbacks.size === 0) {
180
- pendingNotifications.push(notification);
181
- } else {
182
- notificationCallbacks.forEach(callback => callback(notification));
183
- }
184
- }
185
- });
186
-
187
- await waitForOpen(websocket);
188
- await initialize(websocket, pending);
189
-
190
- const request = (method: string, params?: JsonObject): Promise<JsonRpcResponse> => {
191
- const id = state.nextId;
192
- state.nextId += 1;
193
- const message: JsonRpcRequest =
194
- params === undefined
195
- ? { jsonrpc: "2.0", id, method }
196
- : { jsonrpc: "2.0", id, method, params };
197
-
198
- return sendRequest(websocket, pending, message);
199
- };
200
-
201
- return {
202
- request,
203
- notify: (method, params) => {
204
- const message =
205
- params === undefined
206
- ? { jsonrpc: "2.0", method }
207
- : { jsonrpc: "2.0", method, params };
208
- websocket.send(JSON.stringify(message));
209
- },
210
- onNotification: callback => {
211
- notificationCallbacks.add(callback);
212
- pendingNotifications.splice(0).forEach(notification => callback(notification));
213
- },
214
- onRequest: callback => {
215
- requestCallbacks.add(callback);
216
- },
217
- close: () => websocket.close()
218
- };
219
- }
220
-
221
- async function respondToServerRequest(
222
- websocket: WebSocket,
223
- request: JsonRpcRequest,
224
- callbacks: Set<(message: JsonRpcRequest) => JsonValue | Promise<JsonValue>>,
225
- ): Promise<void> {
226
- try {
227
- if (callbacks.size === 0) {
228
- websocket.send(JSON.stringify({
229
- jsonrpc: "2.0",
230
- id: request.id,
231
- error: {
232
- code: -32601,
233
- message: `unhandled Codex app-server request: ${request.method}`,
234
- },
235
- }));
236
- return;
237
- }
238
-
239
- const callback = Array.from(callbacks)[0];
240
-
241
- if (callback === undefined) {
242
- throw new Error(`unhandled Codex app-server request: ${request.method}`);
243
- }
244
-
245
- const result = await callback(request);
246
- websocket.send(JSON.stringify({ jsonrpc: "2.0", id: request.id, result }));
247
- } catch (error) {
248
- websocket.send(JSON.stringify({
249
- jsonrpc: "2.0",
250
- id: request.id,
251
- error: {
252
- code: -32000,
253
- message: error instanceof Error ? error.message : String(error),
254
- },
255
- }));
256
- }
257
- }
258
-
259
- async function initialize(
260
- websocket: WebSocket,
261
- pending: Map<JsonRpcId, PendingRequest>
262
- ): Promise<void> {
263
- const response = await sendRequest(websocket, pending, {
264
- jsonrpc: "2.0",
265
- id: "initialize",
266
- method: "initialize",
267
- params: {
268
- clientInfo: {
269
- name: "kandan-local-codex-runner",
270
- version: "0.0.1"
271
- },
272
- capabilities: {
273
- experimentalApi: true
274
- }
275
- }
276
- });
277
-
278
- if ("error" in response) {
279
- throw new Error(`codex initialize failed: ${response.error.message}`);
280
- }
281
-
282
- websocket.send(JSON.stringify({ jsonrpc: "2.0", method: "initialized" }));
283
- }
284
-
285
- function sendRequest(
286
- websocket: WebSocket,
287
- pending: Map<JsonRpcId, PendingRequest>,
288
- message: JsonRpcRequest
289
- ): Promise<JsonRpcResponse> {
290
- if (websocket.readyState !== WebSocket.OPEN) {
291
- return Promise.reject(new Error("codex app-server websocket is not open"));
292
- }
293
-
294
- return new Promise((resolve, reject) => {
295
- pending.set(message.id, { resolve, reject });
296
- websocket.send(JSON.stringify(message));
297
- });
298
- }
299
-
300
- function waitForOpen(websocket: WebSocket): Promise<void> {
301
- return new Promise((resolve, reject) => {
302
- websocket.addEventListener("open", () => resolve(), { once: true });
303
- websocket.addEventListener("error", () => reject(new Error("websocket open failed")), {
304
- once: true
305
- });
306
- });
307
- }
308
-
309
- function waitForReadyz(
310
- websocketUrl: string,
311
- child: ChildProcess,
312
- timeoutMs = 10_000
313
- ): Promise<void> {
314
- const readyzUrl = readyzUrlForWebsocket(websocketUrl);
315
- const deadline = Date.now() + timeoutMs;
316
-
317
- return new Promise((resolve, reject) => {
318
- let settled = false;
319
- let timer: ReturnType<typeof setTimeout> | undefined;
320
-
321
- const finish = (result: "ready" | Error) => {
322
- if (settled) {
323
- return;
324
- }
325
-
326
- settled = true;
327
-
328
- if (timer !== undefined) {
329
- clearTimeout(timer);
330
- }
331
-
332
- child.off("exit", onExit);
333
-
334
- if (result === "ready") {
335
- resolve();
336
- } else {
337
- reject(result);
338
- }
339
- };
340
-
341
- const onExit = (code: number | null, signal: NodeJS.Signals | null) => {
342
- finish(new Error(`codex app-server exited before readyz: ${code ?? signal ?? "unknown"}`));
343
- };
344
-
345
- const scheduleCheck = () => {
346
- timer = setTimeout(checkReadyz, 50);
347
- };
348
-
349
- const checkReadyz = async () => {
350
- if (Date.now() > deadline) {
351
- finish(new Error("timed out waiting for codex app-server readyz"));
352
- return;
353
- }
354
-
355
- try {
356
- const response = await fetch(readyzUrl);
357
-
358
- if (response.ok) {
359
- finish("ready");
360
- } else {
361
- scheduleCheck();
362
- }
363
- } catch (_error) {
364
- scheduleCheck();
365
- }
366
- };
367
-
368
- child.once("exit", onExit);
369
- void checkReadyz();
370
- });
371
- }
372
-
373
- function readyzUrlForWebsocket(websocketUrl: string): string {
374
- const parsed = new URL(websocketUrl);
375
- parsed.protocol = parsed.protocol === "wss:" ? "https:" : "http:";
376
- parsed.pathname = "/readyz";
377
- parsed.search = "";
378
- parsed.hash = "";
379
- return parsed.toString();
380
- }