thinkpool-pair 0.7.178 → 0.7.179

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
@@ -45,8 +45,8 @@ import { createPermNotifier, shouldNotifyTurnDone, permissionSummary, clipSummar
45
45
  // resolveProviderEnv(id) → {ANTHROPIC_BASE_URL,ANTHROPIC_AUTH_TOKEN,ANTHROPIC_MODEL} for a
46
46
  // registered custom provider, or null for the built-in/unknown (leave the default env intact).
47
47
  // Multi-provider BYOK slice 1: a lane spawned with a `provider` id runs on that endpoint.
48
- import { resolveProviderEnv, providerNameMap, publicKeyB64, announceProviders, listProviders, addProvider, removeProvider, unseal, effectiveLaneModel } from './providers.mjs'
49
- import { validateProviderSwitch, BUILTIN_PROVIDER } from './switch-provider.mjs'
48
+ import { resolveProviderEnv, providerNameMap, publicKeyB64, announceProviders, listProviders, addProvider, removeProvider, unseal, effectiveLaneModel, sameProviderEnv, providerModel } from './providers.mjs'
49
+ import { validateProviderSwitch, providerSwitchPlan, BUILTIN_PROVIDER } from './switch-provider.mjs'
50
50
  import { createSdkMcpServer, tool } from '@anthropic-ai/claude-agent-sdk'
51
51
  import { z } from 'zod'
52
52
  import { startClaudeSession } from './claude-session.mjs'
@@ -2473,7 +2473,16 @@ function respawnStructured(id, provider) {
2473
2473
  const s = sessions.get(id)
2474
2474
  if (!s) return false
2475
2475
  // Capture the lane's identity for re-open (NOT resume — fresh SDK session).
2476
- const { model, log, commands, mode, spawnedBy, flowSessionId, flowTaskKey, cwd, rolePrompt, reviewSliceRoots, openedAt } = s
2476
+ // NOTE the deliberate absence of `model`: a respawn crosses a provider boundary,
2477
+ // and the old lane's model id means nothing on the new backend. Carrying it sent
2478
+ // `glm-4.6` to Anthropic on a GLM→Claude switch, and — once effectiveLaneModel
2479
+ // started honouring explicit non-Claude ids — pinned a GLM→GLM-5.2 switch back to
2480
+ // glm-4.6, so the lane truthfully labelled the model it was wrongly still running
2481
+ // (Max, 2026-07-09: "selecting 5.2 still shows 4.6"). Omitting it lets
2482
+ // openStructured seed from the TARGET provider's configured model, which is the
2483
+ // only model this lane was ever asked for. A same-env model change never reaches
2484
+ // here — that path is an in-place setModel (see provider-switch).
2485
+ const { log, commands, mode, spawnedBy, flowSessionId, flowTaskKey, cwd, rolePrompt, reviewSliceRoots, openedAt } = s
2477
2486
  // Context-carry (2026-07-08): a provider switch is not an SDK resume — the new backend
2478
2487
  // starts blank mid-conversation. Synthesize a plain-text recap from the VISIBLE log NOW
2479
2488
  // (before teardown) and hand it to the fresh session as its first turn so the agent
@@ -2491,7 +2500,33 @@ function respawnStructured(id, provider) {
2491
2500
  // sessionData() (provider included) synchronously on open, so a bridge restart
2492
2501
  // restores the lane on its CURRENT provider, not the original — and its next
2493
2502
  // announce carries the new provider badge (additive {id,name} projection).
2494
- openStructured({ id, model, provider, log, commands, mode, spawnedBy, flowSessionId, flowTaskKey, cwd, rolePrompt, reviewSliceRoots, openedAt, carryRecap })
2503
+ openStructured({ id, provider, log, commands, mode, spawnedBy, flowSessionId, flowTaskKey, cwd, rolePrompt, reviewSliceRoots, openedAt, carryRecap })
2504
+ return true
2505
+ }
2506
+
2507
+ /**
2508
+ * Switch a lane between two providers that share an endpoint AND a key — i.e. two
2509
+ * registry rows for the same z.ai/OpenRouter/proxy account, differing only in model.
2510
+ *
2511
+ * Nothing about the child's env changes except ANTHROPIC_MODEL, and the SDK's `model`
2512
+ * option overrides that anyway, so there is no reason to tear the agent down. setModel()
2513
+ * re-creates the query with `resume`, which keeps the SDK context (and defers a mid-turn
2514
+ * switch to turn-end). The lane keeps its memory; only the model moves.
2515
+ *
2516
+ * Returns false when the lane is gone or the target has no configured model — the caller
2517
+ * falls back to a full respawn rather than leaving the lane on a stale model.
2518
+ */
2519
+ function switchModelInPlace(id, provider) {
2520
+ const s = sessions.get(id)
2521
+ if (!s?.session) return false
2522
+ const nextModel = providerModel(provider)
2523
+ if (!nextModel) return false // no model to switch TO → let respawn seed the env
2524
+ s.provider = provider || null
2525
+ s.model = nextModel // truthful label; announce reads this
2526
+ try { s.session.setModel(nextModel) } catch { return false }
2527
+ // Persist through the entry's own closure (sessionData is private to openStructured),
2528
+ // so a bridge restart restores the lane on the row it was switched TO, not the origin.
2529
+ try { s.flush?.() } catch { /* disk full / racing close — the debounced save still runs */ }
2495
2530
  return true
2496
2531
  }
2497
2532
 
@@ -2953,7 +2988,9 @@ channel
2953
2988
  // projection the client already renders from #219). Spec: docs/specs/2026-07-05-provider-switch.md.
2954
2989
  .on('broadcast', { event: 'provider-switch' }, async ({ payload }) => {
2955
2990
  const nonce = payload?.nonce
2956
- const reply = (ok, error) => channel.send({ type: 'broadcast', event: 'provider-switch-res', payload: { nonce, ok, ...(error ? { error } : {}) } })
2991
+ // `restarted` (0.7.179+): did the agent lose its context? false for a same-key model
2992
+ // change, true for a real provider change. Older clients ignore the extra field.
2993
+ const reply = (ok, error, restarted) => channel.send({ type: 'broadcast', event: 'provider-switch-res', payload: { nonce, ok, ...(error ? { error } : {}), ...(restarted === undefined ? {} : { restarted }) } })
2957
2994
  // AUTH first — anon / non-participant / bad jwt → ok:false 'unauthorized'. No lane
2958
2995
  // state is touched for a rejected sender (fail closed before any lookup).
2959
2996
  if (!(await isRoomParticipant(payload?.jwt))) return reply(false, 'unauthorized')
@@ -2968,12 +3005,32 @@ channel
2968
3005
  registeredIds: listProviders().map((p) => p.id),
2969
3006
  })
2970
3007
  if (!v.ok) return reply(false, v.error)
2971
- if (v.noop) return reply(true) // nothing to do; idempotent ok (UI guarantees a change)
2972
3008
  const target = payload?.provider === BUILTIN_PROVIDER ? null : payload?.provider
3009
+
3010
+ // The three-way choice is pure + unit-tested (switch-provider.mjs). Two registry rows
3011
+ // on ONE endpoint + key (Max's glm-4.6 and glm-5.2 on the same z.ai account) are the
3012
+ // same provider asked for a different model: the child's env is identical, so tearing
3013
+ // the agent down and recapping it is pure loss. Only a different endpoint/key earns a
3014
+ // respawn. An in-place attempt that fails (lane raced closed, setModel threw) falls
3015
+ // back to the respawn rather than leaving the lane on a stale model.
3016
+ const plan = providerSwitchPlan({
3017
+ provider: payload?.provider,
3018
+ currentProvider: s.provider,
3019
+ sameEnv: sameProviderEnv(s.provider, target),
3020
+ targetModel: providerModel(target),
3021
+ })
3022
+ if (plan.action === 'noop') return reply(true, undefined, false) // idempotent (UI guarantees a change)
3023
+
3024
+ if (plan.action === 'in-place' && switchModelInPlace(term, target)) {
3025
+ process.stderr.write(`\n ◆ lane ${String(term).slice(0, 8)} switched model → ${providerModel(target)} (same key, context kept).\n`)
3026
+ announce()
3027
+ return reply(true, undefined, false)
3028
+ }
3029
+
2973
3030
  const done = respawnStructured(term, target)
2974
3031
  if (!done) return reply(false, 'no such live lane') // raced closed between the lookup + respawn
2975
3032
  process.stderr.write(`\n ◆ lane ${String(term).slice(0, 8)} switched provider → ${target || 'anthropic'} (memory reset, transcript kept).\n`)
2976
- reply(true)
3033
+ reply(true, undefined, true)
2977
3034
  })
