thinkpool-pair 0.7.177 → 0.7.178

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
@@ -16,7 +16,7 @@ import os from 'node:os'
16
16
  import { saveAuth, loadAuth, loadDirs, bindDir, loadServed, rememberServed, loadDefaultDir, saveDefaultDir, claudeRecentProjectDir } from './auth-store.mjs'
17
17
  import { isSafeToRestart } from './update-gate.mjs'
18
18
  import { makeThrottledTrack, presenceSelfEchoVerdict } from './presence.mjs'
19
- import { publicKeyB64, announceProviders, listProviders, addProvider, removeProvider, unseal } from './providers.mjs'
19
+ import { publicKeyB64, announceProviders, listProviders, addProvider, addProviderModel, removeProvider, unseal } from './providers.mjs'
20
20
  import { supervisorServes } from './serve-consent.mjs'
21
21
  import { resolveServeDir } from './serve-dir.mjs'
22
22
  import { pairKeyFor, pairTopic, CROSSROOM_BUS } from './cross-terminal.mjs'
@@ -579,6 +579,17 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
579
579
  if (r.ok) { pushPresence(); process.stderr.write(`\n ◆ provider added (${String(fields?.name || '').slice(0, 40)}) — re-announced.\n`) }
580
580
  provReply('provider-add-res', { nonce, ok: r.ok, error: r.ok ? undefined : r.error, id: r.ok ? r.id : undefined })
581
581
  })
582
+ // provider-add-model {id, model, name?, nonce, jwt} → clone the source row's
583
+ // baseUrl + key HOST-SIDE onto a new model, re-announce. Deliberately carries NO
584
+ // `sealed` envelope: the key never leaves the bridge for this op, so there is
585
+ // nothing to decrypt. Owner-authed exactly like its siblings.
586
+ acct.on('broadcast', { event: 'provider-add-model' }, async ({ payload }) => {
587
+ const nonce = payload?.nonce
588
+ if (!(await isOwner(payload?.jwt))) return provReply('provider-add-model-res', { nonce, ok: false, error: 'unauthorized' })
589
+ const r = addProviderModel({ id: payload?.id, model: payload?.model, name: payload?.name })
590
+ if (r.ok) { pushPresence(); process.stderr.write(`\n ◆ model added to an existing provider (${String(payload?.model || '').slice(0, 40)}) — re-announced.\n`) }
591
+ provReply('provider-add-model-res', { nonce, ok: r.ok, error: r.ok ? undefined : r.error, id: r.ok ? r.id : undefined })
592
+ })
582
593
  // provider-remove {id, nonce, jwt} → remove (built-in refuses), re-announce.
