thinkpool-pair 0.7.49 → 0.7.51

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
@@ -142,16 +142,6 @@ if (argv[0] === 'install-service' || argv[0] === 'uninstall-service') {
142
142
  process.exit(0)
143
143
  }
144
144
 
145
- // Restart an installed boot-persistent service (re-fetches @latest, sessions resume):
146
- // `thinkpool-pair restart-service [<ROOM>]` — no room → the account service.
147
- if (argv[0] === 'restart-service') {
148
- const arg1 = argv[1] || ''
149
- const svcRoom = (!arg1 || arg1.startsWith('-')) ? null : arg1.toUpperCase().trim()
150
- const svc = await import('./service.mjs')
151
- svc.restartService(svcRoom)
152
- process.exit(0)
153
- }
154
-
155
145
  // Account mode (Phase 1, docs/specs/2026-06-16-account-bridge.md): one bridge per
156
146
  // ACCOUNT, not per room. `login` links this device; `bind <ROOM> <dir>` maps a
157
147
  // session to a working dir on this machine; a bare invocation discovers + serves
@@ -195,12 +185,8 @@ if ((!argv[0] || argv[0] === 'setup') && process.stdin.isTTY) {
195
185
  child.on('exit', (code) => process.exit(code == null ? 0 : code))
196
186
  }),
197
187
  serveAccountForeground: async () => { const { runAccount } = await import('./account.mjs'); await runAccount(SUPABASE_URL, SUPABASE_ANON) },
198
- // install / uninstall / restart are NOT terminal anymore — they do their work,
199
- // print their notes, and RETURN so the launcher loop drops you back at the menu
200
- // (the service runs in its own launchd/systemd process; this launcher isn't a bridge).
201
- installService: ({ room = null, agentCmd } = {}) => { svc.installService(room, agentCmd ? [agentCmd] : []) },
202
- uninstallService: ({ room = null } = {}) => { svc.uninstallService(room) },
203
- restartService: ({ room = null } = {}) => { svc.restartService(room) },
188
+ installService: ({ room = null, agentCmd } = {}) => { svc.installService(room, agentCmd ? [agentCmd] : []); process.exit(0) },
189
+ uninstallService: ({ room = null } = {}) => { svc.uninstallService(room); process.exit(0) },
204
190
  login: async () => { const { runLogin } = await import('./account.mjs'); await runLogin(SUPABASE_URL, SUPABASE_ANON, WEB_BASE) },
205
191
  // Provider config is NOT terminal — it writes provider.json and returns to the
206
192
  // menu so you can pick a model, switch back, or do something else. (It used to
@@ -667,6 +653,22 @@ function pushLog(entry, evt) {
667
653
  return evt
668
654
  }
669
655
 
656
+ // Resume-idle guard (2026-06-22 cooked-terminal bug): a turn force-killed mid-flight
657
+ // leaves the restored log ending on in-flight events (assistant/you/tool_result) with
658
+ // NO terminal `result`, so the web reads it as "thinking" forever AND Stop is a no-op
659
+ // (the freshly-resumed SDK session has no live turn to interrupt — q.interrupt() on an
660
+ // idle query does nothing). Mirror of the client working-scan in
661
+ // src/pages/code/structured.jsx: walking back from the end, a result/error/propose/pool
662
+ // closes the turn; an assistant/you/tool_result reached first means it's still open.
663
+ function restoredTurnOpen(log) {
664
+ for (let i = log.length - 1; i >= 0; i--) {
665
+ const k = log[i]?.kind
666
+ if (k === 'result' || k === 'error' || k === 'propose' || k === 'pool') return false
667
+ if (k === 'assistant' || k === 'you' || k === 'tool_result') return true
668
+ }
669
+ return false
670
+ }
671
+
670
672
  // The Phase-2 path: instead of a PTY, run Claude Code through the Agent SDK and
671
673
  // relay STRUCTURED events. onEvent → broadcast `code-event` + print locally +
672
674
  // persist to the host file; tool calls round-trip through the perm card; the
