@centralu/darwin-arm64 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.
@@ -0,0 +1,96 @@
1
+ /**
2
+ * **The terminal broker** (Windows, #1067; the host side is `broker-pty.ts`).
3
+ *
4
+ * The host starts this process before it joins the job object that ends its work with it (`lifecycle/work-job.ts`), so
5
+ * it is in no job of the host's. The terminals it starts are therefore outside that job: what a person starts from a
6
+ * Centralu terminal (`code .`, the browser `claude auth login` opens, a server started in the background) outlives the
7
+ * host, as it does on macOS and Linux, while agents, Run commands, git and app servers end with it.
8
+ *
9
+ * A plain `.mjs`, started with `node <path>` in development and beside the bundled host alike (the Codex bridge's way,
10
+ * `adapters/codex/bridge-path.ts`). Its whole protocol is one JSON object per line: the host's requests on stdin,
11
+ * what the terminals do on stdout.
12
+ *
13
+ * - in: `{op:'spawn', id, file, args, cwd, env, cols, rows}` · `{op:'write', id, data}` · `{op:'resize', id, cols, rows}`
14
+ * · `{op:'kill', id}`
15
+ * - out: `{ev:'ready'}` once · `{ev:'spawned', id, pid}` · `{ev:'error', id, message}` · `{ev:'data', id, data}` ·
16
+ * `{ev:'exit', id, exitCode, signal}`
17
+ *
18
+ * Its stdin closing means the host is gone: it exits, which closes every pseudoconsole and so ends each shell as the
19
+ * host's own would have. What a shell started outside its console stays.
20
+ */
21
+ import { createRequire } from 'node:module'
22
+ import { createInterface } from 'node:readline'
23
+
24
+ const require = createRequire(import.meta.url)
25
+ const pty = require('node-pty')
26
+
27
+ /** @type {Map<number, any>} */
28
+ const ptys = new Map()
29
+ /** Kills asked for before the spawn answered (the host does not wait for a pid to close a tab) */
30
+ const killed = new Set()
31
+
32
+ function send(message) {
33
+ process.stdout.write(`${JSON.stringify(message)}\n`)
34
+ }
35
+
36
+ /**
37
+ * On the ConPTY node-pty ships, as the host does (`spawnPty`, #849): Windows' own left a `conhost.exe` per terminal.
38
+ * Only a missing or unloadable `conpty.dll` falls back to Windows' own.
39
+ */
40
+ function spawnPty(m) {
41
+ const opts = { name: 'xterm-256color', cols: m.cols, rows: m.rows, cwd: m.cwd, env: m.env }
42
+ try {
43
+ return pty.spawn(m.file, m.args, { ...opts, useConptyDll: true })
44
+ } catch (err) {
45
+ if (!/conpty\.dll/i.test(err.message)) throw err
46
+ process.stderr.write(`[pty-broker] ${err.message}; using the console of Windows instead\n`)
47
+ return pty.spawn(m.file, m.args, opts)
48
+ }
49
+ }
50
+
51
+ function handle(m) {
52
+ if (m.op === 'spawn') {
53
+ let p
54
+ try {
55
+ p = spawnPty(m)
56
+ } catch (err) {
57
+ return send({ ev: 'error', id: m.id, message: err.message })
58
+ }
59
+ ptys.set(m.id, p)
60
+ send({ ev: 'spawned', id: m.id, pid: p.pid })
61
+ p.onData((data) => send({ ev: 'data', id: m.id, data }))
62
+ p.onExit(({ exitCode, signal }) => {
63
+ ptys.delete(m.id)
64
+ send({ ev: 'exit', id: m.id, exitCode, signal: signal ?? null })
65
+ })
66
+ if (killed.delete(m.id)) p.kill()
67
+ return
68
+ }
69
+ const p = ptys.get(m.id)
70
+ if (m.op === 'kill') {
71
+ if (p) p.kill()
72
+ else killed.add(m.id)
73
+ return
74
+ }
75
+ if (!p) return
76
+ try {
77
+ if (m.op === 'write') p.write(m.data)
78
+ else if (m.op === 'resize') p.resize(m.cols, m.rows)
79
+ } catch {
80
+ // a pty in the middle of ending: its exit follows
81
+ }
82
+ }
83
+
84
+ const lines = createInterface({ input: process.stdin })
85
+ lines.on('line', (line) => {
86
+ let m
87
+ try {
88
+ m = JSON.parse(line)
89
+ } catch {
90
+ return
91
+ }
92
+ handle(m)
93
+ })
94
+ // The host is gone (closed, crashed, killed): every pseudoconsole closes with this process
95
+ lines.on('close', () => process.exit(0))
96
+ send({ ev: 'ready' })
@@ -2,16 +2,18 @@
2
2
  // ssh and runs it on the pinned Node that step 1 placed; it never runs on the hub. It imports Node's
