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 +97 -22
- package/event-id.mjs +29 -0
- package/launcher.mjs +184 -0
- package/package.json +3 -1
- package/service.mjs +6 -2
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
|
|
592
|
-
//
|
|
593
|
-
//
|
|
594
|
-
//
|
|
595
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
//
|
|
1024
|
-
//
|
|
1025
|
-
|
|
1026
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
? '
|
|
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`)
|