thinkpool-pair 0.7.354 → 0.7.357

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/design-edit.mjs CHANGED
@@ -2,13 +2,24 @@ import crypto from 'node:crypto'
2
2
  import fs from 'node:fs'
3
3
  import os from 'node:os'
4
4
  import path from 'node:path'
5
+ import {
6
+ DESIGN_SOURCE_MAP_RELATIVE,
7
+ DESIGN_SOURCE_MAP_VERSION,
8
+ MAX_DESIGN_SOURCE_ENTRIES,
9
+ MAX_DESIGN_SOURCE_MAP_BYTES,
10
+ } from './design-source-contract.mjs'
5
11
 
6
12
  const MAX_SOURCE_BYTES = 2 * 1024 * 1024
7
13
  const MAX_INTENT = 2_000
8
14
  const MAX_TEXT = 4_000
9
15
  const MAX_BATCH_EDITS = 8
16
+ const GENERATED_SOURCE_PARTS = new Set(['dist', 'build', '.next', '.git', 'coverage', 'node_modules'])
10
17
 
11
18
  const clean = (value, max) => String(value ?? '').replace(/\0/g, '').trim().slice(0, max)
19
+ const isInside = (parent, child) => {
20
+ const rel = path.relative(parent, child)
21
+ return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel))
22
+ }
12
23
 
13
24
  // supabase-js does not copy the Auth session into an already-created Realtime
14
25
  // client. Private tpdesign:* joins therefore need the bridge owner's JWT pinned
@@ -28,6 +39,52 @@ export function sourceRevision(source) {
28
39
  return crypto.createHash('sha256').update(String(source)).digest('hex').slice(0, 24)
29
40
  }
30
41
 
42
+ export function resolveDesignSourceMap(file, previewRoot, workspaceRoot) {
43
+ if (!file || !previewRoot || !workspaceRoot) return null
44
+ let sourceMapPath, expectedSourceMapPath, builtRoot, root, raw, parsed
45
+ try {
46
+ sourceMapPath = fs.realpathSync(file)
47
+ builtRoot = fs.realpathSync(previewRoot)
48
+ expectedSourceMapPath = fs.realpathSync(path.join(builtRoot, DESIGN_SOURCE_MAP_RELATIVE))
49
+ root = fs.realpathSync(workspaceRoot)
50
+ const stat = fs.statSync(sourceMapPath)
51
+ if (!stat.isFile() || stat.size <= 0 || stat.size > MAX_DESIGN_SOURCE_MAP_BYTES) return null
52
+ if (sourceMapPath !== expectedSourceMapPath || !isInside(root, builtRoot) || !isInside(builtRoot, sourceMapPath)) return null
53
+ raw = fs.readFileSync(sourceMapPath, 'utf8')
54
+ parsed = JSON.parse(raw)
55
+ } catch { return null }
56
+ if (parsed?.version !== DESIGN_SOURCE_MAP_VERSION || !parsed.entries || typeof parsed.entries !== 'object' || Array.isArray(parsed.entries)) return null
57
+ const rows = Object.entries(parsed.entries)
58
+ if (rows.length > MAX_DESIGN_SOURCE_ENTRIES) return null
59
+
60
+ const files = new Map()
61
+ const entries = new Map()
62
+ for (const [sourceId, value] of rows) {
63
+ if (!/^[A-Za-z0-9_-]{8,80}$/.test(sourceId) || !value || typeof value !== 'object') return null
64
+ const relative = clean(value.file, 700)
65
+ if (!relative || path.isAbsolute(relative) || relative.split(/[\\/]/).some((part) => part === '..' || GENERATED_SOURCE_PARTS.has(part))) return null
66
+ let sourcePath = files.get(relative)
67
+ if (!sourcePath) {
68
+ try {
69
+ sourcePath = fs.realpathSync(path.resolve(root, relative))
70
+ const stat = fs.statSync(sourcePath)
71
+ if (!stat.isFile() || stat.size > MAX_SOURCE_BYTES || !isInside(root, sourcePath)) return null
72
+ } catch { return null }
73
+ files.set(relative, sourcePath)
74
+ }
75
+ const start = Number(value.start)
76
+ const end = Number(value.end)
77
+ const line = Number(value.line)
78
+ const column = Number(value.column)
79
+ const tag = clean(value.tag, 40).toLowerCase()
80
+ const fileHash = clean(value.fileHash, 64).toLowerCase()
81
+ if (![start, end, line, column].every(Number.isSafeInteger) || start < 0 || end <= start || line < 1 || column < 0) return null
82
+ if (!/^[a-z][a-z0-9:-]*$/.test(tag) || !/^[a-f0-9]{24}$/.test(fileHash)) return null
83
+ entries.set(sourceId, { sourceId, file: relative.split(path.sep).join('/'), sourcePath, start, end, line, column, tag, fileHash })
84
+ }
85
+ return { path: sourceMapPath, revision: sourceRevision(raw), buildId: clean(parsed.buildId, 80), entries }
86
+ }
87
+
31
88
  export function resolveDesignSource(file, workspaceRoot) {
32
89
  if (!file || !workspaceRoot) return null
33
90
  let sourcePath, root
@@ -67,11 +124,21 @@ export function resolvePreviewDesignSource(manifest, workspaceRoot, box) {
67
124
  const captureKey = clean(manifest.captureKey, 1000)
68
125
  if (!route.startsWith('/') || !captureKey || captureKey !== JSON.stringify([previewRoot, route])) return null
69
126
  const source = fs.readFileSync(sourcePath, 'utf8')
70
- const revision = sourceRevision(source)
127
+ let builtRoot = null
128
+ try {
129
+ builtRoot = fs.realpathSync(path.resolve(root, previewRoot))
130
+ if (!isInside(root, builtRoot)) return null
131
+ } catch { return null }
132
+ const sourceMap = manifest.sourceMap ? resolveDesignSourceMap(manifest.sourceMap, builtRoot, root) : null
133
+ const revision = sourceRevision(sourceMap ? `${source}\0${sourceMap.revision}` : source)
71
134
  const previewId = crypto.createHash('sha256')
72
135
  .update(`${root}\0${captureKey}\0${revision}`)
73
136
  .digest('base64url').slice(0, 32)
74
- return { sourceKind: 'preview', previewId, revision, sourcePath, workspaceRoot: root, source, outbox, route, previewRoot, captureKey }
137
+ return {
138
+ sourceKind: 'preview', previewId, revision, sourcePath, workspaceRoot: root, source, outbox,
139
+ route, previewRoot, captureKey, sourceMap, sourceMapPath: sourceMap?.path || null,
140
+ sourceMapDeclared: !!manifest.sourceMap,
141
+ }
75
142
  }
76
143
 
77
144
  // Older lane worktrees predate the explicit `source` manifest field and emit the
@@ -108,7 +175,7 @@ export function refreshDesignSource(record) {
108
175
  if (record.sourceKind !== 'preview') return resolveDesignSource(record.sourcePath, record.workspaceRoot)
109
176
  return resolvePreviewDesignSource({
110
177
  sourceKind: 'preview', snapshot: record.sourcePath, route: record.route,
111
- previewRoot: record.previewRoot, captureKey: record.captureKey,
178
+ previewRoot: record.previewRoot, captureKey: record.captureKey, sourceMap: record.sourceMapPath,
112
179
  }, record.workspaceRoot, record.outbox)
113
180
  }
114
181
 
@@ -168,10 +235,35 @@ function safeTarget(value) {
168
235
  canReplaceImage: value.canReplaceImage === true,
169
236
  nodeKey: clean(value.nodeKey, 80),
170
237
  parentNodeKey: clean(value.parentNodeKey, 80),
238
+ sourceId: clean(value.sourceId, 80),
239
+ parentSourceId: clean(value.parentSourceId, 80),
171
240
  rect,
172
241
  }
173
242
  }
