centralu 0.1.0-beta.12 → 0.1.0-beta.13

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'
20
+ import { chmodSync, existsSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, statSync, writeFileSync, writeSync, closeSync } from 'node:fs'
21
21
  import { connect } 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
 
@@ -43,36 +44,62 @@ export const DEFAULT_SERVE_PORT = 17175
43
44
  */
44
45
  export const SERVE_STATE_FILE = 'serve.json'
45
46
 
47
+ /** What a detached serve writes: its own lines, and the host's until it is ready (host.log has the rest) */
48
+ export const SERVE_LOG_FILE = 'serve.log'
49
+
50
+ /** The running launcher's pid and port, so `--stop` can find it. Removed when it exits */
51
+ export const SERVE_PID_FILE = 'serve.pid'
52
+
46
53
  export const SERVE_HELP = `centralu serve: run the Centralu host on this machine, without a window
47
54
 
48
55
  centralu serve Start the host in the foreground, on 127.0.0.1 only
49
56
  centralu serve --port <n> ... on that port (kept for the next start; default ${DEFAULT_SERVE_PORT})
57
+ centralu serve --detach Start it in the background, outside this session; return once it answers
58
+ centralu serve --stop Ask the running host to stop, and wait until it has
50
59
  centralu serve --connection Print how to reach it as one JSON line, then exit
51
60
  centralu serve --rotate-token Replace the token (a running serve keeps the old one until restarted)
52
61
 
53
62
  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.
63
+ this host through an SSH local forward, and starts it with --detach when it is not running.
64
+ Stop a foreground one with Ctrl+C or SIGTERM, a detached one with --stop. A detached one writes
65
+ how its start went to serve.log in the data folder; the host's own log is host.log.
66
+
67
+ serve, --detach and --connection keep ~/.centralu/bin/centralu up to date: a launcher with absolute
68
+ paths, for SSH sessions whose PATH has neither npm's global folder nor node. Not when they run
69
+ from an install the app made over SSH, which has a launcher of its own.`
70
+
71
+ /** The flags that each make serve a different command */
72
+ const MODE_FLAGS = { '--connection': 'connection', '--rotate-token': 'rotate', '--detach': 'detach', '--stop': 'stop' }
73
+ const FLAG_OF = Object.fromEntries(Object.entries(MODE_FLAGS).map(([f, m]) => [m, f]))
55
74
 
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.`
75
+ /**
76
+ * The argument a detached launcher is started with: what it needs from the `--detach` that started
77
+ * it, which a process created through WMI does not inherit (it gets the account's default
78
+ * environment, not this one). Never a secret: the token stays in serve.json. Internal, not in the help.
79
+ */
80
+ const CHILD_FLAG = '--detached-child='
58
81
 
59
82
  /**
60
- * What the arguments after `serve` ask for: `{ mode, port }` or `{ error }`.
83
+ * What the arguments after `serve` ask for: `{ mode, port }` (plus `child` for a detached launcher)
84
+ * or `{ error }`.
61
85
  *
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.
86
+ * `mode` is `serve`, `detach`, `stop`, `connection`, `rotate` or `help`; `port` is null when not
87
+ * given. `--port` goes with `serve` and `--detach` only: `--connection` has to report the port
88
+ * `serve` uses, not one the caller names, and `--stop` stops the one that runs.
64
89
  */
65
90
  export function parseServeArgs(argv) {
66
91
  let mode = 'serve'
67
92
  let port = null
93
+ let help = false
94
+ let child = null
68
95
  for (let i = 0; i < argv.length; i++) {
69
96
  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'
97
+ if (Object.hasOwn(MODE_FLAGS, a)) {
98
+ const m = MODE_FLAGS[a]
99
+ if (mode !== 'serve' && mode !== m) return { error: `${FLAG_OF[mode]} and ${a} are separate commands; pass one` }
100
+ mode = m
101
+ } else if (a === '--help' || a === '-h') help = true
102
+ else if (a.startsWith(CHILD_FLAG)) child = a.slice(CHILD_FLAG.length)
76
103
  else if (a === '--port' || a.startsWith('--port=')) {
77
104
  const raw = a === '--port' ? argv[++i] : a.slice('--port='.length)
78
105
  const n = parsePort(raw)
@@ -80,9 +107,15 @@ export function parseServeArgs(argv) {
80
107
  port = n
81
108
  } else return { error: `unknown option for serve: ${a}\n\n${SERVE_HELP}` }
82
109
  }
110
+ if (help) return { mode: 'help', port }
111
+ if (child !== null) {
112
+ if (mode !== 'serve') return { error: `${CHILD_FLAG.slice(0, -1)} is how --detach starts serve, not an option of ${FLAG_OF[mode]}` }
113
+ return { mode, port, child }
114
+ }
83
115
  if ((mode === 'connection' || mode === 'rotate') && port !== null) {
84
116
  return { error: '--port goes with `centralu serve`; `--connection` reports the port serve listens on' }
85
117
  }
118
+ if (mode === 'stop' && port !== null) return { error: '--stop stops the serve that is running, on the port it recorded; it takes no --port' }
86
119
  return { mode, port }
87
120
  }
88
121
 
@@ -214,9 +247,16 @@ export function ensureLauncher(dataDir, { platform = process.platform, execPath
214
247
  return file
215
248
  }
216
249
 
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
250
+ /**
251
+ * ensureLauncher, with a failure said on stderr instead of thrown: the launcher is a convenience.
252
+ *
253
+ * Not under the managed launcher (`CENTRALU_MANAGED=1`, docs/plans/remote-hub.md §10.1): an install
254
+ * the hub made over ssh has its own launcher in `<data>/remote/bin/`, which reads `current`. Writing
255
+ * `<data>/bin/centralu` from there would point an npm install's launcher at one managed version, and
256
+ * the two would rewrite each other's file on every start.
257
+ */
258
+ function keepLauncher(dataDir, cliPath, env) {
259
+ if (!cliPath || env?.CENTRALU_MANAGED === '1') return
220
260
  try {
221
261
  ensureLauncher(dataDir, { cliPath })
222
262
  } catch (e) {
@@ -269,9 +309,121 @@ export function protocolVersionOf(entry) {
269
309
  return null
270
310
  }
271
311
 
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 })
312
+ /**
313
+ * The `--connection` answer. The shape is the client's interface (docs/agent-host.md §4.7).
314
+ * `install` (additive, the line stays `v: 1`) says what runs this command (`installInfo`); `detach`
315
+ * is added by `--detach` only.
316
+ */
317
+ export function connectionLine({ port, token, version, protocolVersion, dataDir, hostRunning, install, detach }) {
318
+ return JSON.stringify({
319
+ v: 1,
320
+ port,
321
+ token,
322
+ version,
323
+ protocolVersion,
324
+ dataDir,
325
+ hostRunning,
326
+ ...(install ? { install } : {}),
327
+ ...(detach ? { detach } : {}),
328
+ })
329
+ }
330
+
331
+ /**
332
+ * The install the hub manages on this machine (docs/plans/remote-hub.md §10.1): side-by-side
333
+ * versions, the pinned Node they run on, and two pointer files naming what runs and what to roll
334
+ * back to. The hub's installer writes it; `serve` only reads it.
335
+ */
336
+ export function remoteLayout(dataDir) {
337
+ const root = join(dataDir, 'remote')
338
+ return {
339
+ root,
340
+ current: join(root, 'current'),
341
+ previous: join(root, 'previous'),
342
+ versions: join(root, 'versions'),
343
+ node: join(root, 'node'),
344
+ bin: join(root, 'bin'),
345
+ }
346
+ }
347
+
348
+ /** A word a pointer file may hold: a folder name, nothing a path could escape with */
349
+ const POINTER_WORD = /^[0-9A-Za-z][0-9A-Za-z.+-]{0,63}$/
350
+
351
+ /** `current` / `previous`: one line, "<centralu version> <node version>". null for anything else */
352
+ export function parsePointer(text) {
353
+ const m = /^\s*(\S+)[ \t]+(\S+)\s*$/.exec(String(text ?? ''))
354
+ if (!m || !POINTER_WORD.test(m[1]) || !POINTER_WORD.test(m[2])) return null
355
+ return { version: m[1], node: m[2] }
356
+ }
357
+
358
+ /**
359
+ * The connection line's `install` field (docs/plans/remote-hub.md §10.5): whether this command was
360
+ * started by the managed launcher, the managed install's `current` and `previous`, and the Node
361
+ * running this command. Read from the machine at every ask, never kept by the hub (S12).
362
+ */
363
+ export function installInfo(dataDir, env, nodeVersion = process.versions.node) {
364
+ const l = remoteLayout(dataDir)
365
+ const read = (file) => {
366
+ try {
367
+ return parsePointer(readFileSync(file, 'utf8'))
368
+ } catch {
369
+ return null
370
+ }
371
+ }
372
+ return { managed: env.CENTRALU_MANAGED === '1', current: read(l.current), previous: read(l.previous), node: nodeVersion }
373
+ }
374
+
375
+ /**
376
+ * The managed launcher, `<data>/remote/bin/centralu` (`centralu.cmd` on Windows), as the installer
377
+ * writes it (docs/plans/remote-hub.md §10.1, S8).
378
+ *
379
+ * **Its text never changes.** It reads `current` at every start and runs that version's
380
+ * `centralu.mjs` on that version's Node, so switching versions is one file replaced by a rename,
381
+ * atomic on both systems. A launcher rewritten per version would be replaced while it may be
382
+ * running, and cmd.exe reads a batch file by offset as it runs. It sets `CENTRALU_MANAGED=1`, so
383
+ * the `serve` it starts leaves `<data>/bin/` to an npm install on the same machine.
384
+ *
385
+ * Both refuse a pointer that holds anything but plain version words, so an edited `current` cannot
386
+ * point it outside the install.
387
+ */
388
+ export function managedLauncherScript(platform) {
389
+ if (platform === 'win32') {
390
+ return (
391
+ [
392
+ '@echo off',
393
+ 'rem The Centralu launcher for the install the app manages over SSH. It never changes: it reads',
394
+ 'rem "current" at every start, so switching versions replaces that file and never this one.',
395
+ 'setlocal',
396
+ 'set "CENTRALU_REMOTE=%~dp0.."',
397
+ 'set "CENTRALU_LINE="',
398
+ 'set "CENTRALU_V="',
399
+ 'set "CENTRALU_N="',
400
+ 'set /p CENTRALU_LINE=<"%CENTRALU_REMOTE%\\current"',
401
+ 'for /f "tokens=1,2" %%a in ("%CENTRALU_LINE%") do (set "CENTRALU_V=%%a" & set "CENTRALU_N=%%b")',
402
+ 'if not defined CENTRALU_N goto none',
403
+ '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',
404
+ 'set CENTRALU_MANAGED=1',
405
+ '"%CENTRALU_REMOTE%\\node\\v%CENTRALU_N%\\node.exe" "%CENTRALU_REMOTE%\\versions\\%CENTRALU_V%\\node_modules\\centralu\\bin\\centralu.mjs" %*',
406
+ 'exit /b %ERRORLEVEL%',
407
+ ':none',
408
+ 'echo Centralu: no version is installed in %CENTRALU_REMOTE% 1>&2',
409
+ 'exit /b 1',
410
+ ].join('\r\n') + '\r\n'
411
+ )
412
+ }
413
+ return [
414
+ '#!/bin/sh',
415
+ '# The Centralu launcher for the install the app manages over SSH. It never changes: it reads',
416
+ '# `current` at every start, so switching versions replaces that file and never this one.',
417
+ 'remote=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd -P) || exit 1',
418
+ 'v= n=',
419
+ 'read -r v n < "$remote/current" 2>/dev/null',
420
+ 'case "$v" in ""|[!0-9A-Za-z]*|*[!0-9A-Za-z.+-]*) echo "Centralu: no version is installed in $remote" >&2; exit 1 ;; esac',
421
+ 'case "$n" in ""|[!0-9A-Za-z]*|*[!0-9A-Za-z.+-]*) echo "Centralu: no version is installed in $remote" >&2; exit 1 ;; esac',
422
+ 'CENTRALU_MANAGED=1',
423
+ 'export CENTRALU_MANAGED',
424
+ 'exec "$remote/node/v$n/bin/node" "$remote/versions/$v/node_modules/centralu/bin/centralu.mjs" "$@"',
425
+ '',
426
+ ].join('\n')
275
427
  }
276
428
 
277
429
  /**
@@ -285,12 +437,16 @@ export function connectionLine({ port, token, version, protocolVersion, dataDir,
285
437
  * - Headless: no `DISPLAY` / `WAYLAND_DISPLAY`. Nothing the host or an agent starts gets a screen
286
438
  * to open a window or a keyring prompt on, so a tool that would ask falls back to its file store
287
439
  * 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.
440
+ * - The keeper's and dev mode's variables are dropped: this host is neither. So is
441
+ * `CENTRALU_MANAGED`, which is the launcher's business and would otherwise reach every agent.
442
+ * - `CC_SERVE=1` lets the host take `host.stop` (`--stop`); a host the app runs refuses it.
289
443
  */
290
444
  export function hostEnv(env, { token, dataDir, version, hostDir }) {
291
445
  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]
446
+ 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
447
  out.CC_DATA_DIR = dataDir
448
+ // The host `host.stop` may end (`centralu serve --stop`): nothing else stops it in order on Windows
449
+ out.CC_SERVE = '1'
294
450
  out.CC_HOST_TOKEN = token
295
451
  out.CC_HOST_SOURCE = JSON.stringify({ version, bundlePath: hostDir })
296
452
  return out
@@ -432,9 +588,33 @@ function isAlive(pid) {
432
588
  }
433
589
  }
434
590
 
591
+ /**
592
+ * Where `say` writes: stderr, except in a detached launcher, which nobody reads the stderr of and
593
+ * which writes serve.log instead (`runDetachedChild`).
594
+ */
595
+ let sink = (text) => process.stderr.write(text)
596
+
435
597
  /** Writes a line to stderr, prefixed so it reads apart from the host's own log lines */
436
598
  function say(line) {
437
- process.stderr.write(`[centralu serve] ${line}\n`)
599
+ sink(`[centralu serve] ${line}\n`)
600
+ }
601
+
602
+ /** What `--connection` answers, as fields: the running host's versions when one answers, else the installed ones */
603
+ async function describe({ env, dataDir, state, entry, version }) {
604
+ const port = state.port ?? DEFAULT_SERVE_PORT
605
+ const installed = protocolVersionOf(entry)
606
+ const probe = await probeHost({ port, token: state.token, protocolVersion: installed })
607
+ const protocolVersion = (probe.running ? probe.protocolVersion : undefined) ?? installed
608
+ if (protocolVersion === null) return null
609
+ return {
610
+ port,
611
+ token: state.token,
612
+ version: (probe.running ? probe.version : undefined) ?? version,
613
+ protocolVersion,
614
+ dataDir,
615
+ hostRunning: probe.running,
616
+ install: installInfo(dataDir, env),
617
+ }
438
618
  }
439
619
 
440
620
  /**
@@ -444,39 +624,51 @@ function say(line) {
444
624
  export async function printConnection({ env, home, entry, version, cliPath }) {
445
625
  const dataDir = serveDataDir(env, home)
446
626
  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) {
627
+ keepLauncher(dataDir, cliPath, env)
628
+ const d = await describe({ env, dataDir, state, entry, version })
629
+ if (!d) {
453
630
  say(`cannot read the protocol version of the host at ${entry}`)
454
631
  return 1
455
632
  }
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
- )
633
+ process.stdout.write(`${connectionLine(d)}\n`)
466
634
  return 0
467
635
  }
468
636
 
637
+ /** serve.pid: `{ pid, hostPid, port }` of the launcher that runs, or null */
638
+ export function readServePid(dataDir) {
639
+ try {
640
+ const v = JSON.parse(readFileSync(join(dataDir, SERVE_PID_FILE), 'utf8'))
641
+ return Number.isInteger(v?.pid) && v.pid > 0 ? { pid: v.pid, hostPid: Number.isInteger(v.hostPid) ? v.hostPid : null, port: v.port ?? null } : null
642
+ } catch {
643
+ return null
644
+ }
645
+ }
646
+
647
+ function removeServePid(dataDir, pid) {
648
+ if (readServePid(dataDir)?.pid !== pid) return
649
+ try {
650
+ rmSync(join(dataDir, SERVE_PID_FILE), { force: true })
651
+ } catch {
652
+ // another serve's by now, or gone
653
+ }
654
+ }
655
+
469
656
  /**
470
657
  * `centralu serve`. Resolves with the exit code once the host has exited.
471
658
  *
472
659
  * The host's stdout is read here and never passed on: its ready line carries the token, which is
473
660
  * printed nowhere but `--connection`. Every other line the host writes to stdout (a lock conflict,
474
661
  * a store from a newer build) it also writes to stderr, which is passed through untouched.
662
+ *
663
+ * `logFd` is serve.log of a detached launcher (`runDetachedChild`): the host's stderr goes there
664
+ * until it is ready, so a host that dies while starting (a native module that does not load, a store
665
+ * from a newer build) says why in a file `--detach` can quote. From then on host.log has every line,
666
+ * and serve.log stops growing.
475
667
  */
476
- export async function runServe({ env, home, entry, version, cliPath, port: askedPort, platform = process.platform, execPath = process.execPath }) {
668
+ export async function runServe({ env, home, entry, version, cliPath, port: askedPort, platform = process.platform, execPath = process.execPath, logFd = null }) {
477
669
  const dataDir = serveDataDir(env, home)
478
670
  const state = ensureServeState(dataDir, platform)
479
- keepLauncher(dataDir, cliPath)
671
+ keepLauncher(dataDir, cliPath, env)
480
672
  const port = askedPort ?? state.port ?? DEFAULT_SERVE_PORT
481
673
  const protocolVersion = protocolVersionOf(entry)
482
674
 
@@ -492,13 +684,31 @@ export async function runServe({ env, home, entry, version, cliPath, port: asked
492
684
 
493
685
  const plan = hostCommand(execPath, entry, { port, dataDir, platform })
494
686
  const child = spawn(plan.command, plan.args, {
495
- stdio: ['pipe', 'pipe', 'inherit'],
687
+ stdio: ['pipe', 'pipe', logFd === null ? 'inherit' : 'pipe'],
496
688
  env: hostEnv(env, { token: state.token, dataDir, version, hostDir: dirname(entry) }),
497
689
  detached: plan.detached,
498
690
  windowsHide: true,
499
691
  })
500
692
 
501
693
  let ready = false
694
+ if (logFd !== null) {
695
+ // Read to the end in any case: a pipe nobody drains stops the host once its buffer is full
696
+ child.stderr.on('data', (b) => {
697
+ if (ready) return
698
+ try {
699
+ writeSync(logFd, b)
700
+ } catch {
701
+ // the log is a convenience
702
+ }
703
+ })
704
+ }
705
+ if (child.pid) {
706
+ try {
707
+ writeFileSync(join(dataDir, SERVE_PID_FILE), `${JSON.stringify({ pid: process.pid, hostPid: child.pid, port })}\n`)
708
+ } catch (e) {
709
+ say(`could not write ${SERVE_PID_FILE}: ${e.message}`)
710
+ }
711
+ }
502
712
  createInterface({ input: child.stdout }).on('line', (line) => {
503
713
  const p = readyPort(line)
504
714
  if (p === null || ready) return
@@ -509,7 +719,8 @@ export async function runServe({ env, home, entry, version, cliPath, port: asked
509
719
  say(`could not record the port in ${join(dataDir, SERVE_STATE_FILE)}: ${e.message}`)
510
720
  }
511
721
  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`)
