thinkpool-pair 0.7.50 → 0.7.52

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.
Files changed (3) hide show
  1. package/bridge.mjs +29 -1
  2. package/package.json +1 -1
  3. package/service.mjs +28 -16
package/bridge.mjs CHANGED
@@ -136,8 +136,11 @@ if (argv[0] === 'install-service' || argv[0] === 'uninstall-service') {
136
136
  const svcRoom = account ? null : arg1.toUpperCase().trim()
137
137
  const sd = argv.indexOf('--')
138
138
  const svcCmd = sd >= 0 ? argv.slice(sd + 1) : []
139
+ // PINNED to the installed version by default (a bad publish can't auto-roll to the
140
+ // always-on service); --auto-update opts into @latest tracking. (2026-06-22)
141
+ const autoUpdate = argv.includes('--auto-update')
139
142
  const svc = await import('./service.mjs')
140
- if (argv[0] === 'install-service') svc.installService(svcRoom, svcCmd)
143
+ if (argv[0] === 'install-service') svc.installService(svcRoom, svcCmd, { autoUpdate })
141
144
  else svc.uninstallService(svcRoom)
142
145
  process.exit(0)
143
146
  }
@@ -653,6 +656,22 @@ function pushLog(entry, evt) {
653
656
  return evt
654
657
  }
655
658
 
659
+ // Resume-idle guard (2026-06-22 cooked-terminal bug): a turn force-killed mid-flight
660
+ // leaves the restored log ending on in-flight events (assistant/you/tool_result) with
661
+ // NO terminal `result`, so the web reads it as "thinking" forever AND Stop is a no-op
662
+ // (the freshly-resumed SDK session has no live turn to interrupt — q.interrupt() on an
663
+ // idle query does nothing). Mirror of the client working-scan in
664
+ // src/pages/code/structured.jsx: walking back from the end, a result/error/propose/pool
665
+ // closes the turn; an assistant/you/tool_result reached first means it's still open.
666
+ function restoredTurnOpen(log) {
667
+ for (let i = log.length - 1; i >= 0; i--) {
668
+ const k = log[i]?.kind
669
+ if (k === 'result' || k === 'error' || k === 'propose' || k === 'pool') return false
670
+ if (k === 'assistant' || k === 'you' || k === 'tool_result') return true
671
+ }
672
+ return false
673
+ }
674
+
656
675
  // The Phase-2 path: instead of a PTY, run Claude Code through the Agent SDK and
657
676
  // relay STRUCTURED events. onEvent → broadcast `code-event` + print locally +
658
677
  // persist to the host file; tool calls round-trip through the perm card; the
