thinkpool-pair 0.6.15 → 0.6.17
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 +64 -0
- package/claude-session.mjs +17 -0
- 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).
|
|
@@ -652,6 +661,61 @@ setInterval(() => {
|
|
|
652
661
|
}
|
|
653
662
|
}, 15000).unref()
|
|
654
663
|
|
|
664
|
+
// ── auto-update (OS-service tier only) ─────────────────────────────────
|
|
665
|
+
// launchd (KeepAlive) and systemd (Restart=always) re-run
|
|
666
|
+
// `npx -y thinkpool-pair@latest` on every (re)start, so a clean exit here makes
|
|
667
|
+
// the supervisor pull the newest publish; session-store then resumes the
|
|
668
|
+
// session, so the update is invisible to both people in the room. Gated to
|
|
669
|
+
// THINKPOOL_PAIR_AUTOUPDATE=1 (set by the service installer) because under bare
|
|
670
|
+
// `--supervise` the supervisor respawns the SAME version (exit 0 there just
|
|
671
|
+
// stops it) — self-exiting would loop or kill the bridge, not update it.
|
|
672
|
+
// THINKPOOL_PAIR_UPDATE_INTERVAL registry poll seconds (default 1800)
|
|
673
|
+
// THINKPOOL_PAIR_UPDATE_IDLE idle seconds before a restart (default 90)
|
|
674
|
+
if (process.env.THINKPOOL_PAIR_AUTOUPDATE === '1' && VERSION) {
|
|
675
|
+
const POLL_MS = Math.max(60, parseInt(process.env.THINKPOOL_PAIR_UPDATE_INTERVAL, 10) || 1800) * 1000
|
|
676
|
+
const IDLE_MS = Math.max(10, parseInt(process.env.THINKPOOL_PAIR_UPDATE_IDLE, 10) || 90) * 1000
|
|
677
|
+
let pendingUpdate = null // the newer version string, once seen
|
|
678
|
+
|
|
679
|
+
const isIdle = () => {
|
|
680
|
+
if (Date.now() - lastActivity < IDLE_MS) return false
|
|
681
|
+
for (const t of terms.values()) if (t.buf) return false // bytes mid-flush
|
|
682
|
+
return true
|
|
683
|
+
}
|
|
684
|
+
// Newer? Compare the numeric x.y.z core; ignore prerelease tails to stay safe.
|
|
685
|
+
const isNewer = (latest, cur) => {
|
|
686
|
+
const core = (v) => String(v).split('-')[0].split('.').map((n) => parseInt(n, 10) || 0)
|
|
687
|
+
const a = core(latest), b = core(cur)
|
|
688
|
+
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 }
|
|
689
|
+
return false
|
|
690
|
+
}
|
|
691
|
+
const fetchLatest = async () => {
|
|
692
|
+
const ctrl = new AbortController()
|
|
693
|
+
const to = setTimeout(() => ctrl.abort(), 8000)
|
|
694
|
+
try {
|
|
695
|
+
const r = await fetch('https://registry.npmjs.org/thinkpool-pair/latest', { signal: ctrl.signal, headers: { accept: 'application/json' } })
|
|
696
|
+
if (!r.ok) return null
|
|
697
|
+
const j = await r.json()
|
|
698
|
+
return typeof j?.version === 'string' ? j.version : null
|
|
699
|
+
} catch { return null } finally { clearTimeout(to) }
|
|
700
|
+
}
|
|
701
|
+
const applyIfIdle = () => {
|
|
702
|
+
if (!pendingUpdate || !isIdle()) return
|
|
703
|
+
process.stderr.write(`\n ◆ thinkpool-pair ${pendingUpdate} ready (running ${VERSION}) — restarting to update; the session resumes.\n`)
|
|
704
|
+
process.exit(0) // service restarts with @latest; session-store resumes
|
|
705
|
+
}
|
|
706
|
+
const check = async () => {
|
|
707
|
+
const latest = await fetchLatest()
|
|
708
|
+
if (latest && isNewer(latest, VERSION)) {
|
|
709
|
+
if (!pendingUpdate) process.stderr.write(`\n ◆ thinkpool-pair ${latest} published — will restart at the next idle moment.\n`)
|
|
710
|
+
pendingUpdate = latest
|
|
711
|
+
applyIfIdle()
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
setInterval(check, POLL_MS).unref() // poll the registry
|
|
715
|
+
setInterval(applyIfIdle, 15000).unref() // once pending, land it as soon as idle
|
|
716
|
+
setTimeout(check, 60000).unref() // first check after the session settles
|
|
717
|
+
}
|
|
718
|
+
|
|
655
719
|
function shutdown() {
|
|
656
720
|
if (shuttingDown) return
|
|
657
721
|
shuttingDown = true
|
package/claude-session.mjs
CHANGED
|
@@ -123,6 +123,7 @@ export function startClaudeSession({ cwd, model, resume, onEvent, requestPermiss
|
|
|
123
123
|
let mode = 'default' // mirrors Claude Code's ⇧⇥ cycle
|
|
124
124
|
const alwaysAllow = new Set() // tool:risk signatures the user chose "don't ask again" for
|
|
125
125
|
const toolStart = new Map() // tool_use id → start time, for the duration badge
|
|
126
|
+
let effort = null // active reasoning effort for the turn (from the hook input)
|
|
126
127
|
|
|
127
128
|
const emit = (evt) => { try { onEvent?.(evt) } catch { /* never let a consumer throw into the loop */ } }
|
|
128
129
|
|
|
@@ -133,6 +134,11 @@ export function startClaudeSession({ cwd, model, resume, onEvent, requestPermiss
|
|
|
133
134
|
const preTool = async (hookInput) => {
|
|
134
135
|
const toolName = hookInput.tool_name
|
|
135
136
|
const toolInput = hookInput.tool_input
|
|
137
|
+
// The hook input carries the turn's active reasoning effort (post any
|
|
138
|
+
// model downgrade) — the real signal for "thinking with <X> effort".
|
|
139
|
+
// Surface it to the room when it changes; absent on models without effort.
|
|
140
|
+
const lvl = hookInput.effort?.level
|
|
141
|
+
if (lvl && lvl !== effort) { effort = lvl; emit({ kind: 'effort', level: effort }) }
|
|
136
142
|
// ── Plan approval — ExitPlanMode is how the agent presents its plan in
|
|
137
143
|
// plan mode. Render a dedicated plan card (not the generic perm card) with
|
|
138
144
|
// three outcomes: run (exit → default), accept (exit → acceptEdits), keep
|
|
@@ -207,6 +213,10 @@ export function startClaudeSession({ cwd, model, resume, onEvent, requestPermiss
|
|
|
207
213
|
abortController: ac,
|
|
208
214
|
permissionMode: 'default',
|
|
209
215
|
hooks: { PreToolUse: [{ hooks: [preTool] }] },
|
|
216
|
+
// Needed for live thinking-token progress (SDKThinkingTokensMessage) to
|
|
217
|
+
// flow during a turn. We ignore the fine-grained stream_event partials in
|
|
218
|
+
// the loop; only the coarse thinking_tokens system message is surfaced.
|
|
219
|
+
includePartialMessages: true,
|
|
210
220
|
}
|
|
211
221
|
if (cwd) opts.cwd = cwd
|
|
212
222
|
if (model) opts.model = model
|
|
@@ -219,6 +229,13 @@ export function startClaudeSession({ cwd, model, resume, onEvent, requestPermiss
|
|
|
219
229
|
if (closed) break
|
|
220
230
|
switch (m.type) {
|
|
221
231
|
case 'system':
|
|
232
|
+
// Live thinking-token progress — the running estimate while the
|
|
233
|
+
// model reasons, surfaced as the indicator's ↓ N tokens. Coarse,
|
|
234
|
+
// emitted during extended thinking; not a per-token stream.
|
|
235
|
+
if (m.subtype === 'thinking_tokens') {
|
|
236
|
+
emit({ kind: 'thinking_tokens', tokens: m.estimated_tokens, delta: m.estimated_tokens_delta })
|
|
237
|
+
break
|
|
238
|
+
}
|
|
222
239
|
if (m.session_id) sessionId = m.session_id
|
|
223
240
|
emit({ kind: 'system', sessionId, model: m.model || model || null })
|
|
224
241
|
break
|
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) {
|