@linzumi/cli 0.0.3-beta → 0.0.5-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 CHANGED
@@ -27,7 +27,7 @@ runner. It wraps the same local runner that was previously started with
27
27
  Install the exact beta version:
28
28
 
29
29
  ```bash
30
- npm install -g @linzumi/cli@0.0.3-beta
30
+ npm install -g @linzumi/cli@0.0.5-beta
31
31
  ```
32
32
 
33
33
  Or install the current beta tag:
@@ -47,7 +47,7 @@ linzumi --version
47
47
  Expected CLI output:
48
48
 
49
49
  ```bash
50
- linzumi 0.0.3-beta
50
+ linzumi 0.0.5-beta
51
51
  ```
52
52
 
53
53
  ## Prod Quick Start
@@ -55,19 +55,19 @@ linzumi 0.0.3-beta
55
55
  For the Linzumi workspace in prod, this is the first command to try:
56
56
 
57
57
  ```bash
58
- npm install -g @linzumi/cli@0.0.3-beta
58
+ npm install -g @linzumi/cli@0.0.5-beta
59
59
 
60
60
  linzumi connect \
61
61
  --kandan-url wss://serve.kandanai.com \
62
62
  --workspace linzumi \
63
63
  --channel seans-playground \
64
- --listen-user sean \
65
64
  --codex-bin codex \
66
65
  --launch-tui
67
66
  ```
68
67
 
69
68
  The runner handles OAuth itself. If the cached Kandan token is missing or
70
69
  rejected, it opens the OAuth flow and saves the refreshed auth cache.
70
+ By default it listens for replies from the authenticated Kandan user.
71
71
 
72
72
  For a more explicit launch that pins the Codex model, reasoning effort, and
73
73
  fast service tier:
@@ -77,7 +77,6 @@ linzumi connect \
77
77
  --kandan-url wss://serve.kandanai.com \
78
78
  --workspace linzumi \
79
79
  --channel seans-playground \
80
- --listen-user sean \
81
80
  --codex-bin codex \
82
81
  --model gpt-5.5 \
83
82
  --reasoning-effort low \
@@ -100,7 +99,6 @@ linzumi connect \
100
99
  --kandan-url wss://serve.kandanai.com \
101
100
  --workspace linzumi \
102
101
  --channel seans-playground \
103
- --listen-user sean \
104
102
  --codex-bin codex \
105
103
  --launch-tui
