thinkpool-pair 0.7.58 → 0.7.60

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
@@ -223,6 +223,7 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
223
223
  // codes + version) on a per-account channel; the dashboard subscribes read-only.
224
224
  const machine = os.hostname().replace(/\.local$/, '')
225
225
  const acct = sb.channel(`tpacct:${session.user.id}`, { config: { presence: { key: machine }, broadcast: { self: false } } })
226
+ let restarting = false // set by the "restart" broadcast → stop() exits non-zero so a supervisor respawns us
226
227
  // Dashboard "Disconnect" → broadcast shutdown → clean exit (presence leaves, bar flips live).
227
228
  // If we're the auto-restarting background service (launchd KeepAlive / systemd
228
229
  // Restart=always sets AUTOUPDATE=1), a bare exit would be respawned ~2s later —
@@ -244,6 +245,20 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
244
245
  }
245
246
  process.kill(process.pid, 'SIGTERM')
246
247
  })
248
+ // Dashboard "Restart" → broadcast restart → graceful stop with a NON-ZERO exit so
249
+ // EVERY supervisor respawns us: launchd KeepAlive / systemd Restart=always respawn
250
+ // regardless of exit code, and the bridge.mjs --supervise wrappers (cmux tier, room
251
+ // tier) respawn on non-zero but treat 0 as a clean stop. Children persist + resume
252
+ // their sessions (session-store), so the bounce is invisible in-room. Unlike shutdown
253
+ // we do NOT uninstall the service. A plain foreground bridge has no supervisor, so it
254
+ // just stops — restart is meaningful for the always-on bridge. Old bridges (pre-0.7.60)
255
+ // don't subscribe to this event, so the dashboard button is a harmless no-op there.
256
+ acct.on('broadcast', { event: 'restart' }, () => {
257
+ process.stderr.write('\n ◇ restart requested from the dashboard — bouncing bridge (sessions resume).\n')
258
+ restarting = true
259
+ setTimeout(() => { try { releaseSingleton() } catch { /* noop */ } process.exit(1) }, 2500)
260
+ process.kill(process.pid, 'SIGTERM')
261
+ })
247
262
  const pushPresence = () => { try { acct.track({ name: machine, version: VERSION, rooms: [...children.keys()], ts: Date.now() }) } catch { /* noop */ } }
248
263
  // Re-track on EVERY (re)subscribe, not just the first: a realtime reconnect
249
264
  // (network blip, or a token swap mid-flight) rejoins the channel and must
@@ -319,16 +334,19 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
319
334
  await tick()
320
335
  const iv = setInterval(tick, 15000)
321
336
  let stopping = false
337
+ // restart-vs-stop is the ONLY difference in exit code: a web "restart" sets this so
338
+ // we exit non-zero (every supervisor respawns); a normal stop/shutdown exits 0.
322
339
  const stop = (sig) => {
323
340
  if (stopping) return; stopping = true
341
+ const code = restarting ? 1 : 0
324
342
  clearInterval(iv)
325
343
  keepFresh.cancel()
326
344
  releaseSingleton()
327
345
  for (const c of children.values()) { try { c.kill(sig || 'SIGTERM') } catch { /* noop */ } }
328
346
  // Flush the presence LEAVE before exiting so the dashboard flips to
329
347
  // "not connected" in realtime (don't fire-and-forget the untrack).
330
- ;(async () => { try { await acct.untrack() } catch { /* noop */ } try { await sb.removeChannel(acct) } catch { /* noop */ } process.exit(0) })()
331
- setTimeout(() => process.exit(0), 1500) // hard backstop if the flush hangs
348
+ ;(async () => { try { await acct.untrack() } catch { /* noop */ } try { await sb.removeChannel(acct) } catch { /* noop */ } process.exit(code) })()
349
+ setTimeout(() => process.exit(code), 1500) // hard backstop if the flush hangs
332
350
  }
333
351
  for (const sig of ['SIGINT', 'SIGTERM', 'SIGHUP']) process.on(sig, () => stop(sig))
334
352
 
