thinkpool-pair 0.7.55 → 0.7.57

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/account.mjs CHANGED
@@ -186,6 +186,9 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
186
186
  }
187
187
  const { sb, session } = auth
188
188
  const email = session.user?.email || 'your account'
189
+ // Current owner JWT, handed to each room-bridge child for authed web writes
190
+ // (code-mockup, whip L16). Refreshed below; pushed to live children on rotation.
191
+ let currentAccessToken = session.access_token || null
189
192
 
190
193
  // Owner-gate (Contract C-CODE-2): hosting a code session — spawning the agent and
191
194
  // burning compute on this machine — requires Pro or Enterprise. This is the layer
@@ -253,7 +256,13 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
253
256
  const keepFresh = scheduleTokenRefresh({
254
257
  sb,
255
258
  session,
256
- onRefreshed: (s) => { saveAuth(s); pushPresence() },
259
+ onRefreshed: (s) => {
260
+ saveAuth(s); pushPresence()
261
+ // Push the rotated owner token to every live child so their authed writes
262
+ // (code-mockup) never go stale on a long session (whip L16).
263
+ currentAccessToken = s.access_token || currentAccessToken
264
+ for (const c of children.values()) { try { c.send({ t: 'token', accessToken: currentAccessToken }) } catch { /* child not ready */ } }
265
+ },
257
266
  forceMs: parseInt(process.env.TP_TOKEN_REFRESH_FORCE_MS, 10) || 0,
258
267
  })
259
268
 
@@ -287,6 +296,8 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
287
296
  // can't make a child self-exit; flag it as an account child instead.
288
297
  const env = { ...process.env, THINKPOOL_PAIR_ACCOUNT_CHILD: '1' }
289
298
  delete env.THINKPOOL_PAIR_AUTOUPDATE
299
+ // Hand the child the owner JWT for authed web writes (code-mockup, L16).
300
+ if (currentAccessToken) env.TP_ACCESS_TOKEN = currentAccessToken
290
301
  const child = spawn(process.execPath, [BRIDGE, room, '--headless', '--auto=claude'], { cwd: dir, stdio: ['inherit', 'inherit', 'inherit', 'ipc'], env })
291
302
  children.set(room, child)
292
303
  childIdle.set(room, false) // unknown until it reports → never restart a fresh child
package/bridge.mjs CHANGED
@@ -47,6 +47,14 @@ const SUPABASE_URL = process.env.TP_SUPABASE_URL || 'https://daytvtakmlixpfbbq
47
47
  const WEB_BASE = process.env.TP_WEB_BASE || 'https://thinkpool.io'
48
48
  const SUPABASE_ANON = process.env.TP_SUPABASE_ANON || 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImRheXR2dGFrbWxpeHBmYmJxempkIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NzgwNTMxNzEsImV4cCI6MjA5MzYyOTE3MX0.zYmz8bHUNEY4STUr2JuXChAsKwxNwkwFHe1Hd0Betqo'
49
49
 
50
+ // Owner access token for AUTHED web writes (currently /api/code-mockup, whip L16).
51
+ // The room-bridge is anon by design; the account supervisor (account.mjs) owns the
52
+ // session, so it hands a fresh owner token in at spawn (TP_ACCESS_TOKEN) and pushes
53
+ // a rotated one over IPC ({ t:'token' }) on each refresh. Stays null for a
54
+ // directly-started bridge with no account login — those writes go unattributed,
55
+ // never owner-spoofed (the server treats a missing/!owner token accordingly).
56
+ let codeAuthToken = process.env.TP_ACCESS_TOKEN || null
57
+
50
58
  // Per-terminal rolling scrollback (raw chars). Replayed to joining clients.
51
59
  // Kept comfortably under realtime payload limits once base64'd.
52
60
  const SCROLLBACK_MAX = 120_000
@@ -141,7 +149,14 @@ if (argv[0] === 'install-service' || argv[0] === 'uninstall-service') {
141
149
  const autoUpdate = argv.includes('--auto-update')
142
150
  const svc = await import('./service.mjs')
143
151
  if (argv[0] === 'install-service') svc.installService(svcRoom, svcCmd, { autoUpdate })
144
- else svc.uninstallService(svcRoom)
152
+ else {
153
+ // Account scope (room=null) may be the cmux-hosted tier (cmux-service.json) —
154
+ // that's torn down via cmux, not launchctl/systemctl. Room scope is always OS.
155
+ const cmux = await import('./cmux.mjs')
156
+ if (svcRoom === null && cmux.isCmuxServiceInstalled()) {
157
+ cmux.uninstallCmuxService(); process.stderr.write(' ◆ removed the cmux-hosted account service.\n')
158
+ } else svc.uninstallService(svcRoom)
159
+ }
145
160
  process.exit(0)
146
161
  }
