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.
- package/bridge.mjs +20 -1
- package/claude-session.mjs +11 -5
- package/code-event-contract.mjs +10 -0
- package/codex-event-mapper.mjs +4 -1
- package/command-catalog.mjs +1 -0
- package/context-contract.mjs +95 -0
- package/design-edit.mjs +116 -8
- package/design-source-contract.mjs +4 -0
- package/error-recovery.mjs +50 -0
- package/event-bounds.mjs +4 -0
- package/evidence-citations.mjs +56 -0
- package/flow-preview.mjs +4 -0
- package/git-diff-report.mjs +5 -2
- package/hermes-event-mapper.mjs +8 -1
- package/lane-continuation.mjs +83 -0
- package/lane-lifecycle.mjs +7 -1
- package/package.json +9 -1
- package/provider-resilience.mjs +213 -0
- package/recap.mjs +13 -5
- package/repo-search.mjs +190 -0
- package/runtime-contract.mjs +93 -0
- package/runtime-registry.mjs +6 -0
- package/runtime-session.mjs +5 -0
- package/thinkpool-capabilities.json +5 -5
- package/thinkpool-room-prompt.mjs +17 -1
- package/viewport.mjs +18 -0
package/repo-search.mjs
ADDED
|
@@ -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
|
+
}
|
package/runtime-registry.mjs
CHANGED
|
@@ -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
|
)
|
package/runtime-session.mjs
CHANGED
|
@@ -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":
|
|
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":
|
|
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":
|
|
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 =
|
|
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`)
|