thinkpool-pair 0.6.16 → 0.6.18
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 +15 -5
- package/bridge.mjs +81 -6
- package/package.json +1 -1
- package/service.mjs +6 -2
package/README.md
CHANGED
|
@@ -58,19 +58,29 @@ tunnel. So a cloud VM, container, devcontainer, or remote dev box can stream its
|
|
|
58
58
|
Claude Code into the room exactly like a laptop. Run the same command there:
|
|
59
59
|
|
|
60
60
|
```bash
|
|
61
|
-
export ANTHROPIC_API_KEY=sk-ant-...
|
|
61
|
+
export ANTHROPIC_API_KEY=sk-ant-... # headless auth (no Keychain login)
|
|
62
62
|
cd /path/to/your/repo
|
|
63
|
-
npx thinkpool-pair <ROOM> -- claude
|
|
63
|
+
npx thinkpool-pair@latest <ROOM> -- claude # structured Claude, no TTY needed
|
|
64
64
|
```
|
|
65
65
|
|
|
66
66
|
- **Auth**: set `ANTHROPIC_API_KEY` (or `CLAUDE_CODE_OAUTH_TOKEN`) in the box's
|
|
67
67
|
env — the Agent SDK reads it automatically. The structured path runs the SDK's
|
|
68
68
|
bundled `claude`, so you don't need Claude Code separately installed.
|
|
69
|
+
- **Use `@latest`**: a bare `npx thinkpool-pair` reuses npx's local cache and can
|
|
70
|
+
run a stale version across restarts. `@latest` (and the service below) always
|
|
71
|
+
re-resolve the newest publish.
|
|
69
72
|
- **No TTY required**: the agent picker auto-selects and raw-mode is skipped when
|
|
70
73
|
there's no terminal, so it runs fine under CI / a service / `nohup`.
|
|
71
|
-
- **Always-on**: `npx thinkpool-pair install-service <ROOM> -- claude`
|
|
72
|
-
systemd `--user` unit (auto-restart + boot-persistent
|
|
73
|
-
|
|
74
|
+
- **Always-on + auto-update**: `npx thinkpool-pair@latest install-service <ROOM> -- claude`
|
|
75
|
+
installs a systemd `--user` unit (auto-restart + boot-persistent). The bridge
|
|
76
|
+
polls npm for new publishes and **self-restarts at the next idle moment** into
|
|
77
|
+
the new version — the structured session resumes, so an away/unattended box
|
|
78
|
+
upgrades itself with nobody losing their place. On a server, also run
|
|
79
|
+
`loginctl enable-linger $USER` so it survives logout.
|
|
80
|
+
Tune with `THINKPOOL_PAIR_UPDATE_INTERVAL` (poll seconds, default 1800) and
|
|
81
|
+
`THINKPOOL_PAIR_UPDATE_IDLE` (idle seconds before restart, default 90). Live
|
|
82
|
+
auto-update applies to the launchd/systemd service tiers; bare `--supervise`
|
|
83
|
+
respawns the same version, so it updates only on the next manual/boot restart.
|
|
74
84
|
- **Security**: anyone with the room code can drive the agent (shell-trust by
|
|
75
85
|
design) — on a server that's a real shell, so treat the room code like a secret.
|
|
76
86
|
|
package/bridge.mjs
CHANGED
|
@@ -260,7 +260,14 @@ let shuttingDown = false
|
|
|
260
260
|
// straight into the host's ATTACHED terminal, shredding the TUI
|
|
261
261
|
// (2026-06-11). Same delivery, explicit path, no warning: httpSend when
|
|
262
262
|
// the socket is down. Loss on total offline is fine — scrollback replays.
|
|
263
|
+
// Activity clock — the most recent moment the room was doing something (agent
|
|
264
|
+
// output or a human driving). Read by the auto-update poll so an update restart
|
|
265
|
+
// only happens when idle, never mid-turn.
|
|
266
|
+
let lastActivity = Date.now()
|
|
267
|
+
const markActivity = () => { lastActivity = Date.now() }
|
|
268
|
+
|
|
263
269
|
const bcast = (event, payload) => {
|
|
270
|
+
if (event === 'pty-out' || event === 'code-event') lastActivity = Date.now()
|
|
264
271
|
try {
|
|
265
272
|
if (channel.channelAdapter?.canPush?.() ?? true) {
|
|
266
273
|
channel.send({ type: 'broadcast', event, payload })
|
|
@@ -486,6 +493,7 @@ let brokenSince = Date.now()
|
|
|
486
493
|
channel
|
|
487
494
|
.on('broadcast', { event: 'pty-in' }, ({ payload }) => {
|
|
488
495
|
if (!payload?.data) return
|
|
496
|
+
markActivity()
|
|
489
497
|
const t = terms.get(payload.term) || (payload.term == null && attachedId ? terms.get(attachedId) : null)
|
|
490
498
|
if (t) t.term.write(payload.data)
|
|
491
499
|
})
|
|
@@ -569,6 +577,7 @@ channel
|
|
|
569
577
|
.on('broadcast', { event: 'code-turn' }, ({ payload }) => {
|
|
570
578
|
const s = payload?.term && sessions.get(payload.term)
|
|
571
579
|
if (!s || payload.text == null) return
|
|
580
|
+
markActivity()
|
|
572
581
|
const text = String(payload.text)
|
|
573
582
|
// Echo the turn so BOTH readers (and late joiners, via the log) show who
|
|
574
583
|
// said what — UNLESS silent (an @pool synthesis, which renders its own line).
|
|
@@ -578,16 +587,27 @@ channel
|
|
|
578
587
|
s.log.push(evt); if (s.log.length > STRUCTURED_LOG_MAX) s.log.shift()
|
|
579
588
|
bcast('code-event', { term: payload.term, evt })
|
|
580
589
|
}
|
|
581
|
-
//
|
|
582
|
-
//
|
|
583
|
-
//
|
|
584
|
-
//
|
|
590
|
+
// Session controls (/model, /clear) are NOT agent turns — they change the
|
|
591
|
+
// shared session config. Emitting a `you` line for them strands the reader's
|
|
592
|
+
// inferred "working…" state (no turn → no result to clear it; the old bug).
|
|
593
|
+
// Instead announce a ◆ control line both readers see. /compact + any other
|
|
594
|
+
// slash still flow through as a real, self-terminating turn.
|
|
595
|
+
const ctlLine = (ctlText) => {
|
|
596
|
+
const evt = { kind: 'control', text: ctlText, by: payload.by, cid: payload.cid }
|
|
597
|
+
s.log.push(evt); if (s.log.length > STRUCTURED_LOG_MAX) s.log.shift()
|
|
598
|
+
bcast('code-event', { term: payload.term, evt })
|
|
599
|
+
}
|
|
585
600
|
const mm = text.match(/^\/model\b\s*(\S+)?/)
|
|
586
|
-
if (mm) {
|
|
601
|
+
if (mm) {
|
|
602
|
+
if (mm[1]) { s.session.setModel(mm[1]); ctlLine(`switched model → ${mm[1]}`) }
|
|
603
|
+
else { ctlLine('use /model <name> to switch — e.g. opus, sonnet, haiku') }
|
|
604
|
+
return
|
|
605
|
+
}
|
|
587
606
|
if (/^\/clear\s*$/.test(text)) {
|
|
588
|
-
|
|
607
|
+
s.session.sendTurn(text)
|
|
589
608
|
s.log = []; bcast('code-event', { term: payload.term, evt: { kind: 'clear' } })
|
|
590
609
|
flushSession(room, payload.term, { sessionId: s.session?.sessionId || null, log: [] })
|
|
610
|
+
ctlLine('context cleared. You can continue with these answers in mind.')
|
|
591
611
|
return
|
|
592
612
|
}
|
|
593
613
|
s.session.sendTurn(text)
|
|
@@ -652,6 +672,61 @@ setInterval(() => {
|
|
|
652
672
|
}
|
|
653
673
|
}, 15000).unref()
|
|
654
674
|
|
|
675
|
+
// ── auto-update (OS-service tier only) ─────────────────────────────────
|
|
676
|
+
// launchd (KeepAlive) and systemd (Restart=always) re-run
|
|
677
|
+
// `npx -y thinkpool-pair@latest` on every (re)start, so a clean exit here makes
|
|
678
|
+
// the supervisor pull the newest publish; session-store then resumes the
|
|
679
|
+
// session, so the update is invisible to both people in the room. Gated to
|
|
680
|
+
// THINKPOOL_PAIR_AUTOUPDATE=1 (set by the service installer) because under bare
|
|
681
|
+
// `--supervise` the supervisor respawns the SAME version (exit 0 there just
|
|
682
|
+
// stops it) — self-exiting would loop or kill the bridge, not update it.
|
|
683
|
+
// THINKPOOL_PAIR_UPDATE_INTERVAL registry poll seconds (default 1800)
|
|
684
|
+
// THINKPOOL_PAIR_UPDATE_IDLE idle seconds before a restart (default 90)
|
|
685
|
+
if (process.env.THINKPOOL_PAIR_AUTOUPDATE === '1' && VERSION) {
|
|
686
|
+
const POLL_MS = Math.max(60, parseInt(process.env.THINKPOOL_PAIR_UPDATE_INTERVAL, 10) || 1800) * 1000
|
|
687
|
+
const IDLE_MS = Math.max(10, parseInt(process.env.THINKPOOL_PAIR_UPDATE_IDLE, 10) || 90) * 1000
|
|
688
|
+
let pendingUpdate = null // the newer version string, once seen
|
|
689
|
+
|
|
690
|
+
const isIdle = () => {
|
|
691
|
+
if (Date.now() - lastActivity < IDLE_MS) return false
|
|
692
|
+
for (const t of terms.values()) if (t.buf) return false // bytes mid-flush
|
|
693
|
+
return true
|
|
694
|
+
}
|
|
695
|
+
// Newer? Compare the numeric x.y.z core; ignore prerelease tails to stay safe.
|
|
696
|
+
const isNewer = (latest, cur) => {
|
|
697
|
+
const core = (v) => String(v).split('-')[0].split('.').map((n) => parseInt(n, 10) || 0)
|
|
698
|
+
const a = core(latest), b = core(cur)
|
|
699
|
+
for (let i = 0; i < 3; i++) { if ((a[i] || 0) > (b[i] || 0)) return true; if ((a[i] || 0) < (b[i] || 0)) return false }
|
|
700
|
+
return false
|
|
701
|
+
}
|
|
702
|
+
const fetchLatest = async () => {
|
|
703
|
+
const ctrl = new AbortController()
|
|
704
|
+
const to = setTimeout(() => ctrl.abort(), 8000)
|
|
705
|
+
try {
|
|
706
|
+
const r = await fetch('https://registry.npmjs.org/thinkpool-pair/latest', { signal: ctrl.signal, headers: { accept: 'application/json' } })
|
|
707
|
+
if (!r.ok) return null
|
|
708
|
+
const j = await r.json()
|
|
709
|
+
return typeof j?.version === 'string' ? j.version : null
|
|
710
|
+
} catch { return null } finally { clearTimeout(to) }
|
|
711
|
+
}
|
|
712
|
+
const applyIfIdle = () => {
|
|
713
|
+
if (!pendingUpdate || !isIdle()) return
|
|
714
|
+
process.stderr.write(`\n ◆ thinkpool-pair ${pendingUpdate} ready (running ${VERSION}) — restarting to update; the session resumes.\n`)
|
|
715
|
+
process.exit(0) // service restarts with @latest; session-store resumes
|
|
716
|
+
}
|
|
717
|
+
const check = async () => {
|
|
718
|
+
const latest = await fetchLatest()
|
|
719
|
+
if (latest && isNewer(latest, VERSION)) {
|
|
720
|
+
if (!pendingUpdate) process.stderr.write(`\n ◆ thinkpool-pair ${latest} published — will restart at the next idle moment.\n`)
|
|
721
|
+
pendingUpdate = latest
|
|
722
|
+
applyIfIdle()
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
setInterval(check, POLL_MS).unref() // poll the registry
|
|
726
|
+
setInterval(applyIfIdle, 15000).unref() // once pending, land it as soon as idle
|
|
727
|
+
setTimeout(check, 60000).unref() // first check after the session settles
|
|
728
|
+
}
|
|
729
|
+
|
|
655
730
|
function shutdown() {
|
|
656
731
|
if (shuttingDown) return
|
|
657
732
|
shuttingDown = true
|
package/package.json
CHANGED
package/service.mjs
CHANGED
|
@@ -51,7 +51,7 @@ export function buildArtifact(platform, { room, cmdArgs = [] }) {
|
|
|
51
51
|
<key>WorkingDirectory</key><string>${xml(cwd)}</string>
|
|
52
52
|
<key>StandardOutPath</key><string>${xml(log)}</string>
|
|
53
53
|
<key>StandardErrorPath</key><string>${xml(log)}</string>
|
|
54
|
-
<key>EnvironmentVariables</key><dict><key>PATH</key><string>${xml(process.env.PATH || '')}</string></dict>
|
|
54
|
+
<key>EnvironmentVariables</key><dict><key>PATH</key><string>${xml(process.env.PATH || '')}</string><key>THINKPOOL_PAIR_AUTOUPDATE</key><string>1</string></dict>
|
|
55
55
|
</dict></plist>\n`
|
|
56
56
|
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.' }
|
|
57
57
|
}
|
|
@@ -69,6 +69,7 @@ Restart=always
|
|
|
69
69
|
RestartSec=2
|
|
70
70
|
WorkingDirectory=${cwd}
|
|
71
71
|
Environment=PATH=${process.env.PATH || ''}
|
|
72
|
+
Environment=THINKPOOL_PAIR_AUTOUPDATE=1
|
|
72
73
|
StandardOutput=append:${log}
|
|
73
74
|
StandardError=append:${log}
|
|
74
75
|
|
|
@@ -101,7 +102,10 @@ export function installService(room, cmdArgs = []) {
|
|
|
101
102
|
for (const cmd of a.post) {
|
|
102
103
|
try { execSync(cmd, { stdio: 'inherit' }) } catch (e) { process.stderr.write(` ⚠ "${cmd}" failed: ${e.message}\n`) }
|
|
103
104
|
}
|
|
104
|
-
|
|
105
|
+
const autoUpdate = process.platform === 'win32'
|
|
106
|
+
? 'tracks thinkpool-pair@latest — new versions apply on next login/restart.'
|
|
107
|
+
: 'tracks thinkpool-pair@latest — checks for new versions and self-restarts at the next idle moment (session resumes), no re-install.'
|
|
108
|
+
process.stderr.write(` ◆ ${a.note}\n ◆ ${autoUpdate}\n ◆ logs: ${path.join(a.logDir, `${room}.log`)}\n ◆ remove with: thinkpool-pair uninstall-service ${room}\n\n`)
|
|
105
109
|
}
|
|
106
110
|
|
|
107
111
|
export function uninstallService(room) {
|