@linzumi/cli 0.0.11-beta → 0.0.13-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.
@@ -0,0 +1,99 @@
1
+ /*
2
+ - Date: 2026-05-01
3
+ Spec: ../../kandan/server_v2/plans/2026-05-01-runner-editor-dropdown-and-thread-controls.md
4
+ Relationship: Owns the npm-first local runner's trusted-folder config at
5
+ ~/.linzumi/config.json so README path-management commands are product truth.
6
+ */
7
+ import { existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from "node:fs";
8
+ import { homedir } from "node:os";
9
+ import { dirname, resolve } from "node:path";
10
+ import { expandUserPath } from "./localCapabilities";
11
+
12
+ export type LinzumiConfig = {
13
+ readonly version: 1;
14
+ readonly allowedCwds: readonly string[];
15
+ };
16
+
17
+ export function localConfigPath(env: NodeJS.ProcessEnv = process.env): string {
18
+ const override = env.LINZUMI_CONFIG_FILE;
19
+
20
+ return override !== undefined && override.trim() !== ""
21
+ ? resolve(expandUserPath(override))
22
+ : resolve(homedir(), ".linzumi", "config.json");
23
+ }
24
+
25
+ export function readLocalConfig(path: string = localConfigPath()): LinzumiConfig {
26
+ if (!existsSync(path)) {
27
+ return { version: 1, allowedCwds: [] };
28
+ }
29
+
30
+ const parsed = JSON.parse(readFileSync(path, "utf8")) as unknown;
31
+
32
+ if (!isConfigPayload(parsed)) {
33
+ throw new Error(`invalid Linzumi config: ${path}`);
34
+ }
35
+
36
+ return {
37
+ version: 1,
38
+ allowedCwds: uniqueStrings(parsed.allowedCwds),
39
+ };
40
+ }
41
+
42
+ export function readConfiguredAllowedCwds(path: string = localConfigPath()): string[] {
43
+ return readLocalConfig(path).allowedCwds.map((cwd) => {
44
+ try {
45
+ return realpathSync(resolve(expandUserPath(cwd)));
46
+ } catch (_error) {
47
+ throw new Error(`invalid Linzumi config allowed path: ${cwd} does not exist`);
48
+ }
49
+ });
50
+ }
51
+
52
+ export function addAllowedCwd(pathValue: string, path: string = localConfigPath()): string[] {
53
+ const normalizedPath = realpathSync(resolve(expandUserPath(pathValue)));
54
+ const config = readLocalConfig(path);
55
+ const allowedCwds = uniqueStrings([...config.allowedCwds, normalizedPath]);
56
+ writeLocalConfig({ version: 1, allowedCwds }, path);
57
+ return allowedCwds;
58
+ }
59
+
60
+ export function removeAllowedCwd(pathValue: string, path: string = localConfigPath()): string[] {
61
+ const requestedPath = resolve(expandUserPath(pathValue));
62
+ const normalizedRequest = realpathOrResolved(requestedPath);
63
+ const config = readLocalConfig(path);
64
+ const allowedCwds = config.allowedCwds.filter((cwd) => {
65
+ const normalizedExisting = realpathOrResolved(cwd);
66
+ return cwd !== pathValue && normalizedExisting !== normalizedRequest;
67
+ });
68
+ writeLocalConfig({ version: 1, allowedCwds }, path);
69
+ return allowedCwds;
70
+ }
71
+
72
+ export function writeLocalConfig(config: LinzumiConfig, path: string = localConfigPath()): void {
73
+ mkdirSync(dirname(path), { recursive: true });
74
+ writeFileSync(path, `${JSON.stringify(config, null, 2)}\n`, "utf8");
75
+ }
76
+
77
+ function isConfigPayload(value: unknown): value is LinzumiConfig {
78
+ return (
79
+ typeof value === "object" &&
80
+ value !== null &&
81
+ (value as { readonly version?: unknown }).version === 1 &&
82
+ Array.isArray((value as { readonly allowedCwds?: unknown }).allowedCwds) &&
83
+ (value as { readonly allowedCwds: readonly unknown[] }).allowedCwds.every(
84
+ (cwd) => typeof cwd === "string" && cwd.trim() !== "",
85
+ )
86
+ );
87
+ }
88
+
89
+ function uniqueStrings(values: readonly string[]): string[] {
90
+ return [...new Set(values.map((value) => value.trim()).filter((value) => value !== ""))];
91
+ }
92
+
93
+ function realpathOrResolved(pathValue: string): string {
94
+ try {
95
+ return realpathSync(resolve(expandUserPath(pathValue)));
96
+ } catch (_error) {
97
+ return resolve(expandUserPath(pathValue));
98
+ }
99
+ }
@@ -592,7 +592,7 @@ export function prepareLocalEditorCollaboration(
592
592
  return undefined;
593
593
  }
594
594
 
595
- const targetPath = `/local-codex-runner-targets/${encodeURIComponent(runnerId)}/forwards/${serverPort}`;
595
+ const targetPath = `/local-codex-runners/${encodeURIComponent(runnerId)}/forwards/${serverPort}/preview-target`;
596
596
  const serverUrl = new URL(targetPath, `${browserBaseUrl}/`).toString();
597
597
  const bootstrapServerUrl =
598
598
  collaboration.bootstrapToken === undefined || collaboration.bootstrapToken === ""
@@ -211,14 +211,15 @@ function openLocalWebSocket(
211
211
  scheme: "ws" | "wss",
212
212
  ): void {
213
213
  let opened = false;
214
- const websocket = new WebSocket(
215
- localForwardUrl(
216
- scheme === "ws" ? "http" : "https",
217
- control.port,
218
- control.path,
219
- control.queryString,
220
- ).replace(/^http/, scheme),
221
- );
214
+ const url = localForwardUrl(
215
+ scheme === "ws" ? "http" : "https",
216
+ control.port,
217
+ control.path,
218
+ control.queryString,
219
+ ).replace(/^http/, scheme);
220
+ const protocols = webSocketProtocols(control.headers);
221
+ const websocket =
222
+ protocols === undefined ? new WebSocket(url) : new WebSocket(url, protocols);
222
223
  sockets.set(control.socketId, websocket);
223
224
  websocket.addEventListener("open", () => {
224
225
  opened = true;
@@ -260,6 +261,28 @@ function openLocalWebSocket(
260
261
  });
261
262
  }
262
263
 
264
+ function webSocketProtocols(headers: JsonValue | undefined): string[] | undefined {
265
+ if (!Array.isArray(headers)) {
266
+ return undefined;
267
+ }
268
+
269
+ const protocols = headers.flatMap(header => {
270
+ if (
271
+ !isWireHeader(header) ||
272
+ header.name.toLowerCase() !== "sec-websocket-protocol"
273
+ ) {
274
+ return [];
275
+ }
276
+
277
+ return header.value
278
+ .split(",")
279
+ .map(protocol => protocol.trim())
280
+ .filter(protocol => protocol !== "");
281
+ });
282
+
283
+ return protocols.length === 0 ? undefined : protocols;
284
+ }
285
+
263
286
  function localForwardUrl(
264
287
  scheme: "http" | "https",
265
288
  port: number,
package/src/protocol.ts CHANGED
@@ -37,6 +37,11 @@
37
37
  Spec: plans/2026-04-26-local-runner-subdomain-forwarding-epr.md
38
38
  Relationship: Defines the typed WebSocket forwarding controls used by
39
39
  isolated preview subdomains.
40
+
41
+ - Date: 2026-05-02
42
+ Spec: plans/2026-05-02-agent-first-zero-to-codex-launch-plan.md
43
+ Relationship: Keeps the npm CLI runner protocol in parity with the legacy
44
+ runner for runtime session setting updates.
40
45
  */
41
46
  export type JsonValue =
42
47
  | null
@@ -133,6 +138,7 @@ export type KandanControl =
133
138
  readonly type: "start_instance";
134
139
  readonly instanceId?: string;
135
140
  readonly cwd?: string;
141
+ readonly workDescription?: string;
136
142
  readonly launchTui?: boolean;
137
143
  readonly model?: string;
138
144
  readonly reasoningEffort?: string;
@@ -181,6 +187,16 @@ export type KandanControl =
181
187
  readonly requestId: string;
182
188
  readonly decision: "approve" | "deny";
183
189
  }
190
+ | {
191
+ readonly type: "update_session_settings";
192
+ readonly instanceId?: string;
193
+ readonly threadId: string;
194
+ readonly model?: string | null;
195
+ readonly reasoningEffort?: string | null;
196
+ readonly approvalPolicy?: string | null;
197
+ readonly sandbox?: string | null;
198
+ readonly fast?: boolean;
199
+ }
184
200
  | {
185
201
  readonly type: "resolve_port_forward_request";
186
202
  readonly instanceId: string;
package/src/runner.ts CHANGED
@@ -60,7 +60,6 @@ import { join } from "node:path";
60
60
  import { attachChannelSession } from "./channelSession";
61
61
  import { connectCodexAppServer, startCodexAppServer } from "./codexAppServer";
62
62
  import { arrayValue, integerValue, objectValue, stringValue } from "./json";
63
- import { connectForwardTunnel } from "./forwardTunnel";
64
63
  import { resolveAllowedCwd } from "./localCapabilities";
65
64
  import {
66
65
  createForwardWebSocketManager,
@@ -82,6 +81,7 @@ import {
82
81
  import { connectPhoenixClient } from "./phoenix";
83
82
  import {
84
83
  type JsonObject,
84
+ type JsonRpcResponse,
85
85
  type JsonValue,
86
86
  type KandanChannelSessionOptions,
87
87
  type KandanControl,
@@ -183,8 +183,6 @@ async function openLocalCodexRunner(
183
183
  allowedPorts: Array.from(liveForwardPorts).sort(
184
184
  (left, right) => left - right,
185
185
  ),
186
- streamingForwarding: true,
187
- streamingForwardingVersion: 1,
188
186
  toolStatus:
189
187
  options.dependencyStatus === undefined
190
188
  ? null
@@ -209,18 +207,26 @@ async function openLocalCodexRunner(
209
207
  const joinPayload = (): JsonObject => ({
210
208
  clientName: "kandan-local-codex-runner",
211
209
  version: "0.0.1",
210
+ workspace: options.channelSession?.workspaceSlug ?? null,
211
+ channel: options.channelSession?.channelSlug ?? null,
212
212
  capabilities: capabilitiesPayload(),
213
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,
214
+
215
+ const pendingControls: KandanControl[] = [];
216
+ const controlDispatcher: {
217
+ value: ((control: KandanControl) => void) | undefined;
218
+ } = { value: undefined };
219
+ kandan.onControl((control) => {
220
+ const dispatcher = controlDispatcher.value;
221
+ if (dispatcher === undefined) {
222
+ pendingControls.push(control);
223
+ return;
224
+ }
225
+
226
+ dispatcher(control);
222
227
  });
223
- cleanup.actions.push(() => forwardTunnel.close());
228
+
229
+ await kandan.join(topic, joinPayload(), { rejoinPayload: joinPayload });
224
230
 
225
231
  const started =
226
232
  options.codexUrl === undefined
@@ -440,7 +446,7 @@ async function openLocalCodexRunner(
440
446
  channelSession?.handleCodexNotification(notification.method, params);
441
447
  });
442
448
 
443
- kandan.onControl((control) => {
449
+ const handleControl = (control: KandanControl) => {
444
450
  log("kandan.control", { control });
445
451
  if (!controlTargetsInstance(control, instanceId)) {
446
452
  log("kandan.control_ignored", {
@@ -593,7 +599,10 @@ async function openLocalCodexRunner(
593
599
  message: error instanceof Error ? error.message : String(error),
594
600
  });
595
601
  });
596
- });
602
+ };
603
+
604
+ controlDispatcher.value = handleControl;
605
+ pendingControls.splice(0).forEach(handleControl);
597
606
 
598
607
  return { instanceId, codexUrl, close };
599
608
  }
@@ -661,9 +670,25 @@ async function discoverCodexThreads(
661
670
  .filter((thread) => thread.id !== "");
662
671
  }
663
672
 
673
+ function extractStartedThreadId(response: JsonRpcResponse): string | undefined {
674
+ if ("error" in response) {
675
+ return undefined;
676
+ }
677
+
678
+ return stringValue(objectValue(objectValue(response.result)?.thread)?.id);
679
+ }
680
+
681
+ function normalizedWorkDescription(value: string | undefined): string | undefined {
682
+ const normalized = value?.trim();
683
+
684
+ return normalized === undefined || normalized === ""
685
+ ? undefined
686
+ : normalized;
687
+ }
688
+
664
689
  function makeRunnerLogger(options: RunnerOptions): RunnerLogger {
665
690
  return createRunnerLogger(
666
- options.logFile ?? join(options.cwd, ".kandan-local-codex-runner.log"),
691
+ options.logFile ?? join(options.cwd, ".linzumi-runner.log"),
667
692
  options.launchTui ? undefined : reportRunnerConsoleEvent,
668
693
  );
669
694
  }
@@ -808,6 +833,15 @@ async function applyControl(
808
833
  ...(control.sandbox === undefined ? {} : { sandbox: control.sandbox }),
809
834
  ...(control.fast === true ? { serviceTier: "fast" } : {}),
810
835
  });
836
+ const codexThreadId = extractStartedThreadId(response);
837
+ const workDescription = normalizedWorkDescription(control.workDescription);
838
+
839
+ if (codexThreadId !== undefined && workDescription !== undefined) {
840
+ await codex.request("turn/start", {
841
+ threadId: codexThreadId,
842
+ input: [{ type: "text", text: workDescription }],
843
+ });
844
+ }
811
845
 
812
846
  return {
813
847
  instanceId,