thinkpool-pair 0.7.338 → 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
@@ -58,6 +58,7 @@ import { startStructuredSession } from './runtime-session.mjs'
58
58
  import { fallbackTerminalName } from './terminal-name.mjs'
59
59
  import { defaultStructuredMode, normalizeStructuredEffort, shouldDeferStructuredRuntime, structuredModeForSlice, structuredModeLocked, structuredModesForLane, structuredRuntimeForCommand, structuredRuntimeMetadata, structuredRuntimeSupportsMode } from './runtime-registry.mjs'
60
60
  import { commandCatalogForRuntime, commandHelpLine, reconcileCommandCatalog } from './command-catalog.mjs'
61
+ import { gitDiffReport } from './git-diff-report.mjs'
61
62
  import { probeHermesRuntime } from './hermes-probe.mjs'
62
63
  import { hermesRequiredMcpTools, hermesRoleFor } from './hermes-policy.mjs'
63
64
  import { canonicalRoomFilePath, waitForNativeImages } from './codex-images.mjs'
@@ -118,7 +119,7 @@ const flowRedispatch = new Map()
118
119
  // wave BEFORE overrun. Lives bridge-side because waves dispatch across separate
119
120
  // broadcasts; without persistent state the cap can never bite.
120
121
  const flowBudgets = new Map()
121
- 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'
122
123
  import { createStandalonePairResponder, standalonePairIdentity } from './direct-pair-room.mjs'
123
124
  import { dispatchPendingRecap, recoverInterruptedTurn, recoverMissingResumeOnce, sendInterruptedContinue, shouldAttemptNativeResume, supersedeInterruptedResume } from './interrupted-resume.mjs'
124
125
  import { supersedeDispatchLease } from './dispatch-lease.mjs'
@@ -129,6 +130,7 @@ import { createLatestReplayPump, requestedReplayIds } from './replay-transport.m
129
130
  import { classifyCodeEvent, unknownCodeEventNotice } from './code-event-contract.mjs'
130
131
  import { fetchPairControlDeliveryAuthority } from './pair-control-authority.mjs'
131
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'
132
134
  import { planMeterLine } from './plan-meters.mjs'
133
135
  import { priceForModel } from './model-prices.mjs'
134
136
  import { loadHermesModelCache, saveHermesModelCache } from './hermes-model-cache.mjs'
@@ -1109,6 +1111,42 @@ function schedulePendingSideContexts(entry) {
1109
1111
  entry._sideContextTimer.unref?.()
1110
1112
  }
1111
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
+
1112
1150
  function stampStructuredTurn(entry, event) {
1113
1151
  if (event && event.turnRev == null && Number(entry?._turnRev) > 0) event.turnRev = entry._turnRev
1114
1152
  return event
@@ -2138,7 +2176,7 @@ function worktreeSnapshot(cwd) {
2138
2176
  // relay STRUCTURED events. onEvent → broadcast `code-event` + print locally +
2139
2177
  // persist to the host file; tool calls round-trip through the perm card; the
2140
2178
  // rolling log replays to joiners and survives bridge restarts (session-store).
2141
- 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 }) {
2142
2180
  if (sessions.has(id)) return
2143
2181
  runtime = structuredRuntimeMetadata(runtime) ? runtime : 'claude'
2144
2182
  mode = structuredModeForSlice(runtime, { mode, sliceType, flowRole })
@@ -2191,7 +2229,7 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
2191
2229
  : (!provider || provider === 'anthropic')
2192
2230
  ? (laneModel || null)
2193
2231
  : (laneModel || providerNameMap()[provider] || provider),
2194
- 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,
2195
2233
  sliceType: sliceType === 'review' ? 'review' : null,
2196
2234
  flowRole: flowRole || (flowSessionId ? (flowTaskKey ? ((flowReviewTargets?.length || reviewSliceRoots?.length) ? 'reviewer' : 'builder') : 'conductor') : null),
2197
2235
  flowReviewTarget: flowReviewTarget || null,
@@ -2209,6 +2247,12 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
2209
2247
  entry._settledTurnRev = Number(restoredBoundary?.turnRev) || null
2210
2248
  entry._turnSettledAt = Number(restoredBoundary?.ts) || null
2211
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
+ }
2212
2256
  // Slice 3 — a permission card left unanswered past the grace window pushes
