thinkpool-pair 0.7.251 → 0.7.252

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
@@ -3833,8 +3833,17 @@ channel
3833
3833
  const nativeImages = s.runtime === 'codex' || s.runtime === 'hermes'
3834
3834
  ? await waitForNativeImages(payload.files, { updir: UPDIR })
3835
3835
  : []
3836
- s.session.sendTurn(sendText, nativeImages.length ? { images: nativeImages } : undefined)
3836
+ const accepted = s.session.sendTurn(sendText, nativeImages.length ? { images: nativeImages } : undefined)
3837
3837
  echoYou()
3838
+ if (accepted === false) {
3839
+ // A runtime that did not accept a turn must still close the optimistic
3840
+ // user-line lifecycle. Without this boundary the client truthfully shows
3841
+ // what the person sent but falsely leaves Thinking/Stop pinned forever.
3842
+ const evt = { kind: 'error', message: `${structuredRuntimeMetadata(s.runtime)?.label || 'Agent'} did not accept this turn; retry after the lane is available.`, recoverable: true }
3843
+ pushLog(s, evt)
3844
+ bcast('code-event', { term: payload.term, evt })
3845
+ }
3846
+ announce()
3838
3847
  // Pool-order fix (2026-07-02): emit the "dispatched to agent" marker HERE, right after
3839
3848
  // the @pool you-line, so it inherits the NEXT seq (seqable('pool') is true) and orders
3840
3849
  // correctly for EVERY viewer. It used to be pushed web-side with the FIRER's wall-clock
@@ -13,6 +13,7 @@ import { classifyRisk } from './claude-session.mjs'
13
13
  export const HERMES_COMMAND = 'thinkpool'
14
14
  export const HERMES_ACP_PROTOCOL_VERSION = 1
15
15
  export const HERMES_SUPPORTED_MODES = new Set(['default', 'acceptEdits'])
16
+ const HERMES_INITIALIZE_TIMEOUT_MS = 15_000
16
17
 
17
18
  const HERMES_SECRET_ENV_KEY = /(?:TOKEN|SECRET|PASSWORD|PRIVATE_KEY|API_KEY|ACCESS_KEY|CREDENTIAL)/i
18
19
  const HERMES_REPLAY_UPDATES = new Set(['agent_message_chunk', 'agent_thought_chunk', 'tool_call', 'tool_call_update', 'plan'])
