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 +99 -60
- package/event-id.mjs +231 -0
- package/flow-models.mjs +26 -2
- package/flow-task-graph.mjs +3 -3
- package/hermes-acp-bootstrap.py +196 -0
- package/hermes-policy.mjs +85 -0
- package/hermes-probe.mjs +32 -2
- package/hermes-session.mjs +62 -13
- package/package.json +6 -1
- package/review-check.mjs +155 -0
- package/runtime-registry.mjs +1 -1
- package/sdk-admission.mjs +9 -0
- package/session-store.mjs +84 -13
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
|
|
365
|
-
//
|
|
366
|
-
|
|
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(
|
|
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
|
|
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
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
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
|
-
//
|
|
1520
|
-
//
|
|
1521
|
-
//
|
|
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.
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
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
|
|
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.
|
|
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
|
|
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
|
-
?
|
|
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.
|
|
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
|
-
|
|
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 === '
|
|
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 */ } }
|