thinkpool-pair 0.7.40 → 0.7.41

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
 
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.41",
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": {