thinkpool-pair 0.7.339 → 0.7.340

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() {
@@ -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)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thinkpool-pair",
3
- "version": "0.7.339",
3
+ "version": "0.7.340",
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
+ }