thinkpool-pair 0.7.232 → 0.7.234

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/acp-client.mjs ADDED
@@ -0,0 +1,140 @@
1
+ // Runtime-neutral supervised ACP stdio client. Protocol framing, JSON-RPC,
2
+ // cancellation, typed client handlers, and schemas come from the official ACP
3
+ // SDK. ThinkPool owns only subprocess supervision and its stricter stdout rule.
4
+ import { spawn } from 'node:child_process'
5
+ import { Readable, Transform, Writable } from 'node:stream'
6
+ import * as acp from '@agentclientprotocol/sdk'
7
+
8
+ const closedError = (value, fallback = 'ACP process closed') => value instanceof Error
9
+ ? value
10
+ : new Error(String(value || fallback))
11
+
12
+ function strictProtocolGate(onError) {
13
+ let buffered = ''
14
+ const validate = (line) => {
15
+ if (!line.trim()) return
16
+ let message
17
+ try { message = JSON.parse(line) } catch { throw new Error('ACP stdout contained non-JSON protocol data') }
18
+ if (!message || typeof message !== 'object' || Array.isArray(message)) {
19
+ throw new Error('ACP stdout contained invalid JSON-RPC protocol data')
20
+ }
21
+ const hasId = Object.prototype.hasOwnProperty.call(message, 'id')
22
+ const hasMethod = typeof message.method === 'string' && message.method.length > 0
23
+ const hasResult = Object.prototype.hasOwnProperty.call(message, 'result')
24
+ const hasError = Object.prototype.hasOwnProperty.call(message, 'error')
25
+ const isCall = hasMethod && !(hasResult || hasError)
26
+ const isResponse = hasId && !hasMethod && hasResult !== hasError
27
+ if (message.jsonrpc !== '2.0' || (!isCall && !isResponse)) {
28
+ throw new Error('ACP stdout contained invalid JSON-RPC protocol data')
29
+ }
30
+ }
31
+ const gate = new Transform({
32
+ transform(chunk, _encoding, callback) {
33
+ try {
34
+ buffered += String(chunk)
35
+ const lines = buffered.split('\n')
36
+ buffered = lines.pop() || ''
37
+ for (const line of lines) validate(line)
38
+ callback(null, chunk)
39
+ } catch (error) { callback(error) }
40
+ },
41
+ flush(callback) {
42
+ try { validate(buffered); callback(null, buffered || undefined) }
43
+ catch (error) { callback(error) }
44
+ },
45
+ })
46
+ gate.on('error', onError)
47
+ return gate
48
+ }
49
+
50
+ export class AcpClient {
51
+ constructor({ command, args = ['acp'], cwd, env = process.env, spawnImpl = spawn, onNotification, onRequest, onStderr, onClose, timeoutMs = 10_000 } = {}) {
52
+ this.command = command
53
+ this.args = args
54
+ this.cwd = cwd
55
+ this.env = env
56
+ this.spawnImpl = spawnImpl
57
+ this.onNotification = onNotification
58
+ this.onRequest = onRequest
59
+ this.onStderr = onStderr
60
+ this.onClose = onClose
61
+ this.timeoutMs = timeoutMs
62
+ this.child = null
63
+ this.connection = null
64
+ this.closedError = null
65
+ this.stderrTail = ''
66
+ }
67
+
68
+ get alive() { return !!this.child && !!this.connection && !this.closedError }
69
+
70
+ start() {
71
+ if (this.child) return
72
+ const child = this.spawnImpl(this.command, this.args, { cwd: this.cwd, env: this.env, stdio: ['pipe', 'pipe', 'pipe'] })
73
+ this.child = child
74
+ child.stderr?.on?.('data', (chunk) => {
75
+ const text = String(chunk)
76
+ this.stderrTail = (this.stderrTail + text).slice(-2000)
77
+ try { this.onStderr?.(text) } catch { /* consumer isolation */ }
78
+ })
79
+
80
+ const failProtocol = (error) => {
81
+ const failure = closedError(error, 'ACP protocol stream failed')
82
+ this.connection?.close(failure)
83
+ this._close(failure)
84
+ try { child.kill('SIGTERM') } catch { /* noop */ }
85
+ }
86
+ const protocolOutput = strictProtocolGate(failProtocol)
87
+ child.stdout.pipe(protocolOutput)
88
+ const stream = acp.ndJsonStream(Writable.toWeb(child.stdin), Readable.toWeb(protocolOutput))
89
+ const app = acp.client({ name: 'thinkpool-pair' })
90
+ .onRequest(acp.methods.client.session.requestPermission, ({ params, requestId }) => {
91
+ if (!this.onRequest) throw Object.assign(new Error('Unsupported ACP client method: session/request_permission'), { code: -32601 })
92
+ return this.onRequest(acp.methods.client.session.requestPermission, params, requestId)
93
+ })
94
+ .onNotification(acp.methods.client.session.update, ({ params }) => {
95
+ try { this.onNotification?.(acp.methods.client.session.update, params) } catch { /* consumer isolation */ }
96
+ })
97
+ this.connection = app.connect(stream)
98
+ void this.connection.closed.then(() => {
99
+ if (!this.closedError) this._close(closedError(this.connection?.signal?.reason))
100
+ })
101
+
102
+ child.stdin?.on?.('error', (error) => this._close(error))
103
+ child.on('error', (error) => this._close(error))
104
+ child.on('close', (code, signal) => this._close(new Error(`ACP process exited ${code ?? signal ?? 'unknown'}${this.stderrTail ? `: ${this.stderrTail.trim().slice(-300)}` : ''}`)))
105
+ }
106
+
107
+ request(method, params = {}, timeoutMs = this.timeoutMs) {
108
+ if (!this.connection || this.closedError) return Promise.reject(this.closedError || new Error('ACP process is not running'))
109
+ const controller = new AbortController()
110
+ const request = this.connection.agent.request(method, params, { cancellationSignal: controller.signal })
111
+ if (!(timeoutMs > 0)) return request
112
+ return new Promise((resolve, reject) => {
113
+ const timer = setTimeout(() => {
114
+ controller.abort(new Error(`${method} timed out after ${timeoutMs}ms`))
115
+ reject(new Error(`${method} timed out after ${timeoutMs}ms`))
116
+ }, timeoutMs)
117
+ timer.unref?.()
118
+ request.then(resolve, reject).finally(() => clearTimeout(timer))
119
+ })
120
+ }
121
+
122
+ notify(method, params) {
123
+ if (!this.connection || this.closedError) return Promise.reject(this.closedError || new Error('ACP process is not running'))
124
+ return this.connection.agent.notify(method, params)
125
+ }
126
+
127
+ _close(error) {
128
+ if (this.closedError) return
129
+ this.closedError = closedError(error)
130
+ this.child = null
131
+ this.connection?.close(this.closedError)
132
+ try { this.onClose?.(this.closedError) } catch { /* consumer isolation */ }
133
+ }
134
+
135
+ end() {
136
+ const child = this.child
137
+ this._close(new Error('ACP client ended'))
138
+ if (child) { try { child.kill('SIGTERM') } catch { /* noop */ } }
139
+ }
140
+ }
package/agent-detect.mjs CHANGED
@@ -5,7 +5,7 @@ import fs from 'node:fs'
5
5
  import path from 'node:path'
