thinkpool-pair 0.7.232 → 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
@@ -1962,6 +1962,49 @@ function openStructured({ id, runtime = 'claude', model, effort, resume, log, co
1962
1962
  // 1.5s saveSession debounce window) and on shutdown (so events since the last
1963
1963
  // debounced write aren't lost). Contract #2: restart resumes, no lost messages.
1964
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
+ }
1965
2008
  // B2 cross-terminal peek — an in-process, READ-ONLY tool the agent can call to
1966
2009
  // see a SIBLING terminal's recent output (vs B1's always-on @pool digest). The
1967
2010
  // handler closes over the module-scope `sessions`/`terms` Maps + this session's
@@ -2106,43 +2149,81 @@ function openStructured({ id, runtime = 'claude', model, effort, resume, log, co
2106
2149
  return okText(`Delivered to terminal ${target.ref} (${target.cmd}). It will respond in its own lane; check back with read_terminal.`)
2107
2150
  },
2108
2151
  )] : []),
2109
- // 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:
2110
2203
  // an agent fanned a research task into SIBLINGS that were already busy (a
2111
2204
  // release lane, a Q&A lane) because it had no way to make its own lanes.
2112
2205
  // This opens a brand-new structured session server-side (the same path the
2113
2206
  // web's "+ New terminal" and the restore loop use), so announce() surfaces