174
243
 
244
+ export function resolveRequestSourceIdentity(record, target) {
245
+ if (!target?.sourceId) return { ok: true, identity: null }
246
+ const entry = record?.sourceMap?.entries?.get(target.sourceId)
247
+ if (!entry) return { ok: false, error: record?.sourceMapDeclared ? 'stale-source-identity' : 'source-identity-unavailable' }
248
+ if (target.tag && entry.tag !== target.tag) return { ok: false, error: 'source-identity-mismatch' }
249
+ let source
250
+ try { source = fs.readFileSync(entry.sourcePath, 'utf8') } catch { return { ok: false, error: 'stale-source-identity' } }
251
+ if (sourceRevision(source) !== entry.fileHash) return { ok: false, error: 'stale-source-identity' }
252
+ return {
253
+ ok: true,
254
+ identity: {
255
+ sourceId: entry.sourceId,
256
+ file: entry.file,
257
+ start: entry.start,
258
+ end: entry.end,
259
+ line: entry.line,
260
+ column: entry.column,
261
+ tag: entry.tag,
262
+ fileHash: entry.fileHash,
263
+ },
264
+ }
265
+ }
266
+
175
267
  function safeMovement(value) {
176
268
  if (!value || typeof value !== 'object') return null
177
269
  const fromIndex = Number(value.fromIndex)
@@ -211,7 +303,16 @@ export function validateDesignRequest(payload, registry, room = '') {
211
303
  if (mode === 'move' && (!movement || movement.fromIndex === movement.toIndex)) return { ok: false, error: 'invalid-move' }
212
304
  const asset = mode === 'image' ? safeAsset(payload.asset, room) : null
213
305
  if (mode === 'image' && (!target.canReplaceImage || !asset)) return { ok: false, error: 'invalid-image' }
214
- return { ok: true, record, request: { previewId, revision: record.revision, mode, intent, target, movement, asset, cid: clean(payload.cid, 80) } }
306
+ const source = resolveRequestSourceIdentity(record, target)
307
+ if (!source.ok) return source
308
+ return {
309
+ ok: true,
310
+ record,
311
+ request: {
312
+ previewId, revision: record.revision, mode, intent, target, movement, asset,
313
+ sourceIdentity: source.identity, cid: clean(payload.cid, 80),
314
+ },
315
+ }
215
316
  }
216
317
 
217
318
  export function validateDesignBatchRequest(payload, registry, room = '') {
@@ -226,7 +327,7 @@ export function validateDesignBatchRequest(payload, registry, room = '') {
226
327
  for (let index = 0; index < payload.edits.length; index++) {
227
328
  const verdict = validateDesignRequest({ ...payload.edits[index], previewId, revision, cid }, registry, room)
228
329
  if (!verdict.ok) return { ...verdict, editIndex: index }
229
- const targetKey = verdict.request.target.nodeKey || verdict.request.target.selector || verdict.request.target.xpath
330
+ const targetKey = verdict.request.target.sourceId || verdict.request.target.nodeKey || verdict.request.target.selector || verdict.request.target.xpath
230
331
  if (targets.has(targetKey)) return { ok: false, error: 'duplicate-target', editIndex: index }
231
332
  targets.add(targetKey)
232
333
  record = verdict.record
@@ -276,6 +377,13 @@ function designAction(request) {
276
377
  return `Apply this visual change to only the selected element unless the instruction explicitly requires its immediate context:\n${request.intent}`
277
378
  }
278
379
 
380
+ function selectedElementBlock(request) {
381
+ const identity = request.sourceIdentity
382
+ ? `\n\nBridge-verified application source:\n${JSON.stringify(request.sourceIdentity, null, 2)}`
383
+ : '\n\nApplication source identity: unavailable — selector/XPath fallback only.'
384
+ return `Selected element:\n${JSON.stringify(request.target, null, 2)}${identity}`
385
+ }
386
+
279
387
  const designModeLabel = (mode) => ({ text: 'Text', prompt: 'Prompt', move: 'Move', image: 'Image' })[mode] || 'Edit'
280
388
 
281
389
  function visibleDesignAction(request) {
@@ -307,10 +415,10 @@ export function designPrompt({ record, request, by, restore = false, priorRecord
307
415
  const work = restore
308
416
  ? `Restore only the immediately previous Design Mode change by comparing the current artifact with this bridge-created verified backup: ${priorRecord?.backupPath || '(backup unavailable)'}. Do not overwrite unrelated changes.`
309
417
  : batch
310
- ? batch.map((edit, index) => `Edit ${index + 1} of ${batch.length} · ${edit.mode}\nSelected element:\n${JSON.stringify(edit.target, null, 2)}\n\n${designAction(edit)}`).join('\n\n---\n\n')
311
- : `Selected element:\n${JSON.stringify(request.target, null, 2)}\n\n${designAction(request)}`
418
+ ? batch.map((edit, index) => `Edit ${index + 1} of ${batch.length} · ${edit.mode}\n${selectedElementBlock(edit)}\n\n${designAction(edit)}`).join('\n\n---\n\n')
419
+ : `${selectedElementBlock(request)}\n\n${designAction(request)}`
312
420
  if (record.sourceKind === 'preview') {
313
- return `A room member used ThinkPool Design Mode on a rendered application preview. This is an authorized source edit in your current workspace.\n\nRendered snapshot (read-only evidence): ${record.sourcePath}\nApplication workspace: ${record.workspaceRoot}\nPreview root: ${record.previewRoot}\nPreview route: ${record.route}\nExpected snapshot revision: ${record.revision}\nRequested by: ${clean(by || 'A room member', 120)}\nOperation: ${batch ? `${batch.length} queued edits in one batch` : request.mode}\n\n${work}\n\nRules:\n- Re-read the rendered snapshot and confirm the selected element still matches before editing.\n- Do not edit the snapshot. Locate the represented element in the application source inside the workspace and make the smallest source change that produces the requested result.\n- If the selector, text, or surrounding structure does not identify one application source location unambiguously, stop without editing and explain that the person must reselect it.\n${batch ? '- Apply the queued edits in order in one source pass, then build and capture once after the full batch.' : '- Apply only the requested change, then build and capture once.'}\n- Preserve the product page faithfully: keep its real copy, structure, fonts, assets, spacing, responsive behavior, and unrelated source unchanged.\n- Run the project build, then use preview_start with root ${record.previewRoot} and preview_capture with path ${record.route}, both viewports, and title ${JSON.stringify(record.title || record.slug || 'Design preview')}. The bridge correlates that capture to this request.\n- Do not claim the edit is verified until the room receives the new desktop and mobile capture.\n- If the build or capture fails, report the failure instead of substituting a screenshot-only result.`
421
+ return `A room member used ThinkPool Design Mode on a rendered application preview. This is an authorized source edit in your current workspace.\n\nRendered snapshot (read-only evidence): ${record.sourcePath}\nApplication workspace: ${record.workspaceRoot}\nPreview root: ${record.previewRoot}\nPreview route: ${record.route}\nExpected snapshot revision: ${record.revision}\nRequested by: ${clean(by || 'A room member', 120)}\nOperation: ${batch ? `${batch.length} queued edits in one batch` : request.mode}\n\n${work}\n\nRules:\n- Re-read the rendered snapshot and confirm the selected element still matches before editing.\n- Do not edit the snapshot. For an edit with bridge-verified application source, start at that exact relative file and range. For a selector/XPath fallback, locate the represented element in application source and do not call the match exact.\n- Before editing a bridge-verified target, confirm the current source still hashes to the supplied fileHash. If it does not, stop and request a fresh capture/reselection.\n- If fallback selector, text, or surrounding structure does not identify one application source location unambiguously, stop without editing and explain that the person must reselect it.\n${batch ? '- Apply the queued edits in order in one source pass, then build and capture once after the full batch.' : '- Apply only the requested change, then build and capture once.'}\n- Preserve the product page faithfully: keep its real copy, structure, fonts, assets, spacing, responsive behavior, and unrelated source unchanged.\n- Run the project build, then use preview_start with root ${record.previewRoot} and preview_capture with path ${record.route}, both viewports, and title ${JSON.stringify(record.title || record.slug || 'Design preview')}. The bridge correlates that capture to this request.\n- Do not claim the edit is verified until the room receives the new desktop and mobile capture.\n- If the build or capture fails, report the failure instead of substituting a screenshot-only result.`
314
422
  }
315
423
  return `A room member used ThinkPool Design Mode on the artifact below. This is an authorized source edit in your current workspace.\n\nArtifact source: ${record.sourcePath}\nWorkspace: ${record.workspaceRoot}\nExpected source revision: ${record.revision}\nRequested by: ${clean(by || 'A room member', 120)}\nOperation: ${restore ? 'restore the immediately previous verified Design Mode revision' : batch ? `${batch.length} queued edits in one batch` : request.mode}\n\n${work}\n\nRules:\n- Re-read the exact artifact source and confirm its revision/content still matches every selected element before editing.\n${batch ? '- Apply the queued edits in order in one source pass, then render once after the full batch.' : '- Apply only the requested change, then render the result once.'}\n- Edit the canonical source above; do not search for or edit a different plausible file.\n- Preserve the represented product page faithfully: keep its real copy, structure, fonts, assets, spacing, and responsive behavior except for the selected edits. Never replace it with generic mockup content.\n- Preserve unrelated content and styling.\n- Use the existing mockup render workflow to produce both desktop and mobile captures after the edit. Run it with TP_DESIGN_REQUEST_ID=${request.cid} and TP_DESIGN_PARENT_REVISION=${record.revision} in the command environment so the proof correlates to this request.\n- Do not claim this is live until both captures succeed and the room receives the manifest.\n- If any target is stale or ambiguous, stop without editing and explain that the person must reselect it.`
316
424
  }
@@ -0,0 +1,4 @@
1
+ export const DESIGN_SOURCE_MAP_VERSION = 1
2
+ export const DESIGN_SOURCE_MAP_RELATIVE = '.thinkpool-design/source-map.json'
3
+ export const MAX_DESIGN_SOURCE_MAP_BYTES = 2 * 1024 * 1024
4
+ export const MAX_DESIGN_SOURCE_ENTRIES = 50_000
@@ -0,0 +1,50 @@
1
+ // A safe recovery envelope. It records a bounded decision; it never performs a
2
+ // retry and therefore cannot alter native adapter transport behavior.
3
+
4
+ import { isSecretFreeRoomPayload, runtimeSupportsCapability } from './runtime-contract.mjs'
5
+
6
+ export const ERROR_RECOVERY_VERSION = 1
7
+ export const MAX_RECOVERY_ATTEMPTS = 3
8
+ const CODE = /^[a-z][a-z0-9_-]{0,79}$/
9
+ const SAFE_REF = /^[A-Za-z0-9][A-Za-z0-9._:@-]{0,159}$/
10
+
11
+ const policyFor = ({ runtime, capabilityId }) => runtimeSupportsCapability(runtime, capabilityId)
12
+ ? Object.freeze({ maxAttempts: MAX_RECOVERY_ATTEMPTS })
13
+ : Object.freeze({ maxAttempts: 1 })
14
+
15
+ export function normalizeErrorRecovery({ runtime, capabilityId, code = 'runtime_error', retryable = false, attempt = 1, evidenceRefs = [] } = {}) {
16
+ const policy = policyFor({ runtime, capabilityId })
17
+ const safeAttempt = Math.max(1, Math.min(policy.maxAttempts, Number.isSafeInteger(attempt) ? attempt : 1))
18
+ const safeRefs = (Array.isArray(evidenceRefs) ? evidenceRefs : []).slice(0, 16)
19
+ .filter((ref) => ref && SAFE_REF.test(String(ref.type || '')) && SAFE_REF.test(String(ref.id || '')))
20
+ .map((ref) => ({ type: String(ref.type), id: String(ref.id) }))
21
+ const admitted = runtimeSupportsCapability(runtime, capabilityId) && CODE.test(String(code))
22
+ const canRetry = admitted && retryable === true && safeAttempt < policy.maxAttempts
23
+ const envelope = {
24
+ version: ERROR_RECOVERY_VERSION,
25
+ capabilityId: admitted ? capabilityId : 'unknown',
26
+ code: admitted ? String(code) : 'invalid_recovery_input',
27
+ retryable: canRetry,
28
+ attempt: safeAttempt,
29
+ maxAttempts: policy.maxAttempts,
30
+ next: canRetry ? 'retry' : (admitted && retryable === true ? 'ask_human' : 'stop'),
31
+ evidenceRefs: safeRefs,
32
+ }
33
+ return Object.freeze(isSecretFreeRoomPayload(envelope, 4096) ? envelope : {
34
+ version: ERROR_RECOVERY_VERSION, capabilityId: 'unknown', code: 'invalid_recovery_input', retryable: false,
35
+ attempt: 1, maxAttempts: 1, next: 'stop', evidenceRefs: [],
36
+ })
37
+ }
38
+
39
+ export function recoveryCodeEvent(input) {
40
+ const recovery = normalizeErrorRecovery(input)
41
+ return Object.freeze({
42
+ kind: recovery.next === 'ask_human' ? 'needs-input' : 'error',
43
+ subtype: 'bounded_recovery',
44
+ message: recovery.next === 'ask_human'
45
+ ? `Recovery attempt ${recovery.attempt}/${recovery.maxAttempts} is exhausted; human input is required.`
46
+ : `Recovery stopped safely (${recovery.code}).`,
47
+ recovery,
48
+ verified: false,
49
+ })
50
+ }
package/event-bounds.mjs CHANGED
@@ -1,3 +1,5 @@
1
+ import { compactEvidenceEnvelope } from './evidence-compact.mjs'
2
+
1
3
  /* Browser-safe structured-event bounding shared by the bridge broadcast path and
2
4
  the web client's persisted reload snapshot. Large Codex/Claude tool payloads
3
5
  must remain recognisable (tool name + a useful preview), but must never turn a
@@ -66,6 +68,7 @@ export function boundStructuredEvent(event, cap = STRUCTURED_EVENT_CAP) {
66
68
  if (!event || typeof event !== 'object' || sizeOf(event) <= cap) return event
67
69
 
68
70
  const out = { ...event, _bounded: true }
71
+ if (out.evidence) out.evidence = compactEvidenceEnvelope(out.evidence, 0)
69
72
  const stringBudget = Math.min(12000, Math.max(1000, Math.floor(cap / 4)))
70
73
  for (const key of ['text', 'output', 'stdout', 'stderr', 'input']) {
71
74
  if (typeof out[key] === 'string') out[key] = clipString(out[key], stringBudget)
@@ -89,6 +92,7 @@ export function boundStructuredEvent(event, cap = STRUCTURED_EVENT_CAP) {
89
92
  for (const key of ['kind', 'seq', 'cid', 'ts', 'term', 'toolUseId', 'isError', 'by', 'crosspost', 'relaySourceName']) {
90
93
  if (out[key] != null) identity[key] = out[key]
91
94
  }
95
+ if (out.evidence) identity.evidence = compactEvidenceEnvelope(out.evidence, 0)
92
96
  if (Array.isArray(out.blocks)) identity.blocks = out.blocks.slice(0, 4).map(block => compactBlock(block, 6000))
93
97
  else if (Array.isArray(out.content)) identity.content = out.content.slice(0, 4).map(block => compactBlock(block, 6000))
94
98
  else if (typeof out.content === 'string') identity.content = clipString(out.content, 12000)
@@ -0,0 +1,50 @@
1
+ // Strict, runtime-neutral EvidenceEnvelopeV1 parsing. Only an actual
2
+ // project-context evidence tool result can create room evidence in S1.
3
+ import { createHash } from 'node:crypto'
4
+ import { EVIDENCE_CAP, compactEvidenceEnvelope } from './evidence-compact.mjs'
5
+
6
+ export { EVIDENCE_CAP, compactEvidenceEnvelope }
7
+
8
+ export const EVIDENCE_START = '<<<TP_EVIDENCE_V1>>>'
9
+ export const EVIDENCE_END = '<<<END_TP_EVIDENCE_V1>>>'
10
+ const LEVELS = new Set(['exact', 'section', 'source', 'unavailable'])
11
+ const WARNINGS = new Set(['NO_MATCH', 'LOW_LEXICAL_MATCH', 'UNCALIBRATED_RELEVANCE', 'SECTION_ONLY', 'SOURCE_ONLY', 'SOURCE_UNAVAILABLE', 'SPAN_ANCHOR_MISMATCH', 'TRANSCRIPT_BOUNDED'])
12
+ const safeText = (value, max) => typeof value === 'string' && value.length <= max && !/(?:https?:\/\/|file:|bearer\s|token=|signature=|x-amz-|\.\.[\\/]|^\/|[A-Za-z]:[\\/])/i.test(value)
13
+ const isProjectEvidenceTool = (name) => ['mcp__project_context__search_context_evidence', 'mcp__project-context__search_context_evidence'].includes(String(name || ''))
14
+ const textContent = (content) => typeof content === 'string' ? content : Array.isArray(content) ? content.map((part) => typeof part === 'string' ? part : part?.text || '').join('\n') : ''
15
+ const excerptDigest = (value) => createHash('sha256').update(value, 'utf8').digest('hex')
16
+
17
+ function validCitation(raw) {
18
+ if (!raw || raw.v !== 1 || !/^ev1_[a-f0-9]{24,64}$/.test(raw.id || '')) return null
19
+ const source = raw.source || {}, locator = raw.locator || {}, revision = source.revision || {}, assessment = raw.assessment || {}, provenance = raw.provenance || {}
20
+ if (source.kind !== 'project_prose' || source.collection !== 'repo' || !safeText(source.label, 180) || !safeText(source.path, 500) || !/^[a-f0-9]{40,64}$/i.test(revision.value || '') || !['git', 'content_sha256'].includes(revision.kind)) return null
21
+ if (!LEVELS.has(locator.level) || !safeText(locator.heading || '', 180)) return null
22
+ if (!['high', 'medium', 'low', 'unscored'].includes(assessment.relevance) || !['direct', 'partial', 'source_only', 'unavailable'].includes(assessment.confidence) || !Array.isArray(assessment.warnings) || assessment.warnings.some((warning) => !WARNINGS.has(warning))) return null
23
+ if (provenance.capturedBy !== 'project-context' || provenance.method !== 'fts5_heading') return null
24
+ const citation = { v: 1, id: raw.id, source: { kind: source.kind, collection: source.collection, label: source.label, path: source.path, revision: { kind: revision.kind, value: revision.value, ...(safeText(revision.indexedAt || '', 80) ? { indexedAt: revision.indexedAt } : {}) } }, locator: { level: locator.level, ...(safeText(locator.heading || '', 180) ? { heading: locator.heading } : {}) }, provenance: { capturedBy: provenance.capturedBy, method: provenance.method, ...(safeText(provenance.retrievedAt || '', 80) ? { retrievedAt: provenance.retrievedAt } : {}), ...(safeText(provenance.query || '', 160) ? { query: provenance.query } : {}), ...(Number.isSafeInteger(provenance.rank) && provenance.rank > 0 ? { rank: provenance.rank } : {}) }, assessment: { relevance: assessment.relevance, confidence: assessment.confidence, warnings: [...new Set(assessment.warnings)].slice(0, 8) } }
25
+ if (locator.level === 'exact') {
26
+ if (![locator.charStart, locator.charEnd, locator.lineStart, locator.lineEnd].every(Number.isSafeInteger) || locator.charStart < 0 || locator.charEnd <= locator.charStart || locator.lineStart < 1 || locator.lineEnd < locator.lineStart || !safeText(locator.anchorBefore || '', 96) || !safeText(locator.anchorAfter || '', 96) || !safeText(raw.excerpt || '', 600) || !/^[a-f0-9]{64}$/i.test(raw.excerptSha256 || '') || excerptDigest(raw.excerpt) !== raw.excerptSha256) return null
27
+ citation.locator = { ...citation.locator, charStart: locator.charStart, charEnd: locator.charEnd, lineStart: locator.lineStart, lineEnd: locator.lineEnd, anchorBefore: locator.anchorBefore, anchorAfter: locator.anchorAfter }
28
+ citation.excerpt = raw.excerpt
29
+ citation.excerptSha256 = raw.excerptSha256
30
+ }
31
+ return citation
32
+ }
33
+
34
+ export function parseEvidenceEnvelope(toolName, content) {
35
+ if (!isProjectEvidenceTool(toolName)) return null
36
+ const text = textContent(content)
37
+ const start = text.indexOf(EVIDENCE_START), end = text.indexOf(EVIDENCE_END)
38
+ if (start < 0 || end < start || text.indexOf(EVIDENCE_START, start + EVIDENCE_START.length) >= 0 || text.indexOf(EVIDENCE_END, end + EVIDENCE_END.length) >= 0) return null
39
+ let raw
40
+ try { raw = JSON.parse(text.slice(start + EVIDENCE_START.length, end)) } catch { return null }
41
+ if (!raw || raw.v !== 1 || !Array.isArray(raw.citations) || raw.citations.length > EVIDENCE_CAP) return null
42
+ const citations = raw.citations.map(validCitation)
43
+ if (citations.some((citation) => !citation)) return null
44
+ const codes = Array.isArray(raw.summary?.warningCodes) ? raw.summary.warningCodes.filter((code) => WARNINGS.has(code)).slice(0, 8) : []
45
+ const status = ['evidence_found', 'weak_evidence', 'no_evidence'].includes(raw.summary?.status) ? raw.summary.status : null
46
+ if (!status || !Array.isArray(raw.summary?.searchedCollections) || raw.summary.searchedCollections.some((collection) => collection !== 'repo')) return null
47
+ return { v: 1, citations, summary: { searchedCollections: ['repo'], directCount: citations.filter((citation) => ['exact', 'section'].includes(citation.locator.level)).length, warningCodes: [...new Set(codes)], status } }
48
+ }
49
+
50
+ export function evidenceForToolResult(toolName, content) { return parseEvidenceEnvelope(toolName, content) || undefined }
@@ -0,0 +1,11 @@
1
+ // Browser-safe evidence bounding shared by the bridge and replay client.
2
+ // Parsing and digest verification stay in evidence-citations.mjs (Node-only).
3
+ export const EVIDENCE_CAP = 8
4
+
5
+ export function compactEvidenceEnvelope(envelope, maxExcerpt = 600) {
6
+ if (!envelope?.citations?.length) return envelope
7
+ return { ...envelope, citations: envelope.citations.slice(0, EVIDENCE_CAP).map((citation) => {
8
+ if (typeof citation.excerpt !== 'string' || citation.excerpt.length <= maxExcerpt) return citation
9
+ return { ...citation, excerpt: undefined, excerptSha256: undefined, locator: { ...citation.locator, level: 'unavailable' }, assessment: { ...citation.assessment, confidence: 'unavailable', warnings: [...new Set([...citation.assessment.warnings, 'TRANSCRIPT_BOUNDED', 'SOURCE_UNAVAILABLE'])].slice(0, 8) } }
10
+ }) }
11
+ }
package/flow-preview.mjs CHANGED
@@ -82,6 +82,10 @@ function makeHandler (dir) {
82
82
  return async (req, res) => {
83
83
  const resolved = resolveUnderRoot(dir, req.url || '/')
84
84
  if (resolved === null) return send(res, 403, 'text/plain; charset=utf-8', 'Forbidden')
85
+ const relative = path.relative(dir, resolved)
86
+ if (relative.split(path.sep)[0] === '.thinkpool-design') {
87
+ return send(res, 404, 'text/plain; charset=utf-8', 'Not found')
88
+ }
85
89
 
86
90
  // A concrete file hit (and still a real file, not a dir) → serve it.
87
91
  if (await isFile(resolved)) {
@@ -1,3 +1,5 @@
1
+ import { evidenceForToolResult } from './evidence-citations.mjs'
2
+
1
3
  const textOf = (content) => {
2
4
  if (typeof content === 'string') return content
3
5
  if (content?.type === 'text') return String(content.text || '')
@@ -31,6 +33,9 @@ export function hermesToolFor(update = {}) {
31
33
  const kind = String(update.kind || '').toLowerCase()
32
34
  const title = String(update.title || '')
33
35
  const raw = update.rawInput && typeof update.rawInput === 'object' ? update.rawInput : {}
36
+ const server = String(update.server || raw.server || '')
37
+ const tool = String(update.tool || raw.tool || raw.name || '')
38
+ if (server === 'project-context' && tool === 'search_context_evidence') return { name: 'mcp__project-context__search_context_evidence', input: raw }
34
39
  const terminal = (update.content || []).find((part) => part?.type === 'terminal')
35
40
  const shellText = (update.content || []).map(textOf).find((text) => text.trim().startsWith('$ '))
36
41
  const location = update.locations?.[0]?.path
@@ -94,9 +99,11 @@ export class HermesEventMapper {
94
99
  this.tools.set(update.toolCallId, merged)
95
100
  if (!['completed', 'failed'].includes(update.status)) return
96
101
  const completedTool = hermesToolFor(merged)
102
+ const content = [{ type: 'text', text: outputText(merged) }]
103
+ const evidence = evidenceForToolResult(completedTool.name, content)
97
104
  this._emit({
98
105
  kind: 'tool_result', toolUseId: update.toolCallId,
99
- content: [{ type: 'text', text: outputText(merged) }],
106
+ content, ...(evidence ? { evidence } : {}),
100
107
  toolInput: { ...(prior.input || {}), ...(completedTool.input || {}) },
101
108
  isError: update.status === 'failed',
102
109
  durationMs: prior.startedAt ? Date.now() - prior.startedAt : undefined,
@@ -0,0 +1,83 @@
1
+ // A continuation is a durable-safe control projection, never a provider
2
+ // transcript or a command to start a new native turn.
3
+
4
+ import { isSecretFreeRoomPayload, runtimeSupportsCapability } from './runtime-contract.mjs'
5
+
6
+ const STATES = new Set(['waiting_for_human', 'resumable', 'canceled', 'terminal'])
7
+ const RESUME_KINDS = new Set(['human_response', 'approval', 'redispatch', 'reconnect'])
8
+ const EXECUTION = new Set(['Working', 'Done', 'Failed', 'Canceled'])
9
+ const SAFE_ID = /^[A-Za-z0-9][A-Za-z0-9._:@-]{0,159}$/
10
+ const dispatchers = new WeakSet()
11
+
12
+ const canonical = (value) => {
13
+ if (Array.isArray(value)) return value.map(canonical)
14
+ if (value && typeof value === 'object') return Object.fromEntries(Object.keys(value).sort().map((key) => [key, canonical(value[key])]))
15
+ return value
16
+ }
17
+ const same = (a, b) => JSON.stringify(canonical(a)) === JSON.stringify(canonical(b))
18
+ const safeId = (value) => typeof value === 'string' && SAFE_ID.test(value)
19
+ const denied = (code) => Object.freeze({ ok: false, code })
20
+ const clone = (value) => {
21
+ const freeze = (node) => {
22
+ if (node && typeof node === 'object') {
23
+ for (const child of Object.values(node)) freeze(child)
24
+ Object.freeze(node)
25
+ }
26
+ return node
27
+ }
28
+ return freeze(canonical(value))
29
+ }
30
+
31
+ export function isSafeContinuationProjection (record) {
32
+ if (!record || typeof record !== 'object' || Array.isArray(record) || record.version !== 1 || !safeId(record.id) || !safeId(record.laneRef) || !safeId(record.roomCode) || !safeId(record.authorityRef)) return false
33
+ if (!runtimeSupportsCapability(record.runtime, record.capabilityId) || !STATES.has(record.state) || record.verification !== 'unverified') return false
34
+ if (record.resumeKind != null && !RESUME_KINDS.has(record.resumeKind)) return false
35
+ if (record.resumeRef != null && (!record.resumeRef || typeof record.resumeRef !== 'object' || !safeId(record.resumeRef.type) || !safeId(record.resumeRef.id))) return false
36
+ if (!['canceled', 'terminal'].includes(record.state) && (!record.resumeKind || !record.resumeRef)) return false
37
+ if (!record.executionBoundary || !EXECUTION.has(record.executionBoundary.state) || !record.executionBoundary.eventRef || !isSecretFreeRoomPayload(record.executionBoundary.eventRef, 2048)) return false
38
+ if (record.expiresAt != null && (!Number.isSafeInteger(record.expiresAt) || record.expiresAt <= 0)) return false
39
+ return isSecretFreeRoomPayload(record, 8192)
40
+ }
41
+
42
+ export function createContinuationDispatcher ({ roomCode, authorityRef, now = () => Date.now() } = {}) {
43
+ if (!safeId(roomCode) || !safeId(authorityRef) || typeof now !== 'function') throw new TypeError('Invalid continuation dispatcher authority')
44
+ const records = new Map()
45
+ const dispatcher = {
46
+ create (record) {
47
+ const next = { ...record, version: 1, roomCode, authorityRef, verification: 'unverified' }
48
+ if (!isSafeContinuationProjection(next)) return denied('invalid_continuation')
49
+ const prior = records.get(next.id)
50
+ if (prior) return same(prior, next) ? Object.freeze({ ok: true, code: 'idempotent', record: prior }) : denied('continuation_conflict')
51
+ const frozen = clone(next)
52
+ records.set(frozen.id, frozen)
53
+ return Object.freeze({ ok: true, code: 'created', record: frozen })
54
+ },
55
+ consume ({ id, roomCode: consumeRoom, authorityRef: consumeAuthority, control } = {}) {
56
+ const record = records.get(id)
57
+ if (!record) return denied('unknown_continuation')
58
+ if (consumeRoom !== roomCode || consumeAuthority !== authorityRef) return denied('continuation_authority_mismatch')
59
+ if (record.state === 'canceled') return denied('continuation_canceled')
60
+ if (record.state === 'terminal') return denied('continuation_terminal')
61
+ if (record.expiresAt != null && now() > record.expiresAt) return denied('continuation_stale')
62
+ if (!control || control.authorized !== true || control.roomCode !== roomCode || control.authorityRef !== authorityRef || !same(control.resumeRef, record.resumeRef)) return denied('continuation_control_unverified')
63
+ // The adapter must explicitly act on this intent. Returning it is not an
64
+ // auto-resume and cannot fabricate an execution or verification result.
65
+ return Object.freeze({ ok: true, code: 'resume_intent', record, resumeIntent: Object.freeze({ runtime: record.runtime, laneRef: record.laneRef, resumeKind: record.resumeKind, resumeRef: record.resumeRef }) })
66
+ },
67
+ cancel ({ id, roomCode: cancelRoom, authorityRef: cancelAuthority, reason = 'interrupt' } = {}) {
68
+ const record = records.get(id)
69
+ if (!record) return denied('unknown_continuation')
70
+ if (cancelRoom !== roomCode || cancelAuthority !== authorityRef) return denied('continuation_authority_mismatch')
71
+ if (record.state === 'canceled') return Object.freeze({ ok: true, code: 'idempotent_cancel', record })
72
+ // Do not preserve an arbitrary reason string: it might be provider prose.
73
+ // The canceled execution boundary is the safe observable fact.
74
+ const canceled = clone({ ...record, state: 'canceled', resumeKind: null, resumeRef: null, executionBoundary: { ...record.executionBoundary, state: 'Canceled' }, verification: 'unverified' })
75
+ records.set(id, canceled)
76
+ return Object.freeze({ ok: true, code: 'canceled', record: canceled })
77
+ },
78
+ }
79
+ dispatchers.add(dispatcher)
80
+ return Object.freeze(dispatcher)
81
+ }
82
+
83
+ export const isBridgeContinuationDispatcher = (value) => dispatchers.has(value)
@@ -1,4 +1,5 @@
1
1
  import { classifyCodeEvent } from './code-event-contract.mjs'
2
+ import { isSafeContinuationProjection } from './lane-continuation.mjs'
2
3
 
3
4
  // This file ships in the standalone thinkpool-pair tarball. Keep the canonical
4
5
  // vocabulary here rather than importing from ../src (which is not packaged).
@@ -84,7 +85,7 @@ const base = (state, reason = null, extra = {}) => ({ state, reason, phase: null
84
85
  export function projectLaneLifecycle ({
85
86
  events = [], busy = false, pendingCount = 0, pendingReason = null,
86
87
  stalled = false, offline = false, stale = false, failed = false, canceled = false,
87
- rawStatus = null,
88
+ rawStatus = null, continuation = null,
88
89
  } = {}) {
89
90
  let out = base(LIFECYCLE_STATE.BLOCKED, BLOCKED_REASON.NEEDS_INPUT, { rawStatus })
90
91
  const replay = normalizeReplayEvents(events)
@@ -120,6 +121,11 @@ export function projectLaneLifecycle ({
120
121
  else if (failed) out = base(LIFECYCLE_STATE.FAILED, null, { rawStatus })
121
122
  else if (canceled) out = base(LIFECYCLE_STATE.CANCELED, null, { rawStatus })
122
123
  else if (pendingCount > 0) out = base(LIFECYCLE_STATE.BLOCKED, pendingReason || BLOCKED_REASON.NEEDS_DECISION, { rawStatus })
124
+ // This is explanation only. A continuation cannot certify verification or
125
+ // resurrect an interrupted/canceled execution boundary.
126
+ else if (isSafeContinuationProjection(continuation) && !TERMINAL.has(out.state) && continuation.state !== 'terminal') {
127
+ out = base(LIFECYCLE_STATE.BLOCKED, continuation.state === 'waiting_for_human' ? BLOCKED_REASON.NEEDS_INPUT : BLOCKED_REASON.NEEDS_DECISION, { rawStatus, phase: 'continuation' })
128
+ }
123
129
  else if (stalled) out = base(LIFECYCLE_STATE.BLOCKED, BLOCKED_REASON.STALLED, { rawStatus, phase: 'stalled' })
124
130
  else if (busy && !TERMINAL.has(out.state)) out = base(LIFECYCLE_STATE.WORKING, null, { rawStatus, phase: out.phase || 'active' })
125
131
  else if (offline && !TERMINAL.has(out.state)) out = base(LIFECYCLE_STATE.BLOCKED, BLOCKED_REASON.OFFLINE, { rawStatus })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thinkpool-pair",
3
- "version": "0.7.354",
3
+ "version": "0.7.357",
4
4
  "description": "Connect Claude Code, Codex, or Hermes on your computer to a Thinkpool Code room.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -20,6 +20,7 @@
20
20
  "privacy-report.mjs",
21
21
  "agent-visibility.mjs",
22
22
  "byok-detect.mjs",
23
+ "context-contract.mjs",
23
24
  "context-windows.mjs",
24
25
  "claude-session.mjs",
25
26
  "terminal-name.mjs",
@@ -48,6 +49,7 @@
48
49
  "hermes-isolation.mjs",
49
50
  "hermes-delegation-guard.mjs",
50
51
  "runtime-registry.mjs",
52
+ "runtime-contract.mjs",
51
53
  "command-catalog.mjs",
52
54
  "repo-search.mjs",
53
55
  "git-diff-report.mjs",
@@ -57,6 +59,9 @@
57
59
  "agent-notify.mjs",
58
60
  "agent-detect.mjs",
59
61
  "code-event-contract.mjs",
62
+ "evidence-compact.mjs",
63
+ "evidence-citations.mjs",
64
+ "error-recovery.mjs",
60
65
  "pair-control-authority.mjs",
61
66
  "event-id.mjs",
62
67
  "event-bounds.mjs",
@@ -71,6 +76,7 @@
71
76
  "pair-bus.mjs",
72
77
  "direct-pair-room.mjs",
73
78
  "lane-lifecycle.mjs",
79
+ "lane-continuation.mjs",
74
80
  "interrupted-resume.mjs",
75
81
  "dispatch-lease.mjs",
76
82
  "dispatch-permission-cleanup.mjs",
@@ -83,6 +89,7 @@
83
89
  "mockup-delivery.mjs",
84
90
  "viewport.mjs",
85
91
  "design-edit.mjs",
92
+ "design-source-contract.mjs",
86
93
  "flow-review.mjs",
87
94
  "review-check.mjs",
88
95
  "flow-review-gate.mjs",
@@ -106,6 +113,7 @@
106
113
  "supabase-key.mjs",
107
114
  "provider.mjs",
108
115
  "providers.mjs",
116
+ "provider-resilience.mjs",
109
117
  "model-prices.mjs",
110
118
  "README.md"
111
119
  ],