thinkpool-pair 0.7.364 → 0.7.366

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.
Files changed (116) hide show
  1. package/LICENSE +18 -0
  2. package/README.md +20 -0
  3. package/abort-turn-barrier.mjs +1 -23
  4. package/account.mjs +1 -1437
  5. package/acp-client.mjs +1 -140
  6. package/agent-detect.mjs +1 -28
  7. package/agent-notify.mjs +1 -142
  8. package/agent-visibility.mjs +1 -67
  9. package/auth-store.mjs +1 -120
  10. package/bridge.mjs +1 -6266
  11. package/byok-detect.mjs +1 -126
  12. package/claude-command-catalog.mjs +1 -91
  13. package/claude-session.mjs +1 -1519
  14. package/code-event-contract.mjs +1 -118
  15. package/codex-app-server.mjs +1 -340
  16. package/codex-commands.mjs +1 -83
  17. package/codex-event-mapper.mjs +1 -232
  18. package/codex-images.mjs +1 -69
  19. package/codex-mcp-http.mjs +1 -131
  20. package/codex-session.mjs +1 -1295
  21. package/command-catalog.mjs +1 -118
  22. package/command-guidance.mjs +1 -8
  23. package/context-contract.mjs +1 -95
  24. package/context-windows.mjs +1 -107
  25. package/cross-terminal.mjs +1 -789
  26. package/cumulative-event-relay.mjs +1 -53
  27. package/design-edit.mjs +1 -424
  28. package/design-source-contract.mjs +1 -4
  29. package/direct-pair-room.mjs +1 -57
  30. package/dispatch-lease.mjs +1 -37
  31. package/dispatch-permission-cleanup.mjs +1 -86
  32. package/edit-diff.mjs +1 -136
  33. package/error-recovery.mjs +1 -50
  34. package/event-bounds.mjs +1 -121
  35. package/event-delivery-queue.mjs +1 -60
  36. package/event-id.mjs +1 -549
  37. package/evidence-citations.mjs +1 -50
  38. package/evidence-compact.mjs +1 -11
  39. package/flow-assembly.mjs +1 -196
  40. package/flow-budget.mjs +1 -84
  41. package/flow-conductor.mjs +1 -259
  42. package/flow-context-store.mjs +1 -387
  43. package/flow-host-revert.mjs +1 -42
  44. package/flow-models.mjs +1 -139
  45. package/flow-preview.mjs +1 -148
  46. package/flow-receipt.mjs +1 -122
  47. package/flow-redispatch.mjs +1 -72
  48. package/flow-review-gate.mjs +1 -402
  49. package/flow-review-reflect.mjs +1 -115
  50. package/flow-review.mjs +1 -152
  51. package/flow-scope-evidence.mjs +1 -117
  52. package/flow-skill-registry.mjs +1 -140
  53. package/flow-task-graph.mjs +1 -562
  54. package/flow-worktree.mjs +1 -71
  55. package/git-diff-report.mjs +1 -121
  56. package/hermes-delegation-guard.mjs +1 -14
  57. package/hermes-event-mapper.mjs +1 -194
  58. package/hermes-isolation.mjs +1 -61
  59. package/hermes-model-cache.mjs +1 -54
  60. package/hermes-policy.mjs +1 -92
  61. package/hermes-probe.mjs +1 -57
  62. package/hermes-session.mjs +1 -683
  63. package/hermes-setup.mjs +1 -167
  64. package/host-memory.mjs +1 -116
  65. package/interrupted-resume.mjs +1 -95
  66. package/keep-awake.mjs +1 -148
  67. package/key-shape.mjs +1 -49
  68. package/lane-continuation.mjs +1 -83
  69. package/lane-lifecycle.mjs +1 -189
  70. package/lane-worktree.mjs +1 -77
  71. package/launcher.mjs +1 -354
  72. package/mcp-flight-recorder.mjs +1 -79
  73. package/mockup-delivery.mjs +1 -57
  74. package/model-prices.mjs +1 -113
  75. package/package.json +13 -4
  76. package/pair-bus.mjs +1 -98
  77. package/pair-control-authority.mjs +1 -89
  78. package/past-work-search.mjs +1 -105
  79. package/plan-meters.mjs +1 -144
  80. package/presence.mjs +1 -191
  81. package/privacy-report.mjs +1 -108
  82. package/provider-resilience.mjs +1 -356
  83. package/provider.mjs +1 -133
  84. package/providers.mjs +1 -491
  85. package/publish-guard.mjs +2 -0
  86. package/publish-manifest.json +129 -0
  87. package/question-response.mjs +1 -58
  88. package/reap-terminal.mjs +1 -68
  89. package/recap.mjs +1 -297
  90. package/replay-transport.mjs +1 -64
  91. package/repo-search.mjs +1 -190
  92. package/review-check.mjs +1 -182
  93. package/runtime-contract.mjs +1 -93
  94. package/runtime-registry.mjs +1 -64
  95. package/runtime-session.mjs +1 -20
  96. package/scheduled-run-admission.mjs +1 -364
  97. package/scheduled-runs.mjs +1 -268
  98. package/sdk-admission.mjs +1 -9
  99. package/sdk-smoke.mjs +1 -61
  100. package/serve-consent.mjs +1 -118
  101. package/serve-dir.mjs +1 -40
  102. package/service.mjs +1 -877
  103. package/session-store.mjs +1 -484
  104. package/side-lane.mjs +1 -63
  105. package/supabase-key.mjs +1 -176
  106. package/supervisor-ready.mjs +1 -57
  107. package/switch-provider.mjs +1 -84
  108. package/terminal-name.mjs +1 -359
  109. package/terminal-row-reconcile.mjs +1 -70
  110. package/thinkpool-prompt-contracts.mjs +1 -85
  111. package/thinkpool-room-prompt.mjs +1 -187
  112. package/transcript-sanitize.mjs +1 -332
  113. package/turn-stall.mjs +1 -61
  114. package/update-gate.mjs +1 -53
  115. package/viewport.mjs +1 -810
  116. package/worker-completion.mjs +1 -57
