thinkpool-pair 0.7.252 → 0.7.254
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 +16 -86
- package/claude-session.mjs +11 -11
- package/codex-session.mjs +2 -1
- package/cross-terminal.mjs +12 -0
- package/hermes-acp-bootstrap.py +3 -3
- package/hermes-policy.mjs +2 -1
- package/hermes-session.mjs +65 -9
- package/package.json +1 -1
- package/runtime-registry.mjs +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
|
|
@@ -2085,9 +2060,6 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
|
|
|
2085
2060
|
if (runtime === 'hermes' && !modelCatalogValues(catalog).has(args.model)) {
|
|
2086
2061
|
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.` }
|
|
2087
2062
|
}
|
|
2088
|
-
if (runtime === 'hermes' && args?.mode && !['default', 'acceptEdits'].includes(args.mode)) {
|
|
2089
|
-
return { error: `Hermes ACP only exposes default/acceptEdits as user modes; Flow roles use a bridge-owned process-local tool policy.` }
|
|
2090
|
-
}
|
|
2091
2063
|
if (runtime === 'claude' && !args?.provider && args?.model && /^gpt-/i.test(args.model)) {
|
|
2092
2064
|
return { error: `Could not open a Claude terminal on Codex model ${JSON.stringify(args.model)}. Choose runtime="codex" or a Claude model.` }
|
|
2093
2065
|
}
|
|
@@ -2096,7 +2068,10 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
|
|
|
2096
2068
|
model: args?.model || (worker && args?.sliceType
|
|
2097
2069
|
? spawnedLaneModelFor({ sliceType: args.sliceType, runtime, catalog })
|
|
2098
2070
|
: undefined),
|
|
2099
|
-
|
|
2071
|
+
// Worker lanes are the autonomous execution unit. They never inherit a
|
|
2072
|
+
// permission-card mode from the conductor and never honor a lower explicit
|
|
2073
|
+
// override: spawn_terminal always opens them in bypassPermissions.
|
|
2074
|
+
mode: worker ? 'bypassPermissions' : (args?.mode || (entry.mode === 'plan' ? 'default' : entry.mode)),
|
|
2100
2075
|
provider: args?.provider,
|
|
2101
2076
|
}
|
|
2102
2077
|
}
|
|
@@ -2111,23 +2086,6 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
|
|
|
2111
2086
|
plan: ownerPlan,
|
|
2112
2087
|
disabled: process.env.TP_SPAWN_OFF === '1',
|
|
2113
2088
|
})
|
|
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
2089
|
const consumeTerminalOpenBudget = () => {
|
|
2132
2090
|
const now = Date.now()
|
|
2133
2091
|
const gate = spawnDecision({
|
|
@@ -2362,7 +2320,7 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
|
|
|
2362
2320
|
sliceType: z.enum(['scaffold', 'feature', 'fix', 'review']).optional().describe('optional cascade slice tier; omitted preserves normal inheritance/default, explicit model wins'),
|
|
2363
2321
|
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
2322
|
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('
|
|
2323
|
+
mode: z.enum(['default', 'acceptEdits', 'bypassPermissions', 'plan']).optional().describe('accepted for compatibility; spawned workers always run autonomously in bypassPermissions'),
|
|
2366
2324
|
},
|
|
2367
2325
|
async (args) => {
|
|
2368
2326
|
const okText = (t) => ({ content: [{ type: 'text', text: t }] })
|
|
@@ -2402,33 +2360,10 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
|
|
|
2402
2360
|
},
|
|
2403
2361
|
})
|
|
2404
2362
|
} 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
2363
|
const currentNow = Date.now()
|
|
2417
2364
|
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({
|
|
2365
|
+
const authorization = authorizeDirectDispatch({
|
|
2430
2366
|
preview,
|
|
2431
|
-
approvedFingerprint: durable.fingerprint,
|
|
2432
2367
|
args: effectiveArgs,
|
|
2433
2368
|
initiatorTerminalId: id,
|
|
2434
2369
|
roomCode: room,
|
|
@@ -2439,9 +2374,8 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
|
|
|
2439
2374
|
providerName: resolved.provider ? (providerNameMap()[resolved.provider] || resolved.provider) : undefined,
|
|
2440
2375
|
resolvedModel: resolved.model,
|
|
2441
2376
|
},
|
|
2442
|
-
permissionId,
|
|
2443
2377
|
})
|
|
2444
|
-
if (!authorization.ok) return okText(authorization.reason || 'Dispatch
|
|
2378
|
+
if (!authorization.ok) return okText(authorization.reason || 'Dispatch became stale or exceeded a cap. No lane was created.')
|
|
2445
2379
|
// Record this spawn, pruning timestamps that have already left the window
|
|
2446
2380
|
// (identical predicate to recentSpawnCount) so the array can't grow unbounded.
|
|
2447
2381
|
entry.spawnTimes = [...(entry.spawnTimes || []).filter((t) => Number.isFinite(t) && t > currentNow - SPAWN.windowMs), currentNow]
|
|
@@ -2450,12 +2384,8 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
|
|
|
2450
2384
|
const fromRef = String(id).slice(0, 8)
|
|
2451
2385
|
// Name BEFORE opening so openStructured's own announce already carries the label.
|
|
2452
2386
|
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).
|
|
2387
|
+
// Every worker is autonomous by contract: resolveAgentOpen pins
|
|
2388
|
+
// bypassPermissions regardless of the parent or legacy mode argument.
|
|
2459
2389
|
const childSpawnDepth = (entry.spawnDepth || 0) + 1
|
|
2460
2390
|
const childHop = (entry.hop || 0) + 1
|
|
2461
2391
|
let manualReviewSnapshots = []
|
|
@@ -2487,9 +2417,6 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
|
|
|
2487
2417
|
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
2418
|
}
|
|
2489
2419
|
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
2420
|
},
|
|
2494
2421
|
)] : []),
|
|
2495
2422
|
// Research lane — run a REAL multi-source search + adversarial verification and
|
|
@@ -3901,6 +3828,9 @@ channel
|
|
|
3901
3828
|
return
|
|
3902
3829
|
}
|
|
3903
3830
|
s.mode = payload.mode
|
|
3831
|
+
if (payload.mode === 'bypassPermissions') allowPending(s)
|
|
3832
|
+
else if (payload.mode === 'acceptEdits') acceptEditsPending(s)
|
|
3833
|
+
else if (payload.mode === 'plan') drainPending(s)
|
|
3904
3834
|
s.flush?.()
|
|
3905
3835
|
announce()
|
|
3906
3836
|
return
|
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-acp-bootstrap.py
CHANGED
|
@@ -39,7 +39,7 @@ def policy():
|
|
|
39
39
|
builtin = value.get("builtinTools")
|
|
40
40
|
required_builtin = value.get("requiredBuiltinTools")
|
|
41
41
|
tools = value.get("mcpTools")
|
|
42
|
-
if role not in {"ordinary", "builder", "conductor", "reviewer", "manual-review"}:
|
|
42
|
+
if role not in {"ordinary", "plan", "builder", "conductor", "reviewer", "manual-review"}:
|
|
43
43
|
die("unknown role")
|
|
44
44
|
if value.get("mcpServer") != "thinkpool" or not isinstance(builtin, list) or not isinstance(required_builtin, list) or not isinstance(tools, list):
|
|
45
45
|
die("invalid tool policy")
|
|
@@ -49,14 +49,14 @@ def policy():
|
|
|
49
49
|
if forbidden.intersection(builtin) or forbidden.intersection(required_builtin) or forbidden.intersection(tools):
|
|
50
50
|
die("delegation and session search are forbidden")
|
|
51
51
|
required_mcp = {
|
|
52
|
-
"ordinary": {"read_terminal"}, "builder": {"mark_flow_done"},
|
|
52
|
+
"ordinary": {"read_terminal"}, "plan": set(), "builder": {"mark_flow_done"},
|
|
53
53
|
"conductor": {"submit_flow_plan"},
|
|
54
54
|
"reviewer": {"submit_flow_review", "run_review_check", "read_review_file"},
|
|
55
55
|
"manual-review": {"run_review_check", "read_review_file"},
|
|
56
56
|
}
|
|
57
57
|
if not required_mcp[role].issubset(tools):
|
|
58
58
|
die("incomplete role MCP policy")
|
|
59
|
-
restricted = role in {"conductor", "reviewer", "manual-review"}
|
|
59
|
+
restricted = role in {"plan", "conductor", "reviewer", "manual-review"}
|
|
60
60
|
allowed_builtin = READ_ONLY_TOOLS if restricted else CODING_TOOLS
|
|
61
61
|
required = READ_ONLY_TOOLS if restricted else ESSENTIAL_CODING_TOOLS
|
|
62
62
|
if set(builtin) != allowed_builtin:
|
package/hermes-policy.mjs
CHANGED
|
@@ -21,6 +21,7 @@ export const ESSENTIAL_CODING_TOOLS = Object.freeze([
|
|
|
21
21
|
])
|
|
22
22
|
const ROLE_REQUIRED = Object.freeze({
|
|
23
23
|
ordinary: ['read_terminal', 'spawn_terminal', 'close_terminal'],
|
|
24
|
+
plan: [],
|
|
24
25
|
conductor: ['submit_flow_plan'],
|
|
25
26
|
builder: ['mark_flow_done'],
|
|
26
27
|
reviewer: ['submit_flow_review', 'run_review_check', 'read_review_file'],
|
|
@@ -44,7 +45,7 @@ export function hermesPolicyForRole(role, { mcpTools } = {}) {
|
|
|
44
45
|
// non-delegating ordinary child without widening it.
|
|
45
46
|
const minimum = role === 'ordinary' ? ['read_terminal'] : required
|
|
46
47
|
for (const tool of minimum) if (!supplied.includes(tool)) throw new Error(`Hermes ${role} policy is missing required ThinkPool tool ${tool}`)
|
|
47
|
-
const restricted = role === 'conductor' || role === 'reviewer' || role === 'manual-review'
|
|
48
|
+
const restricted = role === 'plan' || role === 'conductor' || role === 'reviewer' || role === 'manual-review'
|
|
48
49
|
const builtinTools = restricted ? READ_ONLY_TOOLS : CODING_TOOLS
|
|
49
50
|
const requiredBuiltinTools = restricted ? READ_ONLY_TOOLS : ESSENTIAL_CODING_TOOLS
|
|
50
51
|
return Object.freeze({
|
package/hermes-session.mjs
CHANGED
|
@@ -8,12 +8,13 @@ import { HermesEventMapper, hermesToolFor } from './hermes-event-mapper.mjs'
|
|
|
8
8
|
import { probeHermesRuntime } from './hermes-probe.mjs'
|
|
9
9
|
import { hermesExactInventory, hermesPolicyEnv, hermesRoleFor } from './hermes-policy.mjs'
|
|
10
10
|
import { startCodexMcpHttp } from './codex-mcp-http.mjs'
|
|
11
|
-
import { classifyRisk } from './claude-session.mjs'
|
|
11
|
+
import { autoAllow, classifyRisk } from './claude-session.mjs'
|
|
12
12
|
|
|
13
13
|
export const HERMES_COMMAND = 'thinkpool'
|
|
14
14
|
export const HERMES_ACP_PROTOCOL_VERSION = 1
|
|
15
|
-
export const HERMES_SUPPORTED_MODES = new Set(['default', 'acceptEdits'])
|
|
15
|
+
export const HERMES_SUPPORTED_MODES = new Set(['default', 'acceptEdits', 'plan', 'bypassPermissions'])
|
|
16
16
|
const HERMES_INITIALIZE_TIMEOUT_MS = 15_000
|
|
17
|
+
const PLAN_SAFE_MCP_TOOLS = new Set(['read_terminal', 'read_review_file'])
|
|
17
18
|
|
|
18
19
|
const HERMES_SECRET_ENV_KEY = /(?:TOKEN|SECRET|PASSWORD|PRIVATE_KEY|API_KEY|ACCESS_KEY|CREDENTIAL)/i
|
|
19
20
|
const HERMES_REPLAY_UPDATES = new Set(['agent_message_chunk', 'agent_thought_chunk', 'tool_call', 'tool_call_update', 'plan'])
|
|
@@ -71,8 +72,19 @@ export function startHermesSession({
|
|
|
71
72
|
let inventoryProbe = null
|
|
72
73
|
let modelSwitchPending = false
|
|
73
74
|
let bootCancelled = false
|
|
75
|
+
let suppressNextSessionPublish = false
|
|
74
76
|
const policyRole = hermesRole || hermesRoleFor({})
|
|
75
77
|
|
|
78
|
+
const effectivePolicyRole = () => activeMode === 'plan' ? 'plan' : policyRole
|
|
79
|
+
const effectiveMcpTools = () => activeMode === 'plan'
|
|
80
|
+
? requiredMcpTools.filter((name) => PLAN_SAFE_MCP_TOOLS.has(name))
|
|
81
|
+
: requiredMcpTools
|
|
82
|
+
const acpModeFor = (value) => value === 'acceptEdits'
|
|
83
|
+
? 'accept_edits'
|
|
84
|
+
: value === 'bypassPermissions'
|
|
85
|
+
? 'dont_ask'
|
|
86
|
+
: 'default'
|
|
87
|
+
|
|
76
88
|
const emit = (event) => { try { onEvent?.(event) } catch { /* consumer isolation */ } }
|
|
77
89
|
|
|
78
90
|
async function onRequest(method, params, requestId) {
|
|
@@ -84,6 +96,15 @@ export function startHermesSession({
|
|
|
84
96
|
input: tool.input,
|
|
85
97
|
risk: classifyRisk(tool.name, tool.input),
|
|
86
98
|
}
|
|
99
|
+
// ACP's native modes currently govern edit proposals only. ThinkPool's
|
|
100
|
+
// permission chip is the cross-runtime authority, so apply the same pure
|
|
101
|
+
// mode policy Claude uses before raising a durable room card. Structural
|
|
102
|
+
// role schemas still win: bypass can auto-allow a request, but it cannot
|
|
103
|
+
// restore a tool that the process-local Hermes policy never exposed.
|
|
104
|
+
if (activeMode === 'plan') return { outcome: { outcome: 'cancelled' } }
|
|
105
|
+
if (autoAllow({ toolName: card.toolName, input: card.input, mode: activeMode })) {
|
|
106
|
+
return { outcome: permissionOutcome('allow', params.options) }
|
|
107
|
+
}
|
|
87
108
|
let decision = 'deny'
|
|
88
109
|
try { decision = await requestPermission?.(card) } catch { /* fail closed */ }
|
|
89
110
|
return { outcome: permissionOutcome(decision, params.options) }
|
|
@@ -103,13 +124,13 @@ export function startHermesSession({
|
|
|
103
124
|
}
|
|
104
125
|
|
|
105
126
|
async function probeMcpTools() {
|
|
106
|
-
const required = [...new Set((Array.isArray(
|
|
127
|
+
const required = [...new Set((Array.isArray(effectiveMcpTools()) ? effectiveMcpTools() : [])
|
|
107
128
|
.map((name) => String(name || '').trim()).filter(Boolean))]
|
|
108
129
|
// Direct runtime tests and unscoped upstream callers have no bridge role
|
|
109
130
|
// contract to prove. Every bridge-created Hermes lane supplies its required
|
|
110
131
|
// MCP list; only those lanes enter the exact-inventory transaction.
|
|
111
|
-
if (!required.length) return { inventory: '', missing: [], forbidden: [] }
|
|
112
|
-
const exact = hermesExactInventory(
|
|
132
|
+
if (!required.length && effectivePolicyRole() !== 'plan') return { inventory: '', missing: [], forbidden: [] }
|
|
133
|
+
const exact = hermesExactInventory(effectivePolicyRole(), { mcpTools: required })
|
|
113
134
|
const allowedBuiltins = exact.builtinTools
|
|
114
135
|
const requiredBuiltins = exact.requiredBuiltinTools
|
|
115
136
|
const expectedMcp = exact.mcpTools
|
|
@@ -175,7 +196,7 @@ export function startHermesSession({
|
|
|
175
196
|
// profile wrapper. HERMES_HOME is the probe-verified isolated profile.
|
|
176
197
|
launchCommand = profile.python
|
|
177
198
|
launchArgs = [profile.bootstrap]
|
|
178
|
-
childEnv = { ...childEnv, HERMES_HOME: profile.profile, THINKPOOL_HERMES_ACP_POLICY: hermesPolicyEnv(
|
|
199
|
+
childEnv = { ...childEnv, HERMES_HOME: profile.profile, THINKPOOL_HERMES_ACP_POLICY: hermesPolicyEnv(effectivePolicyRole(), { mcpTools: effectiveMcpTools() }) }
|
|
179
200
|
}
|
|
180
201
|
let retired = false
|
|
181
202
|
retireClient = () => { retired = true }
|
|
@@ -271,9 +292,18 @@ export function startHermesSession({
|
|
|
271
292
|
const publishedModels = state?.models
|
|
272
293
|
? { ...state.models, currentModelId: activeModel }
|
|
273
294
|
: activeModel ? { currentModelId: activeModel, availableModels: [] } : state?.models
|
|
274
|
-
|
|
295
|
+
if (!suppressNextSessionPublish) {
|
|
296
|
+
mapper.startSession({ sessionId, models: publishedModels, modes: state?.modes, commands: [] })
|
|
297
|
+
} else {
|
|
298
|
+
// A Plan transition recreates only the ACP process so its tool schema
|
|
299
|
+
// can become structurally read-only. Keep the room transcript/catalog
|
|
300
|
+
// stable while the fresh mapper resumes the same native session.
|
|
301
|
+
mapper.sessionId = sessionId
|
|
302
|
+
mapper.model = activeModel
|
|
303
|
+
suppressNextSessionPublish = false
|
|
304
|
+
}
|
|
275
305
|
started = true
|
|
276
|
-
const acpMode = activeMode
|
|
306
|
+
const acpMode = acpModeFor(activeMode)
|
|
277
307
|
if (state?.modes?.availableModes?.some((item) => item.id === acpMode) && state.modes.currentModeId !== acpMode) {
|
|
278
308
|
await client.request('session/set_mode', { sessionId, modeId: acpMode })
|
|
279
309
|
}
|
|
@@ -452,7 +482,33 @@ export function startHermesSession({
|
|
|
452
482
|
},
|
|
453
483
|
setMode(nextMode) {
|
|
454
484
|
if (turnActive || !HERMES_SUPPORTED_MODES.has(nextMode)) return false
|
|
455
|
-
|
|
485
|
+
const priorMode = activeMode
|
|
486
|
+
const changesPolicySchema = (priorMode === 'plan') !== (nextMode === 'plan')
|
|
487
|
+
if (changesPolicySchema) {
|
|
488
|
+
// Plan is stronger than an approval preference: it removes terminal,
|
|
489
|
+
// process, write, patch, browser, and execution schemas. ACP modes alone
|
|
490
|
+
// cannot do that, so resume the exact native session in a fresh process
|
|
491
|
+
// under the bridge-owned read-only policy.
|
|
492
|
+
activeMode = nextMode
|
|
493
|
+
suppressNextSessionPublish = started
|
|
494
|
+
if (started) {
|
|
495
|
+
retireClient()
|
|
496
|
+
const oldClient = client
|
|
497
|
+
client = null
|
|
498
|
+
mapper = null
|
|
499
|
+
started = false
|
|
500
|
+
crashed = false
|
|
501
|
+
stderrTail = ''
|
|
502
|
+
oldClient?.end()
|
|
503
|
+
}
|
|
504
|
+
void boot().then(() => emit({ kind: 'mode', mode: activeMode })).catch((error) => {
|
|
505
|
+
activeMode = priorMode
|
|
506
|
+
suppressNextSessionPublish = false
|
|
507
|
+
emit({ kind: 'error', message: `Hermes mode switch failed: ${error?.message || error}`, recoverable: true })
|
|
508
|
+
})
|
|
509
|
+
return true
|
|
510
|
+
}
|
|
511
|
+
void boot().then(() => client.request('session/set_mode', { sessionId, modeId: acpModeFor(nextMode) })).then(() => {
|
|
456
512
|
activeMode = nextMode
|
|
457
513
|
emit({ kind: 'mode', mode: activeMode })
|
|
458
514
|
}).catch((error) => emit({ kind: 'error', message: `Hermes mode switch failed: ${error?.message || error}`, recoverable: true }))
|
package/package.json
CHANGED
package/runtime-registry.mjs
CHANGED
|
@@ -14,7 +14,7 @@ const RUNTIMES = Object.freeze({
|
|
|
14
14
|
hermes: Object.freeze({
|
|
15
15
|
id: 'hermes', command: 'thinkpool', label: 'Hermes Agent', protocol: 'acp',
|
|
16
16
|
structured: true, flow: true, canSteer: true, images: true, nativeModelCatalog: true, catalogRequiresSession: true, effortControl: false, defaultMode: 'default',
|
|
17
|
-
modes: Object.freeze(['default', 'acceptEdits']),
|
|
17
|
+
modes: Object.freeze(['default', 'acceptEdits', 'plan', 'bypassPermissions']),
|
|
18
18
|
beta: true,
|
|
19
19
|
}),
|
|
20
20
|
})
|