thinkpool-pair 0.6.7 → 0.6.9
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/README.md +25 -0
- package/bridge.mjs +71 -2
- package/claude-session.mjs +21 -0
- package/package.json +2 -1
- package/service.mjs +103 -0
package/README.md
CHANGED
|
@@ -18,6 +18,31 @@ node bridge.mjs <ROOM> # picks an installed agent, shared into room <ROOM>
|
|
|
18
18
|
- Share a specific CLI: `node bridge.mjs <ROOM> -- aider` / `… -- bash`.
|
|
19
19
|
- Pure relay (no local terminal): `node bridge.mjs <ROOM> --headless`.
|
|
20
20
|
|
|
21
|
+
## Keep it stable (auto-restart)
|
|
22
|
+
|
|
23
|
+
The bridge self-heals at three levels — all cross-platform (macOS / Linux / Windows):
|
|
24
|
+
|
|
25
|
+
- **`--supervise`** — wrap it so a crash respawns automatically (exponential
|
|
26
|
+
backoff). Zero-dependency. Combined with session restore (below), a crash is
|
|
27
|
+
invisible — the room reconnects and the Claude session resumes where it left off.
|
|
28
|
+
```bash
|
|
29
|
+
npx thinkpool-pair <ROOM> --supervise -- claude
|
|
30
|
+
```
|
|
31
|
+
- **Boot-persistent service** — survive reboot/logout too. Installs the right
|
|
32
|
+
native service for your OS (launchd on macOS, systemd `--user` on Linux, a
|
|
33
|
+
Startup-folder script on Windows):
|
|
34
|
+
```bash
|
|
35
|
+
npx thinkpool-pair install-service <ROOM> -- claude # set and forget
|
|
36
|
+
npx thinkpool-pair uninstall-service <ROOM> # remove it
|
|
37
|
+
```
|
|
38
|
+
- **Watchdog** — if the realtime channel wedges for >60s, the bridge exits so the
|
|
39
|
+
supervisor/service restarts a clean process. Brief network blips reconnect on
|
|
40
|
+
their own.
|
|
41
|
+
|
|
42
|
+
Structured Claude sessions persist their scrollback + session id to
|
|
43
|
+
`~/.thinkpool-pair/<ROOM>/` and **resume on restart** (live context if recent),
|
|
44
|
+
so none of the above loses your place.
|
|
45
|
+
|
|
21
46
|
Your first agent runs **attached** — use it exactly like your normal terminal,
|
|
22
47
|
every byte mirrors to the web. The web's **"+ New terminal"** spawns additional
|
|
23
48
|
**headless** terminals here (same directory, same env), driven entirely from
|
package/bridge.mjs
CHANGED
|
@@ -32,6 +32,7 @@ import os from 'node:os'
|
|
|
32
32
|
import fs from 'node:fs'
|
|
33
33
|
import path from 'node:path'
|
|
34
34
|
import readline from 'node:readline'
|
|
35
|
+
import { spawn } from 'node:child_process'
|
|
35
36
|
import { randomUUID } from 'node:crypto'
|
|
36
37
|
import { createClient } from '@supabase/supabase-js'
|
|
37
38
|
import { startClaudeSession } from './claude-session.mjs'
|
|
@@ -117,8 +118,51 @@ const pickAgent = (installed) => new Promise((resolve) => {
|
|
|
117
118
|
})
|
|
118
119
|
|
|
119
120
|
const argv = process.argv.slice(2)
|
|
121
|
+
|
|
122
|
+
// Boot-persistent service install (cross-platform: launchd / systemd / Windows
|
|
123
|
+
// Startup). Subcommand form: `thinkpool-pair install-service <ROOM> [-- <cmd>]`.
|
|
124
|
+
if (argv[0] === 'install-service' || argv[0] === 'uninstall-service') {
|
|
125
|
+
const svcRoom = (argv[1] || '').toUpperCase().trim()
|
|
126
|
+
if (!svcRoom || svcRoom.startsWith('-')) { console.error('usage: thinkpool-pair install-service <ROOM> [-- <command…>]'); process.exit(1) }
|
|
127
|
+
const sd = argv.indexOf('--')
|
|
128
|
+
const svcCmd = sd >= 0 ? argv.slice(sd + 1) : []
|
|
129
|
+
const svc = await import('./service.mjs')
|
|
130
|
+
if (argv[0] === 'install-service') svc.installService(svcRoom, svcCmd)
|
|
131
|
+
else svc.uninstallService(svcRoom)
|
|
132
|
+
process.exit(0)
|
|
133
|
+
}
|
|
134
|
+
|
|
120
135
|
const room = (argv[0] || '').toUpperCase().trim()
|
|
121
136
|
if (!room || room.startsWith('-')) { console.error('usage: npx thinkpool-pair <ROOM> [--headless] [--continue|--fresh] [-- <command…>]'); process.exit(1) }
|
|
137
|
+
// ── Supervisor mode (--supervise / --keep-alive): keep the bridge alive across
|
|
138
|
+
// crashes. We re-exec ourselves without the flag and respawn the child on any
|
|
139
|
+
// non-clean exit with exponential backoff. Zero-dependency, cross-platform. With
|
|
140
|
+
// the crash-resume in session-store, a respawn restores the session invisibly.
|
|
141
|
+
// (Boot persistence — surviving reboot/logout — is the launchd/systemd tier.)
|
|
142
|
+
const _superDash = argv.indexOf('--')
|
|
143
|
+
const _superOwn = _superDash >= 0 ? argv.slice(0, _superDash) : argv
|
|
144
|
+
if (_superOwn.includes('--supervise') || _superOwn.includes('--keep-alive')) {
|
|
145
|
+
const childArgs = process.argv.slice(1).filter((a) => a !== '--supervise' && a !== '--keep-alive')
|
|
146
|
+
let backoff = 1000, stopping = false, child = null
|
|
147
|
+
const spawnChild = () => {
|
|
148
|
+
const t0 = Date.now()
|
|
149
|
+
child = spawn(process.execPath, childArgs, { stdio: 'inherit' })
|
|
150
|
+
child.on('exit', (code, signal) => {
|
|
151
|
+
if (stopping || code === 0) process.exit(code || 0) // clean stop → stop supervising
|
|
152
|
+
const ranLong = Date.now() - t0 > 60000
|
|
153
|
+
const wait = ranLong ? 1000 : backoff
|
|
154
|
+
backoff = ranLong ? 1000 : Math.min(backoff * 2, 30000)
|
|
155
|
+
process.stderr.write(`\n ⚠ bridge exited (${signal || `code ${code}`}); restarting in ${Math.round(wait / 1000)}s…\n`)
|
|
156
|
+
setTimeout(spawnChild, wait)
|
|
157
|
+
})
|
|
158
|
+
child.on('error', () => { /* spawn failure → exit handler retries */ })
|
|
159
|
+
}
|
|
160
|
+
for (const sig of ['SIGINT', 'SIGTERM']) process.on(sig, () => { stopping = true; try { child?.kill(sig) } catch { /* noop */ } setTimeout(() => process.exit(0), 400) })
|
|
161
|
+
process.stderr.write(`\n ◆ supervisor up — keeping the bridge for room ${room} alive (auto-restart on crash).\n`)
|
|
162
|
+
spawnChild()
|
|
163
|
+
await new Promise(() => {}) // block forever; the child IS the bridge
|
|
164
|
+
}
|
|
165
|
+
|
|
122
166
|
const headless = argv.includes('--headless')
|
|
123
167
|
// Structured mode (Phase 2, opt-in): Claude Code runs through the Agent SDK
|
|
124
168
|
// (structured events + risk-tiered permission gate) instead of the PTY byte
|
|
@@ -384,10 +428,12 @@ function openStructured({ id, model, resume, log }) {
|
|
|
384
428
|
},
|
|
385
429
|
requestPermission: (req) => new Promise((resolve) => {
|
|
386
430
|
entry.pending.set(req.id, resolve)
|
|
387
|
-
bcast('code-perm-req', { term: id, id: req.id, toolName: req.toolName, input: req.input, risk: req.risk, plan: req.plan })
|
|
431
|
+
bcast('code-perm-req', { term: id, id: req.id, toolName: req.toolName, input: req.input, risk: req.risk, plan: req.plan, questions: req.questions })
|
|
388
432
|
process.stderr.write(req.risk === 'plan'
|
|
389
433
|
? `\n ${A.mag}◆ plan ready — approve in the room.${A.rst}\n`
|
|
390
|
-
:
|
|
434
|
+
: req.risk === 'ask'
|
|
435
|
+
? `\n ${A.cyan}● Claude is asking: ${(req.questions || []).map((q) => q.question).join(' / ').slice(0, 100)} — answer in the room.${A.rst}\n`
|
|
436
|
+
: `\n ${A.yel}● permission: ${req.toolName}${argStr(req.input)} — approve in the room.${A.rst}\n`)
|
|
391
437
|
}),
|
|
392
438
|
})
|
|
393
439
|
announce()
|
|
@@ -419,6 +465,11 @@ process.stdout.on('resize', () => {
|
|
|
419
465
|
try { t.term.resize(cols, rows); announce() } catch { /* noop */ }
|
|
420
466
|
})
|
|
421
467
|
|
|
468
|
+
// Realtime health, read by the subscribe handler + watchdog (declared before
|
|
469
|
+
// the channel chain so the async subscribe callback closes over live bindings).
|
|
470
|
+
let realtimeHealthy = false
|
|
471
|
+
let brokenSince = Date.now()
|
|
472
|
+
|
|
422
473
|
channel
|
|
423
474
|
.on('broadcast', { event: 'pty-in' }, ({ payload }) => {
|
|
424
475
|
if (!payload?.data) return
|
|
@@ -551,6 +602,7 @@ channel
|
|
|
551
602
|
.on('broadcast', { event: 'who' }, announce)
|
|
552
603
|
.subscribe(status => {
|
|
553
604
|
if (status === 'SUBSCRIBED') {
|
|
605
|
+
realtimeHealthy = true; brokenSince = 0
|
|
554
606
|
channel.track({ name, role: 'bridge' })
|
|
555
607
|
if (attachedCmd && !terms.size && !sessions.size) {
|
|
556
608
|
// Claude + structured mode → Agent SDK session; everything else → PTY.
|
|
@@ -567,9 +619,26 @@ channel
|
|
|
567
619
|
process.stderr.write(headless
|
|
568
620
|
? `\n ◆ thinkpool — relaying room ${room} (headless). Open terminals from the web UI.\n\n`
|
|
569
621
|
: `\n ◆ thinkpool — sharing "${attachedCmd}"${continuing ? ' (continuing your latest session)' : ''} into room ${room}. Open the web UI and you're both in.\n\n`)
|
|
622
|
+
} else if (status === 'CHANNEL_ERROR' || status === 'TIMED_OUT' || status === 'CLOSED') {
|
|
623
|
+
// supabase-js retries the socket itself; we just track health for the
|
|
624
|
+
// watchdog. A blip self-heals (next SUBSCRIBED re-announces); a wedge that
|
|
625
|
+
// never recovers trips the watchdog → exit → supervisor respawns clean.
|
|
626
|
+
if (realtimeHealthy || !brokenSince) brokenSince = Date.now()
|
|
627
|
+
realtimeHealthy = false
|
|
628
|
+
process.stderr.write(`\n ⚠ realtime ${status} — reconnecting…\n`)
|
|
570
629
|
}
|
|
571
630
|
})
|
|
572
631
|
|
|
632
|
+
// Watchdog — if the realtime channel is stuck (never connected, or dropped and
|
|
633
|
+
// not recovered) for >60s, exit non-zero so a supervisor/launchd restarts a
|
|
634
|
+
// clean process. Healthy long-lived connections never trip this (brokenSince=0).
|
|
635
|
+
setInterval(() => {
|
|
636
|
+
if (!realtimeHealthy && brokenSince && Date.now() - brokenSince > 60000) {
|
|
637
|
+
process.stderr.write('\n ⚠ realtime wedged >60s — exiting for a clean restart.\n')
|
|
638
|
+
process.exit(1)
|
|
639
|
+
}
|
|
640
|
+
}, 15000).unref()
|
|
641
|
+
|
|
573
642
|
function shutdown() {
|
|
574
643
|
if (shuttingDown) return
|
|
575
644
|
shuttingDown = true
|
package/claude-session.mjs
CHANGED
|
@@ -120,6 +120,27 @@ export function startClaudeSession({ cwd, model, resume, onEvent, requestPermiss
|
|
|
120
120
|
}
|
|
121
121
|
return { continue: true, hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: 'The user chose "keep planning" in the ThinkPool room. Do not exit plan mode — keep refining the plan, then call ExitPlanMode again when ready.' } }
|
|
122
122
|
}
|
|
123
|
+
// ── AskUserQuestion — the agent asks the user a multiple-choice question.
|
|
124
|
+
// It can't run its interactive dialog headless (allowing it errors), so we
|
|
125
|
+
// render the choice card in the room and feed the selection back as the tool
|
|
126
|
+
// outcome. PreToolUse can't inject a tool_result, but a deny's reason IS what
|
|
127
|
+
// the model receives — so we deny and put the answer in the reason.
|
|
128
|
+
if (toolName === 'AskUserQuestion') {
|
|
129
|
+
let decision = ''
|
|
130
|
+
try { decision = await requestPermission?.({ id: randomUUID(), toolName, input: toolInput, risk: 'ask', questions: toolInput?.questions || [] }) ?? '' }
|
|
131
|
+
catch { decision = '' }
|
|
132
|
+
const ans = (typeof decision === 'string' && decision.startsWith('answer:')) ? decision.slice(7) : ''
|
|
133
|
+
return {
|
|
134
|
+
continue: true,
|
|
135
|
+
hookSpecificOutput: {
|
|
136
|
+
hookEventName: 'PreToolUse',
|
|
137
|
+
permissionDecision: 'deny',
|
|
138
|
+
permissionDecisionReason: ans
|
|
139
|
+
? `The user answered in the ThinkPool room — ${ans}. Treat this as their selection and continue; do not call AskUserQuestion again for the same question.`
|
|
140
|
+
: 'The user dismissed the question in the ThinkPool room without selecting. Ask in plain prose, or proceed with a sensible default.',
|
|
141
|
+
},
|
|
142
|
+
}
|
|
143
|
+
}
|
|
123
144
|
const risk = classifyRisk(toolName, toolInput)
|
|
124
145
|
// "Don't ask again" is keyed by tool + risk tier, so allowing medium Bash
|
|
125
146
|
// never silently allows a future destructive one (high always re-asks).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "thinkpool-pair",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.9",
|
|
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": {
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
"bridge.mjs",
|
|
11
11
|
"claude-session.mjs",
|
|
12
12
|
"session-store.mjs",
|
|
13
|
+
"service.mjs",
|
|
13
14
|
"README.md"
|
|
14
15
|
],
|
|
15
16
|
"scripts": {
|
package/service.mjs
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/* ─────────────────────────────────────────────────────────────
|
|
2
|
+
service.mjs — install the bridge as a boot-persistent OS service
|
|
3
|
+
so a customer's bridge survives reboot/logout and auto-restarts
|
|
4
|
+
on crash, on every platform:
|
|
5
|
+
• macOS → launchd LaunchAgent (KeepAlive)
|
|
6
|
+
• Linux → systemd --user unit (Restart=always)
|
|
7
|
+
• Windows → Startup-folder .cmd running --supervise
|
|
8
|
+
`--supervise` (in bridge.mjs) is the cross-platform crash-restart
|
|
9
|
+
baseline that needs no install; this adds boot persistence.
|
|
10
|
+
───────────────────────────────────────────────────────────── */
|
|
11
|
+
|
|
12
|
+
import os from 'node:os'
|
|
13
|
+
import fs from 'node:fs'
|
|
14
|
+
import path from 'node:path'
|
|
15
|
+
import { fileURLToPath } from 'node:url'
|
|
16
|
+
import { execSync } from 'node:child_process'
|
|
17
|
+
|
|
18
|
+
const BRIDGE = fileURLToPath(new URL('./bridge.mjs', import.meta.url))
|
|
19
|
+
const label = (room) => `io.thinkpool.pair.${room.toLowerCase()}`
|
|
20
|
+
const xml = (s) => String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
21
|
+
|
|
22
|
+
// Pure artifact builder — returned shape is testable without side effects.
|
|
23
|
+
// `mode:'service'` lets the OS supervise (no --supervise); 'supervise' wraps it.
|
|
24
|
+
export function buildArtifact(platform, { room, cmdArgs = [] }) {
|
|
25
|
+
const node = process.execPath
|
|
26
|
+
const cwd = process.cwd()
|
|
27
|
+
const logDir = path.join(os.homedir(), '.thinkpool-pair')
|
|
28
|
+
const log = path.join(logDir, `${room}.log`)
|
|
29
|
+
const tail = cmdArgs.length ? ['--', ...cmdArgs] : []
|
|
30
|
+
|
|
31
|
+
if (platform === 'darwin') {
|
|
32
|
+
const args = [node, BRIDGE, room, ...tail] // launchd KeepAlive supervises
|
|
33
|
+
const file = path.join(os.homedir(), 'Library', 'LaunchAgents', `${label(room)}.plist`)
|
|
34
|
+
const content = `<?xml version="1.0" encoding="UTF-8"?>
|
|
35
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
36
|
+
<plist version="1.0"><dict>
|
|
37
|
+
<key>Label</key><string>${label(room)}</string>
|
|
38
|
+
<key>ProgramArguments</key><array>${args.map((a) => `<string>${xml(a)}</string>`).join('')}</array>
|
|
39
|
+
<key>RunAtLoad</key><true/>
|
|
40
|
+
<key>KeepAlive</key><true/>
|
|
41
|
+
<key>WorkingDirectory</key><string>${xml(cwd)}</string>
|
|
42
|
+
<key>StandardOutPath</key><string>${xml(log)}</string>
|
|
43
|
+
<key>StandardErrorPath</key><string>${xml(log)}</string>
|
|
44
|
+
<key>EnvironmentVariables</key><dict><key>PATH</key><string>${xml(process.env.PATH || '')}</string></dict>
|
|
45
|
+
</dict></plist>\n`
|
|
46
|
+
return { file, content, logDir, post: [`launchctl unload "${file}" 2>/dev/null; launchctl load "${file}"`], note: 'Loaded as a LaunchAgent — starts at login, restarts on crash.' }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (platform === 'linux') {
|
|
50
|
+
const args = [node, BRIDGE, room, ...tail] // systemd Restart=always supervises
|
|
51
|
+
const file = path.join(os.homedir(), '.config', 'systemd', 'user', `${label(room)}.service`)
|
|
52
|
+
const content = `[Unit]
|
|
53
|
+
Description=ThinkPool Code bridge (${room})
|
|
54
|
+
After=network-online.target
|
|
55
|
+
|
|
56
|
+
[Service]
|
|
57
|
+
ExecStart=${args.map((a) => `'${a.replace(/'/g, "'\\''")}'`).join(' ')}
|
|
58
|
+
Restart=always
|
|
59
|
+
RestartSec=2
|
|
60
|
+
WorkingDirectory=${cwd}
|
|
61
|
+
Environment=PATH=${process.env.PATH || ''}
|
|
62
|
+
StandardOutput=append:${log}
|
|
63
|
+
StandardError=append:${log}
|
|
64
|
+
|
|
65
|
+
[Install]
|
|
66
|
+
WantedBy=default.target
|
|
67
|
+
`
|
|
68
|
+
return { file, content, logDir, post: ['systemctl --user daemon-reload', `systemctl --user enable --now ${label(room)}.service`], note: 'Enabled as a systemd --user service. Run `loginctl enable-linger $USER` once to keep it running after logout.' }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (platform === 'win32') {
|
|
72
|
+
// No native per-user daemon without extra tooling — a Startup-folder script
|
|
73
|
+
// running --supervise gives login-persistence + crash-restart, dependency-free.
|
|
74
|
+
const startup = path.join(process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'), 'Microsoft', 'Windows', 'Start Menu', 'Programs', 'Startup')
|
|
75
|
+
const file = path.join(startup, `thinkpool-pair-${room}.cmd`)
|
|
76
|
+
const inner = [`"${node}"`, `"${BRIDGE}"`, room, '--supervise', ...tail].join(' ')
|
|
77
|
+
const content = `@echo off\r\ntitle thinkpool-pair ${room}\r\n${inner}\r\n`
|
|
78
|
+
return { file, content, logDir, post: [], note: 'Installed to the Startup folder — runs at login with --supervise (auto-restart on crash). Start it now without rebooting by double-clicking the .cmd, or run it from a terminal.' }
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
throw new Error(`unsupported platform: ${platform}`)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function installService(room, cmdArgs = []) {
|
|
85
|
+
const a = buildArtifact(process.platform, { room, cmdArgs })
|
|
86
|
+
fs.mkdirSync(a.logDir, { recursive: true })
|
|
87
|
+
fs.mkdirSync(path.dirname(a.file), { recursive: true })
|
|
88
|
+
fs.writeFileSync(a.file, a.content)
|
|
89
|
+
process.stderr.write(`\n ◆ wrote ${a.file}\n`)
|
|
90
|
+
for (const cmd of a.post) {
|
|
91
|
+
try { execSync(cmd, { stdio: 'inherit' }) } catch (e) { process.stderr.write(` ⚠ "${cmd}" failed: ${e.message}\n`) }
|
|
92
|
+
}
|
|
93
|
+
process.stderr.write(` ◆ ${a.note}\n ◆ logs: ${path.join(a.logDir, `${room}.log`)}\n ◆ remove with: thinkpool-pair uninstall-service ${room}\n\n`)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function uninstallService(room) {
|
|
97
|
+
const plat = process.platform
|
|
98
|
+
let a
|
|
99
|
+
try { a = buildArtifact(plat, { room }) } catch (e) { process.stderr.write(` ⚠ ${e.message}\n`); return }
|
|
100
|
+
if (plat === 'darwin') { try { execSync(`launchctl unload "${a.file}" 2>/dev/null`) } catch { /* noop */ } }
|
|
101
|
+
if (plat === 'linux') { try { execSync(`systemctl --user disable --now ${label(room)}.service 2>/dev/null`) } catch { /* noop */ } }
|
|
102
|
+
try { fs.rmSync(a.file, { force: true }); process.stderr.write(` ◆ removed ${a.file}\n`) } catch (e) { process.stderr.write(` ⚠ ${e.message}\n`) }
|
|
103
|
+
}
|