thinkpool-pair 0.7.236 → 0.7.238

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
@@ -51,7 +51,7 @@ import { createSdkMcpServer, tool } from '@anthropic-ai/claude-agent-sdk'
51
51
  import { z } from 'zod'
52
52
  import { readCodexDefaultModel, readCodexModels, codexConfigForMode, codexThreadCanResume } from './codex-session.mjs'
53
53
  import { startStructuredSession } from './runtime-session.mjs'
54
- import { defaultStructuredMode, structuredRuntimeForCommand, structuredRuntimeMetadata, structuredRuntimeSupportsMode } from './runtime-registry.mjs'
54
+ import { defaultStructuredMode, shouldDeferStructuredRuntime, structuredRuntimeForCommand, structuredRuntimeMetadata, structuredRuntimeSupportsMode } from './runtime-registry.mjs'
55
55
  import { probeHermesRuntime } from './hermes-probe.mjs'
56
56
  import { canonicalRoomFilePath, waitForNativeImages } from './codex-images.mjs'
57
57
  import { createManagedLaneWorktree, removeManagedLaneWorktree } from './lane-worktree.mjs'
@@ -1694,7 +1694,7 @@ function worktreeSnapshot(cwd) {
1694
1694
  // relay STRUCTURED events. onEvent → broadcast `code-event` + print locally +
1695
1695
  // persist to the host file; tool calls round-trip through the perm card; the
1696
1696
  // rolling log replays to joiners and survives bridge restarts (session-store).
1697
- function openStructured({ id, runtime = 'claude', model, effort, resume, log, commands, mode, spawnedBy, spawnDepth, cascadeRole, hop, sideParent, sideTask, pendingSideContexts, rolePrompt, flowSessionId, flowTaskKey, flowRole, flowReviewTarget, flowReviewTargets, flowReviewRound, dispatchBaseSha, revertTarget, cwd, managedWorktree, reviewSliceRoots, openedAt, defer, provider, carryRecap, lastUsage }) {
1697
+ function openStructured({ id, runtime = 'claude', model, models, effort, resume, log, commands, mode, spawnedBy, spawnDepth, cascadeRole, hop, sideParent, sideTask, pendingSideContexts, rolePrompt, flowSessionId, flowTaskKey, flowRole, flowReviewTarget, flowReviewTargets, flowReviewRound, dispatchBaseSha, revertTarget, cwd, managedWorktree, reviewSliceRoots, openedAt, defer, provider, carryRecap, lastUsage }) {
1698
1698
  if (sessions.has(id)) return
1699
1699
  runtime = structuredRuntimeMetadata(runtime) ? runtime : 'claude'
1700
1700
  // No explicit mode → a sensible default per runtime (see defaultModeForRuntime):
@@ -1733,7 +1733,7 @@ function openStructured({ id, runtime = 'claude', model, effort, resume, log, co
1733
1733
  ? spawnDepth
1734
1734
  : (sideParent || (spawnedBy && !String(spawnedBy).startsWith('flow:')) ? 1 : 0)
1735
1735
  const initialHop = Number.isInteger(hop) && hop >= 0 ? hop : structuralDepth
1736
- const entry = { cmd: runtime, runtime, 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, effort, models: runtime === 'codex' ? codexModels : undefined,
1736
+ const entry = { cmd: runtime, runtime, 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, effort, models: runtime === 'codex' ? codexModels : runtime === 'hermes' && Array.isArray(models) ? models : undefined,
1737
1737
  // model: truthful active-model label — now the SAME `laneModel` the SDK is given, so the
1738
1738
  // chip cannot disagree with the wire. When this lane runs on a custom (non-anthropic)
1739
1739
  // registered provider the SDK id is impersonated (see the onEvent guard below), so
@@ -1996,7 +1996,7 @@ function openStructured({ id, runtime = 'claude', model, effort, resume, log, co
1996
1996
  // restart. Without this, sessionData omitted it → on restart the resumed session
1997
1997
  // re-launched on the host default (Opus) regardless of the last switch, and the
1998
1998
  // switch looked like it "never changed the model" (Max 2026-07-02). Restored below.
1999
- const sessionData = () => ({ sessionId: entry.session?.sessionId || null, runtime: entry.runtime, log: entry.log, commands: entry.commands, mode: entry.mode, effort: entry.effort, model: entry.model || null, provider: entry.provider || null, spawnedBy: entry.spawnedBy, spawnDepth: entry.spawnDepth || 0, cascadeRole: entry.cascadeRole || null, hop: entry.hop || 0, sideParent: entry.sideParent, sideTask: entry.sideTask, pendingSideContexts: entry.pendingSideContexts, flowSessionId: entry.flowSessionId, flowTaskKey: entry.flowTaskKey, flowRole: entry.flowRole || null, flowReviewTargets: entry.flowReviewTargets || [], flowReviewRound: entry.flowReviewRound || 0, dispatchBaseSha: entry.dispatchBaseSha || null, cwd: entry.cwd, managedWorktree: entry.managedWorktree, rolePrompt: entry.rolePrompt, flowReviewTarget: entry.flowReviewTarget || null, revertTarget: entry.revertTarget || null, reviewSliceRoots: entry.reviewSliceRoots || [], openedAt: entry.openedAt || null, lastUsage: entry.lastUsage || null, carryRecap: entry.pendingRecap || null })
1999
+ const sessionData = () => ({ sessionId: entry.session?.sessionId || null, runtime: entry.runtime, log: entry.log, commands: entry.commands, mode: entry.mode, effort: entry.effort, model: entry.model || null, models: entry.runtime === 'hermes' ? (entry.models || []) : undefined, provider: entry.provider || null, spawnedBy: entry.spawnedBy, spawnDepth: entry.spawnDepth || 0, cascadeRole: entry.cascadeRole || null, hop: entry.hop || 0, sideParent: entry.sideParent, sideTask: entry.sideTask, pendingSideContexts: entry.pendingSideContexts, flowSessionId: entry.flowSessionId, flowTaskKey: entry.flowTaskKey, flowRole: entry.flowRole || null, flowReviewTargets: entry.flowReviewTargets || [], flowReviewRound: entry.flowReviewRound || 0, dispatchBaseSha: entry.dispatchBaseSha || null, cwd: entry.cwd, managedWorktree: entry.managedWorktree, rolePrompt: entry.rolePrompt, flowReviewTarget: entry.flowReviewTarget || null, revertTarget: entry.revertTarget || null, reviewSliceRoots: entry.reviewSliceRoots || [], openedAt: entry.openedAt || null, lastUsage: entry.lastUsage || null, carryRecap: entry.pendingRecap || null })
2000
2000
  const persist = () => saveSession(room, id, sessionData())
2001
2001
  // Synchronous flush of this session's record. Used on open (so a brand-new session
2002
2002
  // has a file under its id BEFORE its first event — surviving a restart inside the
@@ -2486,7 +2486,7 @@ function openStructured({ id, runtime = 'claude', model, effort, resume, log, co
2486
2486
  },
2487
2487
  // defer: a restored-idle terminal cold-boots its query only on first use — restore is
2488
2488
  // then instant for display, and only used terminals pay the ~50s MCP boot (Max, 2026-07-02).
2489
- lazy: !!defer || (!cwd && !flowSessionId && !flowTaskKey),
2489
+ lazy: shouldDeferStructuredRuntime({ runtime, defer, cwd, flowSessionId, flowTaskKey, models: entry.models }),
2490
2490
  reviewGate,
2491
2491
  // A Flow CONDUCTOR (flowSessionId set, no flowTaskKey) must DECOMPOSE, not explore via
2492
2492
  // a fan-out of Task/Explore subagents, and must not build. Hard-block Task/Bash/Write for
@@ -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
@@ -3782,7 +3808,7 @@ channel
3782
3808
  // FL-M6 — restore the flow context (id/role/cwd) so an in-flight flow survives a
3783
3809
  // bridge restart: the conductor keeps its subagent-block + plan interception, and
3784
3810
  // lanes keep their worktree cwd + the ability to mark done.
3785
- openStructured({ id: rec.id, runtime: rec.runtime || 'claude', model: rec.model || undefined, effort: rec.effort, provider: rec.runtime === 'claude' ? rec.provider || undefined : undefined, resume: resumable ? rec.sessionId : undefined, log: rec.log, commands: rec.commands, mode: rec.mode, spawnedBy: rec.spawnedBy, spawnDepth: rec.spawnDepth, cascadeRole: rec.cascadeRole, hop: rec.hop, sideParent: rec.sideParent, sideTask: rec.sideTask, pendingSideContexts: rec.pendingSideContexts, flowSessionId: rec.flowSessionId, flowTaskKey: rec.flowTaskKey, flowRole: rec.flowRole, flowReviewTarget: rec.flowReviewTarget, flowReviewTargets: rec.flowReviewTargets, flowReviewRound: rec.flowReviewRound, dispatchBaseSha: rec.dispatchBaseSha, revertTarget: rec.revertTarget, cwd: rec.cwd, managedWorktree: rec.managedWorktree, rolePrompt: rec.rolePrompt, reviewSliceRoots: rec.reviewSliceRoots, openedAt: rec.openedAt, lastUsage: rec.lastUsage, carryRecap: wasInterrupted ? recoveryRecap : rec.carryRecap,
3811
+ openStructured({ id: rec.id, runtime: rec.runtime || 'claude', model: rec.model || undefined, models: rec.models, effort: rec.effort, provider: rec.runtime === 'claude' ? rec.provider || undefined : undefined, resume: resumable ? rec.sessionId : undefined, log: rec.log, commands: rec.commands, mode: rec.mode, spawnedBy: rec.spawnedBy, spawnDepth: rec.spawnDepth, cascadeRole: rec.cascadeRole, hop: rec.hop, sideParent: rec.sideParent, sideTask: rec.sideTask, pendingSideContexts: rec.pendingSideContexts, flowSessionId: rec.flowSessionId, flowTaskKey: rec.flowTaskKey, flowRole: rec.flowRole, flowReviewTarget: rec.flowReviewTarget, flowReviewTargets: rec.flowReviewTargets, flowReviewRound: rec.flowReviewRound, dispatchBaseSha: rec.dispatchBaseSha, revertTarget: rec.revertTarget, cwd: rec.cwd, managedWorktree: rec.managedWorktree, rolePrompt: rec.rolePrompt, reviewSliceRoots: rec.reviewSliceRoots, openedAt: rec.openedAt, lastUsage: rec.lastUsage, carryRecap: wasInterrupted ? recoveryRecap : rec.carryRecap,
3786
3812
  // Lazy-boot restored terminals that were IDLE + not part of a flow: their transcript
3787
3813
  // shows immediately; the query boots on first turn. Mid-turn + flow terminals boot now
3788
3814
  // (mid-turn needs auto-resume; flow needs its lane live).
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.238",
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, catalogRequiresSession: true, effortControl: false, defaultMode: 'default',
17
17
  modes: Object.freeze(['default', 'acceptEdits']),
18
18
  beta: true,
19
19
  }),
@@ -28,3 +28,12 @@ export const structuredRuntimeForCommand = (command) => {
28
28
  export const defaultStructuredMode = (runtime) => structuredRuntimeMetadata(runtime)?.defaultMode || 'default'
29
29
  export const structuredRuntimeSupportsMode = (runtime, mode) => structuredRuntimeMetadata(runtime)?.modes?.includes(mode) === true
30
30
  export const structuredRuntimeSupportsFlow = (runtime) => structuredRuntimeMetadata(runtime)?.flow === true
31
+ export const shouldDeferStructuredRuntime = ({ runtime, defer, cwd, flowSessionId, flowTaskKey, models } = {}) => {
32
+ const metadata = structuredRuntimeMetadata(runtime)
33
+ // Some ACP agents publish their model catalog only from session/new or
34
+ // session/resume. A brand-new lane must perform that zero-inference handshake
35
+ // before its first prompt or the model picker has no truthful choices to show.
36
+ // Once the exact catalog is persisted, the normal lazy-start policy resumes.
37
+ if (metadata?.catalogRequiresSession && !(Array.isArray(models) && models.length)) return false
38
+ return !!defer || (!cwd && !flowSessionId && !flowTaskKey)
39
+ }