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 +39 -0
- package/hermes-event-mapper.mjs +12 -0
- package/hermes-isolation.mjs +59 -0
- package/hermes-probe.mjs +9 -6
- package/hermes-session.mjs +67 -10
- package/hermes-setup.mjs +157 -0
- package/launcher.mjs +28 -13
- package/package.json +4 -1
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)
|
package/hermes-event-mapper.mjs
CHANGED
|
@@ -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
|
|
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
|
|
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) }
|
package/hermes-session.mjs
CHANGED
|
@@ -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
|
|
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 {
|
|
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
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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
|
-
|
|
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
ADDED
|
@@ -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
|
|
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
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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.
|
|
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": {
|