thinkpool-pair 0.7.363 → 0.7.364

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
@@ -158,6 +158,8 @@ import { realtimeRecoveryDecision, turnInFlight } from './update-gate.mjs'
158
158
  import { saveSession, flushSession, deleteSession, loadAll, loadArchived, canResume, loadPtyId, savePtyId, loadNames, saveNames, appendDurableEvents, seedDurableEvents, readDurablePage, readDurableOldestSeq, hasDurableArchive, servesHistoryPage, acknowledgePendingScheduledOutcome, commitRecordedScheduledOutcome, deletePendingScheduledOutcome, loadPendingScheduledOutcome, loadPendingScheduledOutcomes, savePendingScheduledOutcome } from './session-store.mjs'
159
159
  import { stampEvent, makeSeqCounter, maxSeq, seqable, capReplayEvents, chunkReplayEvents, boundEventForBroadcast, inlineImageBlocks, ImageEventQueue, imageQueueConfig, uploadCodeImage as uploadCodeImageRequest, usageReportLine, codexUsageReportLine, appendCurrentPersonRequest, buildCheckpointFromLog, buildRecapFromLog, resolveCheckpointCarry, RECAP_CAP, trimmedBeforeSeq, firstSeq } from './event-id.mjs'
160
160
  import { createLatestReplayPump, requestedReplayIds } from './replay-transport.mjs'
161
+ import { createEventDeliveryQueue } from './event-delivery-queue.mjs'
162
+ import { formatMcpFlight, normalizeMcpFlight, recordMcpFlightEvent } from './mcp-flight-recorder.mjs'
161
163
  import { classifyCodeEvent, unknownCodeEventNotice } from './code-event-contract.mjs'
162
164
  import { fetchPairControlDeliveryAuthority } from './pair-control-authority.mjs'
163
165
  import { finishScheduledRunWithRetry, pollDueScheduledRuns, sanitizeScheduledOutcomeIntent, scheduledOutcomeReconciliationComplete, scheduledRunDeadlineRequired, scheduledRunSpawnAdmission, scheduledRunsCapability, scheduledRunsEnabled } from './scheduled-runs.mjs'
@@ -1343,7 +1345,21 @@ const bcastAwait = async (event, payload, ch = channel) => {
1343
1345
  }
1344
1346
  } catch { return null /* offline — replay covers it */ }
1345
1347
  }
1346
- const bcast = (event, payload, ch = channel) => { void bcastAwait(event, payload, ch) }
1348
+ // Transcript frames are causally ordered. In particular, a small `result` must
1349
+ // never overtake a larger assistant/tool frame when the bridge falls back to
1350
+ // HTTP delivery or a socket is recovering. Retry the event at the head of this
1351
+ // stream until Realtime acknowledges it; the local JSONL archive is the crash
1352
+ // recovery backstop.
1353
+ const codeEventDelivery = createEventDeliveryQueue({
1354
+ send: ({ event, payload, ch }) => bcastAwait(event, payload, ch),
1355
+ })
1356
+ const bcast = (event, payload, ch = channel) => {
1357
+ if (event === 'code-event' && ch === channel) {
1358
+ codeEventDelivery.enqueue({ event, payload, ch })
1359
+ return
1360
+ }
1361
+ void bcastAwait(event, payload, ch)
1362
+ }
1347
1363
  const replayPump = createLatestReplayPump({
1348
1364
  send: ({ event, payload }) => bcastAwait(event, payload),
1349
1365
  })
