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.
- package/bridge.mjs +5 -0
- package/claude-session.mjs +11 -5
- 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 +56 -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 +8 -1
- package/provider-resilience.mjs +213 -0
- 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,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)
|
package/lane-lifecycle.mjs
CHANGED
|
@@ -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.
|
|
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(
|
|
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
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schemaVersion": 1,
|
|
3
|
-
"bundleVersion":
|
|
3
|
+
"bundleVersion": 18,
|
|
4
4
|
"contracts": [
|
|
5
5
|
{
|
|
6
6
|
"id": "room-coordination",
|
|
@@ -88,13 +88,13 @@
|
|
|
88
88
|
},
|
|
89
89
|
{
|
|
90
90
|
"id": "visual-proof",
|
|
91
|
-
"version":
|
|
91
|
+
"version": 5,
|
|
92
92
|
"routes": [
|
|
93
93
|
{
|
|
94
94
|
"id": "visual-proof",
|
|
95
95
|
"tools": ["preview_start", "preview_capture", "preview_inspect", "preview_stop"],
|
|
96
96
|
"trigger": "\\b(ui|ux|visual|design|frontend|html|css|page|route|mockup|screenshot|responsive|desktop|mobile|preview)\\b",
|
|
97
|
-
"prompt": "For built visual work, run the build, use preview_start, preview_capture at desktop and mobile, preview_inspect when rendered state matters, and preview_stop when finished. preview_capture uses a fresh isolated browser and is inline verification evidence by default; it must not create a transcript card. Set card=true only when the person asked for a mockup/Design artifact or the visual itself is an intentional deliverable that should remain editable in the transcript. A card requires settled meaningful content at both desktop and mobile, rejects loading/public-auth fallback shells, and is delivered after the final agent response. Room and protected routes automatically use the bridge account as a read-only authenticated visual harness; external REST writes, presence, ordinary room broadcasts, and refresh-token access are blocked, while non-mutating roster/transcript snapshot requests are allowed. Surface PNG evidence only when no interactive source-backed Design card is displayed."
|
|
97
|
+
"prompt": "For built visual work, run the build, use preview_start, preview_capture at desktop and mobile, preview_inspect when rendered state matters, and preview_stop when finished. For an intentional application-preview Design card, use the project's source-aware Design build command when one exists (for example npm run build:design); otherwise build normally and keep selector-based source matching labeled as fallback. preview_capture uses a fresh isolated browser and is inline verification evidence by default; it must not create a transcript card. Set card=true only when the person asked for a mockup/Design artifact or the visual itself is an intentional deliverable that should remain editable in the transcript. A card requires settled meaningful content at both desktop and mobile, rejects loading/public-auth fallback shells, and is delivered after the final agent response. Room and protected routes automatically use the bridge account as a read-only authenticated visual harness; external REST writes, presence, ordinary room broadcasts, and refresh-token access are blocked, while non-mutating roster/transcript snapshot requests are allowed. Surface PNG evidence only when no interactive source-backed Design card is displayed."
|
|
98
98
|
}
|
|
99
99
|
],
|
|
100
100
|
"impact": [
|
|
@@ -188,8 +188,8 @@
|
|
|
188
188
|
},
|
|
189
189
|
{
|
|
190
190
|
"id": "design-workspace",
|
|
191
|
-
"version":
|
|
192
|
-
"interactionPrompt": "DESIGN EDITING MODEL: every complete bridge preview_capture card and every trusted source-backed mockup card offers Edit in Design. The bridge freezes a safe rendered-DOM snapshot for application previews; Design edits are then applied by the producing lane to the real application source and verified by a fresh correlated desktop+mobile capture. For authored HTML, the producing lane edits the canonical authored HTML source directly. Edit in Design—not ordinary Preview or unrelated artifact delivery—arms a persistent room-level Design workspace, opens the artifact directly in editable mode, and adds Design · Page name beneath the producing terminal in its Ensemble row for both partners; it creates no terminal, agent runtime, or worker slot. The virtual lane can be closed from that Ensemble row without deleting the artifact. Once armed, the Design lane reopens the active artifact at its latest verified revision without another agent turn, history search, HTML/path request, or re-selection. Design is the default canvas there and Preview is only a temporary interaction mode. Direct text, supported movement, and selected-image changes auto-stage as optimistic drafts in the bounded changes queue; queued edits can be reopened, revised, or removed before Apply. Apply changes sends the ordered batch once to the producing lane, and a successful correlated desktop+mobile render advances the verified revision for the room. Never call a staged draft saved or live. When a person asks for many mockups or options that can share a surface, prefer one source-backed multi-option board or gallery in one file and one card so they can compare together; create separate files/cards only when the person explicitly requests independently editable artifacts or the options cannot be represented faithfully together.",
|
|
191
|
+
"version": 7,
|
|
192
|
+
"interactionPrompt": "DESIGN EDITING MODEL: every complete bridge preview_capture card and every trusted source-backed mockup card offers Edit in Design. The bridge freezes a safe rendered-DOM snapshot for application previews; Design edits are then applied by the producing lane to the real application source and verified by a fresh correlated desktop+mobile capture. For source-aware application previews, the bridge validates each opaque source identity against its contained host-only map and the current source-file hash before sharing an exact source location privately with the producing lane; uninstrumented elements keep the selector-based fallback, while stale or mismatched identities fail closed. For authored HTML, the producing lane edits the canonical authored HTML source directly. Edit in Design—not ordinary Preview or unrelated artifact delivery—arms a persistent room-level Design workspace, opens the artifact directly in editable mode, and adds Design · Page name beneath the producing terminal in its Ensemble row for both partners; it creates no terminal, agent runtime, or worker slot. The virtual lane can be closed from that Ensemble row without deleting the artifact. Once armed, the Design lane reopens the active artifact at its latest verified revision without another agent turn, history search, HTML/path request, or re-selection. Design is the default canvas there and Preview is only a temporary interaction mode. Direct text, supported movement, and selected-image changes auto-stage as optimistic drafts in the bounded changes queue; queued edits can be reopened, revised, or removed before Apply. Apply changes sends the ordered batch once to the producing lane, and a successful correlated desktop+mobile render advances the verified revision for the room. Never call a staged draft saved or live. When a person asks for many mockups or options that can share a surface, prefer one source-backed multi-option board or gallery in one file and one card so they can compare together; create separate files/cards only when the person explicitly requests independently editable artifacts or the options cannot be represented faithfully together.",
|
|
193
193
|
"turnReminder": "DESIGN ROUTE: intentional application-preview deliverables use preview_capture with card=true; correlated Design recaptures are recognized automatically. Authored HTML uses the source-backed render helper. Both must produce editable Thinkpool Design cards with verified desktop and mobile renders. Ordinary verification captures stay inline evidence and must not create cards. Cards are delivered after the final agent response so they remain the newest transcript item. Edit in Design explicitly arms the persistent Design workspace and adds its virtual Design · Page beneath the producing terminal in the Ensemble row; Preview alone does not arm it. For large direction sets, consolidate compatible options into one source-backed comparison board/gallery and one card; use separate files/cards only when independent editing is explicitly requested or technically necessary. Do not duplicate the card renders as inline PNGs. Use inline PNG evidence only when no interactive Design artifact is available.",
|
|
194
194
|
"impact": [
|
|
195
195
|
{"path": "src/pages/code/design/"},
|