thinkpool-pair 0.7.40 → 0.7.42

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
@@ -260,13 +260,20 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
260
260
  const tick = async () => {
261
261
  let rooms = []
262
262
  try {
263
- const { data } = await sb.from('code_sessions').select('code,name,last_active_at').order('last_active_at', { ascending: false }).limit(20)
263
+ // Owner-only discovery (Contract C-CODE-2): a host bridges ONLY the sessions
264
+ // it CREATED. RLS on code_sessions returns owned OR participant rooms, so
265
+ // without this owner filter the supervisor auto-serves rooms you merely JOINED
266
+ // as a guest — spawning your machine into another owner's session that already
267
+ // runs its own bridge. The web watches tpacct:<session-owner>, so a
268
+ // participant's bridge is duplication the UI never even looks at.
269
+ const { data } = await sb.from('code_sessions').select('code,name,last_active_at,owner_id').eq('owner_id', session.user.id).order('last_active_at', { ascending: false }).limit(20)
264
270
  rooms = data || []
265
271
  } catch { /* transient — keep existing children, retry next tick */ }
266
272
  const dirs = loadDirs()
267
273
  for (const r of rooms) {
268
274
  const room = r.code
269
275
  if (!room || children.has(room)) continue
276
+ if (r.owner_id && r.owner_id !== session.user.id) continue // never serve a room we don't own (defense-in-depth behind the .eq above)
270
277
  const dir = dirs[room] || DEFAULT_DIR // auto-serve unbound sessions in the launch dir
271
278
  if (!fs.existsSync(dir)) {
272
279
  if (!warned.has(room)) { warned.add(room); process.stderr.write(`\n ◇ ${room}: bound directory is gone (${dir}) — re-bind it.\n`) }
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
 
@@ -442,7 +451,7 @@ const flushAll = () => {
442
451
  bcast('pty-out', { term: id, b64 })
443
452
  }
444
453
  }
445
- const flushTimer = setInterval(flushAll, 35)
454
+ const flushTimer = setInterval(flushAll, 35); flushTimer.unref?.() // don't keep the event loop alive past shutdown
446
455
 
447
456
  // ── mockup hand-off ────────────────────────────────────────────────────
448
457
  // render.sh (mockup-iterate) drops a manifest JSON into TP_MOCKUP_OUTBOX after
@@ -539,6 +548,11 @@ const attachedDims = () => ({
539
548
 
540
549
  function openTerm({ id, cmd, args = [], attached = false, cols, rows }) {
541
550
  if (terms.has(id)) return
551
+ if (terms.size >= 8) { // cap live PTYs (matches session-store KEEP=8) — no unbounded host leak
552
+ process.stderr.write(`\n ⚠ can't open "${cmd}" — terminal cap (8) reached; close one first.\n`)
553
+ bcast('term-exit', { id })
554
+ return
555
+ }
542
556
  if (!pty) { // raw-PTY path needs node-pty; structured Claude never lands here
543
557
  process.stderr.write(`\n ⚠ can't open "${cmd}" — node-pty isn't built (PTY mode disabled).\n`)
544
558
  bcast('term-exit', { id })
@@ -773,7 +787,7 @@ if (attachedCmd && process.stdin.isTTY) process.stdin.setRawMode(true)
773
787
  process.stdin.resume()
774
788
  process.stdin.on('data', d => {
775
789
  const t = attachedId && terms.get(attachedId)
776
- if (t) t.term.write(d.toString('utf8'))
790
+ if (t) { try { t.term.write(d.toString('utf8')) } catch { /* term just exited */ } }
777
791
  else if (d.includes(3)) shutdown() // Ctrl-C once detached/headless
778
792
  })
779
793
  // attached terminal follows the host's TTY size — but never below the
@@ -796,7 +810,7 @@ channel
796
810
  if (!payload?.data) return
797
811
  markActivity()
798
812
  const t = terms.get(payload.term) || (payload.term == null && attachedId ? terms.get(attachedId) : null)
799
- if (t) t.term.write(payload.data)
813
+ if (t) { try { t.term.write(payload.data) } catch { /* term just exited */ } }
800
814
  })
801
815
  .on('broadcast', { event: 'resize' }, ({ payload }) => {
802
816
  // headless terms are web-sized (last writer wins); the attached term is
@@ -84,8 +84,10 @@ function makeInputStream() {
84
84
  return {
85
85
  stream: gen(),
86
86
  push(content) {
87
+ if (ended) return false // session ended — caller (sendTurn) can surface this
87
88
  queue.push({ type: 'user', message: { role: 'user', content } })
88
89
  if (wake) { wake(); wake = null }
90
+ return true
89
91
  },
90
92
  end() { ended = true; if (wake) { wake(); wake = null } },
91
93
  }
@@ -120,7 +122,7 @@ const MODES = new Set(['default', 'acceptEdits', 'plan', 'bypassPermissions'])
120
122
 
121
123
  export function startClaudeSession({ cwd, model, resume, env, mode: initialMode = 'default', onEvent, requestPermission }) {
122
124
  const ac = new AbortController()
123
- const input = makeInputStream()
125
+ let input = makeInputStream() // `let`: auto-restart swaps in a fresh stream
124
126
  let sessionId = resume || null
125
127
  let closed = false
126
128
  let q = null // the live Query — control requests (interrupt /
@@ -150,12 +152,39 @@ export function startClaudeSession({ cwd, model, resume, env, mode: initialMode
150
152
  let turnActive = false // true between a sent turn and its `result`
151
153
  let lastEvtTs = Date.now() // wall-clock of the most recent emitted event
152
154
  let stalledSent = false // one `stalled` per stall, not a storm
155
+ // Auto-restart (item 1) — a transient upstream stream drop (e.g. Anthropic
156
+ // "Connection closed mid-response") used to leave the session half-dead:
157
+ // query loop gone, turnActive stuck, later sendTurns pushed into a dead
158
+ // stream (silent freeze). The loop now self-heals: a recoverable throw
159
+ // re-runs query() with resume, bounded by RESTART_MAX consecutive attempts
160
+ // (reset to 0 on every successful `result`). Exhaustion/non-recoverable →
161
+ // closed, so sendTurn stops silently queuing.
162
+ const RESTART_MAX = 3
163
+ const RECOVERABLE = /connection closed|connection reset|econnreset|etimedout|socket hang up|fetch failed|network error|socket destroyed/i
164
+ let restartCount = 0
165
+ // Force-stop a true wedge (item 3): no result, no error, just silence past
166
+ // FORCE_STOP_MS. Clears turnActive so between-turns updates unblock, and
167
+ // surfaces a recoverable error. The wedged loop is left in place (a hung
168
+ // native call can't be safely restarted mid-flight); if it later throws,
169
+ // the catch self-heals.
170
+ const FORCE_STOP_MS = Math.max(STALL_MS * 3, parseInt(process.env.TP_FORCE_STOP_MS, 10) || 300000)
171
+ let forceStopped = false
153
172
 
154
173
  const emit = (evt) => { lastEvtTs = Date.now(); if (evt && evt.kind !== 'stalled') stalledSent = false; try { onEvent?.(evt) } catch { /* never let a consumer throw into the loop */ } }
155
174
  const stallTimer = setInterval(() => {
156
- if (turnActive && !stalledSent && Date.now() - lastEvtTs > STALL_MS) {
175
+ if (!turnActive) return
176
+ const quiet = Date.now() - lastEvtTs
177
+ if (quiet > FORCE_STOP_MS && !forceStopped) {
178
+ // True wedge — no result, no error, just silence. Unblocks between-turns
179
+ // updates + surfaces a recoverable error. The wedged loop is left alone.
180
+ forceStopped = true
181
+ turnActive = false
182
+ emit({ kind: 'error', message: `agent went silent for ${Math.round(quiet / 1000)}s — force-stopped the turn; send again to resume`, recoverable: true })
183
+ return
184
+ }
185
+ if (!stalledSent && quiet > STALL_MS) {
157
186
  stalledSent = true
158
- emit({ kind: 'stalled', sinceMs: Date.now() - lastEvtTs })
187
+ emit({ kind: 'stalled', sinceMs: quiet })
159
188
  }
160
189
  }, 5000)
161
190
  stallTimer.unref?.()
@@ -317,9 +346,9 @@ export function startClaudeSession({ cwd, model, resume, env, mode: initialMode
317
346
  if (healed.blocks) process.stderr.write(`\n ◆ healed ${healed.blocks} cross-provider tool block(s) in the transcript before resume.\n`)
318
347
  }
319
348
 
320
- ;(async () => {
349
+ const runQuery = async () => {
321
350
  try {
322
- q = query({ prompt: input.stream, options: opts })
351
+ q = query({ prompt: input.stream, options: { ...opts, ...(sessionId ? { resume: sessionId } : {}) } })
323
352
  for await (const m of q) {
324
353
  if (closed) break
325
354
  switch (m.type) {
@@ -388,6 +417,8 @@ export function startClaudeSession({ cwd, model, resume, env, mode: initialMode
388
417
  if (m.session_id) sessionId = m.session_id
389
418
  turnBaseOut = 0; curMsgOut = 0 // reset the live token count for the next turn
390
419
  turnActive = false // turn settled → stall watchdog stands down
420
+ restartCount = 0 // a successful turn refills the auto-restart budget
421
+ forceStopped = false
391
422
  emit({ kind: 'result', subtype: m.subtype, sessionId, costUsd: m.total_cost_usd, usage: m.usage, numTurns: m.num_turns, durationMs: m.duration_ms ?? null, denials: Array.isArray(m.permission_denials) ? m.permission_denials.length : 0 })
392
423
  // Surface a usage/context meter (chrome, not a transcript line). The
393
424
  // context window % comes from the control request; cost is cumulative.
@@ -406,9 +437,29 @@ export function startClaudeSession({ cwd, model, resume, env, mode: initialMode
406
437
  }
407
438
  }
408
439
  } catch (e) {
409
- if (!closed) emit({ kind: 'error', message: e?.message || String(e) })
440
+ if (closed) return
441
+ const msg = e?.message || String(e)
442
+ emit({ kind: 'error', message: msg })
443
+ // Auto-restart a transient upstream stream drop: re-run query() with
444
+ // resume so the conversation continues. Bounded by RESTART_MAX (reset on
445
+ // each successful result) so a persistent outage can't loop forever; a
446
+ // fresh input stream is needed because the throw killed the old iterator.
447
+ if (RECOVERABLE.test(msg) && restartCount < RESTART_MAX) {
448
+ restartCount++
449
+ turnActive = false
450
+ emit({ kind: 'note', text: `connection dropped — reconnecting (${restartCount}/${RESTART_MAX})` })
451
+ input = makeInputStream()
452
+ setTimeout(runQuery, Math.min(1000 * restartCount, 4000)) // 1s, 2s, 4s backoff
453
+ return
454
+ }
455
+ // Non-recoverable, or budget exhausted: mark done so sendTurn stops
456
+ // silently queuing into a dead stream. The bridge's onEvent self-heal
457
+ // still fires for "No conversation found" (it reopens fresh).
458
+ closed = true
459
+ turnActive = false
410
460
  }
411
- })()
461
+ }
462
+ runQuery()
412
463
 
413
464
  return {
414
465
  sendTurn(text) { if (!closed) { turnActive = true; lastEvtTs = Date.now(); stalledSent = false; input.push(String(text)) } },
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.40",
3
+ "version": "0.7.42",
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": {