package/bridge.mjs CHANGED
@@ -149,7 +149,14 @@ if (argv[0] === 'install-service' || argv[0] === 'uninstall-service') {
149
149
  const autoUpdate = argv.includes('--auto-update')
150
150
  const svc = await import('./service.mjs')
151
151
  if (argv[0] === 'install-service') svc.installService(svcRoom, svcCmd, { autoUpdate })
152
- 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
+ }
153
160
  process.exit(0)
154
161
  }
155
162
 
@@ -158,8 +165,10 @@ if (argv[0] === 'install-service' || argv[0] === 'uninstall-service') {
158
165
  if (argv[0] === 'restart-service') {
159
166
  const arg1 = argv[1] || ''
160
167
  const svcRoom = (!arg1 || arg1.startsWith('-')) ? null : arg1.toUpperCase().trim()
161
- const svc = await import('./service.mjs')
162
- 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) }
163
172
  process.exit(0)
164
173
  }
165
174
 
@@ -180,6 +189,62 @@ if (argv[0] === 'login') { const { runLogin } = await import('./account.mjs'); a
180
189
  if (argv[0] === 'bind') { const { runBind } = await import('./account.mjs'); runBind(argv[1], argv[2]) }
181
190
  if (argv[0] === 'provider') { const { runProvider } = await import('./provider.mjs'); await runProvider(argv.slice(1)); process.exit(0) }
182
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
+
225
+ // `npx thinkpool-pair cmux-doctor` — explain why the cmux service tier did/didn't engage.
226
+ // Detection is env-based (CMUX_WORKSPACE_ID/SURFACE_ID); the socket is only needed for the
227
+ // actual surface spawn. This prints both so a "it didn't detect cmux" report is one command.
228
+ if (argv[0] === 'cmux-doctor') {
229
+ const cmux = await import('./cmux.mjs')
230
+ const env = process.env
231
+ const w = (s) => process.stderr.write(s + '\n')
232
+ const reachable = cmux.cmuxReachable()
233
+ w('\n thinkpool-pair · cmux doctor')
234
+ w(` CMUX_WORKSPACE_ID : ${env.CMUX_WORKSPACE_ID || '(unset)'}`)
235
+ w(` CMUX_SURFACE_ID : ${env.CMUX_SURFACE_ID || '(unset)'}`)
236
+ w(` CMUX_SOCKET_PATH : ${env.CMUX_SOCKET_PATH || '(unset)'}`)
237
+ w(` cmux on PATH : ${onPath('cmux')}`)
238
+ w(` inCmux() : ${cmux.inCmux()}`)
239
+ w(` cmux ping : ${reachable ? 'ok' : 'FAILED (socket unreachable)'}`)
240
+ w(` detectCmux() : ${cmux.detectCmux()} ${cmux.detectCmux() ? '→ cmux-surface offered' : '→ falls back to OS service'}`)
241
+ w(` cmux service set : ${cmux.isCmuxServiceInstalled()}`)
242
+ if (!cmux.inCmux()) w('\n ⚠ not launched from cmux (no CMUX_WORKSPACE_ID/SURFACE_ID). Run this INSIDE a cmux\n terminal surface — a plain Terminal/iTerm window will not be detected.')
243
+ else if (!reachable) w('\n ⚠ in cmux, but the automation socket is unreachable. detectCmux() is env-based so\n the surface is still offered, but the actual spawn needs the socket — if install\n falls back to the OS service, enable cmux automation/socket access in cmux settings.')
244
+ else w('\n ✓ cmux detected and drivable — the cmux-surface service tier is available.')
245
+ process.exit(0)
246
+ }
247
+
183
248
  // One-command launcher: a bare invocation on a TTY (or the `setup` alias) opens the
184
249
  // interactive menu instead of going straight to account mode. The menu only ASSEMBLES
185
250
  // existing commands — it re-execs `<ROOM>`, or calls installService / runLogin /
@@ -189,6 +254,8 @@ if (argv[0] === 'provider') { const { runProvider } = await import('./provider.m
189
254
  if ((!argv[0] || argv[0] === 'setup') && process.stdin.isTTY) {
190
255
  const { runLauncher, detectState } = await import('./launcher.mjs')
191
256
  const svc = await import('./service.mjs')
257
+ const cmux = await import('./cmux.mjs')
258
+ const PKG_VERSION = (() => { try { return JSON.parse(fs.readFileSync(new URL('./package.json', import.meta.url), 'utf8')).version } catch { return 'latest' } })()
192
259
  const BRIDGE = fileURLToPath(import.meta.url)
193
260
  const io = {
194
261
  print: (s) => process.stderr.write(s + '\n'),
@@ -210,8 +277,40 @@ if ((!argv[0] || argv[0] === 'setup') && process.stdin.isTTY) {
210
277
  // notes, and RETURN so the launcher loop drops you back at the menu (the service
211
278
  // runs in its own launchd/systemd process; this launcher isn't itself a bridge).
212
279
  installService: ({ room = null, agentCmd } = {}) => { svc.installService(room, agentCmd ? [agentCmd] : []) },
213
- uninstallService: ({ room = null } = {}) => { svc.uninstallService(room) },
214
- restartService: ({ room = null } = {}) => { svc.restartService(room) },
280
+ uninstallService: ({ room = null } = {}) => {
281
+ if (room === null && cmux.isCmuxServiceInstalled()) { cmux.uninstallCmuxService(); process.stderr.write('\n ◆ removed the cmux-hosted account service.\n') }
282
+ else svc.uninstallService(room)
283
+ },
284
+ restartService: ({ room = null } = {}) => {
285
+ if (room === null && cmux.isCmuxServiceInstalled()) { cmux.restartCmuxService(); process.stderr.write('\n ◆ restarted the cmux-hosted account service — sessions resume in place.\n') }
286
+ else svc.restartService(room)
287
+ },
288
+ // "Restart & update the bridge" — re-pin the service to THIS launcher's version
289
+ // (you launched via @latest, so it's the newest publish) and restart. The cmux
290
+ // surface re-pins + respawns; the OS service is reinstalled (rewrites the plist/unit
291
+ // to the new version and reloads). Pinned throughout — no auto-roll.
292
+ restartUpdateService: ({ room = null } = {}) => {
293
+ if (room === null && cmux.isCmuxServiceInstalled()) {
294
+ cmux.restartCmuxService({ version: PKG_VERSION })
295
+ process.stderr.write(`\n ◆ updated the cmux account service to v${PKG_VERSION} and restarted it — sessions resume in place.\n`)
296
+ } else {
297
+ process.stderr.write(`\n ◆ updating the background service to v${PKG_VERSION} and restarting…\n`)
298
+ svc.installService(room, []) // rewrites the plist/unit at this version + reloads = update + restart
299
+ }
300
+ },
301
+ // Account service hosted as a cmux surface — offered only inside cmux, with its
302
+ // weaker-persistence caveat shown first (launcher.mjs serveAccount). Falls back to
303
+ // the OS service if the cmux socket can't actually be driven (detection is env-based).
304
+ detectCmux: () => cmux.detectCmux(),
305
+ installAccountCmux: () => {
306
+ try {
307
+ const rec = cmux.installCmuxService({ version: PKG_VERSION })
308
+ 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`)
309
+ } catch (e) {
310
+ process.stderr.write(`\n ⚠ couldn't spawn a cmux surface (${e.message || e}). The cmux automation socket may be\n disabled — run 'npx thinkpool-pair cmux-doctor' to check. Falling back to the system service.\n`)
311
+ svc.installService(null, [])
312
+ }
313
+ },
215
314
  login: async () => { const { runLogin } = await import('./account.mjs'); await runLogin(SUPABASE_URL, SUPABASE_ANON, WEB_BASE) },
216
315
  // Provider config is NOT terminal — it writes provider.json and returns to the
217
316
  // menu so you can pick a model, switch back, or do something else. (It used to
package/cmux.mjs ADDED
@@ -0,0 +1,148 @@
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
+ // Detection keys ONLY on the env cmux injects into every surface it spawns — NOT on
59
+ // whether the socket answers. The socket ping proved flaky (it needs CMUX_SOCKET_PASSWORD
60
+ // and can broken-pipe even from a real surface), and gating detection on it silently
61
+ // killed the cmux offer in 0.7.57. If the env says cmux we offer the cmux tier; if the
62
+ // actual new-workspace spawn then can't reach the socket, installCmuxService throws and
63
+ // the caller falls back to the OS service. (cmuxReachable stays exported for cmux-doctor.)
64
+ export function detectCmux(env = process.env) {
65
+ return inCmux(env)
66
+ }
67
+
68
+ export function readRecord(recordFile = RECORD) {
69
+ try { return JSON.parse(fs.readFileSync(recordFile, 'utf8')) } catch { return null }
70
+ }
71
+
72
+ export function isCmuxServiceInstalled(recordFile = RECORD) {
73
+ return !!readRecord(recordFile)
74
+ }
75
+
76
+ // The command the cmux surface runs. PINNED to the installing version (same safety
77
+ // default as service.mjs — a bad publish can't auto-roll to the always-on bridge).
78
+ // `serve` runs account mode regardless of TTY (a cmux surface has a TTY, so a bare
79
+ // invocation would open the interactive launcher instead). `--supervise` restarts
80
+ // the account supervisor on crash, since cmux won't re-run a dead surface.
81
+ export function serviceCommand(version) {
82
+ return `npx -y thinkpool-pair@${version || 'latest'} serve --supervise`
83
+ }
84
+
85
+ // Pull a stable handle out of cmux output. Prefer a UUID (stable across reorders);
86
+ // fall back to a positional `workspace:N` ref; null if neither is present.
87
+ export function parseWorkspaceRef(out) {
88
+ 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]
89
+ if (uuid) return uuid
90
+ const ref = (String(out).match(/workspace:\d+/) || [])[0]
91
+ return ref || null
92
+ }
93
+
94
+ // Best-effort: find the service workspace by its name when we have no stored ref
95
+ // (e.g. the record predates ref capture, or parsing failed at install time).
96
+ function resolveByName(run, name = WS_NAME) {
97
+ try {
98
+ const out = run(['--id-format', 'uuids', 'list-workspaces'])
99
+ const line = String(out).split('\n').find((l) => l.includes(name))
100
+ return line ? parseWorkspaceRef(line) : null
101
+ } catch { return null }
102
+ }
103
+
104
+ function spawnSurface(run, command, cwd = HOME) {
105
+ // --focus false: never steal focus from the user's current surface.
106
+ const out = run(['--id-format', 'uuids', 'new-workspace', '--name', WS_NAME, '--cwd', cwd, '--command', command, '--focus', 'false'])
107
+ return parseWorkspaceRef(out)
108
+ }
109
+
110
+ function closeSurface(run, rec) {
111
+ const ref = rec.workspace || resolveByName(run)
112
+ if (!ref) return
113
+ try { run(['close-workspace', '--workspace', ref]) } catch { /* may already be gone */ }
114
+ }
115
+
116
+ export function installCmuxService({ version, cwd = HOME, run = cmux, recordFile = RECORD } = {}) {
117
+ const command = serviceCommand(version)
118
+ const workspace = spawnSurface(run, command, cwd)
119
+ const rec = { kind: 'cmux', version: version || 'latest', name: WS_NAME, command, workspace, installedAt: new Date().toISOString() }
120
+ fs.mkdirSync(path.dirname(recordFile), { recursive: true })
121
+ fs.writeFileSync(recordFile, JSON.stringify(rec, null, 2))
122
+ return rec
123
+ }
124
+
125
+ // Restart = close the surface + spawn a fresh one. The account supervisor restores
126
+ // every active session from disk (session-store crash-resume), so sessions resume
127
+ // in place — same contract as an OS-service restart. Passing `version` re-pins the
128
+ // surface command to that version first (the "Restart & update the bridge" path):
129
+ // the command stays PINNED (never @latest — no auto-roll), just bumped to the version
130
+ // the user explicitly launched.
131
+ export function restartCmuxService({ run = cmux, recordFile = RECORD, version } = {}) {
132
+ const rec = readRecord(recordFile)
133
+ if (!rec) return false
134
+ closeSurface(run, rec)
135
+ if (version) { rec.version = version; rec.command = serviceCommand(version) }
136
+ rec.workspace = spawnSurface(run, rec.command)
137
+ rec.installedAt = new Date().toISOString()
138
+ fs.writeFileSync(recordFile, JSON.stringify(rec, null, 2))
139
+ return true
140
+ }
141
+
142
+ export function uninstallCmuxService({ run = cmux, recordFile = RECORD } = {}) {
143
+ const rec = readRecord(recordFile)
144
+ if (!rec) return false
145
+ closeSurface(run, rec)
146
+ try { fs.rmSync(recordFile, { force: true }) } catch { /* noop */ }
147
+ return true
148
+ }
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 () => {
@@ -174,7 +192,7 @@ export async function runLauncher({ actions, io, state = detectState(), refresh
174
192
  const pick = await askChoice('\n change', [
175
193
  { label: 'LLM provider', hint: `current: ${state.provider}` },
176
194
  { label: 'Account', hint: state.loggedIn ? `linked: ${state.email || 'yes'}` : 'not linked' },
177
- { label: 'Restart the background service', hint: state.accountSvc ? 'bounce it now — sessions resume' : 'none installed' },
195
+ { label: 'Restart & update the bridge', hint: state.accountSvc ? 'pull the newest published version, then restart — sessions resume' : 'none installed' },
178
196
  { label: 'Remove the background service', hint: state.accountSvc ? '' : 'none installed' },
179
197
  { label: '← Back to main menu', hint: '' },
180
198
  ], 5) // Enter = Back
@@ -182,7 +200,7 @@ export async function runLauncher({ actions, io, state = detectState(), refresh
182
200
  if (pick === 0) await providerMenu()
183
201
  else if (pick === 1) { await actions.login(); resync(); return }
184
202
  else if (pick === 2) {
185
- if (state.accountSvc) { await actions.restartService({ room: null }); resync() }
203
+ if (state.accountSvc) { await actions.restartUpdateService({ room: null }); resync() }
186
204
  else io.print(' ' + C.yellow('no background service installed'))
187
205
  }
188
206
  else {
@@ -207,7 +225,7 @@ export async function runLauncher({ actions, io, state = detectState(), refresh
207
225
  { key: 'serve', label: 'Serve all my sessions', hint: 'account mode — needs login' },
208
226
  { key: 'service', label: 'Always-on background service', hint: 'survives reboot, restarts on crash' },
209
227
  ]
210
- if (state.accountSvc) items.push({ key: 'restart', label: 'Restart the background service', hint: 'bounce it now — sessions resume' })
228
+ if (state.accountSvc) items.push({ key: 'restart', label: 'Restart & update the bridge', hint: 'pull the newest version + restart — sessions resume' })
211
229
  items.push({ key: 'settings', label: 'Settings', hint: 'provider · account · restart · remove service' })
212
230
  items.push({ key: 'quit', label: 'Quit', hint: '' })
213
231
  const pick = items[await askChoice('choose', items)]
@@ -215,7 +233,7 @@ export async function runLauncher({ actions, io, state = detectState(), refresh
215
233
  if (pick.key === 'pair') await pairRoom()
216
234
  else if (pick.key === 'serve') await serveAccount()
217
235
  else if (pick.key === 'service') await serviceMenu()
218
- else if (pick.key === 'restart') { await actions.restartService({ room: null }); resync() }
236
+ else if (pick.key === 'restart') { await actions.restartUpdateService({ room: null }); resync() }
219
237
  else if (pick.key === 'settings') await settingsMenu()
220
238
  await io.ask('\n ' + C.dim('press Enter to return to the menu…'))
221
239
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thinkpool-pair",
3
- "version": "0.7.58",
3
+ "version": "0.7.60",
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",