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/bridge.mjs +20 -1
- package/claude-session.mjs +11 -5
- package/code-event-contract.mjs +10 -0
- package/codex-event-mapper.mjs +4 -1
- package/command-catalog.mjs +1 -0
- 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/git-diff-report.mjs +5 -2
- 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 +213 -0
- package/recap.mjs +13 -5
- package/repo-search.mjs +190 -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
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)) {
|
package/git-diff-report.mjs
CHANGED
|
@@ -16,8 +16,11 @@ function defaultRunGit(args, cwd) {
|
|
|
16
16
|
}
|
|
17
17
|
|
|
18
18
|
function cleanLine(value, max = 240) {
|
|
19
|
-
return String(value || '')
|
|
20
|
-
.
|
|
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)
|
package/hermes-event-mapper.mjs
CHANGED
|
@@ -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
|
|
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)
|
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,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(
|
|
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
|
}
|