thinkpool-pair 0.7.354 → 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,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.356",
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,8 @@
57
59
  "agent-notify.mjs",
58
60
  "agent-detect.mjs",
59
61
  "code-event-contract.mjs",
62
+ "evidence-citations.mjs",
63
+ "error-recovery.mjs",
60
64
  "pair-control-authority.mjs",
61
65
  "event-id.mjs",
62
66
  "event-bounds.mjs",
@@ -71,6 +75,7 @@
71
75
  "pair-bus.mjs",
72
76
  "direct-pair-room.mjs",
73
77
  "lane-lifecycle.mjs",
78
+ "lane-continuation.mjs",
74
79
  "interrupted-resume.mjs",
75
80
  "dispatch-lease.mjs",
76
81
  "dispatch-permission-cleanup.mjs",
@@ -83,6 +88,7 @@
83
88
  "mockup-delivery.mjs",
84
89
  "viewport.mjs",
85
90
  "design-edit.mjs",
91
+ "design-source-contract.mjs",
86
92
  "flow-review.mjs",
87
93
  "review-check.mjs",
88
94
  "flow-review-gate.mjs",
@@ -106,6 +112,7 @@
106
112
  "supabase-key.mjs",
107
113
  "provider.mjs",
108
114
  "providers.mjs",
115
+ "provider-resilience.mjs",
109
116
  "model-prices.mjs",
110
117
  "README.md"
111
118
  ],
