thinkpool-pair 0.7.266 → 0.7.268
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 +25 -5
- package/command-catalog.mjs +10 -0
- package/hermes-acp-bootstrap.py +200 -0
- package/hermes-event-mapper.mjs +25 -1
- package/hermes-session.mjs +78 -3
- package/package.json +2 -1
- package/reap-terminal.mjs +18 -4
- package/runtime-registry.mjs +7 -1
package/bridge.mjs
CHANGED
|
@@ -53,7 +53,8 @@ import { z } from 'zod'
|
|
|
53
53
|
import { readCodexDefaultModel, readCodexModels, codexConfigForMode, codexThreadCanResume } from './codex-session.mjs'
|
|
54
54
|
import { withMcpSessionFactory } from './codex-mcp-http.mjs'
|
|
55
55
|
import { startStructuredSession } from './runtime-session.mjs'
|
|
56
|
-
import { defaultStructuredMode, shouldDeferStructuredRuntime, structuredRuntimeForCommand, structuredRuntimeMetadata, structuredRuntimeSupportsMode } from './runtime-registry.mjs'
|
|
56
|
+
import { defaultStructuredMode, normalizeStructuredEffort, shouldDeferStructuredRuntime, structuredRuntimeForCommand, structuredRuntimeMetadata, structuredRuntimeSupportsMode } from './runtime-registry.mjs'
|
|
57
|
+
import { reconcileCommandCatalog } from './command-catalog.mjs'
|
|
57
58
|
import { probeHermesRuntime } from './hermes-probe.mjs'
|
|
58
59
|
import { hermesRequiredMcpTools, hermesRoleFor } from './hermes-policy.mjs'
|
|
59
60
|
import { canonicalRoomFilePath, waitForNativeImages } from './codex-images.mjs'
|
|
@@ -695,7 +696,10 @@ async function refreshOwnerPlan() {
|
|
|
695
696
|
// unit-tested reap-terminal.mjs. Fire-and-forget from endStructured — the module never
|
|
696
697
|
// throws (best-effort), so an unawaited promise can't reject.
|
|
697
698
|
function reapTerminalRow(id) {
|
|
698
|
-
return _reapTerminalRow({
|
|
699
|
+
return _reapTerminalRow({
|
|
700
|
+
id, token: codeAuthToken, supabaseUrl: SUPABASE_URL, anonKey: SUPABASE_ANON, shuttingDown,
|
|
701
|
+
onFailure: ({ error, status }) => process.stderr.write(`\n ⚠ terminal row reap failed (${String(id).slice(0, 8)}${status ? `, HTTP ${status}` : ''}): ${error || 'unknown error'}\n`),
|
|
702
|
+
})
|
|
699
703
|
}
|
|
700
704
|
// Auth: is the sender a PARTICIPANT of THIS room (owner OR granted OR joined)? Room mode
|
|
701
705
|
// serves the room, so RLS returns participants; we confirm the jwt's user is one of them.
|
|
@@ -1770,7 +1774,7 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
|
|
|
1770
1774
|
// spawnedBy: set when this lane was Dispatched (spawn_terminal). Restored from the
|
|
1771
1775
|
// session store so the Ensemble flag survives a bridge restart (else a respin
|
|
1772
1776
|
// stripped it and the lane reverted to a plain tab — the t6 "no chip" bug).
|
|
1773
|
-
effort =
|
|
1777
|
+
effort = normalizeStructuredEffort(runtime, effort)
|
|
1774
1778
|
// Structural role is durable and independent of the per-turn hop breaker. A
|
|
1775
1779
|
// human can speak into a spawned lane (resetting hop to 0) without magically
|
|
1776
1780
|
// turning that lane into a top-level terminal. Legacy records predate
|
|
@@ -2756,6 +2760,17 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
|
|
|
2756
2760
|
announce()
|
|
2757
2761
|
return
|
|
2758
2762
|
}
|
|
2763
|
+
// ACP replays its command catalog after resume/model reconstruction. A
|
|
2764
|
+
// byte-identical catalog is chrome, not a new transcript fact. Do not
|
|
2765
|
+
// return here: this same system event is the lifecycle latch for warm,
|
|
2766
|
+
// carried-recap, and interrupted-turn recovery.
|
|
2767
|
+
const duplicateCommandCatalog = evt.kind === 'system'
|
|
2768
|
+
? reconcileCommandCatalog({
|
|
2769
|
+
current: entry.commands,
|
|
2770
|
+
incoming: evt.commands,
|
|
2771
|
+
onChanged: (commands) => { entry.commands = commands; announce(); persist() },
|
|
2772
|
+
})
|
|
2773
|
+
: false
|
|
2759
2774
|
// Self-heal a stale resume — the saved SDK session expired. Reopen fresh,
|
|
2760
2775
|
// keeping the transcript (scrollback survives; live context is gone).
|
|
2761
2776
|
if (resume && evt.kind === 'error') {
|
|
@@ -2860,6 +2875,11 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
|
|
|
2860
2875
|
// openStructured). Chrome events bypass pushLog/persist in emitTail below, so
|
|
2861
2876
|
// usage needs an explicit persist() here to survive a bridge restart.
|
|
2862
2877
|
if (evt.kind === 'usage') { entry.lastUsage = evt; persist() }
|
|
2878
|
+
if (evt.kind === 'effort') {
|
|
2879
|
+
entry.effort = evt.effort ?? null
|
|
2880
|
+
persist()
|
|
2881
|
+
announce()
|
|
2882
|
+
}
|
|
2863
2883
|
// FL-B2 — fold this flow lane's completed-turn output tokens into its budget so the
|
|
2864
2884
|
// autopilot cap can halt the next wave before overrun (output_tokens = the billed
|
|
2865
2885
|
// reasoning+output spend the indicator already tracks; conservative enough for a guard).
|
|
@@ -2882,7 +2902,6 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
|
|
|
2882
2902
|
// The init system event carries the session's slash command list. Stash it
|
|
2883
2903
|
// on the entry so the ANNOUNCE can hand it to clients that connect/reload
|
|
2884
2904
|
// AFTER init (the one-time code-event would miss them), then re-announce.
|
|
2885
|
-
if (evt.kind === 'system' && Array.isArray(evt.commands) && evt.commands.length && !entry.commands) { entry.commands = evt.commands; announce(); persist() }
|
|
2886
2905
|
// Auto-resume trigger (Max, 2026-07-02): the init `system` event means the SDK session
|
|
2887
2906
|
// is LIVE (input stream now consumed). A restored mid-turn plain terminal flagged for
|
|
2888
2907
|
// resume gets its single "continue" HERE — not on a blind timer that raced the ~40s
|
|
@@ -2934,7 +2953,7 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
|
|
|
2934
2953
|
// card) and is intentionally NOT chrome, so it persists + replays.
|
|
2935
2954
|
// 'suggestion' is Claude Code's predicted-next-prompt ghost text — ephemeral
|
|
2936
2955
|
// composer UI, broadcast to both clients but never logged/persisted/replayed.
|
|
2937
|
-
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'
|
|
2956
|
+
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'
|
|
2938
2957
|
// The compact turn settled with `entry.compacting` STILL TRUE — which, because the
|
|
2939
2958
|
// `compaction` branch above clears the flag the moment the milestone lands, means
|
|
2940
2959
|
// exactly one thing: NO compaction happened. Three ways to get here, all of which
|
|
@@ -3029,6 +3048,7 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
|
|
|
3029
3048
|
// print locally, persist. Shared so a deferred image event re-enters it once
|
|
3030
3049
|
// its base64 has been lifted to a Storage path (see deferImageEvent).
|
|
3031
3050
|
const emitTail = (e) => {
|
|
3051
|
+
if (duplicateCommandCatalog) return
|
|
3032
3052
|
if (!chrome) pushLog(entry, e)
|
|
3033
3053
|
bcast('code-event', { term: id, evt: e })
|
|
3034
3054
|
printLocal(e)
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// Command catalogs are session chrome, but their ACP system event is also the
|
|
2
|
+
// lifecycle-ready signal. Keep those two concerns separate so a reconstructed
|
|
3
|
+
// identical catalog cannot swallow warm/recap/auto-resume work.
|
|
4
|
+
export function reconcileCommandCatalog({ current, incoming, onChanged, onLifecycle } = {}) {
|
|
5
|
+
const hasIncoming = Array.isArray(incoming) && incoming.length > 0
|
|
6
|
+
const duplicate = hasIncoming && JSON.stringify(current || []) === JSON.stringify(incoming)
|
|
7
|
+
if (hasIncoming && !duplicate) onChanged?.(incoming)
|
|
8
|
+
onLifecycle?.({ duplicate, hasIncoming })
|
|
9
|
+
return duplicate
|
|
10
|
+
}
|
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.268",
|
|
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": {
|
|
@@ -35,6 +35,7 @@
|
|
|
35
35
|
"hermes-isolation.mjs",
|
|
36
36
|
"hermes-delegation-guard.mjs",
|
|
37
37
|
"runtime-registry.mjs",
|
|
38
|
+
"command-catalog.mjs",
|
|
38
39
|
"runtime-session.mjs",
|
|
39
40
|
"turn-stall.mjs",
|
|
40
41
|
"update-gate.mjs",
|
package/reap-terminal.mjs
CHANGED
|
@@ -24,10 +24,14 @@
|
|
|
24
24
|
// the feature, not the leak.
|
|
25
25
|
|
|
26
26
|
/**
|
|
27
|
-
* @returns {Promise<{reaped: boolean, reason: 'ok'|'shutdown'|'anon'|'no-id'|'error', error?: string}>}
|
|
27
|
+
* @returns {Promise<{reaped: boolean, reason: 'ok'|'shutdown'|'anon'|'no-id'|'error', error?: string, status?: number}>}
|
|
28
28
|
* Never throws — best-effort; the client reap (#205) is the fallback.
|
|
29
29
|
*/
|
|
30
|
-
export async function reapTerminalRow ({ id, token, supabaseUrl, anonKey, shuttingDown = false, fetchImpl = fetch }) {
|
|
30
|
+
export async function reapTerminalRow ({ id, token, supabaseUrl, anonKey, shuttingDown = false, fetchImpl = fetch, onFailure } = {}) {
|
|
31
|
+
const fail = (outcome) => {
|
|
32
|
+
try { onFailure?.(outcome) } catch { /* observability must not break a close */ }
|
|
33
|
+
return outcome
|
|
34
|
+
}
|
|
31
35
|
if (shuttingDown) return { reaped: false, reason: 'shutdown' }
|
|
32
36
|
if (!token) return { reaped: false, reason: 'anon' }
|
|
33
37
|
if (!id) return { reaped: false, reason: 'no-id' }
|
|
@@ -36,12 +40,22 @@ export async function reapTerminalRow ({ id, token, supabaseUrl, anonKey, shutti
|
|
|
36
40
|
// terminal id IS the code_terminals PK (client/bridge-minted uuid). RLS scopes the
|
|
37
41
|
// delete to this bridge's sessions, so id alone is sufficient + idempotent (a fast
|
|
38
42
|
// client tab may have reaped first — 204 / 0 rows is fine).
|
|
39
|
-
await fetchImpl(`${supabaseUrl}/rest/v1/code_terminals?id=eq.${encodeURIComponent(id)}`, {
|
|
43
|
+
const response = await fetchImpl(`${supabaseUrl}/rest/v1/code_terminals?id=eq.${encodeURIComponent(id)}`, {
|
|
40
44
|
method: 'DELETE',
|
|
41
45
|
headers: { apikey: anonKey, Authorization: `Bearer ${token}` },
|
|
42
46
|
})
|
|
47
|
+
// fetch resolves for HTTP failures. A 401/500 did not reap the row and must
|
|
48
|
+
// stay visible to the fire-and-forget caller as a failed best-effort cleanup.
|
|
49
|
+
if (!response?.ok) {
|
|
50
|
+
return fail({
|
|
51
|
+
reaped: false,
|
|
52
|
+
reason: 'error',
|
|
53
|
+
error: `HTTP ${response?.status ?? 'unknown'}${response?.statusText ? ` ${response.statusText}` : ''}`,
|
|
54
|
+
status: response?.status,
|
|
55
|
+
})
|
|
56
|
+
}
|
|
43
57
|
return { reaped: true, reason: 'ok' }
|
|
44
58
|
} catch (e) {
|
|
45
|
-
return { reaped: false, reason: 'error', error: e?.message }
|
|
59
|
+
return fail({ reaped: false, reason: 'error', error: e?.message })
|
|
46
60
|
}
|
|
47
61
|
}
|
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,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
|
+
const STRUCTURED_EFFORTS = new Set(['low', 'medium', 'high', 'xhigh', 'max'])
|
|
32
|
+
export const normalizeStructuredEffort = (runtime, effort) => {
|
|
33
|
+
if (effort === null) return null
|
|
34
|
+
if (runtime === 'hermes' && effort === 'none') return 'none'
|
|
35
|
+
return STRUCTURED_EFFORTS.has(effort) ? effort : 'high'
|
|
36
|
+
}
|
|
31
37
|
export const shouldDeferStructuredRuntime = ({ runtime, defer, cwd, flowSessionId, flowTaskKey, models } = {}) => {
|
|
32
38
|
const metadata = structuredRuntimeMetadata(runtime)
|
|
33
39
|
// Some ACP agents publish their model catalog only from session/new or
|