thinkpool-pair 0.7.267 → 0.7.269
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 +91 -11
- package/claude-command-catalog.mjs +91 -0
- package/claude-session.mjs +6 -4
- package/codex-app-server.mjs +20 -0
- package/codex-commands.mjs +83 -0
- package/codex-event-mapper.mjs +5 -1
- package/codex-session.mjs +70 -4
- package/command-catalog.mjs +106 -0
- package/hermes-acp-bootstrap.py +200 -0
- package/hermes-event-mapper.mjs +25 -1
- package/hermes-session.mjs +78 -3
- package/package.json +4 -1
- package/runtime-registry.mjs +20 -1
- package/thinkpool-capabilities.json +4 -4
package/bridge.mjs
CHANGED
|
@@ -51,9 +51,11 @@ import { validateProviderSwitch, providerSwitchPlan, BUILTIN_PROVIDER } from './
|
|
|
51
51
|
import { createSdkMcpServer, tool } from '@anthropic-ai/claude-agent-sdk'
|
|
52
52
|
import { z } from 'zod'
|
|
53
53
|
import { readCodexDefaultModel, readCodexModels, codexConfigForMode, codexThreadCanResume } from './codex-session.mjs'
|
|
54
|
+
import { codexAccountUsageLine, codexCreditsReportLine, codexLimitReportLine } from './codex-commands.mjs'
|
|
54
55
|
import { withMcpSessionFactory } from './codex-mcp-http.mjs'
|
|
55
56
|
import { startStructuredSession } from './runtime-session.mjs'
|
|
56
|
-
import { defaultStructuredMode, shouldDeferStructuredRuntime, structuredRuntimeForCommand, structuredRuntimeMetadata, structuredRuntimeSupportsMode } from './runtime-registry.mjs'
|
|
57
|
+
import { defaultStructuredMode, normalizeStructuredEffort, shouldDeferStructuredRuntime, structuredModeForSlice, structuredModeLocked, structuredModesForLane, structuredRuntimeForCommand, structuredRuntimeMetadata, structuredRuntimeSupportsMode } from './runtime-registry.mjs'
|
|
58
|
+
import { commandCatalogForRuntime, commandHelpLine, reconcileCommandCatalog } from './command-catalog.mjs'
|
|
57
59
|
import { probeHermesRuntime } from './hermes-probe.mjs'
|
|
58
60
|
import { hermesRequiredMcpTools, hermesRoleFor } from './hermes-policy.mjs'
|
|
59
61
|
import { canonicalRoomFilePath, waitForNativeImages } from './codex-images.mjs'
|
|
@@ -1127,7 +1129,7 @@ const announce = () => {
|
|
|
1127
1129
|
// laneStatusOf: authoritative busy/idle + last-action timestamp/age +
|
|
1128
1130
|
// STUCK/BLOCKED alert. The bridge owns the turn and permission state, so
|
|
1129
1131
|
// every roster consumer reads one status instead of reconstructing it.
|
|
1130
|
-
...[...sessions.entries()].map(([id, s]) => ({ id, cmd: s.cmd, kind: 'structured', runtime: s.runtime || 'claude', alive: true, ...laneStatusOf(s), hasTranscript: s.log.length > 0, commands: s.commands, mode: s.mode || undefined, effort: s.effort || undefined, name: termNames[id] || undefined, model: s.model || undefined, capabilities: structuredRuntimeMetadata(s.runtime || 'claude'), ...(s.runtime === 'codex' ? { approvalPolicy: codexConfigForMode(s.mode).approvalPolicy, models: s.models || [], canSteer: s.session?.canSteer ?? false } : s.runtime === 'hermes' ? { models: s.models || [], canSteer: s.session?.canSteer ?? false } : {}), ...(s.archiveOldestSeq != null ? { oldestSeq: s.archiveOldestSeq } : {}), ...(s.spawnedBy ? { spawned: true, spawnedBy: s.spawnedBy } : {}), ...(s.sideParent ? { sideParent: s.sideParent, sideTask: s.sideTask || undefined, sideHandback: !!s.sideHandback } : {}), ...(s.flowSessionId ? { flowId: s.flowSessionId, flowRole: s.flowTaskKey ? 'lane' : 'conductor' } : {}),
|
|
1132
|
+
...[...sessions.entries()].map(([id, s]) => ({ id, cmd: s.cmd, kind: 'structured', runtime: s.runtime || 'claude', alive: true, ...laneStatusOf(s), hasTranscript: s.log.length > 0, commands: s.commands, mode: s.mode || undefined, effort: s.effort || undefined, name: termNames[id] || undefined, model: s.model || undefined, capabilities: { ...structuredRuntimeMetadata(s.runtime || 'claude'), modes: structuredModesForLane(s.runtime || 'claude', s) }, ...(s.runtime === 'codex' ? { approvalPolicy: codexConfigForMode(s.mode).approvalPolicy, models: s.models || [], canSteer: s.session?.canSteer ?? false } : s.runtime === 'hermes' ? { models: s.models || [], canSteer: s.session?.canSteer ?? false } : {}), ...(s.archiveOldestSeq != null ? { oldestSeq: s.archiveOldestSeq } : {}), ...(s.spawnedBy ? { spawned: true, spawnedBy: s.spawnedBy } : {}), ...(s.sideParent ? { sideParent: s.sideParent, sideTask: s.sideTask || undefined, sideHandback: !!s.sideHandback } : {}), ...(s.flowSessionId ? { flowId: s.flowSessionId, flowRole: s.flowTaskKey ? 'lane' : 'conductor' } : {}),
|
|
1131
1133
|
// provider: the registered LLM-provider this lane runs on, NAME-ONLY {id,name}
|
|
1132
1134
|
// (NEVER the key or baseUrl). Additive; older clients ignore it. Omitted for the
|
|
1133
1135
|
// built-in/default Claude path (no badge). Makes the lane's provider badge +
|
|
@@ -1746,6 +1748,7 @@ function worktreeSnapshot(cwd) {
|
|
|
1746
1748
|
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 }) {
|
|
1747
1749
|
if (sessions.has(id)) return
|
|
1748
1750
|
runtime = structuredRuntimeMetadata(runtime) ? runtime : 'claude'
|
|
1751
|
+
mode = structuredModeForSlice(runtime, { mode, sliceType, flowRole })
|
|
1749
1752
|
// No explicit mode → a sensible default per runtime (see defaultModeForRuntime):
|
|
1750
1753
|
// codex → bypassPermissions, so a freshly-opened codex terminal can fetch /
|
|
1751
1754
|
// advance / ship instead of hitting the no-network wall; claude → default.
|
|
@@ -1773,7 +1776,7 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
|
|
|
1773
1776
|
// spawnedBy: set when this lane was Dispatched (spawn_terminal). Restored from the
|
|
1774
1777
|
// session store so the Ensemble flag survives a bridge restart (else a respin
|
|
1775
1778
|
// stripped it and the lane reverted to a plain tab — the t6 "no chip" bug).
|
|
1776
|
-
effort =
|
|
1779
|
+
effort = normalizeStructuredEffort(runtime, effort)
|
|
1777
1780
|
// Structural role is durable and independent of the per-turn hop breaker. A
|
|
1778
1781
|
// human can speak into a spawned lane (resetting hop to 0) without magically
|
|
1779
1782
|
// turning that lane into a top-level terminal. Legacy records predate
|
|
@@ -1782,7 +1785,7 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
|
|
|
1782
1785
|
? spawnDepth
|
|
1783
1786
|
: (sideParent || (spawnedBy && !String(spawnedBy).startsWith('flow:')) ? 1 : 0)
|
|
1784
1787
|
const initialHop = Number.isInteger(hop) && hop >= 0 ? hop : structuralDepth
|
|
1785
|
-
const entry = { cmd: runtime, runtime, kind: 'structured', log: Array.isArray(log) ? log.slice(-STRUCTURED_LOG_MAX) : [], pending: new Map(), session: null, recovered: false, commands:
|
|
1788
|
+
const entry = { cmd: runtime, runtime, kind: 'structured', log: Array.isArray(log) ? log.slice(-STRUCTURED_LOG_MAX) : [], pending: new Map(), session: null, recovered: false, commands: commandCatalogForRuntime(runtime, commands), mode, effort, models: runtime === 'codex' ? codexModels : runtime === 'hermes' && Array.isArray(models) ? models : undefined,
|
|
1786
1789
|
// model: truthful active-model label — now the SAME `laneModel` the SDK is given, so the
|
|
1787
1790
|
// chip cannot disagree with the wire. When this lane runs on a custom (non-anthropic)
|
|
1788
1791
|
// registered provider the SDK id is impersonated (see the onEvent guard below), so
|
|
@@ -2426,7 +2429,7 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
|
|
|
2426
2429
|
runtime: resolved.runtime,
|
|
2427
2430
|
model: resolved.model,
|
|
2428
2431
|
provider: resolved.provider,
|
|
2429
|
-
mode: resolved.mode,
|
|
2432
|
+
mode: structuredModeForSlice(resolved.runtime, { mode: resolved.mode, sliceType: args?.sliceType }),
|
|
2430
2433
|
}
|
|
2431
2434
|
let preview
|
|
2432
2435
|
try {
|
|
@@ -2482,7 +2485,7 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
|
|
|
2482
2485
|
// Stamp ownership/depth BEFORE the runtime starts so its first system
|
|
2483
2486
|
// preamble is truthful. Mutating ne.spawnedBy after openStructured was
|
|
2484
2487
|
// too late: Codex/Claude had already booted with the top-level wording.
|
|
2485
|
-
openStructured({ id: newId, runtime: resolved.runtime, model: resolved.model, provider: resolved.provider, mode:
|
|
2488
|
+
openStructured({ id: newId, runtime: resolved.runtime, model: resolved.model, provider: resolved.provider, mode: effectiveArgs.mode, sliceType: args?.sliceType, flowReviewTargets: manualReviewSnapshots.map((item) => item.taskKey), flowReviewSnapshots: manualReviewSnapshots, spawnedBy: id, spawnDepth: childSpawnDepth, cascadeRole: 'worker', hop: childHop })
|
|
2486
2489
|
const ne = sessions.get(newId)
|
|
2487
2490
|
if (!ne) { if (args?.name) { delete termNames[newId]; saveNames(room, termNames) } return okText('Could not open a new lane — the terminal cap may have just been reached. Close one and retry.') }
|
|
2488
2491
|
ne.peekCount = 0; ne.postCount = 0; ne.spawnTimes = []
|
|
@@ -2759,6 +2762,17 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
|
|
|
2759
2762
|
announce()
|
|
2760
2763
|
return
|
|
2761
2764
|
}
|
|
2765
|
+
// ACP replays its command catalog after resume/model reconstruction. A
|
|
2766
|
+
// byte-identical catalog is chrome, not a new transcript fact. Do not
|
|
2767
|
+
// return here: this same system event is the lifecycle latch for warm,
|
|
2768
|
+
// carried-recap, and interrupted-turn recovery.
|
|
2769
|
+
const duplicateCommandCatalog = evt.kind === 'system'
|
|
2770
|
+
? reconcileCommandCatalog({
|
|
2771
|
+
current: entry.commands,
|
|
2772
|
+
incoming: commandCatalogForRuntime(runtime, evt.commands),
|
|
2773
|
+
onChanged: (commands) => { entry.commands = commands; announce(); persist() },
|
|
2774
|
+
})
|
|
2775
|
+
: false
|
|
2762
2776
|
// Self-heal a stale resume — the saved SDK session expired. Reopen fresh,
|
|
2763
2777
|
// keeping the transcript (scrollback survives; live context is gone).
|
|
2764
2778
|
if (resume && evt.kind === 'error') {
|
|
@@ -2863,6 +2877,11 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
|
|
|
2863
2877
|
// openStructured). Chrome events bypass pushLog/persist in emitTail below, so
|
|
2864
2878
|
// usage needs an explicit persist() here to survive a bridge restart.
|
|
2865
2879
|
if (evt.kind === 'usage') { entry.lastUsage = evt; persist() }
|
|
2880
|
+
if (evt.kind === 'effort') {
|
|
2881
|
+
entry.effort = evt.effort ?? null
|
|
2882
|
+
persist()
|
|
2883
|
+
announce()
|
|
2884
|
+
}
|
|
2866
2885
|
// FL-B2 — fold this flow lane's completed-turn output tokens into its budget so the
|
|
2867
2886
|
// autopilot cap can halt the next wave before overrun (output_tokens = the billed
|
|
2868
2887
|
// reasoning+output spend the indicator already tracks; conservative enough for a guard).
|
|
@@ -2885,7 +2904,6 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
|
|
|
2885
2904
|
// The init system event carries the session's slash command list. Stash it
|
|
2886
2905
|
// on the entry so the ANNOUNCE can hand it to clients that connect/reload
|
|
2887
2906
|
// AFTER init (the one-time code-event would miss them), then re-announce.
|
|
2888
|
-
if (evt.kind === 'system' && Array.isArray(evt.commands) && evt.commands.length && !entry.commands) { entry.commands = evt.commands; announce(); persist() }
|
|
2889
2907
|
// Auto-resume trigger (Max, 2026-07-02): the init `system` event means the SDK session
|
|
2890
2908
|
// is LIVE (input stream now consumed). A restored mid-turn plain terminal flagged for
|
|
2891
2909
|
// resume gets its single "continue" HERE — not on a blind timer that raced the ~40s
|
|
@@ -2937,7 +2955,7 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
|
|
|
2937
2955
|
// card) and is intentionally NOT chrome, so it persists + replays.
|
|
2938
2956
|
// 'suggestion' is Claude Code's predicted-next-prompt ghost text — ephemeral
|
|
2939
2957
|
// composer UI, broadcast to both clients but never logged/persisted/replayed.
|
|
2940
|
-
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'
|
|
2958
|
+
const chrome = evt.kind === 'mode' || evt.kind === 'usage' || evt.kind === 'effort' || evt.kind === 'clear' || evt.kind === 'compact' || evt.kind === 'stalled' || evt.kind === 'suggestion' || evt.kind === 'assistant_stream' || evt.kind === 'model-switch'
|
|
2941
2959
|
// The compact turn settled with `entry.compacting` STILL TRUE — which, because the
|
|
2942
2960
|
// `compaction` branch above clears the flag the moment the milestone lands, means
|
|
2943
2961
|
// exactly one thing: NO compaction happened. Three ways to get here, all of which
|
|
@@ -3032,6 +3050,7 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
|
|
|
3032
3050
|
// print locally, persist. Shared so a deferred image event re-enters it once
|
|
3033
3051
|
// its base64 has been lifted to a Storage path (see deferImageEvent).
|
|
3034
3052
|
const emitTail = (e) => {
|
|
3053
|
+
if (duplicateCommandCatalog) return
|
|
3035
3054
|
if (!chrome) pushLog(entry, e)
|
|
3036
3055
|
bcast('code-event', { term: id, evt: e })
|
|
3037
3056
|
printLocal(e)
|
|
@@ -3681,6 +3700,41 @@ channel
|
|
|
3681
3700
|
pushLog(s, evt)
|
|
3682
3701
|
bcast('code-event', { term: payload.term, evt })
|
|
3683
3702
|
}
|
|
3703
|
+
if (/^\/help\s*$/.test(text) && s.runtime !== 'hermes') {
|
|
3704
|
+
ctlLine(`Available commands \u00b7 ${commandHelpLine(s.commands)}`)
|
|
3705
|
+
return
|
|
3706
|
+
}
|
|
3707
|
+
if (/^\/status\s*$/.test(text) && s.runtime !== 'hermes') {
|
|
3708
|
+
const label = structuredRuntimeMetadata(s.runtime)?.label || s.runtime
|
|
3709
|
+
const state = s.session?.turnActive ? 'working' : 'idle'
|
|
3710
|
+
ctlLine(`${label} \u00b7 ${state} \u00b7 model ${s.model || 'default'} \u00b7 ${s.mode || 'default'} permissions \u00b7 effort ${s.effort || 'default'}`)
|
|
3711
|
+
return
|
|
3712
|
+
}
|
|
3713
|
+
if (/^\/context\s*$/.test(text) && s.runtime !== 'hermes') {
|
|
3714
|
+
const ctx = s.lastUsage?.ctx
|
|
3715
|
+
ctlLine(ctx?.max
|
|
3716
|
+
? `Context \u00b7 ${Number(ctx.used || 0).toLocaleString('en-US')} / ${Number(ctx.max).toLocaleString('en-US')} tokens (${ctx.pct ?? Math.round((Number(ctx.used || 0) / Number(ctx.max)) * 100)}%)`
|
|
3717
|
+
: 'Context usage unavailable until the runtime reports a completed turn')
|
|
3718
|
+
return
|
|
3719
|
+
}
|
|
3720
|
+
if (/^\/diff\s*$/.test(text)) {
|
|
3721
|
+
try {
|
|
3722
|
+
const cwd = s.cwd || process.cwd()
|
|
3723
|
+
const summary = execFileSync('git', ['-C', cwd, 'status', '--short'], { encoding: 'utf8', timeout: 3000, stdio: ['ignore', 'pipe', 'ignore'] }).trim()
|
|
3724
|
+
ctlLine(summary ? `Working tree changes\n${summary.slice(0, 1600)}` : 'Working tree clean')
|
|
3725
|
+
} catch { ctlLine('Working-tree diff unavailable outside a readable Git checkout') }
|
|
3726
|
+
return
|
|
3727
|
+
}
|
|
3728
|
+
if (/^\/credits\s*$/.test(text) && s.runtime !== 'hermes') {
|
|
3729
|
+
if (s.runtime !== 'codex' || typeof s.session?.accountUsage !== 'function') {
|
|
3730
|
+
ctlLine('Credit balance is unavailable for this runtime; use /usage for provider limits')
|
|
3731
|
+
return
|
|
3732
|
+
}
|
|
3733
|
+
Promise.resolve(s.session.accountUsage()).then((report) => {
|
|
3734
|
+
ctlLine(codexCreditsReportLine(report?.limits))
|
|
3735
|
+
}).catch(() => ctlLine('Codex credits unavailable for this account'))
|
|
3736
|
+
return
|
|
3737
|
+
}
|
|
3684
3738
|
const mm = text.match(/^\/model\b\s*(\S+)?/)
|
|
3685
3739
|
if (mm) {
|
|
3686
3740
|
if (mm[1]) {
|
|
@@ -3735,7 +3789,7 @@ channel
|
|
|
3735
3789
|
// /compact → show the ephemeral "Compacting…" indicator (the compact-start event),
|
|
3736
3790
|
// track so onEvent clears it + attributes the recap card when the SDK turn finishes.
|
|
3737
3791
|
// No persisted ctl line — the live indicator + the CompactionCard are the record.
|
|
3738
|
-
if (/^\/compact\
|
|
3792
|
+
if (/^\/compact\s*$/.test(text)) {
|
|
3739
3793
|
if (s.runtime === 'codex') {
|
|
3740
3794
|
if (s.session.turnActive) {
|
|
3741
3795
|
ctlLine('finish or stop the current Codex turn before compacting context')
|
|
@@ -3746,6 +3800,15 @@ channel
|
|
|
3746
3800
|
ctlLine('nothing to compact — context unchanged')
|
|
3747
3801
|
return
|
|
3748
3802
|
}
|
|
3803
|
+
const nativeCompacted = await s.session.compactContext?.()
|
|
3804
|
+
if (nativeCompacted) {
|
|
3805
|
+
const ce = { kind: 'compaction', trigger: 'manual', preTokens: s.lastUsage?.ctx?.used || null, by: payload.by, native: true }
|
|
3806
|
+
pushLog(s, ce)
|
|
3807
|
+
bcast('code-event', { term: payload.term, evt: ce })
|
|
3808
|
+
s.lastUsage = null
|
|
3809
|
+
s.flush?.()
|
|
3810
|
+
return
|
|
3811
|
+
}
|
|
3749
3812
|
if (s.session.clearContext?.() === false) {
|
|
3750
3813
|
ctlLine('Codex context compaction unavailable right now')
|
|
3751
3814
|
return
|
|
@@ -3785,7 +3848,17 @@ channel
|
|
|
3785
3848
|
ctlLine(s.runtime === 'codex'
|
|
3786
3849
|
? codexUsageReportLine(s.model, s.session?.usageSnapshot)
|
|
3787
3850
|
: usageReportLine(s.model, s.log))
|
|
3788
|
-
if (s.runtime
|
|
3851
|
+
if (s.runtime === 'codex') {
|
|
3852
|
+
Promise.resolve(s.session?.accountUsage?.()).then((report) => {
|
|
3853
|
+
const usage = codexAccountUsageLine(report?.usage)
|
|
3854
|
+
if (usage) ctlLine(usage)
|
|
3855
|
+
ctlLine(codexLimitReportLine(report?.limits))
|
|
3856
|
+
}).catch(() => ctlLine('Codex provider limits unavailable'))
|
|
3857
|
+
} else planMeterLine().then((l) => { if (l) ctlLine(l) }).catch(() => { /* meters are never load-bearing */ })
|
|
3858
|
+
return
|
|
3859
|
+
}
|
|
3860
|
+
if (s.runtime === 'codex' && /^\/review(?:\s|$)/.test(text) && (s.flowSessionId || s.flowRole || s.sliceType === 'review')) {
|
|
3861
|
+
ctlLine('Native /review is unavailable in Flow and reviewer lanes; their immutable review contract already owns scope and authority')
|
|
3789
3862
|
return
|
|
3790
3863
|
}
|
|
3791
3864
|
// Context-carry (2026-07-08) point 4: a real human turn arrived BEFORE the post-switch/
|
|
@@ -3863,6 +3936,13 @@ channel
|
|
|
3863
3936
|
.on('broadcast', { event: 'code-mode' }, ({ payload }) => {
|
|
3864
3937
|
const s = payload?.term && sessions.get(payload.term)
|
|
3865
3938
|
if (s && STRUCTURED_MODES.has(payload.mode)) {
|
|
3939
|
+
if (structuredModeLocked(s) && payload.mode !== s.mode) {
|
|
3940
|
+
const evt = { kind: 'control', text: `This ${s.flowRole || 'review'} lane is structurally locked to ${s.mode}; Flow and reviewer restrictions take precedence.` }
|
|
3941
|
+
pushLog(s, evt)
|
|
3942
|
+
bcast('code-event', { term: payload.term, evt })
|
|
3943
|
+
announce()
|
|
3944
|
+
return
|
|
3945
|
+
}
|
|
3866
3946
|
if (s.runtime === 'codex' || s.runtime === 'hermes') {
|
|
3867
3947
|
if (!s.session.setMode(payload.mode)) {
|
|
3868
3948
|
const evt = { kind: 'control', text: `finish or stop the current ${structuredRuntimeMetadata(s.runtime)?.label || 'agent'} turn before switching permissions` }
|
|
@@ -4305,7 +4385,7 @@ flowChannel
|
|
|
4305
4385
|
// Model tiers (2026-07-03-flow-lane-model-tiers): pick the lane's brain by slice_type
|
|
4306
4386
|
// (scaffold→sonnet, feature/fix/review→opus; env can blanket-override or `inherit` to
|
|
4307
4387
|
// restore today's exact behavior). undefined → no model key passed (openStructured default).
|
|
4308
|
-
openStructured({ id: laneId, runtime: flowRuntime, cwd: dir, model: flowLaneModelFor({ sliceType: t.slice_type, runtime: flowRuntime, catalog: flowCatalog }), mode: flowRuntime
|
|
4388
|
+
openStructured({ id: laneId, runtime: flowRuntime, cwd: dir, model: flowLaneModelFor({ sliceType: t.slice_type, runtime: flowRuntime, catalog: flowCatalog }), mode: structuredModeForSlice(flowRuntime, { mode: 'bypassPermissions', flowRole: isReview ? 'reviewer' : 'builder' }), rolePrompt: laneRolePrompt, flowSessionId: payload.flowId, flowTaskKey: t.task_key, flowRole: isReview ? 'reviewer' : 'builder', flowReviewTargets: isReview ? (t.deps || []) : [], flowReviewSnapshots, dispatchBaseSha, revertTarget: redispatch?.revertTarget || null, spawnedBy: `flow:${payload.flowId}`, resume: redispatch?.resumeSessionId || undefined, reviewSliceRoots })
|
|
4309
4389
|
const le = sessions.get(laneId)
|
|
4310
4390
|
if (le) {
|
|
4311
4391
|
// S4 — stamp the surviving revert target on the resumed lane so a later reviewer still
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// Claude Code's SDK init payload is an *advertisement*, not a headless command
|
|
2
|
+
// contract. In particular, 2.1.206 advertises TUI/profile commands (`/agents`,
|
|
3
|
+
// `/config`, `/doctor`, …) which are not safe or meaningful in a shared room.
|
|
4
|
+
// Keep the room catalog deliberately small and evidence-based:
|
|
5
|
+
//
|
|
6
|
+
// 1. bridge-owned controls work through the existing room controls;
|
|
7
|
+
// 2. `/compact` is the one native lifecycle command proven on the streaming
|
|
8
|
+
// Agent SDK path; and
|
|
9
|
+
// 3. a discovered slash name is exposed only when the same init event calls it
|
|
10
|
+
// an installed Skill. Skills are structured prompt invocations, unlike
|
|
11
|
+
// Claude Code's interactive/profile command palette.
|
|
12
|
+
//
|
|
13
|
+
// This is also an explicit command matrix for the 2.1.206 probe. Everything
|
|
14
|
+
// outside the allow path is intentionally omitted; `CLAUDE_COMMAND_EXCLUSIONS`
|
|
15
|
+
// records the high-risk/interactive families so a later SDK change cannot turn
|
|
16
|
+
// discovery into accidental exposure.
|
|
17
|
+
|
|
18
|
+
const cleanName = (value) => {
|
|
19
|
+
const name = String(typeof value === 'object' ? value?.name : value || '')
|
|
20
|
+
.trim()
|
|
21
|
+
.replace(/^\/+/, '')
|
|
22
|
+
return /^[A-Za-z0-9][A-Za-z0-9:_-]*$/.test(name) ? name : ''
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const stringMeta = (value) => {
|
|
26
|
+
if (!value || typeof value !== 'object') return {}
|
|
27
|
+
const description = String(value.description || '').trim()
|
|
28
|
+
const inputHint = String(value.inputHint || value.input_hint || value.input?.hint || '').trim()
|
|
29
|
+
return {
|
|
30
|
+
...(description ? { description } : {}),
|
|
31
|
+
...(inputHint ? { inputHint } : {}),
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export const CLAUDE_COMMAND_MATRIX = Object.freeze({
|
|
36
|
+
'/clear': Object.freeze({ description: 'clear context · confirms', owner: 'bridge-control' }),
|
|
37
|
+
'/compact': Object.freeze({ description: 'compact context', owner: 'sdk-lifecycle' }),
|
|
38
|
+
'/model': Object.freeze({ description: 'pick model — opens selector', owner: 'bridge-control' }),
|
|
39
|
+
'/usage': Object.freeze({ description: 'session usage and provider limits', owner: 'bridge-control' }),
|
|
40
|
+
'<installed-skill>': Object.freeze({ description: 'run an installed Claude skill', owner: 'sdk-skill' }),
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
// Exclusions are grouped by why the room cannot safely implement them. This
|
|
44
|
+
// is intentionally a deny-list *and* a default-deny catalog: unknown future
|
|
45
|
+
// slash names stay hidden until they have an explicit structured contract.
|
|
46
|
+
export const CLAUDE_COMMAND_EXCLUSIONS = Object.freeze({
|
|
47
|
+
profileOrIdentity: Object.freeze(['agents', 'color', 'config', 'rename', 'team-onboarding']),
|
|
48
|
+
credentialsOrUpdates: Object.freeze(['claude-api', 'mcp', 'update-config']),
|
|
49
|
+
diagnosticsOrUpload: Object.freeze(['debug', 'doctor', 'heapdump', 'insights']),
|
|
50
|
+
hiddenAgentOrAutomation: Object.freeze(['__remote-workflow', 'batch', 'code-review', 'loop', 'review', 'schedule', 'security-review']),
|
|
51
|
+
unsafeInteractive: Object.freeze(['design', 'design-consent', 'design-revoke', 'fast', 'fewer-permission-prompts', 'goal', 'init', 'recap', 'reload-skills', 'run']),
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
const EXCLUDED = new Set(Object.values(CLAUDE_COMMAND_EXCLUSIONS).flat())
|
|
55
|
+
const BRIDGE_CONTROLS = new Set(['clear', 'compact', 'model', 'usage'])
|
|
56
|
+
|
|
57
|
+
// Convert Claude's string-only 2.1.206 payload (and a future object-shaped
|
|
58
|
+
// payload) into the metadata contract shared by all structured runtimes. An
|
|
59
|
+
// argument-bearing future skill retains its description and input hint; no
|
|
60
|
+
// argument command is silently advertised as a one-tap action.
|
|
61
|
+
export function normalizeClaudeCommandCatalog({ slashCommands, skills } = {}) {
|
|
62
|
+
const advertised = Array.isArray(slashCommands) ? slashCommands : []
|
|
63
|
+
const installedSkills = new Set((Array.isArray(skills) ? skills : []).map(cleanName).filter(Boolean))
|
|
64
|
+
const byName = new Map()
|
|
65
|
+
|
|
66
|
+
for (const command of advertised) {
|
|
67
|
+
const name = cleanName(command)
|
|
68
|
+
if (!name || EXCLUDED.has(name)) continue
|
|
69
|
+
const allowed = BRIDGE_CONTROLS.has(name) || installedSkills.has(name)
|
|
70
|
+
if (!allowed || byName.has(name)) continue
|
|
71
|
+
const canonical = CLAUDE_COMMAND_MATRIX[`/${name}`]
|
|
72
|
+
const supplied = stringMeta(command)
|
|
73
|
+
byName.set(name, {
|
|
74
|
+
name: `/${name}`,
|
|
75
|
+
description: supplied.description || canonical?.description || `Run installed Claude skill: /${name}`,
|
|
76
|
+
...(supplied.inputHint ? { inputHint: supplied.inputHint } : {}),
|
|
77
|
+
})
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Room controls remain available on older SDK init shapes that omit the raw
|
|
81
|
+
// slash list. They are ordered first because they have bridge support.
|
|
82
|
+
const controls = ['clear', 'compact', 'model', 'usage'].map((name) => byName.get(name) || ({
|
|
83
|
+
name: `/${name}`,
|
|
84
|
+
description: CLAUDE_COMMAND_MATRIX[`/${name}`].description,
|
|
85
|
+
}))
|
|
86
|
+
const skillsOnly = [...byName.entries()]
|
|
87
|
+
.filter(([name]) => !BRIDGE_CONTROLS.has(name))
|
|
88
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
89
|
+
.map(([, command]) => command)
|
|
90
|
+
return [...controls, ...skillsOnly]
|
|
91
|
+
}
|
package/claude-session.mjs
CHANGED
|
@@ -21,6 +21,7 @@ import { sanitizeSession } from './transcript-sanitize.mjs'
|
|
|
21
21
|
import { reviewGatePreToolDecision } from './flow-review-gate.mjs'
|
|
22
22
|
import { crossPostNeedsCard } from './cross-terminal.mjs'
|
|
23
23
|
import { correctContext } from './context-windows.mjs'
|
|
24
|
+
import { normalizeClaudeCommandCatalog } from './claude-command-catalog.mjs'
|
|
24
25
|
import { THINKPOOL_CASCADE_RULE, THINKPOOL_REMOTE_DELIVERY_RULES, THINKPOOL_RUNTIME_AUTHORITY_RULE, THINKPOOL_RUNTIME_TURN_REMINDER, buildThinkPoolTurnGuidance, createRoomContextSelector, usesFullThinkPoolReminder } from './thinkpool-room-prompt.mjs'
|
|
25
26
|
import { stallDecision, stallEvent, isCompactTurn } from './turn-stall.mjs'
|
|
26
27
|
|
|
@@ -878,9 +879,10 @@ export function startClaudeSession({ cwd, model, effort: initialEffort = 'high',
|
|
|
878
879
|
readyLogged = true
|
|
879
880
|
process.stderr.write(`\n ◆ session ready in ${Date.now() - spawnT0}ms — MCP ${process.env.TP_MCP_STRICT === '1' ? 'OFF' : 'on'}${resume ? ', resume' : ', fresh'}.\n`)
|
|
880
881
|
}
|
|
881
|
-
//
|
|
882
|
-
//
|
|
883
|
-
//
|
|
882
|
+
// The init advertises both real Skills and Claude Code's TUI/profile
|
|
883
|
+
// palette. Normalise it to the structured room contract: supported
|
|
884
|
+
// bridge controls plus installed Skills, never unsafe interactive or
|
|
885
|
+
// profile-global commands (see claude-command-catalog.mjs).
|
|
884
886
|
// Prefer the INTENDED model (opts.model — set at open and on every /model switch)
|
|
885
887
|
// over the message's own `m.model`. A RESUMED session REPLAYS the transcript's old
|
|
886
888
|
// init messages, all carrying the PRE-switch model, and each one used to reset the
|
|
@@ -888,7 +890,7 @@ export function startClaudeSession({ cwd, model, effort: initialEffort = 'high',
|
|
|
888
890
|
// "init model=claude-opus-4-8" dragged the chip back to Opus with no result yet to
|
|
889
891
|
// correct it). opts.model reflects what the session will actually run, so trust it.
|
|
890
892
|
curModel = opts.model || m.model || model || curModel
|
|
891
|
-
emit({ kind: 'system', sessionId, model: opts.model || m.model || model || null, commands:
|
|
893
|
+
emit({ kind: 'system', sessionId, model: opts.model || m.model || model || null, commands: normalizeClaudeCommandCatalog({ slashCommands: m.slash_commands, skills: m.skills }) })
|
|
892
894
|
// AskUserQuestion regression guard — the init message carries the session's
|
|
893
895
|
// real tool list. If a caret SDK bump silently dropped AskUserQuestion from
|
|
894
896
|
// it (the 2026-07-08 outage), surface it loudly instead of a dead card. One-
|
package/codex-app-server.mjs
CHANGED
|
@@ -257,6 +257,26 @@ export class CodexAppServerClient {
|
|
|
257
257
|
return this.request('turn/interrupt', { threadId, turnId }, this.controlTimeoutMs)
|
|
258
258
|
}
|
|
259
259
|
|
|
260
|
+
compact({ threadId } = {}) {
|
|
261
|
+
return this.request('thread/compact/start', { threadId }, this.controlTimeoutMs)
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
accountUsage() {
|
|
265
|
+
return this.request('account/usage/read', null, this.controlTimeoutMs)
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
accountRateLimits() {
|
|
269
|
+
return this.request('account/rateLimits/read', null, this.controlTimeoutMs)
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
startReview({ threadId, target } = {}) {
|
|
273
|
+
return this.request('review/start', {
|
|
274
|
+
threadId,
|
|
275
|
+
target: target || { type: 'uncommittedChanges' },
|
|
276
|
+
delivery: 'inline',
|
|
277
|
+
}, this.turnStartTimeoutMs)
|
|
278
|
+
}
|
|
279
|
+
|
|
260
280
|
end() {
|
|
261
281
|
const child = this.child
|
|
262
282
|
this._close(new Error('codex app-server ended'))
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// Codex Code-room command policy.
|
|
2
|
+
//
|
|
3
|
+
// This is intentionally NOT a transcription of the interactive `codex` TUI
|
|
4
|
+
// palette. Codex CLI 0.144.1's App Server schema has no slash-command catalog
|
|
5
|
+
// endpoint. Keep only a command which the room can actually execute, and keep
|
|
6
|
+
// bridge-wide controls in bridge.mjs where their shared state semantics live.
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Commands surfaced from the Codex runtime's system catalog.
|
|
10
|
+
*
|
|
11
|
+
* /compact is bridge-executed today: bridge.mjs clears the native Codex context
|
|
12
|
+
* and carries a bounded room recap into the next fresh thread. App Server also
|
|
13
|
+
* exposes thread/compact/start in 0.144.1, but the bridge path is the supported
|
|
14
|
+
* cross-transport implementation, including exec fallback and the room's
|
|
15
|
+
* durable recap semantics.
|
|
16
|
+
*/
|
|
17
|
+
export const CODEX_COMMAND_CATALOG = Object.freeze([])
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Evidence-backed command matrix for the installed Codex CLI (0.144.1).
|
|
21
|
+
* `surface` means what ThinkPool intentionally advertises; it is not a claim
|
|
22
|
+
* that an interactive terminal palette command works through App Server.
|
|
23
|
+
*/
|
|
24
|
+
export const CODEX_COMMAND_MATRIX = Object.freeze([
|
|
25
|
+
Object.freeze({ name: '/compact', surface: true, runtime: 'native', api: 'thread/compact/start', reason: 'Native App Server compaction; bounded-recap reset remains the exec fallback.' }),
|
|
26
|
+
Object.freeze({ name: '/model', surface: true, runtime: 'bridge', api: 'model/list', reason: 'Shared picker owns the model argument.' }),
|
|
27
|
+
Object.freeze({ name: '/clear', surface: true, runtime: 'bridge', api: null, reason: 'Shared reset clears visible lane history and starts a fresh native thread.' }),
|
|
28
|
+
Object.freeze({ name: '/usage', surface: true, runtime: 'bridge+native', api: 'account/usage/read + account/rateLimits/read', reason: 'Reports lane usage plus redacted provider totals and limits.' }),
|
|
29
|
+
Object.freeze({ name: '/credits', surface: true, runtime: 'native', api: 'account/rateLimits/read', reason: 'Reports balance/availability only; never account identity.' }),
|
|
30
|
+
Object.freeze({ name: '/review', surface: true, runtime: 'native', api: 'review/start', reason: 'Ordinary lanes only; Flow and reviewer structural restrictions win.' }),
|
|
31
|
+
])
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Explicit exclusions: present in an interactive CLI or App Server protocol,
|
|
35
|
+
* but not safe/meaningful Code-room commands. Never add these by inference.
|
|
36
|
+
*/
|
|
37
|
+
export const CODEX_COMMAND_EXCLUSIONS = Object.freeze([
|
|
38
|
+
'profile-global configuration, skills, plugins, marketplaces, and experimental features',
|
|
39
|
+
'updates, diagnostics/feedback upload, and external-agent import',
|
|
40
|
+
'credentials, login/logout, identity, and account mutation',
|
|
41
|
+
'thread archive/delete/fork/name/goal/rollback and hidden-agent controls',
|
|
42
|
+
'MCP OAuth, filesystem, shell, and interactive command-exec protocol methods',
|
|
43
|
+
'detached review and review from Flow/reviewer lanes, because visible structural restrictions win',
|
|
44
|
+
])
|
|
45
|
+
|
|
46
|
+
export function isCodexCompactCommand(text) {
|
|
47
|
+
return /^\s*\/compact\s*$/i.test(String(text || ''))
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function codexReviewTarget(text) {
|
|
51
|
+
const body = String(text || '').replace(/^\s*\/review\b/i, '').trim()
|
|
52
|
+
if (!body) return { type: 'uncommittedChanges' }
|
|
53
|
+
const base = body.match(/^base\s*:\s*(\S+)$/i)
|
|
54
|
+
if (base) return { type: 'baseBranch', branch: base[1] }
|
|
55
|
+
const commit = body.match(/^commit\s*:\s*([a-f0-9]{7,64})$/i)
|
|
56
|
+
if (commit) return { type: 'commit', sha: commit[1] }
|
|
57
|
+
return { type: 'custom', instructions: body }
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const pct = (value) => Number.isFinite(Number(value)) ? `${Math.round(Number(value))}%` : null
|
|
61
|
+
const reset = (value) => Number.isFinite(Number(value)) ? new Date(Number(value) * 1000).toISOString().replace('T', ' ').slice(0, 16) + 'Z' : null
|
|
62
|
+
|
|
63
|
+
export function codexLimitReportLine(snapshot) {
|
|
64
|
+
const limits = snapshot?.rateLimits || null
|
|
65
|
+
if (!limits) return 'Codex provider limits unavailable'
|
|
66
|
+
const parts = []
|
|
67
|
+
if (limits.primary?.usedPercent != null) parts.push(`primary ${pct(limits.primary.usedPercent)}${reset(limits.primary.resetsAt) ? ` · resets ${reset(limits.primary.resetsAt)}` : ''}`)
|
|
68
|
+
if (limits.secondary?.usedPercent != null) parts.push(`secondary ${pct(limits.secondary.usedPercent)}${reset(limits.secondary.resetsAt) ? ` · resets ${reset(limits.secondary.resetsAt)}` : ''}`)
|
|
69
|
+
return parts.length ? `Codex limits · ${parts.join(' · ')}` : 'Codex provider limits unavailable'
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function codexCreditsReportLine(snapshot) {
|
|
73
|
+
const credits = snapshot?.rateLimits?.credits
|
|
74
|
+
if (!credits) return 'Codex credits unavailable for this account'
|
|
75
|
+
if (credits.unlimited) return 'Codex credits · unlimited'
|
|
76
|
+
if (credits.balance != null) return `Codex credits · balance ${String(credits.balance)}`
|
|
77
|
+
return `Codex credits · ${credits.hasCredits ? 'available' : 'none available'}`
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function codexAccountUsageLine(snapshot) {
|
|
81
|
+
const total = snapshot?.summary?.lifetimeTokens
|
|
82
|
+
return Number.isFinite(Number(total)) ? `Codex account usage · ${Number(total).toLocaleString('en-US')} lifetime tokens` : null
|
|
83
|
+
}
|
package/codex-event-mapper.mjs
CHANGED
|
@@ -29,6 +29,7 @@
|
|
|
29
29
|
// apply unchanged. The stable exec path is observe-only; the opt-in App Server
|
|
30
30
|
// path routes command/file approval requests through the shared room cards.
|
|
31
31
|
import fs from 'node:fs'
|
|
32
|
+
import { CODEX_COMMAND_CATALOG } from './codex-commands.mjs'
|
|
32
33
|
|
|
33
34
|
const safeMcpPart = (value) => String(value || 'unknown').replace(/[^a-zA-Z0-9_-]/g, '_')
|
|
34
35
|
const mcpToolName = (item) => `mcp__${safeMcpPart(item?.server)}__${safeMcpPart(item?.tool)}`
|
|
@@ -80,7 +81,10 @@ export class CodexEventMapper {
|
|
|
80
81
|
this._sessionId = ev.thread_id || this._sessionId || null
|
|
81
82
|
if (!this._systemSent) {
|
|
82
83
|
this._systemSent = true
|
|
83
|
-
|
|
84
|
+
// Codex App Server 0.144.1 has no slash-command discovery endpoint.
|
|
85
|
+
// Advertise only the explicit room-supported catalog, never guessed
|
|
86
|
+
// interactive-TUI commands.
|
|
87
|
+
this._emit({ kind: 'system', sessionId: this._sessionId, model: this._model, commands: CODEX_COMMAND_CATALOG })
|
|
84
88
|
}
|
|
85
89
|
return
|
|
86
90
|
}
|
package/codex-session.mjs
CHANGED
|
@@ -32,6 +32,7 @@ import { CodexEventMapper } from './codex-event-mapper.mjs'
|
|
|
32
32
|
import { startCodexMcpHttp } from './codex-mcp-http.mjs'
|
|
33
33
|
import { CODEX_THINKPOOL_FIRST_TURN_PREAMBLE, buildThinkPoolTurnGuidance, createRoomContextSelector, usesFullThinkPoolReminder } from './thinkpool-room-prompt.mjs'
|
|
34
34
|
import { questionAnswerResponse } from './question-response.mjs'
|
|
35
|
+
import { codexReviewTarget, isCodexCompactCommand } from './codex-commands.mjs'
|
|
35
36
|
|
|
36
37
|
const DEFAULT_SANDBOX = 'workspace-write'
|
|
37
38
|
const SAFE_SANDBOXES = new Set(['read-only', 'workspace-write', 'danger-full-access'])
|
|
@@ -40,7 +41,10 @@ export const CODEX_MODE_CONFIG = {
|
|
|
40
41
|
default: { sandbox: 'workspace-write', approvalPolicy: 'untrusted' },
|
|
41
42
|
acceptEdits: { sandbox: 'workspace-write', approvalPolicy: 'on-request' },
|
|
42
43
|
plan: { sandbox: 'read-only', approvalPolicy: 'never' },
|
|
43
|
-
|
|
44
|
+
// Reviewer authority is structural, not a prompt convention. It may inspect
|
|
45
|
+
// the pinned snapshot and call the bridge-owned verdict/check tools, but it
|
|
46
|
+
// cannot mutate even its own worktree as an escape hatch.
|
|
47
|
+
review: { sandbox: 'read-only', approvalPolicy: 'never' },
|
|
44
48
|
bypassPermissions: { sandbox: 'danger-full-access', approvalPolicy: 'never' },
|
|
45
49
|
}
|
|
46
50
|
|
|
@@ -586,6 +590,43 @@ export function startCodexSession({ cwd, model, effort: initialEffort = 'high',
|
|
|
586
590
|
return true
|
|
587
591
|
}
|
|
588
592
|
|
|
593
|
+
async function runAppServerReview(commandText) {
|
|
594
|
+
if (!await ensureAppServer()) return false
|
|
595
|
+
mapper.setUsageBaseline(sessionId ? readCodexThreadUsage(sessionId) : null)
|
|
596
|
+
aborted = false
|
|
597
|
+
turnActive = true
|
|
598
|
+
try {
|
|
599
|
+
const started = await appServer.startReview({ threadId: sessionId, target: codexReviewTarget(commandText) })
|
|
600
|
+
activeTurnId = started?.turn?.id || started?.turnId
|
|
601
|
+
if (!activeTurnId) throw new Error('Codex review/start returned no turn id')
|
|
602
|
+
const completed = await appServer.waitForTurn(activeTurnId)
|
|
603
|
+
const status = completed?.turn?.status
|
|
604
|
+
if (aborted || status === 'interrupted') {
|
|
605
|
+
emitTurnBoundary({ kind: 'result', subtype: 'aborted', sessionId, model: activeModel, costUsd: null, usage: null, numTurns: 1, durationMs: completed?.turn?.durationMs, denials: 0, resultText: null })
|
|
606
|
+
} else if (status === 'failed') {
|
|
607
|
+
pushMappedEvent({ type: 'turn.failed', message: completed?.turn?.error?.message || 'codex review failed' })
|
|
608
|
+
} else {
|
|
609
|
+
pushMappedEvent({ type: 'turn.completed' })
|
|
610
|
+
}
|
|
611
|
+
} catch (error) {
|
|
612
|
+
appServerDisabled = true
|
|
613
|
+
appServerThreadReady = false
|
|
614
|
+
try { appServer?.end() } catch { /* noop */ }
|
|
615
|
+
appServer = null
|
|
616
|
+
if (!ended) pushMappedEvent({ type: 'turn.failed', message: `codex review failed: ${error?.message || error}` })
|
|
617
|
+
} finally {
|
|
618
|
+
for (const state of streamedAgentItems.values()) {
|
|
619
|
+
if (!state.text) continue
|
|
620
|
+
try { onEvent?.({ kind: 'assistant', blocks: [{ type: 'text', text: state.text }], parentToolUseId: null, replacesCid: state.cid }) } catch { /* noop */ }
|
|
621
|
+
}
|
|
622
|
+
activeTurnId = null
|
|
623
|
+
turnActive = false
|
|
624
|
+
appServerItems.clear()
|
|
625
|
+
streamedAgentItems.clear()
|
|
626
|
+
}
|
|
627
|
+
return true
|
|
628
|
+
}
|
|
629
|
+
|
|
589
630
|
async function runExec(prompt, options = {}) {
|
|
590
631
|
// First turn: fresh exec. Later turns: resume THIS lane's captured thread.
|
|
591
632
|
// `--last` is process-global and can cross-wire two concurrently-active Codex
|
|
@@ -681,8 +722,15 @@ export function startCodexSession({ cwd, model, effort: initialEffort = 'high',
|
|
|
681
722
|
promptIndex: next.promptIndex,
|
|
682
723
|
forceFullReminder: next.forceFullReminder,
|
|
683
724
|
})
|
|
684
|
-
const
|
|
685
|
-
|
|
725
|
+
const review = /^\s*\/review(?:\s|$)/i.test(String(next.text || ''))
|
|
726
|
+
const usedAppServer = review
|
|
727
|
+
? await runAppServerReview(next.text)
|
|
728
|
+
: await runAppServer(prompt, next.options)
|
|
729
|
+
if (!usedAppServer) {
|
|
730
|
+
if (review) {
|
|
731
|
+
emitTurnBoundary({ kind: 'error', message: 'Codex review is unavailable without the tested App Server runtime.', recoverable: true })
|
|
732
|
+
} else await runExec(prompt, next.options)
|
|
733
|
+
}
|
|
686
734
|
turnNo++
|
|
687
735
|
pump()
|
|
688
736
|
})
|
|
@@ -698,7 +746,7 @@ export function startCodexSession({ cwd, model, effort: initialEffort = 'high',
|
|
|
698
746
|
if (ended) return false
|
|
699
747
|
const promptIndex = userPromptNo++
|
|
700
748
|
const thisTurnForceFull = forceFullReminder
|
|
701
|
-
forceFullReminder = /^\s*\/(?:
|
|
749
|
+
forceFullReminder = isCodexCompactCommand(text) || /^\s*\/(?:reset|clear)\b/i.test(String(text || ''))
|
|
702
750
|
if (!turnActive && turnNo === 0 && prepareCwd) {
|
|
703
751
|
try { cwd = prepareCwd() || cwd } catch { /* keep original cwd */ }
|
|
704
752
|
}
|
|
@@ -779,5 +827,23 @@ export function startCodexSession({ cwd, model, effort: initialEffort = 'high',
|
|
|
779
827
|
appServerThreadReady = false
|
|
780
828
|
return true
|
|
781
829
|
},
|
|
830
|
+
async compactContext() {
|
|
831
|
+
if (turnActive || !await ensureAppServer()) return false
|
|
832
|
+
try {
|
|
833
|
+
await appServer.compact({ threadId: sessionId })
|
|
834
|
+
return true
|
|
835
|
+
} catch (error) {
|
|
836
|
+
note(`Native Codex compaction unavailable; using bounded recap fallback: ${error?.message || error}`)
|
|
837
|
+
return false
|
|
838
|
+
}
|
|
839
|
+
},
|
|
840
|
+
async accountUsage() {
|
|
841
|
+
if (!await ensureAppServer()) return null
|
|
842
|
+
const [usage, limits] = await Promise.all([
|
|
843
|
+
appServer.accountUsage().catch(() => null),
|
|
844
|
+
appServer.accountRateLimits().catch(() => null),
|
|
845
|
+
])
|
|
846
|
+
return { usage, limits }
|
|
847
|
+
},
|
|
782
848
|
}
|
|
783
849
|
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// Runtime-neutral Code-room command catalog. Native runtimes publish very
|
|
2
|
+
// different surfaces: Claude mixes skills with TUI commands, Codex App Server
|
|
3
|
+
// publishes no slash catalog, and Hermes publishes ACP metadata. Keep the safe
|
|
4
|
+
// room controls here so all three runtimes share names, descriptions, hints,
|
|
5
|
+
// and routing while runtime-owned commands remain evidence-gated.
|
|
6
|
+
|
|
7
|
+
const command = (name, description, route, inputHint, runtimes = ['claude', 'codex', 'hermes']) => Object.freeze({
|
|
8
|
+
name,
|
|
9
|
+
description,
|
|
10
|
+
route,
|
|
11
|
+
...(inputHint ? { inputHint } : {}),
|
|
12
|
+
runtimes: Object.freeze(runtimes),
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
export const CODE_ROOM_COMMANDS = Object.freeze([
|
|
16
|
+
command('/side', 'investigate beside this terminal', 'side', 'task'),
|
|
17
|
+
command('/flow', 'open an explicit visible Cascade', 'flow', 'task [--mode guide|steer|autopilot]'),
|
|
18
|
+
command('/help', 'list commands available in this lane', 'control'),
|
|
19
|
+
command('/status', 'runtime, model, permissions, and busy state', 'control'),
|
|
20
|
+
command('/usage', 'session usage and provider limits', 'control'),
|
|
21
|
+
command('/context', 'current context-window usage', 'control'),
|
|
22
|
+
command('/diff', 'working-tree change summary', 'control'),
|
|
23
|
+
command('/compact', 'compact context', 'runtime'),
|
|
24
|
+
command('/clear', 'clear context · confirms', 'clear'),
|
|
25
|
+
command('/model', 'pick model — opens selector', 'model'),
|
|
26
|
+
command('/mode', 'set permission mode', 'mode', 'normal | auto-accept | plan | bypass'),
|
|
27
|
+
command('/effort', 'set reasoning effort', 'effort', 'low | medium | high | xhigh | max'),
|
|
28
|
+
command('/queue', 'run a prompt after the active turn', 'queue', 'prompt'),
|
|
29
|
+
command('/steer', 'guide the active turn', 'steer', 'prompt', ['codex', 'hermes']),
|
|
30
|
+
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
|
+
command('/review', 'review uncommitted changes', 'runtime', null, ['codex']),
|
|
33
|
+
])
|
|
34
|
+
|
|
35
|
+
const cleanRuntime = (runtime) => runtime === 'thinkpool' ? 'hermes' : runtime
|
|
36
|
+
const cleanName = (value) => {
|
|
37
|
+
const raw = typeof value === 'string' ? value : value?.name
|
|
38
|
+
const name = `/${String(raw || '').trim().replace(/^\/+/, '')}`
|
|
39
|
+
return /^\/[A-Za-z0-9][A-Za-z0-9:_-]*$/.test(name) ? name : ''
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const normalizeNative = (value) => {
|
|
43
|
+
const name = cleanName(value)
|
|
44
|
+
if (!name) return null
|
|
45
|
+
if (typeof value === 'string') return { name, description: 'runtime command', route: 'runtime' }
|
|
46
|
+
const description = String(value?.description || '').trim() || 'runtime command'
|
|
47
|
+
const inputHint = String(value?.inputHint || value?.input_hint || value?.input?.hint || '').trim()
|
|
48
|
+
const route = String(value?.route || '').trim() || 'runtime'
|
|
49
|
+
return { name, description, route, ...(inputHint ? { inputHint } : {}) }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Merge native metadata into the shared catalog without allowing a runtime to
|
|
53
|
+
// redefine the routing of a bridge-owned control. Unknown native commands are
|
|
54
|
+
// retained only after the runtime-specific adapter has already allowlisted them.
|
|
55
|
+
export function commandCatalogForRuntime(runtime, nativeCommands = []) {
|
|
56
|
+
const id = cleanRuntime(runtime)
|
|
57
|
+
const byName = new Map()
|
|
58
|
+
for (const item of CODE_ROOM_COMMANDS) {
|
|
59
|
+
if (!item.runtimes.includes(id)) continue
|
|
60
|
+
byName.set(item.name, {
|
|
61
|
+
name: item.name,
|
|
62
|
+
description: item.description,
|
|
63
|
+
route: item.route,
|
|
64
|
+
...(item.inputHint ? { inputHint: item.inputHint } : {}),
|
|
65
|
+
})
|
|
66
|
+
}
|
|
67
|
+
for (const raw of (Array.isArray(nativeCommands) ? nativeCommands : [])) {
|
|
68
|
+
const item = normalizeNative(raw)
|
|
69
|
+
if (!item) continue
|
|
70
|
+
const shared = byName.get(item.name)
|
|
71
|
+
const hermesNativeControl = id === 'hermes' && ['/help', '/status', '/context', '/credits'].includes(item.name)
|
|
72
|
+
byName.set(item.name, shared
|
|
73
|
+
? { ...item, ...shared, description: item.description === 'runtime command' ? shared.description : item.description, ...(item.inputHint ? { inputHint: item.inputHint } : {}), ...(hermesNativeControl ? { route: 'runtime' } : {}) }
|
|
74
|
+
: item)
|
|
75
|
+
}
|
|
76
|
+
return [...byName.values()]
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export const commandNeedsInput = (item) => !!String(item?.inputHint || '').trim()
|
|
80
|
+
export const commandRoute = (item) => String(item?.route || 'runtime')
|
|
81
|
+
|
|
82
|
+
export function commandHelpLine(commands) {
|
|
83
|
+
return (Array.isArray(commands) ? commands : [])
|
|
84
|
+
.map((item) => `${item.name}${item.inputHint ? ` <${item.inputHint}>` : ''}`)
|
|
85
|
+
.join(' ')
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function normalizePermissionMode(value) {
|
|
89
|
+
const mode = String(value || '').trim().toLowerCase().replace(/[\s_-]+/g, '')
|
|
90
|
+
if (['normal', 'default', 'manual'].includes(mode)) return 'default'
|
|
91
|
+
if (['auto', 'autoaccept', 'autoacceptedits', 'acceptedits'].includes(mode)) return 'acceptEdits'
|
|
92
|
+
if (mode === 'plan') return 'plan'
|
|
93
|
+
if (['bypass', 'bypasspermissions', 'fullauto'].includes(mode)) return 'bypassPermissions'
|
|
94
|
+
return null
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Command catalogs are session chrome, but their ACP system event is also the
|
|
98
|
+
// lifecycle-ready signal. Keep those concerns separate so a reconstructed
|
|
99
|
+
// identical catalog cannot swallow warm/recap/auto-resume work.
|
|
100
|
+
export function reconcileCommandCatalog({ current, incoming, onChanged, onLifecycle } = {}) {
|
|
101
|
+
const hasIncoming = Array.isArray(incoming) && incoming.length > 0
|
|
102
|
+
const duplicate = hasIncoming && JSON.stringify(current || []) === JSON.stringify(incoming)
|
|
103
|
+
if (hasIncoming && !duplicate) onChanged?.(incoming)
|
|
104
|
+
onLifecycle?.({ duplicate, hasIncoming })
|
|
105
|
+
return duplicate
|
|
106
|
+
}
|
package/hermes-acp-bootstrap.py
CHANGED
|
@@ -6,7 +6,9 @@ Hermes' installed venv interpreter and passes a validated role policy in env.
|
|
|
6
6
|
"""
|
|
7
7
|
import json
|
|
8
8
|
import os
|
|
9
|
+
import re
|
|
9
10
|
import sys
|
|
11
|
+
from urllib.parse import urlsplit, urlunsplit
|
|
10
12
|
|
|
11
13
|
POLICY_ENV = "THINKPOOL_HERMES_ACP_POLICY"
|
|
12
14
|
|
|
@@ -146,6 +148,202 @@ acp_adapter.session._expand_acp_enabled_toolsets = constrained_expand
|
|
|
146
148
|
|
|
147
149
|
import acp_adapter.server
|
|
148
150
|
|
|
151
|
+
# ThinkPool-only commands live in this process patch rather than in Hermes'
|
|
152
|
+
# profile. They are intentionally narrow: no identity, shared configuration,
|
|
153
|
+
# account tokens, or lifecycle/admin controls enter the room surface.
|
|
154
|
+
_TP_REASONING_CONFIG_ID = "thinkpool_reasoning_effort"
|
|
155
|
+
_TP_REASONING_LEVELS = frozenset({"none", "low", "medium", "high", "xhigh", "max"})
|
|
156
|
+
_TP_COMMANDS = (
|
|
157
|
+
{"name": "credits", "description": "Show safe Nous credit balance and top-up handoff"},
|
|
158
|
+
{"name": "status", "description": "Show session, model, context, version, and reasoning status"},
|
|
159
|
+
{"name": "reasoning", "description": "Set session-only reasoning effort", "input_hint": "low, medium, high, xhigh, max, none, or reset"},
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
_native_compact = getattr(acp_adapter.server.HermesACPAgent, "_cmd_compact", None)
|
|
163
|
+
def thinkpool_compact(self, args, state):
|
|
164
|
+
"""Run a genuine manual compact and never label an unchanged list success."""
|
|
165
|
+
if not state.history:
|
|
166
|
+
return "Nothing to compress — conversation is empty."
|
|
167
|
+
try:
|
|
168
|
+
agent = state.agent
|
|
169
|
+
if not getattr(agent, "compression_enabled", True):
|
|
170
|
+
return "Context compression is disabled for this agent."
|
|
171
|
+
if not hasattr(agent, "_compress_context"):
|
|
172
|
+
return "Context compression not available for this agent."
|
|
173
|
+
from agent.model_metadata import estimate_request_tokens_rough
|
|
174
|
+
original_history = state.history
|
|
175
|
+
original_count = len(original_history)
|
|
176
|
+
system_prompt = getattr(agent, "_cached_system_prompt", "") or ""
|
|
177
|
+
tools = getattr(agent, "tools", None) or None
|
|
178
|
+
original_tokens = estimate_request_tokens_rough(original_history, system_prompt=system_prompt, tools=tools)
|
|
179
|
+
original_session_db = getattr(agent, "_session_db", None)
|
|
180
|
+
try:
|
|
181
|
+
# ACP sessions keep one native identity. Manual compact must bypass
|
|
182
|
+
# an automatic-summary cooldown, matching Hermes' own documented
|
|
183
|
+
# `/compress` contract, while disabling session rotation here.
|
|
184
|
+
agent._session_db = None
|
|
185
|
+
compressed, _ = agent._compress_context(
|
|
186
|
+
original_history, system_prompt, approx_tokens=original_tokens,
|
|
187
|
+
task_id=state.session_id, force=True,
|
|
188
|
+
)
|
|
189
|
+
finally:
|
|
190
|
+
agent._session_db = original_session_db
|
|
191
|
+
if compressed is original_history:
|
|
192
|
+
return "Compression made no progress — context unchanged."
|
|
193
|
+
new_system_prompt = getattr(agent, "_cached_system_prompt", "") or system_prompt
|
|
194
|
+
new_tools = getattr(agent, "tools", None) or tools
|
|
195
|
+
new_tokens = estimate_request_tokens_rough(compressed, system_prompt=new_system_prompt, tools=new_tools)
|
|
196
|
+
# A shorter message list can still be a larger model request when the
|
|
197
|
+
# generated summary exceeds the discarded turns. That is not useful
|
|
198
|
+
# compaction. Restore the exact pre-attempt prompt/history and do not
|
|
199
|
+
# persist a boundary unless estimated request pressure actually falls.
|
|
200
|
+
if new_tokens >= original_tokens:
|
|
201
|
+
agent._cached_system_prompt = system_prompt
|
|
202
|
+
return (
|
|
203
|
+
"Compression made no progress — context unchanged "
|
|
204
|
+
f"(~{original_tokens:,} -> ~{new_tokens:,} estimated tokens)."
|
|
205
|
+
)
|
|
206
|
+
state.history = compressed
|
|
207
|
+
self.session_manager.save_session(state.session_id)
|
|
208
|
+
return (
|
|
209
|
+
f"Context compressed: {original_count} -> {len(compressed)} messages\n"
|
|
210
|
+
f"~{original_tokens:,} -> ~{new_tokens:,} tokens"
|
|
211
|
+
)
|
|
212
|
+
except Exception as error:
|
|
213
|
+
return f"Compression failed: {error}"
|
|
214
|
+
if _native_compact: acp_adapter.server.HermesACPAgent._cmd_compact = thinkpool_compact
|
|
215
|
+
|
|
216
|
+
def _reasoning_config(value):
|
|
217
|
+
value = str(value or "").strip().lower()
|
|
218
|
+
if value == "reset": return None
|
|
219
|
+
if value not in _TP_REASONING_LEVELS: raise ValueError("Usage: /reasoning [low|medium|high|xhigh|max|none|reset]")
|
|
220
|
+
return {"enabled": value != "none", **({"effort": value} if value != "none" else {})}
|
|
221
|
+
|
|
222
|
+
def _apply_reasoning(state):
|
|
223
|
+
config = getattr(state, "reasoning_config", None)
|
|
224
|
+
# AIAgent consumes reasoning_config; setting it on the fresh process-local
|
|
225
|
+
# agent is the important part. The state copy is carried across set_model.
|
|
226
|
+
state.agent.reasoning_config = dict(config) if isinstance(config, dict) else None
|
|
227
|
+
state.agent._reasoning_config = dict(config) if isinstance(config, dict) else None
|
|
228
|
+
|
|
229
|
+
def _safe_topup_url(value):
|
|
230
|
+
try:
|
|
231
|
+
parsed = urlsplit(str(value or ""))
|
|
232
|
+
host = (parsed.hostname or "").lower()
|
|
233
|
+
if (parsed.scheme != "https" or host != "portal.nousresearch.com"
|
|
234
|
+
or parsed.username is not None or parsed.password is not None
|
|
235
|
+
or parsed.port not in {None, 443}):
|
|
236
|
+
return None
|
|
237
|
+
# Never relay account-derived paths, userinfo, query, fragment, or a
|
|
238
|
+
# provider-selected subdomain. Org-pinned paths identify the account;
|
|
239
|
+
# the generic billing page is the only safe room handoff.
|
|
240
|
+
return urlunsplit(("https", "portal.nousresearch.com", "/billing", "", ""))
|
|
241
|
+
except Exception:
|
|
242
|
+
return None
|
|
243
|
+
|
|
244
|
+
def _credits_command():
|
|
245
|
+
try:
|
|
246
|
+
from agent.account_usage import build_credits_view
|
|
247
|
+
view = build_credits_view(markdown=True)
|
|
248
|
+
except Exception:
|
|
249
|
+
return "Credits are unavailable right now; no balance was inferred."
|
|
250
|
+
if view is None or not getattr(view, "logged_in", False):
|
|
251
|
+
return "Credits are unavailable because this Hermes account is not signed in."
|
|
252
|
+
lines = ["Nous credits"]
|
|
253
|
+
# Never echo provider-rendered lines whole. Reconstruct only the exact
|
|
254
|
+
# numeric balance shapes produced by Hermes' current account core; an
|
|
255
|
+
# otherwise-valid prefix with an identity/credential suffix must fail the
|
|
256
|
+
# full match rather than smuggling that suffix into the room transcript.
|
|
257
|
+
balance = re.compile(r"^(Subscription credits|Top-up credits|Total usable|Rollover):\s*\$([0-9][0-9,]*(?:\.[0-9]{2})?)$")
|
|
258
|
+
for line in list(getattr(view, "balance_lines", []) or []):
|
|
259
|
+
rendered = str(line).strip()
|
|
260
|
+
match = balance.fullmatch(rendered)
|
|
261
|
+
if match: lines.append(f"{match.group(1)}: ${match.group(2)}")
|
|
262
|
+
topup = _safe_topup_url(getattr(view, "topup_url", None))
|
|
263
|
+
if topup: lines.extend(["", "Top up: " + topup])
|
|
264
|
+
if len(lines) == 1: lines.append("Balance details are unavailable; no value was inferred.")
|
|
265
|
+
return "\n".join(lines)
|
|
266
|
+
|
|
267
|
+
_available_commands = getattr(acp_adapter.server.HermesACPAgent, "_available_commands", None)
|
|
268
|
+
@classmethod
|
|
269
|
+
def thinkpool_available_commands(cls):
|
|
270
|
+
# Keep the upstream catalog canonical, then append exactly our process-local
|
|
271
|
+
# commands. This drives ACP updates and /help from one registry.
|
|
272
|
+
base = list(_available_commands.__func__(cls)) if _available_commands else []
|
|
273
|
+
try:
|
|
274
|
+
from acp.schema import AvailableCommand, UnstructuredCommandInput
|
|
275
|
+
known = {getattr(item, "name", "") for item in base}
|
|
276
|
+
for spec in _TP_COMMANDS:
|
|
277
|
+
if spec["name"] not in known:
|
|
278
|
+
hint = spec.get("input_hint")
|
|
279
|
+
base.append(AvailableCommand(name=spec["name"], description=spec["description"], input=UnstructuredCommandInput(hint=hint) if hint else None))
|
|
280
|
+
except Exception:
|
|
281
|
+
# In fixture/minimal ACP environments a dict still proves the registry
|
|
282
|
+
# behavior without making bootstrap startup fail.
|
|
283
|
+
base.extend(spec for spec in _TP_COMMANDS if spec["name"] not in {getattr(item, "name", item.get("name", "") if isinstance(item, dict) else "") for item in base})
|
|
284
|
+
return base
|
|
285
|
+
if _available_commands: acp_adapter.server.HermesACPAgent._available_commands = thinkpool_available_commands
|
|
286
|
+
|
|
287
|
+
_slash = getattr(acp_adapter.server.HermesACPAgent, "_handle_slash_command", None)
|
|
288
|
+
def thinkpool_slash(self, text, state):
|
|
289
|
+
parts = str(text or "").split(maxsplit=1)
|
|
290
|
+
command = parts[0].lstrip("/").lower() if parts else ""
|
|
291
|
+
args = parts[1].strip() if len(parts) > 1 else ""
|
|
292
|
+
if command == "credits": return _credits_command()
|
|
293
|
+
if command == "reasoning":
|
|
294
|
+
if not args:
|
|
295
|
+
cfg = getattr(state, "reasoning_config", None)
|
|
296
|
+
level = "reset/default" if not cfg else ("none" if cfg.get("enabled") is False else cfg.get("effort", "medium"))
|
|
297
|
+
return "Session reasoning: " + level
|
|
298
|
+
try: state.reasoning_config = _reasoning_config(args)
|
|
299
|
+
except ValueError as error: return str(error)
|
|
300
|
+
_apply_reasoning(state)
|
|
301
|
+
self.session_manager.save_session(state.session_id)
|
|
302
|
+
return "Session reasoning " + ("reset to model default" if args.lower() == "reset" else "set to " + args.lower())
|
|
303
|
+
if command == "status":
|
|
304
|
+
context = self._cmd_context("", state)
|
|
305
|
+
model = self._cmd_model("", state)
|
|
306
|
+
version = self._cmd_version("", state)
|
|
307
|
+
cfg = getattr(state, "reasoning_config", None)
|
|
308
|
+
effort = "default" if not cfg else ("none" if cfg.get("enabled") is False else cfg.get("effort", "medium"))
|
|
309
|
+
return "\n".join([model, "Session: " + str(state.session_id), "Reasoning: " + effort, version, context])
|
|
310
|
+
return _slash(self, text, state) if _slash else None
|
|
311
|
+
if _slash: acp_adapter.server.HermesACPAgent._handle_slash_command = thinkpool_slash
|
|
312
|
+
|
|
313
|
+
_help = getattr(acp_adapter.server.HermesACPAgent, "_cmd_help", None)
|
|
314
|
+
def thinkpool_help(self, args, state):
|
|
315
|
+
try:
|
|
316
|
+
lines = ["Available commands:", ""]
|
|
317
|
+
for item in self._available_commands():
|
|
318
|
+
name = getattr(item, "name", "")
|
|
319
|
+
description = getattr(item, "description", "")
|
|
320
|
+
if isinstance(item, dict): name, description = item.get("name", ""), item.get("description", "")
|
|
321
|
+
lines.append(f" /{name:10s} {description}")
|
|
322
|
+
return "\n".join(lines)
|
|
323
|
+
except Exception:
|
|
324
|
+
return _help(self, args, state) if _help else "Available commands unavailable."
|
|
325
|
+
if _help: acp_adapter.server.HermesACPAgent._cmd_help = thinkpool_help
|
|
326
|
+
|
|
327
|
+
_set_config = getattr(acp_adapter.server.HermesACPAgent, "set_config_option", None)
|
|
328
|
+
async def thinkpool_set_config(self, config_id, session_id, value, **kwargs):
|
|
329
|
+
if str(config_id) != _TP_REASONING_CONFIG_ID:
|
|
330
|
+
return await _set_config(self, config_id, session_id, value, **kwargs) if _set_config else None
|
|
331
|
+
state = self.session_manager.get_session(session_id)
|
|
332
|
+
if state is None: return None
|
|
333
|
+
try: state.reasoning_config = _reasoning_config(value)
|
|
334
|
+
except ValueError: return None
|
|
335
|
+
_apply_reasoning(state)
|
|
336
|
+
self.session_manager.save_session(session_id)
|
|
337
|
+
try:
|
|
338
|
+
from acp.schema import SetSessionConfigOptionResponse
|
|
339
|
+
return SetSessionConfigOptionResponse(config_options=[])
|
|
340
|
+
except Exception:
|
|
341
|
+
# Minimal fixture/older ACP environments still need a JSON-serializable
|
|
342
|
+
# response; a bare object wedges the JSON-RPC encoder after the config
|
|
343
|
+
# was already applied and makes the bridge time out dishonestly.
|
|
344
|
+
return {"configOptions": []}
|
|
345
|
+
acp_adapter.server.HermesACPAgent.set_config_option = thinkpool_set_config
|
|
346
|
+
|
|
149
347
|
# Hermes 0.18.2 emits the real result through ``tool.completed`` immediately,
|
|
150
348
|
# but its ACP callback ignores that event and waits for the next model-step
|
|
151
349
|
# summary. Parallel Read/search calls are not always present in that summary,
|
|
@@ -205,6 +403,7 @@ async def constrained_register(self, state, mcp_servers):
|
|
|
205
403
|
# a subsequent set_model reconstruction can re-register the same MCP.
|
|
206
404
|
state._thinkpool_mcp_servers = tuple(mcp_servers)
|
|
207
405
|
await _register(self, state, mcp_servers)
|
|
406
|
+
_apply_reasoning(state)
|
|
208
407
|
assert_exact_inventory(state.agent)
|
|
209
408
|
acp_adapter.server.HermesACPAgent._register_session_mcp_servers = constrained_register
|
|
210
409
|
|
|
@@ -226,6 +425,7 @@ async def constrained_set_model(self, model_id, session_id, **kwargs):
|
|
|
226
425
|
if MCP_TOOLS and not servers:
|
|
227
426
|
raise RuntimeError("ThinkPool MCP registration is unavailable after model switch")
|
|
228
427
|
await constrained_register(self, state, list(servers))
|
|
428
|
+
_apply_reasoning(state)
|
|
229
429
|
assert_exact_inventory(state.agent)
|
|
230
430
|
self.session_manager.save_session(session_id)
|
|
231
431
|
return result
|
package/hermes-event-mapper.mjs
CHANGED
|
@@ -112,7 +112,18 @@ export class HermesEventMapper {
|
|
|
112
112
|
return
|
|
113
113
|
}
|
|
114
114
|
case 'available_commands_update':
|
|
115
|
-
|
|
115
|
+
// ACP commands carry useful UI metadata. Keep strings accepted for
|
|
116
|
+
// older runtimes, but never flatten a native catalog on the way out.
|
|
117
|
+
this._emit({ kind: 'system', sessionId: this.sessionId, model: this.model, commands: (update.availableCommands || []).map((command) => {
|
|
118
|
+
if (typeof command === 'string') return command.startsWith('/') ? command : `/${command}`
|
|
119
|
+
const name = String(command?.name || '').replace(/^\/+/, '')
|
|
120
|
+
const hint = command?.input?.hint || command?.inputHint || command?.input_hint || ''
|
|
121
|
+
return {
|
|
122
|
+
name: `/${name}`,
|
|
123
|
+
description: String(command?.description || ''),
|
|
124
|
+
...(hint ? { inputHint: String(hint) } : {}),
|
|
125
|
+
}
|
|
126
|
+
}).filter((command) => typeof command === 'string' ? command !== '/' : command.name !== '/') })
|
|
116
127
|
return
|
|
117
128
|
case 'current_mode_update':
|
|
118
129
|
this._emit({ kind: 'mode', mode: update.currentModeId })
|
|
@@ -160,4 +171,17 @@ export class HermesEventMapper {
|
|
|
160
171
|
this.thought = ''
|
|
161
172
|
this.textCid = null
|
|
162
173
|
}
|
|
174
|
+
|
|
175
|
+
compactOutcome() {
|
|
176
|
+
// /compact is handled locally by Hermes and its response is the only
|
|
177
|
+
// authoritative success signal. Do not infer a reset from a successful
|
|
178
|
+
// ACP envelope: no-op and failure also return end_turn.
|
|
179
|
+
const text = String(this.text || '')
|
|
180
|
+
const hit = text.match(/Context compressed:\s*(\d+)\s*->\s*(\d+)\s*messages[\s\S]*?~?([\d,]+)\s*->\s*~?([\d,]+)\s*tokens/i)
|
|
181
|
+
if (!hit) return null
|
|
182
|
+
return {
|
|
183
|
+
preMessages: Number(hit[1]), postMessages: Number(hit[2]),
|
|
184
|
+
preTokens: Number(hit[3].replace(/,/g, '')), postTokens: Number(hit[4].replace(/,/g, '')),
|
|
185
|
+
}
|
|
186
|
+
}
|
|
163
187
|
}
|
package/hermes-session.mjs
CHANGED
|
@@ -15,6 +15,8 @@ import { buildThinkPoolTurnGuidance, createRoomContextSelector, usesFullThinkPoo
|
|
|
15
15
|
export const HERMES_COMMAND = 'thinkpool'
|
|
16
16
|
export const HERMES_ACP_PROTOCOL_VERSION = 1
|
|
17
17
|
export const HERMES_SUPPORTED_MODES = new Set(['default', 'acceptEdits', 'plan', 'bypassPermissions'])
|
|
18
|
+
export const HERMES_EFFORT_LEVELS = new Set(['none', 'low', 'medium', 'high', 'xhigh', 'max'])
|
|
19
|
+
export const HERMES_EFFORT_CONFIG_ID = 'thinkpool_reasoning_effort'
|
|
18
20
|
const HERMES_INITIALIZE_TIMEOUT_MS = 15_000
|
|
19
21
|
const PLAN_SAFE_MCP_TOOLS = new Set([...HERMES_PLAN_SAFE_MCP_TOOLS, 'read_review_file'])
|
|
20
22
|
|
|
@@ -64,7 +66,7 @@ export function startHermesSession({
|
|
|
64
66
|
roomContext, terminalRolePrompt, rolePrompt, mcpServers, requiredMcpTools = [], prepareCwd = null,
|
|
65
67
|
command = HERMES_COMMAND, args = ['acp'], clientFactory = createAcpClient,
|
|
66
68
|
mcpHttpFactory = startCodexMcpHttp, lazy = false, hermesRole = null,
|
|
67
|
-
crossPostGate = null, didSpawnTarget = null, crossRoomPostGate = null,
|
|
69
|
+
crossPostGate = null, didSpawnTarget = null, crossRoomPostGate = null, effort = 'high',
|
|
68
70
|
} = {}) {
|
|
69
71
|
let activeCwd = cwd
|
|
70
72
|
const requestedModel = model || null
|
|
@@ -93,6 +95,10 @@ export function startHermesSession({
|
|
|
93
95
|
let modelSwitchPending = false
|
|
94
96
|
let bootCancelled = false
|
|
95
97
|
let suppressNextSessionPublish = false
|
|
98
|
+
let activeEffort = effort === null ? null : (HERMES_EFFORT_LEVELS.has(effort) ? effort : 'high')
|
|
99
|
+
const queuedTurns = []
|
|
100
|
+
let drainingQueuedTurns = false
|
|
101
|
+
let queueDrainPending = false
|
|
96
102
|
const policyRole = hermesRole || hermesRoleFor({})
|
|
97
103
|
|
|
98
104
|
const effectivePolicyRole = () => activeMode === 'plan' ? 'plan' : policyRole
|
|
@@ -170,8 +176,12 @@ export function startHermesSession({
|
|
|
170
176
|
if (inventoryProbe) {
|
|
171
177
|
if (update?.sessionUpdate === 'agent_message_chunk' && update?.content?.type === 'text') {
|
|
172
178
|
inventoryProbe.push(String(update.content.text || ''))
|
|
179
|
+
return
|
|
173
180
|
}
|
|
174
|
-
|
|
181
|
+
// `/tools` is a local readiness transaction, but Hermes can publish its
|
|
182
|
+
// command catalog (and other session chrome) concurrently with that
|
|
183
|
+
// response. Suppress only the probe's assistant text; dropping every
|
|
184
|
+
// notification here made the real native catalog disappear permanently.
|
|
175
185
|
}
|
|
176
186
|
if (resuming && HERMES_REPLAY_UPDATES.has(update?.sessionUpdate)) return
|
|
177
187
|
mapper?.push(params)
|
|
@@ -343,6 +353,10 @@ export function startHermesSession({
|
|
|
343
353
|
await client.request('session/set_model', { sessionId, modelId: activeModel })
|
|
344
354
|
await assertMcpReadiness()
|
|
345
355
|
}
|
|
356
|
+
// The bootstrap owns this config id and applies it to the process-local
|
|
357
|
+
// agent. Send it on new, resume and reconstructed sessions; it never
|
|
358
|
+
// touches Hermes' shared profile/config.yaml.
|
|
359
|
+
if (activeEffort) await client.request('session/set_config_option', { sessionId, configId: HERMES_EFFORT_CONFIG_ID, value: activeEffort })
|
|
346
360
|
const publishedModels = state?.models
|
|
347
361
|
? { ...state.models, currentModelId: activeModel }
|
|
348
362
|
: activeModel ? { currentModelId: activeModel, availableModels: [] } : state?.models
|
|
@@ -452,11 +466,45 @@ export function startHermesSession({
|
|
|
452
466
|
if (abortedTurns.has(turnId)) return result
|
|
453
467
|
turnActive = false
|
|
454
468
|
firstTurn = false
|
|
469
|
+
const compact = /^\s*\/compact\b/i.test(String(text || '')) ? mapper.compactOutcome() : null
|
|
470
|
+
if (compact) emit({ kind: 'compaction', trigger: 'manual', ...compact })
|
|
471
|
+
const reasoning = String(text || '').match(/^\s*\/reasoning\s+(low|medium|high|xhigh|max|none|reset)\s*$/i)
|
|
472
|
+
if (reasoning) {
|
|
473
|
+
const requested = reasoning[1].toLowerCase()
|
|
474
|
+
const confirmed = requested === 'reset'
|
|
475
|
+
? /Session reasoning reset to model default/i.test(mapper.text)
|
|
476
|
+
: new RegExp(`Session reasoning set to ${requested}`, 'i').test(mapper.text)
|
|
477
|
+
if (confirmed) {
|
|
478
|
+
activeEffort = requested === 'reset' ? null : requested
|
|
479
|
+
emit({ kind: 'effort', effort: activeEffort })
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
// Reserve the idle-looking result boundary for the existing FIFO before
|
|
483
|
+
// publishing it. An onEvent consumer can synchronously submit a new turn
|
|
484
|
+
// from the result callback; without this latch that turn starts ahead of
|
|
485
|
+
// already-queued work and violates /queue order.
|
|
486
|
+
queueDrainPending = queuedTurns.length > 0 && !ended && !abortedTurns.has(turnId)
|
|
455
487
|
mapper.finishTurn({ stopReason: result?.stopReason, usage: result?.usage })
|
|
488
|
+
if (queueDrainPending) void drainQueuedTurns()
|
|
456
489
|
}
|
|
457
490
|
return result
|
|
458
491
|
}
|
|
459
492
|
|
|
493
|
+
async function drainQueuedTurns() {
|
|
494
|
+
if (drainingQueuedTurns || ended || turnActive) return
|
|
495
|
+
drainingQueuedTurns = true
|
|
496
|
+
try {
|
|
497
|
+
while (queuedTurns.length && !ended && !turnActive) {
|
|
498
|
+
queueDrainPending = false
|
|
499
|
+
const next = queuedTurns.shift()
|
|
500
|
+
const turnId = ++activeTurnId
|
|
501
|
+
turnActive = true
|
|
502
|
+
try { await runPrompt(next.text, next.options, { steering: false, turnId, promptIndex: next.promptIndex, forceFull: next.forceFull }) }
|
|
503
|
+
catch (error) { turnActive = false; emit({ kind: 'error', message: `Hermes queued turn failed: ${error?.message || error}`, recoverable: true }) }
|
|
504
|
+
}
|
|
505
|
+
} finally { drainingQueuedTurns = false; queueDrainPending = false }
|
|
506
|
+
}
|
|
507
|
+
|
|
460
508
|
if (!lazy) queueMicrotask(() => { void boot().catch(() => {}) })
|
|
461
509
|
|
|
462
510
|
return {
|
|
@@ -465,6 +513,7 @@ export function startHermesSession({
|
|
|
465
513
|
get canSteer() { return !!client?.alive },
|
|
466
514
|
get started() { return started },
|
|
467
515
|
get models() { return [] },
|
|
516
|
+
get effort() { return activeEffort },
|
|
468
517
|
sendTurn(text, options = {}) {
|
|
469
518
|
if (ended) return false
|
|
470
519
|
const promptIndex = userPromptNo++
|
|
@@ -474,8 +523,22 @@ export function startHermesSession({
|
|
|
474
523
|
// authority to launch a fresh ACP process and resume the same native
|
|
475
524
|
// session id; this is the recovery path the old permanent latch blocked.
|
|
476
525
|
reviveAfterExplicitRetry()
|
|
526
|
+
const queued = String(text || '').match(/^\s*\/queue\s+([\s\S]+)$/i)
|
|
527
|
+
// A result callback may arrive after turnActive fell but before the FIFO
|
|
528
|
+
// drain claimed the next queued item. Keep any new human turn behind the
|
|
529
|
+
// already-visible queue instead of letting that micro-window reorder it.
|
|
530
|
+
if (queueDrainPending) {
|
|
531
|
+
queuedTurns.push({ text: queued ? queued[1] : String(text || ''), options, promptIndex, forceFull: thisTurnForceFull })
|
|
532
|
+
emit({ kind: 'queue-add', queued: true, depth: queuedTurns.length })
|
|
533
|
+
return true
|
|
534
|
+
}
|
|
477
535
|
// A busy prompt is a genuine ACP /steer call and may run concurrently.
|
|
478
536
|
if (turnActive) {
|
|
537
|
+
if (queued) {
|
|
538
|
+
queuedTurns.push({ text: queued[1], options, promptIndex, forceFull: thisTurnForceFull })
|
|
539
|
+
emit({ kind: 'queue-add', queued: true, depth: queuedTurns.length })
|
|
540
|
+
return true
|
|
541
|
+
}
|
|
479
542
|
const turnId = activeTurnId
|
|
480
543
|
void runPrompt(text, options, { steering: true, turnId, promptIndex, forceFull: thisTurnForceFull }).catch((error) => emit({ kind: 'error', message: `Hermes steering failed: ${error?.message || error}`, recoverable: true }))
|
|
481
544
|
return true
|
|
@@ -492,6 +555,8 @@ export function startHermesSession({
|
|
|
492
555
|
},
|
|
493
556
|
abort() {
|
|
494
557
|
if (!turnActive) return
|
|
558
|
+
queuedTurns.length = 0
|
|
559
|
+
queueDrainPending = false
|
|
495
560
|
const turnId = activeTurnId
|
|
496
561
|
abortedTurns.add(turnId)
|
|
497
562
|
// Bound retained turn ids while preserving any concurrent /steer request
|
|
@@ -523,6 +588,8 @@ export function startHermesSession({
|
|
|
523
588
|
end() {
|
|
524
589
|
ended = true
|
|
525
590
|
turnActive = false
|
|
591
|
+
queuedTurns.length = 0
|
|
592
|
+
queueDrainPending = false
|
|
526
593
|
if (sessionId && client?.alive) void client.request('session/close', { sessionId }, 1500).catch(() => {}).finally(() => client?.end())
|
|
527
594
|
else client?.end()
|
|
528
595
|
void mcpHttp?.close().catch(() => {})
|
|
@@ -578,7 +645,15 @@ export function startHermesSession({
|
|
|
578
645
|
}).catch((error) => emit({ kind: 'error', message: `Hermes mode switch failed: ${error?.message || error}`, recoverable: true }))
|
|
579
646
|
return true
|
|
580
647
|
},
|
|
581
|
-
setEffort() {
|
|
648
|
+
setEffort(nextEffort) {
|
|
649
|
+
if (turnActive || !HERMES_EFFORT_LEVELS.has(nextEffort)) return false
|
|
650
|
+
const requested = String(nextEffort)
|
|
651
|
+
void boot().then(() => client.request('session/set_config_option', { sessionId, configId: HERMES_EFFORT_CONFIG_ID, value: requested })).then(() => {
|
|
652
|
+
activeEffort = requested
|
|
653
|
+
emit({ kind: 'effort', effort: activeEffort })
|
|
654
|
+
}).catch((error) => emit({ kind: 'error', message: `Hermes reasoning change failed: ${error?.message || error}`, recoverable: true }))
|
|
655
|
+
return true
|
|
656
|
+
},
|
|
582
657
|
clearContext() {
|
|
583
658
|
if (turnActive) return false
|
|
584
659
|
return this.sendTurn('/reset')
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "thinkpool-pair",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.269",
|
|
4
4
|
"description": "Share a local coding-agent CLI (Claude Code, Codex, Gemini, Aider, …) into a ThinkPool Code room, live.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
"byok-detect.mjs",
|
|
16
16
|
"context-windows.mjs",
|
|
17
17
|
"claude-session.mjs",
|
|
18
|
+
"claude-command-catalog.mjs",
|
|
18
19
|
"codex-session.mjs",
|
|
19
20
|
"question-response.mjs",
|
|
20
21
|
"thinkpool-room-prompt.mjs",
|
|
@@ -25,6 +26,7 @@
|
|
|
25
26
|
"codex-mcp-http.mjs",
|
|
26
27
|
"lane-worktree.mjs",
|
|
27
28
|
"codex-event-mapper.mjs",
|
|
29
|
+
"codex-commands.mjs",
|
|
28
30
|
"acp-client.mjs",
|
|
29
31
|
"hermes-session.mjs",
|
|
30
32
|
"hermes-policy.mjs",
|
|
@@ -35,6 +37,7 @@
|
|
|
35
37
|
"hermes-isolation.mjs",
|
|
36
38
|
"hermes-delegation-guard.mjs",
|
|
37
39
|
"runtime-registry.mjs",
|
|
40
|
+
"command-catalog.mjs",
|
|
38
41
|
"runtime-session.mjs",
|
|
39
42
|
"turn-stall.mjs",
|
|
40
43
|
"update-gate.mjs",
|
package/runtime-registry.mjs
CHANGED
|
@@ -13,7 +13,7 @@ const RUNTIMES = Object.freeze({
|
|
|
13
13
|
}),
|
|
14
14
|
hermes: Object.freeze({
|
|
15
15
|
id: 'hermes', command: 'thinkpool', label: 'Hermes Agent', protocol: 'acp',
|
|
16
|
-
structured: true, flow: true, canSteer: true, images: true, nativeModelCatalog: true, catalogRequiresSession: true, effortControl:
|
|
16
|
+
structured: true, flow: true, canSteer: true, images: true, nativeModelCatalog: true, catalogRequiresSession: true, effortControl: true, defaultMode: 'default',
|
|
17
17
|
modes: Object.freeze(['default', 'acceptEdits', 'plan', 'bypassPermissions']),
|
|
18
18
|
beta: true,
|
|
19
19
|
}),
|
|
@@ -28,6 +28,25 @@ 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 structuredModeLocked = ({ flowRole, sliceType } = {}) => (
|
|
32
|
+
flowRole === 'conductor' || flowRole === 'reviewer' || sliceType === 'review'
|
|
33
|
+
)
|
|
34
|
+
export const structuredModeForSlice = (runtime, lane = {}) => {
|
|
35
|
+
if (lane.flowRole !== 'reviewer' && lane.sliceType !== 'review') return lane.mode
|
|
36
|
+
if (structuredRuntimeSupportsMode(runtime, 'review')) return 'review'
|
|
37
|
+
if (structuredRuntimeSupportsMode(runtime, 'plan')) return 'plan'
|
|
38
|
+
return lane.mode
|
|
39
|
+
}
|
|
40
|
+
export const structuredModesForLane = (runtime, lane = {}) => {
|
|
41
|
+
const modes = structuredRuntimeMetadata(runtime)?.modes || []
|
|
42
|
+
return structuredModeLocked(lane) && modes.includes(lane.mode) ? [lane.mode] : modes
|
|
43
|
+
}
|
|
44
|
+
const STRUCTURED_EFFORTS = new Set(['low', 'medium', 'high', 'xhigh', 'max'])
|
|
45
|
+
export const normalizeStructuredEffort = (runtime, effort) => {
|
|
46
|
+
if (effort === null) return null
|
|
47
|
+
if (runtime === 'hermes' && effort === 'none') return 'none'
|
|
48
|
+
return STRUCTURED_EFFORTS.has(effort) ? effort : 'high'
|
|
49
|
+
}
|
|
31
50
|
export const shouldDeferStructuredRuntime = ({ runtime, defer, cwd, flowSessionId, flowTaskKey, models } = {}) => {
|
|
32
51
|
const metadata = structuredRuntimeMetadata(runtime)
|
|
33
52
|
// Some ACP agents publish their model catalog only from session/new or
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schemaVersion": 1,
|
|
3
|
-
"bundleVersion":
|
|
3
|
+
"bundleVersion": 2,
|
|
4
4
|
"contracts": [
|
|
5
5
|
{
|
|
6
6
|
"id": "room-coordination",
|
|
@@ -37,16 +37,16 @@
|
|
|
37
37
|
},
|
|
38
38
|
{
|
|
39
39
|
"id": "work-routing",
|
|
40
|
-
"version":
|
|
40
|
+
"version": 2,
|
|
41
41
|
"routes": [
|
|
42
42
|
{
|
|
43
43
|
"id": "work-routing",
|
|
44
44
|
"tools": ["spawn_terminal", "open_main_terminal", "close_terminal"],
|
|
45
45
|
"trigger": "\\b(parallel|delegate|worker|sub[- ]?terminal|conductor|cascade|spawn_terminal|open_main_terminal|close_terminal)\\b",
|
|
46
|
-
"prompt": "For genuinely decomposable work in a conductor-capable role, open visible worker slices with spawn_terminal, collect and verify them, then close_terminal. An explicitly requested separate Cascade/conductor uses open_main_terminal, never spawn_terminal."
|
|
46
|
+
"prompt": "For genuinely decomposable work in a conductor-capable role, open visible worker slices with spawn_terminal, collect and verify them, then close_terminal. An explicitly requested separate Cascade/conductor uses open_main_terminal, never spawn_terminal. Review slices are structurally read-only and must use the review slice type; they never inherit autonomous bypass authority."
|
|
47
47
|
}
|
|
48
48
|
],
|
|
49
|
-
"expandedPrompt": "CASCADE WORKFLOW (default for non-trivial work that genuinely decomposes; mandatory when the people ask for Cascade, tiered lanes, one-shot flow, or unattended multi-slice execution): first investigate enough to name evidence-backed slices and their dependencies, then post a short plan in the room. The current top-level/conductor terminal keeps decomposition, integration, and final judgment; use spawn_terminal only for bounded worker slices, while an explicitly requested separate conductor uses open_main_terminal. Parallelize only disjoint slices and encode dependencies instead of holding them in your head. Choose sliceType=scaffold for mechanical/search work, feature or fix for implementation, and review with a balanced capable tier for adversarial verification. Every worker brief names the evidence/diagnosis, exact scope, observable acceptance proof, relevant repo gates, whether merge/publish is required, and a safe-skip escape hatch. After dispatch, remain responsible: use read_terminal to collect each owned lane, verify its claim, close_terminal immediately, and dispatch newly unblocked work; never declare the Cascade done or yield a final result while owned workers remain uncollected. Check origin/main CI before merge-bearing waves, coordinate shared fixtures/publishers, and treat sibling pushes as context rather than proof. Run an adversarial review after builders (or pipeline review behind completed phases), finish with production-condition evidence, verify every reported SHA/version, and close every worker you opened.",
|
|
49
|
+
"expandedPrompt": "CASCADE WORKFLOW (default for non-trivial work that genuinely decomposes; mandatory when the people ask for Cascade, tiered lanes, one-shot flow, or unattended multi-slice execution): first investigate enough to name evidence-backed slices and their dependencies, then post a short plan in the room. The current top-level/conductor terminal keeps decomposition, integration, and final judgment; use spawn_terminal only for bounded worker slices, while an explicitly requested separate conductor uses open_main_terminal. Parallelize only disjoint slices and encode dependencies instead of holding them in your head. Choose sliceType=scaffold for mechanical/search work, feature or fix for implementation, and review with a balanced capable tier for adversarial verification. Review slices are structurally read-only: they resolve to a native review/Plan mode before launch and can never inherit autonomous bypass authority. Every worker brief names the evidence/diagnosis, exact scope, observable acceptance proof, relevant repo gates, whether merge/publish is required, and a safe-skip escape hatch. After dispatch, remain responsible: use read_terminal to collect each owned lane, verify its claim, close_terminal immediately, and dispatch newly unblocked work; never declare the Cascade done or yield a final result while owned workers remain uncollected. Check origin/main CI before merge-bearing waves, coordinate shared fixtures/publishers, and treat sibling pushes as context rather than proof. Run an adversarial review after builders (or pipeline review behind completed phases), finish with production-condition evidence, verify every reported SHA/version, and close every worker you opened.",
|
|
50
50
|
"impact": [
|
|
51
51
|
{"path": "bridge/bridge.mjs", "diffPattern": "spawn_terminal|open_main_terminal|close_terminal|cascadeRole|spawnDepth"},
|
|
52
52
|
{"path": "bridge/lane-lifecycle.mjs"},
|