@@ -69,6 +70,7 @@ export function startHermesSession({
69
70
  let resuming = false
70
71
  let inventoryProbe = null
71
72
  let modelSwitchPending = false
73
+ let bootCancelled = false
72
74
  const policyRole = hermesRole || hermesRoleFor({})
73
75
 
74
76
  const emit = (event) => { try { onEvent?.(event) } catch { /* consumer isolation */ } }
@@ -177,24 +179,45 @@ export function startHermesSession({
177
179
  }
178
180
  let retired = false
179
181
  retireClient = () => { retired = true }
180
- client = clientFactory({
181
- command: launchCommand, args: launchArgs, cwd: activeCwd, env: childEnv,
182
- onNotification,
183
- onRequest,
184
- onStderr: (text) => { stderrTail = (stderrTail + text).slice(-2000) },
185
- onClose: (error) => {
186
- if (ended || crashed || retired) return
187
- crashed = true
188
- turnActive = false
189
- emit({ kind: 'error', message: `Hermes ACP closed unexpectedly: ${error?.message || error}`, recoverable: true })
190
- },
191
- })
192
- client.start()
193
- const initialized = await client.request('initialize', {
182
+ const initializeParams = {
194
183
  protocolVersion: HERMES_ACP_PROTOCOL_VERSION,
195
184
  clientCapabilities: { fs: { readTextFile: false, writeTextFile: false }, terminal: false },
196
185
  clientInfo: { name: 'thinkpool-pair', title: 'ThinkPool Code', version: '1' },
197
- })
186
+ }
187
+ let initialized
188
+ // Cold Hermes imports take 6–9 seconds on the supported host, and the
189
+ // production ENOSPC incident pushed one child just beyond the old 10s
190
+ // cliff. Initialization is pre-session and pre-inference, so one fresh
191
+ // process retry is safe: no prompt or tool can have executed yet.
192
+ for (let attempt = 0; attempt < 2; attempt++) {
193
+ retired = false
194
+ retireClient = () => { retired = true }
195
+ client = clientFactory({
196
+ command: launchCommand, args: launchArgs, cwd: activeCwd, env: childEnv,
197
+ onNotification,
198
+ onRequest,
199
+ onStderr: (text) => { stderrTail = (stderrTail + text).slice(-2000) },
200
+ onClose: (error) => {
201
+ if (ended || crashed || retired) return
202
+ crashed = true
203
+ turnActive = false
204
+ emit({ kind: 'error', message: `Hermes ACP closed unexpectedly: ${error?.message || error}`, recoverable: true })
205
+ },
206
+ })
207
+ client.start()
208
+ try {
209
+ initialized = await client.request('initialize', initializeParams, HERMES_INITIALIZE_TIMEOUT_MS)
210
+ break
211
+ } catch (error) {
212
+ const retryable = !bootCancelled && attempt === 0 && /^initialize timed out\b/i.test(String(error?.message || error))
213
+ if (!retryable) throw error
214
+ retireClient()
215
+ const timedOutClient = client
216
+ client = null
217
+ timedOutClient?.end()
218
+ stderrTail = ''
219
+ }
220
+ }
198
221
  if (initialized?.protocolVersion !== HERMES_ACP_PROTOCOL_VERSION) {
199
222
  throw new Error(`Unsupported Hermes ACP protocol ${initialized?.protocolVersion ?? 'unknown'}; expected ${HERMES_ACP_PROTOCOL_VERSION}`)
200
223
  }
@@ -255,16 +278,32 @@ export function startHermesSession({
255
278
  await client.request('session/set_mode', { sessionId, modeId: acpMode })
256
279
  }
257
280
  emit({ kind: 'capabilities', runtime: 'hermes', protocol: 'acp', protocolVersion: initialized?.protocolVersion, capabilities: initialized?.agentCapabilities || {}, models: modelList(state?.models), flow: command === HERMES_COMMAND && clientFactory === createAcpClient })
258
- })().catch((error) => {
281
+ })().catch(async (error) => {
259
282
  crashed = true
260
283
  client?.end()
261
- void mcpHttp?.close().catch(() => {})
262
- emit({ kind: 'error', message: `Hermes ACP startup failed: ${error?.message || error}${stderrTail ? `: ${stderrTail.trim().slice(-240)}` : ''}`, recoverable: true })
284
+ try { await mcpHttp?.close() } catch { /* startup cleanup */ }
285
+ mcpHttp = null
286
+ if (!bootCancelled) emit({ kind: 'error', message: `Hermes ACP startup failed: ${error?.message || error}${stderrTail ? `: ${stderrTail.trim().slice(-240)}` : ''}`, recoverable: true })
263
287
  throw error
264
- }).finally(() => { starting = null })
288
+ }).finally(() => { starting = null; bootCancelled = false })
265
289
  return starting
266
290
  }
267
291
 