2213
2257
  // "<lane> — needs you: <what>"; answering it anywhere retracts the banner
2214
2258
  // everywhere. Worker/flow lanes are excluded at arm() time (isUserFacingLane).
@@ -2457,7 +2501,7 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
2457
2501
  // restart. Without this, sessionData omitted it → on restart the resumed session
2458
2502
  // re-launched on the host default (Opus) regardless of the last switch, and the
2459
2503
  // switch looked like it "never changed the model" (Max 2026-07-02). Restored below.
2460
- 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 })
2461
2505
  const persist = () => saveSession(room, id, sessionData())
2462
2506
  // Synchronous flush of this session's record. Used on open (so a brand-new session
2463
2507
  // has a file under its id BEFORE its first event — surviving a restart inside the
@@ -3268,7 +3312,7 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
3268
3312
  openStructured({
3269
3313
  id, runtime: entry.runtime, model: entry.model || model, effort: entry.effort,
3270
3314
  provider: entry.provider, log: entry.log, commands: entry.commands, mode: entry.mode,
3271
- 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,
3272
3316
  flowTaskKey: entry.flowTaskKey, flowRole: entry.flowRole, flowReviewTarget: entry.flowReviewTarget,
3273
3317
  flowReviewTargets: entry.flowReviewTargets, flowReviewSnapshots: entry.flowReviewSnapshots, flowReviewRound: entry.flowReviewRound,
3274
3318
  dispatchBaseSha: entry.dispatchBaseSha, revertTarget: entry.revertTarget, cwd: entry.cwd,
@@ -3490,6 +3534,14 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
3490
3534
  // _turnStart is cleared unconditionally so a stray post-settle event can't
3491
3535
  // re-notify off a stale stamp.
3492
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
+ }
3493
3545
  const edit = designActive.get(id)
3494
3546
  if (edit) {
3495
3547
  const successful = evt.kind === 'result' && (!evt.subtype || evt.subtype === 'success')
@@ -3533,9 +3585,31 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
3533
3585
  const stalePermissionIds = [...entry.pending.keys()]
3534
3586
  drainPending(entry)
3535
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
+ }
3536
3609
  // Bring-to-main never interrupts an active parent turn. If a handoff arrived
3537
3610
  // while this lane was working, start it on the first idle tick after settle.
3538
3611
  schedulePendingSideContexts(entry)
3612
+ schedulePendingWorkerCompletions(entry)
3539
3613
  const settledAt = Date.now()
3540
3614
  if (shouldRecordTurnDone({ entry, subtype: evt.subtype, startedAt, now: settledAt })) {
3541
3615
  persistAgentEvent({
@@ -3596,6 +3670,7 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
3596
3670
  // before the first debounced save still restores this id instead of dropping it (the
3597
3671
  // room would otherwise show a fresh empty terminal + strand the transcript).
3598
3672
  if (!entry.log.length) entry.flush()
3673
+ schedulePendingWorkerCompletions(entry)
3599
3674
  announce()
3600
3675
  if (!entry.log.length) process.stderr.write(`\n ◆ structured ${structuredRuntimeMetadata(runtime)?.label || 'agent'} session (${id.slice(0, 8)}) — driven from the room.\n`)
3601
3676
  return entry
@@ -3748,7 +3823,7 @@ function respawnStructured(id, provider) {
3748
3823
  // openStructured seed from the TARGET provider's configured model, which is the
3749
3824
  // only model this lane was ever asked for. A same-env model change never reaches
3750
3825
  // here — that path is an in-place setModel (see provider-switch).
3751
- 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
3752
3827
  // Context-carry (2026-07-08): a provider switch is not an SDK resume — the new backend
3753
3828
  // starts blank mid-conversation. Synthesize a plain-text recap from the VISIBLE log NOW
3754
3829
  // (before teardown) and hand it to the fresh session as its first turn so the agent
@@ -3767,7 +3842,7 @@ function respawnStructured(id, provider) {
3767
3842
  // sessionData() (provider included) synchronously on open, so a bridge restart
3768
3843
  // restores the lane on its CURRENT provider, not the original — and its next
3769
3844
  // announce carries the new provider badge (additive {id,name} projection).
3770
- 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 })
3771
3846
  return true
3772
3847
  }
3773
3848
 
@@ -4365,8 +4440,7 @@ channel
4365
4440
  if (/^\/diff\s*$/.test(text)) {
4366
4441
  try {
4367
4442
  const cwd = s.cwd || process.cwd()
4368
- const summary = execFileSync('git', ['-C', cwd, 'status', '--short'], { encoding: 'utf8', timeout: 3000, stdio: ['ignore', 'pipe', 'ignore'] }).trim()
4369
- ctlLine(summary ? `Working tree changes\n${summary.slice(0, 1600)}` : 'Working tree clean')
4443
+ ctlLine(gitDiffReport({ cwd }))
4370
4444
  } catch { ctlLine('Working-tree diff unavailable outside a readable Git checkout') }
4371
4445
  return
4372
4446
  }
@@ -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() {
@@ -19,7 +19,7 @@ export const CODE_ROOM_COMMANDS = Object.freeze([
19
19
  command('/status', 'runtime, model, permissions, and busy state', 'control'),
20
20
  command('/usage', 'session usage and provider limits', 'control'),
21
21
  command('/context', 'current context-window usage', 'control'),
22
- command('/diff', 'working-tree change summary', 'control'),
22
+ command('/diff', 'Git changes and branch status', 'control'),
23
23
  command('/compact', 'compact context', 'runtime'),
24
24
  command('/clear', 'clear context · confirms', 'clear'),
25
25
  command('/model', 'pick model — opens selector', 'model'),
@@ -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)
@@ -0,0 +1,118 @@
1
+ import { execFileSync } from 'node:child_process'
2
+
3
+ const DEFAULT_MAX_FILES = 24
4
+ const DEFAULT_MAX_COMMITS = 5
5
+ const DEFAULT_MAX_CHARS = 1800
6
+
7
+ const plural = (count, word) => `${count} ${word}${count === 1 ? '' : 's'}`
8
+
9
+ function defaultRunGit(args, cwd) {
10
+ return execFileSync('git', args, {
11
+ cwd,
12
+ encoding: 'utf8',
13
+ timeout: 3000,
14
+ stdio: ['ignore', 'pipe', 'pipe'],
15
+ })
16
+ }
17
+
18
+ function cleanLine(value, max = 240) {
19
+ return String(value || '')
20
+ .replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g, '')
21
+ .replace(/\s+/g, ' ')
22
+ .trim()
23
+ .slice(0, max)
24
+ }
25
+
26
+ function gitText(runGit, cwd, args) {
27
+ return String(runGit(args, cwd) || '').trim()
28
+ }
29
+
30
+ function tryGit(runGit, cwd, args) {
31
+ try { return gitText(runGit, cwd, args) } catch { return '' }
32
+ }
33
+
34
+ function resolveComparisonRef(runGit, cwd) {
35
+ const remoteHead = tryGit(runGit, cwd, ['symbolic-ref', '--quiet', '--short', 'refs/remotes/origin/HEAD'])
36
+ if (remoteHead && tryGit(runGit, cwd, ['rev-parse', '--verify', '--quiet', remoteHead])) return remoteHead
37
+ for (const candidate of ['origin/main', 'origin/master', 'main', 'master']) {
38
+ if (tryGit(runGit, cwd, ['rev-parse', '--verify', '--quiet', candidate])) return candidate
39
+ }
40
+ return ''
41
+ }
42
+
43
+ function boundedReport(lines, maxChars) {
44
+ const kept = []
45
+ let length = 0
46
+ for (const raw of lines) {
47
+ const line = cleanLine(raw)
48
+ if (!line) continue
49
+ const nextLength = length + (kept.length ? 1 : 0) + line.length
50
+ if (nextLength > maxChars) {
51
+ const suffix = '… output shortened'
52
+ if (length + (kept.length ? 1 : 0) + suffix.length <= maxChars) kept.push(suffix)
53
+ break
54
+ }
55
+ kept.push(line)
56
+ length = nextLength
57
+ }
58
+ return kept.join('\n')
59
+ }
60
+
61
+ export function gitDiffReport({
62
+ cwd = process.cwd(),
63
+ runGit = defaultRunGit,
64
+ maxFiles = DEFAULT_MAX_FILES,
65
+ maxCommits = DEFAULT_MAX_COMMITS,
66
+ maxChars = DEFAULT_MAX_CHARS,
67
+ } = {}) {
68
+ gitText(runGit, cwd, ['rev-parse', '--show-toplevel'])
69
+
70
+ const status = tryGit(runGit, cwd, ['status', '--porcelain=v1', '--untracked-files=normal'])
71
+ const statusLines = status ? status.split(/\r?\n/).filter(Boolean) : []
72
+ const branch = tryGit(runGit, cwd, ['symbolic-ref', '--quiet', '--short', 'HEAD']) || 'detached HEAD'
73
+ const head = tryGit(runGit, cwd, ['rev-parse', '--short=8', 'HEAD']) || 'unknown'
74
+ const subject = cleanLine(tryGit(runGit, cwd, ['log', '-1', '--pretty=%s']), 160)
75
+ const comparisonRef = resolveComparisonRef(runGit, cwd)
76
+ const lines = []
77
+
78
+ if (statusLines.length) {
79
+ lines.push(`${plural(statusLines.length, 'uncommitted file')}`)
80
+ for (const line of statusLines.slice(0, Math.max(0, maxFiles))) lines.push(line)
81
+ if (statusLines.length > maxFiles) lines.push(`… ${plural(statusLines.length - maxFiles, 'more file')}`)
82
+ } else {
83
+ lines.push('No uncommitted changes')
84
+ }
85
+
86
+ lines.push(`Branch · ${branch} · ${head}`)
87
+
88
+ if (!comparisonRef) {
89
+ if (subject) lines.push(`Latest commit · ${head} — ${subject}`)
90
+ return boundedReport(lines, maxChars)
91
+ }
92
+
93
+ const counts = tryGit(runGit, cwd, ['rev-list', '--left-right', '--count', `${comparisonRef}...HEAD`])
94
+ .split(/\s+/)
95
+ .map((value) => Number(value))
96
+ const behind = Number.isFinite(counts[0]) ? counts[0] : 0
97
+ const ahead = Number.isFinite(counts[1]) ? counts[1] : 0
98
+
99
+ if (ahead > 0) {
100
+ lines.push(`${plural(ahead, 'commit')} ahead of ${comparisonRef}${behind ? ` · ${plural(behind, 'commit')} behind` : ''}`)
101
+ const commitLines = tryGit(runGit, cwd, ['log', `--max-count=${Math.max(0, maxCommits)}`, '--pretty=%h %s', `${comparisonRef}..HEAD`])
102
+ .split(/\r?\n/)
103
+ .filter(Boolean)
104
+ if (commitLines.length) {
105
+ lines.push(`Commits not in ${comparisonRef}`)
106
+ lines.push(...commitLines)
107
+ if (ahead > commitLines.length) lines.push(`… ${plural(ahead - commitLines.length, 'more commit')}`)
108
+ }
109
+ } else if (behind > 0) {
110
+ if (subject) lines.push(`Latest commit · ${head} — ${subject} · already in ${comparisonRef}`)
111
+ lines.push(`${plural(behind, 'commit')} behind ${comparisonRef}`)
112
+ } else {
113
+ if (subject) lines.push(`Latest commit · ${head} — ${subject}`)
114
+ lines.push(`Up to date with ${comparisonRef}`)
115
+ }
116
+
117
+ return boundedReport(lines, maxChars)
118
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thinkpool-pair",
3
- "version": "0.7.338",
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": {
@@ -47,6 +47,7 @@
47
47
  "hermes-delegation-guard.mjs",
48
48
  "runtime-registry.mjs",
49
49
  "command-catalog.mjs",
50
+ "git-diff-report.mjs",
50
51
  "runtime-session.mjs",
51
52
  "turn-stall.mjs",
52
53
  "update-gate.mjs",
@@ -62,6 +63,7 @@
62
63
  "transcript-sanitize.mjs",
63
64
  "session-store.mjs",
64
65
  "side-lane.mjs",
66
+ "worker-completion.mjs",
65
67
  "cross-terminal.mjs",
66
68
  "pair-bus.mjs",
67
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
+ }