thinkpool-pair 0.7.241 → 0.7.243

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, closeLaneDecision, 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
  },
@@ -2283,7 +2296,7 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
2283
2296
  ref: String(sid).slice(0, 8),
2284
2297
  name: termNames[sid] || undefined,
2285
2298
  spawnedBy: s.spawnedBy,
2286
- busy: s.session?.turnActive ?? false,
2299
+ busy: (s.session?.turnActive ?? false) || (s.pending?.size ?? 0) > 0,
2287
2300
  }))))
2288
2301
  // Record this spawn, pruning timestamps that have already left the window
2289
2302
  // (identical predicate to recentSpawnCount) so the array can't grow unbounded.
@@ -2363,7 +2376,7 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
2363
2376
  // the web's code-close), which no-ops if the id isn't a live session.
2364
2377
  ...(!entry.flowRole ? [tool(
2365
2378
  'close_terminal',
2366
- 'Close an agent lane that YOU opened with spawn_terminal (identified by ref/id/name). You can only close lanes you spawned yourself — never a sibling someone else is working in, and never the main terminal. Use it to clean up after a fan-out once you have collected the results with read_terminal.',
2379
+ 'Close an IDLE agent lane that YOU opened with spawn_terminal (identified by ref/id/name). Active turns and permission waits are protected: collect the completed result with read_terminal before closing. You can only close lanes you spawned yourself — never a sibling someone else is working in, and never the main terminal.',
2367
2380
  {
2368
2381
  terminal: z.string().describe('ref, id, or name of the lane you spawned (from read_terminal)'),
2369
2382
  },
@@ -2374,7 +2387,15 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
2374
2387
  if (!target) return okText(`No terminal matches "${args?.terminal}". Call read_terminal with no argument to list the open terminals.`)
2375
2388
  const te = sessions.get(target.id)
2376
2389
  if (!te) return okText(`Terminal ${target.ref} is not an agent lane (or already closed) — only spawned agent lanes can be closed this way.`)
2377
- if (te.spawnedBy !== id) return okText(`Terminal ${target.ref} was not opened by you — you can only close lanes you spawned yourself. Leave it to the person running it.`)
2390
+ const close = closeLaneDecision({
2391
+ callerId: id,
2392
+ spawnedBy: te.spawnedBy,
2393
+ busy: (te.session?.turnActive ?? false) || (te.pending?.size ?? 0) > 0,
2394
+ })
2395
+ if (!close.ok && close.reason === 'not-owner') return okText(`Terminal ${target.ref} was not opened by you — you can only close lanes you spawned yourself. Leave it to the person running it.`)
2396
+ if (!close.ok && close.reason === 'busy') {
2397
+ return okText(`Terminal ${target.ref} is still working or waiting for a permission decision. close_terminal will not kill an active turn. Wait until read_terminal reports it idle/completed, collect its result, then close it. If it is genuinely stuck, ask a person to press Stop.`)
2398
+ }
2378
2399
  endStructured(target.id)
2379
2400
  return okText(`Closed agent lane ${target.ref}${target.name ? ` ("${target.name}")` : ''}.`)
2380
2401
  },
@@ -2446,13 +2467,13 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
2446
2467
  // on the init `system` event (see onEvent) — a sendTurn before the input stream is consumed
2447
2468
  // is silently lost (the 2026-07-02 auto-resume bug). Empty string → nothing to carry.
2448
2469
  entry.pendingRecap = (typeof carryRecap === 'string' && carryRecap.trim()) ? carryRecap : null
2449
- const terminalRolePrompt = buildTerminalRolePrompt({
2470
+ const terminalRolePrompt = [buildTerminalRolePrompt({
2450
2471
  spawnedBy: entry.spawnedBy,
2451
2472
  spawnDepth: entry.spawnDepth,
2452
2473
  cascadeRole: entry.cascadeRole,
2453
2474
  flowRole: entry.flowRole,
2454
2475
  sideParent: entry.sideParent,
2455
- })
2476
+ }), runtime === 'hermes' && canSpawnWorkers ? HERMES_VISIBLE_WORKER_FALLBACK_RULE : ''].filter(Boolean).join('\n\n')
2456
2477
  entry.session = startStructuredSession(runtime, {
2457
2478
  // laneModel, NOT the raw `model` param: the SDK's `model` option OVERRIDES the
2458
2479
  // ANTHROPIC_MODEL supplied by resolveProviderEnv() in `env` below, so an inherited
@@ -220,7 +220,48 @@ export const formatPeek = ({ selfId, sessions, terms, names = {}, terminal, line
220
220
  } else {
221
221
  body = cleanPtyTail(hit.buf, n).join('\n') || '(no output yet)'
222
222
  }
223
- return `Terminal ${label(hit)} (${hit.kind} · ${hit.cmd}) — last ${n} lines:\n${body}`
223
+ return `Terminal ${label(hit)} (${hit.kind} · ${hit.cmd}) · ${laneStatusText(hit)} — last ${n} lines:\n${body}`
224
+ }
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.`
224
265
  }
225
266
 
226
267
  // ── Live per-turn room snapshot ("ROOM NOW") ─────────────────────────────────
@@ -376,8 +417,18 @@ export const spawnDecision = ({ hop = 0, spawnTimes = [], now = 0, spawnedLive =
376
417
  // Only lanes this caller spawned are suggested: close_terminal refuses the others, and
377
418
  // suggesting a sibling's lane is how you get an agent killing someone else's work.
378
419
  export const CLOSABLE_HINT_MAX = 6
420
+ export const closeLaneDecision = ({ callerId, spawnedBy, busy } = {}) => {
421
+ if (!callerId || spawnedBy !== callerId) return { ok: false, reason: 'not-owner' }
422
+ if (busy) return { ok: false, reason: 'busy' }
423
+ return { ok: true }
424
+ }
425
+
379
426
  export const closableLanes = (callerId, lanes = []) =>
380
- (Array.isArray(lanes) ? lanes : []).filter((l) => l && l.spawnedBy === callerId && !l.busy)
427
+ (Array.isArray(lanes) ? lanes : []).filter((l) => l && closeLaneDecision({
428
+ callerId,
429
+ spawnedBy: l.spawnedBy,
430
+ busy: l.busy,
431
+ }).ok)
381
432
 
382
433
  export const formatClosableHint = (callerId, lanes = [], max = CLOSABLE_HINT_MAX) => {
383
434
  const idle = closableLanes(callerId, lanes)
@@ -193,11 +193,18 @@ export function startHermesSession({
193
193
  }
194
194
  const serverModel = state?.models?.currentModelId || null
195
195
  activeModel = requestedModel || serverModel || activeModel
196
- mapper.startSession({ sessionId, models: state?.models, modes: state?.modes, commands: [] })
197
- started = true
198
- 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) {
199
201
  await client.request('session/set_model', { sessionId, modelId: activeModel })
200
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
201
208
  const acpMode = activeMode === 'acceptEdits' ? 'accept_edits' : 'default'
202
209
  if (state?.modes?.availableModes?.some((item) => item.id === acpMode) && state.modes.currentModeId !== acpMode) {
203
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.241",
3
+ "version": "0.7.243",
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.'