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/bridge.mjs +42 -1
- package/claude-session.mjs +240 -15
- package/code-event-contract.mjs +9 -0
- package/codex-event-mapper.mjs +4 -1
- 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 +50 -0
- package/evidence-compact.mjs +11 -0
- package/flow-preview.mjs +4 -0
- 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 +356 -0
- package/providers.mjs +61 -2
- package/recap.mjs +13 -5
- package/repo-search.mjs +2 -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
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Provider resilience policy primitives.
|
|
3
|
+
*
|
|
4
|
+
* This module never makes a network call or reads provider credentials. Phase 1
|
|
5
|
+
* adds an injected same-target controller, but transport remains owned by the
|
|
6
|
+
* Claude session and fallback remains unavailable.
|
|
7
|
+
*/
|
|
8
|
+
import { isSecretFreeRoomPayload } from './runtime-contract.mjs'
|
|
9
|
+
|
|
10
|
+
export const RESILIENCE_VERSION = 1
|
|
11
|
+
export const MAX_NETWORK_ATTEMPTS = 3
|
|
12
|
+
export const MAX_SAME_TARGET_ATTEMPTS = 2
|
|
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
|
+
/**
|
|
187
|
+
* Apply the only authoritative provider-work cap currently available at the
|
|
188
|
+
* bridge boundary. Ordinary/uncapped lanes remain admissible, but the result
|
|
189
|
+
* names that absence explicitly so the host can record `cap_not_configured`
|
|
190
|
+
* rather than pretending an approval occurred.
|
|
191
|
+
*/
|
|
192
|
+
export function providerResilienceCapAdmission(budget) {
|
|
193
|
+
if (!budget || budget.capTokens == null) {
|
|
194
|
+
return { allowed: true, configured: false, reason: 'cap_not_configured' }
|
|
195
|
+
}
|
|
196
|
+
const capTokens = Number(budget.capTokens)
|
|
197
|
+
const spentTokens = Number(budget.spentTokens)
|
|
198
|
+
const allowed = Number.isFinite(capTokens)
|
|
199
|
+
&& capTokens >= 0
|
|
200
|
+
&& Number.isFinite(spentTokens)
|
|
201
|
+
&& spentTokens >= 0
|
|
202
|
+
&& budget.killed !== true
|
|
203
|
+
&& spentTokens < capTokens
|
|
204
|
+
return { allowed, configured: true, reason: allowed ? 'within_cap' : 'cap_blocked' }
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Pure, injected Phase-1 controller for a single exact custom Claude target.
|
|
209
|
+
*
|
|
210
|
+
* The bridge/session integration owns transport and invokes this controller at
|
|
211
|
+
* turn start, before each submission, after visible output, and at terminal
|
|
212
|
+
* success/failure. It intentionally has no fallback path and cannot resolve a
|
|
213
|
+
* built-in Anthropic target. A caller must inject both the authoritative cap
|
|
214
|
+
* admission gate and the bridge-scoped memory circuit; missing or broken gates
|
|
215
|
+
* deny the submission rather than guessing that it is safe.
|
|
216
|
+
*/
|
|
217
|
+
export function createSameTargetResilienceController({
|
|
218
|
+
runtime = 'claude', providers = [], providerId, model, requestedModel = model,
|
|
219
|
+
policy, bridgeHostId = null, circuit, capGate, traceId = 'local', turnRev = null,
|
|
220
|
+
onRecord = null,
|
|
221
|
+
} = {}) {
|
|
222
|
+
let checked
|
|
223
|
+
try { checked = validateResiliencePolicy(policy, { providers, primaryProviderId: providerId }) } catch { checked = { ok: false } }
|
|
224
|
+
let target = null
|
|
225
|
+
try { target = resolveAttemptTarget({ runtime, providers, providerId, model }) } catch { /* malformed registry is not retryable */ }
|
|
226
|
+
const configured = checked?.ok && checked.value.enabled && target && runtime === 'claude'
|
|
227
|
+
const circuitReady = !!circuit && typeof circuit.preflight === 'function' && typeof circuit.failure === 'function' && typeof circuit.success === 'function'
|
|
228
|
+
const ceiling = configured ? Math.min(checked.value.maxAttempts, MAX_SAME_TARGET_ATTEMPTS) : 0
|
|
229
|
+
const targetKey = target && { bridgeHostId, providerId: target.providerId, model: target.actualConfiguredModel }
|
|
230
|
+
const base = target && {
|
|
231
|
+
traceId,
|
|
232
|
+
turnRev,
|
|
233
|
+
targetProviderId: target.providerId,
|
|
234
|
+
targetProviderName: target.targetProviderName,
|
|
235
|
+
requestedModel,
|
|
236
|
+
actualConfiguredModel: target.actualConfiguredModel,
|
|
237
|
+
phase: 'same_target',
|
|
238
|
+
}
|
|
239
|
+
let started = false
|
|
240
|
+
let submissions = 0
|
|
241
|
+
let awaitingOutcome = false
|
|
242
|
+
let retryEligible = false
|
|
243
|
+
let outputVisible = false
|
|
244
|
+
let toolOutputVisible = false
|
|
245
|
+
let aborted = false
|
|
246
|
+
let permissionDenied = false
|
|
247
|
+
let finished = false
|
|
248
|
+
|
|
249
|
+
const makeRecord = (outcome, extra = {}, attempt = submissions) => safeTraceRecord({
|
|
250
|
+
...base,
|
|
251
|
+
attempt: Math.max(1, attempt),
|
|
252
|
+
outcome,
|
|
253
|
+
...extra,
|
|
254
|
+
})
|
|
255
|
+
const emit = (outcome, extra) => {
|
|
256
|
+
const record = makeRecord(outcome, extra)
|
|
257
|
+
try { onRecord?.(record) } catch { /* trace observers are never control flow */ }
|
|
258
|
+
return record
|
|
259
|
+
}
|
|
260
|
+
const blocked = (outcome = 'failed', extra) => ({ admitted: false, record: emit(outcome, extra) })
|
|
261
|
+
const replaySafe = () => isReplaySafe({ outputVisible, toolOutputVisible, aborted, permissionDenied })
|
|
262
|
+
|
|
263
|
+
return {
|
|
264
|
+
/** Mark the controller as belonging to this turn; no provider work occurs here. */
|
|
265
|
+
start() { started = true },
|
|
266
|
+
|
|
267
|
+
/** Once any assistant/tool output is visible, automatic replay is permanently disabled. */
|
|
268
|
+
visibleOutput({ tool = false } = {}) {
|
|
269
|
+
outputVisible = true
|
|
270
|
+
if (tool) toolOutputVisible = true
|
|
271
|
+
retryEligible = false
|
|
272
|
+
},
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Admit exactly one upcoming submission. The cap gate is awaited before
|
|
276
|
+
* transport, and any false/unknown/throwing result denies the attempt.
|
|
277
|
+
*/
|
|
278
|
+
async preflight() {
|
|
279
|
+
if (!started) started = true
|
|
280
|
+
if (!configured || !circuitReady || finished || awaitingOutcome || submissions >= ceiling) return blocked()
|
|
281
|
+
if (submissions > 0 && (!retryEligible || !replaySafe())) return blocked()
|
|
282
|
+
const pending = makeRecord(submissions === 0 ? 'started' : 'retrying', {}, submissions + 1)
|
|
283
|
+
let admitted = false
|
|
284
|
+
try {
|
|
285
|
+
const result = await capGate?.(pending)
|
|
286
|
+
admitted = result === true || result?.allowed === true
|
|
287
|
+
} catch { admitted = false }
|
|
288
|
+
if (!admitted) return blocked('cap_blocked')
|
|
289
|
+
let circuitState
|
|
290
|
+
try { circuitState = circuit.preflight(targetKey) } catch { return blocked() }
|
|
291
|
+
if (!circuitState?.ok) return blocked('circuit_open')
|
|
292
|
+
submissions += 1
|
|
293
|
+
awaitingOutcome = true
|
|
294
|
+
retryEligible = false
|
|
295
|
+
return { admitted: true, record: emit(submissions === 1 ? 'started' : 'retrying') }
|
|
296
|
+
},
|
|
297
|
+
|
|
298
|
+
/** Classify a raw transport failure locally and expose only a coarse trace. */
|
|
299
|
+
failure(input = {}) {
|
|
300
|
+
if (!awaitingOutcome || finished) return null
|
|
301
|
+
awaitingOutcome = false
|
|
302
|
+
const failureClass = classifyProviderFailure(input)
|
|
303
|
+
if (failureClass === 'user_abort') aborted = true
|
|
304
|
+
if (failureClass === 'permission_denied') permissionDenied = true
|
|
305
|
+
let circuitUsable = true
|
|
306
|
+
if (TRANSIENT_FAILURES.has(failureClass)) {
|
|
307
|
+
try {
|
|
308
|
+
const state = circuit.failure(targetKey, checked.value, failureClass)
|
|
309
|
+
if (state?.state === 'open') circuitUsable = false
|
|
310
|
+
} catch { circuitUsable = false }
|
|
311
|
+
}
|
|
312
|
+
retryEligible = circuitUsable && checked.value.retryOn.includes(failureClass) && replaySafe() && submissions < ceiling
|
|
313
|
+
return emit('failed', { failureClass })
|
|
314
|
+
},
|
|
315
|
+
|
|
316
|
+
/** Close/reset the injected circuit after a completed provider response. */
|
|
317
|
+
success() {
|
|
318
|
+
if (!awaitingOutcome || finished) return null
|
|
319
|
+
awaitingOutcome = false
|
|
320
|
+
finished = true
|
|
321
|
+
try { circuit.success(targetKey) } catch { /* a completed response is never replayed */ }
|
|
322
|
+
return emit('succeeded')
|
|
323
|
+
},
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
export function safeTraceRecord(input = {}) {
|
|
328
|
+
const out = {}
|
|
329
|
+
for (const key of TRACE_FIELDS) {
|
|
330
|
+
if (input[key] === undefined || input[key] === null) continue
|
|
331
|
+
if (TRACE_TEXT_FIELDS.has(key)) {
|
|
332
|
+
const value = safeTraceText(input[key])
|
|
333
|
+
if (value) out[key] = value
|
|
334
|
+
} else if (key === 'turnRev') {
|
|
335
|
+
if (Number.isSafeInteger(input[key]) && input[key] >= 0) out[key] = input[key]
|
|
336
|
+
} else out[key] = input[key]
|
|
337
|
+
}
|
|
338
|
+
out.attempt = Math.max(1, Math.min(Number(out.attempt) || 1, MAX_NETWORK_ATTEMPTS))
|
|
339
|
+
if (!OUTCOMES.has(out.outcome)) out.outcome = 'failed'
|
|
340
|
+
if (out.failureClass && !/^[a-z0-9_]+$/.test(String(out.failureClass))) delete out.failureClass
|
|
341
|
+
for (const key of ['elapsedMs', 'retryAfterMs']) if (out[key] != null) out[key] = Math.max(0, Math.floor(Number(out[key]) || 0))
|
|
342
|
+
return out
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/** A room-safe line assembled exclusively from the allowlisted coarse record. */
|
|
346
|
+
export function formatResilienceTrace(input) {
|
|
347
|
+
const r = safeTraceRecord(input)
|
|
348
|
+
const target = [r.targetProviderName || r.targetProviderId, r.actualConfiguredModel].filter(Boolean).join(' / ') || 'configured provider'
|
|
349
|
+
const failure = r.failureClass ? ` (${r.failureClass.replaceAll('_', ' ')})` : ''
|
|
350
|
+
if (r.outcome === 'retrying') return `${target} unavailable${failure} — retrying once before output (attempt ${r.attempt} of ${MAX_SAME_TARGET_ATTEMPTS}).`
|
|
351
|
+
if (r.outcome === 'circuit_open') return `${target} is temporarily unavailable; its local circuit is open.`
|
|
352
|
+
if (r.outcome === 'cap_blocked') return `Additional provider work was blocked by the applicable cap.`
|
|
353
|
+
if (r.outcome === 'succeeded') return `${target} completed.`
|
|
354
|
+
if (r.outcome === 'fallback') return `Configured fallback selected: ${target}.`
|
|
355
|
+
return `${target} failed${failure}.`
|
|
356
|
+
}
|
package/providers.mjs
CHANGED
|
@@ -43,6 +43,12 @@ import fs from 'node:fs'
|
|
|
43
43
|
import path from 'node:path'
|
|
44
44
|
import crypto from 'node:crypto'
|
|
45
45
|
import { claudeish } from './event-id.mjs'
|
|
46
|
+
import {
|
|
47
|
+
DEFAULT_RESILIENCE_POLICY,
|
|
48
|
+
resilienceProjection,
|
|
49
|
+
resolveAttemptTarget,
|
|
50
|
+
validateResiliencePolicy,
|
|
51
|
+
} from './provider-resilience.mjs'
|
|
46
52
|
|
|
47
53
|
const DIR = path.join(os.homedir(), '.thinkpool-pair')
|
|
48
54
|
const REG_FILE = path.join(DIR, 'providers.json')
|
|
@@ -54,7 +60,7 @@ export const BUILTIN_ID = 'anthropic'
|
|
|
54
60
|
function ensureDir() { try { fs.mkdirSync(DIR, { recursive: true, mode: 0o700 }) } catch { /* noop */ } }
|
|
55
61
|
|
|
56
62
|
// ── registry load / save ───────────────────────────────────────────────
|
|
57
|
-
/** @returns {Array<{id:string,name:string,baseUrl:string,model?:string,key:string,addedAt:number}>} */
|
|
63
|
+
/** @returns {Array<{id:string,name:string,baseUrl:string,model?:string,key:string,addedAt:number,resilience?:object}>} */
|
|
58
64
|
export function loadProviders() {
|
|
59
65
|
try {
|
|
60
66
|
const arr = JSON.parse(fs.readFileSync(REG_FILE, 'utf8'))
|
|
@@ -244,6 +250,9 @@ export function addProviderModel({ id, model, name } = {}) {
|
|
|
244
250
|
baseUrl: src.baseUrl, // copied host-side
|
|
245
251
|
model: nextModel,
|
|
246
252
|
key: src.key, // copied host-side — never re-sent by the client
|
|
253
|
+
// A cloned credential is not consent to retry it. Each row needs its own
|
|
254
|
+
// explicit Phase-1 opt-in, even when both rows share the same endpoint/key.
|
|
255
|
+
resilience: { ...DEFAULT_RESILIENCE_POLICY, retryOn: [...DEFAULT_RESILIENCE_POLICY.retryOn], circuit: { ...DEFAULT_RESILIENCE_POLICY.circuit }, fallback: [] },
|
|
247
256
|
addedAt: Date.now(),
|
|
248
257
|
})
|
|
249
258
|
saveProviders(arr)
|
|
@@ -322,6 +331,55 @@ export function providerModel(id) {
|
|
|
322
331
|
return loadProviders().find((x) => x.id === id)?.model || null
|
|
323
332
|
}
|
|
324
333
|
|
|
334
|
+
/**
|
|
335
|
+
* Persist one custom provider row's Phase-1 same-target policy. This is a
|
|
336
|
+
* host-local primitive: account RPC/UI wiring deliberately lives elsewhere.
|
|
337
|
+
*
|
|
338
|
+
* `policy` may be a complete v1 policy, or a boolean for the conservative
|
|
339
|
+
* Phase-1 preset. Fallback declarations are rejected here: alternate targets
|
|
340
|
+
* require the later explicit-consent flow, so this API can never turn one on.
|
|
341
|
+
*/
|
|
342
|
+
export function setProviderResiliencePolicy(id, policy) {
|
|
343
|
+
if (!id || id === BUILTIN_ID) return { ok: false, error: 'the built-in Anthropic provider cannot use resilience policy' }
|
|
344
|
+
const arr = loadProviders()
|
|
345
|
+
const provider = arr.find((p) => p.id === id)
|
|
346
|
+
if (!provider) return { ok: false, error: 'no such provider' }
|
|
347
|
+
|
|
348
|
+
const input = typeof policy === 'boolean'
|
|
349
|
+
? { ...DEFAULT_RESILIENCE_POLICY, enabled: policy, retryOn: [...DEFAULT_RESILIENCE_POLICY.retryOn], circuit: { ...DEFAULT_RESILIENCE_POLICY.circuit }, fallback: [] }
|
|
350
|
+
: policy
|
|
351
|
+
const checked = validateResiliencePolicy(input, { providers: arr, primaryProviderId: id })
|
|
352
|
+
if (!checked.ok) return { ok: false, error: checked.error }
|
|
353
|
+
if (checked.value.fallback.length) return { ok: false, error: 'fallback is not available in Phase 1' }
|
|
354
|
+
// An enabled controller must have a configured exact model for its safe
|
|
355
|
+
// target record. A disabled policy remains valid for legacy/no-model rows.
|
|
356
|
+
if (checked.value.enabled && !resolveAttemptTarget({ providers: arr, providerId: id, model: provider.model })) {
|
|
357
|
+
return { ok: false, error: 'an enabled resilience policy requires an exact configured model' }
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
const next = arr.map((p) => p.id === id ? { ...p, resilience: checked.value } : p)
|
|
361
|
+
saveProviders(next)
|
|
362
|
+
return { ok: true, resilience: resilienceProjection(checked.value) }
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* Resolve a custom provider's persisted policy for bridge wiring without ever
|
|
367
|
+
* returning host credentials or endpoint data. Missing policy is safely
|
|
368
|
+
* disabled; malformed stored policy, built-in, unknown, and model-less targets
|
|
369
|
+
* fail closed with null.
|
|
370
|
+
*/
|
|
371
|
+
export function resolveProviderResiliencePolicy(id) {
|
|
372
|
+
if (!id || id === BUILTIN_ID) return null
|
|
373
|
+
const providers = loadProviders()
|
|
374
|
+
const provider = providers.find((p) => p.id === id)
|
|
375
|
+
if (!provider) return null
|
|
376
|
+
const checked = validateResiliencePolicy(provider.resilience, { providers, primaryProviderId: id })
|
|
377
|
+
if (!checked.ok) return null
|
|
378
|
+
const target = resolveAttemptTarget({ providers, providerId: id, model: provider.model })
|
|
379
|
+
if (!target) return null
|
|
380
|
+
return { policy: resilienceProjection(checked.value), target }
|
|
381
|
+
}
|
|
382
|
+
|
|
325
383
|
// ── read-only projections (never expose the raw key) ────────────────────
|
|
326
384
|
/** Masked list for the dashboard: keyHint = last 4 chars only. Built-in first. */
|
|
327
385
|
export function listProviders() {
|
|
@@ -331,8 +389,9 @@ export function listProviders() {
|
|
|
331
389
|
model: p.model || null,
|
|
332
390
|
group: providerGroup(p),
|
|
333
391
|
keyHint: keyHint(p.key),
|
|
392
|
+
resilience: resilienceProjection(p.resilience),
|
|
334
393
|
}))
|
|
335
|
-
return [{ id: BUILTIN_ID, name: 'Anthropic (Claude)', model: null, group: BUILTIN_ID, keyHint: null }, ...custom]
|
|
394
|
+
return [{ id: BUILTIN_ID, name: 'Anthropic (Claude)', model: null, group: BUILTIN_ID, keyHint: null, resilience: null }, ...custom]
|
|
336
395
|
}
|
|
337
396
|
|
|
338
397
|
/** Name-only projection for the announce/presence payload — NO key, NO baseUrl. */
|
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(
|
|
39
|
+
.map(safeRecapText)
|
|
32
40
|
.filter(Boolean)
|
|
33
|
-
const request =
|
|
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 =
|
|
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' &&
|
|
98
|
-
.map((b) => b.text
|
|
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
|
+
}
|
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
|
}
|