thinkpool-pair 0.7.27 → 0.7.29

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bridge.mjs CHANGED
@@ -33,10 +33,12 @@ import fs from 'node:fs'
33
33
  import path from 'node:path'
34
34
  import readline from 'node:readline'
35
35
  import { spawn } from 'node:child_process'
36
+ import { fileURLToPath } from 'node:url'
36
37
  import { randomUUID } from 'node:crypto'
37
38
  import { createClient } from '@supabase/supabase-js'
38
39
  import { startClaudeSession } from './claude-session.mjs'
39
40
  import { saveSession, flushSession, deleteSession, loadAll, canResume, loadPtyId, savePtyId, loadNames, saveNames } from './session-store.mjs'
41
+ import { stampEvent } from './event-id.mjs'
40
42
 
41
43
  // Public client creds (the same anon values the web app ships — safe to embed).
42
44
  // Override with TP_SUPABASE_URL / TP_SUPABASE_ANON if you ever need to.
@@ -153,6 +155,42 @@ applyProviderEnv()
153
155
  if (argv[0] === 'login') { const { runLogin } = await import('./account.mjs'); await runLogin(SUPABASE_URL, SUPABASE_ANON, WEB_BASE) }
154
156
  if (argv[0] === 'bind') { const { runBind } = await import('./account.mjs'); runBind(argv[1], argv[2]) }
155
157
  if (argv[0] === 'provider') { const { runProvider } = await import('./provider.mjs'); await runProvider(argv.slice(1)); process.exit(0) }
158
+
159
+ // One-command launcher: a bare invocation on a TTY (or the `setup` alias) opens the
160
+ // interactive menu instead of going straight to account mode. The menu only ASSEMBLES
161
+ // existing commands — it re-execs `<ROOM>`, or calls installService / runLogin /
162
+ // runProvider / runAccount directly. Non-TTY bare stays account-mode (the line below),
163
+ // so launchd / systemd / CI / pipes are byte-for-byte unchanged.
164
+ // Spec: docs/specs/2026-06-19-bridge-unified-launcher.md (Part 1).
165
+ if ((!argv[0] || argv[0] === 'setup') && process.stdin.isTTY) {
166
+ const { runLauncher } = await import('./launcher.mjs')
167
+ const svc = await import('./service.mjs')
168
+ const BRIDGE = fileURLToPath(import.meta.url)
169
+ const io = {
170
+ print: (s) => process.stderr.write(s + '\n'),
171
+ ask: (q) => new Promise((res) => {
172
+ const rl = readline.createInterface({ input: process.stdin, output: process.stderr })
173
+ rl.question(q, (a) => { rl.close(); res(String(a).trim()) })
174
+ }),
175
+ }
176
+ const actions = {
177
+ // Re-exec the bridge with the assembled argv; the child owns the terminal and the
178
+ // parent waits, exiting with the child's code (so Ctrl-C / exit behave normally).
179
+ pairRoom: ({ room, agentCmd, resumeArgs = [], headless }) => new Promise(() => {
180
+ const args = [BRIDGE, room, ...(headless ? ['--headless'] : []), '--', agentCmd, ...resumeArgs]
181
+ const child = spawn(process.execPath, args, { stdio: 'inherit' })
182
+ child.on('exit', (code) => process.exit(code == null ? 0 : code))
183
+ }),
184
+ serveAccountForeground: async () => { const { runAccount } = await import('./account.mjs'); await runAccount(SUPABASE_URL, SUPABASE_ANON) },
185
+ installService: ({ room = null, agentCmd } = {}) => { svc.installService(room, agentCmd ? [agentCmd] : []); process.exit(0) },
186
+ uninstallService: ({ room = null } = {}) => { svc.uninstallService(room); process.exit(0) },
187
+ login: async () => { const { runLogin } = await import('./account.mjs'); await runLogin(SUPABASE_URL, SUPABASE_ANON, WEB_BASE) },
188
+ setProvider: async ({ kind, base, token }) => { const { runProvider } = await import('./provider.mjs'); await runProvider(kind === 'custom' ? ['custom', '--base', base, '--token', token] : ['anthropic']); process.exit(0) },
189
+ }
190
+ await runLauncher({ actions, io })
191
+ process.exit(0)
192
+ }
193
+
156
194
  if (!argv[0] || argv[0].startsWith('-')) { const { runAccount } = await import('./account.mjs'); await runAccount(SUPABASE_URL, SUPABASE_ANON) }