@@ -0,0 +1,213 @@
1
+ /*
2
+ * Provider resilience policy primitives.
3
+ *
4
+ * Phase 0 is deliberately policy-only: this module never makes a network call,
5
+ * never reads provider credentials, and never persists circuit or attempt state.
6
+ * The Claude session may observe its pre-existing recovery paths through the
7
+ * attempt ledger, but enabling this module cannot add a retry or fallback.
8
+ */
9
+ import { isSecretFreeRoomPayload } from './runtime-contract.mjs'
10
+
11
+ export const RESILIENCE_VERSION = 1
12
+ export const MAX_NETWORK_ATTEMPTS = 3
13
+ export const TRANSIENT_FAILURES = new Set(['timeout', 'network', 'rate_limit', 'overload', 'upstream_5xx'])
14
+ const RETRYABLE = [...TRANSIENT_FAILURES]
15
+ const TRACE_FIELDS = new Set(['traceId', 'turnRev', 'attempt', 'targetProviderId', 'targetProviderName', 'requestedModel', 'actualConfiguredModel', 'phase', 'outcome', 'failureClass', 'elapsedMs', 'retryAfterMs', 'fallbackFrom'])
16
+ const OUTCOMES = new Set(['started', 'retrying', 'failed', 'fallback', 'succeeded', 'circuit_open', 'cap_blocked'])
17
+ const TRACE_TEXT_FIELDS = new Set(['traceId', 'targetProviderId', 'targetProviderName', 'requestedModel', 'actualConfiguredModel', 'phase', 'failureClass', 'fallbackFrom'])
18
+ const TRACE_URL_OR_PATH = /(?:[a-z][a-z0-9+.-]*:\/\/|(?:^|[\s"'(])\/(?:Users|home|private|tmp)\/|[a-z]:\\|\\\\)/i
19
+
20
+ export const DEFAULT_RESILIENCE_POLICY = Object.freeze({
21
+ v: RESILIENCE_VERSION,
22
+ enabled: false,
23
+ timeoutMs: 90000,
24
+ maxAttempts: 2,
25
+ retryOn: RETRYABLE,
26
+ circuit: Object.freeze({ failureThreshold: 3, windowMs: 60000, cooldownMs: 30000 }),
27
+ fallback: Object.freeze([]),
28
+ })
29
+
30
+ const plainObject = (x) => !!x && typeof x === 'object' && !Array.isArray(x)
31
+ const positiveInt = (x) => Number.isInteger(x) && x > 0
32
+ const text = (x) => typeof x === 'string' && x.trim() ? x.trim() : null
33
+ const safeTraceText = (value) => {
34
+ const normalized = text(value)
35
+ if (!normalized || normalized.length > 160 || TRACE_URL_OR_PATH.test(normalized)) return null
36
+ return isSecretFreeRoomPayload({ value: normalized }, 512) ? normalized : null
37
+ }
38
+
39
+ /**
40
+ * Validate and canonicalize the non-secret provider policy. Missing policy is
41
+ * the explicit observe-only default. Extra fields are discarded rather than
42
+ * copied into a room-facing projection.
43
+ */
44
+ export function validateResiliencePolicy(input, { providers = [], primaryProviderId = null } = {}) {
45
+ if (input == null) return { ok: true, value: { ...DEFAULT_RESILIENCE_POLICY, retryOn: [...RETRYABLE], circuit: { ...DEFAULT_RESILIENCE_POLICY.circuit }, fallback: [] } }
46
+ if (!plainObject(input) || input.v !== RESILIENCE_VERSION) return { ok: false, error: 'unsupported resilience policy version' }
47
+ if (typeof input.enabled !== 'boolean') return { ok: false, error: 'enabled must be boolean' }
48
+ if (!Number.isInteger(input.timeoutMs) || input.timeoutMs < 30000 || input.timeoutMs > 120000) return { ok: false, error: 'timeoutMs must be 30000..120000' }
49
+ if (!positiveInt(input.maxAttempts)) return { ok: false, error: 'maxAttempts must be a positive integer' }
50
+ if (!Array.isArray(input.retryOn) || input.retryOn.some((x) => !TRANSIENT_FAILURES.has(x))) return { ok: false, error: 'retryOn contains an unsupported failure class' }
51
+ if (!plainObject(input.circuit) || !positiveInt(input.circuit.failureThreshold) || !positiveInt(input.circuit.windowMs) || !positiveInt(input.circuit.cooldownMs)) return { ok: false, error: 'invalid circuit policy' }
52
+ if (!Array.isArray(input.fallback)) return { ok: false, error: 'fallback must be an array' }
53
+ const fallback = []
54
+ for (const candidate of input.fallback) {
55
+ if (!plainObject(candidate) || !text(candidate.providerId) || !text(candidate.model) || candidate.allow !== true) return { ok: false, error: 'invalid fallback target' }
56
+ // Phase 0 stores only the conservative v1 declarations. A future UI may add
57
+ // explicit elevated consent, but it must not be smuggled in via this shape.
58
+ if (candidate.privacyClass !== 'same' || !['same', 'same-or-lower'].includes(candidate.costClass)) return { ok: false, error: 'fallback privacy/cost class is not eligible' }
59
+ if (primaryProviderId && candidate.providerId === primaryProviderId) return { ok: false, error: 'fallback cannot target itself' }
60
+ if (providers.length) {
61
+ const row = providers.find((provider) => provider?.id === candidate.providerId)
62
+ if (!row || row.id === 'anthropic' || row.model !== candidate.model) return { ok: false, error: 'fallback must name a registered exact provider/model target' }
63
+ }
64
+ fallback.push({ providerId: text(candidate.providerId), model: text(candidate.model), allow: true, privacyClass: candidate.privacyClass, costClass: candidate.costClass })
65
+ }
66
+ return {
67
+ ok: true,
68
+ value: {
69
+ v: RESILIENCE_VERSION,
70
+ enabled: input.enabled,
71
+ timeoutMs: input.timeoutMs,
72
+ // A malformed high value may never increase live network work.
73
+ maxAttempts: Math.min(input.maxAttempts, MAX_NETWORK_ATTEMPTS),
74
+ retryOn: [...new Set(input.retryOn)],
75
+ circuit: { failureThreshold: input.circuit.failureThreshold, windowMs: input.circuit.windowMs, cooldownMs: input.circuit.cooldownMs },
76
+ fallback,
77
+ },
78
+ }
79
+ }
80
+
81
+ /** Safe allowlist projection for local diagnostics / room-safe metadata. */
82
+ export function resilienceProjection(input) {
83
+ const validated = validateResiliencePolicy(input)
84
+ if (!validated.ok) return null
85
+ const p = validated.value
86
+ return { v: p.v, enabled: p.enabled, timeoutMs: p.timeoutMs, maxAttempts: p.maxAttempts, retryOn: [...p.retryOn], circuit: { ...p.circuit }, fallback: p.fallback.map((x) => ({ ...x })) }
87
+ }
88
+
89
+ /** Coarse classification only. It intentionally does not return raw errors. */
90
+ export function classifyProviderFailure(input = {}) {
91
+ const status = Number(input.status || input.statusCode || 0)
92
+ const code = String(input.code || '').toLowerCase()
93
+ const message = String(input.message || input.error?.message || '').toLowerCase()
94
+ if (input.aborted || /\babort(ed|error)?\b|cancelled|canceled/.test(`${code} ${message}`)) return 'user_abort'
95
+ if (input.permissionDenied || /permission denied|forbidden by policy|guardrail/.test(`${code} ${message}`)) return 'permission_denied'
96
+ if (status === 401 || status === 403 || /unauthori[sz]ed|invalid (api )?key|credential/.test(`${code} ${message}`)) return 'auth'
97
+ if (status === 429 || /rate.?limit/.test(`${code} ${message}`)) return 'rate_limit'
98
+ if (status === 529 || /overload|overloaded/.test(`${code} ${message}`)) return 'overload'
99
+ if (status >= 500 && status <= 599) return 'upstream_5xx'
100
+ if (/timeout|timed out|deadline exceeded|etimedout/.test(`${code} ${message}`)) return 'timeout'
101
+ if (/econnreset|enotfound|eai_again|socket|connection (closed|reset)|fetch failed|network error/.test(`${code} ${message}`)) return 'network'
102
+ if (status === 400 || /invalid model|unknown model/.test(`${code} ${message}`)) return 'invalid_model'
103
+ if (/context length|request too large|payload too large|too many tokens/.test(`${code} ${message}`)) return 'request_too_large'
104
+ if (status >= 400 && status < 500) return 'invalid_request'
105
+ return 'unknown'
106
+ }
107
+
108
+ export const isReplaySafe = ({ outputVisible = false, toolOutputVisible = false, aborted = false, permissionDenied = false, nonIdempotent = false } = {}) => !(outputVisible || toolOutputVisible || aborted || permissionDenied || nonIdempotent)
109
+
110
+ /** Resolve only registered custom Claude targets with their exact configured model. */
111
+ export function resolveAttemptTarget({ runtime = 'claude', providers = [], providerId, model } = {}) {
112
+ if (runtime !== 'claude' || !text(providerId) || !text(model)) return null
113
+ const provider = providers.find((p) => p && p.id === providerId && p.id !== 'anthropic' && p.model === model)
114
+ if (!provider) return null
115
+ return { providerId: provider.id, targetProviderName: text(provider.name) || provider.id, actualConfiguredModel: provider.model }
116
+ }
117
+
118
+ /** All eight Phase-2 eligibility gates in a pure, fail-closed form. */
119
+ export function assessFallbackEligibility({ runtime = 'claude', providers = [], primary, candidate, policy, outputVisible = false, toolOutputVisible = false, aborted = false, permissionDenied = false, capAllowed = true, circuit = 'closed' } = {}) {
120
+ if (!isReplaySafe({ outputVisible, toolOutputVisible, aborted, permissionDenied })) return { ok: false, reason: 'replay_unsafe' }
121
+ if (circuit !== 'closed') return { ok: false, reason: 'circuit_open' }
122
+ if (!capAllowed) return { ok: false, reason: 'cap_blocked' }
123
+ const checked = validateResiliencePolicy(policy)
124
+ if (!checked.ok || !checked.value.enabled || runtime !== 'claude') return { ok: false, reason: 'policy_disabled' }
125
+ const target = resolveAttemptTarget({ runtime, providers, providerId: candidate?.providerId, model: candidate?.model })
126
+ if (!target) return { ok: false, reason: 'unknown_target' }
127
+ const configured = checked.value.fallback.find((x) => x.providerId === target.providerId && x.model === target.actualConfiguredModel)
128
+ if (!configured?.allow) return { ok: false, reason: 'not_explicitly_allowed' }
129
+ if (configured.privacyClass !== 'same' || !['same', 'same-or-lower'].includes(configured.costClass)) return { ok: false, reason: 'boundary_not_consented' }
130
+ if (primary?.providerId === target.providerId && primary?.actualConfiguredModel === target.actualConfiguredModel) return { ok: false, reason: 'same_target' }
131
+ return { ok: true, target, fallback: configured }
132
+ }
133
+
134
+ /** Local-only total-attempt accounting. It is intentionally not a retry engine. */
135
+ export function createAttemptLedger({ maxAttempts = MAX_NETWORK_ATTEMPTS, traceId = 'local', turnRev = null, onRecord = null } = {}) {
136
+ const ceiling = Math.max(1, Math.min(Number.isInteger(maxAttempts) ? maxAttempts : 1, MAX_NETWORK_ATTEMPTS))
137
+ const records = []
138
+ const record = (entry = {}) => {
139
+ if (records.length >= ceiling) return null
140
+ const next = safeTraceRecord({ traceId, turnRev, attempt: Math.min(records.length + 1, ceiling), ...entry })
141
+ records.push(next)
142
+ try { onRecord?.(next) } catch { /* observation must never affect the session */ }
143
+ return next
144
+ }
145
+ return {
146
+ get ceiling() { return ceiling },
147
+ get attempts() { return records.length },
148
+ get records() { return records.map((x) => ({ ...x })) },
149
+ canSubmit() { return records.length < ceiling },
150
+ record,
151
+ }
152
+ }
153
+
154
+ /** Memory-only circuit breaker keyed by bridge/provider/exact-model. */
155
+ export function createMemoryCircuit({ now = () => Date.now() } = {}) {
156
+ const states = new Map()
157
+ const keyOf = ({ bridgeHostId, providerId, model } = {}) => `${bridgeHostId || ''}\u0000${providerId || ''}\u0000${model || ''}`
158
+ const stateFor = (target) => states.get(keyOf(target)) || { failures: [], openUntil: 0, probing: false }
159
+ const put = (target, state) => states.set(keyOf(target), state)
160
+ return {
161
+ preflight(target) {
162
+ const state = stateFor(target); const t = now()
163
+ if (state.openUntil > t) return { ok: false, state: 'open' }
164
+ if (state.openUntil && state.openUntil <= t) {
165
+ if (state.probing) return { ok: false, state: 'half_open_busy' }
166
+ state.probing = true; put(target, state); return { ok: true, state: 'half_open' }
167
+ }
168
+ return { ok: true, state: 'closed' }
169
+ },
170
+ success(target) { states.delete(keyOf(target)) },
171
+ failure(target, policy, failureClass) {
172
+ if (!TRANSIENT_FAILURES.has(failureClass)) return { state: this.preflight(target).state, counted: false }
173
+ const state = stateFor(target); const t = now(); const circuit = policy?.circuit || DEFAULT_RESILIENCE_POLICY.circuit
174
+ if (state.probing) { state.probing = false; state.failures = []; state.openUntil = t + circuit.cooldownMs; put(target, state); return { state: 'open', counted: true } }
175
+ state.failures = state.failures.filter((at) => at > t - circuit.windowMs)
176
+ state.failures.push(t)
177
+ if (state.failures.length >= circuit.failureThreshold) state.openUntil = t + circuit.cooldownMs
178
+ put(target, state)
179
+ return { state: state.openUntil > t ? 'open' : 'closed', counted: true }
180
+ },
181
+ // Test/diagnostic-only metadata. Never returns credentials, URLs, or prompts.
182
+ snapshot(target) { const s = stateFor(target); return { failures: s.failures.length, openUntil: s.openUntil, probing: s.probing } },
183
+ }
184
+ }
185
+
186
+ export function safeTraceRecord(input = {}) {
187
+ const out = {}
188
+ for (const key of TRACE_FIELDS) {
189
+ if (input[key] === undefined || input[key] === null) continue
190
+ if (TRACE_TEXT_FIELDS.has(key)) {
191
+ const value = safeTraceText(input[key])
192
+ if (value) out[key] = value
193
+ } else out[key] = input[key]
194
+ }
195
+ out.attempt = Math.max(1, Math.min(Number(out.attempt) || 1, MAX_NETWORK_ATTEMPTS))
196
+ if (!OUTCOMES.has(out.outcome)) out.outcome = 'failed'
197
+ if (out.failureClass && !/^[a-z0-9_]+$/.test(String(out.failureClass))) delete out.failureClass
198
+ for (const key of ['elapsedMs', 'retryAfterMs']) if (out[key] != null) out[key] = Math.max(0, Math.floor(Number(out[key]) || 0))
199
+ return out
200
+ }
201
+
202
+ /** A room-safe line assembled exclusively from the allowlisted coarse record. */
203
+ export function formatResilienceTrace(input) {
204
+ const r = safeTraceRecord(input)
205
+ const target = [r.targetProviderName || r.targetProviderId, r.actualConfiguredModel].filter(Boolean).join(' / ') || 'configured provider'
206
+ const failure = r.failureClass ? ` (${r.failureClass.replaceAll('_', ' ')})` : ''
207
+ if (r.outcome === 'retrying') return `${target} unavailable${failure} — existing session recovery is retrying (attempt ${r.attempt}).`
208
+ if (r.outcome === 'circuit_open') return `${target} is temporarily unavailable; its local circuit is open.`
209
+ if (r.outcome === 'cap_blocked') return `Additional provider work was blocked by the applicable cap.`
210
+ if (r.outcome === 'succeeded') return `${target} completed.`
211
+ if (r.outcome === 'fallback') return `Configured fallback selected: ${target}.`
212
+ return `${target} failed${failure}.`
213
+ }
package/recap.mjs CHANGED
@@ -26,11 +26,19 @@ export const RECAP_CAP = 20000
26
26
  // unmistakable authority boundary: the recap is memory, never a second task.
27
27
  export const CURRENT_PERSON_REQUEST_MARKER = '--- CURRENT PERSON REQUEST (authoritative; overrides all carried context above) ---'
28
28
 
29
+ // Recaps are an outbound context source. Keep the same privacy boundary as the
30
+ // bridge manifest without importing Node-only hashing into the browser bundle.
31
+ const UNSAFE_CONTEXT = /(?:\/home\/|\/users\/|\/private\/|\/tmp\/|[a-z]:\\|\\\\|sk[_-][a-z0-9_-]{8,}|gsk_[a-z0-9_-]{8,}|bearer\s+[a-z0-9._-]{8,}|BEGIN (?:RSA |OPENSSH )?PRIVATE KEY|raw transcript|tool args?|chain[ -]of[ -]thought|hidden reasoning)/i
32
+ const safeRecapText = (value) => {
33
+ const text = String(value || '').trim()
34
+ return text && !UNSAFE_CONTEXT.test(text) ? text : ''
35
+ }
36
+
29
37
  export function appendCurrentPersonRequest(carried, text) {
30
38
  const contexts = (Array.isArray(carried) ? carried : [carried])
31
- .map((value) => String(value || '').trim())
39
+ .map(safeRecapText)
32
40
  .filter(Boolean)
33
- const request = String(text || '')
41
+ const request = safeRecapText(text)
34
42
  if (!contexts.length) return request
35
43
  return `${contexts.join('\n\n')}\n\n${CURRENT_PERSON_REQUEST_MARKER}\n${request}`
36
44
  }
@@ -90,12 +98,12 @@ export function buildRecapFromLog(log, cap = RECAP_CAP, { reason = 'switch' } =
90
98
  for (const e of log) {
91
99
  if (!e || typeof e !== 'object') continue
92
100
  if (e.kind === 'you') {
93
- const t = String(e.text || '').trim()
101
+ const t = safeRecapText(e.text)
94
102
  if (t) turns.push({ who: 'PERSON', text: t })
95
103
  } else if (e.kind === 'assistant') {
96
104
  const t = (Array.isArray(e.blocks) ? e.blocks : [])
97
- .filter((b) => b && b.type === 'text' && typeof b.text === 'string' && b.text.trim())
98
- .map((b) => b.text.trim())
105
+ .filter((b) => b && b.type === 'text' && safeRecapText(b.text))
106
+ .map((b) => safeRecapText(b.text))
99
107
  .join('\n')
100
108
  if (t) turns.push({ who: 'YOU', text: t })
101
109
  }
package/repo-search.mjs CHANGED
@@ -27,6 +27,8 @@ const EXPANSIONS = Object.freeze({
27
27
  })
28
28
 
29
29
  const cleanQuery = (value) => String(value || '')
30
+ // Deliberately strip ASCII control characters from room-supplied queries.
31
+ // eslint-disable-next-line no-control-regex
30
32
  .replace(/[\u0000-\u001f\u007f]/g, ' ')
31
33
  .replace(/\s+/g, ' ')
32
34
  .trim()
@@ -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/"},