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.
- package/hermes-event-mapper.mjs +12 -0
- package/hermes-probe.mjs +4 -1
- package/hermes-session.mjs +67 -10
- package/hermes-setup.mjs +13 -1
- package/package.json +1 -1
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 })
|
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
|
|
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
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)
|
|
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
|
}
|