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 +100 -11
- package/codex-session.mjs +9 -2
- package/command-catalog.mjs +1 -1
- package/cross-terminal.mjs +12 -0
- package/git-diff-report.mjs +118 -0
- package/package.json +3 -1
- package/worker-completion.mjs +57 -0
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
|
-
|
|
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
|
-
|
|
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
|
|
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() {
|
package/command-catalog.mjs
CHANGED
|
@@ -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', '
|
|
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'),
|
package/cross-terminal.mjs
CHANGED
|
@@ -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.
|
|
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
|
+
}
|