thinkpool-pair 0.7.77 → 0.7.79

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 CHANGED
@@ -32,7 +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
+ import { spawn, execSync } from 'node:child_process'
36
36
  import { fileURLToPath } from 'node:url'
37
37
  import { randomUUID } from 'node:crypto'
38
38
  import { createClient } from '@supabase/supabase-js'
@@ -213,12 +213,22 @@ if ((!argv[0] || argv[0] === 'setup') && process.stdin.isTTY) {
213
213
  installService: ({ room = null, agentCmd } = {}) => { svc.installService(room, agentCmd ? [agentCmd] : []) },
214
214
  uninstallService: ({ room = null } = {}) => { svc.uninstallService(room) },
215
215
  restartService: ({ room = null } = {}) => { svc.restartService(room) },
216
- // "Restart & update the bridge" — re-pin the OS service to THIS launcher's version
217
- // (you launched via @latest, so it's the newest publish) and restart: the service is
218
- // reinstalled (rewrites the plist/unit to the new version and reloads). Pinned — no auto-roll.
216
+ // "Restart & update the bridge" — resolve the NEWEST PUBLISHED version from npm and
217
+ // re-pin the OS service to it (stale-proof: the rewritten launch wipes the npx cache
218
+ // each start so it can't re-serve an old build). We must NOT trust PKG_VERSION here:
219
+ // npx can run a STALE cached launcher, so this very menu may be an old version — that's
220
+ // the bug that left the bridge stuck on 0.7.75. `npm view` hits the registry live, so it
221
+ // updates correctly even from a stale launcher. Pinned to the resolved number — no auto-roll.
219
222
  restartUpdateService: ({ room = null } = {}) => {
220
- process.stderr.write(`\n ◆ updating the background service to v${PKG_VERSION} and restarting…\n`)
221
- svc.installService(room, []) // rewrites the plist/unit at this version + reloads = update + restart
223
+ let target = null
224
+ try { target = execSync('npm view thinkpool-pair version', { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim() } catch { /* offline / no npm */ }
225
+ if (!target) {
226
+ target = PKG_VERSION
227
+ process.stderr.write(`\n ⚠ couldn't reach npm to resolve the newest version — re-pinning this launcher's v${PKG_VERSION} (stale-proof) and restarting…\n`)
228
+ } else {
229
+ process.stderr.write(`\n ◆ updating the background service to v${target} (newest published) and restarting…\n`)
230
+ }
231
+ svc.installService(room, [], { version: target, staleProof: true }) // rewrites plist/unit at resolved version + reloads = update + restart
222
232
  },
223
233
  login: async () => { const { runLogin } = await import('./account.mjs'); await runLogin(SUPABASE_URL, SUPABASE_ANON, WEB_BASE) },
224
234
  // Provider config is NOT terminal — it writes provider.json and returns to the
@@ -177,6 +177,21 @@ export function startClaudeSession({ cwd, model, resume, env, mode: initialMode
177
177
  // the catch self-heals.
178
178
  const FORCE_STOP_MS = Math.max(STALL_MS * 3, parseInt(process.env.TP_FORCE_STOP_MS, 10) || 300000)
179
179
  let forceStopped = false
180
+ // Set while an interrupt is settling. q.interrupt() doesn't end the turn cleanly on this
181
+ // SDK — it surfaces a REDUNDANT pair of terminal results (subtype `aborted` AND
182
+ // `error_during_execution`) and re-inits the session. abort() emits the single canonical
183
+ // aborted boundary itself; this flag tells the result handler to swallow the SDK's extra
184
+ // teardown results so the web never sees a second turn-end. Without it, the web suppresses
185
+ // only the first (isAbortEcho) and the second buried the flushed steer — "Stop injects the
186
+ // queued message but nothing happens". Cleared by the first real (non-teardown) result.
187
+ // Verified: bridge/test-steer-after-abort.mjs (Maks 2026-06-26). The teardown results
188
+ // arrive async and can interleave with the NEXT turn (a late error_during_execution can
189
+ // land after the steer's own output), so the window is bounded by a timer rather than
190
+ // cleared on the next result — "all teardown has arrived" isn't observable. `success`
191
+ // results are never swallowed, so the flushed steer's completion always surfaces.
192
+ let interrupting = false
193
+ let interruptTimer = null
194
+ const INTERRUPT_SWALLOW_MS = 6000
180
195
  // ── Haiku suggestion fallback ──
181
196
  // Claude Code's own `prompt_suggestion` rarely fires in /code — the binary suppresses it
182
197
  // per-turn (cache_cold when a turn's tokens > 10k, rate_limit when throttled, first turn).
@@ -504,6 +519,11 @@ export function startClaudeSession({ cwd, model, resume, env, mode: initialMode
504
519
  }
505
520
  case 'result':
506
521
  if (m.session_id) sessionId = m.session_id
522
+ // Swallow the interrupt's redundant teardown results (see `interrupting` above):
523
+ // abort() already emitted the one canonical aborted boundary. `success` (and any
524
+ // non-teardown subtype) always emits; only the aborted/error_during_execution pair
525
+ // the interrupt churns out is dropped, and only inside the timer-bounded window.
526
+ if (interrupting && (m.subtype === 'aborted' || m.subtype === 'error_during_execution')) { turnActive = false; break }
507
527
  turnBaseOut = 0; curMsgOut = 0 // reset the live token count for the next turn
508
528
  turnActive = false // turn settled → stall watchdog stands down
509
529
  restartCount = 0 // a successful turn refills the auto-restart budget
@@ -590,6 +610,15 @@ export function startClaudeSession({ cwd, model, resume, env, mode: initialMode
590
610
  // Graceful interrupt (Esc / Stop) — stops the current turn but keeps the
591
611
  // session alive for the next one. ac.abort() is teardown only (end()).
592
612
  async abort() {
613
+ // Swallow the SDK's redundant interrupt teardown (aborted + error_during_execution +
614
+ // re-init) — we emit the one canonical boundary below. Set BEFORE interrupt so the
615
+ // teardown results, which arrive async on the query loop, are caught; the timer bounds
616
+ // the window so a genuine later turn failure still surfaces.
617
+ if (turnActive) {
618
+ interrupting = true
619
+ clearTimeout(interruptTimer)
620
+ interruptTimer = setTimeout(() => { interrupting = false }, INTERRUPT_SWALLOW_MS)
621
+ }
593
622
  try { await q?.interrupt?.() } catch { /* noop */ }
594
623
  // interrupt() stops the turn but emits NO terminal message. Without one the
595
624
  // struct stream ends on a non-terminal event, so on resume the SDK treats the
@@ -599,7 +628,7 @@ export function startClaudeSession({ cwd, model, resume, env, mode: initialMode
599
628
  // SDK already surfaced one for the interrupt. (restored from 0.7.49)
600
629
  if (turnActive) { turnActive = false; emit({ kind: 'result', subtype: 'aborted', sessionId }) }
601
630
  },
602
- end() { closed = true; clearInterval(stallTimer); input.end(); try { ac.abort() } catch { /* noop */ } },
631
+ end() { closed = true; clearInterval(stallTimer); clearTimeout(interruptTimer); input.end(); try { ac.abort() } catch { /* noop */ } },
603
632
  get sessionId() { return sessionId },
604
633
  get mode() { return mode },
605
634
  get turnActive() { return turnActive }, // a turn is in flight (gates between-turns update restart — Slice 3 Contract #1)
package/launcher.mjs CHANGED
@@ -18,6 +18,11 @@ import { execSync } from 'node:child_process'
18
18
  const HOME = os.homedir()
19
19
  const CFG_DIR = path.join(HOME, '.thinkpool-pair')
20
20
 
21
+ // Version of THIS running launcher (read from the package it shipped with). Shown in the
22
+ // header so the running build is visible — and so a stale npx cache is obvious at a glance
23
+ // (header says 0.7.75 while npm @latest is 0.7.78 → time to "Restart & update the bridge").
24
+ const VERSION = (() => { try { return JSON.parse(fs.readFileSync(new URL('./package.json', import.meta.url), 'utf8')).version } catch { return null } })()
25
+
21
26
  export const KNOWN_AGENTS = [
22
27
  { label: 'Claude Code', cmd: 'claude', resume: ['--continue'] },
23
28
  { label: 'Codex CLI', cmd: 'codex' },
@@ -75,6 +80,7 @@ export function detectState() {
75
80
  agents: KNOWN_AGENTS.filter(a => onPath(a.cmd)),
76
81
  accountSvc: serviceLoaded(null),
77
82
  cwd: process.cwd(),
83
+ version: VERSION,
78
84
  }
79
85
  }
80
86
 
@@ -103,7 +109,7 @@ export async function runLauncher({ actions, io, state = detectState(), refresh
103
109
 
104
110
  const header = () => {
105
111
  io.print('')
106
- io.print(' ' + C.dim('┌ ') + C.bold('thinkpool-pair') + C.dim(' ' + '─'.repeat(40)))
112
+ io.print(' ' + C.dim('┌ ') + C.bold('thinkpool-pair') + (state.version ? C.dim(` v${state.version}`) : '') + C.dim(' ' + '─'.repeat(state.version ? 40 - (` v${state.version}`).length : 40)))
107
113
  io.print(` ${C.dim('│ account ')} ${state.loggedIn ? C.green((state.email || 'linked') + ' ✓') : C.yellow('not linked')}`)
108
114
  io.print(` ${C.dim('│ provider ')} ${state.provider}`)
109
115
  io.print(` ${C.dim('│ agents ')} ${state.agents.length ? state.agents.map(a => a.label).join(', ') : C.yellow('none on PATH')}`)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thinkpool-pair",
3
- "version": "0.7.77",
3
+ "version": "0.7.79",
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": {
package/service.mjs CHANGED
@@ -27,6 +27,14 @@ import { execSync } from 'node:child_process'
27
27
  const label = (room) => room ? `io.thinkpool.pair.${room.toLowerCase()}` : 'io.thinkpool.pair.account'
28
28
  const slug = (room) => room || 'account'
29
29
  const xml = (s) => String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
30
+ const shq = (s) => `'${String(s).replace(/'/g, `'\\''`)}'`
31
+ // Stale-proof launch wrapper: wipe any cached thinkpool-pair in the npx store, then exec
32
+ // the pinned package FRESH. Folds update-local-service.sh into the builder — without this a
33
+ // deliberate "Restart & update" (or any KeepAlive restart) can re-serve a stale npx cache,
34
+ // which is what left Max's account bridge stuck on 0.7.75 while @latest was 0.7.78.
35
+ // `home` is the platform-correct, runtime-expanded home token ("$HOME" on darwin under
36
+ // /bin/bash; "$$HOME" inside a systemd unit so systemd emits a literal $HOME to bash).
37
+ const cacheWipe = (home) => `rm -rf ${home}/.npm/_npx/*/node_modules/thinkpool-pair 2>/dev/null`
30
38
 
31
39
  // The version installing the service. By DEFAULT the service is pinned to this exact
32
40
  // version (not @latest) so a future bad npm publish can't auto-roll to every machine's
@@ -46,7 +54,7 @@ function npxPath(platform) {
46
54
 
47
55
  // Pure artifact builder — returned shape is testable without side effects.
48
56
  // room falsy → ACCOUNT service (bare `thinkpool-pair`, auto-serves all sessions).
49
- export function buildArtifact(platform, { room = null, cmdArgs = [], autoUpdate = false } = {}) {
57
+ export function buildArtifact(platform, { room = null, cmdArgs = [], autoUpdate = false, version = VERSION, staleProof = false } = {}) {
50
58
  const npx = npxPath(platform)
51
59
  const cwd = process.cwd()
52
60
  const id = slug(room)
@@ -58,14 +66,18 @@ export function buildArtifact(platform, { room = null, cmdArgs = [], autoUpdate
58
66
  // PINNED by default to `@${VERSION}` so a KeepAlive restart re-runs the SAME known-good
59
67
  // version — a bad publish can't auto-roll here. `autoUpdate` opts into `@latest` tracking
60
68
  // (and the in-process self-updater via THINKPOOL_PAIR_AUTOUPDATE below).
61
- const verSpec = autoUpdate ? 'thinkpool-pair@latest' : `thinkpool-pair@${VERSION}`
69
+ const verSpec = autoUpdate ? 'thinkpool-pair@latest' : `thinkpool-pair@${version}`
62
70
  const pkgArgs = room ? ['-y', verSpec, room] : ['-y', verSpec]
63
71
  // In-process self-update (account.mjs / bridge.mjs) is gated on this env — only on when
64
72
  // the user explicitly opted into auto-update.
65
73
  const autoUpdateEnv = autoUpdate ? { THINKPOOL_PAIR_AUTOUPDATE: '1' } : {}
66
74
 
67
75
  if (platform === 'darwin') {
68
- const args = [npx, ...pkgArgs, ...tail] // launchd KeepAlive supervises
76
+ // launchd KeepAlive supervises. staleProof → wrap in a cache-wiping /bin/bash launch so
77
+ // every (re)start re-fetches the pinned version fresh (deliberate-update path).
78
+ const args = staleProof
79
+ ? ['/bin/bash', '-lc', `${cacheWipe('"$HOME"')}; exec ${[npx, ...pkgArgs, ...tail].map(shq).join(' ')}`]
80
+ : [npx, ...pkgArgs, ...tail]
69
81
  const file = path.join(os.homedir(), 'Library', 'LaunchAgents', `${label(room)}.plist`)
70
82
  const content = `<?xml version="1.0" encoding="UTF-8"?>
71
83
  <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
@@ -102,12 +114,17 @@ export function buildArtifact(platform, { room = null, cmdArgs = [], autoUpdate
102
114
  if (platform === 'linux') {
103
115
  const args = [npx, ...pkgArgs, ...tail] // systemd Restart=always supervises
104
116
  const file = path.join(os.homedir(), '.config', 'systemd', 'user', `${label(room)}.service`)
117
+ // staleProof → cache-wiping /bin/bash launch (same intent as darwin). `$$HOME` so systemd
118
+ // emits a literal $HOME to bash rather than expanding it itself (empty + warning).
119
+ const execStart = staleProof
120
+ ? `/bin/bash -lc "${cacheWipe('$$HOME')}; exec ${[npx, ...pkgArgs, ...tail].join(' ')}"`
121
+ : args.map((a) => `'${a.replace(/'/g, "'\\''")}'`).join(' ')
105
122
  const content = `[Unit]
106
123
  Description=ThinkPool Code ${desc}
107
124
  After=network-online.target
108
125
 
109
126
  [Service]
110
- ExecStart=${args.map((a) => `'${a.replace(/'/g, "'\\''")}'`).join(' ')}
127
+ ExecStart=${execStart}
111
128
  Restart=always
112
129
  RestartSec=2
113
130
  WorkingDirectory=${cwd}
@@ -138,8 +155,14 @@ WantedBy=default.target
138
155
  throw new Error(`unsupported platform: ${platform}`)
139
156
  }
140
157
 
141
- export function installService(room, cmdArgs = [], { autoUpdate = false } = {}) {
142
- const a = buildArtifact(process.platform, { room, cmdArgs, autoUpdate })
158
+ // staleProof defaults ON for every real install: even a FIRST install can be launched from
159
+ // a stale npx cache (the launcher itself is npx-resolved), so without the cache-wiping
160
+ // wrapper a fresh `install-service` could pin a stale build just like the update path did.
161
+ // Pinning still holds (we pin a fixed version number) — staleProof only guarantees that
162
+ // pinned version is fetched fresh on every (re)start. buildArtifact keeps staleProof=false
163
+ // so its unit tests assert the pure pinned shape.
164
+ export function installService(room, cmdArgs = [], { autoUpdate = false, version = VERSION, staleProof = true } = {}) {
165
+ const a = buildArtifact(process.platform, { room, cmdArgs, autoUpdate, version, staleProof })
143
166
  fs.mkdirSync(a.logDir, { recursive: true })
144
167
  fs.mkdirSync(path.dirname(a.file), { recursive: true })
145
168
  fs.writeFileSync(a.file, a.content)
@@ -155,7 +178,7 @@ export function installService(room, cmdArgs = [], { autoUpdate = false } = {})
155
178
  ? (process.platform === 'win32'
156
179
  ? 'auto-update ON: a new thinkpool-pair@latest applies on the next login/restart (Windows has no in-process self-update).'
157
180
  : `auto-update ON: checks npm ~every 30 min and restarts to apply a new @latest once sessions are idle (≥${idleSecs}s quiet; they resume in place).`)
158
- : `PINNED to ${VERSION} (safe default — a bad publish can't auto-roll to this always-on bridge). To update: re-run install-service from a newer thinkpool-pair. Pass --auto-update to track @latest instead.`
181
+ : `PINNED to ${version}${staleProof ? ' (stale-proof: every restart re-fetches it fresh, never a stale npx cache)' : ''} (safe default — a bad publish can't auto-roll to this always-on bridge). To update: use "Restart & update the bridge" (or re-run install-service from a newer thinkpool-pair). Pass --auto-update to track @latest instead.`
159
182
  const removeArg = room ? ` ${room}` : ''
160
183
  const what = room ? `room ${room}` : 'your account (auto-serves every session)'
161
184
  process.stderr.write(` ◆ ${what}\n ◆ ${a.note}\n ◆ ${updateNote}\n ◆ logs: ${path.join(a.logDir, `${slug(room)}.log`)}\n ◆ remove with: thinkpool-pair uninstall-service${removeArg}\n\n`)