thinkpool-pair 0.7.236 → 0.7.238
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 +34 -8
- package/event-id.mjs +1 -1
- package/hermes-session.mjs +10 -5
- package/package.json +1 -1
- package/runtime-registry.mjs +12 -3
package/bridge.mjs
CHANGED
|
@@ -51,7 +51,7 @@ import { createSdkMcpServer, tool } from '@anthropic-ai/claude-agent-sdk'
|
|
|
51
51
|
import { z } from 'zod'
|
|
52
52
|
import { readCodexDefaultModel, readCodexModels, codexConfigForMode, codexThreadCanResume } from './codex-session.mjs'
|
|
53
53
|
import { startStructuredSession } from './runtime-session.mjs'
|
|
54
|
-
import { defaultStructuredMode, structuredRuntimeForCommand, structuredRuntimeMetadata, structuredRuntimeSupportsMode } from './runtime-registry.mjs'
|
|
54
|
+
import { defaultStructuredMode, shouldDeferStructuredRuntime, structuredRuntimeForCommand, structuredRuntimeMetadata, structuredRuntimeSupportsMode } from './runtime-registry.mjs'
|
|
55
55
|
import { probeHermesRuntime } from './hermes-probe.mjs'
|
|
56
56
|
import { canonicalRoomFilePath, waitForNativeImages } from './codex-images.mjs'
|
|
57
57
|
import { createManagedLaneWorktree, removeManagedLaneWorktree } from './lane-worktree.mjs'
|
|
@@ -1694,7 +1694,7 @@ function worktreeSnapshot(cwd) {
|
|
|
1694
1694
|
// relay STRUCTURED events. onEvent → broadcast `code-event` + print locally +
|
|
1695
1695
|
// persist to the host file; tool calls round-trip through the perm card; the
|
|
1696
1696
|
// rolling log replays to joiners and survives bridge restarts (session-store).
|
|
1697
|
-
function openStructured({ id, runtime = 'claude', model, effort, resume, log, commands, mode, spawnedBy, spawnDepth, cascadeRole, hop, sideParent, sideTask, pendingSideContexts, rolePrompt, flowSessionId, flowTaskKey, flowRole, flowReviewTarget, flowReviewTargets, flowReviewRound, dispatchBaseSha, revertTarget, cwd, managedWorktree, reviewSliceRoots, openedAt, defer, provider, carryRecap, lastUsage }) {
|
|
1697
|
+
function openStructured({ id, runtime = 'claude', model, models, effort, resume, log, commands, mode, spawnedBy, spawnDepth, cascadeRole, hop, sideParent, sideTask, pendingSideContexts, rolePrompt, flowSessionId, flowTaskKey, flowRole, flowReviewTarget, flowReviewTargets, flowReviewRound, dispatchBaseSha, revertTarget, cwd, managedWorktree, reviewSliceRoots, openedAt, defer, provider, carryRecap, lastUsage }) {
|
|
1698
1698
|
if (sessions.has(id)) return
|
|
1699
1699
|
runtime = structuredRuntimeMetadata(runtime) ? runtime : 'claude'
|
|
1700
1700
|
// No explicit mode → a sensible default per runtime (see defaultModeForRuntime):
|
|
@@ -1733,7 +1733,7 @@ function openStructured({ id, runtime = 'claude', model, effort, resume, log, co
|
|
|
1733
1733
|
? spawnDepth
|
|
1734
1734
|
: (sideParent || (spawnedBy && !String(spawnedBy).startsWith('flow:')) ? 1 : 0)
|
|
1735
1735
|
const initialHop = Number.isInteger(hop) && hop >= 0 ? hop : structuralDepth
|
|
1736
|
-
const entry = { cmd: runtime, runtime, kind: 'structured', log: Array.isArray(log) ? log.slice(-STRUCTURED_LOG_MAX) : [], pending: new Map(), session: null, recovered: false, commands: Array.isArray(commands) ? commands : undefined, mode, effort, models: runtime === 'codex' ? codexModels : undefined,
|
|
1736
|
+
const entry = { cmd: runtime, runtime, kind: 'structured', log: Array.isArray(log) ? log.slice(-STRUCTURED_LOG_MAX) : [], pending: new Map(), session: null, recovered: false, commands: Array.isArray(commands) ? commands : undefined, mode, effort, models: runtime === 'codex' ? codexModels : runtime === 'hermes' && Array.isArray(models) ? models : undefined,
|
|
1737
1737
|
// model: truthful active-model label — now the SAME `laneModel` the SDK is given, so the
|
|
1738
1738
|
// chip cannot disagree with the wire. When this lane runs on a custom (non-anthropic)
|
|
1739
1739
|
// registered provider the SDK id is impersonated (see the onEvent guard below), so
|
|
@@ -1996,7 +1996,7 @@ function openStructured({ id, runtime = 'claude', model, effort, resume, log, co
|
|
|
1996
1996
|
// restart. Without this, sessionData omitted it → on restart the resumed session
|
|
1997
1997
|
// re-launched on the host default (Opus) regardless of the last switch, and the
|
|
1998
1998
|
// switch looked like it "never changed the model" (Max 2026-07-02). Restored below.
|
|
1999
|
-
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, 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, flowSessionId: entry.flowSessionId, flowTaskKey: entry.flowTaskKey, flowRole: entry.flowRole || null, flowReviewTargets: entry.flowReviewTargets || [], 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 })
|
|
1999
|
+
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, flowSessionId: entry.flowSessionId, flowTaskKey: entry.flowTaskKey, flowRole: entry.flowRole || null, flowReviewTargets: entry.flowReviewTargets || [], 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 })
|
|
2000
2000
|
const persist = () => saveSession(room, id, sessionData())
|
|
2001
2001
|
// Synchronous flush of this session's record. Used on open (so a brand-new session
|
|
2002
2002
|
// has a file under its id BEFORE its first event — surviving a restart inside the
|
|
@@ -2486,7 +2486,7 @@ function openStructured({ id, runtime = 'claude', model, effort, resume, log, co
|
|
|
2486
2486
|
},
|
|
2487
2487
|
// defer: a restored-idle terminal cold-boots its query only on first use — restore is
|
|
2488
2488
|
// then instant for display, and only used terminals pay the ~50s MCP boot (Max, 2026-07-02).
|
|
2489
|
-
lazy:
|
|
2489
|
+
lazy: shouldDeferStructuredRuntime({ runtime, defer, cwd, flowSessionId, flowTaskKey, models: entry.models }),
|
|
2490
2490
|
reviewGate,
|
|
2491
2491
|
// A Flow CONDUCTOR (flowSessionId set, no flowTaskKey) must DECOMPOSE, not explore via
|
|
2492
2492
|
// a fan-out of Task/Explore subagents, and must not build. Hard-block Task/Bash/Write for
|
|
@@ -2616,12 +2616,29 @@ function openStructured({ id, runtime = 'claude', model, effort, resume, log, co
|
|
|
2616
2616
|
// run on different registered providers. (bridge-truth slice.)
|
|
2617
2617
|
{ const onBuiltin = entry.runtime !== 'hermes' && (!entry.provider || entry.provider === 'anthropic')
|
|
2618
2618
|
const m = (entry.runtime === 'hermes' || onBuiltin) ? (evt.model || evt.ctx?.model) : null
|
|
2619
|
-
if (m && m !== entry.model) { entry.model = m; announce() }
|
|
2619
|
+
if (m && m !== entry.model) { entry.model = m; announce(); if (evt.kind === 'model-switch') persist() }
|
|
2620
2620
|
// A custom Anthropic-compatible endpoint may expose its own catalog through the
|
|
2621
2621
|
// same SDK method. Never publish that as the ROOM's Claude catalog: its models
|
|
2622
2622
|
// already render in the provider group and otherwise leak under ANTHROPIC.
|
|
2623
2623
|
if (entry.runtime === 'hermes' && evt.kind === 'models' && Array.isArray(evt.models) && evt.models.length) { entry.models = evt.models; announce() }
|
|
2624
2624
|
else if (onBuiltin && evt.kind === 'models' && Array.isArray(evt.models) && evt.models.length) { roomModels = evt.models; announce() } }
|
|
2625
|
+
// Hermes model changes are asynchronous ACP requests. Keep the previous model
|
|
2626
|
+
// authoritative until Hermes acknowledges session/set_model; then settle the
|
|
2627
|
+
// originating control line exactly once. A rejected model never poisons the
|
|
2628
|
+
// saved session or leaves a cross-device chip claiming the optimistic pick.
|
|
2629
|
+
if (evt.kind === 'model-switch') {
|
|
2630
|
+
const request = entry.pendingModelSwitch
|
|
2631
|
+
entry.pendingModelSwitch = null
|
|
2632
|
+
const requested = evt.requested || request?.model || 'requested model'
|
|
2633
|
+
const text = evt.status === 'success'
|
|
2634
|
+
? `switched model → ${evt.model || requested}`
|
|
2635
|
+
: `couldn’t switch to ${requested} — still using ${entry.model || 'the previous model'}`
|
|
2636
|
+
const control = { kind: 'control', text, by: request?.by || null, cid: request?.cid || undefined }
|
|
2637
|
+
pushLog(entry, control)
|
|
2638
|
+
bcast('code-event', { term: id, evt: control })
|
|
2639
|
+
entry.flush?.()
|
|
2640
|
+
announce()
|
|
2641
|
+
}
|
|
2625
2642
|
// Cache the last usage/ctx meter on the entry (persisted + replayed — see
|
|
2626
2643
|
// openStructured). Chrome events bypass pushLog/persist in emitTail below, so
|
|
2627
2644
|
// usage needs an explicit persist() here to survive a bridge restart.
|
|
@@ -2700,7 +2717,7 @@ function openStructured({ id, runtime = 'claude', model, effort, resume, log, co
|
|
|
2700
2717
|
// card) and is intentionally NOT chrome, so it persists + replays.
|
|
2701
2718
|
// 'suggestion' is Claude Code's predicted-next-prompt ghost text — ephemeral
|
|
2702
2719
|
// composer UI, broadcast to both clients but never logged/persisted/replayed.
|
|
2703
|
-
const chrome = evt.kind === 'mode' || evt.kind === 'usage' || evt.kind === 'clear' || evt.kind === 'compact' || evt.kind === 'stalled' || evt.kind === 'suggestion' || evt.kind === 'assistant_stream'
|
|
2720
|
+
const chrome = evt.kind === 'mode' || evt.kind === 'usage' || evt.kind === 'clear' || evt.kind === 'compact' || evt.kind === 'stalled' || evt.kind === 'suggestion' || evt.kind === 'assistant_stream' || evt.kind === 'model-switch'
|
|
2704
2721
|
// The compact turn settled with `entry.compacting` STILL TRUE — which, because the
|
|
2705
2722
|
// `compaction` branch above clears the flag the moment the milestone lands, means
|
|
2706
2723
|
// exactly one thing: NO compaction happened. Three ways to get here, all of which
|
|
@@ -3428,10 +3445,19 @@ channel
|
|
|
3428
3445
|
const mm = text.match(/^\/model\b\s*(\S+)?/)
|
|
3429
3446
|
if (mm) {
|
|
3430
3447
|
if (mm[1]) {
|
|
3448
|
+
if (s.runtime === 'hermes' && s.pendingModelSwitch) {
|
|
3449
|
+
ctlLine(`finish the current Hermes model switch before choosing another model`)
|
|
3450
|
+
return
|
|
3451
|
+
}
|
|
3431
3452
|
if (s.session.setModel(mm[1]) === false) {
|
|
3432
3453
|
ctlLine(`finish or stop the current ${structuredRuntimeMetadata(s.runtime)?.label || 'agent'} turn before switching models`)
|
|
3433
3454
|
return
|
|
3434
3455
|
}
|
|
3456
|
+
if (s.runtime === 'hermes') {
|
|
3457
|
+
s.pendingModelSwitch = { model: mm[1], by: payload.by, cid: payload.cid }
|
|
3458
|
+
bcast('code-event', { term: payload.term, evt: { kind: 'model-switch', status: 'pending', requested: mm[1], ts: Date.now() } })
|
|
3459
|
+
return
|
|
3460
|
+
}
|
|
3435
3461
|
// Reflect the switch in the announce IMMEDIATELY (before any turn). The SDK
|
|
3436
3462
|
// only echoes the resolved full id on the next turn, and meta/ctx model go
|
|
3437
3463
|
// stale until then, so without this the chip snapped back to the old model
|
|
@@ -3782,7 +3808,7 @@ channel
|
|
|
3782
3808
|
// FL-M6 — restore the flow context (id/role/cwd) so an in-flight flow survives a
|
|
3783
3809
|
// bridge restart: the conductor keeps its subagent-block + plan interception, and
|
|
3784
3810
|
// lanes keep their worktree cwd + the ability to mark done.
|
|
3785
|
-
openStructured({ id: rec.id, runtime: rec.runtime || 'claude', model: rec.model || undefined, 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, flowSessionId: rec.flowSessionId, flowTaskKey: rec.flowTaskKey, flowRole: rec.flowRole, flowReviewTarget: rec.flowReviewTarget, flowReviewTargets: rec.flowReviewTargets, 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,
|
|
3811
|
+
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, flowSessionId: rec.flowSessionId, flowTaskKey: rec.flowTaskKey, flowRole: rec.flowRole, flowReviewTarget: rec.flowReviewTarget, flowReviewTargets: rec.flowReviewTargets, 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,
|
|
3786
3812
|
// Lazy-boot restored terminals that were IDLE + not part of a flow: their transcript
|
|
3787
3813
|
// shows immediately; the query boots on first turn. Mid-turn + flow terminals boot now
|
|
3788
3814
|
// (mid-turn needs auto-resume; flow needs its lane live).
|
package/event-id.mjs
CHANGED
|
@@ -61,7 +61,7 @@ export function makeSeqCounter(startAfter = 0) {
|
|
|
61
61
|
// consumed a seq + set the durable archive floor while the client dropped them, leaving
|
|
62
62
|
// the client's oldest RENDERED seq above the floor → a phantom "load earlier history"
|
|
63
63
|
// pill on a fresh lane (models fires on every session init) that fetched nothing.
|
|
64
|
-
export const NO_SEQ_KINDS = new Set(['system', 'thinking_tokens', 'effort', 'models', 'queue-add', 'queue-cancel', 'propose-done', 'update-available'])
|
|
64
|
+
export const NO_SEQ_KINDS = new Set(['system', 'thinking_tokens', 'effort', 'models', 'model-switch', 'queue-add', 'queue-cancel', 'propose-done', 'update-available'])
|
|
65
65
|
export const seqable = (evt) => !!evt && typeof evt === 'object' && !NO_SEQ_KINDS.has(evt.kind)
|
|
66
66
|
|
|
67
67
|
// Highest numeric `seq` across a log of events (0 when none) — the floor a restored
|
package/hermes-session.mjs
CHANGED
|
@@ -66,6 +66,7 @@ export function startHermesSession({
|
|
|
66
66
|
const abortedTurns = new Set()
|
|
67
67
|
let retireClient = () => {}
|
|
68
68
|
let resuming = false
|
|
69
|
+
let modelSwitchPending = false
|
|
69
70
|
|
|
70
71
|
const emit = (event) => { try { onEvent?.(event) } catch { /* consumer isolation */ } }
|
|
71
72
|
|
|
@@ -277,12 +278,16 @@ export function startHermesSession({
|
|
|
277
278
|
void mcpHttp?.close().catch(() => {})
|
|
278
279
|
},
|
|
279
280
|
setModel(nextModel) {
|
|
280
|
-
if (!nextModel || turnActive) return false
|
|
281
|
-
|
|
282
|
-
|
|
281
|
+
if (!nextModel || turnActive || modelSwitchPending) return false
|
|
282
|
+
const requested = String(nextModel)
|
|
283
|
+
modelSwitchPending = true
|
|
284
|
+
void boot().then(() => client.request('session/set_model', { sessionId, modelId: requested })).then(() => {
|
|
285
|
+
if (ended) return
|
|
286
|
+
activeModel = requested
|
|
283
287
|
if (mapper) mapper.model = activeModel
|
|
284
|
-
emit({ kind: '
|
|
285
|
-
}).catch((error) => emit({ kind: '
|
|
288
|
+
emit({ kind: 'model-switch', status: 'success', model: activeModel, requested })
|
|
289
|
+
}).catch((error) => { if (!ended) emit({ kind: 'model-switch', status: 'failed', model: activeModel, requested, message: `Hermes model switch failed: ${error?.message || error}` }) })
|
|
290
|
+
.finally(() => { modelSwitchPending = false })
|
|
286
291
|
return true
|
|
287
292
|
},
|
|
288
293
|
setMode(nextMode) {
|
package/package.json
CHANGED
package/runtime-registry.mjs
CHANGED
|
@@ -3,17 +3,17 @@ import path from 'node:path'
|
|
|
3
3
|
const RUNTIMES = Object.freeze({
|
|
4
4
|
claude: Object.freeze({
|
|
5
5
|
id: 'claude', command: 'claude', label: 'Claude Code', protocol: 'agent-sdk',
|
|
6
|
-
structured: true, flow: true, canSteer: false, images: true, nativeModelCatalog: false, defaultMode: 'default',
|
|
6
|
+
structured: true, flow: true, canSteer: false, images: true, nativeModelCatalog: false, effortControl: true, defaultMode: 'default',
|
|
7
7
|
modes: Object.freeze(['default', 'acceptEdits', 'plan', 'bypassPermissions']),
|
|
8
8
|
}),
|
|
9
9
|
codex: Object.freeze({
|
|
10
10
|
id: 'codex', command: 'codex', label: 'Codex', protocol: 'codex-app-server',
|
|
11
|
-
structured: true, flow: true, canSteer: true, images: true, nativeModelCatalog: true, defaultMode: 'bypassPermissions',
|
|
11
|
+
structured: true, flow: true, canSteer: true, images: true, nativeModelCatalog: true, effortControl: true, defaultMode: 'bypassPermissions',
|
|
12
12
|
modes: Object.freeze(['default', 'acceptEdits', 'plan', 'bypassPermissions', 'review']),
|
|
13
13
|
}),
|
|
14
14
|
hermes: Object.freeze({
|
|
15
15
|
id: 'hermes', command: 'thinkpool', label: 'Hermes Agent', protocol: 'acp',
|
|
16
|
-
structured: true, flow: false, canSteer: true, images: true, nativeModelCatalog: true, defaultMode: 'default',
|
|
16
|
+
structured: true, flow: false, canSteer: true, images: true, nativeModelCatalog: true, catalogRequiresSession: true, effortControl: false, defaultMode: 'default',
|
|
17
17
|
modes: Object.freeze(['default', 'acceptEdits']),
|
|
18
18
|
beta: true,
|
|
19
19
|
}),
|
|
@@ -28,3 +28,12 @@ export const structuredRuntimeForCommand = (command) => {
|
|
|
28
28
|
export const defaultStructuredMode = (runtime) => structuredRuntimeMetadata(runtime)?.defaultMode || 'default'
|
|
29
29
|
export const structuredRuntimeSupportsMode = (runtime, mode) => structuredRuntimeMetadata(runtime)?.modes?.includes(mode) === true
|
|
30
30
|
export const structuredRuntimeSupportsFlow = (runtime) => structuredRuntimeMetadata(runtime)?.flow === true
|
|
31
|
+
export const shouldDeferStructuredRuntime = ({ runtime, defer, cwd, flowSessionId, flowTaskKey, models } = {}) => {
|
|
32
|
+
const metadata = structuredRuntimeMetadata(runtime)
|
|
33
|
+
// Some ACP agents publish their model catalog only from session/new or
|
|
34
|
+
// session/resume. A brand-new lane must perform that zero-inference handshake
|
|
35
|
+
// before its first prompt or the model picker has no truthful choices to show.
|
|
36
|
+
// Once the exact catalog is persisted, the normal lazy-start policy resumes.
|
|
37
|
+
if (metadata?.catalogRequiresSession && !(Array.isArray(models) && models.length)) return false
|
|
38
|
+
return !!defer || (!cwd && !flowSessionId && !flowTaskKey)
|
|
39
|
+
}
|