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 +34 -8
- package/event-delivery-queue.mjs +60 -0
- package/hermes-policy.mjs +4 -3
- package/mcp-flight-recorder.mjs +79 -0
- package/package.json +3 -1
- package/thinkpool-capabilities.json +11 -2
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
|
-
|
|
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.
|
|
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":
|
|
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'"},
|