centralu 0.1.0-beta.12 → 0.1.0-beta.14

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
@@ -25,6 +25,7 @@
25
25
  import { execFileSync, spawn } from 'node:child_process'
26
26
  import { copyDiffers, isNewer } from './semver.mjs'
27
27
  import {
28
+ asideCopies,
28
29
  busyMessage,
29
30
  earlyExitMessage,
30
31
  executableIn,
@@ -41,8 +42,18 @@ import {
41
42
  webview2Status,
42
43
  windowsInstall,
43
44
  } from './platform.mjs'
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'
45
+ import {
46
+ decodeChildSpec,
47
+ parseServeArgs,
48
+ printConnection,
49
+ rotateToken,
50
+ runDetach,
51
+ runDetachedChild,
52
+ runServe,
53
+ runStop,
54
+ SERVE_HELP,
55
+ } from './serve.mjs'
56
+ import { chmodSync, cpSync, existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync } from 'node:fs'
46
57
  import { createRequire } from 'node:module'
47
58
  import { homedir } from 'node:os'
48
59
  import { dirname, join } from 'node:path'
@@ -322,6 +333,7 @@ function installDesktopEntry(app) {
322
333
  function installWindows(app) {
323
334
  const staging = `${WIN.dir}.new`
324
335
  const aside = `${WIN.dir}.old-${Date.now()}`
336
+ sweepAsideCopies()
325
337
  rmSync(staging, { recursive: true, force: true })
326
338
  mkdirSync(dirname(WIN.dir), { recursive: true })
327
339
  cpSync(app, staging, { recursive: true })
@@ -358,6 +370,32 @@ function installWindows(app) {
358
370
  console.log('It can now be found in Start and Windows search, and `centralu` starts this copy too.')
359
371
  }
360
372
 
373
+ /**
374
+ * Removes the copies earlier installs renamed aside (`Centralu.old-<time>`), whichever can go.
375
+ *
376
+ * An update from inside the app renames the running copy aside, and Windows will not delete a
377
+ * running exe, so that copy stays until the app quits. Nothing else would ever remove it, and
378
+ * each in-app update would leave one more (about 70 files each, measured 2026-10-08). One still
379
+ * in use is left for the next install; deleting what can be deleted from it does not disturb the
380
+ * app running from it, which reaches its files by the folder's original path, now the new copy.
381
+ */
382
+ function sweepAsideCopies() {
383
+ const parent = dirname(WIN.dir)
384
+ let names
385
+ try {
386
+ names = readdirSync(parent)
387
+ } catch {
388
+ return
389
+ }
390
+ for (const name of asideCopies(names)) {
391
+ try {
392
+ rmSync(join(parent, name), { recursive: true, force: true })
393
+ } catch {
394
+ // Still in use: the next install tries again
395
+ }
396
+ }
397
+ }
398
+
361
399
  function uninstall() {
362
400
  const paths = installedPaths(PLATFORM, process.env, HOME)
363
401
  const present = paths.filter((p) => existsSync(p))
@@ -551,9 +589,21 @@ switch (cmd) {
551
589
  process.exitCode = rotateToken({ env: process.env, home: HOME })
552
590
  break
553
591
  }
592
+ // A launcher `--detach` started: what it carried (the data folder, a host entry) applies before
593
+ // anything below reads the environment
594
+ const spec = args.child !== undefined ? decodeChildSpec(args.child) : null
595
+ if (args.child !== undefined && !spec) {
596
+ console.error('centralu serve: --detached-child is how --detach starts serve; this one could not be read')
597
+ process.exit(2)
598
+ }
599
+ if (spec) Object.assign(process.env, spec.env)
554
600
  // realpath: npm's bin is a symlink to this file, and the launcher must not depend on the link
555
601
  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 })
602
+ if (args.mode === 'connection') process.exitCode = await printConnection(opts)
603
+ else if (args.mode === 'detach') process.exitCode = await runDetach({ ...opts, port: args.port })
604
+ else if (args.mode === 'stop') process.exitCode = await runStop(opts)
605
+ else if (spec) process.exitCode = await runDetachedChild({ ...opts, spec, port: args.port })
606
+ else process.exitCode = await runServe({ ...opts, port: args.port })
557
607
  } catch (e) {
558
608
  // A state file that cannot be read or written: one sentence, not a stack, and nothing on stdout
559
609
  console.error(`centralu serve: ${e?.message ?? e}`)
package/bin/platform.mjs CHANGED
@@ -137,6 +137,16 @@ export function windowsInstall(env, home) {
137
137
  }
138
138
  }
139
139
 
140
+ /**
141
+ * Which of the names in `%LOCALAPPDATA%\Programs` are copies an earlier `centralu install` renamed
142
+ * aside (`Centralu.old-<time>`). Only those: the folder is shared with every other per-user
143
+ * program, and `Centralu` itself and a `Centralu.new` being assembled are not leftovers.
144
+ */
145
+ export function asideCopies(names) {
146
+ const prefix = `${APP_NAME}.old-`.toLowerCase()
147
+ return names.filter((n) => n.toLowerCase().startsWith(prefix) && /^\d+$/.test(n.slice(prefix.length)))
148
+ }
149
+
140
150
  /**
141
151
  * What `centralu install` creates, per platform. `uninstall` removes exactly these and
142
152
  * `update` refreshes them when the first one exists.
package/bin/serve.mjs CHANGED
@@ -17,8 +17,9 @@
17
17
  */
18
18
  import { spawn } from 'node:child_process'
19
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'
20
+ import { chmodSync, existsSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, statSync, writeFileSync, writeSync, closeSync } from 'node:fs'
21
+ import { connect, createServer } from 'node:net'
22
+ import { userInfo } from 'node:os'
22
23
  import { dirname, join } from 'node:path'
23
24
  import { createInterface } from 'node:readline'
24
25
 
@@ -33,6 +34,70 @@ import { createInterface } from 'node:readline'
33
34
  */
34
35
  export const DEFAULT_SERVE_PORT = 17175
35
36
 
37
+ /** How many ports from the recorded one `serve` tries when it is taken (#482); none at or above 20000 */
38
+ export const SERVE_PORT_TRIES = 20
39
+
40
+ /**
41
+ * The port `serve` starts the host on (#482): `{ port, from }`, `from` being the port it moved away
42
+ * from (null when it did not), or null when every candidate is taken.
43
+ *
44
+ * A port the person gave (`--port`) is used as given: they asked for that one. Otherwise the recorded
45
+ * port (else the default) when it is free, and when another program listens there, the next free
46
+ * one above it, below 20000 (the ranges `DEFAULT_SERVE_PORT` keeps out of). The host's ready line
47
+ * then records the port it got, so `--connection` reports it and the hub forwards to it.
48
+ *
49
+ * The usual other program is the other half of one Windows laptop: WSL2 relays a distro's
50
+ * 127.0.0.1 ports to Windows (`wslrelay.exe`), so a Windows install and a WSL install linked as two
51
+ * machines both wanted 17175, and whichever started second could not start at all.
52
+ * `taken(port)` answers whether something listens there.
53
+ */
54
+ export async function chooseServePort({ asked, recorded, taken }) {
55
+ if (asked !== null && asked !== undefined) return { port: asked, from: null }
56
+ const first = recorded ?? DEFAULT_SERVE_PORT
57
+ for (let p = first; p < first + SERVE_PORT_TRIES && p <= 65535; p++) {
58
+ if (p !== first && p >= 20000) break
59
+ if (!(await taken(p))) return { port: p, from: p === first ? null : first }
60
+ }
61
+ return null
62
+ }
63
+
64
+ /** Whether something listens on 127.0.0.1:`port` here: a listen that fails with EADDRINUSE (or EACCES) */
65
+ export function listensHere(port) {
66
+ return new Promise((resolve) => {
67
+ const srv = createServer()
68
+ srv.once('error', (e) => resolve(e?.code === 'EADDRINUSE' || e?.code === 'EACCES'))
69
+ srv.listen({ host: '127.0.0.1', port, exclusive: true }, () => srv.close(() => resolve(false)))
70
+ })
71
+ }
72
+
73
+ /**
74
+ * The TCP ports Windows listens on, from `netstat.exe -an -p TCP` output: a listening socket is the
75
+ * row whose foreign address is `0.0.0.0:0` (or `[::]:0`). Read that way rather than by the state
76
+ * column, which Windows translates (a Korean Windows does not print LISTENING everywhere).
77
+ */
78
+ export function parseNetstatListening(text) {
79
+ const ports = new Set()
80
+ for (const line of String(text).split(/\r?\n/)) {
81
+ const m = /^\s*TCP\s+(?:127\.0\.0\.1|0\.0\.0\.0|\[::1?\]):(\d+)\s+(?:0\.0\.0\.0:0|\[::\]:0)\s/i.exec(line)
82
+ if (m) ports.add(Number(m[1]))
83
+ }
84
+ return ports
85
+ }
86
+
87
+ /**
88
+ * The ports the Windows side listens on, seen from inside a WSL distro (empty elsewhere, or when
89
+ * Windows cannot be asked). Inside the distro a port Windows holds looks free, yet a host there
90
+ * would be unreachable from the hub: the hub's forward ends on Windows' 127.0.0.1, where the
91
+ * Windows program answers instead (#482).
92
+ */
93
+ async function windowsListening(env, platform) {
94
+ if (detachHow(platform, env) !== 'wsl') return new Set()
95
+ const netstat = windowsTool('wsl', env, ['System32', 'netstat.exe'])
96
+ if (!netstat) return new Set()
97
+ const r = await runCaptured(netstat, ['-an', '-p', 'TCP'], env)
98
+ return r.code === 0 ? parseNetstatListening(r.stdout) : new Set()
99
+ }
100
+
36
101
  /**
37
102
  * The file in the data folder that holds the token and the port, mode 0600.
38
103
  *
@@ -43,36 +108,62 @@ export const DEFAULT_SERVE_PORT = 17175
43
108
  */
44
109
  export const SERVE_STATE_FILE = 'serve.json'
45
110
 
111
+ /** What a detached serve writes: its own lines, and the host's until it is ready (host.log has the rest) */
112
+ export const SERVE_LOG_FILE = 'serve.log'
113
+
114
+ /** The running launcher's pid and port, so `--stop` can find it. Removed when it exits */
115
+ export const SERVE_PID_FILE = 'serve.pid'
116
+
46
117
  export const SERVE_HELP = `centralu serve: run the Centralu host on this machine, without a window
47
118
 
48
119
  centralu serve Start the host in the foreground, on 127.0.0.1 only
49
120
  centralu serve --port <n> ... on that port (kept for the next start; default ${DEFAULT_SERVE_PORT})
121
+ centralu serve --detach Start it in the background, outside this session; return once it answers
122
+ centralu serve --stop Ask the running host to stop, and wait until it has
50
123
  centralu serve --connection Print how to reach it as one JSON line, then exit
51
124
  centralu serve --rotate-token Replace the token (a running serve keeps the old one until restarted)
52
125
 
53
126
  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.
127
+ this host through an SSH local forward, and starts it with --detach when it is not running.
128
+ Stop a foreground one with Ctrl+C or SIGTERM, a detached one with --stop. A detached one writes
129
+ how its start went to serve.log in the data folder; the host's own log is host.log.
55
130
 
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.`
131
+ serve, --detach and --connection keep ~/.centralu/bin/centralu up to date: a launcher with absolute
132
+ paths, for SSH sessions whose PATH has neither npm's global folder nor node. Not when they run
133
+ from an install the app made over SSH, which has a launcher of its own.`
134
+
135
+ /** The flags that each make serve a different command */
136
+ const MODE_FLAGS = { '--connection': 'connection', '--rotate-token': 'rotate', '--detach': 'detach', '--stop': 'stop' }
137
+ const FLAG_OF = Object.fromEntries(Object.entries(MODE_FLAGS).map(([f, m]) => [m, f]))
58
138
 
59
139
  /**
60
- * What the arguments after `serve` ask for: `{ mode, port }` or `{ error }`.
140
+ * The argument a detached launcher is started with: what it needs from the `--detach` that started
141
+ * it, which a process created through WMI does not inherit (it gets the account's default
142
+ * environment, not this one). Never a secret: the token stays in serve.json. Internal, not in the help.
143
+ */
144
+ const CHILD_FLAG = '--detached-child='
145
+
146
+ /**
147
+ * What the arguments after `serve` ask for: `{ mode, port }` (plus `child` for a detached launcher)
148
+ * or `{ error }`.
61
149
  *
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.
150
+ * `mode` is `serve`, `detach`, `stop`, `connection`, `rotate` or `help`; `port` is null when not
151
+ * given. `--port` goes with `serve` and `--detach` only: `--connection` has to report the port
152
+ * `serve` uses, not one the caller names, and `--stop` stops the one that runs.
64
153
  */
65
154
  export function parseServeArgs(argv) {
66
155
  let mode = 'serve'
67
156
  let port = null
157
+ let help = false
158
+ let child = null
68
159
  for (let i = 0; i < argv.length; i++) {
69
160
  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'
161
+ if (Object.hasOwn(MODE_FLAGS, a)) {
162
+ const m = MODE_FLAGS[a]
163
+ if (mode !== 'serve' && mode !== m) return { error: `${FLAG_OF[mode]} and ${a} are separate commands; pass one` }
164
+ mode = m
165
+ } else if (a === '--help' || a === '-h') help = true
166
+ else if (a.startsWith(CHILD_FLAG)) child = a.slice(CHILD_FLAG.length)
76
167
  else if (a === '--port' || a.startsWith('--port=')) {
77
168
  const raw = a === '--port' ? argv[++i] : a.slice('--port='.length)
78
169
  const n = parsePort(raw)
@@ -80,9 +171,15 @@ export function parseServeArgs(argv) {
80
171
  port = n
81
172
  } else return { error: `unknown option for serve: ${a}\n\n${SERVE_HELP}` }
82
173
  }
174
+ if (help) return { mode: 'help', port }
175
+ if (child !== null) {
176
+ if (mode !== 'serve') return { error: `${CHILD_FLAG.slice(0, -1)} is how --detach starts serve, not an option of ${FLAG_OF[mode]}` }
177
+ return { mode, port, child }
178
+ }
83
179
  if ((mode === 'connection' || mode === 'rotate') && port !== null) {
84
180
  return { error: '--port goes with `centralu serve`; `--connection` reports the port serve listens on' }
85
181
  }
182
+ if (mode === 'stop' && port !== null) return { error: '--stop stops the serve that is running, on the port it recorded; it takes no --port' }
86
183
  return { mode, port }
87
184
  }
88
185
 
@@ -214,9 +311,16 @@ export function ensureLauncher(dataDir, { platform = process.platform, execPath
214
311
  return file
215
312
  }
216
313
 
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
314
+ /**
315
+ * ensureLauncher, with a failure said on stderr instead of thrown: the launcher is a convenience.
316
+ *
317
+ * Not under the managed launcher (`CENTRALU_MANAGED=1`, docs/plans/remote-hub.md §10.1): an install
318
+ * the hub made over ssh has its own launcher in `<data>/remote/bin/`, which reads `current`. Writing
319
+ * `<data>/bin/centralu` from there would point an npm install's launcher at one managed version, and
320
+ * the two would rewrite each other's file on every start.
321
+ */
322
+ function keepLauncher(dataDir, cliPath, env) {
323
+ if (!cliPath || env?.CENTRALU_MANAGED === '1') return
220
324
  try {
221
325
  ensureLauncher(dataDir, { cliPath })
222
326
  } catch (e) {
@@ -269,9 +373,121 @@ export function protocolVersionOf(entry) {
269
373
  return null
270
374
  }
271
375
 
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 })
376
+ /**
377
+ * The `--connection` answer. The shape is the client's interface (docs/agent-host.md §4.7).
378
+ * `install` (additive, the line stays `v: 1`) says what runs this command (`installInfo`); `detach`
379
+ * is added by `--detach` only.
380
+ */
381
+ export function connectionLine({ port, token, version, protocolVersion, dataDir, hostRunning, install, detach }) {
382
+ return JSON.stringify({
383
+ v: 1,
384
+ port,
385
+ token,
386
+ version,
387
+ protocolVersion,
388
+ dataDir,
389
+ hostRunning,
390
+ ...(install ? { install } : {}),
391
+ ...(detach ? { detach } : {}),
392
+ })
393
+ }
394
+
395
+ /**
396
+ * The install the hub manages on this machine (docs/plans/remote-hub.md §10.1): side-by-side
397
+ * versions, the pinned Node they run on, and two pointer files naming what runs and what to roll
398
+ * back to. The hub's installer writes it; `serve` only reads it.
399
+ */
400
+ export function remoteLayout(dataDir) {
401
+ const root = join(dataDir, 'remote')
402
+ return {
403
+ root,
404
+ current: join(root, 'current'),
405
+ previous: join(root, 'previous'),
406
+ versions: join(root, 'versions'),
407
+ node: join(root, 'node'),
408
+ bin: join(root, 'bin'),
409
+ }
410
+ }
411
+
412
+ /** A word a pointer file may hold: a folder name, nothing a path could escape with */
413
+ const POINTER_WORD = /^[0-9A-Za-z][0-9A-Za-z.+-]{0,63}$/
414
+
415
+ /** `current` / `previous`: one line, "<centralu version> <node version>". null for anything else */
416
+ export function parsePointer(text) {
417
+ const m = /^\s*(\S+)[ \t]+(\S+)\s*$/.exec(String(text ?? ''))
418
+ if (!m || !POINTER_WORD.test(m[1]) || !POINTER_WORD.test(m[2])) return null
419
+ return { version: m[1], node: m[2] }
420
+ }
421
+
422
+ /**
423
+ * The connection line's `install` field (docs/plans/remote-hub.md §10.5): whether this command was
424
+ * started by the managed launcher, the managed install's `current` and `previous`, and the Node
425
+ * running this command. Read from the machine at every ask, never kept by the hub (S12).
426
+ */
427
+ export function installInfo(dataDir, env, nodeVersion = process.versions.node) {
428
+ const l = remoteLayout(dataDir)
429
+ const read = (file) => {
430
+ try {
431
+ return parsePointer(readFileSync(file, 'utf8'))
432
+ } catch {
433
+ return null
434
+ }
435
+ }
436
+ return { managed: env.CENTRALU_MANAGED === '1', current: read(l.current), previous: read(l.previous), node: nodeVersion }
437
+ }
438
+
439
+ /**
440
+ * The managed launcher, `<data>/remote/bin/centralu` (`centralu.cmd` on Windows), as the installer
441
+ * writes it (docs/plans/remote-hub.md §10.1, S8).
442
+ *
443
+ * **Its text never changes.** It reads `current` at every start and runs that version's
444
+ * `centralu.mjs` on that version's Node, so switching versions is one file replaced by a rename,
445
+ * atomic on both systems. A launcher rewritten per version would be replaced while it may be
446
+ * running, and cmd.exe reads a batch file by offset as it runs. It sets `CENTRALU_MANAGED=1`, so
447
+ * the `serve` it starts leaves `<data>/bin/` to an npm install on the same machine.
448
+ *
449
+ * Both refuse a pointer that holds anything but plain version words, so an edited `current` cannot
450
+ * point it outside the install.
451
+ */
452
+ export function managedLauncherScript(platform) {
453
+ if (platform === 'win32') {
454
+ return (
455
+ [
456
+ '@echo off',
457
+ 'rem The Centralu launcher for the install the app manages over SSH. It never changes: it reads',
458
+ 'rem "current" at every start, so switching versions replaces that file and never this one.',
459
+ 'setlocal',
460
+ 'set "CENTRALU_REMOTE=%~dp0.."',
461
+ 'set "CENTRALU_LINE="',
462
+ 'set "CENTRALU_V="',
463
+ 'set "CENTRALU_N="',
464
+ 'set /p CENTRALU_LINE=<"%CENTRALU_REMOTE%\\current"',
465
+ 'for /f "tokens=1,2" %%a in ("%CENTRALU_LINE%") do (set "CENTRALU_V=%%a" & set "CENTRALU_N=%%b")',
466
+ 'if not defined CENTRALU_N goto none',
467
+ 'echo %CENTRALU_V% %CENTRALU_N%| findstr /r /c:"^[0-9A-Za-z][0-9A-Za-z.+-]* [0-9A-Za-z][0-9A-Za-z.+-]*$" >nul || goto none',
468
+ 'set CENTRALU_MANAGED=1',
469
+ '"%CENTRALU_REMOTE%\\node\\v%CENTRALU_N%\\node.exe" "%CENTRALU_REMOTE%\\versions\\%CENTRALU_V%\\node_modules\\centralu\\bin\\centralu.mjs" %*',
470
+ 'exit /b %ERRORLEVEL%',
471
+ ':none',
472
+ 'echo Centralu: no version is installed in %CENTRALU_REMOTE% 1>&2',
473
+ 'exit /b 1',
474
+ ].join('\r\n') + '\r\n'
475
+ )
476
+ }
477
+ return [
478
+ '#!/bin/sh',
479
+ '# The Centralu launcher for the install the app manages over SSH. It never changes: it reads',
480
+ '# `current` at every start, so switching versions replaces that file and never this one.',
481
+ 'remote=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd -P) || exit 1',
482
+ 'v= n=',
483
+ 'read -r v n < "$remote/current" 2>/dev/null',
484
+ 'case "$v" in ""|[!0-9A-Za-z]*|*[!0-9A-Za-z.+-]*) echo "Centralu: no version is installed in $remote" >&2; exit 1 ;; esac',
485
+ 'case "$n" in ""|[!0-9A-Za-z]*|*[!0-9A-Za-z.+-]*) echo "Centralu: no version is installed in $remote" >&2; exit 1 ;; esac',
486
+ 'CENTRALU_MANAGED=1',
487
+ 'export CENTRALU_MANAGED',
488
+ 'exec "$remote/node/v$n/bin/node" "$remote/versions/$v/node_modules/centralu/bin/centralu.mjs" "$@"',
489
+ '',
490
+ ].join('\n')
275
491
  }
276
492
 
277
493
  /**
@@ -285,12 +501,16 @@ export function connectionLine({ port, token, version, protocolVersion, dataDir,
285
501
  * - Headless: no `DISPLAY` / `WAYLAND_DISPLAY`. Nothing the host or an agent starts gets a screen
286
502
  * to open a window or a keyring prompt on, so a tool that would ask falls back to its file store
287
503
  * 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.
504
+ * - The keeper's and dev mode's variables are dropped: this host is neither. So is
505
+ * `CENTRALU_MANAGED`, which is the launcher's business and would otherwise reach every agent.
506
+ * - `CC_SERVE=1` lets the host take `host.stop` (`--stop`); a host the app runs refuses it.
289
507
  */
290
508
  export function hostEnv(env, { token, dataDir, version, hostDir }) {
291
509
  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]
510
+ for (const k of ['DISPLAY', 'WAYLAND_DISPLAY', 'CC_KEEPER', 'CC_FRONT_DOOR', 'CC_DEV', 'CC_HOST_TOKEN', 'CC_HOST_SOURCE', 'CENTRALU_MANAGED']) delete out[k]
293
511
  out.CC_DATA_DIR = dataDir
512
+ // The host `host.stop` may end (`centralu serve --stop`): nothing else stops it in order on Windows
513
+ out.CC_SERVE = '1'
294
514
  out.CC_HOST_TOKEN = token
295
515
  out.CC_HOST_SOURCE = JSON.stringify({ version, bundlePath: hostDir })
296
516
  return out
@@ -432,9 +652,33 @@ function isAlive(pid) {
432
652
  }
433
653
  }
434
654
 
655
+ /**
656
+ * Where `say` writes: stderr, except in a detached launcher, which nobody reads the stderr of and
657
+ * which writes serve.log instead (`runDetachedChild`).
658
+ */
659
+ let sink = (text) => process.stderr.write(text)
660
+
435
661
  /** Writes a line to stderr, prefixed so it reads apart from the host's own log lines */
436
662
  function say(line) {
437
- process.stderr.write(`[centralu serve] ${line}\n`)
663
+ sink(`[centralu serve] ${line}\n`)
664
+ }
665
+
666
+ /** What `--connection` answers, as fields: the running host's versions when one answers, else the installed ones */
667
+ async function describe({ env, dataDir, state, entry, version }) {
668
+ const port = state.port ?? DEFAULT_SERVE_PORT
669
+ const installed = protocolVersionOf(entry)
670
+ const probe = await probeHost({ port, token: state.token, protocolVersion: installed })
671
+ const protocolVersion = (probe.running ? probe.protocolVersion : undefined) ?? installed
672
+ if (protocolVersion === null) return null
673
+ return {
674
+ port,
675
+ token: state.token,
676
+ version: (probe.running ? probe.version : undefined) ?? version,
677
+ protocolVersion,
678
+ dataDir,
679
+ hostRunning: probe.running,
680
+ install: installInfo(dataDir, env),
681
+ }
438
682
  }
439
683
 
440
684
  /**
@@ -444,40 +688,51 @@ function say(line) {
444
688
  export async function printConnection({ env, home, entry, version, cliPath }) {
445
689
  const dataDir = serveDataDir(env, home)
446
690
  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) {
691
+ keepLauncher(dataDir, cliPath, env)
692
+ const d = await describe({ env, dataDir, state, entry, version })
693
+ if (!d) {
453
694
  say(`cannot read the protocol version of the host at ${entry}`)
454
695
  return 1
455
696
  }
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
- )
697
+ process.stdout.write(`${connectionLine(d)}\n`)
466
698
  return 0
467
699
  }
468
700
 
701
+ /** serve.pid: `{ pid, hostPid, port }` of the launcher that runs, or null */
702
+ export function readServePid(dataDir) {
703
+ try {
704
+ const v = JSON.parse(readFileSync(join(dataDir, SERVE_PID_FILE), 'utf8'))
705
+ return Number.isInteger(v?.pid) && v.pid > 0 ? { pid: v.pid, hostPid: Number.isInteger(v.hostPid) ? v.hostPid : null, port: v.port ?? null } : null
706
+ } catch {
707
+ return null
708
+ }
709
+ }
710
+
711
+ function removeServePid(dataDir, pid) {
712
+ if (readServePid(dataDir)?.pid !== pid) return
713
+ try {
714
+ rmSync(join(dataDir, SERVE_PID_FILE), { force: true })
715
+ } catch {
716
+ // another serve's by now, or gone
717
+ }
718
+ }
719
+
469
720
  /**
470
721
  * `centralu serve`. Resolves with the exit code once the host has exited.
471
722
  *
472
723
  * The host's stdout is read here and never passed on: its ready line carries the token, which is
473
724
  * printed nowhere but `--connection`. Every other line the host writes to stdout (a lock conflict,
474
725
  * a store from a newer build) it also writes to stderr, which is passed through untouched.
726
+ *
727
+ * `logFd` is serve.log of a detached launcher (`runDetachedChild`): the host's stderr goes there
728
+ * until it is ready, so a host that dies while starting (a native module that does not load, a store
729
+ * from a newer build) says why in a file `--detach` can quote. From then on host.log has every line,
730
+ * and serve.log stops growing.
475
731
  */
476
- export async function runServe({ env, home, entry, version, cliPath, port: askedPort, platform = process.platform, execPath = process.execPath }) {
732
+ export async function runServe({ env, home, entry, version, cliPath, port: askedPort, platform = process.platform, execPath = process.execPath, logFd = null }) {
477
733
  const dataDir = serveDataDir(env, home)
478
734
  const state = ensureServeState(dataDir, platform)
479
- keepLauncher(dataDir, cliPath)
480
- const port = askedPort ?? state.port ?? DEFAULT_SERVE_PORT
735
+ keepLauncher(dataDir, cliPath, env)
481
736
  const protocolVersion = protocolVersionOf(entry)
482
737
 
483
738
  // A serve that is already up answers on the port it recorded; say so before starting a host that
@@ -490,15 +745,45 @@ export async function runServe({ env, home, entry, version, cliPath, port: asked
490
745
  return 1
491
746
  }
492
747
 
748
+ const onWindows = await windowsListening(env, platform)
749
+ const chosen = await chooseServePort({ asked: askedPort, recorded: state.port, taken: async (p) => onWindows.has(p) || (await listensHere(p)) })
750
+ if (chosen === null) {
751
+ say(`another program listens on 127.0.0.1:${recorded} and on the ${SERVE_PORT_TRIES - 1} ports above it. Give a free one with --port <n>.`)
752
+ return 1
753
+ }
754
+ const port = chosen.port
755
+ if (chosen.from !== null) {
756
+ const who = onWindows.has(chosen.from) ? 'Windows' : 'another program'
757
+ say(`127.0.0.1:${chosen.from} is in use by ${who}; starting on ${port} instead (kept for the next start)`)
758
+ }
759
+
493
760
  const plan = hostCommand(execPath, entry, { port, dataDir, platform })
494
761
  const child = spawn(plan.command, plan.args, {
495
- stdio: ['pipe', 'pipe', 'inherit'],
762
+ stdio: ['pipe', 'pipe', logFd === null ? 'inherit' : 'pipe'],
496
763
  env: hostEnv(env, { token: state.token, dataDir, version, hostDir: dirname(entry) }),
497
764
  detached: plan.detached,
498
765
  windowsHide: true,
499
766
  })
500
767
 
501
768
  let ready = false
769
+ if (logFd !== null) {
770
+ // Read to the end in any case: a pipe nobody drains stops the host once its buffer is full
771
+ child.stderr.on('data', (b) => {
772
+ if (ready) return
773
+ try {
774
+ writeSync(logFd, b)
775
+ } catch {
776
+ // the log is a convenience
777
+ }
778
+ })
779
+ }
780
+ if (child.pid) {
781
+ try {
782
+ writeFileSync(join(dataDir, SERVE_PID_FILE), `${JSON.stringify({ pid: process.pid, hostPid: child.pid, port })}\n`)
783
+ } catch (e) {
784
+ say(`could not write ${SERVE_PID_FILE}: ${e.message}`)
785
+ }
786
+ }
502
787
  createInterface({ input: child.stdout }).on('line', (line) => {
503
788
  const p = readyPort(line)
504
789
  if (p === null || ready) return
@@ -509,7 +794,8 @@ export async function runServe({ env, home, entry, version, cliPath, port: asked
509
794
  say(`could not record the port in ${join(dataDir, SERVE_STATE_FILE)}: ${e.message}`)
510
795
  }
511
796
  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`)
