centralu 0.1.0-beta.47 → 0.1.0-beta.48

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.
@@ -267,6 +267,8 @@ export async function runDetachedChild({ spec, ...opts }) {
267
267
  * A stop that went through is remembered in the data folder (`markStopped`, #1143), so a linked hub does not start the
268
268
  * host again by itself; the next host that starts here removes the mark. Not for a hub's own stop, which starts the
269
269
  * host again itself (an update, a rollback): the hub runs it with `CENTRALU_STOP_BY=hub` (links/tunnel.ts `stopCommand`).
270
+ * The window's "Quit completely" on this host runs it with `CENTRALU_STOP_BY=quit` (#1466), so the mark says the person quit
271
+ * Centralu there, as the app's own host's does, not that they ran `serve --stop`.
270
272
  * With `CENTRALU_STOP_FOR=update` too (#1618), the host is asked to list the turns it ends, which the next host continues
271
273
  */
272
274
  export async function runStop({
@@ -281,6 +283,6 @@ export async function runStop({
281
283
  }) {
282
284
  const dataDir = serveDataDir(env, home)
283
285
  const stopped = await stopServedHost({ dataDir, entry, platform, forUpdate: env.CENTRALU_STOP_FOR === 'update', timeoutMs, graceMs, kill })
284
- if (stopped.ok && env.CENTRALU_STOP_BY !== 'hub') markStopped(dataDir, 'stop')
286
+ if (stopped.ok && env.CENTRALU_STOP_BY !== 'hub') markStopped(dataDir, env.CENTRALU_STOP_BY === 'quit' ? 'quit' : 'stop')
285
287
  return answerLine(write, 'stop', stopped)
286
288
  }
@@ -26,7 +26,7 @@ import { isAlive, say, sleep, waitUntil } from './serve-util.mjs'
26
26
  * And only a host `serve` started (`startedByServe`, #1722): a lock held by the app's or the keeper's host is theirs,
27
27
  * whatever answers on the port, so `--stop` says whose it is and leaves it.
28
28
  *
29
- * `{ ok, wasRunning, how?, message? }`, the `--stop` line's `stop`. With `ifIdle` (#1224, the hand-over) the host stops only
29
+ * `{ ok, wasRunning, how?, heldByOther?, message? }`, the `--stop` line's `stop`. With `ifIdle` (#1224, the hand-over) the host stops only
30
30
  * when nothing runs there, and `{ ok: false, busy: true }` says it did not. `forUpdate` (#1618): a hub's update stops it, and
31
31
  * the host writes the turns it ends for the next one (`host.stop { forUpdate }`); a host that does not take the request is
32
32
  * ended as any is, and lists nothing.
@@ -45,7 +45,8 @@ export async function stopServedHost({ dataDir, entry, platform = process.platfo
45
45
  // The folder is held, but not by a serve (#1722): the app's or the keeper's host, whatever answered on the port
46
46
  const other = foreignHolder(dataDir)
47
47
  const whose = other ? `. It belongs to another Centralu host (pid ${other.pid}), ${holderWho(other.by)}; not stopping it` : ''
48
- return { ok: true, wasRunning: false, message: `centralu serve is not running for ${dataDir}${whose}` }
48
+ // `heldByOther`: the folder is held by a host that is not this serve's, which this left alone (the window's quit says so rather than claim a stop)
49
+ return { ok: true, wasRunning: false, ...(other ? { heldByOther: true } : {}), message: `centralu serve is not running for ${dataDir}${whose}` }
49
50
  }
50
51
 
51
52
  const hostPid = lockHolderPid(dataDir)
@@ -7,7 +7,7 @@
7
7
  import { join } from 'node:path'
8
8
  import { pairServe, readServeState } from './serve-files.mjs'
9
9
  import { DEFAULT_SERVE_PORT } from './serve-port.mjs'
10
- import { probeHost, protocolVersionOf } from './serve-host.mjs'
10
+ import { askHostRpc, probeHost, protocolVersionOf } from './serve-host.mjs'
11
11
  import { serveFolder } from './serve-run.mjs'
12
12
  import { runDetach } from './serve-detach.mjs'
13
13
  import { startInSignIn } from './serve-autostart.mjs'
@@ -27,6 +27,9 @@ import { say, sleep } from './serve-util.mjs'
27
27
  * (`moveIntoSignIn`, serve-handover.mjs).
28
28
  * - stdin is the window (it runs this with stdin a pipe): when it ends, this exits 0 and leaves the host running,
29
29
  * since it serves the hub's link too.
30
+ * - A line `{"op":"system-ending"}` on that stdin (`"cancelled":true` calls it off) is the window saying the system is ending, a
31
+ * shutdown, restart or sign-out (#1970): the host this runs is no child of the window and hears nothing of it, so this tells it by
32
+ * `host.systemEnding`, with the window's full credential. A sign-out (`"logoff":true`) is not told to a host in a network logon, which it does not end.
30
33
  * - The host stops answering for `misses` polls in a row (the hand-over's gap, or it ended): this exits 1, and the
31
34
  * window's supervisor starts this again, which finds the new host or starts one.
32
35
  *
@@ -45,6 +48,7 @@ export async function runWindow({
45
48
  signIn = startInSignIn,
46
49
  pollMs = 5_000,
47
50
  misses = 3,
51
+ ask = askHostRpc,
48
52
  }) {
49
53
  const { dataDir } = serveFolder({ env, home, cliPath, platform })
50
54
  const protocolVersion = protocolVersionOf(entry) ?? 0
@@ -82,6 +86,16 @@ export async function runWindow({
82
86
  const state = readServeState(dataDir)
83
87
  write(`${JSON.stringify({ ready: true, port: state.port ?? DEFAULT_SERVE_PORT, token, db: join(dataDir, 'store.db') })}\n`)
84
88
 
89
+ const tellEnding = async (line) => {
90
+ const op = parseLine(line)
91
+ if (op?.op !== 'system-ending') return
92
+ // A sign-out does not end a host the ssh logon of a hub started: it runs on
93
+ if (op.logoff === true && op.cancelled !== true && (await probe()).logon === 'network') return
94
+ const { port, proofKey } = readServeState(dataDir) ?? {}
95
+ const sent = await ask({ port: port ?? DEFAULT_SERVE_PORT, token, proofKey, protocolVersion, method: 'host.systemEnding', params: op.cancelled === true ? { cancelled: true } : {} })
96
+ if (!sent) say('the host did not take the notice that the system is ending')
97
+ }
98
+
85
99
  return await new Promise((resolve) => {
86
100
  let done = false
87
101
  const finish = (code) => {
@@ -89,6 +103,9 @@ export async function runWindow({
89
103
  done = true
90
104
  resolve(code)
91
105
  }
106
+ // One after the other: the call-off must not overtake the notice it calls off
107
+ let told = Promise.resolve()
108
+ stdin.on('data', lineReader((line) => void (told = told.then(() => tellEnding(line)).catch(() => {}))))
92
109
  stdin.once('end', () => finish(0))
93
110
  stdin.once('close', () => finish(0))
94
111
  stdin.resume()
@@ -114,3 +131,17 @@ function parseLine(text) {
114
131
  return null
115
132
  }
116
133
  }
134
+
135
+ /** A `data` handler that gives `onLine` each complete line of the stream, however its chunks cut them */
136
+ function lineReader(onLine) {
137
+ let rest = ''
138
+ return (chunk) => {
139
+ rest += String(chunk)
140
+ let i
141
+ while ((i = rest.indexOf('\n')) >= 0) {
142
+ onLine(rest.slice(0, i))
143
+ rest = rest.slice(i + 1)
144
+ }
145
+ if (rest.length > 64 * 1024) rest = ''
146
+ }
147
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "centralu",
3
- "version": "0.1.0-beta.47",
3
+ "version": "0.1.0-beta.48",
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.47",
42
- "@centralu/linux-arm64": "0.1.0-beta.47",
43
- "@centralu/linux-x64": "0.1.0-beta.47",
44
- "@centralu/win32-x64": "0.1.0-beta.47"
41
+ "@centralu/darwin-arm64": "0.1.0-beta.48",
42
+ "@centralu/linux-arm64": "0.1.0-beta.48",
43
+ "@centralu/linux-x64": "0.1.0-beta.48",
44
+ "@centralu/win32-x64": "0.1.0-beta.48"
45
45
  }
46
46
  }