@linzumi/cli 0.0.10-beta → 0.0.12-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 +92 -218
- package/package.json +2 -2
- package/src/channelSession.ts +14 -0
- package/src/index.ts +87 -6
- package/src/localCapabilities.ts +14 -1
- package/src/localConfig.ts +99 -0
- package/src/localEditor.ts +1 -1
- package/src/protocol.ts +1 -0
- package/src/runner.ts +28 -12
package/README.md
CHANGED
|
@@ -1,21 +1,29 @@
|
|
|
1
1
|
# Linzumi CLI
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
3
|
+
```text
|
|
4
|
+
▓▓╗ ▓▓╗▓▓▓╗ ▓▓╗▓▓▓▓▓▓▓╗▓▓╗ ▓▓╗▓▓▓╗ ▓▓▓╗▓▓╗
|
|
5
|
+
▓▓║ ▓▓║▓▓▓▓╗ ▓▓║╚══▓▓▓╔╝▓▓║ ▓▓║▓▓▓▓╗ ▓▓▓▓║▓▓║
|
|
6
|
+
▓▓║ ▓▓║▓▓╔▓▓╗ ▓▓║ ▓▓▓╔╝ ▓▓║ ▓▓║▓▓╔▓▓▓▓╔▓▓║▓▓║
|
|
7
|
+
▓▓║ ▓▓║▓▓║╚▓▓╗▓▓║ ▓▓▓╔╝ ▓▓║ ▓▓║▓▓║╚▓▓╔╝▓▓║▓▓║
|
|
8
|
+
▓▓▓▓▓▓▓░░░░░░░░╚▓▓▓▓║▓▓▓▓▓▓▓╗╚▓▓▓▓▓▓╔╝░░░░░░░░▓▓║▓▓║
|
|
9
|
+
╚═══▒▒▒@@@@@@@@▒▒═══╝╚══════╝ ╚════▒▒▒@@@@@@@@▒▒╝╚═╝
|
|
10
|
+
▒@@@@@@@@@@@@@@@@▒ ▒@@@@@@@@@@@@@@@@▒
|
|
11
|
+
@@@@@@@@@@@@@@@@@@@@@ @@@@@@@@@@@@@@@@@@@@@
|
|
12
|
+
@@@@@@@@@@@@@@@ @@@@@@@@@@@@@@@
|
|
13
|
+
║║ ║║
|
|
14
|
+
║║ ║║
|
|
15
|
+
║║ ()-().----. ║║
|
|
16
|
+
║║ \"/` ___ ;_________║║_.'
|
|
17
|
+
║║ ` ^^ ^^ ║║
|
|
18
|
+
──────────────╨╨─────────────··────··────────╨╨──────────────
|
|
19
|
+
multiplayer Codex, on your own code
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Linzumi turns a folder on your laptop into a shared workspace. You and your teammates open the same browser app, point AI coding agents at the real code on your machines, and watch each other work — no cloud VM, no pushing a branch just to show someone a preview.
|
|
23
|
+
|
|
24
|
+
Your code, your terminal, and your dev server stay on your laptop. Linzumi handles sign-in, sharing controls, and the secure browser link your teammates use to join you.
|
|
25
|
+
|
|
26
|
+
## Quick start
|
|
19
27
|
|
|
20
28
|
Use Chrome, Edge, Arc, Brave, or another Chromium-based browser.
|
|
21
29
|
|
|
@@ -24,191 +32,99 @@ npm install -g @linzumi/cli@beta
|
|
|
24
32
|
linzumi start ~/code/my-app
|
|
25
33
|
```
|
|
26
34
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
```text
|
|
30
|
-
Explain this project, open the editor, and tell me how to run it.
|
|
31
|
-
```
|
|
32
|
-
|
|
33
|
-
If the project has a dev server, start it locally:
|
|
34
|
-
|
|
35
|
-
```bash
|
|
36
|
-
npm run dev
|
|
37
|
-
```
|
|
38
|
-
|
|
39
|
-
Open the forwarded preview from Kandan. You should get a normal HTTPS URL that
|
|
40
|
-
you can share with an approved teammate, without exposing a raw `localhost`
|
|
41
|
-
address.
|
|
35
|
+
That is it. Here is what happens next:
|
|
42
36
|
|
|
43
|
-
|
|
37
|
+
1. Your browser opens to Linzumi.
|
|
38
|
+
2. Sign in (or sign up — one click).
|
|
39
|
+
3. Linzumi asks if it can connect to this computer. Click allow.
|
|
40
|
+
4. Your computer shows up as available in your workspace.
|
|
41
|
+
5. Type something in chat — like *"Explain this project and tell me how to run it."* — and Codex picks it up on your machine.
|
|
44
42
|
|
|
45
|
-
|
|
46
|
-
npm install -g @linzumi/cli@0.0.10-beta
|
|
47
|
-
linzumi start ~/code/my-app
|
|
48
|
-
```
|
|
43
|
+
The rest of this README is detail.
|
|
49
44
|
|
|
50
|
-
## What
|
|
45
|
+
## What you can do with it
|
|
51
46
|
|
|
52
|
-
|
|
47
|
+
- **Onboard yourself to a new repo.** Ask Codex to map the project for you — what it does, where to start reading, how to run it.
|
|
48
|
+
- **Make a real change without context-switching.** Codex edits the files on your disk. You watch the diff land in chat, or jump into the browser editor (it is VS Code, in your browser, pointed at your folder).
|
|
49
|
+
- **Show someone what is on your screen.** Open the browser editor or share your local dev server through a normal HTTPS link. No exposing `localhost` to the internet, no copy-pasting IPs.
|
|
50
|
+
- **Work with a teammate on the same code.** They join your channel, see the same threads, and can start their own Codex run on their own machine.
|
|
53
51
|
|
|
54
|
-
|
|
55
|
-
2. You can open a browser editor for that local folder.
|
|
56
|
-
3. Local app previews are reachable through Kandan HTTPS URLs.
|
|
57
|
-
4. Approved teammates can join the editor or preview.
|
|
58
|
-
5. Kandan enforces auth, sharing grants, and allowed-port policy.
|
|
52
|
+
## Working as a team
|
|
59
53
|
|
|
60
|
-
|
|
61
|
-
to work.
|
|
54
|
+
This is the part that makes Linzumi different from running an AI coding agent alone in a terminal.
|
|
62
55
|
|
|
63
|
-
|
|
56
|
+
- **Your computers are always in sight.** Every machine you have run `linzumi start` on shows up as a runner in your workspace. From any channel you are in, you can see how many of your runners are reachable right now and which ones are listening on that channel.
|
|
64
57
|
|
|
65
|
-
|
|
58
|
+
- **Put Codex on the job from the channel.** Pick an available runner and trusted folder from the channel menu, enter what Codex should work on, and start the session. Linzumi asks that runner to attach a fresh Codex to the folder you picked, using the runner, folder, and Codex settings shown in the menu.
|
|
66
59
|
|
|
67
|
-
-
|
|
68
|
-
- download the Kandan-approved editor runtime
|
|
69
|
-
- start code-server for the folder you chose
|
|
70
|
-
- connect to explicitly approved local ports
|
|
71
|
-
- stream editor and preview traffic through Kandan
|
|
60
|
+
- **One Codex per thread.** Once a Codex picks up a thread, that thread belongs to it — no second Codex can step in and trample the work. (A future release will allow handing a thread off; for now the lock holds for the life of the thread.)
|
|
72
61
|
|
|
73
|
-
|
|
62
|
+
- **Browse what your computers have been up to.** Open the runners dropdown and you see, across all your devices, the most recently active threads — each with its title and a short AI-written summary of what is happening. Pop into any of them and keep working.
|
|
74
63
|
|
|
75
|
-
-
|
|
76
|
-
- local-runner status
|
|
77
|
-
- editor and preview links
|
|
78
|
-
- sharing controls
|
|
79
|
-
- HTTPS termination
|
|
80
|
-
- access checks before traffic reaches your machine
|
|
64
|
+
- **Chat with humans without waking up Codex.** Start a message with `&` and Codex will not see it. Use it for side conversations with teammates inside a thread, without nudging the agent.
|
|
81
65
|
|
|
82
|
-
|
|
83
|
-
means your computer, not the Kandan server.
|
|
84
|
-
|
|
85
|
-
## Requirements
|
|
86
|
-
|
|
87
|
-
Install these before running the beta:
|
|
66
|
+
## Pin a specific version
|
|
88
67
|
|
|
89
68
|
```bash
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
bun --version
|
|
93
|
-
codex --version
|
|
94
|
-
```
|
|
95
|
-
|
|
96
|
-
Expected:
|
|
97
|
-
|
|
98
|
-
- Node.js 20 or newer
|
|
99
|
-
- npm
|
|
100
|
-
- Bun 1.2 or newer
|
|
101
|
-
- Codex CLI
|
|
102
|
-
- Chrome, Edge, Arc, Brave, or another Chromium-based browser
|
|
103
|
-
|
|
104
|
-
Safari is semi-supported for now. Use Chromium for the best editor and
|
|
105
|
-
collaboration behavior.
|
|
106
|
-
|
|
107
|
-
## First Run
|
|
108
|
-
|
|
109
|
-
```bash
|
|
110
|
-
linzumi start ~/code/my-app
|
|
69
|
+
npm install -g @linzumi/cli@0.0.12-beta
|
|
70
|
+
linzumi --version
|
|
111
71
|
```
|
|
112
72
|
|
|
113
|
-
What
|
|
114
|
-
|
|
115
|
-
1. The CLI opens Kandan.
|
|
116
|
-
2. You sign up or sign in.
|
|
117
|
-
3. Kandan asks permission to connect this computer.
|
|
118
|
-
4. The CLI stores a scoped local-runner token.
|
|
119
|
-
5. The CLI checks Bun, Codex, and the Kandan editor runtime.
|
|
120
|
-
6. Kandan shows the computer as connected.
|
|
121
|
-
|
|
122
|
-
The first editor launch can download a Kandan-approved runtime archive. Later
|
|
123
|
-
runs reuse the verified runtime from:
|
|
124
|
-
|
|
125
|
-
```text
|
|
126
|
-
~/.linzumi/editor-runtimes
|
|
127
|
-
```
|
|
73
|
+
## What You Get
|
|
128
74
|
|
|
129
|
-
|
|
130
|
-
publishes a checksummed runtime manifest, and the CLI only advertises editor
|
|
131
|
-
readiness after that runtime is verified locally.
|
|
75
|
+
Run an AI coding session on the code that is already on your computer. Linzumi is for the moments when you do not want to move the project into a cloud VM just to let teammates watch Codex work, open the browser editor, or review a forwarded local preview.
|
|
132
76
|
|
|
133
77
|
## Good Things To Try
|
|
134
78
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
What does this app do? Where should I start reading?
|
|
139
|
-
```
|
|
140
|
-
|
|
141
|
-
Ask Codex to make a small change:
|
|
142
|
-
|
|
143
|
-
```text
|
|
144
|
-
Find the main settings page and add a clear empty state.
|
|
145
|
-
```
|
|
79
|
+
- Start in a repo and ask Codex to explain how to run it.
|
|
80
|
+
- Open the Codex editor from the runner controls once the folder is trusted.
|
|
81
|
+
- Forward a local dev server and share the preview with a teammate.
|
|
146
82
|
|
|
147
|
-
|
|
83
|
+
## Trusted folders
|
|
148
84
|
|
|
149
|
-
|
|
85
|
+
Linzumi only starts Codex or opens the Codex editor inside folders you trust. The default trusted folder list lives at `~/.linzumi/config.json`.
|
|
150
86
|
|
|
151
87
|
```bash
|
|
152
|
-
|
|
88
|
+
linzumi paths list
|
|
89
|
+
linzumi paths add ~/code/my-app
|
|
90
|
+
linzumi paths remove ~/code/my-app
|
|
153
91
|
```
|
|
154
92
|
|
|
155
|
-
|
|
93
|
+
`linzumi connect` uses those trusted folders by default. Pass `--allowed-cwd <paths>` when you want one runner process to use an explicit comma-separated folder list instead of `~/.linzumi/config.json`.
|
|
156
94
|
|
|
157
|
-
|
|
158
|
-
linzumi start ~/code/my-app --forward-port 3000
|
|
159
|
-
```
|
|
95
|
+
## What you need installed
|
|
160
96
|
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
97
|
+
- Node.js 20 or newer
|
|
98
|
+
- Bun 1.2 or newer
|
|
99
|
+
- The Codex CLI
|
|
100
|
+
- A Chromium-based browser (Chrome, Edge, Arc, or Brave)
|
|
164
101
|
|
|
165
|
-
|
|
102
|
+
Safari is semi-supported. Live editing and live collaboration are smoother in Chromium.
|
|
166
103
|
|
|
167
|
-
|
|
104
|
+
## If something looks wrong
|
|
168
105
|
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
106
|
+
- **`linzumi: command not found`** — your global npm bin folder is not on `PATH`. Run `npm prefix -g` and add the `bin` folder under that path to your shell `PATH`.
|
|
107
|
+
- **`bun: command not found`** — install Bun, then rerun `linzumi start`.
|
|
108
|
+
- **`codex: command not found`** — install or configure the Codex CLI, or pass `--codex-bin <path>` to `linzumi start`.
|
|
109
|
+
- **The browser sign-in opens but never returns to the CLI** — your browser cannot reach the address the CLI is listening on. Pass `--oauth-callback-host <ip-or-host-your-browser-can-reach>`.
|
|
110
|
+
- **The browser editor never says it is ready** — rerun `linzumi start` and watch the console for the editor download step. Slow networks can make the very first launch take a minute or two.
|
|
111
|
+
- **Collaboration looks off in Safari** — switch to a Chromium browser for now.
|
|
172
112
|
|
|
173
|
-
|
|
174
|
-
flags. The CLI derives the HTTPS API origin from the websocket URL for OAuth,
|
|
175
|
-
runtime manifest download, and runtime archive download.
|
|
113
|
+
## Advanced
|
|
176
114
|
|
|
177
|
-
|
|
115
|
+
Most people never need anything in this section.
|
|
178
116
|
|
|
179
|
-
|
|
180
|
-
KANDAN_TLS_CA_FILE=/path/to/ca.crt linzumi start ~/code/my-app \
|
|
181
|
-
--kandan-url wss://linzumi.io:4140
|
|
182
|
-
```
|
|
183
|
-
|
|
184
|
-
## Tailscale Development
|
|
185
|
-
|
|
186
|
-
If your browser needs to reach a local Kandan server through Tailscale:
|
|
117
|
+
### Point at a self-hosted Linzumi
|
|
187
118
|
|
|
188
119
|
```bash
|
|
189
|
-
linzumi start ~/code/my-app
|
|
190
|
-
--kandan-url ws://100.71.192.98:4162 \
|
|
191
|
-
--oauth-callback-host 100.71.192.98
|
|
120
|
+
linzumi start ~/code/my-app --kandan-url wss://your-host
|
|
192
121
|
```
|
|
193
122
|
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
## Commands
|
|
197
|
-
|
|
198
|
-
```bash
|
|
199
|
-
linzumi
|
|
200
|
-
linzumi --help
|
|
201
|
-
linzumi --version
|
|
202
|
-
linzumi start <folder>
|
|
203
|
-
linzumi connect --help
|
|
204
|
-
linzumi connect [runner options]
|
|
205
|
-
linzumi auth [auth options]
|
|
206
|
-
```
|
|
123
|
+
Public TLS hosts work without extra flags. For local development against a private CA, set `KANDAN_TLS_CA_FILE` to your CA bundle and pass `--kandan-url` as usual.
|
|
207
124
|
|
|
208
|
-
|
|
125
|
+
### Lower-level connect
|
|
209
126
|
|
|
210
|
-
`linzumi
|
|
211
|
-
workspace and channel:
|
|
127
|
+
`linzumi start` is what you want almost every time. The lower-level form, useful when you already know your workspace and channel:
|
|
212
128
|
|
|
213
129
|
```bash
|
|
214
130
|
linzumi connect \
|
|
@@ -218,71 +134,29 @@ linzumi connect \
|
|
|
218
134
|
--cwd ~/code/my-app
|
|
219
135
|
```
|
|
220
136
|
|
|
221
|
-
|
|
137
|
+
### All the flags
|
|
222
138
|
|
|
223
|
-
```
|
|
224
|
-
--kandan-url <ws-url>
|
|
225
|
-
--oauth-callback-host <ip>
|
|
139
|
+
```text
|
|
140
|
+
--kandan-url <ws-url> Linzumi base URL (defaults to the hosted service)
|
|
141
|
+
--oauth-callback-host <ip> Sign-in callback host your browser can reach
|
|
226
142
|
--runner-id <id> Stable id for this computer
|
|
227
|
-
--codex-bin <path> Codex executable, default codex
|
|
228
|
-
--model <name> Codex model metadata shown in
|
|
229
|
-
--reasoning-effort <value> Codex reasoning metadata shown in
|
|
143
|
+
--codex-bin <path> Codex executable, default `codex`
|
|
144
|
+
--model <name> Codex model metadata shown in Linzumi
|
|
145
|
+
--reasoning-effort <value> Codex reasoning metadata shown in Linzumi
|
|
230
146
|
--fast Mark this runner as low-latency
|
|
231
|
-
--forward-port <ports> Comma-separated local ports
|
|
232
|
-
--allowed-cwd <paths>
|
|
147
|
+
--forward-port <ports> Comma-separated local ports Linzumi may share
|
|
148
|
+
--allowed-cwd <paths> Override ~/.linzumi/config.json with comma-separated trusted roots
|
|
233
149
|
--log-file <path> JSONL runner event log
|
|
234
150
|
```
|
|
235
151
|
|
|
236
|
-
|
|
237
|
-
supported production editor path.
|
|
238
|
-
|
|
239
|
-
## Troubleshooting
|
|
240
|
-
|
|
241
|
-
Check the CLI version:
|
|
242
|
-
|
|
243
|
-
```bash
|
|
244
|
-
linzumi --version
|
|
245
|
-
```
|
|
246
|
-
|
|
247
|
-
Expected:
|
|
248
|
-
|
|
249
|
-
```text
|
|
250
|
-
linzumi 0.0.10-beta
|
|
251
|
-
```
|
|
252
|
-
|
|
253
|
-
If `linzumi` is not found, your global npm bin directory is not on `PATH`.
|
|
254
|
-
|
|
255
|
-
If `bun` is not found, install Bun and rerun `linzumi start`.
|
|
152
|
+
### Tailscale
|
|
256
153
|
|
|
257
|
-
If
|
|
258
|
-
`--codex-bin`.
|
|
259
|
-
|
|
260
|
-
If OAuth opens but does not return to the CLI, pass an
|
|
261
|
-
`--oauth-callback-host` that your browser can reach.
|
|
262
|
-
|
|
263
|
-
If the editor does not become ready, do not install a local code-server package
|
|
264
|
-
as a workaround. The server-managed runtime must download and verify cleanly.
|
|
265
|
-
Rerun the CLI and check the runner log.
|
|
266
|
-
|
|
267
|
-
If collaboration behaves oddly in Safari, retry in Chromium. Safari is currently
|
|
268
|
-
semi-supported.
|
|
269
|
-
|
|
270
|
-
## For Kandan Release Engineers
|
|
271
|
-
|
|
272
|
-
The production contract is:
|
|
273
|
-
|
|
274
|
-
1. Build the server-approved editor runtime archive.
|
|
275
|
-
2. Publish the manifest and archive from Kandan.
|
|
276
|
-
3. Publish the CLI beta.
|
|
277
|
-
4. The CLI downloads only the approved runtime archive.
|
|
278
|
-
5. The CLI verifies the archive SHA before advertising editor readiness.
|
|
279
|
-
|
|
280
|
-
For local package verification:
|
|
154
|
+
If your browser reaches your local Linzumi server through Tailscale, pass both your Tailscale IP for the URL and for the sign-in callback:
|
|
281
155
|
|
|
282
156
|
```bash
|
|
283
|
-
|
|
284
|
-
|
|
157
|
+
linzumi start ~/code/my-app \
|
|
158
|
+
--kandan-url ws://100.71.192.98:4162 \
|
|
159
|
+
--oauth-callback-host 100.71.192.98
|
|
285
160
|
```
|
|
286
161
|
|
|
287
|
-
|
|
288
|
-
runtime files.
|
|
162
|
+
Use your own Tailscale IP, not that one.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@linzumi/cli",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.12-beta",
|
|
4
4
|
"description": "Connect your computer to Kandan for local Codex sessions, editors, and forwarded previews",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -29,5 +29,5 @@
|
|
|
29
29
|
"node": ">=20",
|
|
30
30
|
"bun": ">=1.2.0"
|
|
31
31
|
},
|
|
32
|
-
"
|
|
32
|
+
"readmeFilename": "README.md"
|
|
33
33
|
}
|
package/src/channelSession.ts
CHANGED
|
@@ -1171,6 +1171,20 @@ async function handleKandanChatEvent(
|
|
|
1171
1171
|
return;
|
|
1172
1172
|
}
|
|
1173
1173
|
|
|
1174
|
+
if (event.body.trimStart().startsWith("&")) {
|
|
1175
|
+
args.log("kandan.message_ignored", {
|
|
1176
|
+
seq: event.seq,
|
|
1177
|
+
actor_slug: event.actorSlug ?? null,
|
|
1178
|
+
actor_user_id: event.actorUserId ?? null,
|
|
1179
|
+
reason: "human_only",
|
|
1180
|
+
});
|
|
1181
|
+
await publishKandanMessageState(args, event, {
|
|
1182
|
+
status: "ignored",
|
|
1183
|
+
reason: "human_only",
|
|
1184
|
+
});
|
|
1185
|
+
return;
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1174
1188
|
const portForwardDecision = parsePortForwardDecision(event.body);
|
|
1175
1189
|
if (portForwardDecision !== undefined) {
|
|
1176
1190
|
const result = await resolvePendingPortForwardRequest(args, state, payloadContext, {
|
package/src/index.ts
CHANGED
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
capability metadata without creating an implicit tunnel.
|
|
23
23
|
*/
|
|
24
24
|
import { randomUUID } from "node:crypto";
|
|
25
|
+
import { realpathSync } from "node:fs";
|
|
25
26
|
import { homedir } from "node:os";
|
|
26
27
|
import { resolve } from "node:path";
|
|
27
28
|
import { runLocalCodexRunner, type RunnerOptions } from "./runner";
|
|
@@ -30,9 +31,17 @@ import { resolveLocalRunnerToken } from "./authResolution";
|
|
|
30
31
|
import { identityFromAccessToken } from "./channelSessionSupport";
|
|
31
32
|
import {
|
|
32
33
|
assertConfiguredAllowedCwds,
|
|
34
|
+
expandUserPath,
|
|
33
35
|
parseAllowedCwdList,
|
|
34
36
|
parseAllowedPortList,
|
|
35
37
|
} from "./localCapabilities";
|
|
38
|
+
import {
|
|
39
|
+
addAllowedCwd,
|
|
40
|
+
localConfigPath,
|
|
41
|
+
readConfiguredAllowedCwds,
|
|
42
|
+
readLocalConfig,
|
|
43
|
+
removeAllowedCwd,
|
|
44
|
+
} from "./localConfig";
|
|
36
45
|
import {
|
|
37
46
|
acquireLocalRunnerTokenDetails,
|
|
38
47
|
fetchLocalRunnerStartTarget,
|
|
@@ -101,11 +110,14 @@ async function main(args: readonly string[]): Promise<void> {
|
|
|
101
110
|
process.stdout.write(connectGuideText());
|
|
102
111
|
return;
|
|
103
112
|
case "version":
|
|
104
|
-
process.stdout.write("linzumi 0.0.
|
|
113
|
+
process.stdout.write("linzumi 0.0.12-beta\n");
|
|
105
114
|
return;
|
|
106
115
|
case "auth":
|
|
107
116
|
await runAuthCommand(parsed.args);
|
|
108
117
|
return;
|
|
118
|
+
case "paths":
|
|
119
|
+
runPathsCommand(parsed.args);
|
|
120
|
+
return;
|
|
109
121
|
case "start": {
|
|
110
122
|
const options = await parseStartRunnerArgs(parsed.args);
|
|
111
123
|
await runLocalCodexRunner(options);
|
|
@@ -123,6 +135,7 @@ type ParsedCommand =
|
|
|
123
135
|
| { readonly command: "guide"; readonly args: readonly string[] }
|
|
124
136
|
| { readonly command: "version"; readonly args: readonly string[] }
|
|
125
137
|
| { readonly command: "auth"; readonly args: readonly string[] }
|
|
138
|
+
| { readonly command: "paths"; readonly args: readonly string[] }
|
|
126
139
|
| { readonly command: "start"; readonly args: readonly string[] }
|
|
127
140
|
| { readonly command: "run"; readonly args: readonly string[] };
|
|
128
141
|
|
|
@@ -143,6 +156,8 @@ function parseCommand(args: readonly string[]): ParsedCommand {
|
|
|
143
156
|
return { command: "run", args: ["--help"] };
|
|
144
157
|
case "auth":
|
|
145
158
|
return { command: "auth", args: rest };
|
|
159
|
+
case "paths":
|
|
160
|
+
return { command: "paths", args: rest };
|
|
146
161
|
case "start":
|
|
147
162
|
return { command: "start", args: rest };
|
|
148
163
|
case "run":
|
|
@@ -152,6 +167,53 @@ function parseCommand(args: readonly string[]): ParsedCommand {
|
|
|
152
167
|
}
|
|
153
168
|
}
|
|
154
169
|
|
|
170
|
+
function runPathsCommand(args: readonly string[]): void {
|
|
171
|
+
const [subcommand, pathValue, ...rest] = args;
|
|
172
|
+
|
|
173
|
+
if (subcommand === undefined || subcommand === "help" || subcommand === "--help") {
|
|
174
|
+
process.stdout.write(pathsHelpText());
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (rest.length > 0) {
|
|
179
|
+
throw new Error("linzumi paths accepts one path argument");
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
switch (subcommand) {
|
|
183
|
+
case "list": {
|
|
184
|
+
const config = readLocalConfig();
|
|
185
|
+
if (config.allowedCwds.length === 0) {
|
|
186
|
+
process.stdout.write(`No trusted paths configured in ${localConfigPath()}\n`);
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
process.stdout.write(`${config.allowedCwds.join("\n")}\n`);
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
case "add": {
|
|
194
|
+
if (pathValue === undefined || pathValue.trim() === "") {
|
|
195
|
+
throw new Error("missing path for linzumi paths add");
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const trustedPath = realpathSync(resolve(expandUserPath(pathValue)));
|
|
199
|
+
addAllowedCwd(pathValue);
|
|
200
|
+
process.stdout.write(`Trusted ${trustedPath}\n`);
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
case "remove": {
|
|
204
|
+
if (pathValue === undefined || pathValue.trim() === "") {
|
|
205
|
+
throw new Error("missing path for linzumi paths remove");
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
removeAllowedCwd(pathValue);
|
|
209
|
+
process.stdout.write(`Removed trusted path ${pathValue}\n`);
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
default:
|
|
213
|
+
throw new Error(`invalid paths command: ${subcommand}`);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
155
217
|
async function runAuthCommand(args: readonly string[]): Promise<void> {
|
|
156
218
|
const values = strictFlagValues(args);
|
|
157
219
|
|
|
@@ -210,7 +272,9 @@ export async function parseStartRunnerArgs(
|
|
|
210
272
|
|
|
211
273
|
const kandanUrl = stringValue(values, "kandan-url") ?? "wss://serve.kandanai.com";
|
|
212
274
|
const requestedCwd = resolveUserPath(cwdArg ?? process.cwd());
|
|
213
|
-
const allowedCwds =
|
|
275
|
+
const allowedCwds = values.has("allowed-cwd")
|
|
276
|
+
? assertConfiguredAllowedCwds(parseAllowedCwdList(stringValue(values, "allowed-cwd")))
|
|
277
|
+
: assertConfiguredAllowedCwds([requestedCwd]);
|
|
214
278
|
const cwd = allowedCwds[0] ?? requestedCwd;
|
|
215
279
|
const codexBin = stringValue(values, "codex-bin") ?? "codex";
|
|
216
280
|
const customCodeServerBin = stringValue(values, "code-server-bin");
|
|
@@ -347,7 +411,7 @@ export async function parseRunnerArgs(
|
|
|
347
411
|
}
|
|
348
412
|
|
|
349
413
|
if (values.get("version") === true) {
|
|
350
|
-
process.stdout.write("linzumi 0.0.
|
|
414
|
+
process.stdout.write("linzumi 0.0.12-beta\n");
|
|
351
415
|
process.exit(0);
|
|
352
416
|
}
|
|
353
417
|
|
|
@@ -395,9 +459,9 @@ export async function parseRunnerArgs(
|
|
|
395
459
|
launchTui: values.get("launch-tui") === true,
|
|
396
460
|
fast: values.get("fast") === true,
|
|
397
461
|
logFile: stringValue(values, "log-file"),
|
|
398
|
-
allowedCwds:
|
|
399
|
-
parseAllowedCwdList(stringValue(values, "allowed-cwd"))
|
|
400
|
-
|
|
462
|
+
allowedCwds: values.has("allowed-cwd")
|
|
463
|
+
? assertConfiguredAllowedCwds(parseAllowedCwdList(stringValue(values, "allowed-cwd")))
|
|
464
|
+
: readConfiguredAllowedCwds(),
|
|
401
465
|
allowedForwardPorts: parseAllowedPortList(
|
|
402
466
|
stringValue(values, "forward-port"),
|
|
403
467
|
),
|
|
@@ -647,6 +711,7 @@ function helpText(): string {
|
|
|
647
711
|
Usage:
|
|
648
712
|
linzumi
|
|
649
713
|
linzumi start <folder> [options]
|
|
714
|
+
linzumi paths list|add|remove [path]
|
|
650
715
|
linzumi connect --kandan-url <ws-url> --workspace <slug> --channel <slug> [options]
|
|
651
716
|
linzumi auth --kandan-url <ws-url> [--workspace <slug> --channel <slug>]
|
|
652
717
|
|
|
@@ -686,6 +751,8 @@ Examples:
|
|
|
686
751
|
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
|
|
687
752
|
linzumi auth --kandan-url ws://127.0.0.1:4160 --workspace default --channel seans-playground
|
|
688
753
|
linzumi auth --kandan-url ws://100.71.192.98:4160 --oauth-callback-host 100.71.192.98 --workspace default --channel seans-playground
|
|
754
|
+
linzumi paths add ~/code/linzumi
|
|
755
|
+
linzumi paths list
|
|
689
756
|
linzumi connect --kandan-url ws://127.0.0.1:4160 --token "$TOKEN" --workspace default --channel seans-playground --cwd /tmp/kandan-runner-a
|
|
690
757
|
linzumi connect --kandan-url ws://127.0.0.1:4160 --workspace default --channel seans-playground
|
|
691
758
|
linzumi connect --kandan-url ws://127.0.0.1:4160 --token "$TOKEN" --channel default/seans-playground --listen-user all --launch-tui
|
|
@@ -705,6 +772,19 @@ Examples:
|
|
|
705
772
|
`;
|
|
706
773
|
}
|
|
707
774
|
|
|
775
|
+
function pathsHelpText(): string {
|
|
776
|
+
return `Linzumi trusted paths
|
|
777
|
+
|
|
778
|
+
Usage:
|
|
779
|
+
linzumi paths list
|
|
780
|
+
linzumi paths add <path>
|
|
781
|
+
linzumi paths remove <path>
|
|
782
|
+
|
|
783
|
+
Trusted paths are stored in ~/.linzumi/config.json. linzumi connect uses them
|
|
784
|
+
unless --allowed-cwd is passed for that runner process.
|
|
785
|
+
`;
|
|
786
|
+
}
|
|
787
|
+
|
|
708
788
|
function startHelpText(): string {
|
|
709
789
|
return `Linzumi one-command local runner
|
|
710
790
|
|
|
@@ -748,6 +828,7 @@ This opens Kandan in your browser, creates or reuses your personal coding
|
|
|
748
828
|
space, and starts this computer as a local Codex runner.
|
|
749
829
|
|
|
750
830
|
Advanced:
|
|
831
|
+
linzumi paths add "$PWD"
|
|
751
832
|
linzumi connect
|
|
752
833
|
|
|
753
834
|
Connect this computer to Kandan as a local Codex runner.
|
package/src/localCapabilities.ts
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
forwarding is advertised only for explicitly configured local ports.
|
|
8
8
|
*/
|
|
9
9
|
import { realpathSync } from "node:fs";
|
|
10
|
+
import { homedir } from "node:os";
|
|
10
11
|
import { isAbsolute, relative, resolve } from "node:path";
|
|
11
12
|
|
|
12
13
|
export type CwdCapabilityDecision =
|
|
@@ -66,13 +67,25 @@ export function assertConfiguredAllowedCwds(
|
|
|
66
67
|
): string[] {
|
|
67
68
|
return paths.map((path) => {
|
|
68
69
|
try {
|
|
69
|
-
return realpathSync(resolve(path));
|
|
70
|
+
return realpathSync(resolve(expandUserPath(path)));
|
|
70
71
|
} catch (_error) {
|
|
71
72
|
throw new Error(`invalid --allowed-cwd: ${path} does not exist`);
|
|
72
73
|
}
|
|
73
74
|
});
|
|
74
75
|
}
|
|
75
76
|
|
|
77
|
+
export function expandUserPath(pathValue: string): string {
|
|
78
|
+
if (pathValue === "~") {
|
|
79
|
+
return homedir();
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (pathValue.startsWith("~/")) {
|
|
83
|
+
return resolve(homedir(), pathValue.slice(2));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return pathValue;
|
|
87
|
+
}
|
|
88
|
+
|
|
76
89
|
export function resolveAllowedCwd(
|
|
77
90
|
requestedCwd: string | undefined,
|
|
78
91
|
allowedRoots: readonly string[],
|
|
@@ -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
|
+
}
|
package/src/localEditor.ts
CHANGED
|
@@ -592,7 +592,7 @@ export function prepareLocalEditorCollaboration(
|
|
|
592
592
|
return undefined;
|
|
593
593
|
}
|
|
594
594
|
|
|
595
|
-
const targetPath = `/local-codex-
|
|
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 === ""
|
package/src/protocol.ts
CHANGED
|
@@ -133,6 +133,7 @@ export type KandanControl =
|
|
|
133
133
|
readonly type: "start_instance";
|
|
134
134
|
readonly instanceId?: string;
|
|
135
135
|
readonly cwd?: string;
|
|
136
|
+
readonly workDescription?: string;
|
|
136
137
|
readonly launchTui?: boolean;
|
|
137
138
|
readonly model?: string;
|
|
138
139
|
readonly reasoningEffort?: 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,11 @@ 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
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,
|
|
222
|
-
});
|
|
223
|
-
cleanup.actions.push(() => forwardTunnel.close());
|
|
224
215
|
|
|
225
216
|
const started =
|
|
226
217
|
options.codexUrl === undefined
|
|
@@ -661,6 +652,22 @@ async function discoverCodexThreads(
|
|
|
661
652
|
.filter((thread) => thread.id !== "");
|
|
662
653
|
}
|
|
663
654
|
|
|
655
|
+
function extractStartedThreadId(response: JsonRpcResponse): string | undefined {
|
|
656
|
+
if ("error" in response) {
|
|
657
|
+
return undefined;
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
return stringValue(objectValue(objectValue(response.result)?.thread)?.id);
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
function normalizedWorkDescription(value: string | undefined): string | undefined {
|
|
664
|
+
const normalized = value?.trim();
|
|
665
|
+
|
|
666
|
+
return normalized === undefined || normalized === ""
|
|
667
|
+
? undefined
|
|
668
|
+
: normalized;
|
|
669
|
+
}
|
|
670
|
+
|
|
664
671
|
function makeRunnerLogger(options: RunnerOptions): RunnerLogger {
|
|
665
672
|
return createRunnerLogger(
|
|
666
673
|
options.logFile ?? join(options.cwd, ".kandan-local-codex-runner.log"),
|
|
@@ -808,6 +815,15 @@ async function applyControl(
|
|
|
808
815
|
...(control.sandbox === undefined ? {} : { sandbox: control.sandbox }),
|
|
809
816
|
...(control.fast === true ? { serviceTier: "fast" } : {}),
|
|
810
817
|
});
|
|
818
|
+
const codexThreadId = extractStartedThreadId(response);
|
|
819
|
+
const workDescription = normalizedWorkDescription(control.workDescription);
|
|
820
|
+
|
|
821
|
+
if (codexThreadId !== undefined && workDescription !== undefined) {
|
|
822
|
+
await codex.request("turn/start", {
|
|
823
|
+
threadId: codexThreadId,
|
|
824
|
+
input: [{ type: "text", text: workDescription }],
|
|
825
|
+
});
|
|
826
|
+
}
|
|
811
827
|
|
|
812
828
|
return {
|
|
813
829
|
instanceId,
|