thinkpool-pair 0.7.267 → 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 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'
@@ -1773,7 +1774,7 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
1773
1774
  // spawnedBy: set when this lane was Dispatched (spawn_terminal). Restored from the
1774
1775
  // session store so the Ensemble flag survives a bridge restart (else a respin
1775
1776
  // stripped it and the lane reverted to a plain tab — the t6 "no chip" bug).
1776
- effort = new Set(['low', 'medium', 'high', 'xhigh', 'max']).has(effort) ? effort : 'high'
1777
+ effort = normalizeStructuredEffort(runtime, effort)
1777
1778
  // Structural role is durable and independent of the per-turn hop breaker. A
1778
1779
  // human can speak into a spawned lane (resetting hop to 0) without magically
1779
1780
  // turning that lane into a top-level terminal. Legacy records predate
@@ -2759,6 +2760,17 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
2759
2760
  announce()
2760
2761
  return
2761
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
2762
2774
  // Self-heal a stale resume — the saved SDK session expired. Reopen fresh,
2763
2775
  // keeping the transcript (scrollback survives; live context is gone).
2764
2776
  if (resume && evt.kind === 'error') {
@@ -2863,6 +2875,11 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
2863
2875
  // openStructured). Chrome events bypass pushLog/persist in emitTail below, so
2864
2876
  // usage needs an explicit persist() here to survive a bridge restart.
2865
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
+ }
2866
2883
  // FL-B2 — fold this flow lane's completed-turn output tokens into its budget so the
2867
2884
  // autopilot cap can halt the next wave before overrun (output_tokens = the billed
2868
2885
  // reasoning+output spend the indicator already tracks; conservative enough for a guard).
@@ -2885,7 +2902,6 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
2885
2902
  // The init system event carries the session's slash command list. Stash it
2886
2903
  // on the entry so the ANNOUNCE can hand it to clients that connect/reload
2887
2904
  // 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
2905
  // Auto-resume trigger (Max, 2026-07-02): the init `system` event means the SDK session
2890
2906
  // is LIVE (input stream now consumed). A restored mid-turn plain terminal flagged for
2891
2907
  // resume gets its single "continue" HERE — not on a blind timer that raced the ~40s
@@ -2937,7 +2953,7 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
2937
2953
  // card) and is intentionally NOT chrome, so it persists + replays.
2938
2954
  // 'suggestion' is Claude Code's predicted-next-prompt ghost text — ephemeral
2939
2955
  // 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'
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'
2941
2957
  // The compact turn settled with `entry.compacting` STILL TRUE — which, because the
2942
2958
  // `compaction` branch above clears the flag the moment the milestone lands, means
2943
2959
  // exactly one thing: NO compaction happened. Three ways to get here, all of which
@@ -3032,6 +3048,7 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
3032
3048
  // print locally, persist. Shared so a deferred image event re-enters it once
3033
3049
  // its base64 has been lifted to a Storage path (see deferImageEvent).
3034
3050
  const emitTail = (e) => {
3051
+ if (duplicateCommandCatalog) return
3035
3052
  if (!chrome) pushLog(entry, e)
3036
3053
  bcast('code-event', { term: id, evt: e })
3037
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
+ }
@@ -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
@@ -112,7 +112,18 @@ export class HermesEventMapper {
112
112
  return
113
113
  }
114
114
  case 'available_commands_update':
115
- this._emit({ kind: 'system', sessionId: this.sessionId, model: this.model, commands: (update.availableCommands || []).map((command) => `/${command.name}`) })
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
  }
@@ -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
- return
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() { return false },
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.267",
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",
@@ -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: false, defaultMode: 'default',
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