6
6
 
7
7
  export const KNOWN_AGENT_COMMANDS = [
8
- 'claude', 'codex', 'gemini', 'aider', 'cursor-agent',
8
+ 'claude', 'codex', 'thinkpool', 'gemini', 'aider', 'cursor-agent',
9
9
  'opencode', 'copilot', 'goose', 'crush', 'qwen',
10
10
  ]
11
11
 
package/bridge.mjs CHANGED
@@ -49,8 +49,10 @@ import { resolveProviderEnv, providerNameMap, publicKeyB64, announceProviders, l
49
49
  import { validateProviderSwitch, providerSwitchPlan, BUILTIN_PROVIDER } from './switch-provider.mjs'
50
50
  import { createSdkMcpServer, tool } from '@anthropic-ai/claude-agent-sdk'
51
51
  import { z } from 'zod'
52
- import { startClaudeSession } from './claude-session.mjs'
53
- import { startCodexSession, readCodexDefaultModel, readCodexModels, codexConfigForMode, codexThreadCanResume, defaultModeForRuntime } from './codex-session.mjs'
52
+ import { readCodexDefaultModel, readCodexModels, codexConfigForMode, codexThreadCanResume } from './codex-session.mjs'
53
+ import { startStructuredSession } from './runtime-session.mjs'
54
+ import { defaultStructuredMode, structuredRuntimeForCommand, structuredRuntimeMetadata, structuredRuntimeSupportsMode } from './runtime-registry.mjs'
55
+ import { probeHermesRuntime } from './hermes-probe.mjs'
54
56
  import { canonicalRoomFilePath, waitForNativeImages } from './codex-images.mjs'
55
57
  import { createManagedLaneWorktree, removeManagedLaneWorktree } from './lane-worktree.mjs'
56
58
  import { commandOnPath } from './agent-detect.mjs'
@@ -103,7 +105,7 @@ const flowRedispatch = new Map()
103
105
  // broadcasts; without persistent state the cap can never bite.
104
106
  const flowBudgets = new Map()
105
107
  import { formatPeek, PEEK, siblingsOf, resolveSibling, crossPostDecision, CROSSPOST, spawnDecision, SPAWN, CROSSROOM, formatPairRoster, crossRoomPostDecision, formatRoomNow, formatClosableHint, laneStatusOf } from './cross-terminal.mjs'
106
- import { dispatchPendingRecap, recoverInterruptedTurn, recoverMissingResumeOnce, sendInterruptedContinue, supersedeInterruptedResume } from './interrupted-resume.mjs'
108
+ import { dispatchPendingRecap, recoverInterruptedTurn, recoverMissingResumeOnce, sendInterruptedContinue, shouldAttemptNativeResume, supersedeInterruptedResume } from './interrupted-resume.mjs'
107
109
  import { turnInFlight } from './update-gate.mjs'
108
110
  import { saveSession, flushSession, deleteSession, loadAll, canResume, loadPtyId, savePtyId, loadNames, saveNames, appendDurableEvents, seedDurableEvents, readDurablePage, readDurableOldestSeq, hasDurableArchive, servesHistoryPage } from './session-store.mjs'
109
111
  import { stampEvent, makeSeqCounter, maxSeq, seqable, capReplayEvents, chunkReplayEvents, boundEventForBroadcast, inlineImageBlocks, usageReportLine, codexUsageReportLine, buildRecapFromLog, RECAP_CAP, trimmedBeforeSeq, firstSeq } from './event-id.mjs'
@@ -167,6 +169,7 @@ catch {
167
169
  const KNOWN_AGENTS = [
168
170
  { label: 'Claude Code', cmd: 'claude', resume: { args: ['--continue'], probe: claudeHasSession } },
169
171
  { label: 'Codex CLI', cmd: 'codex' },
172
+ { label: 'Hermes Agent', cmd: 'thinkpool', availability: () => probeHermesRuntime().available },
170
173
  { label: 'Gemini CLI', cmd: 'gemini' },
171
174
  { label: 'Aider', cmd: 'aider' },
172
175
  { label: 'Cursor CLI', cmd: 'cursor-agent' },
@@ -437,12 +440,9 @@ const STRUCTURED = process.env.TP_STRUCTURED === '1' || argv.includes('--structu
437
440
  // Claude Code runs as a STRUCTURED Agent-SDK session by default now (the picked
438
441
  // design — structured reader, risk-tiered permissions). Other CLIs keep the PTY
439
442
  // relay. TP_PTY=1 forces claude back to the raw PTY if ever needed.
440
- const structuredRuntime = (cmd) => {
441
- const bin = path.basename(String(cmd || '')).toLowerCase()
442
- return bin === 'codex' ? 'codex' : bin === 'claude' ? 'claude' : null
443
- }
443
+ const structuredRuntime = structuredRuntimeForCommand
444
444
  const wantStructured = (cmd) => !!structuredRuntime(cmd) && process.env.TP_PTY !== '1'
445
- const installedAgents = KNOWN_AGENTS.filter(a => onPath(a.cmd))
445
+ const installedAgents = KNOWN_AGENTS.filter(a => onPath(a.cmd) && (!a.availability || a.availability()))
446
446
  const dashIdx = argv.indexOf('--')
447
447
  // Own flags are only read from BEFORE `--`; after it, every token belongs
448
448
  // to the shared command (`-- claude --continue` must not trip these).
@@ -1063,7 +1063,7 @@ const announce = () => {
1063
1063
  // laneStatusOf: authoritative busy/idle + last-action timestamp/age +
1064
1064
  // STUCK/BLOCKED alert. The bridge owns the turn and permission state, so
1065
1065
  // every roster consumer reads one status instead of reconstructing it.
1066
- ...[...sessions.entries()].map(([id, s]) => ({ id, cmd: s.cmd, kind: 'structured', runtime: s.runtime || 'claude', alive: true, ...laneStatusOf(s), hasTranscript: s.log.length > 0, commands: s.commands, mode: s.mode || undefined, effort: s.effort || undefined, name: termNames[id] || undefined, model: s.model || undefined, ...(s.runtime === 'codex' ? { approvalPolicy: codexConfigForMode(s.mode).approvalPolicy, models: s.models || [], canSteer: s.session?.canSteer ?? false } : {}), ...(s.archiveOldestSeq != null ? { oldestSeq: s.archiveOldestSeq } : {}), ...(s.spawnedBy ? { spawned: true, spawnedBy: s.spawnedBy } : {}), ...(s.sideParent ? { sideParent: s.sideParent, sideTask: s.sideTask || undefined, sideHandback: !!s.sideHandback } : {}), ...(s.flowSessionId ? { flowId: s.flowSessionId, flowRole: s.flowTaskKey ? 'lane' : 'conductor' } : {}),
1066
+ ...[...sessions.entries()].map(([id, s]) => ({ id, cmd: s.cmd, kind: 'structured', runtime: s.runtime || 'claude', alive: true, ...laneStatusOf(s), hasTranscript: s.log.length > 0, commands: s.commands, mode: s.mode || undefined, effort: s.effort || undefined, name: termNames[id] || undefined, model: s.model || undefined, capabilities: structuredRuntimeMetadata(s.runtime || 'claude'), ...(s.runtime === 'codex' ? { approvalPolicy: codexConfigForMode(s.mode).approvalPolicy, models: s.models || [], canSteer: s.session?.canSteer ?? false } : s.runtime === 'hermes' ? { models: s.models || [], canSteer: s.session?.canSteer ?? false } : {}), ...(s.archiveOldestSeq != null ? { oldestSeq: s.archiveOldestSeq } : {}), ...(s.spawnedBy ? { spawned: true, spawnedBy: s.spawnedBy } : {}), ...(s.sideParent ? { sideParent: s.sideParent, sideTask: s.sideTask || undefined, sideHandback: !!s.sideHandback } : {}), ...(s.flowSessionId ? { flowId: s.flowSessionId, flowRole: s.flowTaskKey ? 'lane' : 'conductor' } : {}),
1067
1067
  // provider: the registered LLM-provider this lane runs on, NAME-ONLY {id,name}
1068
1068
  // (NEVER the key or baseUrl). Additive; older clients ignore it. Omitted for the
1069
1069
  // built-in/default Claude path (no badge). Makes the lane's provider badge +
@@ -1657,14 +1657,14 @@ function worktreeSnapshot(cwd) {
1657
1657
  // rolling log replays to joiners and survives bridge restarts (session-store).
1658
1658
  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 }) {
1659
1659
  if (sessions.has(id)) return
1660
- runtime = runtime === 'codex' ? 'codex' : 'claude'
1660
+ runtime = structuredRuntimeMetadata(runtime) ? runtime : 'claude'
1661
1661
  // No explicit mode → a sensible default per runtime (see defaultModeForRuntime):
1662
1662
  // codex → bypassPermissions, so a freshly-opened codex terminal can fetch /
1663
1663
  // advance / ship instead of hitting the no-network wall; claude → default.
1664
1664
  // Only fires when no mode was passed — a person's pick, a resume (persisted
1665
1665
  // entry.mode), a flow lane, and spawn_terminal (inherits the parent's mode +
1666
1666
  // its own bypass-escalation gate) all pass an explicit mode and skip this.
1667
- mode = STRUCTURED_MODES.has(mode) ? mode : defaultModeForRuntime(runtime)
1667
+ mode = STRUCTURED_MODES.has(mode) && structuredRuntimeSupportsMode(runtime, mode) ? mode : defaultStructuredMode(runtime)
1668
1668
  assertRuntimeModelCompatible({ runtime, provider, model })
1669
1669
  // The lane's EFFECTIVE model — computed ONCE and used for BOTH the truthful display
1670
1670
  // label (entry.model) and the SDK's `model` option below. These used to be computed
@@ -1679,6 +1679,8 @@ function openStructured({ id, runtime = 'claude', model, effort, resume, log, co
1679
1679
  ? (codexRequestedModel
1680
1680
  ? resolveCodexModel(codexRequestedModel, codexModels)
1681
1681
  : flowLaneModelFor({ sliceType: 'feature', runtime: 'codex', catalog: codexModels }))
1682
+ : runtime === 'hermes'
1683
+ ? (model || undefined)
1682
1684
  : effectiveLaneModel({ provider, model, configuredModel: resolveProviderEnv(provider)?.ANTHROPIC_MODEL })
1683
1685
  // spawnedBy: set when this lane was Dispatched (spawn_terminal). Restored from the
1684
1686
  // session store so the Ensemble flag survives a bridge restart (else a respin
@@ -1700,7 +1702,7 @@ function openStructured({ id, runtime = 'claude', model, effort, resume, log, co
1700
1702
  // No configured model → the provider's NAME, never a false Claude id (an inherited
1701
1703
  // claudeish id was dropped upstream by effectiveLaneModel; it must not sneak back in as a
1702
1704
  // label fallback here). Built-in Anthropic keeps the passed model.
1703
- model: runtime === 'codex'
1705
+ model: runtime === 'codex' || runtime === 'hermes'
1704
1706
  ? (laneModel || null)
1705
1707
  : (!provider || provider === 'anthropic')
1706
1708
  ? (laneModel || null)
@@ -1962,6 +1964,49 @@ function openStructured({ id, runtime = 'claude', model, effort, resume, log, co
1962
1964
  // 1.5s saveSession debounce window) and on shutdown (so events since the last
1963
1965
  // debounced write aren't lost). Contract #2: restart resumes, no lost messages.
1964
1966
  entry.flush = () => flushSession(room, id, sessionData())
1967
+ // Main-terminal creation and Ensemble dispatch are different capabilities.
1968
+ // A main terminal (human-opened or agent-opened via open_main_terminal) may
1969
+ // dispatch workers. A spawned/Side/Flow lane can never elevate itself or a
1970
+ // child into a conductor merely by wording its task that way.
1971
+ const isIndependentMain = !entry.spawnedBy && !entry.sideParent && !entry.flowRole && (entry.spawnDepth || 0) === 0
1972
+ const canOpenMainConductor = isIndependentMain && entry.cascadeRole !== 'conductor'
1973
+ const canSpawnWorkers = isIndependentMain
1974
+ const resolveAgentOpen = (args, { worker = false } = {}) => {
1975
+ const runtime = args?.runtime || entry.runtime || 'claude'
1976
+ 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.' }
1977
+ const catalog = runtime === 'codex' ? readCodexModels() : []
1978
+ if (runtime === 'codex' && args?.model && !modelCatalogValues(catalog).has(args.model)) {
1979
+ return { error: `Could not open a Codex terminal on ${JSON.stringify(args.model)} — that model is not in this host's visible Codex catalog.` }
1980
+ }
1981
+ if (runtime === 'claude' && !args?.provider && args?.model && /^gpt-/i.test(args.model)) {
1982
+ return { error: `Could not open a Claude terminal on Codex model ${JSON.stringify(args.model)}. Choose runtime="codex" or a Claude model.` }
1983
+ }
1984
+ return {
1985
+ runtime,
1986
+ model: args?.model || (worker && args?.sliceType
1987
+ ? spawnedLaneModelFor({ sliceType: args.sliceType, runtime, catalog })
1988
+ : undefined),
1989
+ mode: args?.mode || (entry.mode === 'plan' ? 'default' : entry.mode),
1990
+ provider: args?.provider,
1991
+ }
1992
+ }
1993
+ const consumeTerminalOpenBudget = () => {
1994
+ const now = Date.now()
1995
+ const gate = spawnDecision({
1996
+ hop: 0,
1997
+ spawnTimes: entry.spawnTimes || [],
1998
+ now,
1999
+ // Main terminals do not consume the Ensemble plan ceiling. The pure
2000
+ // gate still supplies the shared kill-switch, machine cap, and burst cap.
2001
+ spawnedLive: 0,
2002
+ totalLive: sessions.size + terms.size,
2003
+ plan: ownerPlan,
2004
+ disabled: process.env.TP_SPAWN_OFF === '1',
2005
+ })
2006
+ if (!gate.ok) return gate
2007
+ entry.spawnTimes = [...(entry.spawnTimes || []).filter((t) => Number.isFinite(t) && t > now - SPAWN.windowMs), now]
2008
+ return gate
2009
+ }
1965
2010
  // B2 cross-terminal peek — an in-process, READ-ONLY tool the agent can call to
1966
2011
  // see a SIBLING terminal's recent output (vs B1's always-on @pool digest). The
1967
2012
  // handler closes over the module-scope `sessions`/`terms` Maps + this session's
@@ -2106,43 +2151,81 @@ function openStructured({ id, runtime = 'claude', model, effort, resume, log, co
2106
2151
  return okText(`Delivered to terminal ${target.ref} (${target.cmd}). It will respond in its own lane; check back with read_terminal.`)
2107
2152
  },
2108
2153
  )] : []),
2109
- // Tier C+ — SPAWN a fresh agent lane the caller OWNS. The motivating bug:
2154
+ // MAIN CONDUCTOR — create the same independent structured terminal the web
2155
+ // "+ New terminal" path creates: no spawnedBy marker, no Ensemble ownership,
2156
+ // depth 0. Only an ordinary independent main terminal receives this tool;
2157
+ // conductor mains, spawned workers, Side lanes, and Flow lanes cannot use it.
2158
+ ...(canOpenMainConductor ? [tool(
2159
+ 'open_main_terminal',
2160
+ '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.',
2161
+ {
2162
+ name: z.string().max(80).optional().describe('short label for the main conductor, e.g. "Cascade · Multi-device chaos"'),
2163
+ task: z.string().min(1).describe('complete initial Cascade brief for the conductor'),
2164
+ model: z.string().optional().describe('optional conductor model, e.g. opus / gpt-5.6-sol'),
2165
+ runtime: z.enum(['claude', 'codex']).optional().describe('agent runtime; defaults to inheriting this main terminal'),
2166
+ provider: z.string().optional().describe('optional registered LLM provider id for a Claude conductor'),
2167
+ mode: z.enum(['default', 'acceptEdits', 'bypassPermissions', 'plan']).optional().describe('permission mode; defaults to inheriting this main terminal, except plan falls back to default'),
2168
+ },
2169
+ async (args) => {
2170
+ const okText = (t) => ({ content: [{ type: 'text', text: t }] })
2171
+ const resolved = resolveAgentOpen(args)
2172
+ if (resolved.error) return okText(resolved.error)
2173
+ const gate = consumeTerminalOpenBudget()
2174
+ if (!gate.ok) return okText(gate.reason)
2175
+ const newId = randomUUID()
2176
+ const newRef = String(newId).slice(0, 8)
2177
+ const fromRef = String(id).slice(0, 8)
2178
+ if (args?.name) { termNames[newId] = String(args.name).slice(0, 80); saveNames(room, termNames) }
2179
+ openStructured({
2180
+ id: newId,
2181
+ runtime: resolved.runtime,
2182
+ model: resolved.model,
2183
+ provider: resolved.provider,
2184
+ mode: resolved.mode,
2185
+ spawnDepth: 0,
2186
+ cascadeRole: 'conductor',
2187
+ hop: 0,
2188
+ })
2189
+ const conductor = sessions.get(newId)
2190
+ if (!conductor) {
2191
+ if (args?.name) { delete termNames[newId]; saveNames(room, termNames) }
2192
+ return okText('Could not open the main conductor terminal — the machine terminal cap may have just been reached.')
2193
+ }
2194
+ conductor.peekCount = 0; conductor.postCount = 0; conductor.spawnTimes = []
2195
+ announce()
2196
+ const msg = `[Task from main terminal ${fromRef}'s agent — opened as an independent MAIN CASCADE CONDUCTOR terminal, not an Ensemble child]\n${String(args.task).trim()}`
2197
+ const evt = { kind: 'you', text: msg, by: `main terminal ${fromRef} (agent)`, crosspost: true, relaySourceName: termNames[id] || undefined }
2198
+ stampEvent(evt); pushLog(conductor, evt); bcast('code-event', { term: newId, evt })
2199
+ try { conductor.session.sendTurn(msg) }
2200
+ catch { return okText(`Opened main conductor ${newRef}${args?.name ? ` ("${args.name}")` : ''}, but it may still be starting — the initial task could not be delivered.`) }
2201
+ return okText(`Opened independent main Cascade conductor ${newRef}${args?.name ? ` ("${args.name}")` : ''} and handed it the task. It is a main terminal, not an Ensemble lane.`)
2202
+ },
2203
+ )] : []),
2204
+ // Tier C+ — SPAWN a fresh WORKER lane the caller OWNS. The motivating bug:
2110
2205
  // an agent fanned a research task into SIBLINGS that were already busy (a
2111
2206
  // release lane, a Q&A lane) because it had no way to make its own lanes.
2112
2207
  // This opens a brand-new structured session server-side (the same path the
2113
2208
  // web's "+ New terminal" and the restore loop use), so announce() surfaces
2114
2209
  // its tab in the open web UI live. Auto-allowed by the PreToolUse gate but
2115
- // bounded by spawnDecision (depth-2 hop bound, burst window, the live terminal
2116
- // cap, TP_SPAWN_OFF kill-switch). A person's lane (hop 0) may spawn conductors
2117
- // (hop 1) which may spawn workers (hop 2); hop 2 can never spawn onward — the
2118
- // fork-bomb breaker, now one level deeper (2026-07-10 cascade-spawn-depth spec).
2119
- ...(!entry.flowRole ? [tool(
2210
+ // bounded by spawnDecision (one-worker-tier hop bound, burst window, the live terminal
2211
+ // cap, TP_SPAWN_OFF kill-switch). Spawned lanes are always workers and do
2212
+ // not receive this tool, so the hierarchy is capability-enforced.
2213
+ ...(canSpawnWorkers ? [tool(
2120
2214
  'spawn_terminal',
2121
- 'Open a NEW agent terminal (Claude or Codex) in this ThinkPool Code room — YOUR lane to use, not a sibling that is already working. ALWAYS declare cascadeRole="conductor" when this new terminal must decompose and spawn its own worker sub-terminals; declare cascadeRole="worker" for an assigned slice that must work directly. The bridge injects that identity into the child\'s first system preamble. The runtime defaults to inheriting this lane, or pass `runtime` to dispatch across runtimes. Choose `model` explicitly from advertised metadata when practical: for Codex use Luna/Mini for mechanical work, Terra for ordinary feature/fix work, and Sol only for repeated-failure, architecture/conductor, or adversarial final-review work; never inherit Sol by default, and omit `model` for runtime fallback when the named tier is unavailable. For cascades, pass sliceType=scaffold for mechanical work, feature/fix for builders, and review for adversarial verification (review never routes to the cheap tier); an explicit model overrides slice tiering. Omit sliceType to preserve ordinary model inheritance/default behavior. Give the lane an initial `task`, collect its result with read_terminal, then ALWAYS close_terminal it — a finished lane left open holds a slot against the room cap and blocks the next fan-out. By default the new lane INHERITS your current permission mode. ALWAYS prefer spawning a fresh lane over post_to_terminal into one that is already busy. Bounded by cascade depth, the room terminal cap, and per-turn limits.',
2215
+ '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.',
2122
2216
  {
2123
2217
  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"'),
2124
2218
  task: z.string().optional().describe('an initial task to hand the new lane immediately; omit to open it idle'),
2125
2219
  model: z.string().optional().describe('optional model for the chosen runtime, e.g. opus / sonnet / gpt-5.6-sol'),
2126
2220
  sliceType: z.enum(['scaffold', 'feature', 'fix', 'review']).optional().describe('optional cascade slice tier; omitted preserves normal inheritance/default, explicit model wins'),
2127
- cascadeRole: z.enum(['conductor', 'worker']).optional().describe('structural Cascade identity injected into the child system preamble: conductor may dispatch one worker tier; worker does the assigned slice directly'),
2128
2221
  runtime: z.enum(['claude', 'codex']).optional().describe('agent runtime for the new lane; defaults to inheriting this lane'),
2129
2222
  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'),
2130
2223
  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.'),
2131
2224
  },
2132
2225
  async (args) => {
2133
2226
  const okText = (t) => ({ content: [{ type: 'text', text: t }] })
2134
- const childRuntime = args?.runtime || entry.runtime || 'claude'
2135
- if (childRuntime === 'codex' && args?.provider) return okText('A Codex lane uses its Codex/OpenAI login in v1; custom bridge providers are not wired to Codex yet. Omit provider or spawn a Claude lane for that provider.')
2136
- const childCatalog = childRuntime === 'codex' ? readCodexModels() : []
2137
- if (childRuntime === 'codex' && args?.model && !modelCatalogValues(childCatalog).has(args.model)) {
2138
- return okText(`Could not open a Codex lane on ${JSON.stringify(args.model)} — that model is not in this host's visible Codex catalog.`)
2139
- }
2140
- if (childRuntime === 'claude' && !args?.provider && args?.model && /^gpt-/i.test(args.model)) {
2141
- return okText(`Could not open a Claude lane on Codex model ${JSON.stringify(args.model)}. Choose runtime="codex" or a Claude model.`)
2142
- }
2143
- const childModel = args?.model || (args?.sliceType
2144
- ? spawnedLaneModelFor({ sliceType: args.sliceType, runtime: childRuntime, catalog: childCatalog })
2145
- : undefined)
2227
+ const resolved = resolveAgentOpen(args, { worker: true })
2228
+ if (resolved.error) return okText(resolved.error)
2146
2229
  const now = Date.now()
2147
2230
  const gate = spawnDecision({
2148
2231
  hop: entry.hop || 0,
@@ -2176,13 +2259,12 @@ function openStructured({ id, runtime = 'claude', model, effort, resume, log, co
2176
2259
  // PM-m1 — never INHERIT plan mode into a spawned worker lane: a lane spawned from a
2177
2260
  // plan-mode lane would stall on ExitPlanMode cards. An explicit args.mode still wins;
2178
2261
  // otherwise inherit, but fall plan → default (an autonomous worker doesn't plan-gate).
2179
- const childMode = args?.mode || (entry.mode === 'plan' ? 'default' : entry.mode)
2180
2262
  const childSpawnDepth = (entry.spawnDepth || 0) + 1
2181
2263
  const childHop = (entry.hop || 0) + 1
2182
2264
  // Stamp ownership/depth BEFORE the runtime starts so its first system
2183
2265
  // preamble is truthful. Mutating ne.spawnedBy after openStructured was
2184
2266
  // too late: Codex/Claude had already booted with the top-level wording.
2185
- openStructured({ id: newId, runtime: childRuntime, model: childModel, provider: args?.provider, mode: childMode, spawnedBy: id, spawnDepth: childSpawnDepth, cascadeRole: args?.cascadeRole, hop: childHop })
2267
+ openStructured({ id: newId, runtime: resolved.runtime, model: resolved.model, provider: resolved.provider, mode: resolved.mode, spawnedBy: id, spawnDepth: childSpawnDepth, cascadeRole: 'worker', hop: childHop })
2186
2268
  const ne = sessions.get(newId)
2187
2269
  if (!ne) { if (args?.name) { delete termNames[newId]; saveNames(room, termNames) } return okText('Could not open a new lane — the terminal cap may have just been reached. Close one and retry.') }
2188
2270
  ne.peekCount = 0; ne.postCount = 0; ne.spawnTimes = []
@@ -2190,10 +2272,7 @@ function openStructured({ id, runtime = 'claude', model, effort, resume, log, co
2190
2272
  if (args?.task) {
2191
2273
  // The user-visible relay reinforces (but never defines) the structural
2192
2274
  // role already injected by the system preamble above.
2193
- const roleRelay = args?.cascadeRole === 'conductor'
2194
- ? 'you are its Cascade conductor terminal, responsible for worker sub-terminals'
2195
- : 'you are its worker sub-terminal, not a top-level room terminal'
2196
- const msg = `[Task from terminal ${fromRef}'s agent — relayed via ThinkPool cross-terminal; ${roleRelay}]\n${args.task}`
2275
+ const msg = `[Task from terminal ${fromRef}'s agent — relayed via ThinkPool Ensemble; you are its WORKER SUB-TERMINAL, never a main terminal or Cascade conductor]\n${args.task}`
2197
2276
  const evt = { kind: 'you', text: msg, by: `terminal ${fromRef} (agent)`, crosspost: true, relaySourceName: termNames[id] || undefined }
2198
2277
  stampEvent(evt); pushLog(ne, evt); bcast('code-event', { term: newId, evt })
2199
2278
  try { ne.session.sendTurn(msg) } catch { return okText(`Opened lane ${newRef}${args?.name ? ` ("${args.name}")` : ''}, but it may still be starting — could not hand off the task. Try post_to_terminal shortly.`) }
@@ -2333,7 +2412,7 @@ function openStructured({ id, runtime = 'claude', model, effort, resume, log, co
2333
2412
  flowRole: entry.flowRole,
2334
2413
  sideParent: entry.sideParent,
2335
2414
  })
2336
- entry.session = (runtime === 'codex' ? startCodexSession : startClaudeSession)({
2415
+ entry.session = startStructuredSession(runtime, {
2337
2416
  // laneModel, NOT the raw `model` param: the SDK's `model` option OVERRIDES the
2338
2417
  // ANTHROPIC_MODEL supplied by resolveProviderEnv() in `env` below, so an inherited
2339
2418
  // Claude id here defeats the whole per-provider env. undefined → option omitted.
@@ -2479,7 +2558,7 @@ function openStructured({ id, runtime = 'claude', model, effort, resume, log, co
2479
2558
  // App Server steering is opt-in and can fall back to exec at runtime. Keep
2480
2559
  // the roster capability authoritative so the browser never immediate-sends
2481
2560
  // into an exec-only turn and loses its shared queue.
2482
- { const canSteerNow = entry.runtime === 'codex' && (entry.session?.canSteer ?? false)
2561
+ { const canSteerNow = structuredRuntimeMetadata(entry.runtime)?.canSteer === true && (entry.session?.canSteer ?? false)
2483
2562
  if (canSteerNow !== entry._canSteerAnn) { entry._canSteerAnn = canSteerNow; announce() } }
2484
2563
  // Capture this session's current MODEL (resolved full id, with version — from
2485
2564
  // system/result `model` or usage `ctx.model`) and the SDK's supported-model
@@ -2496,13 +2575,14 @@ function openStructured({ id, runtime = 'claude', model, effort, resume, log, co
2496
2575
  // SDK id overwrite it. Only trust the SDK's model on the built-in Anthropic path.
2497
2576
  // Keyed on the LANE's provider (entry.provider), not the bridge default — lanes can
2498
2577
  // run on different registered providers. (bridge-truth slice.)
2499
- { const onBuiltin = !entry.provider || entry.provider === 'anthropic'
2500
- const m = onBuiltin ? (evt.model || evt.ctx?.model) : null
2578
+ { const onBuiltin = entry.runtime !== 'hermes' && (!entry.provider || entry.provider === 'anthropic')
2579
+ const m = (entry.runtime === 'hermes' || onBuiltin) ? (evt.model || evt.ctx?.model) : null
2501
2580
  if (m && m !== entry.model) { entry.model = m; announce() }
2502
2581
  // A custom Anthropic-compatible endpoint may expose its own catalog through the
2503
2582
  // same SDK method. Never publish that as the ROOM's Claude catalog: its models
2504
2583
  // already render in the provider group and otherwise leak under ANTHROPIC.
2505
- if (onBuiltin && evt.kind === 'models' && Array.isArray(evt.models) && evt.models.length) { roomModels = evt.models; announce() } }
2584
+ if (entry.runtime === 'hermes' && evt.kind === 'models' && Array.isArray(evt.models) && evt.models.length) { entry.models = evt.models; announce() }
2585
+ else if (onBuiltin && evt.kind === 'models' && Array.isArray(evt.models) && evt.models.length) { roomModels = evt.models; announce() } }
2506
2586
  // Cache the last usage/ctx meter on the entry (persisted + replayed — see
2507
2587
  // openStructured). Chrome events bypass pushLog/persist in emitTail below, so
2508
2588
  // usage needs an explicit persist() here to survive a bridge restart.
@@ -2718,7 +2798,7 @@ function openStructured({ id, runtime = 'claude', model, effort, resume, log, co
2718
2798
  process.stderr.write(req.risk === 'plan'
2719
2799
  ? `\n ${A.mag}◆ plan ready — approve in the room.${A.rst}\n`
2720
2800
  : req.risk === 'ask'
2721
- ? `\n ${A.cyan}● ${req.asker || (runtime === 'codex' ? 'Codex' : 'Claude')} is asking: ${(req.questions || []).map((q) => q.question).join(' / ').slice(0, 100)} — answer in the room.${A.rst}\n`
2801
+ ? `\n ${A.cyan}● ${req.asker || structuredRuntimeMetadata(runtime)?.label || 'Agent'} is asking: ${(req.questions || []).map((q) => q.question).join(' / ').slice(0, 100)} — answer in the room.${A.rst}\n`
2722
2802
  : `\n ${A.yel}● permission: ${req.toolName}${argStr(req.input)} — approve in the room.${A.rst}\n`)
2723
2803
  const autoResolutionMs = Number(req.autoResolutionMs)
2724
2804
  if (req.risk === 'ask' && Number.isFinite(autoResolutionMs) && autoResolutionMs > 0) {
@@ -2753,7 +2833,7 @@ function openStructured({ id, runtime = 'claude', model, effort, resume, log, co
2753
2833
  // room would otherwise show a fresh empty terminal + strand the transcript).
2754
2834
  if (!entry.log.length) entry.flush()
2755
2835
  announce()
2756
- if (!entry.log.length) process.stderr.write(`\n ◆ structured Claude session (${id.slice(0, 8)}) — driven from the room.\n`)
2836
+ if (!entry.log.length) process.stderr.write(`\n ◆ structured ${structuredRuntimeMetadata(runtime)?.label || 'agent'} session (${id.slice(0, 8)}) — driven from the room.\n`)
2757
2837
  return entry
2758
2838
  }
2759
2839
 
@@ -3063,7 +3143,7 @@ channel
3063
3143
  runtime,
3064
3144
  mode: parent?.mode || payload.mode,
3065
3145
  model: parent?.model || payload.model || undefined,
3066
- provider: runtime === 'codex' ? undefined : (parent?.provider || payload.provider || undefined),
3146
+ provider: runtime === 'claude' ? (parent?.provider || payload.provider || undefined) : undefined,
3067
3147
  cwd: parent ? (parent.cwd || process.cwd()) : undefined,
3068
3148
  sideParent: sideParent || undefined,
3069
3149
  sideTask: sideParent ? String(payload.sideTask).trim() : undefined,
@@ -3241,7 +3321,7 @@ channel
3241
3321
  // ── structured-session control (Phase 2) ──
3242
3322
  .on('broadcast', { event: 'code-open' }, ({ payload }) => {
3243
3323
  if (payload?.host && payload.host !== name) return
3244
- openStructured({ id: payload?.id || randomUUID(), runtime: payload?.runtime, model: payload?.model, provider: payload?.runtime === 'codex' ? undefined : payload?.provider, resume: payload?.resume, mode: payload?.mode, defer: true })
3324
+ openStructured({ id: payload?.id || randomUUID(), runtime: payload?.runtime, model: payload?.model, provider: payload?.runtime === 'claude' ? payload?.provider : undefined, resume: payload?.resume, mode: payload?.mode, defer: true })
3245
3325
  })
3246
3326
  .on('broadcast', { event: 'side-handback-request' }, ({ payload }) => {
3247
3327
  const side = payload?.term && sessions.get(payload.term)
@@ -3310,7 +3390,7 @@ channel
3310
3390
  if (mm) {
3311
3391
  if (mm[1]) {
3312
3392
  if (s.session.setModel(mm[1]) === false) {
3313
- ctlLine(s.runtime === 'codex' ? 'finish or stop the current Codex turn before switching models' : 'model switch unavailable right now')
3393
+ ctlLine(`finish or stop the current ${structuredRuntimeMetadata(s.runtime)?.label || 'agent'} turn before switching models`)
3314
3394
  return
3315
3395
  }
3316
3396
  // Reflect the switch in the announce IMMEDIATELY (before any turn). The SDK
@@ -3333,7 +3413,7 @@ channel
3333
3413
  }
3334
3414
  if (/^\/clear\s*$/.test(text)) {
3335
3415
  s.pendingRecap = null // /clear means forget context — drop any un-fired carry recap
3336
- if (s.runtime === 'codex') s.session.clearContext?.()
3416
+ if (s.runtime === 'codex' || s.runtime === 'hermes') s.session.clearContext?.()
3337
3417
  else s.session.sendTurn(text)
3338
3418
  // The seq reset is load-bearing on BOTH ends: the client's clear handler sets
3339
3419
  // seqHi = 0 (room.jsx), so the counter must restart with it. The durable archive
@@ -3417,7 +3497,7 @@ channel
3417
3497
  // image paths from the bridge-owned UPDIR + deterministic attachment fields,
3418
3498
  // then cover the separate file-put/code-turn download race with a bounded
3419
3499
  // readiness wait. A late image remains available through its quoted text path.
3420
- const nativeImages = s.runtime === 'codex'
3500
+ const nativeImages = s.runtime === 'codex' || s.runtime === 'hermes'
3421
3501
  ? await waitForNativeImages(payload.files, { updir: UPDIR })
3422
3502
  : []
3423
3503
  s.session.sendTurn(sendText, nativeImages.length ? { images: nativeImages } : undefined)
@@ -3458,9 +3538,9 @@ channel
3458
3538
  .on('broadcast', { event: 'code-mode' }, ({ payload }) => {
3459
3539
  const s = payload?.term && sessions.get(payload.term)
3460
3540
  if (s && STRUCTURED_MODES.has(payload.mode)) {
3461
- if (s.runtime === 'codex') {
3541
+ if (s.runtime === 'codex' || s.runtime === 'hermes') {
3462
3542
  if (!s.session.setMode(payload.mode)) {
3463
- const evt = { kind: 'control', text: 'finish or stop the current Codex turn before switching permissions' }
3543
+ const evt = { kind: 'control', text: `finish or stop the current ${structuredRuntimeMetadata(s.runtime)?.label || 'agent'} turn before switching permissions` }
3464
3544
  pushLog(s, evt)
3465
3545
  bcast('code-event', { term: payload.term, evt })
3466
3546
  announce()
@@ -3658,13 +3738,12 @@ channel
3658
3738
  // leaves that file at zero bytes; both App Server and exec reject
3659
3739
  // it. Treat only that provably-empty case as non-resumable so the
3660
3740
  // existing fresh-thread recap recovery handles it.
3661
- const resumable = canResume(rec)
3662
- && (rec.runtime !== 'codex' || codexThreadCanResume(rec.sessionId))
3741
+ const resumable = shouldAttemptNativeResume(rec, { canResume, codexThreadCanResume })
3663
3742
  const recoveryRecap = wasInterrupted ? buildRecapFromLog(rec.log || [], RECAP_CAP) : ''
3664
3743
  // FL-M6 — restore the flow context (id/role/cwd) so an in-flight flow survives a
3665
3744
  // bridge restart: the conductor keeps its subagent-block + plan interception, and
3666
3745
  // lanes keep their worktree cwd + the ability to mark done.
3667
- openStructured({ id: rec.id, runtime: rec.runtime || 'claude', model: rec.model || undefined, effort: rec.effort, provider: rec.runtime === 'codex' ? undefined : rec.provider || 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,
3746
+ 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,
3668
3747
  // Lazy-boot restored terminals that were IDLE + not part of a flow: their transcript
3669
3748
  // shows immediately; the query boots on first turn. Mid-turn + flow terminals boot now
3670
3749
  // (mid-turn needs auto-resume; flow needs its lane live).