157
195
 
158
196
  const room = (argv[0] || '').toUpperCase().trim()
@@ -588,11 +626,13 @@ function openStructured({ id, model, resume, log, commands, mode }) {
588
626
  openStructured({ id, model, log: entry.log, commands: entry.commands, mode: entry.mode })
589
627
  return
590
628
  }
591
- // Stamp a wall-clock ts on every transcript event so the web client can
592
- // sort agent turns chronologically against room chat/whispers on reload
593
- // (the client merges the replayed log with persisted human lines). Used
594
- // for ordering only agent timestamps are never displayed.
595
- if (typeof evt.ts !== 'number') evt.ts = Date.now()
629
+ // Stamp a wall-clock ts AND a stable cid on every transcript event before
630
+ // it's logged + broadcast. ts: lets the web client sort agent turns
631
+ // chronologically against room chat/whispers on reload. cid: the room's
632
+ // replay-union dedupes ONLY by cid, and SDK events carry none — without an
633
+ // id, an event that arrives both live AND in a reconnect replay renders
634
+ // twice (the 2026-06-19 duplicate-message bug). See event-id.mjs.
635
+ stampEvent(evt)
596
636
  // The init system event carries the session's slash command list. Stash it
597
637
  // on the entry so the ANNOUNCE can hand it to clients that connect/reload
598
638
  // AFTER init (the one-time code-event would miss them), then re-announce.
