thinkpool-pair 0.7.235 → 0.7.237

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
@@ -2616,12 +2616,29 @@ function openStructured({ id, runtime = 'claude', model, effort, resume, log, co
2616
2616
  // run on different registered providers. (bridge-truth slice.)
2617
2617
  { const onBuiltin = entry.runtime !== 'hermes' && (!entry.provider || entry.provider === 'anthropic')
2618
2618
  const m = (entry.runtime === 'hermes' || onBuiltin) ? (evt.model || evt.ctx?.model) : null
2619
- if (m && m !== entry.model) { entry.model = m; announce() }
2619
+ if (m && m !== entry.model) { entry.model = m; announce(); if (evt.kind === 'model-switch') persist() }
2620
2620
  // A custom Anthropic-compatible endpoint may expose its own catalog through the
2621
2621
  // same SDK method. Never publish that as the ROOM's Claude catalog: its models
2622
2622
  // already render in the provider group and otherwise leak under ANTHROPIC.
2623
2623
  if (entry.runtime === 'hermes' && evt.kind === 'models' && Array.isArray(evt.models) && evt.models.length) { entry.models = evt.models; announce() }
2624
2624
  else if (onBuiltin && evt.kind === 'models' && Array.isArray(evt.models) && evt.models.length) { roomModels = evt.models; announce() } }
2625
+ // Hermes model changes are asynchronous ACP requests. Keep the previous model
2626
+ // authoritative until Hermes acknowledges session/set_model; then settle the
2627
+ // originating control line exactly once. A rejected model never poisons the
2628
+ // saved session or leaves a cross-device chip claiming the optimistic pick.
2629
+ if (evt.kind === 'model-switch') {
2630
+ const request = entry.pendingModelSwitch
2631
+ entry.pendingModelSwitch = null
2632
+ const requested = evt.requested || request?.model || 'requested model'
2633
+ const text = evt.status === 'success'
2634
+ ? `switched model → ${evt.model || requested}`
2635
+ : `couldn’t switch to ${requested} — still using ${entry.model || 'the previous model'}`
2636
+ const control = { kind: 'control', text, by: request?.by || null, cid: request?.cid || undefined }
2637
+ pushLog(entry, control)
2638
+ bcast('code-event', { term: id, evt: control })
2639
+ entry.flush?.()
2640
+ announce()
2641
+ }
2625
2642
  // Cache the last usage/ctx meter on the entry (persisted + replayed — see
2626
2643
  // openStructured). Chrome events bypass pushLog/persist in emitTail below, so
2627
2644
  // usage needs an explicit persist() here to survive a bridge restart.
@@ -2700,7 +2717,7 @@ function openStructured({ id, runtime = 'claude', model, effort, resume, log, co
2700
2717
  // card) and is intentionally NOT chrome, so it persists + replays.
2701
2718
  // 'suggestion' is Claude Code's predicted-next-prompt ghost text — ephemeral
2702
2719
  // composer UI, broadcast to both clients but never logged/persisted/replayed.
2703
- const chrome = evt.kind === 'mode' || evt.kind === 'usage' || evt.kind === 'clear' || evt.kind === 'compact' || evt.kind === 'stalled' || evt.kind === 'suggestion' || evt.kind === 'assistant_stream'
2720
+ const chrome = evt.kind === 'mode' || evt.kind === 'usage' || evt.kind === 'clear' || evt.kind === 'compact' || evt.kind === 'stalled' || evt.kind === 'suggestion' || evt.kind === 'assistant_stream' || evt.kind === 'model-switch'
2704
2721
  // The compact turn settled with `entry.compacting` STILL TRUE — which, because the
2705
2722
  // `compaction` branch above clears the flag the moment the milestone lands, means
2706
2723
  // exactly one thing: NO compaction happened. Three ways to get here, all of which
@@ -3428,10 +3445,19 @@ channel
3428
3445
  const mm = text.match(/^\/model\b\s*(\S+)?/)
3429
3446
  if (mm) {
3430
3447
  if (mm[1]) {
3448
+ if (s.runtime === 'hermes' && s.pendingModelSwitch) {
3449
+ ctlLine(`finish the current Hermes model switch before choosing another model`)
3450
+ return
3451
+ }
3431
3452
  if (s.session.setModel(mm[1]) === false) {
3432
3453
  ctlLine(`finish or stop the current ${structuredRuntimeMetadata(s.runtime)?.label || 'agent'} turn before switching models`)
3433
3454
  return
3434
3455
  }
3456
+ if (s.runtime === 'hermes') {
3457
+ s.pendingModelSwitch = { model: mm[1], by: payload.by, cid: payload.cid }
3458
+ bcast('code-event', { term: payload.term, evt: { kind: 'model-switch', status: 'pending', requested: mm[1], ts: Date.now() } })
3459
+ return
3460
+ }
3435
3461
  // Reflect the switch in the announce IMMEDIATELY (before any turn). The SDK
3436
3462
  // only echoes the resolved full id on the next turn, and meta/ctx model go
3437
3463
  // stale until then, so without this the chip snapped back to the old model
package/event-id.mjs CHANGED
@@ -61,7 +61,7 @@ export function makeSeqCounter(startAfter = 0) {
61
61
  // consumed a seq + set the durable archive floor while the client dropped them, leaving
62
62
  // the client's oldest RENDERED seq above the floor → a phantom "load earlier history"
63
63
  // pill on a fresh lane (models fires on every session init) that fetched nothing.
64
- export const NO_SEQ_KINDS = new Set(['system', 'thinking_tokens', 'effort', 'models', 'queue-add', 'queue-cancel', 'propose-done', 'update-available'])
64
+ export const NO_SEQ_KINDS = new Set(['system', 'thinking_tokens', 'effort', 'models', 'model-switch', 'queue-add', 'queue-cancel', 'propose-done', 'update-available'])
65
65
  export const seqable = (evt) => !!evt && typeof evt === 'object' && !NO_SEQ_KINDS.has(evt.kind)
66
66
 
67
67
  // Highest numeric `seq` across a log of events (0 when none) — the floor a restored
@@ -109,6 +109,18 @@ export class HermesEventMapper {
109
109
  }
110
110
 
111
111
  finishTurn({ stopReason = 'end_turn', usage = null } = {}) {
112
+ if (stopReason === 'cancelled') {
113
+ for (const [toolCallId, tool] of this.tools) {
114
+ this._emit({
115
+ kind: 'tool_result', toolUseId: toolCallId,
116
+ content: [{ type: 'text', text: '[cancelled]' }],
117
+ isError: false,
118
+ durationMs: tool.startedAt ? Date.now() - tool.startedAt : undefined,
119
+ parentToolUseId: null,
120
+ })
121
+ }
122
+ }
123
+ this.tools.clear()
112
124
  const blocks = []
113
125
  if (this.thought) blocks.push({ type: 'thinking', text: this.thought })
114
126
  if (this.text) blocks.push({ type: 'text', text: this.text })
package/hermes-probe.mjs CHANGED
@@ -8,11 +8,14 @@ export function probeHermesRuntime({ command = 'thinkpool', prefixArgs = [], exe
8
8
  const version = clean(versionOutput).match(/Hermes Agent v([^\s]+)/i)?.[1] || null
9
9
  execFile(command, [...prefixArgs, 'acp', '--check'], { encoding: 'utf8', env, timeout: 15_000, stdio: ['ignore', 'pipe', 'pipe'] })
10
10
  const hooksOutput = execFile(command, [...prefixArgs, 'hooks', 'list'], { encoding: 'utf8', env, timeout: 10_000, stdio: ['ignore', 'pipe', 'pipe'] })
11
+ const doctorOutput = execFile(command, [...prefixArgs, 'hooks', 'doctor'], { encoding: 'utf8', env, timeout: 10_000, stdio: ['ignore', 'pipe', 'pipe'] })
11
12
  const guardOutput = execFile(command, [...prefixArgs, 'hooks', 'test', 'pre_tool_call', '--for-tool', 'delegate_task'], { encoding: 'utf8', env, timeout: 10_000, stdio: ['ignore', 'pipe', 'pipe'] })
13
+ const doctorHealthy = /All shell hooks look healthy/i.test(doctorOutput)
14
+ && !/(?:issue\(s\) found|modified since approval|⚠)/i.test(doctorOutput)
12
15
  const delegationBlocked = /(?:action|decision)["']?\s*[:=]\s*["']block/i.test(guardOutput)
13
16
  && /ThinkPool blocks hidden Hermes delegation/i.test(guardOutput)
14
17
  && /pre_tool_call[\s\S]*delegate_task[\s\S]*(?:✓ allowed|allowed)/i.test(hooksOutput)
15
- if (!delegationBlocked) return { available: false, version, reason: 'delegate_task is not structurally blocked by the dedicated Hermes profile' }
18
+ if (!doctorHealthy || !delegationBlocked) return { available: false, version, reason: 'delegate_task guard is not healthy, unchanged, allowlisted, and structurally blocking in the dedicated Hermes profile' }
16
19
  return { available: true, version, acpProtocol: 1, delegationBlocked: true }
17
20
  } catch (error) {
18
21
  return { available: false, version: null, reason: clean(error?.stderr || error?.message || error) }
@@ -14,6 +14,7 @@ export const HERMES_ACP_PROTOCOL_VERSION = 1
14
14
  export const HERMES_SUPPORTED_MODES = new Set(['default', 'acceptEdits'])
15
15
 
16
16
  const HERMES_SECRET_ENV_KEY = /(?:TOKEN|SECRET|PASSWORD|PRIVATE_KEY|API_KEY|ACCESS_KEY|CREDENTIAL)/i
17
+ const HERMES_REPLAY_UPDATES = new Set(['agent_message_chunk', 'agent_thought_chunk', 'tool_call', 'tool_call_update', 'plan'])
17
18
  export const hermesChildEnv = (env = {}) => Object.fromEntries(
18
19
  Object.entries(env).filter(([key]) => !HERMES_SECRET_ENV_KEY.test(key)),
19
20
  )
@@ -61,6 +62,11 @@ export function startHermesSession({
61
62
  let firstTurn = true
62
63
  let stderrTail = ''
63
64
  let promptChain = Promise.resolve()
65
+ let activeTurnId = 0
66
+ const abortedTurns = new Set()
67
+ let retireClient = () => {}
68
+ let resuming = false
69
+ let modelSwitchPending = false
64
70
 
65
71
  const emit = (event) => { try { onEvent?.(event) } catch { /* consumer isolation */ } }
66
72
 
@@ -79,7 +85,10 @@ export function startHermesSession({
79
85
  }
80
86
 
81
87
  function onNotification(method, params) {
82
- if (method === 'session/update') mapper?.push(params)
88
+ if (method !== 'session/update') return
89
+ const update = params?.update || params
90
+ if (resuming && HERMES_REPLAY_UPDATES.has(update?.sessionUpdate)) return
91
+ mapper?.push(params)
83
92
  }
84
93
 
85
94
  async function boot() {
@@ -101,13 +110,15 @@ export function startHermesSession({
101
110
  const profile = probeHermesRuntime({ command, env: childEnv })
102
111
  if (!profile.available) throw new Error(`Hermes profile safety check failed: ${profile.reason || 'runtime unavailable'}`)
103
112
  }
113
+ let retired = false
114
+ retireClient = () => { retired = true }
104
115
  client = clientFactory({
105
116
  command, args, cwd: activeCwd, env: childEnv,
106
117
  onNotification,
107
118
  onRequest,
108
119
  onStderr: (text) => { stderrTail = (stderrTail + text).slice(-2000) },
109
120
  onClose: (error) => {
110
- if (ended || crashed) return
121
+ if (ended || crashed || retired) return
111
122
  crashed = true
112
123
  turnActive = false
113
124
  emit({ kind: 'error', message: `Hermes ACP closed unexpectedly: ${error?.message || error}`, recoverable: true })
@@ -129,10 +140,14 @@ export function startHermesSession({
129
140
  if (!initialized?.agentCapabilities?.sessionCapabilities?.resume) {
130
141
  throw new Error(`Hermes session ${sessionId} cannot be resumed by this ACP release; no fresh session was substituted`)
131
142
  }
132
- try { state = await client.request('session/resume', { ...params, sessionId }, 30_000) }
143
+ try {
144
+ resuming = true
145
+ state = await client.request('session/resume', { ...params, sessionId }, 30_000)
146
+ }
133
147
  catch (error) {
134
148
  throw new Error(`Hermes session ${sessionId} is not available; no fresh session was substituted (${error?.message || error})`)
135
149
  }
150
+ finally { resuming = false }
136
151
  } else state = await client.request('session/new', params, 30_000)
137
152
  if (!sessionId) sessionId = state?.sessionId || null
138
153
  if (!sessionId) throw new Error('Hermes ACP did not return a session id')
@@ -158,6 +173,15 @@ export function startHermesSession({
158
173
  return starting
159
174
  }
160
175
 
176
+ async function restartAfterCancelledResponseFailure() {
177
+ const oldClient = client
178
+ retireClient()
179
+ client = null
180
+ mapper = null
181
+ oldClient?.end()
182
+ await boot()
183
+ }
184
+
161
185
  function promptBlocks(text, options = {}) {
162
186
  const rawText = String(text)
163
187
  // Hermes handles /steer, /queue, /compact and /reset only when the slash is
@@ -175,14 +199,33 @@ export function startHermesSession({
175
199
  async function runPrompt(text, options = {}) {
176
200
  await boot()
177
201
  const steering = turnActive
202
+ const turnId = steering ? activeTurnId : ++activeTurnId
178
203
  const body = steering ? `/steer ${String(text)}` : String(text)
179
204
  if (!steering) turnActive = true
180
- const result = await client.request('session/prompt', {
181
- sessionId,
182
- prompt: promptBlocks(body, options),
183
- messageId: randomUUID(),
184
- }, 0)
205
+ let result
206
+ try {
207
+ result = await client.request('session/prompt', {
208
+ sessionId,
209
+ prompt: promptBlocks(body, options),
210
+ messageId: randomUUID(),
211
+ }, 0)
212
+ } catch (error) {
213
+ // Hermes 0.18.2 successfully interrupts the model/tool, then its ACP
214
+ // prompt handler can reject with Internal error while constructing the
215
+ // cancelled response. The bridge already owns the canonical aborted
216
+ // boundary below, so a late response failure for that exact turn is not a
217
+ // second turn failure. Process-close errors still surface through onClose.
218
+ if (abortedTurns.has(turnId)) {
219
+ // Hermes 0.18.2 throws before clearing its transient is_running flag.
220
+ // Resume the same persisted session in a fresh per-lane ACP process so
221
+ // the next queued prompt cannot disappear into that stale runtime queue.
222
+ if (!ended) await restartAfterCancelledResponseFailure()
223
+ return { stopReason: 'cancelled' }
224
+ }
225
+ throw error
226
+ }
185
227
  if (!steering) {
228
+ if (abortedTurns.has(turnId)) return result
186
229
  turnActive = false
187
230
  firstTurn = false
188
231
  mapper.finishTurn({ stopReason: result?.stopReason, usage: result?.usage })
@@ -209,8 +252,23 @@ export function startHermesSession({
209
252
  return true
210
253
  },
211
254
  abort() {
212
- if (!sessionId || !client?.alive) return
213
- void client.notify('session/cancel', { sessionId }).catch(() => { /* process already closed */ })
255
+ if (!sessionId || !client?.alive || !turnActive) return
256
+ const turnId = activeTurnId
257
+ abortedTurns.add(turnId)
258
+ // Bound retained turn ids while preserving any concurrent /steer request
259
+ // that may settle after the primary prompt response.
260
+ if (abortedTurns.size > 32) abortedTurns.delete(abortedTurns.values().next().value)
261
+ turnActive = false
262
+ firstTurn = false
263
+ mapper?.finishTurn({ stopReason: 'cancelled' })
264
+ void client.notify('session/cancel', { sessionId }).catch((error) => {
265
+ // Never display a stopped lane while the un-cancelled Hermes process
266
+ // might still be executing. A transport failure tears down the ACP
267
+ // process and makes the lane honestly non-resumable until restart.
268
+ crashed = true
269
+ client?.end()
270
+ emit({ kind: 'error', message: `Hermes cancellation delivery failed; ACP process stopped: ${error?.message || error}`, recoverable: true })
271
+ })
214
272
  },
215
273
  end() {
216
274
  ended = true
@@ -220,12 +278,16 @@ export function startHermesSession({
220
278
  void mcpHttp?.close().catch(() => {})
221
279
  },
222
280
  setModel(nextModel) {
223
- if (!nextModel || turnActive) return false
224
- void boot().then(() => client.request('session/set_model', { sessionId, modelId: String(nextModel) })).then(() => {
225
- activeModel = String(nextModel)
281
+ if (!nextModel || turnActive || modelSwitchPending) return false
282
+ const requested = String(nextModel)
283
+ modelSwitchPending = true
284
+ void boot().then(() => client.request('session/set_model', { sessionId, modelId: requested })).then(() => {
285
+ if (ended) return
286
+ activeModel = requested
226
287
  if (mapper) mapper.model = activeModel
227
- emit({ kind: 'note', text: `model → ${activeModel}` })
228
- }).catch((error) => emit({ kind: 'error', message: `Hermes model switch failed: ${error?.message || error}`, recoverable: true }))
288
+ emit({ kind: 'model-switch', status: 'success', model: activeModel, requested })
289
+ }).catch((error) => { if (!ended) emit({ kind: 'model-switch', status: 'failed', model: activeModel, requested, message: `Hermes model switch failed: ${error?.message || error}` }) })
290
+ .finally(() => { modelSwitchPending = false })
229
291
  return true
230
292
  },
231
293
  setMode(nextMode) {
package/hermes-setup.mjs CHANGED
@@ -39,11 +39,16 @@ function snapshotFile(filePath, fsImpl) {
39
39
  return {
40
40
  content: fsImpl.readFileSync(filePath),
41
41
  mode: fsImpl.statSync(filePath).mode & 0o777,
42
+ atimeMs: fsImpl.statSync(filePath).atimeMs,
43
+ mtimeMs: fsImpl.statSync(filePath).mtimeMs,
42
44
  }
43
45
  }
44
46
 
45
47
  function restoreFile(filePath, snapshot, fsImpl) {
46
- if (snapshot) atomicWrite(filePath, snapshot.content, { fsImpl, mode: snapshot.mode })
48
+ if (snapshot) {
49
+ atomicWrite(filePath, snapshot.content, { fsImpl, mode: snapshot.mode })
50
+ fsImpl.utimesSync?.(filePath, snapshot.atimeMs / 1000, snapshot.mtimeMs / 1000)
51
+ }
47
52
  else fsImpl.rmSync(filePath, { force: true })
48
53
  }
49
54
 
@@ -116,13 +121,19 @@ export function setupHermesRuntime({
116
121
  ]
117
122
 
118
123
  const guardPath = hermesGuardPath({ homeDir, env, platform })
124
+ const allowlistPath = path.join(profilePath, 'shell-hooks-allowlist.json')
119
125
  const guardSnapshot = snapshotFile(guardPath, fsImpl)
120
126
  const configSnapshot = snapshotFile(configPath, fsImpl)
127
+ const allowlistSnapshot = snapshotFile(allowlistPath, fsImpl)
121
128
  try {
122
129
  atomicWrite(guardPath, guardSource, { fsImpl, mode: 0o700 })
123
130
  atomicWrite(configPath, YAML.stringify(config, { lineWidth: 0 }), { fsImpl, mode: 0o600 })
124
131
 
125
132
  const consentEnv = { ...env, HERMES_ACCEPT_HOOKS: '1' }
133
+ // Hermes does not refresh an existing approval when the same command's
134
+ // script mtime changes. Revoke only ThinkPool's exact guard command, then
135
+ // let the synthetic test consent to that exact command again.
136
+ run([...profileArgs, 'hooks', 'revoke', commandText(guardPath)], { env: consentEnv })
126
137
  const testOutput = run([...profileArgs, 'hooks', 'test', 'pre_tool_call', '--for-tool', 'delegate_task'], { env: consentEnv })
127
138
  if (!/(?:action|decision)["']?\s*[:=]\s*["']block/i.test(testOutput)
128
139
  || !/ThinkPool blocks hidden Hermes delegation/i.test(testOutput)) {
@@ -137,6 +148,7 @@ export function setupHermesRuntime({
137
148
  try {
138
149
  restoreFile(configPath, configSnapshot, fsImpl)
139
150
  restoreFile(guardPath, guardSnapshot, fsImpl)
151
+ restoreFile(allowlistPath, allowlistSnapshot, fsImpl)
140
152
  } catch (rollbackError) {
141
153
  error.message = `${error.message}; rollback also failed: ${clean(rollbackError.message)}`
142
154
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thinkpool-pair",
3
- "version": "0.7.235",
3
+ "version": "0.7.237",
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": {
@@ -3,17 +3,17 @@ import path from 'node:path'
3
3
  const RUNTIMES = Object.freeze({
4
4
  claude: Object.freeze({
5
5
  id: 'claude', command: 'claude', label: 'Claude Code', protocol: 'agent-sdk',
6
- structured: true, flow: true, canSteer: false, images: true, nativeModelCatalog: false, defaultMode: 'default',
6
+ structured: true, flow: true, canSteer: false, images: true, nativeModelCatalog: false, effortControl: true, defaultMode: 'default',
7
7
  modes: Object.freeze(['default', 'acceptEdits', 'plan', 'bypassPermissions']),
8
8
  }),
9
9
  codex: Object.freeze({
10
10
  id: 'codex', command: 'codex', label: 'Codex', protocol: 'codex-app-server',
11
- structured: true, flow: true, canSteer: true, images: true, nativeModelCatalog: true, defaultMode: 'bypassPermissions',
11
+ structured: true, flow: true, canSteer: true, images: true, nativeModelCatalog: true, effortControl: true, defaultMode: 'bypassPermissions',
12
12
  modes: Object.freeze(['default', 'acceptEdits', 'plan', 'bypassPermissions', 'review']),
13
13
  }),
14
14
  hermes: Object.freeze({
15
15
  id: 'hermes', command: 'thinkpool', label: 'Hermes Agent', protocol: 'acp',
16
- structured: true, flow: false, canSteer: true, images: true, nativeModelCatalog: true, defaultMode: 'default',
16
+ structured: true, flow: false, canSteer: true, images: true, nativeModelCatalog: true, effortControl: false, defaultMode: 'default',
17
17
  modes: Object.freeze(['default', 'acceptEdits']),
18
18
  beta: true,
19
19
  }),