@centralu/host-linux-x64 0.0.0-stage → 0.1.0-beta.16

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.
Files changed (84) hide show
  1. package/README.md +11 -2
  2. package/host/app-template/AGENTS.md +140 -0
  3. package/host/app-template/CLAUDE.md +1 -0
  4. package/host/app-template/centralu.app.json +9 -0
  5. package/host/app-template/gitattributes +2 -0
  6. package/host/app-template/runtime/THIRD_PARTY_LICENSES.txt +950 -0
  7. package/host/app-template/runtime/centralu-app-runtime.mjs +82 -0
  8. package/host/app-template/runtime/centralu-app-runtime.mjs.map +1 -0
  9. package/host/app-template/runtime/mcp-app.js +104 -0
  10. package/host/app-template/server.mjs +71 -0
  11. package/host/app-template/ui/index.html +145 -0
  12. package/host/bundle-info.json +8 -0
  13. package/host/codex-orchestrator-bridge.mjs +3859 -0
  14. package/host/main.mjs +118664 -0
  15. package/host/node_modules/better-sqlite3/build/Release/better_sqlite3.node +0 -0
  16. package/host/node_modules/better-sqlite3/lib/binding.js +62 -0
  17. package/host/node_modules/better-sqlite3/lib/darwin-arm64.js +3 -0
  18. package/host/node_modules/better-sqlite3/lib/darwin-x64.js +3 -0
  19. package/host/node_modules/better-sqlite3/lib/database.js +109 -0
  20. package/host/node_modules/better-sqlite3/lib/index.js +3 -0
  21. package/host/node_modules/better-sqlite3/lib/linux-arm64.js +3 -0
  22. package/host/node_modules/better-sqlite3/lib/linux-x64.js +3 -0
  23. package/host/node_modules/better-sqlite3/lib/linuxmusl-arm64.js +4 -0
  24. package/host/node_modules/better-sqlite3/lib/linuxmusl-x64.js +4 -0
  25. package/host/node_modules/better-sqlite3/lib/methods/aggregate.js +43 -0
  26. package/host/node_modules/better-sqlite3/lib/methods/backup.js +67 -0
  27. package/host/node_modules/better-sqlite3/lib/methods/explain.js +9 -0
  28. package/host/node_modules/better-sqlite3/lib/methods/function.js +31 -0
  29. package/host/node_modules/better-sqlite3/lib/methods/inspect.js +7 -0
  30. package/host/node_modules/better-sqlite3/lib/methods/pragma.js +12 -0
  31. package/host/node_modules/better-sqlite3/lib/methods/serialize.js +16 -0
  32. package/host/node_modules/better-sqlite3/lib/methods/table.js +195 -0
  33. package/host/node_modules/better-sqlite3/lib/methods/transaction.js +78 -0
  34. package/host/node_modules/better-sqlite3/lib/methods/wrappers.js +54 -0
  35. package/host/node_modules/better-sqlite3/lib/sqlite-error.js +25 -0
  36. package/host/node_modules/better-sqlite3/lib/util.js +12 -0
  37. package/host/node_modules/better-sqlite3/lib/win32-arm64.js +3 -0
  38. package/host/node_modules/better-sqlite3/lib/win32-x64.js +3 -0
  39. package/host/node_modules/better-sqlite3/package.json +67 -0
  40. package/host/node_modules/better-sqlite3/prebuilds/linux-x64.node +0 -0
  41. package/host/node_modules/node-pty/build/Release/pty.node +0 -0
  42. package/host/node_modules/node-pty/lib/conpty_console_list_agent.js +16 -0
  43. package/host/node_modules/node-pty/lib/conpty_console_list_agent.js.map +1 -0
  44. package/host/node_modules/node-pty/lib/eventEmitter2.js +47 -0
  45. package/host/node_modules/node-pty/lib/eventEmitter2.js.map +1 -0
  46. package/host/node_modules/node-pty/lib/eventEmitter2.test.js +30 -0
  47. package/host/node_modules/node-pty/lib/eventEmitter2.test.js.map +1 -0
  48. package/host/node_modules/node-pty/lib/index.js +52 -0
  49. package/host/node_modules/node-pty/lib/index.js.map +1 -0
  50. package/host/node_modules/node-pty/lib/interfaces.js +7 -0
  51. package/host/node_modules/node-pty/lib/interfaces.js.map +1 -0
  52. package/host/node_modules/node-pty/lib/shared/conout.js +11 -0
  53. package/host/node_modules/node-pty/lib/shared/conout.js.map +1 -0
  54. package/host/node_modules/node-pty/lib/terminal.js +190 -0
  55. package/host/node_modules/node-pty/lib/terminal.js.map +1 -0
  56. package/host/node_modules/node-pty/lib/terminal.test.js +139 -0
  57. package/host/node_modules/node-pty/lib/terminal.test.js.map +1 -0
  58. package/host/node_modules/node-pty/lib/testUtils.test.js +28 -0
  59. package/host/node_modules/node-pty/lib/testUtils.test.js.map +1 -0
  60. package/host/node_modules/node-pty/lib/types.js +7 -0
  61. package/host/node_modules/node-pty/lib/types.js.map +1 -0
  62. package/host/node_modules/node-pty/lib/unixTerminal.js +346 -0
  63. package/host/node_modules/node-pty/lib/unixTerminal.js.map +1 -0
  64. package/host/node_modules/node-pty/lib/unixTerminal.test.js +351 -0
  65. package/host/node_modules/node-pty/lib/unixTerminal.test.js.map +1 -0
  66. package/host/node_modules/node-pty/lib/utils.js +39 -0
  67. package/host/node_modules/node-pty/lib/utils.js.map +1 -0
  68. package/host/node_modules/node-pty/lib/windowsConoutConnection.js +125 -0
  69. package/host/node_modules/node-pty/lib/windowsConoutConnection.js.map +1 -0
  70. package/host/node_modules/node-pty/lib/windowsPtyAgent.js +320 -0
  71. package/host/node_modules/node-pty/lib/windowsPtyAgent.js.map +1 -0
  72. package/host/node_modules/node-pty/lib/windowsPtyAgent.test.js +90 -0
  73. package/host/node_modules/node-pty/lib/windowsPtyAgent.test.js.map +1 -0
  74. package/host/node_modules/node-pty/lib/windowsTerminal.js +199 -0
  75. package/host/node_modules/node-pty/lib/windowsTerminal.js.map +1 -0
  76. package/host/node_modules/node-pty/lib/windowsTerminal.test.js +219 -0
  77. package/host/node_modules/node-pty/lib/windowsTerminal.test.js.map +1 -0
  78. package/host/node_modules/node-pty/lib/worker/conoutSocketWorker.js +22 -0
  79. package/host/node_modules/node-pty/lib/worker/conoutSocketWorker.js.map +1 -0
  80. package/host/node_modules/node-pty/package.json +64 -0
  81. package/host/remote-install.mjs +376 -0
  82. package/host/remote-runtime.json +21 -0
  83. package/host/schema.sql +98 -0
  84. package/package.json +19 -4