797
+ if (logFd === null) say(`from your computer: ssh -N -L ${p}:127.0.0.1:${p} <this machine>, or let the app connect over SSH`)
798
+ else say(`the host's own log continues in ${join(dataDir, 'host.log')}`)
513
799
  })
514
800
 
515
801
  /*
@@ -532,11 +818,13 @@ export async function runServe({ env, home, entry, version, cliPath, port: asked
532
818
 
533
819
  return await new Promise((resolve) => {
534
820
  child.on('error', (e) => {
821
+ removeServePid(dataDir, process.pid)
535
822
  say(`could not start the host (${plan.command}): ${e.message}`)
536
823
  resolve(1)
537
824
  })
538
825
  child.on('exit', (code, signal) => {
539
826
  for (const s of signals) process.off(s, forward)
827
+ removeServePid(dataDir, process.pid)
540
828
  if (!ready && code !== 0) {
541
829
  const pid = lockHolderPid(dataDir)
542
830
  if (pid && pid !== child.pid && isAlive(pid)) {
@@ -546,6 +834,7 @@ export async function runServe({ env, home, entry, version, cliPath, port: asked
546
834
  )
547
835
  }
548
836
  }
837
+ if (logFd !== null) say(`the host exited (${signal ?? code})`)
549
838
  resolve(code ?? (signal ? 1 : 0))
550
839
  })
551
840
  })
@@ -559,3 +848,473 @@ export function rotateToken({ env, home }) {
559
848
  say('A serve that is running keeps the old one until it restarts; clients read the new one with --connection.')
560
849
  return 0
561
850
  }
851
+
852
+ // ── --detach ────────────────────────────────────────────────────────────────────────────────
853
+
854
+ /**
855
+ * What a detached launcher needs from the `--detach` that started it. A process created through WMI
856
+ * does not inherit this environment: it gets the account's default one. Everything else it needs
857
+ * (the token, the port) is in the data folder. Never a secret.
858
+ */
859
+ export const CARRIED_ENV = ['CC_DATA_DIR', 'CENTRALU_HOST_ENTRY', 'CENTRALU_MANAGED']
860
+
861
+ export function encodeChildSpec({ env, log }) {
862
+ const carried = {}
863
+ for (const k of CARRIED_ENV) if (typeof env[k] === 'string' && env[k]) carried[k] = env[k]
864
+ return Buffer.from(JSON.stringify({ log, env: carried }), 'utf8').toString('base64url')
865
+ }
866
+
867
+ /** `{ log, env }` from `--detached-child=`, only the carried variables; null when it is not one */
868
+ export function decodeChildSpec(raw) {
869
+ try {
870
+ const v = JSON.parse(Buffer.from(String(raw), 'base64url').toString('utf8'))
871
+ if (typeof v?.log !== 'string' || !v.log) return null
872
+ const env = {}
873
+ for (const k of CARRIED_ENV) if (typeof v.env?.[k] === 'string') env[k] = v.env[k]
874
+ return { log: v.log, env }
875
+ } catch {
876
+ return null
877
+ }
878
+ }
879
+
880
+ /**
881
+ * How `--detach` starts the launcher so that it outlives the session that asked (measured,
882
+ * docs/plans/remote-hub.md §10.4):
883
+ *
884
+ * setsid posix: a new session, no terminal, nothing from the ssh session's process group. Once
885
+ * `--detach` returns, the launcher is reparented to init (the second fork of the usual
886
+ * double fork); it opens no terminal, so it can never get a controlling one back
887
+ * wmi Windows: `Win32_Process.Create`. Windows' OpenSSH ends every process of a session with
888
+ * it, `Start-Process` included; a process WMI creates is not part of the session
889
+ * wsl inside WSL: the same WMI call from the Windows side, creating `wsl.exe -d <distro>
890
+ * --exec <launcher>`. A `setsid` launcher inside the distro survives the session but not the
891
+ * distro, which WSL stops about 15 s after its last `wsl.exe` client exits; this
892
+ * `wsl.exe` is a client for as long as the host runs, and ending it ends the host
893
+ */
894
+ export function detachHow(platform, env, interop = wslInterop) {
895
+ if (platform === 'win32') return 'wmi'
896
+ if (platform === 'linux' && env.WSL_DISTRO_NAME && interop()) return 'wsl'
897
+ return 'setsid'
898
+ }
899
+
900
+ function wslInterop() {
901
+ return existsSync('/proc/sys/fs/binfmt_misc/WSLInterop') || existsSync('/proc/sys/fs/binfmt_misc/WSLInterop-late')
902
+ }
903
+
904
+ /**
905
+ * One argument for a Windows command line, as `CommandLineToArgvW` and the C runtime read it: always
906
+ * quoted, backslashes before the closing quote doubled. A double quote or a line break cannot be a
907
+ * path on Windows and is refused rather than escaped.
908
+ */
909
+ export function windowsArg(arg) {
910
+ const s = String(arg)
911
+ if (/["\r\n\0]/.test(s)) throw new Error(`cannot pass ${JSON.stringify(s)} on a Windows command line`)
912
+ return `"${s.replace(/(\\+)$/, '$1$1')}"`
913
+ }
914
+
915
+ /** The PowerShell that asks WMI to create `commandLine`. Values cross as base64, so no quoting can break */
916
+ export function wmiScript({ commandLine, cwd = null }) {
917
+ const text = (s) => `[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('${Buffer.from(s, 'utf8').toString('base64')}'))`
918
+ return [
919
+ "$ErrorActionPreference = 'Stop'",
920
+ '[Console]::OutputEncoding = [Text.Encoding]::UTF8',
921
+ `$a = @{ CommandLine = ${text(commandLine)} }`,
922
+ ...(cwd ? [`$a.CurrentDirectory = ${text(cwd)}`] : []),
923
+ // No console window when this runs from a desktop session; left out where it cannot be built
924
+ 'try { $a.ProcessStartupInformation = New-CimInstance -ClassName Win32_ProcessStartup -ClientOnly -Property @{ ShowWindow = [uint16]0 } } catch { }',
925
+ 'try {',
926
+ ' $r = Invoke-CimMethod -ClassName Win32_Process -MethodName Create -Arguments $a',
927
+ " if ($r.ReturnValue -eq 21 -and $a.ContainsKey('ProcessStartupInformation')) { $a.Remove('ProcessStartupInformation'); $r = Invoke-CimMethod -ClassName Win32_Process -MethodName Create -Arguments $a }",
928
+ ' "CENTRALU-WMI $($r.ReturnValue) $($r.ProcessId)"',
929
+ '} catch { "CENTRALU-WMI-ERROR $($_.Exception.Message)" }',
930
+ ].join('\n')
931
+ }
932
+
933
+ /** `Win32_Process.Create`'s return values (Microsoft's documentation of the method) */
934
+ const WMI_CODES = { 2: 'access denied', 3: 'insufficient privilege', 8: 'unknown failure', 9: 'path not found', 21: 'invalid parameter' }
935
+
936
+ /** What the WMI script said: `{ ok: true, pid }` or `{ ok: false, message }` */
937
+ export function parseWmiAnswer(stdout) {
938
+ const text = String(stdout ?? '')
939
+ const m = /CENTRALU-WMI (\d+) (\d*)/.exec(text)
940
+ if (m) {
941
+ const rv = Number(m[1])
942
+ if (rv === 0 && Number(m[2]) > 0) return { ok: true, pid: Number(m[2]) }
943
+ return { ok: false, message: `WMI's Win32_Process.Create returned ${rv}${WMI_CODES[rv] ? ` (${WMI_CODES[rv]})` : ''}` }
944
+ }
945
+ const e = /CENTRALU-WMI-ERROR (.*)/.exec(text)
946
+ if (e) return { ok: false, message: `WMI refused to create the process: ${e[1].trim()}` }
947
+ return { ok: false, message: 'PowerShell did not answer the WMI request' }
948
+ }
949
+
950
+ /**
951
+ * The sentence for a start WMI did not make. Some managed machines block process creation through
952
+ * WMI (Microsoft Defender's attack surface reduction rule "Block process creations originating from
953
+ * PSExec and WMI commands", off by default); the hub then runs the host bound to its link
954
+ * (docs/plans/remote-hub.md §10.9, decision 3).
955
+ */
956
+ export function wmiBlockedMessage(detail) {
957
+ return (
958
+ `Windows did not start Centralu through WMI (${detail}). Some managed Windows machines block it ` +
959
+ '(Microsoft Defender\'s attack surface reduction rule "Block process creations originating from PSExec and WMI commands"). ' +
960
+ 'Centralu can run there while this computer is linked instead, and stops when the link does.'
961
+ )
962
+ }
963
+
964
+ /** Windows PowerShell 5.1, by full path: under %SystemRoot% on Windows, through the drive WSL mounts inside a distro */
965
+ export function windowsPowershell(how, env, mounts) {
966
+ return windowsTool(how, env, ['System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe'], mounts)
967
+ }
968
+
969
+ /** A program under the Windows folder, by full path, from Windows or from inside a WSL distro; null when unreachable */
970
+ function windowsTool(how, env, rel, mounts = () => readFileSync('/proc/mounts', 'utf8')) {
971
+ if (how === 'wmi') return [env.SystemRoot || env.SYSTEMROOT || 'C:\\Windows', ...rel].join('\\')
972
+ let text = ''
973
+ try {
974
+ text = mounts()
975
+ } catch {
976
+ // no /proc: not WSL after all
977
+ }
978
+ for (const line of text.split('\n')) {
979
+ const [, at, type] = line.split(' ')
980
+ if (!at || !(type === '9p' || type === 'drvfs')) continue
981
+ const candidate = [at, 'Windows', ...rel].join('/')
982
+ if (existsSync(candidate)) return candidate
983
+ }
984
+ return null
985
+ }
986
+
987
+ function runCaptured(command, args, env) {
988
+ return new Promise((resolve) => {
989
+ let stdout = ''
990
+ let stderr = ''
991
+ let child
992
+ try {
993
+ child = spawn(command, args, { env, stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true })
994
+ } catch (e) {
995
+ return resolve({ code: null, stdout, stderr: e.message })
996
+ }
997
+ child.stdout.on('data', (b) => (stdout += String(b)))
998
+ child.stderr.on('data', (b) => (stderr += String(b)))
999
+ child.once('error', (e) => resolve({ code: null, stdout, stderr: e.message }))
1000
+ child.once('close', (code) => resolve({ code, stdout, stderr }))
1001
+ })
1002
+ }
1003
+
1004
+ /**
1005
+ * Starts the launcher outside this session (`detachHow`). `{ ok: true, pid, exited?, alive? }` once
1006
+ * something was created, `{ ok: false, blocked, message }` when nothing was: `blocked` is the WMI
1007
+ * refusal the hub falls back from.
1008
+ */
1009
+ export async function startDetached({ how, execPath, args, env, cwd }) {
1010
+ if (how === 'setsid') {
1011
+ return await new Promise((resolve) => {
1012
+ let child
1013
+ try {
1014
+ child = spawn(execPath, args, { detached: true, stdio: 'ignore', cwd, env, windowsHide: true })
1015
+ } catch (e) {
1016
+ return resolve({ ok: false, blocked: false, message: `could not start ${execPath}: ${e.message}` })
1017
+ }
1018
+ child.once('error', (e) => resolve({ ok: false, blocked: false, message: `could not start ${execPath}: ${e.message}` }))
1019
+ child.once('spawn', () => {
1020
+ const exited = new Promise((r) => child.once('exit', (code, signal) => r(signal ?? code)))
1021
+ child.unref()
1022
+ resolve({ ok: true, pid: child.pid, exited })
1023
+ })
1024
+ })
1025
+ }
1026
+ const ps = windowsPowershell(how, env)
1027
+ if (!ps) return { ok: false, blocked: true, message: wmiBlockedMessage('Windows PowerShell is not reachable from this WSL distro') }
1028
+ let commandLine
1029
+ try {
1030
+ if (how === 'wmi') commandLine = [execPath, ...args].map(windowsArg).join(' ')
1031
+ else {
1032
+ const distro = env.WSL_DISTRO_NAME ?? ''
1033
+ const user = userInfo().username
1034
+ if (!/^[A-Za-z0-9._-]{1,64}$/.test(distro)) throw new Error(`not a WSL distro name: ${distro}`)
1035
+ if (!/^[A-Za-z_][A-Za-z0-9_.-]{0,31}$/.test(user)) throw new Error(`cannot name the user ${user} to wsl.exe`)
1036
+ commandLine = ['wsl.exe', '-d', distro, '-u', user, '--cd', cwd, '--exec', execPath, ...args].map(windowsArg).join(' ')
1037
+ }
1038
+ } catch (e) {
1039
+ return { ok: false, blocked: false, message: e.message }
1040
+ }
1041
+ const script = wmiScript({ commandLine, cwd: how === 'wmi' ? cwd : null })
1042
+ const r = await runCaptured(ps, ['-NoProfile', '-NonInteractive', '-EncodedCommand', Buffer.from(script, 'utf16le').toString('base64')], env)
1043
+ const answer = parseWmiAnswer(r.stdout)
1044
+ if (!answer.ok) return { ok: false, blocked: true, message: wmiBlockedMessage(r.stdout.trim() || !r.stderr.trim() ? answer.message : r.stderr.trim().split(/\r?\n/).at(-1)) }
1045
+ // A Windows pid can be watched from Windows; from inside WSL it names nothing here
1046
+ /*
1047
+ * Measured on a Windows 11 laptop (2026-10-08): a `wsl.exe` that WMI creates can hang without
1048
+ * running anything in the distro (it starts a second wsl.exe and waits), while `wsl.exe --list`
1049
+ * from the same context answers. `--detach` reports that as a start that never ran, and ends what
1050
+ * it created rather than leave it waiting: this pid and its children, nothing else.
1051
+ */
1052
+ const taskkill = windowsTool(how, env, ['System32', 'taskkill.exe'])
1053
+ const stop = taskkill ? () => runCaptured(taskkill, ['/PID', String(answer.pid), '/T', '/F'], env) : null
1054
+ return { ok: true, pid: answer.pid, alive: how === 'wmi' ? () => isAlive(answer.pid) : null, stop }
1055
+ }
1056
+
1057
+ /** The last lines of a file, for a reason; empty when it cannot be read */
1058
+ function tail(file, n = 15) {
1059
+ try {
1060
+ return readFileSync(file, 'utf8').trimEnd().split(/\r?\n/).slice(-n).join('\n')
1061
+ } catch {
1062
+ return ''
1063
+ }
1064
+ }
1065
+
1066
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
1067
+
1068
+ /**
1069
+ * `centralu serve --detach` (docs/plans/remote-hub.md §10.4, S9): starts `serve` outside this
1070
+ * session (`detachHow`) and returns once its host answers a hello, so the caller, typically the
1071
+ * hub over ssh, can connect at once.
1072
+ *
1073
+ * stdout carries one JSON line, read by the hub (`links/tunnel.ts`): the connection line with
1074
+ * `hostRunning: true` and `detach: { ok: true, how, already }`, or `{ v: 1, detach: { ok: false,
1075
+ * how, reason, message } }`. `reason` is `wmi_blocked` when WMI created nothing or created something
1076
+ * that never ran, which the hub answers with a link-bound start; `exited` and `timeout` quote the
1077
+ * end of serve.log. Exit codes do not survive Windows' ssh (agent-host.md §4.8), so the line is
1078
+ * what counts; the exit code is 0 or 1 for a person at a prompt.
1079
+ *
1080
+ * `start` and the two waits are parameters so tests can stand in for WMI.
1081
+ */
1082
+ export async function runDetach({
1083
+ env,
1084
+ home,
1085
+ entry,
1086
+ version,
1087
+ cliPath,
1088
+ port = null,
1089
+ platform = process.platform,
1090
+ execPath = process.execPath,
1091
+ cwd = process.cwd(),
1092
+ start = startDetached,
1093
+ how = detachHow(platform, env),
1094
+ timeoutMs = 30_000,
1095
+ noStartMs = 10_000,
1096
+ write = (line) => process.stdout.write(line),
1097
+ }) {
1098
+ const dataDir = serveDataDir(env, home)
1099
+ // Created here, so the token exists before the launcher reads it and `answered` reads it again
1100
+ ensureServeState(dataDir, platform)
1101
+ keepLauncher(dataDir, cliPath, env)
1102
+ const fail = (reason, message) => {
1103
+ say(message)
1104
+ write(`${JSON.stringify({ v: 1, detach: { ok: false, how, reason, message } })}\n`)
1105
+ return 1
1106
+ }
1107
+ const answered = async (already) => {
1108
+ const d = await describe({ env, dataDir, state: ensureServeState(dataDir, platform), entry, version })
1109
+ if (!d?.hostRunning) return false
1110
+ write(`${connectionLine({ ...d, detach: { ok: true, how, already } })}\n`)
1111
+ return true
1112
+ }
1113
+ if (await answered(true)) return 0
1114
+
1115
+ const log = join(dataDir, SERVE_LOG_FILE)
1116
+ // One start per log: the last one's is kept beside it, and its absence is how a start that never ran shows
1117
+ try {
1118
+ renameSync(log, `${log}.1`)
1119
+ } catch {
1120
+ // there was none
1121
+ }
1122
+ const args = [cliPath, 'serve', ...(port !== null ? ['--port', String(port)] : []), `${CHILD_FLAG}${encodeChildSpec({ env, log })}`]
1123
+ let started
1124
+ try {
1125
+ started = await start({ how, execPath, args, env, cwd })
1126
+ } catch (e) {
1127
+ return fail('failed', `could not start centralu serve in the background: ${e.message}`)
1128
+ }
1129
+ if (!started.ok) return fail(started.blocked ? 'wmi_blocked' : 'failed', started.message)
1130
+ say(`started centralu serve in the background (${how}, pid ${started.pid ?? 'unknown'}); waiting for its host`)
1131
+
1132
+ let exited
1133
+ started.exited?.then((c) => (exited = c ?? 'unknown'))
1134
+ const t0 = Date.now()
1135
+ while (Date.now() - t0 < timeoutMs) {
1136
+ if (await answered(false)) return 0
1137
+ const why = tail(log)
1138
+ if (exited !== undefined) return fail('exited', `centralu serve exited (${exited}) before its host answered${why ? `:\n${why}` : ''}`)
1139
+ const gone = started.alive?.() === false
1140
+ if (how !== 'setsid' && !existsSync(log) && (gone || Date.now() - t0 > noStartMs)) {
1141
+ // WMI said yes, and nothing ran: the process was stopped before Node started, or hangs (`startDetached`)
1142
+ if (!gone) await started.stop?.()
1143
+ return fail('wmi_blocked', wmiBlockedMessage(gone ? `process ${started.pid} ended before it started` : `nothing started within ${Math.round(noStartMs / 1000)} s`))
1144
+ }
1145
+ if (gone) return fail('exited', `centralu serve exited before its host answered${why ? `:\n${why}` : ''}`)
1146
+ await sleep(250)
1147
+ }
1148
+ const why = tail(log)
1149
+ return fail('timeout', `the host did not answer within ${Math.round(timeoutMs / 1000)} s${why ? `:\n${why}` : ''}`)
1150
+ }
1151
+
1152
+ /**
1153
+ * The launcher `--detach` started: `runServe` writing to serve.log. The carried variables are
1154
+ * already in `env` (centralu.mjs merges them before it looks for the host).
1155
+ */
1156
+ export async function runDetachedChild({ spec, ...opts }) {
1157
+ let fd = null
1158
+ try {
1159
+ fd = openSync(spec.log, 'a')
1160
+ sink = (text) => {
1161
+ try {
1162
+ writeSync(fd, text)
1163
+ } catch {
1164
+ // nowhere left to say it
1165
+ }
1166
+ }
1167
+ } catch {
1168
+ // no log: say() keeps stderr, which nobody reads; --detach then reports the start as never run
1169
+ }
1170
+ say(`started in the background (pid ${process.pid}, Node ${process.version})`)
1171
+ try {
1172
+ return await runServe({ ...opts, logFd: fd })
1173
+ } catch (e) {
1174
+ say(e?.message ?? String(e))
1175
+ return 1
1176
+ }
1177
+ }
1178
+
1179
+ // ── --stop ──────────────────────────────────────────────────────────────────────────────────
1180
+
1181
+ /**
1182
+ * Asks a running host to stop (`host.stop`, an additive RPC): a hello with the token, then the call.
1183
+ * `{ ok: true }` once the host took it, `{ ok: false, message, protocolVersion? }` otherwise; the
1184
+ * last names the host's protocol when the hello was refused for it, so the caller can ask again.
1185
+ */
1186
+ export function askHostToStop({ port, token, protocolVersion, timeoutMs = 5000, WebSocketImpl = globalThis.WebSocket }) {
1187
+ if (typeof WebSocketImpl !== 'function') return Promise.resolve({ ok: false, message: `Node ${process.version} has no WebSocket to ask with` })
1188
+ return new Promise((resolve) => {
1189
+ let ws = null
1190
+ let settled = false
1191
+ const done = (result) => {
1192
+ if (settled) return
1193
+ settled = true
1194
+ clearTimeout(timer)
1195
+ try {
1196
+ ws?.close()
1197
+ } catch {
1198
+ // already closed
1199
+ }
1200
+ resolve(result)
1201
+ }
1202
+ const timer = setTimeout(() => done({ ok: false, message: 'no answer in time' }), timeoutMs)
1203
+ try {
1204
+ ws = new WebSocketImpl(`ws://127.0.0.1:${port}`)
1205
+ } catch (e) {
1206
+ done({ ok: false, message: e.message })
1207
+ return
1208
+ }
1209
+ ws.onopen = () => ws.send(JSON.stringify({ kind: 'hello', token, protocolVersion: protocolVersion ?? 0 }))
1210
+ ws.onmessage = (ev) => {
1211
+ let m
1212
+ try {
1213
+ m = JSON.parse(String(ev.data))
1214
+ } catch {
1215
+ return
1216
+ }
1217
+ if (m?.kind === 'hello_ok') ws.send(JSON.stringify({ kind: 'rpc', id: 'stop', method: 'host.stop', params: {} }))
1218
+ else if (m?.kind === 'res' && m.id === 'stop') done(m.ok ? { ok: true } : { ok: false, message: m.error?.message ?? 'refused' })
1219
+ else if (m?.kind === 'res' && m.ok === false) {
1220
+ const pv = m.error?.code === 'version_mismatch' ? (m.error?.data?.protocolVersion ?? mismatchServerVersion(m.error.message)) : null
1221
+ done({ ok: false, message: m.error?.message ?? 'refused', ...(Number.isInteger(pv) ? { protocolVersion: pv } : {}) })
1222
+ }
1223
+ }
1224
+ ws.onerror = () => done({ ok: false, message: 'could not connect' })
1225
+ ws.onclose = () => done({ ok: false, message: 'the connection closed' })
1226
+ })
1227
+ }
1228
+
1229
+ async function waitUntil(pred, ms) {
1230
+ const t0 = Date.now()
1231
+ while (Date.now() - t0 < ms) {
1232
+ if (await pred()) return true
1233
+ await sleep(200)
1234
+ }
1235
+ return pred()
1236
+ }
1237
+
1238
+ /**
1239
+ * `centralu serve --stop` (docs/plans/remote-hub.md §10.4, S10): asks the running host to stop over
1240
+ * its own socket and waits until it has, so its shutdown runs (its agents, terminals and app
1241
+ * processes stopped in order, the store closed). Measured on Windows: killing the WMI-started
1242
+ * launcher ended the host 143 ms later with no "shutting down" line, because the host sits in
1243
+ * libuv's kill-on-close job object; so on Windows killing is the last resort, not the way.
1244
+ *
1245
+ * Then, only for a host that did not go: posix SIGTERM to the launcher (which passes it to the host,
1246
+ * the same clean stop), and on Windows ending the host. A host from before `host.stop` gets that
1247
+ * too. The launcher ends with its host; one still there afterwards is ended.
1248
+ *
1249
+ * Only the processes serve.pid and host.lock name, and only while they agree with each other and
1250
+ * the host answered with this folder's token: a stale pid is never signalled.
1251
+ *
1252
+ * stdout: one line, `{ v: 1, stop: { ok, wasRunning, how?, message? } }`.
1253
+ */
1254
+ export async function runStop({
1255
+ env,
1256
+ home,
1257
+ entry,
1258
+ platform = process.platform,
1259
+ timeoutMs = 30_000,
1260
+ graceMs = 15_000,
1261
+ kill = (pid, sig) => process.kill(pid, sig),
1262
+ write = (line) => process.stdout.write(line),
1263
+ }) {
1264
+ const dataDir = serveDataDir(env, home)
1265
+ const report = (stop) => {
1266
+ if (stop.message) say(stop.message)
1267
+ write(`${JSON.stringify({ v: 1, stop })}\n`)
1268
+ return stop.ok ? 0 : 1
1269
+ }
1270
+ const state = readServeState(dataDir)
1271
+ if (!state) return report({ ok: true, wasRunning: false, message: `no centralu serve has run for ${dataDir}` })
1272
+ const port = state.port ?? DEFAULT_SERVE_PORT
1273
+ const installed = protocolVersionOf(entry) ?? 0
1274
+ const probe = () => probeHost({ port, token: state.token, protocolVersion: installed })
1275
+ const first = await probe()
1276
+ if (!first.running) return report({ ok: true, wasRunning: false, message: `centralu serve is not running for ${dataDir}` })
1277
+
1278
+ const hostPid = lockHolderPid(dataDir)
1279
+ const launcher = readServePid(dataDir)
1280
+ // serve.pid is only believed when it names the host that holds the folder now
1281
+ const launcherPid = launcher && hostPid && launcher.hostPid === hostPid ? launcher.pid : null
1282
+ const down = async () => !(await probe()).running && !(hostPid && isAlive(hostPid))
1283
+
1284
+ let asked = await askHostToStop({ port, token: state.token, protocolVersion: first.protocolVersion ?? installed })
1285
+ if (!asked.ok && asked.protocolVersion !== undefined) asked = await askHostToStop({ port, token: state.token, protocolVersion: asked.protocolVersion })
1286
+ let how = 'asked'
1287
+ let stopped = asked.ok && (await waitUntil(down, timeoutMs))
1288
+ let note = asked.ok ? null : `the host did not take the request to stop (${asked.message})`
1289
+ if (!stopped) {
1290
+ const target = platform !== 'win32' && launcherPid ? launcherPid : hostPid
1291
+ if (target) {
1292
+ how = platform === 'win32' ? 'ended' : 'signal'
1293
+ try {
1294
+ kill(target, 'SIGTERM')
1295
+ } catch {
1296
+ // gone in the meantime
1297
+ }
1298
+ stopped = await waitUntil(down, platform === 'win32' ? 5_000 : graceMs)
1299
+ if (!stopped && hostPid && isAlive(hostPid)) {
1300
+ how = 'ended'
1301
+ try {
1302
+ kill(hostPid, 'SIGKILL')
1303
+ } catch {
1304
+ // gone in the meantime
1305
+ }
1306
+ stopped = await waitUntil(down, 5_000)
1307
+ }
1308
+ }
1309
+ if (how === 'ended') note = `${note ? `${note}; ` : ''}ended the host without its shutdown`
1310
+ }
1311
+ if (launcherPid && launcherPid !== process.pid && !(await waitUntil(() => !isAlive(launcherPid), 5_000))) {
1312
+ try {
1313
+ kill(launcherPid, 'SIGKILL')
1314
+ } catch {
1315
+ // gone in the meantime
1316
+ }
1317
+ }
1318
+ if (!stopped) return report({ ok: false, wasRunning: true, how, message: `the host on 127.0.0.1:${port} is still running${note ? ` (${note})` : ''}` })
1319
+ return report({ ok: true, wasRunning: true, how, ...(note ? { message: note } : {}) })
1320
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "centralu",
3
- "version": "0.1.0-beta.12",
3
+ "version": "0.1.0-beta.14",
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.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"
40
+ "@centralu/darwin-arm64": "0.1.0-beta.14",
41
+ "@centralu/linux-arm64": "0.1.0-beta.14",
42
+ "@centralu/linux-x64": "0.1.0-beta.14",
43
+ "@centralu/win32-x64": "0.1.0-beta.14"
44
44
  }
45
45
  }