thinkpool-pair 0.7.245 → 0.7.247

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
@@ -54,9 +54,11 @@ import { withMcpSessionFactory } from './codex-mcp-http.mjs'
54
54
  import { startStructuredSession } from './runtime-session.mjs'
55
55
  import { defaultStructuredMode, shouldDeferStructuredRuntime, structuredRuntimeForCommand, structuredRuntimeMetadata, structuredRuntimeSupportsMode } from './runtime-registry.mjs'
56
56
  import { probeHermesRuntime } from './hermes-probe.mjs'
57
+ import { hermesRequiredMcpTools, hermesRoleFor } from './hermes-policy.mjs'
57
58
  import { canonicalRoomFilePath, waitForNativeImages } from './codex-images.mjs'
58
59
  import { createManagedLaneWorktree, removeManagedLaneWorktree } from './lane-worktree.mjs'
59
60
  import { commandOnPath } from './agent-detect.mjs'
61
+ import { requiresSdkAdmission, sdkSmokePassed } from './sdk-admission.mjs'
60
62
 
61
63
  const STRUCTURED_MODES = new Set(['default', 'acceptEdits', 'plan', 'review', 'bypassPermissions'])
62
64
  import { FLOW_CONDUCTOR_PROMPT, FLOW_LANE_PROMPT, FLOW_CODEX_CONDUCTOR_PROMPT, FLOW_CODEX_LANE_PROMPT, buildConductorEnv, assembleCrossWaveContext, buildLanePrompt } from './flow-conductor.mjs'
@@ -81,6 +83,7 @@ function stopFlowPreviews (flowId, laneId = null) {
81
83
  }
82
84
  import { FLOW_REVIEWER_PROMPT, FLOW_CODEX_REVIEWER_PROMPT, revertLane, parseReviewVerdict, reviewVerdictToReflection } from './flow-review.mjs'
83
85
  import { reviewGateDecision } from './flow-review-gate.mjs'
86
+ import { readReviewFile, runReviewCheck } from './review-check.mjs'
84
87
  import { pairAdjudicationPrompt, reviewReflectionDecision, REVIEW_DEFAULTS } from './flow-review-reflect.mjs'
85
88
  import { mergeWorktrees, inlineSingleHtml, initRepo } from './flow-assembly.mjs'
86
89
  import { canDispatch, FLOW_LIMITS, makeBudget, recordSpend, killSwitchEnv } from './flow-budget.mjs'
