thinkpool-pair 0.7.339 → 0.7.341

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
@@ -119,7 +119,7 @@ const flowRedispatch = new Map()
119
119
  // wave BEFORE overrun. Lives bridge-side because waves dispatch across separate
120
120
  // broadcasts; without persistent state the cap can never bite.
121
121
  const flowBudgets = new Map()
122
- import { formatPeek, PEEK, readTerminalTurnBudgetDecision, siblingsOf, resolveSibling, crossPostDecision, CROSSPOST, spawnDecision, SPAWN, CROSSROOM, CROSSROOM_BUS, formatPairRoster, crossRoomPostDecision, formatRoomNow, formatClosableHint, closeLaneDecision, laneBusyOf, laneStatusOf, settleLaneBusy, nativeClaudeFallbackHint, buildDispatchPreview, authorizeDirectDispatch } from './cross-terminal.mjs'
122
+ import { formatPeek, PEEK, readTerminalTurnBudgetDecision, siblingsOf, resolveSibling, crossPostDecision, CROSSPOST, spawnDecision, SPAWN, CROSSROOM, CROSSROOM_BUS, formatPairRoster, crossRoomPostDecision, formatRoomNow, formatClosableHint, closeLaneDecision, laneBusyOf, laneStatusOf, settleLaneBusy, settleLaneControl, nativeClaudeFallbackHint, buildDispatchPreview, authorizeDirectDispatch } from './cross-terminal.mjs'
123
123
  import { createStandalonePairResponder, standalonePairIdentity } from './direct-pair-room.mjs'
124
124
  import { dispatchPendingRecap, recoverInterruptedTurn, recoverMissingResumeOnce, sendInterruptedContinue, shouldAttemptNativeResume, supersedeInterruptedResume } from './interrupted-resume.mjs'
125
125
  import { supersedeDispatchLease } from './dispatch-lease.mjs'
@@ -130,6 +130,7 @@ import { createLatestReplayPump, requestedReplayIds } from './replay-transport.m
130
130
  import { classifyCodeEvent, unknownCodeEventNotice } from './code-event-contract.mjs'
131
131
  import { fetchPairControlDeliveryAuthority } from './pair-control-authority.mjs'
132
132
  import { SIDE_HANDOFF_PROMPT, SIDE_ROLE_PROMPT, appendSideContext, assistantTextSince, dispatchSideContexts, sideContextBlock, sideSnapshot } from './side-lane.mjs'
133
+ import { acknowledgeWorkerCompletions, enqueueWorkerCompletion, workerCompletionPrompt, workerCompletionRecord } from './worker-completion.mjs'
133
134
  import { planMeterLine } from './plan-meters.mjs'
134
135
  import { priceForModel } from './model-prices.mjs'
135
136
  import { loadHermesModelCache, saveHermesModelCache } from './hermes-model-cache.mjs'
@@ -1110,6 +1111,42 @@ function schedulePendingSideContexts(entry) {
1110
1111
  entry._sideContextTimer.unref?.()
1111
1112
  }
1112
1113
 