@@ -680,6 +682,15 @@ function openStructured({ id, model, resume, log, commands, mode }) {
680
682
  // event-id.mjs + src/pages/code/seqDedup.js). pushLog() is the single stamp point.
681
683
  entry.seq = makeSeqCounter(maxSeq(entry.log))
682
684
  sessions.set(id, entry)
685
+ // Close an interrupted turn so a restored terminal comes back IDLE-with-history
686
+ // instead of stuck "thinking" (see restoredTurnOpen). Idempotent: the closer is
687
+ // persisted, so a later resume sees a closed turn and no-ops.
688
+ if (entry.log.length && restoredTurnOpen(entry.log)) {
689
+ const closer = { kind: 'result', subtype: 'aborted', recovered: true }
690
+ stampEvent(closer)
691
+ pushLog(entry, closer)
692
+ process.stderr.write(`\n ◆ closed an interrupted turn on resume (${id.slice(0, 8)}) — restored idle.\n`)
693
+ }
683
694
  // This session's private mockup outbox — render.sh writes manifests here (via
684
695
  // TP_MOCKUP_OUTBOX below) and the watcher attributes every card to THIS id, so
685
696
  // they land on this session even after other terminals open/close.
@@ -361,8 +361,7 @@ export function startClaudeSession({ cwd, model, resume, env, mode: initialMode
361
361
  // alone defers to the server-side `tengu_prompt_suggestion` feature gate, which
362
362
  // Anthropic flips on/off during rollout — so the ghost-text chip silently died
363
363
  // mid-day with NO change on our side. CLAUDE_CODE_ENABLE_PROMPT_SUGGESTION is the
364
- // documented override (the SDK docs name `=false` as the kill switch; `=1` forces
365
- // on past the gate). opts.env REPLACES the child env, so spread the inherited one.
364
+ // documented override. opts.env REPLACES the child env, so spread the inherited one.
366
365
  opts.env = { ...(env || process.env), CLAUDE_CODE_ENABLE_PROMPT_SUGGESTION: '1' }
367
366
 
368
367
  // Before the SDK replays this transcript, heal any cross-provider tool blocks
@@ -473,15 +472,9 @@ export function startClaudeSession({ cwd, model, resume, env, mode: initialMode
473
472
  // Claude Code's own predicted next prompt (promptSuggestions opt-in).
474
473
  // Arrives once per turn AFTER `result`; surfaced verbatim as ephemeral
475
474
  // chrome (a ghost-text chip in the room composer), never persisted.
476
- process.stderr.write(`\n ◆ [suggestion] received: ${JSON.stringify(m.suggestion ?? null)}\n`)
477
475
  if (m.suggestion) emit({ kind: 'suggestion', text: m.suggestion })
478
476
  break
479
477
  default:
480
- // Debug: log any unhandled message type so we can see if prompt_suggestion
481
- // is arriving under a different name. Remove once confirmed working.
482
- if (m.type && !['stream_event', 'user', 'agent_metadata'].includes(m.type)) {
483
- process.stderr.write(` [debug] unhandled sdk msg type=${m.type}${m.suggestion ? ` (HAS .suggestion=${JSON.stringify(m.suggestion)})` : ''}\n`)
484
- }
485
478
  break
486
479
  }
487
480
  }
@@ -541,12 +534,11 @@ export function startClaudeSession({ cwd, model, resume, env, mode: initialMode
541
534
  async abort() {
542
535
  try { await q?.interrupt?.() } catch { /* noop */ }
543
536
  // interrupt() stops the turn but emits NO terminal message. Without one the
544
- // struct event stream ends on a non-terminal event, so a page refresh replays
545
- // it as still "thinking" (the client's optimistic stop is in-memory only and is
546
- // lost on reload). Emit a terminal result so busy clears AND persists across
547
- // refresh — the client persists on `result` and structWorkingFor() treats it as
548
- // done. Guarded on turnActive so we don't double-emit if the SDK already
549
- // surfaced a result for the interrupt. (2026-06-22)
537
+ // struct stream ends on a non-terminal event, so on resume the SDK treats the
538
+ // turn as INCOMPLETE and auto-continues it — the 2026-06-22 "zombie turn that
539
+ // resumes itself" storm. Emit a terminal result so the turn is marked DONE +
540
+ // persists across refresh. Guarded on turnActive to avoid a double-emit if the
541
+ // SDK already surfaced one for the interrupt. (restored from 0.7.49)
550
542
  if (turnActive) { turnActive = false; emit({ kind: 'result', subtype: 'aborted', sessionId }) }
551
543
  },
552
544
  end() { closed = true; clearInterval(stallTimer); input.end(); try { ac.abort() } catch { /* noop */ } },
package/launcher.mjs CHANGED
@@ -125,10 +125,8 @@ export async function runLauncher({ actions, io, state = detectState(), refresh
125
125
  const how = await askChoice('run it', [
126
126
  { label: 'As an always-on background service', hint: 'recommended — survives reboot, auto-updates' },
127
127
  { label: 'Here in this terminal (foreground)', hint: 'stops when you close it' },
128
- { label: '← Back to main menu', hint: '' },
129
128
  ])
130
- if (how === 2) return
131
- if (how === 0) { await actions.installService({ room: null }); resync() }
129
+ if (how === 0) await actions.installService({ room: null })
132
130
  else await actions.serveAccountForeground()
133
131
  }
134
132
 
@@ -136,17 +134,15 @@ export async function runLauncher({ actions, io, state = detectState(), refresh
136
134
  const what = await askChoice('install for', [
137
135
  { label: 'All my sessions (account)', hint: state.loggedIn ? '' : 'needs login first' },
138
136
  { label: 'One specific room', hint: 'no login needed' },
139
- { label: '← Back to main menu', hint: '' },
140
137
  ])
141
- if (what === 2) return
142
138
  if (what === 0) {
143
139
  if (!state.loggedIn) { io.print('\n ' + C.yellow('Account service needs a login first (pick “Serve all my sessions”).')); return }
144
- await actions.installService({ room: null }); resync(); return
140
+ await actions.installService({ room: null }); return
145
141
  }
146
- const room = (await io.ask(`\n Room code ${C.dim('(blank to go back)')} ${C.cyan('▸')} `)).toUpperCase().trim()
142
+ const room = (await io.ask(`\n Room code ${C.cyan('▸')} `)).toUpperCase().trim()
147
143
  if (!room) { io.print(' ' + C.yellow('no room — back to menu')); return }
148
144
  const agent = state.agents[0] || { cmd: 'claude' }
149
- await actions.installService({ room, agentCmd: agent.cmd }); resync()
145
+ await actions.installService({ room, agentCmd: agent.cmd })
150
146
  }
151
147
 
152
148
  const providerMenu = async () => {
@@ -174,49 +170,39 @@ export async function runLauncher({ actions, io, state = detectState(), refresh
174
170
  const pick = await askChoice('\n change', [
175
171
  { label: 'LLM provider', hint: `current: ${state.provider}` },
176
172
  { label: 'Account', hint: state.loggedIn ? `linked: ${state.email || 'yes'}` : 'not linked' },
177
- { label: 'Restart the background service', hint: state.accountSvc ? 'apply the latest update now' : 'none installed' },
178
173
  { label: 'Remove the background service', hint: state.accountSvc ? '' : 'none installed' },
179
174
  { label: '← Back to main menu', hint: '' },
180
- ], 5) // Enter = Back
181
- if (pick === 4) return
175
+ ], 4) // Enter = Back
176
+ if (pick === 3) return
182
177
  if (pick === 0) await providerMenu()
183
178
  else if (pick === 1) { await actions.login(); resync(); return }
184
- else if (pick === 2) {
185
- if (state.accountSvc) { await actions.restartService({ room: null }); resync() }
186
- else io.print(' ' + C.yellow('no background service installed'))
187
- }
188
179
  else {
189
- if (state.accountSvc) { await actions.uninstallService({ room: null }); resync() }
180
+ if (state.accountSvc) await actions.uninstallService({ room: null })
190
181
  else io.print(' ' + C.yellow('no background service installed'))
191
182
  }
192
183
  }
193
184
  }
194
185
 
195
186
  // ── menu loop ──
196
- // pairRoom re-execs + waits, and account-foreground runs forever, so those choices
197
- // never return here. Everything else (install / restart / uninstall / settings /
198
- // provider) just does its work, prints its notes, and RETURNS — so the loop always
199
- // drops you back at the menu. Submenus carry a "← Back" so you can bail at any point.
200
- // The trailing "press Enter" lands on every returning path.
187
+ // Real actions either replace the process (pairRoom re-execs + waits; serve/install
188
+ // call process.exit) or run forever (account foreground), so a real "do something"
189
+ // choice never returns here. Dry-run/mock actions just print + return → the loop
190
+ // re-renders. The "press Enter" only lands on a no-op / back-out path.
201
191
  for (;;) {
202
192
  header()
203
193
  io.print(' ' + C.bold('What do you want to do?') + '\n')
204
- // Built per-render so the contextual "Restart" only appears once a service exists.
205
- const items = [
206
- { key: 'pair', label: 'Pair one room now', hint: 'run here, Ctrl-C stops it' },
207
- { key: 'serve', label: 'Serve all my sessions', hint: 'account mode — needs login' },
208
- { key: 'service', label: 'Always-on background service', hint: 'survives reboot, auto-updates' },
209
- ]
210
- if (state.accountSvc) items.push({ key: 'restart', label: 'Restart the background service', hint: 'apply the latest update now' })
211
- items.push({ key: 'settings', label: 'Settings', hint: 'provider · account · restart · remove service' })
212
- items.push({ key: 'quit', label: 'Quit', hint: '' })
213
- const pick = items[await askChoice('choose', items)]
214
- if (pick.key === 'quit') return
215
- if (pick.key === 'pair') await pairRoom()
216
- else if (pick.key === 'serve') await serveAccount()
217
- else if (pick.key === 'service') await serviceMenu()
218
- else if (pick.key === 'restart') { await actions.restartService({ room: null }); resync() }
219
- else if (pick.key === 'settings') await settingsMenu()
194
+ const choice = await askChoice('choose', [
195
+ { label: 'Pair one room now', hint: 'run here, Ctrl-C stops it' },
196
+ { label: 'Serve all my sessions', hint: 'account mode — needs login' },
197
+ { label: 'Always-on background service', hint: 'survives reboot, auto-updates' },
198
+ { label: 'Settings', hint: 'provider · account · remove service' },
199
+ { label: 'Quit', hint: '' },
200
+ ])
201
+ if (choice === 4) return // Quit
202
+ if (choice === 0) await pairRoom()
203
+ else if (choice === 1) await serveAccount()
204
+ else if (choice === 2) await serviceMenu()
205
+ else if (choice === 3) await settingsMenu()
220
206
  await io.ask('\n ' + C.dim('press Enter to return to the menu…'))
221
207
  }
222
208
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thinkpool-pair",
3
- "version": "0.7.49",
3
+ "version": "0.7.51",
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": {
@@ -27,7 +27,7 @@
27
27
  "node": ">=18"
28
28
  },
29
29
  "dependencies": {
30
- "@anthropic-ai/claude-agent-sdk": "^0.3.185",
30
+ "@anthropic-ai/claude-agent-sdk": "0.3.185",
31
31
  "@supabase/supabase-js": "^2.45.0"
32
32
  },
33
33
  "optionalDependencies": {
package/service.mjs CHANGED
@@ -126,40 +126,12 @@ export function installService(room, cmdArgs = []) {
126
126
  const idleSecs = Math.max(10, parseInt(process.env.THINKPOOL_PAIR_UPDATE_IDLE, 10) || 90)
127
127
  const autoUpdate = process.platform === 'win32'
128
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 (pick “Restart the background service” in the menu, run 'thinkpool-pair restart-service${slug(room) === 'account' ? '' : ' ' + room}', or 'launchctl kickstart -k gui/$(id -u)/io.thinkpool.pair.${slug(room)}' on macOS).`
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).`
130
130
  const removeArg = room ? ` ${room}` : ''
131
131
  const what = room ? `room ${room}` : 'your account (auto-serves every session)'
132
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`)
133
133
  }
134
134
 
135
- // Restart an already-installed boot-persistent service so it re-resolves
136
- // thinkpool-pair@latest right now — the supported "apply the update now" path.
137
- // launchd/systemd relaunch `npx … @latest`, so a just-published version is picked
138
- // up; any active session resumes in place (the bridge restores from disk).
139
- // room falsy → the ACCOUNT service. Returns true on success.
140
- export function restartService(room) {
141
- const plat = process.platform
142
- if (!isServiceInstalled(room)) {
143
- process.stderr.write(' ⚠ no background service installed to restart — install one first.\n')
144
- return false
145
- }
146
- const id = label(room)
147
- try {
148
- if (plat === 'darwin') execSync(`launchctl kickstart -k gui/$(id -u)/${id}`, { stdio: 'inherit' })
149
- else if (plat === 'linux') execSync(`systemctl --user restart ${id}.service`, { stdio: 'inherit' })
150
- else {
151
- // Windows Startup .cmd has no managed daemon to kick — guide the manual path.
152
- process.stderr.write(' ⚠ Windows has no managed daemon — close the running thinkpool-pair window and re-launch the Startup .cmd (or reboot) to restart.\n')
153
- return false
154
- }
155
- process.stderr.write(` ◆ restarted ${id} — it re-fetches thinkpool-pair@latest; any active session resumes in place.\n`)
156
- return true
157
- } catch (e) {
158
- process.stderr.write(` ⚠ restart failed: ${e.message}\n`)
159
- return false
160
- }
161
- }
162
-
163
135
  // Is a boot-persistent service already installed? (artifact file present.)
164
136
  // room falsy → checks the ACCOUNT service. Lets the main run skip the offer +
165
137
  // avoid starting a second bridge that would race the service.