centralu 0.1.0-beta.27 → 0.1.0-beta.29

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/platform.mjs CHANGED
@@ -42,6 +42,14 @@ export function targetFor(platform, arch) {
42
42
  return TARGETS[`${platform}-${arch}`] ?? null
43
43
  }
44
44
 
45
+ /**
46
+ * Whether replacing a file by rename fails while another program has the target open (a reader, a virus scanner):
47
+ * Windows, which then answers EPERM, EBUSY or EACCES for a moment. Elsewhere a rename over an open file just works.
48
+ */
49
+ export function renameWaitsForReaders(platform = process.platform) {
50
+ return platform === 'win32'
51
+ }
52
+
45
53
  /**
46
54
  * The file that proves the artifact is there and is what gets started.
47
55
  *
@@ -7,7 +7,7 @@ import { execFileSync } from 'node:child_process'
7
7
  import { randomBytes } from 'node:crypto'
8
8
  import { chmodSync, existsSync, mkdirSync, openSync, readFileSync, rmSync, statSync, writeSync, closeSync } from 'node:fs'
9
9
  import { join } from 'node:path'
10
- import { isAlive, replaceFile } from './serve-util.mjs'
10
+ import { isAlive, 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.
@@ -30,14 +30,29 @@ export function serveDataDir(env, home) {
30
30
  return env.CC_DATA_DIR || join(home, '.centralu')
31
31
  }
32
32
 
33
- /** The state, or null when there is none yet or it cannot be read as one */
34
- export function readServeState(dataDir) {
35
- let raw
36
- try {
37
- raw = readFileSync(join(dataDir, SERVE_STATE_FILE), 'utf8')
38
- } catch {
39
- return null
33
+ /**
34
+ * The state, or null when there is none yet or it cannot be read as one. A file that is refused (Windows, while a
35
+ * writer or a scanner has it) or does not parse is read again a few times before it counts as unreadable: a
36
+ * `--detach` waiting for the port reads it while the launcher replaces it (#1041).
37
+ */
38
+ export function readServeState(dataDir, { tries = 5, sleep = sleepSync } = {}) {
39
+ const file = join(dataDir, SERVE_STATE_FILE)
40
+ for (let i = 1; ; i++) {
41
+ let raw
42
+ try {
43
+ raw = readFileSync(file, 'utf8')
44
+ } catch (e) {
45
+ if (e?.code === 'ENOENT' || i >= tries) return null
46
+ sleep(10 * i)
47
+ continue
48
+ }
49
+ const state = parseServeState(raw)
50
+ if (state || i >= tries) return state
51
+ sleep(10 * i)
40
52
  }
53
+ }
54
+
55
+ function parseServeState(raw) {
41
56
  try {
42
57
  const v = JSON.parse(raw)
43
58
  if (typeof v?.token !== 'string' || v.token.length < 32) return null
@@ -54,9 +54,12 @@ export function connectionLine({ port, token, version, protocolVersion, dataDir,
54
54
  /**
55
55
  * The host's environment.
56
56
  *
57
- * - The token travels in `CC_HOST_TOKEN`, not `--token`: arguments are readable by every user on
58
- * the machine (`ps`), a process's environment only by its owner. The host deletes it after
59
- * reading, so nothing it spawns inherits it.
57
+ * - The token is in neither: arguments are readable by every user on the machine (`ps`), and a
58
+ * process's start environment by every process of its user for as long as it runs (`ps eww`,
59
+ * #502). `CC_HOST_TOKEN_ON_STDIN=1` tells the host it comes as the first line on its stdin
60
+ * (`tokenLine`, #538), which is a pipe to this launcher anyway (`--watch-parent`). A host bundle
61
+ * from before #538 reads only `CC_HOST_TOKEN`: given `legacyToken` (`takesTokenOnStdin` said no),
62
+ * it gets the token there, as before.
60
63
  * - `CC_HOST_SOURCE` is the record `hello_ok.build` is made from (keeper-link.ts); without it a
61
64
  * client could not see which version the host it reached is.
62
65
  * - Headless: no `DISPLAY` / `WAYLAND_DISPLAY`. Nothing the host or an agent starts gets a screen
@@ -66,17 +69,37 @@ export function connectionLine({ port, token, version, protocolVersion, dataDir,
66
69
  * `CENTRALU_MANAGED`, which is the launcher's business and would otherwise reach every agent.
67
70
  * - `CC_SERVE=1` lets the host take `host.stop` (`--stop`); a host the app runs refuses it.
68
71
  */
69
- export function hostEnv(env, { token, dataDir, version, hostDir }) {
72
+ export function hostEnv(env, { dataDir, version, hostDir, legacyToken }) {
70
73
  const out = { ...env }
71
- 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]
74
+ for (const k of ['DISPLAY', 'WAYLAND_DISPLAY', 'CC_KEEPER', 'CC_FRONT_DOOR', 'CC_DEV', 'CC_HOST_TOKEN', 'CC_HOST_TOKEN_ON_STDIN', 'CC_HOST_SOURCE', 'CENTRALU_MANAGED']) delete out[k]
72
75
  out.CC_DATA_DIR = dataDir
73
76
  // The host `host.stop` may end (`centralu serve --stop`): nothing else stops it in order on Windows
74
77
  out.CC_SERVE = '1'
75
- out.CC_HOST_TOKEN = token
78
+ if (legacyToken === undefined) out.CC_HOST_TOKEN_ON_STDIN = '1'
79
+ else out.CC_HOST_TOKEN = legacyToken
76
80
  out.CC_HOST_SOURCE = JSON.stringify({ version, bundlePath: hostDir })
77
81
  return out
78
82
  }
79
83
 
84
+ /**
85
+ * Whether the host at `entry` reads its token from stdin (#538): a bundle says so in `bundle-info.json`
86
+ * (`tokenOnStdin`), as the keeper reads it; a host run from source always does. `CENTRALU_HOST_ENTRY` can point at an
87
+ * older bundle, which would otherwise get no token and refuse every hello.
88
+ */
89
+ export function takesTokenOnStdin(entry) {
90
+ try {
91
+ return JSON.parse(readFileSync(join(dirname(entry), 'bundle-info.json'), 'utf8')).tokenOnStdin === 1
92
+ } catch {
93
+ // not a bundle: the source, which reads it
94
+ }
95
+ return !existsSync(join(dirname(entry), 'bundle-info.json'))
96
+ }
97
+
98
+ /** The first line on the host's stdin: the token (#538, the host's `readHandedToken`) */
99
+ export function tokenLine(token) {
100
+ return `${JSON.stringify({ token })}\n`
101
+ }
102
+
80
103
  /**
81
104
  * How to start the host.
82
105
  *
@@ -114,6 +114,21 @@ export function parsePointer(text) {
114
114
  return { version: m[1], node: m[2] }
115
115
  }
116
116
 
117
+ /**
118
+ * Where a managed version came from, from its `install.json`: `{ kind: 'dev', commit, dirty }` for a dev build put
119
+ * there from a git commit (`pnpm remote-dev`, #1036), null for a release (or anything unreadable). The hub shows it
120
+ * on the machine's row, so a build no release produced is never mistaken for one.
121
+ */
122
+ export function installSource(versionDir) {
123
+ try {
124
+ const s = JSON.parse(readFileSync(join(versionDir, 'install.json'), 'utf8')).source
125
+ if (s?.kind === 'dev' && typeof s.commit === 'string' && /^[0-9a-f]{7,64}$/.test(s.commit)) return { kind: 'dev', commit: s.commit, dirty: s.dirty === true }
126
+ } catch {
127
+ // no install.json, or not one this reads
128
+ }
129
+ return null
130
+ }
131
+
117
132
  /**
118
133
  * The connection line's `install` field (docs/plans/remote-hub.md §10.5): whether this command was
119
134
  * started by the managed launcher, the managed install's `current` and `previous`, and the Node
@@ -122,11 +137,14 @@ export function parsePointer(text) {
122
137
  export function installInfo(dataDir, env, nodeVersion = process.versions.node) {
123
138
  const l = remoteLayout(dataDir)
124
139
  const read = (file) => {
140
+ let p
125
141
  try {
126
- return parsePointer(readFileSync(file, 'utf8'))
142
+ p = parsePointer(readFileSync(file, 'utf8'))
127
143
  } catch {
128
144
  return null
129
145
  }
146
+ const source = p && installSource(join(l.versions, p.version))
147
+ return p && source ? { ...p, source } : p
130
148
  }
131
149
  return { managed: env.CENTRALU_MANAGED === '1', current: read(l.current), previous: read(l.previous), node: nodeVersion }
132
150
  }
package/bin/serve-run.mjs CHANGED
@@ -7,7 +7,7 @@ import { isAlive, say } from './serve-util.mjs'
7
7
  import { SERVE_STATE_FILE, SERVE_PID_FILE, serveDataDir, ensureServeState, recordServePort, rotateServeToken, heldHere, 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
- import { protocolVersionOf, connectionLine, hostEnv, hostCommand, readyPort, hostProvesOf, probeHost } from './serve-host.mjs'
10
+ import { protocolVersionOf, connectionLine, hostEnv, hostCommand, readyPort, hostProvesOf, probeHost, takesTokenOnStdin, tokenLine } from './serve-host.mjs'
11
11
  import { autostartInfo } from './serve-autostart.mjs'
12
12
 
13
13
  /**
@@ -97,12 +97,15 @@ export async function runServe({ env, home, entry, version, cliPath, port: asked
97
97
  }
98
98
 
99
99
  const plan = hostCommand(execPath, entry, { port, dataDir, platform })
100
+ const onStdin = takesTokenOnStdin(entry)
100
101
  const child = spawn(plan.command, plan.args, {
101
102
  stdio: ['pipe', 'pipe', logFd === null ? 'inherit' : 'pipe'],
102
- env: hostEnv(env, { token: state.token, dataDir, version, hostDir: dirname(entry) }),
103
+ env: hostEnv(env, { dataDir, version, hostDir: dirname(entry), ...(onStdin ? {} : { legacyToken: state.token }) }),
103
104
  detached: plan.detached,
104
105
  windowsHide: true,
105
106
  })
107
+ // The token, before anything else on the pipe the host watches (#538); stdin stays open for --watch-parent
108
+ if (onStdin) child.stdin.write(tokenLine(state.token))
106
109
 
107
110
  let ready = false
108
111
  if (logFd !== null) {
@@ -3,7 +3,8 @@
3
3
  * output, a process check, and waiting.
4
4
  */
5
5
  import { spawn } from 'node:child_process'
6
- import { closeSync, openSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
6
+ import { closeSync, openSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs'
7
+ import { renameWaitsForReaders } from './platform.mjs'
7
8
 
8
9
  /**
9
10
  * Where `say` writes: stderr, except in a detached launcher, which nobody reads the stderr of and
@@ -31,12 +32,39 @@ export function sayTo(write) {
31
32
  sink = write
32
33
  }
33
34
 
35
+ /** Blocks this thread for `ms`, for the synchronous callers that cannot await (a rename that is retried) */
36
+ export function sleepSync(ms) {
37
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms)
38
+ }
39
+
40
+ /** What a rename over a file says on Windows while another handle has the target open (a reader, a virus scanner) */
41
+ const BUSY_CODES = new Set(['EPERM', 'EBUSY', 'EACCES'])
42
+
43
+ /**
44
+ * `rename`, tried again for up to `budgetMs` while the system refuses it because someone has the target open (#1041;
45
+ * `retry`: Windows only, `renameWaitsForReaders`). With `retry` off, and for any other error, it is one try. Backs off 10, 20, 40 ... 200 ms between tries; the last
46
+ * error is thrown when the budget is spent.
47
+ */
48
+ export function renameRetrying(from, to, { retry = renameWaitsForReaders(), rename = renameSync, sleep = sleepSync, budgetMs = 5_000, now = Date.now } = {}) {
49
+ const t0 = now()
50
+ for (let delay = 10; ; delay = Math.min(delay * 2, 200)) {
51
+ try {
52
+ return rename(from, to)
53
+ } catch (e) {
54
+ if (!retry || !BUSY_CODES.has(e?.code) || now() - t0 + delay > budgetMs) throw e
55
+ }
56
+ sleep(delay)
57
+ }
58
+ }
59
+
34
60
  /**
35
61
  * Replaces `file` with `text` at once: written to a temporary file beside it, then renamed over it, so a reader finds
36
62
  * the old file or the new one and never half of one. `mode` and `flag` are the temporary file's: a private file is
37
63
  * created with its mode from the first byte, and with `wx`, never written into a file that is already there.
64
+ * On Windows a rename fails while a reader has the target open, so it is retried (`renameRetrying`); when it still
65
+ * fails, the temporary file is removed and the error thrown, the old file untouched.
38
66
  */
39
- export function replaceFile(file, text, { mode = 0o666, flag = 'w' } = {}) {
67
+ export function replaceFile(file, text, { mode = 0o666, flag = 'w', ...rename } = {}) {
40
68
  const tmp = `${file}.${process.pid}.tmp`
41
69
  const fd = openSync(tmp, flag, mode)
42
70
  try {
@@ -44,7 +72,12 @@ export function replaceFile(file, text, { mode = 0o666, flag = 'w' } = {}) {
44
72
  } finally {
45
73
  closeSync(fd)
46
74
  }
47
- renameSync(tmp, file)
75
+ try {
76
+ renameRetrying(tmp, file, rename)
77
+ } catch (e) {
78
+ rmSync(tmp, { force: true })
79
+ throw e
80
+ }
48
81
  }
49
82
 
50
83
  export function runCaptured(command, args, env) {
package/bin/serve.mjs CHANGED
@@ -29,7 +29,7 @@ export { detachHow, windowsArg, wmiScript, parseWmiAnswer, wmiBlockedMessage, wi
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'
31
31
  export { launcherScript, ensureLauncher, remoteLayout, parsePointer, installInfo, managedLauncherScript } from './serve-install.mjs'
32
- export { protocolVersionOf, connectionLine, hostEnv, hostCommand, readyPort, mismatchServerVersion, hostProvesOf, probeHost, askHostToStop } from './serve-host.mjs'
32
+ export { protocolVersionOf, connectionLine, hostEnv, hostCommand, readyPort, mismatchServerVersion, hostProvesOf, probeHost, askHostToStop, takesTokenOnStdin, tokenLine } from './serve-host.mjs'
33
33
  export { AUTOSTART_FILE, AUTOSTART_ENV, autostartHow, autostartUnsupported, pathKey, autostartName, autostartLauncher, systemdUnit, taskActionScript, taskScript, parseTaskAnswer, readAutostartMark, autostartInfo, lingerMessage, runAutostart } from './serve-autostart.mjs'
34
34
  export { printConnection, runServe, rotateToken } from './serve-run.mjs'
35
35
  export { CARRIED_ENV, encodeChildSpec, decodeChildSpec, startDetached, runDetach, runDetachedChild, runStop } from './serve-detach.mjs'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "centralu",
3
- "version": "0.1.0-beta.27",
3
+ "version": "0.1.0-beta.29",
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.27",
42
- "@centralu/linux-arm64": "0.1.0-beta.27",
43
- "@centralu/linux-x64": "0.1.0-beta.27",
44
- "@centralu/win32-x64": "0.1.0-beta.27"
41
+ "@centralu/darwin-arm64": "0.1.0-beta.29",
42
+ "@centralu/linux-arm64": "0.1.0-beta.29",
43
+ "@centralu/linux-x64": "0.1.0-beta.29",
44
+ "@centralu/win32-x64": "0.1.0-beta.29"
45
45
  }
46
46
  }