thinkpool-pair 0.7.280 → 0.7.282

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
@@ -46,7 +46,7 @@ import { createPermNotifier, shouldNotifyTurnDone, permissionSummary, clipSummar
46
46
  // resolveProviderEnv(id) → {ANTHROPIC_BASE_URL,ANTHROPIC_AUTH_TOKEN,ANTHROPIC_MODEL} for a
47
47
  // registered custom provider, or null for the built-in/unknown (leave the default env intact).
48
48
  // Multi-provider BYOK slice 1: a lane spawned with a `provider` id runs on that endpoint.
49
- import { resolveProviderEnv, providerNameMap, publicKeyB64, announceProviders, listProviders, addProvider, removeProvider, unseal, effectiveLaneModel, sameProviderEnv, providerModel } from './providers.mjs'
49
+ import { resolveProviderEnv, resolveProviderRef, providerNameMap, publicKeyB64, announceProviders, listProviders, addProvider, removeProvider, unseal, effectiveLaneModel, sameProviderEnv, providerModel } from './providers.mjs'
50
50
  import { validateProviderSwitch, providerSwitchPlan, BUILTIN_PROVIDER } from './switch-provider.mjs'
51
51
  import { createSdkMcpServer, tool } from '@anthropic-ai/claude-agent-sdk'
52
52
  import { z } from 'zod'
@@ -68,7 +68,7 @@ import { hostMemoryAdmission } from './host-memory.mjs'
68
68
  const STRUCTURED_MODES = new Set(['default', 'acceptEdits', 'plan', 'review', 'bypassPermissions'])
69
69
  import { FLOW_CONDUCTOR_PROMPT, FLOW_LANE_PROMPT, FLOW_CODEX_CONDUCTOR_PROMPT, FLOW_CODEX_LANE_PROMPT, buildConductorEnv, assembleCrossWaveContext, buildLanePrompt } from './flow-conductor.mjs'
70
70
  import { legacyBuilderCompletionAllowed, normalizePlanOutput, validatePlanForRuntime, validReviewTargetShape } from './flow-task-graph.mjs'
71
- import { normalizeFlowRuntime, flowLaneModelFor, flowConductorModelFor, spawnedLaneModelFor, modelCatalogValues, resolveCodexModel, assertRuntimeModelCompatible } from './flow-models.mjs'
71
+ import { normalizeFlowRuntime, flowLaneModelFor, flowConductorModelFor, spawnedLaneModelFor, modelCatalogValues, resolveCodexModel, resolveHermesOpenModel, assertRuntimeModelCompatible } from './flow-models.mjs'
72
72
  // S1 (context-offload) — durable digest store. mark_flow_done digests a closed slice in;
73
73
  // the dispatch loop reads the bounded cross-wave context back out (via assembleCrossWaveContext,
74
74
  // which enforces the CEILING). Consume the store — the internals live in flow-context-store.mjs.
@@ -2071,32 +2071,43 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
2071
2071
  const isIndependentMain = !entry.spawnedBy && !entry.sideParent && !entry.flowRole && (entry.spawnDepth || 0) === 0
2072
2072
  const canOpenMainConductor = isIndependentMain && entry.cascadeRole !== 'conductor'
2073
2073
  const canSpawnWorkers = isIndependentMain
2074
+ const registeredProviderChoices = () => announceProviders()
2075
+ .filter((provider) => provider.id !== 'anthropic')
2076
+ .map((provider) => `${provider.name}${provider.model ? ` (${provider.model})` : ''}`)
2077
+ .join(', ')
2074
2078
  const resolveAgentOpen = (args, { worker = false } = {}) => {
2075
2079
  const runtime = args?.runtime || entry.runtime || 'claude'
2076
2080
  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.' }
2077
2081
  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.' }
2078
- 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.' }
2079
- const catalog = runtime === 'codex' ? readCodexModels() : runtime === 'hermes' ? (entry.models || []) : []
2082
+ const hermesCatalog = entry.runtime === 'hermes' && entry.models?.length
2083
+ ? entry.models
2084
+ : [...sessions.values()].flatMap((session) => session.runtime === 'hermes' ? (session.models || []) : [])
2085
+ const catalog = runtime === 'codex' ? readCodexModels() : runtime === 'hermes' ? hermesCatalog : []
2080
2086
  if (runtime === 'codex' && args?.model && !modelCatalogValues(catalog).has(args.model)) {
2081
2087
  return { error: `Could not open a Codex terminal on ${JSON.stringify(args.model)} — that model is not in this host's visible Codex catalog.` }
2082
2088
  }
2083
- if (runtime === 'hermes' && !args?.model) return { error: 'Opening a Hermes worker requires an explicit exact model ID from this session\'s ACP catalog.' }
2084
- if (runtime === 'hermes' && !modelCatalogValues(catalog).has(args.model)) {
2085
- 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.` }
2089
+ const hermesModel = runtime === 'hermes'
2090
+ ? resolveHermesOpenModel(args?.model, catalog, { strictCatalog: entry.runtime === 'hermes' })
2091
+ : null
2092
+ if (hermesModel && !hermesModel.ok) return { error: hermesModel.error }
2093
+ const provider = runtime === 'claude' && args?.provider ? resolveProviderRef(args.provider) : args?.provider
2094
+ if (runtime === 'claude' && args?.provider && !provider) {
2095
+ const choices = registeredProviderChoices()
2096
+ return { error: `Unknown registered provider ${JSON.stringify(args.provider)}. No lane was opened and the built-in Claude login was not used.${choices ? ` Available provider names: ${choices}.` : ' No custom providers are registered on this host.'}` }
2086
2097
  }
2087
2098
  if (runtime === 'claude' && !args?.provider && args?.model && /^gpt-/i.test(args.model)) {
2088
2099
  return { error: `Could not open a Claude terminal on Codex model ${JSON.stringify(args.model)}. Choose runtime="codex" or a Claude model.` }
2089
2100
  }
2090
2101
  return {
2091
2102
  runtime,
2092
- model: args?.model || (worker && args?.sliceType
2103
+ model: hermesModel?.model || args?.model || (worker && args?.sliceType
2093
2104
  ? spawnedLaneModelFor({ sliceType: args.sliceType, runtime, catalog })
2094
2105
  : undefined),
2095
2106
  // Worker lanes are the autonomous execution unit. They never inherit a
2096
2107
  // permission-card mode from the conductor and never honor a lower explicit
2097
2108
  // override: spawn_terminal always opens them in bypassPermissions.
2098
2109
  mode: worker ? 'bypassPermissions' : (args?.mode || (entry.mode === 'plan' ? 'default' : entry.mode)),
2099
- provider: args?.provider,
2110
+ provider,
2100
2111
  }
2101
2112
  }
2102
2113
  const dispatchContext = (now = Date.now()) => ({
@@ -2350,13 +2361,13 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
2350
2361
  // conductor mains, spawned workers, Side lanes, and Flow lanes cannot use it.
2351
2362
  ...(canOpenMainConductor ? [tool(
2352
2363
  'open_main_terminal',
2353
- 'Open a separate independent MAIN terminal to conduct a Cascade. Use this whenever a person explicitly asks to open, launch, start, or spawn a new Cascade/conductor terminal. This is the top-level room lifecycle, NOT Ensemble dispatch: the new terminal has no spawnedBy owner and appears as a main terminal. Never substitute spawn_terminal, which creates worker sub-terminals only. Give the conductor its full initial task; it will use spawn_terminal for its workers.',
2364
+ 'Open a separate independent MAIN terminal to conduct a Cascade on any supported runtime: Claude (including a connected Anthropic-compatible BYOK provider), Codex, or Hermes. Use this whenever a person explicitly asks to open, launch, start, or spawn a new Cascade/conductor terminal. This is the top-level room lifecycle, NOT Ensemble dispatch: the new terminal has no spawnedBy owner and appears as a main terminal. Never substitute spawn_terminal, which creates worker sub-terminals only. Give the conductor its full initial task; it will use spawn_terminal for its workers.',
2354
2365
  {
2355
2366
  name: z.string().max(80).optional().describe('short label for the main conductor, e.g. "Cascade · Multi-device chaos"'),
2356
2367
  task: z.string().min(1).describe('complete initial Cascade brief for the conductor'),
2357
- model: z.string().optional().describe('optional conductor model, e.g. opus / gpt-5.6-sol'),
2358
- runtime: z.enum(['claude', 'codex']).optional().describe('agent runtime; defaults to inheriting this main terminal'),
2359
- provider: z.string().optional().describe('optional registered LLM provider id for a Claude conductor'),
2368
+ model: z.string().optional().describe('optional conductor model, e.g. opus / gpt-5.6-sol / nous:z-ai/glm-5.2'),
2369
+ runtime: z.enum(['claude', 'codex', 'hermes']).optional().describe('agent runtime; every top-level terminal can open every supported runtime, defaulting to its own'),
2370
+ provider: z.string().optional().describe(`optional registered Claude-compatible provider name or id; omit for built-in Anthropic${registeredProviderChoices() ? `. Available now: ${registeredProviderChoices()}` : ''}`),
2360
2371
  mode: z.enum(['default', 'acceptEdits', 'bypassPermissions', 'plan']).optional().describe('permission mode; defaults to inheriting this main terminal, except plan falls back to default'),
2361
2372
  },
2362
2373
  async (args) => {
@@ -2407,14 +2418,14 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
2407
2418
  // not receive this tool, so the hierarchy is capability-enforced.
2408
2419
  ...(canSpawnWorkers ? [tool(
2409
2420
  'spawn_terminal',
2410
- '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. For ordinary workers use an appropriate non-Sol tier: gpt-5.6-luna or gpt-5.4-mini for scaffold/search, gpt-5.6-terra for feature/fix, and a balanced tier for adversarial review; never select Sol for routine workers. A Hermes sliceType=review lane is structurally reviewer-scoped before ACP startup: it can inspect files and use the bridge review-check tool, but has no terminal, write, patch, delegate, browser, skill, memory, or session-history schema. 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.',
2421
+ 'Open a visible WORKER SUB-TERMINAL on any supported runtime—Claude (including a connected Anthropic-compatible BYOK provider), Codex, or Hermes—from any top-level terminal. spawn_terminal is worker-only: it can never create a Cascade conductor or main terminal. For ordinary workers use an appropriate non-Sol tier: gpt-5.6-luna or gpt-5.4-mini for scaffold/search, gpt-5.6-terra for feature/fix, and a balanced tier for adversarial review; never select Sol for routine workers. A Hermes sliceType=review lane is structurally reviewer-scoped before ACP startup: it can inspect files and use the bridge review-check tool, but has no terminal, write, patch, delegate, browser, skill, memory, or session-history schema. 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.',
2411
2422
  {
2412
2423
  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"'),
2413
2424
  task: z.string().optional().describe('an initial task to hand the new lane immediately; omit to open it idle'),
2414
- 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'),
2425
+ model: z.string().optional().describe('model for the chosen runtime; Hermes accepts its profile default or an ACP ID such as nous:z-ai/glm-5.2'),
2415
2426
  sliceType: z.enum(['scaffold', 'feature', 'fix', 'review']).optional().describe('optional cascade slice tier; omitted preserves normal inheritance/default, explicit model wins'),
2416
- 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'),
2417
- 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'),
2427
+ runtime: z.enum(['claude', 'codex', 'hermes']).optional().describe('agent runtime for the new lane; every top-level terminal can open every supported runtime'),
2428
+ provider: z.string().optional().describe(`optional registered Claude-compatible provider name or id; omit for built-in Anthropic${registeredProviderChoices() ? `. Available now: ${registeredProviderChoices()}` : ''}`),
2418
2429
  mode: z.enum(['default', 'acceptEdits', 'bypassPermissions', 'plan']).optional().describe('accepted for compatibility; spawned workers always run autonomously in bypassPermissions'),
2419
2430
  },
2420
2431
  async (args) => {
@@ -2666,7 +2677,7 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
2666
2677
  cascadeRole: entry.cascadeRole,
2667
2678
  flowRole: entry.flowRole,
2668
2679
  sideParent: entry.sideParent,
2669
- }), runtime === 'hermes' && canSpawnWorkers ? HERMES_VISIBLE_WORKER_FALLBACK_RULE : ''].filter(Boolean).join('\n\n')
2680
+ }), canSpawnWorkers ? HERMES_VISIBLE_WORKER_FALLBACK_RULE : ''].filter(Boolean).join('\n\n')
2670
2681
  entry.session = startStructuredSession(runtime, {
2671
2682
  // laneModel, NOT the raw `model` param: the SDK's `model` option OVERRIDES the
2672
2683
  // ANTHROPIC_MODEL supplied by resolveProviderEnv() in `env` below, so an inherited
package/flow-models.mjs CHANGED
@@ -24,6 +24,38 @@ export function modelCatalogValues(catalog = []) {
24
24
  )).filter(Boolean))
25
25
  }
26
26
 
27
+ const exactHermesModelId = (value) => /^[A-Za-z0-9._-]+:[A-Za-z0-9._-]+\/[A-Za-z0-9._:/-]+$/.test(value)
28
+
29
+ const expandHermesModelAlias = (value) => {
30
+ if (/^glm[-_.]/i.test(value)) return `nous:z-ai/${value.toLowerCase()}`
31
+ const shorthand = value.match(/^([^:/]+):([^/]+)$/)
32
+ if (!shorthand) return value
33
+ const provider = shorthand[1].toLowerCase() === 'zai' ? 'z-ai' : shorthand[1].toLowerCase()
34
+ return `nous:${provider}/${shorthand[2]}`
35
+ }
36
+
37
+ /**
38
+ * Resolve a Hermes model for any bridge opener. A Hermes parent has a live ACP
39
+ * catalog and remains strict. Other runtimes may use a full id or unambiguous
40
+ * shorthand; the child session acknowledges session/set_model before inference.
41
+ * Omitting a model intentionally selects the isolated Hermes profile default.
42
+ */
43
+ export function resolveHermesOpenModel(model, catalog = [], { strictCatalog = false } = {}) {
44
+ const requested = String(model || '').trim()
45
+ if (!requested) return { ok: true, model: undefined, validatedBy: 'default' }
46
+ const values = [...modelCatalogValues(catalog)]
47
+ if (values.includes(requested)) return { ok: true, model: requested, validatedBy: 'catalog' }
48
+
49
+ const expanded = expandHermesModelAlias(requested)
50
+ const normalized = (value) => String(value).toLowerCase().replace(/[^a-z0-9]+/g, '')
51
+ const requestedKey = normalized(expanded).replace(/^nous/, '')
52
+ const aliases = values.filter((value) => normalized(value).replace(/^nous/, '') === requestedKey)
53
+ if (aliases.length === 1) return { ok: true, model: aliases[0], validatedBy: 'catalog' }
54
+ if (strictCatalog && values.length) return { ok: false, error: `Could not open a Hermes terminal on ${JSON.stringify(requested)} — that model is not in this session's ACP catalog.` }
55
+ if (!exactHermesModelId(expanded)) return { ok: false, error: `Could not resolve Hermes model ${JSON.stringify(requested)}. Use a full ACP model ID such as "nous:z-ai/glm-5.2".` }
56
+ return { ok: true, model: expanded, validatedBy: 'child-runtime' }
57
+ }
58
+
27
59
  function firstVisible(candidates, catalog) {
28
60
  const visible = modelCatalogValues(catalog)
29
61
  for (const candidate of candidates) if (visible.has(candidate)) return candidate
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thinkpool-pair",
3
- "version": "0.7.280",
3
+ "version": "0.7.282",
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
@@ -333,6 +333,27 @@ export function announceProviders() {
333
333
  return [{ id: BUILTIN_ID, name: 'Anthropic (Claude)', model: null, group: BUILTIN_ID }, ...custom]
334
334
  }
335
335
 
336
+ /**
337
+ * Resolve an agent-facing provider reference without ever falling through to the
338
+ * built-in Claude login. Accept an exact id, a unique case-insensitive display
339
+ * name, or a unique configured model. Unknown/ambiguous references return null
340
+ * so callers can fail closed and show the current safe choices.
341
+ */
342
+ export function resolveProviderRef(ref) {
343
+ if (!ref || ref === BUILTIN_ID) return BUILTIN_ID
344
+ const raw = String(ref).trim()
345
+ if (!raw) return BUILTIN_ID
346
+ const providers = loadProviders()
347
+ const exact = providers.find((provider) => provider.id === raw)
348
+ if (exact) return exact.id
349
+ const needle = raw.toLocaleLowerCase()
350
+ const matches = providers.filter((provider) => (
351
+ String(provider.name || '').trim().toLocaleLowerCase() === needle
352
+ || String(provider.model || '').trim().toLocaleLowerCase() === needle
353
+ ))
354
+ return matches.length === 1 ? matches[0].id : null
355
+ }
356
+
336
357
  /**
337
358
  * id → name map for the ROOM announce's per-lane provider projection (name-only,
338
359
  * NEVER key or baseUrl). Built once per announce so a cross-device viewer resolves
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "bundleVersion": 6,
3
+ "bundleVersion": 8,
4
4
  "contracts": [
5
5
  {
6
6
  "id": "room-coordination",
@@ -41,7 +41,9 @@
41
41
  },
42
42
  {
43
43
  "id": "work-routing",
44
- "version": 2,
44
+ "version": 4,
45
+ "providerRoutingContract": "A Claude lane may select a connected Anthropic-compatible provider by durable id, unique display name, or unique configured model. Unknown or ambiguous references fail closed and must never fall through to built-in Claude.",
46
+ "openerParityContract": "Every top-level terminal may use spawn_terminal or open_main_terminal to open every supported structured runtime: Claude, Codex, or Hermes. Runtime-specific provider/model validation remains authoritative and occurs before inference.",
45
47
  "routes": [
46
48
  {
47
49
  "id": "work-routing",
@@ -152,8 +152,7 @@ export const CODEX_THINKPOOL_FIRST_TURN_PREAMBLE = [
152
152
  ...THINKPOOL_REMOTE_DELIVERY_RULES,
153
153
  ].join(' ')
154
154
 
155
- // Hermes mains can dispatch visible ordinary workers, including a Claude-family
156
- // model served through the parent's Nous Portal catalog when native Claude Code
157
- // account access is unavailable. This is manual orchestration only: ACP 0.18.2
158
- // does not expose structural read-only review policy, and Flow remains disabled.
159
- 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.'
155
+ // Every top-level main can open every supported runtime. Hermes model selection
156
+ // is acknowledged by the child ACP session before inference, so it is safe for a
157
+ // Claude or Codex parent to request a visible Hermes lane.
158
+ export const HERMES_VISIBLE_WORKER_FALLBACK_RULE = 'UNIVERSAL OPENER ROUTING: a top-level terminal of any runtime may use spawn_terminal or open_main_terminal to open Claude (including a connected Anthropic-compatible BYOK provider), Codex, or Hermes. For Hermes, omit model to use the isolated profile default or pass an ACP ID such as model="nous:z-ai/glm-5.2"; common GLM shorthand is normalized and the child acknowledges the selected model before inference. 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.'