thinkpool-pair 0.7.339 → 0.7.341
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bridge.mjs +98 -9
- package/codex-session.mjs +9 -2
- package/command-catalog.mjs +14 -3
- package/cross-terminal.mjs +12 -0
- package/hermes-acp-bootstrap.py +7 -3
- package/package.json +2 -1
- package/worker-completion.mjs +57 -0
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
|
-
|
|
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
|
@@ -14,7 +14,6 @@ const command = (name, description, route, inputHint, runtimes = ['claude', 'cod
|
|
|
14
14
|
|
|
15
15
|
export const CODE_ROOM_COMMANDS = Object.freeze([
|
|
16
16
|
command('/side', 'investigate beside this terminal', 'side', 'task'),
|
|
17
|
-
command('/flow', 'open an explicit visible Cascade', 'flow', 'task [--mode guide|steer|autopilot]'),
|
|
18
17
|
command('/help', 'list commands available in this lane', 'control'),
|
|
19
18
|
command('/status', 'runtime, model, permissions, and busy state', 'control'),
|
|
20
19
|
command('/usage', 'session usage and provider limits', 'control'),
|
|
@@ -28,10 +27,21 @@ export const CODE_ROOM_COMMANDS = Object.freeze([
|
|
|
28
27
|
command('/queue', 'run a prompt after the active turn', 'queue', 'prompt'),
|
|
29
28
|
command('/steer', 'guide the active turn', 'steer', 'prompt', ['codex', 'hermes']),
|
|
30
29
|
command('/credits', 'provider credit balance', 'credits', null, ['codex', 'hermes']),
|
|
31
|
-
command('/reasoning', 'Hermes reasoning effort', 'runtime', 'low | medium | high | xhigh | max | none | reset', ['hermes']),
|
|
32
30
|
command('/review', 'review uncommitted changes', 'runtime', null, ['codex']),
|
|
33
31
|
])
|
|
34
32
|
|
|
33
|
+
// Native catalogs are runtime evidence, never portable session metadata.
|
|
34
|
+
// Claude's adapter default-denies raw SDK commands and passes only proven room
|
|
35
|
+
// controls plus installed Skills. Hermes' isolated ACP bootstrap owns its safe
|
|
36
|
+
// native catalog. Codex App Server publishes no native slash catalog, so a
|
|
37
|
+
// restored/foreign list must not manufacture one. Compatibility aliases stay
|
|
38
|
+
// callable at their runtime boundary without remaining discoverable.
|
|
39
|
+
const NATIVE_COMMAND_POLICY = Object.freeze({
|
|
40
|
+
claude: Object.freeze({ accept: true, hidden: Object.freeze(new Set(['/flow'])) }),
|
|
41
|
+
codex: Object.freeze({ accept: false, hidden: Object.freeze(new Set(['/flow'])) }),
|
|
42
|
+
hermes: Object.freeze({ accept: true, hidden: Object.freeze(new Set(['/flow', '/reasoning'])) }),
|
|
43
|
+
})
|
|
44
|
+
|
|
35
45
|
const cleanRuntime = (runtime) => runtime === 'thinkpool' ? 'hermes' : runtime
|
|
36
46
|
const cleanName = (value) => {
|
|
37
47
|
const raw = typeof value === 'string' ? value : value?.name
|
|
@@ -54,6 +64,7 @@ const normalizeNative = (value) => {
|
|
|
54
64
|
// retained only after the runtime-specific adapter has already allowlisted them.
|
|
55
65
|
export function commandCatalogForRuntime(runtime, nativeCommands = []) {
|
|
56
66
|
const id = cleanRuntime(runtime)
|
|
67
|
+
const nativePolicy = NATIVE_COMMAND_POLICY[id] || NATIVE_COMMAND_POLICY.codex
|
|
57
68
|
const byName = new Map()
|
|
58
69
|
for (const item of CODE_ROOM_COMMANDS) {
|
|
59
70
|
if (!item.runtimes.includes(id)) continue
|
|
@@ -66,7 +77,7 @@ export function commandCatalogForRuntime(runtime, nativeCommands = []) {
|
|
|
66
77
|
}
|
|
67
78
|
for (const raw of (Array.isArray(nativeCommands) ? nativeCommands : [])) {
|
|
68
79
|
const item = normalizeNative(raw)
|
|
69
|
-
if (!item) continue
|
|
80
|
+
if (!item || !nativePolicy.accept || nativePolicy.hidden.has(item.name)) continue
|
|
70
81
|
const shared = byName.get(item.name)
|
|
71
82
|
const hermesNativeControl = id === 'hermes' && ['/help', '/status', '/context', '/credits'].includes(item.name)
|
|
72
83
|
byName.set(item.name, shared
|
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)
|
package/hermes-acp-bootstrap.py
CHANGED
|
@@ -168,10 +168,10 @@ acp_adapter.server.HermesACPAgent._build_model_state = nous_only_model_state
|
|
|
168
168
|
# account tokens, or lifecycle/admin controls enter the room surface.
|
|
169
169
|
_TP_REASONING_CONFIG_ID = "thinkpool_reasoning_effort"
|
|
170
170
|
_TP_REASONING_LEVELS = frozenset({"none", "low", "medium", "high", "xhigh", "max"})
|
|
171
|
+
_TP_HIDDEN_COMMANDS = frozenset({"reasoning"})
|
|
171
172
|
_TP_COMMANDS = (
|
|
172
173
|
{"name": "credits", "description": "Show safe Nous credit balance and top-up handoff"},
|
|
173
174
|
{"name": "status", "description": "Show session, model, context, version, and reasoning status"},
|
|
174
|
-
{"name": "reasoning", "description": "Set session-only reasoning effort", "input_hint": "low, medium, high, xhigh, max, none, or reset"},
|
|
175
175
|
)
|
|
176
176
|
|
|
177
177
|
_native_compact = getattr(acp_adapter.server.HermesACPAgent, "_cmd_compact", None)
|
|
@@ -283,8 +283,12 @@ _available_commands = getattr(acp_adapter.server.HermesACPAgent, "_available_com
|
|
|
283
283
|
@classmethod
|
|
284
284
|
def thinkpool_available_commands(cls):
|
|
285
285
|
# Keep the upstream catalog canonical, then append exactly our process-local
|
|
286
|
-
# commands.
|
|
287
|
-
|
|
286
|
+
# commands. Compatibility aliases remain executable without appearing in
|
|
287
|
+
# ACP updates or /help; the room-level /effort control is canonical.
|
|
288
|
+
base = [
|
|
289
|
+
item for item in (list(_available_commands.__func__(cls)) if _available_commands else [])
|
|
290
|
+
if str(getattr(item, "name", item.get("name", "") if isinstance(item, dict) else "") or "").lstrip("/").lower() not in _TP_HIDDEN_COMMANDS
|
|
291
|
+
]
|
|
288
292
|
try:
|
|
289
293
|
from acp.schema import AvailableCommand, UnstructuredCommandInput
|
|
290
294
|
known = {getattr(item, "name", "") for item in base}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "thinkpool-pair",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.341",
|
|
4
4
|
"description": "Connect Claude Code, Codex, or Hermes on your computer to a Thinkpool Code room.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -63,6 +63,7 @@
|
|
|
63
63
|
"transcript-sanitize.mjs",
|
|
64
64
|
"session-store.mjs",
|
|
65
65
|
"side-lane.mjs",
|
|
66
|
+
"worker-completion.mjs",
|
|
66
67
|
"cross-terminal.mjs",
|
|
67
68
|
"pair-bus.mjs",
|
|
68
69
|
"direct-pair-room.mjs",
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// Plus permits six simultaneously dispatched workers. The durable owner queue
|
|
2
|
+
// must retain a completion from every one of them while the owner is busy.
|
|
3
|
+
export const WORKER_COMPLETION_CAP = 6
|
|
4
|
+
|
|
5
|
+
const clean = (value, cap = 160) => String(value || '').replace(/\s+/g, ' ').trim().slice(0, cap)
|
|
6
|
+
|
|
7
|
+
export function workerCompletionRecord({
|
|
8
|
+
workerId,
|
|
9
|
+
workerName,
|
|
10
|
+
turnRev,
|
|
11
|
+
subtype,
|
|
12
|
+
} = {}) {
|
|
13
|
+
const id = clean(workerId, 80)
|
|
14
|
+
const rev = Math.max(0, Number(turnRev) || 0)
|
|
15
|
+
if (!id || !rev) return null
|
|
16
|
+
return {
|
|
17
|
+
workerId: id,
|
|
18
|
+
workerRef: id.slice(0, 8),
|
|
19
|
+
workerName: clean(workerName, 80) || null,
|
|
20
|
+
turnRev: rev,
|
|
21
|
+
outcome: subtype && subtype !== 'success' ? clean(subtype, 40) : 'completed',
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function enqueueWorkerCompletion(pending, completion, cap = WORKER_COMPLETION_CAP) {
|
|
26
|
+
const queue = (Array.isArray(pending) ? pending : [])
|
|
27
|
+
.filter((item) => item?.workerId && Number(item?.turnRev) > 0)
|
|
28
|
+
if (!completion?.workerId || Number(completion?.turnRev) <= 0) return queue.slice(-cap)
|
|
29
|
+
const key = `${completion.workerId}:${completion.turnRev}`
|
|
30
|
+
const deduped = queue.filter((item) => `${item.workerId}:${item.turnRev}` !== key)
|
|
31
|
+
return [...deduped, completion].slice(-cap)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function acknowledgeWorkerCompletions(pending, inFlight) {
|
|
35
|
+
const acknowledged = new Set((Array.isArray(inFlight) ? inFlight : [])
|
|
36
|
+
.filter((item) => item?.workerId && Number(item?.turnRev) > 0)
|
|
37
|
+
.map((item) => `${item.workerId}:${item.turnRev}`))
|
|
38
|
+
return (Array.isArray(pending) ? pending : [])
|
|
39
|
+
.filter((item) => item?.workerId && Number(item?.turnRev) > 0)
|
|
40
|
+
.filter((item) => !acknowledged.has(`${item.workerId}:${item.turnRev}`))
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function workerCompletionPrompt(pending) {
|
|
44
|
+
const queue = (Array.isArray(pending) ? pending : [])
|
|
45
|
+
.filter((item) => item?.workerId && Number(item?.turnRev) > 0)
|
|
46
|
+
.slice(-WORKER_COMPLETION_CAP)
|
|
47
|
+
if (!queue.length) return ''
|
|
48
|
+
const lanes = queue.map((item) => {
|
|
49
|
+
const label = item.workerName ? `${item.workerName} (${item.workerRef || String(item.workerId).slice(0, 8)})` : (item.workerRef || String(item.workerId).slice(0, 8))
|
|
50
|
+
return `- ${label}: ${item.outcome || 'completed'}`
|
|
51
|
+
}).join('\n')
|
|
52
|
+
return `[ThinkPool worker completion signal]
|
|
53
|
+
Your owned worker lane${queue.length === 1 ? ' has finished and is' : 's have finished and are'} idle:
|
|
54
|
+
${lanes}
|
|
55
|
+
|
|
56
|
+
Continue the current task now. Call read_terminal exactly once for each finished worker, verify and integrate its result, then call close_terminal for that worker. Do not wait for a person to prompt you.`
|
|
57
|
+
}
|