thinkpool-pair 0.7.234 → 0.7.235
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-isolation.mjs +59 -0
- package/hermes-probe.mjs +5 -5
- package/hermes-setup.mjs +145 -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)
|
|
@@ -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,13 +2,13 @@ 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 guardOutput = execFile(command, [...prefixArgs, 'hooks', 'test', 'pre_tool_call', '--for-tool', 'delegate_task'], { encoding: 'utf8', env, timeout: 10_000, stdio: ['ignore', 'pipe', 'pipe'] })
|
|
12
12
|
const delegationBlocked = /(?:action|decision)["']?\s*[:=]\s*["']block/i.test(guardOutput)
|
|
13
13
|
&& /ThinkPool blocks hidden Hermes delegation/i.test(guardOutput)
|
|
14
14
|
&& /pre_tool_call[\s\S]*delegate_task[\s\S]*(?:✓ allowed|allowed)/i.test(hooksOutput)
|
package/hermes-setup.mjs
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
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
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function restoreFile(filePath, snapshot, fsImpl) {
|
|
46
|
+
if (snapshot) atomicWrite(filePath, snapshot.content, { fsImpl, mode: snapshot.mode })
|
|
47
|
+
else fsImpl.rmSync(filePath, { force: true })
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function profileAliasReady(output, profile) {
|
|
51
|
+
const escaped = profile.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
52
|
+
return new RegExp(`Alias:\\s+${escaped}\\s+.*hermes\\s+-p\\s+${escaped}`, 'i').test(String(output || ''))
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function setupHermesRuntime({
|
|
56
|
+
mode,
|
|
57
|
+
profile = HERMES_PROFILE,
|
|
58
|
+
command = 'hermes',
|
|
59
|
+
env = process.env,
|
|
60
|
+
homeDir = os.homedir(),
|
|
61
|
+
platform = process.platform,
|
|
62
|
+
execFile = execFileSync,
|
|
63
|
+
fsImpl = fs,
|
|
64
|
+
guardSource = fs.readFileSync(new URL('./hermes-delegation-guard.mjs', import.meta.url), 'utf8'),
|
|
65
|
+
} = {}) {
|
|
66
|
+
if (profile !== HERMES_PROFILE) throw new Error(`ThinkPool only manages the isolated Hermes profile ${HERMES_PROFILE}`)
|
|
67
|
+
if (mode != null && mode !== 'clone' && mode !== 'clean') throw new Error('Hermes setup mode must be clone or clean')
|
|
68
|
+
|
|
69
|
+
const run = (args, options = {}) => execFile(command, args, {
|
|
70
|
+
encoding: 'utf8', env: options.env || env, timeout: options.timeout || 20_000,
|
|
71
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
72
|
+
})
|
|
73
|
+
const profileArgs = ['-p', profile]
|
|
74
|
+
let show = ''
|
|
75
|
+
let created = false
|
|
76
|
+
try { show = run(['profile', 'show', profile]) } catch { /* absent */ }
|
|
77
|
+
if (!/Profile:\s*thinkpool/i.test(show)) {
|
|
78
|
+
if (!mode) throw new Error('No isolated Hermes thinkpool profile exists; choose --clone or --clean')
|
|
79
|
+
const createArgs = ['profile', 'create', profile, '--description', 'Isolated ThinkPool Code runtime.']
|
|
80
|
+
if (mode === 'clone') createArgs.push('--clone')
|
|
81
|
+
else createArgs.push('--no-skills')
|
|
82
|
+
run(createArgs, { timeout: 60_000 })
|
|
83
|
+
created = true
|
|
84
|
+
show = run(['profile', 'show', profile])
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (!profileAliasReady(show, profile)) {
|
|
88
|
+
run(['profile', 'alias', profile, '--name', profile])
|
|
89
|
+
show = run(['profile', 'show', profile])
|
|
90
|
+
if (!profileAliasReady(show, profile)) throw new Error('Hermes did not create the required thinkpool profile alias')
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const rawConfigPath = clean(run([...profileArgs, 'config', 'path']))
|
|
94
|
+
if (!rawConfigPath) throw new Error('Hermes did not report the thinkpool profile config path')
|
|
95
|
+
const configPath = rawConfigPath.startsWith('~') ? path.join(homeDir, rawConfigPath.slice(1).replace(/^[/\\]/, '')) : path.resolve(rawConfigPath)
|
|
96
|
+
const profilePathMatch = String(show).match(/^Path:\s*(.+)$/mi)
|
|
97
|
+
if (!profilePathMatch) throw new Error('Hermes did not report the isolated thinkpool profile path')
|
|
98
|
+
const profilePath = profilePathMatch[1].trim().startsWith('~')
|
|
99
|
+
? path.join(homeDir, profilePathMatch[1].trim().slice(1).replace(/^[/\\]/, ''))
|
|
100
|
+
: path.resolve(profilePathMatch[1].trim())
|
|
101
|
+
const relativeConfig = path.relative(profilePath, configPath)
|
|
102
|
+
if (relativeConfig.startsWith('..') || path.isAbsolute(relativeConfig)) throw new Error('Hermes reported a config path outside the isolated thinkpool profile')
|
|
103
|
+
let config = {}
|
|
104
|
+
if (fsImpl.existsSync(configPath)) {
|
|
105
|
+
const parsed = YAML.parse(fsImpl.readFileSync(configPath, 'utf8'))
|
|
106
|
+
if (parsed != null && !isObject(parsed)) throw new Error('Hermes thinkpool config is not a YAML mapping; refusing to overwrite it')
|
|
107
|
+
config = parsed || {}
|
|
108
|
+
}
|
|
109
|
+
if (config.hooks != null && !isObject(config.hooks)) throw new Error('Hermes thinkpool hooks config is not a mapping; refusing to overwrite it')
|
|
110
|
+
const hooks = config.hooks ||= {}
|
|
111
|
+
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')
|
|
112
|
+
const prior = hooks.pre_tool_call || []
|
|
113
|
+
hooks.pre_tool_call = [
|
|
114
|
+
...prior.filter((entry) => !isThinkPoolGuard(entry)),
|
|
115
|
+
{ matcher: '^delegate_task$', command: commandText(hermesGuardPath({ homeDir, env, platform })), timeout: 5 },
|
|
116
|
+
]
|
|
117
|
+
|
|
118
|
+
const guardPath = hermesGuardPath({ homeDir, env, platform })
|
|
119
|
+
const guardSnapshot = snapshotFile(guardPath, fsImpl)
|
|
120
|
+
const configSnapshot = snapshotFile(configPath, fsImpl)
|
|
121
|
+
try {
|
|
122
|
+
atomicWrite(guardPath, guardSource, { fsImpl, mode: 0o700 })
|
|
123
|
+
atomicWrite(configPath, YAML.stringify(config, { lineWidth: 0 }), { fsImpl, mode: 0o600 })
|
|
124
|
+
|
|
125
|
+
const consentEnv = { ...env, HERMES_ACCEPT_HOOKS: '1' }
|
|
126
|
+
const testOutput = run([...profileArgs, 'hooks', 'test', 'pre_tool_call', '--for-tool', 'delegate_task'], { env: consentEnv })
|
|
127
|
+
if (!/(?:action|decision)["']?\s*[:=]\s*["']block/i.test(testOutput)
|
|
128
|
+
|| !/ThinkPool blocks hidden Hermes delegation/i.test(testOutput)) {
|
|
129
|
+
throw new Error('Hermes ran the ThinkPool delegation guard but did not report an explicit block')
|
|
130
|
+
}
|
|
131
|
+
run([...profileArgs, 'hooks', 'doctor'], { env: consentEnv })
|
|
132
|
+
run([...profileArgs, 'acp', '--check'], { env: consentEnv })
|
|
133
|
+
const probe = probeHermesRuntime({ command, prefixArgs: profileArgs, execFile, env: consentEnv })
|
|
134
|
+
if (!probe.available) throw new Error(`Hermes setup did not pass the final runtime probe: ${probe.reason || 'unavailable'}`)
|
|
135
|
+
return { ...probe, profile, created, mode: created ? mode : 'existing', guardPath, configPath }
|
|
136
|
+
} catch (error) {
|
|
137
|
+
try {
|
|
138
|
+
restoreFile(configPath, configSnapshot, fsImpl)
|
|
139
|
+
restoreFile(guardPath, guardSnapshot, fsImpl)
|
|
140
|
+
} catch (rollbackError) {
|
|
141
|
+
error.message = `${error.message}; rollback also failed: ${clean(rollbackError.message)}`
|
|
142
|
+
}
|
|
143
|
+
throw error
|
|
144
|
+
}
|
|
145
|
+
}
|
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.235",
|
|
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": {
|