583
594
  acct.on('broadcast', { event: 'provider-remove' }, async ({ payload }) => {
584
595
  const nonce = payload?.nonce
package/bridge.mjs CHANGED
@@ -45,7 +45,7 @@ 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 } from './providers.mjs'
48
+ import { resolveProviderEnv, providerNameMap, publicKeyB64, announceProviders, listProviders, addProvider, removeProvider, unseal, effectiveLaneModel } from './providers.mjs'
49
49
  import { validateProviderSwitch, BUILTIN_PROVIDER } from './switch-provider.mjs'
50
50
  import { createSdkMcpServer, tool } from '@anthropic-ai/claude-agent-sdk'
51
51
  import { z } from 'zod'
@@ -1568,18 +1568,28 @@ function worktreeSnapshot(cwd) {
1568
1568
  // rolling log replays to joiners and survives bridge restarts (session-store).
1569
1569
  function openStructured({ id, model, resume, log, commands, mode, spawnedBy, rolePrompt, flowSessionId, flowTaskKey, cwd, reviewSliceRoots, openedAt, defer, provider, carryRecap }) {
1570
1570
  if (sessions.has(id)) return
1571
+ // The lane's EFFECTIVE model — computed ONCE and used for BOTH the truthful display
1572
+ // label (entry.model) and the SDK's `model` option below. These used to be computed
1573
+ // separately: the label resolved the provider's configured model while the SDK got the
1574
+ // RAW inherited `model`, which OVERRIDES ANTHROPIC_MODEL from resolveProviderEnv. A GLM
1575
+ // lane opened while focused on Fable therefore showed `glm-4.6` and sent
1576
+ // `claude-fable-5` → 400 [1211][Unknown Model]. One value, one truth.
1577
+ // undefined → omit the SDK option entirely and let the provider env/endpoint default.
1578
+ const laneModel = effectiveLaneModel({ provider, model, configuredModel: resolveProviderEnv(provider)?.ANTHROPIC_MODEL })
1571
1579
  // spawnedBy: set when this lane was Dispatched (spawn_terminal). Restored from the
1572
1580
  // session store so the Ensemble flag survives a bridge restart (else a respin
1573
1581
  // stripped it and the lane reverted to a plain tab — the t6 "no chip" bug).
1574
1582
  const entry = { cmd: 'claude', kind: 'structured', log: Array.isArray(log) ? log.slice(-STRUCTURED_LOG_MAX) : [], pending: new Map(), session: null, recovered: false, commands: Array.isArray(commands) ? commands : undefined, mode,
1575
- // model: truthful active-model label. When this lane runs on a custom (non-anthropic)
1576
- // registered provider the SDK id is impersonated (see the onEvent guard below), so seed
1577
- // from THAT provider's CONFIGURED model — glm-5.2, not a Claude label — before any turn.
1578
- // No configured model → the provider's name, never a false Claude id. Built-in Anthropic
1579
- // keeps the passed model. Keyed on the lane's own provider id, not the bridge default.
1583
+ // model: truthful active-model label — now the SAME `laneModel` the SDK is given, so the
1584
+ // chip cannot disagree with the wire. When this lane runs on a custom (non-anthropic)
1585
+ // registered provider the SDK id is impersonated (see the onEvent guard below), so
1586
+ // laneModel resolves from THAT provider's CONFIGURED model — glm-4.6, not a Claude label.
1587
+ // No configured model → the provider's NAME, never a false Claude id (an inherited
1588
+ // claudeish id was dropped upstream by effectiveLaneModel; it must not sneak back in as a
1589
+ // label fallback here). Built-in Anthropic keeps the passed model.
1580
1590
  model: (!provider || provider === 'anthropic')
1581
- ? (model || null)
1582
- : (resolveProviderEnv(provider)?.ANTHROPIC_MODEL || providerNameMap()[provider] || model || provider),
1591
+ ? (laneModel || null)
1592
+ : (laneModel || providerNameMap()[provider] || provider),
1583
1593
  provider: provider || null, spawnedBy: spawnedBy || undefined, flowSessionId: flowSessionId || null, flowTaskKey: flowTaskKey || null, cwd: cwd || null,
1584
1594
  // Stable creation order — persisted so a bridge restart restores tabs in the SAME
1585
1595
  // order (not readdir/filesystem order). Legacy recs (no openedAt) derive it from the
@@ -2095,7 +2105,15 @@ function openStructured({ id, model, resume, log, commands, mode, spawnedBy, rol
2095
2105
  // is silently lost (the 2026-07-02 auto-resume bug). Empty string → nothing to carry.
2096
2106
  entry.pendingRecap = (typeof carryRecap === 'string' && carryRecap.trim()) ? carryRecap : null
2097
2107
  entry.session = startClaudeSession({
2098
- cwd: cwd || process.cwd(), model, resume, mode, rolePrompt,
2108
+ // laneModel, NOT the raw `model` param: the SDK's `model` option OVERRIDES the
2109
+ // ANTHROPIC_MODEL supplied by resolveProviderEnv() in `env` below, so an inherited
2110
+ // Claude id here defeats the whole per-provider env. undefined → option omitted.
2111
+ cwd: cwd || process.cwd(), model: laneModel, resume, mode, rolePrompt,
2112
+ // Haiku composer-prefill is an Anthropic-subscription freebie. This lane's env carries a
2113
+ // CUSTOM provider's base URL + auth token, so on a non-anthropic lane that call would bill
2114
+ // the user's own BYOK key for a feature they didn't ask for (and 400 on strict providers).
2115
+ // Same lane-provider key as the model-truth guard in onEvent below.
2116
+ suggest: !provider || provider === 'anthropic',
2099
2117
  // Live ROOM NOW tail appended to every turn's <system-reminder>: sibling lanes
2100
2118
  // (same registries the read_terminal roster uses) + active git worktrees (only
2101
2119
  // when >1 — a solo checkout has nothing to coordinate about). Keeps every lane
@@ -176,6 +176,8 @@ const simplifyBlocks = (blocks = []) => blocks.map((b) => {
176
176
  * @param {(req)=>Promise<'allow'|'deny'>} o.requestPermission
177
177
  * called for EVERY tool call with { id, toolName, input, risk };
178
178
  * resolve 'allow'/'deny'. (Caller implements any auto-allow policy.)
179
+ * @param {boolean=} o.suggest allow the Haiku composer-prefill fallback.
180
+ * MUST be false on a lane running a custom provider — see haikuSuggest.
179
181
  * @returns {{ sendTurn(text), abort(), end(), readonly sessionId }}
180
182
  */
181
183
  const MODES = new Set(['default', 'acceptEdits', 'plan', 'bypassPermissions'])
@@ -203,7 +205,7 @@ const TP_ROOM_REMINDER = [
203
205
  'Verify before claiming done — show runtime evidence you produced, not "should work, go test it".',
204
206
  ].join(' ')
205
207
 
206
- export function startClaudeSession({ cwd, model, resume, env, mode: initialMode = 'default', onEvent, requestPermission, mcpServers, crossPostGate, crossRoomPostGate, didSpawnTarget = null, rolePrompt, blockSubagents = false, onSubmitPlan = null, onLaneDone = null, onReviewVerdict = null, reviewGate = null, lazy = false, roomContext = null }) {
208
+ export function startClaudeSession({ cwd, model, resume, env, mode: initialMode = 'default', onEvent, requestPermission, mcpServers, crossPostGate, crossRoomPostGate, didSpawnTarget = null, rolePrompt, blockSubagents = false, onSubmitPlan = null, onLaneDone = null, onReviewVerdict = null, reviewGate = null, lazy = false, roomContext = null, suggest = true }) {
207
209
  // Per-turn reminder + live ROOM NOW tail. roomContext (bridge-supplied) returns the
208
210
  // room's CURRENT state — sibling lanes, active git worktrees — or null. The static
209
211
  // rules keep the agent aware of the room's FEATURES; the live tail keeps it aware of
@@ -746,7 +748,16 @@ export function startClaudeSession({ cwd, model, resume, env, mode: initialMode
746
748
  // (opts.env carries the OAuth — no API key needed), but as a BARE model call: no
747
749
  // settingSources, no MCP, no tools — so it's a fast cold call, not a full session boot.
748
750
  // Fed only `lastAssistantText` (trimmed). Never blocks a turn; any failure is silent.
751
+ //
752
+ // BUILT-IN ANTHROPIC PATH ONLY (`suggest`). On a lane running a registered custom
753
+ // provider, opts.env carries that provider's ANTHROPIC_BASE_URL + ANTHROPIC_AUTH_TOKEN —
754
+ // so this hardcoded `claude-haiku-4-5` call would spend the USER'S OWN BYOK credit on a
755
+ // composer prefill they never asked for, at whatever (wrong, flagship) model the endpoint
756
+ // maps the unknown id to. z.ai silently serves glm for any `claude-*` id, which is exactly
757
+ // why this went unnoticed; a stricter provider 400s. This is an Anthropic-subscription
758
+ // freebie — it does not get a per-provider small model, it gets switched off.
749
759
  const haikuSuggest = async () => {
760
+ if (!suggest) return
750
761
  const seed = (lastAssistantText || '').trim().slice(-1500)
751
762
  if (!seed || closed) return
752
763
  const ac2 = new AbortController()
package/event-id.mjs CHANGED
@@ -215,6 +215,14 @@ export function summarizeUsage(logEvents) {
215
215
  return { turns: results.length, per: [...per.values()], totalIn, totalOut, totalCost, haveCost }
216
216
  }
217
217
 
218
+ // Does this model id name a Claude/Anthropic model? Used by two callers with the
219
+ // same question and NO room for a second opinion:
220
+ // • usageReportLine — token/cost reporting is Claude-only.
221
+ // • providers.mjs/effectiveLaneModel — a lane on a CUSTOM (non-anthropic) provider
222
+ // must never be handed an inherited Claude id; the endpoint 400s on it.
223
+ // Exported (not a local const) precisely so there is exactly one definition.
224
+ export const claudeish = (m) => /claude|anthropic|opus|sonnet|haiku|fable/i.test(String(m || ''))
225
+
218
226
  // model id → friendly label for the /usage report: claude-opus-4-8 → "Opus 4.8".
219
227
  // Bridge-local twin of the web's prettyModel (structured.jsx); falls back to the raw id.
220
228
  export function prettyModelId (m) {
@@ -229,7 +237,6 @@ export function prettyModelId (m) {
229
237
  // handler is a thin wrapper: ctlLine(usageReportLine(s.model, s.log)). `sessionModel` is
230
238
  // the resolved model id the /model state tracks; `logEvents` the session's retained log.
231
239
  export function usageReportLine (sessionModel, logEvents) {
232
- const claudeish = (m) => /claude|anthropic|opus|sonnet|haiku|fable/i.test(String(m || ''))
233
240
  const sum = summarizeUsage(logEvents)
234
241
  // Gate (Max's ask: "make /usage work if users bridge is on claude llms"). The Code bridge
235
242
  // runs the Claude Agent SDK, so a normal lane IS a Claude model — but a session pointed at a
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thinkpool-pair",
3
- "version": "0.7.177",
3
+ "version": "0.7.178",
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
@@ -42,6 +42,7 @@ import os from 'node:os'
42
42
  import fs from 'node:fs'
43
43
  import path from 'node:path'
44
44
  import crypto from 'node:crypto'
45
+ import { claudeish } from './event-id.mjs'
45
46
 
46
47
  const DIR = path.join(os.homedir(), '.thinkpool-pair')
47
48
  const REG_FILE = path.join(DIR, 'providers.json')
@@ -180,6 +181,59 @@ export function addProvider(fields) {
180
181
  return { ok: true, id }
181
182
  }
182
183
 
184
+ /**
185
+ * Add ANOTHER model to an already-registered provider — without the user ever
186
+ * retyping the key (Max, 2026-07-09). The registry's unit is a {provider,model}
187
+ * pair, not a provider, and `buildSwitchMenu` already groups rows by brand
188
+ * ("Z.ai GLM 4.5" / "Z.ai GLM Air" collapse under one "z.ai" header) — so a new
189
+ * model is simply a CLONE of the source row with a fresh id and a new model.
190
+ * No schema change, no new menu shape.
191
+ *
192
+ * The clone copies `key` + `baseUrl` HOST-SIDE from the row that already holds
193
+ * them, so no key material crosses the wire for this operation — the caller's
194
+ * RPC carries no `sealed` envelope at all (see account.mjs `provider-add-model`).
195
+ *
196
+ * @param {{id:string, model:string, name?:string}} fields id = the SOURCE provider
197
+ * @returns {{ok:true,id:string} | {ok:false,error:string}}
198
+ */
199
+ export function addProviderModel({ id, model, name } = {}) {
200
+ if (!id || id === BUILTIN_ID) return { ok: false, error: 'the built-in Anthropic provider has no key to reuse' }
201
+ if (!model || typeof model !== 'string' || !model.trim()) return { ok: false, error: 'model is required' }
202
+ const arr = loadProviders()
203
+ const src = arr.find((p) => p.id === id)
204
+ if (!src) return { ok: false, error: 'no such provider' }
205
+
206
+ const nextModel = String(model).trim()
207
+ // An endpoint+model pair is the identity of a row — a second row for the same
208
+ // pair would render as a duplicate menu entry that switches to nothing new.
209
+ if (arr.some((p) => p.baseUrl === src.baseUrl && (p.model || '') === nextModel)) {
210
+ return { ok: false, error: `${nextModel} is already registered on this provider` }
211
+ }
212
+
213
+ const nextName = (name && String(name).trim()) || `${brandOf(src.name)} ${nextModel}`.trim()
214
+ const newId = crypto.randomUUID()
215
+ arr.push({
216
+ id: newId,
217
+ name: nextName,
218
+ baseUrl: src.baseUrl, // copied host-side
219
+ model: nextModel,
220
+ key: src.key, // copied host-side — never re-sent by the client
221
+ addedAt: Date.now(),
222
+ })
223
+ saveProviders(arr)
224
+ return { ok: true, id: newId }
225
+ }
226
+
227
+ /**
228
+ * First name token — the brand, matching code-constants.js `brandKey`. "Z.ai GLM
229
+ * 4.5" → "Z.ai". Used to derive a default name for a cloned row so the switch
230
+ * menu groups it under the SAME header as its source.
231
+ */
232
+ function brandOf(name) {
233
+ const first = String(name || '').trim().split(/\s+/)[0]
234
+ return first || 'Provider'
235
+ }
236
+
183
237
  /**
184
238
  * Remove a provider by id. The built-in `anthropic` refuses.
185
239
  * @returns {{ok:true} | {ok:false,error:string}}
@@ -244,3 +298,35 @@ export function resolveProviderEnv(id) {
244
298
  if (p.model) env.ANTHROPIC_MODEL = p.model
245
299
  return env
246
300
  }
301
+
302
+ /**
303
+ * The ONE model id a lane actually runs on — used for BOTH the display label and the
304
+ * SDK's `model` option, which is the whole point: they drifted, and the SDK option
305
+ * silently OVERRIDES the ANTHROPIC_MODEL env resolveProviderEnv() supplies.
306
+ *
307
+ * The bug this closes: a new lane inherits the focused lane's model (client-side), so
308
+ * opening a GLM lane while on Fable sent `model: 'claude-fable-5'` to
309
+ * https://api.z.ai/api/anthropic → `API Error: 400 [1211][Unknown Model]`, while the
310
+ * chip truthfully read `glm-4.6`. Label and wire disagreed; the wire lost.
311
+ *
312
+ * Rules (custom = any registered, non-built-in provider):
313
+ * • built-in anthropic / no provider → passthrough, unchanged. Default path is sacred.
314
+ * • custom + model equals the configured model → honour it.
315
+ * • custom + model is a Claude id (claudeish) → DROP it. It leaked across a provider
316
+ * boundary and the endpoint cannot serve it. Fall back to the configured model, or
317
+ * to undefined (no `model` option → the env / endpoint default decides).
318
+ * • custom + explicit non-Claude model (glm-4.5, deepseek-…) → honour it. This is a
319
+ * deliberate pick (/model, spawn_terminal({provider, model})), not an inheritance leak.
320
+ *
321
+ * NEVER returns an inherited Claude id on a custom provider — that is the invariant.
322
+ * Pure: `configuredModel` is passed in (not read from disk) so it is directly testable.
323
+ *
324
+ * @param {{provider?:string|null, model?:string|null, configuredModel?:string|null}} a
325
+ * @returns {string|undefined} the model to send; undefined → omit the SDK `model` option
326
+ */
327
+ export function effectiveLaneModel({ provider, model, configuredModel } = {}) {
328
+ if (!provider || provider === BUILTIN_ID) return model || undefined
329
+ if (model && model === configuredModel) return model
330
+ if (model && !claudeish(model)) return model
331
+ return configuredModel || undefined
332
+ }