292
+ function reviveAfterExplicitRetry() {
293
+ if (!crashed || ended) return
294
+ retireClient()
295
+ client?.end()
296
+ client = null
297
+ mapper = null
298
+ crashed = false
299
+ stderrTail = ''
300
+ }
301
+
302
+ function finishAbortedTurn() {
303
+ if (mapper) mapper.finishTurn({ stopReason: 'cancelled' })
304
+ else emit({ kind: 'result', subtype: 'aborted', sessionId, model: activeModel, costUsd: null, usage: null, numTurns: 1, durationMs: undefined, denials: 0, resultText: null })
305
+ }
306
+
268
307
  async function restartAfterCancelledResponseFailure() {
269
308
  const oldClient = client
270
309
  retireClient()
@@ -288,12 +327,14 @@ export function startHermesSession({
288
327
  return blocks
289
328
  }
290
329
 
291
- async function runPrompt(text, options = {}) {
292
- await boot()
293
- const steering = turnActive
294
- const turnId = steering ? activeTurnId : ++activeTurnId
330
+ async function runPrompt(text, options = {}, { steering = false, turnId = activeTurnId } = {}) {
331
+ try { await boot() }
332
+ catch (error) {
333
+ if (abortedTurns.has(turnId)) return { stopReason: 'cancelled' }
334
+ throw error
335
+ }
336
+ if (abortedTurns.has(turnId)) return { stopReason: 'cancelled' }
295
337
  const body = steering ? `/steer ${String(text)}` : String(text)
296
- if (!steering) turnActive = true
297
338
  let result
298
339
  try {
299
340
  result = await client.request('session/prompt', {
@@ -334,17 +375,29 @@ export function startHermesSession({
334
375
  get started() { return started },
335
376
  get models() { return [] },
336
377
  sendTurn(text, options = {}) {
337
- if (ended || crashed) return false
378
+ if (ended) return false
379
+ // A crash never replays work by itself. A later explicit turn is the
380
+ // authority to launch a fresh ACP process and resume the same native
381
+ // session id; this is the recovery path the old permanent latch blocked.
382
+ reviveAfterExplicitRetry()
338
383
  // A busy prompt is a genuine ACP /steer call and may run concurrently.
339
- if (turnActive) { void runPrompt(text, options).catch((error) => emit({ kind: 'error', message: `Hermes steering failed: ${error?.message || error}`, recoverable: true })); return true }
340
- promptChain = promptChain.then(() => runPrompt(text, options)).catch((error) => {
384
+ if (turnActive) {
385
+ const turnId = activeTurnId
386
+ void runPrompt(text, options, { steering: true, turnId }).catch((error) => emit({ kind: 'error', message: `Hermes steering failed: ${error?.message || error}`, recoverable: true }))
387
+ return true
388
+ }
389
+ const turnId = ++activeTurnId
390
+ // Claim the turn synchronously, before the cold safety probe/import. The
391
+ // room can now show Thinking + Stop for the whole accepted lifecycle.
392
+ turnActive = true
393
+ promptChain = promptChain.then(() => runPrompt(text, options, { steering: false, turnId })).catch((error) => {
341
394
  turnActive = false
342
395
  emit({ kind: 'error', message: `Hermes turn failed: ${error?.message || error}`, recoverable: true })
343
396
  })
344
397
  return true
345
398
  },
346
399
  abort() {
347
- if (!sessionId || !client?.alive || !turnActive) return
400
+ if (!turnActive) return
348
401
  const turnId = activeTurnId
349
402
  abortedTurns.add(turnId)
350
403
  // Bound retained turn ids while preserving any concurrent /steer request
@@ -352,7 +405,18 @@ export function startHermesSession({
352
405
  if (abortedTurns.size > 32) abortedTurns.delete(abortedTurns.values().next().value)
353
406
  turnActive = false
354
407
  firstTurn = false
355
- mapper?.finishTurn({ stopReason: 'cancelled' })
408
+ if (starting) {
409
+ bootCancelled = true
410
+ retireClient()
411
+ client?.end()
412
+ finishAbortedTurn()
413
+ return
414
+ }
415
+ if (!sessionId || !client?.alive) {
416
+ finishAbortedTurn()
417
+ return
418
+ }
419
+ finishAbortedTurn()
356
420
  void client.notify('session/cancel', { sessionId }).catch((error) => {
357
421
  // Never display a stopped lane while the un-cancelled Hermes process
358
422
  // might still be executing. A transport failure tears down the ACP
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thinkpool-pair",
3
- "version": "0.7.251",
3
+ "version": "0.7.252",
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": {