@@ -666,6 +685,15 @@ function openStructured({ id, model, resume, log, commands, mode }) {
666
685
  // event-id.mjs + src/pages/code/seqDedup.js). pushLog() is the single stamp point.
667
686
  entry.seq = makeSeqCounter(maxSeq(entry.log))
668
687
  sessions.set(id, entry)
688
+ // Close an interrupted turn so a restored terminal comes back IDLE-with-history
689
+ // instead of stuck "thinking" (see restoredTurnOpen). Idempotent: the closer is
690
+ // persisted, so a later resume sees a closed turn and no-ops.
691
+ if (entry.log.length && restoredTurnOpen(entry.log)) {
692
+ const closer = { kind: 'result', subtype: 'aborted', recovered: true }
693
+ stampEvent(closer)
694
+ pushLog(entry, closer)
695
+ process.stderr.write(`\n ◆ closed an interrupted turn on resume (${id.slice(0, 8)}) — restored idle.\n`)
696
+ }
669
697
  // This session's private mockup outbox — render.sh writes manifests here (via
670
698
  // TP_MOCKUP_OUTBOX below) and the watcher attributes every card to THIS id, so
671
699
  // they land on this session even after other terminals open/close.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thinkpool-pair",
3
- "version": "0.7.50",
3
+ "version": "0.7.52",
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
@@ -28,6 +28,12 @@ const label = (room) => room ? `io.thinkpool.pair.${room.toLowerCase()}` : 'io.t
28
28
  const slug = (room) => room || 'account'
29
29
  const xml = (s) => String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
30
30
 
31
+ // The version installing the service. By DEFAULT the service is pinned to this exact
32
+ // version (not @latest) so a future bad npm publish can't auto-roll to every machine's
33
+ // always-on bridge — that's what spread the broken 0.7.49 on 2026-06-22. Updates become
34
+ // deliberate (re-run install-service). `--auto-update` opts back into @latest tracking.
35
+ const VERSION = (() => { try { return JSON.parse(fs.readFileSync(new URL('./package.json', import.meta.url), 'utf8')).version } catch { return 'latest' } })()
36
+
31
37
  // Resolve the real npx next to the current node, so the service stays seamless:
32
38
  // it runs `npx -y thinkpool-pair@latest …`, always picking up the newest publish
33
39
  // with no re-install. Absolute path + injected PATH so it works under launchd /
@@ -40,7 +46,7 @@ function npxPath(platform) {
40
46
 
41
47
  // Pure artifact builder — returned shape is testable without side effects.
42
48
  // room falsy → ACCOUNT service (bare `thinkpool-pair`, auto-serves all sessions).
43
- export function buildArtifact(platform, { room = null, cmdArgs = [] } = {}) {
49
+ export function buildArtifact(platform, { room = null, cmdArgs = [], autoUpdate = false } = {}) {
44
50
  const npx = npxPath(platform)
45
51
  const cwd = process.cwd()
46
52
  const id = slug(room)
@@ -48,10 +54,15 @@ export function buildArtifact(platform, { room = null, cmdArgs = [] } = {}) {
48
54
  const log = path.join(logDir, `${id}.log`)
49
55
  const tail = cmdArgs.length ? ['--', ...cmdArgs] : []
50
56
  const desc = room ? `bridge (${room})` : 'account bridge'
51
- // OS-supervised tiers (launchd KeepAlive / systemd Restart) don't need
52
- // --supervise; the latest published bridge is fetched by npx each (re)start.
53
- // Account mode passes NO room arg — that's what selects the account supervisor.
54
- const pkgArgs = room ? ['-y', 'thinkpool-pair@latest', room] : ['-y', 'thinkpool-pair@latest']
57
+ // OS-supervised tiers (launchd KeepAlive / systemd Restart) don't need --supervise.
58
+ // PINNED by default to `@${VERSION}` so a KeepAlive restart re-runs the SAME known-good
59
+ // version — a bad publish can't auto-roll here. `autoUpdate` opts into `@latest` tracking
60
+ // (and the in-process self-updater via THINKPOOL_PAIR_AUTOUPDATE below).
61
+ const verSpec = autoUpdate ? 'thinkpool-pair@latest' : `thinkpool-pair@${VERSION}`
62
+ const pkgArgs = room ? ['-y', verSpec, room] : ['-y', verSpec]
63
+ // In-process self-update (account.mjs / bridge.mjs) is gated on this env — only on when
64
+ // the user explicitly opted into auto-update.
65
+ const autoUpdateEnv = autoUpdate ? { THINKPOOL_PAIR_AUTOUPDATE: '1' } : {}
55
66
 
56
67
  if (platform === 'darwin') {
57
68
  const args = [npx, ...pkgArgs, ...tail] // launchd KeepAlive supervises
@@ -66,7 +77,7 @@ export function buildArtifact(platform, { room = null, cmdArgs = [] } = {}) {
66
77
  <key>WorkingDirectory</key><string>${xml(cwd)}</string>
67
78
  <key>StandardOutPath</key><string>${xml(log)}</string>
68
79
  <key>StandardErrorPath</key><string>${xml(log)}</string>
69
- <key>EnvironmentVariables</key><dict><key>PATH</key><string>${xml(process.env.PATH || '')}</string><key>THINKPOOL_PAIR_AUTOUPDATE</key><string>1</string></dict>
80
+ <key>EnvironmentVariables</key><dict><key>PATH</key><string>${xml(process.env.PATH || '')}</string>${autoUpdate ? '<key>THINKPOOL_PAIR_AUTOUPDATE</key><string>1</string>' : ''}</dict>
70
81
  </dict></plist>\n`
71
82
  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.' }
72
83
  }
@@ -84,8 +95,7 @@ Restart=always
84
95
  RestartSec=2
85
96
  WorkingDirectory=${cwd}
86
97
  Environment=PATH=${process.env.PATH || ''}
87
- Environment=THINKPOOL_PAIR_AUTOUPDATE=1
88
- StandardOutput=append:${log}
98
+ ${autoUpdate ? 'Environment=THINKPOOL_PAIR_AUTOUPDATE=1\n' : ''}StandardOutput=append:${log}
89
99
  StandardError=append:${log}
90
100
 
91
101
  [Install]
@@ -102,8 +112,8 @@ WantedBy=default.target
102
112
  const startup = path.join(process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'), 'Microsoft', 'Windows', 'Start Menu', 'Programs', 'Startup')
103
113
  const file = path.join(startup, `thinkpool-pair-${id}.cmd`)
104
114
  const inner = room
105
- ? ['npx', '-y', 'thinkpool-pair@latest', room, '--supervise', ...tail].join(' ')
106
- : ['npx', '-y', 'thinkpool-pair@latest'].join(' ')
115
+ ? ['npx', '-y', verSpec, room, '--supervise', ...tail].join(' ')
116
+ : ['npx', '-y', verSpec].join(' ')
107
117
  const content = `@echo off\r\ntitle thinkpool-pair ${id}\r\n${inner}\r\n`
108
118
  return { file, content, logDir, post: [], note: 'Installed to the Startup folder — runs at login' + (room ? ' with --supervise (auto-restart on crash).' : ' (account mode).') + ' Start it now without rebooting by double-clicking the .cmd, or run it from a terminal.' }
109
119
  }
@@ -111,8 +121,8 @@ WantedBy=default.target
111
121
  throw new Error(`unsupported platform: ${platform}`)
112
122
  }
113
123
 
114
- export function installService(room, cmdArgs = []) {
115
- const a = buildArtifact(process.platform, { room, cmdArgs })
124
+ export function installService(room, cmdArgs = [], { autoUpdate = false } = {}) {
125
+ const a = buildArtifact(process.platform, { room, cmdArgs, autoUpdate })
116
126
  fs.mkdirSync(a.logDir, { recursive: true })
117
127
  fs.mkdirSync(path.dirname(a.file), { recursive: true })
118
128
  fs.writeFileSync(a.file, a.content)
@@ -124,12 +134,14 @@ export function installService(room, cmdArgs = []) {
124
134
  // roughly every 30 min (THINKPOOL_PAIR_UPDATE_INTERVAL) and only restarts once
125
135
  // every served session is idle, so a just-published version can take a while.
126
136
  const idleSecs = Math.max(10, parseInt(process.env.THINKPOOL_PAIR_UPDATE_IDLE, 10) || 90)
127
- const autoUpdate = process.platform === 'win32'
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).`
137
+ const updateNote = autoUpdate
138
+ ? (process.platform === 'win32'
139
+ ? 'auto-update ON: a new thinkpool-pair@latest applies on the next login/restart (Windows has no in-process self-update).'
140
+ : `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).`)
141
+ : `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.`
130
142
  const removeArg = room ? ` ${room}` : ''
131
143
  const what = room ? `room ${room}` : 'your account (auto-serves every session)'
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`)
144
+ 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`)
133
145
  }
134
146
 
135
147
  // Is a boot-persistent service already installed? (artifact file present.)