thinkpool-pair 0.7.231 → 0.7.233

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
@@ -111,6 +111,7 @@ import { SIDE_HANDOFF_PROMPT, SIDE_ROLE_PROMPT, appendSideContext, assistantText
111
111
  import { planMeterLine } from './plan-meters.mjs'
112
112
  import { makeThrottledTrack } from './presence.mjs'
113
113
  import { resolveAnonKey, DEFAULT_SUPABASE_URL } from './supabase-key.mjs'
114
+ import { buildTerminalRolePrompt } from './thinkpool-room-prompt.mjs'
114
115
 
115
116
  // Public client creds (the same anon values the web app ships — safe to embed).
116
117
  // Override with TP_SUPABASE_URL / TP_SUPABASE_ANON if you ever need to.
@@ -1654,7 +1655,7 @@ function worktreeSnapshot(cwd) {
1654
1655
  // relay STRUCTURED events. onEvent → broadcast `code-event` + print locally +
1655
1656
  // persist to the host file; tool calls round-trip through the perm card; the
1656
1657
  // rolling log replays to joiners and survives bridge restarts (session-store).
1657
- function openStructured({ id, runtime = 'claude', model, effort, resume, log, commands, mode, spawnedBy, sideParent, sideTask, pendingSideContexts, rolePrompt, flowSessionId, flowTaskKey, flowRole, flowReviewTarget, flowReviewTargets, flowReviewRound, dispatchBaseSha, revertTarget, cwd, managedWorktree, reviewSliceRoots, openedAt, defer, provider, carryRecap, lastUsage }) {
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 }) {
1658
1659
  if (sessions.has(id)) return
1659
1660
  runtime = runtime === 'codex' ? 'codex' : 'claude'
1660
1661
  // No explicit mode → a sensible default per runtime (see defaultModeForRuntime):
@@ -1683,6 +1684,14 @@ function openStructured({ id, runtime = 'claude', model, effort, resume, log, co
1683
1684
  // session store so the Ensemble flag survives a bridge restart (else a respin
1684
1685
  // stripped it and the lane reverted to a plain tab — the t6 "no chip" bug).
1685
1686
  effort = new Set(['low', 'medium', 'high', 'xhigh', 'max']).has(effort) ? effort : 'high'
1687
+ // Structural role is durable and independent of the per-turn hop breaker. A
1688
+ // human can speak into a spawned lane (resetting hop to 0) without magically
1689
+ // turning that lane into a top-level terminal. Legacy records predate
1690
+ // spawnDepth; infer only the first level from their ownership marker.
1691
+ const structuralDepth = Number.isInteger(spawnDepth) && spawnDepth >= 0
1692
+ ? spawnDepth
1693
+ : (sideParent || (spawnedBy && !String(spawnedBy).startsWith('flow:')) ? 1 : 0)
1694
+ const initialHop = Number.isInteger(hop) && hop >= 0 ? hop : structuralDepth
1686
1695
  const entry = { cmd: runtime, runtime, kind: 'structured', log: Array.isArray(log) ? log.slice(-STRUCTURED_LOG_MAX) : [], pending: new Map(), session: null, recovered: false, commands: Array.isArray(commands) ? commands : undefined, mode, effort, models: runtime === 'codex' ? codexModels : undefined,
1687
1696
  // model: truthful active-model label — now the SAME `laneModel` the SDK is given, so the
1688
1697
  // chip cannot disagree with the wire. When this lane runs on a custom (non-anthropic)
@@ -1696,7 +1705,7 @@ function openStructured({ id, runtime = 'claude', model, effort, resume, log, co
1696
1705
  : (!provider || provider === 'anthropic')
1697
1706
  ? (laneModel || null)
1698
1707
  : (laneModel || providerNameMap()[provider] || provider),
1699
- provider: provider || null, spawnedBy: spawnedBy || undefined, sideParent: sideParent || undefined, sideTask: sideTask || undefined, pendingSideContexts: Array.isArray(pendingSideContexts) ? pendingSideContexts.filter(Boolean).slice(-4) : [], flowSessionId: flowSessionId || null, flowTaskKey: flowTaskKey || null, cwd: cwd || null, managedWorktree: managedWorktree || null,
1708
+ provider: provider || null, spawnedBy: spawnedBy || undefined, spawnDepth: structuralDepth, cascadeRole: cascadeRole === 'conductor' || cascadeRole === 'worker' ? cascadeRole : null, hop: initialHop, sideParent: sideParent || undefined, sideTask: sideTask || undefined, pendingSideContexts: Array.isArray(pendingSideContexts) ? pendingSideContexts.filter(Boolean).slice(-4) : [], flowSessionId: flowSessionId || null, flowTaskKey: flowTaskKey || null, cwd: cwd || null, managedWorktree: managedWorktree || null,
1700
1709
  flowRole: flowRole || (flowSessionId ? (flowTaskKey ? ((flowReviewTargets?.length || reviewSliceRoots?.length) ? 'reviewer' : 'builder') : 'conductor') : null),
1701
1710
  flowReviewTarget: flowReviewTarget || null,
1702
1711
  flowReviewTargets: Array.isArray(flowReviewTargets) && flowReviewTargets.length ? flowReviewTargets.filter(Boolean) : (flowReviewTarget ? [flowReviewTarget] : []),
@@ -1946,13 +1955,56 @@ function openStructured({ id, runtime = 'claude', model, effort, resume, log, co
1946
1955
  // restart. Without this, sessionData omitted it → on restart the resumed session
1947
1956
  // re-launched on the host default (Opus) regardless of the last switch, and the
1948
1957
  // switch looked like it "never changed the model" (Max 2026-07-02). Restored below.
1949
- const sessionData = () => ({ sessionId: entry.session?.sessionId || null, runtime: entry.runtime, log: entry.log, commands: entry.commands, mode: entry.mode, effort: entry.effort, model: entry.model || null, provider: entry.provider || null, spawnedBy: entry.spawnedBy, sideParent: entry.sideParent, sideTask: entry.sideTask, pendingSideContexts: entry.pendingSideContexts, flowSessionId: entry.flowSessionId, flowTaskKey: entry.flowTaskKey, flowRole: entry.flowRole || null, flowReviewTargets: entry.flowReviewTargets || [], flowReviewRound: entry.flowReviewRound || 0, dispatchBaseSha: entry.dispatchBaseSha || null, cwd: entry.cwd, managedWorktree: entry.managedWorktree, rolePrompt: entry.rolePrompt, flowReviewTarget: entry.flowReviewTarget || null, revertTarget: entry.revertTarget || null, reviewSliceRoots: entry.reviewSliceRoots || [], openedAt: entry.openedAt || null, lastUsage: entry.lastUsage || null, carryRecap: entry.pendingRecap || null })
1958
+ const sessionData = () => ({ sessionId: entry.session?.sessionId || null, runtime: entry.runtime, log: entry.log, commands: entry.commands, mode: entry.mode, effort: entry.effort, model: entry.model || null, provider: entry.provider || null, spawnedBy: entry.spawnedBy, spawnDepth: entry.spawnDepth || 0, cascadeRole: entry.cascadeRole || null, hop: entry.hop || 0, sideParent: entry.sideParent, sideTask: entry.sideTask, pendingSideContexts: entry.pendingSideContexts, flowSessionId: entry.flowSessionId, flowTaskKey: entry.flowTaskKey, flowRole: entry.flowRole || null, flowReviewTargets: entry.flowReviewTargets || [], flowReviewRound: entry.flowReviewRound || 0, dispatchBaseSha: entry.dispatchBaseSha || null, cwd: entry.cwd, managedWorktree: entry.managedWorktree, rolePrompt: entry.rolePrompt, flowReviewTarget: entry.flowReviewTarget || null, revertTarget: entry.revertTarget || null, reviewSliceRoots: entry.reviewSliceRoots || [], openedAt: entry.openedAt || null, lastUsage: entry.lastUsage || null, carryRecap: entry.pendingRecap || null })
1950
1959
  const persist = () => saveSession(room, id, sessionData())
1951
1960
  // Synchronous flush of this session's record. Used on open (so a brand-new session
1952
1961
  // has a file under its id BEFORE its first event — surviving a restart inside the
1953
1962
  // 1.5s saveSession debounce window) and on shutdown (so events since the last
1954
1963
  // debounced write aren't lost). Contract #2: restart resumes, no lost messages.
1955
1964
  entry.flush = () => flushSession(room, id, sessionData())
1965
+ // Main-terminal creation and Ensemble dispatch are different capabilities.
1966
+ // A main terminal (human-opened or agent-opened via open_main_terminal) may
1967
+ // dispatch workers. A spawned/Side/Flow lane can never elevate itself or a
1968
+ // child into a conductor merely by wording its task that way.
1969
+ const isIndependentMain = !entry.spawnedBy && !entry.sideParent && !entry.flowRole && (entry.spawnDepth || 0) === 0
1970
+ const canOpenMainConductor = isIndependentMain && entry.cascadeRole !== 'conductor'
1971
+ const canSpawnWorkers = isIndependentMain
1972
+ const resolveAgentOpen = (args, { worker = false } = {}) => {
1973
+ const runtime = args?.runtime || entry.runtime || 'claude'
1974
+ 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.' }
1975
+ const catalog = runtime === 'codex' ? readCodexModels() : []
1976
+ if (runtime === 'codex' && args?.model && !modelCatalogValues(catalog).has(args.model)) {
1977
+ return { error: `Could not open a Codex terminal on ${JSON.stringify(args.model)} — that model is not in this host's visible Codex catalog.` }
1978
+ }
1979
+ if (runtime === 'claude' && !args?.provider && args?.model && /^gpt-/i.test(args.model)) {
1980
+ return { error: `Could not open a Claude terminal on Codex model ${JSON.stringify(args.model)}. Choose runtime="codex" or a Claude model.` }
1981
+ }
1982
+ return {
1983
+ runtime,
1984
+ model: args?.model || (worker && args?.sliceType
1985
+ ? spawnedLaneModelFor({ sliceType: args.sliceType, runtime, catalog })
1986
+ : undefined),
1987
+ mode: args?.mode || (entry.mode === 'plan' ? 'default' : entry.mode),
1988
+ provider: args?.provider,
1989
+ }
1990
+ }
1991
+ const consumeTerminalOpenBudget = () => {
1992
+ const now = Date.now()
1993
+ const gate = spawnDecision({
1994
+ hop: 0,
1995
+ spawnTimes: entry.spawnTimes || [],
1996
+ now,
1997
+ // Main terminals do not consume the Ensemble plan ceiling. The pure
1998
+ // gate still supplies the shared kill-switch, machine cap, and burst cap.
1999
+ spawnedLive: 0,
2000
+ totalLive: sessions.size + terms.size,
2001
+ plan: ownerPlan,
2002
+ disabled: process.env.TP_SPAWN_OFF === '1',
2003
+ })
2004
+ if (!gate.ok) return gate
2005
+ entry.spawnTimes = [...(entry.spawnTimes || []).filter((t) => Number.isFinite(t) && t > now - SPAWN.windowMs), now]
2006
+ return gate
2007
+ }
1956
2008
  // B2 cross-terminal peek — an in-process, READ-ONLY tool the agent can call to
1957
2009
  // see a SIBLING terminal's recent output (vs B1's always-on @pool digest). The
1958
2010
  // handler closes over the module-scope `sessions`/`terms` Maps + this session's
@@ -2097,19 +2149,68 @@ function openStructured({ id, runtime = 'claude', model, effort, resume, log, co
2097
2149
  return okText(`Delivered to terminal ${target.ref} (${target.cmd}). It will respond in its own lane; check back with read_terminal.`)
2098
2150
  },
2099
2151
  )] : []),
2100
- // Tier C+ — SPAWN a fresh agent lane the caller OWNS. The motivating bug:
2152
+ // MAIN CONDUCTOR — create the same independent structured terminal the web
2153
+ // "+ New terminal" path creates: no spawnedBy marker, no Ensemble ownership,
2154
+ // depth 0. Only an ordinary independent main terminal receives this tool;
2155
+ // conductor mains, spawned workers, Side lanes, and Flow lanes cannot use it.
2156
+ ...(canOpenMainConductor ? [tool(
2157
+ 'open_main_terminal',
2158
+ '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.',
2159
+ {
2160
+ name: z.string().max(80).optional().describe('short label for the main conductor, e.g. "Cascade · Multi-device chaos"'),
2161
+ task: z.string().min(1).describe('complete initial Cascade brief for the conductor'),
2162
+ model: z.string().optional().describe('optional conductor model, e.g. opus / gpt-5.6-sol'),
2163
+ runtime: z.enum(['claude', 'codex']).optional().describe('agent runtime; defaults to inheriting this main terminal'),
2164
+ provider: z.string().optional().describe('optional registered LLM provider id for a Claude conductor'),
2165
+ mode: z.enum(['default', 'acceptEdits', 'bypassPermissions', 'plan']).optional().describe('permission mode; defaults to inheriting this main terminal, except plan falls back to default'),
2166
+ },
2167
+ async (args) => {
2168
+ const okText = (t) => ({ content: [{ type: 'text', text: t }] })
2169
+ const resolved = resolveAgentOpen(args)
2170
+ if (resolved.error) return okText(resolved.error)
2171
+ const gate = consumeTerminalOpenBudget()
2172
+ if (!gate.ok) return okText(gate.reason)
2173
+ const newId = randomUUID()
2174
+ const newRef = String(newId).slice(0, 8)
2175
+ const fromRef = String(id).slice(0, 8)
2176
+ if (args?.name) { termNames[newId] = String(args.name).slice(0, 80); saveNames(room, termNames) }
2177
+ openStructured({
2178
+ id: newId,
2179
+ runtime: resolved.runtime,
2180
+ model: resolved.model,
2181
+ provider: resolved.provider,
2182
+ mode: resolved.mode,
2183
+ spawnDepth: 0,
2184
+ cascadeRole: 'conductor',
2185
+ hop: 0,
2186
+ })
2187
+ const conductor = sessions.get(newId)
2188
+ if (!conductor) {
2189
+ if (args?.name) { delete termNames[newId]; saveNames(room, termNames) }
2190
+ return okText('Could not open the main conductor terminal — the machine terminal cap may have just been reached.')
2191
+ }
2192
+ conductor.peekCount = 0; conductor.postCount = 0; conductor.spawnTimes = []
2193
+ announce()
2194
+ 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()}`
2195
+ const evt = { kind: 'you', text: msg, by: `main terminal ${fromRef} (agent)`, crosspost: true, relaySourceName: termNames[id] || undefined }
2196
+ stampEvent(evt); pushLog(conductor, evt); bcast('code-event', { term: newId, evt })
2197
+ try { conductor.session.sendTurn(msg) }
2198
+ catch { return okText(`Opened main conductor ${newRef}${args?.name ? ` ("${args.name}")` : ''}, but it may still be starting — the initial task could not be delivered.`) }
2199
+ 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.`)
2200
+ },
2201
+ )] : []),
2202
+ // Tier C+ — SPAWN a fresh WORKER lane the caller OWNS. The motivating bug:
2101
2203
  // an agent fanned a research task into SIBLINGS that were already busy (a
2102
2204
  // release lane, a Q&A lane) because it had no way to make its own lanes.
2103
2205
  // This opens a brand-new structured session server-side (the same path the
2104
2206
  // web's "+ New terminal" and the restore loop use), so announce() surfaces
2105
2207
  // its tab in the open web UI live. Auto-allowed by the PreToolUse gate but
2106
- // bounded by spawnDecision (depth-2 hop bound, burst window, the live terminal
2107
- // cap, TP_SPAWN_OFF kill-switch). A person's lane (hop 0) may spawn conductors
2108
- // (hop 1) which may spawn workers (hop 2); hop 2 can never spawn onward — the
2109
- // fork-bomb breaker, now one level deeper (2026-07-10 cascade-spawn-depth spec).
2110
- ...(!entry.flowRole ? [tool(
2208
+ // bounded by spawnDecision (one-worker-tier hop bound, burst window, the live terminal
2209
+ // cap, TP_SPAWN_OFF kill-switch). Spawned lanes are always workers and do
2210
+ // not receive this tool, so the hierarchy is capability-enforced.
2211
+ ...(canSpawnWorkers ? [tool(
2111
2212
  'spawn_terminal',
2112
- 'Open a NEW agent terminal (Claude or Codex) in this ThinkPool Code room — YOUR lane to use, not a sibling that is already working. 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.',
2213
+ '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.',
2113
2214
  {
2114
2215
  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"'),
2115
2216
  task: z.string().optional().describe('an initial task to hand the new lane immediately; omit to open it idle'),
@@ -2121,18 +2222,8 @@ function openStructured({ id, runtime = 'claude', model, effort, resume, log, co
2121
2222
  },
2122
2223
  async (args) => {
2123
2224
  const okText = (t) => ({ content: [{ type: 'text', text: t }] })
2124
- const childRuntime = args?.runtime || entry.runtime || 'claude'
2125
- 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.')
2126
- const childCatalog = childRuntime === 'codex' ? readCodexModels() : []
2127
- if (childRuntime === 'codex' && args?.model && !modelCatalogValues(childCatalog).has(args.model)) {
2128
- return okText(`Could not open a Codex lane on ${JSON.stringify(args.model)} — that model is not in this host's visible Codex catalog.`)
2129
- }
2130
- if (childRuntime === 'claude' && !args?.provider && args?.model && /^gpt-/i.test(args.model)) {
2131
- return okText(`Could not open a Claude lane on Codex model ${JSON.stringify(args.model)}. Choose runtime="codex" or a Claude model.`)
2132
- }
2133
- const childModel = args?.model || (args?.sliceType
2134
- ? spawnedLaneModelFor({ sliceType: args.sliceType, runtime: childRuntime, catalog: childCatalog })
2135
- : undefined)
2225
+ const resolved = resolveAgentOpen(args, { worker: true })
2226
+ if (resolved.error) return okText(resolved.error)
2136
2227
  const now = Date.now()
2137
2228
  const gate = spawnDecision({
2138
2229
  hop: entry.hop || 0,
@@ -2166,19 +2257,20 @@ function openStructured({ id, runtime = 'claude', model, effort, resume, log, co
2166
2257
  // PM-m1 — never INHERIT plan mode into a spawned worker lane: a lane spawned from a
2167
2258
  // plan-mode lane would stall on ExitPlanMode cards. An explicit args.mode still wins;
2168
2259
  // otherwise inherit, but fall plan → default (an autonomous worker doesn't plan-gate).
2169
- const childMode = args?.mode || (entry.mode === 'plan' ? 'default' : entry.mode)
2170
- openStructured({ id: newId, runtime: childRuntime, model: childModel, provider: args?.provider, mode: childMode })
2260
+ const childSpawnDepth = (entry.spawnDepth || 0) + 1
2261
+ const childHop = (entry.hop || 0) + 1
2262
+ // Stamp ownership/depth BEFORE the runtime starts so its first system
2263
+ // preamble is truthful. Mutating ne.spawnedBy after openStructured was
2264
+ // too late: Codex/Claude had already booted with the top-level wording.
2265
+ openStructured({ id: newId, runtime: resolved.runtime, model: resolved.model, provider: resolved.provider, mode: resolved.mode, spawnedBy: id, spawnDepth: childSpawnDepth, cascadeRole: 'worker', hop: childHop })
2171
2266
  const ne = sessions.get(newId)
2172
2267
  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.') }
2173
- ne.spawnedBy = id // ownership: only the spawner may close_terminal it
2268
+ ne.peekCount = 0; ne.postCount = 0; ne.spawnTimes = []
2174
2269
  announce() // (defensive) ensure the web renders the new tab live
2175
2270
  if (args?.task) {
2176
- // One level deeper than the spawner: hop = parent.hop + 1, so the depth-2
2177
- // bound in spawnDecision binds at every level (hop-0 person's lane → hop-1
2178
- // conductor → hop-2 worker, which can never spawn onward). A person speaking
2179
- // to any lane still resets it to hop 0 (code-turn). Mirrors Tier C injection.
2180
- ne.hop = (entry.hop || 0) + 1; ne.peekCount = 0; ne.postCount = 0; ne.spawnTimes = []
2181
- const msg = `[Task from terminal ${fromRef}'s agent — relayed via ThinkPool cross-terminal; you are a fresh lane it opened for you]\n${args.task}`
2271
+ // The user-visible relay reinforces (but never defines) the structural
2272
+ // role already injected by the system preamble above.
2273
+ 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}`
2182
2274
  const evt = { kind: 'you', text: msg, by: `terminal ${fromRef} (agent)`, crosspost: true, relaySourceName: termNames[id] || undefined }
2183
2275
  stampEvent(evt); pushLog(ne, evt); bcast('code-event', { term: newId, evt })
2184
2276
  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.`) }
@@ -2311,11 +2403,18 @@ function openStructured({ id, runtime = 'claude', model, effort, resume, log, co
2311
2403
  // on the init `system` event (see onEvent) — a sendTurn before the input stream is consumed
2312
2404
  // is silently lost (the 2026-07-02 auto-resume bug). Empty string → nothing to carry.
2313
2405
  entry.pendingRecap = (typeof carryRecap === 'string' && carryRecap.trim()) ? carryRecap : null
2406
+ const terminalRolePrompt = buildTerminalRolePrompt({
2407
+ spawnedBy: entry.spawnedBy,
2408
+ spawnDepth: entry.spawnDepth,
2409
+ cascadeRole: entry.cascadeRole,
2410
+ flowRole: entry.flowRole,
2411
+ sideParent: entry.sideParent,
2412
+ })
2314
2413
  entry.session = (runtime === 'codex' ? startCodexSession : startClaudeSession)({
2315
2414
  // laneModel, NOT the raw `model` param: the SDK's `model` option OVERRIDES the
2316
2415
  // ANTHROPIC_MODEL supplied by resolveProviderEnv() in `env` below, so an inherited
2317
2416
  // Claude id here defeats the whole per-provider env. undefined → option omitted.
2318
- cwd: cwd || process.cwd(), model: laneModel, effort, resume, mode, rolePrompt,
2417
+ cwd: cwd || process.cwd(), model: laneModel, effort, resume, mode, terminalRolePrompt, rolePrompt,
2319
2418
  // Idle ordinary tabs create nothing. Immediately before the FIRST real
2320
2419
  // turn (human prompt, dispatch, or cascade task), the runtime calls this
2321
2420
  // while still cold; subsequent turns reuse the persisted worktree.
@@ -2413,7 +2512,7 @@ function openStructured({ id, runtime = 'claude', model, effort, resume, log, co
2413
2512
  openStructured({
2414
2513
  id, runtime: entry.runtime, model: entry.model || model, effort: entry.effort,
2415
2514
  provider: entry.provider, log: entry.log, commands: entry.commands, mode: entry.mode,
2416
- spawnedBy: entry.spawnedBy, sideParent: entry.sideParent, sideTask: entry.sideTask, pendingSideContexts: entry.pendingSideContexts, flowSessionId: entry.flowSessionId,
2515
+ spawnedBy: entry.spawnedBy, spawnDepth: entry.spawnDepth, cascadeRole: entry.cascadeRole, hop: entry.hop, sideParent: entry.sideParent, sideTask: entry.sideTask, pendingSideContexts: entry.pendingSideContexts, flowSessionId: entry.flowSessionId,
2417
2516
  flowTaskKey: entry.flowTaskKey, flowRole: entry.flowRole, flowReviewTarget: entry.flowReviewTarget,
2418
2517
  flowReviewTargets: entry.flowReviewTargets, flowReviewRound: entry.flowReviewRound,
2419
2518
  dispatchBaseSha: entry.dispatchBaseSha, revertTarget: entry.revertTarget, cwd: entry.cwd,
@@ -2830,7 +2929,7 @@ function respawnStructured(id, provider) {
2830
2929
  // openStructured seed from the TARGET provider's configured model, which is the
2831
2930
  // only model this lane was ever asked for. A same-env model change never reaches
2832
2931
  // here — that path is an in-place setModel (see provider-switch).
2833
- const { runtime, log, commands, mode, spawnedBy, sideParent, sideTask, pendingSideContexts, flowSessionId, flowTaskKey, flowRole, flowReviewTarget, flowReviewTargets, flowReviewRound, dispatchBaseSha, revertTarget, cwd, managedWorktree, rolePrompt, reviewSliceRoots, openedAt, lastUsage } = s
2932
+ const { runtime, log, commands, mode, spawnedBy, spawnDepth, cascadeRole, hop, sideParent, sideTask, pendingSideContexts, flowSessionId, flowTaskKey, flowRole, flowReviewTarget, flowReviewTargets, flowReviewRound, dispatchBaseSha, revertTarget, cwd, managedWorktree, rolePrompt, reviewSliceRoots, openedAt, lastUsage } = s
2834
2933
  // Context-carry (2026-07-08): a provider switch is not an SDK resume — the new backend
2835
2934
  // starts blank mid-conversation. Synthesize a plain-text recap from the VISIBLE log NOW
2836
2935
  // (before teardown) and hand it to the fresh session as its first turn so the agent
@@ -2849,7 +2948,7 @@ function respawnStructured(id, provider) {
2849
2948
  // sessionData() (provider included) synchronously on open, so a bridge restart
2850
2949
  // restores the lane on its CURRENT provider, not the original — and its next
2851
2950
  // announce carries the new provider badge (additive {id,name} projection).
2852
- openStructured({ id, runtime, provider, log, commands, mode, spawnedBy, sideParent, sideTask, pendingSideContexts, flowSessionId, flowTaskKey, flowRole, flowReviewTarget, flowReviewTargets, flowReviewRound, dispatchBaseSha, revertTarget, cwd, managedWorktree, rolePrompt, reviewSliceRoots, openedAt, lastUsage, carryRecap })
2951
+ openStructured({ id, runtime, provider, log, commands, mode, spawnedBy, spawnDepth, cascadeRole, hop, sideParent, sideTask, pendingSideContexts, flowSessionId, flowTaskKey, flowRole, flowReviewTarget, flowReviewTargets, flowReviewRound, dispatchBaseSha, revertTarget, cwd, managedWorktree, rolePrompt, reviewSliceRoots, openedAt, lastUsage, carryRecap })
2853
2952
  return true
2854
2953
  }
2855
2954
 
@@ -3642,7 +3741,7 @@ channel
3642
3741
  // FL-M6 — restore the flow context (id/role/cwd) so an in-flight flow survives a
3643
3742
  // bridge restart: the conductor keeps its subagent-block + plan interception, and
3644
3743
  // lanes keep their worktree cwd + the ability to mark done.
3645
- 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, 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,
3744
+ 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,
3646
3745
  // Lazy-boot restored terminals that were IDLE + not part of a flow: their transcript
3647
3746
  // shows immediately; the query boots on first turn. Mid-turn + flow terminals boot now
3648
3747
  // (mid-turn needs auto-resume; flow needs its lane live).
@@ -200,14 +200,14 @@ const TP_ROOM_REMINDER = [
200
200
  THINKPOOL_DESIGN_DELIVERY_RULE,
201
201
  'REMOTE USER: the people here may have no terminal and no access to this host — never ask them to run a local command, open a local file, or check something on the machine. Anything host-side, YOU run, and show the result.',
202
202
  'SHOW, don\'t narrate: a picture of the result beats a wall of text on a phone — err toward more screenshots.',
203
- 'ENSEMBLE: delegated work goes through the room — fan parallel work out with spawn_terminal (your own fresh lanes, visible and steerable by the people here), NOT the built-in invisible Task/Agent subagents; never hijack a busy sibling.',
203
+ 'TERMINAL HIERARCHY: obey your authoritative TERMINAL ROLE. Conductors are independent main terminals; Ensemble lanes are workers only. When a person explicitly asks to open, launch, start, or spawn a separate Cascade/conductor terminal, use open_main_terminal — NEVER spawn_terminal. If open_main_terminal is unavailable, say so; never substitute an Ensemble child. Only conductor-capable roles fan worker slices out with spawn_terminal. Leaf, worker, Side, and managed Flow lanes work directly. Never use built-in invisible Task/Agent subagents or hijack a busy sibling.',
204
204
  'PEER: before substantive work, check what the other lanes are doing (the ROOM NOW snapshot below, read_terminal for detail; list_sessions/read_session across rooms) — coordinate on shared files/branches instead of colliding.',
205
205
  'WORKTREES: parallel lanes share one repo — before code edits run `git worktree list`; if linked worktrees exist, take your OWN worktree + branch, never the shared checkout or a branch another lane is on.',
206
- 'BUILD WORKFLOW (default, no magic word): right-size — a trivial ask you just do; a non-trivial build you FIRST write a short plan as a chat message, THEN fan independent slices out to visible spawn_terminal lanes, build, and self-verify. Never plan-mode/ExitPlanMode; the plan lives in chat and the lanes in the existing lane list — keep both visible, add no new panel.',
206
+ 'BUILD WORKFLOW (default, no magic word): right-size within your TERMINAL ROLE — a trivial ask or delegated slice you just do; a conductor-capable role with a genuinely decomposable build FIRST writes a short plan in chat, THEN fans worker slices into visible spawn_terminal lanes and verifies them. Worker/leaf/Side/managed Flow roles do not fan out. A requested separate conductor uses open_main_terminal. Never plan-mode/ExitPlanMode; plans live in chat and lanes in the existing list.',
207
207
  'Verify before claiming done — show runtime evidence you produced, not "should work, go test it".',
208
208
  ].join(' ')
209
209
 
210
- export function startClaudeSession({ cwd, model, effort: initialEffort = 'high', resume, env, mode: initialMode = 'default', onEvent, requestPermission, mcpServers, crossPostGate, crossRoomPostGate, didSpawnTarget = null, rolePrompt, blockSubagents = false, onSubmitPlan = null, onLaneDone = null, onReviewVerdict = null, reviewGate = null, lazy = false, roomContext = null, suggest = true, prepareCwd = null }) {
210
+ export function startClaudeSession({ cwd, model, effort: initialEffort = 'high', resume, env, mode: initialMode = 'default', onEvent, requestPermission, mcpServers, crossPostGate, crossRoomPostGate, didSpawnTarget = null, terminalRolePrompt, rolePrompt, blockSubagents = false, onSubmitPlan = null, onLaneDone = null, onReviewVerdict = null, reviewGate = null, lazy = false, roomContext = null, suggest = true, prepareCwd = null }) {
211
211
  // Per-turn reminder + live ROOM NOW tail. roomContext (bridge-supplied) returns the
212
212
  // room's CURRENT state — sibling lanes, active git worktrees — or null. The static
213
213
  // rules keep the agent aware of the room's FEATURES; the live tail keeps it aware of
@@ -216,7 +216,8 @@ export function startClaudeSession({ cwd, model, effort: initialEffort = 'high',
216
216
  const roomReminder = () => {
217
217
  let live = null
218
218
  try { live = roomContext ? roomContext() : null } catch { live = null }
219
- return `<system-reminder>\n${TP_ROOM_REMINDER}${live ? `\n\n${live}` : ''}\n</system-reminder>`
219
+ const role = String(terminalRolePrompt || '').trim()
220
+ return `<system-reminder>\n${role ? `${role}\n\n` : ''}${TP_ROOM_REMINDER}${live ? `\n\n${live}` : ''}\n</system-reminder>`
220
221
  }
221
222
  const ac = new AbortController()
222
223
  let input = makeInputStream() // `let`: auto-restart swaps in a fresh stream
@@ -515,25 +516,25 @@ export function startClaudeSession({ cwd, model, effort: initialEffort = 'high',
515
516
  if (toolName === 'mcp__thinkpool__close_terminal') {
516
517
  return { continue: true, hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'allow', permissionDecisionReason: 'Auto-approved (ThinkPool cross-terminal close — only self-spawned lanes).' } }
517
518
  }
518
- // ThinkPool cross-terminal SPAWN (Tier C+) — opens a FRESH lane the agent owns
519
- // (never touches a sibling). A spawned lane INHERITS the spawner's permission mode
519
+ // ThinkPool terminal creation — open_main_terminal creates an independent main
520
+ // conductor; spawn_terminal creates a worker the agent owns. Both inherit mode
520
521
  // (`mode` here is THIS lane's current mode) unless an explicit `mode` overrides it.
521
522
  // The only thing worth a human card is an ESCALATION: raising a child to
522
523
  // bypassPermissions from a non-bypass lane (full unattended access the operator
523
524
  // hasn't already opted into). If the spawner is already in bypass, children inherit
524
525
  // bypass with NO prompt (you opted in once); a non-bypass child gates itself per
525
526
  // action anyway, so no spawn prompt there either. Bridge-side spawnDecision still
526
- // enforces the depth-2 hop bound / burst window / machineMax / TP_SPAWN_OFF regardless.
527
- if (toolName === 'mcp__thinkpool__spawn_terminal') {
527
+ // enforces the one-worker-tier bound / burst window / machineMax / TP_SPAWN_OFF regardless.
528
+ if (toolName === 'mcp__thinkpool__spawn_terminal' || toolName === 'mcp__thinkpool__open_main_terminal') {
528
529
  const childMode = toolInput?.mode || mode // inherit this lane's mode by default
529
530
  const escalating = childMode === 'bypassPermissions' && mode !== 'bypassPermissions'
530
531
  if (!escalating) {
531
- return { continue: true, hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'allow', permissionDecisionReason: `Auto-approved (spawn a fresh lane in ${childMode} mode — inherited, no escalation; bounded by the room caps).` } }
532
+ return { continue: true, hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'allow', permissionDecisionReason: `Auto-approved (open a fresh ThinkPool terminal in ${childMode} mode — inherited, no escalation; bounded by room caps).` } }
532
533
  }
533
534
  let decision = 'deny'
534
535
  try { decision = await requestPermission?.({ id: randomUUID(), toolName, input: { ...toolInput, mode: childMode }, risk: 'high' }) ?? 'deny' } catch { decision = 'deny' }
535
536
  const allowed = decision === 'allow'
536
- return { continue: true, hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: allowed ? 'allow' : 'deny', permissionDecisionReason: allowed ? 'Bypass spawn approved in the ThinkPool room — the new lane runs autonomously.' : 'Bypass spawn denied in the room — do not retry as bypass; re-spawn without mode:bypassPermissions to get a gated lane, or ask what to do.' } }
537
+ return { continue: true, hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: allowed ? 'allow' : 'deny', permissionDecisionReason: allowed ? 'Bypass terminal creation approved in the ThinkPool room — the new terminal runs autonomously.' : 'Bypass terminal creation denied in the room — do not retry as bypass; open it without mode:bypassPermissions or ask what to do.' } }
537
538
  }
538
539
  // ThinkPool cross-terminal POST (Tier C) WRITES into a sibling agent's lane.
539
540
  // First a bridge-side precheck (kill-switch / loop-breaker / per-turn cap) so a
@@ -690,6 +691,7 @@ export function startClaudeSession({ cwd, model, effort: initialEffort = 'high',
690
691
  // a bypass session still planned. Bias the room session to DO the work; only
691
692
  // plan when the room is actually in Plan mode (⇧⇥) or the user explicitly asks.
692
693
  appendSystemPrompt: [
694
+ ...(terminalRolePrompt ? [terminalRolePrompt] : []),
693
695
  ...(rolePrompt ? [rolePrompt] : []),
694
696
  'ENVIRONMENT (authoritative — overrides any user-global CLAUDE.md or memory that claims otherwise): You are Claude running inside a ThinkPool Code room, driven live by a user (and possibly a partner) from a phone or browser, via the thinkpool-pair bridge.',
695
697
  'You are NOT in cmux. cmux may be installed on this machine and appear in $PATH, but it did not spawn you and its automation/orchestration rules do not apply. If any loaded memory says "Claude Code lives inside cmux," it is describing direct terminal use, not this room — disregard it here. The runtime truth is the env: THINKPOOL_PAIR_ACCOUNT_CHILD / npm_lifecycle_script=thinkpool-pair mark a ThinkPool Code session.',
@@ -700,15 +702,15 @@ export function startClaudeSession({ cwd, model, effort: initialEffort = 'high',
700
702
  // no summon word. The agent INFERS build intent and runs plan→(optional)fan-out→build→self-verify,
701
703
  // right-sized to the task, surfaced in the room's EXISTING surfaces (chat + lane list) — never a new panel,
702
704
  // never plan-mode's approval card. Trivial asks stay single-lane with zero ceremony.
703
- 'DEFAULT BUILD WORKFLOW (this IS your standard behaviour — no keyword and no "flow" word is ever required to trigger it; you infer it from the request itself). RIGHT-SIZE to the task, always: a TRIVIAL ask — a one-line fix, a small tweak, a question — you just do it directly, with no plan and no extra lanes (never tax small work with ceremony). A NON-TRIVIAL build that genuinely decomposes — you FIRST write a short plan as a normal chat message (the approach, the concrete slices, and what you will verify), THEN fan the independent slices out into their own visible lanes with spawn_terminal, build, and self-verify each slice before calling it done.',
705
+ 'DEFAULT BUILD WORKFLOW (this IS your standard behaviour — no keyword and no "flow" word is ever required to trigger it; you infer it from the request itself). RIGHT-SIZE to the task within the authoritative TERMINAL ROLE, always: a TRIVIAL ask — a one-line fix, a small tweak, a question — you just do it directly, with no plan and no extra lanes (never tax small work with ceremony). A NON-TRIVIAL build that genuinely decomposes, when your role permits delegation — you FIRST write a short plan as a normal chat message (the approach, the concrete slices, and what you will verify), THEN fan worker slices into visible Ensemble lanes with spawn_terminal, build, and self-verify each slice before calling it done. When the person explicitly asks you to launch a SEPARATE Cascade/conductor terminal, call open_main_terminal instead; never represent that request with spawn_terminal. Worker, leaf, Side, and managed Flow lanes complete their assigned work directly and do not fan out.',
704
706
  'CRUCIAL RECONCILIATION for that workflow: it is NOT plan mode. Never call ExitPlanMode and never make the room wait behind a "plan ready — approve to start" card — your plan lives in the CHAT as a message, and your lanes live in the room\'s EXISTING terminal/lane list. Reuse only those two surfaces; there is no new Flow panel or mode to switch into, and you must not ask for one. Keep the plan and the lanes VISIBLE — that shared visibility is the whole point (it is the pair differentiator, and it catches bugs a single silent lane would hide); never collapse a decomposable build into one hidden lane just to look tidy.',
705
707
  ...THINKPOOL_REMOTE_DELIVERY_RULES,
706
708
  'CROSS-TERMINAL AWARENESS: this room may have other terminals open alongside yours — other agents working, or shells the people are driving. You have a READ-ONLY tool, read_terminal: call it with no arguments to list the other open terminals, or with a terminal ref/id/command to read that terminal\'s recent activity. Reach for it when your work depends on what another terminal is doing (e.g. someone says "see what the other terminal hit", or you need to coordinate with a sibling agent before acting). It only ever reads — it never changes another terminal. Identify a terminal by its NAME or its ref/id from the roster, never by an on-screen number like "Terminal 2" — those positional labels renumber when a terminal is closed, so they do not reliably point at a lane.',
707
709
  'CROSS-TERMINAL HAND-OFF: you also have post_to_terminal(terminal, text) to send a message or task to ANOTHER AGENT terminal in this room (not a plain shell). Use it sparingly and only when the people clearly want the lanes to coordinate — e.g. "tell the backend terminal the API is ready", or to hand a sibling agent a concrete task. Every post requires a person in the room to approve a card before it is delivered, and an agent that was itself reached via a cross-post cannot post onward — so do not rely on it for chit-chat or loops. Prefer read_terminal to understand a sibling before you ever post to it.',
708
- 'CROSS-TERMINAL SPAWN: when a person asks you to fan work out across multiple agents — research lanes, parallel sub-tasks, a swarm — open YOUR OWN fresh lanes with spawn_terminal(name?, task?, model?); do NOT dump the work into siblings that are already busy (a sibling is mid-task unless read_terminal shows it idle, and hijacking it derails their work). spawn_terminal opens a brand-new Claude lane and, if you pass `task`, hands it that task immediately; it appears as a new terminal in the room. A spawned lane INHERITS your current permission mode by default — so if you are in bypass, your lanes run autonomously with no per-action clicking; pass `mode` only to override. Raising a lane to bypass from a non-bypass lane asks the room to confirm once (everything else spawns without a prompt). Collect each lane\'s result later with read_terminal (a spawned lane cannot post back to you). CLOSING IS PART OF THE JOB, not housekeeping you get to skip: the moment you have read a lane\'s result and acted on it, close_terminal it. A finished lane left open holds a slot against the room\'s machine cap and against your dispatch ceiling, so the NEXT fan-out — yours, or a person\'s — gets refused; rooms silt up with dead lanes exactly this way. Before each new wave of spawns, list your open lanes (read_terminal with no argument) and close the finished ones first. If a spawn is refused for capacity, the refusal names your own idle lanes so you do not have to hunt. You may only close lanes you spawned yourself — never close a sibling\'s, and never close one that is mid-turn. You are bounded: a sliding spawn-burst window, a per-plan dispatch ceiling on concurrent spawned lanes (Free 3 / Plus 6) that is separate from the human terminals so it never blocks them, an overall machine cap on live lanes, and a DEPTH bound — a lane opened by a person may spawn workers, and those workers may spawn one further level (a conductor dispatching its crew), but that second level can never spawn onward (so no runaway). This is how you "open the terminals yourself" instead of asking a person to.',
710
+ 'TERMINAL CREATION CONTRACT: conductors and workers use different tools. When a person explicitly asks to open, launch, start, or spawn a separate Cascade/conductor terminal, use open_main_terminal(name?, task?, model?) — it creates an independent MAIN terminal with no Ensemble owner. Never use spawn_terminal for that request, and if open_main_terminal is unavailable say so instead of substituting. Use spawn_terminal(name?, task?, model?, sliceType?) only for bounded WORKER SUB-TERMINALS when your authoritative role permits delegation. Workers never receive the creation tools and never conduct. Do not dump work into busy siblings. Spawned workers inherit your permission mode by default; raising one to bypass asks the room once. Collect each worker with read_terminal and close_terminal immediately after using its result. Main conductors are independent terminals and are not owned/closed through Ensemble. Room caps and the kill-switch bind both creation paths.',
709
711
  'CROSS-SESSION AWARENESS: the Ensemble reaches across your SESSIONS, not just the terminals in this room. list_sessions() lists your OTHER ThinkPool Code rooms — both your own rooms running on this machine AND your partner\'s rooms in the same pair, reachable over the per-pair bus (a room on the partner\'s machine shows its host). read_session(session, terminal?) reads recent activity inside one (omit `terminal` to list that room\'s terminals, or pass a ref/name to read that lane). Both are READ-ONLY — they never change another session, and they reach ONLY your own rooms and rooms you share with your partner, never a stranger\'s. Reach for them when work spans rooms — "what\'s the other project up to", "pick up where the other session left off", or to check a long-running task elsewhere before you act here.',
710
712
  'CROSS-SESSION HAND-OFF: post_to_session(session, text, terminal?) sends a task or message to an agent in ANOTHER of your rooms — your own, or your partner\'s over the pair bus. Use it sparingly and only when the people clearly want the rooms to coordinate — e.g. hand the API room\'s agent a concrete follow-up once the frontend is ready. It is dual-consent: a person in YOUR room approves sending, and a person in the TARGET room approves receiving, before anything is delivered — so never rely on it for chit-chat or loops, and an agent that was itself reached via a cross-room post cannot post onward to a third room. It spends real model tokens in the other room (maybe on the other person\'s machine), so prefer read_session to understand a room before you ever post into it, and only post one concrete hand-off at a time. All of this works only under the ThinkPool account bridge; a standalone room sees just its own terminals.',
711
- 'SUBAGENT POLICY: in this room, delegated work goes through the Ensemble. When you fan out research, parallel sub-tasks, reviews, or any substantive multi-step delegated work, open spawn_terminal lanes — they are visible in the room, the people can watch and steer them, and every other lane can peer at their work. Do NOT reach for the built-in Task/Agent subagents for that work: an in-process subagent is invisible to the room, cannot be peered at or steered, and its work is lost to the Ensemble. The only exception is a trivial, seconds-scale read-only lookup where a room lane would be pure overhead.',
713
+ 'SUBAGENT POLICY: in this room, a main conductor delegates worker slices through visible spawn_terminal Ensemble lanes. A requested separate conductor is created with open_main_terminal, never Ensemble. Worker, leaf, Side, and managed Flow lanes do their assigned work directly. Do NOT reach for built-in Task/Agent subagents: an in-process subagent is invisible to the room, cannot be peered at or steered, and its work is lost to the Ensemble.',
712
714
  'RESEARCH LANE: you have a `research` tool that runs a REAL multi-source web search + adversarial verification and returns each claim marked HELD or REJECTED with citations. Reach for it when the people would genuinely benefit from looking something external up or settling a question of current fact — pricing, "is X still maintained / deprecated", "is that benchmark real", a debate over facts you are not sure of. Do NOT run it unprompted or for things you already know: first OFFER in plain language ("want me to spawn a research lane on that and check it?"), and only call `research(question)` once they agree — it spends real budget (plan-gated Free 5 / Plus 100 runs a month) and takes ~a minute. When it returns, present the held/rejected findings clearly and invite both people to weigh the sources, flagging any held claim that rests on a source they might not trust — that shared scrutiny is the point.',
713
715
  'PEER FIRST: other lanes may be working in the same repo as you, right now. Before starting substantive work — and before any code edit that could overlap another lane — check what the room is doing: the ROOM NOW snapshot appended to your latest turn, or read_terminal for detail; list_sessions/read_session when the question spans your other rooms. If a sibling is touching the same files or branch, coordinate (read its lane, or raise it in chat) instead of colliding.',
714
716
  'WORKTREES: parallel lanes share one machine and usually one repo. Run `git worktree list` before your first code edit; if linked worktrees exist, the shared main checkout is contended (and may be guard-blocked) — do your work in your OWN worktree on your OWN branch (`git worktree add <dir> -b <branch>`), and never edit a checkout or ride a branch another lane is using.',
package/codex-session.mjs CHANGED
@@ -210,13 +210,14 @@ export function codexPublishAccess({ cwd, env = process.env, tmpdir = os.tmpdir(
210
210
  }
211
211
 
212
212
  export const CODEX_ROOM_CASCADE_REMINDER = [
213
- 'THINKPOOL ROOM WORKFLOW: delegated work belongs in visible spawn_terminal lanes, never hidden in-process subagents. Each lane owns its own worktree. For a genuinely decomposable task, state a short plan in chat, use sliceType scaffold for mechanical work, feature/fix for builders, and review for adversarial verification; review must use a balanced model tier. Read results, verify the integrated outcome, and close every lane you spawned so it does not consume room capacity.',
213
+ 'THINKPOOL ROOM WORKFLOW: obey the authoritative TERMINAL ROLE above. Conductors are independent main terminals; Ensemble lanes are workers only. When a person explicitly asks to open, launch, start, or spawn a separate Cascade/conductor terminal, use open_main_terminal — NEVER spawn_terminal. Use spawn_terminal only for worker slices from a role permitted to delegate. If open_main_terminal is unavailable, say so; never substitute an Ensemble child. Leaf, worker, Side, and managed Flow lanes work directly and must not delegate. Never use hidden in-process subagents. Each worker owns its own worktree. For a genuinely decomposable task in a conductor-capable role, state a short plan in chat, use sliceType scaffold for mechanical work, feature/fix for builders, and review for adversarial verification; review must use a balanced model tier. Read results, verify the integrated outcome, and close every worker you spawned.',
214
214
  ].join(' ')
215
215
 
216
- export function buildCodexPrompt({ text, rolePrompt, roomContext, firstTurn = false }) {
216
+ export function buildCodexPrompt({ text, terminalRolePrompt, rolePrompt, roomContext, firstTurn = false }) {
217
217
  const context = typeof roomContext === 'function' ? roomContext() : roomContext
218
218
  const additions = [
219
219
  firstTurn ? CODEX_THINKPOOL_FIRST_TURN_PREAMBLE : '',
220
+ terminalRolePrompt,
220
221
  rolePrompt || CODEX_ROOM_CASCADE_REMINDER,
221
222
  context,
222
223
  ].map((v) => String(v || '').trim()).filter(Boolean)
@@ -381,7 +382,7 @@ function appServerItemForMapper(item) {
381
382
  * @param {string} [o.providerConfig] optional -c overrides / provider block (M2: from the provider registry)
382
383
  * @returns {{ sendTurn(text, options?), abort(), end(), readonly sessionId }}
383
384
  */
384
- export function startCodexSession({ cwd, model, effort: initialEffort = 'high', resume, env, sandbox, mode = 'default', onEvent, providerConfig, rolePrompt, roomContext, mcpServers, prepareCwd = null, requestPermission, appServerGate = codexAppServerGate, appServerFactory = createCodexAppServer, spawnImpl = spawn }) {
385
+ export function startCodexSession({ cwd, model, effort: initialEffort = 'high', resume, env, sandbox, mode = 'default', onEvent, providerConfig, terminalRolePrompt, rolePrompt, roomContext, mcpServers, prepareCwd = null, requestPermission, appServerGate = codexAppServerGate, appServerFactory = createCodexAppServer, spawnImpl = spawn }) {
385
386
  let activeMode = CODEX_MODE_CONFIG[mode] ? mode : 'default'
386
387
  let modeConfig = codexConfigForMode(activeMode)
387
388
  sandbox = normalizeCodexSandbox(sandbox || modeConfig.sandbox)
@@ -686,7 +687,7 @@ export function startCodexSession({ cwd, model, effort: initialEffort = 'high',
686
687
  // Claude receives these through the Agent SDK's system-reminder path.
687
688
  // Codex is one-shot per turn, so give it the same lane identity and live
688
689
  // room awareness in a compact envelope before the person's text.
689
- const prompt = buildCodexPrompt({ text: next.text, rolePrompt, roomContext, firstTurn: turnNo === 0 })
690
+ const prompt = buildCodexPrompt({ text: next.text, terminalRolePrompt, rolePrompt, roomContext, firstTurn: turnNo === 0 })
690
691
  const usedAppServer = await runAppServer(prompt, next.options)
691
692
  if (!usedAppServer) await runExec(prompt, next.options)
692
693
  turnNo++
@@ -706,7 +707,7 @@ export function startCodexSession({ cwd, model, effort: initialEffort = 'high',
706
707
  try { cwd = prepareCwd() || cwd } catch { /* keep original cwd */ }
707
708
  }
708
709
  if (turnActive && appServer && activeTurnId) {
709
- const prompt = buildCodexPrompt({ text, rolePrompt, roomContext })
710
+ const prompt = buildCodexPrompt({ text, terminalRolePrompt, rolePrompt, roomContext })
710
711
  const targetTurnId = activeTurnId
711
712
  // Serialize authored follow-ups: parallel RPCs can complete out of order.
712
713
  steerChain = steerChain.then(() => appServer.steer({ threadId: sessionId, turnId: targetTurnId, input: prompt, images: Array.isArray(options.images) ? options.images : [] }))
@@ -301,8 +301,9 @@ export const crossPostNeedsCard = ({ mode = 'default', spawnedByMe = false } = {
301
301
  // OWN lanes. spawn_terminal gives it fresh lanes; this gate keeps that from
302
302
  // becoming a fork bomb. Bounds, in order:
303
303
  // • kill-switch (TP_SPAWN_OFF)
304
- // • hop — a lane REACHED via cross-post/spawn (hop ≥ 1) cannot spawn onward, so
305
- // a spawned agent can't recursively spawn (the loop breaker, mirrors CROSSPOST)
304
+ // • hop — a lane REACHED via cross-post/spawn (hop ≥ 1) cannot spawn onward.
305
+ // Cascade conductors now use the independent main-terminal lifecycle; every
306
+ // Ensemble-spawned lane is a worker (the loop breaker mirrors CROSSPOST).
306
307
  // • machineMax — overall hard cap on LIVE lanes (sessions+terms): the runaway /
307
308
  // don't-melt-the-laptop backstop, plan-INDEPENDENT.
308
309
  // • dispatch ceiling — a SEPARATE, plan-tiered budget counting ONLY spawned lanes
@@ -323,12 +324,9 @@ export const crossPostNeedsCard = ({ mode = 'default', spawnedByMe = false } = {
323
324
  // this stays a PURE function (no Date.now inside; the clock is injected, like
324
325
  // spawnCount was before).
325
326
  export const SPAWN = {
326
- maxHop: 2, // human turn = hop 0; its spawned lane (hop 1, a "conductor") MAY spawn
327
- // workers (hop 2); hop ≥ 2 can never spawn onward. Depth-2 cascades,
328
- // fork-bomb still impossible by construction — every level also passes
329
- // machineMax + the plan ceiling + the burst window + the kill-switch.
330
- // Was 1 until 2026-07-10 (docs/specs/2026-07-10-cascade-spawn-depth.md):
331
- // conductors dispatched by a person's lane could not dispatch at all.
327
+ maxHop: 1, // main terminal/conductor = hop 0 and may dispatch workers;
328
+ // Ensemble worker = hop 1 and can never spawn onward. Conductors
329
+ // are created via open_main_terminal, not spawn_terminal.
332
330
  perWindowCap: 6, // spawn_terminal calls allowed per rolling window (burst breaker);
333
331
  // 6 matches plusMaxSpawned so one wave can open a full crew (was 4)
334
332
  windowMs: 10 * 60 * 1000, // sliding window over which perWindowCap applies (10 min)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thinkpool-pair",
3
- "version": "0.7.231",
3
+ "version": "0.7.233",
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": {
@@ -15,8 +15,45 @@ export const THINKPOOL_REMOTE_DELIVERY_RULES = Object.freeze([
15
15
  'VERIFY BEFORE CLAIMING: run or serve what you changed, observe it, and show the evidence in the room — the PNG, passing test output, or real response. If something could not be verified, say exactly what remains unverified.',
16
16
  ])
17
17
 
18
+ // One authoritative terminal-identity preamble for BOTH structured runtimes.
19
+ // The bridge derives this from durable structural metadata — never from the text
20
+ // of the task handed to the model. That distinction matters because a spawned
21
+ // lane can be asked to "act as conductor", but it is still a sub-terminal owned
22
+ // by its parent; conversely, a human-opened terminal is top-level even when its
23
+ // first task is tiny. Keep capability wording subordinate to structural identity.
24
+ export function buildTerminalRolePrompt({ spawnedBy = null, spawnDepth = 0, cascadeRole = null, flowRole = null, sideParent = null } = {}) {
25
+ const depth = Math.max(0, Number.isFinite(Number(spawnDepth)) ? Math.trunc(Number(spawnDepth)) : 0)
26
+ const parentRef = String(sideParent || spawnedBy || '').slice(0, 8)
27
+
28
+ if (flowRole === 'conductor') {
29
+ return 'TERMINAL ROLE (authoritative): You are a system-managed THINKPOOL FLOW CONDUCTOR. You are not a human-opened top-level terminal and not a worker sub-terminal. Your role is decomposition and Flow coordination only; the Flow runtime dispatches its managed lanes. Do not use ordinary spawn_terminal delegation or claim to be an independent room terminal. Follow the stricter Flow conductor role prompt below.'
30
+ }
31
+ if (flowRole === 'builder' || flowRole === 'reviewer') {
32
+ return `TERMINAL ROLE (authoritative): You are a system-managed THINKPOOL FLOW ${flowRole.toUpperCase()} LANE. You are a leaf worker/reviewer, not a top-level terminal and not a conductor. Complete only the assigned Flow slice, do not spawn terminals, and finish through the Flow completion contract in your role prompt.`
33
+ }
34
+ if (sideParent) {
35
+ return `TERMINAL ROLE (authoritative): You are a SIDE SUB-TERMINAL attached to main terminal ${parentRef}. You are not an independent top-level terminal and not a Cascade conductor. Complete the bounded side task directly, do not fan out more terminals, and return a compact handoff to main.`
36
+ }
37
+ if (cascadeRole === 'conductor') {
38
+ if (!spawnedBy && depth === 0) {
39
+ return 'TERMINAL ROLE (authoritative): You are an independent MAIN CASCADE CONDUCTOR TERMINAL opened through the room\'s top-level terminal lifecycle. You are not an Ensemble child, spawned sub-terminal, Side lane, or Flow lane. Keep decomposition and integration here. Open only worker SUB-TERMINALS with spawn_terminal, collect and verify their results, and close them. Never call open_main_terminal yourself or delegate conductor responsibility to a worker.'
40
+ }
41
+ return `TERMINAL ROLE (authoritative): You are a MISCLASSIFIED ENSEMBLE SUB-TERMINAL at spawn depth ${Math.max(1, depth)}, owned by parent terminal ${parentRef}. A spawned lane can never be a Cascade conductor. Work directly as a worker, do not spawn terminals, and report that the parent must use open_main_terminal for a separate conductor.`
42
+ }
43
+ if (!spawnedBy && depth === 0) {
44
+ return 'TERMINAL ROLE (authoritative): You are an independent TOP-LEVEL ThinkPool terminal opened directly in the room. You are not a spawned sub-terminal or worker lane. You may conduct the current task here and open worker SUB-TERMINALS with spawn_terminal. When a person explicitly asks you to open, launch, or start a separate Cascade/conductor terminal, use open_main_terminal — never spawn_terminal. Conductors are main terminals; Ensemble-spawned lanes are workers only.'
45
+ }
46
+ if (depth >= 2) {
47
+ return `TERMINAL ROLE (authoritative): You are a LEAF SUB-TERMINAL at spawn depth ${depth}, owned by parent terminal ${parentRef}. You are not top-level and not a conductor. Complete the assigned slice directly, do not spawn another terminal, and hand evidence/results back to your parent.`
48
+ }
49
+ if (cascadeRole === 'worker') {
50
+ return `TERMINAL ROLE (authoritative): You are a WORKER SUB-TERMINAL at spawn depth ${Math.max(1, depth)}, owned by parent terminal ${parentRef}. You are not top-level and not a conductor. Complete the assigned slice directly, do not spawn another terminal, and hand evidence/results back to your parent.`
51
+ }
52
+ return `TERMINAL ROLE (authoritative): You are a SPAWNED WORKER SUB-TERMINAL at depth ${Math.max(1, depth)}, owned by parent terminal ${parentRef}. You are not an independent top-level terminal or a Cascade conductor. Complete the delegated task directly, do not spawn another terminal, and hand evidence/results back to your parent. If the task asks you to conduct, report that the parent must use open_main_terminal instead.`
53
+ }
54
+
18
55
  export const CODEX_THINKPOOL_FIRST_TURN_PREAMBLE = [
19
56
  'ENVIRONMENT (authoritative): You are Codex running inside a ThinkPool Code room, driven live through the thinkpool-pair bridge by a user and possibly a partner. This is a shared remote workspace, not an ordinary local terminal session; act accordingly.',
20
- 'MODEL TIERING FOR DISPATCH: When calling spawn_terminal for Codex, explicitly choose from the available model metadata when practical: gpt-5.6-luna or gpt-5.4-mini for mechanical/scaffold/search/formatting work; gpt-5.6-terra for ordinary feature/fix work; gpt-5.6-sol only for trap-laden repeated failures, architecture/conductor work, or adversarial final review. Do not inherit or select Sol by default. If a named model is unavailable, use the nearest advertised lower tier or omit model and allow runtime fallback.',
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.',
21
58
  ...THINKPOOL_REMOTE_DELIVERY_RULES,
22
59
  ].join(' ')