@@ -109,7 +112,7 @@ import { formatPeek, PEEK, siblingsOf, resolveSibling, crossPostDecision, CROSSP
109
112
  import { dispatchPendingRecap, recoverInterruptedTurn, recoverMissingResumeOnce, sendInterruptedContinue, shouldAttemptNativeResume, supersedeInterruptedResume } from './interrupted-resume.mjs'
110
113
  import { turnInFlight } from './update-gate.mjs'
111
114
  import { saveSession, flushSession, deleteSession, loadAll, canResume, loadPtyId, savePtyId, loadNames, saveNames, appendDurableEvents, seedDurableEvents, readDurablePage, readDurableOldestSeq, hasDurableArchive, servesHistoryPage } from './session-store.mjs'
112
- import { stampEvent, makeSeqCounter, maxSeq, seqable, capReplayEvents, chunkReplayEvents, boundEventForBroadcast, inlineImageBlocks, usageReportLine, codexUsageReportLine, buildRecapFromLog, RECAP_CAP, trimmedBeforeSeq, firstSeq } from './event-id.mjs'
115
+ import { stampEvent, makeSeqCounter, maxSeq, seqable, capReplayEvents, chunkReplayEvents, boundEventForBroadcast, inlineImageBlocks, ImageEventQueue, imageQueueConfig, uploadCodeImage as uploadCodeImageRequest, usageReportLine, codexUsageReportLine, buildRecapFromLog, RECAP_CAP, trimmedBeforeSeq, firstSeq } from './event-id.mjs'
113
116
  import { classifyCodeEvent, unknownCodeEventNotice } from './code-event-contract.mjs'
114
117
  import { fetchPairControlDeliveryAuthority } from './pair-control-authority.mjs'
115
118
  import { SIDE_HANDOFF_PROMPT, SIDE_ROLE_PROMPT, appendSideContext, assistantTextSince, sideContextBlock, sideSnapshot } from './side-lane.mjs'
@@ -122,6 +125,7 @@ import { buildTerminalRolePrompt, HERMES_VISIBLE_WORKER_FALLBACK_RULE } from './
122
125
  // Override with TP_SUPABASE_URL / TP_SUPABASE_ANON if you ever need to.
123
126
  const SUPABASE_URL = process.env.TP_SUPABASE_URL || DEFAULT_SUPABASE_URL
124
127
  const WEB_BASE = process.env.TP_WEB_BASE || 'https://thinkpool.io'
128
+ const IMAGE_QUEUE_CONFIG = imageQueueConfig()
125
129
 
126
130
  // The anon key is RESOLVED, not baked. It used to be a string literal here, and
127
131
  // that literal now sits in 224 published tarballs on 224 users' laptops: disable
@@ -361,10 +365,9 @@ if ((!argv[0] || argv[0] === 'setup') && process.stdin.isTTY) {
361
365
  // The SDK tracks a caret (^0.3.x), so a restart can auto-pull a NEW SDK, and its
362
366
  // interrupt/turn API has broken between 0.3.x minors before. Smoke-test the API
363
367
  // surface (offline, no tokens) the FIRST time a given SDK version is seen, so a
364
- // bad one is caught + surfaced loudly (stderr + the announce banner) instead of
365
- // silently breaking turns. sdkStatus feeds the announce. Fully guarded — a check
366
- // error never blocks serving.
367
- const SMOKE_PATH = path.join(path.dirname(fileURLToPath(import.meta.url)), 'sdk-smoke.mjs')
368
+ // bad one is caught before a room can serve turns. Account/service commands remain
369
+ // available so the operator can repair the installation.
370
+ const SMOKE_PATH = process.env.TP_SDK_SMOKE_PATH || path.join(path.dirname(fileURLToPath(import.meta.url)), 'sdk-smoke.mjs')
368
371
  let sdkStatus = { ok: null, version: 'unknown', reason: '' }
369
372
  function currentSdkVersion() {
370
373
  try {
@@ -382,7 +385,7 @@ function runSmoke() {
382
385
  catch (e) { return (e.stdout || '').toString().trim() || `SMOKE:FAIL:${currentSdkVersion()}:${e.code === 'ETIMEDOUT' ? 'timeout' : (e.message || 'run error').slice(0, 80)}` }
383
386
  }
384
387
  if (argv[0] === 'verify-sdk') {
385
- const out = runSmoke(); console.log(out); process.exit(/^SMOKE:PASS/.test(out) ? 0 : 1)
388
+ const out = runSmoke(); console.log(out); process.exit(sdkSmokePassed(out) ? 0 : 1)
386
389
  }
387
390
  function checkSdkCompat() {
388
391
  const dir = path.join(os.homedir(), '.thinkpool-pair'); const okFile = path.join(dir, 'sdk-ok')
@@ -397,10 +400,9 @@ function checkSdkCompat() {
397
400
  } else {
398
401
  sdkStatus = { ok: false, version: (m && m[2]) || version, reason: (m && m[3]) || 'unknown' }
399
402
  try { fs.mkdirSync(dir, { recursive: true }); fs.writeFileSync(path.join(dir, 'sdk-bad'), `${sdkStatus.version}: ${sdkStatus.reason}`) } catch { /* noop */ }
400
- process.stderr.write(`\n ⚠⚠ agent SDK v${sdkStatus.version} FAILED the compatibility smoke test — ${sdkStatus.reason}.\n ⚠⚠ turns may misbehave. Pin a known-good SDK + restart:\n bridge/package.json → "@anthropic-ai/claude-agent-sdk": "<good version>", then republish + Restart & update.\n`)
403
+ process.stderr.write(`\n ⛔ agent SDK v${sdkStatus.version} FAILED the compatibility smoke test — ${sdkStatus.reason}.\n ⛔ refusing to serve agent turns. Repair or pin a known-good SDK, then restart.\n`)
401
404
  }
402
405
  }
403
- try { checkSdkCompat() } catch (e) { process.stderr.write(` ⚠ SDK smoke check skipped: ${e.message}\n`) }
404
406
 
405
407
  if (!argv[0] || argv[0].startsWith('-')) { const { runAccount } = await import('./account.mjs'); await runAccount(SUPABASE_URL, SUPABASE_ANON) }
406
408
 
@@ -418,6 +420,10 @@ if (!/^[A-Z0-9_-]{2,32}$/.test(room)) {
418
420
  console.error(`refused: invalid room code ${JSON.stringify(room)} (expected 2–32 alphanumerics, dash, or underscore)`)
419
421
  process.exit(1)
420
422
  }
423
+ if (requiresSdkAdmission(argv)) {
424
+ checkSdkCompat()
425
+ if (!sdkStatus.ok) process.exit(1)
426
+ }
421
427
  // ── Supervisor mode (--supervise / --keep-alive): keep the bridge alive across
422
428
  // crashes. We re-exec ourselves without the flag and respawn the child on any
423
429
  // non-clean exit with exponential backoff. Zero-dependency, cross-platform. With
@@ -1503,38 +1509,30 @@ watchOutbox(MOCKUP_OUTBOX, () => [...sessions.keys()][0] || attachedId || [...te
1503
1509
  // { type:'image', path } ref. The persisted log + every replay then carry the path,
1504
1510
  // never base64; the web client mints a signed URL on render (structured.jsx, same
1505
1511
  // as the mockup thumbs). Spec: docs/specs/2026-06-26-code-agent-images.md
1506
- async function uploadCodeImage(term, cid, im) {
1507
- const headers = { 'Content-Type': 'application/json' }
1508
- if (codeAuthToken) headers.Authorization = `Bearer ${codeAuthToken}`
1509
- const res = await fetch(`${WEB_BASE}/api/code-image`, {
1510
- method: 'POST',
1511
- headers,
1512
- body: JSON.stringify({ code: room, term, cid, idx: im.idx, mediaType: im.mediaType, b64: im.b64 }),
1512
+ async function uploadCodeImage(term, cid, im, activeUploads) {
1513
+ return uploadCodeImageRequest({
1514
+ webBase: WEB_BASE,
1515
+ room,
1516
+ authToken: codeAuthToken,
1517
+ term,
1518
+ cid,
1519
+ image: im,
1520
+ timeoutMs: IMAGE_QUEUE_CONFIG.uploadTimeoutMs,
1521
+ activeUploads,
1513
1522
  })
1514
- if (!res.ok) throw new Error(`code-image ${res.status}`)
1515
- const { path } = await res.json()
1516
- if (!path) throw new Error('code-image: no path')
1517
- return path
1518
1523
  }
1519
- // Defer an image-bearing tool_result behind a per-session upload chain (preserves
1520
- // arrival order among images), then log + broadcast the URL-only event through the
1521
- // normal path. pushLog is the single seq-assignment point, so the deferred event
1522
- // still gets a contiguous seq at chain-resolution time; its ts was stamped at
1523
- // arrival, so it sorts into place. On upload failure the block degrades to the same
1524
- // render-safe text placeholder — base64 is NEVER logged, broadcast, or persisted.
1524
+ // Every structured event enters one per-session emission boundary. With no upload in
1525
+ // flight the fast path stays synchronous; after an image, later tool/results wait in
1526
+ // runtime order. The queue owns timeout, raw-base64 budgets, and teardown degradation.
1525
1527
  function deferImageEvent(entry, id, evt, imgs, emitTail) {
1526
- entry.imageChain = (entry.imageChain || Promise.resolve()).then(async () => {
1527
- for (const im of imgs) {
1528
- try {
1529
- const path = await uploadCodeImage(id, evt.cid, im)
1530
- evt.content[im.idx] = { type: 'image', path, mediaType: im.mediaType }
1531
- } catch (e) {
1532
- process.stderr.write(`\n ◇ screenshot upload failed (${e?.message || e}).\n`)
1533
- evt.content[im.idx] = { type: 'text', text: '[screenshot omitted — upload failed]' }
1534
- }
1535
- }
1536
- emitTail(evt)
1537
- }).catch(() => {})
1528
+ entry.imageQueue ||= new ImageEventQueue({
1529
+ maxPending: IMAGE_QUEUE_CONFIG.maxPending,
1530
+ maxBytes: IMAGE_QUEUE_CONFIG.maxBytes,
1531
+ upload: (queuedEvt, im, activeUploads) => uploadCodeImage(id, queuedEvt.cid, im, activeUploads),
1532
+ onUploadError: (e) => process.stderr.write(`\n ◇ screenshot upload failed (${e?.message || e}).\n`),
1533
+ onOverload: ({ maxPending, maxBytes }) => process.stderr.write(`\n ◇ screenshot omitted (image queue cap: ${maxPending} pending / ${maxBytes} encoded bytes).\n`),
1534
+ })
1535
+ entry.imageQueue.enqueue(evt, imgs, emitTail)
1538
1536
  }
1539
1537
  // Fire-and-forget: drop a closed term's screenshots from Storage so they don't
1540
1538
  // outlive the room (COGS). Per-term scope — never touches a sibling term's images.
@@ -1731,6 +1729,12 @@ function restoredTurnOpen(log) {
1731
1729
  // minutes-scale. Plain `git worktree list` output (path · sha · [branch]) is the
1732
1730
  // readable shape an agent acts on. Fail-quiet [] — a snapshot must never break a turn.
1733
1731
  const _wtCache = new Map() // cwd → { at, list }
1732
+ function immutableReviewSnapshot(cwd, taskKey = 'parent') {
1733
+ const source = path.resolve(cwd || process.cwd())
1734
+ const sha = execFileSync('git', ['-C', source, 'rev-parse', '--verify', 'HEAD^{commit}'], { encoding: 'utf8', timeout: 3000, stdio: ['ignore', 'pipe', 'ignore'] }).trim()
1735
+ if (!/^[0-9a-f]{40}$/i.test(sha)) throw new Error('parent review source has no immutable HEAD')
1736
+ return { taskKey, cwd: source, sha }
1737
+ }
1734
1738
  function worktreeSnapshot(cwd) {
1735
1739
  const key = cwd || process.cwd()
1736
1740
  const hit = _wtCache.get(key)
@@ -1748,7 +1752,7 @@ function worktreeSnapshot(cwd) {
1748
1752
  // relay STRUCTURED events. onEvent → broadcast `code-event` + print locally +
1749
1753
  // persist to the host file; tool calls round-trip through the perm card; the
1750
1754
  // rolling log replays to joiners and survives bridge restarts (session-store).
1751
- function openStructured({ id, runtime = 'claude', model, models, effort, resume, log, commands, mode, spawnedBy, spawnDepth, cascadeRole, hop, sideParent, sideTask, pendingSideContexts, rolePrompt, flowSessionId, flowTaskKey, flowRole, flowReviewTarget, flowReviewTargets, flowReviewRound, dispatchBaseSha, revertTarget, cwd, managedWorktree, reviewSliceRoots, openedAt, defer, provider, carryRecap, lastUsage }) {
1755
+ function openStructured({ id, runtime = 'claude', model, models, effort, resume, log, commands, mode, spawnedBy, spawnDepth, cascadeRole, hop, sideParent, sideTask, pendingSideContexts, rolePrompt, sliceType, flowSessionId, flowTaskKey, flowRole, flowReviewTarget, flowReviewTargets, flowReviewSnapshots, flowReviewRound, dispatchBaseSha, revertTarget, cwd, managedWorktree, reviewSliceRoots, openedAt, defer, provider, carryRecap, lastUsage }) {
1752
1756
  if (sessions.has(id)) return
1753
1757
  runtime = structuredRuntimeMetadata(runtime) ? runtime : 'claude'
1754
1758
  // No explicit mode → a sensible default per runtime (see defaultModeForRuntime):
@@ -1801,9 +1805,11 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
1801
1805
  ? (laneModel || null)
1802
1806
  : (laneModel || providerNameMap()[provider] || provider),
1803
1807
  provider: provider || null, spawnedBy: spawnedBy || undefined, spawnDepth: structuralDepth, cascadeRole: cascadeRole === 'conductor' || cascadeRole === 'worker' ? cascadeRole : null, hop: initialHop, sideParent: sideParent || undefined, sideTask: sideTask || undefined, pendingSideContexts: Array.isArray(pendingSideContexts) ? pendingSideContexts.filter(Boolean).slice(-4) : [], flowSessionId: flowSessionId || null, flowTaskKey: flowTaskKey || null, cwd: cwd || null, managedWorktree: managedWorktree || null,
1808
+ sliceType: sliceType === 'review' ? 'review' : null,
1804
1809
  flowRole: flowRole || (flowSessionId ? (flowTaskKey ? ((flowReviewTargets?.length || reviewSliceRoots?.length) ? 'reviewer' : 'builder') : 'conductor') : null),
1805
1810
  flowReviewTarget: flowReviewTarget || null,
1806
1811
  flowReviewTargets: Array.isArray(flowReviewTargets) && flowReviewTargets.length ? flowReviewTargets.filter(Boolean) : (flowReviewTarget ? [flowReviewTarget] : []),
1812
+ flowReviewSnapshots: Array.isArray(flowReviewSnapshots) ? flowReviewSnapshots.filter((item) => item?.taskKey && item?.sha && item?.cwd) : [],
1807
1813
  flowReviewRound: Number.isInteger(flowReviewRound) && flowReviewRound >= 0 ? flowReviewRound : 0,
1808
1814
  dispatchBaseSha: dispatchBaseSha || null,
1809
1815
  revertTarget: revertTarget || null,
@@ -2050,7 +2056,7 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
2050
2056
  // restart. Without this, sessionData omitted it → on restart the resumed session
2051
2057
  // re-launched on the host default (Opus) regardless of the last switch, and the
2052
2058
  // switch looked like it "never changed the model" (Max 2026-07-02). Restored below.
2053
- 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, pendingSideContexts: entry.pendingSideContexts, flowSessionId: entry.flowSessionId, flowTaskKey: entry.flowTaskKey, flowRole: entry.flowRole || null, flowReviewTargets: entry.flowReviewTargets || [], flowReviewRound: entry.flowReviewRound || 0, 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 })
2059
+ 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, pendingSideContexts: entry.pendingSideContexts, sliceType: entry.sliceType, flowSessionId: entry.flowSessionId, flowTaskKey: entry.flowTaskKey, flowRole: entry.flowRole || null, flowReviewTargets: entry.flowReviewTargets || [], flowReviewSnapshots: entry.flowReviewSnapshots || [], flowReviewRound: entry.flowReviewRound || 0, 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 })
2054
2060
  const persist = () => saveSession(room, id, sessionData())
2055
2061
  // Synchronous flush of this session's record. Used on open (so a brand-new session
2056
2062
  // has a file under its id BEFORE its first event — surviving a restart inside the
@@ -2078,7 +2084,7 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
2078
2084
  return { error: `Could not open a Hermes worker on ${JSON.stringify(args.model)} — that exact model is not in this parent session's ACP catalog.` }
2079
2085
  }
2080
2086
  if (runtime === 'hermes' && args?.mode && !['default', 'acceptEdits'].includes(args.mode)) {
2081
- return { error: `Hermes ACP does not structurally support ${JSON.stringify(args.mode)} mode. Use default or acceptEdits; Flow/plan/review safety remains disabled.` }
2087
+ return { error: `Hermes ACP only exposes default/acceptEdits as user modes; Flow roles use a bridge-owned process-local tool policy.` }
2082
2088
  }
2083
2089
  if (runtime === 'claude' && !args?.provider && args?.model && /^gpt-/i.test(args.model)) {
2084
2090
  return { error: `Could not open a Claude terminal on Codex model ${JSON.stringify(args.model)}. Choose runtime="codex" or a Claude model.` }
@@ -2346,7 +2352,7 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
2346
2352
  // not receive this tool, so the hierarchy is capability-enforced.
2347
2353
  ...(canSpawnWorkers ? [tool(
2348
2354
  'spawn_terminal',
2349
- 'Open a visible WORKER SUB-TERMINAL (Claude, Codex, or—only from a Hermes parent—Hermes) in the ThinkPool Ensemble. spawn_terminal is worker-only: it can never create a Cascade conductor or main terminal. When a person asks for a separate Cascade/conductor terminal, use open_main_terminal instead. The runtime defaults to inheriting this main/conductor terminal, or pass `runtime`. Choose `model` explicitly when practical: Codex Luna/Mini for mechanical work, Terra for ordinary feature/fix work, and a balanced tier for adversarial review; never select Sol for routine workers. A Hermes worker requires an explicit exact model ID from the parent ACP catalog. After a native Claude account/provider failure, do not retry Claude aliases on that provider; follow read_terminal\'s one-time visible Hermes fallback guidance. A Hermes review worker in default mode is permission-gated, not structurally read-only—deny/stop writes and never claim Flow safety. Pass sliceType=scaffold for mechanical work, feature/fix for builders, and review for adversarial verification. Give the worker a bounded initial task, collect its result with read_terminal, then ALWAYS close_terminal it.',
2355
+ 'Open a visible WORKER SUB-TERMINAL (Claude, Codex, or—only from a Hermes parent—Hermes) in the ThinkPool Ensemble. spawn_terminal is worker-only: it can never create a Cascade conductor or main terminal. For ordinary workers use an appropriate non-Sol tier: gpt-5.6-luna or gpt-5.4-mini for scaffold/search, gpt-5.6-terra for feature/fix, and a balanced tier for adversarial review; never select Sol for routine workers. A Hermes sliceType=review lane is structurally reviewer-scoped before ACP startup: it can inspect files and use the bridge review-check tool, but has no terminal, write, patch, delegate, browser, skill, memory, or session-history schema. Pass sliceType=scaffold for mechanical work, feature/fix for builders, and review for adversarial verification. Give the worker a bounded initial task, collect its result with read_terminal, then ALWAYS close_terminal it.',
2350
2356
  {
2351
2357
  name: z.string().max(80).optional().describe('a short label for the new lane so the room (and you) can find it, e.g. "Research: topologies"'),
2352
2358
  task: z.string().optional().describe('an initial task to hand the new lane immediately; omit to open it idle'),
@@ -2442,10 +2448,18 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
2442
2448
  // otherwise inherit, but fall plan → default (an autonomous worker doesn't plan-gate).
2443
2449
  const childSpawnDepth = (entry.spawnDepth || 0) + 1
2444
2450
  const childHop = (entry.hop || 0) + 1
2451
+ let manualReviewSnapshots = []
2452
+ if (args?.sliceType === 'review') {
2453
+ // A manual reviewer never accepts a model-supplied path/ref. It
2454
+ // reads this exact parent HEAD through read_review_file and runs
2455
+ // checks only through its archived `parent` target.
2456
+ try { manualReviewSnapshots = [immutableReviewSnapshot(entry.cwd || process.cwd())] }
2457
+ catch (error) { return okText(`Review lane was not opened: could not pin the spawning parent’s HEAD (${error?.message || error}).`) }
2458
+ }
2445
2459
  // Stamp ownership/depth BEFORE the runtime starts so its first system
2446
2460
  // preamble is truthful. Mutating ne.spawnedBy after openStructured was
2447
2461
  // too late: Codex/Claude had already booted with the top-level wording.
2448
- openStructured({ id: newId, runtime: resolved.runtime, model: resolved.model, provider: resolved.provider, mode: resolved.mode, spawnedBy: id, spawnDepth: childSpawnDepth, cascadeRole: 'worker', hop: childHop })
2462
+ openStructured({ id: newId, runtime: resolved.runtime, model: resolved.model, provider: resolved.provider, mode: resolved.mode, sliceType: args?.sliceType, flowReviewTargets: manualReviewSnapshots.map((item) => item.taskKey), flowReviewSnapshots: manualReviewSnapshots, spawnedBy: id, spawnDepth: childSpawnDepth, cascadeRole: 'worker', hop: childHop })
2449
2463
  const ne = sessions.get(newId)
2450
2464
  if (!ne) { if (args?.name) { delete termNames[newId]; saveNames(room, termNames) } return okText('Could not open a new lane — the terminal cap may have just been reached. Close one and retry.') }
2451
2465
  ne.peekCount = 0; ne.postCount = 0; ne.spawnTimes = []
@@ -2453,7 +2467,10 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
2453
2467
  if (args?.task) {
2454
2468
  // The user-visible relay reinforces (but never defines) the structural
2455
2469
  // role already injected by the system preamble above.
2456
- const msg = `[Task from terminal ${fromRef}'s agent — relayed via ThinkPool Ensemble; you are its WORKER SUB-TERMINAL, never a main terminal or Cascade conductor]\n${args.task}`
2470
+ const reviewTarget = manualReviewSnapshots.length
2471
+ ? `\n\n[Immutable review target: parent at spawn time — target key "parent", pinned HEAD ${manualReviewSnapshots[0].sha}. Use read_review_file with target="parent" for source and run_review_check with target="parent" for fixed checks. Do not infer a target path/ref or mutate anything.]`
2472
+ : ''
2473
+ const msg = `[Task from terminal ${fromRef}'s agent — relayed via ThinkPool Ensemble; you are its WORKER SUB-TERMINAL, never a main terminal or Cascade conductor]\n${args.task}${reviewTarget}`
2457
2474
  const evt = { kind: 'you', text: msg, by: `terminal ${fromRef} (agent)`, crosspost: true, relaySourceName: termNames[id] || undefined }
2458
2475
  stampEvent(evt); pushLog(ne, evt); bcast('code-event', { term: newId, evt })
2459
2476
  try { ne.session.sendTurn(msg) } catch { return okText(`Opened lane ${newRef}${args?.name ? ` ("${args.name}")` : ''}, but it may still be starting — could not hand off the task. Try post_to_terminal shortly.`) }
@@ -2568,6 +2585,17 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
2568
2585
  return { content: [{ type: 'text', text: result.message }] }
2569
2586
  },
2570
2587
  )] : []),
2588
+ ...(entry.flowRole === 'reviewer' || entry.sliceType === 'review' ? [tool(
2589
+ 'read_review_file',
2590
+ 'Read one regular file from the declared immutable review target. This always reads the target’s pinned Git object, never a live worktree; target and path are validated and the response is byte-bounded.',
2591
+ { target: z.string(), path: z.string() },
2592
+ async (args) => ({ content: [{ type: 'text', text: JSON.stringify(await readReviewFile({ target: args?.target, filePath: args?.path, snapshots: entry.flowReviewSnapshots })) }] }),
2593
+ ), tool(
2594
+ 'run_review_check',
2595
+ 'Run one fixed verification check against an immutable git-archived review target. This accepts only the declared target and a fixed check id; it never accepts a shell command, cwd, environment, executable, or arguments. The check runs in a scratch archive, never in a builder worktree.',
2596
+ { target: z.string(), checkId: z.string() },
2597
+ async (args) => ({ content: [{ type: 'text', text: JSON.stringify(await runReviewCheck({ target: args?.target, checkId: args?.checkId, snapshots: entry.flowReviewSnapshots })) }] }),
2598
+ )] : []),
2571
2599
  ],
2572
2600
  })
2573
2601
  const peekServer = withMcpSessionFactory(createPeekServer)
@@ -2667,8 +2695,9 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
2667
2695
  // role-scoped tools through local /tools before a user turn. Claude/Codex
2668
2696
  // already fail startup when their required MCP handshake fails.
2669
2697
  requiredMcpTools: runtime === 'hermes'
2670
- ? ['read_terminal', ...(canSpawnWorkers ? ['spawn_terminal', 'close_terminal'] : [])]
2698
+ ? hermesRequiredMcpTools(hermesRoleFor({ flowRole: entry.flowRole, sliceType: entry.sliceType }), { canSpawnWorkers })
2671
2699
  : undefined,
2700
+ hermesRole: runtime === 'hermes' ? hermesRoleFor({ flowRole: entry.flowRole, sliceType: entry.sliceType }) : undefined,
2672
2701
  // Tier C precheck — the PreToolUse gate calls this BEFORE raising a card, so a
2673
2702
  // disabled/looping/over-cap post never bothers a person. Closes over `entry`.
2674
2703
  crossPostGate: () => crossPostDecision({ hop: entry.hop || 0, postCount: entry.postCount || 0, disabled: process.env.TP_CROSSPOST_OFF === '1' }),
@@ -2715,14 +2744,15 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
2715
2744
  message: evt.message,
2716
2745
  recap: recoveryRecap,
2717
2746
  reopen: (carryRecap) => {
2747
+ entry.imageQueue?.close()
2718
2748
  try { entry.mockupWatcher?.close() } catch { /* noop */ }
2719
2749
  sessions.delete(id)
2720
2750
  openStructured({
2721
2751
  id, runtime: entry.runtime, model: entry.model || model, effort: entry.effort,
2722
2752
  provider: entry.provider, log: entry.log, commands: entry.commands, mode: entry.mode,
2723
- spawnedBy: entry.spawnedBy, spawnDepth: entry.spawnDepth, cascadeRole: entry.cascadeRole, hop: entry.hop, sideParent: entry.sideParent, sideTask: entry.sideTask, pendingSideContexts: entry.pendingSideContexts, flowSessionId: entry.flowSessionId,
2753
+ spawnedBy: entry.spawnedBy, spawnDepth: entry.spawnDepth, cascadeRole: entry.cascadeRole, hop: entry.hop, sideParent: entry.sideParent, sideTask: entry.sideTask, pendingSideContexts: entry.pendingSideContexts, sliceType: entry.sliceType, flowSessionId: entry.flowSessionId,
2724
2754
  flowTaskKey: entry.flowTaskKey, flowRole: entry.flowRole, flowReviewTarget: entry.flowReviewTarget,
2725
- flowReviewTargets: entry.flowReviewTargets, flowReviewRound: entry.flowReviewRound,
2755
+ flowReviewTargets: entry.flowReviewTargets, flowReviewSnapshots: entry.flowReviewSnapshots, flowReviewRound: entry.flowReviewRound,
2726
2756
  dispatchBaseSha: entry.dispatchBaseSha, revertTarget: entry.revertTarget, cwd: entry.cwd,
2727
2757
  managedWorktree: entry.managedWorktree, rolePrompt: entry.rolePrompt,
2728
2758
  reviewSliceRoots: entry.reviewSliceRoots, openedAt: entry.openedAt,
@@ -2988,10 +3018,10 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
2988
3018
  // (live OR replay). Lift it to Storage FIRST, then emit the URL-only event —
2989
3019
  // so the persisted log + replay carry a path, never base64. Order/seq stay
2990
3020
  // intact: pushLog (inside emitTail) is the single seq point, called when the
2991
- // upload resolves. Other kinds emit synchronously, unchanged.
3021
+ // upload resolves. Later events share the same queue; otherwise a closing
3022
+ // result could overtake its delayed tool_result (BH-DATA-003).
2992
3023
  const imgs = inlineImageBlocks(evt)
2993
- if (imgs.length) { deferImageEvent(entry, id, evt, imgs, emitTail); return }
2994
- emitTail(evt)
3024
+ deferImageEvent(entry, id, evt, imgs, emitTail)
2995
3025
  },
2996
3026
  requestPermission: (req) => new Promise((resolve) => {
2997
3027
  // FLOW conductor plan → intercept (no generic card). The plan JSON rides
@@ -3147,6 +3177,7 @@ function acceptEditsPending(s) {
3147
3177
  function respawnStructured(id, provider) {
3148
3178
  const s = sessions.get(id)
3149
3179
  if (!s) return false
3180
+ s.imageQueue?.close()
3150
3181
  // Capture the lane's identity for re-open (NOT resume — fresh SDK session).
3151
3182
  // NOTE the deliberate absence of `model`: a respawn crosses a provider boundary,
3152
3183
  // and the old lane's model id means nothing on the new backend. Carrying it sent
@@ -3157,7 +3188,7 @@ function respawnStructured(id, provider) {
3157
3188
  // openStructured seed from the TARGET provider's configured model, which is the
3158
3189
  // only model this lane was ever asked for. A same-env model change never reaches
3159
3190
  // here — that path is an in-place setModel (see provider-switch).
3160
- const { runtime, log, commands, mode, spawnedBy, spawnDepth, cascadeRole, hop, sideParent, sideTask, pendingSideContexts, flowSessionId, flowTaskKey, flowRole, flowReviewTarget, flowReviewTargets, flowReviewRound, dispatchBaseSha, revertTarget, cwd, managedWorktree, rolePrompt, reviewSliceRoots, openedAt, lastUsage } = s
3191
+ const { runtime, log, commands, mode, spawnedBy, spawnDepth, cascadeRole, hop, sideParent, sideTask, pendingSideContexts, sliceType, flowSessionId, flowTaskKey, flowRole, flowReviewTarget, flowReviewTargets, flowReviewSnapshots, flowReviewRound, dispatchBaseSha, revertTarget, cwd, managedWorktree, rolePrompt, reviewSliceRoots, openedAt, lastUsage } = s
3161
3192
  // Context-carry (2026-07-08): a provider switch is not an SDK resume — the new backend
3162
3193
  // starts blank mid-conversation. Synthesize a plain-text recap from the VISIBLE log NOW
3163
3194
  // (before teardown) and hand it to the fresh session as its first turn so the agent
@@ -3176,7 +3207,7 @@ function respawnStructured(id, provider) {
3176
3207
  // sessionData() (provider included) synchronously on open, so a bridge restart
3177
3208
  // restores the lane on its CURRENT provider, not the original — and its next
3178
3209
  // announce carries the new provider badge (additive {id,name} projection).
3179
- openStructured({ id, runtime, provider, log, commands, mode, spawnedBy, spawnDepth, cascadeRole, hop, sideParent, sideTask, pendingSideContexts, flowSessionId, flowTaskKey, flowRole, flowReviewTarget, flowReviewTargets, flowReviewRound, dispatchBaseSha, revertTarget, cwd, managedWorktree, rolePrompt, reviewSliceRoots, openedAt, lastUsage, carryRecap })
3210
+ openStructured({ id, runtime, provider, log, commands, mode, spawnedBy, spawnDepth, cascadeRole, hop, sideParent, sideTask, pendingSideContexts, sliceType, flowSessionId, flowTaskKey, flowRole, flowReviewTarget, flowReviewTargets, flowReviewSnapshots, flowReviewRound, dispatchBaseSha, revertTarget, cwd, managedWorktree, rolePrompt, reviewSliceRoots, openedAt, lastUsage, carryRecap })
3180
3211
  return true
3181
3212
  }
3182
3213
 
@@ -3210,6 +3241,7 @@ function endStructured(id) {
3210
3241
  if (!id) return
3211
3242
  const s = sessions.get(id)
3212
3243
  if (s) {
3244
+ s.imageQueue?.close()
3213
3245
  drainPending(s)
3214
3246
  try { s.session?.end() } catch { /* noop */ }
3215
3247
  try { s.mockupWatcher?.close() } catch { /* noop */ }
@@ -3989,7 +4021,7 @@ channel
3989
4021
  // FL-M6 — restore the flow context (id/role/cwd) so an in-flight flow survives a
3990
4022
  // bridge restart: the conductor keeps its subagent-block + plan interception, and
3991
4023
  // lanes keep their worktree cwd + the ability to mark done.
3992
- 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, pendingSideContexts: rec.pendingSideContexts, flowSessionId: rec.flowSessionId, flowTaskKey: rec.flowTaskKey, flowRole: rec.flowRole, flowReviewTarget: rec.flowReviewTarget, flowReviewTargets: rec.flowReviewTargets, flowReviewRound: rec.flowReviewRound, dispatchBaseSha: rec.dispatchBaseSha, revertTarget: rec.revertTarget, cwd: rec.cwd, managedWorktree: rec.managedWorktree, rolePrompt: rec.rolePrompt, reviewSliceRoots: rec.reviewSliceRoots, openedAt: rec.openedAt, lastUsage: rec.lastUsage, carryRecap: wasInterrupted ? recoveryRecap : rec.carryRecap,
4024
+ 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, pendingSideContexts: rec.pendingSideContexts, sliceType: rec.sliceType, flowSessionId: rec.flowSessionId, flowTaskKey: rec.flowTaskKey, flowRole: rec.flowRole, flowReviewTarget: rec.flowReviewTarget, flowReviewTargets: rec.flowReviewTargets, flowReviewSnapshots: rec.flowReviewSnapshots, flowReviewRound: rec.flowReviewRound, dispatchBaseSha: rec.dispatchBaseSha, revertTarget: rec.revertTarget, cwd: rec.cwd, managedWorktree: rec.managedWorktree, rolePrompt: rec.rolePrompt, reviewSliceRoots: rec.reviewSliceRoots, openedAt: rec.openedAt, lastUsage: rec.lastUsage, carryRecap: wasInterrupted ? recoveryRecap : rec.carryRecap,
3993
4025
  // Lazy-boot restored terminals that were IDLE + not part of a flow: their transcript
3994
4026
  // shows immediately; the query boots on first turn. Mid-turn + flow terminals boot now
3995
4027
  // (mid-turn needs auto-resume; flow needs its lane live).
@@ -4104,7 +4136,7 @@ flowChannel
4104
4136
  if (payload.originTerm && !origin) return
4105
4137
  const flowRuntime = origin ? normalizeFlowRuntime(origin.runtime, null) : 'claude'
4106
4138
  if (!flowRuntime) return
4107
- const flowCatalog = flowRuntime === 'codex' ? (origin?.models || readCodexModels()) : []
4139
+ const flowCatalog = flowRuntime === 'codex' ? (origin?.models || readCodexModels()) : flowRuntime === 'hermes' ? (origin?.models || []) : []
4108
4140
  const conductorModel = flowConductorModelFor({ runtime: flowRuntime, originModel: origin?.model, catalog: flowCatalog })
4109
4141
  const cid = randomUUID()
4110
4142
  termNames[cid] = `Flow · ${String(payload.flowId).slice(0, 6)}`
@@ -4116,7 +4148,7 @@ flowChannel
4116
4148
  // Model tiers (2026-07-03-flow-lane-model-tiers): the conductor keeps whatever brain it was
4117
4149
  // given by default (TP_FLOW_CONDUCTOR_MODEL unset → undefined → today's behavior); set the env
4118
4150
  // to pin a cheaper/smarter conductor. Lanes get tiered below via laneModelFor.
4119
- openStructured({ id: cid, runtime: flowRuntime, model: conductorModel, mode: flowRuntime === 'codex' ? 'plan' : 'default', rolePrompt: flowRuntime === 'codex' ? FLOW_CODEX_CONDUCTOR_PROMPT : FLOW_CONDUCTOR_PROMPT, flowSessionId: payload.flowId, flowRole: 'conductor', spawnedBy: `flow:${payload.flowId}` })
4151
+ openStructured({ id: cid, runtime: flowRuntime, model: conductorModel, mode: flowRuntime === 'codex' ? 'plan' : 'default', rolePrompt: flowRuntime === 'claude' ? FLOW_CONDUCTOR_PROMPT : FLOW_CODEX_CONDUCTOR_PROMPT, flowSessionId: payload.flowId, flowRole: 'conductor', spawnedBy: `flow:${payload.flowId}` })
4120
4152
  const ce = sessions.get(cid)
4121
4153
  if (ce?.session) { try { ce.session.sendTurn(payload.prompt || '') } catch { /* session still starting */ } }
4122
4154
  process.stderr.write(`\n ${A.mag}◆ flow conductor launched — flow ${String(payload.flowId).slice(0, 8)} (plan mode).${A.rst}\n`)
@@ -4137,7 +4169,7 @@ flowChannel
4137
4169
  }
4138
4170
  const flowRuntime = normalizeFlowRuntime(conductor.runtime, null)
4139
4171
  if (!flowRuntime) return
4140
- const flowCatalog = flowRuntime === 'codex' ? (conductor.models || readCodexModels()) : []
4172
+ const flowCatalog = flowRuntime === 'codex' ? (conductor.models || readCodexModels()) : flowRuntime === 'hermes' ? (conductor.models || []) : []
4141
4173
  const assignments = []
4142
4174
  // Step 6 — cap concurrent Flow lanes (invariant: ≤ FLOW_LIMITS.maxConcurrentLanes).
4143
4175
  // Overflow tasks stay pending; the room re-dispatches them in the next wave.
@@ -4167,7 +4199,7 @@ flowChannel
4167
4199
  // every other slice gets the builder prompt.
4168
4200
  const isReview = t.slice_type === 'review'
4169
4201
  if (!validReviewTargetShape(t, flowRuntime)) {
4170
- process.stderr.write(`\n ${A.yel}◆ flow dispatch held — Codex review ${t.task_key} must target exactly one dependency.${A.rst}\n`)
4202
+ process.stderr.write(`\n ${A.yel}◆ flow dispatch held — ${flowRuntime === 'hermes' ? 'Hermes' : 'Codex'} review ${t.task_key} must target exactly one dependency.${A.rst}\n`)
4171
4203
  continue
4172
4204
  }
4173
4205
  const { dir } = createFlowWorktree({ flowId: payload.flowId, taskKey: t.task_key })
@@ -4195,6 +4227,12 @@ flowChannel
4195
4227
  const reviewSliceRoots = isReview
4196
4228
  ? (t.deps || []).map((dep) => worktreeSpec({ flowId: payload.flowId, taskKey: dep }).dir)
4197
4229
  : []
4230
+ const flowReviewSnapshots = isReview ? (t.deps || []).map((dep) => {
4231
+ const cwd = worktreeSpec({ flowId: payload.flowId, taskKey: dep }).dir
4232
+ let sha = null
4233
+ try { sha = execFileSync('git', ['-C', cwd, 'rev-parse', 'HEAD'], { encoding: 'utf8', timeout: 3000, stdio: ['ignore', 'pipe', 'ignore'] }).trim() } catch { /* target is not safely reviewable */ }
4234
+ return { taskKey: dep, cwd, sha }
4235
+ }).filter((item) => item.sha) : []
4198
4236
  // Lanes build autonomously in their own worktree — bypassPermissions so they
4199
4237
  // don't stall on a card for every write/bash (matches the user's expectation that
4200
4238
  // a Flow summoned from a bypass terminal runs hands-off).
@@ -4205,14 +4243,14 @@ flowChannel
4205
4243
  // a lane later, on demand, via activateLaneSkill — never the base prompt here.
4206
4244
  // S4 — resume: on a re-dispatch, replay the killed lane's HEALED transcript (Heal-3'd,
4207
4245
  // no dangling tool_use → no 400) instead of a cold start; undefined for a fresh lane.
4208
- const laneBase = flowRuntime === 'codex'
4246
+ const laneBase = flowRuntime === 'codex' || flowRuntime === 'hermes'
4209
4247
  ? (isReview ? FLOW_CODEX_REVIEWER_PROMPT : FLOW_CODEX_LANE_PROMPT)
4210
4248
  : (isReview ? FLOW_REVIEWER_PROMPT : FLOW_LANE_PROMPT)
4211
4249
  const laneRolePrompt = buildLanePrompt({ base: laneBase })
4212
4250
  // Model tiers (2026-07-03-flow-lane-model-tiers): pick the lane's brain by slice_type
4213
4251
  // (scaffold→sonnet, feature/fix/review→opus; env can blanket-override or `inherit` to
4214
4252
  // restore today's exact behavior). undefined → no model key passed (openStructured default).
4215
- openStructured({ id: laneId, runtime: flowRuntime, cwd: dir, model: flowLaneModelFor({ sliceType: t.slice_type, runtime: flowRuntime, catalog: flowCatalog }), mode: flowRuntime === 'codex' && isReview ? 'review' : 'bypassPermissions', rolePrompt: laneRolePrompt, flowSessionId: payload.flowId, flowTaskKey: t.task_key, flowRole: isReview ? 'reviewer' : 'builder', flowReviewTargets: isReview ? (t.deps || []) : [], dispatchBaseSha, revertTarget: redispatch?.revertTarget || null, spawnedBy: `flow:${payload.flowId}`, resume: redispatch?.resumeSessionId || undefined, reviewSliceRoots })
4253
+ openStructured({ id: laneId, runtime: flowRuntime, cwd: dir, model: flowLaneModelFor({ sliceType: t.slice_type, runtime: flowRuntime, catalog: flowCatalog }), mode: flowRuntime === 'codex' && isReview ? 'review' : 'bypassPermissions', rolePrompt: laneRolePrompt, flowSessionId: payload.flowId, flowTaskKey: t.task_key, flowRole: isReview ? 'reviewer' : 'builder', flowReviewTargets: isReview ? (t.deps || []) : [], flowReviewSnapshots, dispatchBaseSha, revertTarget: redispatch?.revertTarget || null, spawnedBy: `flow:${payload.flowId}`, resume: redispatch?.resumeSessionId || undefined, reviewSliceRoots })
4216
4254
  const le = sessions.get(laneId)
4217
4255
  if (le) {
4218
4256
  // S4 — stamp the surviving revert target on the resumed lane so a later reviewer still
@@ -4237,7 +4275,7 @@ flowChannel
4237
4275
  : '') +
4238
4276
  `ACCEPTANCE to INDEPENDENTLY verify: ${t.acceptance || t.title}\n` +
4239
4277
  `\nProject (context): ${payload.flowPrompt || ''}\n\n` +
4240
- (flowRuntime === 'codex'
4278
+ (flowRuntime === 'codex' || flowRuntime === 'hermes'
4241
4279
  ? `Review it without mutating the builder worktree. For write-producing install/build/test commands, copy its source into scratch under your own current worktree first. Submit the structured verdict with the ThinkPool submit_flow_review MCP tool; never write FLOW_REVIEW.json or call mark_flow_done.`
4242
4280
  : `Review it — run it, hunt for failure. When done, use the Write tool to write FLOW_REVIEW.json with {"pass":<boolean>,"reasons":["<specific finding>"],"taskKey":"<the slice you reviewed>"}. That Write IS your verdict.`)
4243
4281
  : `[Flow lane — slice "${t.task_key}" of flow ${String(payload.flowId).slice(0, 8)}]\n` +
@@ -4252,7 +4290,7 @@ flowChannel
4252
4290
  assembleCrossWaveContext(payload.flowId, { baseDir: process.cwd(), deps: t.deps }).text) +
4253
4291
  `\nProject (context): ${payload.flowPrompt || ''}\n\n` +
4254
4292
  `Build your slice. Own only your files. Done = it runs + meets acceptance. Commit when done.` +
4255
- (flowRuntime === 'codex' ? ` Then call the ThinkPool mark_flow_done MCP tool; do not write FLOW_DONE.` : '')
4293
+ (flowRuntime === 'codex' || flowRuntime === 'hermes' ? ` Then call the ThinkPool mark_flow_done MCP tool; do not write FLOW_DONE.` : '')
4256
4294
  try { le.session.sendTurn(spec) } catch { /* session still starting */ }
4257
4295
  }
4258
4296
  }
@@ -4565,6 +4603,7 @@ async function shutdown(code = 0, farewell = true) {
4565
4603
  // Flush structured session state synchronously BEFORE ending sessions. saveSession
4566
4604
  // debounces ~1.5s, so without this any events since the last write are lost on exit
4567
4605
  // (Contract #2). writeFileSync, well inside the 1500ms hard-exit backstop above.
4606
+ for (const s of sessions.values()) { try { s.imageQueue?.close() } catch { /* noop */ } }
4568
4607
  for (const s of sessions.values()) { try { s.flush?.() } catch { /* noop */ } }
4569
4608
  for (const t of terms.values()) { try { t.term.kill() } catch { /* noop */ } }
4570
4609
  for (const s of sessions.values()) { try { s.session.end() } catch { /* noop */ } }