1114
+ function dispatchPendingWorkerCompletions(entry) {
1115
+ if (!entry?.pendingWorkerCompletions?.length || typeof entry.session?.sendTurn !== 'function') return false
1116
+ if (entry.workerCompletionsInFlight?.length) return false
1117
+ if (laneBusyOf(entry) || (entry.pending?.size || 0) > 0) return false
1118
+ const batch = entry.pendingWorkerCompletions.slice()
1119
+ const prompt = workerCompletionPrompt(batch)
1120
+ if (!prompt) {
1121
+ entry.pendingWorkerCompletions = []
1122
+ return false
1123
+ }
1124
+ // Write the dispatch intent before sendTurn. A process death after acceptance
1125
+ // must leave enough durable state to resume or redeliver the continuation.
1126
+ entry.workerCompletionsInFlight = batch
1127
+ entry.flush?.()
1128
+ const dispatched = dispatchStructuredTurn(entry, prompt, {
1129
+ rejectionMessage: 'The worker-completion continuation was not accepted; it remains queued.',
1130
+ })
1131
+ if (!dispatched.accepted) {
1132
+ entry.workerCompletionsInFlight = []
1133
+ entry.flush?.()
1134
+ return false
1135
+ }
1136
+ return true
1137
+ }
1138
+
1139
+ function schedulePendingWorkerCompletions(entry) {
1140
+ if (!entry?.pendingWorkerCompletions?.length || entry._workerCompletionTimer) return
1141
+ entry._workerCompletionTimer = setTimeout(() => {
1142
+ entry._workerCompletionTimer = null
1143
+ if (dispatchPendingWorkerCompletions(entry)) {
1144
+ process.stderr.write('\n ◆ worker finished — continued its owning terminal.\n')
1145
+ }
1146
+ }, 0)
1147
+ entry._workerCompletionTimer.unref?.()
1148
+ }
1149
+
1113
1150
  function stampStructuredTurn(entry, event) {
1114
1151
  if (event && event.turnRev == null && Number(entry?._turnRev) > 0) event.turnRev = entry._turnRev
1115
1152
  return event
@@ -2139,7 +2176,7 @@ function worktreeSnapshot(cwd) {
2139
2176
  // relay STRUCTURED events. onEvent → broadcast `code-event` + print locally +
2140
2177
  // persist to the host file; tool calls round-trip through the perm card; the
2141
2178
  // rolling log replays to joiners and survives bridge restarts (session-store).
2142
- function openStructured({ id, runtime = 'claude', model, models, effort, resume, log, commands, mode, spawnedBy, spawnDepth, cascadeRole, hop, sideParent, sideTask, pendingSideContexts, rolePrompt, sliceType, flowSessionId, flowTaskKey, flowRole, flowReviewTarget, flowReviewTargets, flowReviewSnapshots, flowReviewRound, dispatchBaseSha, revertTarget, cwd, managedWorktree, reviewSliceRoots, openedAt, defer, provider, carryRecap, lastUsage }) {
2179
+ function openStructured({ id, runtime = 'claude', model, models, effort, resume, log, commands, mode, spawnedBy, spawnDepth, cascadeRole, hop, sideParent, sideTask, pendingSideContexts, pendingWorkerCompletions, workerCompletionsInFlight, rolePrompt, sliceType, flowSessionId, flowTaskKey, flowRole, flowReviewTarget, flowReviewTargets, flowReviewSnapshots, flowReviewRound, dispatchBaseSha, revertTarget, cwd, managedWorktree, reviewSliceRoots, openedAt, defer, provider, carryRecap, lastUsage }) {
2143
2180
  if (sessions.has(id)) return
2144
2181
  runtime = structuredRuntimeMetadata(runtime) ? runtime : 'claude'
2145
2182
  mode = structuredModeForSlice(runtime, { mode, sliceType, flowRole })
@@ -2192,7 +2229,7 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
2192
2229
  : (!provider || provider === 'anthropic')
2193
2230
  ? (laneModel || null)
2194
2231
  : (laneModel || providerNameMap()[provider] || provider),
2195
- provider: provider || null, spawnedBy: spawnedBy || undefined, spawnDepth: structuralDepth, cascadeRole: cascadeRole === 'conductor' || cascadeRole === 'worker' ? cascadeRole : null, hop: initialHop, sideParent: sideParent || undefined, sideTask: sideTask || undefined, pendingSideContexts: Array.isArray(pendingSideContexts) ? pendingSideContexts.filter(Boolean).slice(-4) : [], flowSessionId: flowSessionId || null, flowTaskKey: flowTaskKey || null, cwd: cwd || null, managedWorktree: managedWorktree || null,
2232
+ provider: provider || null, spawnedBy: spawnedBy || undefined, spawnDepth: structuralDepth, cascadeRole: cascadeRole === 'conductor' || cascadeRole === 'worker' ? cascadeRole : null, hop: initialHop, sideParent: sideParent || undefined, sideTask: sideTask || undefined, pendingSideContexts: Array.isArray(pendingSideContexts) ? pendingSideContexts.filter(Boolean).slice(-4) : [], pendingWorkerCompletions: Array.isArray(pendingWorkerCompletions) ? pendingWorkerCompletions.filter((item) => item?.workerId && Number(item?.turnRev) > 0).slice(-6) : [], workerCompletionsInFlight: Array.isArray(workerCompletionsInFlight) ? workerCompletionsInFlight.filter((item) => item?.workerId && Number(item?.turnRev) > 0).slice(-6) : [], flowSessionId: flowSessionId || null, flowTaskKey: flowTaskKey || null, cwd: cwd || null, managedWorktree: managedWorktree || null,
2196
2233
  sliceType: sliceType === 'review' ? 'review' : null,
2197
2234
  flowRole: flowRole || (flowSessionId ? (flowTaskKey ? ((flowReviewTargets?.length || reviewSliceRoots?.length) ? 'reviewer' : 'builder') : 'conductor') : null),
2198
2235
  flowReviewTarget: flowReviewTarget || null,
@@ -2210,6 +2247,12 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
2210
2247
  entry._settledTurnRev = Number(restoredBoundary?.turnRev) || null
2211
2248
  entry._turnSettledAt = Number(restoredBoundary?.ts) || null
2212
2249
  entry.interruptedRecap = restoredTurnOpen(entry.log) ? buildRecapFromLog(entry.log, RECAP_CAP) : null
2250
+ // If the bridge died after recording dispatch intent but before the synthetic
2251
+ // turn emitted any transcript evidence, no native turn can be resumed. Keep
2252
+ // the pending outbox and make it eligible for redelivery.
2253
+ if (entry.workerCompletionsInFlight.length && !entry.interruptedRecap) {
2254
+ entry.workerCompletionsInFlight = []
2255
+ }
2213
2256
  // Slice 3 — a permission card left unanswered past the grace window pushes
2214
2257
  // "<lane> — needs you: <what>"; answering it anywhere retracts the banner
2215
2258
  // everywhere. Worker/flow lanes are excluded at arm() time (isUserFacingLane).
@@ -2458,7 +2501,7 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
2458
2501
  // restart. Without this, sessionData omitted it → on restart the resumed session
2459
2502
  // re-launched on the host default (Opus) regardless of the last switch, and the
2460
2503
  // switch looked like it "never changed the model" (Max 2026-07-02). Restored below.
2461
- const sessionData = () => ({ sessionId: entry.session?.sessionId || null, runtime: entry.runtime, log: entry.log, commands: entry.commands, mode: entry.mode, effort: entry.effort, model: entry.model || null, models: entry.runtime === 'hermes' ? (entry.models || []) : undefined, provider: entry.provider || null, spawnedBy: entry.spawnedBy, spawnDepth: entry.spawnDepth || 0, cascadeRole: entry.cascadeRole || null, hop: entry.hop || 0, sideParent: entry.sideParent, sideTask: entry.sideTask, pendingSideContexts: entry.pendingSideContexts, sliceType: entry.sliceType, flowSessionId: entry.flowSessionId, flowTaskKey: entry.flowTaskKey, flowRole: entry.flowRole || null, flowReviewTargets: entry.flowReviewTargets || [], flowReviewSnapshots: entry.flowReviewSnapshots || [], flowReviewRound: entry.flowReviewRound || 0, dispatchBaseSha: entry.dispatchBaseSha || null, cwd: entry.cwd, managedWorktree: entry.managedWorktree, rolePrompt: entry.rolePrompt, flowReviewTarget: entry.flowReviewTarget || null, revertTarget: entry.revertTarget || null, reviewSliceRoots: entry.reviewSliceRoots || [], openedAt: entry.openedAt || null, lastUsage: entry.lastUsage || null, carryRecap: entry.pendingRecap || null })
2504
+ const sessionData = () => ({ sessionId: entry.session?.sessionId || null, runtime: entry.runtime, log: entry.log, commands: entry.commands, mode: entry.mode, effort: entry.effort, model: entry.model || null, models: entry.runtime === 'hermes' ? (entry.models || []) : undefined, provider: entry.provider || null, spawnedBy: entry.spawnedBy, spawnDepth: entry.spawnDepth || 0, cascadeRole: entry.cascadeRole || null, hop: entry.hop || 0, sideParent: entry.sideParent, sideTask: entry.sideTask, pendingSideContexts: entry.pendingSideContexts, pendingWorkerCompletions: entry.pendingWorkerCompletions, workerCompletionsInFlight: entry.workerCompletionsInFlight, sliceType: entry.sliceType, flowSessionId: entry.flowSessionId, flowTaskKey: entry.flowTaskKey, flowRole: entry.flowRole || null, flowReviewTargets: entry.flowReviewTargets || [], flowReviewSnapshots: entry.flowReviewSnapshots || [], flowReviewRound: entry.flowReviewRound || 0, dispatchBaseSha: entry.dispatchBaseSha || null, cwd: entry.cwd, managedWorktree: entry.managedWorktree, rolePrompt: entry.rolePrompt, flowReviewTarget: entry.flowReviewTarget || null, revertTarget: entry.revertTarget || null, reviewSliceRoots: entry.reviewSliceRoots || [], openedAt: entry.openedAt || null, lastUsage: entry.lastUsage || null, carryRecap: entry.pendingRecap || null })
2462
2505
  const persist = () => saveSession(room, id, sessionData())
2463
2506
  // Synchronous flush of this session's record. Used on open (so a brand-new session
2464
2507
  // has a file under its id BEFORE its first event — surviving a restart inside the
@@ -3269,7 +3312,7 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
3269
3312
  openStructured({
3270
3313
  id, runtime: entry.runtime, model: entry.model || model, effort: entry.effort,
3271
3314
  provider: entry.provider, log: entry.log, commands: entry.commands, mode: entry.mode,
3272
- spawnedBy: entry.spawnedBy, spawnDepth: entry.spawnDepth, cascadeRole: entry.cascadeRole, hop: entry.hop, sideParent: entry.sideParent, sideTask: entry.sideTask, pendingSideContexts: entry.pendingSideContexts, sliceType: entry.sliceType, flowSessionId: entry.flowSessionId,
3315
+ spawnedBy: entry.spawnedBy, spawnDepth: entry.spawnDepth, cascadeRole: entry.cascadeRole, hop: entry.hop, sideParent: entry.sideParent, sideTask: entry.sideTask, pendingSideContexts: entry.pendingSideContexts, pendingWorkerCompletions: entry.pendingWorkerCompletions, workerCompletionsInFlight: entry.workerCompletionsInFlight, sliceType: entry.sliceType, flowSessionId: entry.flowSessionId,
3273
3316
  flowTaskKey: entry.flowTaskKey, flowRole: entry.flowRole, flowReviewTarget: entry.flowReviewTarget,
3274
3317
  flowReviewTargets: entry.flowReviewTargets, flowReviewSnapshots: entry.flowReviewSnapshots, flowReviewRound: entry.flowReviewRound,
3275
3318
  dispatchBaseSha: entry.dispatchBaseSha, revertTarget: entry.revertTarget, cwd: entry.cwd,
@@ -3491,6 +3534,14 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
3491
3534
  // _turnStart is cleared unconditionally so a stray post-settle event can't
3492
3535
  // re-notify off a stale stamp.
3493
3536
  if (evt.kind === 'result' || evt.kind === 'error') {
3537
+ if (terminalBoundary && entry.workerCompletionsInFlight?.length) {
3538
+ entry.pendingWorkerCompletions = acknowledgeWorkerCompletions(
3539
+ entry.pendingWorkerCompletions,
3540
+ entry.workerCompletionsInFlight,
3541
+ )
3542
+ entry.workerCompletionsInFlight = []
3543
+ entry.flush?.()
3544
+ }
3494
3545
  const edit = designActive.get(id)
3495
3546
  if (edit) {
3496
3547
  const successful = evt.kind === 'result' && (!evt.subtype || evt.subtype === 'success')
@@ -3534,9 +3585,31 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
3534
3585
  const stalePermissionIds = [...entry.pending.keys()]
3535
3586
  drainPending(entry)
3536
3587
  for (const pendingId of stalePermissionIds) bcast('code-perm', { term: id, id: pendingId, decision: 'deny', name: 'agent' })
3588
+ // A spawned worker settling is an execution signal for its owning main
3589
+ // terminal, not a human-facing push. Queue it durably, then start the
3590
+ // owner's continuation immediately if idle (or after its current turn).
3591
+ if (terminalBoundary && entry.spawnedBy && !String(entry.spawnedBy).startsWith('flow:')) {
3592
+ const parent = sessions.get(entry.spawnedBy)
3593
+ const turnRev = Number(evt.turnRev) || Number(entry._turnRev) || 0
3594
+ if (parent && turnRev > 0 && entry._ownerNotifiedTurnRev !== turnRev) {
3595
+ const completion = workerCompletionRecord({
3596
+ workerId: id,
3597
+ workerName: termNames[id],
3598
+ turnRev,
3599
+ subtype: evt.subtype,
3600
+ })
3601
+ if (completion) {
3602
+ entry._ownerNotifiedTurnRev = turnRev
3603
+ parent.pendingWorkerCompletions = enqueueWorkerCompletion(parent.pendingWorkerCompletions, completion)
3604
+ parent.flush?.()
3605
+ schedulePendingWorkerCompletions(parent)
3606
+ }
3607
+ }
3608
+ }
3537
3609
  // Bring-to-main never interrupts an active parent turn. If a handoff arrived
3538
3610
  // while this lane was working, start it on the first idle tick after settle.
3539
3611
  schedulePendingSideContexts(entry)
3612
+ schedulePendingWorkerCompletions(entry)
3540
3613
  const settledAt = Date.now()
3541
3614
  if (shouldRecordTurnDone({ entry, subtype: evt.subtype, startedAt, now: settledAt })) {
3542
3615
  persistAgentEvent({
@@ -3597,6 +3670,7 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
3597
3670
  // before the first debounced save still restores this id instead of dropping it (the
3598
3671
  // room would otherwise show a fresh empty terminal + strand the transcript).
3599
3672
  if (!entry.log.length) entry.flush()
3673
+ schedulePendingWorkerCompletions(entry)
3600
3674
  announce()
3601
3675
  if (!entry.log.length) process.stderr.write(`\n ◆ structured ${structuredRuntimeMetadata(runtime)?.label || 'agent'} session (${id.slice(0, 8)}) — driven from the room.\n`)
3602
3676
  return entry
@@ -3749,7 +3823,7 @@ function respawnStructured(id, provider) {
3749
3823
  // openStructured seed from the TARGET provider's configured model, which is the
3750
3824
  // only model this lane was ever asked for. A same-env model change never reaches
3751
3825
  // here — that path is an in-place setModel (see provider-switch).
3752
- const { runtime, log, commands, mode, spawnedBy, spawnDepth, cascadeRole, hop, sideParent, sideTask, pendingSideContexts, sliceType, flowSessionId, flowTaskKey, flowRole, flowReviewTarget, flowReviewTargets, flowReviewSnapshots, flowReviewRound, dispatchBaseSha, revertTarget, cwd, managedWorktree, rolePrompt, reviewSliceRoots, openedAt, lastUsage } = s
3826
+ const { runtime, log, commands, mode, spawnedBy, spawnDepth, cascadeRole, hop, sideParent, sideTask, pendingSideContexts, pendingWorkerCompletions, workerCompletionsInFlight, sliceType, flowSessionId, flowTaskKey, flowRole, flowReviewTarget, flowReviewTargets, flowReviewSnapshots, flowReviewRound, dispatchBaseSha, revertTarget, cwd, managedWorktree, rolePrompt, reviewSliceRoots, openedAt, lastUsage } = s
3753
3827
  // Context-carry (2026-07-08): a provider switch is not an SDK resume — the new backend
3754
3828
  // starts blank mid-conversation. Synthesize a plain-text recap from the VISIBLE log NOW
3755
3829
  // (before teardown) and hand it to the fresh session as its first turn so the agent
@@ -3768,7 +3842,7 @@ function respawnStructured(id, provider) {
3768
3842
  // sessionData() (provider included) synchronously on open, so a bridge restart
3769
3843
  // restores the lane on its CURRENT provider, not the original — and its next
3770
3844
  // announce carries the new provider badge (additive {id,name} projection).
3771
- openStructured({ id, runtime, provider, log, commands, mode, spawnedBy, spawnDepth, cascadeRole, hop, sideParent, sideTask, pendingSideContexts, sliceType, flowSessionId, flowTaskKey, flowRole, flowReviewTarget, flowReviewTargets, flowReviewSnapshots, flowReviewRound, dispatchBaseSha, revertTarget, cwd, managedWorktree, rolePrompt, reviewSliceRoots, openedAt, lastUsage, carryRecap })
3845
+ openStructured({ id, runtime, provider, log, commands, mode, spawnedBy, spawnDepth, cascadeRole, hop, sideParent, sideTask, pendingSideContexts, pendingWorkerCompletions, workerCompletionsInFlight, sliceType, flowSessionId, flowTaskKey, flowRole, flowReviewTarget, flowReviewTargets, flowReviewSnapshots, flowReviewRound, dispatchBaseSha, revertTarget, cwd, managedWorktree, rolePrompt, reviewSliceRoots, openedAt, lastUsage, carryRecap })
3772
3846
  return true
3773
3847
  }
3774
3848
 
@@ -4447,7 +4521,22 @@ channel
4447
4521
  }
4448
4522
  const preTokens = s.lastUsage?.ctx?.used || null
4449
4523
  bcast('code-event', { term: payload.term, evt: { kind: 'compact', status: 'start', ts: Date.now() } })
4450
- const nativeCompacted = await s.session.compactContext?.()
4524
+ let nativeCompacted = false
4525
+ try {
4526
+ nativeCompacted = await s.session.compactContext?.()
4527
+ } catch {
4528
+ // The bounded recap below is the safe fallback for a native transport
4529
+ // failure. The roster edge is still settled in finally.
4530
+ } finally {
4531
+ // Native compaction emits usage while compactActive=true, so onEvent
4532
+ // legitimately announces a busy edge. It has no ordinary result event;
4533
+ // close that borrowed edge here after compactContext's finally clears
4534
+ // compactActive, or "Compacting…" degrades into stale "Thinking…".
4535
+ // A browser turn may have arrived while compactContext awaited App
4536
+ // Server readiness. Never let control cleanup settle that ordinary
4537
+ // turn's busy edge; the normal result boundary owns it.
4538
+ if (!s.session.turnActive && settleLaneControl(s)) announce()
4539
+ }
4451
4540
  if (nativeCompacted) {
4452
4541
  const ce = { kind: 'compaction', trigger: 'manual', preTokens, by: payload.by, native: true }
4453
4542
  pushLog(s, ce)
@@ -4711,7 +4800,7 @@ channel
4711
4800
  // FL-M6 — restore the flow context (id/role/cwd) so an in-flight flow survives a
4712
4801
  // bridge restart: the conductor keeps its subagent-block + plan interception, and
4713
4802
  // lanes keep their worktree cwd + the ability to mark done.
4714
- openStructured({ id: rec.id, runtime: rec.runtime || 'claude', model: rec.model || undefined, models: rec.models, effort: rec.effort, provider: rec.runtime === 'claude' ? rec.provider || undefined : undefined, resume: resumable ? rec.sessionId : undefined, log: rec.log, commands: rec.commands, mode: rec.mode, spawnedBy: rec.spawnedBy, spawnDepth: rec.spawnDepth, cascadeRole: rec.cascadeRole, hop: rec.hop, sideParent: rec.sideParent, sideTask: rec.sideTask, pendingSideContexts: rec.pendingSideContexts, sliceType: rec.sliceType, flowSessionId: rec.flowSessionId, flowTaskKey: rec.flowTaskKey, flowRole: rec.flowRole, flowReviewTarget: rec.flowReviewTarget, flowReviewTargets: rec.flowReviewTargets, flowReviewSnapshots: rec.flowReviewSnapshots, flowReviewRound: rec.flowReviewRound, dispatchBaseSha: rec.dispatchBaseSha, revertTarget: rec.revertTarget, cwd: rec.cwd, managedWorktree: rec.managedWorktree, rolePrompt: rec.rolePrompt, reviewSliceRoots: rec.reviewSliceRoots, openedAt: rec.openedAt, lastUsage: rec.lastUsage, carryRecap: wasInterrupted ? recoveryRecap : rec.carryRecap,
4803
+ openStructured({ id: rec.id, runtime: rec.runtime || 'claude', model: rec.model || undefined, models: rec.models, effort: rec.effort, provider: rec.runtime === 'claude' ? rec.provider || undefined : undefined, resume: resumable ? rec.sessionId : undefined, log: rec.log, commands: rec.commands, mode: rec.mode, spawnedBy: rec.spawnedBy, spawnDepth: rec.spawnDepth, cascadeRole: rec.cascadeRole, hop: rec.hop, sideParent: rec.sideParent, sideTask: rec.sideTask, pendingSideContexts: rec.pendingSideContexts, pendingWorkerCompletions: rec.pendingWorkerCompletions, workerCompletionsInFlight: rec.workerCompletionsInFlight, sliceType: rec.sliceType, flowSessionId: rec.flowSessionId, flowTaskKey: rec.flowTaskKey, flowRole: rec.flowRole, flowReviewTarget: rec.flowReviewTarget, flowReviewTargets: rec.flowReviewTargets, flowReviewSnapshots: rec.flowReviewSnapshots, flowReviewRound: rec.flowReviewRound, dispatchBaseSha: rec.dispatchBaseSha, revertTarget: rec.revertTarget, cwd: rec.cwd, managedWorktree: rec.managedWorktree, rolePrompt: rec.rolePrompt, reviewSliceRoots: rec.reviewSliceRoots, openedAt: rec.openedAt, lastUsage: rec.lastUsage, carryRecap: wasInterrupted ? recoveryRecap : rec.carryRecap,
4715
4804
  // Lazy-boot restored terminals that were IDLE + not part of a flow: their transcript
4716
4805
  // shows immediately; the query boots on first turn. Mid-turn + flow terminals boot now
4717
4806
  // (mid-turn needs auto-resume; flow needs its lane live).
package/codex-session.mjs CHANGED
@@ -1099,7 +1099,7 @@ export function startCodexSession({ cwd, model, effort: initialEffort = 'high',
1099
1099
  armTurnLiveness()
1100
1100
  }
1101
1101
  // start the pump if idle
1102
- if (queue.length === 1) pump()
1102
+ if (queue.length === 1 && !compactActive) pump()
1103
1103
  return true
1104
1104
  },
1105
1105
  abort() {
@@ -1169,7 +1169,11 @@ export function startCodexSession({ cwd, model, effort: initialEffort = 'high',
1169
1169
  return true
1170
1170
  },
1171
1171
  async compactContext() {
1172
- if (turnActive || compactActive || !await ensureAppServer()) return false
1172
+ if (turnActive || compactActive) return false
1173
+ const readyAppServer = await ensureAppServer()
1174
+ // ensureAppServer may need to boot the transport. Re-check after that
1175
+ // await so a normal turn that began meanwhile owns the lane exclusively.
1176
+ if (!readyAppServer || turnActive || compactActive) return false
1173
1177
  compactActive = true
1174
1178
  let startTimer = null
1175
1179
  try {
@@ -1195,6 +1199,9 @@ export function startCodexSession({ cwd, model, effort: initialEffort = 'high',
1195
1199
  if (startTimer) clearTimeout(startTimer)
1196
1200
  compactStartWaiter = null
1197
1201
  compactActive = false
1202
+ // A human turn accepted while compaction owned the App Server is queued,
1203
+ // not started concurrently. Hand it to the ordinary serialized pump now.
1204
+ if (queue.length) pump()
1198
1205
  }
1199
1206
  },
1200
1207
  async accountUsage() {
@@ -14,7 +14,6 @@ const command = (name, description, route, inputHint, runtimes = ['claude', 'cod
14
14
 
15
15
  export const CODE_ROOM_COMMANDS = Object.freeze([
16
16
  command('/side', 'investigate beside this terminal', 'side', 'task'),
17
- command('/flow', 'open an explicit visible Cascade', 'flow', 'task [--mode guide|steer|autopilot]'),
18
17
  command('/help', 'list commands available in this lane', 'control'),
19
18
  command('/status', 'runtime, model, permissions, and busy state', 'control'),
20
19
  command('/usage', 'session usage and provider limits', 'control'),
@@ -28,10 +27,21 @@ export const CODE_ROOM_COMMANDS = Object.freeze([
28
27
  command('/queue', 'run a prompt after the active turn', 'queue', 'prompt'),
29
28
  command('/steer', 'guide the active turn', 'steer', 'prompt', ['codex', 'hermes']),
30
29
  command('/credits', 'provider credit balance', 'credits', null, ['codex', 'hermes']),
31
- command('/reasoning', 'Hermes reasoning effort', 'runtime', 'low | medium | high | xhigh | max | none | reset', ['hermes']),
32
30
  command('/review', 'review uncommitted changes', 'runtime', null, ['codex']),
33
31
  ])
34
32
 
33
+ // Native catalogs are runtime evidence, never portable session metadata.
34
+ // Claude's adapter default-denies raw SDK commands and passes only proven room
35
+ // controls plus installed Skills. Hermes' isolated ACP bootstrap owns its safe
36
+ // native catalog. Codex App Server publishes no native slash catalog, so a
37
+ // restored/foreign list must not manufacture one. Compatibility aliases stay
38
+ // callable at their runtime boundary without remaining discoverable.
39
+ const NATIVE_COMMAND_POLICY = Object.freeze({
40
+ claude: Object.freeze({ accept: true, hidden: Object.freeze(new Set(['/flow'])) }),
41
+ codex: Object.freeze({ accept: false, hidden: Object.freeze(new Set(['/flow'])) }),
42
+ hermes: Object.freeze({ accept: true, hidden: Object.freeze(new Set(['/flow', '/reasoning'])) }),
43
+ })
44
+
35
45
  const cleanRuntime = (runtime) => runtime === 'thinkpool' ? 'hermes' : runtime
36
46
  const cleanName = (value) => {
37
47
  const raw = typeof value === 'string' ? value : value?.name
@@ -54,6 +64,7 @@ const normalizeNative = (value) => {
54
64
  // retained only after the runtime-specific adapter has already allowlisted them.
55
65
  export function commandCatalogForRuntime(runtime, nativeCommands = []) {
56
66
  const id = cleanRuntime(runtime)
67
+ const nativePolicy = NATIVE_COMMAND_POLICY[id] || NATIVE_COMMAND_POLICY.codex
57
68
  const byName = new Map()
58
69
  for (const item of CODE_ROOM_COMMANDS) {
59
70
  if (!item.runtimes.includes(id)) continue
@@ -66,7 +77,7 @@ export function commandCatalogForRuntime(runtime, nativeCommands = []) {
66
77
  }
67
78
  for (const raw of (Array.isArray(nativeCommands) ? nativeCommands : [])) {
68
79
  const item = normalizeNative(raw)
69
- if (!item) continue
80
+ if (!item || !nativePolicy.accept || nativePolicy.hidden.has(item.name)) continue
70
81
  const shared = byName.get(item.name)
71
82
  const hermesNativeControl = id === 'hermes' && ['/help', '/status', '/context', '/credits'].includes(item.name)
72
83
  byName.set(item.name, shared
@@ -123,6 +123,18 @@ export const settleLaneBusy = (entry) => {
123
123
  return true
124
124
  }
125
125
 
126
+ // Structured controls such as native Codex compaction can borrow the runtime's
127
+ // turnActive flag without emitting an ordinary result event. Close that borrowed
128
+ // roster edge with the same durable revision proof as a normal terminal boundary.
129
+ export const settleLaneControl = (entry, now = Date.now()) => {
130
+ if (!entry || entry._busyAnn !== true) return false
131
+ if (!settleLaneBusy(entry)) return false
132
+ entry._settledTurnRev = Number(entry._turnRev) || null
133
+ entry._turnSettledAt = Number(now) || Date.now()
134
+ entry._turnStart = null
135
+ return true
136
+ }
137
+
126
138
  export const laneStatusOf = (entry = {}, now = Date.now(), limits = LANE_STATUS) => {
127
139
  const baseLog = Array.isArray(entry?.log) ? entry.log : []
128
140
  const log = baseLog.slice(-limits.failureWindow)
@@ -168,10 +168,10 @@ acp_adapter.server.HermesACPAgent._build_model_state = nous_only_model_state
168
168
  # account tokens, or lifecycle/admin controls enter the room surface.
169
169
  _TP_REASONING_CONFIG_ID = "thinkpool_reasoning_effort"
170
170
  _TP_REASONING_LEVELS = frozenset({"none", "low", "medium", "high", "xhigh", "max"})
171
+ _TP_HIDDEN_COMMANDS = frozenset({"reasoning"})
171
172
  _TP_COMMANDS = (
172
173
  {"name": "credits", "description": "Show safe Nous credit balance and top-up handoff"},
173
174
  {"name": "status", "description": "Show session, model, context, version, and reasoning status"},
174
- {"name": "reasoning", "description": "Set session-only reasoning effort", "input_hint": "low, medium, high, xhigh, max, none, or reset"},
175
175
  )
176
176
 
177
177
  _native_compact = getattr(acp_adapter.server.HermesACPAgent, "_cmd_compact", None)
@@ -283,8 +283,12 @@ _available_commands = getattr(acp_adapter.server.HermesACPAgent, "_available_com
283
283
  @classmethod
284
284
  def thinkpool_available_commands(cls):
285
285
  # Keep the upstream catalog canonical, then append exactly our process-local
286
- # commands. This drives ACP updates and /help from one registry.
287
- base = list(_available_commands.__func__(cls)) if _available_commands else []
286
+ # commands. Compatibility aliases remain executable without appearing in
287
+ # ACP updates or /help; the room-level /effort control is canonical.
288
+ base = [
289
+ item for item in (list(_available_commands.__func__(cls)) if _available_commands else [])
290
+ if str(getattr(item, "name", item.get("name", "") if isinstance(item, dict) else "") or "").lstrip("/").lower() not in _TP_HIDDEN_COMMANDS
291
+ ]
288
292
  try:
289
293
  from acp.schema import AvailableCommand, UnstructuredCommandInput
290
294
  known = {getattr(item, "name", "") for item in base}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thinkpool-pair",
3
- "version": "0.7.339",
3
+ "version": "0.7.341",
4
4
  "description": "Connect Claude Code, Codex, or Hermes on your computer to a Thinkpool Code room.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -63,6 +63,7 @@
63
63
  "transcript-sanitize.mjs",
64
64
  "session-store.mjs",
65
65
  "side-lane.mjs",
66
+ "worker-completion.mjs",
66
67
  "cross-terminal.mjs",
67
68
  "pair-bus.mjs",
68
69
  "direct-pair-room.mjs",
@@ -0,0 +1,57 @@
1
+ // Plus permits six simultaneously dispatched workers. The durable owner queue
2
+ // must retain a completion from every one of them while the owner is busy.
3
+ export const WORKER_COMPLETION_CAP = 6
4
+
5
+ const clean = (value, cap = 160) => String(value || '').replace(/\s+/g, ' ').trim().slice(0, cap)
6
+
7
+ export function workerCompletionRecord({
8
+ workerId,
9
+ workerName,
10
+ turnRev,
11
+ subtype,
12
+ } = {}) {
13
+ const id = clean(workerId, 80)
14
+ const rev = Math.max(0, Number(turnRev) || 0)
15
+ if (!id || !rev) return null
16
+ return {
17
+ workerId: id,
18
+ workerRef: id.slice(0, 8),
19
+ workerName: clean(workerName, 80) || null,
20
+ turnRev: rev,
21
+ outcome: subtype && subtype !== 'success' ? clean(subtype, 40) : 'completed',
22
+ }
23
+ }
24
+
25
+ export function enqueueWorkerCompletion(pending, completion, cap = WORKER_COMPLETION_CAP) {
26
+ const queue = (Array.isArray(pending) ? pending : [])
27
+ .filter((item) => item?.workerId && Number(item?.turnRev) > 0)
28
+ if (!completion?.workerId || Number(completion?.turnRev) <= 0) return queue.slice(-cap)
29
+ const key = `${completion.workerId}:${completion.turnRev}`
30
+ const deduped = queue.filter((item) => `${item.workerId}:${item.turnRev}` !== key)
31
+ return [...deduped, completion].slice(-cap)
32
+ }
33
+
34
+ export function acknowledgeWorkerCompletions(pending, inFlight) {
35
+ const acknowledged = new Set((Array.isArray(inFlight) ? inFlight : [])
36
+ .filter((item) => item?.workerId && Number(item?.turnRev) > 0)
37
+ .map((item) => `${item.workerId}:${item.turnRev}`))
38
+ return (Array.isArray(pending) ? pending : [])
39
+ .filter((item) => item?.workerId && Number(item?.turnRev) > 0)
40
+ .filter((item) => !acknowledged.has(`${item.workerId}:${item.turnRev}`))
41
+ }
42
+
43
+ export function workerCompletionPrompt(pending) {
44
+ const queue = (Array.isArray(pending) ? pending : [])
45
+ .filter((item) => item?.workerId && Number(item?.turnRev) > 0)
46
+ .slice(-WORKER_COMPLETION_CAP)
47
+ if (!queue.length) return ''
48
+ const lanes = queue.map((item) => {
49
+ const label = item.workerName ? `${item.workerName} (${item.workerRef || String(item.workerId).slice(0, 8)})` : (item.workerRef || String(item.workerId).slice(0, 8))
50
+ return `- ${label}: ${item.outcome || 'completed'}`
51
+ }).join('\n')
52
+ return `[ThinkPool worker completion signal]
53
+ Your owned worker lane${queue.length === 1 ? ' has finished and is' : 's have finished and are'} idle:
54
+ ${lanes}
55
+
56
+ Continue the current task now. Call read_terminal exactly once for each finished worker, verify and integrate its result, then call close_terminal for that worker. Do not wait for a person to prompt you.`
57
+ }