centralu 0.1.0-beta.30 → 0.1.0-beta.31

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.
@@ -194,10 +194,18 @@ export function taskScript(action, { name, run = '', legacy = null }) {
194
194
  const head = ["$ErrorActionPreference = 'Stop'", PS_MODULE_PATH, '[Console]::OutputEncoding = [Text.Encoding]::UTF8', `$name = ${psText(name)}`]
195
195
  const fail = `} catch { "${TASK_WORD}-ERROR $($_.Exception.Message -replace '\\s+', ' ')" }`
196
196
  const find = "$t = Get-ScheduledTask -TaskName $name -TaskPath '\\' -ErrorAction SilentlyContinue"
197
- // Whether a task's principal is this user's, as this serve registers it. Task Scheduler gives the id with or without the domain
197
+ /*
198
+ * Who this user is: their account's SID, and its name as Windows resolves it (`PC\user`, `DOMAIN\user`), which a task
199
+ * principal is registered with. Never USERDOMAIN: in an ssh logon on a PC in a workgroup it says `WORKGROUP`, which
200
+ * names no account (#1223, measured on a laptop with beta.30). Whether a task's principal is this user's, as this
201
+ * serve registers it, is decided by SID too: Task Scheduler gives the id with the domain, without it, or as a SID
202
+ */
198
203
  const mine = [
199
- '$me = if ($env:USERDOMAIN) { "$env:USERDOMAIN\\$env:USERNAME" } else { $env:USERNAME }',
200
- "function Test-Mine($x) { $x -and $x.Principal -and ($x.Principal.UserId -eq $me -or $x.Principal.UserId -eq $env:USERNAME) -and \"$($x.Principal.LogonType)\" -eq 'Interactive' -and \"$($x.Principal.RunLevel)\" -eq 'Limited' }",
204
+ '$account = [System.Security.Principal.WindowsIdentity]::GetCurrent()',
205
+ '$sid = $account.User.Value',
206
+ '$me = $account.Name',
207
+ "function Get-Sid($n) { if (\"$n\" -match '^S-1-') { return \"$n\" }; if (-not \"$n\") { return $null }; try { (New-Object System.Security.Principal.NTAccount(\"$n\")).Translate([System.Security.Principal.SecurityIdentifier]).Value } catch { $null } }",
208
+ "function Test-Mine($x) { $x -and $x.Principal -and ((Get-Sid $x.Principal.UserId) -eq $sid) -and \"$($x.Principal.LogonType)\" -eq 'Interactive' -and \"$($x.Principal.RunLevel)\" -eq 'Limited' }",
201
209
  ]
202
210
  const old = legacy
203
211
  ? [`$legacy = ${psText(legacy)}`, "$old = Get-ScheduledTask -TaskName $legacy -TaskPath '\\' -ErrorAction SilentlyContinue"]
@@ -238,15 +246,21 @@ export function taskScript(action, { name, run = '', legacy = null }) {
238
246
  if (action === 'signin') {
239
247
  /*
240
248
  * Someone is signed in when this user owns an Explorer in a session of its own (not session 0, where an ssh
241
- * logon's processes run). Asked of WMI for the owner, which needs no administrator for the user's own processes
242
- * and reads the same in every language
249
+ * logon's processes run). Asked of WMI for the owner's SID, which needs no administrator for the user's own
250
+ * processes, reads the same in every language, and does not depend on what this logon calls its domain (GetOwner's
251
+ * domain is the PC's name for a local account, while an ssh logon's USERDOMAIN may say `WORKGROUP`). When none is
252
+ * found, how many desktop Explorers there were at all, and how many of their owners WMI would not tell, goes into
253
+ * serve.log's line
243
254
  */
244
255
  return [
245
256
  ...head,
246
257
  'try {',
247
258
  ...mine.map((l) => ` ${l}`),
248
- " $desk = @(Get-CimInstance Win32_Process -Filter \"Name='explorer.exe'\" | Where-Object { $_.SessionId -gt 0 } | Where-Object { $o = Invoke-CimMethod -InputObject $_ -MethodName GetOwner; $o.ReturnValue -eq 0 -and $o.User -eq $env:USERNAME -and (-not $env:USERDOMAIN -or $o.Domain -eq $env:USERDOMAIN) })",
249
- ` if ($desk.Count -eq 0) { '${TASK_WORD} nosession' } else {`,
259
+ " $all = @(Get-CimInstance Win32_Process -Filter \"Name='explorer.exe'\" | Where-Object { $_.SessionId -gt 0 })",
260
+ ' $owners = @($all | ForEach-Object { Invoke-CimMethod -InputObject $_ -MethodName GetOwnerSid })',
261
+ ' $desk = @($owners | Where-Object { $_.ReturnValue -eq 0 -and $_.Sid -eq $sid })',
262
+ ' $unread = @($owners | Where-Object { $_.ReturnValue -ne 0 }).Count',
263
+ ` if ($desk.Count -eq 0) { "${TASK_WORD} nosession desktops=$($all.Count) unreadable=$unread" } else {`,
250
264
  ...[...want, ...old, find, dropOld].map((l) => ` ${l}`),
251
265
  ' $acts = @(if ($t) { $t.Actions })',
252
266
  ' $same = (Test-Mine $t) -and $acts.Count -eq 1 -and $acts[0].Execute -eq $exe -and $acts[0].Arguments -eq $arg',
@@ -409,7 +423,9 @@ async function taskAutostart(action, { dataDir, env, home, name, run, user, plat
409
423
  * network logon, which cannot open the user's Credential Manager vault, runs elevated for an administrator (#702) and
410
424
  * opens what it shows in session 0, where nobody sees it.
411
425
  *
412
- * `{ started: true, name }`, or `{ started: false, reason, message? }`: `nosession` (nobody is signed in here),
426
+ * `{ started: true, name }`, or `{ started: false, reason, message?, desktops? }`: `nosession` (this user is not signed
427
+ * in here; `desktops` is how many desktop Explorers, of any account, there were, `unreadable` how many of their owners
428
+ * WMI would not tell),
413
429
  * `off` (`CENTRALU_SIGNIN_TASK=off`, for a person who wants no task, and for tests on a machine with a desktop),
414
430
  * `not_windows`, `no_launcher`, `error`. `run` is a parameter so tests never reach the real Task Scheduler.
415
431
  */
@@ -422,10 +438,14 @@ export async function startInSignIn({ dataDir, env, home, platform = process.pla
422
438
  if (!ps) return { started: false, reason: 'error', message: 'Windows PowerShell is not reachable' }
423
439
  const name = autostartName('task', { dataDir, home, env, platform, user })
424
440
  const runScript = taskActionScript({ how: 'wmi', launcher, env })
425
- const a = await askPowershell(ps, taskScript('signin', { name, run: runScript, legacy: legacyTaskName({ dataDir, home, env, platform }) }), env, parseTaskAnswer, run)
441
+ const parse = (out) => {
442
+ const m = /nosession desktops=(\d+)(?: unreadable=(\d+))?/.exec(String(out ?? ''))
443
+ return { ...parseTaskAnswer(out), ...(m ? { desktops: Number(m[1]), unreadable: Number(m[2] ?? 0) } : {}) }
444
+ }
445
+ const a = await askPowershell(ps, taskScript('signin', { name, run: runScript, legacy: legacyTaskName({ dataDir, home, env, platform }) }), env, parse, run)
426
446
  if (!a.ok) return { started: false, reason: 'error', message: a.message }
427
447
  if (a.word === 'started') return { started: true, name }
428
- if (a.word === 'nosession') return { started: false, reason: 'nosession' }
448
+ if (a.word === 'nosession') return { started: false, reason: 'nosession', ...(a.desktops !== undefined ? { desktops: a.desktops, unreadable: a.unreadable } : {}) }
429
449
  return { started: false, reason: 'error', message: `Task Scheduler answered ${a.word}` }
430
450
  }
431
451
 
@@ -3,11 +3,11 @@
3
3
  * stopping it in order (docs/plans/remote-hub.md §10.4).
4
4
  */
5
5
  import { spawn } from 'node:child_process'
6
- import { existsSync, openSync, renameSync, writeSync } from 'node:fs'
6
+ import { existsSync, openSync, rmSync, writeFileSync, writeSync } from 'node:fs'
7
7
  import { userInfo } from 'node:os'
8
8
  import { join } from 'node:path'
9
9
  import { answerLine, isAlive, say, sayTo, runCaptured, tail, sleep } from './serve-util.mjs'
10
- import { SERVE_LOG_FILE, serveDataDir, ensureServeState } from './serve-files.mjs'
10
+ import { SERVE_LOG_FILE, serveDataDir, ensureServeState, markStopped, rotateServeLog } from './serve-files.mjs'
11
11
  import { askPowershell, detachHow, windowsArg, wmiScript, parseWmiAnswer, wmiBlockedMessage, windowsPowershell, windowsTool, wslDistro, wslUser } from './serve-windows.mjs'
12
12
  import { CHILD_FLAG } from './serve-args.mjs'
13
13
  import { startInSignIn } from './serve-autostart.mjs'
@@ -22,20 +22,24 @@ import { describe, runServe, serveFolder } from './serve-run.mjs'
22
22
  */
23
23
  export const CARRIED_ENV = ['CC_DATA_DIR', 'CENTRALU_HOST_ENTRY', 'CENTRALU_MANAGED', 'CENTRALU_STARTED_FROM']
24
24
 
25
- export function encodeChildSpec({ env, log }) {
25
+ /**
26
+ * `start` is how the launcher was started and why (`through WMI: <reason>`), which it writes into its first serve.log
27
+ * line, so a host that ended up outside the Windows sign-in says why (#1223)
28
+ */
29
+ export function encodeChildSpec({ env, log, start = null }) {
26
30
  const carried = {}
27
31
  for (const k of CARRIED_ENV) if (typeof env[k] === 'string' && env[k]) carried[k] = env[k]
28
- return Buffer.from(JSON.stringify({ log, env: carried }), 'utf8').toString('base64url')
32
+ return Buffer.from(JSON.stringify({ log, env: carried, ...(start ? { start } : {}) }), 'utf8').toString('base64url')
29
33
  }
30
34
 
31
- /** `{ log, env }` from `--detached-child=`, only the carried variables; null when it is not one */
35
+ /** `{ log, env, start? }` from `--detached-child=`, only the carried variables; null when it is not one */
32
36
  export function decodeChildSpec(raw) {
33
37
  try {
34
38
  const v = JSON.parse(Buffer.from(String(raw), 'base64url').toString('utf8'))
35
39
  if (typeof v?.log !== 'string' || !v.log) return null
36
40
  const env = {}
37
41
  for (const k of CARRIED_ENV) if (typeof v.env?.[k] === 'string') env[k] = v.env[k]
38
- return { log: v.log, env }
42
+ return { log: v.log, env, ...(typeof v.start === 'string' && v.start ? { start: v.start } : {}) }
39
43
  } catch {
40
44
  return null
41
45
  }
@@ -135,40 +139,52 @@ export async function runDetach({
135
139
  }
136
140
  if (await answered(true)) return 0
137
141
 
142
+ const log = join(dataDir, SERVE_LOG_FILE)
143
+ /*
144
+ * One start per log: the earlier ones are kept beside it, three of them (`rotateServeLog`, #1143), and its absence is
145
+ * how a start that never ran shows. So a log that cannot be moved aside is removed rather than left to read as a start
146
+ */
147
+ const freshLog = () => rotateServeLog(log)
148
+
138
149
  /*
139
150
  * Windows: in the person's sign-in when someone is signed in (#1224), through the scheduled task, and through WMI only
140
151
  * when nobody is. WMI's process keeps this ssh session's network logon, where Git cannot use the credentials saved in
141
152
  * Windows. A task-started host that does not answer in time is left to the lock: the WMI start below then either
142
- * holds the folder or is refused by it, never a second host on it.
153
+ * holds the folder or is refused by it, never a second host on it. Which way it went, and why, is one line of
154
+ * serve.log (#1223): the task's serve writes no serve.log, so that line is written here; a WMI launcher writes its own
143
155
  */
156
+ let notInSignIn = null
144
157
  if (how === 'wmi') {
145
158
  const s = await signIn({ dataDir, env, home, platform })
146
159
  if (s.started) {
147
- say(`started centralu serve in the Windows sign-in (the scheduled task "${s.name}"); waiting for its host`)
160
+ const line = `started centralu serve in the Windows sign-in (the scheduled task "${s.name}"); waiting for its host`
161
+ say(line)
162
+ freshLog()
163
+ try {
164
+ writeFileSync(log, `[centralu serve] ${line}\n`)
165
+ } catch {
166
+ // the line on stderr is all there is
167
+ }
148
168
  const t0 = Date.now()
149
169
  while (Date.now() - t0 < signInMs) {
150
170
  if (await answered(false, 'task')) return 0
151
171
  await sleep(250)
152
172
  }
153
- say(`the host the sign-in task started did not answer within ${Math.round(signInMs / 1000)} s; starting one through WMI instead`)
154
- } else if (s.reason === 'nosession') {
155
- say('nobody is signed in to Windows here: starting the host through WMI, outside any sign-in, where Git cannot use the credentials saved in Windows')
156
- } else if (s.reason === 'error') {
157
- say(`could not start the host in the Windows sign-in (${s.message}); starting it through WMI`)
173
+ notInSignIn = `the host the sign-in task "${s.name}" started did not answer within ${Math.round(signInMs / 1000)} s`
174
+ // The WMI launcher's first line says so; the task's line goes, so `serve.log.1` keeps the start before this one
175
+ rmSync(log, { force: true, maxRetries: 5 })
176
+ } else {
177
+ notInSignIn = signInMissed(s)
158
178
  }
179
+ say(`${notInSignIn}: starting the host through WMI${s.reason === 'nosession' ? ', outside any sign-in, where Git cannot use the credentials saved in Windows' : ''}`)
159
180
  }
160
181
 
161
- const log = join(dataDir, SERVE_LOG_FILE)
162
- // One start per log: the last one's is kept beside it, and its absence is how a start that never ran shows
163
- try {
164
- renameSync(log, `${log}.1`)
165
- } catch {
166
- // there was none
167
- }
182
+ freshLog()
168
183
  // Where this was asked for from (#1223): the launcher runs outside this ssh session, so it is told
169
184
  const asked = serveFrom(env, from)
170
185
  const childEnv = asked ? { ...env, CENTRALU_STARTED_FROM: JSON.stringify(asked) } : env
171
- const args = [cliPath, 'serve', ...(port !== null ? ['--port', String(port)] : []), `${CHILD_FLAG}${encodeChildSpec({ env: childEnv, log })}`]
186
+ const startedHow = `through ${how === 'wmi' ? 'WMI' : how === 'wsl' ? 'a WMI-created wsl.exe' : how}${notInSignIn ? `: ${notInSignIn}` : ''}`
187
+ const args = [cliPath, 'serve', ...(port !== null ? ['--port', String(port)] : []), `${CHILD_FLAG}${encodeChildSpec({ env: childEnv, log, start: startedHow })}`]
172
188
  let started
173
189
  try {
174
190
  started = await start({ how, execPath, args, env, cwd })
@@ -198,6 +214,26 @@ export async function runDetach({
198
214
  return fail('timeout', `the host did not answer within ${Math.round(timeoutMs / 1000)} s${why ? `:\n${why}` : ''}`)
199
215
  }
200
216
 
217
+ /** Why the host was not started in the Windows sign-in, from `startInSignIn`'s answer, for serve.log and stderr */
218
+ export function signInMissed(s) {
219
+ if (s.reason === 'nosession') {
220
+ const n = s.desktops
221
+ const unread = s.unreadable ? `, ${s.unreadable} of them with an owner WMI would not tell` : ''
222
+ const seen =
223
+ typeof n !== 'number'
224
+ ? 'no Explorer of this account in a desktop session'
225
+ : n === 0
226
+ ? 'no Explorer in any desktop session'
227
+ : `none of the ${n} Explorer${n === 1 ? '' : 's'} in desktop sessions is this account's${unread}`
228
+ return `no Windows sign-in of this user was found (${seen})`
229
+ }
230
+ // It travels on the launcher's command line: bounded
231
+ if (s.reason === 'error') return `the sign-in task failed: ${String(s.message).slice(0, 300)}`
232
+ if (s.reason === 'off') return 'the sign-in task is turned off (CENTRALU_SIGNIN_TASK=off)'
233
+ if (s.reason === 'no_launcher') return 'there is no launcher in the data folder for the sign-in task to run'
234
+ return `the sign-in task was not used (${s.reason})`
235
+ }
236
+
201
237
  /**
202
238
  * The launcher `--detach` started: `runServe` writing to serve.log. The carried variables are
203
239
  * already in `env` (centralu.mjs merges them before it looks for the host).
@@ -216,7 +252,8 @@ export async function runDetachedChild({ spec, ...opts }) {
216
252
  } catch {
217
253
  // no log: say() keeps stderr, which nobody reads; --detach then reports the start as never run
218
254
  }
219
- say(`started in the background (pid ${process.pid}, Node ${process.version})`)
255
+ // One line saying how it was started and why (#1223), and what runs
256
+ say(`started in the background${spec.start ? ` ${spec.start}` : ''} (pid ${process.pid}, Node ${process.version})`)
220
257
  try {
221
258
  return await runServe({ ...opts, logFd: fd })
222
259
  } catch (e) {
@@ -230,6 +267,10 @@ export async function runDetachedChild({ spec, ...opts }) {
230
267
  * socket, and waited for (`stopServedHost` in serve-handover.mjs, which the hand-over into the sign-in shares, #1224).
231
268
  *
232
269
  * stdout: one line, `{ v: 1, stop: { ok, wasRunning, how?, message? } }`.
270
+ *
271
+ * A stop that went through is remembered in the data folder (`markStopped`, #1143), so a linked hub does not start the
272
+ * host again by itself; the next host that starts here removes the mark. Not for a hub's own stop, which starts the
273
+ * host again itself (an update, a rollback): the hub runs it with `CENTRALU_STOP_BY=hub` (links/tunnel.ts `stopCommand`)
233
274
  */
234
275
  export async function runStop({
235
276
  env,
@@ -242,5 +283,7 @@ export async function runStop({
242
283
  write = (line) => process.stdout.write(line),
243
284
  }) {
244
285
  const dataDir = serveDataDir(env, home)
245
- return answerLine(write, 'stop', await stopServedHost({ dataDir, entry, platform, timeoutMs, graceMs, kill }))
286
+ const stopped = await stopServedHost({ dataDir, entry, platform, timeoutMs, graceMs, kill })
287
+ if (stopped.ok && env.CENTRALU_STOP_BY !== 'hub') markStopped(dataDir, 'stop')
288
+ return answerLine(write, 'stop', stopped)
246
289
  }
@@ -5,9 +5,9 @@
5
5
  */
6
6
  import { execFileSync } from 'node:child_process'
7
7
  import { randomBytes } from 'node:crypto'
8
- import { chmodSync, existsSync, mkdirSync, openSync, readFileSync, rmSync, statSync, writeSync, closeSync } from 'node:fs'
8
+ import { chmodSync, existsSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, statSync, writeSync, closeSync } from 'node:fs'
9
9
  import { join } from 'node:path'
10
- import { isAlive, replaceFile, sleepSync } from './serve-util.mjs'
10
+ import { isAlive, renameRetrying, replaceFile, sleepSync } from './serve-util.mjs'
11
11
 
12
12
  /**
13
13
  * The file in the data folder that holds the token and the port, mode 0600.
@@ -25,6 +25,73 @@ export const SERVE_LOG_FILE = 'serve.log'
25
25
  /** The running launcher's pid and port, so `--stop` can find it. Removed when it exits */
26
26
  export const SERVE_PID_FILE = 'serve.pid'
27
27
 
28
+ /** How many earlier starts' serve.log are kept, as serve.log.1 (the last) to serve.log.3 */
29
+ export const SERVE_LOGS_KEPT = 3
30
+
31
+ /**
32
+ * Makes room for a new start's serve.log (#1143): the earlier ones move down a place, and the oldest past
33
+ * `SERVE_LOGS_KEPT` goes. A hub that starts a host again after it stopped makes at most three starts in ten minutes,
34
+ * so the log of the start that failed first is still here when it stops trying. The last one is moved aside with
35
+ * `renameRetrying` (a reader may hold the file on Windows, #1223); one that cannot be moved is removed rather than
36
+ * left to read as the new start's. Never fatal
37
+ */
38
+ export function rotateServeLog(log, kept = SERVE_LOGS_KEPT, rename = renameRetrying) {
39
+ // No log to move aside, no room to make: a second call for the same start (the task's line removed before a WMI start)
40
+ if (!existsSync(log)) return
41
+ for (let i = kept - 1; i >= 1; i--) {
42
+ try {
43
+ renameSync(`${log}.${i}`, `${log}.${i + 1}`)
44
+ } catch {
45
+ // there was none, or a reader holds it: the older log stays where it is
46
+ }
47
+ }
48
+ try {
49
+ rename(log, `${log}.1`)
50
+ } catch (e) {
51
+ if (e?.code === 'ENOENT') return
52
+ try {
53
+ rmSync(log, { force: true, maxRetries: 5 })
54
+ } catch {
55
+ // nothing more to do
56
+ }
57
+ }
58
+ }
59
+
60
+ /**
61
+ * The person's stop, kept in the data folder (#1143): `centralu serve --stop` writes it (`by: 'stop'`), and so does a
62
+ * host that Centralu's own app stops for good when the person quits it there (`by: 'quit'`, the host's
63
+ * lifecycle/stopped-mark.ts, which names the same file). Every host that starts in the folder removes it.
64
+ * `--connection` reports it while no host runs, and a hub then starts none by itself. A hub's own stop (an update)
65
+ * leaves none: it says so with `CENTRALU_STOP_BY=hub`
66
+ */
67
+ export const SERVE_STOPPED_FILE = 'stopped.json'
68
+
69
+ /** Writes the mark; never fatal, a stop that could not note itself still stopped */
70
+ export function markStopped(dataDir, by, now = Date.now()) {
71
+ try {
72
+ replaceFile(join(dataDir, SERVE_STOPPED_FILE), `${JSON.stringify({ v: 1, by, at: now })}\n`)
73
+ } catch {
74
+ // the folder is read-only or gone: nothing to remember it in
75
+ }
76
+ }
77
+
78
+ /** The mark as `{ by, at }`, or null when there is none. One that does not parse is still a stop, by `stop`, at no time */
79
+ export function readStopped(dataDir) {
80
+ let text
81
+ try {
82
+ text = readFileSync(join(dataDir, SERVE_STOPPED_FILE), 'utf8')
83
+ } catch {
84
+ return null
85
+ }
86
+ let o = null
87
+ try {
88
+ o = JSON.parse(text)
89
+ } catch {
90
+ // half written, or written by hand
91
+ }
92
+ return { by: o?.by === 'quit' ? 'quit' : 'stop', at: typeof o?.at === 'number' && Number.isFinite(o.at) ? o.at : null }
93
+ }
94
+
28
95
  /** The data folder: the host's own rule (`dataRoot()` in agent-host), CC_DATA_DIR or ~/.centralu */
29
96
  export function serveDataDir(env, home) {
30
97
  return env.CC_DATA_DIR || join(home, '.centralu')
@@ -34,7 +34,7 @@ export function protocolVersionOf(entry) {
34
34
  * `install` (additive, the line stays `v: 1`) says what runs this command (`installInfo`); `detach`
35
35
  * is added by `--detach` only.
36
36
  */
37
- export function connectionLine({ port, token, version, protocolVersion, dataDir, hostRunning, install, autostart, detach, proof }) {
37
+ export function connectionLine({ port, token, version, protocolVersion, dataDir, hostRunning, install, autostart, detach, proof, stopped }) {
38
38
  /*
39
39
  * The options this serve takes that an older one refuses (#1223): a hub adds `--from <its name>` to `--detach` only
40
40
  * when the line says so, since an older serve answers an option it does not know with an error. `window`: the
@@ -54,6 +54,8 @@ export function connectionLine({ port, token, version, protocolVersion, dataDir,
54
54
  ...(detach ? { detach } : {}),
55
55
  // The running host proves it holds the token before the hub sends it (#838); absent: the hub sends hello as before
56
56
  ...(proof ? { proof: true } : {}),
57
+ // The person stopped the host here, and when (#1143, `readStopped`); absent: an older serve, or no such stop
58
+ ...(stopped ? { stopped } : {}),
57
59
  takes,
58
60
  })
59
61
  }
package/bin/serve-run.mjs CHANGED
@@ -4,7 +4,7 @@ import { writeFileSync, writeSync } from 'node:fs'
4
4
  import { dirname, join } from 'node:path'
5
5
  import { createInterface } from 'node:readline'
6
6
  import { isAlive, say } from './serve-util.mjs'
7
- import { SERVE_STATE_FILE, SERVE_PID_FILE, serveDataDir, ensureServeState, recordServePort, rotateServeToken, heldHere, lockHolder, lockHolderPid, removeServePid } from './serve-files.mjs'
7
+ import { SERVE_STATE_FILE, SERVE_PID_FILE, readStopped, serveDataDir, ensureServeState, recordServePort, rotateServeToken, heldHere, lockHolder, lockHolderPid, removeServePid } from './serve-files.mjs'
8
8
  import { DEFAULT_SERVE_PORT, SERVE_PORT_TRIES, chooseServePort, listensHere, windowsListening } from './serve-port.mjs'
9
9
  import { keepLauncher, installInfo } from './serve-install.mjs'
10
10
  import { protocolVersionOf, connectionLine, hostEnv, hostCommand, readyPort, hostProvesOf, probeHost, serveFrom, takesTokenOnStdin, tokenLine } from './serve-host.mjs'
@@ -31,6 +31,9 @@ export async function describe({ env, dataDir, state, entry, version }) {
31
31
  const probe = await probeHost({ port, token: state.token, protocolVersion: installed, prove: proof })
32
32
  const protocolVersion = (probe.running ? probe.protocolVersion : undefined) ?? installed
33
33
  if (protocolVersion === null) return null
34
+ const hostRunning = heldHere(probe, dataDir)
35
+ // The person stopped it here (#1143): said while no host runs, so a hub starts none by itself
36
+ const stopped = hostRunning ? null : readStopped(dataDir)
34
37
  return {
35
38
  proof,
36
39
  port,
@@ -38,9 +41,10 @@ export async function describe({ env, dataDir, state, entry, version }) {
38
41
  version: (probe.running ? probe.version : undefined) ?? version,
39
42
  protocolVersion,
40
43
  dataDir,
41
- hostRunning: heldHere(probe, dataDir),
44
+ hostRunning,
42
45
  install: installInfo(dataDir, env),
43
46
  autostart: autostartInfo(dataDir, process.platform, env),
47
+ ...(stopped ? { stopped } : {}),
44
48
  }
45
49
  }
46
50
 
package/bin/serve.mjs CHANGED
@@ -24,7 +24,7 @@
24
24
  * (`serve-autostart.mjs`), Windows, WSL and PowerShell (`serve-windows.mjs`), and small helpers
25
25
  * (`serve-util.mjs`).
26
26
  */
27
- export { SERVE_STATE_FILE, SERVE_LOG_FILE, SERVE_PID_FILE, serveDataDir, readServeState, ensureServeState, recordServePort, rotateServeToken, heldHere, lockHolderIsAlive, processStartTime, lockHolder, lockHolderPid, readServePid } from './serve-files.mjs'
27
+ export { SERVE_STATE_FILE, SERVE_LOG_FILE, SERVE_PID_FILE, serveDataDir, readServeState, ensureServeState, recordServePort, rotateServeToken, heldHere, lockHolderIsAlive, processStartTime, lockHolder, lockHolderPid, readServePid, SERVE_STOPPED_FILE, SERVE_LOGS_KEPT, markStopped, readStopped, rotateServeLog } from './serve-files.mjs'
28
28
  export { detachHow, windowsArg, wmiScript, parseWmiAnswer, wmiBlockedMessage, windowsPowershell, ownLogon } from './serve-windows.mjs'
29
29
  export { DEFAULT_SERVE_PORT, SERVE_PORT_TRIES, chooseServePort, listensHere, parseNetstatListening } from './serve-port.mjs'
30
30
  export { SERVE_HELP, AUTOSTART_ACTIONS, parseServeArgs } from './serve-args.mjs'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "centralu",
3
- "version": "0.1.0-beta.30",
3
+ "version": "0.1.0-beta.31",
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",
@@ -38,9 +38,9 @@
38
38
  "win32"
39
39
  ],
40
40
  "optionalDependencies": {
41
- "@centralu/darwin-arm64": "0.1.0-beta.30",
42
- "@centralu/linux-arm64": "0.1.0-beta.30",
43
- "@centralu/linux-x64": "0.1.0-beta.30",
44
- "@centralu/win32-x64": "0.1.0-beta.30"
41
+ "@centralu/darwin-arm64": "0.1.0-beta.31",
42
+ "@centralu/linux-arm64": "0.1.0-beta.31",
43
+ "@centralu/linux-x64": "0.1.0-beta.31",
44
+ "@centralu/win32-x64": "0.1.0-beta.31"
45
45
  }
46
46
  }