thinkpool-pair 0.7.234 → 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.
package/bridge.mjs CHANGED
@@ -264,6 +264,39 @@ if (argv[0] === 'set-default-dir') {
264
264
  process.exit(0)
265
265
  }
266
266
  if (argv[0] === 'provider') { const { runProvider } = await import('./provider.mjs'); await runProvider(argv.slice(1)); process.exit(0) }
267
+ if (argv[0] === 'setup-hermes') {
268
+ const clone = argv.includes('--clone')
269
+ const clean = argv.includes('--clean')
270
+ if (clone && clean) { console.error(' choose one Hermes setup mode: --clone or --clean'); process.exit(1) }
271
+ let mode = clone ? 'clone' : clean ? 'clean' : undefined
272
+ if (!mode && process.stdin.isTTY) {
273
+ const answer = await new Promise((resolve) => {
274
+ const rl = readline.createInterface({ input: process.stdin, output: process.stderr })
275
+ rl.question('\n Clone your active Hermes provider/config into an isolated thinkpool profile? [Y/n] ', (value) => { rl.close(); resolve(String(value).trim()) })
276
+ })
277
+ mode = /^n/i.test(answer) ? 'clean' : 'clone'
278
+ }
279
+ try {
280
+ const { setupHermesRuntime } = await import('./hermes-setup.mjs')
281
+ const result = setupHermesRuntime({ mode })
282
+ process.stderr.write(`\n ◆ Hermes ${result.version || ''} is ready for ThinkPool ordinary lanes.\n profile: ${result.profile} (${result.created ? `${result.mode} created` : 'existing'})\n hidden delegation: blocked\n${result.created && result.mode === 'clean' ? ' provider: run `thinkpool setup` before the first model turn\n' : ''}\n`)
283
+ process.exit(0)
284
+ } catch (error) {
285
+ console.error(`\n ◇ Hermes setup failed: ${error?.message || error}\n New profile: npx thinkpool-pair@latest setup-hermes --clone\n Clean profile: npx thinkpool-pair@latest setup-hermes --clean\n`)
286
+ process.exit(1)
287
+ }
288
+ }
289
+ if (argv[0] === 'verify-hermes-isolation') {
290
+ try {
291
+ const { verifyHermesMcpIsolation } = await import('./hermes-isolation.mjs')
292
+ const evidence = await verifyHermesMcpIsolation({ rounds: 2 })
293
+ process.stderr.write(`\n ◆ Hermes MCP isolation HELD for ${evidence.length} consecutive two-lane rounds.\n Each ACP session advertised only its lane-bound marker endpoint.\n No model inference was used.\n\n`)
294
+ process.exit(0)
295
+ } catch (error) {
296
+ console.error(`\n ◇ Hermes MCP isolation VIOLATED or unproven: ${error?.message || error}\n`)
297
+ process.exit(1)
298
+ }
299
+ }
267
300
 
268
301
  // One-command launcher: a bare invocation on a TTY (or the `setup` alias) opens the
269
302
  // interactive menu instead of going straight to account mode. The menu only ASSEMBLES
@@ -310,6 +343,12 @@ if ((!argv[0] || argv[0] === 'setup') && process.stdin.isTTY) {
310
343
  : ['anthropic']
311
344
  await runProvider(args)
312
345
  },
346
+ setupHermes: async ({ mode }) => {
347
+ const { setupHermesRuntime } = await import('./hermes-setup.mjs')
348
+ const result = setupHermesRuntime({ mode })
349
+ io.print(`\n ✓ Hermes ${result.version || ''} ready — isolated profile, ACP healthy, hidden delegation blocked.`)
350
+ if (result.created && result.mode === 'clean') io.print(' Run `thinkpool setup` once to choose a provider before the first model turn.')
351
+ },
313
352
  }
314
353
  await runLauncher({ actions, io, refresh: detectState })
315
354
  process.exit(0)