106
104
  ```
@@ -130,7 +128,7 @@ linzumi auth [auth options]
130
128
  --workspace <slug> Workspace slug, for example linzumi
131
129
  --channel <slug|w/c> Channel slug, or workspace/channel
132
130
  --kandan-thread-id <uuid> Resume an existing Kandan thread
133
- --listen-user <user|all> User whose replies are accepted, or all
131
+ --listen-user <user|all> User whose replies are accepted, default authenticated user
134
132
  --cwd <path> Working directory for Codex
135
133
  --codex-bin <path> Codex executable, default codex
136
134
  --model <name> Codex model
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@linzumi/cli",
3
- "version": "0.0.3-beta",
3
+ "version": "0.0.5-beta",
4
4
  "description": "Linzumi local Codex runner CLI.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -139,18 +139,18 @@ export function senderAllowed(
139
139
  event: Pick<KandanChatEvent, "actorSlug" | "actorUserId">,
140
140
  runnerIdentity: RunnerIdentity,
141
141
  ): boolean {
142
- const normalized = listenUser.trim();
142
+ const normalized = listenUser.trim().toLocaleLowerCase();
143
143
 
144
144
  if (normalized === "all") {
145
145
  return true;
146
146
  }
147
147
 
148
- if (event.actorSlug !== undefined && event.actorSlug === normalized) {
148
+ if (event.actorSlug !== undefined && event.actorSlug.toLocaleLowerCase() === normalized) {
149
149
  return true;
150
150
  }
151
151
 
152
152
  return (
153
- runnerIdentity.actorUsername === normalized &&
153
+ runnerIdentity.actorUsername?.toLocaleLowerCase() === normalized &&
154
154
  runnerIdentity.actorUserId !== undefined &&
155
155
  event.actorUserId === runnerIdentity.actorUserId
156
156
  );
package/src/index.ts CHANGED
@@ -18,6 +18,7 @@ import { randomUUID } from "node:crypto";
18
18
  import { runLocalCodexRunner, type RunnerOptions } from "./runner";
19
19
  import { writeCachedLocalRunnerToken } from "./authCache";
20
20
  import { resolveLocalRunnerToken } from "./authResolution";
21
+ import { identityFromAccessToken } from "./channelSessionSupport";
21
22
  import { acquireLocalRunnerTokenDetails } from "./oauth";
22
23
 
23
24
  type FlagDefinition = {
@@ -48,11 +49,13 @@ const flagDefinitions = new Map<string, FlagDefinition>([
48
49
  ["help", { kind: "boolean" }],
49
50
  ]);
50
51
 
51
- try {
52
- await main(process.argv.slice(2));
53
- } catch (error) {
54
- process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
55
- process.exit(1);
52
+ if (import.meta.main) {
53
+ try {
54
+ await main(process.argv.slice(2));
55
+ } catch (error) {
56
+ process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
57
+ process.exit(1);
58
+ }
56
59
  }
57
60
 
58
61
  async function main(args: readonly string[]): Promise<void> {
@@ -63,7 +66,7 @@ async function main(args: readonly string[]): Promise<void> {
63
66
  process.stdout.write(connectGuideText());
64
67
  return;
65
68
  case "version":
66
- process.stdout.write("linzumi 0.0.3-beta\n");
69
+ process.stdout.write("linzumi 0.0.5-beta\n");
67
70
  return;
68
71
  case "auth":
69
72
  await runAuthCommand(parsed.args);
@@ -132,7 +135,7 @@ async function runAuthCommand(args: readonly string[]): Promise<void> {
132
135
  process.stdout.write(`Saved Kandan local runner auth for ${cached.kandanBaseUrl}\n`);
133
136
  }
134
137
 
135
- async function parseRunnerArgs(args: readonly string[]): Promise<RunnerOptions> {
138
+ export async function parseRunnerArgs(args: readonly string[]): Promise<RunnerOptions> {
136
139
  const values = strictFlagValues(args);
137
140
 
138
141
  if (values.get("help") === true) {
@@ -141,24 +144,25 @@ async function parseRunnerArgs(args: readonly string[]): Promise<RunnerOptions>
141
144
  }
142
145
 
143
146
  if (values.get("version") === true) {
144
- process.stdout.write("linzumi 0.0.3-beta\n");
147
+ process.stdout.write("linzumi 0.0.5-beta\n");
145
148
  process.exit(0);
146
149
  }
147
150
 
148
- const channelSession = parseChannelSession(values);
151
+ const channelTarget = parseChannelSessionTarget(values);
149
152
  const kandanUrl = required(values, "kandan-url");
150
153
  const explicitToken = stringValue(values, "token");
151
154
  const token = await resolveLocalRunnerToken({
152
155
  kandanUrl,
153
156
  explicitToken,
154
- workspaceSlug: channelSession?.workspaceSlug,
155
- channelSlug: channelSession?.channelSlug,
157
+ workspaceSlug: channelTarget?.workspaceSlug,
158
+ channelSlug: channelTarget?.channelSlug,
156
159
  authFilePath: stringValue(values, "auth-file"),
157
160
  callbackHost: stringValue(values, "oauth-callback-host"),
158
161
  reportRejectedCachedToken: () => {
159
162
  process.stderr.write("Cached Kandan local runner auth was rejected; starting OAuth.\n");
160
163
  },
161
164
  });
165
+ const channelSession = parseChannelSession(values, token, channelTarget);
162
166
 
163
167
  return {
164
168
  kandanUrl,
@@ -210,15 +214,16 @@ function strictFlagValues(args: readonly string[]): Map<string, string | true> {
210
214
  }
211
215
 
212
216
  function parseChannelSession(
213
- values: Map<string, string | true>
217
+ values: Map<string, string | true>,
218
+ token: string,
219
+ target: { readonly workspaceSlug: string; readonly channelSlug: string } | undefined,
214
220
  ): RunnerOptions["channelSession"] {
215
- const target = parseOptionalChannelTarget(values);
216
-
217
221
  if (target === undefined) {
218
222
  return undefined;
219
223
  }
220
224
 
221
- const listenUser = required(values, "listen-user");
225
+ const listenUser =
226
+ stringValue(values, "listen-user") ?? defaultListenUserFromToken(token);
222
227
 
223
228
  return {
224
229
  workspaceSlug: target.workspaceSlug,
@@ -232,6 +237,22 @@ function parseChannelSession(
232
237
  };
233
238
  }
234
239
 
240
+ function parseChannelSessionTarget(
241
+ values: Map<string, string | true>
242
+ ): { readonly workspaceSlug: string; readonly channelSlug: string } | undefined {
243
+ return parseOptionalChannelTarget(values);
244
+ }
245
+
246
+ function defaultListenUserFromToken(token: string): string {
247
+ const username = identityFromAccessToken(token).actorUsername;
248
+
249
+ if (username !== undefined) {
250
+ return username;
251
+ }
252
+
253
+ throw new Error("missing --listen-user and authenticated user is unavailable");
254
+ }
255
+
235
256
  function parseOptionalChannelTarget(
236
257
  values: Map<string, string | true>
237
258
  ): { readonly workspaceSlug: string; readonly channelSlug: string } | undefined {
@@ -297,7 +318,7 @@ function helpText(): string {
297
318
 
298
319
  Usage:
299
320
  linzumi
300
- linzumi connect --kandan-url <ws-url> --workspace <slug> --channel <slug> --listen-user <username|all> [options]
321
+ linzumi connect --kandan-url <ws-url> --workspace <slug> --channel <slug> [options]
301
322
  linzumi auth --kandan-url <ws-url> [--workspace <slug> --channel <slug>]
302
323
 
303
324
  Required:
@@ -310,7 +331,7 @@ Channel binding:
310
331
  --workspace <slug> Workspace slug
311
332
  --channel <slug|w/c> Channel slug, or workspace/channel
312
333
  --kandan-thread-id <uuid> Resume an existing Kandan thread instead of announcing a new root
313
- --listen-user <user|all> User whose replies are accepted, or all
334
+ --listen-user <user|all> User whose replies are accepted, default authenticated user
314
335
 
315
336
  Codex:
316
337
  --cwd <path> Working directory for Codex, default current directory
@@ -326,17 +347,17 @@ Codex:
326
347
 
327
348
  Examples:
328
349
  Good:
329
- linzumi connect --kandan-url wss://serve.kandanai.com --workspace linzumi --channel seans-playground --listen-user sean --codex-bin codex --launch-tui
330
- linzumi connect --kandan-url wss://serve.kandanai.com --workspace linzumi --channel seans-playground --listen-user sean --codex-bin codex --model gpt-5.5 --reasoning-effort low --fast --launch-tui
350
+ linzumi connect --kandan-url wss://serve.kandanai.com --workspace linzumi --channel seans-playground --codex-bin codex --launch-tui
351
+ linzumi connect --kandan-url wss://serve.kandanai.com --workspace linzumi --channel seans-playground --codex-bin codex --model gpt-5.5 --reasoning-effort low --fast --launch-tui
331
352
  linzumi auth --kandan-url ws://127.0.0.1:4160 --workspace default --channel seans-playground
332
353
  linzumi auth --kandan-url ws://100.71.192.98:4160 --oauth-callback-host 100.71.192.98 --workspace default --channel seans-playground
333
- linzumi connect --kandan-url ws://127.0.0.1:4160 --token "$TOKEN" --workspace default --channel seans-playground --listen-user sean --cwd /tmp/kandan-runner-a
334
- linzumi connect --kandan-url ws://127.0.0.1:4160 --workspace default --channel seans-playground --listen-user sean
354
+ linzumi connect --kandan-url ws://127.0.0.1:4160 --token "$TOKEN" --workspace default --channel seans-playground --cwd /tmp/kandan-runner-a
355
+ linzumi connect --kandan-url ws://127.0.0.1:4160 --workspace default --channel seans-playground
335
356
  linzumi connect --kandan-url ws://127.0.0.1:4160 --token "$TOKEN" --channel default/seans-playground --listen-user all --launch-tui
336
357
 
337
358
  Bad:
338
- linzumi connect --kandan-url ws://127.0.0.1:4160 --workspace default --channel seans-playground
339
- Missing --listen-user.
359
+ linzumi connect --kandan-url ws://127.0.0.1:4160 --token not-a-jwt --workspace default --channel seans-playground
360
+ Missing --listen-user and authenticated user is unavailable.
340
361
  linzumi connect --kandan-url ws://127.0.0.1:4160 --token "$TOKEN" --listen-users sean
341
362
  Invalid flag: use --listen-user.
342
363
  `;
