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 +37 -0
- package/launcher.mjs +184 -0
- package/package.json +2 -1
- package/service.mjs +6 -2
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.
|
|
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
|
-
? '
|
|
125
|
-
:
|
|
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`)
|