722
+ 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`)
723
+ else say(`the host's own log continues in ${join(dataDir, 'host.log')}`)
513
724
  })
514
725
 
515
726
  /*
@@ -532,11 +743,13 @@ export async function runServe({ env, home, entry, version, cliPath, port: asked
532
743
 
533
744
  return await new Promise((resolve) => {
534
745
  child.on('error', (e) => {
746
+ removeServePid(dataDir, process.pid)
535
747
  say(`could not start the host (${plan.command}): ${e.message}`)
536
748
  resolve(1)
537
749
  })
538
750
  child.on('exit', (code, signal) => {
539
751
  for (const s of signals) process.off(s, forward)
752
+ removeServePid(dataDir, process.pid)
540
753
  if (!ready && code !== 0) {
541
754
  const pid = lockHolderPid(dataDir)
542
755
  if (pid && pid !== child.pid && isAlive(pid)) {
@@ -546,6 +759,7 @@ export async function runServe({ env, home, entry, version, cliPath, port: asked
546
759
  )
547
760
  }
548
761
  }
762
+ if (logFd !== null) say(`the host exited (${signal ?? code})`)
549
763
  resolve(code ?? (signal ? 1 : 0))
550
764
  })
551
765
  })
@@ -559,3 +773,473 @@ export function rotateToken({ env, home }) {
559
773
  say('A serve that is running keeps the old one until it restarts; clients read the new one with --connection.')
560
774
  return 0
561
775
  }
776
+
777
+ // ── --detach ────────────────────────────────────────────────────────────────────────────────
778
+
779
+ /**
780
+ * What a detached launcher needs from the `--detach` that started it. A process created through WMI
781
+ * does not inherit this environment: it gets the account's default one. Everything else it needs
782
+ * (the token, the port) is in the data folder. Never a secret.
783
+ */
784
+ export const CARRIED_ENV = ['CC_DATA_DIR', 'CENTRALU_HOST_ENTRY', 'CENTRALU_MANAGED']
785
+
786
+ export function encodeChildSpec({ env, log }) {
787
+ const carried = {}
788
+ for (const k of CARRIED_ENV) if (typeof env[k] === 'string' && env[k]) carried[k] = env[k]
789
+ return Buffer.from(JSON.stringify({ log, env: carried }), 'utf8').toString('base64url')
790
+ }
791
+
792
+ /** `{ log, env }` from `--detached-child=`, only the carried variables; null when it is not one */
793
+ export function decodeChildSpec(raw) {
794
+ try {
795
+ const v = JSON.parse(Buffer.from(String(raw), 'base64url').toString('utf8'))
796
+ if (typeof v?.log !== 'string' || !v.log) return null
797
+ const env = {}
798
+ for (const k of CARRIED_ENV) if (typeof v.env?.[k] === 'string') env[k] = v.env[k]
799
+ return { log: v.log, env }
800
+ } catch {
801
+ return null
802
+ }
803
+ }
804
+
805
+ /**
806
+ * How `--detach` starts the launcher so that it outlives the session that asked (measured,
807
+ * docs/plans/remote-hub.md §10.4):
808
+ *
809
+ * setsid posix: a new session, no terminal, nothing from the ssh session's process group. Once
810
+ * `--detach` returns, the launcher is reparented to init (the second fork of the usual
811
+ * double fork); it opens no terminal, so it can never get a controlling one back
812
+ * wmi Windows: `Win32_Process.Create`. Windows' OpenSSH ends every process of a session with
813
+ * it, `Start-Process` included; a process WMI creates is not part of the session
814
+ * wsl inside WSL: the same WMI call from the Windows side, creating `wsl.exe -d <distro>
815
+ * --exec <launcher>`. A `setsid` launcher inside the distro survives the session but not the
816
+ * distro, which WSL stops about 15 s after its last `wsl.exe` client exits; this
817
+ * `wsl.exe` is a client for as long as the host runs, and ending it ends the host
818
+ */
819
+ export function detachHow(platform, env, interop = wslInterop) {
820
+ if (platform === 'win32') return 'wmi'
821
+ if (platform === 'linux' && env.WSL_DISTRO_NAME && interop()) return 'wsl'
822
+ return 'setsid'
823
+ }
824
+
825
+ function wslInterop() {
826
+ return existsSync('/proc/sys/fs/binfmt_misc/WSLInterop') || existsSync('/proc/sys/fs/binfmt_misc/WSLInterop-late')
827
+ }
828
+
829
+ /**
830
+ * One argument for a Windows command line, as `CommandLineToArgvW` and the C runtime read it: always
831
+ * quoted, backslashes before the closing quote doubled. A double quote or a line break cannot be a
832
+ * path on Windows and is refused rather than escaped.
833
+ */
834
+ export function windowsArg(arg) {
835
+ const s = String(arg)
836
+ if (/["\r\n\0]/.test(s)) throw new Error(`cannot pass ${JSON.stringify(s)} on a Windows command line`)
837
+ return `"${s.replace(/(\\+)$/, '$1$1')}"`
838
+ }
839
+
840
+ /** The PowerShell that asks WMI to create `commandLine`. Values cross as base64, so no quoting can break */
841
+ export function wmiScript({ commandLine, cwd = null }) {
842
+ const text = (s) => `[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('${Buffer.from(s, 'utf8').toString('base64')}'))`
843
+ return [
844
+ "$ErrorActionPreference = 'Stop'",
845
+ '[Console]::OutputEncoding = [Text.Encoding]::UTF8',
846
+ `$a = @{ CommandLine = ${text(commandLine)} }`,
847
+ ...(cwd ? [`$a.CurrentDirectory = ${text(cwd)}`] : []),
848
+ // No console window when this runs from a desktop session; left out where it cannot be built
849
+ 'try { $a.ProcessStartupInformation = New-CimInstance -ClassName Win32_ProcessStartup -ClientOnly -Property @{ ShowWindow = [uint16]0 } } catch { }',
850
+ 'try {',
851
+ ' $r = Invoke-CimMethod -ClassName Win32_Process -MethodName Create -Arguments $a',
852
+ " if ($r.ReturnValue -eq 21 -and $a.ContainsKey('ProcessStartupInformation')) { $a.Remove('ProcessStartupInformation'); $r = Invoke-CimMethod -ClassName Win32_Process -MethodName Create -Arguments $a }",
853
+ ' "CENTRALU-WMI $($r.ReturnValue) $($r.ProcessId)"',
854
+ '} catch { "CENTRALU-WMI-ERROR $($_.Exception.Message)" }',
855
+ ].join('\n')
856
+ }
857
+
858
+ /** `Win32_Process.Create`'s return values (Microsoft's documentation of the method) */
859
+ const WMI_CODES = { 2: 'access denied', 3: 'insufficient privilege', 8: 'unknown failure', 9: 'path not found', 21: 'invalid parameter' }
860
+
861
+ /** What the WMI script said: `{ ok: true, pid }` or `{ ok: false, message }` */
862
+ export function parseWmiAnswer(stdout) {
863
+ const text = String(stdout ?? '')
864
+ const m = /CENTRALU-WMI (\d+) (\d*)/.exec(text)
865
+ if (m) {
866
+ const rv = Number(m[1])
867
+ if (rv === 0 && Number(m[2]) > 0) return { ok: true, pid: Number(m[2]) }
868
+ return { ok: false, message: `WMI's Win32_Process.Create returned ${rv}${WMI_CODES[rv] ? ` (${WMI_CODES[rv]})` : ''}` }
869
+ }
870
+ const e = /CENTRALU-WMI-ERROR (.*)/.exec(text)
871
+ if (e) return { ok: false, message: `WMI refused to create the process: ${e[1].trim()}` }
872
+ return { ok: false, message: 'PowerShell did not answer the WMI request' }
873
+ }
874
+
875
+ /**
876
+ * The sentence for a start WMI did not make. Some managed machines block process creation through
877
+ * WMI (Microsoft Defender's attack surface reduction rule "Block process creations originating from
878
+ * PSExec and WMI commands", off by default); the hub then runs the host bound to its link
879
+ * (docs/plans/remote-hub.md §10.9, decision 3).
880
+ */
881
+ export function wmiBlockedMessage(detail) {
882
+ return (
883
+ `Windows did not start Centralu through WMI (${detail}). Some managed Windows machines block it ` +
884
+ '(Microsoft Defender\'s attack surface reduction rule "Block process creations originating from PSExec and WMI commands"). ' +
885
+ 'Centralu can run there while this computer is linked instead, and stops when the link does.'
886
+ )
887
+ }
888
+
889
+ /** Windows PowerShell 5.1, by full path: under %SystemRoot% on Windows, through the drive WSL mounts inside a distro */
890
+ export function windowsPowershell(how, env, mounts) {
891
+ return windowsTool(how, env, ['System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe'], mounts)
892
+ }
893
+
894
+ /** A program under the Windows folder, by full path, from Windows or from inside a WSL distro; null when unreachable */
895
+ function windowsTool(how, env, rel, mounts = () => readFileSync('/proc/mounts', 'utf8')) {
896
+ if (how === 'wmi') return [env.SystemRoot || env.SYSTEMROOT || 'C:\\Windows', ...rel].join('\\')
897
+ let text = ''
898
+ try {
899
+ text = mounts()
900
+ } catch {
901
+ // no /proc: not WSL after all
902
+ }
903
+ for (const line of text.split('\n')) {
904
+ const [, at, type] = line.split(' ')
905
+ if (!at || !(type === '9p' || type === 'drvfs')) continue
906
+ const candidate = [at, 'Windows', ...rel].join('/')
907
+ if (existsSync(candidate)) return candidate
908
+ }
909
+ return null
910
+ }
911
+
912
+ function runCaptured(command, args, env) {
913
+ return new Promise((resolve) => {
914
+ let stdout = ''
915
+ let stderr = ''
916
+ let child
917
+ try {
918
+ child = spawn(command, args, { env, stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true })
919
+ } catch (e) {
920
+ return resolve({ code: null, stdout, stderr: e.message })
921
+ }
922
+ child.stdout.on('data', (b) => (stdout += String(b)))
923
+ child.stderr.on('data', (b) => (stderr += String(b)))
924
+ child.once('error', (e) => resolve({ code: null, stdout, stderr: e.message }))
925
+ child.once('close', (code) => resolve({ code, stdout, stderr }))
926
+ })
927
+ }
928
+
929
+ /**
930
+ * Starts the launcher outside this session (`detachHow`). `{ ok: true, pid, exited?, alive? }` once
931
+ * something was created, `{ ok: false, blocked, message }` when nothing was: `blocked` is the WMI
932
+ * refusal the hub falls back from.
933
+ */
934
+ export async function startDetached({ how, execPath, args, env, cwd }) {
935
+ if (how === 'setsid') {
936
+ return await new Promise((resolve) => {
937
+ let child
938
+ try {
939
+ child = spawn(execPath, args, { detached: true, stdio: 'ignore', cwd, env, windowsHide: true })
940
+ } catch (e) {
941
+ return resolve({ ok: false, blocked: false, message: `could not start ${execPath}: ${e.message}` })
942
+ }
943
+ child.once('error', (e) => resolve({ ok: false, blocked: false, message: `could not start ${execPath}: ${e.message}` }))
944
+ child.once('spawn', () => {
945
+ const exited = new Promise((r) => child.once('exit', (code, signal) => r(signal ?? code)))
946
+ child.unref()
947
+ resolve({ ok: true, pid: child.pid, exited })
948
+ })
949
+ })
950
+ }
951
+ const ps = windowsPowershell(how, env)
952
+ if (!ps) return { ok: false, blocked: true, message: wmiBlockedMessage('Windows PowerShell is not reachable from this WSL distro') }
953
+ let commandLine
954
+ try {
955
+ if (how === 'wmi') commandLine = [execPath, ...args].map(windowsArg).join(' ')
956
+ else {
957
+ const distro = env.WSL_DISTRO_NAME ?? ''
958
+ const user = userInfo().username
959
+ if (!/^[A-Za-z0-9._-]{1,64}$/.test(distro)) throw new Error(`not a WSL distro name: ${distro}`)
960
+ if (!/^[A-Za-z_][A-Za-z0-9_.-]{0,31}$/.test(user)) throw new Error(`cannot name the user ${user} to wsl.exe`)
961
+ commandLine = ['wsl.exe', '-d', distro, '-u', user, '--cd', cwd, '--exec', execPath, ...args].map(windowsArg).join(' ')
962
+ }
963
+ } catch (e) {
964
+ return { ok: false, blocked: false, message: e.message }
965
+ }
966
+ const script = wmiScript({ commandLine, cwd: how === 'wmi' ? cwd : null })
967
+ const r = await runCaptured(ps, ['-NoProfile', '-NonInteractive', '-EncodedCommand', Buffer.from(script, 'utf16le').toString('base64')], env)
968
+ const answer = parseWmiAnswer(r.stdout)
969
+ 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)) }
970
+ // A Windows pid can be watched from Windows; from inside WSL it names nothing here
971
+ /*
972
+ * Measured on a Windows 11 laptop (2026-10-08): a `wsl.exe` that WMI creates can hang without
973
+ * running anything in the distro (it starts a second wsl.exe and waits), while `wsl.exe --list`
974
+ * from the same context answers. `--detach` reports that as a start that never ran, and ends what
975
+ * it created rather than leave it waiting: this pid and its children, nothing else.
976
+ */
977
+ const taskkill = windowsTool(how, env, ['System32', 'taskkill.exe'])
978
+ const stop = taskkill ? () => runCaptured(taskkill, ['/PID', String(answer.pid), '/T', '/F'], env) : null
979
+ return { ok: true, pid: answer.pid, alive: how === 'wmi' ? () => isAlive(answer.pid) : null, stop }
980
+ }
981
+
982
+ /** The last lines of a file, for a reason; empty when it cannot be read */
983
+ function tail(file, n = 15) {
984
+ try {
985
+ return readFileSync(file, 'utf8').trimEnd().split(/\r?\n/).slice(-n).join('\n')
986
+ } catch {
987
+ return ''
988
+ }
989
+ }
990
+
991
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
992
+
993
+ /**
994
+ * `centralu serve --detach` (docs/plans/remote-hub.md §10.4, S9): starts `serve` outside this
995
+ * session (`detachHow`) and returns once its host answers a hello, so the caller, typically the
996
+ * hub over ssh, can connect at once.
997
+ *
998
+ * stdout carries one JSON line, read by the hub (`links/tunnel.ts`): the connection line with
999
+ * `hostRunning: true` and `detach: { ok: true, how, already }`, or `{ v: 1, detach: { ok: false,
1000
+ * how, reason, message } }`. `reason` is `wmi_blocked` when WMI created nothing or created something
1001
+ * that never ran, which the hub answers with a link-bound start; `exited` and `timeout` quote the
1002
+ * end of serve.log. Exit codes do not survive Windows' ssh (agent-host.md §4.8), so the line is
1003
+ * what counts; the exit code is 0 or 1 for a person at a prompt.
1004
+ *
1005
+ * `start` and the two waits are parameters so tests can stand in for WMI.
1006
+ */
1007
+ export async function runDetach({
1008
+ env,
1009
+ home,
1010
+ entry,
1011
+ version,
1012
+ cliPath,
1013
+ port = null,
1014
+ platform = process.platform,
1015
+ execPath = process.execPath,
1016
+ cwd = process.cwd(),
1017
+ start = startDetached,
1018
+ how = detachHow(platform, env),
1019
+ timeoutMs = 30_000,
1020
+ noStartMs = 10_000,
1021
+ write = (line) => process.stdout.write(line),
1022
+ }) {
1023
+ const dataDir = serveDataDir(env, home)
1024
+ // Created here, so the token exists before the launcher reads it and `answered` reads it again
1025
+ ensureServeState(dataDir, platform)
1026
+ keepLauncher(dataDir, cliPath, env)
1027
+ const fail = (reason, message) => {
1028
+ say(message)
1029
+ write(`${JSON.stringify({ v: 1, detach: { ok: false, how, reason, message } })}\n`)
1030
+ return 1
1031
+ }
1032
+ const answered = async (already) => {
1033
+ const d = await describe({ env, dataDir, state: ensureServeState(dataDir, platform), entry, version })
1034
+ if (!d?.hostRunning) return false
1035
+ write(`${connectionLine({ ...d, detach: { ok: true, how, already } })}\n`)
1036
+ return true
1037
+ }
1038
+ if (await answered(true)) return 0
1039
+
1040
+ const log = join(dataDir, SERVE_LOG_FILE)
1041
+ // One start per log: the last one's is kept beside it, and its absence is how a start that never ran shows
1042
+ try {
1043
+ renameSync(log, `${log}.1`)
1044
+ } catch {
1045
+ // there was none
1046
+ }
1047
+ const args = [cliPath, 'serve', ...(port !== null ? ['--port', String(port)] : []), `${CHILD_FLAG}${encodeChildSpec({ env, log })}`]
1048
+ let started
1049
+ try {
1050
+ started = await start({ how, execPath, args, env, cwd })
1051
+ } catch (e) {
1052
+ return fail('failed', `could not start centralu serve in the background: ${e.message}`)
1053
+ }
1054
+ if (!started.ok) return fail(started.blocked ? 'wmi_blocked' : 'failed', started.message)
1055
+ say(`started centralu serve in the background (${how}, pid ${started.pid ?? 'unknown'}); waiting for its host`)
1056
+
1057
+ let exited
1058
+ started.exited?.then((c) => (exited = c ?? 'unknown'))
1059
+ const t0 = Date.now()
1060
+ while (Date.now() - t0 < timeoutMs) {
1061
+ if (await answered(false)) return 0
1062
+ const why = tail(log)
1063
+ if (exited !== undefined) return fail('exited', `centralu serve exited (${exited}) before its host answered${why ? `:\n${why}` : ''}`)
1064
+ const gone = started.alive?.() === false
1065
+ if (how !== 'setsid' && !existsSync(log) && (gone || Date.now() - t0 > noStartMs)) {
1066
+ // WMI said yes, and nothing ran: the process was stopped before Node started, or hangs (`startDetached`)
1067
+ if (!gone) await started.stop?.()
1068
+ return fail('wmi_blocked', wmiBlockedMessage(gone ? `process ${started.pid} ended before it started` : `nothing started within ${Math.round(noStartMs / 1000)} s`))
1069
+ }
1070
+ if (gone) return fail('exited', `centralu serve exited before its host answered${why ? `:\n${why}` : ''}`)
1071
+ await sleep(250)
1072
+ }
1073
+ const why = tail(log)
1074
+ return fail('timeout', `the host did not answer within ${Math.round(timeoutMs / 1000)} s${why ? `:\n${why}` : ''}`)
1075
+ }
1076
+
1077
+ /**
1078
+ * The launcher `--detach` started: `runServe` writing to serve.log. The carried variables are
1079
+ * already in `env` (centralu.mjs merges them before it looks for the host).
1080
+ */
1081
+ export async function runDetachedChild({ spec, ...opts }) {
1082
+ let fd = null
1083
+ try {
1084
+ fd = openSync(spec.log, 'a')
1085
+ sink = (text) => {
1086
+ try {
1087
+ writeSync(fd, text)
1088
+ } catch {
1089
+ // nowhere left to say it
1090
+ }
1091
+ }
1092
+ } catch {
1093
+ // no log: say() keeps stderr, which nobody reads; --detach then reports the start as never run
1094
+ }
1095
+ say(`started in the background (pid ${process.pid}, Node ${process.version})`)
1096
+ try {
1097
+ return await runServe({ ...opts, logFd: fd })
1098
+ } catch (e) {
1099
+ say(e?.message ?? String(e))
1100
+ return 1
1101
+ }
1102
+ }
1103
+
1104
+ // ── --stop ──────────────────────────────────────────────────────────────────────────────────
1105
+
1106
+ /**
1107
+ * Asks a running host to stop (`host.stop`, an additive RPC): a hello with the token, then the call.
1108
+ * `{ ok: true }` once the host took it, `{ ok: false, message, protocolVersion? }` otherwise; the
1109
+ * last names the host's protocol when the hello was refused for it, so the caller can ask again.
1110
+ */
1111
+ export function askHostToStop({ port, token, protocolVersion, timeoutMs = 5000, WebSocketImpl = globalThis.WebSocket }) {
1112
+ if (typeof WebSocketImpl !== 'function') return Promise.resolve({ ok: false, message: `Node ${process.version} has no WebSocket to ask with` })
1113
+ return new Promise((resolve) => {
1114
+ let ws = null
1115
+ let settled = false
1116
+ const done = (result) => {
1117
+ if (settled) return
1118
+ settled = true
1119
+ clearTimeout(timer)
1120
+ try {
1121
+ ws?.close()
1122
+ } catch {
1123
+ // already closed
1124
+ }
1125
+ resolve(result)
1126
+ }
1127
+ const timer = setTimeout(() => done({ ok: false, message: 'no answer in time' }), timeoutMs)
1128
+ try {
1129
+ ws = new WebSocketImpl(`ws://127.0.0.1:${port}`)
1130
+ } catch (e) {
1131
+ done({ ok: false, message: e.message })
1132
+ return
1133
+ }
1134
+ ws.onopen = () => ws.send(JSON.stringify({ kind: 'hello', token, protocolVersion: protocolVersion ?? 0 }))
1135
+ ws.onmessage = (ev) => {
1136
+ let m
1137
+ try {
1138
+ m = JSON.parse(String(ev.data))
1139
+ } catch {
1140
+ return
1141
+ }
1142
+ if (m?.kind === 'hello_ok') ws.send(JSON.stringify({ kind: 'rpc', id: 'stop', method: 'host.stop', params: {} }))
1143
+ else if (m?.kind === 'res' && m.id === 'stop') done(m.ok ? { ok: true } : { ok: false, message: m.error?.message ?? 'refused' })
1144
+ else if (m?.kind === 'res' && m.ok === false) {
1145
+ const pv = m.error?.code === 'version_mismatch' ? (m.error?.data?.protocolVersion ?? mismatchServerVersion(m.error.message)) : null
1146
+ done({ ok: false, message: m.error?.message ?? 'refused', ...(Number.isInteger(pv) ? { protocolVersion: pv } : {}) })
1147
+ }
1148
+ }
1149
+ ws.onerror = () => done({ ok: false, message: 'could not connect' })
1150
+ ws.onclose = () => done({ ok: false, message: 'the connection closed' })
1151
+ })
1152
+ }
1153
+
1154
+ async function waitUntil(pred, ms) {
1155
+ const t0 = Date.now()
1156
+ while (Date.now() - t0 < ms) {
1157
+ if (await pred()) return true
1158
+ await sleep(200)
1159
+ }
1160
+ return pred()
1161
+ }
1162
+
1163
+ /**
1164
+ * `centralu serve --stop` (docs/plans/remote-hub.md §10.4, S10): asks the running host to stop over
1165
+ * its own socket and waits until it has, so its shutdown runs (its agents, terminals and app
1166
+ * processes stopped in order, the store closed). Measured on Windows: killing the WMI-started
1167
+ * launcher ended the host 143 ms later with no "shutting down" line, because the host sits in
1168
+ * libuv's kill-on-close job object; so on Windows killing is the last resort, not the way.
1169
+ *
1170
+ * Then, only for a host that did not go: posix SIGTERM to the launcher (which passes it to the host,
1171
+ * the same clean stop), and on Windows ending the host. A host from before `host.stop` gets that
1172
+ * too. The launcher ends with its host; one still there afterwards is ended.
1173
+ *
1174
+ * Only the processes serve.pid and host.lock name, and only while they agree with each other and
1175
+ * the host answered with this folder's token: a stale pid is never signalled.
1176
+ *
1177
+ * stdout: one line, `{ v: 1, stop: { ok, wasRunning, how?, message? } }`.
1178
+ */
1179
+ export async function runStop({
1180
+ env,
1181
+ home,
1182
+ entry,
1183
+ platform = process.platform,
1184
+ timeoutMs = 30_000,
1185
+ graceMs = 15_000,
1186
+ kill = (pid, sig) => process.kill(pid, sig),
1187
+ write = (line) => process.stdout.write(line),
1188
+ }) {
1189
+ const dataDir = serveDataDir(env, home)
1190
+ const report = (stop) => {
1191
+ if (stop.message) say(stop.message)
1192
+ write(`${JSON.stringify({ v: 1, stop })}\n`)
1193
+ return stop.ok ? 0 : 1
1194
+ }
1195
+ const state = readServeState(dataDir)
1196
+ if (!state) return report({ ok: true, wasRunning: false, message: `no centralu serve has run for ${dataDir}` })
1197
+ const port = state.port ?? DEFAULT_SERVE_PORT
1198
+ const installed = protocolVersionOf(entry) ?? 0
1199
+ const probe = () => probeHost({ port, token: state.token, protocolVersion: installed })
1200
+ const first = await probe()
1201
+ if (!first.running) return report({ ok: true, wasRunning: false, message: `centralu serve is not running for ${dataDir}` })
1202
+
1203
+ const hostPid = lockHolderPid(dataDir)
1204
+ const launcher = readServePid(dataDir)
1205
+ // serve.pid is only believed when it names the host that holds the folder now
1206
+ const launcherPid = launcher && hostPid && launcher.hostPid === hostPid ? launcher.pid : null
1207
+ const down = async () => !(await probe()).running && !(hostPid && isAlive(hostPid))
1208
+
1209
+ let asked = await askHostToStop({ port, token: state.token, protocolVersion: first.protocolVersion ?? installed })
1210
+ if (!asked.ok && asked.protocolVersion !== undefined) asked = await askHostToStop({ port, token: state.token, protocolVersion: asked.protocolVersion })
1211
+ let how = 'asked'
1212
+ let stopped = asked.ok && (await waitUntil(down, timeoutMs))
1213
+ let note = asked.ok ? null : `the host did not take the request to stop (${asked.message})`
1214
+ if (!stopped) {
1215
+ const target = platform !== 'win32' && launcherPid ? launcherPid : hostPid
1216
+ if (target) {
1217
+ how = platform === 'win32' ? 'ended' : 'signal'
1218
+ try {
1219
+ kill(target, 'SIGTERM')
1220
+ } catch {
1221
+ // gone in the meantime
1222
+ }
1223
+ stopped = await waitUntil(down, platform === 'win32' ? 5_000 : graceMs)
1224
+ if (!stopped && hostPid && isAlive(hostPid)) {
1225
+ how = 'ended'
1226
+ try {
1227
+ kill(hostPid, 'SIGKILL')
1228
+ } catch {
1229
+ // gone in the meantime
1230
+ }
1231
+ stopped = await waitUntil(down, 5_000)
1232
+ }
1233
+ }
1234
+ if (how === 'ended') note = `${note ? `${note}; ` : ''}ended the host without its shutdown`
1235
+ }
1236
+ if (launcherPid && launcherPid !== process.pid && !(await waitUntil(() => !isAlive(launcherPid), 5_000))) {
1237
+ try {
1238
+ kill(launcherPid, 'SIGKILL')
1239
+ } catch {
1240
+ // gone in the meantime
1241
+ }
1242
+ }
1243
+ if (!stopped) return report({ ok: false, wasRunning: true, how, message: `the host on 127.0.0.1:${port} is still running${note ? ` (${note})` : ''}` })
1244
+ return report({ ok: true, wasRunning: true, how, ...(note ? { message: note } : {}) })
1245
+ }
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.13",
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.13",
41
+ "@centralu/linux-arm64": "0.1.0-beta.13",
42
+ "@centralu/linux-x64": "0.1.0-beta.13",
43
+ "@centralu/win32-x64": "0.1.0-beta.13"
44
44
  }
45
45
  }