@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 +5 -7
- package/package.json +1 -1
- package/src/channelSessionSupport.ts +3 -3
- package/src/index.ts +45 -24
- package/src/phoenix.ts +17 -1
- package/src/runner.ts +24 -0
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.
|
|
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.
|
|
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.
|
|
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,
|
|
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
|
@@ -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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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.
|
|
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.
|
|
147
|
+
process.stdout.write("linzumi 0.0.5-beta\n");
|
|
145
148
|
process.exit(0);
|
|
146
149
|
}
|
|
147
150
|
|
|
148
|
-
const
|
|
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:
|
|
155
|
-
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 =
|
|
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>
|
|
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,
|
|
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 --
|
|
330
|
-
linzumi connect --kandan-url wss://serve.kandanai.com --workspace linzumi --channel seans-playground --
|
|
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 --
|
|
334
|
-
linzumi connect --kandan-url ws://127.0.0.1:4160 --workspace default --channel seans-playground
|
|
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>
|
|
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;
|