thinkpool-pair 0.7.236 → 0.7.237

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
@@ -2616,12 +2616,29 @@ function openStructured({ id, runtime = 'claude', model, effort, resume, log, co
2616
2616
  // run on different registered providers. (bridge-truth slice.)
2617
2617
  { const onBuiltin = entry.runtime !== 'hermes' && (!entry.provider || entry.provider === 'anthropic')
2618
2618
  const m = (entry.runtime === 'hermes' || onBuiltin) ? (evt.model || evt.ctx?.model) : null
2619
- if (m && m !== entry.model) { entry.model = m; announce() }
2619
+ if (m && m !== entry.model) { entry.model = m; announce(); if (evt.kind === 'model-switch') persist() }
2620
2620
  // A custom Anthropic-compatible endpoint may expose its own catalog through the
2621
2621
  // same SDK method. Never publish that as the ROOM's Claude catalog: its models
2622
2622
  // already render in the provider group and otherwise leak under ANTHROPIC.
2623
2623
  if (entry.runtime === 'hermes' && evt.kind === 'models' && Array.isArray(evt.models) && evt.models.length) { entry.models = evt.models; announce() }
2624
2624
  else if (onBuiltin && evt.kind === 'models' && Array.isArray(evt.models) && evt.models.length) { roomModels = evt.models; announce() } }
2625
+ // Hermes model changes are asynchronous ACP requests. Keep the previous model
2626
+ // authoritative until Hermes acknowledges session/set_model; then settle the
2627
+ // originating control line exactly once. A rejected model never poisons the
2628
+ // saved session or leaves a cross-device chip claiming the optimistic pick.
2629
+ if (evt.kind === 'model-switch') {
2630
+ const request = entry.pendingModelSwitch
2631
+ entry.pendingModelSwitch = null
2632
+ const requested = evt.requested || request?.model || 'requested model'
2633
+ const text = evt.status === 'success'
2634
+ ? `switched model → ${evt.model || requested}`
2635
+ : `couldn’t switch to ${requested} — still using ${entry.model || 'the previous model'}`
2636
+ const control = { kind: 'control', text, by: request?.by || null, cid: request?.cid || undefined }
2637
+ pushLog(entry, control)
2638
+ bcast('code-event', { term: id, evt: control })
2639
+ entry.flush?.()
2640
+ announce()
2641
+ }
2625
2642
  // Cache the last usage/ctx meter on the entry (persisted + replayed — see
2626
2643
  // openStructured). Chrome events bypass pushLog/persist in emitTail below, so
2627
2644
  // usage needs an explicit persist() here to survive a bridge restart.
@@ -2700,7 +2717,7 @@ function openStructured({ id, runtime = 'claude', model, effort, resume, log, co
2700
2717
  // card) and is intentionally NOT chrome, so it persists + replays.
2701
2718
  // 'suggestion' is Claude Code's predicted-next-prompt ghost text — ephemeral
2702
2719
  // composer UI, broadcast to both clients but never logged/persisted/replayed.
2703
- const chrome = evt.kind === 'mode' || evt.kind === 'usage' || evt.kind === 'clear' || evt.kind === 'compact' || evt.kind === 'stalled' || evt.kind === 'suggestion' || evt.kind === 'assistant_stream'
2720
+ const chrome = evt.kind === 'mode' || evt.kind === 'usage' || evt.kind === 'clear' || evt.kind === 'compact' || evt.kind === 'stalled' || evt.kind === 'suggestion' || evt.kind === 'assistant_stream' || evt.kind === 'model-switch'
2704
2721
  // The compact turn settled with `entry.compacting` STILL TRUE — which, because the
2705
2722
  // `compaction` branch above clears the flag the moment the milestone lands, means
2706
2723
  // exactly one thing: NO compaction happened. Three ways to get here, all of which
@@ -3428,10 +3445,19 @@ channel
3428
3445
  const mm = text.match(/^\/model\b\s*(\S+)?/)
3429
3446
  if (mm) {
3430
3447
  if (mm[1]) {
3448
+ if (s.runtime === 'hermes' && s.pendingModelSwitch) {
3449
+ ctlLine(`finish the current Hermes model switch before choosing another model`)
3450
+ return
3451
+ }
3431
3452
  if (s.session.setModel(mm[1]) === false) {
3432
3453
  ctlLine(`finish or stop the current ${structuredRuntimeMetadata(s.runtime)?.label || 'agent'} turn before switching models`)
3433
3454
  return
3434
3455
  }
3456
+ if (s.runtime === 'hermes') {
3457
+ s.pendingModelSwitch = { model: mm[1], by: payload.by, cid: payload.cid }
3458
+ bcast('code-event', { term: payload.term, evt: { kind: 'model-switch', status: 'pending', requested: mm[1], ts: Date.now() } })
3459
+ return
3460
+ }
3435
3461
  // Reflect the switch in the announce IMMEDIATELY (before any turn). The SDK
3436
3462
  // only echoes the resolved full id on the next turn, and meta/ctx model go
3437
3463
  // stale until then, so without this the chip snapped back to the old model
package/event-id.mjs CHANGED
@@ -61,7 +61,7 @@ export function makeSeqCounter(startAfter = 0) {
61
61
  // consumed a seq + set the durable archive floor while the client dropped them, leaving
62
62
  // the client's oldest RENDERED seq above the floor → a phantom "load earlier history"
63
63
  // pill on a fresh lane (models fires on every session init) that fetched nothing.
64
- export const NO_SEQ_KINDS = new Set(['system', 'thinking_tokens', 'effort', 'models', 'queue-add', 'queue-cancel', 'propose-done', 'update-available'])
64
+ export const NO_SEQ_KINDS = new Set(['system', 'thinking_tokens', 'effort', 'models', 'model-switch', 'queue-add', 'queue-cancel', 'propose-done', 'update-available'])
65
65
  export const seqable = (evt) => !!evt && typeof evt === 'object' && !NO_SEQ_KINDS.has(evt.kind)
66
66
 
67
67
  // Highest numeric `seq` across a log of events (0 when none) — the floor a restored
@@ -66,6 +66,7 @@ export function startHermesSession({
66
66
  const abortedTurns = new Set()
67
67
  let retireClient = () => {}
68
68
  let resuming = false
69
+ let modelSwitchPending = false
69
70
 
70
71
  const emit = (event) => { try { onEvent?.(event) } catch { /* consumer isolation */ } }
71
72
 
@@ -277,12 +278,16 @@ export function startHermesSession({
277
278
  void mcpHttp?.close().catch(() => {})
278
279
  },
279
280
  setModel(nextModel) {
280
- if (!nextModel || turnActive) return false
281
- void boot().then(() => client.request('session/set_model', { sessionId, modelId: String(nextModel) })).then(() => {
282
- activeModel = String(nextModel)
281
+ if (!nextModel || turnActive || modelSwitchPending) return false
282
+ const requested = String(nextModel)
283
+ modelSwitchPending = true
284
+ void boot().then(() => client.request('session/set_model', { sessionId, modelId: requested })).then(() => {
285
+ if (ended) return
286
+ activeModel = requested
283
287
  if (mapper) mapper.model = activeModel
284
- emit({ kind: 'note', text: `model → ${activeModel}` })
285
- }).catch((error) => emit({ kind: 'error', message: `Hermes model switch failed: ${error?.message || error}`, recoverable: true }))
288
+ emit({ kind: 'model-switch', status: 'success', model: activeModel, requested })
289
+ }).catch((error) => { if (!ended) emit({ kind: 'model-switch', status: 'failed', model: activeModel, requested, message: `Hermes model switch failed: ${error?.message || error}` }) })
290
+ .finally(() => { modelSwitchPending = false })
286
291
  return true
287
292
  },
288
293
  setMode(nextMode) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thinkpool-pair",
3
- "version": "0.7.236",
3
+ "version": "0.7.237",
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": {
@@ -3,17 +3,17 @@ import path from 'node:path'
3
3
  const RUNTIMES = Object.freeze({
4
4
  claude: Object.freeze({
5
5
  id: 'claude', command: 'claude', label: 'Claude Code', protocol: 'agent-sdk',
6
- structured: true, flow: true, canSteer: false, images: true, nativeModelCatalog: false, defaultMode: 'default',
6
+ structured: true, flow: true, canSteer: false, images: true, nativeModelCatalog: false, effortControl: true, defaultMode: 'default',
7
7
  modes: Object.freeze(['default', 'acceptEdits', 'plan', 'bypassPermissions']),
8
8
  }),
9
9
  codex: Object.freeze({
10
10
  id: 'codex', command: 'codex', label: 'Codex', protocol: 'codex-app-server',
11
- structured: true, flow: true, canSteer: true, images: true, nativeModelCatalog: true, defaultMode: 'bypassPermissions',
11
+ structured: true, flow: true, canSteer: true, images: true, nativeModelCatalog: true, effortControl: true, defaultMode: 'bypassPermissions',
12
12
  modes: Object.freeze(['default', 'acceptEdits', 'plan', 'bypassPermissions', 'review']),
13
13
  }),
14
14
  hermes: Object.freeze({
15
15
  id: 'hermes', command: 'thinkpool', label: 'Hermes Agent', protocol: 'acp',
16
- structured: true, flow: false, canSteer: true, images: true, nativeModelCatalog: true, defaultMode: 'default',
16
+ structured: true, flow: false, canSteer: true, images: true, nativeModelCatalog: true, effortControl: false, defaultMode: 'default',
17
17
  modes: Object.freeze(['default', 'acceptEdits']),
18
18
  beta: true,
19
19
  }),