thinkpool-pair 0.7.354 → 0.7.357

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bridge.mjs CHANGED
@@ -47,7 +47,8 @@ import { createPermNotifier, shouldNotifyTurnDone, shouldRecordTurnDone, permiss
47
47
  // resolveProviderEnv(id) → {ANTHROPIC_BASE_URL,ANTHROPIC_AUTH_TOKEN,ANTHROPIC_MODEL} for a
48
48
  // registered custom provider, or null for the built-in/unknown (leave the default env intact).
49
49
  // Multi-provider BYOK slice 1: a lane spawned with a `provider` id runs on that endpoint.
50
- import { resolveProviderEnv, resolveProviderRef, providerNameMap, publicKeyB64, bridgeHostId, announceProviders, listProviders, effectiveLaneModel, sameProviderEnv, providerModel } from './providers.mjs'
50
+ import { resolveProviderEnv, resolveProviderRef, resolveProviderResiliencePolicy, providerNameMap, publicKeyB64, bridgeHostId, announceProviders, listProviders, effectiveLaneModel, sameProviderEnv, providerModel } from './providers.mjs'
51
+ import { createMemoryCircuit, providerResilienceCapAdmission } from './provider-resilience.mjs'
51
52
  import { validateProviderSwitch, providerSwitchPlan, BUILTIN_PROVIDER } from './switch-provider.mjs'
52
53
  import { createSdkMcpServer, tool } from '@anthropic-ai/claude-agent-sdk'
53
54
  import { z } from 'zod'
@@ -55,6 +56,7 @@ import { readCodexDefaultModel, readCodexModels, codexConfigForMode, codexThread
55
56
  import { codexAccountUsageLine, codexCreditsReportLine, codexLimitReportLine } from './codex-commands.mjs'
56
57
  import { withMcpSessionFactory } from './codex-mcp-http.mjs'
57
58
  import { startStructuredSession } from './runtime-session.mjs'
59
+ import { admitRuntimeCapability } from './runtime-contract.mjs'
58
60
  import { cleanTerminalName, modelTerminalNameInput } from './terminal-name.mjs'
59
61
  import { defaultStructuredMode, normalizeStructuredEffort, shouldDeferStructuredRuntime, structuredModeForSlice, structuredModeLocked, structuredModesForLane, structuredRuntimeForCommand, structuredRuntimeMetadata, structuredRuntimeSupportsMode } from './runtime-registry.mjs'
60
62
  import { commandCatalogForRuntime, commandHelpLine, reconcileCommandCatalog } from './command-catalog.mjs'
@@ -671,6 +673,10 @@ const BRIDGE_STARTED_AT = Date.now()
671
673
  // top-level only (one bridge per announce); older clients ignore the unknown key.
672
674
  const host = (os.hostname() || 'host').split('.')[0].slice(0, 24)
673
675
  const hostId = bridgeHostId()
676
+ // Phase 1 circuit state is deliberately memory-only. Its primitive keys state
677
+ // by bridge/provider/exact-model; a process restart clears it and no credential,
678
+ // endpoint, prompt, or raw error ever enters the map.
679
+ const providerResilienceCircuit = createMemoryCircuit()
674
680
 
675
681
  // Repo awareness — the room shows which project this machine is sharing.
676
682
  // Cheap reads, no subprocess: directory name + .git/HEAD.
@@ -2263,6 +2269,10 @@ function worktreeSnapshot(cwd) {
2263
2269
  function openStructured({ id, runtime = 'claude', model, models, effort, resume, log, commands, mode, spawnedBy, spawnDepth, cascadeRole, hop, sideParent, sideTask, pendingSideContexts, pendingWorkerCompletions, workerCompletionsInFlight, rolePrompt, sliceType, flowSessionId, flowTaskKey, flowRole, flowReviewTarget, flowReviewTargets, flowReviewSnapshots, flowReviewRound, dispatchBaseSha, revertTarget, cwd, managedWorktree, reviewSliceRoots, openedAt, defer, provider, carryRecap, lastUsage, receivedTurnCids }) {
2264
2270
  if (sessions.has(id)) return
2265
2271
  runtime = structuredRuntimeMetadata(runtime) ? runtime : 'claude'
2272
+ // Fail closed before exposing a native lane if its bridge semantic contract
2273
+ // was removed or mismatched. This does not replace native tool transports.
2274
+ const runtimeContract = admitRuntimeCapability({ runtime, capabilityId: 'terminal_interrupt', input: {} })
2275
+ if (!runtimeContract.ok) throw new TypeError(`Structured runtime contract rejected: ${runtimeContract.code}`)
2266
2276
  mode = structuredModeForSlice(runtime, { mode, sliceType, flowRole })
2267
2277
  // No explicit mode → a sensible default per runtime (see defaultModeForRuntime):
2268
2278
  // codex → bypassPermissions, so a freshly-opened codex terminal can fetch /
@@ -3249,6 +3259,33 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
3249
3259
  flowRole: entry.flowRole,
3250
3260
  sideParent: entry.sideParent,
3251
3261
  }), canSpawnWorkers ? HERMES_VISIBLE_WORKER_FALLBACK_RULE : ''].filter(Boolean).join('\n\n')
3262
+ const resolvedProviderResilience = runtime === 'claude'
3263
+ ? resolveProviderResiliencePolicy(provider)
3264
+ : null
3265
+ let resilienceCapAbsenceLogged = false
3266
+ const providerResilience = resolvedProviderResilience?.policy?.enabled === true
3267
+ ? {
3268
+ policy: resolvedProviderResilience.policy,
3269
+ providers: listProviders(),
3270
+ providerId: resolvedProviderResilience.target.providerId,
3271
+ model: resolvedProviderResilience.target.actualConfiguredModel,
3272
+ requestedModel: laneModel || resolvedProviderResilience.target.actualConfiguredModel,
3273
+ bridgeHostId: hostId,
3274
+ circuit: providerResilienceCircuit,
3275
+ // sendTurn creates the controller immediately before the bridge advances
3276
+ // its public turn revision, so project the revision that admission owns.
3277
+ turnRev: () => (Number(entry._turnRev) || 0) + 1,
3278
+ capGate: () => {
3279
+ const budget = entry.flowSessionId ? flowBudgets.get(entry.flowSessionId) : null
3280
+ const admission = providerResilienceCapAdmission(budget)
3281
+ if (!admission.configured && !resilienceCapAbsenceLogged) {
3282
+ resilienceCapAbsenceLogged = true
3283
+ process.stderr.write(`\n ${A.dim}◇ provider resilience cap_not_configured (${String(id).slice(0, 8)})${A.rst}\n`)
3284
+ }
3285
+ return admission
3286
+ },
3287
+ }
3288
+ : null
3252
3289
  entry.session = startStructuredSession(runtime, {
3253
3290
  // laneModel, NOT the raw `model` param: the SDK's `model` option OVERRIDES the
3254
3291
  // ANTHROPIC_MODEL supplied by resolveProviderEnv() in `env` below, so an inherited
@@ -3355,6 +3392,10 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
3355
3392
  // per-machine default (provider.mjs/applyProviderEnv). null for built-in/unknown → the
3356
3393
  // default Claude env is left exactly as-is (unchanged path).
3357
3394
  env: { ...process.env, ...buildConductorEnv({ flowSessionId, mode }), ...(resolveProviderEnv(provider) || {}), TP_MOCKUP_OUTBOX: mockupOutbox },
3395
+ // Same-target custom-provider resilience is a host-local dark-launch
3396
+ // primitive. Missing/disabled policy, built-in Anthropic, Codex, and Hermes
3397
+ // receive null and preserve their established transport behavior exactly.
3398
+ resilience: providerResilience,
3358
3399
  onTurnStart: (options = {}) => {
3359
3400
  // Hermes promotes /queue items internally, without a second code-turn.
3360
3401
  // Advance the lifecycle before its first output and publish the deferred
@@ -22,8 +22,10 @@ import { reviewGatePreToolDecision } from './flow-review-gate.mjs'
22
22
  import { crossPostNeedsCard } from './cross-terminal.mjs'
23
23
  import { correctContext } from './context-windows.mjs'
24
24
  import { normalizeClaudeCommandCatalog } from './claude-command-catalog.mjs'
25
+ import { evidenceForToolResult } from './evidence-citations.mjs'
25
26
  import { THINKPOOL_CASCADE_RULE, THINKPOOL_REMOTE_DELIVERY_RULES, THINKPOOL_RUNTIME_AUTHORITY_RULE, THINKPOOL_RUNTIME_TURN_REMINDER, buildThinkPoolTurnGuidance, createRoomContextSelector, usesFullThinkPoolReminder } from './thinkpool-room-prompt.mjs'
26
27
  import { stallDecision, stallEvent, isCompactTurn } from './turn-stall.mjs'
28
+ import { createSameTargetResilienceController, formatResilienceTrace } from './provider-resilience.mjs'
27
29
 
28
30
  // The caret-pulled SDK's real version (^0.3.x auto-upgrades on restart). Resolved
29
31
  // once at import by walking up from the package entry to its own package.json.
@@ -244,7 +246,7 @@ const TP_ROOM_REMINDER = [
244
246
  'BUILD WORKFLOW (default, no magic word): right-size within your TERMINAL ROLE — a trivial ask or delegated slice you just do; a conductor-capable role with a genuinely decomposable build FIRST writes a short plan in chat, THEN fans worker slices into visible spawn_terminal lanes and verifies them. Worker/leaf/Side/managed Flow roles do not fan out. A person-requested new or separate terminal uses open_main_terminal. Never plan-mode/ExitPlanMode; plans live in chat and lanes in the existing list.',
245
247
  ].join(' ')
246
248
 
247
- export function startClaudeSession({ cwd, model, effort: initialEffort = 'high', resume, env, mode: initialMode = 'default', onEvent, requestPermission, mcpServers, crossPostGate, crossRoomPostGate, didSpawnTarget = null, terminalRolePrompt, rolePrompt, blockSubagents = false, onSubmitPlan = null, onLaneDone = null, onReviewVerdict = null, reviewGate = null, lazy = false, roomContext = null, suggest = true, prepareCwd = null, admitStart = null }) {
249
+ export function startClaudeSession({ cwd, model, effort: initialEffort = 'high', resume, env, mode: initialMode = 'default', onEvent, requestPermission, mcpServers, crossPostGate, crossRoomPostGate, didSpawnTarget = null, terminalRolePrompt, rolePrompt, blockSubagents = false, onSubmitPlan = null, onLaneDone = null, onReviewVerdict = null, reviewGate = null, lazy = false, roomContext = null, suggest = true, prepareCwd = null, admitStart = null, resilienceObserver = null, resilience = null }) {
248
250
  // Per-turn reminder + live ROOM NOW tail. roomContext (bridge-supplied) returns the
249
251
  // room's CURRENT state — sibling lanes, active git worktrees — or null. The static
250
252
  // rules keep the agent aware of the room's FEATURES; the live tail keeps it aware of
@@ -271,6 +273,7 @@ export function startClaudeSession({ cwd, model, effort: initialEffort = 'high',
271
273
  let persistedSessionId = resume || null
272
274
  let lastTurnText = null // the most recent turn text, so a bad-resume recovery can re-deliver it
273
275
  let lastTurnReminder = null
276
+ let lastTurnBlocks = null // exact current prompt blocks; same-target retry must replay byte-for-byte
274
277
  let closed = false
275
278
  // Lazy boot (2026-07-02): a RESTORED-IDLE terminal returns a full session object but
276
279
  // defers the expensive query() cold-start (MCP + settingSources, ~50s each) until its
@@ -297,7 +300,7 @@ export function startClaudeSession({ cwd, model, effort: initialEffort = 'high',
297
300
  // never throws an ExitPlanMode card. Falls back to 'default' for unknown values.
298
301
  let mode = MODES.has(initialMode) ? initialMode : 'default' // mirrors ⇧⇥ cycle
299
302
  const alwaysAllow = new Set() // tool:risk signatures the user chose "don't ask again" for
300
- const toolStart = new Map() // tool_use id → start time, for the duration badge
303
+ const toolStart = new Map() // tool_use id → source metadata for duration/evidence
301
304
  const effortLevels = new Set(['low', 'medium', 'high', 'xhigh', 'max'])
302
305
  let effort = effortLevels.has(initialEffort) ? initialEffort : 'high'
303
306
  // Live token count for the thinking indicator — mirrors Claude Code's
@@ -348,6 +351,17 @@ export function startClaudeSession({ cwd, model, effort: initialEffort = 'high',
348
351
  const RESTART_MAX = 3
349
352
  const RECOVERABLE = /connection closed|connection reset|econnreset|etimedout|socket hang up|fetch failed|network error|socket destroyed/i
350
353
  let restartCount = 0
354
+ // Phase 0 measurement seam. The observer receives only a coarse recovery
355
+ // reason/count; it cannot alter this session's existing retry behavior.
356
+ const observeRecovery = (reason, detail = {}) => { try { resilienceObserver?.({ reason, restartCount, ...detail }) } catch { /* observer is strictly read-only */ } }
357
+ // Phase 1 is opt-in and applies only to a named non-Anthropic custom target. Keep
358
+ // disabled/built-in sessions on the established RESTART_MAX recovery path exactly.
359
+ const resilienceEnabled = !!(resilience?.policy?.enabled && resilience?.providerId && resilience.providerId !== 'anthropic')
360
+ let turnResilience = null
361
+ let resilienceDeadlineTimer = null
362
+ let resilienceRetryPending = false
363
+ let resilienceTurnRevision = 0
364
+ let abortPending = false
351
365
  let restartTimer = null // the pending auto-restart backoff — cancelled by end()
352
366
  // Force-stop a true wedge (item 3): no result, no error, just silence past
353
367
  // FORCE_STOP_MS. As of the 2026-07-08 hardening we no longer just surface an error
@@ -396,6 +410,7 @@ export function startClaudeSession({ cwd, model, effort: initialEffort = 'high',
396
410
  // cleared on the next result — "all teardown has arrived" isn't observable. `success`
397
411
  // results are never swallowed, so the flushed steer's completion always surfaces.
398
412
  let interrupting = false
413
+ let interruptingRevision = null
399
414
  let interruptTimer = null
400
415
  const INTERRUPT_SWALLOW_MS = 6000
401
416
  // ── Haiku suggestion fallback ──
@@ -419,6 +434,59 @@ export function startClaudeSession({ cwd, model, effort: initialEffort = 'high',
419
434
  // (`result`/`error` — covers a no-op "Not enough messages to compact" and a Stop). After
420
435
  // that the normal wedge timeline applies again.
421
436
  const emit = (evt) => { lastEvtTs = Date.now(); if (evt && evt.kind !== 'stalled') stalledSent = false; if (evt && (evt.kind === 'compaction' || evt.kind === 'result' || evt.kind === 'error')) compacting = false; emitRaw(evt) }
437
+ // The controller only receives its own allowlisted records. Room notes are likewise
438
+ // rendered from that projection, never from an SDK error, URL, credential, or prompt.
439
+ const resilienceRecord = (record) => {
440
+ try { resilience?.onRecord?.(record) } catch { /* observer is never control flow */ }
441
+ if (record?.outcome === 'retrying' || record?.outcome === 'cap_blocked' || record?.outcome === 'circuit_open') {
442
+ emit({ kind: 'note', text: formatResilienceTrace(record) })
443
+ }
444
+ }
445
+ const clearResilienceDeadline = () => {
446
+ if (resilienceDeadlineTimer == null) return
447
+ try { (resilience?.clearTimer || clearTimeout)(resilienceDeadlineTimer) } catch { /* deadline cleanup is best-effort */ }
448
+ resilienceDeadlineTimer = null
449
+ }
450
+ const armResilienceDeadline = () => {
451
+ clearResilienceDeadline()
452
+ if (!turnResilience) return
453
+ const timeoutMs = Number(resilience?.policy?.timeoutMs)
454
+ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) return
455
+ const schedule = resilience?.setTimer || setTimeout
456
+ resilienceDeadlineTimer = schedule(() => {
457
+ resilienceDeadlineTimer = null
458
+ if (closed || !turnResilience || !turnActive) return
459
+ // This is a bridge-authored timeout classification, never a raw provider
460
+ // error. Abort the wedged iterator, then reuse the same controller budget.
461
+ turnResilience.failure({ message: 'timeout' })
462
+ turnActive = false
463
+ try { qAc?.abort() } catch { /* the disposal wait below still fails closed */ }
464
+ retryResilientTurn()
465
+ }, timeoutMs)
466
+ }
467
+ const createTurnResilience = () => {
468
+ if (!resilienceEnabled) return null
469
+ try {
470
+ const controller = createSameTargetResilienceController({
471
+ runtime: 'claude', providers: resilience.providers || [], providerId: resilience.providerId,
472
+ model: resilience.model || opts.model || model,
473
+ requestedModel: resilience.requestedModel || resilience.model || opts.model || model,
474
+ policy: resilience.policy, bridgeHostId: resilience.bridgeHostId,
475
+ circuit: resilience.circuit, capGate: resilience.capGate,
476
+ traceId: resilience.traceId,
477
+ turnRev: typeof resilience.turnRev === 'function' ? resilience.turnRev() : resilience.turnRev,
478
+ onRecord: resilienceRecord,
479
+ })
480
+ controller.start()
481
+ return controller
482
+ } catch { return null }
483
+ }
484
+ const admitResilientSubmission = async () => {
485
+ if (!turnResilience) return true
486
+ let admission = null
487
+ try { admission = await turnResilience.preflight() } catch { return false }
488
+ return admission?.admitted === true
489
+ }
422
490
  const stallTimer = setInterval(() => {
423
491
  const quiet = Date.now() - lastEvtTs
424
492
  const action = stallDecision({ turnActive, awaitingUser, quietMs: quiet, stallMs: STALL_MS, forceStopMs: FORCE_STOP_MS, stalledSent, stallRetried, compacting, compactForceStopMs: COMPACT_FORCE_STOP_MS })
@@ -428,7 +496,23 @@ export function startClaudeSession({ cwd, model, effort: initialEffort = 'high',
428
496
  const ev = stallEvent(action, quiet)
429
497
  if (ev) emitRaw(ev)
430
498
  if (action === 'status') { stalledSent = true; return }
431
- if (action === 'retry') { forceStopped = true; retryStalledTurn(quiet); return }
499
+ if (action === 'retry') {
500
+ forceStopped = true
501
+ if (turnResilience) {
502
+ // The legacy watchdog must never create a second retry ledger. Route
503
+ // its terminal timeout through the same controller; visible output or
504
+ // an exhausted attempt budget makes the following preflight fail closed.
505
+ observeRecovery('resilience_stall', { quietMs: quiet })
506
+ turnResilience.failure({ message: 'timeout' })
507
+ turnActive = false
508
+ try { qAc?.abort() } catch { /* disposal is awaited by the shared retry path */ }
509
+ retryResilientTurn()
510
+ return
511
+ }
512
+ observeRecovery('stall_replay', { quietMs: quiet })
513
+ retryStalledTurn(quiet)
514
+ return
515
+ }
432
516
  // 'giveup' — the one auto-retry ALSO stalled past FORCE_STOP_MS. Fall back to the
433
517
  // pre-2026-07-08 behavior: force-stop the turn so between-turns updates unblock, and
434
518
  // let the human resend. The wedged loop is left in place; if it later throws, the
@@ -682,6 +766,7 @@ export function startClaudeSession({ cwd, model, effort: initialEffort = 'high',
682
766
  // On deny, permissionDecisionReason IS what the model receives as the
683
767
  // tool error — make it a real instruction, not an opaque tag.
684
768
  const denied = decision === 'deny'
769
+ if (denied && turnResilience) turnResilience.failure({ permissionDenied: true })
685
770
  return {
686
771
  continue: true,
687
772
  hookSpecificOutput: {
@@ -947,9 +1032,13 @@ export function startClaudeSession({ cwd, model, effort: initialEffort = 'high',
947
1032
  }
948
1033
  break
949
1034
  case 'assistant':
1035
+ clearResilienceDeadline()
1036
+ // Any assistant block (including a tool_use) has crossed the replay
1037
+ // boundary. A same-target retry is only safe before visible output.
1038
+ turnResilience?.visibleOutput()
950
1039
  // Stamp tool-call start times so tool_result can report a duration.
951
1040
  for (const b of (m.message?.content || [])) {
952
- if (b?.type === 'tool_use' && b.id) toolStart.set(b.id, Date.now())
1041
+ if (b?.type === 'tool_use' && b.id) toolStart.set(b.id, { at: Date.now(), name: b.name, input: b.input })
953
1042
  }
954
1043
  // parentToolUseId: non-null when this assistant message comes from a
955
1044
  // sub-agent (Task tool) — the universal nesting spine. Thread it so the
@@ -962,9 +1051,12 @@ export function startClaudeSession({ cwd, model, effort: initialEffort = 'high',
962
1051
  // tool_result blocks arrive on the user-role echo
963
1052
  for (const b of (m.message?.content || [])) {
964
1053
  if (b?.type === 'tool_result') {
1054
+ clearResilienceDeadline()
1055
+ turnResilience?.visibleOutput({ tool: true })
965
1056
  const start = toolStart.get(b.tool_use_id)
966
1057
  if (start != null) toolStart.delete(b.tool_use_id)
967
- emit({ kind: 'tool_result', toolUseId: b.tool_use_id, content: b.content, isError: !!b.is_error, durationMs: start != null ? Date.now() - start : undefined, parentToolUseId: m.parent_tool_use_id || null })
1058
+ const evidence = evidenceForToolResult(start?.name, b.content)
1059
+ emit({ kind: 'tool_result', toolUseId: b.tool_use_id, content: b.content, ...(evidence ? { evidence } : {}), isError: !!b.is_error, durationMs: start?.at != null ? Date.now() - start.at : undefined, parentToolUseId: m.parent_tool_use_id || null })
968
1060
  }
969
1061
  }
970
1062
  break
@@ -985,6 +1077,7 @@ export function startClaudeSession({ cwd, model, effort: initialEffort = 'high',
985
1077
  break
986
1078
  }
987
1079
  case 'result':
1080
+ clearResilienceDeadline()
988
1081
  if (m.session_id) sessionId = m.session_id
989
1082
  // Bad resume target: the CLI can't find the session we tried to resume — a forked
990
1083
  // id that was never persisted, or a pruned transcript. It surfaces as an is_error
@@ -1014,7 +1107,26 @@ export function startClaudeSession({ cwd, model, effort: initialEffort = 'high',
1014
1107
  // abort() already emitted the one canonical aborted boundary. `success` (and any
1015
1108
  // non-teardown subtype) always emits; only the aborted/error_during_execution pair
1016
1109
  // the interrupt churns out is dropped, and only inside the timer-bounded window.
1017
- if (interrupting && (m.subtype === 'aborted' || m.subtype === 'error_during_execution')) { turnActive = false; break }
1110
+ // This guard MUST precede resilient `is_error` handling: the second teardown echo
1111
+ // is itself an is_error result and may arrive after the next turn has installed a
1112
+ // new controller. Letting it reach that controller would retry the wrong prompt.
1113
+ if (interrupting && (m.subtype === 'aborted' || m.subtype === 'error_during_execution')) {
1114
+ // A late teardown echo from the stopped turn may arrive after the
1115
+ // next turn is accepted. Swallow the echo, but only mutate liveness
1116
+ // while the stopped revision still owns the lane.
1117
+ if (resilienceTurnRevision === interruptingRevision) turnActive = false
1118
+ break
1119
+ }
1120
+ // Some SDK transport failures arrive as an error result instead of a
1121
+ // thrown iterator error. Treat them identically, without serializing
1122
+ // `m.errors` or `m.result` into the room event stream.
1123
+ if (m.is_error && turnResilience) {
1124
+ if (resilienceRetryPending) break
1125
+ turnResilience.failure({ message: Array.isArray(m.errors) ? m.errors.join(' ') : m.result, status: m.status ?? m.statusCode, code: m.code })
1126
+ turnActive = false
1127
+ retryResilientTurn()
1128
+ break
1129
+ }
1018
1130
  turnBaseOut = 0; curMsgOut = 0 // reset the live token count for the next turn
1019
1131
  turnActive = false // turn settled → stall watchdog stands down
1020
1132
  // A model switch requested mid-turn was deferred — apply it now the turn is done.
@@ -1022,6 +1134,7 @@ export function startClaudeSession({ cwd, model, effort: initialEffort = 'high',
1022
1134
  // doesn't await itself; the setTimeout runs after this iterator yields.
1023
1135
  if (pendingSwitch) { pendingSwitch = false; setTimeout(() => { if (!closed) recreateForSwitch() }, 0) }
1024
1136
  if (m.subtype === 'success') { restartCount = 0; if (sessionId) persistedSessionId = sessionId } // ONLY a real success refills the
1137
+ if (m.subtype === 'success') { turnResilience?.success(); turnResilience = null }
1025
1138
  // auto-restart budget (else a flapping connection that lands one aborted turn between
1026
1139
  // drops refills every cycle past RESTART_MAX) — and marks this session id durably
1027
1140
  // persisted so a later model-switch re-create can safely resume it.
@@ -1075,11 +1188,22 @@ export function startClaudeSession({ cwd, model, effort: initialEffort = 'high',
1075
1188
  }
1076
1189
  }
1077
1190
  } catch (e) {
1191
+ clearResilienceDeadline()
1078
1192
  if (closed) return
1079
1193
  // This query was intentionally aborted (a model switch superseded it, or the
1080
1194
  // session is ending) — not a real error. Stay silent; the re-create owns what's next.
1081
1195
  if (myAc.signal.aborted) return
1082
1196
  const msg = e?.message || String(e)
1197
+ // Enabled custom-provider turns have a strict, per-turn controller. It
1198
+ // classifies the raw transport error locally, then either admits one exact
1199
+ // replay before any output or terminates without putting provider details in
1200
+ // the room. The legacy path below is intentionally untouched when disabled.
1201
+ if (turnResilience && turnActive) {
1202
+ turnResilience.failure({ message: msg, status: e?.status ?? e?.statusCode, code: e?.code })
1203
+ turnActive = false
1204
+ retryResilientTurn()
1205
+ return
1206
+ }
1083
1207
  // onEvent is also the bridge's synchronous busy-edge sampling point. The
1084
1208
  // query has already ended here, so publish the failure only after clearing
1085
1209
  // the turn. Otherwise a non-recoverable SDK error leaves the roster on its
@@ -1092,6 +1216,7 @@ export function startClaudeSession({ cwd, model, effort: initialEffort = 'high',
1092
1216
  // fresh input stream is needed because the throw killed the old iterator.
1093
1217
  if (RECOVERABLE.test(msg) && restartCount < RESTART_MAX) {
1094
1218
  restartCount++
1219
+ observeRecovery('stream_reconnect', { failureClass: 'network' })
1095
1220
  emit({ kind: 'note', text: `connection dropped — reconnecting (${restartCount}/${RESTART_MAX})` })
1096
1221
  // End the OLD input stream before swapping — else its generator leaks
1097
1222
  // (suspended forever) and a turn pushed into it after the throw is silently
@@ -1186,12 +1311,63 @@ export function startClaudeSession({ cwd, model, effort: initialEffort = 'high',
1186
1311
  emitRaw({ kind: 'note', text: 'retrying the stalled turn on a fresh connection' })
1187
1312
  }
1188
1313
  }
1189
- if (!lazy) runQuery()
1314
+ // A resilience retry has to wait for the failed Query's disposal: otherwise the
1315
+ // replay races its session-file lock. Admission is deliberately before runQuery(),
1316
+ // so a refused cap/circuit sends no second provider submission.
1317
+ const retryResilientTurn = () => {
1318
+ if (closed || abortPending || resilienceRetryPending) return
1319
+ const retryingTurn = turnResilience
1320
+ const retryingRevision = resilienceTurnRevision
1321
+ const retryingBlocks = lastTurnBlocks
1322
+ resilienceRetryPending = true
1323
+ const dying = qDone
1324
+ const oldInput = input
1325
+ input = makeInputStream()
1326
+ try { oldInput.end() } catch { /* noop */ }
1327
+ restartTimer = setTimeout(async () => {
1328
+ restartTimer = null
1329
+ try { await dying } catch { /* disposal failure still fails closed below */ }
1330
+ if (closed || !resilienceRetryPending || turnResilience !== retryingTurn || resilienceTurnRevision !== retryingRevision) return
1331
+ if (!(await admitResilientSubmission())) {
1332
+ resilienceRetryPending = false
1333
+ finishResilientFailure()
1334
+ return
1335
+ }
1336
+ if (closed || !resilienceRetryPending || turnResilience !== retryingTurn || resilienceTurnRevision !== retryingRevision) return
1337
+ resilienceRetryPending = false
1338
+ turnActive = true
1339
+ lastEvtTs = Date.now()
1340
+ stalledSent = false
1341
+ runQuery()
1342
+ // This is the exact previously accepted prompt block array, not a rebuilt
1343
+ // string/reminder. It is only reached before assistant or tool output.
1344
+ if (retryingBlocks) input.push(retryingBlocks)
1345
+ armResilienceDeadline()
1346
+ }, 0)
1347
+ }
1348
+ const finishResilientFailure = () => {
1349
+ clearResilienceDeadline()
1350
+ turnActive = false
1351
+ turnResilience = null
1352
+ // The failed Query is gone, but the lane remains recoverable for a later
1353
+ // human turn. `started=false` ensures that turn creates a fresh Query rather
1354
+ // than pushing into the ended stream.
1355
+ started = false
1356
+ emit({
1357
+ kind: 'error',
1358
+ message: 'The configured provider could not complete this turn. Nothing else was sent.',
1359
+ recoverable: true,
1360
+ })
1361
+ }
1362
+ // A custom resilient session defers its otherwise-eager query creation until a
1363
+ // turn clears the injected admission gate. Disabled and built-in sessions retain
1364
+ // their existing eager start and RESTART_MAX behavior.
1365
+ if (!lazy && !resilienceEnabled) runQuery()
1190
1366
 
1191
1367
  return {
1192
1368
  // A lazy (restored-idle) session cold-boots the query on its first turn. input.push
1193
1369
  // is queue-backed, so the pushed turn buffers and runs once the query is ready.
1194
- sendTurn(text) { if (!closed) { if (!started && !admitColdStart()) return false; if (!started && prepareCwd) { try { const next = prepareCwd(); if (next) { cwd = next; opts.cwd = next } } catch { /* keep original cwd */ } } if (!started) runQuery({ admitted: true }); turnActive = true; sawSuggestion = false; lastTurnText = String(text); if (sugTimer) { clearTimeout(sugTimer); sugTimer = null } lastEvtTs = Date.now(); stalledSent = false; stallRetried = false; forceStopped = false;
1370
+ sendTurn(text) { if (closed || abortPending || resilienceRetryPending) return false; if (!started && !admitColdStart()) return false; if (!started && prepareCwd) { try { const next = prepareCwd(); if (next) { cwd = next; opts.cwd = next } } catch { /* keep original cwd */ } } turnActive = true; sawSuggestion = false; lastTurnText = String(text); if (sugTimer) { clearTimeout(sugTimer); sugTimer = null } lastEvtTs = Date.now(); stalledSent = false; stallRetried = false; forceStopped = false;
1195
1371
  const t = String(text)
1196
1372
  const promptIndex = userPromptNo++
1197
1373
  const thisTurnForceFull = forceFullReminder
@@ -1210,11 +1386,34 @@ export function startClaudeSession({ cwd, model, effort: initialEffort = 'high',
1210
1386
  // recognized). So a slash command goes CLEAN; conversational turns keep the reminder.
1211
1387
  // Normal turns never start with "/" (composeAgentStdin prepends the preamble), and the
1212
1388
  // web already routes "/"-prefixed input as a command (pane.jsx), so this matches intent.
1213
- input.push(/^\s*\//.test(t) ? [{ type: 'text', text: t }] : [{ type: 'text', text: t }, { type: 'text', text: lastTurnReminder }])
1214
- } },
1389
+ lastTurnBlocks = /^\s*\//.test(t) ? [{ type: 'text', text: t }] : [{ type: 'text', text: t }, { type: 'text', text: lastTurnReminder }]
1390
+ resilienceTurnRevision++
1391
+ turnResilience = createTurnResilience()
1392
+ if (turnResilience) {
1393
+ const submittingTurn = turnResilience
1394
+ const submittingRevision = resilienceTurnRevision
1395
+ const submittingBlocks = lastTurnBlocks
1396
+ // Keep the public sendTurn edge synchronous. The prompt is held locally until
1397
+ // the asynchronous bridge cap admits it; a rejection never reaches the SDK.
1398
+ void admitResilientSubmission().then((admitted) => {
1399
+ // Stop or a newer human turn can land while cap admission is pending.
1400
+ // In either case this exact turn no longer owns the submission edge.
1401
+ if (closed || !turnActive || turnResilience !== submittingTurn || resilienceTurnRevision !== submittingRevision) return
1402
+ if (!admitted) { finishResilientFailure(); return }
1403
+ if (!started) runQuery({ admitted: true })
1404
+ input.push(submittingBlocks)
1405
+ armResilienceDeadline()
1406
+ }).catch(() => {
1407
+ if (!closed && turnActive && turnResilience === submittingTurn && resilienceTurnRevision === submittingRevision) finishResilientFailure()
1408
+ })
1409
+ } else {
1410
+ if (!started) runQuery({ admitted: true })
1411
+ input.push(lastTurnBlocks)
1412
+ }
1413
+ },
1215
1414
  // Cold-boot the query WITHOUT sending a turn — the background warmer calls this on
1216
1415
  // lazily-restored idle terminals so they're ready before the user clicks them.
1217
- warm() { if (!started && !closed) runQuery() },
1416
+ warm() { if (!resilienceEnabled && !started && !closed) runQuery() },
1218
1417
  get started() { return started },
1219
1418
  // Set the permission mode — Claude Code's ⇧⇥ cycle. setPermissionMode is a
1220
1419
  // streaming control request (drives plan-mode behaviour SDK-side); the local
@@ -1268,25 +1467,51 @@ export function startClaudeSession({ cwd, model, effort: initialEffort = 'high',
1268
1467
  // Graceful interrupt (Esc / Stop) — stops the current turn but keeps the
1269
1468
  // session alive for the next one. ac.abort() is teardown only (end()).
1270
1469
  async abort() {
1470
+ if (abortPending) return
1471
+ abortPending = true
1472
+ try {
1271
1473
  // Swallow the SDK's redundant interrupt teardown (aborted + error_during_execution +
1272
1474
  // re-init) — we emit the one canonical boundary below. Set BEFORE interrupt so the
1273
1475
  // teardown results, which arrive async on the query loop, are caught; the timer bounds
1274
1476
  // the window so a genuine later turn failure still surfaces.
1275
- if (turnActive) {
1477
+ const retryWasPending = resilienceRetryPending
1478
+ const retryDisposal = retryWasPending ? qDone : null
1479
+ if (turnActive || retryWasPending) {
1480
+ resilienceTurnRevision++
1481
+ // Keep the retry-pending ingress guard armed while Stop awaits the old
1482
+ // process. The revision invalidates the retry itself; the flag prevents
1483
+ // a new human turn from entering its reader-less replacement stream.
1484
+ if (!retryWasPending) resilienceRetryPending = false
1485
+ clearResilienceDeadline()
1486
+ turnResilience?.failure({ aborted: true })
1487
+ if (retryWasPending) {
1488
+ clearTimeout(restartTimer)
1489
+ restartTimer = null
1490
+ started = false
1491
+ }
1276
1492
  interrupting = true
1493
+ interruptingRevision = resilienceTurnRevision
1277
1494
  clearTimeout(interruptTimer)
1278
- interruptTimer = setTimeout(() => { interrupting = false }, INTERRUPT_SWALLOW_MS)
1495
+ interruptTimer = setTimeout(() => { interrupting = false; interruptingRevision = null }, INTERRUPT_SWALLOW_MS)
1279
1496
  }
1280
1497
  try { await q?.interrupt?.() } catch { /* noop */ }
1498
+ // A failed query may already be inside its bounded disposal while its
1499
+ // retry owns no live SDK turn. Keep Stop's barrier open until that process
1500
+ // is actually gone so a later human turn cannot race its session lock.
1501
+ if (retryDisposal) { try { await retryDisposal } catch { /* already disposed */ } }
1502
+ if (retryWasPending) resilienceRetryPending = false
1281
1503
  // interrupt() stops the turn but emits NO terminal message. Without one the
1282
1504
  // struct stream ends on a non-terminal event, so on resume the SDK treats the
1283
1505
  // turn as INCOMPLETE and auto-continues it — the 2026-06-22 "zombie turn that
1284
1506
  // resumes itself" storm. Emit a terminal result so the turn is marked DONE +
1285
1507
  // persists across refresh. Guarded on turnActive to avoid a double-emit if the
1286
1508
  // SDK already surfaced one for the interrupt. (restored from 0.7.49)
1287
- if (turnActive) { turnActive = false; emit({ kind: 'result', subtype: 'aborted', sessionId }) }
1509
+ if (turnActive || retryWasPending) { turnActive = false; emit({ kind: 'result', subtype: 'aborted', sessionId }) }
1510
+ } finally {
1511
+ abortPending = false
1512
+ }
1288
1513
  },
1289
- end() { closed = true; clearInterval(stallTimer); clearTimeout(interruptTimer); clearTimeout(restartTimer); if (sugTimer) clearTimeout(sugTimer); input.end(); try { ac.abort() } catch { /* noop */ } },
1514
+ end() { closed = true; clearResilienceDeadline(); clearInterval(stallTimer); clearTimeout(interruptTimer); clearTimeout(restartTimer); if (sugTimer) clearTimeout(sugTimer); input.end(); try { ac.abort() } catch { /* noop */ } },
1290
1515
  get sessionId() { return sessionId },
1291
1516
  get mode() { return mode },
1292
1517
  get turnActive() { return turnActive }, // a turn is in flight (gates between-turns update restart — Slice 3 Contract #1)
@@ -1,6 +1,8 @@
1
1
  /* Canonical ThinkPool Code event contract.
2
2
  Keep this module browser-safe: the bridge and src/pages/code both consume it. */
3
3
 
4
+ import { recoveryCodeEvent } from './error-recovery.mjs'
5
+
4
6
  export const EVENT_CLASS = Object.freeze({
5
7
  VOLATILE_PRESENCE: 'volatile-presence',
6
8
  REPLAYABLE_PROGRESS: 'replayable-progress',
@@ -60,6 +62,7 @@ export const CODE_EVENT_REGISTRY = Object.freeze({
60
62
  image: E,
61
63
  'needs-input': E,
62
64
  'needs-resolved': E,
65
+ continuation: E,
63
66
  'turn-done': E,
64
67
  })
65
68
 
@@ -106,3 +109,9 @@ export function unknownCodeEventNotice (eventOrKind) {
106
109
  }
107
110
 
108
111
  export const isKnownCodeEventKind = (kind) => Object.hasOwn(CODE_EVENT_REGISTRY, kind)
112
+
113
+ // Normalize recovery reporting before it enters the durable room log. This is a
114
+ // projection only: adapters retain their existing retry and session behavior.
115
+ export function boundedRecoveryCodeEvent(input) {
116
+ return recoveryCodeEvent(input)
117
+ }
@@ -31,6 +31,7 @@
31
31
  import fs from 'node:fs'
32
32
  import { CODEX_COMMAND_CATALOG } from './codex-commands.mjs'
33
33
  import { normalizeEditKind, summarizeTextDiff } from './edit-diff.mjs'
34
+ import { evidenceForToolResult } from './evidence-citations.mjs'
34
35
 
35
36
  const safeMcpPart = (value) => String(value || 'unknown').replace(/[^a-zA-Z0-9_-]/g, '_')
36
37
  const mcpToolName = (item) => `mcp__${safeMcpPart(item?.server)}__${safeMcpPart(item?.tool)}`
@@ -165,7 +166,9 @@ export class CodexEventMapper {
165
166
  const query = webSearchQuery(it)
166
167
  this._emit({ kind: 'tool_result', toolUseId: it.id, toolInput: { query }, content: [{ type: 'text', text: query || 'search completed' }], isError: false, durationMs, parentToolUseId: null })
167
168
  } else if (it.type === 'mcp_tool_call') {
168
- this._emit({ kind: 'tool_result', toolUseId: it.id, content: [{ type: 'text', text: it.error?.message || resultText(it.result) }], isError: it.status === 'failed' || !!it.error, durationMs, parentToolUseId: null })
169
+ const content = [{ type: 'text', text: it.error?.message || resultText(it.result) }]
170
+ const evidence = evidenceForToolResult(mcpToolName(it), content)
171
+ this._emit({ kind: 'tool_result', toolUseId: it.id, content, ...(evidence ? { evidence } : {}), isError: it.status === 'failed' || !!it.error, durationMs, parentToolUseId: null })
169
172
  }
170
173
  // plan_update remains an additive future event shape. Unknown
171
174
  // items are ignored rather than risking a malformed transcript row.
@@ -0,0 +1,95 @@
1
+ // Deterministic, local-only description of the safe context ThinkPool supplies
2
+ // to a native runtime. This is deliberately not a transcript store.
3
+
4
+ import { createHash } from 'node:crypto'
5
+
6
+ export const CONTEXT_CONTRACT_VERSION = 1
7
+ export const CONTEXT_OMISSIONS = Object.freeze(['raw_pty', 'credentials', 'absolute_paths', 'unapproved_cross_room'])
8
+ export const CONTEXT_SOURCE_POLICY = Object.freeze([
9
+ Object.freeze({ kind: 'current_user_turn', maxBytes: 8192 }),
10
+ Object.freeze({ kind: 'room_now_delta', maxBytes: 4096 }),
11
+ Object.freeze({ kind: 'flow_slice_digest', maxBytes: 8192 }),
12
+ Object.freeze({ kind: 'approved_human_response', maxBytes: 4096 }),
13
+ ])
14
+
15
+ const BY_KIND = new Map(CONTEXT_SOURCE_POLICY.map((source) => [source.kind, source]))
16
+ const HOST_PATH = /(?:\/home\/|\/users\/|\/private\/|\/tmp\/|[a-z]:\\|\\\\|(?:^|[\\/])\.\.(?:[\\/]|$))/i
17
+ const SECRET_KEY = /(secret|token|password|authorization|api.?key|private.?key)/i
18
+ const SECRET_VALUE = /(?:sk-[a-z0-9_-]{8,}|gsk_[a-z0-9_-]{8,}|AIza[a-z0-9_-]{8,}|gh[pousr]_[a-z0-9]{20,}|github_pat_[a-z0-9_]{20,}|xox[baprs]-[a-z0-9-]{10,}|AKIA[0-9A-Z]{16}|sbp_[a-z0-9]{20,}|eyJ[a-z0-9_-]{16,}\.[a-z0-9_-]{16,}\.[a-z0-9_-]{8,}|bearer\s+[a-z0-9._-]{8,})/i
19
+ const PROHIBITED = /(?:raw transcript|tool args?|chain[ -]of[ -]thought|hidden reasoning|system prompt|environment dump|provider key|BEGIN (?:RSA |OPENSSH )?PRIVATE KEY)/i
20
+ const SAFE_REF = /^[A-Za-z0-9][A-Za-z0-9._:@-]{0,159}$/
21
+ const bytes = (value) => {
22
+ try {
23
+ const serialized = typeof value === 'string' ? value : JSON.stringify(value)
24
+ return new TextEncoder().encode(serialized ?? '').length
25
+ } catch {
26
+ return Infinity
27
+ }
28
+ }
29
+ const stable = (value) => Array.isArray(value)
30
+ ? value.map(stable)
31
+ : value && typeof value === 'object'
32
+ ? Object.fromEntries(Object.keys(value).sort().map((key) => [key, stable(value[key])]))
33
+ : value
34
+
35
+ export function isSafeContextValue(value, maxBytes = 8192) {
36
+ if (value == null || bytes(value) > maxBytes) return false
37
+ const visit = (node) => {
38
+ if (typeof node === 'string') return node.length <= 2048 && !HOST_PATH.test(node) && !SECRET_VALUE.test(node) && !PROHIBITED.test(node)
39
+ if (node == null || typeof node === 'number' || typeof node === 'boolean') return true
40
+ if (Array.isArray(node)) return node.length <= 64 && node.every(visit)
41
+ if (typeof node !== 'object') return false
42
+ return Object.entries(node).every(([key, child]) => !SECRET_KEY.test(key) && visit(child))
43
+ }
44
+ return visit(value)
45
+ }
46
+
47
+ function safeRef(ref) {
48
+ if (!ref || typeof ref !== 'object' || Array.isArray(ref)) return null
49
+ if (!SAFE_REF.test(String(ref.type || '')) || !SAFE_REF.test(String(ref.id || ''))) return null
50
+ return { type: String(ref.type), id: String(ref.id) }
51
+ }
52
+
53
+ // The visible room receives only lane identity/status aggregates, never terminal
54
+ // snippets or host worktree paths. This intentionally trades detail for privacy.
55
+ export function projectRoomContext(roomNow) {
56
+ const lines = String(roomNow || '').split('\n')
57
+ const lanes = []
58
+ let worktrees = 0
59
+ for (const line of lines) {
60
+ const lane = line.match(/^\s*-\s*([A-Za-z0-9._:@-]{4,160})\s+·\s+"([^"\n]{1,160})"\s+·\s+[^·\n]+\s+·\s+([^·\n]+)/)
61
+ if (lane && isSafeContextValue(lane[2], 512) && isSafeContextValue(lane[3], 128)) {
62
+ lanes.push({ ref: lane[1], title: lane[2].trim(), state: lane[3].trim() })
63
+ continue
64
+ }
65
+ if (/^\s*-\s*\S+\s+[0-9a-f]{7,40}\s+\[/.test(line)) worktrees++
66
+ }
67
+ const projection = { lanes: lanes.slice(0, 32), worktreeCount: Math.min(worktrees, 999) }
68
+ return isSafeContextValue(projection, 4096) ? projection : { lanes: [], worktreeCount: 0 }
69
+ }
70
+
71
+ export function formatRoomContextProjection(roomNow) {
72
+ const projection = projectRoomContext(roomNow)
73
+ if (!projection.lanes.length && !projection.worktreeCount) return ''
74
+ const laneText = projection.lanes.map((lane) => `${lane.ref} · ${lane.title} · ${lane.state}`).join('\n')
75
+ return ['ROOM NOW (safe projection):', laneText, projection.worktreeCount ? `Active git worktrees: ${projection.worktreeCount}` : ''].filter(Boolean).join('\n')
76
+ }
77
+
78
+ export function buildContextManifest({ promptBundle = {}, sources = [] } = {}) {
79
+ const accepted = new Map()
80
+ for (const candidate of Array.isArray(sources) ? sources : []) {
81
+ const policy = BY_KIND.get(candidate?.kind)
82
+ if (!policy || accepted.has(policy.kind)) continue
83
+ const ref = candidate.ref == null ? null : safeRef(candidate.ref)
84
+ if (candidate.ref != null && !ref) continue
85
+ if (candidate.value != null && !isSafeContextValue(candidate.value, policy.maxBytes)) continue
86
+ accepted.set(policy.kind, ref ? { kind: policy.kind, ref, maxBytes: policy.maxBytes } : { kind: policy.kind, maxBytes: policy.maxBytes })
87
+ }
88
+ const manifest = {
89
+ version: CONTEXT_CONTRACT_VERSION,
90
+ promptBundle: { version: Number(promptBundle?.version) || 1, hash: /^[a-f0-9]{64}$/i.test(String(promptBundle?.hash || '')) ? String(promptBundle.hash).toLowerCase() : null },
91
+ sources: CONTEXT_SOURCE_POLICY.map((policy) => accepted.get(policy.kind)).filter(Boolean),
92
+ omissions: [...CONTEXT_OMISSIONS],
93
+ }
94
+ return Object.freeze({ ...manifest, hash: createHash('sha256').update(JSON.stringify(stable(manifest))).digest('hex') })
95
+ }