@@ -1,356 +1 @@
1
- /*
2
- * Provider resilience policy primitives.
3
- *
4
- * This module never makes a network call or reads provider credentials. Phase 1
5
- * adds an injected same-target controller, but transport remains owned by the
6
- * Claude session and fallback remains unavailable.
7
- */
8
- import { isSecretFreeRoomPayload } from './runtime-contract.mjs'
9
-
10
- export const RESILIENCE_VERSION = 1
11
- export const MAX_NETWORK_ATTEMPTS = 3
12
- export const MAX_SAME_TARGET_ATTEMPTS = 2
13
- export const TRANSIENT_FAILURES = new Set(['timeout', 'network', 'rate_limit', 'overload', 'upstream_5xx'])
14
- const RETRYABLE = [...TRANSIENT_FAILURES]
15
- const TRACE_FIELDS = new Set(['traceId', 'turnRev', 'attempt', 'targetProviderId', 'targetProviderName', 'requestedModel', 'actualConfiguredModel', 'phase', 'outcome', 'failureClass', 'elapsedMs', 'retryAfterMs', 'fallbackFrom'])
16
- const OUTCOMES = new Set(['started', 'retrying', 'failed', 'fallback', 'succeeded', 'circuit_open', 'cap_blocked'])
17
- const TRACE_TEXT_FIELDS = new Set(['traceId', 'targetProviderId', 'targetProviderName', 'requestedModel', 'actualConfiguredModel', 'phase', 'failureClass', 'fallbackFrom'])
18
- const TRACE_URL_OR_PATH = /(?:[a-z][a-z0-9+.-]*:\/\/|(?:^|[\s"'(])\/(?:Users|home|private|tmp)\/|[a-z]:\\|\\\\)/i
19
-
20
- export const DEFAULT_RESILIENCE_POLICY = Object.freeze({
21
- v: RESILIENCE_VERSION,
22
- enabled: false,
23
- timeoutMs: 90000,
24
- maxAttempts: 2,
25
- retryOn: RETRYABLE,
26
- circuit: Object.freeze({ failureThreshold: 3, windowMs: 60000, cooldownMs: 30000 }),
27
- fallback: Object.freeze([]),
28
- })
29
-
30
- const plainObject = (x) => !!x && typeof x === 'object' && !Array.isArray(x)
31
- const positiveInt = (x) => Number.isInteger(x) && x > 0
32
- const text = (x) => typeof x === 'string' && x.trim() ? x.trim() : null
33
- const safeTraceText = (value) => {
34
- const normalized = text(value)
35
- if (!normalized || normalized.length > 160 || TRACE_URL_OR_PATH.test(normalized)) return null
36
- return isSecretFreeRoomPayload({ value: normalized }, 512) ? normalized : null
37
- }
38
-
39
- /**
40
- * Validate and canonicalize the non-secret provider policy. Missing policy is
41
- * the explicit observe-only default. Extra fields are discarded rather than
42
- * copied into a room-facing projection.
43
- */
44
- export function validateResiliencePolicy(input, { providers = [], primaryProviderId = null } = {}) {
45
- if (input == null) return { ok: true, value: { ...DEFAULT_RESILIENCE_POLICY, retryOn: [...RETRYABLE], circuit: { ...DEFAULT_RESILIENCE_POLICY.circuit }, fallback: [] } }
46
- if (!plainObject(input) || input.v !== RESILIENCE_VERSION) return { ok: false, error: 'unsupported resilience policy version' }
47
- if (typeof input.enabled !== 'boolean') return { ok: false, error: 'enabled must be boolean' }
48
- if (!Number.isInteger(input.timeoutMs) || input.timeoutMs < 30000 || input.timeoutMs > 120000) return { ok: false, error: 'timeoutMs must be 30000..120000' }
49
- if (!positiveInt(input.maxAttempts)) return { ok: false, error: 'maxAttempts must be a positive integer' }
50
- if (!Array.isArray(input.retryOn) || input.retryOn.some((x) => !TRANSIENT_FAILURES.has(x))) return { ok: false, error: 'retryOn contains an unsupported failure class' }
51
- if (!plainObject(input.circuit) || !positiveInt(input.circuit.failureThreshold) || !positiveInt(input.circuit.windowMs) || !positiveInt(input.circuit.cooldownMs)) return { ok: false, error: 'invalid circuit policy' }
52
- if (!Array.isArray(input.fallback)) return { ok: false, error: 'fallback must be an array' }
53
- const fallback = []
54
- for (const candidate of input.fallback) {
55
- if (!plainObject(candidate) || !text(candidate.providerId) || !text(candidate.model) || candidate.allow !== true) return { ok: false, error: 'invalid fallback target' }
56
- // Phase 0 stores only the conservative v1 declarations. A future UI may add
57
- // explicit elevated consent, but it must not be smuggled in via this shape.
58
- if (candidate.privacyClass !== 'same' || !['same', 'same-or-lower'].includes(candidate.costClass)) return { ok: false, error: 'fallback privacy/cost class is not eligible' }
59
- if (primaryProviderId && candidate.providerId === primaryProviderId) return { ok: false, error: 'fallback cannot target itself' }
60
- if (providers.length) {
61
- const row = providers.find((provider) => provider?.id === candidate.providerId)
62
- if (!row || row.id === 'anthropic' || row.model !== candidate.model) return { ok: false, error: 'fallback must name a registered exact provider/model target' }
63
- }
64
- fallback.push({ providerId: text(candidate.providerId), model: text(candidate.model), allow: true, privacyClass: candidate.privacyClass, costClass: candidate.costClass })
65
- }
66
- return {
67
- ok: true,
68
- value: {
69
- v: RESILIENCE_VERSION,
70
- enabled: input.enabled,
71
- timeoutMs: input.timeoutMs,
72
- // A malformed high value may never increase live network work.
73
- maxAttempts: Math.min(input.maxAttempts, MAX_NETWORK_ATTEMPTS),
74
- retryOn: [...new Set(input.retryOn)],
75
- circuit: { failureThreshold: input.circuit.failureThreshold, windowMs: input.circuit.windowMs, cooldownMs: input.circuit.cooldownMs },
76
- fallback,
77
- },
78
- }
79
- }
80
-
81
- /** Safe allowlist projection for local diagnostics / room-safe metadata. */
82
- export function resilienceProjection(input) {
83
- const validated = validateResiliencePolicy(input)
84
- if (!validated.ok) return null
85
- const p = validated.value
86
- return { v: p.v, enabled: p.enabled, timeoutMs: p.timeoutMs, maxAttempts: p.maxAttempts, retryOn: [...p.retryOn], circuit: { ...p.circuit }, fallback: p.fallback.map((x) => ({ ...x })) }
87
- }
88
-
89
- /** Coarse classification only. It intentionally does not return raw errors. */
90
- export function classifyProviderFailure(input = {}) {
91
- const status = Number(input.status || input.statusCode || 0)
92
- const code = String(input.code || '').toLowerCase()
93
- const message = String(input.message || input.error?.message || '').toLowerCase()
94
- if (input.aborted || /\babort(ed|error)?\b|cancelled|canceled/.test(`${code} ${message}`)) return 'user_abort'
95
- if (input.permissionDenied || /permission denied|forbidden by policy|guardrail/.test(`${code} ${message}`)) return 'permission_denied'
96
- if (status === 401 || status === 403 || /unauthori[sz]ed|invalid (api )?key|credential/.test(`${code} ${message}`)) return 'auth'
97
- if (status === 429 || /rate.?limit/.test(`${code} ${message}`)) return 'rate_limit'
98
- if (status === 529 || /overload|overloaded/.test(`${code} ${message}`)) return 'overload'
99
- if (status >= 500 && status <= 599) return 'upstream_5xx'
100
- if (/timeout|timed out|deadline exceeded|etimedout/.test(`${code} ${message}`)) return 'timeout'
101
- if (/econnreset|enotfound|eai_again|socket|connection (closed|reset)|fetch failed|network error/.test(`${code} ${message}`)) return 'network'
102
- if (status === 400 || /invalid model|unknown model/.test(`${code} ${message}`)) return 'invalid_model'
103
- if (/context length|request too large|payload too large|too many tokens/.test(`${code} ${message}`)) return 'request_too_large'
104
- if (status >= 400 && status < 500) return 'invalid_request'
105
- return 'unknown'
106
- }
107
-
108
- export const isReplaySafe = ({ outputVisible = false, toolOutputVisible = false, aborted = false, permissionDenied = false, nonIdempotent = false } = {}) => !(outputVisible || toolOutputVisible || aborted || permissionDenied || nonIdempotent)
109
-
110
- /** Resolve only registered custom Claude targets with their exact configured model. */
111
- export function resolveAttemptTarget({ runtime = 'claude', providers = [], providerId, model } = {}) {
112
- if (runtime !== 'claude' || !text(providerId) || !text(model)) return null
113
- const provider = providers.find((p) => p && p.id === providerId && p.id !== 'anthropic' && p.model === model)
114
- if (!provider) return null
115
- return { providerId: provider.id, targetProviderName: text(provider.name) || provider.id, actualConfiguredModel: provider.model }
116
- }
117
-
118
- /** All eight Phase-2 eligibility gates in a pure, fail-closed form. */
119
- export function assessFallbackEligibility({ runtime = 'claude', providers = [], primary, candidate, policy, outputVisible = false, toolOutputVisible = false, aborted = false, permissionDenied = false, capAllowed = true, circuit = 'closed' } = {}) {
120
- if (!isReplaySafe({ outputVisible, toolOutputVisible, aborted, permissionDenied })) return { ok: false, reason: 'replay_unsafe' }
121
- if (circuit !== 'closed') return { ok: false, reason: 'circuit_open' }
122
- if (!capAllowed) return { ok: false, reason: 'cap_blocked' }
123
- const checked = validateResiliencePolicy(policy)
124
- if (!checked.ok || !checked.value.enabled || runtime !== 'claude') return { ok: false, reason: 'policy_disabled' }
125
- const target = resolveAttemptTarget({ runtime, providers, providerId: candidate?.providerId, model: candidate?.model })
126
- if (!target) return { ok: false, reason: 'unknown_target' }
127
- const configured = checked.value.fallback.find((x) => x.providerId === target.providerId && x.model === target.actualConfiguredModel)
128
- if (!configured?.allow) return { ok: false, reason: 'not_explicitly_allowed' }
129
- if (configured.privacyClass !== 'same' || !['same', 'same-or-lower'].includes(configured.costClass)) return { ok: false, reason: 'boundary_not_consented' }
130
- if (primary?.providerId === target.providerId && primary?.actualConfiguredModel === target.actualConfiguredModel) return { ok: false, reason: 'same_target' }
131
- return { ok: true, target, fallback: configured }
132
- }
133
-
134
- /** Local-only total-attempt accounting. It is intentionally not a retry engine. */
135
- export function createAttemptLedger({ maxAttempts = MAX_NETWORK_ATTEMPTS, traceId = 'local', turnRev = null, onRecord = null } = {}) {
136
- const ceiling = Math.max(1, Math.min(Number.isInteger(maxAttempts) ? maxAttempts : 1, MAX_NETWORK_ATTEMPTS))
137
- const records = []
138
- const record = (entry = {}) => {
139
- if (records.length >= ceiling) return null
140
- const next = safeTraceRecord({ traceId, turnRev, attempt: Math.min(records.length + 1, ceiling), ...entry })
141
- records.push(next)
142
- try { onRecord?.(next) } catch { /* observation must never affect the session */ }
143
- return next
144
- }
145
- return {
146
- get ceiling() { return ceiling },
147
- get attempts() { return records.length },
148
- get records() { return records.map((x) => ({ ...x })) },
149
- canSubmit() { return records.length < ceiling },
150
- record,
151
- }
152
- }
153
-
154
- /** Memory-only circuit breaker keyed by bridge/provider/exact-model. */
155
- export function createMemoryCircuit({ now = () => Date.now() } = {}) {
156
- const states = new Map()
157
- const keyOf = ({ bridgeHostId, providerId, model } = {}) => `${bridgeHostId || ''}\u0000${providerId || ''}\u0000${model || ''}`
158
- const stateFor = (target) => states.get(keyOf(target)) || { failures: [], openUntil: 0, probing: false }
159
- const put = (target, state) => states.set(keyOf(target), state)
160
- return {
161
- preflight(target) {
162
- const state = stateFor(target); const t = now()
163
- if (state.openUntil > t) return { ok: false, state: 'open' }
164
- if (state.openUntil && state.openUntil <= t) {
165
- if (state.probing) return { ok: false, state: 'half_open_busy' }
166
- state.probing = true; put(target, state); return { ok: true, state: 'half_open' }
167
- }
168
- return { ok: true, state: 'closed' }
169
- },
170
- success(target) { states.delete(keyOf(target)) },
171
- failure(target, policy, failureClass) {
172
- if (!TRANSIENT_FAILURES.has(failureClass)) return { state: this.preflight(target).state, counted: false }
173
- const state = stateFor(target); const t = now(); const circuit = policy?.circuit || DEFAULT_RESILIENCE_POLICY.circuit
174
- if (state.probing) { state.probing = false; state.failures = []; state.openUntil = t + circuit.cooldownMs; put(target, state); return { state: 'open', counted: true } }
175
- state.failures = state.failures.filter((at) => at > t - circuit.windowMs)
176
- state.failures.push(t)
177
- if (state.failures.length >= circuit.failureThreshold) state.openUntil = t + circuit.cooldownMs
178
- put(target, state)
179
- return { state: state.openUntil > t ? 'open' : 'closed', counted: true }
180
- },
181
- // Test/diagnostic-only metadata. Never returns credentials, URLs, or prompts.
182
- snapshot(target) { const s = stateFor(target); return { failures: s.failures.length, openUntil: s.openUntil, probing: s.probing } },
183
- }
184
- }
185
-
186
- /**
187
- * Apply the only authoritative provider-work cap currently available at the
188
- * bridge boundary. Ordinary/uncapped lanes remain admissible, but the result
189
- * names that absence explicitly so the host can record `cap_not_configured`
190
- * rather than pretending an approval occurred.
191
- */
192
- export function providerResilienceCapAdmission(budget) {
193
- if (!budget || budget.capTokens == null) {
194
- return { allowed: true, configured: false, reason: 'cap_not_configured' }
195
- }
196
- const capTokens = Number(budget.capTokens)
197
- const spentTokens = Number(budget.spentTokens)
198
- const allowed = Number.isFinite(capTokens)
199
- && capTokens >= 0
200
- && Number.isFinite(spentTokens)
201
- && spentTokens >= 0
202
- && budget.killed !== true
203
- && spentTokens < capTokens
204
- return { allowed, configured: true, reason: allowed ? 'within_cap' : 'cap_blocked' }
205
- }
206
-
207
- /**
208
- * Pure, injected Phase-1 controller for a single exact custom Claude target.
209
- *
210
- * The bridge/session integration owns transport and invokes this controller at
211
- * turn start, before each submission, after visible output, and at terminal
212
- * success/failure. It intentionally has no fallback path and cannot resolve a
213
- * built-in Anthropic target. A caller must inject both the authoritative cap
214
- * admission gate and the bridge-scoped memory circuit; missing or broken gates
215
- * deny the submission rather than guessing that it is safe.
216
- */
217
- export function createSameTargetResilienceController({
218
- runtime = 'claude', providers = [], providerId, model, requestedModel = model,
219
- policy, bridgeHostId = null, circuit, capGate, traceId = 'local', turnRev = null,
220
- onRecord = null,
221
- } = {}) {
222
- let checked
223
- try { checked = validateResiliencePolicy(policy, { providers, primaryProviderId: providerId }) } catch { checked = { ok: false } }
224
- let target = null
225
- try { target = resolveAttemptTarget({ runtime, providers, providerId, model }) } catch { /* malformed registry is not retryable */ }
226
- const configured = checked?.ok && checked.value.enabled && target && runtime === 'claude'
227
- const circuitReady = !!circuit && typeof circuit.preflight === 'function' && typeof circuit.failure === 'function' && typeof circuit.success === 'function'
228
- const ceiling = configured ? Math.min(checked.value.maxAttempts, MAX_SAME_TARGET_ATTEMPTS) : 0
229
- const targetKey = target && { bridgeHostId, providerId: target.providerId, model: target.actualConfiguredModel }
230
- const base = target && {
231
- traceId,
232
- turnRev,
233
- targetProviderId: target.providerId,
234
- targetProviderName: target.targetProviderName,
235
- requestedModel,
236
- actualConfiguredModel: target.actualConfiguredModel,
237
- phase: 'same_target',
238
- }
239
- let started = false
240
- let submissions = 0
241
- let awaitingOutcome = false
242
- let retryEligible = false
243
- let outputVisible = false
244
- let toolOutputVisible = false
245
- let aborted = false
246
- let permissionDenied = false
247
- let finished = false
248
-
249
- const makeRecord = (outcome, extra = {}, attempt = submissions) => safeTraceRecord({
250
- ...base,
251
- attempt: Math.max(1, attempt),
252
- outcome,
253
- ...extra,
254
- })
255
- const emit = (outcome, extra) => {
256
- const record = makeRecord(outcome, extra)
257
- try { onRecord?.(record) } catch { /* trace observers are never control flow */ }
258
- return record
259
- }
260
- const blocked = (outcome = 'failed', extra) => ({ admitted: false, record: emit(outcome, extra) })
261
- const replaySafe = () => isReplaySafe({ outputVisible, toolOutputVisible, aborted, permissionDenied })
262
-
263
- return {
264
- /** Mark the controller as belonging to this turn; no provider work occurs here. */
265
- start() { started = true },
266
-
267
- /** Once any assistant/tool output is visible, automatic replay is permanently disabled. */
268
- visibleOutput({ tool = false } = {}) {
269
- outputVisible = true
270
- if (tool) toolOutputVisible = true
271
- retryEligible = false
272
- },
273
-
274
- /**
275
- * Admit exactly one upcoming submission. The cap gate is awaited before
276
- * transport, and any false/unknown/throwing result denies the attempt.
277
- */
278
- async preflight() {
279
- if (!started) started = true
280
- if (!configured || !circuitReady || finished || awaitingOutcome || submissions >= ceiling) return blocked()
281
- if (submissions > 0 && (!retryEligible || !replaySafe())) return blocked()
282
- const pending = makeRecord(submissions === 0 ? 'started' : 'retrying', {}, submissions + 1)
283
- let admitted = false
284
- try {
285
- const result = await capGate?.(pending)
286
- admitted = result === true || result?.allowed === true
287
- } catch { admitted = false }
288
- if (!admitted) return blocked('cap_blocked')
289
- let circuitState
290
- try { circuitState = circuit.preflight(targetKey) } catch { return blocked() }
291
- if (!circuitState?.ok) return blocked('circuit_open')
292
- submissions += 1
293
- awaitingOutcome = true
294
- retryEligible = false
295
- return { admitted: true, record: emit(submissions === 1 ? 'started' : 'retrying') }
296
- },
297
-
298
- /** Classify a raw transport failure locally and expose only a coarse trace. */
299
- failure(input = {}) {
300
- if (!awaitingOutcome || finished) return null
301
- awaitingOutcome = false
302
- const failureClass = classifyProviderFailure(input)
303
- if (failureClass === 'user_abort') aborted = true
304
- if (failureClass === 'permission_denied') permissionDenied = true
305
- let circuitUsable = true
306
- if (TRANSIENT_FAILURES.has(failureClass)) {
307
- try {
308
- const state = circuit.failure(targetKey, checked.value, failureClass)
309
- if (state?.state === 'open') circuitUsable = false
310
- } catch { circuitUsable = false }
311
- }
312
- retryEligible = circuitUsable && checked.value.retryOn.includes(failureClass) && replaySafe() && submissions < ceiling
313
- return emit('failed', { failureClass })
314
- },
315
-
316
- /** Close/reset the injected circuit after a completed provider response. */
317
- success() {
318
- if (!awaitingOutcome || finished) return null
319
- awaitingOutcome = false
320
- finished = true
321
- try { circuit.success(targetKey) } catch { /* a completed response is never replayed */ }
322
- return emit('succeeded')
323
- },
324
- }
325
- }
326
-
327
- export function safeTraceRecord(input = {}) {
328
- const out = {}
329
- for (const key of TRACE_FIELDS) {
330
- if (input[key] === undefined || input[key] === null) continue
331
- if (TRACE_TEXT_FIELDS.has(key)) {
332
- const value = safeTraceText(input[key])
333
- if (value) out[key] = value
334
- } else if (key === 'turnRev') {
335
- if (Number.isSafeInteger(input[key]) && input[key] >= 0) out[key] = input[key]
336
- } else out[key] = input[key]
337
- }
338
- out.attempt = Math.max(1, Math.min(Number(out.attempt) || 1, MAX_NETWORK_ATTEMPTS))
339
- if (!OUTCOMES.has(out.outcome)) out.outcome = 'failed'
340
- if (out.failureClass && !/^[a-z0-9_]+$/.test(String(out.failureClass))) delete out.failureClass
341
- for (const key of ['elapsedMs', 'retryAfterMs']) if (out[key] != null) out[key] = Math.max(0, Math.floor(Number(out[key]) || 0))
342
- return out
343
- }
344
-
345
- /** A room-safe line assembled exclusively from the allowlisted coarse record. */
346
- export function formatResilienceTrace(input) {
347
- const r = safeTraceRecord(input)
348
- const target = [r.targetProviderName || r.targetProviderId, r.actualConfiguredModel].filter(Boolean).join(' / ') || 'configured provider'
349
- const failure = r.failureClass ? ` (${r.failureClass.replaceAll('_', ' ')})` : ''
350
- if (r.outcome === 'retrying') return `${target} unavailable${failure} — retrying once before output (attempt ${r.attempt} of ${MAX_SAME_TARGET_ATTEMPTS}).`
351
- if (r.outcome === 'circuit_open') return `${target} is temporarily unavailable; its local circuit is open.`
352
- if (r.outcome === 'cap_blocked') return `Additional provider work was blocked by the applicable cap.`
353
- if (r.outcome === 'succeeded') return `${target} completed.`
354
- if (r.outcome === 'fallback') return `Configured fallback selected: ${target}.`
355
- return `${target} failed${failure}.`
356
- }
1
+ import{isSecretFreeRoomPayload as e}from"./runtime-contract.mjs";export const RESILIENCE_VERSION=1;export const MAX_NETWORK_ATTEMPTS=3;export const MAX_SAME_TARGET_ATTEMPTS=2;export const TRANSIENT_FAILURES=new Set(["timeout","network","rate_limit","overload","upstream_5xx"]);const t=[...TRANSIENT_FAILURES],r=new Set(["traceId","turnRev","attempt","targetProviderId","targetProviderName","requestedModel","actualConfiguredModel","phase","outcome","failureClass","elapsedMs","retryAfterMs","fallbackFrom"]),o=new Set(["started","retrying","failed","fallback","succeeded","circuit_open","cap_blocked"]),i=new Set(["traceId","targetProviderId","targetProviderName","requestedModel","actualConfiguredModel","phase","failureClass","fallbackFrom"]),a=/(?:[a-z][a-z0-9+.-]*:\/\/|(?:^|[\s"'(])\/(?:Users|home|private|tmp)\/|[a-z]:\\|\\\\)/i;export const DEFAULT_RESILIENCE_POLICY=Object.freeze({v:1,enabled:!1,timeoutMs:9e4,maxAttempts:2,retryOn:t,circuit:Object.freeze({failureThreshold:3,windowMs:6e4,cooldownMs:3e4}),fallback:Object.freeze([])});const n=e=>!!e&&"object"==typeof e&&!Array.isArray(e),l=e=>Number.isInteger(e)&&e>0,s=e=>"string"==typeof e&&e.trim()?e.trim():null,c=t=>{const r=s(t);return!r||r.length>160||a.test(r)?null:e({value:r},512)?r:null};export function validateResiliencePolicy(e,{providers:r=[],primaryProviderId:o=null}={}){if(null==e)return{ok:!0,value:{...DEFAULT_RESILIENCE_POLICY,retryOn:[...t],circuit:{...DEFAULT_RESILIENCE_POLICY.circuit},fallback:[]}};if(!n(e)||1!==e.v)return{ok:!1,error:"unsupported resilience policy version"};if("boolean"!=typeof e.enabled)return{ok:!1,error:"enabled must be boolean"};if(!Number.isInteger(e.timeoutMs)||e.timeoutMs<3e4||e.timeoutMs>12e4)return{ok:!1,error:"timeoutMs must be 30000..120000"};if(!l(e.maxAttempts))return{ok:!1,error:"maxAttempts must be a positive integer"};if(!Array.isArray(e.retryOn)||e.retryOn.some(e=>!TRANSIENT_FAILURES.has(e)))return{ok:!1,error:"retryOn contains an unsupported failure class"};if(!(n(e.circuit)&&l(e.circuit.failureThreshold)&&l(e.circuit.windowMs)&&l(e.circuit.cooldownMs)))return{ok:!1,error:"invalid circuit policy"};if(!Array.isArray(e.fallback))return{ok:!1,error:"fallback must be an array"};const i=[];for(const t of e.fallback){if(!n(t)||!s(t.providerId)||!s(t.model)||!0!==t.allow)return{ok:!1,error:"invalid fallback target"};if("same"!==t.privacyClass||!["same","same-or-lower"].includes(t.costClass))return{ok:!1,error:"fallback privacy/cost class is not eligible"};if(o&&t.providerId===o)return{ok:!1,error:"fallback cannot target itself"};if(r.length){const e=r.find(e=>e?.id===t.providerId);if(!e||"anthropic"===e.id||e.model!==t.model)return{ok:!1,error:"fallback must name a registered exact provider/model target"}}i.push({providerId:s(t.providerId),model:s(t.model),allow:!0,privacyClass:t.privacyClass,costClass:t.costClass})}return{ok:!0,value:{v:1,enabled:e.enabled,timeoutMs:e.timeoutMs,maxAttempts:Math.min(e.maxAttempts,3),retryOn:[...new Set(e.retryOn)],circuit:{failureThreshold:e.circuit.failureThreshold,windowMs:e.circuit.windowMs,cooldownMs:e.circuit.cooldownMs},fallback:i}}}export function resilienceProjection(e){const t=validateResiliencePolicy(e);if(!t.ok)return null;const r=t.value;return{v:r.v,enabled:r.enabled,timeoutMs:r.timeoutMs,maxAttempts:r.maxAttempts,retryOn:[...r.retryOn],circuit:{...r.circuit},fallback:r.fallback.map(e=>({...e}))}}export function classifyProviderFailure(e={}){const t=Number(e.status||e.statusCode||0),r=String(e.code||"").toLowerCase(),o=String(e.message||e.error?.message||"").toLowerCase();return e.aborted||/\babort(ed|error)?\b|cancelled|canceled/.test(`${r} ${o}`)?"user_abort":e.permissionDenied||/permission denied|forbidden by policy|guardrail/.test(`${r} ${o}`)?"permission_denied":401===t||403===t||/unauthori[sz]ed|invalid (api )?key|credential/.test(`${r} ${o}`)?"auth":429===t||/rate.?limit/.test(`${r} ${o}`)?"rate_limit":529===t||/overload|overloaded/.test(`${r} ${o}`)?"overload":t>=500&&t<=599?"upstream_5xx":/timeout|timed out|deadline exceeded|etimedout/.test(`${r} ${o}`)?"timeout":/econnreset|enotfound|eai_again|socket|connection (closed|reset)|fetch failed|network error/.test(`${r} ${o}`)?"network":400===t||/invalid model|unknown model/.test(`${r} ${o}`)?"invalid_model":/context length|request too large|payload too large|too many tokens/.test(`${r} ${o}`)?"request_too_large":t>=400&&t<500?"invalid_request":"unknown"}export const isReplaySafe=({outputVisible:e=!1,toolOutputVisible:t=!1,aborted:r=!1,permissionDenied:o=!1,nonIdempotent:i=!1}={})=>!(e||t||r||o||i);export function resolveAttemptTarget({runtime:e="claude",providers:t=[],providerId:r,model:o}={}){if("claude"!==e||!s(r)||!s(o))return null;const i=t.find(e=>e&&e.id===r&&"anthropic"!==e.id&&e.model===o);return i?{providerId:i.id,targetProviderName:s(i.name)||i.id,actualConfiguredModel:i.model}:null}export function assessFallbackEligibility({runtime:e="claude",providers:t=[],primary:r,candidate:o,policy:i,outputVisible:a=!1,toolOutputVisible:n=!1,aborted:l=!1,permissionDenied:s=!1,capAllowed:c=!0,circuit:u="closed"}={}){if(!isReplaySafe({outputVisible:a,toolOutputVisible:n,aborted:l,permissionDenied:s}))return{ok:!1,reason:"replay_unsafe"};if("closed"!==u)return{ok:!1,reason:"circuit_open"};if(!c)return{ok:!1,reason:"cap_blocked"};const d=validateResiliencePolicy(i);if(!d.ok||!d.value.enabled||"claude"!==e)return{ok:!1,reason:"policy_disabled"};const p=resolveAttemptTarget({runtime:e,providers:t,providerId:o?.providerId,model:o?.model});if(!p)return{ok:!1,reason:"unknown_target"};const f=d.value.fallback.find(e=>e.providerId===p.providerId&&e.model===p.actualConfiguredModel);return f?.allow?"same"===f.privacyClass&&["same","same-or-lower"].includes(f.costClass)?r?.providerId===p.providerId&&r?.actualConfiguredModel===p.actualConfiguredModel?{ok:!1,reason:"same_target"}:{ok:!0,target:p,fallback:f}:{ok:!1,reason:"boundary_not_consented"}:{ok:!1,reason:"not_explicitly_allowed"}}export function createAttemptLedger({maxAttempts:e=3,traceId:t="local",turnRev:r=null,onRecord:o=null}={}){const i=Math.max(1,Math.min(Number.isInteger(e)?e:1,3)),a=[];return{get ceiling(){return i},get attempts(){return a.length},get records(){return a.map(e=>({...e}))},canSubmit:()=>a.length<i,record:(e={})=>{if(a.length>=i)return null;const n=safeTraceRecord({traceId:t,turnRev:r,attempt:Math.min(a.length+1,i),...e});a.push(n);try{o?.(n)}catch{}return n}}}export function createMemoryCircuit({now:e=()=>Date.now()}={}){const t=new Map,r=({bridgeHostId:e,providerId:t,model:r}={})=>`${e||""}\0${t||""}\0${r||""}`,o=e=>t.get(r(e))||{failures:[],openUntil:0,probing:!1},i=(e,o)=>t.set(r(e),o);return{preflight(t){const r=o(t),a=e();return r.openUntil>a?{ok:!1,state:"open"}:r.openUntil&&r.openUntil<=a?r.probing?{ok:!1,state:"half_open_busy"}:(r.probing=!0,i(t,r),{ok:!0,state:"half_open"}):{ok:!0,state:"closed"}},success(e){t.delete(r(e))},failure(t,r,a){if(!TRANSIENT_FAILURES.has(a))return{state:this.preflight(t).state,counted:!1};const n=o(t),l=e(),s=r?.circuit||DEFAULT_RESILIENCE_POLICY.circuit;return n.probing?(n.probing=!1,n.failures=[],n.openUntil=l+s.cooldownMs,i(t,n),{state:"open",counted:!0}):(n.failures=n.failures.filter(e=>e>l-s.windowMs),n.failures.push(l),n.failures.length>=s.failureThreshold&&(n.openUntil=l+s.cooldownMs),i(t,n),{state:n.openUntil>l?"open":"closed",counted:!0})},snapshot(e){const t=o(e);return{failures:t.failures.length,openUntil:t.openUntil,probing:t.probing}}}}export function providerResilienceCapAdmission(e){if(!e||null==e.capTokens)return{allowed:!0,configured:!1,reason:"cap_not_configured"};const t=Number(e.capTokens),r=Number(e.spentTokens),o=Number.isFinite(t)&&t>=0&&Number.isFinite(r)&&r>=0&&!0!==e.killed&&r<t;return{allowed:o,configured:!0,reason:o?"within_cap":"cap_blocked"}}export function createSameTargetResilienceController({runtime:e="claude",providers:t=[],providerId:r,model:o,requestedModel:i=o,policy:a,bridgeHostId:n=null,circuit:l,capGate:s,traceId:c="local",turnRev:u=null,onRecord:d=null}={}){let p;try{p=validateResiliencePolicy(a,{providers:t,primaryProviderId:r})}catch{p={ok:!1}}let f=null;try{f=resolveAttemptTarget({runtime:e,providers:t,providerId:r,model:o})}catch{}const m=p?.ok&&p.value.enabled&&f&&"claude"===e,b=!!l&&"function"==typeof l.preflight&&"function"==typeof l.failure&&"function"==typeof l.success,v=m?Math.min(p.value.maxAttempts,2):0,g=f&&{bridgeHostId:n,providerId:f.providerId,model:f.actualConfiguredModel},y=f&&{traceId:c,turnRev:u,targetProviderId:f.providerId,targetProviderName:f.targetProviderName,requestedModel:i,actualConfiguredModel:f.actualConfiguredModel,phase:"same_target"};let k=!1,h=0,I=!1,_=!1,M=!1,w=!1,A=!1,C=!1,R=!1;const T=(e,t={},r=h)=>safeTraceRecord({...y,attempt:Math.max(1,r),outcome:e,...t}),x=(e,t)=>{const r=T(e,t);try{d?.(r)}catch{}return r},E=(e="failed",t)=>({admitted:!1,record:x(e,t)}),N=()=>isReplaySafe({outputVisible:M,toolOutputVisible:w,aborted:A,permissionDenied:C});return{start(){k=!0},visibleOutput({tool:e=!1}={}){M=!0,e&&(w=!0),_=!1},async preflight(){if(k||(k=!0),!m||!b||R||I||h>=v)return E();if(h>0&&(!_||!N()))return E();const e=T(0===h?"started":"retrying",{},h+1);let t,r=!1;try{const t=await(s?.(e));r=!0===t||!0===t?.allowed}catch{r=!1}if(!r)return E("cap_blocked");try{t=l.preflight(g)}catch{return E()}return t?.ok?(h+=1,I=!0,_=!1,{admitted:!0,record:x(1===h?"started":"retrying")}):E("circuit_open")},failure(e={}){if(!I||R)return null;I=!1;const t=classifyProviderFailure(e);"user_abort"===t&&(A=!0),"permission_denied"===t&&(C=!0);let r=!0;if(TRANSIENT_FAILURES.has(t))try{const e=l.failure(g,p.value,t);"open"===e?.state&&(r=!1)}catch{r=!1}return _=r&&p.value.retryOn.includes(t)&&N()&&h<v,x("failed",{failureClass:t})},success(){if(!I||R)return null;I=!1,R=!0;try{l.success(g)}catch{}return x("succeeded")}}}export function safeTraceRecord(e={}){const t={};for(const o of r)if(void 0!==e[o]&&null!==e[o])if(i.has(o)){const r=c(e[o]);r&&(t[o]=r)}else"turnRev"===o?Number.isSafeInteger(e[o])&&e[o]>=0&&(t[o]=e[o]):t[o]=e[o];t.attempt=Math.max(1,Math.min(Number(t.attempt)||1,3)),o.has(t.outcome)||(t.outcome="failed"),t.failureClass&&!/^[a-z0-9_]+$/.test(String(t.failureClass))&&delete t.failureClass;for(const e of["elapsedMs","retryAfterMs"])null!=t[e]&&(t[e]=Math.max(0,Math.floor(Number(t[e])||0)));return t}export function formatResilienceTrace(e){const t=safeTraceRecord(e),r=[t.targetProviderName||t.targetProviderId,t.actualConfiguredModel].filter(Boolean).join(" / ")||"configured provider",o=t.failureClass?` (${t.failureClass.replaceAll("_"," ")})`:"";return"retrying"===t.outcome?`${r} unavailable${o} — retrying once before output (attempt ${t.attempt} of 2).`:"circuit_open"===t.outcome?`${r} is temporarily unavailable; its local circuit is open.`:"cap_blocked"===t.outcome?"Additional provider work was blocked by the applicable cap.":"succeeded"===t.outcome?`${r} completed.`:"fallback"===t.outcome?`Configured fallback selected: ${r}.`:`${r} failed${o}.`}
package/provider.mjs CHANGED
@@ -1,133 +1 @@
1
- /* ─────────────────────────────────────────────────────────────
2
- provider.mjs — per-machine LLM provider choice for the ThinkPool
3
- bridge. Lets a Code room run on ANY Anthropic-compatible endpoint
4
- (Z.ai GLM, OpenRouter, a local proxy, …) instead of plain Anthropic,
5
- INDEPENDENT of the launching shell. Why this exists: the launchd
6
- service does NOT source ~/.zshrc, so a shell `export ANTHROPIC_*` can
7
- never reach a serviced bridge — its spawned Claude children fall back
8
- to the host's claude.ai login and hit the Anthropic spend wall.
9
- provider.json is the bridge's OWN source of truth.
10
-
11
- applyProviderEnv() runs once at the top of bridge.mjs (the universal
12
- entry point), BEFORE any child is spawned. It sets ANTHROPIC_BASE_URL
13
- / ANTHROPIC_AUTH_TOKEN / ANTHROPIC_MODEL on process.env. Because the
14
- account-supervisor spawn (account.mjs) and the Agent-SDK env
15
- (claude-session.mjs) both spread `...process.env`, the choice reaches
16
- every code session with no other change. Default (no file, or
17
- provider:"anthropic") = the host's regular Claude login, untouched.
18
-
19
- CLI-agent-compatible ONLY. The bridge spawns the `claude` CLI, which
20
- talks the Anthropic Messages API — so the base url MUST be an
21
- Anthropic-compatible root (the SDK appends /v1/messages). An OpenAI-
22
- style /v1/chat/completions endpoint will NOT work. Common roots:
23
- Z.ai GLM → https://api.z.ai/api/anthropic
24
- OpenRouter → https://openrouter.ai/api
25
-
26
- CLI (dispatched from bridge.mjs):
27
- npx thinkpool-pair provider # show current
28
- npx thinkpool-pair provider custom --base <url> --token <key> [--model <m>] # any Anthropic-compatible endpoint
29
- npx thinkpool-pair provider anthropic # reset to Claude
30
- ───────────────────────────────────────────────────────────── */
31
- import os from 'node:os'
32
- import fs from 'node:fs'
33
- import path from 'node:path'
34
- import { pairCli } from './command-guidance.mjs'
35
-
36
- const DIR = path.join(os.homedir(), '.thinkpool-pair')
37
- const FILE = path.join(DIR, 'provider.json')
38
-
39
- function ensureDir() { try { fs.mkdirSync(DIR, { recursive: true }) } catch { /* noop */ } }
40
- function opt(args, flag) { const i = args.indexOf(flag); return i >= 0 ? args[i + 1] : undefined }
41
-
42
- export function loadProvider() {
43
- try { return JSON.parse(fs.readFileSync(FILE, 'utf8')) } catch { return null }
44
- }
45
-
46
- export function saveProvider(p) {
47
- ensureDir()
48
- fs.writeFileSync(FILE, JSON.stringify(p, null, 2), { mode: 0o600 })
49
- }
50
-
51
- /**
52
- * Merge the persisted provider choice (or TP_* env overrides) into
53
- * process.env. Call once at startup, before spawning any child. Idempotent.
54
- * A no-op for the default Anthropic provider — we only ever SET the custom
55
- * vars, never clobber a host's real Claude login env. Env vars win over the
56
- * file so a one-off launch can flip providers without writing anything.
57
- *
58
- * Provider-name-agnostic: any provider saved to provider.json (glm, custom,
59
- * a leftover openrouter file, …) is handled by one branch that only cares
60
- * that baseUrl/authToken are present.
61
- * @returns {{provider:string,baseUrl?:string,model?:string,source:string}}
62
- */
63
- export function applyProviderEnv() {
64
- const cfg = loadProvider() || {}
65
- const provider = (process.env.TP_PROVIDER || cfg.provider || 'anthropic').toLowerCase()
66
- if (provider === 'anthropic' || !provider) return { provider: 'anthropic', source: 'default' }
67
-
68
- const baseUrl = process.env.TP_ANTHROPIC_BASE_URL || cfg.baseUrl
69
- const authToken = process.env.TP_ANTHROPIC_AUTH_TOKEN || cfg.authToken
70
- const model = process.env.TP_ANTHROPIC_MODEL || cfg.model
71
- if (!baseUrl || !authToken) {
72
- process.stderr.write(`\n ◇ provider "${provider}" is set but is missing baseUrl/authToken.\n Finish setup: ${pairCli('provider', 'custom', '--base', '<url>', '--token', '<key>')}\n Falling back to regular Claude for now.\n`)
73
- return { provider: 'anthropic', source: 'incomplete' }
74
- }
75
- process.env.ANTHROPIC_BASE_URL = baseUrl
76
- process.env.ANTHROPIC_AUTH_TOKEN = authToken
77
- if (model) process.env.ANTHROPIC_MODEL = model
78
- return { provider, baseUrl, model, source: cfg.provider ? 'file' : 'env' }
79
- }
80
-
81
- /** CLI handler for `thinkpool-pair provider [...]`. */
82
- export async function runProvider(args) {
83
- const sub = (args[0] || '').toLowerCase()
84
-
85
- // ── show ──
86
- if (!sub || sub === 'show' || sub === 'status') {
87
- const cfg = loadProvider()
88
- if (!cfg || cfg.provider === 'anthropic' || !cfg.provider) {
89
- console.error('\n ◆ provider: anthropic (regular Claude login) — the default.\n')
90
- } else {
91
- console.error(`\n ◆ provider: ${cfg.provider}\n base url: ${cfg.baseUrl || '(unset)'}\n model: ${cfg.model || '(unset)'}\n token: ${cfg.authToken ? cfg.authToken.slice(0, 8) + '…' : '(unset)'}\n`)
92
- }
93
- return
94
- }
95
-
96
- // ── reset to Anthropic ──
97
- if (sub === 'anthropic' || sub === 'clear' || sub === 'reset') {
98
- try { fs.unlinkSync(FILE) } catch { /* noop */ }
99
- console.error('\n ✓ provider reset to anthropic (regular Claude login).\n')
100
- return
101
- }
102
-
103
- // ── any Anthropic-compatible endpoint ──
104
- if (sub === 'custom') {
105
- const token = opt(args, '--token') || opt(args, '--key')
106
- const baseUrl = opt(args, '--base') || opt(args, '--base-url')
107
- const model = opt(args, '--model')
108
- if (!token || !baseUrl) {
109
- console.error(
110
- '\n ✗ custom needs --base <url> and --token <key>. [--model <name>] is optional.\n' +
111
- ' The base url is the Anthropic-compatible root the SDK appends /v1/messages to:\n' +
112
- ' Z.ai GLM → https://api.z.ai/api/anthropic\n' +
113
- ' OpenRouter → https://openrouter.ai/api\n' +
114
- ' Restart the bridge (or its launchd service) to apply.\n'
115
- )
116
- process.exit(1)
117
- }
118
- saveProvider({ provider: 'custom', baseUrl, authToken: token, model: model || undefined, savedAt: Date.now() })
119
- console.error(`\n ✓ provider set to custom.\n base url: ${baseUrl}\n model: ${model || '(default)'}\n token: ${token.slice(0, 8)}…\n Restart the bridge (or its launchd service) to apply.\n`)
120
- return
121
- }
122
-
123
- console.error(
124
- '\n usage:\n' +
125
- ` ${pairCli('provider')} # show current\n` +
126
- ` ${pairCli('provider', 'custom', '--base', '<url>', '--token', '<key>', '[--model <m>]')} # any Anthropic-compatible endpoint\n` +
127
- ` ${pairCli('provider', 'anthropic')} # reset to Claude\n\n` +
128
- ' common base urls (SDK appends /v1/messages):\n' +
129
- ' Z.ai GLM → https://api.z.ai/api/anthropic\n' +
130
- ' OpenRouter → https://openrouter.ai/api\n'
131
- )
132
- process.exit(1)
133
- }
1
+ import e from"node:os";import r from"node:fs";import o from"node:path";import{pairCli as n}from"./command-guidance.mjs";const t=o.join(e.homedir(),".thinkpool-pair"),s=o.join(t,"provider.json");function i(e,r){const o=e.indexOf(r);return o>=0?e[o+1]:void 0}export function loadProvider(){try{return JSON.parse(r.readFileSync(s,"utf8"))}catch{return null}}export function saveProvider(e){!function(){try{r.mkdirSync(t,{recursive:!0})}catch{}}(),r.writeFileSync(s,JSON.stringify(e,null,2),{mode:384})}export function applyProviderEnv(){const e=loadProvider()||{},r=(process.env.TP_PROVIDER||e.provider||"anthropic").toLowerCase();if("anthropic"===r||!r)return{provider:"anthropic",source:"default"};const o=process.env.TP_ANTHROPIC_BASE_URL||e.baseUrl,t=process.env.TP_ANTHROPIC_AUTH_TOKEN||e.authToken,s=process.env.TP_ANTHROPIC_MODEL||e.model;return o&&t?(process.env.ANTHROPIC_BASE_URL=o,process.env.ANTHROPIC_AUTH_TOKEN=t,s&&(process.env.ANTHROPIC_MODEL=s),{provider:r,baseUrl:o,model:s,source:e.provider?"file":"env"}):(process.stderr.write(`\n ◇ provider "${r}" is set but is missing baseUrl/authToken.\n Finish setup: ${n("provider","custom","--base","<url>","--token","<key>")}\n Falling back to regular Claude for now.\n`),{provider:"anthropic",source:"incomplete"})}export async function runProvider(e){const o=(e[0]||"").toLowerCase();if(!o||"show"===o||"status"===o){const e=loadProvider();return void(e&&"anthropic"!==e.provider&&e.provider?console.error(`\n ◆ provider: ${e.provider}\n base url: ${e.baseUrl||"(unset)"}\n model: ${e.model||"(unset)"}\n token: ${e.authToken?e.authToken.slice(0,8)+"…":"(unset)"}\n`):console.error("\n ◆ provider: anthropic (regular Claude login) — the default.\n"))}if("anthropic"!==o&&"clear"!==o&&"reset"!==o){if("custom"===o){const r=i(e,"--token")||i(e,"--key"),o=i(e,"--base")||i(e,"--base-url"),n=i(e,"--model");return r&&o||(console.error("\n ✗ custom needs --base <url> and --token <key>. [--model <name>] is optional.\n The base url is the Anthropic-compatible root the SDK appends /v1/messages to:\n Z.ai GLM → https://api.z.ai/api/anthropic\n OpenRouter → https://openrouter.ai/api\n Restart the bridge (or its launchd service) to apply.\n"),process.exit(1)),saveProvider({provider:"custom",baseUrl:o,authToken:r,model:n||void 0,savedAt:Date.now()}),void console.error(`\n ✓ provider set to custom.\n base url: ${o}\n model: ${n||"(default)"}\n token: ${r.slice(0,8)}…\n Restart the bridge (or its launchd service) to apply.\n`)}console.error(`\n usage:\n ${n("provider")} # show current\n ${n("provider","custom","--base","<url>","--token","<key>","[--model <m>]")} # any Anthropic-compatible endpoint\n ${n("provider","anthropic")} # reset to Claude\n\n common base urls (SDK appends /v1/messages):\n Z.ai GLM → https://api.z.ai/api/anthropic\n OpenRouter → https://openrouter.ai/api\n`),process.exit(1)}else{try{r.unlinkSync(s)}catch{}console.error("\n ✓ provider reset to anthropic (regular Claude login).\n")}}