thinkpool-pair 0.7.34 → 0.7.36

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/account.mjs CHANGED
@@ -208,7 +208,8 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
208
208
  process.stderr.write(`\n ◆ thinkpool-pair — account mode · ${email}\n Auto-serving your sessions from ${DEFAULT_DIR}\n (point one at another repo: npx thinkpool-pair bind <code> <dir>)\n${svcHint}`)
209
209
 
210
210
  const children = new Map() // room -> child process
211
- const childIdle = new Map() // room -> bool (last idle report; unknown = not idle)
211
+ const childIdle = new Map() // room -> bool (last idle report: in the quiet window)
212
+ const childBetween = new Map() // room -> bool (between turns now — safe to restart per Contract #1)
212
213
  const childPeer = new Map() // room -> bool (a web client is watching this room)
213
214
  let applyRequested = false // a user clicked "apply" in some room (Slice 3)
214
215
  let pendingUpdate = null // newest published version once the poll sees it
@@ -282,13 +283,14 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
282
283
  const child = spawn(process.execPath, [BRIDGE, room, '--headless', '--auto=claude'], { cwd: dir, stdio: ['inherit', 'inherit', 'inherit', 'ipc'], env })
283
284
  children.set(room, child)
284
285
  childIdle.set(room, false) // unknown until it reports → never restart a fresh child
286
+ childBetween.set(room, false)
285
287
  childPeer.set(room, false)
286
288
  child.on('message', (m) => {
287
289
  if (!m) return
288
- if (m.t === 'idle') { childIdle.set(room, !!m.idle); childPeer.set(room, !!m.webPeer) }
290
+ if (m.t === 'idle') { childIdle.set(room, !!m.idle); childBetween.set(room, m.between != null ? !!m.between : !!m.idle); childPeer.set(room, !!m.webPeer) }
289
291
  else if (m.t === 'apply-update') { applyRequested = true; applyIfIdle() } // user clicked the chip
290
292
  })
291
- child.on('exit', () => { children.delete(room); childIdle.delete(room); childPeer.delete(room) }) // re-served on the next tick
293
+ child.on('exit', () => { children.delete(room); childIdle.delete(room); childBetween.delete(room); childPeer.delete(room) }) // re-served on the next tick
292
294
  // If an update is already pending, tell the freshly-served child immediately so
293
295
  // its room shows the "update ready" chip without waiting for the next poll.
294
296
  if (pendingUpdate) { try { child.send({ t: 'update-available', version: pendingUpdate }) } catch { /* child not ready */ } }
@@ -317,7 +319,7 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
317
319
  // child idle (between turns) AND either a user clicked apply OR nobody's watching
318
320
  // (unattended fallback). The predicate is unit-tested (tests/update-gate.test.mjs).
319
321
  const applyIfIdle = () => {
320
- if (!isSafeToRestart({ pendingUpdate, stopping, childIdle, childPeer, requested: applyRequested })) return
322
+ if (!isSafeToRestart({ pendingUpdate, stopping, childIdle, childBetween, childPeer, requested: applyRequested })) return
321
323
  process.stderr.write(`\n ◆ thinkpool-pair ${pendingUpdate} ready (running ${VERSION}) — restarting account bridge to update; sessions resume.\n`)
322
324
  stop('SIGTERM') // graceful: children persist + leave presence; exit 0 → service reruns @latest
323
325
  }