2978
3035
  .subscribe(status => {
2979
3036
  if (status === 'SUBSCRIBED') {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thinkpool-pair",
3
- "version": "0.7.178",
3
+ "version": "0.7.179",
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/providers.mjs CHANGED
@@ -247,6 +247,55 @@ export function removeProvider(id) {
247
247
  return { ok: true }
248
248
  }
249
249
 
250
+ /* ── the provider GROUP token (Max, 2026-07-09) ──────────────────────────
251
+ Two registry rows are the SAME PROVIDER when they point at the same endpoint
252
+ with the same key — they differ only in which model they ask it for. That is
253
+ the identity that matters: it decides whether the switch menu unites them, and
254
+ whether switching between them needs a fresh agent (it doesn't — same env).
255
+
256
+ Before this, the clients grouped on `brandKey(name)`: the first whitespace token
257
+ of the user's DISPLAY NAME, lowercased. "GLM" → `glm`, "GLM-5.2" → `glm5.2`, so
258
+ Max's two models on one z.ai key rendered as two unrelated providers, each
259
+ threatening to restart his agent. Identity was being derived from a label.
260
+
261
+ `group` is a one-way digest of baseUrl + key, NOT either value. It is safe on
262
+ the announce (which deliberately carries neither): a 12-hex prefix reveals only
263
+ *sameness*, and preimaging it means preimaging the key. The built-in Anthropic
264
+ has no stored key, so it gets the literal 'anthropic' — it groups with nothing.
265
+
266
+ NUL-separated so ("https://a.io/x", "key") and ("https://a.io/xkey", "") can't
267
+ collide into one group. */
268
+ export function providerGroup(p) {
269
+ if (!p || p.id === BUILTIN_ID) return BUILTIN_ID
270
+ return crypto.createHash('sha256')
271
+ .update(`${p.baseUrl || ''}\0${p.key || ''}`)
272
+ .digest('hex')
273
+ .slice(0, 12)
274
+ }
275
+
276
+ /**
277
+ * Do these two registered providers share an endpoint AND a key? Then a switch
278
+ * between them changes only ANTHROPIC_MODEL — the child's env is otherwise
279
+ * identical, so the live session can just setModel() and keep its context
280
+ * instead of being torn down and recapped. Built-in ↔ custom is never same-env.
281
+ */
282
+ export function sameProviderEnv(idA, idB) {
283
+ const a = effectiveId(idA), b = effectiveId(idB)
284
+ if (a === BUILTIN_ID || b === BUILTIN_ID) return a === b // both built-in, or one of them
285
+ const arr = loadProviders()
286
+ const pa = arr.find((x) => x.id === a), pb = arr.find((x) => x.id === b)
287
+ if (!pa || !pb) return false // unknown id → never same
288
+ return providerGroup(pa) === providerGroup(pb)
289
+ }
290
+
291
+ const effectiveId = (p) => (!p || p === BUILTIN_ID) ? BUILTIN_ID : String(p)
292
+
293
+ /** The model a registered provider is configured to ask for (null for built-in/unknown). */
294
+ export function providerModel(id) {
295
+ if (!id || id === BUILTIN_ID) return null
296
+ return loadProviders().find((x) => x.id === id)?.model || null
297
+ }
298
+
250
299
  // ── read-only projections (never expose the raw key) ────────────────────
251
300
  /** Masked list for the dashboard: keyHint = last 4 chars only. Built-in first. */
252
301
  export function listProviders() {
@@ -254,15 +303,16 @@ export function listProviders() {
254
303
  id: p.id,
255
304
  name: p.name,
256
305
  model: p.model || null,
306
+ group: providerGroup(p),
257
307
  keyHint: keyHint(p.key),
258
308
  }))
259
- return [{ id: BUILTIN_ID, name: 'Anthropic (Claude)', model: null, keyHint: null }, ...custom]
309
+ return [{ id: BUILTIN_ID, name: 'Anthropic (Claude)', model: null, group: BUILTIN_ID, keyHint: null }, ...custom]
260
310
  }
261
311
 
262
312
  /** Name-only projection for the announce/presence payload — NO key, NO baseUrl. */
263
313
  export function announceProviders() {
264
- const custom = loadProviders().map((p) => ({ id: p.id, name: p.name, model: p.model || null }))
265
- return [{ id: BUILTIN_ID, name: 'Anthropic (Claude)', model: null }, ...custom]
314
+ const custom = loadProviders().map((p) => ({ id: p.id, name: p.name, model: p.model || null, group: providerGroup(p) }))
315
+ return [{ id: BUILTIN_ID, name: 'Anthropic (Claude)', model: null, group: BUILTIN_ID }, ...custom]
266
316
  }
267
317
 
268
318
  /**
@@ -55,3 +55,30 @@ export function validateProviderSwitch({ provider, currentProvider, registeredId
55
55
  const noop = effectiveProvider(id) === effectiveProvider(currentProvider)
56
56
  return { ok: true, noop }
57
57
  }
58
+
59
+ /**
60
+ * WHAT KIND of switch is this? The pure half of the provider-switch handler, so the
61
+ * three-way choice is testable without booting a bridge (Max, 2026-07-09).
62
+ *
63
+ * 'noop' — same provider; nothing to do.
64
+ * 'in-place' — a different registry row on the SAME endpoint + key (two models on
65
+ * one z.ai account). Only ANTHROPIC_MODEL differs, so the live session
66
+ * just setModel()s and KEEPS ITS CONTEXT. Requires a model to switch to.
67
+ * 'respawn' — a genuinely different endpoint or key. Fresh agent, transcript kept,
68
+ * recap carried. This is the only case that costs the user their context.
69
+ *
70
+ * `sameEnv` is supplied by the caller (providers.mjs `sameProviderEnv`) rather than
71
+ * imported, so this module stays free of registry/disk state — the whole point of it.
72
+ * A same-env target with NO configured model falls back to 'respawn': openStructured
73
+ * would then seed the model from the target's env, which an in-place setModel cannot do.
74
+ *
75
+ * @param {{provider:string, currentProvider?:string|null, sameEnv:boolean, targetModel?:string|null}} input
76
+ * @returns {{action:'noop'|'in-place'|'respawn', restarted:boolean}}
77
+ */
78
+ export function providerSwitchPlan({ provider, currentProvider, sameEnv, targetModel }) {
79
+ if (effectiveProvider(provider) === effectiveProvider(currentProvider)) {
80
+ return { action: 'noop', restarted: false }
81
+ }
82
+ if (sameEnv && targetModel) return { action: 'in-place', restarted: false }
83
+ return { action: 'respawn', restarted: true }
84
+ }