2114
2207
  // 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(
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(
2120
2212
  '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.',
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.',
2122
2214
  {
2123
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"'),
2124
2216
  task: z.string().optional().describe('an initial task to hand the new lane immediately; omit to open it idle'),
2125
2217
  model: z.string().optional().describe('optional model for the chosen runtime, e.g. opus / sonnet / gpt-5.6-sol'),
2126
2218
  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
2219
  runtime: z.enum(['claude', 'codex']).optional().describe('agent runtime for the new lane; defaults to inheriting this lane'),
2129
2220
  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
2221
  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
2222
  },
2132
2223
  async (args) => {
2133
2224
  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)
2225
+ const resolved = resolveAgentOpen(args, { worker: true })
2226
+ if (resolved.error) return okText(resolved.error)
2146
2227
  const now = Date.now()
2147
2228
  const gate = spawnDecision({
2148
2229
  hop: entry.hop || 0,
@@ -2176,13 +2257,12 @@ function openStructured({ id, runtime = 'claude', model, effort, resume, log, co
2176
2257
  // PM-m1 — never INHERIT plan mode into a spawned worker lane: a lane spawned from a
2177
2258
  // plan-mode lane would stall on ExitPlanMode cards. An explicit args.mode still wins;
2178
2259
  // 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
2260
  const childSpawnDepth = (entry.spawnDepth || 0) + 1
2181
2261
  const childHop = (entry.hop || 0) + 1
2182
2262
  // Stamp ownership/depth BEFORE the runtime starts so its first system
2183
2263
  // preamble is truthful. Mutating ne.spawnedBy after openStructured was
2184
2264
  // 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 })
2265
+ openStructured({ id: newId, runtime: resolved.runtime, model: resolved.model, provider: resolved.provider, mode: resolved.mode, spawnedBy: id, spawnDepth: childSpawnDepth, cascadeRole: 'worker', hop: childHop })
2186
2266
  const ne = sessions.get(newId)
2187
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.') }
2188
2268
  ne.peekCount = 0; ne.postCount = 0; ne.spawnTimes = []
@@ -2190,10 +2270,7 @@ function openStructured({ id, runtime = 'claude', model, effort, resume, log, co
2190
2270
  if (args?.task) {
2191
2271
  // The user-visible relay reinforces (but never defines) the structural
2192
2272
  // 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}`
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}`
2197
2274
  const evt = { kind: 'you', text: msg, by: `terminal ${fromRef} (agent)`, crosspost: true, relaySourceName: termNames[id] || undefined }
2198
2275
  stampEvent(evt); pushLog(ne, evt); bcast('code-event', { term: newId, evt })
2199
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.`) }
@@ -200,10 +200,10 @@ 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: obey your authoritative TERMINAL ROLE. Only conductor-capable roles fan work out with spawn_terminal (visible and steerable here); leaf, Side, and managed Flow lanes work directly. When opening a Cascade conductor, pass cascadeRole="conductor"; that conductor passes cascadeRole="worker" for slice lanes. Never use built-in invisible Task/Agent subagents or 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 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 independent slices into visible spawn_terminal lanes and verifies them. Leaf/Side/managed Flow roles do not fan out. Never plan-mode/ExitPlanMode; plans live in chat and lanes in the existing list.',
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
 
@@ -516,25 +516,25 @@ export function startClaudeSession({ cwd, model, effort: initialEffort = 'high',
516
516
  if (toolName === 'mcp__thinkpool__close_terminal') {
517
517
  return { continue: true, hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'allow', permissionDecisionReason: 'Auto-approved (ThinkPool cross-terminal close — only self-spawned lanes).' } }
518
518
  }
519
- // ThinkPool cross-terminal SPAWN (Tier C+) — opens a FRESH lane the agent owns
520
- // (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
521
521
  // (`mode` here is THIS lane's current mode) unless an explicit `mode` overrides it.
522
522
  // The only thing worth a human card is an ESCALATION: raising a child to
523
523
  // bypassPermissions from a non-bypass lane (full unattended access the operator
524
524
  // hasn't already opted into). If the spawner is already in bypass, children inherit
525
525
  // bypass with NO prompt (you opted in once); a non-bypass child gates itself per
526
526
  // action anyway, so no spawn prompt there either. Bridge-side spawnDecision still
527
- // enforces the depth-2 hop bound / burst window / machineMax / TP_SPAWN_OFF regardless.
528
- 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') {
529
529
  const childMode = toolInput?.mode || mode // inherit this lane's mode by default
530
530
  const escalating = childMode === 'bypassPermissions' && mode !== 'bypassPermissions'
531
531
  if (!escalating) {
532
- 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).` } }
533
533
  }
534
534
  let decision = 'deny'
535
535
  try { decision = await requestPermission?.({ id: randomUUID(), toolName, input: { ...toolInput, mode: childMode }, risk: 'high' }) ?? 'deny' } catch { decision = 'deny' }
536
536
  const allowed = decision === 'allow'
537
- 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.' } }
538
538
  }
539
539
  // ThinkPool cross-terminal POST (Tier C) WRITES into a sibling agent's lane.
540
540
  // First a bridge-side precheck (kill-switch / loop-breaker / per-turn cap) so a
@@ -702,15 +702,15 @@ export function startClaudeSession({ cwd, model, effort: initialEffort = 'high',
702
702
  // no summon word. The agent INFERS build intent and runs plan→(optional)fan-out→build→self-verify,
703
703
  // right-sized to the task, surfaced in the room's EXISTING surfaces (chat + lane list) — never a new panel,
704
704
  // never plan-mode's approval card. Trivial asks stay single-lane with zero ceremony.
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 the independent slices out into their own visible lanes with spawn_terminal(cascadeRole="worker"), build, and self-verify each slice before calling it done. Worker, leaf, Side, and managed Flow lanes complete their assigned work directly and do not fan out.',
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.',
706
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.',
707
707
  ...THINKPOOL_REMOTE_DELIVERY_RULES,
708
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.',
709
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.',
710
- 'CROSS-TERMINAL SPAWN: only when your authoritative TERMINAL ROLE permits delegation, open YOUR OWN fresh lanes with spawn_terminal(name?, task?, model?, cascadeRole?). Declare cascadeRole="conductor" when the new visible terminal must decompose a Cascade and dispatch its own crew; declare cascadeRole="worker" for every direct slice lane. The bridge injects that identity before the child\'s first turn. Never ask a worker, leaf, Side, or managed Flow lane to pretend it is a conductor. Do not dump 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.',
711
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.',
712
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.',
713
- 'SUBAGENT POLICY: in this room, delegated work goes through the Ensemble only from a conductor-capable TERMINAL ROLE. When such a role fans out research, parallel sub-tasks, reviews, or substantive multi-step work, open visible spawn_terminal lanes with explicit cascadeRole metadata. Worker, leaf, Side, and managed Flow lanes do their assigned work directly. Do NOT reach for the 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.',
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.',
714
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.',
715
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.',
716
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,7 +210,7 @@ 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: obey the authoritative TERMINAL ROLE above. Only a role permitted to delegate may open visible spawn_terminal lanes; leaf, Side, and managed Flow lanes must do their assigned work directly. Never use hidden in-process subagents. When opening a Cascade conductor, pass cascadeRole="conductor"; that conductor must pass cascadeRole="worker" for its slice lanes so each child receives the correct identity in its first system preamble. Each delegated lane 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 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
216
  export function buildCodexPrompt({ text, terminalRolePrompt, rolePrompt, roomContext, firstTurn = false }) {
@@ -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.232",
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": {
@@ -34,23 +34,26 @@ export function buildTerminalRolePrompt({ spawnedBy = null, spawnDepth = 0, casc
34
34
  if (sideParent) {
35
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
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
+ }
37
43
  if (!spawnedBy && depth === 0) {
38
- 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 own and conduct a Cascade: terminals you open with spawn_terminal are your visible SUB-TERMINALS. Keep conductor responsibility here; never tell a spawned worker to pretend it is top-level.'
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.'
39
45
  }
40
46
  if (depth >= 2) {
41
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.`
42
48
  }
43
- if (cascadeRole === 'conductor') {
44
- return `TERMINAL ROLE (authoritative): You are a CASCADE CONDUCTOR TERMINAL at spawn depth ${Math.max(1, depth)}, launched by parent terminal ${parentRef}. You are a visible conductor terminal, not one of the worker sub-terminals you will dispatch. Decompose the assigned Cascade, open bounded worker SUB-TERMINALS with spawn_terminal(cascadeRole="worker"), collect and verify their results, then close them. You are still owned by your parent and must return the integrated outcome there.`
45
- }
46
49
  if (cascadeRole === 'worker') {
47
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.`
48
51
  }
49
- return `TERMINAL ROLE (authoritative): You are a SPAWNED SUB-TERMINAL at depth ${Math.max(1, depth)}, owned by parent terminal ${parentRef}. You are not an independent top-level terminal. Your default job is the delegated task. You may open one final worker tier only when your parent explicitly assigned you to conduct a Cascade; otherwise work directly. Never describe yourself as top-level, and close any workers you open after collecting their results.`
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.`
50
53
  }
51
54
 
52
55
  export const CODEX_THINKPOOL_FIRST_TURN_PREAMBLE = [
53
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.',
54
- '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.',
55
58
  ...THINKPOOL_REMOTE_DELIVERY_RULES,
56
59
  ].join(' ')