centralu 0.1.0-beta.25 → 0.1.0-beta.26

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 (2) hide show
  1. package/bin/serve.mjs +201 -60
  2. package/package.json +5 -5
package/bin/serve.mjs CHANGED
@@ -15,8 +15,8 @@
15
15
  * import time and cannot be imported by a test. package.json "files" lists the whole bin/
16
16
  * directory, so this ships automatically.
17
17
  */
18
- import { spawn } from 'node:child_process'
19
- import { createHash, randomBytes } from 'node:crypto'
18
+ import { execFileSync, spawn } from 'node:child_process'
19
+ import { createHash, createHmac, randomBytes } from 'node:crypto'
20
20
  import { chmodSync, existsSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, statSync, writeFileSync, writeSync, closeSync } from 'node:fs'
21
21
  import { connect, createServer } from 'node:net'
22
22
  import { userInfo } from 'node:os'
@@ -295,7 +295,29 @@ export function rotateServeToken(dataDir, platform = process.platform) {
295
295
  */
296
296
  export function launcherScript(platform, execPath, cliPath) {
297
297
  if (platform === 'win32') {
298
- return `@echo off\r\nrem Written by \`centralu serve\`: reaches this install without npm's folder on PATH.\r\n"${execPath}" "${cliPath}" %*\r\n`
298
+ // A batch file reads every % as the start of a variable (`100% cli.mjs" %*` came out as `100*`); %% is a literal one
299
+ const lit = (v) => String(v).replaceAll('%', '%%')
300
+ /*
301
+ * The file is UTF-8, and cmd.exe reads each line of a batch file in the console's code page as it reaches it: 949
302
+ * on a Korean Windows, where a profile folder named in Korean came out as other characters and node found no
303
+ * centralu.mjs there (#849). So the lines before the paths, all ASCII, switch the console to UTF-8 (65001), and the
304
+ * code page it had is put back on the line with the paths, ahead of node: cmd.exe reads and decodes a whole line
305
+ * before it runs any of it, so the paths are already read as UTF-8 by then. The console is the shell's that ran
306
+ * this, and node runs on the page it had; a Ctrl+C answered at "Terminate batch job (Y/N)?" ends this file
307
+ * before any later line, and a restore there was skipped. chcp is named by its full path: the shell's PATH may
308
+ * not have System32 on it.
309
+ */
310
+ return [
311
+ '@echo off',
312
+ "rem Written by `centralu serve`: reaches this install without npm's folder on PATH.",
313
+ 'setlocal',
314
+ 'set "CENTRALU_CP="',
315
+ `for /f "tokens=2 delims=:." %%c in ('%SystemRoot%\\System32\\chcp.com') do set /a "CENTRALU_CP=%%c" >nul 2>&1`,
316
+ '%SystemRoot%\\System32\\chcp.com 65001 >nul 2>&1',
317
+ `(if defined CENTRALU_CP %SystemRoot%\\System32\\chcp.com %CENTRALU_CP% >nul 2>&1) & "${lit(execPath)}" "${lit(cliPath)}" %*`,
318
+ 'exit /b %ERRORLEVEL%',
319
+ '',
320
+ ].join('\r\n')
299
321
  }
300
322
  const q = (v) => `'${String(v).replaceAll("'", "'\\''")}'`
301
323
  return (
@@ -398,7 +420,7 @@ export function protocolVersionOf(entry) {
398
420
  * `install` (additive, the line stays `v: 1`) says what runs this command (`installInfo`); `detach`
399
421
  * is added by `--detach` only.
400
422
  */
401
- export function connectionLine({ port, token, version, protocolVersion, dataDir, hostRunning, install, autostart, detach }) {
423
+ export function connectionLine({ port, token, version, protocolVersion, dataDir, hostRunning, install, autostart, detach, proof }) {
402
424
  return JSON.stringify({
403
425
  v: 1,
404
426
  port,
@@ -410,6 +432,8 @@ export function connectionLine({ port, token, version, protocolVersion, dataDir,
410
432
  ...(install ? { install } : {}),
411
433
  ...(autostart ? { autostart } : {}),
412
434
  ...(detach ? { detach } : {}),
435
+ // The running host proves it holds the token before the hub sends it (#838); absent: the hub sends hello as before
436
+ ...(proof ? { proof: true } : {}),
413
437
  })
414
438
  }
415
439
 
@@ -584,16 +608,73 @@ export function mismatchServerVersion(message) {
584
608
  return m ? Number(m[1] ?? m[2]) : null
585
609
  }
586
610
 
611
+ /**
612
+ * Whether the installed host answers a `prove` (#838): a bundle says so in `bundle-info.json` (`hostProof`), a host run
613
+ * from source has `transport/host-proof.ts`. The connection line's `proof` is this, not a live answer: a program another
614
+ * account put on the port could otherwise answer the probe so as to make the line say no, and the hub send the token.
615
+ */
616
+ export function hostProvesOf(entry) {
617
+ try {
618
+ return JSON.parse(readFileSync(join(dirname(entry), 'bundle-info.json'), 'utf8')).hostProof === 1
619
+ } catch {
620
+ // not a bundle; try the source below
621
+ }
622
+ return existsSync(join(dirname(entry), 'transport', 'host-proof.ts'))
623
+ }
624
+
625
+ /** The answer a host holding `token` gives to a `prove` of `nonce` (the host's `hostProof`) */
626
+ function proofOf(token, nonce) {
627
+ return createHmac('sha256', token).update(`centralu host proof v1\0${nonce}`).digest('base64url')
628
+ }
629
+
630
+ /**
631
+ * Opens a socket to the host and says hello with the token, the way `probeHost` and `askHostToStop` do. With `prove`
632
+ * (the installed host can, `hostProvesOf`), the host first proves it holds the token (#838), and the token goes out only
633
+ * then: whatever else listens on the port (another account's program, while the host is down) never sees it.
634
+ * `on.hello(ws)` runs after the hello is sent; `on.unproven()` when the proof did not come.
635
+ */
636
+ function helloSocket({ port, token, protocolVersion, prove, WebSocketImpl, on }) {
637
+ const ws = new WebSocketImpl(`ws://127.0.0.1:${port}`)
638
+ const nonce = prove ? randomBytes(24).toString('base64url') : null
639
+ // Proving only once the socket is open: a port where nothing listens is gone, not unproven
640
+ let proving = false
641
+ const hello = () => ws.send(JSON.stringify({ kind: 'hello', token, protocolVersion: protocolVersion ?? 0 }))
642
+ ws.onopen = () => {
643
+ if (!prove) return hello()
644
+ proving = true
645
+ ws.send(JSON.stringify({ kind: 'prove', nonce }))
646
+ }
647
+ ws.onmessage = (ev) => {
648
+ let m
649
+ try {
650
+ m = JSON.parse(String(ev.data))
651
+ } catch {
652
+ m = null
653
+ }
654
+ if (proving) {
655
+ proving = false
656
+ if (m?.kind === 'proof' && m.nonce === nonce && m.mac === proofOf(token, nonce)) return hello()
657
+ return on.unproven()
658
+ }
659
+ if (m) on.message(m)
660
+ }
661
+ ws.onerror = () => (proving ? on.unproven() : on.gone())
662
+ ws.onclose = () => (proving ? on.unproven() : on.gone())
663
+ return ws
664
+ }
665
+
587
666
  /**
588
667
  * Whether a host that holds this token answers on the port: a real hello, not a TCP connect, so a
589
668
  * stranger's program on the same port does not count, and the answer carries the running host's
590
669
  * own protocol and version (which differ from the installed package's after an `npm i -g` the
591
- * host was not restarted for).
670
+ * host was not restarted for). With `prove` the host proves it holds the token first (#838).
592
671
  *
593
- * `{ running, protocolVersion?, version? }`. Node 22.4+ has a global WebSocket; on an older 22
594
- * this falls back to a TCP connect and reports only `running`.
672
+ * `{ running, protocolVersion?, version?, unproven? }`. `unproven`: something answered on the port but did not prove
673
+ * it holds the token, so it was not sent: another program, or a host from before #838 still running after an update
674
+ * (`heldHere` tells those apart by the data folder's lock). Node 22.4+ has a global WebSocket; on an older 22 this falls
675
+ * back to a TCP connect and reports only `running`.
595
676
  */
596
- export function probeHost({ port, token, protocolVersion, timeoutMs = 2000, WebSocketImpl = globalThis.WebSocket }) {
677
+ export function probeHost({ port, token, protocolVersion, prove = false, timeoutMs = 2000, WebSocketImpl = globalThis.WebSocket }) {
597
678
  if (typeof WebSocketImpl !== 'function') return tcpProbe(port, timeoutMs)
598
679
  return new Promise((resolve) => {
599
680
  let ws = null
@@ -611,35 +692,84 @@ export function probeHost({ port, token, protocolVersion, timeoutMs = 2000, WebS
611
692
  }
612
693
  const timer = setTimeout(() => done({ running: false }), timeoutMs)
613
694
  try {
614
- ws = new WebSocketImpl(`ws://127.0.0.1:${port}`)
695
+ ws = helloSocket({
696
+ port, token, protocolVersion, prove, WebSocketImpl,
697
+ on: {
698
+ unproven: () => done({ running: false, unproven: true }),
699
+ gone: () => done({ running: false }),
700
+ message: (m) => {
701
+ if (m.kind === 'hello_ok') {
702
+ done({
703
+ running: true,
704
+ protocolVersion: Number.isInteger(m.protocolVersion) ? m.protocolVersion : undefined,
705
+ version: typeof m.build?.version === 'string' ? m.build.version : undefined,
706
+ })
707
+ } else if (m.kind === 'res' && m.ok === false && m.error?.code === 'version_mismatch') {
708
+ // The token was right (it is checked first), so this is our host, on another protocol
709
+ done({ running: true, protocolVersion: mismatchServerVersion(m.error.message) ?? undefined })
710
+ }
711
+ },
712
+ },
713
+ })
615
714
  } catch {
616
715
  done({ running: false })
617
- return
618
716
  }
619
- ws.onopen = () => ws.send(JSON.stringify({ kind: 'hello', token, protocolVersion: protocolVersion ?? 0 }))
620
- ws.onmessage = (ev) => {
621
- let m
622
- try {
623
- m = JSON.parse(String(ev.data))
624
- } catch {
625
- return
626
- }
627
- if (m?.kind === 'hello_ok') {
628
- done({
629
- running: true,
630
- protocolVersion: Number.isInteger(m.protocolVersion) ? m.protocolVersion : undefined,
631
- version: typeof m.build?.version === 'string' ? m.build.version : undefined,
632
- })
633
- } else if (m?.kind === 'res' && m.ok === false && m.error?.code === 'version_mismatch') {
634
- // The token was right (it is checked first), so this is our host, on another protocol
635
- done({ running: true, protocolVersion: mismatchServerVersion(m.error.message) ?? undefined })
636
- }
637
- }
638
- ws.onerror = () => done({ running: false })
639
- ws.onclose = () => done({ running: false })
640
717
  })
641
718
  }
642
719
 
720
+ /**
721
+ * A probe that did not prove the token, while this data folder's host is alive (`host.lock`): a host from before #838
722
+ * still running after an update. It is running, and is not sent the token; a hub asking it for a proof is refused
723
+ * until it restarts. Without a live lock holder, what answered is not ours.
724
+ */
725
+ export function heldHere(probe, dataDir, startOf = processStartTime) {
726
+ if (probe.running) return true
727
+ if (!probe.unproven) return false
728
+ return lockHolderIsAlive(dataDir, startOf)
729
+ }
730
+
731
+ /**
732
+ * Whether the process `host.lock` names is the one that wrote it (#838 review): alive, and started when the lock says
733
+ * (`ps -o lstart`, as the host writes it, `instance-lock.ts`). A lock left by a host that died, whose pid another
734
+ * process of the same user now has, names a stranger: counted as running, `--stop` would signal it. A lock without a
735
+ * start time (a host from before #184) or a system without `ps` (Windows) cannot be told apart, so it does not count.
736
+ */
737
+ export function lockHolderIsAlive(dataDir, startOf = processStartTime) {
738
+ let holder
739
+ try {
740
+ holder = parseLockHolder(readFileSync(join(dataDir, 'host.lock'), 'utf8'))
741
+ } catch {
742
+ return false
743
+ }
744
+ if (!holder || !holder.started || !isAlive(holder.pid)) return false
745
+ return startOf(holder.pid) === holder.started
746
+ }
747
+
748
+ /** host.lock's `{ pid, started }`; a bare pid (an older host) has no start time */
749
+ function parseLockHolder(text) {
750
+ const t = text.trim()
751
+ if (/^\d+$/.test(t)) return { pid: Number(t), started: null }
752
+ try {
753
+ const v = JSON.parse(t)
754
+ return Number.isInteger(v?.pid) && v.pid > 0 ? { pid: v.pid, started: typeof v.started === 'string' ? v.started : null } : null
755
+ } catch {
756
+ return null
757
+ }
758
+ }
759
+
760
+ /**
761
+ * A pid's start time, as `instance-lock.ts`'s `processStartTime` reads it: `ps -o lstart=` with the C locale, so the
762
+ * host that wrote the lock and this launcher read the same text. null without `ps` (Windows) or when it fails.
763
+ */
764
+ export function processStartTime(pid) {
765
+ if (process.platform === 'win32') return null
766
+ try {
767
+ return execFileSync('ps', ['-o', 'lstart=', '-p', String(pid)], { encoding: 'utf8', timeout: 2000, env: { ...process.env, LC_ALL: 'C' }, stdio: ['ignore', 'pipe', 'ignore'] }).trim() || null
768
+ } catch {
769
+ return null
770
+ }
771
+ }
772
+
643
773
  function tcpProbe(port, timeoutMs) {
644
774
  return new Promise((resolve) => {
645
775
  const s = connect({ host: '127.0.0.1', port })
@@ -688,16 +818,19 @@ function say(line) {
688
818
  async function describe({ env, dataDir, state, entry, version }) {
689
819
  const port = state.port ?? DEFAULT_SERVE_PORT
690
820
  const installed = protocolVersionOf(entry)
691
- const probe = await probeHost({ port, token: state.token, protocolVersion: installed })
821
+ // Whether the installed host proves it holds the token (#838): asked of the running one before its hello, and said in the line
822
+ const proof = hostProvesOf(entry)
823
+ const probe = await probeHost({ port, token: state.token, protocolVersion: installed, prove: proof })
692
824
  const protocolVersion = (probe.running ? probe.protocolVersion : undefined) ?? installed
693
825
  if (protocolVersion === null) return null
694
826
  return {
827
+ proof,
695
828
  port,
696
829
  token: state.token,
697
830
  version: (probe.running ? probe.version : undefined) ?? version,
698
831
  protocolVersion,
699
832
  dataDir,
700
- hostRunning: probe.running,
833
+ hostRunning: heldHere(probe, dataDir),
701
834
  install: installInfo(dataDir, env),
702
835
  autostart: autostartInfo(dataDir, process.platform, env),
703
836
  }
@@ -760,8 +893,8 @@ export async function runServe({ env, home, entry, version, cliPath, port: asked
760
893
  // A serve that is already up answers on the port it recorded; say so before starting a host that
761
894
  // would only fail on the data folder's lock with a less specific message.
762
895
  const recorded = state.port ?? DEFAULT_SERVE_PORT
763
- const existing = await probeHost({ port: recorded, token: state.token, protocolVersion })
764
- if (existing.running) {
896
+ const existing = await probeHost({ port: recorded, token: state.token, protocolVersion, prove: hostProvesOf(entry) })
897
+ if (heldHere(existing, dataDir)) {
765
898
  const pid = lockHolderPid(dataDir)
766
899
  say(`centralu serve is already running for ${dataDir} on 127.0.0.1:${recorded}${pid ? ` (pid ${pid})` : ''}. Not starting a second one.`)
767
900
  return 1
@@ -934,11 +1067,21 @@ export function windowsArg(arg) {
934
1067
  return `"${s.replace(/(\\+)$/, '$1$1')}"`
935
1068
  }
936
1069
 
1070
+ /**
1071
+ * The first line of a script run by Windows PowerShell 5.1: its own module path. Started from
1072
+ * PowerShell 7 (pwsh, a `DefaultShell` of it, a CI runner), 5.1 inherits pwsh's `PSModulePath`, whose
1073
+ * first folder holds 7.x copies of the built-in modules that 5.1 cannot load: `Invoke-CimMethod` and
1074
+ * `Get-ScheduledTask` are then "found in the module … but the module could not be loaded". Same line as
1075
+ * the hub's (links/tunnel.ts, `PS_PREAMBLE`)
1076
+ */
1077
+ const PS_MODULE_PATH = "if ($env:PSModulePath -match '\\x5cPowerShell\\x5c') { $env:PSModulePath = \"$env:ProgramFiles\\WindowsPowerShell\\Modules;$PSHOME\\Modules\" }"
1078
+
937
1079
  /** The PowerShell that asks WMI to create `commandLine`. Values cross as base64, so no quoting can break */
938
1080
  export function wmiScript({ commandLine, cwd = null }) {
939
1081
  const text = (s) => `[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('${Buffer.from(s, 'utf8').toString('base64')}'))`
940
1082
  return [
941
1083
  "$ErrorActionPreference = 'Stop'",
1084
+ PS_MODULE_PATH,
942
1085
  '[Console]::OutputEncoding = [Text.Encoding]::UTF8',
943
1086
  `$a = @{ CommandLine = ${text(commandLine)} }`,
944
1087
  ...(cwd ? [`$a.CurrentDirectory = ${text(cwd)}`] : []),
@@ -1205,7 +1348,7 @@ export async function runDetachedChild({ spec, ...opts }) {
1205
1348
  * `{ ok: true }` once the host took it, `{ ok: false, message, protocolVersion? }` otherwise; the
1206
1349
  * last names the host's protocol when the hello was refused for it, so the caller can ask again.
1207
1350
  */
1208
- export function askHostToStop({ port, token, protocolVersion, timeoutMs = 5000, WebSocketImpl = globalThis.WebSocket }) {
1351
+ export function askHostToStop({ port, token, protocolVersion, prove = false, timeoutMs = 5000, WebSocketImpl = globalThis.WebSocket }) {
1209
1352
  if (typeof WebSocketImpl !== 'function') return Promise.resolve({ ok: false, message: `Node ${process.version} has no WebSocket to ask with` })
1210
1353
  return new Promise((resolve) => {
1211
1354
  let ws = null
@@ -1223,28 +1366,25 @@ export function askHostToStop({ port, token, protocolVersion, timeoutMs = 5000,
1223
1366
  }
1224
1367
  const timer = setTimeout(() => done({ ok: false, message: 'no answer in time' }), timeoutMs)
1225
1368
  try {
1226
- ws = new WebSocketImpl(`ws://127.0.0.1:${port}`)
1369
+ // With `prove`, the token goes out only to a host that proved it holds it (#838)
1370
+ ws = helloSocket({
1371
+ port, token, protocolVersion, prove, WebSocketImpl,
1372
+ on: {
1373
+ unproven: () => done({ ok: false, message: 'what answers on the port did not prove it holds the token' }),
1374
+ gone: () => done({ ok: false, message: 'the connection closed' }),
1375
+ message: (m) => {
1376
+ if (m.kind === 'hello_ok') ws.send(JSON.stringify({ kind: 'rpc', id: 'stop', method: 'host.stop', params: {} }))
1377
+ else if (m.kind === 'res' && m.id === 'stop') done(m.ok ? { ok: true } : { ok: false, message: m.error?.message ?? 'refused' })
1378
+ else if (m.kind === 'res' && m.ok === false) {
1379
+ const pv = m.error?.code === 'version_mismatch' ? (m.error?.data?.protocolVersion ?? mismatchServerVersion(m.error.message)) : null
1380
+ done({ ok: false, message: m.error?.message ?? 'refused', ...(Number.isInteger(pv) ? { protocolVersion: pv } : {}) })
1381
+ }
1382
+ },
1383
+ },
1384
+ })
1227
1385
  } catch (e) {
1228
1386
  done({ ok: false, message: e.message })
1229
- return
1230
- }
1231
- ws.onopen = () => ws.send(JSON.stringify({ kind: 'hello', token, protocolVersion: protocolVersion ?? 0 }))
1232
- ws.onmessage = (ev) => {
1233
- let m
1234
- try {
1235
- m = JSON.parse(String(ev.data))
1236
- } catch {
1237
- return
1238
- }
1239
- if (m?.kind === 'hello_ok') ws.send(JSON.stringify({ kind: 'rpc', id: 'stop', method: 'host.stop', params: {} }))
1240
- else if (m?.kind === 'res' && m.id === 'stop') done(m.ok ? { ok: true } : { ok: false, message: m.error?.message ?? 'refused' })
1241
- else if (m?.kind === 'res' && m.ok === false) {
1242
- const pv = m.error?.code === 'version_mismatch' ? (m.error?.data?.protocolVersion ?? mismatchServerVersion(m.error.message)) : null
1243
- done({ ok: false, message: m.error?.message ?? 'refused', ...(Number.isInteger(pv) ? { protocolVersion: pv } : {}) })
1244
- }
1245
1387
  }
1246
- ws.onerror = () => done({ ok: false, message: 'could not connect' })
1247
- ws.onclose = () => done({ ok: false, message: 'the connection closed' })
1248
1388
  })
1249
1389
  }
1250
1390
 
@@ -1293,9 +1433,10 @@ export async function runStop({
1293
1433
  if (!state) return report({ ok: true, wasRunning: false, message: `no centralu serve has run for ${dataDir}` })
1294
1434
  const port = state.port ?? DEFAULT_SERVE_PORT
1295
1435
  const installed = protocolVersionOf(entry) ?? 0
1296
- const probe = () => probeHost({ port, token: state.token, protocolVersion: installed })
1436
+ const prove = hostProvesOf(entry)
1437
+ const probe = () => probeHost({ port, token: state.token, protocolVersion: installed, prove })
1297
1438
  const first = await probe()
1298
- if (!first.running) return report({ ok: true, wasRunning: false, message: `centralu serve is not running for ${dataDir}` })
1439
+ if (!heldHere(first, dataDir)) return report({ ok: true, wasRunning: false, message: `centralu serve is not running for ${dataDir}` })
1299
1440
 
1300
1441
  const hostPid = lockHolderPid(dataDir)
1301
1442
  const launcher = readServePid(dataDir)
@@ -1303,8 +1444,8 @@ export async function runStop({
1303
1444
  const launcherPid = launcher && hostPid && launcher.hostPid === hostPid ? launcher.pid : null
1304
1445
  const down = async () => !(await probe()).running && !(hostPid && isAlive(hostPid))
1305
1446
 
1306
- let asked = await askHostToStop({ port, token: state.token, protocolVersion: first.protocolVersion ?? installed })
1307
- if (!asked.ok && asked.protocolVersion !== undefined) asked = await askHostToStop({ port, token: state.token, protocolVersion: asked.protocolVersion })
1447
+ let asked = await askHostToStop({ port, token: state.token, protocolVersion: first.protocolVersion ?? installed, prove })
1448
+ if (!asked.ok && asked.protocolVersion !== undefined) asked = await askHostToStop({ port, token: state.token, protocolVersion: asked.protocolVersion, prove })
1308
1449
  let how = 'asked'
1309
1450
  let stopped = asked.ok && (await waitUntil(down, timeoutMs))
1310
1451
  let note = asked.ok ? null : `the host did not take the request to stop (${asked.message})`
@@ -1499,7 +1640,7 @@ const TASK_WORD = 'CENTRALU-AUTOSTART'
1499
1640
  * on battery too, and with no time limit (the default ends a task after 72 hours).
1500
1641
  */
1501
1642
  export function taskScript(action, { name, run = '' }) {
1502
- const head = ["$ErrorActionPreference = 'Stop'", '[Console]::OutputEncoding = [Text.Encoding]::UTF8', `$name = ${psText(name)}`]
1643
+ const head = ["$ErrorActionPreference = 'Stop'", PS_MODULE_PATH, '[Console]::OutputEncoding = [Text.Encoding]::UTF8', `$name = ${psText(name)}`]
1503
1644
  const fail = `} catch { "${TASK_WORD}-ERROR $($_.Exception.Message -replace '\\s+', ' ')" }`
1504
1645
  const find = "$t = Get-ScheduledTask -TaskName $name -TaskPath '\\' -ErrorAction SilentlyContinue"
1505
1646
  if (action === 'status') return [...head, 'try {', ` ${find}`, ` if ($t) { "${TASK_WORD} state $($t.State)" } else { '${TASK_WORD} state none' }`, fail].join('\n')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "centralu",
3
- "version": "0.1.0-beta.25",
3
+ "version": "0.1.0-beta.26",
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.25",
42
- "@centralu/linux-arm64": "0.1.0-beta.25",
43
- "@centralu/linux-x64": "0.1.0-beta.25",
44
- "@centralu/win32-x64": "0.1.0-beta.25"
41
+ "@centralu/darwin-arm64": "0.1.0-beta.26",
42
+ "@centralu/linux-arm64": "0.1.0-beta.26",
43
+ "@centralu/linux-x64": "0.1.0-beta.26",
44
+ "@centralu/win32-x64": "0.1.0-beta.26"
45
45
  }
46
46
  }