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 +42 -1
- package/claude-session.mjs +240 -15
- package/code-event-contract.mjs +9 -0
- package/codex-event-mapper.mjs +4 -1
- package/context-contract.mjs +95 -0
- package/design-edit.mjs +116 -8
- package/design-source-contract.mjs +4 -0
- package/error-recovery.mjs +50 -0
- package/event-bounds.mjs +4 -0
- package/evidence-citations.mjs +50 -0
- package/evidence-compact.mjs +11 -0
- package/flow-preview.mjs +4 -0
- package/hermes-event-mapper.mjs +8 -1
- package/lane-continuation.mjs +83 -0
- package/lane-lifecycle.mjs +7 -1
- package/package.json +9 -1
- package/provider-resilience.mjs +356 -0
- package/providers.mjs +61 -2
- package/recap.mjs +13 -5
- package/repo-search.mjs +2 -0
- package/runtime-contract.mjs +93 -0
- package/runtime-registry.mjs +6 -0
- package/runtime-session.mjs +5 -0
- package/thinkpool-capabilities.json +5 -5
- package/thinkpool-room-prompt.mjs +17 -1
- package/viewport.mjs +18 -0
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
|
package/claude-session.mjs
CHANGED
|
@@ -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 →
|
|
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') {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
package/code-event-contract.mjs
CHANGED
|
@@ -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
|
+
}
|
package/codex-event-mapper.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|