@@ -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 })
@@ -0,0 +1,59 @@
1
+ import fs from 'node:fs'
2
+ import os from 'node:os'
3
+ import path from 'node:path'
4
+ import { randomUUID } from 'node:crypto'
5
+ import { createSdkMcpServer, tool } from '@anthropic-ai/claude-agent-sdk'
6
+ import { startHermesSession } from './hermes-session.mjs'
7
+
8
+ const waitFor = (predicate, timeoutMs = 25_000) => new Promise((resolve, reject) => {
9
+ const started = Date.now()
10
+ const timer = setInterval(() => {
11
+ if (predicate()) { clearInterval(timer); resolve() }
12
+ else if (Date.now() - started > timeoutMs) { clearInterval(timer); reject(new Error('Hermes MCP isolation probe timed out')) }
13
+ }, 50)
14
+ })
15
+
16
+ export function assertHermesIsolation(outputs, markers) {
17
+ if (!Array.isArray(outputs) || outputs.length !== 2 || !Array.isArray(markers) || markers.length !== 2) {
18
+ throw new TypeError('Hermes MCP isolation requires exactly two session outputs and markers')
19
+ }
20
+ for (let i = 0; i < 2; i++) {
21
+ if (!outputs[i].includes(`mcp__thinkpool__${markers[i]}`)) throw new Error(`Hermes lane ${i + 1} did not advertise its own MCP marker`)
22
+ if (outputs[i].includes(`mcp__thinkpool__${markers[1 - i]}`)) throw new Error(`Hermes lane ${i + 1} advertised the other lane's MCP marker`)
23
+ }
24
+ return true
25
+ }
26
+
27
+ export async function verifyHermesMcpIsolation({ rounds = 2, sessionFactory = startHermesSession } = {}) {
28
+ const evidence = []
29
+ for (let round = 0; round < rounds; round++) {
30
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'tp-hermes-mcp-isolation-'))
31
+ const suffix = randomUUID().replaceAll('-', '').slice(0, 10)
32
+ const markers = [`lane_alpha_${suffix}`, `lane_beta_${suffix}`]
33
+ const events = [[], []]
34
+ const sessions = []
35
+ try {
36
+ for (let i = 0; i < 2; i++) {
37
+ const cwd = path.join(root, i === 0 ? 'alpha' : 'beta')
38
+ fs.mkdirSync(cwd)
39
+ const sdkServer = createSdkMcpServer({
40
+ name: 'thinkpool', version: '1.0.0',
41
+ tools: [tool(markers[i], 'Synthetic ThinkPool lane-isolation marker.', {}, async () => ({ content: [{ type: 'text', text: markers[i] }] }))],
42
+ })
43
+ sessions.push(sessionFactory({ cwd, lazy: true, mcpServers: { thinkpool: sdkServer }, onEvent: (event) => events[i].push(event) }))
44
+ }
45
+ sessions.forEach((session) => session.sendTurn('/tools'))
46
+ await waitFor(() => events.every((list) => list.some((event) => event.kind === 'result') || list.some((event) => event.kind === 'error')))
47
+ const errors = events.flatMap((list) => list.filter((event) => event.kind === 'error'))
48
+ if (errors.length) throw new Error(`Hermes MCP isolation probe failed: ${errors.map((event) => event.message).join('; ')}`)
49
+ const outputs = events.map((list) => list.filter((event) => event.kind === 'assistant_stream').map((event) => event.text).join(''))
50
+ assertHermesIsolation(outputs, markers)
51
+ evidence.push({ round: round + 1, markers, sessionIds: sessions.map((session) => session.sessionId) })
52
+ } finally {
53
+ sessions.forEach((session) => session.end())
54
+ await new Promise((resolve) => setTimeout(resolve, 250))
55
+ fs.rmSync(root, { recursive: true, force: true })
56
+ }
57
+ }
58
+ return evidence
59
+ }
package/hermes-probe.mjs CHANGED
@@ -2,17 +2,20 @@ import { execFileSync } from 'node:child_process'
2
2
 
3
3
  const clean = (value) => String(value || '').replace(/[\r\n]+/g, ' ').trim()
4
4
 