3
3
  // own modules only, and reads one argument: the base64 of a JSON object the hub wrote
4
4
  // ({ version, node, platform, hub, packages: [{ name, tarball, integrity }], activate? }), or for the
5
- // update's later steps ({ action: 'pointers', current, previous } and { action: 'prune' }; plan §10.5).
5
+ // update's later steps ({ action: 'pointers', current, previous } and { action: 'prune' }; plan §10.5), or a dev
6
+ // build of the host from a git commit in a working copy there ({ action: 'dev', repo, commit, … }; `pnpm remote-dev`, #1036)
7
+ // and the copy of the store taken before it first runs ({ action: 'store-backup' | 'store-restore' }).
6
8
  //
7
9
  // It prints one line for the hub to read, `CENTRALU-INSTALL done <json>` or
8
10
  // `CENTRALU-INSTALL fail <code> <detail>`, and nothing that matters on its exit code (exit codes do
9
11
  // not survive the way back from Windows; tunnel.ts). The sentences for the person are the hub's.
10
12
  import { spawnSync } from 'node:child_process'
11
13
  import { createHash, randomBytes } from 'node:crypto'
12
- import { chmodSync, existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs'
14
+ import { chmodSync, cpSync, existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs'
13
15
  import { homedir } from 'node:os'
14
- import { basename, join, win32 } from 'node:path'
16
+ import { basename, isAbsolute, join, win32 } from 'node:path'
15
17
  import { pathToFileURL } from 'node:url'
16
18
 
17
19
  const WORD = /^[0-9A-Za-z][0-9A-Za-z.+-]{0,63}$/
@@ -27,7 +29,16 @@ class Refusal extends Error {
27
29
  /** The data folder: `CC_DATA_DIR`, else `~/.centralu`, the rule `serve` and the host use */
28
30
  export function remoteLayout(dataDir) {
29
31
  const root = join(dataDir, 'remote')
30
- return { root, current: join(root, 'current'), previous: join(root, 'previous'), versions: join(root, 'versions'), node: join(root, 'node'), bin: join(root, 'bin'), lock: join(root, 'install.lock') }
32
+ return {
33
+ root,
34
+ current: join(root, 'current'),
35
+ previous: join(root, 'previous'),
36
+ versions: join(root, 'versions'),
37
+ node: join(root, 'node'),
38
+ bin: join(root, 'bin'),
39
+ lock: join(root, 'install.lock'),
40
+ storeBackup: join(root, 'store-backup'),
41
+ }
31
42
  }
32
43
 
33
44
  /** `current` / `previous`: "<centralu version> <node version>", two plain words, or null */
@@ -97,6 +108,13 @@ export function prune(l) {
97
108
  }
98
109
  } catch {}
99
110
  sweep(l.node, new Set(keep.map((p) => `v${p.node}`)))
111
+ // The store copies of versions that are gone (#1036): nothing can start them to need the copy any more. Not reported:
112
+ // `removed` names versions and Nodes
113
+ try {
114
+ for (const name of readdirSync(l.storeBackup).filter((n) => !keep.some((p) => p.version === n))) rmSync(join(l.storeBackup, name), { recursive: true, force: true })
115
+ } catch {
116
+ // none, or one still open; the next prune tries again
117
+ }
100
118
  return { removed, left }
101
119
  }
102
120
 
@@ -365,10 +383,201 @@ export function pruneNow({ dataDir = process.env.CC_DATA_DIR || join(homedir(),
365
383
  }
366
384
  }
367
385
 
386
+ /** The files of the host's store in the data folder: the database and SQLite's write-ahead log beside it */
387
+ const STORE_FILES = ['store.db', 'store.db-wal', 'store.db-shm']
388
+
389
+ /**
390
+ * A copy of the store taken while no host runs, before a dev build first opens it (#1036): a dev build may migrate the
391
+ * store past what the version before it reads (a step that raises `min_reader_version`), and that version would then
392
+ * refuse to start (`StoreTooNewError`), leaving the machine with no host. The copy is kept per `owner`, the version
393
+ * that left the store so (`store-backup/<owner>/`), replaced whole, and `before` names the build it was taken before.
394
+ * A copy goes with its version (`prune`)
395
+ */
396
+ export function storeBackup(p, { dataDir = process.env.CC_DATA_DIR || join(homedir(), '.centralu') } = {}) {
397
+ if (!WORD.test(String(p.owner)) || !WORD.test(String(p.before))) throw new Refusal('params', 'store backup')
398
+ const l = remoteLayout(dataDir)
399
+ mkdirSync(l.storeBackup, { recursive: true })
400
+ const unlock = lock(l)
401
+ try {
402
+ const partial = join(l.storeBackup, `.partial-${process.pid}`)
403
+ rmSync(partial, { recursive: true, force: true })
404
+ try {
405
+ mkdirSync(partial, { recursive: true })
406
+ const files = STORE_FILES.filter((f) => existsSync(join(dataDir, f)))
407
+ for (const f of files) cpSync(join(dataDir, f), join(partial, f))
408
+ writeFileSync(join(partial, 'backup.json'), JSON.stringify({ owner: p.owner, before: p.before, files, at: new Date().toISOString() }, null, 2) + '\n')
409
+ rmSync(join(l.storeBackup, p.owner), { recursive: true, force: true })
410
+ renameSync(partial, join(l.storeBackup, p.owner))
411
+ return { saved: files }
412
+ } catch (err) {
413
+ throw err instanceof Refusal ? err : new Refusal('store_backup', err.message)
414
+ } finally {
415
+ rmSync(partial, { recursive: true, force: true })
416
+ }
417
+ } finally {
418
+ unlock()
419
+ }
420
+ }
421
+
422
+ /**
423
+ * Puts back the copy `storeBackup` took for `owner` (the version about to start), with no host running; without one,
424
+ * touches nothing and says so. A write-ahead log the copy did not have is removed, so SQLite does not replay the dev
425
+ * build's changes onto the restored database. What the dev build wrote to the store since is gone; nothing else in the
426
+ * data folder is touched. The copy stays, so a second attempt finds it again
427
+ */
428
+ export function storeRestore(p, { dataDir = process.env.CC_DATA_DIR || join(homedir(), '.centralu') } = {}) {
429
+ if (!WORD.test(String(p.owner))) throw new Refusal('params', 'store restore')
430
+ const l = remoteLayout(dataDir)
431
+ mkdirSync(l.root, { recursive: true })
432
+ const unlock = lock(l)
433
+ try {
434
+ const dir = join(l.storeBackup, p.owner)
435
+ let meta
436
+ try {
437
+ meta = JSON.parse(readFileSync(join(dir, 'backup.json'), 'utf8'))
438
+ } catch {
439
+ return { restored: false }
440
+ }
441
+ if (meta.owner !== p.owner || !Array.isArray(meta.files)) return { restored: false }
442
+ try {
443
+ for (const f of STORE_FILES) rmSync(join(dataDir, f), { force: true })
444
+ for (const f of meta.files.filter((f) => STORE_FILES.includes(f))) cpSync(join(dir, f), join(dataDir, f))
445
+ } catch (err) {
446
+ throw new Refusal('store_restore', err.message)
447
+ }
448
+ return { restored: true, before: meta.before ?? null, at: meta.at ?? null }
449
+ } finally {
450
+ unlock()
451
+ }
452
+ }
453
+
454
+ /** `a` at least `b`, both plain `x.y.z` (a prerelease or build suffix is ignored) */
455
+ function atLeast(a, b) {
456
+ const n = (v) => String(v).replace(/^v/, '').split(/[-+]/)[0].split('.').map((x) => Number(x) || 0)
457
+ const [x, y] = [n(a), n(b)]
458
+ for (let i = 0; i < 3; i++) if ((x[i] ?? 0) !== (y[i] ?? 0)) return (x[i] ?? 0) > (y[i] ?? 0)
459
+ return true
460
+ }
461
+
462
+ /** A release a dev build can be cut from: a prerelease, since `<base>.local.N` is SemVer only on one (`isLocalBuildVersion`) */
463
+ const DEV_BASE = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*$/
464
+
465
+ /** git in the working copy, by name on PATH as the person runs it there; what it printed, or a refusal */
466
+ function git(repo, args) {
467
+ const r = spawnSync('git', ['-C', repo, ...args], { cwd: repo, encoding: 'utf8', windowsHide: true, maxBuffer: 16 * 1024 * 1024 })
468
+ if (r.error) throw new Refusal('dev_git', r.error.message)
469
+ if (r.status !== 0) throw new Refusal('dev_git', (r.stderr || '').trim().split('\n').at(-1) || `exit ${r.status}`)
470
+ return r.stdout
471
+ }
472
+
473
+ /** The last line of what a run printed that says anything */
474
+ function lastSaid(...texts) {
475
+ for (const t of texts) {
476
+ const line = String(t ?? '').trim().split(/\r?\n/).filter((l) => l.trim()).at(-1)
477
+ if (line) return line.trim()
478
+ }
479
+ return ''
480
+ }
481
+
482
+ /**
483
+ * A dev build of the host from a git commit (`pnpm remote-dev`, #1036; docs/plans/remote-hub.md §10.11), placed beside
484
+ * what is there as `<base>.local.N` with nothing switched: the hub's update steps stop, switch, start and check it
485
+ * (update.ts `switchRemote`).
486
+ *
487
+ * Checked before anything is built: the working copy's HEAD is exactly `p.commit`, it has no changes unless
488
+ * `p.allowDirty`, it speaks `p.protocol` (the protocol the hub links on), and this Node (the pinned one `current` runs
489
+ * on) is not below its floor. Then the working copy's own `bundle.mjs` runs on this Node into the new version's folder
490
+ * (`CC_BUNDLE_OUT`), its launcher package is copied beside with the version stamped into its `package.json` (what the
491
+ * host says it is, through `CC_HOST_SOURCE`), and `install.json` says where it came from:
492
+ * `source: { kind: 'dev', commit, dirty }`. What the build printed goes to `<data>/remote/dev-build.log`.
493
+ */
494
+ export function devBuild(p, { dataDir = process.env.CC_DATA_DIR || join(homedir(), '.centralu'), platform = process.platform, arch = process.arch } = {}) {
495
+ if (typeof p.repo !== 'string' || !isAbsolute(p.repo)) throw new Refusal('dev_repo', String(p.repo ?? ''))
496
+ if (typeof p.commit !== 'string' || !/^[0-9a-f]{40}$/.test(p.commit)) throw new Refusal('params', 'commit')
497
+ const l = remoteLayout(dataDir)
498
+ mkdirSync(l.root, { recursive: true })
499
+ const unlock = lock(l)
500
+ try {
501
+ const current = readPointer(l.current)
502
+ if (!current) throw new Refusal('dev_unmanaged')
503
+ const repo = p.repo
504
+ const bundler = join(repo, 'packages', 'agent-host', 'scripts', 'bundle.mjs')
505
+ if (!existsSync(bundler)) throw new Refusal('dev_repo', repo)
506
+ const head = git(repo, ['rev-parse', 'HEAD']).trim()
507
+ if (head !== p.commit) throw new Refusal('dev_commit', `${head} ${p.commit}`)
508
+ const changes = git(repo, ['status', '--porcelain']).split('\n').filter((line) => line.trim()).length
509
+ if (changes > 0 && !p.allowDirty) throw new Refusal('dev_dirty', String(changes))
510
+ const shim = JSON.parse(readFileSync(join(repo, 'packaging', 'npm', 'centralu', 'package.json'), 'utf8'))
511
+ const base = String(shim.version ?? '')
512
+ if (!DEV_BASE.test(base) || base.includes('.local.')) throw new Refusal('dev_base', base)
513
+ const protocol = Number(/export const PROTOCOL_VERSION = (\d+)/.exec(readFileSync(join(repo, 'packages', 'protocol', 'src', 'envelope.ts'), 'utf8'))?.[1])
514
+ if (protocol !== p.protocol) throw new Refusal('dev_protocol', `${protocol} ${p.protocol}`)
515
+ const floor = /(\d+\.\d+\.\d+)/.exec(String(shim.engines?.node ?? ''))?.[1]
516
+ if (floor && !atLeast(process.versions.node, floor)) throw new Refusal('dev_node', `${process.versions.node} ${floor}`)
517
+ if (!existsSync(join(repo, 'node_modules', 'esbuild'))) throw new Refusal('dev_deps', repo)
518
+ // The next number after every dev build of this release still there, and any a pointer names
519
+ const local = new RegExp(`^${base.replace(/[.+-]/g, '\\$&')}\\.local\\.([1-9]\\d*)$`)
520
+ let names = []
521
+ try {
522
+ names = readdirSync(l.versions)
523
+ } catch {
524
+ // no versions folder: the first one
525
+ }
526
+ const taken = [...names, current.version, readPointer(l.previous)?.version ?? ''].map((n) => Number(local.exec(n)?.[1] ?? 0))
527
+ const version = `${base}.local.${Math.max(0, ...taken) + 1}`
528
+ if (!WORD.test(version)) throw new Refusal('dev_base', version)
529
+ const target = `${platform}-${arch}`
530
+ const partial = join(l.root, `.partial-${version}-${process.pid}`)
531
+ rmSync(partial, { recursive: true, force: true })
532
+ try {
533
+ const hostPkg = join(partial, 'node_modules', '@centralu', `host-${target}`)
534
+ const r = spawnSync(process.execPath, [bundler], {
535
+ cwd: repo,
536
+ env: { ...process.env, CC_BUNDLE_OUT: join(hostPkg, 'host') },
537
+ encoding: 'utf8',
538
+ windowsHide: true,
539
+ maxBuffer: 64 * 1024 * 1024,
540
+ timeout: 20 * 60_000,
541
+ })
542
+ writeFileSync(join(l.root, 'dev-build.log'), `${r.stdout ?? ''}${r.stderr ?? ''}${r.error ? `\n${r.error.message}\n` : ''}`)
543
+ if (r.status !== 0 || !existsSync(join(hostPkg, 'host', 'main.mjs'))) throw new Refusal('dev_build', lastSaid(r.stderr, r.error?.message, r.stdout) || `exit ${r.status}`)
544
+ writeFileSync(join(hostPkg, 'package.json'), JSON.stringify({ name: `@centralu/host-${target}`, version, files: ['host'] }, null, 2) + '\n')
545
+ const shimDir = join(partial, 'node_modules', 'centralu')
546
+ cpSync(join(repo, 'packaging', 'npm', 'centralu', 'bin'), join(shimDir, 'bin'), { recursive: true })
547
+ // The platform packages it names are npm's to fetch; here the host sits beside it, as a hub's install puts it
548
+ const { optionalDependencies: _platforms, ...stamped } = shim
549
+ writeFileSync(join(shimDir, 'package.json'), JSON.stringify({ ...stamped, version }, null, 2) + '\n')
550
+ const source = { kind: 'dev', commit: p.commit, dirty: changes > 0 }
551
+ writeFileSync(
552
+ join(partial, 'install.json'),
553
+ JSON.stringify({ version, node: current.node, platform: target, packages: [], installedAt: new Date().toISOString(), by: p.hub ?? null, source }, null, 2) + '\n',
554
+ )
555
+ mkdirSync(l.versions, { recursive: true })
556
+ placeVersion(l, partial, join(l.versions, version))
557
+ return { installed: { version, node: current.node, source }, current, previous: readPointer(l.previous), removed: [], left: [] }
558
+ } finally {
559
+ rmSync(partial, { recursive: true, force: true })
560
+ }
561
+ } finally {
562
+ unlock()
563
+ }
564
+ }
565
+
368
566
  async function main() {
369
567
  try {
370
568
  const p = JSON.parse(Buffer.from(process.argv[2] ?? '', 'base64').toString('utf8'))
371
- const r = p.action === 'pointers' ? await setPointers(p) : p.action === 'prune' ? pruneNow() : await install(p)
569
+ const r =
570
+ p.action === 'pointers'
571
+ ? await setPointers(p)
572
+ : p.action === 'prune'
573
+ ? pruneNow()
574
+ : p.action === 'dev'
575
+ ? devBuild(p)
576
+ : p.action === 'store-backup'
577
+ ? storeBackup(p)
578
+ : p.action === 'store-restore'
579
+ ? storeRestore(p)
580
+ : await install(p)
372
581
  process.stdout.write(`CENTRALU-INSTALL done ${JSON.stringify(r)}\n`)
373
582
  } catch (err) {
374
583
  const code = err instanceof Refusal ? err.code : 'error'
@@ -65,22 +65,6 @@ CREATE TABLE IF NOT EXISTS approval_rules (
65
65
  created_at INTEGER NOT NULL
66
66
  );
67
67
 
68
- -- Unused: a usage aggregate planned for the first milestone and never built (docs/agent-host.md §6); no build has ever
69
- -- written a row. Released builds up to v0.1.0-beta.10 still run `DELETE FROM usage_facts` when a project is deleted.
70
- -- Dropping it is a contract step (docs/agent-host.md §5.1 rule 2: an older copy of this file creates it), so it waits
71
- -- for a release after the first one that no longer touches the table (#372 follow-up).
72
- CREATE TABLE IF NOT EXISTS usage_facts (
73
- date TEXT NOT NULL,
74
- tool TEXT NOT NULL,
75
- model TEXT NOT NULL,
76
- project_id TEXT,
77
- input_tokens INTEGER NOT NULL DEFAULT 0,
78
- output_tokens INTEGER NOT NULL DEFAULT 0,
79
- cache_tokens INTEGER NOT NULL DEFAULT 0,
80
- cost_est REAL NOT NULL DEFAULT 0,
81
- PRIMARY KEY (date, tool, model, project_id)
82
- );
83
-
84
68
  CREATE TABLE IF NOT EXISTS workspace (
85
69
  id INTEGER PRIMARY KEY CHECK (id = 1),
86
70
  layout TEXT NOT NULL,
@@ -1,8 +1,9 @@
1
1
  {
2
- "commit": "bd2f740",
2
+ "commit": "c807879",
3
3
  "protocolVersion": 1,
4
4
  "hostProof": 1,
5
- "builtAt": "2026-10-17T09:59:26.169Z",
5
+ "tokenOnStdin": 1,
6
+ "builtAt": "2026-10-18T08:56:16.485Z",
6
7
  "runtime": "system-node",
7
8
  "platform": "darwin",
8
9
  "arch": "arm64"
@@ -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
  }