thinkpool-pair 0.7.240 → 0.7.242

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
@@ -105,7 +105,7 @@ const flowRedispatch = new Map()
105
105
  // wave BEFORE overrun. Lives bridge-side because waves dispatch across separate
106
106
  // broadcasts; without persistent state the cap can never bite.
107
107
  const flowBudgets = new Map()
108
- import { formatPeek, PEEK, siblingsOf, resolveSibling, crossPostDecision, CROSSPOST, spawnDecision, SPAWN, CROSSROOM, formatPairRoster, crossRoomPostDecision, formatRoomNow, formatClosableHint, laneStatusOf } from './cross-terminal.mjs'
108
+ import { formatPeek, PEEK, siblingsOf, resolveSibling, crossPostDecision, CROSSPOST, spawnDecision, SPAWN, CROSSROOM, formatPairRoster, crossRoomPostDecision, formatRoomNow, formatClosableHint, laneStatusOf, nativeClaudeFallbackHint } from './cross-terminal.mjs'
109
109
  import { dispatchPendingRecap, recoverInterruptedTurn, recoverMissingResumeOnce, sendInterruptedContinue, shouldAttemptNativeResume, supersedeInterruptedResume } from './interrupted-resume.mjs'
110
110
  import { turnInFlight } from './update-gate.mjs'
111
111
  import { saveSession, flushSession, deleteSession, loadAll, canResume, loadPtyId, savePtyId, loadNames, saveNames, appendDurableEvents, seedDurableEvents, readDurablePage, readDurableOldestSeq, hasDurableArchive, servesHistoryPage } from './session-store.mjs'
@@ -114,7 +114,7 @@ import { SIDE_HANDOFF_PROMPT, SIDE_ROLE_PROMPT, appendSideContext, assistantText
114
114
  import { planMeterLine } from './plan-meters.mjs'
115
115
  import { makeThrottledTrack } from './presence.mjs'
116
116
  import { resolveAnonKey, DEFAULT_SUPABASE_URL } from './supabase-key.mjs'
117
- import { buildTerminalRolePrompt } from './thinkpool-room-prompt.mjs'
117
+ import { buildTerminalRolePrompt, HERMES_VISIBLE_WORKER_FALLBACK_RULE } from './thinkpool-room-prompt.mjs'
118
118
 
119
119
  // Public client creds (the same anon values the web app ships — safe to embed).
120
120
  // Override with TP_SUPABASE_URL / TP_SUPABASE_ANON if you ever need to.
@@ -2014,10 +2014,19 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
2014
2014
  const resolveAgentOpen = (args, { worker = false } = {}) => {
2015
2015
  const runtime = args?.runtime || entry.runtime || 'claude'
2016
2016
  if (runtime === 'codex' && args?.provider) return { error: 'A Codex terminal uses its Codex/OpenAI login in v1; custom bridge providers are not wired to Codex yet. Omit provider or open a Claude terminal for that provider.' }
2017
- const catalog = runtime === 'codex' ? readCodexModels() : []
2017
+ if (runtime === 'hermes' && args?.provider) return { error: 'A Hermes terminal uses its isolated Hermes profile and ACP model catalog; bridge provider IDs do not apply. Omit provider and choose an exact Hermes model ID.' }
2018
+ if (runtime === 'hermes' && (!worker || entry.runtime !== 'hermes')) return { error: 'A Hermes worker can only be opened by a top-level Hermes terminal with a live ACP model catalog.' }
2019
+ const catalog = runtime === 'codex' ? readCodexModels() : runtime === 'hermes' ? (entry.models || []) : []
2018
2020
  if (runtime === 'codex' && args?.model && !modelCatalogValues(catalog).has(args.model)) {
2019
2021
  return { error: `Could not open a Codex terminal on ${JSON.stringify(args.model)} — that model is not in this host's visible Codex catalog.` }
2020
2022
  }
2023
+ if (runtime === 'hermes' && !args?.model) return { error: 'Opening a Hermes worker requires an explicit exact model ID from this session\'s ACP catalog.' }
2024
+ if (runtime === 'hermes' && !modelCatalogValues(catalog).has(args.model)) {
2025
+ return { error: `Could not open a Hermes worker on ${JSON.stringify(args.model)} — that exact model is not in this parent session's ACP catalog.` }
2026
+ }
2027
+ if (runtime === 'hermes' && args?.mode && !['default', 'acceptEdits'].includes(args.mode)) {
2028
+ return { error: `Hermes ACP does not structurally support ${JSON.stringify(args.mode)} mode. Use default or acceptEdits; Flow/plan/review safety remains disabled.` }
2029
+ }
2021
2030
  if (runtime === 'claude' && !args?.provider && args?.model && /^gpt-/i.test(args.model)) {
2022
2031
  return { error: `Could not open a Claude terminal on Codex model ${JSON.stringify(args.model)}. Choose runtime="codex" or a Claude model.` }
2023
2032
  }
@@ -2070,7 +2079,11 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
2070
2079
  if (entry.peekCount > PEEK.perTurnCap) {
2071
2080
  return { content: [{ type: 'text', text: `Cross-terminal read limit reached for this turn (${PEEK.perTurnCap}). Continue with what you have, or ask the people in the room.` }] }
2072
2081
  }
2082
+ const sib = siblingsOf({ selfId: id, sessions, terms, names: termNames })
2083
+ const target = args?.terminal ? resolveSibling(sib, args.terminal) : null
2084
+ const targetEntry = target?.kind === 'agent' ? sessions.get(target.id) : null
2073
2085
  const text = formatPeek({ selfId: id, sessions, terms, names: termNames, terminal: args?.terminal, lines: args?.lines })
2086
+ + nativeClaudeFallbackHint({ parentRuntime: entry.runtime, parentModels: entry.models, targetRuntime: targetEntry?.runtime, targetLog: targetEntry?.log })
2074
2087
  return { content: [{ type: 'text', text }] }
2075
2088
  },
2076
2089
  ),