@@ -637,10 +677,22 @@ function openStructured({ id, model, resume, log, commands, mode }) {
637
677
  // from memory, delete its on-disk record (so a restart can't resurrect it), and tell
638
678
  // every client it's gone. Idempotent + id-scoped — no-ops for ids that aren't a
639
679
  // structured session, so it's safe to call from the PTY-oriented term-close path too.
680
+ // Resolve any unanswered permission resolvers to 'deny' and clear them. A card
681
+ // that's never answered (both clients gone, tab closed, or an abort) otherwise
682
+ // leaves the SDK's PreToolUse hook (claude-session.mjs) awaiting our promise
683
+ // forever — the structured turn freezes mid-flight. 'deny' is the fail-safe the
684
+ // hook already degrades to, so draining to deny is always safe.
685
+ function drainPending(s) {
686
+ if (!s?.pending) return
687
+ for (const [, p] of s.pending) { try { p.resolve('deny') } catch { /* noop */ } }
688
+ s.pending.clear()
689
+ }
690
+
640
691
  function endStructured(id) {
641
692
  if (!id) return
642
693
  const s = sessions.get(id)
643
694
  if (s) {
695
+ drainPending(s)
644
696
  try { s.session?.end() } catch { /* noop */ }
645
697
  try { s.mockupWatcher?.close() } catch { /* noop */ }
646
698
  sessions.delete(id)
@@ -843,7 +895,7 @@ channel
843
895
  })
844
896
  .on('broadcast', { event: 'code-abort' }, ({ payload }) => {
845
897
  const s = payload?.term && sessions.get(payload.term)
846
- if (s) s.session.abort()
898
+ if (s) { drainPending(s); s.session.abort() } // settle any open permission card so the hook doesn't hang
847
899
  })
848
900
  .on('broadcast', { event: 'code-mode' }, ({ payload }) => {
849
901
  const s = payload?.term && sessions.get(payload.term)
@@ -916,7 +968,7 @@ channel
916
968
  setInterval(() => {
917
969
  if (!realtimeHealthy && brokenSince && Date.now() - brokenSince > 60000) {
918
970
  process.stderr.write('\n ⚠ realtime wedged >60s — exiting for a clean restart.\n')
919
- process.exit(1)
971
+ shutdown(1) // reap PTYs + attempt presence-leave (was a raw exit → orphaned children)
920
972
  }
921
973
  }, 15000).unref()
922
974
 
@@ -960,7 +1012,7 @@ if (process.env.THINKPOOL_PAIR_AUTOUPDATE === '1' && VERSION) {
960
1012
  const applyIfIdle = () => {
961
1013
  if (!pendingUpdate || !isIdle()) return
962
1014
  process.stderr.write(`\n ◆ thinkpool-pair ${pendingUpdate} ready (running ${VERSION}) — restarting to update; the session resumes.\n`)
963
- process.exit(0) // service restarts with @latest; session-store resumes
1015
+ shutdown(0, false) // clean teardown (no farewell banner); service reruns @latest, session-store resumes
964
1016
  }
965
1017
  const check = async () => {
966
1018
  const latest = await fetchLatest()
@@ -1010,24 +1062,47 @@ if (process.env.THINKPOOL_PAIR_AUTOUPDATE === '1' && VERSION) {
1010
1062
  }, 60000).unref()
1011
1063
  }
1012
1064
 
1013
- async function shutdown() {
1065
+ // code: process exit code. farewell: print the "[ shared session ended ]" banner
1066
+ // (suppressed for an auto-update restart, which is meant to be invisible in-room).
1067
+ async function shutdown(code = 0, farewell = true) {
1014
1068
  if (shuttingDown) return
1015
1069
  shuttingDown = true
1070
+ // Hard exit backstop, armed FIRST and independent of every await below — a wedged
1071
+ // realtime socket (the watchdog path) or a hung untrack must NEVER leave the
1072
+ // process alive with orphaned PTY children + stale "live" presence.
1073
+ setTimeout(() => process.exit(code), 1500)
1016
1074
  clearInterval(flushTimer)
1017
- try {
1018
- const bye = Buffer.from('\r\n[ shared session ended ]\r\n', 'utf8').toString('base64')
1019
- for (const id of terms.keys()) bcast('pty-out', { term: id, b64: bye })
1020
- } catch { /* noop */ }
1021
- // Leave presence EXPLICITLY before exiting so the web room flips to dormant
1022
- // immediately. Previously we removeChannel()'d and process.exit()'d on the next
1023
- // line the leave frame never flushed, so the web only noticed via the realtime
1024
- // heartbeat timeout (tens of seconds later) and looked stuck "live".
1025
- try { await channel.untrack() } catch { /* noop */ }
1026
- try { await supabase.removeChannel(channel) } catch { /* noop */ }
1075
+ if (farewell) {
1076
+ try {
1077
+ const bye = Buffer.from('\r\n[ shared session ended ]\r\n', 'utf8').toString('base64')
1078
+ for (const id of terms.keys()) bcast('pty-out', { term: id, b64: bye })
1079
+ } catch { /* noop */ }
1080
+ }
1081
+ // Reap children FIRST killing PTYs / ending SDK sessions is unconditionally
1082
+ // correct and must not hang on the (possibly dead) realtime socket. This is the
1083
+ // bug behind the watchdog/auto-update raw process.exit() paths, which skipped
1084
+ // teardown entirely and orphaned agent processes + left stale presence.
1027
1085
  for (const t of terms.values()) { try { t.term.kill() } catch { /* noop */ } }
1028
1086
  for (const s of sessions.values()) { try { s.session.end() } catch { /* noop */ } }
1029
1087
  detachLocal()
1030
- setTimeout(() => process.exit(0), 250) // small grace for the leave/close frames to flush over the socket
1088
+ // Best-effort presence leave so the room flips to dormant immediately (when the
1089
+ // socket is alive; on a wedge it won't flush and the hard backstop above wins).
1090
+ try { await channel.untrack() } catch { /* noop */ }
1091
+ try { await supabase.removeChannel(channel) } catch { /* noop */ }
1092
+ setTimeout(() => process.exit(code), 250) // grace for the leave/close frames to flush
1031
1093
  }
1032
- process.on('SIGINT', shutdown)
1033
- process.on('SIGTERM', shutdown)
1094
+ process.on('SIGINT', () => shutdown(0))
1095
+ process.on('SIGTERM', () => shutdown(0))
1096
+ // A throw escaping any async callback (an onEvent consumer, a JSON-serialize on a
1097
+ // circular payload, a node-pty native error) would otherwise terminate the process
1098
+ // with PTYs orphaned and presence stuck "live" (Node terminates on both since v15).
1099
+ // Route both through shutdown() so children are reaped + presence left, then the
1100
+ // supervisor / launchd / systemd respawns a clean process.
1101
+ process.on('uncaughtException', (err) => {
1102
+ try { process.stderr.write(`\n ⚠ uncaughtException: ${err?.stack || err}\n ◆ shutting down cleanly for a fresh respawn.\n`) } catch { /* noop */ }
1103
+ shutdown(1)
1104
+ })
1105
+ process.on('unhandledRejection', (reason) => {
1106
+ try { process.stderr.write(`\n ⚠ unhandledRejection: ${reason?.stack || reason}\n ◆ shutting down cleanly for a fresh respawn.\n`) } catch { /* noop */ }
1107
+ shutdown(1)
1108
+ })
package/event-id.mjs ADDED
@@ -0,0 +1,29 @@
1
+ import { randomUUID } from 'node:crypto'
2
+
3
+ /* event-id.mjs — stamp a structured event with a stable identity + timestamp
4
+ BEFORE it is logged + broadcast.
5
+
6
+ WHY (the duplicate-message bug, 2026-06-19): structured transcript events
7
+ (assistant / tool_result / thinking / result) carry NO id from the Claude
8
+ Agent SDK. The web room's replay-union (src/pages/code/room.jsx, the
9
+ `code-replay` handler) dedupes ONLY by `cid`: an event with a cid is merged
10
+ once; an event WITHOUT a cid is appended unconditionally. So an id-less event
11
+ that arrives both LIVE (`code-event`) and again in a reconnect REPLAY
12
+ (`code-replay`, which re-sends the whole rolling log) is rendered TWICE.
13
+ On mobile a background/foreground or network blip routinely triggers that
14
+ replay — which is exactly when the duplicates appeared.
15
+
16
+ Fix at the source: give every structured event a stable `cid` here, once, so
17
+ the live copy and every replayed copy share an id and the room's existing
18
+ cid-union collapses them. Idempotent — never overwrites a cid the client
19
+ already set (chat / whisper / pool / propose carry their own).
20
+
21
+ Kept as its own import-safe module so it can be unit-tested
22
+ (bridge/test-structured-cid.mjs); bridge.mjs runs the bridge on import and
23
+ cannot be imported by a test. */
24
+ export function stampEvent(evt, gen = randomUUID) {
25
+ if (!evt || typeof evt !== 'object') return evt
26
+ if (typeof evt.ts !== 'number') evt.ts = Date.now()
27
+ if (!evt.cid) evt.cid = gen()
28
+ return evt
29
+ }
package/launcher.mjs ADDED
@@ -0,0 +1,184 @@
1
+ /* launcher.mjs — the one-command interactive menu for `thinkpool-pair`.
2
+ Spec: docs/specs/2026-06-19-bridge-unified-launcher.md (Part 1).
3
+
4
+ ONE implementation, three callers, via dependency injection:
5
+ • bridge.mjs → real `actions` (spawn a room bridge / installService / runLogin / …)
6
+ • setup-prototype → dry-run `actions` (print the command instead of running it)
7
+ • test-launcher → mock `actions` + scripted `io` (assert menu choice → action + args)
8
+
9
+ detectState() is real + read-only (auth.json / provider.json / PATH agents / service
10
+ artifact presence). runLauncher() renders the menu and calls actions.* — it NEVER runs
11
+ anything itself, so it is safe to unit-test and safe to dry-run. */
12
+
13
+ import os from 'node:os'
14
+ import fs from 'node:fs'
15
+ import path from 'node:path'
16
+
17
+ const HOME = os.homedir()
18
+ const CFG_DIR = path.join(HOME, '.thinkpool-pair')
19
+
20
+ export const KNOWN_AGENTS = [
21
+ { label: 'Claude Code', cmd: 'claude', resume: ['--continue'] },
22
+ { label: 'Codex CLI', cmd: 'codex' },
23
+ { label: 'Gemini CLI', cmd: 'gemini' },
24
+ { label: 'Aider', cmd: 'aider' },
25
+ { label: 'Cursor CLI', cmd: 'cursor-agent' },
26
+ { label: 'opencode', cmd: 'opencode' },
27
+ { label: 'Copilot CLI', cmd: 'copilot' },
28
+ { label: 'Goose', cmd: 'goose' },
29
+ { label: 'Crush', cmd: 'crush' },
30
+ { label: 'Qwen Code', cmd: 'qwen' },
31
+ ]
32
+
33
+ function onPath(c) {
34
+ const exts = process.platform === 'win32' ? (process.env.PATHEXT || '.EXE;.CMD;.BAT;.COM').split(';') : ['']
35
+ for (const dir of (process.env.PATH || '').split(path.delimiter)) {
36
+ if (!dir) continue
37
+ for (const ext of exts) { try { if (fs.existsSync(path.join(dir, c + ext))) return true } catch { /* noop */ } }
38
+ }
39
+ return false
40
+ }
41
+ function readJson(p) { try { return JSON.parse(fs.readFileSync(p, 'utf8')) } catch { return null } }
42
+
43
+ function serviceFile(room) {
44
+ const label = room ? `io.thinkpool.pair.${room.toLowerCase()}` : 'io.thinkpool.pair.account'
45
+ if (process.platform === 'darwin') return path.join(HOME, 'Library', 'LaunchAgents', `${label}.plist`)
46
+ if (process.platform === 'linux') return path.join(HOME, '.config', 'systemd', 'user', `${label}.service`)
47
+ const startup = path.join(process.env.APPDATA || path.join(HOME, 'AppData', 'Roaming'), 'Microsoft', 'Windows', 'Start Menu', 'Programs', 'Startup')
48
+ return path.join(startup, `thinkpool-pair-${room || 'account'}.cmd`)
49
+ }
50
+
51
+ export function detectState() {
52
+ const auth = readJson(path.join(CFG_DIR, 'auth.json'))
53
+ const provider = readJson(path.join(CFG_DIR, 'provider.json'))
54
+ return {
55
+ loggedIn: !!auth?.refresh_token,
56
+ email: auth?.email || auth?.user?.email || null,
57
+ provider: provider?.kind === 'custom' ? `Custom (${provider.base || 'endpoint'})` : 'Anthropic (default)',
58
+ agents: KNOWN_AGENTS.filter(a => onPath(a.cmd)),
59
+ accountSvc: fs.existsSync(serviceFile(null)),
60
+ cwd: process.cwd(),
61
+ }
62
+ }
63
+
64
+ // ── colors ──
65
+ const C = {
66
+ dim: (s) => `\x1b[2m${s}\x1b[0m`, bold: (s) => `\x1b[1m${s}\x1b[0m`,
67
+ cyan: (s) => `\x1b[36m${s}\x1b[0m`, green: (s) => `\x1b[32m${s}\x1b[0m`, yellow: (s) => `\x1b[33m${s}\x1b[0m`,
68
+ }
69
+
70
+ // io: { ask(prompt)->Promise<string>, print(s) }. actions: real | dry-run | mock.
71
+ export async function runLauncher({ actions, io, state = detectState() }) {
72
+ const askChoice = async (prompt, options, def = 1) => {
73
+ options.forEach((o, i) => io.print(` ${C.cyan(String(i + 1))}) ${o.label}${o.hint ? ' ' + C.dim(o.hint) : ''}`))
74
+ const a = await io.ask(`\n ${prompt} ${C.dim(`[${def}]`)} ${C.cyan('▸')} `)
75
+ const n = parseInt(a, 10)
76
+ return Number.isInteger(n) && n >= 1 && n <= options.length ? n - 1 : def - 1
77
+ }
78
+ const askYesNo = async (q, def = true) => {
79
+ const a = (await io.ask(` ${q} ${C.dim(def ? '[Y/n]' : '[y/N]')} ${C.cyan('▸')} `)).toLowerCase()
80
+ return a ? a[0] === 'y' : def
81
+ }
82
+
83
+ const header = () => {
84
+ io.print('')
85
+ io.print(' ' + C.dim('┌ ') + C.bold('thinkpool-pair') + C.dim(' ' + '─'.repeat(40)))
86
+ io.print(` ${C.dim('│ account ')} ${state.loggedIn ? C.green((state.email || 'linked') + ' ✓') : C.yellow('not linked')}`)
87
+ io.print(` ${C.dim('│ provider ')} ${state.provider}`)
88
+ io.print(` ${C.dim('│ agents ')} ${state.agents.length ? state.agents.map(a => a.label).join(', ') : C.yellow('none on PATH')}`)
89
+ io.print(` ${C.dim('│ directory')} ${C.dim(state.cwd)}`)
90
+ io.print(` ${C.dim('│ service ')} ${state.accountSvc ? C.green('account service installed') : 'none'}`)
91
+ io.print(' ' + C.dim('└' + '─'.repeat(54)) + '\n')
92
+ }
93
+
94
+ const pairRoom = async () => {
95
+ const room = (await io.ask(`\n Room code ${C.dim('(from /code in the web app)')} ${C.cyan('▸')} `)).toUpperCase().trim()
96
+ if (!room) { io.print(' ' + C.yellow('no room — back to menu')); return }
97
+ if (!state.agents.length) { io.print('\n ' + C.yellow('No coding-agent CLI on your PATH (claude / codex / gemini / aider …).')); return }
98
+ let agent = state.agents[0]
99
+ if (state.agents.length > 1) { io.print('\n Share which agent?'); agent = state.agents[await askChoice('agent', state.agents.map(a => ({ label: a.label })))] }
100
+ io.print('\n Run mode?')
101
+ const headless = (await askChoice('mode', [
102
+ { label: 'Attached', hint: 'use it like your own terminal, mirrored to the web' },
103
+ { label: 'Headless', hint: 'pure relay, drive entirely from the web' },
104
+ ])) === 1
105
+ let cont = false
106
+ if (!headless && agent.resume) cont = await askYesNo(`\n Continue your latest ${agent.label} session in this directory?`, true)
107
+ await actions.pairRoom({ room, agentCmd: agent.cmd, resumeArgs: cont ? agent.resume : [], headless })
108
+ }
109
+
110
+ const serveAccount = async () => {
111
+ if (!state.loggedIn) {
112
+ io.print('\n ' + C.yellow('You are not linked to a ThinkPool account yet.'))
113
+ if (await askYesNo(' Link this device now?', true)) await actions.login()
114
+ return
115
+ }
116
+ io.print(`\n Serving every session you host, from ${C.dim(state.cwd)}.\n`)
117
+ const how = await askChoice('run it', [
118
+ { label: 'As an always-on background service', hint: 'recommended — survives reboot, auto-updates' },
119
+ { label: 'Here in this terminal (foreground)', hint: 'stops when you close it' },
120
+ ])
121
+ if (how === 0) await actions.installService({ room: null })
122
+ else await actions.serveAccountForeground()
123
+ }
124
+
125
+ const serviceMenu = async () => {
126
+ const what = await askChoice('install for', [
127
+ { label: 'All my sessions (account)', hint: state.loggedIn ? '' : 'needs login first' },
128
+ { label: 'One specific room', hint: 'no login needed' },
129
+ ])
130
+ if (what === 0) {
131
+ if (!state.loggedIn) { io.print('\n ' + C.yellow('Account service needs a login first (pick “Serve all my sessions”).')); return }
132
+ await actions.installService({ room: null }); return
133
+ }
134
+ const room = (await io.ask(`\n Room code ${C.cyan('▸')} `)).toUpperCase().trim()
135
+ if (!room) { io.print(' ' + C.yellow('no room — back to menu')); return }
136
+ const agent = state.agents[0] || { cmd: 'claude' }
137
+ await actions.installService({ room, agentCmd: agent.cmd })
138
+ }
139
+
140
+ const settingsMenu = async () => {
141
+ const pick = await askChoice('\n change', [
142
+ { label: 'LLM provider', hint: `current: ${state.provider}` },
143
+ { label: 'Account', hint: state.loggedIn ? `linked: ${state.email || 'yes'}` : 'not linked' },
144
+ { label: 'Remove the background service', hint: state.accountSvc ? '' : 'none installed' },
145
+ ])
146
+ if (pick === 0) {
147
+ const p = await askChoice('\n provider', [{ label: 'Anthropic (default)' }, { label: 'Custom endpoint', hint: 'Z.ai GLM, OpenRouter, your own proxy' }])
148
+ if (p === 0) await actions.setProvider({ kind: 'anthropic' })
149
+ else {
150
+ const base = (await io.ask(`\n Base url ${C.dim('(SDK appends /v1/messages)')} ${C.cyan('▸')} `)).trim()
151
+ const token = (await io.ask(` API token ${C.cyan('▸')} `)).trim()
152
+ await actions.setProvider({ kind: 'custom', base, token })
153
+ }
154
+ } else if (pick === 1) {
155
+ await actions.login()
156
+ } else {
157
+ if (state.accountSvc) await actions.uninstallService({ room: null })
158
+ else io.print(' ' + C.yellow('no background service installed'))
159
+ }
160
+ }
161
+
162
+ // ── menu loop ──
163
+ // Real actions either replace the process (pairRoom re-execs + waits; serve/install
164
+ // call process.exit) or run forever (account foreground), so a real "do something"
165
+ // choice never returns here. Dry-run/mock actions just print + return → the loop
166
+ // re-renders. The "press Enter" only lands on a no-op / back-out path.
167
+ for (;;) {
168
+ header()
169
+ io.print(' ' + C.bold('What do you want to do?') + '\n')
170
+ const choice = await askChoice('choose', [
171
+ { label: 'Pair one room now', hint: 'run here, Ctrl-C stops it' },
172
+ { label: 'Serve all my sessions', hint: 'account mode — needs login' },
173
+ { label: 'Always-on background service', hint: 'survives reboot, auto-updates' },
174
+ { label: 'Settings', hint: 'provider · account · remove service' },
175
+ { label: 'Quit', hint: '' },
176
+ ])
177
+ if (choice === 4) return // Quit
178
+ if (choice === 0) await pairRoom()
179
+ else if (choice === 1) await serveAccount()
180
+ else if (choice === 2) await serviceMenu()
181
+ else if (choice === 3) await settingsMenu()
182
+ await io.ask('\n ' + C.dim('press Enter to return to the menu…'))
183
+ }
184
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thinkpool-pair",
3
- "version": "0.7.27",
3
+ "version": "0.7.29",
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": {
@@ -8,7 +8,9 @@
8
8
  },
9
9
  "files": [
10
10
  "bridge.mjs",
11
+ "launcher.mjs",
11
12
  "claude-session.mjs",
13
+ "event-id.mjs",
12
14
  "transcript-sanitize.mjs",
13
15
  "session-store.mjs",
14
16
  "service.mjs",
package/service.mjs CHANGED
@@ -120,9 +120,13 @@ export function installService(room, cmdArgs = []) {
120
120
  for (const cmd of a.post) {
121
121
  try { execSync(cmd, { stdio: 'inherit' }) } catch (e) { process.stderr.write(` ⚠ "${cmd}" failed: ${e.message}\n`) }
122
122
  }
123
+ // Be honest about the cadence — it is NOT instant. The supervisor polls npm
124
+ // roughly every 30 min (THINKPOOL_PAIR_UPDATE_INTERVAL) and only restarts once
125
+ // every served session is idle, so a just-published version can take a while.
126
+ const idleSecs = Math.max(10, parseInt(process.env.THINKPOOL_PAIR_UPDATE_IDLE, 10) || 90)
123
127
  const autoUpdate = process.platform === 'win32'
124
- ? 'tracks thinkpool-pair@latest new versions apply on next login/restart.'
125
- : 'tracks thinkpool-pair@latest checks for new versions and self-restarts at the next idle moment (sessions resume), no re-install.'
128
+ ? 'auto-update: a new thinkpool-pair@latest applies on the next login/restart (Windows has no in-process self-update).'
129
+ : `auto-update: checks npm ~every 30 min and restarts to apply a new version once sessions are idle (≥${idleSecs}s quiet; they resume in place). Not instant — to update now, restart it (re-run install-service, or 'launchctl kickstart -k gui/$(id -u)/io.thinkpool.pair.${slug(room)}' on macOS).`
126
130
  const removeArg = room ? ` ${room}` : ''
127
131
  const what = room ? `room ${room}` : 'your account (auto-serves every session)'
128
132
  process.stderr.write(` ◆ ${what}\n ◆ ${a.note}\n ◆ ${autoUpdate}\n ◆ logs: ${path.join(a.logDir, `${slug(room)}.log`)}\n ◆ remove with: thinkpool-pair uninstall-service${removeArg}\n\n`)