@@ -0,0 +1,376 @@
1
+ // Centralu's remote installer, step 2 (docs/plans/remote-hub.md §10.2). The hub sends this file over
2
+ // ssh and runs it on the pinned Node that step 1 placed; it never runs on the hub. It imports Node's
3
+ // own modules only, and reads one argument: the base64 of a JSON object the hub wrote
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).
6
+ //
7
+ // It prints one line for the hub to read, `CENTRALU-INSTALL done <json>` or
8
+ // `CENTRALU-INSTALL fail <code> <detail>`, and nothing that matters on its exit code (exit codes do
9
+ // not survive the way back from Windows; tunnel.ts). The sentences for the person are the hub's.
10
+ import { spawnSync } from 'node:child_process'
11
+ import { createHash, randomBytes } from 'node:crypto'
12
+ import { chmodSync, existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs'
13
+ import { homedir } from 'node:os'
14
+ import { basename, join } from 'node:path'
15
+ import { pathToFileURL } from 'node:url'
16
+
17
+ const WORD = /^[0-9A-Za-z][0-9A-Za-z.+-]{0,63}$/
18
+
19
+ class Refusal extends Error {
20
+ constructor(code, detail = '') {
21
+ super(`${code} ${detail}`.trim())
22
+ this.code = code
23
+ this.detail = detail
24
+ }
25
+ }
26
+
27
+ /** The data folder: `CC_DATA_DIR`, else `~/.centralu`, the rule `serve` and the host use */
28
+ export function remoteLayout(dataDir) {
29
+ 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') }
31
+ }
32
+
33
+ /** `current` / `previous`: "<centralu version> <node version>", two plain words, or null */
34
+ export function readPointer(file) {
35
+ try {
36
+ const m = /^\s*(\S+)[ \t]+(\S+)\s*$/.exec(readFileSync(file, 'utf8'))
37
+ return m && WORD.test(m[1]) && WORD.test(m[2]) ? { version: m[1], node: m[2] } : null
38
+ } catch {
39
+ return null
40
+ }
41
+ }
42
+
43
+ /**
44
+ * Replaces `file` by a rename over it, so a reader (the launcher, `serve --connection`) sees the old
45
+ * line or the new one and never half of one. Atomic on posix, and measured atomic on Windows
46
+ * (`renameSync` over an existing file replaces it; plan §10.1)
47
+ */
48
+ export function writeAtomic(file, text) {
49
+ const tmp = `${file}.${process.pid}-${randomBytes(4).toString('hex')}.tmp`
50
+ writeFileSync(tmp, text)
51
+ renameSync(tmp, file)
52
+ }
53
+
54
+ /** Points `current` at `next`, the old `current` becoming `previous` (plan §10.5 step 3) */
55
+ export function switchTo(l, next) {
56
+ const cur = readPointer(l.current)
57
+ if (cur && cur.version === next.version && cur.node === next.node) return { current: next, previous: readPointer(l.previous) }
58
+ if (cur) writeAtomic(l.previous, `${cur.version} ${cur.node}\n`)
59
+ writeAtomic(l.current, `${next.version} ${next.node}\n`)
60
+ return { current: next, previous: cur ?? readPointer(l.previous) }
61
+ }
62
+
63
+ /**
64
+ * Removes every version that is neither `current` nor `previous`, and every Node neither uses. A
65
+ * folder that cannot go (a Windows program still running from it) stays and is named, never fatal
66
+ */
67
+ export function prune(l) {
68
+ const keep = [readPointer(l.current), readPointer(l.previous)].filter(Boolean)
69
+ const removed = []
70
+ const left = []
71
+ const sweep = (dir, kept) => {
72
+ let names = []
73
+ try {
74
+ names = readdirSync(dir)
75
+ } catch {
76
+ return
77
+ }
78
+ for (const name of names) {
79
+ if (kept.has(name) || name.startsWith('.')) continue
80
+ try {
81
+ rmSync(join(dir, name), { recursive: true, force: true })
82
+ removed.push(name)
83
+ } catch {
84
+ left.push(name)
85
+ }
86
+ }
87
+ }
88
+ sweep(l.versions, new Set(keep.map((p) => p.version)))
89
+ // What `placeVersion` moved aside and could not remove then
90
+ try {
91
+ for (const name of readdirSync(l.root).filter((n) => n.startsWith('.replaced-'))) {
92
+ try {
93
+ rmSync(join(l.root, name), { recursive: true, force: true })
94
+ } catch {
95
+ left.push(name)
96
+ }
97
+ }
98
+ } catch {}
99
+ sweep(l.node, new Set(keep.map((p) => `v${p.node}`)))
100
+ return { removed, left }
101
+ }
102
+
103
+ /** A lock folder with no pid in it, younger than this, is held: its installer has made it and not yet written the pid */
104
+ const LOCK_YOUNG_MS = 60_000
105
+
106
+ /**
107
+ * One installer at a time per data folder; a lock whose process is gone is taken over.
108
+ *
109
+ * Taking it is `mkdir`, which only one installer can do; the pid follows. A lock found with no pid is
110
+ * held while it is young (another installer between the two, which reading it as pid 0 once took
111
+ * over), and stale only after `LOCK_YOUNG_MS` (an installer that died between them). A stale lock is
112
+ * taken over by moving it aside first, so of two installers that both found it stale only one removes
113
+ * it, and one that moved a lock just replaced by a live one puts it back. Unlock removes the lock only
114
+ * while it is still this run's own (its `token`).
115
+ */
116
+ export function lock(l) {
117
+ const token = `${process.pid}-${randomBytes(8).toString('hex')}`
118
+ for (let i = 0; i < 3; i++) {
119
+ try {
120
+ mkdirSync(l.lock)
121
+ writeFileSync(join(l.lock, 'pid'), String(process.pid))
122
+ writeFileSync(join(l.lock, 'token'), token)
123
+ return () => {
124
+ if (readFileSafe(join(l.lock, 'token')) === token) rmSync(l.lock, { recursive: true, force: true })
125
+ }
126
+ } catch (err) {
127
+ if (err.code !== 'EEXIST') throw err
128
+ }
129
+ const held = readFileSafe(join(l.lock, 'pid'))
130
+ if (!lockIsStale(l.lock, held)) throw new Refusal('busy', held.trim())
131
+ const aside = `${l.lock}.${token}.stale`
132
+ try {
133
+ renameSync(l.lock, aside)
134
+ } catch {
135
+ continue // gone already, or another installer moved it first
136
+ }
137
+ const moved = readFileSafe(join(aside, 'pid'))
138
+ if (moved !== held) {
139
+ // Replaced by a live lock between reading it and moving it: not ours to remove
140
+ try {
141
+ renameSync(aside, l.lock)
142
+ } catch {
143
+ // Someone holds a newer one; this one stays aside, named, and harmless
144
+ }
145
+ throw new Refusal('busy', moved.trim())
146
+ }
147
+ rmSync(aside, { recursive: true, force: true })
148
+ }
149
+ throw new Refusal('busy')
150
+ }
151
+
152
+ function lockIsStale(dir, pidText) {
153
+ const pid = Number(pidText.trim())
154
+ if (pidText.trim() && pid > 0) return !alive(pid)
155
+ try {
156
+ return Date.now() - statSync(dir).mtimeMs > LOCK_YOUNG_MS
157
+ } catch {
158
+ return true // gone: the next turn takes the lock
159
+ }
160
+ }
161
+
162
+ function readFileSafe(file) {
163
+ try {
164
+ return readFileSync(file, 'utf8')
165
+ } catch {
166
+ return ''
167
+ }
168
+ }
169
+
170
+ function alive(pid) {
171
+ try {
172
+ process.kill(pid, 0)
173
+ return true
174
+ } catch (err) {
175
+ return err.code === 'EPERM'
176
+ }
177
+ }
178
+
179
+ /** The system's tar. On Windows the one in System32 (bsdtar): a GNU tar on PATH reads `C:` as a host */
180
+ function tarProgram() {
181
+ return process.platform === 'win32' ? join(process.env.SystemRoot || 'C:\\Windows', 'System32', 'tar.exe') : 'tar'
182
+ }
183
+
184
+ /** The tarball's bytes, refused unless their sha512 is the integrity the hub read from the signed metadata */
185
+ export async function fetchChecked(pkg) {
186
+ let res
187
+ try {
188
+ res = await fetch(pkg.tarball, { signal: AbortSignal.timeout(300_000) })
189
+ } catch (err) {
190
+ throw new Refusal('download', `${pkg.name}: ${err.cause?.message ?? err.message}`)
191
+ }
192
+ if (!res.ok) throw new Refusal('download', `${pkg.name}: HTTP ${res.status}`)
193
+ const bytes = Buffer.from(await res.arrayBuffer())
194
+ const have = `sha512-${createHash('sha512').update(bytes).digest('base64')}`
195
+ if (have !== pkg.integrity) throw new Refusal('integrity', pkg.name)
196
+ return bytes
197
+ }
198
+
199
+ /**
200
+ * Which package brings the host, where the host sits in it, and what of it a remote never runs (plan
201
+ * S6). The host-only package (`@centralu/host-<platform>`) is the host and nothing else; a version
202
+ * published before it existed comes as its platform package, whose window is thrown away
203
+ */
204
+ function hostParts(platform, packages) {
205
+ const hostOnly = `@centralu/host-${platform}`
206
+ if (packages.some((pkg) => pkg.name === hostOnly)) return { pkg: hostOnly, host: join('host', 'main.mjs'), drop: [] }
207
+ const pkg = `@centralu/${platform}`
208
+ if (platform === 'win32-x64') return { pkg, host: join('Centralu', 'resources', 'host', 'main.mjs'), drop: [join('Centralu', 'centralu.exe')] }
209
+ return { pkg, host: join('host', 'main.mjs'), drop: ['Centralu.AppImage'] }
210
+ }
211
+
212
+ function sameInstall(dir, p) {
213
+ try {
214
+ const was = JSON.parse(readFileSync(join(dir, 'install.json'), 'utf8'))
215
+ return was.version === p.version && p.packages.every((pkg) => was.packages?.some((w) => w.name === pkg.name && w.integrity === pkg.integrity))
216
+ } catch {
217
+ return false
218
+ }
219
+ }
220
+
221
+ /**
222
+ * Puts the unpacked `partial` folder in place as `dir`. A `dir` already there (the same version
223
+ * installed again with other bytes, possibly the one `current` runs) is moved aside first and removed
224
+ * only once the new one is in place, and put back if that fails: removing it first would leave
225
+ * `current` naming nothing after a failed rename, and on Windows a removal can stop halfway on the
226
+ * files of a host running from it (moving that folder is allowed, plan §10.5). What cannot be removed
227
+ * stays aside under a dot name, and the next prune tries again
228
+ */
229
+ export function placeVersion(l, partial, dir) {
230
+ const aside = existsSync(dir) ? join(l.root, `.replaced-${basename(dir)}-${process.pid}-${randomBytes(4).toString('hex')}`) : null
231
+ if (aside) renameSync(dir, aside)
232
+ try {
233
+ renameSync(partial, dir)
234
+ } catch (err) {
235
+ if (aside) renameSync(aside, dir)
236
+ throw err
237
+ }
238
+ if (aside) {
239
+ try {
240
+ rmSync(aside, { recursive: true, force: true })
241
+ } catch {
242
+ // A Windows program still running from it; harmless where it is
243
+ }
244
+ }
245
+ }
246
+
247
+ /** Installs `p.version` beside what is there, switches `current` to it, and keeps `previous` */
248
+ export async function install(p, { dataDir = process.env.CC_DATA_DIR || join(homedir(), '.centralu'), platform = process.platform } = {}) {
249
+ if (!WORD.test(p.version) || !WORD.test(p.node)) throw new Refusal('params', 'version')
250
+ const l = remoteLayout(dataDir)
251
+ mkdirSync(l.root, { recursive: true })
252
+ const unlock = lock(l)
253
+ try {
254
+ const dir = join(l.versions, p.version)
255
+ const parts = hostParts(p.platform, p.packages)
256
+ if (!sameInstall(dir, p)) {
257
+ // Everything lands in a folder of its own first; the version's folder appears by one rename
258
+ const partial = join(l.root, `.partial-${p.version}-${process.pid}`)
259
+ rmSync(partial, { recursive: true, force: true })
260
+ try {
261
+ for (const [i, pkg] of p.packages.entries()) {
262
+ const bytes = await fetchChecked(pkg)
263
+ const file = join(partial, `${i}.tgz`)
264
+ const dest = join(partial, 'node_modules', ...pkg.name.split('/'))
265
+ mkdirSync(dest, { recursive: true })
266
+ writeFileSync(file, bytes)
267
+ const r = spawnSync(tarProgram(), ['-xzf', file, '-C', dest, '--strip-components', '1'], { encoding: 'utf8', windowsHide: true })
268
+ if (r.status !== 0) throw new Refusal('unpack', `${pkg.name}: ${(r.stderr || r.error?.message || '').trim().split('\n').at(-1)}`)
269
+ rmSync(file, { force: true })
270
+ }
271
+ const pkgDir = join(partial, 'node_modules', ...parts.pkg.split('/'))
272
+ if (!existsSync(join(pkgDir, parts.host))) throw new Refusal('no_host', p.platform)
273
+ for (const d of parts.drop) rmSync(join(pkgDir, d), { force: true })
274
+ writeFileSync(
275
+ join(partial, 'install.json'),
276
+ JSON.stringify({ version: p.version, node: p.node, platform: p.platform, packages: p.packages.map(({ name, integrity }) => ({ name, integrity })), installedAt: new Date().toISOString(), by: p.hub ?? null }, null, 2) + '\n',
277
+ )
278
+ mkdirSync(l.versions, { recursive: true })
279
+ placeVersion(l, partial, dir)
280
+ } finally {
281
+ rmSync(partial, { recursive: true, force: true })
282
+ }
283
+ }
284
+ // An update installs beside and switches later, once the running host is stopped (plan §10.5):
285
+ // nothing is pointed at the new version and nothing is removed yet. Without a `current`, no
286
+ // launcher either, so the lookup still finds the Centralu that runs now
287
+ if (p.activate === false) {
288
+ return { installed: { version: p.version, node: p.node }, current: readPointer(l.current), previous: readPointer(l.previous), removed: [], left: [] }
289
+ }
290
+ const launcher = await ensureManagedLauncher(l, dir, p.version, platform)
291
+ const pointers = switchTo(l, { version: p.version, node: p.node })
292
+ return { ...pointers, ...prune(l), launcher }
293
+ } finally {
294
+ unlock()
295
+ }
296
+ }
297
+
298
+ function launcherPath(l, platform) {
299
+ return join(l.bin, platform === 'win32' ? 'centralu.cmd' : 'centralu')
300
+ }
301
+
302
+ /** The launcher reads `current` at every start and never changes (S8): written once, never over */
303
+ async function ensureManagedLauncher(l, dir, version, platform) {
304
+ const launcher = launcherPath(l, platform)
305
+ if (!existsSync(launcher)) {
306
+ const serve = await import(pathToFileURL(join(dir, 'node_modules', 'centralu', 'bin', 'serve.mjs')).href)
307
+ if (typeof serve.managedLauncherScript !== 'function') throw new Refusal('no_launcher', version)
308
+ mkdirSync(l.bin, { recursive: true })
309
+ writeAtomic(launcher, serve.managedLauncherScript(platform))
310
+ if (platform !== 'win32') chmodSync(launcher, 0o755)
311
+ }
312
+ return launcher
313
+ }
314
+
315
+ const pointerOf = (v) => (v && WORD.test(v.version) && WORD.test(v.node) ? { version: v.version, node: v.node } : null)
316
+
317
+ /**
318
+ * Writes both pointers as given (plan §10.5 steps 3 and 5, and rollback): `previous` first, then
319
+ * `current`, each by rename; a null `previous` removes that file. `current` must name a version and a
320
+ * Node that are both there. A null `current` puts the machine back to having no managed version: the
321
+ * pointers and the launcher go, so the lookup finds the Centralu it ran before (an npm install).
322
+ * Nothing is removed from `versions/` or `node/`
323
+ */
324
+ export async function setPointers(p, { dataDir = process.env.CC_DATA_DIR || join(homedir(), '.centralu'), platform = process.platform } = {}) {
325
+ const l = remoteLayout(dataDir)
326
+ mkdirSync(l.root, { recursive: true })
327
+ const unlock = lock(l)
328
+ try {
329
+ const current = pointerOf(p.current)
330
+ const previous = pointerOf(p.previous)
331
+ if (!current) {
332
+ for (const f of [l.previous, l.current, launcherPath(l, platform)]) rmSync(f, { force: true })
333
+ return { current: null, previous: null }
334
+ }
335
+ const dir = join(l.versions, current.version)
336
+ // Both must be there: a `previous` naming nothing would offer a rollback to a version that is gone
337
+ for (const v of [current, previous].filter(Boolean)) {
338
+ if (!existsSync(join(l.versions, v.version, 'install.json')) || !existsSync(join(l.node, `v${v.node}`))) throw new Refusal('missing', `${v.version} ${v.node}`)
339
+ }
340
+ await ensureManagedLauncher(l, dir, current.version, platform)
341
+ if (previous) writeAtomic(l.previous, `${previous.version} ${previous.node}\n`)
342
+ else rmSync(l.previous, { force: true })
343
+ writeAtomic(l.current, `${current.version} ${current.node}\n`)
344
+ return { current, previous }
345
+ } finally {
346
+ unlock()
347
+ }
348
+ }
349
+
350
+ /** Plan §10.5 step 6, on its own: what is neither `current` nor `previous` goes */
351
+ export function pruneNow({ dataDir = process.env.CC_DATA_DIR || join(homedir(), '.centralu') } = {}) {
352
+ const l = remoteLayout(dataDir)
353
+ mkdirSync(l.root, { recursive: true })
354
+ const unlock = lock(l)
355
+ try {
356
+ return { current: readPointer(l.current), previous: readPointer(l.previous), ...prune(l) }
357
+ } finally {
358
+ unlock()
359
+ }
360
+ }
361
+
362
+ async function main() {
363
+ try {
364
+ const p = JSON.parse(Buffer.from(process.argv[2] ?? '', 'base64').toString('utf8'))
365
+ const r = p.action === 'pointers' ? await setPointers(p) : p.action === 'prune' ? pruneNow() : await install(p)
366
+ process.stdout.write(`CENTRALU-INSTALL done ${JSON.stringify(r)}\n`)
367
+ } catch (err) {
368
+ const code = err instanceof Refusal ? err.code : 'error'
369
+ const detail = err instanceof Refusal ? err.detail : err.message
370
+ process.stdout.write(`CENTRALU-INSTALL fail ${code} ${String(detail).replace(/\s+/g, ' ')}\n`)
371
+ process.exitCode = 1
372
+ }
373
+ }
374
+
375
+ // Run as a program (not imported by a test). Node names its main module by its real path: compare that
376
+ if (process.argv[1] && import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href) await main()
@@ -0,0 +1,21 @@
1
+ {
2
+ "node": {
3
+ "version": "24.21.0",
4
+ "signedBy": "5BE8A3F6C8A5C01D106C0AD820B1A390B168D356",
5
+ "releaseKeys": "481637f813e912c4aa3622d7964ab426c97b8e8d",
6
+ "archives": {
7
+ "linux-arm64": {
8
+ "file": "node-v24.21.0-linux-arm64.tar.gz",
9
+ "sha256": "724282c3b43aec998aa9527380465b45d229e021b58035f5f4f63095eabfe5d5"
10
+ },
11
+ "linux-x64": {
12
+ "file": "node-v24.21.0-linux-x64.tar.gz",
13
+ "sha256": "6e1db87ef58b8819e5d5402eff1536491b18edd8eb7bee5ef7897876e88dc5ff"
14
+ },
15
+ "win32-x64": {
16
+ "file": "node-v24.21.0-win-x64.zip",
17
+ "sha256": "158f7685b44de51f6c0df1d153526cbcd3e1bc739a8dfc607721cef75de9e541"
18
+ }
19
+ }
20
+ }
21
+ }
@@ -0,0 +1,98 @@
1
+ -- Centralu local storage schema v1
2
+ -- Read by the host (better-sqlite3). Migrations are handled by the steps in dev-services/store.ts.
3
+ --
4
+ -- **user_version is never set here.** This file runs every time the database is opened (safe,
5
+ -- since every table uses CREATE IF NOT EXISTS), so if `PRAGMA user_version = 1` were here, a
6
+ -- database already at v27 would reset to 1 on every open and **rerun all 26 migrations**.
7
+ -- Measured (2026-09-02, store.db at 94MB, 66,700 messages): 4.4 to 5.0 seconds per open, of
8
+ -- which v3 (1.4s), v11 (1.7s) and v21 (1.7s) were each a full table scan — a startup cost that
9
+ -- only grows as the conversation history piles up.
10
+ --
11
+ -- Leaving the value unset entirely means a new database starts at 0 and runs every step once
12
+ -- (the same as before), while an existing database keeps whatever number the last step left
13
+ -- behind, so nothing reruns.
14
+
15
+ CREATE TABLE IF NOT EXISTS projects (
16
+ id TEXT PRIMARY KEY,
17
+ path TEXT NOT NULL UNIQUE,
18
+ name TEXT NOT NULL,
19
+ default_tool TEXT NOT NULL DEFAULT 'claude',
20
+ -- The default model and effort per tool (#107): {"codex":{"model":"gpt-5.6","effort":"high"}, ...}.
21
+ -- This is where the scalars default_model/default_effort used to be — a model name is a tool's
22
+ -- own vocabulary, so remembering a single one with no tool attached meant a session for a
23
+ -- different tool inherited that name and died on its first turn.
24
+ default_models TEXT,
25
+ sidebar_order INTEGER NOT NULL DEFAULT 0,
26
+ created_at INTEGER NOT NULL
27
+ );
28
+
29
+ CREATE TABLE IF NOT EXISTS sessions (
30
+ id TEXT PRIMARY KEY,
31
+ -- The orchestrator does not belong to any project (one per app, crossing all projects).
32
+ -- Making this NOT NULL would force it to hang off some project, and deleting that project
33
+ -- would kill it too via CASCADE.
34
+ project_id TEXT REFERENCES projects(id) ON DELETE CASCADE,
35
+ tool TEXT NOT NULL,
36
+ external_id TEXT,
37
+ name TEXT NOT NULL,
38
+ auto_named INTEGER NOT NULL DEFAULT 1,
39
+ state TEXT NOT NULL DEFAULT 'idle',
40
+ is_orchestrator INTEGER NOT NULL DEFAULT 0,
41
+ last_read_seq INTEGER NOT NULL DEFAULT 0,
42
+ waiting_since INTEGER,
43
+ created_at INTEGER NOT NULL
44
+ );
45
+ CREATE INDEX IF NOT EXISTS idx_sessions_project ON sessions(project_id);
46
+
47
+ CREATE TABLE IF NOT EXISTS messages (
48
+ session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
49
+ seq INTEGER NOT NULL,
50
+ role TEXT NOT NULL,
51
+ kind TEXT NOT NULL,
52
+ payload TEXT NOT NULL,
53
+ ts INTEGER NOT NULL,
54
+ PRIMARY KEY (session_id, seq)
55
+ );
56
+
57
+ -- "Always allow" rules (FR-3). Uses session_id when scope=session, project_id when scope=project
58
+ CREATE TABLE IF NOT EXISTS approval_rules (
59
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
60
+ scope TEXT NOT NULL,
61
+ project_id TEXT,
62
+ session_id TEXT,
63
+ matcher TEXT NOT NULL,
64
+ decision TEXT NOT NULL,
65
+ created_at INTEGER NOT NULL
66
+ );
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
+ CREATE TABLE IF NOT EXISTS workspace (
85
+ id INTEGER PRIMARY KEY CHECK (id = 1),
86
+ layout TEXT NOT NULL,
87
+ updated_at INTEGER NOT NULL
88
+ );
89
+
90
+ -- Commit attribution (#50): which session made this commit — recorded only here, never in the repository.
91
+ -- The hash is picked up from the agent's git commit tool output, so it can be short (matched by prefix).
92
+ CREATE TABLE IF NOT EXISTS commit_sessions (
93
+ project_id TEXT NOT NULL,
94
+ sha TEXT NOT NULL,
95
+ session_id TEXT NOT NULL,
96
+ ts INTEGER NOT NULL,
97
+ PRIMARY KEY (project_id, sha)
98
+ );
package/package.json CHANGED
@@ -1,6 +1,21 @@
1
1
  {
2
2
  "name": "@centralu/host-linux-x64",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0-beta.16",
4
+ "description": "Centralu's host without the window, for Linux on x86-64: what a hub installs on a linked machine",
5
+ "license": "MIT",
6
+ "author": "ijun17",
7
+ "homepage": "https://github.com/ijun17/centralu",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/ijun17/centralu.git"
11
+ },
12
+ "os": [
13
+ "linux"
14
+ ],
15
+ "cpu": [
16
+ "x64"
17
+ ],
18
+ "files": [
19
+ "host"
20
+ ]
21
+ }