@@ -348,7 +369,7 @@ function connectGuideText(): string {
348
369
  Connect this computer to Kandan as a local Codex runner.
349
370
 
350
371
  Use:
351
- linzumi connect --kandan-url <ws-url> --workspace <slug> --channel <slug> --listen-user <username|all> [options]
372
+ linzumi connect --kandan-url <ws-url> --workspace <slug> --channel <slug> [options]
352
373
 
353
374
  For help:
354
375
  linzumi connect --help
package/src/phoenix.ts CHANGED
@@ -13,6 +13,11 @@
13
13
  Spec: plans/2026-04-24-local-codex-runner-deep-quality-spec.md
14
14
  Relationship: Treats the Kandan websocket as a reconnectable transport so
15
15
  server restarts do not terminate the durable local Codex session.
16
+
17
+ - Date: 2026-04-26
18
+ Spec: plans/2026_04_26_linzumi_cli_review_followups_note.md
19
+ Relationship: Treats non-ok Phoenix replies for runner pushes as transport
20
+ failures so failed Kandan writes cannot be mistaken for synchronized state.
16
21
  */
17
22
  import {
18
23
  type JsonObject,
@@ -23,6 +28,7 @@ import {
23
28
  } from "./protocol";
24
29
 
25
30
  type PendingPush = {
31
+ readonly event: string;
26
32
  readonly resolve: (payload: JsonValue) => void;
27
33
  readonly reject: (error: Error) => void;
28
34
  };
@@ -112,6 +118,8 @@ export async function connectPhoenixClient(
112
118
 
113
119
  if (name === "phx_error") {
114
120
  pendingPush.reject(new Error("phoenix push failed"));
121
+ } else if (isNonOkPushReply(payload) && pendingPush.event !== "phx_join") {
122
+ pendingPush.reject(new Error(`phoenix push failed: ${replyErrorMessage(payload)}`));
115
123
  } else {
116
124
  pendingPush.resolve(payload);
117
125
  }
@@ -143,7 +151,7 @@ export async function connectPhoenixClient(
143
151
  const frame: PhoenixFrame = [null, ref, topic, event, payload];
144
152
 
145
153
  return new Promise((resolve, reject) => {
146
- pending.set(ref, { resolve, reject });
154
+ pending.set(ref, { event, resolve, reject });
147
155
  websocket.send(JSON.stringify(frame));
148
156
  });
149
157
  };
@@ -304,6 +312,10 @@ function isJoinReply(value: JsonValue): value is {
304
312
  }
305
313
 
306
314
  function joinErrorMessage(value: JsonValue): string {
315
+ return replyErrorMessage(value);
316
+ }
317
+
318
+ function replyErrorMessage(value: JsonValue): string {
307
319
  if (!isJsonObject(value)) {
308
320
  return "invalid reply";
309
321
  }
@@ -321,6 +333,10 @@ function joinErrorMessage(value: JsonValue): string {
321
333
  return "unknown";
322
334
  }
323
335
 
336
+ function isNonOkPushReply(value: JsonValue): boolean {
337
+ return isJsonObject(value) && value.status !== "ok";
338
+ }
339
+
324
340
  function isKandanControl(value: JsonValue): value is KandanControl {
325
341
  return isJsonObject(value) && typeof value.type === "string";
326
342
  }
package/src/runner.ts CHANGED
@@ -30,6 +30,11 @@
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.
33
38
  */
34
39
  import { spawn, type ChildProcess } from "node:child_process";
35
40
  import { randomUUID } from "node:crypto";
@@ -270,6 +275,16 @@ async function openLocalCodexRunner(
270
275
 
271
276
  kandan.onControl(control => {
272
277
  log("kandan.control", { control });
278
+ if (!controlTargetsInstance(control, instanceId)) {
279
+ log("kandan.control_ignored", {
280
+ reason: "instance_id_mismatch",
281
+ instanceId,
282
+ controlInstanceId: control.instanceId,
283
+ controlType: control.type,
284
+ });
285
+ return;
286
+ }
287
+
273
288
  void (channelSession?.handleControl(control) ?? Promise.resolve(undefined))
274
289
  .then(handled => {
275
290
  if (handled !== undefined) {
@@ -286,12 +301,21 @@ async function openLocalCodexRunner(
286
301
  instanceId,
287
302
  message: error instanceof Error ? error.message : String(error),
288
303
  });
304
+ })
305
+ .catch(error => {
306
+ log("kandan.control_response_push_failed", {
307
+ message: error instanceof Error ? error.message : String(error),
308
+ });
289
309
  });
290
310
  });
291
311
 
292
312
  return { instanceId, codexUrl, close };
293
313
  }
294
314
 
315
+ function controlTargetsInstance(control: KandanControl, instanceId: string): boolean {
316
+ return control.instanceId === undefined || control.instanceId === instanceId;
317
+ }
318
+
295
319
  async function closeCleanupStack(cleanup: CleanupStack): Promise<void> {
296
320
  if (cleanup.closePromise !== undefined) {
297
321
  return cleanup.closePromise;