thinkpool-pair 0.7.235 → 0.7.236

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.
@@ -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,10 @@ 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
64
69
 
65
70
  const emit = (event) => { try { onEvent?.(event) } catch { /* consumer isolation */ } }
66
71
 
@@ -79,7 +84,10 @@ export function startHermesSession({
79
84
  }
80
85
 
81
86
  function onNotification(method, params) {
82
- if (method === 'session/update') mapper?.push(params)
87
+ if (method !== 'session/update') return
88
+ const update = params?.update || params
89
+ if (resuming && HERMES_REPLAY_UPDATES.has(update?.sessionUpdate)) return
90
+ mapper?.push(params)
83
91
  }
84
92
 
85
93
  async function boot() {
@@ -101,13 +109,15 @@ export function startHermesSession({
101
109
  const profile = probeHermesRuntime({ command, env: childEnv })
102
110
  if (!profile.available) throw new Error(`Hermes profile safety check failed: ${profile.reason || 'runtime unavailable'}`)
103
111
  }
112
+ let retired = false
113
+ retireClient = () => { retired = true }
104
114
  client = clientFactory({
105
115
  command, args, cwd: activeCwd, env: childEnv,
106
116
  onNotification,
107
117
  onRequest,
108
118
  onStderr: (text) => { stderrTail = (stderrTail + text).slice(-2000) },
109
119
  onClose: (error) => {
110
- if (ended || crashed) return
120
+ if (ended || crashed || retired) return
111
121
  crashed = true
112
122
  turnActive = false
113
123
  emit({ kind: 'error', message: `Hermes ACP closed unexpectedly: ${error?.message || error}`, recoverable: true })
@@ -129,10 +139,14 @@ export function startHermesSession({
129
139
  if (!initialized?.agentCapabilities?.sessionCapabilities?.resume) {
130
140
  throw new Error(`Hermes session ${sessionId} cannot be resumed by this ACP release; no fresh session was substituted`)
131
141
  }
132
- try { state = await client.request('session/resume', { ...params, sessionId }, 30_000) }
142
+ try {
143
+ resuming = true
144
+ state = await client.request('session/resume', { ...params, sessionId }, 30_000)
145
+ }
133
146
  catch (error) {
134
147
  throw new Error(`Hermes session ${sessionId} is not available; no fresh session was substituted (${error?.message || error})`)
135
148
  }
149
+ finally { resuming = false }
136
150
  } else state = await client.request('session/new', params, 30_000)
137
151
  if (!sessionId) sessionId = state?.sessionId || null
138
152
  if (!sessionId) throw new Error('Hermes ACP did not return a session id')
@@ -158,6 +172,15 @@ export function startHermesSession({
158
172
  return starting
159
173
  }
160
174
 
175
+ async function restartAfterCancelledResponseFailure() {
176
+ const oldClient = client
177
+ retireClient()
178
+ client = null
179
+ mapper = null
180
+ oldClient?.end()
181
+ await boot()
182
+ }
183
+
161
184
  function promptBlocks(text, options = {}) {
162
185
  const rawText = String(text)
163
186
  // Hermes handles /steer, /queue, /compact and /reset only when the slash is
@@ -175,14 +198,33 @@ export function startHermesSession({
175
198
  async function runPrompt(text, options = {}) {
176
199
  await boot()
177
200
  const steering = turnActive
201
+ const turnId = steering ? activeTurnId : ++activeTurnId
178
202
  const body = steering ? `/steer ${String(text)}` : String(text)
179
203
  if (!steering) turnActive = true
180
- const result = await client.request('session/prompt', {
181
- sessionId,
182
- prompt: promptBlocks(body, options),
183
- messageId: randomUUID(),
184
- }, 0)
204
+ let result
205
+ try {
206
+ result = await client.request('session/prompt', {
207
+ sessionId,
208
+ prompt: promptBlocks(body, options),
209
+ messageId: randomUUID(),
210
+ }, 0)
211
+ } catch (error) {
212
+ // Hermes 0.18.2 successfully interrupts the model/tool, then its ACP
213
+ // prompt handler can reject with Internal error while constructing the
214
+ // cancelled response. The bridge already owns the canonical aborted
215
+ // boundary below, so a late response failure for that exact turn is not a
216
+ // second turn failure. Process-close errors still surface through onClose.
217
+ if (abortedTurns.has(turnId)) {
218
+ // Hermes 0.18.2 throws before clearing its transient is_running flag.
219
+ // Resume the same persisted session in a fresh per-lane ACP process so
220
+ // the next queued prompt cannot disappear into that stale runtime queue.
221
+ if (!ended) await restartAfterCancelledResponseFailure()
222
+ return { stopReason: 'cancelled' }
223
+ }
224
+ throw error
225
+ }
185
226
  if (!steering) {
227
+ if (abortedTurns.has(turnId)) return result
186
228
  turnActive = false
187
229
  firstTurn = false
188
230
  mapper.finishTurn({ stopReason: result?.stopReason, usage: result?.usage })
@@ -209,8 +251,23 @@ export function startHermesSession({
209
251
  return true
210
252
  },
211
253
  abort() {
212
- if (!sessionId || !client?.alive) return
213
- void client.notify('session/cancel', { sessionId }).catch(() => { /* process already closed */ })
254
+ if (!sessionId || !client?.alive || !turnActive) return
255
+ const turnId = activeTurnId
256
+ abortedTurns.add(turnId)
257
+ // Bound retained turn ids while preserving any concurrent /steer request
258
+ // that may settle after the primary prompt response.
259
+ if (abortedTurns.size > 32) abortedTurns.delete(abortedTurns.values().next().value)
260
+ turnActive = false
261
+ firstTurn = false
262
+ mapper?.finishTurn({ stopReason: 'cancelled' })
263
+ void client.notify('session/cancel', { sessionId }).catch((error) => {
264
+ // Never display a stopped lane while the un-cancelled Hermes process
265
+ // might still be executing. A transport failure tears down the ACP
266
+ // process and makes the lane honestly non-resumable until restart.
267
+ crashed = true
268
+ client?.end()
269
+ emit({ kind: 'error', message: `Hermes cancellation delivery failed; ACP process stopped: ${error?.message || error}`, recoverable: true })
270
+ })
214
271
  },
215
272
  end() {
216
273
  ended = true
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.236",
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": {