thinkpool-pair 0.7.353 → 0.7.356

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.
@@ -0,0 +1,190 @@
1
+ import { execFileSync } from 'node:child_process'
2
+
3
+ const MAX_QUERY_CHARS = 160
4
+ const DEFAULT_MAX_RESULTS = 8
5
+ const MAX_FILES_BUFFER = 16 * 1024 * 1024
6
+
7
+ const STOP_WORDS = new Set([
8
+ 'a', 'an', 'and', 'every', 'file', 'files', 'find', 'for', 'from', 'in',
9
+ 'inside', 'is', 'locate', 'me', 'of', 'please', 'show', 'the', 'to', 'under',
10
+ 'where', 'with',
11
+ ])
12
+
13
+ const EXPANSIONS = Object.freeze({
14
+ api: ['endpoint', 'route', 'server'],
15
+ auth: ['login', 'session', 'oauth'],
16
+ component: ['card', 'panel', 'view', 'widget'],
17
+ config: ['configuration', 'settings'],
18
+ database: ['db', 'sql', 'schema'],
19
+ image: ['asset', 'gallery', 'media', 'photo', 'picture', 'thumbnail'],
20
+ library: ['catalog', 'collection', 'index', 'lib'],
21
+ login: ['auth', 'oauth', 'session'],
22
+ session: ['auth', 'login'],
23
+ style: ['css', 'theme'],
24
+ styles: ['css', 'theme'],
25
+ test: ['spec'],
26
+ tests: ['spec'],
27
+ })
28
+
29
+ const cleanQuery = (value) => String(value || '')
30
+ // Deliberately strip ASCII control characters from room-supplied queries.
31
+ // eslint-disable-next-line no-control-regex
32
+ .replace(/[\u0000-\u001f\u007f]/g, ' ')
33
+ .replace(/\s+/g, ' ')
34
+ .trim()
35
+
36
+ const unique = (values) => [...new Set(values.filter(Boolean))]
37
+ const escapeRegex = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
38
+ const normalizePath = (value) => String(value || '').replace(/\\/g, '/').replace(/^\.\//, '')
39
+ const wordish = (value) => String(value || '').toLowerCase().replace(/[^\p{L}\p{N}]+/gu, ' ').trim()
40
+ const pathContainsSegment = (path, segment) => `/${normalizePath(path).toLowerCase()}/`.includes(`/${segment.toLowerCase().replace(/^\/+|\/+$/g, '')}/`)
41
+
42
+ function queryTokens(query) {
43
+ return unique((query.toLowerCase().match(/[\p{L}\p{N}][\p{L}\p{N}_-]*/gu) || [])
44
+ .map((token) => token.replace(/^[-_]+|[-_]+$/g, ''))
45
+ .filter((token) => token && !STOP_WORDS.has(token)))
46
+ }
47
+
48
+ function locationHints(query) {
49
+ return unique([...query.toLowerCase().matchAll(/\b(?:from|in|inside|under)\s+([\p{L}\p{N}_.\/-]+)/gu)]
50
+ .map((match) => match[1].replace(/^\/+|\/+$/g, ''))
51
+ .filter(Boolean))
52
+ }
53
+
54
+ function defaultRun(command, args, cwd) {
55
+ return execFileSync(command, args, {
56
+ cwd,
57
+ encoding: 'utf8',
58
+ timeout: command === 'rg' ? 1400 : 3000,
59
+ maxBuffer: MAX_FILES_BUFFER,
60
+ stdio: ['ignore', 'pipe', 'pipe'],
61
+ })
62
+ }
63
+
64
+ function listedFiles(run, cwd) {
65
+ const raw = String(run('git', ['ls-files', '-z', '--cached', '--others', '--exclude-standard'], cwd) || '')
66
+ return unique(raw.split('\0').map(normalizePath).filter(Boolean))
67
+ }
68
+
69
+ function contentMatches(run, cwd, terms, knownFiles) {
70
+ const matches = new Map()
71
+ if (!terms.length) return matches
72
+ const pattern = terms.slice().sort((a, b) => b.length - a.length).map(escapeRegex).join('|')
73
+ let raw = ''
74
+ try {
75
+ raw = String(run('rg', [
76
+ '--json',
77
+ '--ignore-case',
78
+ '--max-count', '4',
79
+ '--no-messages',
80
+ '--regexp', pattern,
81
+ '.',
82
+ ], cwd) || '')
83
+ } catch {
84
+ // `rg` is the fast path. A host without it still gets filename/path search;
85
+ // repository search must never block the room or fall through to an LLM turn.
86
+ return matches
87
+ }
88
+ for (const line of raw.split(/\r?\n/)) {
89
+ if (!line) continue
90
+ let record
91
+ try { record = JSON.parse(line) } catch { continue }
92
+ if (record?.type !== 'match') continue
93
+ const path = normalizePath(record.data?.path?.text)
94
+ if (!knownFiles.has(path)) continue
95
+ const lineNumber = Number(record.data?.line_number)
96
+ const text = String(record.data?.lines?.text || '').toLowerCase()
97
+ const prior = matches.get(path)
98
+ if (!prior) matches.set(path, { line: Number.isFinite(lineNumber) ? lineNumber : null, text })
99
+ else prior.text += ` ${text}`
100
+ }
101
+ return matches
102
+ }
103
+
104
+ function candidateScore(path, original, expanded, content) {
105
+ const lowerPath = path.toLowerCase()
106
+ const base = lowerPath.split('/').at(-1) || lowerPath
107
+ const pathWords = ` ${wordish(lowerPath)} `
108
+ const contentText = content?.text || ''
109
+ let score = 0
110
+
111
+ const phrase = original.join(' ')
112
+ if (phrase && pathWords.includes(` ${phrase} `)) score += 100
113
+ for (const token of original) {
114
+ if (base.includes(token)) score += 30
115
+ if (lowerPath.includes(token)) score += 16
116
+ if (pathWords.includes(` ${token} `)) score += 8
117
+ if (contentText.includes(token)) score += 8
118
+ }
119
+ for (const token of expanded) {
120
+ if (base.includes(token)) score += 8
121
+ else if (lowerPath.includes(token)) score += 5
122
+ if (contentText.includes(token)) score += 2
123
+ }
124
+
125
+ const rootMatches = (haystack) => original.filter((token) =>
126
+ haystack.includes(token) || (EXPANSIONS[token] || []).some((alias) => haystack.includes(alias)))
127
+ const pathCoverage = rootMatches(lowerPath).length
128
+ const coverage = rootMatches(`${lowerPath} ${contentText}`).length
129
+ score += (coverage ** 2) * 18 + (pathCoverage ** 2) * 22
130
+
131
+ const ext = base.includes('.') ? base.slice(base.lastIndexOf('.')) : ''
132
+ const componentFile = ['.jsx', '.tsx', '.vue', '.svelte'].includes(ext)
133
+ if (original.includes('component') && componentFile) score += 60
134
+ if (original.some((token) => token === 'test' || token === 'tests') && /(?:^|\/)(?:test|tests|spec|specs)(?:\/|$)|\.(?:test|spec)\./.test(lowerPath)) score += 10
135
+ if (original.some((token) => token === 'asset' || token === 'assets') && /(?:^|\/)assets?(?:\/|$)/.test(lowerPath)) score += 10
136
+ const testPath = /(?:^|\/)(?:test|tests|spec|specs)(?:\/|$)|\.(?:test|spec)\./.test(lowerPath)
137
+ if (testPath && !original.some((token) => token === 'test' || token === 'tests')) score -= 25
138
+ if (/^(?:docs|marketing|archive|\.claude)\//.test(lowerPath)) score -= 35
139
+ if (/^(?:src|app|lib|api|bridge|server|client|components|packages)\//.test(lowerPath)) score += 12
140
+ return { score, coverage, pathCoverage, componentFile }
141
+ }
142
+
143
+ export function repoSearch({
144
+ cwd = process.cwd(),
145
+ query,
146
+ maxResults = DEFAULT_MAX_RESULTS,
147
+ run = defaultRun,
148
+ now = () => Date.now(),
149
+ } = {}) {
150
+ const startedAt = now()
151
+ const cleaned = cleanQuery(query)
152
+ const finish = (result) => ({ ...result, durationMs: Math.max(0, Math.round(now() - startedAt)) })
153
+
154
+ if (!cleaned) return finish({ query: '', results: [], total: 0, truncated: false, error: 'Use /find followed by a filename, symbol, or code concept.' })
155
+ if (cleaned.length > MAX_QUERY_CHARS) return finish({ query: cleaned.slice(0, MAX_QUERY_CHARS), results: [], total: 0, truncated: false, error: `Keep /find queries under ${MAX_QUERY_CHARS} characters.` })
156
+
157
+ const original = queryTokens(cleaned)
158
+ if (!original.length) return finish({ query: cleaned, results: [], total: 0, truncated: false, error: 'Try a filename, symbol, or code concept.' })
159
+ const expanded = unique(original.flatMap((token) => EXPANSIONS[token] || []))
160
+
161
+ let files
162
+ try { files = listedFiles(run, cwd) } catch {
163
+ return finish({ query: cleaned, results: [], total: 0, truncated: false, error: 'Repository search is unavailable outside a readable Git checkout.' })
164
+ }
165
+ const activeLocations = locationHints(cleaned).filter((hint) => files.some((path) => pathContainsSegment(path, hint)))
166
+ const eligibleFiles = activeLocations.length
167
+ ? files.filter((path) => activeLocations.every((hint) => pathContainsSegment(path, hint)))
168
+ : files
169
+ const knownFiles = new Set(eligibleFiles)
170
+ const matches = contentMatches(run, cwd, unique([...original, ...expanded]), knownFiles)
171
+ const minimumCoverage = Math.max(1, Math.ceil(original.length * 0.6))
172
+ const ranked = eligibleFiles
173
+ .map((path) => {
174
+ const rank = candidateScore(path, original, expanded, matches.get(path))
175
+ return { path, line: matches.get(path)?.line || null, ...rank }
176
+ })
177
+ .filter((item) => item.score > 0 && (
178
+ item.coverage >= minimumCoverage
179
+ || (original.includes('component') && item.componentFile && item.coverage >= 2)
180
+ ))
181
+ .sort((a, b) => b.score - a.score || b.pathCoverage - a.pathCoverage || a.path.length - b.path.length || a.path.localeCompare(b.path))
182
+
183
+ const limit = Math.max(1, Math.min(20, Number(maxResults) || DEFAULT_MAX_RESULTS))
184
+ return finish({
185
+ query: cleaned,
186
+ results: ranked.slice(0, limit).map(({ path, line }) => ({ path, ...(line ? { line } : {}) })),
187
+ total: ranked.length,
188
+ truncated: ranked.length > limit,
189
+ })
190
+ }
@@ -0,0 +1,93 @@
1
+ // Bridge-owned semantic capabilities. Native runtimes keep their own tool
2
+ // transport; this module only admits the stable room-facing meaning.
3
+
4
+ const RUNTIMES = Object.freeze(['claude', 'codex', 'hermes'])
5
+ const SUPPORT = Object.freeze({ claude: 'native', codex: 'native', hermes: 'native' })
6
+ const SAFE_ID = /^[A-Za-z0-9][A-Za-z0-9._:@-]{0,159}$/
7
+ const HOST_PATH = /(?:\/home\/|\/users\/|\/private\/|\/tmp\/|[a-z]:\\|\\\\|(?:^|[\\/])\.\.(?:[\\/]|$))/i
8
+ const SECRET_KEY = /(secret|token|password|authorization|api.?key|private.?key)/i
9
+ const SECRET_VALUE = /(?:sk-[a-z0-9_-]{8,}|gsk_[a-z0-9_-]{8,}|AIza[a-z0-9_-]{8,}|gh[pousr]_[a-z0-9]{20,}|github_pat_[a-z0-9_]{20,}|xox[baprs]-[a-z0-9-]{10,}|AKIA[0-9A-Z]{16}|sbp_[a-z0-9]{20,}|eyJ[a-z0-9_-]{16,}\.[a-z0-9_-]{16,}\.[a-z0-9_-]{8,}|bearer\s+[a-z0-9._-]{8,})/i
10
+ const PROHIBITED_PROSE = /(?:raw transcript|tool args?|chain[ -]of[ -]thought|hidden reasoning|system prompt|environment dump|provider key|BEGIN (?:RSA |OPENSSH )?PRIVATE KEY)/i
11
+
12
+ const schema = (required = [], properties = {}) => Object.freeze({
13
+ type: 'object', additionalProperties: false, required: Object.freeze(required), properties: Object.freeze(properties),
14
+ })
15
+ const text = (maxLength = 500) => Object.freeze({ type: 'string', minLength: 1, maxLength })
16
+ const optionalText = (maxLength = 500) => Object.freeze({ type: 'string', maxLength })
17
+ const bool = Object.freeze({ type: 'boolean' })
18
+ const integer = (minimum, maximum) => Object.freeze({ type: 'integer', minimum, maximum })
19
+ const id = Object.freeze({ type: 'string', minLength: 1, maxLength: 160, pattern: '^[A-Za-z0-9][A-Za-z0-9._:@-]*$' })
20
+ const option = schema(['label', 'description'], { label: text(80), description: text(240) })
21
+ const question = schema(['id', 'header', 'question', 'options'], {
22
+ id: Object.freeze({ type: 'string', minLength: 1, maxLength: 80, pattern: '^[A-Za-z0-9][A-Za-z0-9_-]*$' }),
23
+ header: text(12), question: text(500), options: Object.freeze({ type: 'array', minItems: 2, maxItems: 3, items: option }), multiSelect: bool,
24
+ })
25
+ const evidenceRef = schema(['type', 'id'], { type: id, id })
26
+
27
+ export const RUNTIME_CAPABILITY_VERSION = 1
28
+ export const RUNTIME_CAPABILITIES = Object.freeze([
29
+ Object.freeze({ id: 'request_user_input', version: 1, runtimeSupport: SUPPORT, inputSchema: schema(['questions'], { questions: Object.freeze({ type: 'array', minItems: 1, maxItems: 3, items: question }), autoResolutionMs: integer(60_000, 240_000) }), output: 'accepted | deferred | rejected | failed', authority: 'pair-control', persistence: 'durable-safe-ref', continuation: 'resumable', secretFreeRoomPayload: true }),
30
+ Object.freeze({ id: 'request_permission', version: 1, runtimeSupport: SUPPORT, inputSchema: schema(['action'], { action: text(160), reason: optionalText(280) }), output: 'accepted | deferred | rejected | failed', authority: 'pair-control', persistence: 'durable-safe-ref', continuation: 'resumable', secretFreeRoomPayload: true }),
31
+ Object.freeze({ id: 'submit_flow_plan', version: 1, runtimeSupport: SUPPORT, inputSchema: schema(['plan'], { plan: text(32_768) }), output: 'accepted | rejected | failed', authority: 'pair-control', persistence: 'durable-safe-ref', continuation: 'resumable', secretFreeRoomPayload: true }),
32
+ Object.freeze({ id: 'mark_flow_done', version: 1, runtimeSupport: SUPPORT, inputSchema: schema([], { evidenceRef }), output: 'accepted | rejected | failed', authority: 'bridge', persistence: 'durable-safe-ref', continuation: 'none', secretFreeRoomPayload: true }),
33
+ Object.freeze({ id: 'submit_flow_review', version: 1, runtimeSupport: SUPPORT, inputSchema: schema(['verdict'], { verdict: text(16_384) }), output: 'accepted | rejected | failed', authority: 'bridge', persistence: 'durable-safe-ref', continuation: 'none', secretFreeRoomPayload: true }),
34
+ Object.freeze({ id: 'terminal_interrupt', version: 1, runtimeSupport: SUPPORT, inputSchema: schema([], { reason: optionalText(280) }), output: 'accepted | rejected | failed', authority: 'bridge', persistence: 'ephemeral', continuation: 'none', secretFreeRoomPayload: true }),
35
+ Object.freeze({ id: 'lane_continuation', version: 1, runtimeSupport: SUPPORT, inputSchema: schema(['state'], { state: Object.freeze({ type: 'string', enum: Object.freeze(['waiting_for_human', 'resumable', 'canceled', 'terminal']) }), resumeKind: Object.freeze({ type: 'string', enum: Object.freeze(['human_response', 'approval', 'redispatch', 'reconnect']) }) }), output: 'accepted | deferred | rejected | failed', authority: 'bridge', persistence: 'durable-safe-ref', continuation: 'resumable', secretFreeRoomPayload: true }),
36
+ ])
37
+
38
+ const CAPABILITIES = new Map(RUNTIME_CAPABILITIES.map((capability) => [capability.id, capability]))
39
+ export const runtimeCapability = (id) => CAPABILITIES.get(id) || null
40
+ export const runtimeSupportsCapability = (runtime, capabilityId) => RUNTIMES.includes(runtime) && runtimeCapability(capabilityId)?.runtimeSupport?.[runtime] === 'native'
41
+
42
+ const jsonBytes = (value) => {
43
+ try { return new TextEncoder().encode(JSON.stringify(value)).length } catch { return Infinity }
44
+ }
45
+
46
+ // This deliberately mirrors the pair-control redaction boundary without pulling
47
+ // browser source into the published bridge package.
48
+ export function isSecretFreeRoomPayload (value, maxBytes = 8192) {
49
+ if (value == null || jsonBytes(value) > maxBytes) return false
50
+ const visit = (node) => {
51
+ if (typeof node === 'string') return node.length <= 2048 && !HOST_PATH.test(node) && !SECRET_VALUE.test(node) && !PROHIBITED_PROSE.test(node)
52
+ if (node == null || typeof node === 'number' || typeof node === 'boolean') return true
53
+ if (Array.isArray(node)) return node.length <= 64 && node.every(visit)
54
+ if (typeof node !== 'object') return false
55
+ return Object.entries(node).every(([key, child]) => !SECRET_KEY.test(key) && visit(child))
56
+ }
57
+ return visit(value)
58
+ }
59
+
60
+ function validValue (value, definition) {
61
+ if (!definition) return true
62
+ if (definition.type === 'object') {
63
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return false
64
+ if (definition.additionalProperties === false && Object.keys(value).some((key) => !Object.hasOwn(definition.properties || {}, key))) return false
65
+ if ((definition.required || []).some((key) => !Object.hasOwn(value, key))) return false
66
+ return Object.entries(definition.properties || {}).every(([key, child]) => !Object.hasOwn(value, key) || validValue(value[key], child))
67
+ }
68
+ if (definition.type === 'array') return Array.isArray(value) && value.length >= (definition.minItems || 0) && value.length <= (definition.maxItems ?? Infinity) && (!definition.items || value.every((item) => validValue(item, definition.items)))
69
+ if (definition.type === 'string') return typeof value === 'string' && value.length >= (definition.minLength || 0) && value.length <= (definition.maxLength ?? Infinity) && (!definition.enum || definition.enum.includes(value)) && (!definition.pattern || new RegExp(definition.pattern).test(value))
70
+ if (definition.type === 'integer') return Number.isSafeInteger(value) && value >= (definition.minimum ?? -Infinity) && value <= (definition.maximum ?? Infinity)
71
+ if (definition.type === 'boolean') return typeof value === 'boolean'
72
+ return false
73
+ }
74
+
75
+ export function validateRuntimeCapabilityInput (capabilityId, input) {
76
+ const capability = runtimeCapability(capabilityId)
77
+ return !!capability && validValue(input, capability.inputSchema)
78
+ }
79
+
80
+ export function admitRuntimeCapability ({ runtime, capabilityId, input = {}, roomPayload = null } = {}) {
81
+ const capability = runtimeCapability(capabilityId)
82
+ if (!capability) return Object.freeze({ ok: false, code: 'unregistered_capability' })
83
+ if (!RUNTIMES.includes(runtime) || capability.runtimeSupport[runtime] !== 'native') return Object.freeze({ ok: false, code: 'runtime_capability_mismatch' })
84
+ if (!validateRuntimeCapabilityInput(capabilityId, input)) return Object.freeze({ ok: false, code: 'invalid_capability_input' })
85
+ if (capability.secretFreeRoomPayload && roomPayload != null && !isSecretFreeRoomPayload(roomPayload)) return Object.freeze({ ok: false, code: 'unsafe_room_payload' })
86
+ return Object.freeze({ ok: true, code: 'admitted', capability })
87
+ }
88
+
89
+ export function assertRuntimeCapability (request) {
90
+ const admitted = admitRuntimeCapability(request)
91
+ if (!admitted.ok) throw new TypeError(`Runtime capability rejected: ${admitted.code}`)
92
+ return admitted.capability
93
+ }
@@ -1,4 +1,5 @@
1
1
  import path from 'node:path'
2
+ import { runtimeCapability, runtimeSupportsCapability } from './runtime-contract.mjs'
2
3
 
3
4
  const RUNTIMES = Object.freeze({
4
5
  claude: Object.freeze({
@@ -28,6 +29,11 @@ export const structuredRuntimeForCommand = (command) => {
28
29
  export const defaultStructuredMode = (runtime) => structuredRuntimeMetadata(runtime)?.defaultMode || 'default'
29
30
  export const structuredRuntimeSupportsMode = (runtime, mode) => structuredRuntimeMetadata(runtime)?.modes?.includes(mode) === true
30
31
  export const structuredRuntimeSupportsFlow = (runtime) => structuredRuntimeMetadata(runtime)?.flow === true
32
+ // This is deliberately semantic rather than a list of native tool names. The
33
+ // bridge may expose an MCP tool, an SDK hook, or ACP registration underneath.
34
+ export const structuredRuntimeCapability = (runtime, capabilityId) => (
35
+ runtimeSupportsCapability(runtime, capabilityId) ? runtimeCapability(capabilityId) : null
36
+ )
31
37
  export const structuredModeLocked = ({ flowRole, sliceType } = {}) => (
32
38
  flowRole === 'conductor' || flowRole === 'reviewer' || sliceType === 'review'
33
39
  )
@@ -1,6 +1,7 @@
1
1
  import { startClaudeSession } from './claude-session.mjs'
2
2
  import { startCodexSession } from './codex-session.mjs'
3
3
  import { startHermesSession } from './hermes-session.mjs'
4
+ import { assertRuntimeCapability } from './runtime-contract.mjs'
4
5
 
5
6
  const FACTORIES = Object.freeze({
6
7
  claude: startClaudeSession,
@@ -11,5 +12,9 @@ const FACTORIES = Object.freeze({
11
12
  export function startStructuredSession(runtime, options) {
12
13
  const factory = FACTORIES[runtime]
13
14
  if (!factory) throw new Error(`Unsupported structured runtime: ${runtime}`)
15
+ // Every live adapter has a native interrupt boundary. Assert the semantic
16
+ // contract here so an accidental registry/runtime divergence fails at launch,
17
+ // rather than silently accepting a provider-specific fallback later.
18
+ assertRuntimeCapability({ runtime, capabilityId: 'terminal_interrupt', input: {} })
14
19
  return factory(options)
15
20
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "bundleVersion": 17,
3
+ "bundleVersion": 18,
4
4
  "contracts": [
5
5
  {
6
6
  "id": "room-coordination",
@@ -88,13 +88,13 @@
88
88
  },
89
89
  {
90
90
  "id": "visual-proof",
91
- "version": 4,
91
+ "version": 5,
92
92
  "routes": [
93
93
  {
94
94
  "id": "visual-proof",
95
95
  "tools": ["preview_start", "preview_capture", "preview_inspect", "preview_stop"],
96
96
  "trigger": "\\b(ui|ux|visual|design|frontend|html|css|page|route|mockup|screenshot|responsive|desktop|mobile|preview)\\b",
97
- "prompt": "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. preview_capture uses a fresh isolated browser and is inline verification evidence by default; it must not create a transcript card. Set card=true only when the person asked for a mockup/Design artifact or the visual itself is an intentional deliverable that should remain editable in the transcript. A card requires settled meaningful content at both desktop and mobile, rejects loading/public-auth fallback shells, and is delivered after the final agent response. Room and protected routes automatically use the bridge account as a read-only authenticated visual harness; external REST writes, presence, ordinary room broadcasts, and refresh-token access are blocked, while non-mutating roster/transcript snapshot requests are allowed. Surface PNG evidence only when no interactive source-backed Design card is displayed."
97
+ "prompt": "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. For an intentional application-preview Design card, use the project's source-aware Design build command when one exists (for example npm run build:design); otherwise build normally and keep selector-based source matching labeled as fallback. preview_capture uses a fresh isolated browser and is inline verification evidence by default; it must not create a transcript card. Set card=true only when the person asked for a mockup/Design artifact or the visual itself is an intentional deliverable that should remain editable in the transcript. A card requires settled meaningful content at both desktop and mobile, rejects loading/public-auth fallback shells, and is delivered after the final agent response. Room and protected routes automatically use the bridge account as a read-only authenticated visual harness; external REST writes, presence, ordinary room broadcasts, and refresh-token access are blocked, while non-mutating roster/transcript snapshot requests are allowed. Surface PNG evidence only when no interactive source-backed Design card is displayed."
98
98
  }
99
99
  ],
100
100
  "impact": [
@@ -188,8 +188,8 @@
188
188
  },
189
189
  {
190
190
  "id": "design-workspace",
191
- "version": 6,
192
- "interactionPrompt": "DESIGN EDITING MODEL: every complete bridge preview_capture card and every trusted source-backed mockup card offers Edit in Design. The bridge freezes a safe rendered-DOM snapshot for application previews; Design edits are then applied by the producing lane to the real application source and verified by a fresh correlated desktop+mobile capture. For authored HTML, the producing lane edits the canonical authored HTML source directly. Edit in Design—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 beneath the producing terminal in its Ensemble row for both partners; it creates no terminal, agent runtime, or worker slot. The virtual lane can be closed from that Ensemble row without deleting the artifact. 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; queued edits can be reopened, revised, or removed before Apply. Apply changes sends the ordered batch once to the producing lane, and a successful correlated desktop+mobile render advances the verified revision for the room. Never call a staged draft saved or live. When a person asks for many mockups or options that can share a surface, prefer one source-backed multi-option board or gallery in one file and one card so they can compare together; create separate files/cards only when the person explicitly requests independently editable artifacts or the options cannot be represented faithfully together.",
191
+ "version": 7,
192
+ "interactionPrompt": "DESIGN EDITING MODEL: every complete bridge preview_capture card and every trusted source-backed mockup card offers Edit in Design. The bridge freezes a safe rendered-DOM snapshot for application previews; Design edits are then applied by the producing lane to the real application source and verified by a fresh correlated desktop+mobile capture. For source-aware application previews, the bridge validates each opaque source identity against its contained host-only map and the current source-file hash before sharing an exact source location privately with the producing lane; uninstrumented elements keep the selector-based fallback, while stale or mismatched identities fail closed. For authored HTML, the producing lane edits the canonical authored HTML source directly. Edit in Design—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 beneath the producing terminal in its Ensemble row for both partners; it creates no terminal, agent runtime, or worker slot. The virtual lane can be closed from that Ensemble row without deleting the artifact. 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; queued edits can be reopened, revised, or removed before Apply. Apply changes sends the ordered batch once to the producing lane, and a successful correlated desktop+mobile render advances the verified revision for the room. Never call a staged draft saved or live. When a person asks for many mockups or options that can share a surface, prefer one source-backed multi-option board or gallery in one file and one card so they can compare together; create separate files/cards only when the person explicitly requests independently editable artifacts or the options cannot be represented faithfully together.",
193
193
  "turnReminder": "DESIGN ROUTE: intentional application-preview deliverables use preview_capture with card=true; correlated Design recaptures are recognized automatically. Authored HTML uses the source-backed render helper. Both must produce editable Thinkpool Design cards with verified desktop and mobile renders. Ordinary verification captures stay inline evidence and must not create cards. Cards are delivered after the final agent response so they remain the newest transcript item. Edit in Design explicitly arms the persistent Design workspace and adds its virtual Design · Page beneath the producing terminal in the Ensemble row; Preview alone does not arm it. For large direction sets, consolidate compatible options into one source-backed comparison board/gallery and one card; use separate files/cards only when independent editing is explicitly requested or technically necessary. Do not duplicate the card renders as inline PNGs. Use inline PNG evidence only when no interactive Design artifact is available.",
194
194
  "impact": [
195
195
  {"path": "src/pages/code/design/"},
@@ -9,6 +9,7 @@
9
9
  // tells a fresh agent WHEN ThinkPool expects each capability without requiring
10
10
  // the people in the room to know a tool name or summon word.
11
11
  import { THINKPOOL_CAPABILITY_ROUTES, THINKPOOL_PROMPT_BUNDLE, thinkPoolCapabilityContract } from './thinkpool-prompt-contracts.mjs'
12
+ import { buildContextManifest, formatRoomContextProjection } from './context-contract.mjs'
12
13
 
13
14
  export { THINKPOOL_CAPABILITY_ROUTES, THINKPOOL_PROMPT_BUNDLE }
14
15
 
@@ -113,7 +114,7 @@ export function createRoomContextSelector(roomContext) {
113
114
  return ({ force = false } = {}) => {
114
115
  let context = null
115
116
  try { context = typeof roomContext === 'function' ? roomContext() : roomContext } catch { context = null }
116
- const value = String(context || '').trim()
117
+ const value = formatRoomContextProjection(context)
117
118
  if (!value) return ''
118
119
  const fingerprint = roomContextFingerprint(value)
119
120
  const changed = fingerprint !== previousFingerprint
@@ -122,6 +123,21 @@ export function createRoomContextSelector(roomContext) {
122
123
  }
123
124
  }
124
125
 
126
+ // A caller can inspect the deterministic selection without retaining its raw
127
+ // text. The manifest contains only allow-listed source kinds and safe refs.
128
+ export function buildThinkPoolContextManifest({ currentUserTurn = '', roomNow = '', flowSliceDigest = null, approvedHumanResponse = null } = {}) {
129
+ const roomProjection = formatRoomContextProjection(roomNow)
130
+ return buildContextManifest({
131
+ promptBundle: THINKPOOL_PROMPT_BUNDLE,
132
+ sources: [
133
+ { kind: 'current_user_turn', value: currentUserTurn },
134
+ { kind: 'room_now_delta', value: roomProjection },
135
+ ...(flowSliceDigest ? [{ kind: 'flow_slice_digest', value: flowSliceDigest, ref: { type: 'flow_digest', id: 'local' } }] : []),
136
+ ...(approvedHumanResponse ? [{ kind: 'approved_human_response', value: approvedHumanResponse, ref: { type: 'control_item', id: 'approved' } }] : []),
137
+ ],
138
+ })
139
+ }
140
+
125
141
  // One authoritative terminal-identity preamble for every structured runtime.
126
142
  // The bridge derives this from durable structural metadata — never from the text
127
143
  // of the task handed to the model. That distinction matters because a spawned
package/viewport.mjs CHANGED
@@ -17,6 +17,10 @@ import os from 'node:os'
17
17
  import path from 'node:path'
18
18
  import { randomUUID } from 'node:crypto'
19
19
  import { previews, startPreview } from './flow-preview.mjs'
20
+ import {
21
+ DESIGN_SOURCE_MAP_RELATIVE,
22
+ MAX_DESIGN_SOURCE_MAP_BYTES,
23
+ } from './design-source-contract.mjs'
20
24
 
21
25
  export const DEFAULT_VIEWPORTS = Object.freeze({
22
26
  desktop: Object.freeze({ width: 1440, height: 900 }),
@@ -51,6 +55,18 @@ export async function resolveContainedRoot(workspaceRoot, requested = 'dist') {
51
55
  return resolved
52
56
  }
53
57
 
58
+ export async function resolvePreviewSourceMap(previewRoot) {
59
+ if (!previewRoot) return null
60
+ let root, sourceMap
61
+ try {
62
+ root = await fsp.realpath(previewRoot)
63
+ sourceMap = await fsp.realpath(path.join(root, DESIGN_SOURCE_MAP_RELATIVE))
64
+ const stat = await fsp.stat(sourceMap)
65
+ if (!stat.isFile() || stat.size <= 0 || stat.size > MAX_DESIGN_SOURCE_MAP_BYTES) return null
66
+ } catch { return null }
67
+ return isInside(root, sourceMap) ? sourceMap : null
68
+ }
69
+
54
70
  export function normalizeRoute(value = '/') {
55
71
  const route = String(value || '/').trim()
56
72
  if (!route.startsWith('/') || route.startsWith('//')) throw new Error('Preview path must start with one "/" and cannot be a URL.')
@@ -674,11 +690,13 @@ export class ViewportManager {
674
690
  if (snapshotPath) await fsp.writeFile(snapshotPath, snapshot)
675
691
  let manifestPath = null
676
692
  if (cardReady) {
693
+ const sourceMap = await resolvePreviewSourceMap(this.root)
677
694
  const manifest = {
678
695
  slug, title: String(title || 'Viewport capture').slice(0, 120),
679
696
  desktop: captures.desktop.file, mobile: captures.mobile.file, ts: Date.now(),
680
697
  sourceKind: 'preview', deliveryIntent: 'card', readinessValidated: true,
681
698
  snapshot: snapshotPath, previewRoot, route: normalizedRoute, captureKey,
699
+ ...(sourceMap ? { sourceMap } : {}),
682
700
  ...correlation,
683
701
  }
684
702
  manifestPath = path.join(this.outbox, `${slug}.json`)