centralu 0.1.0-beta.10 → 0.1.0-beta.12

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/bin/centralu.mjs CHANGED
@@ -28,6 +28,7 @@ import {
28
28
  busyMessage,
29
29
  earlyExitMessage,
30
30
  executableIn,
31
+ hostDirIn,
31
32
  installedPaths,
32
33
  isBusyError,
33
34
  launchPlan,
@@ -40,7 +41,8 @@ import {
40
41
  webview2Status,
41
42
  windowsInstall,
42
43
  } from './platform.mjs'
43
- import { chmodSync, cpSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs'
44
+ import { parseServeArgs, printConnection, rotateToken, runServe, SERVE_HELP } from './serve.mjs'
45
+ import { chmodSync, cpSync, existsSync, mkdirSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync } from 'node:fs'
44
46
  import { createRequire } from 'node:module'
45
47
  import { homedir } from 'node:os'
46
48
  import { dirname, join } from 'node:path'
@@ -104,6 +106,37 @@ function explainMissing() {
104
106
  )
105
107
  }
106
108
 
109
+ /**
110
+ * The bundled host's entry (`resources/host/main.mjs`) for `centralu serve`, or exit with why not.
111
+ *
112
+ * `CENTRALU_HOST_ENTRY` points it at another host instead: a source checkout's
113
+ * `packages/agent-host/src/main.ts` while developing, or a freshly bundled `resources/host/main.mjs`.
114
+ * The tests start `serve` that way (tooling/launcher-serve.test.ts).
115
+ */
116
+ function requireHostEntry() {
117
+ if (process.env.CENTRALU_HOST_ENTRY) return process.env.CENTRALU_HOST_ENTRY
118
+ if (!TARGET) {
119
+ console.error(explainMissing())
120
+ process.exit(1)
121
+ }
122
+ let root
123
+ try {
124
+ root = dirname(require.resolve(`${TARGET.pkg}/package.json`))
125
+ } catch {
126
+ console.error(explainMissing())
127
+ process.exit(1)
128
+ }
129
+ const entry = join(hostDirIn(PLATFORM, join(root, TARGET.artifact)), 'main.mjs')
130
+ if (!existsSync(entry)) {
131
+ console.error(
132
+ `The host is missing from ${TARGET.pkg} (looked for ${entry}).\n` +
133
+ 'Linux packages published before `centralu serve` existed do not carry it. Update with `npm i -g centralu`.',
134
+ )
135
+ process.exit(1)
136
+ }
137
+ return entry
138
+ }
139
+
107
140
  function requireApp() {
108
141
  const app = bundledApp()
109
142
  if (!app) {
@@ -484,6 +517,8 @@ const HELP = `${APP_NAME} ${pkg.version}
484
517
  Windows: %LOCALAPPDATA%\\Programs\\Centralu and a Start menu shortcut)
485
518
  centralu uninstall Removes that registration (conversation history is kept)
486
519
  centralu update Upgrades if a new version is available
520
+ centralu serve Runs the host alone on this machine, for the app on another computer
521
+ to reach over SSH (\`centralu serve --help\`)
487
522
  centralu --version Prints the version
488
523
 
489
524
  Requires: Node 22+, and the claude or codex CLI (the app's first screen reports the status)`
@@ -499,6 +534,33 @@ switch (cmd) {
499
534
  case 'update':
500
535
  await update()
501
536
  break
537
+ case 'serve': {
538
+ // No update notice here: `--connection`'s stdout is read by a program, and a foreground
539
+ // server's terminal is a log.
540
+ const args = parseServeArgs(rest)
541
+ if (args.error) {
542
+ console.error(args.error)
543
+ process.exit(2)
544
+ }
545
+ if (args.mode === 'help') {
546
+ console.log(SERVE_HELP)
547
+ break
548
+ }
549
+ try {
550
+ if (args.mode === 'rotate') {
551
+ process.exitCode = rotateToken({ env: process.env, home: HOME })
552
+ break
553
+ }
554
+ // realpath: npm's bin is a symlink to this file, and the launcher must not depend on the link
555
+ const opts = { env: process.env, home: HOME, entry: requireHostEntry(), version: pkg.version, cliPath: realpathSync(process.argv[1]) }
556
+ process.exitCode = args.mode === 'connection' ? await printConnection(opts) : await runServe({ ...opts, port: args.port })
557
+ } catch (e) {
558
+ // A state file that cannot be read or written: one sentence, not a stack, and nothing on stdout
559
+ console.error(`centralu serve: ${e?.message ?? e}`)
560
+ process.exitCode = 1
561
+ }
562
+ break
563
+ }
502
564
  case '--version':
503
565
  case '-v':
504
566
  console.log(pkg.version)
package/bin/platform.mjs CHANGED
@@ -53,6 +53,23 @@ export function executableIn(platform, app) {
53
53
  return platform === 'win32' ? win32.join(app, 'centralu.exe') : app
54
54
  }
55
55
 
56
+ /**
57
+ * The bundled host (`resources/host`, the folder whose `main.mjs` the app runs) inside a
58
+ * platform package, for `centralu serve` (#82).
59
+ *
60
+ * - macOS: inside the bundle, where Tauri puts `bundle.resources`.
61
+ * - Windows: beside the exe, which is already how the folder ships (W1 in #307).
62
+ * - Linux: a `host/` folder next to the AppImage. The host inside the AppImage sits in a
63
+ * squashfs that is only reachable by mounting it (FUSE, which headless servers often lack)
64
+ * or by extracting it on every start, so `scripts/release-npm.mts` ships the same folder a
65
+ * second time, unpacked. It is 11 MB; the AppImage is several times that.
66
+ */
67
+ export function hostDirIn(platform, app) {
68
+ if (platform === 'darwin') return posix.join(app, 'Contents', 'Resources', 'resources', 'host')
69
+ if (platform === 'win32') return win32.join(app, 'resources', 'host')
70
+ return posix.join(posix.dirname(app), 'host')
71
+ }
72
+
56
73
  /**
57
74
  * How to start the app: the command, its arguments, the spawn options, and whether the
58
75
  * launcher stays attached until it exits.
package/bin/serve.mjs ADDED
@@ -0,0 +1,561 @@
1
+ /**
2
+ * `centralu serve`: the host without the app, for the remote mode's first phase (#82).
3
+ *
4
+ * The person starts this by hand on another machine (an SSH server, later a laptop). It runs the
5
+ * same bundled host the app runs (`resources/host/main.mjs`, on the system Node), with no window
6
+ * and no keeper, bound to 127.0.0.1 only. The app on the person's own computer reaches it through
7
+ * an SSH local forward, so nothing ever listens on a public interface. The design and its limits
8
+ * are in docs/agent-host.md §4.7; the trust argument in docs/security-boundaries.md.
9
+ *
10
+ * `centralu serve --connection` is the one question the client asks over `ssh <target>`: where to
11
+ * forward and how to authenticate. It answers with one JSON line on stdout and nothing else.
12
+ *
13
+ * Everything here takes its inputs as arguments (environment, home, platform) instead of reading
14
+ * `process`, so `tooling/launcher-serve.test.ts` can import it: `centralu.mjs` runs a command at
15
+ * import time and cannot be imported by a test. package.json "files" lists the whole bin/
16
+ * directory, so this ships automatically.
17
+ */
18
+ import { spawn } from 'node:child_process'
19
+ import { randomBytes } from 'node:crypto'
20
+ import { chmodSync, existsSync, mkdirSync, openSync, readFileSync, renameSync, statSync, writeSync, closeSync } from 'node:fs'
21
+ import { connect } from 'node:net'
22
+ import { dirname, join } from 'node:path'
23
+ import { createInterface } from 'node:readline'
24
+
25
+ /**
26
+ * The port `serve` listens on unless told otherwise, and keeps across restarts.
27
+ *
28
+ * Stable, because the client is told it once per connection and the person may write it into an
29
+ * SSH config or a systemd unit. Below 20000 on purpose: every OS lends ports from its ephemeral
30
+ * range to outgoing connections (Linux 32768–60999, macOS and Windows 49152–65535), and the host
31
+ * itself hands out per-app view origins from 20000–32767 (views/origin-ports.ts). A default inside
32
+ * either range could be taken by something transient the day the host restarts.
33
+ */
34
+ export const DEFAULT_SERVE_PORT = 17175
35
+
36
+ /**
37
+ * The file in the data folder that holds the token and the port, mode 0600.
38
+ *
39
+ * The token is generated once, the first time either `serve` or `serve --connection` runs, and kept:
40
+ * the client stores it (in the OS secret store) and has to find the same one after the host
41
+ * restarts. The port is the one the last `serve` actually listened on, which is how
42
+ * `--connection`, a separate process, learns a `--port` given to `serve` in a systemd unit.
43
+ */
44
+ export const SERVE_STATE_FILE = 'serve.json'
45
+
46
+ export const SERVE_HELP = `centralu serve: run the Centralu host on this machine, without a window
47
+
48
+ centralu serve Start the host in the foreground, on 127.0.0.1 only
49
+ centralu serve --port <n> ... on that port (kept for the next start; default ${DEFAULT_SERVE_PORT})
50
+ centralu serve --connection Print how to reach it as one JSON line, then exit
51
+ centralu serve --rotate-token Replace the token (a running serve keeps the old one until restarted)
52
+
53
+ The data folder is ~/.centralu (CC_DATA_DIR overrides it). The app on another computer reaches
54
+ this host through an SSH local forward. Stop it with Ctrl+C or SIGTERM.
55
+
56
+ Both serve and --connection keep ~/.centralu/bin/centralu up to date: a launcher with absolute
57
+ paths, for SSH sessions whose PATH has neither npm's global folder nor node.`
58
+
59
+ /**
60
+ * What the arguments after `serve` ask for: `{ mode, port }` or `{ error }`.
61
+ *
62
+ * `mode` is `serve`, `connection`, `rotate` or `help`; `port` is null when not given. `--port` does not go
63
+ * with `--connection`: the answer has to be the port `serve` uses, not one the caller names.
64
+ */
65
+ export function parseServeArgs(argv) {
66
+ let mode = 'serve'
67
+ let port = null
68
+ for (let i = 0; i < argv.length; i++) {
69
+ const a = argv[i]
70
+ if (a === '--connection' || a === '--rotate-token') {
71
+ const m = a === '--connection' ? 'connection' : 'rotate'
72
+ if (mode !== 'serve' && mode !== 'help' && mode !== m) return { error: '--connection and --rotate-token are separate commands; pass one' }
73
+ if (mode !== 'help') mode = m
74
+ }
75
+ else if (a === '--help' || a === '-h') mode = 'help'
76
+ else if (a === '--port' || a.startsWith('--port=')) {
77
+ const raw = a === '--port' ? argv[++i] : a.slice('--port='.length)
78
+ const n = parsePort(raw)
79
+ if (n === null) return { error: `--port needs a port number between 1 and 65535 (got ${raw === undefined ? 'nothing' : JSON.stringify(raw)})` }
80
+ port = n
81
+ } else return { error: `unknown option for serve: ${a}\n\n${SERVE_HELP}` }
82
+ }
83
+ if ((mode === 'connection' || mode === 'rotate') && port !== null) {
84
+ return { error: '--port goes with `centralu serve`; `--connection` reports the port serve listens on' }
85
+ }
86
+ return { mode, port }
87
+ }
88
+
89
+ function parsePort(raw) {
90
+ if (typeof raw !== 'string' || !/^\d{1,5}$/.test(raw)) return null
91
+ const n = Number(raw)
92
+ return n >= 1 && n <= 65535 ? n : null
93
+ }
94
+
95
+ /** The data folder: the host's own rule (`dataRoot()` in agent-host), CC_DATA_DIR or ~/.centralu */
96
+ export function serveDataDir(env, home) {
97
+ return env.CC_DATA_DIR || join(home, '.centralu')
98
+ }
99
+
100
+ /** The state, or null when there is none yet or it cannot be read as one */
101
+ export function readServeState(dataDir) {
102
+ let raw
103
+ try {
104
+ raw = readFileSync(join(dataDir, SERVE_STATE_FILE), 'utf8')
105
+ } catch {
106
+ return null
107
+ }
108
+ try {
109
+ const v = JSON.parse(raw)
110
+ if (typeof v?.token !== 'string' || v.token.length < 32) return null
111
+ return { token: v.token, port: Number.isInteger(v.port) && v.port >= 1 && v.port <= 65535 ? v.port : null }
112
+ } catch {
113
+ return null
114
+ }
115
+ }
116
+
117
+ /**
118
+ * The state, created on first use. Never replaces a token that is already there.
119
+ *
120
+ * Created with `wx`, so two first runs at the same moment (a `serve` and a `--connection`) cannot
121
+ * each write a different token: the second one fails to create and reads the first one's. A file
122
+ * that exists but cannot be read as state is refused rather than overwritten: it may be a token
123
+ * the client already holds, and a silent new one would lock that client out with "bad token".
124
+ */
125
+ export function ensureServeState(dataDir, platform = process.platform) {
126
+ mkdirSync(dataDir, { recursive: true })
127
+ const file = join(dataDir, SERVE_STATE_FILE)
128
+ if (!existsSync(file)) {
129
+ const token = randomBytes(32).toString('base64url')
130
+ try {
131
+ writePrivate(file, { v: 1, token, port: null }, 'wx')
132
+ } catch (e) {
133
+ if (e?.code !== 'EEXIST') throw e
134
+ }
135
+ }
136
+ const state = readServeState(dataDir)
137
+ if (!state) throw new Error(`${file} exists but is not a serve state file. Move it aside and run again.`)
138
+ keepPrivate(file, platform)
139
+ return state
140
+ }
141
+
142
+ /** Writes the port `serve` listened on, keeping the token. Atomic: a temp file renamed over it */
143
+ export function recordServePort(dataDir, port, platform = process.platform) {
144
+ const file = join(dataDir, SERVE_STATE_FILE)
145
+ const state = readServeState(dataDir)
146
+ if (!state) throw new Error(`${file} is missing or unreadable`)
147
+ if (state.port === port) return
148
+ const tmp = `${file}.${process.pid}.tmp`
149
+ writePrivate(tmp, { v: 1, token: state.token, port }, 'wx')
150
+ renameSync(tmp, file)
151
+ keepPrivate(file, platform)
152
+ }
153
+
154
+ /**
155
+ * A new token, keeping the port. Only when the person asks (`--rotate-token`): every client that
156
+ * stored the old one has to fetch the new one with `--connection`, and a serve already running
157
+ * keeps answering to the old one until it restarts.
158
+ */
159
+ export function rotateServeToken(dataDir, platform = process.platform) {
160
+ const before = ensureServeState(dataDir, platform)
161
+ const file = join(dataDir, SERVE_STATE_FILE)
162
+ const tmp = `${file}.${process.pid}.tmp`
163
+ writePrivate(tmp, { v: 1, token: randomBytes(32).toString('base64url'), port: before.port }, 'wx')
164
+ renameSync(tmp, file)
165
+ keepPrivate(file, platform)
166
+ return readServeState(dataDir)
167
+ }
168
+
169
+ /**
170
+ * The launcher `serve` keeps at `<data folder>/bin/centralu` (`centralu.cmd` on Windows).
171
+ *
172
+ * The client runs `ssh -T -o BatchMode=yes <target> centralu …`, and a non-interactive SSH shell
173
+ * reads no profile: npm's global bin under nvm, fnm, volta or `~/.npm-global` is not on its PATH,
174
+ * and often neither is `node`. So the plain name fails with exit 127, and the person, who just ran
175
+ * `centralu serve` in their own shell, sees no reason why. This file names both by absolute path,
176
+ * so `~/.centralu/bin/centralu` works from any shell. It is rewritten whenever it differs from
177
+ * what this run would write, which follows a Node upgrade or a moved npm prefix on the next start.
178
+ */
179
+ export function launcherScript(platform, execPath, cliPath) {
180
+ if (platform === 'win32') {
181
+ return `@echo off\r\nrem Written by \`centralu serve\`: reaches this install without npm's folder on PATH.\r\n"${execPath}" "${cliPath}" %*\r\n`
182
+ }
183
+ const q = (v) => `'${String(v).replaceAll("'", "'\\''")}'`
184
+ return (
185
+ '#!/bin/sh\n' +
186
+ "# Written by `centralu serve`: reaches this install from a shell whose PATH has neither npm's global\n" +
187
+ '# folder nor node (a non-interactive SSH session). Rewritten when node or the package moves.\n' +
188
+ `exec ${q(execPath)} ${q(cliPath)} "$@"\n`
189
+ )
190
+ }
191
+
192
+ /** Writes the launcher if it is missing or out of date. Returns its path. Never fatal to the caller */
193
+ export function ensureLauncher(dataDir, { platform = process.platform, execPath = process.execPath, cliPath }) {
194
+ const file = join(dataDir, 'bin', platform === 'win32' ? 'centralu.cmd' : 'centralu')
195
+ const want = launcherScript(platform, execPath, cliPath)
196
+ let have = null
197
+ try {
198
+ have = readFileSync(file, 'utf8')
199
+ } catch {
200
+ // not there yet
201
+ }
202
+ if (have !== want) {
203
+ mkdirSync(dirname(file), { recursive: true })
204
+ const tmp = `${file}.${process.pid}.tmp`
205
+ const fd = openSync(tmp, 'w', 0o755)
206
+ try {
207
+ writeSync(fd, want)
208
+ } finally {
209
+ closeSync(fd)
210
+ }
211
+ renameSync(tmp, file)
212
+ }
213
+ if (platform !== 'win32' && (statSync(file).mode & 0o777) !== 0o755) chmodSync(file, 0o755)
214
+ return file
215
+ }
216
+
217
+ /** ensureLauncher, with a failure said on stderr instead of thrown: the launcher is a convenience */
218
+ function keepLauncher(dataDir, cliPath) {
219
+ if (!cliPath) return
220
+ try {
221
+ ensureLauncher(dataDir, { cliPath })
222
+ } catch (e) {
223
+ say(`could not write the launcher in ${join(dataDir, 'bin')}: ${e.message}`)
224
+ }
225
+ }
226
+
227
+ /**
228
+ * Created 0600 from the first byte, not chmod-ed after: a file written with the default mode and
229
+ * narrowed afterwards is readable by everyone on the machine for the moment in between.
230
+ */
231
+ function writePrivate(file, value, flag) {
232
+ const fd = openSync(file, flag, 0o600)
233
+ try {
234
+ writeSync(fd, `${JSON.stringify(value)}\n`)
235
+ } finally {
236
+ closeSync(fd)
237
+ }
238
+ }
239
+
240
+ /**
241
+ * 0600 again on every use. A copy, a restore from backup or an editor that rewrites the file can
242
+ * leave it readable by the group; the token in it is the key to every RPC. Windows has no mode
243
+ * bits to set (the profile folder's ACL is what protects it there).
244
+ */
245
+ function keepPrivate(file, platform) {
246
+ if (platform === 'win32') return
247
+ if ((statSync(file).mode & 0o777) !== 0o600) chmodSync(file, 0o600)
248
+ }
249
+
250
+ /**
251
+ * The protocol a host folder speaks, from the `bundle-info.json` the bundler writes next to
252
+ * `main.mjs`. For a host run from source (`CENTRALU_HOST_ENTRY=…/src/main.ts`) there is no bundle
253
+ * info, so the constant is read where the bundler reads it. null when neither is there.
254
+ */
255
+ export function protocolVersionOf(entry) {
256
+ try {
257
+ const v = JSON.parse(readFileSync(join(dirname(entry), 'bundle-info.json'), 'utf8')).protocolVersion
258
+ if (Number.isInteger(v)) return v
259
+ } catch {
260
+ // not a bundle; try the source below
261
+ }
262
+ try {
263
+ const src = readFileSync(join(dirname(entry), '..', '..', 'protocol', 'src', 'envelope.ts'), 'utf8')
264
+ const m = /export const PROTOCOL_VERSION = (\d+)/.exec(src)
265
+ if (m) return Number(m[1])
266
+ } catch {
267
+ // nothing to read
268
+ }
269
+ return null
270
+ }
271
+
272
+ /** The `--connection` answer. The shape is the client's interface (docs/agent-host.md §4.7) */
273
+ export function connectionLine({ port, token, version, protocolVersion, dataDir, hostRunning }) {
274
+ return JSON.stringify({ v: 1, port, token, version, protocolVersion, dataDir, hostRunning })
275
+ }
276
+
277
+ /**
278
+ * The host's environment.
279
+ *
280
+ * - The token travels in `CC_HOST_TOKEN`, not `--token`: arguments are readable by every user on
281
+ * the machine (`ps`), a process's environment only by its owner. The host deletes it after
282
+ * reading, so nothing it spawns inherits it.
283
+ * - `CC_HOST_SOURCE` is the record `hello_ok.build` is made from (keeper-link.ts); without it a
284
+ * client could not see which version the host it reached is.
285
+ * - Headless: no `DISPLAY` / `WAYLAND_DISPLAY`. Nothing the host or an agent starts gets a screen
286
+ * to open a window or a keyring prompt on, so a tool that would ask falls back to its file store
287
+ * or fails with a message in the log instead of waiting on a dialog nobody can see.
288
+ * - The keeper's and dev mode's variables are dropped: this host is neither.
289
+ */
290
+ export function hostEnv(env, { token, dataDir, version, hostDir }) {
291
+ const out = { ...env }
292
+ for (const k of ['DISPLAY', 'WAYLAND_DISPLAY', 'CC_KEEPER', 'CC_FRONT_DOOR', 'CC_DEV', 'CC_HOST_TOKEN', 'CC_HOST_SOURCE']) delete out[k]
293
+ out.CC_DATA_DIR = dataDir
294
+ out.CC_HOST_TOKEN = token
295
+ out.CC_HOST_SOURCE = JSON.stringify({ version, bundlePath: hostDir })
296
+ return out
297
+ }
298
+
299
+ /**
300
+ * How to start the host.
301
+ *
302
+ * `--watch-parent` with stdin a pipe: if this launcher dies however it dies (SIGKILL included), the
303
+ * pipe closes and the host shuts down with its children, instead of living on unsupervised.
304
+ * `detached` on POSIX puts the host in its own process group, so the terminal's Ctrl+C reaches
305
+ * only this launcher, which passes one SIGINT to the host alone; the host then stops its own
306
+ * children in order. Without it the terminal would signal every agent at the same time as the
307
+ * host. Not on Windows: there `detached` means a new console, and Ctrl+C reaches the host through
308
+ * the console it shares with this launcher.
309
+ */
310
+ export function hostCommand(execPath, entry, { port, dataDir, platform }) {
311
+ return {
312
+ command: execPath,
313
+ args: [
314
+ // A source entry (development, tests) needs the TypeScript loader; a bundle does not.
315
+ ...(entry.endsWith('.ts') ? ['--import', 'tsx'] : []),
316
+ entry,
317
+ '--port',
318
+ String(port),
319
+ '--db',
320
+ join(dataDir, 'store.db'),
321
+ '--watch-parent',
322
+ ],
323
+ detached: platform !== 'win32',
324
+ }
325
+ }
326
+
327
+ /** The port from the host's ready line (`{"ready":true,"port":…,"token":…}`), null for any other line */
328
+ export function readyPort(line) {
329
+ try {
330
+ const v = JSON.parse(line)
331
+ return v?.ready === true && Number.isInteger(v.port) ? v.port : null
332
+ } catch {
333
+ return null
334
+ }
335
+ }
336
+
337
+ /**
338
+ * The protocol a host's `version_mismatch` refusal names, from either wording: this one's
339
+ * ("this host speaks protocol 2") or the one before it ("server 2, client 1"). null if neither.
340
+ */
341
+ export function mismatchServerVersion(message) {
342
+ const m = /host speaks protocol (\d+)|\bserver (\d+)/.exec(String(message ?? ''))
343
+ return m ? Number(m[1] ?? m[2]) : null
344
+ }
345
+
346
+ /**
347
+ * Whether a host that holds this token answers on the port: a real hello, not a TCP connect, so a
348
+ * stranger's program on the same port does not count, and the answer carries the running host's
349
+ * own protocol and version (which differ from the installed package's after an `npm i -g` the
350
+ * host was not restarted for).
351
+ *
352
+ * `{ running, protocolVersion?, version? }`. Node 22.4+ has a global WebSocket; on an older 22
353
+ * this falls back to a TCP connect and reports only `running`.
354
+ */
355
+ export function probeHost({ port, token, protocolVersion, timeoutMs = 2000, WebSocketImpl = globalThis.WebSocket }) {
356
+ if (typeof WebSocketImpl !== 'function') return tcpProbe(port, timeoutMs)
357
+ return new Promise((resolve) => {
358
+ let ws = null
359
+ let settled = false
360
+ const done = (result) => {
361
+ if (settled) return
362
+ settled = true
363
+ clearTimeout(timer)
364
+ try {
365
+ ws?.close()
366
+ } catch {
367
+ // already closed
368
+ }
369
+ resolve(result)
370
+ }
371
+ const timer = setTimeout(() => done({ running: false }), timeoutMs)
372
+ try {
373
+ ws = new WebSocketImpl(`ws://127.0.0.1:${port}`)
374
+ } catch {
375
+ done({ running: false })
376
+ return
377
+ }
378
+ ws.onopen = () => ws.send(JSON.stringify({ kind: 'hello', token, protocolVersion: protocolVersion ?? 0 }))
379
+ ws.onmessage = (ev) => {
380
+ let m
381
+ try {
382
+ m = JSON.parse(String(ev.data))
383
+ } catch {
384
+ return
385
+ }
386
+ if (m?.kind === 'hello_ok') {
387
+ done({
388
+ running: true,
389
+ protocolVersion: Number.isInteger(m.protocolVersion) ? m.protocolVersion : undefined,
390
+ version: typeof m.build?.version === 'string' ? m.build.version : undefined,
391
+ })
392
+ } else if (m?.kind === 'res' && m.ok === false && m.error?.code === 'version_mismatch') {
393
+ // The token was right (it is checked first), so this is our host, on another protocol
394
+ done({ running: true, protocolVersion: mismatchServerVersion(m.error.message) ?? undefined })
395
+ }
396
+ }
397
+ ws.onerror = () => done({ running: false })
398
+ ws.onclose = () => done({ running: false })
399
+ })
400
+ }
401
+
402
+ function tcpProbe(port, timeoutMs) {
403
+ return new Promise((resolve) => {
404
+ const s = connect({ host: '127.0.0.1', port })
405
+ const done = (running) => {
406
+ s.destroy()
407
+ resolve({ running })
408
+ }
409
+ s.setTimeout(timeoutMs, () => done(false))
410
+ s.once('connect', () => done(true))
411
+ s.once('error', () => done(false))
412
+ })
413
+ }
414
+
415
+ /** Who `host.lock` names as the data folder's owner, or null */
416
+ export function lockHolderPid(dataDir) {
417
+ try {
418
+ const text = readFileSync(join(dataDir, 'host.lock'), 'utf8').trim()
419
+ const pid = /^\d+$/.test(text) ? Number(text) : JSON.parse(text)?.pid
420
+ return Number.isInteger(pid) && pid > 0 ? pid : null
421
+ } catch {
422
+ return null
423
+ }
424
+ }
425
+
426
+ function isAlive(pid) {
427
+ try {
428
+ process.kill(pid, 0)
429
+ return true
430
+ } catch (e) {
431
+ return e?.code === 'EPERM'
432
+ }
433
+ }
434
+
435
+ /** Writes a line to stderr, prefixed so it reads apart from the host's own log lines */
436
+ function say(line) {
437
+ process.stderr.write(`[centralu serve] ${line}\n`)
438
+ }
439
+
440
+ /**
441
+ * `centralu serve --connection`. stdout carries exactly one line, the JSON; everything else goes to
442
+ * stderr. Exit 1 when there is no host to describe.
443
+ */
444
+ export async function printConnection({ env, home, entry, version, cliPath }) {
445
+ const dataDir = serveDataDir(env, home)
446
+ const state = ensureServeState(dataDir)
447
+ keepLauncher(dataDir, cliPath)
448
+ const port = state.port ?? DEFAULT_SERVE_PORT
449
+ const installed = protocolVersionOf(entry)
450
+ const probe = await probeHost({ port, token: state.token, protocolVersion: installed })
451
+ const protocolVersion = (probe.running ? probe.protocolVersion : undefined) ?? installed
452
+ if (protocolVersion === null) {
453
+ say(`cannot read the protocol version of the host at ${entry}`)
454
+ return 1
455
+ }
456
+ process.stdout.write(
457
+ `${connectionLine({
458
+ port,
459
+ token: state.token,
460
+ version: (probe.running ? probe.version : undefined) ?? version,
461
+ protocolVersion,
462
+ dataDir,
463
+ hostRunning: probe.running,
464
+ })}\n`,
465
+ )
466
+ return 0
467
+ }
468
+
469
+ /**
470
+ * `centralu serve`. Resolves with the exit code once the host has exited.
471
+ *
472
+ * The host's stdout is read here and never passed on: its ready line carries the token, which is
473
+ * printed nowhere but `--connection`. Every other line the host writes to stdout (a lock conflict,
474
+ * a store from a newer build) it also writes to stderr, which is passed through untouched.
475
+ */
476
+ export async function runServe({ env, home, entry, version, cliPath, port: askedPort, platform = process.platform, execPath = process.execPath }) {
477
+ const dataDir = serveDataDir(env, home)
478
+ const state = ensureServeState(dataDir, platform)
479
+ keepLauncher(dataDir, cliPath)
480
+ const port = askedPort ?? state.port ?? DEFAULT_SERVE_PORT
481
+ const protocolVersion = protocolVersionOf(entry)
482
+
483
+ // A serve that is already up answers on the port it recorded; say so before starting a host that
484
+ // would only fail on the data folder's lock with a less specific message.
485
+ const recorded = state.port ?? DEFAULT_SERVE_PORT
486
+ const existing = await probeHost({ port: recorded, token: state.token, protocolVersion })
487
+ if (existing.running) {
488
+ const pid = lockHolderPid(dataDir)
489
+ say(`centralu serve is already running for ${dataDir} on 127.0.0.1:${recorded}${pid ? ` (pid ${pid})` : ''}. Not starting a second one.`)
490
+ return 1
491
+ }
492
+
493
+ const plan = hostCommand(execPath, entry, { port, dataDir, platform })
494
+ const child = spawn(plan.command, plan.args, {
495
+ stdio: ['pipe', 'pipe', 'inherit'],
496
+ env: hostEnv(env, { token: state.token, dataDir, version, hostDir: dirname(entry) }),
497
+ detached: plan.detached,
498
+ windowsHide: true,
499
+ })
500
+
501
+ let ready = false
502
+ createInterface({ input: child.stdout }).on('line', (line) => {
503
+ const p = readyPort(line)
504
+ if (p === null || ready) return
505
+ ready = true
506
+ try {
507
+ recordServePort(dataDir, p, platform)
508
+ } catch (e) {
509
+ say(`could not record the port in ${join(dataDir, SERVE_STATE_FILE)}: ${e.message}`)
510
+ }
511
+ say(`listening on 127.0.0.1:${p} (Centralu ${version}, protocol ${protocolVersion ?? 'unknown'}, data ${dataDir})`)
512
+ say(`from your computer: ssh -N -L ${p}:127.0.0.1:${p} <this machine>, or let the app connect over SSH`)
513
+ })
514
+
515
+ /*
516
+ * One signal to the host alone, whatever reached this launcher. SIGHUP (the terminal closed) is
517
+ * passed as SIGTERM: the host has no SIGHUP handler, and Node's default for it is to exit on the
518
+ * spot, skipping the shutdown that stops the agents. A second signal changes nothing on the host
519
+ * side (its shutdown runs once), so forwarding every one is safe.
520
+ */
521
+ const forward = (sig) => {
522
+ if (child.exitCode !== null || child.signalCode !== null) return
523
+ if (platform === 'win32') return // the host shares the console and received Ctrl+C itself
524
+ try {
525
+ child.kill(sig === 'SIGHUP' ? 'SIGTERM' : sig)
526
+ } catch {
527
+ // already gone
528
+ }
529
+ }
530
+ const signals = platform === 'win32' ? ['SIGINT'] : ['SIGINT', 'SIGTERM', 'SIGHUP']
531
+ for (const s of signals) process.on(s, forward)
532
+
533
+ return await new Promise((resolve) => {
534
+ child.on('error', (e) => {
535
+ say(`could not start the host (${plan.command}): ${e.message}`)
536
+ resolve(1)
537
+ })
538
+ child.on('exit', (code, signal) => {
539
+ for (const s of signals) process.off(s, forward)
540
+ if (!ready && code !== 0) {
541
+ const pid = lockHolderPid(dataDir)
542
+ if (pid && pid !== child.pid && isAlive(pid)) {
543
+ say(
544
+ `the host did not start. ${dataDir} belongs to another Centralu host (pid ${pid}), ` +
545
+ 'probably the Centralu app on this machine. Quit it, or point CC_DATA_DIR at another folder.',
546
+ )
547
+ }
548
+ }
549
+ resolve(code ?? (signal ? 1 : 0))
550
+ })
551
+ })
552
+ }
553
+
554
+ /** `centralu serve --rotate-token` */
555
+ export function rotateToken({ env, home }) {
556
+ const dataDir = serveDataDir(env, home)
557
+ rotateServeToken(dataDir)
558
+ say(`replaced the token in ${join(dataDir, SERVE_STATE_FILE)}.`)
559
+ say('A serve that is running keeps the old one until it restarts; clients read the new one with --connection.')
560
+ return 0
561
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "centralu",
3
- "version": "0.1.0-beta.10",
3
+ "version": "0.1.0-beta.12",
4
4
  "description": "Run, watch and steer several Claude Code and Codex CLI sessions from one window",
5
5
  "license": "MIT",
6
6
  "author": "ijun17",
@@ -37,9 +37,9 @@
37
37
  "win32"
38
38
  ],
39
39
  "optionalDependencies": {
40
- "@centralu/darwin-arm64": "0.1.0-beta.10",
41
- "@centralu/linux-arm64": "0.1.0-beta.10",
42
- "@centralu/linux-x64": "0.1.0-beta.10",
43
- "@centralu/win32-x64": "0.1.0-beta.10"
40
+ "@centralu/darwin-arm64": "0.1.0-beta.12",
41
+ "@centralu/linux-arm64": "0.1.0-beta.12",
42
+ "@centralu/linux-x64": "0.1.0-beta.12",
43
+ "@centralu/win32-x64": "0.1.0-beta.12"
44
44
  }
45
45
  }