thinkpool-pair 0.7.28 → 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,6 +33,7 @@ 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'
@@ -154,6 +155,42 @@ applyProviderEnv()
154
155
  if (argv[0] === 'login') { const { runLogin } = await import('./account.mjs'); await runLogin(SUPABASE_URL, SUPABASE_ANON, WEB_BASE) }
155
156
  if (argv[0] === 'bind') { const { runBind } = await import('./account.mjs'); runBind(argv[1], argv[2]) }
156
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
+
157
194
  if (!argv[0] || argv[0].startsWith('-')) { const { runAccount } = await import('./account.mjs'); await runAccount(SUPABASE_URL, SUPABASE_ANON) }
158
195
 
159
196
  const room = (argv[0] || '').toUpperCase().trim()
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.28",
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,6 +8,7 @@
8
8
  },
9
9
  "files": [
10
10
  "bridge.mjs",
11
+ "launcher.mjs",
11
12
  "claude-session.mjs",
12
13
  "event-id.mjs",
13
14
  "transcript-sanitize.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`)