@@ -2252,13 +2265,13 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
2252
2265
  // not receive this tool, so the hierarchy is capability-enforced.
2253
2266
  ...(canSpawnWorkers ? [tool(
2254
2267
  'spawn_terminal',
2255
- 'Open a WORKER SUB-TERMINAL (Claude or Codex) in the ThinkPool Ensemble. spawn_terminal is worker-only: it can never create a Cascade conductor or main terminal. When a person asks for a separate Cascade/conductor terminal, use open_main_terminal instead. The runtime defaults to inheriting this main/conductor terminal, or pass `runtime`. Choose `model` explicitly when practical: Codex Luna/Mini for mechanical work, Terra for ordinary feature/fix work, and a balanced tier for adversarial review; never select Sol for routine workers. Pass sliceType=scaffold for mechanical work, feature/fix for builders, and review for adversarial verification. Give the worker a bounded initial task, collect its result with read_terminal, then ALWAYS close_terminal it.',
2268
+ 'Open a visible WORKER SUB-TERMINAL (Claude, Codex, or—only from a Hermes parent—Hermes) in the ThinkPool Ensemble. spawn_terminal is worker-only: it can never create a Cascade conductor or main terminal. When a person asks for a separate Cascade/conductor terminal, use open_main_terminal instead. The runtime defaults to inheriting this main/conductor terminal, or pass `runtime`. Choose `model` explicitly when practical: Codex Luna/Mini for mechanical work, Terra for ordinary feature/fix work, and a balanced tier for adversarial review; never select Sol for routine workers. A Hermes worker requires an explicit exact model ID from the parent ACP catalog. After a native Claude account/provider failure, do not retry Claude aliases on that provider; follow read_terminal\'s one-time visible Hermes fallback guidance. A Hermes review worker in default mode is permission-gated, not structurally read-only—deny/stop writes and never claim Flow safety. Pass sliceType=scaffold for mechanical work, feature/fix for builders, and review for adversarial verification. Give the worker a bounded initial task, collect its result with read_terminal, then ALWAYS close_terminal it.',
2256
2269
  {
2257
2270
  name: z.string().max(80).optional().describe('a short label for the new lane so the room (and you) can find it, e.g. "Research: topologies"'),
2258
2271
  task: z.string().optional().describe('an initial task to hand the new lane immediately; omit to open it idle'),
2259
- model: z.string().optional().describe('optional model for the chosen runtime, e.g. opus / sonnet / gpt-5.6-sol'),
2272
+ model: z.string().optional().describe('model for the chosen runtime; Hermes workers require an exact ACP catalog ID such as nous:anthropic/claude-sonnet-4.6'),
2260
2273
  sliceType: z.enum(['scaffold', 'feature', 'fix', 'review']).optional().describe('optional cascade slice tier; omitted preserves normal inheritance/default, explicit model wins'),
2261
- runtime: z.enum(['claude', 'codex']).optional().describe('agent runtime for the new lane; defaults to inheriting this lane'),
2274
+ runtime: z.enum(['claude', 'codex', 'hermes']).optional().describe('agent runtime for the new lane; Hermes is allowed only from a top-level Hermes parent with an explicit catalog model'),
2262
2275
  provider: z.string().optional().describe('optional registered LLM provider id to run this lane on (from the account\'s provider registry); omit for the default Claude/Anthropic path'),
2263
2276
  mode: z.enum(['default', 'acceptEdits', 'bypassPermissions', 'plan']).optional().describe('permission mode for the new lane; defaults to inheriting YOUR current mode. Raising a lane to bypassPermissions from a non-bypass lane asks the room to confirm once.'),
2264
2277
  },
@@ -2446,13 +2459,13 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
2446
2459
  // on the init `system` event (see onEvent) — a sendTurn before the input stream is consumed
2447
2460
  // is silently lost (the 2026-07-02 auto-resume bug). Empty string → nothing to carry.
2448
2461
  entry.pendingRecap = (typeof carryRecap === 'string' && carryRecap.trim()) ? carryRecap : null
2449
- const terminalRolePrompt = buildTerminalRolePrompt({
2462
+ const terminalRolePrompt = [buildTerminalRolePrompt({
2450
2463
  spawnedBy: entry.spawnedBy,
2451
2464
  spawnDepth: entry.spawnDepth,
2452
2465
  cascadeRole: entry.cascadeRole,
2453
2466
  flowRole: entry.flowRole,
2454
2467
  sideParent: entry.sideParent,
2455
- })
2468
+ }), runtime === 'hermes' && canSpawnWorkers ? HERMES_VISIBLE_WORKER_FALLBACK_RULE : ''].filter(Boolean).join('\n\n')
2456
2469
  entry.session = startStructuredSession(runtime, {
2457
2470
  // laneModel, NOT the raw `model` param: the SDK's `model` option OVERRIDES the
2458
2471
  // ANTHROPIC_MODEL supplied by resolveProviderEnv() in `env` below, so an inherited
@@ -2513,6 +2526,13 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
2513
2526
  // FL-B4 — a review lane records its verdict via FLOW_REVIEW.json (revert-on-fail).
2514
2527
  onReviewVerdict: entry.flowRole === 'reviewer' ? onReviewVerdict : null,
2515
2528
  mcpServers: { thinkpool: peekServer },
2529
+ // Hermes' ACP MCP registration is intentionally non-fatal upstream. Make
2530
+ // ThinkPool's room-tool contract stricter: a lane must prove its actual
2531
+ // role-scoped tools through local /tools before a user turn. Claude/Codex
2532
+ // already fail startup when their required MCP handshake fails.
2533
+ requiredMcpTools: runtime === 'hermes'
2534
+ ? ['read_terminal', ...(canSpawnWorkers ? ['spawn_terminal', 'close_terminal'] : [])]
2535
+ : undefined,
2516
2536
  // Tier C precheck — the PreToolUse gate calls this BEFORE raising a card, so a
2517
2537
  // disabled/looping/over-cap post never bothers a person. Closes over `entry`.
2518
2538
  crossPostGate: () => crossPostDecision({ hop: entry.hop || 0, postCount: entry.postCount || 0, disabled: process.env.TP_CROSSPOST_OFF === '1' }),
@@ -223,6 +223,47 @@ export const formatPeek = ({ selfId, sessions, terms, names = {}, terminal, line
223
223
  return `Terminal ${label(hit)} (${hit.kind} · ${hit.cmd}) — last ${n} lines:\n${body}`
224
224
  }
225
225
 
226
+ // A native Claude Code account failure is provider-wide, not model-specific. Retrying
227
+ // Sonnet → Opus → Haiku on the same built-in Anthropic path only repeats the same
228
+ // failure and burns orchestration time. Keep this classifier deliberately narrow:
229
+ // transient model/rate/overload errors must not disable the whole provider.
230
+ export const nativeClaudeProviderAccessFailed = (events) => {
231
+ let text = ''
232
+ try { text = typeof events === 'string' ? events : JSON.stringify(events || '') }
233
+ catch { text = String(events || '') }
234
+ return /organization has disabled (?:claude )?subscription access|(?:claude )?subscription access (?:has been |is )?disabled|invalid anthropic (?:api )?key|invalid x-api-key|not logged in to claude|(?:run|use) \/login[^\n]{0,80}claude|no valid anthropic (?:api )?key/i.test(text)
235
+ }
236
+
237
+ const HERMES_CLAUDE_REVIEW_PREFERENCE = Object.freeze([
238
+ 'nous:anthropic/claude-sonnet-4.6',
239
+ 'nous:anthropic/claude-sonnet-4.5',
240
+ 'nous:anthropic/claude-sonnet-4',
241
+ ])
242
+
243
+ export const preferredHermesClaudeReviewModel = (models = []) => {
244
+ const values = new Set((Array.isArray(models) ? models : []).map((model) => (
245
+ typeof model === 'string' ? model : model?.value || model?.slug
246
+ )).filter(Boolean))
247
+ for (const model of HERMES_CLAUDE_REVIEW_PREFERENCE) if (values.has(model)) return model
248
+ for (const model of values) if (/^nous:anthropic\/claude-sonnet-/i.test(model)) return model
249
+ for (const model of values) if (/^nous:anthropic\/claude-haiku-/i.test(model)) return model
250
+ for (const model of values) if (/^nous:anthropic\/claude-opus-/i.test(model)) return model
251
+ return null
252
+ }
253
+
254
+ // Returned alongside read_terminal output so the orchestrator gets a structural
255
+ // provider verdict at the exact moment it observes the failed worker. A Hermes
256
+ // fallback is a visible ordinary worker, never hidden delegate_task and never Flow.
257
+ // Hermes ACP 0.18.2 cannot enforce read-only review, so the note states the honest
258
+ // permission-gated boundary instead of pretending mode="default" is structural plan.
259
+ export const nativeClaudeFallbackHint = ({ parentRuntime, parentModels, targetRuntime, targetLog } = {}) => {
260
+ if (targetRuntime !== 'claude' || !nativeClaudeProviderAccessFailed(targetLog)) return ''
261
+ const stop = 'ThinkPool provider status: native Claude access failed at the account/provider level. Do not retry Sonnet, Opus, Haiku, or another model alias on the same native Claude provider during this task.'
262
+ const fallback = parentRuntime === 'hermes' ? preferredHermesClaudeReviewModel(parentModels) : null
263
+ if (!fallback) return `\n\n${stop} Use a different available runtime/provider once, or report the blocker.`
264
+ return `\n\n${stop} For a visible manual review fallback, spawn exactly one worker with runtime="hermes", model=${JSON.stringify(fallback)}, mode="default", and sliceType="review". This Hermes lane is permission-gated, not structurally read-only: instruct it not to edit, deny/stop any write request, and do not treat its review as a Flow safety proof. Never use Hermes delegate_task.`
265
+ }
266
+
226
267
  // ── Live per-turn room snapshot ("ROOM NOW") ─────────────────────────────────
227
268
  // The compact tail claude-session appends to EVERY user turn's <system-reminder>
228
269
  // (via the bridge's roomContext callback), so a lane sees the room's CURRENT
@@ -42,7 +42,7 @@ const permissionOutcome = (decision, options = []) => {
42
42
 
43
43
  export function startHermesSession({
44
44
  cwd, model, resume, env = process.env, mode = 'default', onEvent, requestPermission,
45
- roomContext, terminalRolePrompt, rolePrompt, mcpServers, prepareCwd = null,
45
+ roomContext, terminalRolePrompt, rolePrompt, mcpServers, requiredMcpTools = [], prepareCwd = null,
46
46
  command = HERMES_COMMAND, args = ['acp'], clientFactory = createAcpClient,
47
47
  mcpHttpFactory = startCodexMcpHttp, lazy = false,
48
48
  } = {}) {
@@ -66,6 +66,7 @@ export function startHermesSession({
66
66
  const abortedTurns = new Set()
67
67
  let retireClient = () => {}
68
68
  let resuming = false
69
+ let inventoryProbe = null
69
70
  let modelSwitchPending = false
70
71
 
71
72
  const emit = (event) => { try { onEvent?.(event) } catch { /* consumer isolation */ } }
@@ -87,10 +88,33 @@ export function startHermesSession({
87
88
  function onNotification(method, params) {
88
89
  if (method !== 'session/update') return
89
90
  const update = params?.update || params
91
+ if (inventoryProbe) {
92
+ if (update?.sessionUpdate === 'agent_message_chunk' && update?.content?.type === 'text') {
93
+ inventoryProbe.push(String(update.content.text || ''))
94
+ }
95
+ return
96
+ }
90
97
  if (resuming && HERMES_REPLAY_UPDATES.has(update?.sessionUpdate)) return
91
98
  mapper?.push(params)
92
99
  }
93
100
 
101
+ async function probeMcpTools() {
102
+ const required = [...new Set((Array.isArray(requiredMcpTools) ? requiredMcpTools : [])
103
+ .map((name) => String(name || '').trim()).filter(Boolean))]
104
+ if (!required.length) return []
105
+ const chunks = []
106
+ inventoryProbe = chunks
107
+ try {
108
+ await client.request('session/prompt', {
109
+ sessionId,
110
+ prompt: [{ type: 'text', text: '/tools' }],
111
+ messageId: randomUUID(),
112
+ }, 30_000)
113
+ } finally { inventoryProbe = null }
114
+ const inventory = chunks.join('')
115
+ return required.filter((name) => !inventory.includes(`mcp__thinkpool__${name}`))
116
+ }
117
+
94
118
  async function boot() {
95
119
  if (ended) throw new Error('Hermes session is closed')
96
120
  if (crashed) throw new Error('Hermes ACP process crashed; this lane must be restarted before sending another turn')
@@ -151,13 +175,36 @@ export function startHermesSession({
151
175
  } else state = await client.request('session/new', params, 30_000)
152
176
  if (!sessionId) sessionId = state?.sessionId || null
153
177
  if (!sessionId) throw new Error('Hermes ACP did not return a session id')
178
+ let missingMcpTools = await probeMcpTools()
179
+ // Hermes treats ACP-provided MCP registration as non-fatal. A transient
180
+ // first connection can therefore leave an otherwise healthy session with
181
+ // only built-in tools. Re-running the released resume lifecycle retries
182
+ // registration without inventing a new native identity. Bound this to one
183
+ // retry, then fail closed before any user prompt reaches a tool-less lane.
184
+ if (missingMcpTools.length && initialized?.agentCapabilities?.sessionCapabilities?.resume) {
185
+ try {
186
+ resuming = true
187
+ state = await client.request('session/resume', { ...params, sessionId }, 30_000)
188
+ } finally { resuming = false }
189
+ missingMcpTools = await probeMcpTools()
190
+ }
191
+ if (missingMcpTools.length) {
192
+ throw new Error(`Hermes ThinkPool MCP readiness failed; missing ${missingMcpTools.join(', ')}`)
193
+ }
154
194
  const serverModel = state?.models?.currentModelId || null
155
195
  activeModel = requestedModel || serverModel || activeModel
156
- mapper.startSession({ sessionId, models: state?.models, modes: state?.modes, commands: [] })
157
- started = true
158
- if (requestedModel && serverModel && requestedModel !== serverModel) {
196
+ // A spawned Hermes worker may be pinned to an exact model from its parent's
197
+ // ACP catalog. Acknowledge that model BEFORE publishing the initial system/
198
+ // models events; otherwise the worker runs the requested model while the UI
199
+ // and persisted resume record lie that it still uses the profile default.
200
+ if (requestedModel && requestedModel !== serverModel) {
159
201
  await client.request('session/set_model', { sessionId, modelId: activeModel })
160
202
  }
203
+ const publishedModels = state?.models
204
+ ? { ...state.models, currentModelId: activeModel }
205
+ : activeModel ? { currentModelId: activeModel, availableModels: [] } : state?.models
206
+ mapper.startSession({ sessionId, models: publishedModels, modes: state?.modes, commands: [] })
207
+ started = true
161
208
  const acpMode = activeMode === 'acceptEdits' ? 'accept_edits' : 'default'
162
209
  if (state?.modes?.availableModes?.some((item) => item.id === acpMode) && state.modes.currentModeId !== acpMode) {
163
210
  await client.request('session/set_mode', { sessionId, modeId: acpMode })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thinkpool-pair",
3
- "version": "0.7.240",
3
+ "version": "0.7.242",
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": {
@@ -57,3 +57,9 @@ export const CODEX_THINKPOOL_FIRST_TURN_PREAMBLE = [
57
57
  'MODEL TIERING FOR TERMINALS: When calling open_main_terminal for a genuinely complex Cascade conductor, gpt-5.6-sol may be appropriate. When calling spawn_terminal for worker slices, explicitly choose gpt-5.6-luna or gpt-5.4-mini for mechanical/scaffold/search/formatting work, gpt-5.6-terra for ordinary feature/fix work, and a balanced tier for adversarial review. Never select Sol for routine workers. If a named model is unavailable, use the nearest advertised lower tier or omit model and allow runtime fallback.',
58
58
  ...THINKPOOL_REMOTE_DELIVERY_RULES,
59
59
  ].join(' ')
60
+
61
+ // Hermes mains can dispatch visible ordinary workers, including a Claude-family
62
+ // model served through the parent's Nous Portal catalog when native Claude Code
63
+ // account access is unavailable. This is manual orchestration only: ACP 0.18.2
64
+ // does not expose structural read-only review policy, and Flow remains disabled.
65
+ export const HERMES_VISIBLE_WORKER_FALLBACK_RULE = 'HERMES VISIBLE-WORKER FALLBACK: spawn_terminal may open a visible Hermes worker only from a top-level Hermes terminal and only with an explicit exact model ID advertised in this session\'s ACP catalog. If a native Claude Code worker reports an account/provider authentication or subscription-access failure, treat the entire native Claude provider as unavailable for the rest of the task—do not retry Sonnet, Opus, or Haiku aliases on it. For a manual Claude-family review fallback, use runtime="hermes", the exact Nous Claude model suggested by read_terminal, mode="default", and sliceType="review". This fallback is permission-gated, not structurally read-only: instruct it not to edit, stop/deny any write request, and never call it Flow/reviewer safety. Never use hidden Hermes delegate_task.'