package/bridge.mjs CHANGED
@@ -164,7 +164,7 @@ if (argv[0] === 'provider') { const { runProvider } = await import('./provider.m
164
164
  // so launchd / systemd / CI / pipes are byte-for-byte unchanged.
165
165
  // Spec: docs/specs/2026-06-19-bridge-unified-launcher.md (Part 1).
166
166
  if ((!argv[0] || argv[0] === 'setup') && process.stdin.isTTY) {
167
- const { runLauncher } = await import('./launcher.mjs')
167
+ const { runLauncher, detectState } = await import('./launcher.mjs')
168
168
  const svc = await import('./service.mjs')
169
169
  const BRIDGE = fileURLToPath(import.meta.url)
170
170
  const io = {
@@ -186,9 +186,18 @@ if ((!argv[0] || argv[0] === 'setup') && process.stdin.isTTY) {
186
186
  installService: ({ room = null, agentCmd } = {}) => { svc.installService(room, agentCmd ? [agentCmd] : []); process.exit(0) },
187
187
  uninstallService: ({ room = null } = {}) => { svc.uninstallService(room); process.exit(0) },
188
188
  login: async () => { const { runLogin } = await import('./account.mjs'); await runLogin(SUPABASE_URL, SUPABASE_ANON, WEB_BASE) },
189
- setProvider: async ({ kind, base, token }) => { const { runProvider } = await import('./provider.mjs'); await runProvider(kind === 'custom' ? ['custom', '--base', base, '--token', token] : ['anthropic']); process.exit(0) },
189
+ // Provider config is NOT terminal — it writes provider.json and returns to the
190
+ // menu so you can pick a model, switch back, or do something else. (It used to
191
+ // process.exit(0), which dumped you to the shell mid-setup.)
192
+ setProvider: async ({ kind, base, token, model }) => {
193
+ const { runProvider } = await import('./provider.mjs')
194
+ const args = kind === 'custom'
195
+ ? ['custom', '--base', base, '--token', token, ...(model ? ['--model', model] : [])]
196
+ : ['anthropic']
197
+ await runProvider(args)
198
+ },
190
199
  }
191
- await runLauncher({ actions, io })
200
+ await runLauncher({ actions, io, refresh: detectState })
192
201
  process.exit(0)
193
202
  }
194
203
 
@@ -650,7 +659,13 @@ function openStructured({ id, model, resume, log, commands, mode }) {
650
659
  if (entry.log.length) process.stderr.write(`\n ◆ restored ${entry.log.length} prior events (${id.slice(0, 8)})${resume ? ' + resuming live context' : ''}.\n`)
651
660
  // Persist the permission mode alongside the transcript so a bridge restart
652
661
  // restores the session in the SAME mode (a bypass room stays bypass on resume).
653
- const persist = () => saveSession(room, id, { sessionId: entry.session?.sessionId || resume || null, log: entry.log, commands: entry.commands, mode: entry.mode })
662
+ const sessionData = () => ({ sessionId: entry.session?.sessionId || resume || null, log: entry.log, commands: entry.commands, mode: entry.mode })
663
+ const persist = () => saveSession(room, id, sessionData())
664
+ // Synchronous flush of this session's record. Used on open (so a brand-new session
665
+ // has a file under its id BEFORE its first event — surviving a restart inside the
666
+ // 1.5s saveSession debounce window) and on shutdown (so events since the last
667
+ // debounced write aren't lost). Contract #2: restart resumes, no lost messages.
668
+ entry.flush = () => flushSession(room, id, sessionData())
654
669
  entry.session = startClaudeSession({
655
670
  cwd: process.cwd(), model, resume, mode,
656
671
  env: { ...process.env, TP_MOCKUP_OUTBOX: mockupOutbox },
@@ -708,6 +723,10 @@ function openStructured({ id, model, resume, log, commands, mode }) {
708
723
  : `\n ${A.yel}● permission: ${req.toolName}${argStr(req.input)} — approve in the room.${A.rst}\n`)
709
724
  }),
710
725
  })
726
+ // Brand-new session (no restored log): write its record now so a restart that lands
727
+ // before the first debounced save still restores this id instead of dropping it (the
728
+ // room would otherwise show a fresh empty terminal + strand the transcript).
729
+ if (!entry.log.length) entry.flush()
711
730
  announce()
712
731
  if (!entry.log.length) process.stderr.write(`\n ◆ structured Claude session (${id.slice(0, 8)}) — driven from the room.\n`)
713
732
  return entry
@@ -1002,7 +1021,11 @@ channel
1002
1021
  // only runs on a fresh start (guard above), so a network re-subscribe
1003
1022
  // with live sessions won't re-spawn them.
1004
1023
  if (wantStructured(startCmd)) {
1005
- const all = loadAll(room).filter((r) => r.log?.length || r.sessionId)
1024
+ // Restore EVERY saved rec — a freshly opened session (persisted on open) may
1025
+ // have an empty log + no sessionId yet and must still come back under its id.
1026
+ // Closed sessions are unlinked by deleteSession, so a file existing on disk ==
1027
+ // a session that was live at shutdown (never an explicitly closed one).
1028
+ const all = loadAll(room)
1006
1029
  if (all.length) for (const rec of all) openStructured({ id: rec.id, resume: canResume(rec) ? rec.sessionId : undefined, log: rec.log, commands: rec.commands, mode: rec.mode })
1007
1030
  // Fresh open: ONLY for an explicit `-- <claude>` share. In account mode
1008
1031
  // (autoAgent set, no attachedCmd) the web's `?new=1` flow opens the first
@@ -1059,12 +1082,15 @@ if (process.env.THINKPOOL_PAIR_AUTOUPDATE === '1' && VERSION) {
1059
1082
  const POLL_MS = Math.max(60, parseInt(process.env.THINKPOOL_PAIR_UPDATE_INTERVAL, 10) || 1800) * 1000
1060
1083
  const IDLE_MS = Math.max(10, parseInt(process.env.THINKPOOL_PAIR_UPDATE_IDLE, 10) || 90) * 1000
1061
1084
  // pendingUpdate is module-level now (shared with surfaceUpdate + the room chip).
1062
- const isIdle = () => {
1085
+ // Between turns = safe to restart per Contract #1 (no turn in flight, no bytes
1086
+ // mid-flush). Idle = that PLUS the quiet window — the extra conservatism only the
1087
+ // unattended auto-apply needs; an explicit click restarts on between-turns alone.
1088
+ const betweenTurns = () => {
1063
1089
  if (turnInFlight(sessions)) return false // a structured turn is live
1064
- if (Date.now() - lastActivity < IDLE_MS) return false
1065
1090
  for (const t of terms.values()) if (t.buf) return false // bytes mid-flush
1066
1091
  return true
1067
1092
  }
1093
+ const isIdle = () => betweenTurns() && (Date.now() - lastActivity >= IDLE_MS)
1068
1094
  // Newer? Compare the numeric x.y.z core; ignore prerelease tails to stay safe.
1069
1095
  const isNewer = (latest, cur) => {
1070
1096
  const core = (v) => String(v).split('-')[0].split('.').map((n) => parseInt(n, 10) || 0)
@@ -1083,11 +1109,13 @@ if (process.env.THINKPOOL_PAIR_AUTOUPDATE === '1' && VERSION) {
1083
1109
  } catch { return null } finally { clearTimeout(to) }
1084
1110
  }
1085
1111
  const applyIfIdle = () => {
1086
- if (!pendingUpdate || !isIdle()) return
1087
- // Slice 3: when a web client is watching, wait for their "apply" click instead
1088
- // of restarting under them. With nobody present (cloud VM / closed tab) the
1089
- // unattended fallback still auto-applies at idle so headless boxes self-heal.
1090
- if (!applyRequested && webPeerPresent()) return
1112
+ if (!pendingUpdate) return
1113
+ // Explicit "apply" → go at the next between-turns gap (the click means the user
1114
+ // opted in; don't make them wait out the quiet window). Unattended (no click) →
1115
+ // require the full idle window AND nobody watching, so a headless box self-heals
1116
+ // without ever restarting under someone who's mid-session.
1117
+ if (applyRequested) { if (!betweenTurns()) return }
1118
+ else if (!isIdle() || webPeerPresent()) return
1091
1119
  process.stderr.write(`\n ◆ thinkpool-pair ${pendingUpdate} ready (running ${VERSION}) — restarting to update; the session resumes.\n`)
1092
1120
  shutdown(0, false) // clean teardown (no farewell banner); service reruns @latest, session-store resumes
1093
1121
  }
@@ -1108,17 +1136,19 @@ if (process.env.THINKPOOL_PAIR_AUTOUPDATE === '1' && VERSION) {
1108
1136
  // supervisor restart re-resolves @latest. Instead we report our idle state so
1109
1137
  // the supervisor can pick a moment when EVERY child is idle to restart-update.
1110
1138
  const IDLE_MS = Math.max(10, parseInt(process.env.THINKPOOL_PAIR_UPDATE_IDLE, 10) || 90) * 1000
1111
- const idleNow = () => {
1139
+ const betweenTurns = () => {
1112
1140
  if (turnInFlight(sessions)) return false // a structured turn is live
1113
- if (Date.now() - lastActivity < IDLE_MS) return false
1114
1141
  for (const t of terms.values()) if (t.buf) return false // bytes mid-flush
1115
1142
  return true
1116
1143
  }
1144
+ const idleNow = () => betweenTurns() && (Date.now() - lastActivity >= IDLE_MS)
1117
1145
  // The supervisor owns updates (children never self-update). It IPCs us when a newer
1118
- // version is out → surface the chip; we report idle + whether a web peer is present
1119
- // so it restarts only between turns and only when nobody is mid-click (Slice 3).
1146
+ // version is out → surface the chip; we report `between` (safe to restart now, per
1147
+ // Contract #1) + `idle` (the quiet window, for the unattended fallback) + whether a
1148
+ // web peer is present, so the supervisor restarts on an explicit click between turns
1149
+ // and otherwise waits for true idle with nobody watching (Slice 3).
1120
1150
  process.on('message', (m) => { if (m && m.t === 'update-available') surfaceUpdate(m.version) })
1121
- setInterval(() => { try { process.send({ t: 'idle', idle: idleNow(), webPeer: webPeerPresent() }) } catch { /* parent gone */ } }, 5000).unref()
1151
+ setInterval(() => { try { process.send({ t: 'idle', idle: idleNow(), between: betweenTurns(), webPeer: webPeerPresent() }) } catch { /* parent gone */ } }, 5000).unref()
1122
1152
  } else if (VERSION) {
1123
1153
  // Foreground run (not the supervised service): it can't self-restart, but
1124
1154
  // nudge once if a newer version is out so a relaunch with @latest picks it up.
@@ -1164,6 +1194,10 @@ async function shutdown(code = 0, farewell = true) {
1164
1194
  // correct and must not hang on the (possibly dead) realtime socket. This is the
1165
1195
  // bug behind the watchdog/auto-update raw process.exit() paths, which skipped
1166
1196
  // teardown entirely and orphaned agent processes + left stale presence.
1197
+ // Flush structured session state synchronously BEFORE ending sessions. saveSession
1198
+ // debounces ~1.5s, so without this any events since the last write are lost on exit
1199
+ // (Contract #2). writeFileSync, well inside the 1500ms hard-exit backstop above.
1200
+ for (const s of sessions.values()) { try { s.flush?.() } catch { /* noop */ } }
1167
1201
  for (const t of terms.values()) { try { t.term.kill() } catch { /* noop */ } }
1168
1202
  for (const s of sessions.values()) { try { s.session.end() } catch { /* noop */ } }
1169
1203
  detachLocal()
package/launcher.mjs CHANGED
@@ -51,10 +51,14 @@ function serviceFile(room) {
51
51
  export function detectState() {
52
52
  const auth = readJson(path.join(CFG_DIR, 'auth.json'))
53
53
  const provider = readJson(path.join(CFG_DIR, 'provider.json'))
54
+ // provider.json is written with { provider, baseUrl, model } (see provider.mjs).
55
+ // Anything whose `provider` field is set and not "anthropic" is a custom endpoint.
56
+ const isCustom = !!provider?.provider && provider.provider !== 'anthropic'
54
57
  return {
55
58
  loggedIn: !!auth?.refresh_token,
56
59
  email: auth?.email || auth?.user?.email || null,
57
- provider: provider?.kind === 'custom' ? `Custom (${provider.base || 'endpoint'})` : 'Anthropic (default)',
60
+ provider: isCustom ? `Custom (${provider.baseUrl || 'endpoint'})` : 'Anthropic (default)',
61
+ providerModel: isCustom ? (provider.model || null) : null,
58
62
  agents: KNOWN_AGENTS.filter(a => onPath(a.cmd)),
59
63
  accountSvc: fs.existsSync(serviceFile(null)),
60
64
  cwd: process.cwd(),
@@ -68,7 +72,11 @@ const C = {
68
72
  }
69
73
 
70
74
  // io: { ask(prompt)->Promise<string>, print(s) }. actions: real | dry-run | mock.
71
- export async function runLauncher({ actions, io, state = detectState() }) {
75
+ // refresh: optional () => state — re-read disk after a mutating action so the header
76
+ // reflects the change (real bridge passes detectState; tests omit it to keep the
77
+ // injected state stable).
78
+ export async function runLauncher({ actions, io, state = detectState(), refresh = null }) {
79
+ const resync = () => { if (refresh) state = refresh() }
72
80
  const askChoice = async (prompt, options, def = 1) => {
73
81
  options.forEach((o, i) => io.print(` ${C.cyan(String(i + 1))}) ${o.label}${o.hint ? ' ' + C.dim(o.hint) : ''}`))
74
82
  const a = await io.ask(`\n ${prompt} ${C.dim(`[${def}]`)} ${C.cyan('▸')} `)
@@ -137,25 +145,41 @@ export async function runLauncher({ actions, io, state = detectState() }) {
137
145
  await actions.installService({ room, agentCmd: agent.cmd })
138
146
  }
139
147
 
148
+ const providerMenu = async () => {
149
+ const cur = state.provider + (state.providerModel ? `, model ${state.providerModel}` : '')
150
+ const p = await askChoice('\n provider', [
151
+ { label: 'Anthropic (default)', hint: 'regular Claude login' },
152
+ { label: 'Custom endpoint', hint: 'Z.ai GLM, OpenRouter, your own proxy' },
153
+ { label: '← Back', hint: `current: ${cur}` },
154
+ ], 3) // Enter = Back
155
+ if (p === 2) return
156
+ if (p === 0) { await actions.setProvider({ kind: 'anthropic' }); resync(); io.print('\n ' + C.green('✓ back on regular Claude.')); return }
157
+ // custom — every step can be aborted with a blank line, which backs out cleanly.
158
+ const base = (await io.ask(`\n Base url ${C.dim('(blank to cancel · SDK appends /v1/messages)')} ${C.cyan('▸')} `)).trim()
159
+ if (!base) { io.print(' ' + C.yellow('cancelled — provider unchanged')); return }
160
+ const token = (await io.ask(` API token ${C.dim('(blank to cancel)')} ${C.cyan('▸')} `)).trim()
161
+ if (!token) { io.print(' ' + C.yellow('cancelled — provider unchanged')); return }
162
+ const model = (await io.ask(` Model ${C.dim('(blank = endpoint default · e.g. glm-4.6)')} ${C.cyan('▸')} `)).trim()
163
+ await actions.setProvider({ kind: 'custom', base, token, model: model || undefined })
164
+ resync()
165
+ io.print(`\n ${C.green('✓ provider set to custom.')}\n base url: ${state.provider}\n model: ${state.providerModel || '(endpoint default)'}`)
166
+ }
167
+
140
168
  const settingsMenu = async () => {
141
- const pick = await askChoice('\n change', [
142
- { label: 'LLM provider', hint: `current: ${state.provider}` },
143
- { label: 'Account', hint: state.loggedIn ? `linked: ${state.email || 'yes'}` : 'not linked' },
144
- { label: 'Remove the background service', hint: state.accountSvc ? '' : 'none installed' },
145
- ])
146
- if (pick === 0) {
147
- const p = await askChoice('\n provider', [{ label: 'Anthropic (default)' }, { label: 'Custom endpoint', hint: 'Z.ai GLM, OpenRouter, your own proxy' }])
148
- if (p === 0) await actions.setProvider({ kind: 'anthropic' })
169
+ for (;;) {
170
+ const pick = await askChoice('\n change', [
171
+ { label: 'LLM provider', hint: `current: ${state.provider}` },
172
+ { label: 'Account', hint: state.loggedIn ? `linked: ${state.email || 'yes'}` : 'not linked' },
173
+ { label: 'Remove the background service', hint: state.accountSvc ? '' : 'none installed' },
174
+ { label: '← Back to main menu', hint: '' },
175
+ ], 4) // Enter = Back
176
+ if (pick === 3) return
177
+ if (pick === 0) await providerMenu()
178
+ else if (pick === 1) { await actions.login(); resync(); return }
149
179
  else {
150
- const base = (await io.ask(`\n Base url ${C.dim('(SDK appends /v1/messages)')} ${C.cyan('▸')} `)).trim()
151
- const token = (await io.ask(` API token ${C.cyan('▸')} `)).trim()
152
- await actions.setProvider({ kind: 'custom', base, token })
180
+ if (state.accountSvc) await actions.uninstallService({ room: null })
181
+ else io.print(' ' + C.yellow('no background service installed'))
153
182
  }
154
- } else if (pick === 1) {
155
- await actions.login()
156
- } else {
157
- if (state.accountSvc) await actions.uninstallService({ room: null })
158
- else io.print(' ' + C.yellow('no background service installed'))
159
183
  }
160
184
  }
161
185
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thinkpool-pair",
3
- "version": "0.7.34",
3
+ "version": "0.7.36",
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/update-gate.mjs CHANGED
@@ -11,10 +11,18 @@
11
11
  // child is idle (a child reports idle=false while a turn is in flight — that's what
12
12
  // keeps Contract #1), AND either a user requested apply (clicked the chip) OR — the
13
13
  // unattended fallback — no child has a web peer present (nobody is there to click).
14
- export function isSafeToRestart({ pendingUpdate, stopping = false, childIdle, childPeer, requested = false }) {
14
+ export function isSafeToRestart({ pendingUpdate, stopping = false, childIdle, childBetween, childPeer, requested = false }) {
15
15
  if (!pendingUpdate || stopping) return false
16
- for (const idle of childIdle.values()) if (!idle) return false // any busy child → wait (between-turns)
17
- if (requested) return true // user clicked apply + all idle → go
16
+ if (requested) {
17
+ // Explicit "apply" → restart at the next BETWEEN-TURNS gap (no turn in flight),
18
+ // NOT the full quiet window — in an active room that 90s of silence never comes,
19
+ // so the click would otherwise do nothing. Fall back to childIdle when a child
20
+ // doesn't report `between` (older child during the mixed-version update itself).
21
+ const gate = childBetween instanceof Map ? childBetween : childIdle
22
+ for (const ready of gate.values()) if (!ready) return false // any busy child → wait (Contract #1)
23
+ return true
24
+ }
25
+ for (const idle of childIdle.values()) if (!idle) return false // unattended: full idle window
18
26
  for (const present of childPeer.values()) if (present) return false // someone's watching, no click → wait
19
27
  return true // unattended: idle + nobody watching → auto-apply
20
28
  }