thinkpool-pair 0.7.230 → 0.7.232

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,7 +1955,7 @@ 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
@@ -2109,12 +2118,13 @@ function openStructured({ id, runtime = 'claude', model, effort, resume, log, co
2109
2118
  // fork-bomb breaker, now one level deeper (2026-07-10 cascade-spawn-depth spec).
2110
2119
  ...(!entry.flowRole ? [tool(
2111
2120
  '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.',
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.',
2113
2122
  {
2114
2123
  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
2124
  task: z.string().optional().describe('an initial task to hand the new lane immediately; omit to open it idle'),
2116
2125
  model: z.string().optional().describe('optional model for the chosen runtime, e.g. opus / sonnet / gpt-5.6-sol'),
2117
2126
  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'),
2118
2128
  runtime: z.enum(['claude', 'codex']).optional().describe('agent runtime for the new lane; defaults to inheriting this lane'),
2119
2129
  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'),
2120
2130
  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.'),
@@ -2167,18 +2177,23 @@ function openStructured({ id, runtime = 'claude', model, effort, resume, log, co
2167
2177
  // plan-mode lane would stall on ExitPlanMode cards. An explicit args.mode still wins;
2168
2178
  // otherwise inherit, but fall plan → default (an autonomous worker doesn't plan-gate).
2169
2179
  const childMode = args?.mode || (entry.mode === 'plan' ? 'default' : entry.mode)
2170
- openStructured({ id: newId, runtime: childRuntime, model: childModel, provider: args?.provider, mode: childMode })
2180
+ const childSpawnDepth = (entry.spawnDepth || 0) + 1
2181
+ const childHop = (entry.hop || 0) + 1
2182
+ // Stamp ownership/depth BEFORE the runtime starts so its first system
2183
+ // preamble is truthful. Mutating ne.spawnedBy after openStructured was
2184
+ // 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 })
2171
2186
  const ne = sessions.get(newId)
2172
2187
  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
2188
+ ne.peekCount = 0; ne.postCount = 0; ne.spawnTimes = []
2174
2189
  announce() // (defensive) ensure the web renders the new tab live
2175
2190
  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}`
2191
+ // The user-visible relay reinforces (but never defines) the structural
2192
+ // 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}`
2182
2197
  const evt = { kind: 'you', text: msg, by: `terminal ${fromRef} (agent)`, crosspost: true, relaySourceName: termNames[id] || undefined }
2183
2198
  stampEvent(evt); pushLog(ne, evt); bcast('code-event', { term: newId, evt })
2184
2199
  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 +2326,18 @@ function openStructured({ id, runtime = 'claude', model, effort, resume, log, co
2311
2326
  // on the init `system` event (see onEvent) — a sendTurn before the input stream is consumed
2312
2327
  // is silently lost (the 2026-07-02 auto-resume bug). Empty string → nothing to carry.
2313
2328
  entry.pendingRecap = (typeof carryRecap === 'string' && carryRecap.trim()) ? carryRecap : null
2329
+ const terminalRolePrompt = buildTerminalRolePrompt({
2330
+ spawnedBy: entry.spawnedBy,
2331
+ spawnDepth: entry.spawnDepth,
2332
+ cascadeRole: entry.cascadeRole,
2333
+ flowRole: entry.flowRole,
2334
+ sideParent: entry.sideParent,
2335
+ })
2314
2336
  entry.session = (runtime === 'codex' ? startCodexSession : startClaudeSession)({
2315
2337
  // laneModel, NOT the raw `model` param: the SDK's `model` option OVERRIDES the
2316
2338
  // ANTHROPIC_MODEL supplied by resolveProviderEnv() in `env` below, so an inherited
2317
2339
  // Claude id here defeats the whole per-provider env. undefined → option omitted.
2318
- cwd: cwd || process.cwd(), model: laneModel, effort, resume, mode, rolePrompt,
2340
+ cwd: cwd || process.cwd(), model: laneModel, effort, resume, mode, terminalRolePrompt, rolePrompt,
2319
2341
  // Idle ordinary tabs create nothing. Immediately before the FIRST real
2320
2342
  // turn (human prompt, dispatch, or cascade task), the runtime calls this
2321
2343
  // while still cold; subsequent turns reuse the persisted worktree.
@@ -2413,7 +2435,7 @@ function openStructured({ id, runtime = 'claude', model, effort, resume, log, co
2413
2435
  openStructured({
2414
2436
  id, runtime: entry.runtime, model: entry.model || model, effort: entry.effort,
2415
2437
  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,
2438
+ 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
2439
  flowTaskKey: entry.flowTaskKey, flowRole: entry.flowRole, flowReviewTarget: entry.flowReviewTarget,
2418
2440
  flowReviewTargets: entry.flowReviewTargets, flowReviewRound: entry.flowReviewRound,
2419
2441
  dispatchBaseSha: entry.dispatchBaseSha, revertTarget: entry.revertTarget, cwd: entry.cwd,
@@ -2830,7 +2852,7 @@ function respawnStructured(id, provider) {
2830
2852
  // openStructured seed from the TARGET provider's configured model, which is the
2831
2853
  // only model this lane was ever asked for. A same-env model change never reaches
2832
2854
  // 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
2855
+ 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
2856
  // Context-carry (2026-07-08): a provider switch is not an SDK resume — the new backend
2835
2857
  // starts blank mid-conversation. Synthesize a plain-text recap from the VISIBLE log NOW
2836
2858
  // (before teardown) and hand it to the fresh session as its first turn so the agent
@@ -2849,7 +2871,7 @@ function respawnStructured(id, provider) {
2849
2871
  // sessionData() (provider included) synchronously on open, so a bridge restart
2850
2872
  // restores the lane on its CURRENT provider, not the original — and its next
2851
2873
  // 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 })
2874
+ 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
2875
  return true
2854
2876
  }
2855
2877
 
@@ -3642,7 +3664,7 @@ channel
3642
3664
  // FL-M6 — restore the flow context (id/role/cwd) so an in-flight flow survives a
3643
3665
  // bridge restart: the conductor keeps its subagent-block + plan interception, and
3644
3666
  // 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,
3667
+ openStructured({ id: rec.id, runtime: rec.runtime || 'claude', model: rec.model || undefined, effort: rec.effort, provider: rec.runtime === 'codex' ? undefined : rec.provider || undefined, resume: resumable ? rec.sessionId : undefined, log: rec.log, commands: rec.commands, mode: rec.mode, spawnedBy: rec.spawnedBy, spawnDepth: rec.spawnDepth, cascadeRole: rec.cascadeRole, hop: rec.hop, sideParent: rec.sideParent, sideTask: rec.sideTask, pendingSideContexts: rec.pendingSideContexts, flowSessionId: rec.flowSessionId, flowTaskKey: rec.flowTaskKey, flowRole: rec.flowRole, flowReviewTarget: rec.flowReviewTarget, flowReviewTargets: rec.flowReviewTargets, flowReviewRound: rec.flowReviewRound, dispatchBaseSha: rec.dispatchBaseSha, revertTarget: rec.revertTarget, cwd: rec.cwd, managedWorktree: rec.managedWorktree, rolePrompt: rec.rolePrompt, reviewSliceRoots: rec.reviewSliceRoots, openedAt: rec.openedAt, lastUsage: rec.lastUsage, carryRecap: wasInterrupted ? recoveryRecap : rec.carryRecap,
3646
3668
  // Lazy-boot restored terminals that were IDLE + not part of a flow: their transcript
3647
3669
  // shows immediately; the query boots on first turn. Mid-turn + flow terminals boot now
3648
3670
  // (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
+ '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.',
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 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.',
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
@@ -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 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.',
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
+ '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.',
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, 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.',
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.',
@@ -59,7 +59,14 @@ export function codexAppServerGate({ env = process.env, execFile = execFileSync
59
59
  export function buildCodexAppServerArgs({ providerConfig, mcpUrl, writableDirs = [] } = {}) {
60
60
  const args = ['app-server', '--stdio']
61
61
  if (providerConfig) args.push('-c', providerConfig)
62
- if (mcpUrl) args.push('-c', `mcp_servers.thinkpool.url=${JSON.stringify(mcpUrl)}`)
62
+ if (mcpUrl) {
63
+ args.push('-c', `mcp_servers.thinkpool.url=${JSON.stringify(mcpUrl)}`)
64
+ // The room prompt promises spawn/read/close_terminal as native room tools.
65
+ // Fail startup instead of silently running a tool-less Codex turn when the
66
+ // private loopback MCP handshake is unavailable. Existing MCP approval
67
+ // policy still owns the room permission card for each call.
68
+ args.push('-c', 'mcp_servers.thinkpool.required=true')
69
+ }
63
70
  if (writableDirs.length) args.push('-c', `sandbox_workspace_write.writable_roots=${JSON.stringify(writableDirs)}`)
64
71
  return args
65
72
  }
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. 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.',
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)
@@ -248,7 +249,13 @@ export function buildCodexExecArgs({ sessionId, model, effort, sandbox, approval
248
249
  // accept --add-dir itself. Supply these only when creating the thread.
249
250
  if (!sessionId) for (const dir of writableDirs) args.push('--add-dir', dir)
250
251
  // Per-lane override only: never mutate the person's global Codex config.
251
- if (mcpUrl) args.push('-c', `mcp_servers.thinkpool.url=${JSON.stringify(mcpUrl)}`)
252
+ if (mcpUrl) {
253
+ args.push('-c', `mcp_servers.thinkpool.url=${JSON.stringify(mcpUrl)}`)
254
+ // Room tools are part of the ThinkPool execution contract, not optional
255
+ // enrichment. A failed handshake must abort this invocation so Codex
256
+ // cannot continue after spawn_terminal vanished from its callable set.
257
+ args.push('-c', 'mcp_servers.thinkpool.required=true')
258
+ }
252
259
  // Both fresh `exec` and `exec resume` accept repeatable --image flags. Only
253
260
  // bridge-derived absolute paths reach this layer; relative values are ignored
254
261
  // so a future caller cannot silently change the child's path interpretation.
@@ -375,7 +382,7 @@ function appServerItemForMapper(item) {
375
382
  * @param {string} [o.providerConfig] optional -c overrides / provider block (M2: from the provider registry)
376
383
  * @returns {{ sendTurn(text, options?), abort(), end(), readonly sessionId }}
377
384
  */
378
- 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 }) {
379
386
  let activeMode = CODEX_MODE_CONFIG[mode] ? mode : 'default'
380
387
  let modeConfig = codexConfigForMode(activeMode)
381
388
  sandbox = normalizeCodexSandbox(sandbox || modeConfig.sandbox)
@@ -680,7 +687,7 @@ export function startCodexSession({ cwd, model, effort: initialEffort = 'high',
680
687
  // Claude receives these through the Agent SDK's system-reminder path.
681
688
  // Codex is one-shot per turn, so give it the same lane identity and live
682
689
  // room awareness in a compact envelope before the person's text.
683
- const prompt = buildCodexPrompt({ text: next.text, rolePrompt, roomContext, firstTurn: turnNo === 0 })
690
+ const prompt = buildCodexPrompt({ text: next.text, terminalRolePrompt, rolePrompt, roomContext, firstTurn: turnNo === 0 })
684
691
  const usedAppServer = await runAppServer(prompt, next.options)
685
692
  if (!usedAppServer) await runExec(prompt, next.options)
686
693
  turnNo++
@@ -700,7 +707,7 @@ export function startCodexSession({ cwd, model, effort: initialEffort = 'high',
700
707
  try { cwd = prepareCwd() || cwd } catch { /* keep original cwd */ }
701
708
  }
702
709
  if (turnActive && appServer && activeTurnId) {
703
- const prompt = buildCodexPrompt({ text, rolePrompt, roomContext })
710
+ const prompt = buildCodexPrompt({ text, terminalRolePrompt, rolePrompt, roomContext })
704
711
  const targetTurnId = activeTurnId
705
712
  // Serialize authored follow-ups: parallel RPCs can complete out of order.
706
713
  steerChain = steerChain.then(() => appServer.steer({ threadId: sessionId, turnId: targetTurnId, input: prompt, images: Array.isArray(options.images) ? options.images : [] }))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thinkpool-pair",
3
- "version": "0.7.230",
3
+ "version": "0.7.232",
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,6 +15,40 @@ 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 (!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.'
39
+ }
40
+ if (depth >= 2) {
41
+ 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
+ }
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
+ if (cascadeRole === 'worker') {
47
+ 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
+ }
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.`
50
+ }
51
+
18
52
  export const CODEX_THINKPOOL_FIRST_TURN_PREAMBLE = [
19
53
  '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
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.',