thinkpool-pair 0.7.258 → 0.7.260

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.
@@ -21,7 +21,7 @@ import { sanitizeSession } from './transcript-sanitize.mjs'
21
21
  import { reviewGatePreToolDecision } from './flow-review-gate.mjs'
22
22
  import { crossPostNeedsCard } from './cross-terminal.mjs'
23
23
  import { correctContext } from './context-windows.mjs'
24
- import { THINKPOOL_REMOTE_DELIVERY_RULES, THINKPOOL_RUNTIME_TURN_REMINDER } from './thinkpool-room-prompt.mjs'
24
+ import { THINKPOOL_CASCADE_RULE, THINKPOOL_REMOTE_DELIVERY_RULES, THINKPOOL_RUNTIME_AUTHORITY_RULE, THINKPOOL_RUNTIME_TURN_REMINDER, buildThinkPoolTurnGuidance, createRoomContextSelector, usesFullThinkPoolReminder } from './thinkpool-room-prompt.mjs'
25
25
  import { stallDecision, stallEvent, isCompactTurn } from './turn-stall.mjs'
26
26
 
27
27
  // The caret-pulled SDK's real version (^0.3.x auto-upgrades on restart). Resolved
@@ -183,18 +183,19 @@ const simplifyBlocks = (blocks = []) => blocks.map((b) => {
183
183
  */
184
184
  const MODES = new Set(['default', 'acceptEdits', 'plan', 'bypassPermissions'])
185
185
 
186
- // Per-turn salience reminder. The full ThinkPool Code ruleset lives in
186
+ // Periodic salience reminder. The full ThinkPool Code ruleset lives in
187
187
  // `appendSystemPrompt` (baked once at session start), but on a long session — heavy
188
188
  // tool output, and especially after auto-compaction — the model's attention drifts
189
189
  // off a system prompt that sits behind the large host CLAUDE.md. The classic tell:
190
190
  // the agent hands the room a host-local path (`open …`, "it's in ~/claude-shots/")
191
191
  // or narrates instead of showing, forgetting it's driven from a phone. So we re-state
192
- // the highest-drift rules as a compact <system-reminder> appended to EVERY user turn,
192
+ // the highest-drift rules in a <system-reminder> at session start, after recovery,
193
+ // and every fifth prompt. Ordinary turns get only the compact invariant and any
194
+ // route-specific guidance implied by the request.
193
195
  // adjacent to where the model's attention actually is — the same trick the host
194
196
  // harness uses to keep CLAUDE.md alive. The shared capability router deliberately
195
- // costs ~900 tokens/turn so a fresh user never needs to know the room's tool names.
196
- // A live "ROOM NOW" tail (sibling lanes + active worktrees) is appended per turn by
197
- // the bridge's roomContext callback — see roomReminder() inside startClaudeSession.
197
+ // A live "ROOM NOW" tail (sibling lanes + active worktrees) is appended when it
198
+ // changes and on the same periodic refresh cadence.
198
199
  const TP_ROOM_REMINDER = [
199
200
  'You are Claude in a ThinkPool Code room, driven live from a phone or browser — NOT a local terminal. Keep using the room\'s features.',
200
201
  THINKPOOL_RUNTIME_TURN_REMINDER,
@@ -210,11 +211,15 @@ export function startClaudeSession({ cwd, model, effort: initialEffort = 'high',
210
211
  // rules keep the agent aware of the room's FEATURES; the live tail keeps it aware of
211
212
  // the room's STATE (2026-07-02 ask: "aware at all points that it's in a ThinkPool
212
213
  // Code session"). A broken snapshot must never break a turn — fail-quiet to static.
213
- const roomReminder = () => {
214
- let live = null
215
- try { live = roomContext ? roomContext() : null } catch { live = null }
214
+ const selectRoomContext = createRoomContextSelector(roomContext)
215
+ let userPromptNo = 0
216
+ let forceFullReminder = true
217
+ const roomReminder = (text, { promptIndex = 0, forceFull = false } = {}) => {
218
+ const fullReminder = usesFullThinkPoolReminder({ promptIndex, forceFull })
219
+ const live = selectRoomContext({ force: fullReminder })
216
220
  const role = String(terminalRolePrompt || '').trim()
217
- return `<system-reminder>\n${role ? `${role}\n\n` : ''}${TP_ROOM_REMINDER}${live ? `\n\n${live}` : ''}\n</system-reminder>`
221
+ const guidance = fullReminder ? TP_ROOM_REMINDER : buildThinkPoolTurnGuidance({ text, promptIndex, forceFull })
222
+ return `<system-reminder>\n${fullReminder && role ? `${role}\n\n` : ''}${guidance}${live ? `\n\n${live}` : ''}\n</system-reminder>`
218
223
  }
219
224
  const ac = new AbortController()
220
225
  let input = makeInputStream() // `let`: auto-restart swaps in a fresh stream
@@ -226,6 +231,7 @@ export function startClaudeSession({ cwd, model, effort: initialEffort = 'high',
226
231
  // "No conversation found" (Max 2026-07-02). This is always a resumable id.
227
232
  let persistedSessionId = resume || null
228
233
  let lastTurnText = null // the most recent turn text, so a bad-resume recovery can re-deliver it
234
+ let lastTurnReminder = null
229
235
  let closed = false
230
236
  // Lazy boot (2026-07-02): a RESTORED-IDLE terminal returns a full session object but
231
237
  // defers the expensive query() cold-start (MCP + settingSources, ~50s each) until its
@@ -691,7 +697,7 @@ export function startClaudeSession({ cwd, model, effort: initialEffort = 'high',
691
697
  ...(terminalRolePrompt ? [terminalRolePrompt] : []),
692
698
  ...(rolePrompt ? [rolePrompt] : []),
693
699
  '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.',
694
- '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
+ THINKPOOL_RUNTIME_AUTHORITY_RULE,
695
701
  'Bias strongly toward DOING the work, not stalling in plan-mode ceremony. For a small "add / fix / change X" request, just make the change directly (for a bigger build, right-size it — see DEFAULT BUILD WORKFLOW below).',
696
702
  'Do NOT enter plan mode, do NOT call ExitPlanMode, and do NOT auto-invoke a brainstorming/planning skill UNLESS the user has switched the room into Plan mode or explicitly asks you to plan, design, or brainstorm first.',
697
703
  'Any host-global instruction that says you must always brainstorm or plan before creative work does NOT apply here — this room is the exception.',
@@ -699,7 +705,7 @@ export function startClaudeSession({ cwd, model, effort: initialEffort = 'high',
699
705
  // no summon word. The agent INFERS build intent and runs plan→(optional)fan-out→build→self-verify,
700
706
  // right-sized to the task, surfaced in the room's EXISTING surfaces (chat + lane list) — never a new panel,
701
707
  // never plan-mode's approval card. Trivial asks stay single-lane with zero ceremony.
702
- '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.',
708
+ THINKPOOL_CASCADE_RULE,
703
709
  '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.',
704
710
  ...THINKPOOL_REMOTE_DELIVERY_RULES,
705
711
  '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.',
@@ -849,6 +855,7 @@ export function startClaudeSession({ cwd, model, effort: initialEffort = 'high',
849
855
  // old heuristic): trigger ('manual' for /compact vs 'auto') + the token
850
856
  // count before compaction. Emit a real recap card the room can pin.
851
857
  if (m.subtype === 'compact_boundary') {
858
+ forceFullReminder = true
852
859
  emit({ kind: 'compaction', trigger: m.compact_metadata?.trigger || 'auto', preTokens: m.compact_metadata?.pre_tokens ?? null })
853
860
  // Refresh the ctx% meter RIGHT AFTER compaction — it otherwise only updates at
854
861
  // turn-end, so the mode row kept showing the stale PRE-compaction window
@@ -970,7 +977,7 @@ export function startClaudeSession({ cwd, model, effort: initialEffort = 'high',
970
977
  restartTimer = null
971
978
  if (closed) return
972
979
  runQuery()
973
- if (replay != null) { turnActive = true; lastEvtTs = Date.now(); stalledSent = false; input.push([{ type: 'text', text: replay }, { type: 'text', text: roomReminder() }]) }
980
+ if (replay != null) { turnActive = true; lastEvtTs = Date.now(); stalledSent = false; input.push([{ type: 'text', text: replay }, { type: 'text', text: lastTurnReminder || roomReminder(replay, { forceFull: true }) }]) }
974
981
  }, 300)
975
982
  break
976
983
  }
@@ -1144,7 +1151,7 @@ export function startClaudeSession({ cwd, model, effort: initialEffort = 'high',
1144
1151
  if (replay != null) {
1145
1152
  turnActive = true; lastEvtTs = Date.now(); stalledSent = false // re-arm liveness for the retried turn
1146
1153
  // Match sendTurn's block shape: a slash command goes clean; a normal turn keeps the reminder.
1147
- input.push(/^\s*\//.test(replay) ? [{ type: 'text', text: replay }] : [{ type: 'text', text: replay }, { type: 'text', text: roomReminder() }])
1154
+ input.push(/^\s*\//.test(replay) ? [{ type: 'text', text: replay }] : [{ type: 'text', text: replay }, { type: 'text', text: lastTurnReminder || roomReminder(replay, { forceFull: true }) }])
1148
1155
  emitRaw({ kind: 'note', text: 'retrying the stalled turn on a fresh connection' })
1149
1156
  }
1150
1157
  }
@@ -1155,6 +1162,10 @@ export function startClaudeSession({ cwd, model, effort: initialEffort = 'high',
1155
1162
  // is queue-backed, so the pushed turn buffers and runs once the query is ready.
1156
1163
  sendTurn(text) { if (!closed) { if (!started && prepareCwd) { try { const next = prepareCwd(); if (next) { cwd = next; opts.cwd = next } } catch { /* keep original cwd */ } } if (!started) runQuery(); turnActive = true; sawSuggestion = false; lastTurnText = String(text); if (sugTimer) { clearTimeout(sugTimer); sugTimer = null } lastEvtTs = Date.now(); stalledSent = false; stallRetried = false; forceStopped = false;
1157
1164
  const t = String(text)
1165
+ const promptIndex = userPromptNo++
1166
+ const thisTurnForceFull = forceFullReminder
1167
+ forceFullReminder = /^\s*\/(?:compact|clear|reset)\b/i.test(t)
1168
+ lastTurnReminder = /^\s*\//.test(t) ? null : roomReminder(t, { promptIndex, forceFull: thisTurnForceFull })
1158
1169
  // Arm the compaction window BEFORE the push: from here until the `compaction` milestone
1159
1170
  // (or the turn's result) the SDK is allowed to be silent for minutes without the stall
1160
1171
  // watchdog aborting it. See turn-stall.mjs — aborting a compaction is what produced
@@ -1168,7 +1179,7 @@ export function startClaudeSession({ cwd, model, effort: initialEffort = 'high',
1168
1179
  // recognized). So a slash command goes CLEAN; conversational turns keep the reminder.
1169
1180
  // Normal turns never start with "/" (composeAgentStdin prepends the preamble), and the
1170
1181
  // web already routes "/"-prefixed input as a command (pane.jsx), so this matches intent.
1171
- input.push(/^\s*\//.test(t) ? [{ type: 'text', text: t }] : [{ type: 'text', text: t }, { type: 'text', text: roomReminder() }])
1182
+ input.push(/^\s*\//.test(t) ? [{ type: 'text', text: t }] : [{ type: 'text', text: t }, { type: 'text', text: lastTurnReminder }])
1172
1183
  } },
1173
1184
  // Cold-boot the query WITHOUT sending a turn — the background warmer calls this on
1174
1185
  // lazily-restored idle terminals so they're ready before the user clicks them.
package/codex-session.mjs CHANGED
@@ -30,7 +30,7 @@ import readline from 'node:readline'
30
30
  import { buildCodexAppServerArgs, codexAppServerGate, createCodexAppServer } from './codex-app-server.mjs'
31
31
  import { CodexEventMapper } from './codex-event-mapper.mjs'
32
32
  import { startCodexMcpHttp } from './codex-mcp-http.mjs'
33
- import { CODEX_THINKPOOL_FIRST_TURN_PREAMBLE, THINKPOOL_RUNTIME_TURN_REMINDER } from './thinkpool-room-prompt.mjs'
33
+ import { CODEX_THINKPOOL_FIRST_TURN_PREAMBLE, buildThinkPoolTurnGuidance, createRoomContextSelector, usesFullThinkPoolReminder } from './thinkpool-room-prompt.mjs'
34
34
 
35
35
  const DEFAULT_SANDBOX = 'workspace-write'
36
36
  const SAFE_SANDBOXES = new Set(['read-only', 'workspace-write', 'danger-full-access'])
@@ -211,16 +211,17 @@ export function codexPublishAccess({ cwd, env = process.env, tmpdir = os.tmpdir(
211
211
  }
212
212
 
213
213
  export const CODEX_ROOM_CASCADE_REMINDER = [
214
- '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
+ 'THINKPOOL ROOM ROLE ENFORCEMENT: obey the authoritative TERMINAL ROLE above. Conductors are independent main terminals; Ensemble lanes are workers only. An explicitly requested separate Cascade/conductor uses open_main_terminal — NEVER spawn_terminal. Use spawn_terminal only for bounded 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.',
215
215
  ].join(' ')
216
216
 
217
- export function buildCodexPrompt({ text, terminalRolePrompt, rolePrompt, roomContext, firstTurn = false }) {
217
+ export function buildCodexPrompt({ text, terminalRolePrompt, rolePrompt, roomContext, firstTurn = false, promptIndex = 0, forceFullReminder = false }) {
218
218
  const context = typeof roomContext === 'function' ? roomContext() : roomContext
219
+ const fullReminder = usesFullThinkPoolReminder({ promptIndex, forceFull: forceFullReminder })
219
220
  const additions = [
220
221
  firstTurn ? CODEX_THINKPOOL_FIRST_TURN_PREAMBLE : '',
221
- terminalRolePrompt,
222
- THINKPOOL_RUNTIME_TURN_REMINDER,
223
- rolePrompt || CODEX_ROOM_CASCADE_REMINDER,
222
+ fullReminder ? terminalRolePrompt : '',
223
+ buildThinkPoolTurnGuidance({ text, promptIndex, forceFull: forceFullReminder }),
224
+ fullReminder ? (rolePrompt || CODEX_ROOM_CASCADE_REMINDER) : '',
224
225
  context,
225
226
  ].map((v) => String(v || '').trim()).filter(Boolean)
226
227
  if (!additions.length) return String(text ?? '')
@@ -396,6 +397,9 @@ export function startCodexSession({ cwd, model, effort: initialEffort = 'high',
396
397
  })
397
398
  let sessionId = resumeUsable ? requestedResume : null
398
399
  let turnNo = sessionId ? 1 : 0
400
+ let userPromptNo = 0
401
+ let forceFullReminder = true
402
+ const selectRoomContext = createRoomContextSelector(roomContext)
399
403
  let child = null
400
404
  let aborted = false
401
405
  let ended = false
@@ -689,7 +693,16 @@ export function startCodexSession({ cwd, model, effort: initialEffort = 'high',
689
693
  // Claude receives these through the Agent SDK's system-reminder path.
690
694
  // Codex is one-shot per turn, so give it the same lane identity and live
691
695
  // room awareness in a compact envelope before the person's text.
692
- const prompt = buildCodexPrompt({ text: next.text, terminalRolePrompt, rolePrompt, roomContext, firstTurn: turnNo === 0 })
696
+ const fullReminder = usesFullThinkPoolReminder({ promptIndex: next.promptIndex, forceFull: next.forceFullReminder })
697
+ const prompt = buildCodexPrompt({
698
+ text: next.text,
699
+ terminalRolePrompt,
700
+ rolePrompt,
701
+ roomContext: () => selectRoomContext({ force: fullReminder }),
702
+ firstTurn: turnNo === 0,
703
+ promptIndex: next.promptIndex,
704
+ forceFullReminder: next.forceFullReminder,
705
+ })
693
706
  const usedAppServer = await runAppServer(prompt, next.options)
694
707
  if (!usedAppServer) await runExec(prompt, next.options)
695
708
  turnNo++
@@ -705,11 +718,22 @@ export function startCodexSession({ cwd, model, effort: initialEffort = 'high',
705
718
  get started() { return turnNo > 0 || turnActive },
706
719
  sendTurn(text, options = {}) {
707
720
  if (ended) return false
721
+ const promptIndex = userPromptNo++
722
+ const thisTurnForceFull = forceFullReminder
723
+ forceFullReminder = /^\s*\/(?:compact|reset|clear)\b/i.test(String(text || ''))
708
724
  if (!turnActive && turnNo === 0 && prepareCwd) {
709
725
  try { cwd = prepareCwd() || cwd } catch { /* keep original cwd */ }
710
726
  }
711
727
  if (turnActive && appServer && activeTurnId) {
712
- const prompt = buildCodexPrompt({ text, terminalRolePrompt, rolePrompt, roomContext })
728
+ const fullReminder = usesFullThinkPoolReminder({ promptIndex, forceFull: thisTurnForceFull })
729
+ const prompt = buildCodexPrompt({
730
+ text,
731
+ terminalRolePrompt,
732
+ rolePrompt,
733
+ roomContext: () => selectRoomContext({ force: fullReminder }),
734
+ promptIndex,
735
+ forceFullReminder: thisTurnForceFull,
736
+ })
713
737
  const targetTurnId = activeTurnId
714
738
  // Serialize authored follow-ups: parallel RPCs can complete out of order.
715
739
  steerChain = steerChain.then(() => appServer.steer({ threadId: sessionId, turnId: targetTurnId, input: prompt, images: Array.isArray(options.images) ? options.images : [] }))
@@ -718,7 +742,7 @@ export function startCodexSession({ cwd, model, effort: initialEffort = 'high',
718
742
  // pre-write failure. Timeout/close after write is delivery-uncertain;
719
743
  // replaying it as a new turn can execute the same instruction twice.
720
744
  if (error?.delivery === 'rejected' || error?.delivery === 'not_sent') {
721
- queue.push({ text, options })
745
+ queue.push({ text, options, promptIndex, forceFullReminder: thisTurnForceFull })
722
746
  if (!turnActive && queue.length === 1) pump()
723
747
  } else {
724
748
  try { onEvent?.({ kind: 'error', message: 'Codex steering delivery is uncertain; the message was not replayed automatically.', recoverable: true }) } catch { /* noop */ }
@@ -726,7 +750,7 @@ export function startCodexSession({ cwd, model, effort: initialEffort = 'high',
726
750
  })
727
751
  return true
728
752
  }
729
- queue.push({ text, options })
753
+ queue.push({ text, options, promptIndex, forceFullReminder: thisTurnForceFull })
730
754
  // start the pump if idle
731
755
  if (queue.length === 1) pump()
732
756
  return true
@@ -14,7 +14,9 @@ import { BLOCKED_REASON, projectLaneLifecycle } from './lane-lifecycle.mjs'
14
14
  // Bounds — the pull tool is self-limiting (the agent only calls it when it needs
15
15
  // to), but a runaway loop must still be capped (the cost-guard / C10 class).
16
16
  export const PEEK = {
17
- defaultLines: 40,
17
+ // Keep the implicit pull concise for consumer turns. Agents can request more
18
+ // explicitly when a diagnosis genuinely needs deeper sibling history.
19
+ defaultLines: 20,
18
20
  maxLines: 200,
19
21
  perTurnCap: 10, // read_terminal calls allowed per user turn (bridge resets it)
20
22
  lineCap: 200, // per-line truncation
@@ -10,7 +10,7 @@ import { HERMES_PLAN_SAFE_MCP_TOOLS, HERMES_QUESTION_MCP_TOOL, hermesExactInvent
10
10
  import { startCodexMcpHttp } from './codex-mcp-http.mjs'
11
11
  import { autoAllow, classifyRisk } from './claude-session.mjs'
12
12
  import { crossPostNeedsCard } from './cross-terminal.mjs'
13
- import { THINKPOOL_RUNTIME_TURN_REMINDER } from './thinkpool-room-prompt.mjs'
13
+ import { buildThinkPoolTurnGuidance, createRoomContextSelector, usesFullThinkPoolReminder } from './thinkpool-room-prompt.mjs'
14
14
 
15
15
  export const HERMES_COMMAND = 'thinkpool'
16
16
  export const HERMES_ACP_PROTOCOL_VERSION = 1
@@ -35,13 +35,14 @@ const modelList = (state) => (state?.availableModels || []).map((item) => ({ val
35
35
  const mcpDescriptor = (url) => ({ type: 'http', name: 'thinkpool', url, headers: [] })
36
36
  const createAcpClient = (options) => new AcpClient(options)
37
37
 
38
- export function buildHermesPromptText({ text, firstTurn = false, terminalRolePrompt, rolePrompt, roomContext } = {}) {
38
+ export function buildHermesPromptText({ text, firstTurn = false, terminalRolePrompt, rolePrompt, roomContext, promptIndex = 0, forceFullReminder = false } = {}) {
39
39
  const context = typeof roomContext === 'function' ? roomContext() : roomContext
40
40
  const inThinkPoolRoom = !!(terminalRolePrompt || rolePrompt || context)
41
+ const fullReminder = usesFullThinkPoolReminder({ promptIndex, forceFull: forceFullReminder })
41
42
  const preamble = [
42
- firstTurn ? terminalRolePrompt : '',
43
- inThinkPoolRoom ? THINKPOOL_RUNTIME_TURN_REMINDER : '',
44
- firstTurn ? rolePrompt : '',
43
+ firstTurn || fullReminder ? terminalRolePrompt : '',
44
+ inThinkPoolRoom ? buildThinkPoolTurnGuidance({ text, promptIndex, forceFull: forceFullReminder }) : '',
45
+ firstTurn || fullReminder ? rolePrompt : '',
45
46
  context,
46
47
  ].filter(Boolean).join('\n\n')
47
48
  const body = String(text ?? '')
@@ -79,6 +80,9 @@ export function startHermesSession({
79
80
  let started = false
80
81
  let crashed = false
81
82
  let firstTurn = true
83
+ let userPromptNo = 0
84
+ let forceFullReminder = true
85
+ const selectRoomContext = createRoomContextSelector(roomContext)
82
86
  let stderrTail = ''
83
87
  let promptChain = Promise.resolve()
84
88
  let activeTurnId = 0
@@ -393,19 +397,28 @@ export function startHermesSession({
393
397
  await boot()
394
398
  }
395
399
 
396
- function promptBlocks(text, options = {}) {
400
+ function promptBlocks(text, options = {}, { promptIndex = 0, forceFull = false } = {}) {
397
401
  const rawText = String(text)
398
402
  // Hermes handles /steer, /queue, /compact and /reset only when the slash is
399
403
  // the first character of a pure-text prompt.
400
404
  if (/^\s*\//.test(rawText)) return [{ type: 'text', text: rawText }]
401
- const blocks = [{ type: 'text', text: buildHermesPromptText({ text: rawText, firstTurn, terminalRolePrompt, rolePrompt, roomContext }) }]
405
+ const fullReminder = usesFullThinkPoolReminder({ promptIndex, forceFull })
406
+ const blocks = [{ type: 'text', text: buildHermesPromptText({
407
+ text: rawText,
408
+ firstTurn,
409
+ terminalRolePrompt,
410
+ rolePrompt,
411
+ roomContext: () => selectRoomContext({ force: fullReminder }),
412
+ promptIndex,
413
+ forceFullReminder: forceFull,
414
+ }) }]
402
415
  for (const imagePath of (Array.isArray(options.images) ? options.images : [])) {
403
416
  try { blocks.push(imageBlock(imagePath)) } catch { /* quoted host path remains in text */ }
404
417
  }
405
418
  return blocks
406
419
  }
407
420
 
408
- async function runPrompt(text, options = {}, { steering = false, turnId = activeTurnId } = {}) {
421
+ async function runPrompt(text, options = {}, { steering = false, turnId = activeTurnId, promptIndex = 0, forceFull = false } = {}) {
409
422
  try { await boot() }
410
423
  catch (error) {
411
424
  if (abortedTurns.has(turnId)) return { stopReason: 'cancelled' }
@@ -417,7 +430,7 @@ export function startHermesSession({
417
430
  try {
418
431
  result = await client.request('session/prompt', {
419
432
  sessionId,
420
- prompt: promptBlocks(body, options),
433
+ prompt: promptBlocks(body, options, { promptIndex, forceFull }),
421
434
  messageId: randomUUID(),
422
435
  }, 0)
423
436
  } catch (error) {
@@ -454,6 +467,9 @@ export function startHermesSession({
454
467
  get models() { return [] },
455
468
  sendTurn(text, options = {}) {
456
469
  if (ended) return false
470
+ const promptIndex = userPromptNo++
471
+ const thisTurnForceFull = forceFullReminder
472
+ forceFullReminder = /^\s*\/(?:compact|reset|clear)\b/i.test(String(text || ''))
457
473
  // A crash never replays work by itself. A later explicit turn is the
458
474
  // authority to launch a fresh ACP process and resume the same native
459
475
  // session id; this is the recovery path the old permanent latch blocked.
@@ -461,14 +477,14 @@ export function startHermesSession({
461
477
  // A busy prompt is a genuine ACP /steer call and may run concurrently.
462
478
  if (turnActive) {
463
479
  const turnId = activeTurnId
464
- void runPrompt(text, options, { steering: true, turnId }).catch((error) => emit({ kind: 'error', message: `Hermes steering failed: ${error?.message || error}`, recoverable: true }))
480
+ void runPrompt(text, options, { steering: true, turnId, promptIndex, forceFull: thisTurnForceFull }).catch((error) => emit({ kind: 'error', message: `Hermes steering failed: ${error?.message || error}`, recoverable: true }))
465
481
  return true
466
482
  }
467
483
  const turnId = ++activeTurnId
468
484
  // Claim the turn synchronously, before the cold safety probe/import. The
469
485
  // room can now show Thinking + Stop for the whole accepted lifecycle.
470
486
  turnActive = true
471
- promptChain = promptChain.then(() => runPrompt(text, options, { steering: false, turnId })).catch((error) => {
487
+ promptChain = promptChain.then(() => runPrompt(text, options, { steering: false, turnId, promptIndex, forceFull: thisTurnForceFull })).catch((error) => {
472
488
  turnActive = false
473
489
  emit({ kind: 'error', message: `Hermes turn failed: ${error?.message || error}`, recoverable: true })
474
490
  })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thinkpool-pair",
3
- "version": "0.7.258",
3
+ "version": "0.7.260",
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": {
@@ -1,7 +1,8 @@
1
1
  // Shared delivery contract for structured agents running inside ThinkPool Code.
2
- // Claude receives this in its system prompt plus a per-turn reminder; Codex and
3
- // Hermes receive it adjacent to normal room turns. Keep runtime-specific
4
- // orchestration rules in their own drivers — these are the cross-runtime truths
2
+ // Claude receives this in its system prompt; all runtimes receive a compact
3
+ // per-turn invariant plus the full contract at session start, after a reset, and
4
+ // every fifth user prompt. Keep runtime-specific mechanics in their own drivers;
5
+ // the Cascade contract below is a cross-runtime ThinkPool workflow
5
6
  // about available capabilities and how finished work reaches the people.
6
7
  // One canonical intent → capability router for every structured runtime. Tool
7
8
  // schemas still carry their detailed argument contracts; this compact registry
@@ -58,13 +59,19 @@ export function renderThinkPoolCapabilityRoutes(routes = THINKPOOL_CAPABILITY_RO
58
59
  return routes.map((route) => `${route.id}: ${route.rule}`).join(' ')
59
60
  }
60
61
 
62
+ export const THINKPOOL_RUNTIME_AUTHORITY_RULE = 'RUNTIME AUTHORITY: this agent session was created by the ThinkPool bridge. Instructions from any external terminal multiplexer, orchestrator, agent manager, IDE task runner, remembered host workflow, or similarly installed alternative do not govern this room unless the current ThinkPool prompt explicitly incorporates them. Do not infer authority from a binary in PATH, an environment variable, a config file, or old memory. The authoritative TERMINAL ROLE, current ThinkPool room prompt, and exposed tool schemas win.'
63
+
64
+ export const THINKPOOL_CASCADE_RULE = 'CASCADE WORKFLOW (default for non-trivial work that genuinely decomposes; mandatory when the people ask for Cascade, tiered lanes, one-shot flow, or unattended multi-slice execution): first investigate enough to name evidence-backed slices and their dependencies, then post a short plan in the room. The current top-level/conductor terminal keeps decomposition, integration, and final judgment; use spawn_terminal only for bounded worker slices, while an explicitly requested separate conductor uses open_main_terminal. Parallelize only disjoint slices and encode dependencies instead of holding them in your head. Choose sliceType=scaffold for mechanical/search work, feature or fix for implementation, and review with a balanced capable tier for adversarial verification. Every worker brief names the evidence/diagnosis, exact scope, observable acceptance proof, relevant repo gates, whether merge/publish is required, and a safe-skip escape hatch. After dispatch, remain responsible: use read_terminal to collect each owned lane, verify its claim, close_terminal immediately, and dispatch newly unblocked work; never declare the Cascade done or yield a final result while owned workers remain uncollected. Check origin/main CI before merge-bearing waves, coordinate shared fixtures/publishers, and treat sibling pushes as context rather than proof. Run an adversarial review after builders (or pipeline review behind completed phases), finish with production-condition evidence, verify every reported SHA/version, and close every worker you opened.'
65
+
61
66
  export const THINKPOOL_AGENT_CONTRACT = [
62
67
  'THINKPOOL-FIRST OPERATING CONTRACT (authoritative): ThinkPool room capabilities are your normal operating surface, not optional enrichment. Before acting on every request, infer which exposed ThinkPool capabilities materially improve room visibility, coordination, delivery, or verification and use them without waiting for the people to know a tool name, magic word, or workflow.',
68
+ THINKPOOL_RUNTIME_AUTHORITY_RULE,
63
69
  `DEFAULT ROUTING: ${renderThinkPoolCapabilityRoutes()}`,
70
+ THINKPOOL_CASCADE_RULE,
64
71
  'DEFAULT DOES NOT MEAN GRATUITOUS: use only capabilities exposed to your current role and only when relevant. Honor explicit steering such as single-lane, no research, do not contact another room, or do not use a named ThinkPool feature. Metered and side-effecting capabilities still obey their stated offer, approval, and consent contracts.',
65
72
  ].join(' ')
66
73
 
67
- export const THINKPOOL_DESIGN_INTERACTION_RULE = 'DESIGN EDITING MODEL: the user edits through a source-backed mockup card\'s Work on design action, which opens the fullscreen mockup/Design popup; never ask them to locate or edit HTML. Popup edits are optimistic drafts only. Apply sends the bounded batch to the producing lane, that lane edits the canonical authored HTML, and a successful desktop+mobile render publishes the next verified revision back into the popup. Never call an in-popup draft saved or live. A preview_capture card is visual evidence, not an editable Design artifact.'
74
+ export const THINKPOOL_DESIGN_INTERACTION_RULE = 'DESIGN EDITING MODEL: a trusted source-backed mockup card offers Work on design. That explicit action—not ordinary Preview or unrelated artifact delivery—arms a persistent room-level Design workspace, opens the artifact directly in editable mode, and adds Design · Page name as a virtual Ensemble lane for both partners; it creates no terminal, agent runtime, or worker slot. Once armed, the Design lane reopens the active artifact at its latest verified revision without another agent turn, history search, HTML/path request, or re-selection. Design is the default canvas there and Preview is only a temporary interaction mode. Direct text, supported movement, and selected-image changes auto-stage as optimistic drafts in the bounded changes queue. Apply changes sends the ordered batch once to the producing lane, that lane edits the canonical authored HTML, and a successful desktop+mobile render advances the verified revision for the room. Never call a staged draft saved or live. A preview_capture card is visual evidence, not an editable Design artifact.'
68
75
 
69
76
  export const THINKPOOL_DESIGN_DELIVERY_RULE = `THINKPOOL DESIGN (mandatory for authored HTML): save the editable HTML source in the workspace, then use the $TP_MOCKUP_OUTBOX render helper so the room receives a source-backed Thinkpool Design card with both desktop (1440×900) and mobile (390×844) previews. The interactive Design card/popup is the visible result: the renders remain required verification inputs, but when that card is displayed do not also surface duplicate inline PNGs. Surface desktop/mobile PNGs only when no interactive source-backed Design artifact is available. ${THINKPOOL_DESIGN_INTERACTION_RULE} When the requested Design surface is an existing product page or route, first build and capture the actual route at both viewports, then read its current source, styles, copy, fonts, and assets. Derive the editable Design artifact from that evidence and compare both artifact captures against the real route before delivery. Preserve the real page faithfully except for explicitly proposed edits—never hand-recreate it from memory, simplify it, replace it with generic mockup content, or label an approximation as the product. If a faithful editable artifact cannot be produced, surface the actual route captures and say that Design editing is unavailable for that surface. Never paste raw HTML into chat, send an .html file as the room deliverable, or substitute a bare URL or single screenshot for this card. A shareable browser URL may accompany the card, but never replaces it.`
70
77
 
@@ -90,6 +97,64 @@ export const THINKPOOL_RUNTIME_TURN_REMINDER = [
90
97
  'VERIFY BEFORE CLAIMING: run or serve what changed and show the real response, passing output, or rendered evidence; state exactly what remains unverified.',
91
98
  ].join(' ')
92
99
 
100
+ // Repeating the complete router on every turn made a durable operating contract
101
+ // compete with the user's actual request. The model still gets a small invariant
102
+ // on ordinary turns, while periodic and recovery turns refresh the complete map.
103
+ export const THINKPOOL_FULL_REMINDER_INTERVAL = 5
104
+
105
+ export const THINKPOOL_RUNTIME_SALIENCE_REMINDER = 'THINKPOOL: prefer relevant exposed room tools unless the user explicitly opts out; obey the durable terminal role and consent rules. Assume the people are remote, do host work yourself, and deliver reachable verified results.'
106
+
107
+ const ROUTE_TRIGGERS = Object.freeze({
108
+ 'room-awareness': /\b(other|another|sibling|peer)\s+(lane|terminal)|\bread_terminal\b/i,
109
+ 'cross-room-awareness': /\b(other|another|cross[- ]?room|cross[- ]?session)\s+(room|session)|\b(list_sessions|read_session)\b/i,
110
+ 'visible-handoff': /\b(hand[ -]?off|tell|send|post)\b.{0,40}\b(lane|terminal|room|session|agent)\b|\b(post_to_terminal|post_to_session)\b/i,
111
+ 'work-routing': /\b(parallel|delegate|worker|sub[- ]?terminal|conductor|cascade|spawn_terminal|open_main_terminal|close_terminal)\b/i,
112
+ 'room-question': /\b(request_user_input)\b/i,
113
+ 'visual-proof': /\b(ui|ux|visual|design|frontend|html|css|page|route|mockup|screenshot|responsive|desktop|mobile|preview)\b/i,
114
+ 'external-research': /\b(research|latest|current|look up|browse|web search|online source|verify online)\b/i,
115
+ 'flow-completion': /\b(flow|submit_flow_plan|mark_flow_done|submit_flow_review|read_review_file|run_review_check)\b/i,
116
+ })
117
+
118
+ const DESIGN_ROUTE_REMINDER = 'DESIGN ROUTE: authored HTML must produce a source-backed Thinkpool Design card with verified desktop and mobile renders. Work on design explicitly arms the persistent Design workspace and adds its virtual Design · Page Ensemble lane; Preview alone does not arm it. Do not duplicate the card renders as inline PNGs. Use inline PNG evidence only when no interactive Design artifact is available.'
119
+
120
+ export function usesFullThinkPoolReminder({ promptIndex = 0, forceFull = false } = {}) {
121
+ const index = Math.max(0, Number.isFinite(Number(promptIndex)) ? Math.trunc(Number(promptIndex)) : 0)
122
+ return !!forceFull || index % THINKPOOL_FULL_REMINDER_INTERVAL === 0
123
+ }
124
+
125
+ export function buildThinkPoolTurnGuidance({ text = '', promptIndex = 0, forceFull = false } = {}) {
126
+ if (usesFullThinkPoolReminder({ promptIndex, forceFull })) return THINKPOOL_RUNTIME_TURN_REMINDER
127
+ const body = String(text || '')
128
+ const routed = THINKPOOL_CAPABILITY_ROUTES
129
+ .filter((route) => ROUTE_TRIGGERS[route.id]?.test(body))
130
+ .map((route) => `${route.id}: ${route.rule}`)
131
+ if (ROUTE_TRIGGERS['work-routing'].test(body)) routed.push(THINKPOOL_CASCADE_RULE)
132
+ if (ROUTE_TRIGGERS['visual-proof'].test(body)) routed.push(DESIGN_ROUTE_REMINDER)
133
+ return [THINKPOOL_RUNTIME_SALIENCE_REMINDER, ...routed].join(' ')
134
+ }
135
+
136
+ export function roomContextFingerprint(value) {
137
+ return String(value || '')
138
+ // Relative ages change while the underlying lane state does not. Ignore
139
+ // that clock churn when deciding whether a room snapshot is new information.
140
+ .replace(/ · (?:\d+[smhd] ago|no activity)(?=\s+—)/g, ' · age')
141
+ .trim()
142
+ }
143
+
144
+ export function createRoomContextSelector(roomContext) {
145
+ let previousFingerprint = null
146
+ return ({ force = false } = {}) => {
147
+ let context = null
148
+ try { context = typeof roomContext === 'function' ? roomContext() : roomContext } catch { context = null }
149
+ const value = String(context || '').trim()
150
+ if (!value) return ''
151
+ const fingerprint = roomContextFingerprint(value)
152
+ const changed = fingerprint !== previousFingerprint
153
+ previousFingerprint = fingerprint
154
+ return force || changed ? value : ''
155
+ }
156
+ }
157
+
93
158
  // One authoritative terminal-identity preamble for every structured runtime.
94
159
  // The bridge derives this from durable structural metadata — never from the text
95
160
  // of the task handed to the model. That distinction matters because a spawned