thinkpool-pair 0.7.353 → 0.7.356

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/flow-preview.mjs CHANGED
@@ -82,6 +82,10 @@ function makeHandler (dir) {
82
82
  return async (req, res) => {
83
83
  const resolved = resolveUnderRoot(dir, req.url || '/')
84
84
  if (resolved === null) return send(res, 403, 'text/plain; charset=utf-8', 'Forbidden')
85
+ const relative = path.relative(dir, resolved)
86
+ if (relative.split(path.sep)[0] === '.thinkpool-design') {
87
+ return send(res, 404, 'text/plain; charset=utf-8', 'Not found')
88
+ }
85
89
 
86
90
  // A concrete file hit (and still a real file, not a dir) → serve it.
87
91
  if (await isFile(resolved)) {
@@ -16,8 +16,11 @@ function defaultRunGit(args, cwd) {
16
16
  }
17
17
 
18
18
  function cleanLine(value, max = 240) {
19
- return String(value || '')
20
- .replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g, '')
19
+ return Array.from(String(value || ''), (character) => {
20
+ const code = character.charCodeAt(0)
21
+ const removableControl = (code <= 0x1f && code !== 0x09 && code !== 0x0a && code !== 0x0d) || code === 0x7f
22
+ return removableControl ? '' : character
23
+ }).join('')
21
24
  .replace(/\s+/g, ' ')
22
25
  .trim()
23
26
  .slice(0, max)
@@ -1,3 +1,5 @@
1
+ import { evidenceForToolResult } from './evidence-citations.mjs'
2
+
1
3
  const textOf = (content) => {
2
4
  if (typeof content === 'string') return content
3
5
  if (content?.type === 'text') return String(content.text || '')
@@ -31,6 +33,9 @@ export function hermesToolFor(update = {}) {
31
33
  const kind = String(update.kind || '').toLowerCase()
32
34
  const title = String(update.title || '')
33
35
  const raw = update.rawInput && typeof update.rawInput === 'object' ? update.rawInput : {}
36
+ const server = String(update.server || raw.server || '')
37
+ const tool = String(update.tool || raw.tool || raw.name || '')
38
+ if (server === 'project-context' && tool === 'search_context_evidence') return { name: 'mcp__project-context__search_context_evidence', input: raw }
34
39
  const terminal = (update.content || []).find((part) => part?.type === 'terminal')
35
40
  const shellText = (update.content || []).map(textOf).find((text) => text.trim().startsWith('$ '))
36
41
  const location = update.locations?.[0]?.path
@@ -94,9 +99,11 @@ export class HermesEventMapper {
94
99
  this.tools.set(update.toolCallId, merged)
95
100
  if (!['completed', 'failed'].includes(update.status)) return
96
101
  const completedTool = hermesToolFor(merged)
102
+ const content = [{ type: 'text', text: outputText(merged) }]
103
+ const evidence = evidenceForToolResult(completedTool.name, content)
97
104
  this._emit({
98
105
  kind: 'tool_result', toolUseId: update.toolCallId,
99
- content: [{ type: 'text', text: outputText(merged) }],
106
+ content, ...(evidence ? { evidence } : {}),
100
107
  toolInput: { ...(prior.input || {}), ...(completedTool.input || {}) },
101
108
  isError: update.status === 'failed',
102
109
  durationMs: prior.startedAt ? Date.now() - prior.startedAt : undefined,
@@ -0,0 +1,83 @@
1
+ // A continuation is a durable-safe control projection, never a provider
2
+ // transcript or a command to start a new native turn.
3
+
4
+ import { isSecretFreeRoomPayload, runtimeSupportsCapability } from './runtime-contract.mjs'
5
+
6
+ const STATES = new Set(['waiting_for_human', 'resumable', 'canceled', 'terminal'])
7
+ const RESUME_KINDS = new Set(['human_response', 'approval', 'redispatch', 'reconnect'])
8
+ const EXECUTION = new Set(['Working', 'Done', 'Failed', 'Canceled'])
9
+ const SAFE_ID = /^[A-Za-z0-9][A-Za-z0-9._:@-]{0,159}$/
10
+ const dispatchers = new WeakSet()
11
+
12
+ const canonical = (value) => {
13
+ if (Array.isArray(value)) return value.map(canonical)
14
+ if (value && typeof value === 'object') return Object.fromEntries(Object.keys(value).sort().map((key) => [key, canonical(value[key])]))
15
+ return value
16
+ }
17
+ const same = (a, b) => JSON.stringify(canonical(a)) === JSON.stringify(canonical(b))
18
+ const safeId = (value) => typeof value === 'string' && SAFE_ID.test(value)
19
+ const denied = (code) => Object.freeze({ ok: false, code })
20
+ const clone = (value) => {
21
+ const freeze = (node) => {
22
+ if (node && typeof node === 'object') {
23
+ for (const child of Object.values(node)) freeze(child)
24
+ Object.freeze(node)
25
+ }
26
+ return node
27
+ }
28
+ return freeze(canonical(value))
29
+ }
30
+
31
+ export function isSafeContinuationProjection (record) {
32
+ if (!record || typeof record !== 'object' || Array.isArray(record) || record.version !== 1 || !safeId(record.id) || !safeId(record.laneRef) || !safeId(record.roomCode) || !safeId(record.authorityRef)) return false
33
+ if (!runtimeSupportsCapability(record.runtime, record.capabilityId) || !STATES.has(record.state) || record.verification !== 'unverified') return false
34
+ if (record.resumeKind != null && !RESUME_KINDS.has(record.resumeKind)) return false
35
+ if (record.resumeRef != null && (!record.resumeRef || typeof record.resumeRef !== 'object' || !safeId(record.resumeRef.type) || !safeId(record.resumeRef.id))) return false
36
+ if (!['canceled', 'terminal'].includes(record.state) && (!record.resumeKind || !record.resumeRef)) return false
37
+ if (!record.executionBoundary || !EXECUTION.has(record.executionBoundary.state) || !record.executionBoundary.eventRef || !isSecretFreeRoomPayload(record.executionBoundary.eventRef, 2048)) return false
38
+ if (record.expiresAt != null && (!Number.isSafeInteger(record.expiresAt) || record.expiresAt <= 0)) return false
39
+ return isSecretFreeRoomPayload(record, 8192)
40
+ }
41
+
42
+ export function createContinuationDispatcher ({ roomCode, authorityRef, now = () => Date.now() } = {}) {
43
+ if (!safeId(roomCode) || !safeId(authorityRef) || typeof now !== 'function') throw new TypeError('Invalid continuation dispatcher authority')
44
+ const records = new Map()
45
+ const dispatcher = {
46
+ create (record) {
47
+ const next = { ...record, version: 1, roomCode, authorityRef, verification: 'unverified' }
48
+ if (!isSafeContinuationProjection(next)) return denied('invalid_continuation')
49
+ const prior = records.get(next.id)
50
+ if (prior) return same(prior, next) ? Object.freeze({ ok: true, code: 'idempotent', record: prior }) : denied('continuation_conflict')
51
+ const frozen = clone(next)
52
+ records.set(frozen.id, frozen)
53
+ return Object.freeze({ ok: true, code: 'created', record: frozen })
54
+ },
55
+ consume ({ id, roomCode: consumeRoom, authorityRef: consumeAuthority, control } = {}) {
56
+ const record = records.get(id)
57
+ if (!record) return denied('unknown_continuation')
58
+ if (consumeRoom !== roomCode || consumeAuthority !== authorityRef) return denied('continuation_authority_mismatch')
59
+ if (record.state === 'canceled') return denied('continuation_canceled')
60
+ if (record.state === 'terminal') return denied('continuation_terminal')
61
+ if (record.expiresAt != null && now() > record.expiresAt) return denied('continuation_stale')
62
+ if (!control || control.authorized !== true || control.roomCode !== roomCode || control.authorityRef !== authorityRef || !same(control.resumeRef, record.resumeRef)) return denied('continuation_control_unverified')
63
+ // The adapter must explicitly act on this intent. Returning it is not an
64
+ // auto-resume and cannot fabricate an execution or verification result.
65
+ return Object.freeze({ ok: true, code: 'resume_intent', record, resumeIntent: Object.freeze({ runtime: record.runtime, laneRef: record.laneRef, resumeKind: record.resumeKind, resumeRef: record.resumeRef }) })
66
+ },
67
+ cancel ({ id, roomCode: cancelRoom, authorityRef: cancelAuthority, reason = 'interrupt' } = {}) {
68
+ const record = records.get(id)
69
+ if (!record) return denied('unknown_continuation')
70
+ if (cancelRoom !== roomCode || cancelAuthority !== authorityRef) return denied('continuation_authority_mismatch')
71
+ if (record.state === 'canceled') return Object.freeze({ ok: true, code: 'idempotent_cancel', record })
72
+ // Do not preserve an arbitrary reason string: it might be provider prose.
73
+ // The canceled execution boundary is the safe observable fact.
74
+ const canceled = clone({ ...record, state: 'canceled', resumeKind: null, resumeRef: null, executionBoundary: { ...record.executionBoundary, state: 'Canceled' }, verification: 'unverified' })
75
+ records.set(id, canceled)
76
+ return Object.freeze({ ok: true, code: 'canceled', record: canceled })
77
+ },
78
+ }
79
+ dispatchers.add(dispatcher)
80
+ return Object.freeze(dispatcher)
81
+ }
82
+
83
+ export const isBridgeContinuationDispatcher = (value) => dispatchers.has(value)
@@ -1,4 +1,5 @@
1
1
  import { classifyCodeEvent } from './code-event-contract.mjs'
2
+ import { isSafeContinuationProjection } from './lane-continuation.mjs'
2
3
 
3
4
  // This file ships in the standalone thinkpool-pair tarball. Keep the canonical
4
5
  // vocabulary here rather than importing from ../src (which is not packaged).
@@ -84,7 +85,7 @@ const base = (state, reason = null, extra = {}) => ({ state, reason, phase: null
84
85
  export function projectLaneLifecycle ({
85
86
  events = [], busy = false, pendingCount = 0, pendingReason = null,
86
87
  stalled = false, offline = false, stale = false, failed = false, canceled = false,
87
- rawStatus = null,
88
+ rawStatus = null, continuation = null,
88
89
  } = {}) {
89
90
  let out = base(LIFECYCLE_STATE.BLOCKED, BLOCKED_REASON.NEEDS_INPUT, { rawStatus })
90
91
  const replay = normalizeReplayEvents(events)
@@ -120,6 +121,11 @@ export function projectLaneLifecycle ({
120
121
  else if (failed) out = base(LIFECYCLE_STATE.FAILED, null, { rawStatus })
121
122
  else if (canceled) out = base(LIFECYCLE_STATE.CANCELED, null, { rawStatus })
122
123
  else if (pendingCount > 0) out = base(LIFECYCLE_STATE.BLOCKED, pendingReason || BLOCKED_REASON.NEEDS_DECISION, { rawStatus })
124
+ // This is explanation only. A continuation cannot certify verification or
125
+ // resurrect an interrupted/canceled execution boundary.
126
+ else if (isSafeContinuationProjection(continuation) && !TERMINAL.has(out.state) && continuation.state !== 'terminal') {
127
+ out = base(LIFECYCLE_STATE.BLOCKED, continuation.state === 'waiting_for_human' ? BLOCKED_REASON.NEEDS_INPUT : BLOCKED_REASON.NEEDS_DECISION, { rawStatus, phase: 'continuation' })
128
+ }
123
129
  else if (stalled) out = base(LIFECYCLE_STATE.BLOCKED, BLOCKED_REASON.STALLED, { rawStatus, phase: 'stalled' })
124
130
  else if (busy && !TERMINAL.has(out.state)) out = base(LIFECYCLE_STATE.WORKING, null, { rawStatus, phase: out.phase || 'active' })
125
131
  else if (offline && !TERMINAL.has(out.state)) out = base(LIFECYCLE_STATE.BLOCKED, BLOCKED_REASON.OFFLINE, { rawStatus })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thinkpool-pair",
3
- "version": "0.7.353",
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,7 +49,9 @@
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",
54
+ "repo-search.mjs",
52
55
  "git-diff-report.mjs",
53
56
  "runtime-session.mjs",
54
57
  "turn-stall.mjs",
@@ -56,6 +59,8 @@
56
59
  "agent-notify.mjs",
57
60
  "agent-detect.mjs",
58
61
  "code-event-contract.mjs",
62
+ "evidence-citations.mjs",
63
+ "error-recovery.mjs",
59
64
  "pair-control-authority.mjs",
60
65
  "event-id.mjs",
61
66
  "event-bounds.mjs",
@@ -70,6 +75,7 @@
70
75
  "pair-bus.mjs",
71
76
  "direct-pair-room.mjs",
72
77
  "lane-lifecycle.mjs",
78
+ "lane-continuation.mjs",
73
79
  "interrupted-resume.mjs",
74
80
  "dispatch-lease.mjs",
75
81
  "dispatch-permission-cleanup.mjs",
@@ -82,6 +88,7 @@
82
88
  "mockup-delivery.mjs",
83
89
  "viewport.mjs",
84
90
  "design-edit.mjs",
91
+ "design-source-contract.mjs",
85
92
  "flow-review.mjs",
86
93
  "review-check.mjs",
87
94
  "flow-review-gate.mjs",
@@ -105,6 +112,7 @@
105
112
  "supabase-key.mjs",
106
113
  "provider.mjs",
107
114
  "providers.mjs",
115
+ "provider-resilience.mjs",
108
116
  "model-prices.mjs",
109
117
  "README.md"
110
118
  ],
@@ -0,0 +1,213 @@
1
+ /*
2
+ * Provider resilience policy primitives.
3
+ *
4
+ * Phase 0 is deliberately policy-only: this module never makes a network call,
5
+ * never reads provider credentials, and never persists circuit or attempt state.
6
+ * The Claude session may observe its pre-existing recovery paths through the
7
+ * attempt ledger, but enabling this module cannot add a retry or fallback.
8
+ */
9
+ import { isSecretFreeRoomPayload } from './runtime-contract.mjs'
10
+
11
+ export const RESILIENCE_VERSION = 1
12
+ export const MAX_NETWORK_ATTEMPTS = 3
13
+ export const TRANSIENT_FAILURES = new Set(['timeout', 'network', 'rate_limit', 'overload', 'upstream_5xx'])
14
+ const RETRYABLE = [...TRANSIENT_FAILURES]
15
+ const TRACE_FIELDS = new Set(['traceId', 'turnRev', 'attempt', 'targetProviderId', 'targetProviderName', 'requestedModel', 'actualConfiguredModel', 'phase', 'outcome', 'failureClass', 'elapsedMs', 'retryAfterMs', 'fallbackFrom'])
16
+ const OUTCOMES = new Set(['started', 'retrying', 'failed', 'fallback', 'succeeded', 'circuit_open', 'cap_blocked'])
17
+ const TRACE_TEXT_FIELDS = new Set(['traceId', 'targetProviderId', 'targetProviderName', 'requestedModel', 'actualConfiguredModel', 'phase', 'failureClass', 'fallbackFrom'])
18
+ const TRACE_URL_OR_PATH = /(?:[a-z][a-z0-9+.-]*:\/\/|(?:^|[\s"'(])\/(?:Users|home|private|tmp)\/|[a-z]:\\|\\\\)/i
19
+
20
+ export const DEFAULT_RESILIENCE_POLICY = Object.freeze({
21
+ v: RESILIENCE_VERSION,
22
+ enabled: false,
23
+ timeoutMs: 90000,
24
+ maxAttempts: 2,
25
+ retryOn: RETRYABLE,
26
+ circuit: Object.freeze({ failureThreshold: 3, windowMs: 60000, cooldownMs: 30000 }),
27
+ fallback: Object.freeze([]),
28
+ })
29
+
30
+ const plainObject = (x) => !!x && typeof x === 'object' && !Array.isArray(x)
31
+ const positiveInt = (x) => Number.isInteger(x) && x > 0
32
+ const text = (x) => typeof x === 'string' && x.trim() ? x.trim() : null
33
+ const safeTraceText = (value) => {
34
+ const normalized = text(value)
35
+ if (!normalized || normalized.length > 160 || TRACE_URL_OR_PATH.test(normalized)) return null
36
+ return isSecretFreeRoomPayload({ value: normalized }, 512) ? normalized : null
37
+ }
38
+
39
+ /**
40
+ * Validate and canonicalize the non-secret provider policy. Missing policy is
41
+ * the explicit observe-only default. Extra fields are discarded rather than
42
+ * copied into a room-facing projection.
43
+ */
44
+ export function validateResiliencePolicy(input, { providers = [], primaryProviderId = null } = {}) {
45
+ if (input == null) return { ok: true, value: { ...DEFAULT_RESILIENCE_POLICY, retryOn: [...RETRYABLE], circuit: { ...DEFAULT_RESILIENCE_POLICY.circuit }, fallback: [] } }
46
+ if (!plainObject(input) || input.v !== RESILIENCE_VERSION) return { ok: false, error: 'unsupported resilience policy version' }
47
+ if (typeof input.enabled !== 'boolean') return { ok: false, error: 'enabled must be boolean' }
48
+ if (!Number.isInteger(input.timeoutMs) || input.timeoutMs < 30000 || input.timeoutMs > 120000) return { ok: false, error: 'timeoutMs must be 30000..120000' }
49
+ if (!positiveInt(input.maxAttempts)) return { ok: false, error: 'maxAttempts must be a positive integer' }
50
+ if (!Array.isArray(input.retryOn) || input.retryOn.some((x) => !TRANSIENT_FAILURES.has(x))) return { ok: false, error: 'retryOn contains an unsupported failure class' }
51
+ if (!plainObject(input.circuit) || !positiveInt(input.circuit.failureThreshold) || !positiveInt(input.circuit.windowMs) || !positiveInt(input.circuit.cooldownMs)) return { ok: false, error: 'invalid circuit policy' }
52
+ if (!Array.isArray(input.fallback)) return { ok: false, error: 'fallback must be an array' }
53
+ const fallback = []
54
+ for (const candidate of input.fallback) {
55
+ if (!plainObject(candidate) || !text(candidate.providerId) || !text(candidate.model) || candidate.allow !== true) return { ok: false, error: 'invalid fallback target' }
56
+ // Phase 0 stores only the conservative v1 declarations. A future UI may add
57
+ // explicit elevated consent, but it must not be smuggled in via this shape.
58
+ if (candidate.privacyClass !== 'same' || !['same', 'same-or-lower'].includes(candidate.costClass)) return { ok: false, error: 'fallback privacy/cost class is not eligible' }
59
+ if (primaryProviderId && candidate.providerId === primaryProviderId) return { ok: false, error: 'fallback cannot target itself' }
60
+ if (providers.length) {
61
+ const row = providers.find((provider) => provider?.id === candidate.providerId)
62
+ if (!row || row.id === 'anthropic' || row.model !== candidate.model) return { ok: false, error: 'fallback must name a registered exact provider/model target' }
63
+ }
64
+ fallback.push({ providerId: text(candidate.providerId), model: text(candidate.model), allow: true, privacyClass: candidate.privacyClass, costClass: candidate.costClass })
65
+ }
66
+ return {
67
+ ok: true,
68
+ value: {
69
+ v: RESILIENCE_VERSION,
70
+ enabled: input.enabled,
71
+ timeoutMs: input.timeoutMs,
72
+ // A malformed high value may never increase live network work.
73
+ maxAttempts: Math.min(input.maxAttempts, MAX_NETWORK_ATTEMPTS),
74
+ retryOn: [...new Set(input.retryOn)],
75
+ circuit: { failureThreshold: input.circuit.failureThreshold, windowMs: input.circuit.windowMs, cooldownMs: input.circuit.cooldownMs },
76
+ fallback,
77
+ },
78
+ }
79
+ }
80
+
81
+ /** Safe allowlist projection for local diagnostics / room-safe metadata. */
82
+ export function resilienceProjection(input) {
83
+ const validated = validateResiliencePolicy(input)
84
+ if (!validated.ok) return null
85
+ const p = validated.value
86
+ return { v: p.v, enabled: p.enabled, timeoutMs: p.timeoutMs, maxAttempts: p.maxAttempts, retryOn: [...p.retryOn], circuit: { ...p.circuit }, fallback: p.fallback.map((x) => ({ ...x })) }
87
+ }
88
+
89
+ /** Coarse classification only. It intentionally does not return raw errors. */
90
+ export function classifyProviderFailure(input = {}) {
91
+ const status = Number(input.status || input.statusCode || 0)
92
+ const code = String(input.code || '').toLowerCase()
93
+ const message = String(input.message || input.error?.message || '').toLowerCase()
94
+ if (input.aborted || /\babort(ed|error)?\b|cancelled|canceled/.test(`${code} ${message}`)) return 'user_abort'
95
+ if (input.permissionDenied || /permission denied|forbidden by policy|guardrail/.test(`${code} ${message}`)) return 'permission_denied'
96
+ if (status === 401 || status === 403 || /unauthori[sz]ed|invalid (api )?key|credential/.test(`${code} ${message}`)) return 'auth'
97
+ if (status === 429 || /rate.?limit/.test(`${code} ${message}`)) return 'rate_limit'
98
+ if (status === 529 || /overload|overloaded/.test(`${code} ${message}`)) return 'overload'
99
+ if (status >= 500 && status <= 599) return 'upstream_5xx'
100
+ if (/timeout|timed out|deadline exceeded|etimedout/.test(`${code} ${message}`)) return 'timeout'
101
+ if (/econnreset|enotfound|eai_again|socket|connection (closed|reset)|fetch failed|network error/.test(`${code} ${message}`)) return 'network'
102
+ if (status === 400 || /invalid model|unknown model/.test(`${code} ${message}`)) return 'invalid_model'
103
+ if (/context length|request too large|payload too large|too many tokens/.test(`${code} ${message}`)) return 'request_too_large'
104
+ if (status >= 400 && status < 500) return 'invalid_request'
105
+ return 'unknown'
106
+ }
107
+
108
+ export const isReplaySafe = ({ outputVisible = false, toolOutputVisible = false, aborted = false, permissionDenied = false, nonIdempotent = false } = {}) => !(outputVisible || toolOutputVisible || aborted || permissionDenied || nonIdempotent)
109
+
110
+ /** Resolve only registered custom Claude targets with their exact configured model. */
111
+ export function resolveAttemptTarget({ runtime = 'claude', providers = [], providerId, model } = {}) {
112
+ if (runtime !== 'claude' || !text(providerId) || !text(model)) return null
113
+ const provider = providers.find((p) => p && p.id === providerId && p.id !== 'anthropic' && p.model === model)
114
+ if (!provider) return null
115
+ return { providerId: provider.id, targetProviderName: text(provider.name) || provider.id, actualConfiguredModel: provider.model }
116
+ }
117
+
118
+ /** All eight Phase-2 eligibility gates in a pure, fail-closed form. */
119
+ export function assessFallbackEligibility({ runtime = 'claude', providers = [], primary, candidate, policy, outputVisible = false, toolOutputVisible = false, aborted = false, permissionDenied = false, capAllowed = true, circuit = 'closed' } = {}) {
120
+ if (!isReplaySafe({ outputVisible, toolOutputVisible, aborted, permissionDenied })) return { ok: false, reason: 'replay_unsafe' }
121
+ if (circuit !== 'closed') return { ok: false, reason: 'circuit_open' }
122
+ if (!capAllowed) return { ok: false, reason: 'cap_blocked' }
123
+ const checked = validateResiliencePolicy(policy)
124
+ if (!checked.ok || !checked.value.enabled || runtime !== 'claude') return { ok: false, reason: 'policy_disabled' }
125
+ const target = resolveAttemptTarget({ runtime, providers, providerId: candidate?.providerId, model: candidate?.model })
126
+ if (!target) return { ok: false, reason: 'unknown_target' }
127
+ const configured = checked.value.fallback.find((x) => x.providerId === target.providerId && x.model === target.actualConfiguredModel)
128
+ if (!configured?.allow) return { ok: false, reason: 'not_explicitly_allowed' }
129
+ if (configured.privacyClass !== 'same' || !['same', 'same-or-lower'].includes(configured.costClass)) return { ok: false, reason: 'boundary_not_consented' }
130
+ if (primary?.providerId === target.providerId && primary?.actualConfiguredModel === target.actualConfiguredModel) return { ok: false, reason: 'same_target' }
131
+ return { ok: true, target, fallback: configured }
132
+ }
133
+
134
+ /** Local-only total-attempt accounting. It is intentionally not a retry engine. */
135
+ export function createAttemptLedger({ maxAttempts = MAX_NETWORK_ATTEMPTS, traceId = 'local', turnRev = null, onRecord = null } = {}) {
136
+ const ceiling = Math.max(1, Math.min(Number.isInteger(maxAttempts) ? maxAttempts : 1, MAX_NETWORK_ATTEMPTS))
137
+ const records = []
138
+ const record = (entry = {}) => {
139
+ if (records.length >= ceiling) return null
140
+ const next = safeTraceRecord({ traceId, turnRev, attempt: Math.min(records.length + 1, ceiling), ...entry })
141
+ records.push(next)
142
+ try { onRecord?.(next) } catch { /* observation must never affect the session */ }
143
+ return next
144
+ }
145
+ return {
146
+ get ceiling() { return ceiling },
147
+ get attempts() { return records.length },
148
+ get records() { return records.map((x) => ({ ...x })) },
149
+ canSubmit() { return records.length < ceiling },
150
+ record,
151
+ }
152
+ }
153
+
154
+ /** Memory-only circuit breaker keyed by bridge/provider/exact-model. */
155
+ export function createMemoryCircuit({ now = () => Date.now() } = {}) {
156
+ const states = new Map()
157
+ const keyOf = ({ bridgeHostId, providerId, model } = {}) => `${bridgeHostId || ''}\u0000${providerId || ''}\u0000${model || ''}`
158
+ const stateFor = (target) => states.get(keyOf(target)) || { failures: [], openUntil: 0, probing: false }
159
+ const put = (target, state) => states.set(keyOf(target), state)
160
+ return {
161
+ preflight(target) {
162
+ const state = stateFor(target); const t = now()
163
+ if (state.openUntil > t) return { ok: false, state: 'open' }
164
+ if (state.openUntil && state.openUntil <= t) {
165
+ if (state.probing) return { ok: false, state: 'half_open_busy' }
166
+ state.probing = true; put(target, state); return { ok: true, state: 'half_open' }
167
+ }
168
+ return { ok: true, state: 'closed' }
169
+ },
170
+ success(target) { states.delete(keyOf(target)) },
171
+ failure(target, policy, failureClass) {
172
+ if (!TRANSIENT_FAILURES.has(failureClass)) return { state: this.preflight(target).state, counted: false }
173
+ const state = stateFor(target); const t = now(); const circuit = policy?.circuit || DEFAULT_RESILIENCE_POLICY.circuit
174
+ if (state.probing) { state.probing = false; state.failures = []; state.openUntil = t + circuit.cooldownMs; put(target, state); return { state: 'open', counted: true } }
175
+ state.failures = state.failures.filter((at) => at > t - circuit.windowMs)
176
+ state.failures.push(t)
177
+ if (state.failures.length >= circuit.failureThreshold) state.openUntil = t + circuit.cooldownMs
178
+ put(target, state)
179
+ return { state: state.openUntil > t ? 'open' : 'closed', counted: true }
180
+ },
181
+ // Test/diagnostic-only metadata. Never returns credentials, URLs, or prompts.
182
+ snapshot(target) { const s = stateFor(target); return { failures: s.failures.length, openUntil: s.openUntil, probing: s.probing } },
183
+ }
184
+ }
185
+
186
+ export function safeTraceRecord(input = {}) {
187
+ const out = {}
188
+ for (const key of TRACE_FIELDS) {
189
+ if (input[key] === undefined || input[key] === null) continue
190
+ if (TRACE_TEXT_FIELDS.has(key)) {
191
+ const value = safeTraceText(input[key])
192
+ if (value) out[key] = value
193
+ } else out[key] = input[key]
194
+ }
195
+ out.attempt = Math.max(1, Math.min(Number(out.attempt) || 1, MAX_NETWORK_ATTEMPTS))
196
+ if (!OUTCOMES.has(out.outcome)) out.outcome = 'failed'
197
+ if (out.failureClass && !/^[a-z0-9_]+$/.test(String(out.failureClass))) delete out.failureClass
198
+ for (const key of ['elapsedMs', 'retryAfterMs']) if (out[key] != null) out[key] = Math.max(0, Math.floor(Number(out[key]) || 0))
199
+ return out
200
+ }
201
+
202
+ /** A room-safe line assembled exclusively from the allowlisted coarse record. */
203
+ export function formatResilienceTrace(input) {
204
+ const r = safeTraceRecord(input)
205
+ const target = [r.targetProviderName || r.targetProviderId, r.actualConfiguredModel].filter(Boolean).join(' / ') || 'configured provider'
206
+ const failure = r.failureClass ? ` (${r.failureClass.replaceAll('_', ' ')})` : ''
207
+ if (r.outcome === 'retrying') return `${target} unavailable${failure} — existing session recovery is retrying (attempt ${r.attempt}).`
208
+ if (r.outcome === 'circuit_open') return `${target} is temporarily unavailable; its local circuit is open.`
209
+ if (r.outcome === 'cap_blocked') return `Additional provider work was blocked by the applicable cap.`
210
+ if (r.outcome === 'succeeded') return `${target} completed.`
211
+ if (r.outcome === 'fallback') return `Configured fallback selected: ${target}.`
212
+ return `${target} failed${failure}.`
213
+ }
package/recap.mjs CHANGED
@@ -26,11 +26,19 @@ export const RECAP_CAP = 20000
26
26
  // unmistakable authority boundary: the recap is memory, never a second task.
27
27
  export const CURRENT_PERSON_REQUEST_MARKER = '--- CURRENT PERSON REQUEST (authoritative; overrides all carried context above) ---'
28
28
 
29
+ // Recaps are an outbound context source. Keep the same privacy boundary as the
30
+ // bridge manifest without importing Node-only hashing into the browser bundle.
31
+ const UNSAFE_CONTEXT = /(?:\/home\/|\/users\/|\/private\/|\/tmp\/|[a-z]:\\|\\\\|sk[_-][a-z0-9_-]{8,}|gsk_[a-z0-9_-]{8,}|bearer\s+[a-z0-9._-]{8,}|BEGIN (?:RSA |OPENSSH )?PRIVATE KEY|raw transcript|tool args?|chain[ -]of[ -]thought|hidden reasoning)/i
32
+ const safeRecapText = (value) => {
33
+ const text = String(value || '').trim()
34
+ return text && !UNSAFE_CONTEXT.test(text) ? text : ''
35
+ }
36
+
29
37
  export function appendCurrentPersonRequest(carried, text) {
30
38
  const contexts = (Array.isArray(carried) ? carried : [carried])
31
- .map((value) => String(value || '').trim())
39
+ .map(safeRecapText)
32
40
  .filter(Boolean)
33
- const request = String(text || '')
41
+ const request = safeRecapText(text)
34
42
  if (!contexts.length) return request
35
43
  return `${contexts.join('\n\n')}\n\n${CURRENT_PERSON_REQUEST_MARKER}\n${request}`
36
44
  }
@@ -90,12 +98,12 @@ export function buildRecapFromLog(log, cap = RECAP_CAP, { reason = 'switch' } =
90
98
  for (const e of log) {
91
99
  if (!e || typeof e !== 'object') continue
92
100
  if (e.kind === 'you') {
93
- const t = String(e.text || '').trim()
101
+ const t = safeRecapText(e.text)
94
102
  if (t) turns.push({ who: 'PERSON', text: t })
95
103
  } else if (e.kind === 'assistant') {
96
104
  const t = (Array.isArray(e.blocks) ? e.blocks : [])
97
- .filter((b) => b && b.type === 'text' && typeof b.text === 'string' && b.text.trim())
98
- .map((b) => b.text.trim())
105
+ .filter((b) => b && b.type === 'text' && safeRecapText(b.text))
106
+ .map((b) => safeRecapText(b.text))
99
107
  .join('\n')
100
108
  if (t) turns.push({ who: 'YOU', text: t })
101
109
  }