@@ -2401,6 +2417,9 @@ function pushLog(entry, evt) {
2401
2417
  // stampEvent is idempotent, so the callers that already stamp are unaffected.
2402
2418
  stampStructuredTurn(entry, evt)
2403
2419
  stampEvent(evt)
2420
+ // Observe the normalized runtime event, never the SDK/provider wire payload.
2421
+ // The recorder retains only tool identity, timing, outcome, and argument count.
2422
+ entry.mcpFlight = recordMcpFlightEvent(entry.mcpFlight, evt, { now: evt.ts })
2404
2423
  entry.lastActionAt = evt.ts
2405
2424
  entry.lastEvent = evt
2406
2425
  // Only TRANSCRIPT events consume a seq (seqable) — replay-chrome kinds the web
@@ -2561,7 +2580,7 @@ function worktreeSnapshot(cwd) {
2561
2580
  // relay STRUCTURED events. onEvent → broadcast `code-event` + print locally +
2562
2581
  // persist to the host file; tool calls round-trip through the perm card; the
2563
2582
  // rolling log replays to joiners and survives bridge restarts (session-store).
2564
- 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, flowTaskContract, flowRole, flowReviewTarget, flowReviewTargets, flowReviewSnapshots, flowReviewRound, flowReviewConclusion, dispatchBaseSha, revertTarget, cwd, managedWorktree, reviewSliceRoots, openedAt, defer, provider, carryRecap, carryCheckpoint, lastUsage, receivedTurnCids, scheduleRunId, scheduleDeadlineAt, scheduleOutcomeRecorded, scheduleAdmissionLease }) {
2583
+ 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, flowTaskContract, flowRole, flowReviewTarget, flowReviewTargets, flowReviewSnapshots, flowReviewRound, flowReviewConclusion, dispatchBaseSha, revertTarget, cwd, managedWorktree, reviewSliceRoots, openedAt, defer, provider, carryRecap, carryCheckpoint, lastUsage, receivedTurnCids, mcpFlight, scheduleRunId, scheduleDeadlineAt, scheduleOutcomeRecorded, scheduleAdmissionLease }) {
2565
2584
  if (sessions.has(id)) return
2566
2585
  runtime = structuredRuntimeMetadata(runtime) ? runtime : 'claude'
2567
2586
  // Fail closed before exposing a native lane if its bridge semantic contract
@@ -2614,7 +2633,7 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
2614
2633
  const restoredScheduleOutcome = restoredScheduleOutcomeCandidate?.runId === scheduleRunId
2615
2634
  ? restoredScheduleOutcomeCandidate
2616
2635
  : null
2617
- const entry = { cmd: runtime, runtime, kind: 'structured', log: restoredLog, pending: new Map(), receivedTurnCids: new Set(Array.isArray(receivedTurnCids) ? receivedTurnCids.filter(Boolean).slice(-1000) : []), session: null, recovered: false, commands: commandCatalogForRuntime(runtime, commands), mode, effort, models: runtime === 'codex' ? codexModels : runtime === 'hermes' ? (Array.isArray(models) && models.length ? models : hostHermesModels) : undefined,
2636
+ const entry = { cmd: runtime, runtime, kind: 'structured', log: restoredLog, pending: new Map(), receivedTurnCids: new Set(Array.isArray(receivedTurnCids) ? receivedTurnCids.filter(Boolean).slice(-1000) : []), mcpFlight: normalizeMcpFlight(mcpFlight), session: null, recovered: false, commands: commandCatalogForRuntime(runtime, commands), mode, effort, models: runtime === 'codex' ? codexModels : runtime === 'hermes' ? (Array.isArray(models) && models.length ? models : hostHermesModels) : undefined,
2618
2637
  // model: truthful active-model label — now the SAME `laneModel` the SDK is given, so the
2619
2638
  // chip cannot disagree with the wire. When this lane runs on a custom (non-anthropic)
2620
2639
  // registered provider the SDK id is impersonated (see the onEvent guard below), so
@@ -2990,7 +3009,7 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
2990
3009
  // restart. Without this, sessionData omitted it → on restart the resumed session
2991
3010
  // re-launched on the host default (Opus) regardless of the last switch, and the
2992
3011
  // switch looked like it "never changed the model" (Max 2026-07-02). Restored below.
2993
- const sessionData = () => ({ sessionId: entry.session?.sessionId || null, runtime: entry.runtime, log: entry.log, commands: entry.commands, mode: entry.mode, effort: entry.effort, model: entry.model || null, models: entry.runtime === 'hermes' ? (entry.models || []) : undefined, provider: entry.provider || null, spawnedBy: entry.spawnedBy, spawnDepth: entry.spawnDepth || 0, cascadeRole: entry.cascadeRole || null, hop: entry.hop || 0, sideParent: entry.sideParent, sideTask: entry.sideTask, scheduleRunId: entry.scheduleRunId || null, scheduleDeadlineAt: entry.scheduleDeadlineAt || null, scheduleOutcomeRecorded: entry.scheduleOutcomeRecorded === true, scheduleAdmissionLease: entry.scheduleAdmissionLease || null, pendingSideContexts: entry.pendingSideContexts, pendingWorkerCompletions: entry.pendingWorkerCompletions, workerCompletionsInFlight: entry.workerCompletionsInFlight, sliceType: entry.sliceType, flowSessionId: entry.flowSessionId, flowTaskKey: entry.flowTaskKey, flowTaskContract: entry.flowTaskContract, flowRole: entry.flowRole || null, flowReviewTargets: entry.flowReviewTargets || [], flowReviewSnapshots: entry.flowReviewSnapshots || [], flowReviewRound: entry.flowReviewRound || 0, flowReviewConclusion: entry.flowReviewConclusion, dispatchBaseSha: entry.dispatchBaseSha || null, cwd: entry.cwd, managedWorktree: entry.managedWorktree, rolePrompt: entry.rolePrompt, flowReviewTarget: entry.flowReviewTarget || null, revertTarget: entry.revertTarget || null, reviewSliceRoots: entry.reviewSliceRoots || [], openedAt: entry.openedAt || null, lastUsage: entry.lastUsage || null, carryRecap: entry.pendingRecap || null, carryCheckpoint: entry.pendingCheckpoint || null, receivedTurnCids: [...entry.receivedTurnCids].slice(-1000) })
3012
+ const sessionData = () => ({ sessionId: entry.session?.sessionId || null, runtime: entry.runtime, log: entry.log, commands: entry.commands, mode: entry.mode, effort: entry.effort, model: entry.model || null, models: entry.runtime === 'hermes' ? (entry.models || []) : undefined, provider: entry.provider || null, spawnedBy: entry.spawnedBy, spawnDepth: entry.spawnDepth || 0, cascadeRole: entry.cascadeRole || null, hop: entry.hop || 0, sideParent: entry.sideParent, sideTask: entry.sideTask, scheduleRunId: entry.scheduleRunId || null, scheduleDeadlineAt: entry.scheduleDeadlineAt || null, scheduleOutcomeRecorded: entry.scheduleOutcomeRecorded === true, scheduleAdmissionLease: entry.scheduleAdmissionLease || null, pendingSideContexts: entry.pendingSideContexts, pendingWorkerCompletions: entry.pendingWorkerCompletions, workerCompletionsInFlight: entry.workerCompletionsInFlight, sliceType: entry.sliceType, flowSessionId: entry.flowSessionId, flowTaskKey: entry.flowTaskKey, flowTaskContract: entry.flowTaskContract, flowRole: entry.flowRole || null, flowReviewTargets: entry.flowReviewTargets || [], flowReviewSnapshots: entry.flowReviewSnapshots || [], flowReviewRound: entry.flowReviewRound || 0, flowReviewConclusion: entry.flowReviewConclusion, dispatchBaseSha: entry.dispatchBaseSha || null, cwd: entry.cwd, managedWorktree: entry.managedWorktree, rolePrompt: entry.rolePrompt, flowReviewTarget: entry.flowReviewTarget || null, revertTarget: entry.revertTarget || null, reviewSliceRoots: entry.reviewSliceRoots || [], openedAt: entry.openedAt || null, lastUsage: entry.lastUsage || null, carryRecap: entry.pendingRecap || null, carryCheckpoint: entry.pendingCheckpoint || null, receivedTurnCids: [...entry.receivedTurnCids].slice(-1000), mcpFlight: entry.mcpFlight })
2994
3013
  const persist = () => saveSession(room, id, sessionData())
2995
3014
  // Synchronous flush of this session's record. Used on open (so a brand-new session
2996
3015
  // has a file under its id BEFORE its first event — surviving a restart inside the
@@ -3129,6 +3148,12 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
3129
3148
  name: 'thinkpool',
3130
3149
  version: '1.0.0',
3131
3150
  tools: [
3151
+ ...(!entry.flowRole && entry.sliceType !== 'review' ? [tool(
3152
+ 'read_mcp_flight_recorder',
3153
+ 'Read the privacy-filtered MCP flight recorder for THIS terminal. Use it to diagnose a tool path or audit recent MCP activity. It reports only the MCP tool name, start time, duration, and outcome; it never records or returns arguments, result bodies, prompts, secrets, or host paths.',
3154
+ { limit: z.number().int().min(1).max(100).optional().describe('maximum recent calls to return (default 40)') },
3155
+ async (args) => ({ content: [{ type: 'text', text: formatMcpFlight(entry.mcpFlight, { limit: args?.limit }) }] }),
3156
+ )] : []),
3132
3157
  ...(runtime === 'hermes' && hermesRoleFor({ flowRole: entry.flowRole, sliceType: entry.sliceType }) === 'ordinary' ? [tool(
3133
3158
  'request_user_input',
3134
3159
  'Ask the people in this ThinkPool room one to three multiple-choice questions and wait for their answer. Use this for choices that genuinely block useful progress. Each question needs two or three mutually exclusive options; a free-text answer remains available in the room card.',
@@ -3900,7 +3925,7 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
3900
3925
  dispatchBaseSha: entry.dispatchBaseSha, revertTarget: entry.revertTarget, cwd: entry.cwd,
3901
3926
  managedWorktree: entry.managedWorktree, rolePrompt: entry.rolePrompt,
3902
3927
  reviewSliceRoots: entry.reviewSliceRoots, openedAt: entry.openedAt,
3903
- lastUsage: entry.lastUsage, carryRecap, carryCheckpoint: entry.pendingCheckpoint,
3928
+ lastUsage: entry.lastUsage, mcpFlight: entry.mcpFlight, carryRecap, carryCheckpoint: entry.pendingCheckpoint,
3904
3929
  })
3905
3930
  return sessions.get(id) || null
3906
3931
  },
@@ -4423,7 +4448,7 @@ function respawnStructured(id, provider) {
4423
4448
  // openStructured seed from the TARGET provider's configured model, which is the
4424
4449
  // only model this lane was ever asked for. A same-env model change never reaches
4425
4450
  // here — that path is an in-place setModel (see provider-switch).
4426
- const { runtime, log, commands, mode, spawnedBy, spawnDepth, cascadeRole, hop, sideParent, sideTask, pendingSideContexts, pendingWorkerCompletions, workerCompletionsInFlight, sliceType, flowSessionId, flowTaskKey, flowTaskContract, flowRole, flowReviewTarget, flowReviewTargets, flowReviewSnapshots, flowReviewRound, flowReviewConclusion, dispatchBaseSha, revertTarget, cwd, managedWorktree, rolePrompt, reviewSliceRoots, openedAt, lastUsage, pendingCheckpoint } = s
4451
+ const { runtime, log, commands, mode, spawnedBy, spawnDepth, cascadeRole, hop, sideParent, sideTask, pendingSideContexts, pendingWorkerCompletions, workerCompletionsInFlight, sliceType, flowSessionId, flowTaskKey, flowTaskContract, flowRole, flowReviewTarget, flowReviewTargets, flowReviewSnapshots, flowReviewRound, flowReviewConclusion, dispatchBaseSha, revertTarget, cwd, managedWorktree, rolePrompt, reviewSliceRoots, openedAt, lastUsage, mcpFlight, pendingCheckpoint } = s
4427
4452
  // Context-carry (2026-07-08): a provider switch is not an SDK resume — the new backend
4428
4453
  // starts blank mid-conversation. Synthesize a plain-text recap from the VISIBLE log NOW
4429
4454
  // (before teardown) and hand it to the fresh session as its first turn so the agent
@@ -4442,7 +4467,7 @@ function respawnStructured(id, provider) {
4442
4467
  // sessionData() (provider included) synchronously on open, so a bridge restart
4443
4468
  // restores the lane on its CURRENT provider, not the original — and its next
4444
4469
  // announce carries the new provider badge (additive {id,name} projection).
4445
- openStructured({ id, runtime, provider, log, commands, mode, spawnedBy, spawnDepth, cascadeRole, hop, sideParent, sideTask, pendingSideContexts, pendingWorkerCompletions, workerCompletionsInFlight, sliceType, flowSessionId, flowTaskKey, flowTaskContract, flowRole, flowReviewTarget, flowReviewTargets, flowReviewSnapshots, flowReviewRound, flowReviewConclusion, dispatchBaseSha, revertTarget, cwd, managedWorktree, rolePrompt, reviewSliceRoots, openedAt, lastUsage, carryRecap, carryCheckpoint: pendingCheckpoint })
4470
+ openStructured({ id, runtime, provider, log, commands, mode, spawnedBy, spawnDepth, cascadeRole, hop, sideParent, sideTask, pendingSideContexts, pendingWorkerCompletions, workerCompletionsInFlight, sliceType, flowSessionId, flowTaskKey, flowTaskContract, flowRole, flowReviewTarget, flowReviewTargets, flowReviewSnapshots, flowReviewRound, flowReviewConclusion, dispatchBaseSha, revertTarget, cwd, managedWorktree, rolePrompt, reviewSliceRoots, openedAt, lastUsage, mcpFlight, carryRecap, carryCheckpoint: pendingCheckpoint })
4446
4471
  return true
4447
4472
  }
4448
4473
 
@@ -5416,7 +5441,7 @@ channel
5416
5441
  // FL-M6 — restore the flow context (id/role/cwd) so an in-flight flow survives a
5417
5442
  // bridge restart: the conductor keeps its subagent-block + plan interception, and
5418
5443
  // lanes keep their worktree cwd + the ability to mark done.
5419
- openStructured({ id: rec.id, runtime: rec.runtime || 'claude', model: rec.model || undefined, models: rec.models, effort: rec.effort, provider: rec.runtime === 'claude' ? rec.provider || undefined : undefined, resume: resumable ? rec.sessionId : undefined, log: rec.log, commands: rec.commands, mode: rec.mode, spawnedBy: rec.spawnedBy, spawnDepth: rec.spawnDepth, cascadeRole: rec.cascadeRole, hop: rec.hop, sideParent: rec.sideParent, sideTask: rec.sideTask, scheduleRunId: rec.scheduleRunId, scheduleDeadlineAt: rec.scheduleDeadlineAt, scheduleOutcomeRecorded: rec.scheduleOutcomeRecorded, scheduleAdmissionLease: rec.scheduleAdmissionLease, pendingSideContexts: rec.pendingSideContexts, pendingWorkerCompletions: rec.pendingWorkerCompletions, workerCompletionsInFlight: rec.workerCompletionsInFlight, sliceType: rec.sliceType, flowSessionId: rec.flowSessionId, flowTaskKey: rec.flowTaskKey, flowTaskContract: rec.flowTaskContract, flowRole: rec.flowRole, flowReviewTarget: rec.flowReviewTarget, flowReviewTargets: rec.flowReviewTargets, flowReviewSnapshots: rec.flowReviewSnapshots, flowReviewRound: rec.flowReviewRound, flowReviewConclusion: rec.flowReviewConclusion, dispatchBaseSha: rec.dispatchBaseSha, revertTarget: rec.revertTarget, cwd: rec.cwd, managedWorktree: rec.managedWorktree, rolePrompt: rec.rolePrompt, reviewSliceRoots: rec.reviewSliceRoots, openedAt: rec.openedAt, lastUsage: rec.lastUsage, receivedTurnCids: rec.receivedTurnCids, carryRecap: wasInterrupted ? recoveryRecap : rec.carryRecap, carryCheckpoint: rec.carryCheckpoint,
5444
+ openStructured({ id: rec.id, runtime: rec.runtime || 'claude', model: rec.model || undefined, models: rec.models, effort: rec.effort, provider: rec.runtime === 'claude' ? rec.provider || undefined : undefined, resume: resumable ? rec.sessionId : undefined, log: rec.log, commands: rec.commands, mode: rec.mode, spawnedBy: rec.spawnedBy, spawnDepth: rec.spawnDepth, cascadeRole: rec.cascadeRole, hop: rec.hop, sideParent: rec.sideParent, sideTask: rec.sideTask, scheduleRunId: rec.scheduleRunId, scheduleDeadlineAt: rec.scheduleDeadlineAt, scheduleOutcomeRecorded: rec.scheduleOutcomeRecorded, scheduleAdmissionLease: rec.scheduleAdmissionLease, pendingSideContexts: rec.pendingSideContexts, pendingWorkerCompletions: rec.pendingWorkerCompletions, workerCompletionsInFlight: rec.workerCompletionsInFlight, sliceType: rec.sliceType, flowSessionId: rec.flowSessionId, flowTaskKey: rec.flowTaskKey, flowTaskContract: rec.flowTaskContract, flowRole: rec.flowRole, flowReviewTarget: rec.flowReviewTarget, flowReviewTargets: rec.flowReviewTargets, flowReviewSnapshots: rec.flowReviewSnapshots, flowReviewRound: rec.flowReviewRound, flowReviewConclusion: rec.flowReviewConclusion, dispatchBaseSha: rec.dispatchBaseSha, revertTarget: rec.revertTarget, cwd: rec.cwd, managedWorktree: rec.managedWorktree, rolePrompt: rec.rolePrompt, reviewSliceRoots: rec.reviewSliceRoots, openedAt: rec.openedAt, lastUsage: rec.lastUsage, receivedTurnCids: rec.receivedTurnCids, mcpFlight: rec.mcpFlight, carryRecap: wasInterrupted ? recoveryRecap : rec.carryRecap, carryCheckpoint: rec.carryCheckpoint,
5420
5445
  // Lazy-boot restored terminals that were IDLE + not part of a flow: their transcript
5421
5446
  // shows immediately; the query boots on first turn. Mid-turn + flow terminals boot now
5422
5447
  // (mid-turn needs auto-resume; flow needs its lane live).
@@ -6175,6 +6200,7 @@ if (process.env.THINKPOOL_PAIR_AUTOUPDATE === '1' && VERSION) {
6175
6200
  async function shutdown(code = 0, farewell = true) {
6176
6201
  if (shuttingDown) return
6177
6202
  shuttingDown = true
6203
+ codeEventDelivery.close()
6178
6204
  // Hard exit backstop, armed FIRST and independent of every await below — a wedged
6179
6205
  // realtime socket (the watchdog path) or a hung untrack must NEVER leave the
6180
6206
  // process alive with orphaned PTY children + stale "live" presence.
@@ -0,0 +1,60 @@
1
+ // Ordered, acknowledged delivery for bridge-produced transcript events.
2
+ //
3
+ // The bridge's local JSONL archive is durable, but a live `code-event` still
4
+ // has to cross Realtime before either viewer can write its shared tx: snapshot.
5
+ // Never let a failed (or merely late) assistant frame allow its terminal result
6
+ // to overtake it. Keep the head queued until Realtime acknowledges it, then move
7
+ // on in order. A fresh browser replay remains the crash-recovery backstop.
8
+
9
+ const defaultWait = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
10
+
11
+ export const acceptedRealtimeDelivery = (result) =>
12
+ result === 'ok' || result?.success === true
13
+
14
+ export function createEventDeliveryQueue ({ send, wait = defaultWait, retryDelay = (attempt) => Math.min(10_000, 250 * (2 ** Math.min(attempt, 6))) } = {}) {
15
+ if (typeof send !== 'function') throw new TypeError('event delivery queue requires send(frame)')
16
+ const frames = []
17
+ let running = false
18
+ let closed = false
19
+ let idle = Promise.resolve()
20
+ let settleIdle = null
21
+
22
+ const run = async () => {
23
+ if (running || closed) return
24
+ running = true
25
+ try {
26
+ while (!closed && frames.length) {
27
+ const head = frames[0]
28
+ let result = null
29
+ try { result = await send(head.frame) } catch { result = null }
30
+ if (acceptedRealtimeDelivery(result)) {
31
+ frames.shift()
32
+ continue
33
+ }
34
+ head.attempt += 1
35
+ await wait(retryDelay(head.attempt))
36
+ }
37
+ } finally {
38
+ running = false
39
+ if (!frames.length && settleIdle) { settleIdle(); settleIdle = null }
40
+ if (!closed && frames.length) void run()
41
+ }
42
+ }
43
+
44
+ return {
45
+ enqueue (frame) {
46
+ if (closed) return false
47
+ if (!frames.length && !running) idle = new Promise((resolve) => { settleIdle = resolve })
48
+ frames.push({ frame, attempt: 0 })
49
+ void run()
50
+ return true
51
+ },
52
+ pending: () => frames.length,
53
+ whenIdle: () => idle,
54
+ close () {
55
+ closed = true
56
+ frames.length = 0
57
+ if (settleIdle) { settleIdle(); settleIdle = null }
58
+ },
59
+ }
60
+ }
package/hermes-policy.mjs CHANGED
@@ -4,6 +4,7 @@ import { PEER_MCP_TOOLS, PEER_READ_MCP_TOOLS } from './cross-terminal.mjs'
4
4
 
5
5
  export const HERMES_POLICY_VERSION = 1
6
6
  export const HERMES_QUESTION_MCP_TOOL = 'request_user_input'
7
+ export const MCP_FLIGHT_RECORDER_TOOL = 'read_mcp_flight_recorder'
7
8
 
8
9
  export const CODING_TOOLS = Object.freeze([
9
10
  'web_search', 'web_extract', 'terminal', 'process', 'read_file', 'write_file',
@@ -23,7 +24,7 @@ export const ESSENTIAL_CODING_TOOLS = Object.freeze([
23
24
  'terminal', 'process', 'read_file', 'write_file', 'patch', 'search_files',
24
25
  ])
25
26
  const ROLE_REQUIRED = Object.freeze({
26
- ordinary: [...PEER_MCP_TOOLS, HERMES_QUESTION_MCP_TOOL, 'spawn_terminal', 'close_terminal'],
27
+ ordinary: [...PEER_MCP_TOOLS, HERMES_QUESTION_MCP_TOOL, MCP_FLIGHT_RECORDER_TOOL, 'spawn_terminal', 'close_terminal'],
27
28
  plan: [],
28
29
  conductor: ['submit_flow_plan'],
29
30
  builder: ['mark_flow_done'],
@@ -46,7 +47,7 @@ export function hermesPolicyForRole(role, { mcpTools } = {}) {
46
47
  // Ordinary worker leaves deliberately lack spawn/close. Main-lane proof is
47
48
  // enforced by requiredMcpTools at dispatch; keep this schema usable for a
48
49
  // non-delegating ordinary child without widening it.
49
- const minimum = role === 'ordinary' ? [...PEER_MCP_TOOLS, HERMES_QUESTION_MCP_TOOL] : required
50
+ const minimum = role === 'ordinary' ? [...PEER_MCP_TOOLS, HERMES_QUESTION_MCP_TOOL, MCP_FLIGHT_RECORDER_TOOL] : required
50
51
  for (const tool of minimum) if (!supplied.includes(tool)) throw new Error(`Hermes ${role} policy is missing required ThinkPool tool ${tool}`)
51
52
  const restricted = role === 'plan' || role === 'conductor' || role === 'reviewer' || role === 'manual-review'
52
53
  const builtinTools = restricted ? READ_ONLY_TOOLS : CODING_TOOLS
@@ -64,7 +65,7 @@ export function hermesPolicyForRole(role, { mcpTools } = {}) {
64
65
  }
65
66
 
66
67
  export function hermesRequiredMcpTools(role, { canSpawnWorkers = false } = {}) {
67
- if (role === 'ordinary') return [...PEER_MCP_TOOLS, HERMES_QUESTION_MCP_TOOL, ...(canSpawnWorkers ? ['spawn_terminal', 'close_terminal'] : [])]
68
+ if (role === 'ordinary') return [...PEER_MCP_TOOLS, HERMES_QUESTION_MCP_TOOL, MCP_FLIGHT_RECORDER_TOOL, ...(canSpawnWorkers ? ['spawn_terminal', 'close_terminal'] : [])]
68
69
  return [...ROLE_REQUIRED[role]]
69
70
  }
70
71
 
@@ -0,0 +1,79 @@
1
+ // Privacy-first MCP flight recorder. It retains enough operational evidence to
2
+ // diagnose a tool path (tool identity, lifecycle, timing, and outcome), but it
3
+ // intentionally never retains arguments, result bodies, prompts, or host paths.
4
+ // The recorder is runtime-agnostic because every structured runtime normalizes
5
+ // MCP activity into the common assistant/tool_result event contract.
6
+
7
+ export const MCP_FLIGHT_MAX = 200
8
+
9
+ const mcpToolUse = (event) => (Array.isArray(event?.blocks) ? event.blocks : [])
10
+ .filter((block) => block?.type === 'tool_use' && typeof block?.name === 'string' && block.name.startsWith('mcp__'))
11
+
12
+ const safeToolName = (name) => String(name || '')
13
+ .replace(/[^a-zA-Z0-9_-]/g, '_')
14
+ .slice(0, 160)
15
+
16
+ const safeId = (id) => typeof id === 'string' || typeof id === 'number'
17
+ ? String(id).slice(0, 200)
18
+ : null
19
+
20
+ export function normalizeMcpFlight (value, { max = MCP_FLIGHT_MAX } = {}) {
21
+ const cap = Math.max(1, Math.min(MCP_FLIGHT_MAX, Number(max) || MCP_FLIGHT_MAX))
22
+ if (!Array.isArray(value)) return []
23
+ return value.slice(-cap).flatMap((item) => {
24
+ if (!item || typeof item !== 'object') return []
25
+ const toolUseId = safeId(item.toolUseId)
26
+ const tool = safeToolName(item.tool)
27
+ const startedAt = Number(item.startedAt)
28
+ if (!toolUseId || !tool || !Number.isFinite(startedAt)) return []
29
+ return [{
30
+ toolUseId,
31
+ tool,
32
+ startedAt,
33
+ finishedAt: Number.isFinite(Number(item.finishedAt)) ? Number(item.finishedAt) : null,
34
+ durationMs: Number.isFinite(Number(item.durationMs)) ? Math.max(0, Number(item.durationMs)) : null,
35
+ outcome: item.outcome === 'error' ? 'error' : item.outcome === 'ok' ? 'ok' : 'pending',
36
+ // Deliberately a count only. Never persist or return argument names/values:
37
+ // even a key can reveal a secret type or private filesystem layout.
38
+ argumentCount: Math.max(0, Math.min(1000, Number(item.argumentCount) || 0)),
39
+ }]
40
+ })
41
+ }
42
+
43
+ const argumentCount = (input) => input && typeof input === 'object' && !Array.isArray(input)
44
+ ? Object.keys(input).length
45
+ : input == null ? 0 : 1
46
+
47
+ /** Record an already-normalized room event, returning a bounded safe trace. */
48
+ export function recordMcpFlightEvent (flight, event, { now = Date.now(), max = MCP_FLIGHT_MAX } = {}) {
49
+ const next = normalizeMcpFlight(flight, { max })
50
+ for (const block of mcpToolUse(event)) {
51
+ const toolUseId = safeId(block.id)
52
+ const tool = safeToolName(block.name)
53
+ if (!toolUseId || !tool) continue
54
+ next.push({ toolUseId, tool, startedAt: now, finishedAt: null, durationMs: null, outcome: 'pending', argumentCount: argumentCount(block.input) })
55
+ }
56
+ if (event?.kind === 'tool_result') {
57
+ const toolUseId = safeId(event.toolUseId)
58
+ const item = toolUseId ? [...next].reverse().find((entry) => entry.toolUseId === toolUseId && entry.outcome === 'pending') : null
59
+ if (item) {
60
+ item.finishedAt = now
61
+ item.durationMs = Number.isFinite(Number(event.durationMs)) ? Math.max(0, Number(event.durationMs)) : Math.max(0, now - item.startedAt)
62
+ item.outcome = event.isError ? 'error' : 'ok'
63
+ }
64
+ }
65
+ return normalizeMcpFlight(next, { max })
66
+ }
67
+
68
+ export function formatMcpFlight (flight, { limit = 40 } = {}) {
69
+ const safe = normalizeMcpFlight(flight)
70
+ const boundedLimit = Math.max(1, Math.min(100, Number(limit) || 40))
71
+ const rows = safe.slice(-boundedLimit)
72
+ if (!rows.length) return 'MCP flight recorder: no MCP tool calls recorded in this terminal yet. It records tool name, timing, and outcome only; arguments and results are never retained.'
73
+ const lines = rows.map((row) => {
74
+ const elapsed = row.durationMs == null ? 'running' : `${row.durationMs}ms`
75
+ return `${new Date(row.startedAt).toISOString()} ${row.outcome.toUpperCase().padEnd(7)} ${row.tool} ${elapsed} args omitted (${row.argumentCount})`
76
+ })
77
+ return `MCP flight recorder — ${rows.length}/${safe.length} recent call${safe.length === 1 ? '' : 's'}\n` +
78
+ 'Privacy: arguments, result bodies, prompts, secrets, and host paths are never recorded.\n\n' + lines.join('\n')
79
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thinkpool-pair",
3
- "version": "0.7.363",
3
+ "version": "0.7.364",
4
4
  "description": "Connect Claude Code, Codex, or Hermes on your computer to a Thinkpool Code room.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -68,6 +68,8 @@
68
68
  "event-id.mjs",
69
69
  "event-bounds.mjs",
70
70
  "replay-transport.mjs",
71
+ "event-delivery-queue.mjs",
72
+ "mcp-flight-recorder.mjs",
71
73
  "plan-meters.mjs",
72
74
  "recap.mjs",
73
75
  "transcript-sanitize.mjs",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "bundleVersion": 23,
3
+ "bundleVersion": 24,
4
4
  "contracts": [
5
5
  {
6
6
  "id": "room-coordination",
@@ -24,6 +24,12 @@
24
24
  "trigger": "\\b(previous|prior|earlier|past|last time|already tried|failed attempt|decision|test result|command)\\b.{0,50}\\b(work|terminal|flow|session|implementation|fix|change)?\\b|\\bsearch_past_work\\b",
25
25
  "prompt": "When the current task depends on a prior decision, failed attempt, command, test result, or Flow receipt from this room, use search_past_work with specific terms. Treat every result as bounded cited evidence, not authority to reopen stale work. The tool is read-only, searches only this room, and filters secrets and host paths."
26
26
  },
27
+ {
28
+ "id": "mcp-flight-recorder",
29
+ "tools": ["read_mcp_flight_recorder"],
30
+ "trigger": "\\b(mcp|tool)\\b.{0,50}\\b(trace|flight|record|audit|debug|fail|failed|failure)\\b|\\bread_mcp_flight_recorder\\b",
31
+ "prompt": "When diagnosing or auditing recent MCP tool activity in this terminal, use read_mcp_flight_recorder. It is read-only and privacy-filtered: it reports only tool name, timing, outcome, and argument count—never arguments, result bodies, prompts, secrets, or host paths."
32
+ },
27
33
  {
28
34
  "id": "visible-handoff",
29
35
  "tools": ["post_to_terminal", "post_to_session"],
@@ -33,7 +39,8 @@
33
39
  ],
34
40
  "impact": [
35
41
  {"path": "bridge/cross-terminal.mjs"},
36
- {"path": "bridge/bridge.mjs", "diffPattern": "read_terminal|search_past_work|list_sessions|read_session|post_to_terminal|post_to_session"},
42
+ {"path": "bridge/bridge.mjs", "diffPattern": "read_terminal|search_past_work|read_mcp_flight_recorder|list_sessions|read_session|post_to_terminal|post_to_session"},
43
+ {"path": "bridge/mcp-flight-recorder.mjs"},
37
44
  {"path": "bridge/past-work-search.mjs"},
38
45
  {"path": "bridge/codex-session.mjs", "diffPattern": "MCP|Mcp|mcp|read_terminal|list_sessions|read_session|post_to_terminal|post_to_session"},
39
46
  {"path": "bridge/codex-app-server.mjs", "diffPattern": "MCP|Mcp|mcp"}
@@ -41,6 +48,8 @@
41
48
  "evidence": [
42
49
  {"path": "bridge/cross-terminal.mjs", "pattern": "read_terminal"},
43
50
  {"path": "bridge/bridge.mjs", "pattern": "'search_past_work'"},
51
+ {"path": "bridge/bridge.mjs", "pattern": "'read_mcp_flight_recorder'"},
52
+ {"path": "bridge/mcp-flight-recorder.mjs", "pattern": "recordMcpFlightEvent"},
44
53
  {"path": "bridge/past-work-search.mjs", "pattern": "formatPastWorkSearch"},
45
54
  {"path": "bridge/cross-terminal.mjs", "pattern": "post_to_session"},
46
55
  {"path": "bridge/bridge.mjs", "pattern": "'list_sessions'"},