5
- export function probeHermesRuntime({ command = 'thinkpool', execFile = execFileSync, env = process.env } = {}) {
5
+ export function probeHermesRuntime({ command = 'thinkpool', prefixArgs = [], execFile = execFileSync, env = process.env } = {}) {
6
6
  try {
7
- const versionOutput = execFile(command, ['--version'], { encoding: 'utf8', env, timeout: 10_000, stdio: ['ignore', 'pipe', 'pipe'] })
7
+ const versionOutput = execFile(command, [...prefixArgs, '--version'], { encoding: 'utf8', env, timeout: 10_000, stdio: ['ignore', 'pipe', 'pipe'] })
8
8
  const version = clean(versionOutput).match(/Hermes Agent v([^\s]+)/i)?.[1] || null
9
- execFile(command, ['acp', '--check'], { encoding: 'utf8', env, timeout: 15_000, stdio: ['ignore', 'pipe', 'pipe'] })
10
- const hooksOutput = execFile(command, ['hooks', 'list'], { encoding: 'utf8', env, timeout: 10_000, stdio: ['ignore', 'pipe', 'pipe'] })
11
- const guardOutput = execFile(command, ['hooks', 'test', 'pre_tool_call', '--for-tool', 'delegate_task'], { encoding: 'utf8', env, timeout: 10_000, stdio: ['ignore', 'pipe', 'pipe'] })
9
+ execFile(command, [...prefixArgs, 'acp', '--check'], { encoding: 'utf8', env, timeout: 15_000, stdio: ['ignore', 'pipe', 'pipe'] })
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'] })
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
@@ -0,0 +1,157 @@
1
+ import fs from 'node:fs'
2
+ import os from 'node:os'
3
+ import path from 'node:path'
4
+ import { execFileSync } from 'node:child_process'
5
+ import YAML from 'yaml'
6
+ import { probeHermesRuntime } from './hermes-probe.mjs'
7
+
8
+ export const HERMES_PROFILE = 'thinkpool'
9
+ export const HERMES_GUARD_FILENAME = 'hermes-delegation-guard.mjs'
10
+
11
+ const clean = (value) => String(value || '').replace(/[\r\n]+/g, ' ').trim()
12
+ const commandText = (guardPath) => `node ${JSON.stringify(guardPath)}`
13
+ const isObject = (value) => value != null && typeof value === 'object' && !Array.isArray(value)
14
+ const isThinkPoolGuard = (entry) => isObject(entry)
15
+ && entry.matcher === '^delegate_task$'
16
+ && new RegExp(`${HERMES_GUARD_FILENAME.replaceAll('.', '\\.')}$`).test(String(entry.command || '').replace(/["']/g, ''))
17
+
18
+ export function hermesGuardPath({ homeDir = os.homedir(), env = process.env, platform = process.platform } = {}) {
19
+ const dataRoot = platform === 'win32'
20
+ ? (env.LOCALAPPDATA || path.join(homeDir, 'AppData', 'Local'))
21
+ : (env.XDG_DATA_HOME || path.join(homeDir, '.local', 'share'))
22
+ return path.join(dataRoot, 'thinkpool-pair', HERMES_GUARD_FILENAME)
23
+ }
24
+
25
+ function atomicWrite(filePath, content, { fsImpl = fs, mode = 0o600 } = {}) {
26
+ fsImpl.mkdirSync(path.dirname(filePath), { recursive: true })
27
+ const tempPath = `${filePath}.tmp-${process.pid}-${Date.now()}`
28
+ try {
29
+ fsImpl.writeFileSync(tempPath, content, { mode })
30
+ fsImpl.chmodSync?.(tempPath, mode)
31
+ fsImpl.renameSync(tempPath, filePath)
32
+ } finally {
33
+ try { fsImpl.rmSync(tempPath, { force: true }) } catch { /* already renamed */ }
34
+ }
35
+ }
36
+
37
+ function snapshotFile(filePath, fsImpl) {
38
+ if (!fsImpl.existsSync(filePath)) return null
39
+ return {
40
+ content: fsImpl.readFileSync(filePath),
41
+ mode: fsImpl.statSync(filePath).mode & 0o777,
42
+ atimeMs: fsImpl.statSync(filePath).atimeMs,
43
+ mtimeMs: fsImpl.statSync(filePath).mtimeMs,
44
+ }
45
+ }
46
+
47
+ function restoreFile(filePath, snapshot, fsImpl) {
48
+ if (snapshot) {
49
+ atomicWrite(filePath, snapshot.content, { fsImpl, mode: snapshot.mode })
50
+ fsImpl.utimesSync?.(filePath, snapshot.atimeMs / 1000, snapshot.mtimeMs / 1000)
51
+ }
52
+ else fsImpl.rmSync(filePath, { force: true })
53
+ }
54
+
55
+ function profileAliasReady(output, profile) {
56
+ const escaped = profile.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
57
+ return new RegExp(`Alias:\\s+${escaped}\\s+.*hermes\\s+-p\\s+${escaped}`, 'i').test(String(output || ''))
58
+ }
59
+
60
+ export function setupHermesRuntime({
61
+ mode,
62
+ profile = HERMES_PROFILE,
63
+ command = 'hermes',
64
+ env = process.env,
65
+ homeDir = os.homedir(),
66
+ platform = process.platform,
67
+ execFile = execFileSync,
68
+ fsImpl = fs,
69
+ guardSource = fs.readFileSync(new URL('./hermes-delegation-guard.mjs', import.meta.url), 'utf8'),
70
+ } = {}) {
71
+ if (profile !== HERMES_PROFILE) throw new Error(`ThinkPool only manages the isolated Hermes profile ${HERMES_PROFILE}`)
72
+ if (mode != null && mode !== 'clone' && mode !== 'clean') throw new Error('Hermes setup mode must be clone or clean')
73
+
74
+ const run = (args, options = {}) => execFile(command, args, {
75
+ encoding: 'utf8', env: options.env || env, timeout: options.timeout || 20_000,
76
+ stdio: ['ignore', 'pipe', 'pipe'],
77
+ })
78
+ const profileArgs = ['-p', profile]
79
+ let show = ''
80
+ let created = false
81
+ try { show = run(['profile', 'show', profile]) } catch { /* absent */ }
82
+ if (!/Profile:\s*thinkpool/i.test(show)) {
83
+ if (!mode) throw new Error('No isolated Hermes thinkpool profile exists; choose --clone or --clean')
84
+ const createArgs = ['profile', 'create', profile, '--description', 'Isolated ThinkPool Code runtime.']
85
+ if (mode === 'clone') createArgs.push('--clone')
86
+ else createArgs.push('--no-skills')
87
+ run(createArgs, { timeout: 60_000 })
88
+ created = true
89
+ show = run(['profile', 'show', profile])
90
+ }
91
+
92
+ if (!profileAliasReady(show, profile)) {
93
+ run(['profile', 'alias', profile, '--name', profile])
94
+ show = run(['profile', 'show', profile])
95
+ if (!profileAliasReady(show, profile)) throw new Error('Hermes did not create the required thinkpool profile alias')
96
+ }
97
+
98
+ const rawConfigPath = clean(run([...profileArgs, 'config', 'path']))
99
+ if (!rawConfigPath) throw new Error('Hermes did not report the thinkpool profile config path')
100
+ const configPath = rawConfigPath.startsWith('~') ? path.join(homeDir, rawConfigPath.slice(1).replace(/^[/\\]/, '')) : path.resolve(rawConfigPath)
101
+ const profilePathMatch = String(show).match(/^Path:\s*(.+)$/mi)
102
+ if (!profilePathMatch) throw new Error('Hermes did not report the isolated thinkpool profile path')
103
+ const profilePath = profilePathMatch[1].trim().startsWith('~')
104
+ ? path.join(homeDir, profilePathMatch[1].trim().slice(1).replace(/^[/\\]/, ''))
105
+ : path.resolve(profilePathMatch[1].trim())
106
+ const relativeConfig = path.relative(profilePath, configPath)
107
+ if (relativeConfig.startsWith('..') || path.isAbsolute(relativeConfig)) throw new Error('Hermes reported a config path outside the isolated thinkpool profile')
108
+ let config = {}
109
+ if (fsImpl.existsSync(configPath)) {
110
+ const parsed = YAML.parse(fsImpl.readFileSync(configPath, 'utf8'))
111
+ if (parsed != null && !isObject(parsed)) throw new Error('Hermes thinkpool config is not a YAML mapping; refusing to overwrite it')
112
+ config = parsed || {}
113
+ }
114
+ if (config.hooks != null && !isObject(config.hooks)) throw new Error('Hermes thinkpool hooks config is not a mapping; refusing to overwrite it')
115
+ const hooks = config.hooks ||= {}
116
+ if (hooks.pre_tool_call != null && !Array.isArray(hooks.pre_tool_call)) throw new Error('Hermes pre_tool_call hooks are not a list; refusing to overwrite them')
117
+ const prior = hooks.pre_tool_call || []
118
+ hooks.pre_tool_call = [
119
+ ...prior.filter((entry) => !isThinkPoolGuard(entry)),
120
+ { matcher: '^delegate_task$', command: commandText(hermesGuardPath({ homeDir, env, platform })), timeout: 5 },
121
+ ]
122
+
123
+ const guardPath = hermesGuardPath({ homeDir, env, platform })
124
+ const allowlistPath = path.join(profilePath, 'shell-hooks-allowlist.json')
125
+ const guardSnapshot = snapshotFile(guardPath, fsImpl)
126
+ const configSnapshot = snapshotFile(configPath, fsImpl)
127
+ const allowlistSnapshot = snapshotFile(allowlistPath, fsImpl)
128
+ try {
129
+ atomicWrite(guardPath, guardSource, { fsImpl, mode: 0o700 })
130
+ atomicWrite(configPath, YAML.stringify(config, { lineWidth: 0 }), { fsImpl, mode: 0o600 })
131
+
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 })
137
+ const testOutput = run([...profileArgs, 'hooks', 'test', 'pre_tool_call', '--for-tool', 'delegate_task'], { env: consentEnv })
138
+ if (!/(?:action|decision)["']?\s*[:=]\s*["']block/i.test(testOutput)
139
+ || !/ThinkPool blocks hidden Hermes delegation/i.test(testOutput)) {
140
+ throw new Error('Hermes ran the ThinkPool delegation guard but did not report an explicit block')
141
+ }
142
+ run([...profileArgs, 'hooks', 'doctor'], { env: consentEnv })
143
+ run([...profileArgs, 'acp', '--check'], { env: consentEnv })
144
+ const probe = probeHermesRuntime({ command, prefixArgs: profileArgs, execFile, env: consentEnv })
145
+ if (!probe.available) throw new Error(`Hermes setup did not pass the final runtime probe: ${probe.reason || 'unavailable'}`)
146
+ return { ...probe, profile, created, mode: created ? mode : 'existing', guardPath, configPath }
147
+ } catch (error) {
148
+ try {
149
+ restoreFile(configPath, configSnapshot, fsImpl)
150
+ restoreFile(guardPath, guardSnapshot, fsImpl)
151
+ restoreFile(allowlistPath, allowlistSnapshot, fsImpl)
152
+ } catch (rollbackError) {
153
+ error.message = `${error.message}; rollback also failed: ${clean(rollbackError.message)}`
154
+ }
155
+ throw error
156
+ }
157
+ }
package/launcher.mjs CHANGED
@@ -15,6 +15,7 @@ import fs from 'node:fs'
15
15
  import path from 'node:path'
16
16
  import { execSync } from 'node:child_process'
17
17
  import { detectProvider, fetchModels, ANTHROPIC_COMPATIBLE, gatewayHint } from './byok-detect.mjs'
18
+ import { probeHermesRuntime } from './hermes-probe.mjs'
18
19
 
19
20
  const HOME = os.homedir()
20
21
  const CFG_DIR = path.join(HOME, '.thinkpool-pair')
@@ -74,12 +75,16 @@ export function detectState() {
74
75
  // provider.json is written with { provider, baseUrl, model } (see provider.mjs).
75
76
  // Anything whose `provider` field is set and not "anthropic" is a custom endpoint.
76
77
  const isCustom = !!provider?.provider && provider.provider !== 'anthropic'
78
+ const hermesInstalled = onPath('hermes')
79
+ const hermesReady = onPath('thinkpool') && probeHermesRuntime().available
77
80
  return {
78
81
  loggedIn: !!auth?.refresh_token,
79
82
  email: auth?.email || auth?.user?.email || null,
80
83
  provider: isCustom ? `Custom (${provider.baseUrl || 'endpoint'})` : 'Anthropic (default)',
81
84
  providerModel: isCustom ? (provider.model || null) : null,
82
- agents: KNOWN_AGENTS.filter(a => onPath(a.cmd)),
85
+ agents: KNOWN_AGENTS.filter(a => onPath(a.cmd) && (a.cmd !== 'thinkpool' || hermesReady)),
86
+ hermesInstalled,
87
+ hermesReady,
83
88
  accountSvc: serviceLoaded(null),
84
89
  cwd: process.cwd(),
85
90
  version: VERSION,
@@ -206,24 +211,34 @@ export async function runLauncher({ actions, io, state = detectState(), refresh
206
211
 
207
212
  const settingsMenu = async () => {
208
213
  for (;;) {
209
- const pick = await askChoice('\n change', [
210
- { label: 'LLM provider', hint: `current: ${state.provider}` },
211
- { label: 'Account', hint: state.loggedIn ? `linked: ${state.email || 'yes'}` : 'not linked' },
212
- { label: 'Restart & update the bridge', hint: state.accountSvc ? 'pull the newest published version, then restart — sessions resume' : 'none installed' },
213
- { label: 'Remove the background service', hint: state.accountSvc ? '' : 'none installed' },
214
- { label: '← Back to main menu', hint: '' },
215
- ], 5) // Enter = Back
216
- if (pick === 4) return
217
- if (pick === 0) await providerMenu()
218
- else if (pick === 1) { await actions.login(); resync(); return }
219
- else if (pick === 2) {
214
+ const options = [
215
+ { key: 'provider', label: 'LLM provider', hint: `current: ${state.provider}` },
216
+ { key: 'account', label: 'Account', hint: state.loggedIn ? `linked: ${state.email || 'yes'}` : 'not linked' },
217
+ { key: 'restart', label: 'Restart & update the bridge', hint: state.accountSvc ? 'pull the newest published version, then restart — sessions resume' : 'none installed' },
218
+ { key: 'remove', label: 'Remove the background service', hint: state.accountSvc ? '' : 'none installed' },
219
+ ]
220
+ if (state.hermesInstalled) options.push({ key: 'hermes', label: 'Hermes Agent', hint: state.hermesReady ? 'isolated profile ready' : 'set up isolated profile + delegation guard' })
221
+ options.push({ key: 'back', label: '← Back to main menu', hint: '' })
222
+ const picked = options[await askChoice('\n change', options, options.length)]
223
+ if (picked.key === 'back') return
224
+ if (picked.key === 'provider') await providerMenu()
225
+ else if (picked.key === 'account') { await actions.login(); resync(); return }
226
+ else if (picked.key === 'restart') {
220
227
  if (state.accountSvc) { await actions.restartUpdateService({ room: null }); resync() }
221
228
  else io.print(' ' + C.yellow('no background service installed'))
222
229
  }
223
- else {
230
+ else if (picked.key === 'remove') {
224
231
  if (state.accountSvc) { await actions.uninstallService({ room: null }); resync() }
225
232
  else io.print(' ' + C.yellow('no background service installed'))
226
233
  }
234
+ else if (picked.key === 'hermes') {
235
+ const setup = await askChoice('\n Hermes profile', [
236
+ { label: 'Clone active profile', hint: 'copies provider/config explicitly; fresh ThinkPool session history' },
237
+ { label: 'Create clean profile', hint: 'no copied credentials or bundled skills; configure provider afterward' },
238
+ { label: '← Back', hint: '' },
239
+ ], 3)
240
+ if (setup !== 2) { await actions.setupHermes({ mode: setup === 0 ? 'clone' : 'clean' }); resync() }
241
+ }
227
242
  }
228
243
  }
229
244
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thinkpool-pair",
3
- "version": "0.7.234",
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": {
@@ -24,6 +24,8 @@
24
24
  "hermes-session.mjs",
25
25
  "hermes-event-mapper.mjs",
26
26
  "hermes-probe.mjs",
27
+ "hermes-setup.mjs",
28
+ "hermes-isolation.mjs",
27
29
  "hermes-delegation-guard.mjs",
28
30
  "runtime-registry.mjs",
29
31
  "runtime-session.mjs",
@@ -81,6 +83,7 @@
81
83
  "@anthropic-ai/claude-agent-sdk": "^0.3.198",
82
84
  "@modelcontextprotocol/sdk": "^1.29.0",
83
85
  "@supabase/supabase-js": "^2.45.0",
86
+ "yaml": "2.9.0",
84
87
  "zod": "^4.4.3"
85
88
  },
86
89
  "optionalDependencies": {