thinkpool-pair 0.7.256 → 0.7.258

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
@@ -119,6 +119,7 @@ import { classifyCodeEvent, unknownCodeEventNotice } from './code-event-contract
119
119
  import { fetchPairControlDeliveryAuthority } from './pair-control-authority.mjs'
120
120
  import { SIDE_HANDOFF_PROMPT, SIDE_ROLE_PROMPT, appendSideContext, assistantTextSince, sideContextBlock, sideSnapshot } from './side-lane.mjs'
121
121
  import { planMeterLine } from './plan-meters.mjs'
122
+ import { priceForModel } from './model-prices.mjs'
122
123
  import { makeThrottledTrack } from './presence.mjs'
123
124
  import { resolveAnonKey, DEFAULT_SUPABASE_URL } from './supabase-key.mjs'
124
125
  import { buildTerminalRolePrompt, HERMES_VISIBLE_WORKER_FALLBACK_RULE } from './thinkpool-room-prompt.mjs'
@@ -869,15 +870,24 @@ function recordRoomSpend (tokens) {
869
870
  // TP_BUDGET_OFF (that only governs the cap ledger; usage accounting should still
870
871
  // run). input = full input incl. cache; cached = cache-read slice; both mapped to
871
872
  // pricing.js's rowCostEur formula. No model → skip (can't price/label it).
872
- function recordCodeUsage (model, usage) {
873
+ function recordCodeUsage (model, usage, provider) {
873
874
  if (!codeAuthToken || !room || !model) return
874
875
  const u = usage || {}
875
876
  const input = (u.input_tokens || 0) + (u.cache_creation_input_tokens || 0) + (u.cache_read_input_tokens || 0)
876
877
  const output = u.output_tokens || 0
877
878
  const cached = u.cache_read_input_tokens || 0
878
879
  if (!input && !output) return
879
- budgetRpc('record_code_usage', { p_room: room, p_model: model, p_input: input, p_output: output, p_cached: cached })
880
- .catch(() => { /* best-effort — usage accounting is not load-bearing for the turn */ })
880
+ // Persist the public rate resolved for this exact runtime model. A private
881
+ // model can lack a public rate; its token row is still written and Settings
882
+ // marks only the estimate as pending rather than making one up.
883
+ priceForModel(model).then((rate) => budgetRpc('record_code_usage', {
884
+ p_room: room, p_model: model, p_input: input, p_output: output, p_cached: cached,
885
+ p_provider: provider || 'unknown',
886
+ p_input_usd_per_mtok: rate?.input ?? null,
887
+ p_output_usd_per_mtok: rate?.output ?? null,
888
+ p_cached_input_usd_per_mtok: rate?.cached ?? null,
889
+ p_rate_source: rate?.source ?? null,
890
+ })).catch(() => { /* usage accounting is never load-bearing for a turn */ })
881
891
  }
882
892
  // Throttled presence track() (presence.mjs) — a reconnect storm re-fires SUBSCRIBED and
883
893
  // the handler re-track()s; without throttle that spirals into the per-client presence
@@ -2862,7 +2872,7 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
2862
2872
  // Fold EVERY completed turn's output tokens into the room's durable monthly ledger
2863
2873
  // (lanes + normal turns) — powers the Session-info "Session tokens" stat. (The cap that
2864
2874
  // consumed this ledger was removed; the accounting stays.)
2865
- if (evt.kind === 'result' && evt.usage) { recordRoomSpend(evt.usage.output_tokens || 0); recordCodeUsage(evt.model, evt.usage) }
2875
+ if (evt.kind === 'result' && evt.usage) { recordRoomSpend(evt.usage.output_tokens || 0); recordCodeUsage(evt.model, evt.usage, entry.provider) }
2866
2876
  // FL-B1 (lane) — a flow lane that ENDS ITS TURN is done: a bypass lane runs its slice
2867
2877
  // to completion in one turn, then narrates "done" and stops. Models often skip the
2868
2878
  // explicit signal (FLOW_DONE / mark_flow_done), which left the slice stuck 'dispatched'
@@ -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_DESIGN_DELIVERY_RULE, THINKPOOL_REMOTE_DELIVERY_RULES } from './thinkpool-room-prompt.mjs'
24
+ import { THINKPOOL_REMOTE_DELIVERY_RULES, THINKPOOL_RUNTIME_TURN_REMINDER } 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
@@ -191,20 +191,17 @@ const MODES = new Set(['default', 'acceptEdits', 'plan', 'bypassPermissions'])
191
191
  // or narrates instead of showing, forgetting it's driven from a phone. So we re-state
192
192
  // the highest-drift rules as a compact <system-reminder> appended to EVERY user turn,
193
193
  // adjacent to where the model's attention actually is — the same trick the host
194
- // harness uses to keep CLAUDE.md alive. ~250 tokens/turn; changes nothing but salience.
194
+ // 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.
195
196
  // A live "ROOM NOW" tail (sibling lanes + active worktrees) is appended per turn by
196
197
  // the bridge's roomContext callback — see roomReminder() inside startClaudeSession.
197
198
  const TP_ROOM_REMINDER = [
198
199
  '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.',
199
- 'ARTIFACTS: never hand the room a local path, a file:// URL, a localhost address, an `open …`/Preview reference, or "it\'s in ~/claude-shots/" — the room cannot see the host. Surface every image by reading the PNG into your turn so it renders inline.',
200
- THINKPOOL_DESIGN_DELIVERY_RULE,
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
- 'SHOW, don\'t narrate: a picture of the result beats a wall of text on a phone — err toward more screenshots.',
200
+ THINKPOOL_RUNTIME_TURN_REMINDER,
203
201
  'TERMINAL HIERARCHY: obey your authoritative TERMINAL ROLE. Conductors are independent main terminals; Ensemble lanes are workers only. When a person explicitly asks to open, launch, start, or spawn a separate Cascade/conductor terminal, use open_main_terminal — NEVER spawn_terminal. If open_main_terminal is unavailable, say so; never substitute an Ensemble child. Only conductor-capable roles fan worker slices out with spawn_terminal. Leaf, worker, Side, and managed Flow lanes work directly. Never use built-in invisible Task/Agent subagents or hijack a busy sibling.',
204
202
  '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
203
  '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
204
  'BUILD WORKFLOW (default, no magic word): right-size within your TERMINAL ROLE — a trivial ask or delegated slice you just do; a conductor-capable role with a genuinely decomposable build FIRST writes a short plan in chat, THEN fans worker slices into visible spawn_terminal lanes and verifies them. Worker/leaf/Side/managed Flow roles do not fan out. A requested separate conductor uses open_main_terminal. Never plan-mode/ExitPlanMode; plans live in chat and lanes in the existing list.',
207
- 'Verify before claiming done — show runtime evidence you produced, not "should work, go test it".',
208
205
  ].join(' ')
209
206
 
210
207
  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 }) {
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 } from './thinkpool-room-prompt.mjs'
33
+ import { CODEX_THINKPOOL_FIRST_TURN_PREAMBLE, THINKPOOL_RUNTIME_TURN_REMINDER } 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'])
@@ -219,6 +219,7 @@ export function buildCodexPrompt({ text, terminalRolePrompt, rolePrompt, roomCon
219
219
  const additions = [
220
220
  firstTurn ? CODEX_THINKPOOL_FIRST_TURN_PREAMBLE : '',
221
221
  terminalRolePrompt,
222
+ THINKPOOL_RUNTIME_TURN_REMINDER,
222
223
  rolePrompt || CODEX_ROOM_CASCADE_REMINDER,
223
224
  context,
224
225
  ].map((v) => String(v || '').trim()).filter(Boolean)
@@ -145,6 +145,57 @@ def constrained_expand(toolsets_arg=None, mcp_server_names=None):
145
145
  acp_adapter.session._expand_acp_enabled_toolsets = constrained_expand
146
146
 
147
147
  import acp_adapter.server
148
+
149
+ # Hermes 0.18.2 emits the real result through ``tool.completed`` immediately,
150
+ # but its ACP callback ignores that event and waits for the next model-step
151
+ # summary. Parallel Read/search calls are not always present in that summary,
152
+ # leaving clients with a ToolCallStart and no ToolCallUpdate forever. Complete
153
+ # from the authoritative execution callback and consume the same per-name FIFO;
154
+ # the later step callback then sees an empty queue and cannot double-emit. If a
155
+ # future Hermes release handles completion itself, it consumes the queue before
156
+ # this wrapper runs and this compatibility branch becomes a no-op.
157
+ from collections import deque
158
+ import acp_adapter.events
159
+ import acp_adapter.tools
160
+
161
+ _tool_progress_factory = acp_adapter.server.make_tool_progress_cb
162
+ def completion_aware_tool_progress_factory(
163
+ conn, session_id, loop, tool_call_ids, tool_call_meta,
164
+ edit_approval_policy_getter=None,
165
+ ):
166
+ upstream = _tool_progress_factory(
167
+ conn, session_id, loop, tool_call_ids, tool_call_meta,
168
+ edit_approval_policy_getter=edit_approval_policy_getter,
169
+ )
170
+
171
+ def progress(event_type, name=None, preview=None, args=None, **kwargs):
172
+ upstream(event_type, name, preview, args, **kwargs)
173
+ if event_type != "tool.completed" or not name:
174
+ return
175
+ queue = tool_call_ids.get(name)
176
+ if isinstance(queue, str):
177
+ queue = deque([queue])
178
+ tool_call_ids[name] = queue
179
+ if not queue:
180
+ return
181
+ tool_call_id = queue.popleft()
182
+ meta = tool_call_meta.pop(tool_call_id, {})
183
+ result = kwargs.get("result")
184
+ update = acp_adapter.tools.build_tool_complete(
185
+ tool_call_id,
186
+ name,
187
+ result=str(result) if result is not None else None,
188
+ function_args=meta.get("args"),
189
+ snapshot=meta.get("snapshot"),
190
+ )
191
+ acp_adapter.events._send_update(conn, session_id, loop, update)
192
+ if not queue:
193
+ tool_call_ids.pop(name, None)
194
+
195
+ return progress
196
+
197
+ acp_adapter.server.make_tool_progress_cb = completion_aware_tool_progress_factory
198
+
148
199
  _register = acp_adapter.server.HermesACPAgent._register_session_mcp_servers
149
200
  async def constrained_register(self, state, mcp_servers):
150
201
  if any(getattr(server, "name", None) != "thinkpool" for server in (mcp_servers or [])):
@@ -128,16 +128,17 @@ export class HermesEventMapper {
128
128
  }
129
129
 
130
130
  finishTurn({ stopReason = 'end_turn', usage = null } = {}) {
131
- if (stopReason === 'cancelled') {
132
- for (const [toolCallId, tool] of this.tools) {
133
- this._emit({
134
- kind: 'tool_result', toolUseId: toolCallId,
135
- content: [{ type: 'text', text: '[cancelled]' }],
136
- isError: false,
137
- durationMs: tool.startedAt ? Date.now() - tool.startedAt : undefined,
138
- parentToolUseId: null,
139
- })
140
- }
131
+ for (const [toolCallId, tool] of this.tools) {
132
+ const cancelled = stopReason === 'cancelled'
133
+ this._emit({
134
+ kind: 'tool_result', toolUseId: toolCallId,
135
+ content: [{ type: 'text', text: cancelled ? '[cancelled]' : 'Hermes ended the turn without reporting this tool result.' }],
136
+ toolInput: tool.input || undefined,
137
+ isError: !cancelled && stopReason !== 'end_turn',
138
+ missing: !cancelled,
139
+ durationMs: tool.startedAt ? Date.now() - tool.startedAt : undefined,
140
+ parentToolUseId: null,
141
+ })
141
142
  }
142
143
  this.tools.clear()
143
144
  const blocks = []
@@ -10,6 +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
14
 
14
15
  export const HERMES_COMMAND = 'thinkpool'
15
16
  export const HERMES_ACP_PROTOCOL_VERSION = 1
@@ -34,6 +35,19 @@ const modelList = (state) => (state?.availableModels || []).map((item) => ({ val
34
35
  const mcpDescriptor = (url) => ({ type: 'http', name: 'thinkpool', url, headers: [] })
35
36
  const createAcpClient = (options) => new AcpClient(options)
36
37
 
38
+ export function buildHermesPromptText({ text, firstTurn = false, terminalRolePrompt, rolePrompt, roomContext } = {}) {
39
+ const context = typeof roomContext === 'function' ? roomContext() : roomContext
40
+ const inThinkPoolRoom = !!(terminalRolePrompt || rolePrompt || context)
41
+ const preamble = [
42
+ firstTurn ? terminalRolePrompt : '',
43
+ inThinkPoolRoom ? THINKPOOL_RUNTIME_TURN_REMINDER : '',
44
+ firstTurn ? rolePrompt : '',
45
+ context,
46
+ ].filter(Boolean).join('\n\n')
47
+ const body = String(text ?? '')
48
+ return preamble ? `${preamble}\n\n${body}` : body
49
+ }
50
+
37
51
  const permissionOutcome = (decision, options = []) => {
38
52
  if (decision === 'deny' || !decision) return { outcome: 'cancelled' }
39
53
  const preferred = decision === 'always'
@@ -384,9 +398,7 @@ export function startHermesSession({
384
398
  // Hermes handles /steer, /queue, /compact and /reset only when the slash is
385
399
  // the first character of a pure-text prompt.
386
400
  if (/^\s*\//.test(rawText)) return [{ type: 'text', text: rawText }]
387
- const context = typeof roomContext === 'function' ? roomContext() : roomContext
388
- const preamble = firstTurn ? [terminalRolePrompt, rolePrompt, context].filter(Boolean).join('\n\n') : context
389
- const blocks = [{ type: 'text', text: preamble ? `${preamble}\n\n${rawText}` : rawText }]
401
+ const blocks = [{ type: 'text', text: buildHermesPromptText({ text: rawText, firstTurn, terminalRolePrompt, rolePrompt, roomContext }) }]
390
402
  for (const imagePath of (Array.isArray(options.images) ? options.images : [])) {
391
403
  try { blocks.push(imageBlock(imagePath)) } catch { /* quoted host path remains in text */ }
392
404
  }
@@ -0,0 +1,113 @@
1
+ // Public model-price resolver for the bridge's usage ledger.
2
+ // Runtime model catalogs tell us what ran, but not what a provider bills for
3
+ // it. LiteLLM maintains a public catalog; cache its validated data locally and
4
+ // persist the resolved rate beside each turn. A failed refresh never blocks a
5
+ // turn or drops token accounting.
6
+
7
+ import fs from 'node:fs'
8
+ import os from 'node:os'
9
+ import path from 'node:path'
10
+
11
+ const CATALOG_URL = 'https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json'
12
+ const CACHE_TTL_MS = 24 * 60 * 60 * 1000
13
+ const CACHE_MAX_BYTES = 4 * 1024 * 1024
14
+ const cachePath = () => process.env.TP_MODEL_PRICE_CACHE || path.join(process.env.TP_PAIR_ROOT || path.join(os.homedir(), '.thinkpool-pair'), 'model-prices.json')
15
+
16
+ // First-party runtimes remain priced while offline or before the catalog loads.
17
+ const FALLBACKS = Object.freeze({
18
+ 'gpt-5.6-sol': { input: 5, cached: 0.5, output: 30 },
19
+ 'gpt-5.6-terra': { input: 2.5, cached: 0.25, output: 15 },
20
+ 'gpt-5.6-luna': { input: 1, cached: 0.1, output: 6 },
21
+ 'gpt-5.4': { input: 2.5, cached: 0.25, output: 15 },
22
+ 'gpt-5.4-mini': { input: 0.75, cached: 0.075, output: 4.5 },
23
+ 'gpt-5.4-nano': { input: 0.2, cached: 0.02, output: 1.25 },
24
+ 'claude-opus-4-8': { input: 5, cached: 0.5, output: 25 },
25
+ 'claude-fable-5': { input: 10, cached: 1, output: 50 },
26
+ 'claude-sonnet-5': { input: 2, cached: 0.2, output: 10 },
27
+ 'claude-haiku-4-5': { input: 1, cached: 0.1, output: 5 },
28
+ 'glm-5.2': { input: 1.4, cached: 0.26, output: 4.4 },
29
+ 'glm-4.6': { input: 0.6, cached: 0.11, output: 2.2 },
30
+ })
31
+
32
+ function cleanRate(raw) {
33
+ // Catalog rates are per token; normalize at the persistence boundary so a
34
+ // decimal like 0.0000001 does not become 0.09999999999999999 in Settings.
35
+ const perMillion = (value) => Math.round(Number(value) * 1_000_000 * 1_000_000) / 1_000_000
36
+ const input = perMillion(raw?.input_cost_per_token)
37
+ const output = perMillion(raw?.output_cost_per_token)
38
+ const cached = perMillion(raw?.cache_read_input_token_cost ?? raw?.cache_read_input_tokens_cost)
39
+ if (!Number.isFinite(input) || input < 0 || !Number.isFinite(output) || output < 0) return null
40
+ return { input, output, cached: Number.isFinite(cached) && cached >= 0 ? cached : null }
41
+ }
42
+
43
+ export function modelPriceKeys(model) {
44
+ const raw = String(model || '').trim()
45
+ if (!raw) return []
46
+ const withoutMarker = raw.replace(/\[[^\]]+\]/g, '')
47
+ const withoutPrefix = withoutMarker.replace(/^[^:/]+:/, '')
48
+ const tail = withoutPrefix.includes('/') ? withoutPrefix.slice(withoutPrefix.lastIndexOf('/') + 1) : withoutPrefix
49
+ return [...new Set([raw, withoutMarker, withoutPrefix, tail].filter(Boolean))]
50
+ }
51
+
52
+ function readCache() {
53
+ try {
54
+ const file = cachePath()
55
+ if ((Number(fs.statSync(file).size) || 0) > CACHE_MAX_BYTES) return null
56
+ const parsed = JSON.parse(fs.readFileSync(file, 'utf8'))
57
+ return parsed?.models && typeof parsed.models === 'object' ? parsed : null
58
+ } catch { return null }
59
+ }
60
+
61
+ function writeCache(models) {
62
+ try {
63
+ const file = cachePath()
64
+ fs.mkdirSync(path.dirname(file), { recursive: true })
65
+ fs.writeFileSync(file, JSON.stringify({ fetchedAt: Date.now(), models }), { mode: 0o600 })
66
+ } catch { /* cache is opportunistic */ }
67
+ }
68
+
69
+ export function rateFromCatalog(model, models) {
70
+ for (const key of modelPriceKeys(model)) {
71
+ const rate = cleanRate(models?.[key])
72
+ if (rate) return rate
73
+ }
74
+ for (const key of modelPriceKeys(model)) {
75
+ const fallback = FALLBACKS[key.toLowerCase()]
76
+ if (fallback) return fallback
77
+ }
78
+ return null
79
+ }
80
+
81
+ export function createModelPriceResolver({ fetchImpl = globalThis.fetch } = {}) {
82
+ let cached = readCache()
83
+ let refresh = null
84
+ const refreshCatalog = async () => {
85
+ if (refresh) return refresh
86
+ refresh = (async () => {
87
+ let timer
88
+ try {
89
+ const controller = new AbortController()
90
+ timer = setTimeout(() => controller.abort(), 4_000)
91
+ const response = await fetchImpl(CATALOG_URL, { signal: controller.signal })
92
+ if (!response?.ok) return cached?.models || null
93
+ const models = await response.json()
94
+ if (!models || typeof models !== 'object') return cached?.models || null
95
+ cached = { fetchedAt: Date.now(), models }
96
+ writeCache(models)
97
+ return models
98
+ } catch { return cached?.models || null }
99
+ finally { clearTimeout(timer); refresh = null }
100
+ })()
101
+ return refresh
102
+ }
103
+ return async (model) => {
104
+ const current = rateFromCatalog(model, cached?.models)
105
+ if (current) return { ...current, source: cached?.models ? 'litellm-cache' : 'bridge-fallback' }
106
+ const stale = !cached || (Date.now() - Number(cached.fetchedAt || 0)) > CACHE_TTL_MS
107
+ const models = stale ? await refreshCatalog() : cached?.models
108
+ const rate = rateFromCatalog(model, models)
109
+ return rate ? { ...rate, source: models ? 'litellm' : 'bridge-fallback' } : null
110
+ }
111
+ }
112
+
113
+ export const priceForModel = createModelPriceResolver()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thinkpool-pair",
3
- "version": "0.7.256",
3
+ "version": "0.7.258",
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": {
@@ -80,6 +80,7 @@
80
80
  "supabase-key.mjs",
81
81
  "provider.mjs",
82
82
  "providers.mjs",
83
+ "model-prices.mjs",
83
84
  "README.md"
84
85
  ],
85
86
  "scripts": {
@@ -1,21 +1,96 @@
1
1
  // Shared delivery contract for structured agents running inside ThinkPool Code.
2
- // Claude receives this as part of its system prompt; Codex receives it ahead of
3
- // the first human turn in each fresh native thread. Keep the runtime-specific
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
4
  // orchestration rules in their own drivers — these are the cross-runtime truths
5
- // about where the people are and how finished work must reach them.
6
- export const THINKPOOL_DESIGN_DELIVERY_RULE = 'THINKPOOL DESIGN (mandatory for HTML): save the editable HTML source in the workspace, then use the $TP_MOCKUP_OUTBOX render helper so the room receives a Thinkpool Design card with both desktop (1440×900) and mobile (390×844) previews. 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.'
5
+ // about available capabilities and how finished work reaches the people.
6
+ // One canonical intent → capability router for every structured runtime. Tool
7
+ // schemas still carry their detailed argument contracts; this compact registry
8
+ // tells a fresh agent WHEN ThinkPool expects each capability without requiring
9
+ // the people in the room to know a tool name or summon word.
10
+ export const THINKPOOL_CAPABILITY_ROUTES = Object.freeze([
11
+ Object.freeze({
12
+ id: 'room-awareness',
13
+ tools: Object.freeze(['read_terminal']),
14
+ rule: 'When the request depends on another lane, a sibling may overlap the work, or a person refers to another terminal, inspect it with read_terminal before acting.',
15
+ }),
16
+ Object.freeze({
17
+ id: 'cross-room-awareness',
18
+ tools: Object.freeze(['list_sessions', 'read_session']),
19
+ rule: 'When work depends on another ThinkPool room, use list_sessions then read_session instead of asking the people to relay host-side state.',
20
+ }),
21
+ Object.freeze({
22
+ id: 'visible-handoff',
23
+ tools: Object.freeze(['post_to_terminal', 'post_to_session']),
24
+ rule: 'When the people want a handoff, read the target first, then use post_to_terminal or post_to_session; let the room approval contract handle consent.',
25
+ }),
26
+ Object.freeze({
27
+ id: 'work-routing',
28
+ tools: Object.freeze(['spawn_terminal', 'open_main_terminal', 'close_terminal']),
29
+ rule: 'For genuinely decomposable work in a conductor-capable role, open visible worker slices with spawn_terminal, collect and verify them, then close_terminal. An explicitly requested separate Cascade/conductor uses open_main_terminal, never spawn_terminal.',
30
+ }),
31
+ Object.freeze({
32
+ id: 'room-question',
33
+ tools: Object.freeze(['request_user_input']),
34
+ rule: 'When a missing choice genuinely blocks useful progress, use request_user_input so the question is answerable in the room; otherwise make a safe in-scope assumption and continue.',
35
+ }),
36
+ Object.freeze({
37
+ id: 'visual-proof',
38
+ tools: Object.freeze(['preview_start', 'preview_capture', 'preview_inspect', 'preview_stop']),
39
+ rule: 'For built visual work, run the build, use preview_start, preview_capture at desktop and mobile, preview_inspect when rendered state matters, and preview_stop when finished. Surface PNG evidence only when no interactive source-backed Design card/popup is displayed.',
40
+ }),
41
+ Object.freeze({
42
+ id: 'external-research',
43
+ tools: Object.freeze(['research']),
44
+ rule: 'For current external facts that materially benefit from multi-source verification, offer the metered research lane and call research only after the people agree.',
45
+ }),
46
+ Object.freeze({
47
+ id: 'flow-completion',
48
+ tools: Object.freeze(['submit_flow_plan', 'mark_flow_done', 'submit_flow_review', 'read_review_file', 'run_review_check']),
49
+ rule: 'Managed Flow roles use only their exposed completion and immutable-review tools: submit_flow_plan, mark_flow_done, submit_flow_review, read_review_file, and run_review_check.',
50
+ }),
51
+ ])
52
+
53
+ export const THINKPOOL_ROUTED_TOOLS = Object.freeze([
54
+ ...new Set(THINKPOOL_CAPABILITY_ROUTES.flatMap((route) => route.tools)),
55
+ ])
56
+
57
+ export function renderThinkPoolCapabilityRoutes(routes = THINKPOOL_CAPABILITY_ROUTES) {
58
+ return routes.map((route) => `${route.id}: ${route.rule}`).join(' ')
59
+ }
60
+
61
+ export const THINKPOOL_AGENT_CONTRACT = [
62
+ '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.',
63
+ `DEFAULT ROUTING: ${renderThinkPoolCapabilityRoutes()}`,
64
+ '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
+ ].join(' ')
66
+
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.'
68
+
69
+ 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.`
7
70
 
8
71
  export const THINKPOOL_REMOTE_DELIVERY_RULES = Object.freeze([
9
72
  'REMOTE USER (authoritative default for ThinkPool Code rooms): unless a person explicitly says they are at the host machine, assume everyone driving this room is remote — possibly on a phone — with no terminal and no access to the host filesystem. Never ask them to run a local command, open a local file, or "go check" something on the machine. Anything host-side, you run yourself and show the result in the room. Only suggest actions they can actually do from the room UI or a browser.',
10
73
  'LINKS & ARTIFACTS: a local filesystem path, file:// URL, localhost/127.0.0.1 address, or host-only preview is useless to a remote room. Every link you surface must be reachable by the people in the room.',
11
74
  'Whenever you produce HTML or shareable markup — a demo, mockup, preview, report, or page — publish it to a browser-renderable GitHub-shareable URL, normally GitHub Pages (or an equivalent URL that actually renders; raw.githubusercontent.com serves HTML as plain text). Give the room that shareable URL in addition to the Thinkpool Design card, never instead of it and never only as a local path. If you cannot publish it, say so instead of falling back to a host-only path.',
12
- 'SHOW VISUAL WORK: whenever you build, change, or fix anything visual — UI, page, HTML artifact, chart, diagram, or rendered result — capture a PNG and surface the image in your turn so it appears inline in the room; the room auto-uploads surfaced images. Prefer showing the result over describing it.',
75
+ 'SHOW VISUAL WORK: whenever you build, change, or fix anything visual, show the result in the room. A source-backed Thinkpool Design card/popup is already the interactive visible result, so do not additionally surface duplicate PNGs. When no interactive Design artifact is available, capture and surface desktop/mobile PNG evidence inline.',
13
76
  'BRIDGE PREVIEWS: for a built web UI, use preview_start (default root: dist), then preview_capture for exact desktop 1440x900 and mobile 390x844 PNGs, and preview_inspect when DOM text or selector geometry helps. Run the project build first and stop the preview server with preview_stop when done.',
14
77
  THINKPOOL_DESIGN_DELIVERY_RULE,
15
78
  '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
79
  ])
17
80
 
18
- // One authoritative terminal-identity preamble for BOTH structured runtimes.
81
+ // Compact enough to repeat adjacent to every normal user turn. The full remote
82
+ // delivery contract is still installed at session boot where the runtime offers
83
+ // a durable system channel; this reminder keeps the high-drift decisions salient
84
+ // after long tool runs, resumes, and compaction without duplicating the manual.
85
+ export const THINKPOOL_RUNTIME_TURN_REMINDER = [
86
+ THINKPOOL_AGENT_CONTRACT,
87
+ 'REMOTE DELIVERY: assume the people are remote unless they explicitly say otherwise. Run host-side work yourself; never hand them a local path, file:// or localhost URL, or ask them to use the host terminal. Surface reachable links and inline evidence in the room.',
88
+ 'VISUAL DELIVERY: build first and verify exact desktop and mobile renders. If a source-backed Thinkpool Design card/popup is displayed, do not separately surface its PNGs; if no interactive Design artifact is available, surface the desktop/mobile PNG evidence inline. Stop bridge previews when finished.',
89
+ THINKPOOL_DESIGN_INTERACTION_RULE,
90
+ 'VERIFY BEFORE CLAIMING: run or serve what changed and show the real response, passing output, or rendered evidence; state exactly what remains unverified.',
91
+ ].join(' ')
92
+
93
+ // One authoritative terminal-identity preamble for every structured runtime.
19
94
  // The bridge derives this from durable structural metadata — never from the text
20
95
  // of the task handed to the model. That distinction matters because a spawned
21
96
  // lane can be asked to "act as conductor", but it is still a sub-terminal owned