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 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, authorizeDispatchApproval, verifyDurableDispatchAuthority } from './cross-terminal.mjs'
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 { beginDispatchLease, finishDispatchLease, isDispatchLeaseCurrent, supersedeDispatchLease } from './dispatch-lease.mjs'
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 (inherits the parent's mode +
1765
- // its own bypass-escalation gate) all pass an explicit mode and skip this.
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
- mode: args?.mode || (entry.mode === 'plan' ? 'default' : entry.mode),
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('permission mode for the new lane; defaults to inheriting YOUR current mode. Raising a lane to bypassPermissions from a non-bypass lane asks the room to confirm once.'),
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 durable = await readDurableDispatchAuthority({
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 approval is stale or does not match this request. No lane was created.')
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
- // Inherit the spawner's permission mode by default (so a bypass orchestrator's
2454
- // lanes run autonomously); an explicit `mode` overrides. The PreToolUse gate
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
@@ -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
- // ThinkPool terminal creation — open_main_terminal creates an independent main
520
- // conductor; spawn_terminal creates a worker the agent owns. Both inherit mode
521
- // (`mode` here is THIS lane's current mode) unless an explicit `mode` overrides it.
522
- // The only thing worth a human card is an ESCALATION: raising a child to
523
- // bypassPermissions from a non-bypass lane (full unattended access the operator
524
- // hasn't already opted into). If the spawner is already in bypass, children inherit
525
- // bypass with NO prompt (you opted in once); a non-bypass child gates itself per
526
- // action anyway, so no spawn prompt there either. Bridge-side spawnDecision still
527
- // enforces the one-worker-tier bound / burst window / machineMax / TP_SPAWN_OFF regardless.
528
- if (toolName === 'mcp__thinkpool__spawn_terminal' || toolName === 'mcp__thinkpool__open_main_terminal') {
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 inherit your permission mode by default; raising one to bypass asks the room once. Collect each worker with read_terminal and close_terminal immediately after using its result. Main conductors are independent terminals and are not owned/closed through Ensemble. Room caps and the kill-switch bind both creation paths.',
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, spawn_terminal's inherited mode) always override this.
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'
@@ -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' }
@@ -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({
@@ -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(requiredMcpTools) ? requiredMcpTools : [])
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(policyRole, { mcpTools: required })
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(policyRole, { mcpTools: requiredMcpTools }) }
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
- mapper.startSession({ sessionId, models: publishedModels, modes: state?.modes, commands: [] })
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 === 'acceptEdits' ? 'accept_edits' : 'default'
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
- void boot().then(() => client.request('session/set_mode', { sessionId, modeId: nextMode === 'acceptEdits' ? 'accept_edits' : 'default' })).then(() => {
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thinkpool-pair",
3
- "version": "0.7.252",
3
+ "version": "0.7.254",
4
4
  "description": "Share a local coding-agent CLI (Claude Code, Codex, Gemini, Aider, …) into a ThinkPool Code room, live.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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
  })