thinkpool-pair 0.7.251 → 0.7.253
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 +23 -84
- package/claude-session.mjs +11 -11
- package/codex-session.mjs +2 -1
- package/cross-terminal.mjs +12 -0
- package/hermes-session.mjs +93 -29
- package/package.json +1 -1
package/bridge.mjs
CHANGED
|
@@ -109,9 +109,9 @@ const flowRedispatch = new Map()
|
|
|
109
109
|
// wave BEFORE overrun. Lives bridge-side because waves dispatch across separate
|
|
110
110
|
// broadcasts; without persistent state the cap can never bite.
|
|
111
111
|
const flowBudgets = new Map()
|
|
112
|
-
import { formatPeek, PEEK, siblingsOf, resolveSibling, crossPostDecision, CROSSPOST, spawnDecision, SPAWN, CROSSROOM, formatPairRoster, crossRoomPostDecision, formatRoomNow, formatClosableHint, closeLaneDecision, laneStatusOf, nativeClaudeFallbackHint, buildDispatchPreview,
|
|
112
|
+
import { formatPeek, PEEK, siblingsOf, resolveSibling, crossPostDecision, CROSSPOST, spawnDecision, SPAWN, CROSSROOM, formatPairRoster, crossRoomPostDecision, formatRoomNow, formatClosableHint, closeLaneDecision, laneStatusOf, nativeClaudeFallbackHint, buildDispatchPreview, authorizeDirectDispatch } from './cross-terminal.mjs'
|
|
113
113
|
import { dispatchPendingRecap, recoverInterruptedTurn, recoverMissingResumeOnce, sendInterruptedContinue, shouldAttemptNativeResume, supersedeInterruptedResume } from './interrupted-resume.mjs'
|
|
114
|
-
import {
|
|
114
|
+
import { supersedeDispatchLease } from './dispatch-lease.mjs'
|
|
115
115
|
import { turnInFlight } from './update-gate.mjs'
|
|
116
116
|
import { saveSession, flushSession, deleteSession, loadAll, canResume, loadPtyId, savePtyId, loadNames, saveNames, appendDurableEvents, seedDurableEvents, readDurablePage, readDurableOldestSeq, hasDurableArchive, servesHistoryPage } from './session-store.mjs'
|
|
117
117
|
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'
|
|
@@ -653,31 +653,6 @@ const supabase = createClient(SUPABASE_URL, SUPABASE_ANON, {
|
|
|
653
653
|
realtime: { params: { eventsPerSecond: 60 } },
|
|
654
654
|
})
|
|
655
655
|
|
|
656
|
-
// Authenticated re-read for previewed Dispatch. Realtime is intentionally not
|
|
657
|
-
// trusted here: any room member can observe the public wake-up frame. RLS plus
|
|
658
|
-
// the bridge's live member token supplies the item/event rows that the pure
|
|
659
|
-
// verifier consumes. Timeout/offline/malformed state all fail closed.
|
|
660
|
-
async function readDurableDispatchAuthority ({ controlItemId, expected, timeoutMs = 5000 } = {}) {
|
|
661
|
-
if (!codeAuthToken || !/^[0-9a-f-]{36}$/i.test(String(controlItemId || ''))) return { ok: false, code: 'durable_authority_unavailable' }
|
|
662
|
-
const controller = new AbortController()
|
|
663
|
-
const timer = setTimeout(() => controller.abort(), timeoutMs)
|
|
664
|
-
timer.unref?.()
|
|
665
|
-
const headers = { apikey: SUPABASE_ANON, Authorization: `Bearer ${codeAuthToken}` }
|
|
666
|
-
try {
|
|
667
|
-
const itemUrl = `${SUPABASE_URL}/rest/v1/code_pair_control_items?id=eq.${encodeURIComponent(controlItemId)}&select=*`
|
|
668
|
-
const eventsUrl = `${SUPABASE_URL}/rest/v1/code_pair_control_events?item_id=eq.${encodeURIComponent(controlItemId)}&select=*&order=created_at.asc`
|
|
669
|
-
const [itemRes, eventRes] = await Promise.all([
|
|
670
|
-
fetch(itemUrl, { headers, signal: controller.signal }),
|
|
671
|
-
fetch(eventsUrl, { headers, signal: controller.signal }),
|
|
672
|
-
])
|
|
673
|
-
if (!itemRes.ok || !eventRes.ok) return { ok: false, code: 'durable_authority_read_failed' }
|
|
674
|
-
const [items, events] = await Promise.all([itemRes.json(), eventRes.json()])
|
|
675
|
-
if (!Array.isArray(items) || items.length !== 1 || !Array.isArray(events)) return { ok: false, code: 'durable_authority_shape' }
|
|
676
|
-
return verifyDurableDispatchAuthority({ item: items[0], events, expected: { ...expected, controlItemId } })
|
|
677
|
-
} catch { return { ok: false, code: 'durable_authority_unavailable' } }
|
|
678
|
-
finally { clearTimeout(timer) }
|
|
679
|
-
}
|
|
680
|
-
|
|
681
656
|
// Owner's plan (free|plus) — gates the Ensemble dispatch ceiling (Free 3 / Plus 6).
|
|
682
657
|
// Resolved from the owner token (codeAuthToken); RLS lets a user read their OWN
|
|
683
658
|
// profiles row. Cached; defaults to 'free' (the safe lower ceiling) until the fetch
|
|
@@ -1761,8 +1736,8 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
|
|
|
1761
1736
|
// codex → bypassPermissions, so a freshly-opened codex terminal can fetch /
|
|
1762
1737
|
// advance / ship instead of hitting the no-network wall; claude → default.
|
|
1763
1738
|
// Only fires when no mode was passed — a person's pick, a resume (persisted
|
|
1764
|
-
// entry.mode), a flow lane, and spawn_terminal (
|
|
1765
|
-
//
|
|
1739
|
+
// entry.mode), a flow lane, and spawn_terminal (always bypassPermissions) all
|
|
1740
|
+
// pass an explicit mode and skip this.
|
|
1766
1741
|
mode = STRUCTURED_MODES.has(mode) && structuredRuntimeSupportsMode(runtime, mode) ? mode : defaultStructuredMode(runtime)
|
|
1767
1742
|
assertRuntimeModelCompatible({ runtime, provider, model })
|
|
1768
1743
|
// The lane's EFFECTIVE model — computed ONCE and used for BOTH the truthful display
|
|
@@ -2096,7 +2071,10 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
|
|
|
2096
2071
|
model: args?.model || (worker && args?.sliceType
|
|
2097
2072
|
? spawnedLaneModelFor({ sliceType: args.sliceType, runtime, catalog })
|
|
2098
2073
|
: undefined),
|
|
2099
|
-
|
|
2074
|
+
// Worker lanes are the autonomous execution unit. They never inherit a
|
|
2075
|
+
// permission-card mode from the conductor and never honor a lower explicit
|
|
2076
|
+
// override: spawn_terminal always opens them in bypassPermissions.
|
|
2077
|
+
mode: worker ? 'bypassPermissions' : (args?.mode || (entry.mode === 'plan' ? 'default' : entry.mode)),
|
|
2100
2078
|
provider: args?.provider,
|
|
2101
2079
|
}
|
|
2102
2080
|
}
|
|
@@ -2111,23 +2089,6 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
|
|
|
2111
2089
|
plan: ownerPlan,
|
|
2112
2090
|
disabled: process.env.TP_SPAWN_OFF === '1',
|
|
2113
2091
|
})
|
|
2114
|
-
// Dispatch uses the same durable pair-control permission transport as every
|
|
2115
|
-
// protected action. The preview is additive evidence on that one request;
|
|
2116
|
-
// this Promise is not another approval store or an autonomous allow path.
|
|
2117
|
-
const requestDispatchApproval = (req) => new Promise((resolve) => {
|
|
2118
|
-
const payload = attachDurablePermissionSource(entry, {
|
|
2119
|
-
term: id, id: req.id, toolName: 'mcp__thinkpool__spawn_terminal',
|
|
2120
|
-
// Exact arguments are already bound into dispatchPreview.fingerprint. Do not
|
|
2121
|
-
// copy raw tool input into the public wake-up frame or the reconnect cache.
|
|
2122
|
-
risk: 'high', answerFormat: 'dispatch',
|
|
2123
|
-
dispatchPreview: req.dispatchPreview,
|
|
2124
|
-
})
|
|
2125
|
-
entry.pending.set(req.id, { resolve, payload, timer: null })
|
|
2126
|
-
announce()
|
|
2127
|
-
bcast('code-perm-req', payload)
|
|
2128
|
-
if (isUserFacingLane(entry)) entry.permNotifier?.arm(req.id, permissionSummary(payload))
|
|
2129
|
-
process.stderr.write(`\n ${A.yel}● Dispatch preview ready — approve or cancel in the room.${A.rst}\n`)
|
|
2130
|
-
})
|
|
2131
2092
|
const consumeTerminalOpenBudget = () => {
|
|
2132
2093
|
const now = Date.now()
|
|
2133
2094
|
const gate = spawnDecision({
|
|
@@ -2362,7 +2323,7 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
|
|
|
2362
2323
|
sliceType: z.enum(['scaffold', 'feature', 'fix', 'review']).optional().describe('optional cascade slice tier; omitted preserves normal inheritance/default, explicit model wins'),
|
|
2363
2324
|
runtime: z.enum(['claude', 'codex', 'hermes']).optional().describe('agent runtime for the new lane; Hermes is allowed only from a top-level Hermes parent with an explicit catalog model'),
|
|
2364
2325
|
provider: z.string().optional().describe('optional registered LLM provider id to run this lane on (from the account\'s provider registry); omit for the default Claude/Anthropic path'),
|
|
2365
|
-
mode: z.enum(['default', 'acceptEdits', 'bypassPermissions', 'plan']).optional().describe('
|
|
2326
|
+
mode: z.enum(['default', 'acceptEdits', 'bypassPermissions', 'plan']).optional().describe('accepted for compatibility; spawned workers always run autonomously in bypassPermissions'),
|
|
2366
2327
|
},
|
|
2367
2328
|
async (args) => {
|
|
2368
2329
|
const okText = (t) => ({ content: [{ type: 'text', text: t }] })
|
|
@@ -2402,33 +2363,10 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
|
|
|
2402
2363
|
},
|
|
2403
2364
|
})
|
|
2404
2365
|
} catch { return okText('Dispatch preview could not be built safely. No lane was created.') }
|
|
2405
|
-
const permissionId = randomUUID()
|
|
2406
|
-
const dispatchAdmission = beginDispatchLease(entry, permissionId)
|
|
2407
|
-
if (!dispatchAdmission.ok) {
|
|
2408
|
-
return okText('A dispatch choice is already awaiting approval in the room. Do not retry or switch runtimes; wait for that choice to be approved or canceled, or for a new human turn to supersede it.')
|
|
2409
|
-
}
|
|
2410
|
-
const dispatchLease = dispatchAdmission.lease
|
|
2411
|
-
try {
|
|
2412
|
-
let approval = { decision: 'deny' }
|
|
2413
|
-
try { approval = await requestDispatchApproval({ id: permissionId, input: effectiveArgs, dispatchPreview: preview }) } catch { /* fail closed */ }
|
|
2414
|
-
if (approval?.decision !== 'allow') return okText('Dispatch canceled. No lane or worktree was created.')
|
|
2415
|
-
if (!isDispatchLeaseCurrent(entry, dispatchLease)) return okText('That dispatch choice was superseded by a newer turn. No lane or worktree was created.')
|
|
2416
2366
|
const currentNow = Date.now()
|
|
2417
2367
|
const current = dispatchContext(currentNow)
|
|
2418
|
-
const
|
|
2419
|
-
controlItemId: approval.controlItemId,
|
|
2420
|
-
expected: {
|
|
2421
|
-
roomCode: room,
|
|
2422
|
-
bridgeAuthorityId: BRIDGE_ID,
|
|
2423
|
-
localAuthorityId: id,
|
|
2424
|
-
fingerprint: preview.fingerprint,
|
|
2425
|
-
},
|
|
2426
|
-
})
|
|
2427
|
-
if (!durable.ok) return okText('Dispatch approval could not be verified from the durable room authority. No lane was created.')
|
|
2428
|
-
if (!isDispatchLeaseCurrent(entry, dispatchLease)) return okText('That dispatch choice was superseded by a newer turn. No lane or worktree was created.')
|
|
2429
|
-
const authorization = authorizeDispatchApproval({
|
|
2368
|
+
const authorization = authorizeDirectDispatch({
|
|
2430
2369
|
preview,
|
|
2431
|
-
approvedFingerprint: durable.fingerprint,
|
|
2432
2370
|
args: effectiveArgs,
|
|
2433
2371
|
initiatorTerminalId: id,
|
|
2434
2372
|
roomCode: room,
|
|
@@ -2439,9 +2377,8 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
|
|
|
2439
2377
|
providerName: resolved.provider ? (providerNameMap()[resolved.provider] || resolved.provider) : undefined,
|
|
2440
2378
|
resolvedModel: resolved.model,
|
|
2441
2379
|
},
|
|
2442
|
-
permissionId,
|
|
2443
2380
|
})
|
|
2444
|
-
if (!authorization.ok) return okText(authorization.reason || 'Dispatch
|
|
2381
|
+
if (!authorization.ok) return okText(authorization.reason || 'Dispatch became stale or exceeded a cap. No lane was created.')
|
|
2445
2382
|
// Record this spawn, pruning timestamps that have already left the window
|
|
2446
2383
|
// (identical predicate to recentSpawnCount) so the array can't grow unbounded.
|
|
2447
2384
|
entry.spawnTimes = [...(entry.spawnTimes || []).filter((t) => Number.isFinite(t) && t > currentNow - SPAWN.windowMs), currentNow]
|
|
@@ -2450,12 +2387,8 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
|
|
|
2450
2387
|
const fromRef = String(id).slice(0, 8)
|
|
2451
2388
|
// Name BEFORE opening so openStructured's own announce already carries the label.
|
|
2452
2389
|
if (args?.name) { termNames[newId] = String(args.name).slice(0, 80); saveNames(room, termNames) }
|
|
2453
|
-
//
|
|
2454
|
-
//
|
|
2455
|
-
// already confirmed any bypass-escalation from a non-bypass lane before we got here.
|
|
2456
|
-
// PM-m1 — never INHERIT plan mode into a spawned worker lane: a lane spawned from a
|
|
2457
|
-
// plan-mode lane would stall on ExitPlanMode cards. An explicit args.mode still wins;
|
|
2458
|
-
// otherwise inherit, but fall plan → default (an autonomous worker doesn't plan-gate).
|
|
2390
|
+
// Every worker is autonomous by contract: resolveAgentOpen pins
|
|
2391
|
+
// bypassPermissions regardless of the parent or legacy mode argument.
|
|
2459
2392
|
const childSpawnDepth = (entry.spawnDepth || 0) + 1
|
|
2460
2393
|
const childHop = (entry.hop || 0) + 1
|
|
2461
2394
|
let manualReviewSnapshots = []
|
|
@@ -2487,9 +2420,6 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
|
|
|
2487
2420
|
return okText(`Opened agent lane ${newRef}${args?.name ? ` ("${args.name}")` : ''} and handed it the task. It runs in its own lane — check back with read_terminal, then close_terminal when done.`)
|
|
2488
2421
|
}
|
|
2489
2422
|
return okText(`Opened idle agent lane ${newRef}${args?.name ? ` ("${args.name}")` : ''}. Hand it work with post_to_terminal, or a person can type into it.`)
|
|
2490
|
-
} finally {
|
|
2491
|
-
finishDispatchLease(entry, dispatchLease)
|
|
2492
|
-
}
|
|
2493
2423
|
},
|
|
2494
2424
|
)] : []),
|
|
2495
2425
|
// Research lane — run a REAL multi-source search + adversarial verification and
|
|
@@ -3833,8 +3763,17 @@ channel
|
|
|
3833
3763
|
const nativeImages = s.runtime === 'codex' || s.runtime === 'hermes'
|
|
3834
3764
|
? await waitForNativeImages(payload.files, { updir: UPDIR })
|
|
3835
3765
|
: []
|
|
3836
|
-
s.session.sendTurn(sendText, nativeImages.length ? { images: nativeImages } : undefined)
|
|
3766
|
+
const accepted = s.session.sendTurn(sendText, nativeImages.length ? { images: nativeImages } : undefined)
|
|
3837
3767
|
echoYou()
|
|
3768
|
+
if (accepted === false) {
|
|
3769
|
+
// A runtime that did not accept a turn must still close the optimistic
|
|
3770
|
+
// user-line lifecycle. Without this boundary the client truthfully shows
|
|
3771
|
+
// what the person sent but falsely leaves Thinking/Stop pinned forever.
|
|
3772
|
+
const evt = { kind: 'error', message: `${structuredRuntimeMetadata(s.runtime)?.label || 'Agent'} did not accept this turn; retry after the lane is available.`, recoverable: true }
|
|
3773
|
+
pushLog(s, evt)
|
|
3774
|
+
bcast('code-event', { term: payload.term, evt })
|
|
3775
|
+
}
|
|
3776
|
+
announce()
|
|
3838
3777
|
// Pool-order fix (2026-07-02): emit the "dispatched to agent" marker HERE, right after
|
|
3839
3778
|
// the @pool you-line, so it inherits the NEXT seq (seqable('pool') is true) and orders
|
|
3840
3779
|
// correctly for EVERY viewer. It used to be pushed web-side with the FIRER's wall-clock
|
package/claude-session.mjs
CHANGED
|
@@ -516,16 +516,16 @@ export function startClaudeSession({ cwd, model, effort: initialEffort = 'high',
|
|
|
516
516
|
if (toolName === 'mcp__thinkpool__close_terminal') {
|
|
517
517
|
return { continue: true, hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'allow', permissionDecisionReason: 'Auto-approved (ThinkPool cross-terminal close — only self-spawned lanes).' } }
|
|
518
518
|
}
|
|
519
|
-
//
|
|
520
|
-
//
|
|
521
|
-
//
|
|
522
|
-
//
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
//
|
|
527
|
-
//
|
|
528
|
-
if (toolName === '
|
|
519
|
+
// A spawned worker is the autonomous execution unit: always auto-allow the tool.
|
|
520
|
+
// Bridge-side resolveAgentOpen pins every worker to bypassPermissions regardless
|
|
521
|
+
// of parent mode or the legacy mode argument. The one-worker-tier / plan width /
|
|
522
|
+
// machine / burst / TP_SPAWN_OFF caps still fail closed before the lane opens.
|
|
523
|
+
if (toolName === 'mcp__thinkpool__spawn_terminal') {
|
|
524
|
+
return { continue: true, hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'allow', permissionDecisionReason: 'Auto-approved (autonomous ThinkPool worker; bounded by room caps and isolated in its own worktree).' } }
|
|
525
|
+
}
|
|
526
|
+
// Independent main terminals keep their requested/inherited permission mode.
|
|
527
|
+
// Raising a new main terminal to bypass from a non-bypass parent still asks once.
|
|
528
|
+
if (toolName === 'mcp__thinkpool__open_main_terminal') {
|
|
529
529
|
const childMode = toolInput?.mode || mode // inherit this lane's mode by default
|
|
530
530
|
const escalating = childMode === 'bypassPermissions' && mode !== 'bypassPermissions'
|
|
531
531
|
if (!escalating) {
|
|
@@ -707,7 +707,7 @@ export function startClaudeSession({ cwd, model, effort: initialEffort = 'high',
|
|
|
707
707
|
...THINKPOOL_REMOTE_DELIVERY_RULES,
|
|
708
708
|
'CROSS-TERMINAL AWARENESS: this room may have other terminals open alongside yours — other agents working, or shells the people are driving. You have a READ-ONLY tool, read_terminal: call it with no arguments to list the other open terminals, or with a terminal ref/id/command to read that terminal\'s recent activity. Reach for it when your work depends on what another terminal is doing (e.g. someone says "see what the other terminal hit", or you need to coordinate with a sibling agent before acting). It only ever reads — it never changes another terminal. Identify a terminal by its NAME or its ref/id from the roster, never by an on-screen number like "Terminal 2" — those positional labels renumber when a terminal is closed, so they do not reliably point at a lane.',
|
|
709
709
|
'CROSS-TERMINAL HAND-OFF: you also have post_to_terminal(terminal, text) to send a message or task to ANOTHER AGENT terminal in this room (not a plain shell). Use it sparingly and only when the people clearly want the lanes to coordinate — e.g. "tell the backend terminal the API is ready", or to hand a sibling agent a concrete task. Every post requires a person in the room to approve a card before it is delivered, and an agent that was itself reached via a cross-post cannot post onward — so do not rely on it for chit-chat or loops. Prefer read_terminal to understand a sibling before you ever post to it.',
|
|
710
|
-
'TERMINAL CREATION CONTRACT: conductors and workers use different tools. When a person explicitly asks to open, launch, start, or spawn a separate Cascade/conductor terminal, use open_main_terminal(name?, task?, model?) — it creates an independent MAIN terminal with no Ensemble owner. Never use spawn_terminal for that request, and if open_main_terminal is unavailable say so instead of substituting. Use spawn_terminal(name?, task?, model?, sliceType?) only for bounded WORKER SUB-TERMINALS when your authoritative role permits delegation. Workers never receive the creation tools and never conduct. Do not dump work into busy siblings. Spawned workers
|
|
710
|
+
'TERMINAL CREATION CONTRACT: conductors and workers use different tools. When a person explicitly asks to open, launch, start, or spawn a separate Cascade/conductor terminal, use open_main_terminal(name?, task?, model?) — it creates an independent MAIN terminal with no Ensemble owner. Never use spawn_terminal for that request, and if open_main_terminal is unavailable say so instead of substituting. Use spawn_terminal(name?, task?, model?, sliceType?) only for bounded WORKER SUB-TERMINALS when your authoritative role permits delegation. Workers never receive the creation tools and never conduct. Do not dump work into busy siblings. Spawned workers are always autonomous in bypassPermissions and never ask the room for an approval card; the bridge still enforces room caps, hop limits, the kill-switch, and isolated linked worktrees. Collect each worker with read_terminal and close_terminal immediately after using its result. Main conductors are independent terminals, keep their requested permission mode, and are not owned/closed through Ensemble.',
|
|
711
711
|
'CROSS-SESSION AWARENESS: the Ensemble reaches across your SESSIONS, not just the terminals in this room. list_sessions() lists your OTHER ThinkPool Code rooms — both your own rooms running on this machine AND your partner\'s rooms in the same pair, reachable over the per-pair bus (a room on the partner\'s machine shows its host). read_session(session, terminal?) reads recent activity inside one (omit `terminal` to list that room\'s terminals, or pass a ref/name to read that lane). Both are READ-ONLY — they never change another session, and they reach ONLY your own rooms and rooms you share with your partner, never a stranger\'s. Reach for them when work spans rooms — "what\'s the other project up to", "pick up where the other session left off", or to check a long-running task elsewhere before you act here.',
|
|
712
712
|
'CROSS-SESSION HAND-OFF: post_to_session(session, text, terminal?) sends a task or message to an agent in ANOTHER of your rooms — your own, or your partner\'s over the pair bus. Use it sparingly and only when the people clearly want the rooms to coordinate — e.g. hand the API room\'s agent a concrete follow-up once the frontend is ready. It is dual-consent: a person in YOUR room approves sending, and a person in the TARGET room approves receiving, before anything is delivered — so never rely on it for chit-chat or loops, and an agent that was itself reached via a cross-room post cannot post onward to a third room. It spends real model tokens in the other room (maybe on the other person\'s machine), so prefer read_session to understand a room before you ever post into it, and only post one concrete hand-off at a time. All of this works only under the ThinkPool account bridge; a standalone room sees just its own terminals.',
|
|
713
713
|
'SUBAGENT POLICY: in this room, a main conductor delegates worker slices through visible spawn_terminal Ensemble lanes. A requested separate conductor is created with open_main_terminal, never Ensemble. Worker, leaf, Side, and managed Flow lanes do their assigned work directly. Do NOT reach for built-in Task/Agent subagents: an in-process subagent is invisible to the room, cannot be peered at or steered, and its work is lost to the Ensemble.',
|
package/codex-session.mjs
CHANGED
|
@@ -68,7 +68,8 @@ export function codexDefaultCollaborationMode(model, effort) {
|
|
|
68
68
|
* hobbled out-of-the-box. bypass gives a person-opened codex terminal parity
|
|
69
69
|
* with how Claude lanes run. Claude keeps 'default' — its permission modes don't
|
|
70
70
|
* gate network. Explicit modes (a person's pick, resume's persisted entry.mode,
|
|
71
|
-
* a flow lane
|
|
71
|
+
* a flow lane) always override this. spawn_terminal separately pins every worker
|
|
72
|
+
* to bypassPermissions as part of the autonomous-worker contract.
|
|
72
73
|
*/
|
|
73
74
|
export function defaultModeForRuntime(runtime) {
|
|
74
75
|
return runtime === 'codex' ? 'bypassPermissions' : 'default'
|
package/cross-terminal.mjs
CHANGED
|
@@ -512,6 +512,18 @@ export function buildDispatchPreview ({ args, initiatorTerminalId, roomCode, bri
|
|
|
512
512
|
export function authorizeDispatchApproval ({ preview, approvedFingerprint, args, initiatorTerminalId, roomCode, bridgeAuthorityId, localAuthorityId, context = {}, decision = 'allow', consumed = false, permissionId = null, consumedPermissionId = null, limits = SPAWN } = {}) {
|
|
513
513
|
if (decision !== 'allow') return { ok: false, code: 'dispatch_canceled' }
|
|
514
514
|
if (consumed) return { ok: false, code: permissionId && permissionId === consumedPermissionId ? 'idempotent_replay' : 'approval_replayed' }
|
|
515
|
+
return authorizeBoundDispatch({ preview, approvedFingerprint, args, initiatorTerminalId, roomCode, bridgeAuthorityId, localAuthorityId, context, limits })
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
// Spawned workers are autonomous by product contract: Dispatch never pauses on
|
|
519
|
+
// a room approval card, and every worker runs in bypassPermissions. The immutable
|
|
520
|
+
// preview still binds the exact package and caps, which are rechecked immediately
|
|
521
|
+
// before opening the lane.
|
|
522
|
+
export function authorizeDirectDispatch ({ preview, args, initiatorTerminalId, roomCode, bridgeAuthorityId, localAuthorityId, context = {}, limits = SPAWN } = {}) {
|
|
523
|
+
return authorizeBoundDispatch({ preview, approvedFingerprint: preview?.fingerprint, args, initiatorTerminalId, roomCode, bridgeAuthorityId, localAuthorityId, context, limits })
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
function authorizeBoundDispatch ({ preview, approvedFingerprint, args, initiatorTerminalId, roomCode, bridgeAuthorityId, localAuthorityId, context = {}, limits = SPAWN } = {}) {
|
|
515
527
|
let current
|
|
516
528
|
try { current = buildDispatchPreview({ args, initiatorTerminalId, roomCode, bridgeAuthorityId, localAuthorityId, context, limits }) } catch { return { ok: false, code: 'invalid_dispatch' } }
|
|
517
529
|
if (!preview || preview.fingerprint !== approvedFingerprint) return { ok: false, code: 'approval_mismatch' }
|
package/hermes-session.mjs
CHANGED
|
@@ -13,6 +13,7 @@ import { classifyRisk } from './claude-session.mjs'
|
|
|
13
13
|
export const HERMES_COMMAND = 'thinkpool'
|
|
14
14
|
export const HERMES_ACP_PROTOCOL_VERSION = 1
|
|
15
15
|
export const HERMES_SUPPORTED_MODES = new Set(['default', 'acceptEdits'])
|
|
16
|
+
const HERMES_INITIALIZE_TIMEOUT_MS = 15_000
|
|
16
17
|
|
|
17
18
|
const HERMES_SECRET_ENV_KEY = /(?:TOKEN|SECRET|PASSWORD|PRIVATE_KEY|API_KEY|ACCESS_KEY|CREDENTIAL)/i
|
|
18
19
|
const HERMES_REPLAY_UPDATES = new Set(['agent_message_chunk', 'agent_thought_chunk', 'tool_call', 'tool_call_update', 'plan'])
|
|
@@ -69,6 +70,7 @@ export function startHermesSession({
|
|
|
69
70
|
let resuming = false
|
|
70
71
|
let inventoryProbe = null
|
|
71
72
|
let modelSwitchPending = false
|
|
73
|
+
let bootCancelled = false
|
|
72
74
|
const policyRole = hermesRole || hermesRoleFor({})
|
|
73
75
|
|
|
74
76
|
const emit = (event) => { try { onEvent?.(event) } catch { /* consumer isolation */ } }
|
|
@@ -177,24 +179,45 @@ export function startHermesSession({
|
|
|
177
179
|
}
|
|
178
180
|
let retired = false
|
|
179
181
|
retireClient = () => { retired = true }
|
|
180
|
-
|
|
181
|
-
command: launchCommand, args: launchArgs, cwd: activeCwd, env: childEnv,
|
|
182
|
-
onNotification,
|
|
183
|
-
onRequest,
|
|
184
|
-
onStderr: (text) => { stderrTail = (stderrTail + text).slice(-2000) },
|
|
185
|
-
onClose: (error) => {
|
|
186
|
-
if (ended || crashed || retired) return
|
|
187
|
-
crashed = true
|
|
188
|
-
turnActive = false
|
|
189
|
-
emit({ kind: 'error', message: `Hermes ACP closed unexpectedly: ${error?.message || error}`, recoverable: true })
|
|
190
|
-
},
|
|
191
|
-
})
|
|
192
|
-
client.start()
|
|
193
|
-
const initialized = await client.request('initialize', {
|
|
182
|
+
const initializeParams = {
|
|
194
183
|
protocolVersion: HERMES_ACP_PROTOCOL_VERSION,
|
|
195
184
|
clientCapabilities: { fs: { readTextFile: false, writeTextFile: false }, terminal: false },
|
|
196
185
|
clientInfo: { name: 'thinkpool-pair', title: 'ThinkPool Code', version: '1' },
|
|
197
|
-
}
|
|
186
|
+
}
|
|
187
|
+
let initialized
|
|
188
|
+
// Cold Hermes imports take 6–9 seconds on the supported host, and the
|
|
189
|
+
// production ENOSPC incident pushed one child just beyond the old 10s
|
|
190
|
+
// cliff. Initialization is pre-session and pre-inference, so one fresh
|
|
191
|
+
// process retry is safe: no prompt or tool can have executed yet.
|
|
192
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
193
|
+
retired = false
|
|
194
|
+
retireClient = () => { retired = true }
|
|
195
|
+
client = clientFactory({
|
|
196
|
+
command: launchCommand, args: launchArgs, cwd: activeCwd, env: childEnv,
|
|
197
|
+
onNotification,
|
|
198
|
+
onRequest,
|
|
199
|
+
onStderr: (text) => { stderrTail = (stderrTail + text).slice(-2000) },
|
|
200
|
+
onClose: (error) => {
|
|
201
|
+
if (ended || crashed || retired) return
|
|
202
|
+
crashed = true
|
|
203
|
+
turnActive = false
|
|
204
|
+
emit({ kind: 'error', message: `Hermes ACP closed unexpectedly: ${error?.message || error}`, recoverable: true })
|
|
205
|
+
},
|
|
206
|
+
})
|
|
207
|
+
client.start()
|
|
208
|
+
try {
|
|
209
|
+
initialized = await client.request('initialize', initializeParams, HERMES_INITIALIZE_TIMEOUT_MS)
|
|
210
|
+
break
|
|
211
|
+
} catch (error) {
|
|
212
|
+
const retryable = !bootCancelled && attempt === 0 && /^initialize timed out\b/i.test(String(error?.message || error))
|
|
213
|
+
if (!retryable) throw error
|
|
214
|
+
retireClient()
|
|
215
|
+
const timedOutClient = client
|
|
216
|
+
client = null
|
|
217
|
+
timedOutClient?.end()
|
|
218
|
+
stderrTail = ''
|
|
219
|
+
}
|
|
220
|
+
}
|
|
198
221
|
if (initialized?.protocolVersion !== HERMES_ACP_PROTOCOL_VERSION) {
|
|
199
222
|
throw new Error(`Unsupported Hermes ACP protocol ${initialized?.protocolVersion ?? 'unknown'}; expected ${HERMES_ACP_PROTOCOL_VERSION}`)
|
|
200
223
|
}
|
|
@@ -255,16 +278,32 @@ export function startHermesSession({
|
|
|
255
278
|
await client.request('session/set_mode', { sessionId, modeId: acpMode })
|
|
256
279
|
}
|
|
257
280
|
emit({ kind: 'capabilities', runtime: 'hermes', protocol: 'acp', protocolVersion: initialized?.protocolVersion, capabilities: initialized?.agentCapabilities || {}, models: modelList(state?.models), flow: command === HERMES_COMMAND && clientFactory === createAcpClient })
|
|
258
|
-
})().catch((error) => {
|
|
281
|
+
})().catch(async (error) => {
|
|
259
282
|
crashed = true
|
|
260
283
|
client?.end()
|
|
261
|
-
|
|
262
|
-
|
|
284
|
+
try { await mcpHttp?.close() } catch { /* startup cleanup */ }
|
|
285
|
+
mcpHttp = null
|
|
286
|
+
if (!bootCancelled) emit({ kind: 'error', message: `Hermes ACP startup failed: ${error?.message || error}${stderrTail ? `: ${stderrTail.trim().slice(-240)}` : ''}`, recoverable: true })
|
|
263
287
|
throw error
|
|
264
|
-
}).finally(() => { starting = null })
|
|
288
|
+
}).finally(() => { starting = null; bootCancelled = false })
|
|
265
289
|
return starting
|
|
266
290
|
}
|
|
267
291
|
|
|
292
|
+
function reviveAfterExplicitRetry() {
|
|
293
|
+
if (!crashed || ended) return
|
|
294
|
+
retireClient()
|
|
295
|
+
client?.end()
|
|
296
|
+
client = null
|
|
297
|
+
mapper = null
|
|
298
|
+
crashed = false
|
|
299
|
+
stderrTail = ''
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function finishAbortedTurn() {
|
|
303
|
+
if (mapper) mapper.finishTurn({ stopReason: 'cancelled' })
|
|
304
|
+
else emit({ kind: 'result', subtype: 'aborted', sessionId, model: activeModel, costUsd: null, usage: null, numTurns: 1, durationMs: undefined, denials: 0, resultText: null })
|
|
305
|
+
}
|
|
306
|
+
|
|
268
307
|
async function restartAfterCancelledResponseFailure() {
|
|
269
308
|
const oldClient = client
|
|
270
309
|
retireClient()
|
|
@@ -288,12 +327,14 @@ export function startHermesSession({
|
|
|
288
327
|
return blocks
|
|
289
328
|
}
|
|
290
329
|
|
|
291
|
-
async function runPrompt(text, options = {}) {
|
|
292
|
-
await boot()
|
|
293
|
-
|
|
294
|
-
|
|
330
|
+
async function runPrompt(text, options = {}, { steering = false, turnId = activeTurnId } = {}) {
|
|
331
|
+
try { await boot() }
|
|
332
|
+
catch (error) {
|
|
333
|
+
if (abortedTurns.has(turnId)) return { stopReason: 'cancelled' }
|
|
334
|
+
throw error
|
|
335
|
+
}
|
|
336
|
+
if (abortedTurns.has(turnId)) return { stopReason: 'cancelled' }
|
|
295
337
|
const body = steering ? `/steer ${String(text)}` : String(text)
|
|
296
|
-
if (!steering) turnActive = true
|
|
297
338
|
let result
|
|
298
339
|
try {
|
|
299
340
|
result = await client.request('session/prompt', {
|
|
@@ -334,17 +375,29 @@ export function startHermesSession({
|
|
|
334
375
|
get started() { return started },
|
|
335
376
|
get models() { return [] },
|
|
336
377
|
sendTurn(text, options = {}) {
|
|
337
|
-
if (ended
|
|
378
|
+
if (ended) return false
|
|
379
|
+
// A crash never replays work by itself. A later explicit turn is the
|
|
380
|
+
// authority to launch a fresh ACP process and resume the same native
|
|
381
|
+
// session id; this is the recovery path the old permanent latch blocked.
|
|
382
|
+
reviveAfterExplicitRetry()
|
|
338
383
|
// A busy prompt is a genuine ACP /steer call and may run concurrently.
|
|
339
|
-
if (turnActive) {
|
|
340
|
-
|
|
384
|
+
if (turnActive) {
|
|
385
|
+
const turnId = activeTurnId
|
|
386
|
+
void runPrompt(text, options, { steering: true, turnId }).catch((error) => emit({ kind: 'error', message: `Hermes steering failed: ${error?.message || error}`, recoverable: true }))
|
|
387
|
+
return true
|
|
388
|
+
}
|
|
389
|
+
const turnId = ++activeTurnId
|
|
390
|
+
// Claim the turn synchronously, before the cold safety probe/import. The
|
|
391
|
+
// room can now show Thinking + Stop for the whole accepted lifecycle.
|
|
392
|
+
turnActive = true
|
|
393
|
+
promptChain = promptChain.then(() => runPrompt(text, options, { steering: false, turnId })).catch((error) => {
|
|
341
394
|
turnActive = false
|
|
342
395
|
emit({ kind: 'error', message: `Hermes turn failed: ${error?.message || error}`, recoverable: true })
|
|
343
396
|
})
|
|
344
397
|
return true
|
|
345
398
|
},
|
|
346
399
|
abort() {
|
|
347
|
-
if (!
|
|
400
|
+
if (!turnActive) return
|
|
348
401
|
const turnId = activeTurnId
|
|
349
402
|
abortedTurns.add(turnId)
|
|
350
403
|
// Bound retained turn ids while preserving any concurrent /steer request
|
|
@@ -352,7 +405,18 @@ export function startHermesSession({
|
|
|
352
405
|
if (abortedTurns.size > 32) abortedTurns.delete(abortedTurns.values().next().value)
|
|
353
406
|
turnActive = false
|
|
354
407
|
firstTurn = false
|
|
355
|
-
|
|
408
|
+
if (starting) {
|
|
409
|
+
bootCancelled = true
|
|
410
|
+
retireClient()
|
|
411
|
+
client?.end()
|
|
412
|
+
finishAbortedTurn()
|
|
413
|
+
return
|
|
414
|
+
}
|
|
415
|
+
if (!sessionId || !client?.alive) {
|
|
416
|
+
finishAbortedTurn()
|
|
417
|
+
return
|
|
418
|
+
}
|
|
419
|
+
finishAbortedTurn()
|
|
356
420
|
void client.notify('session/cancel', { sessionId }).catch((error) => {
|
|
357
421
|
// Never display a stopped lane while the un-cancelled Hermes process
|
|
358
422
|
// might still be executing. A transport failure tears down the ACP
|