147
162
 
@@ -150,8 +165,10 @@ if (argv[0] === 'install-service' || argv[0] === 'uninstall-service') {
150
165
  if (argv[0] === 'restart-service') {
151
166
  const arg1 = argv[1] || ''
152
167
  const svcRoom = (!arg1 || arg1.startsWith('-')) ? null : arg1.toUpperCase().trim()
153
- const svc = await import('./service.mjs')
154
- svc.restartService(svcRoom)
168
+ const cmux = await import('./cmux.mjs')
169
+ if (svcRoom === null && cmux.isCmuxServiceInstalled()) {
170
+ cmux.restartCmuxService(); process.stderr.write(' ◆ restarted the cmux-hosted account service — sessions resume in place.\n')
171
+ } else { const svc = await import('./service.mjs'); svc.restartService(svcRoom) }
155
172
  process.exit(0)
156
173
  }
157
174
 
@@ -172,6 +189,39 @@ if (argv[0] === 'login') { const { runLogin } = await import('./account.mjs'); a
172
189
  if (argv[0] === 'bind') { const { runBind } = await import('./account.mjs'); runBind(argv[1], argv[2]) }
173
190
  if (argv[0] === 'provider') { const { runProvider } = await import('./provider.mjs'); await runProvider(argv.slice(1)); process.exit(0) }
174
191
 
192
+ // Explicit account-serve entrypoint — used by the cmux-hosted service tier (cmux.mjs).
193
+ // A cmux surface HAS a TTY, so a bare invocation would open the interactive launcher
194
+ // instead of serving; `serve` runs account mode directly, regardless of TTY. With
195
+ // `--supervise` it first wraps itself in a crash-restart loop (re-execing `serve`
196
+ // without the flag): cmux, unlike launchd KeepAlive, will not relaunch a surface
197
+ // whose command exits, so the account supervisor must restart itself.
198
+ if (argv[0] === 'serve') {
199
+ if (argv.includes('--supervise') || argv.includes('--keep-alive')) {
200
+ const childArgs = process.argv.slice(1).filter((a) => a !== '--supervise' && a !== '--keep-alive')
201
+ let backoff = 1000, stopping = false, child = null
202
+ const spawnChild = () => {
203
+ const t0 = Date.now()
204
+ child = spawn(process.execPath, childArgs, { stdio: 'inherit' })
205
+ child.on('exit', (code, signal) => {
206
+ if (stopping || code === 0) process.exit(code || 0)
207
+ const ranLong = Date.now() - t0 > 60000
208
+ const wait = ranLong ? 1000 : backoff
209
+ backoff = ranLong ? 1000 : Math.min(backoff * 2, 30000)
210
+ process.stderr.write(`\n ⚠ account supervisor exited (${signal || `code ${code}`}); restarting in ${Math.round(wait / 1000)}s…\n`)
211
+ setTimeout(spawnChild, wait)
212
+ })
213
+ child.on('error', () => { /* spawn failure → exit handler retries */ })
214
+ }
215
+ for (const sig of ['SIGINT', 'SIGTERM']) process.on(sig, () => { stopping = true; try { child?.kill(sig) } catch { /* noop */ } setTimeout(() => process.exit(0), 400) })
216
+ process.stderr.write('\n ◆ supervisor up — keeping the account bridge alive (auto-restart on crash).\n')
217
+ spawnChild()
218
+ await new Promise(() => {}) // block forever; the child IS the account supervisor
219
+ }
220
+ const { runAccount } = await import('./account.mjs')
221
+ await runAccount(SUPABASE_URL, SUPABASE_ANON)
222
+ process.exit(0)
223
+ }
224
+
175
225
  // One-command launcher: a bare invocation on a TTY (or the `setup` alias) opens the
176
226
  // interactive menu instead of going straight to account mode. The menu only ASSEMBLES
177
227
  // existing commands — it re-execs `<ROOM>`, or calls installService / runLogin /
@@ -181,6 +231,8 @@ if (argv[0] === 'provider') { const { runProvider } = await import('./provider.m
181
231
  if ((!argv[0] || argv[0] === 'setup') && process.stdin.isTTY) {
182
232
  const { runLauncher, detectState } = await import('./launcher.mjs')
183
233
  const svc = await import('./service.mjs')
234
+ const cmux = await import('./cmux.mjs')
235
+ const PKG_VERSION = (() => { try { return JSON.parse(fs.readFileSync(new URL('./package.json', import.meta.url), 'utf8')).version } catch { return 'latest' } })()
184
236
  const BRIDGE = fileURLToPath(import.meta.url)
185
237
  const io = {
186
238
  print: (s) => process.stderr.write(s + '\n'),
@@ -202,8 +254,21 @@ if ((!argv[0] || argv[0] === 'setup') && process.stdin.isTTY) {
202
254
  // notes, and RETURN so the launcher loop drops you back at the menu (the service
203
255
  // runs in its own launchd/systemd process; this launcher isn't itself a bridge).
204
256
  installService: ({ room = null, agentCmd } = {}) => { svc.installService(room, agentCmd ? [agentCmd] : []) },
205
- uninstallService: ({ room = null } = {}) => { svc.uninstallService(room) },
206
- restartService: ({ room = null } = {}) => { svc.restartService(room) },
257
+ uninstallService: ({ room = null } = {}) => {
258
+ if (room === null && cmux.isCmuxServiceInstalled()) { cmux.uninstallCmuxService(); process.stderr.write('\n ◆ removed the cmux-hosted account service.\n') }
259
+ else svc.uninstallService(room)
260
+ },
261
+ restartService: ({ room = null } = {}) => {
262
+ if (room === null && cmux.isCmuxServiceInstalled()) { cmux.restartCmuxService(); process.stderr.write('\n ◆ restarted the cmux-hosted account service — sessions resume in place.\n') }
263
+ else svc.restartService(room)
264
+ },
265
+ // Account service hosted as a cmux surface — offered only inside cmux, with its
266
+ // weaker-persistence caveat shown first (launcher.mjs serveAccount).
267
+ detectCmux: () => cmux.detectCmux(),
268
+ installAccountCmux: () => {
269
+ const rec = cmux.installCmuxService({ version: PKG_VERSION })
270
+ process.stderr.write(`\n ◆ account service now runs as a cmux surface ("${cmux.WS_NAME}") — visible and killable.\n ◆ it restarts itself on crash; stop it with: npx thinkpool-pair uninstall-service\n ◆ pinned to v${rec.version}.\n\n`)
271
+ },
207
272
  login: async () => { const { runLogin } = await import('./account.mjs'); await runLogin(SUPABASE_URL, SUPABASE_ANON, WEB_BASE) },
208
273
  // Provider config is NOT terminal — it writes provider.json and returns to the
209
274
  // menu so you can pick a model, switch back, or do something else. (It used to
@@ -518,9 +583,14 @@ const handleManifest = async (box, file, term) => {
518
583
  const readTxt = (p) => { try { return p && fs.readFileSync(p, 'utf8') } catch { return null } }
519
584
  const cid = randomUUID()
520
585
  try {
586
+ const mockupHeaders = { 'Content-Type': 'application/json' }
587
+ // Authenticate as the owner so the server can attribute the persisted row to a
588
+ // verified user instead of trusting "knows the code" (whip L16). Omitted when
589
+ // we have no token (direct bridge, no login) → server persists it unattributed.
590
+ if (codeAuthToken) mockupHeaders.Authorization = `Bearer ${codeAuthToken}`
521
591
  const res = await fetch(`${WEB_BASE}/api/code-mockup`, {
522
592
  method: 'POST',
523
- headers: { 'Content-Type': 'application/json' },
593
+ headers: mockupHeaders,
524
594
  body: JSON.stringify({
525
595
  code: room, slug: m.slug, title: m.title, cid, ts: m.ts, term,
526
596
  desktopPng: readB64(m.desktop), mobilePng: readB64(m.mobile), html: readTxt(m.html),
@@ -1206,7 +1276,13 @@ if (process.env.THINKPOOL_PAIR_AUTOUPDATE === '1' && VERSION) {
1206
1276
  // Contract #1) + `idle` (the quiet window, for the unattended fallback) + whether a
1207
1277
  // web peer is present, so the supervisor restarts on an explicit click between turns
1208
1278
  // and otherwise waits for true idle with nobody watching (Slice 3).
1209
- process.on('message', (m) => { if (m && m.t === 'update-available') surfaceUpdate(m.version) })
1279
+ process.on('message', (m) => {
1280
+ if (!m) return
1281
+ if (m.t === 'update-available') surfaceUpdate(m.version)
1282
+ // Supervisor rotated the owner JWT (account.mjs scheduleTokenRefresh) — adopt
1283
+ // it so our authed writes (code-mockup) never go stale on a long session (L16).
1284
+ else if (m.t === 'token') codeAuthToken = m.accessToken || null
1285
+ })
1210
1286
  setInterval(() => { try { process.send({ t: 'idle', idle: idleNow(), between: betweenTurns(), webPeer: webPeerPresent() }) } catch { /* parent gone */ } }, 5000).unref()
1211
1287
  } else if (VERSION) {
1212
1288
  // Foreground run (not the supervised service): it can't self-restart, but
package/cmux.mjs ADDED
@@ -0,0 +1,139 @@
1
+ /* ─────────────────────────────────────────────────────────────
2
+ cmux.mjs — host the ACCOUNT service as a cmux surface instead of
3
+ a detached launchd/systemd daemon, when the bridge was launched
4
+ from inside cmux.
5
+
6
+ Why a separate tier: the OS service (service.mjs) is invisible —
7
+ it logs to a file and is supervised by launchd KeepAlive. Inside
8
+ cmux, the user would rather see the service running in a normal
9
+ surface they can watch and kill, alongside everything else. cmux,
10
+ unlike launchd, does NOT relaunch a surface whose command exits,
11
+ so the surface runs `serve --supervise` (bridge.mjs) which restarts
12
+ the account supervisor itself on crash.
13
+
14
+ Detection is deliberately strict: being on $PATH is NOT proof cmux
15
+ spawned us (claude-session.mjs makes the same distinction). Only the
16
+ CMUX_WORKSPACE_ID / CMUX_SURFACE_ID env vars — which cmux injects
17
+ into every surface it creates — prove it, and we additionally confirm
18
+ the socket actually answers before committing to this tier. If either
19
+ check fails the caller falls back to the OS service.
20
+
21
+ Persistence trade-off (flagged to the user in launcher.mjs before
22
+ install): up while cmux runs · crash-restart via --supervise ·
23
+ best-effort return on cmux session-restore · does NOT survive
24
+ logout/reboot unless cmux launches at login.
25
+
26
+ Spec: docs/specs/2026-06-23-cmux-hosted-account-service.md
27
+ ───────────────────────────────────────────────────────────── */
28
+
29
+ import os from 'node:os'
30
+ import fs from 'node:fs'
31
+ import path from 'node:path'
32
+ import { execFileSync } from 'node:child_process'
33
+
34
+ const HOME = os.homedir()
35
+ const CFG_DIR = path.join(HOME, '.thinkpool-pair')
36
+ // One stable record per machine → exactly one cmux-hosted account service.
37
+ export const RECORD = path.join(CFG_DIR, 'cmux-service.json')
38
+ export const WS_NAME = 'thinkpool-pair service'
39
+
40
+ // Run a cmux CLI command; return trimmed stdout, or throw. Isolated so the rest
41
+ // of the module (and its tests) can run without the socket by injecting `run`.
42
+ function cmux(args, { timeout = 6000 } = {}) {
43
+ return execFileSync('cmux', args, { encoding: 'utf8', timeout, stdio: ['ignore', 'pipe', 'ignore'] }).trim()
44
+ }
45
+
46
+ // cmux injects these into every surface it spawns. PATH containing a cmux shim is
47
+ // NOT enough — only the env proves cmux launched this shell.
48
+ export function inCmux(env = process.env) {
49
+ return !!(env.CMUX_WORKSPACE_ID || env.CMUX_SURFACE_ID)
50
+ }
51
+
52
+ // Can we actually drive cmux right now? (socket answers a ping.) Returns false on
53
+ // any failure — a broken/absent socket means we cannot host a surface, so fall back.
54
+ export function cmuxReachable(run = cmux) {
55
+ try { run(['ping'], { timeout: 4000 }); return true } catch { return false }
56
+ }
57
+
58
+ // Use the cmux tier only when BOTH hold: cmux spawned us AND the socket answers.
59
+ export function detectCmux(env = process.env, run = cmux) {
60
+ return inCmux(env) && cmuxReachable(run)
61
+ }
62
+
63
+ export function readRecord(recordFile = RECORD) {
64
+ try { return JSON.parse(fs.readFileSync(recordFile, 'utf8')) } catch { return null }
65
+ }
66
+
67
+ export function isCmuxServiceInstalled(recordFile = RECORD) {
68
+ return !!readRecord(recordFile)
69
+ }
70
+
71
+ // The command the cmux surface runs. PINNED to the installing version (same safety
72
+ // default as service.mjs — a bad publish can't auto-roll to the always-on bridge).
73
+ // `serve` runs account mode regardless of TTY (a cmux surface has a TTY, so a bare
74
+ // invocation would open the interactive launcher instead). `--supervise` restarts
75
+ // the account supervisor on crash, since cmux won't re-run a dead surface.
76
+ export function serviceCommand(version) {
77
+ return `npx -y thinkpool-pair@${version || 'latest'} serve --supervise`
78
+ }
79
+
80
+ // Pull a stable handle out of cmux output. Prefer a UUID (stable across reorders);
81
+ // fall back to a positional `workspace:N` ref; null if neither is present.
82
+ export function parseWorkspaceRef(out) {
83
+ const uuid = (String(out).match(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i) || [])[0]
84
+ if (uuid) return uuid
85
+ const ref = (String(out).match(/workspace:\d+/) || [])[0]
86
+ return ref || null
87
+ }
88
+
89
+ // Best-effort: find the service workspace by its name when we have no stored ref
90
+ // (e.g. the record predates ref capture, or parsing failed at install time).
91
+ function resolveByName(run, name = WS_NAME) {
92
+ try {
93
+ const out = run(['--id-format', 'uuids', 'list-workspaces'])
94
+ const line = String(out).split('\n').find((l) => l.includes(name))
95
+ return line ? parseWorkspaceRef(line) : null
96
+ } catch { return null }
97
+ }
98
+
99
+ function spawnSurface(run, command, cwd = HOME) {
100
+ // --focus false: never steal focus from the user's current surface.
101
+ const out = run(['--id-format', 'uuids', 'new-workspace', '--name', WS_NAME, '--cwd', cwd, '--command', command, '--focus', 'false'])
102
+ return parseWorkspaceRef(out)
103
+ }
104
+
105
+ function closeSurface(run, rec) {
106
+ const ref = rec.workspace || resolveByName(run)
107
+ if (!ref) return
108
+ try { run(['close-workspace', '--workspace', ref]) } catch { /* may already be gone */ }
109
+ }
110
+
111
+ export function installCmuxService({ version, cwd = HOME, run = cmux, recordFile = RECORD } = {}) {
112
+ const command = serviceCommand(version)
113
+ const workspace = spawnSurface(run, command, cwd)
114
+ const rec = { kind: 'cmux', version: version || 'latest', name: WS_NAME, command, workspace, installedAt: new Date().toISOString() }
115
+ fs.mkdirSync(path.dirname(recordFile), { recursive: true })
116
+ fs.writeFileSync(recordFile, JSON.stringify(rec, null, 2))
117
+ return rec
118
+ }
119
+
120
+ // Restart = close the surface + spawn a fresh one. The account supervisor restores
121
+ // every active session from disk (session-store crash-resume), so sessions resume
122
+ // in place — same contract as an OS-service restart.
123
+ export function restartCmuxService({ run = cmux, recordFile = RECORD } = {}) {
124
+ const rec = readRecord(recordFile)
125
+ if (!rec) return false
126
+ closeSurface(run, rec)
127
+ rec.workspace = spawnSurface(run, rec.command)
128
+ rec.installedAt = new Date().toISOString()
129
+ fs.writeFileSync(recordFile, JSON.stringify(rec, null, 2))
130
+ return true
131
+ }
132
+
133
+ export function uninstallCmuxService({ run = cmux, recordFile = RECORD } = {}) {
134
+ const rec = readRecord(recordFile)
135
+ if (!rec) return false
136
+ closeSurface(run, rec)
137
+ try { fs.rmSync(recordFile, { force: true }) } catch { /* noop */ }
138
+ return true
139
+ }
package/launcher.mjs CHANGED
@@ -13,6 +13,7 @@
13
13
  import os from 'node:os'
14
14
  import fs from 'node:fs'
15
15
  import path from 'node:path'
16
+ import { isCmuxServiceInstalled } from './cmux.mjs'
16
17
 
17
18
  const HOME = os.homedir()
18
19
  const CFG_DIR = path.join(HOME, '.thinkpool-pair')
@@ -60,7 +61,11 @@ export function detectState() {
60
61
  provider: isCustom ? `Custom (${provider.baseUrl || 'endpoint'})` : 'Anthropic (default)',
61
62
  providerModel: isCustom ? (provider.model || null) : null,
62
63
  agents: KNOWN_AGENTS.filter(a => onPath(a.cmd)),
63
- accountSvc: fs.existsSync(serviceFile(null)),
64
+ accountSvc: fs.existsSync(serviceFile(null)) || isCmuxServiceInstalled(),
65
+ // Which tier hosts the account service, so the menu can label it honestly.
66
+ // Cheap, read-only (file existence) — no cmux socket call here; the live
67
+ // cmux detection (which pings) happens in serveAccount at install time.
68
+ accountSvcKind: isCmuxServiceInstalled() ? 'cmux' : (fs.existsSync(serviceFile(null)) ? 'os' : null),
64
69
  cwd: process.cwd(),
65
70
  }
66
71
  }
@@ -95,7 +100,7 @@ export async function runLauncher({ actions, io, state = detectState(), refresh
95
100
  io.print(` ${C.dim('│ provider ')} ${state.provider}`)
96
101
  io.print(` ${C.dim('│ agents ')} ${state.agents.length ? state.agents.map(a => a.label).join(', ') : C.yellow('none on PATH')}`)
97
102
  io.print(` ${C.dim('│ directory')} ${C.dim(state.cwd)}`)
98
- io.print(` ${C.dim('│ service ')} ${state.accountSvc ? C.green('account service installed') : 'none'}`)
103
+ io.print(` ${C.dim('│ service ')} ${state.accountSvc ? C.green('account service installed' + (state.accountSvcKind === 'cmux' ? ' (cmux surface)' : state.accountSvcKind === 'os' ? ' (system service)' : '')) : 'none'}`)
99
104
  io.print(' ' + C.dim('└' + '─'.repeat(54)) + '\n')
100
105
  }
101
106
 
@@ -128,8 +133,21 @@ export async function runLauncher({ actions, io, state = detectState(), refresh
128
133
  { label: '← Back to main menu', hint: '' },
129
134
  ])
130
135
  if (how === 2) return
131
- if (how === 0) { await actions.installService({ room: null }); resync() }
132
- else await actions.serveAccountForeground()
136
+ if (how === 1) { await actions.serveAccountForeground(); return }
137
+ // how === 0 — always-on. Environment-aware: inside cmux, offer the cmux-hosted
138
+ // surface (visible + killable) — but flag its weaker persistence FIRST and let
139
+ // the user opt back into the durable OS service.
140
+ if (actions.detectCmux && actions.detectCmux()) {
141
+ io.print('\n ' + C.cyan('Detected cmux') + ' — I can run the service as a cmux surface you can see and kill,')
142
+ io.print(' alongside your other terminals. Heads up — this is ' + C.yellow('less durable') + ' than the system service:')
143
+ io.print(' ' + C.dim('•') + ' stays up while cmux is running')
144
+ io.print(' ' + C.dim('•') + ' restarts itself on crash (--supervise)')
145
+ io.print(' ' + C.dim('•') + ' best-effort return via cmux session-restore')
146
+ io.print(' ' + C.dim('•') + ' ' + C.yellow('does NOT survive logout/reboot') + ' unless cmux launches at login')
147
+ const useCmux = await askYesNo('\n Run it as a cmux surface? (n = always-on system service)', true)
148
+ if (useCmux) { await actions.installAccountCmux(); resync(); return }
149
+ }
150
+ await actions.installService({ room: null }); resync()
133
151
  }
134
152
 
135
153
  const serviceMenu = async () => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thinkpool-pair",
3
- "version": "0.7.55",
3
+ "version": "0.7.57",
4
4
  "description": "Share a local coding-agent CLI (Claude Code, Codex, Gemini, Aider, …) into a ThinkPool Code room, live.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -15,6 +15,7 @@
15
15
  "transcript-sanitize.mjs",
16
16
  "session-store.mjs",
17
17
  "service.mjs",
18
+ "cmux.mjs",
18
19
  "account.mjs",
19
20
  "auth-store.mjs",
20
21
  "provider.mjs",