thinkpool-pair 0.7.275 → 0.7.277
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/account.mjs +11 -0
- package/bridge.mjs +32 -5
- package/claude-session.mjs +11 -3
- package/codex-session.mjs +22 -2
- package/hermes-session.mjs +10 -0
- package/host-memory.mjs +116 -0
- package/package.json +2 -1
- package/service.mjs +4 -1
package/account.mjs
CHANGED
|
@@ -21,6 +21,7 @@ import { supervisorServes } from './serve-consent.mjs'
|
|
|
21
21
|
import { resolveServeDir } from './serve-dir.mjs'
|
|
22
22
|
import { pairKeyFor, pairTopic, CROSSROOM_BUS } from './cross-terminal.mjs'
|
|
23
23
|
import { installedAgentCommands } from './agent-detect.mjs'
|
|
24
|
+
import { hostMemoryAdmission } from './host-memory.mjs'
|
|
24
25
|
|
|
25
26
|
const VERSION = (() => { try { return JSON.parse(fs.readFileSync(new URL('./package.json', import.meta.url), 'utf8')).version } catch { return null } })()
|
|
26
27
|
|
|
@@ -892,6 +893,16 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
|
|
|
892
893
|
|
|
893
894
|
// ── supervisor child_spawn logging (cascade brg-instrument) ─────────────────
|
|
894
895
|
const alreadyHad = children.has(room)
|
|
896
|
+
const memory = hostMemoryAdmission(`spawn the room bridge for ${room}`)
|
|
897
|
+
if (!memory.ok) {
|
|
898
|
+
if (!warned.has(`memory:${room}`)) {
|
|
899
|
+
warned.add(`memory:${room}`)
|
|
900
|
+
process.stderr.write(`\n ◇ ${room}: ${memory.reason}\n`)
|
|
901
|
+
}
|
|
902
|
+
refused.set(room, 'host-memory')
|
|
903
|
+
continue
|
|
904
|
+
}
|
|
905
|
+
warned.delete(`memory:${room}`)
|
|
895
906
|
const child = spawn(process.execPath, [BRIDGE, room, '--headless', '--auto=claude'], { cwd: dir, stdio: ['inherit', 'inherit', 'inherit', 'ipc'], env })
|
|
896
907
|
console.log(`child_spawn sup=${SUP_ID} room=${room} pid=${child.pid} dir=${dir} had_prior=${alreadyHad}`)
|
|
897
908
|
|
package/bridge.mjs
CHANGED
|
@@ -63,6 +63,7 @@ import { createManagedLaneWorktree, removeManagedLaneWorktree } from './lane-wor
|
|
|
63
63
|
import { commandOnPath } from './agent-detect.mjs'
|
|
64
64
|
import { requiresSdkAdmission, sdkSmokePassed } from './sdk-admission.mjs'
|
|
65
65
|
import { hermesUserInputResponse } from './question-response.mjs'
|
|
66
|
+
import { hostMemoryAdmission } from './host-memory.mjs'
|
|
66
67
|
|
|
67
68
|
const STRUCTURED_MODES = new Set(['default', 'acceptEdits', 'plan', 'review', 'bypassPermissions'])
|
|
68
69
|
import { FLOW_CONDUCTOR_PROMPT, FLOW_LANE_PROMPT, FLOW_CODEX_CONDUCTOR_PROMPT, FLOW_CODEX_LANE_PROMPT, buildConductorEnv, assembleCrossWaveContext, buildLanePrompt } from './flow-conductor.mjs'
|
|
@@ -1230,7 +1231,7 @@ async function receiveCrossRoomPost({ fromRoom, fromHost, fromTerminalName, text
|
|
|
1230
1231
|
const msg = `[From: room ${fromLabel} — relayed via the ThinkPool cross-room Ensemble, approved by a person in this room]\n${body}`
|
|
1231
1232
|
const evt = { kind: 'you', text: msg, by: `room ${fromRoom}`, crosspost: true, relaySourceName: String(fromTerminalName || '').trim().slice(0, 80) || undefined }
|
|
1232
1233
|
stampEvent(evt); pushLog(te, evt); bcast('code-event', { term: targetId, evt })
|
|
1233
|
-
try { te.session.sendTurn(msg) } catch { return { error: `Could not deliver to room ${room} — the lane may have just closed.` } }
|
|
1234
|
+
try { if (te.session.sendTurn(msg) === false) return { error: `Could not deliver to room ${room} — the lane refused the turn (check its visible host-memory/runtime error).` } } catch { return { error: `Could not deliver to room ${room} — the lane may have just closed.` } }
|
|
1234
1235
|
return { ok: true, ref: String(targetId).slice(0, 8) }
|
|
1235
1236
|
}
|
|
1236
1237
|
|
|
@@ -1576,6 +1577,12 @@ function openTerm({ id, cmd, args = [], attached = false, cols, rows }) {
|
|
|
1576
1577
|
bcast('term-exit', { id })
|
|
1577
1578
|
return
|
|
1578
1579
|
}
|
|
1580
|
+
const memory = hostMemoryAdmission(`start the ${cmd} terminal`)
|
|
1581
|
+
if (!memory.ok) {
|
|
1582
|
+
process.stderr.write(`\n ⚠ ${memory.reason}\n`)
|
|
1583
|
+
bcast('term-exit', { id, reason: memory.reason })
|
|
1584
|
+
return
|
|
1585
|
+
}
|
|
1579
1586
|
const ad = attached ? attachedDims() : null
|
|
1580
1587
|
// This terminal's private mockup outbox — render.sh run inside it writes here,
|
|
1581
1588
|
// and the watcher attributes every card to THIS id (not a Map-order guess).
|
|
@@ -2333,7 +2340,7 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
|
|
|
2333
2340
|
stampEvent(evt)
|
|
2334
2341
|
pushLog(te, evt)
|
|
2335
2342
|
bcast('code-event', { term: target.id, evt })
|
|
2336
|
-
try { te.session.sendTurn(msg) } catch { return okText(`Could not deliver to terminal ${target.ref} — it may have just closed.`) }
|
|
2343
|
+
try { if (te.session.sendTurn(msg) === false) return okText(`Could not deliver to terminal ${target.ref} — it refused the turn. Check its visible host-memory/runtime error.`) } catch { return okText(`Could not deliver to terminal ${target.ref} — it may have just closed.`) }
|
|
2337
2344
|
return okText(`Delivered to terminal ${target.ref} (${target.cmd}). It will respond in its own lane; check back with read_terminal.`)
|
|
2338
2345
|
},
|
|
2339
2346
|
)] : []),
|
|
@@ -2356,6 +2363,8 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
|
|
|
2356
2363
|
const okText = (t) => ({ content: [{ type: 'text', text: t }] })
|
|
2357
2364
|
const resolved = resolveAgentOpen(args)
|
|
2358
2365
|
if (resolved.error) return okText(resolved.error)
|
|
2366
|
+
const memory = hostMemoryAdmission('start another Cascade conductor')
|
|
2367
|
+
if (!memory.ok) return okText(memory.reason)
|
|
2359
2368
|
const gate = consumeTerminalOpenBudget()
|
|
2360
2369
|
if (!gate.ok) return okText(gate.reason)
|
|
2361
2370
|
const newId = randomUUID()
|
|
@@ -2382,7 +2391,7 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
|
|
|
2382
2391
|
const msg = `[Task from main terminal ${fromRef}'s agent — opened as an independent MAIN CASCADE CONDUCTOR terminal, not an Ensemble child]\n${String(args.task).trim()}`
|
|
2383
2392
|
const evt = { kind: 'you', text: msg, by: `main terminal ${fromRef} (agent)`, crosspost: true, relaySourceName: termNames[id] || undefined }
|
|
2384
2393
|
stampEvent(evt); pushLog(conductor, evt); bcast('code-event', { term: newId, evt })
|
|
2385
|
-
try { conductor.session.sendTurn(msg) }
|
|
2394
|
+
try { if (conductor.session.sendTurn(msg) === false) return okText(`Opened main conductor ${newRef}, but host pressure prevented its runtime from starting. Existing lanes remain connected; free memory and retry the task.`) }
|
|
2386
2395
|
catch { return okText(`Opened main conductor ${newRef}${args?.name ? ` ("${args.name}")` : ''}, but it may still be starting — the initial task could not be delivered.`) }
|
|
2387
2396
|
return okText(`Opened independent main Cascade conductor ${newRef}${args?.name ? ` ("${args.name}")` : ''} and handed it the task. It is a main terminal, not an Ensemble lane.`)
|
|
2388
2397
|
},
|
|
@@ -2424,6 +2433,8 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
|
|
|
2424
2433
|
spawnedBy: s.spawnedBy,
|
|
2425
2434
|
busy: (s.session?.turnActive ?? false) || (s.pending?.size ?? 0) > 0,
|
|
2426
2435
|
}))))
|
|
2436
|
+
const memory = hostMemoryAdmission('start another worker lane')
|
|
2437
|
+
if (!memory.ok) return okText(memory.reason)
|
|
2427
2438
|
const effectiveArgs = {
|
|
2428
2439
|
...args,
|
|
2429
2440
|
runtime: resolved.runtime,
|
|
@@ -2499,7 +2510,7 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
|
|
|
2499
2510
|
const msg = `[Task from terminal ${fromRef}'s agent — relayed via ThinkPool Ensemble; you are its WORKER SUB-TERMINAL, never a main terminal or Cascade conductor]\n${args.task}${reviewTarget}`
|
|
2500
2511
|
const evt = { kind: 'you', text: msg, by: `terminal ${fromRef} (agent)`, crosspost: true, relaySourceName: termNames[id] || undefined }
|
|
2501
2512
|
stampEvent(evt); pushLog(ne, evt); bcast('code-event', { term: newId, evt })
|
|
2502
|
-
try { ne.session.sendTurn(msg) } catch { return okText(`Opened lane ${newRef}${args?.name ? ` ("${args.name}")` : ''}, but it may still be starting — could not hand off the task. Try post_to_terminal shortly.`) }
|
|
2513
|
+
try { if (ne.session.sendTurn(msg) === false) return okText(`Opened lane ${newRef}${args?.name ? ` ("${args.name}")` : ''}, but host pressure prevented its runtime from accepting the task. Existing lanes remain connected; free memory and retry.`) } catch { return okText(`Opened lane ${newRef}${args?.name ? ` ("${args.name}")` : ''}, but it may still be starting — could not hand off the task. Try post_to_terminal shortly.`) }
|
|
2503
2514
|
return okText(`Opened agent lane ${newRef}${args?.name ? ` ("${args.name}")` : ''} and handed it the task. It runs in its own lane — check back with read_terminal, then close_terminal when done.`)
|
|
2504
2515
|
}
|
|
2505
2516
|
return okText(`Opened idle agent lane ${newRef}${args?.name ? ` ("${args.name}")` : ''}. Hand it work with post_to_terminal, or a person can type into it.`)
|
|
@@ -2698,6 +2709,10 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
|
|
|
2698
2709
|
// defer: a restored-idle terminal cold-boots its query only on first use — restore is
|
|
2699
2710
|
// then instant for display, and only used terminals pay the ~50s MCP boot (Max, 2026-07-02).
|
|
2700
2711
|
lazy: shouldDeferStructuredRuntime({ runtime, defer, cwd, flowSessionId, flowTaskKey, models: entry.models }),
|
|
2712
|
+
// One shared host-pressure circuit at the actual cold-runtime boundary. An
|
|
2713
|
+
// empty/restored tab remains visible under pressure; its first expensive
|
|
2714
|
+
// agent process is refused with a recoverable room event until memory returns.
|
|
2715
|
+
admitStart: () => hostMemoryAdmission('start this agent runtime'),
|
|
2701
2716
|
reviewGate,
|
|
2702
2717
|
// A Flow CONDUCTOR (flowSessionId set, no flowTaskKey) must DECOMPOSE, not explore via
|
|
2703
2718
|
// a fan-out of Task/Explore subagents, and must not build. Hard-block Task/Bash/Write for
|
|
@@ -3478,7 +3493,7 @@ channel
|
|
|
3478
3493
|
stampEvent(parentEvent); pushLog(parent, parentEvent); bcast('code-event', { term: sideParent, evt: parentEvent })
|
|
3479
3494
|
const snapshot = sideSnapshot(parent.log.filter((event) => event.cid !== parentEvent.cid))
|
|
3480
3495
|
const prompt = snapshot ? `${snapshot}\n\n--- side task ---\n${task}` : task
|
|
3481
|
-
try { child.session.sendTurn(prompt) } catch {
|
|
3496
|
+
try { if (child.session.sendTurn(prompt) === false) throw new Error('runtime refused the turn') } catch {
|
|
3482
3497
|
bcast('side-open-failed', { id: payload.id, parent: sideParent, message: 'The side agent could not start.' })
|
|
3483
3498
|
endStructured(payload.id)
|
|
3484
3499
|
return
|
|
@@ -4279,6 +4294,12 @@ flowChannel
|
|
|
4279
4294
|
if (!flowRuntime) return
|
|
4280
4295
|
const flowCatalog = flowRuntime === 'codex' ? (origin?.models || readCodexModels()) : flowRuntime === 'hermes' ? (origin?.models || []) : []
|
|
4281
4296
|
const conductorModel = flowConductorModelFor({ runtime: flowRuntime, originModel: origin?.model, catalog: flowCatalog })
|
|
4297
|
+
const memory = hostMemoryAdmission('start the Flow conductor')
|
|
4298
|
+
if (!memory.ok) {
|
|
4299
|
+
process.stderr.write(`\n ${A.yel}◆ flow start held — ${memory.reason}${A.rst}\n`)
|
|
4300
|
+
bcast('flow-host-pressure', { term: 'flow', flowId: payload.flowId, reason: memory.reason }, flowChannel)
|
|
4301
|
+
return
|
|
4302
|
+
}
|
|
4282
4303
|
const cid = randomUUID()
|
|
4283
4304
|
termNames[cid] = `Flow · ${String(payload.flowId).slice(0, 6)}`
|
|
4284
4305
|
saveNames(room, termNames)
|
|
@@ -4335,6 +4356,12 @@ flowChannel
|
|
|
4335
4356
|
}
|
|
4336
4357
|
const gate = canDispatch({ mode: dispMode, liveLanes, budget })
|
|
4337
4358
|
if (!gate.ok) { process.stderr.write(`\n ${A.yel}◆ flow dispatch held (${gate.reason}, ${liveLanes}/${FLOW_LIMITS.maxConcurrentLanes} lanes) — ${assignments.length} spawned this wave.${A.rst}\n`); break }
|
|
4359
|
+
const memory = hostMemoryAdmission('start another Flow lane')
|
|
4360
|
+
if (!memory.ok) {
|
|
4361
|
+
process.stderr.write(`\n ${A.yel}◆ flow dispatch held — ${memory.reason}${A.rst}\n`)
|
|
4362
|
+
bcast('flow-host-pressure', { term: 'flow', flowId: payload.flowId, reason: memory.reason }, flowChannel)
|
|
4363
|
+
break
|
|
4364
|
+
}
|
|
4338
4365
|
try {
|
|
4339
4366
|
// Step 4 — a `review` slice gets the adversarial reviewer prompt (try-to-break),
|
|
4340
4367
|
// every other slice gets the builder prompt.
|
package/claude-session.mjs
CHANGED
|
@@ -206,7 +206,7 @@ const TP_ROOM_REMINDER = [
|
|
|
206
206
|
'BUILD WORKFLOW (default, no magic word): right-size within your TERMINAL ROLE — a trivial ask or delegated slice you just do; a conductor-capable role with a genuinely decomposable build FIRST writes a short plan in chat, THEN fans worker slices into visible spawn_terminal lanes and verifies them. Worker/leaf/Side/managed Flow roles do not fan out. A requested separate conductor uses open_main_terminal. Never plan-mode/ExitPlanMode; plans live in chat and lanes in the existing list.',
|
|
207
207
|
].join(' ')
|
|
208
208
|
|
|
209
|
-
export function startClaudeSession({ cwd, model, effort: initialEffort = 'high', resume, env, mode: initialMode = 'default', onEvent, requestPermission, mcpServers, crossPostGate, crossRoomPostGate, didSpawnTarget = null, terminalRolePrompt, rolePrompt, blockSubagents = false, onSubmitPlan = null, onLaneDone = null, onReviewVerdict = null, reviewGate = null, lazy = false, roomContext = null, suggest = true, prepareCwd = null }) {
|
|
209
|
+
export function startClaudeSession({ cwd, model, effort: initialEffort = 'high', resume, env, mode: initialMode = 'default', onEvent, requestPermission, mcpServers, crossPostGate, crossRoomPostGate, didSpawnTarget = null, terminalRolePrompt, rolePrompt, blockSubagents = false, onSubmitPlan = null, onLaneDone = null, onReviewVerdict = null, reviewGate = null, lazy = false, roomContext = null, suggest = true, prepareCwd = null, admitStart = null }) {
|
|
210
210
|
// Per-turn reminder + live ROOM NOW tail. roomContext (bridge-supplied) returns the
|
|
211
211
|
// room's CURRENT state — sibling lanes, active git worktrees — or null. The static
|
|
212
212
|
// rules keep the agent aware of the room's FEATURES; the live tail keeps it aware of
|
|
@@ -812,7 +812,15 @@ export function startClaudeSession({ cwd, model, effort: initialEffort = 'high',
|
|
|
812
812
|
finally { clearTimeout(t) }
|
|
813
813
|
}
|
|
814
814
|
|
|
815
|
-
const
|
|
815
|
+
const admitColdStart = () => {
|
|
816
|
+
const gate = typeof admitStart === 'function' ? admitStart() : { ok: true }
|
|
817
|
+
if (gate?.ok !== false) return true
|
|
818
|
+
emit({ kind: 'error', message: gate.reason || 'Host memory is critically low. This agent runtime was not started.', recoverable: true })
|
|
819
|
+
return false
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
const runQuery = async ({ admitted = false } = {}) => {
|
|
823
|
+
if (!started && !admitted && !admitColdStart()) return false
|
|
816
824
|
if (spawnT0 == null) spawnT0 = Date.now()
|
|
817
825
|
started = true
|
|
818
826
|
// Per-query AbortController (child of the session `ac`): lets a model switch kill
|
|
@@ -1164,7 +1172,7 @@ export function startClaudeSession({ cwd, model, effort: initialEffort = 'high',
|
|
|
1164
1172
|
return {
|
|
1165
1173
|
// A lazy (restored-idle) session cold-boots the query on its first turn. input.push
|
|
1166
1174
|
// is queue-backed, so the pushed turn buffers and runs once the query is ready.
|
|
1167
|
-
sendTurn(text) { if (!closed) { if (!started && prepareCwd) { try { const next = prepareCwd(); if (next) { cwd = next; opts.cwd = next } } catch { /* keep original cwd */ } } if (!started) runQuery(); turnActive = true; sawSuggestion = false; lastTurnText = String(text); if (sugTimer) { clearTimeout(sugTimer); sugTimer = null } lastEvtTs = Date.now(); stalledSent = false; stallRetried = false; forceStopped = false;
|
|
1175
|
+
sendTurn(text) { if (!closed) { if (!started && !admitColdStart()) return false; if (!started && prepareCwd) { try { const next = prepareCwd(); if (next) { cwd = next; opts.cwd = next } } catch { /* keep original cwd */ } } if (!started) runQuery({ admitted: true }); turnActive = true; sawSuggestion = false; lastTurnText = String(text); if (sugTimer) { clearTimeout(sugTimer); sugTimer = null } lastEvtTs = Date.now(); stalledSent = false; stallRetried = false; forceStopped = false;
|
|
1168
1176
|
const t = String(text)
|
|
1169
1177
|
const promptIndex = userPromptNo++
|
|
1170
1178
|
const thisTurnForceFull = forceFullReminder
|
package/codex-session.mjs
CHANGED
|
@@ -267,7 +267,16 @@ export function buildCodexExecArgs({ sessionId, model, effort, sandbox, approval
|
|
|
267
267
|
// Both fresh `exec` and `exec resume` accept repeatable --image flags. Only
|
|
268
268
|
// bridge-derived absolute paths reach this layer; relative values are ignored
|
|
269
269
|
// so a future caller cannot silently change the child's path interpretation.
|
|
270
|
-
|
|
270
|
+
let hasNativeImages = false
|
|
271
|
+
for (const imagePath of images) {
|
|
272
|
+
if (!path.isAbsolute(String(imagePath || ''))) continue
|
|
273
|
+
args.push('--image', String(imagePath))
|
|
274
|
+
hasNativeImages = true
|
|
275
|
+
}
|
|
276
|
+
// Codex CLI 0.144.1 defines --image as a variadic option. Without an option
|
|
277
|
+
// terminator it consumes the positional prompt (and, on resume, the thread
|
|
278
|
+
// id), then exits because stdin is intentionally /dev/null in the bridge.
|
|
279
|
+
if (hasNativeImages) args.push('--')
|
|
271
280
|
if (sessionId) args.push(sessionId)
|
|
272
281
|
args.push(String(prompt ?? ''))
|
|
273
282
|
return args
|
|
@@ -352,7 +361,7 @@ function appServerItemForMapper(item) {
|
|
|
352
361
|
* @param {string} [o.providerConfig] optional -c overrides / provider block (M2: from the provider registry)
|
|
353
362
|
* @returns {{ sendTurn(text, options?), abort(), end(), readonly sessionId }}
|
|
354
363
|
*/
|
|
355
|
-
export function startCodexSession({ cwd, model, effort: initialEffort = 'high', resume, env, sandbox, mode = 'default', onEvent, providerConfig, terminalRolePrompt, rolePrompt, roomContext, mcpServers, prepareCwd = null, requestPermission, appServerGate = codexAppServerGate, appServerFactory = createCodexAppServer, mcpHttpFactory = startCodexMcpHttp, spawnImpl = spawn }) {
|
|
364
|
+
export function startCodexSession({ cwd, model, effort: initialEffort = 'high', resume, env, sandbox, mode = 'default', onEvent, providerConfig, terminalRolePrompt, rolePrompt, roomContext, mcpServers, prepareCwd = null, requestPermission, appServerGate = codexAppServerGate, appServerFactory = createCodexAppServer, mcpHttpFactory = startCodexMcpHttp, spawnImpl = spawn, admitStart = null }) {
|
|
356
365
|
let activeMode = CODEX_MODE_CONFIG[mode] ? mode : 'default'
|
|
357
366
|
let modeConfig = codexConfigForMode(activeMode)
|
|
358
367
|
sandbox = normalizeCodexSandbox(sandbox || modeConfig.sandbox)
|
|
@@ -764,6 +773,17 @@ export function startCodexSession({ cwd, model, effort: initialEffort = 'high',
|
|
|
764
773
|
get started() { return turnNo > 0 || turnActive },
|
|
765
774
|
sendTurn(text, options = {}) {
|
|
766
775
|
if (ended) return false
|
|
776
|
+
// App Server stays resident between turns. Only gate when an idle turn
|
|
777
|
+
// would have to create a new host process (fresh/resumed cold lane or
|
|
778
|
+
// exec fallback after App Server failure). Steering an existing process
|
|
779
|
+
// and reading a restored transcript remain available under pressure.
|
|
780
|
+
if (!turnActive && queue.length === 0 && !appServerThreadReady && !child) {
|
|
781
|
+
const gate = typeof admitStart === 'function' ? admitStart() : { ok: true }
|
|
782
|
+
if (gate?.ok === false) {
|
|
783
|
+
try { onEvent?.({ kind: 'error', message: gate.reason || 'Host memory is critically low. This Codex runtime was not started.', recoverable: true }) } catch { /* noop */ }
|
|
784
|
+
return false
|
|
785
|
+
}
|
|
786
|
+
}
|
|
767
787
|
const promptIndex = userPromptNo++
|
|
768
788
|
const thisTurnForceFull = forceFullReminder
|
|
769
789
|
forceFullReminder = isCodexCompactCommand(text) || /^\s*\/(?:reset|clear)\b/i.test(String(text || ''))
|
package/hermes-session.mjs
CHANGED
|
@@ -67,6 +67,7 @@ export function startHermesSession({
|
|
|
67
67
|
command = HERMES_COMMAND, args = ['acp'], clientFactory = createAcpClient,
|
|
68
68
|
mcpHttpFactory = startCodexMcpHttp, lazy = false, hermesRole = null,
|
|
69
69
|
crossPostGate = null, didSpawnTarget = null, crossRoomPostGate = null, effort = 'high',
|
|
70
|
+
admitStart = null,
|
|
70
71
|
} = {}) {
|
|
71
72
|
let activeCwd = cwd
|
|
72
73
|
const requestedModel = model || null
|
|
@@ -241,6 +242,8 @@ export function startHermesSession({
|
|
|
241
242
|
if (crashed) throw new Error('Hermes ACP process crashed; this lane must be restarted before sending another turn')
|
|
242
243
|
if (client?.alive && mapper) return
|
|
243
244
|
if (starting) return starting
|
|
245
|
+
const gate = typeof admitStart === 'function' ? admitStart() : { ok: true }
|
|
246
|
+
if (gate?.ok === false) throw new Error(gate.reason || 'Host memory is critically low. This Hermes runtime was not started.')
|
|
244
247
|
starting = (async () => {
|
|
245
248
|
if (!started && prepareCwd) {
|
|
246
249
|
try { activeCwd = prepareCwd() || activeCwd } catch { /* retain original cwd */ }
|
|
@@ -516,6 +519,13 @@ export function startHermesSession({
|
|
|
516
519
|
get effort() { return activeEffort },
|
|
517
520
|
sendTurn(text, options = {}) {
|
|
518
521
|
if (ended) return false
|
|
522
|
+
if (!client?.alive && !starting) {
|
|
523
|
+
const gate = typeof admitStart === 'function' ? admitStart() : { ok: true }
|
|
524
|
+
if (gate?.ok === false) {
|
|
525
|
+
emit({ kind: 'error', message: gate.reason || 'Host memory is critically low. This Hermes runtime was not started.', recoverable: true })
|
|
526
|
+
return false
|
|
527
|
+
}
|
|
528
|
+
}
|
|
519
529
|
const promptIndex = userPromptNo++
|
|
520
530
|
const thisTurnForceFull = forceFullReminder
|
|
521
531
|
forceFullReminder = /^\s*\/(?:compact|reset|clear)\b/i.test(String(text || ''))
|
package/host-memory.mjs
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import os from 'node:os'
|
|
2
|
+
import fs from 'node:fs'
|
|
3
|
+
import { execFileSync } from 'node:child_process'
|
|
4
|
+
|
|
5
|
+
const MIB = 1024 * 1024
|
|
6
|
+
const DEFAULT_BLOCK_PERCENT = 8
|
|
7
|
+
const DEFAULT_RELEASE_PERCENT = 12
|
|
8
|
+
const DEFAULT_BLOCK_BYTES = 512 * MIB
|
|
9
|
+
const DEFAULT_RELEASE_BYTES = 1024 * MIB
|
|
10
|
+
|
|
11
|
+
function finiteNumber(value, fallback) {
|
|
12
|
+
const n = Number(value)
|
|
13
|
+
return Number.isFinite(n) ? n : fallback
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function parseMacMemoryPressure(output) {
|
|
17
|
+
const match = String(output || '').match(/System-wide memory free percentage:\s*([0-9]+(?:\.[0-9]+)?)%/i)
|
|
18
|
+
return match ? Number(match[1]) : null
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function parseLinuxMemAvailable(output) {
|
|
22
|
+
const text = String(output || '')
|
|
23
|
+
const available = text.match(/^MemAvailable:\s*(\d+)\s*kB\s*$/mi)
|
|
24
|
+
const total = text.match(/^MemTotal:\s*(\d+)\s*kB\s*$/mi)
|
|
25
|
+
if (!available || !total) return null
|
|
26
|
+
return { availableBytes: Number(available[1]) * 1024, totalBytes: Number(total[1]) * 1024, source: 'proc-meminfo' }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function memoryDecision(snapshot, { blocked = false, env = process.env } = {}) {
|
|
30
|
+
if (env.TP_MEMORY_GUARD_OFF === '1') return { ok: true, blocked: false, source: 'disabled' }
|
|
31
|
+
if (!snapshot) return { ok: true, blocked: false, source: 'unavailable' }
|
|
32
|
+
|
|
33
|
+
const blockPercent = Math.max(1, finiteNumber(env.TP_MEMORY_BLOCK_PERCENT, DEFAULT_BLOCK_PERCENT))
|
|
34
|
+
const releasePercent = Math.max(blockPercent + 1, finiteNumber(env.TP_MEMORY_RELEASE_PERCENT, DEFAULT_RELEASE_PERCENT))
|
|
35
|
+
const blockBytes = Math.max(64 * MIB, finiteNumber(env.TP_MEMORY_BLOCK_MIB, DEFAULT_BLOCK_BYTES / MIB) * MIB)
|
|
36
|
+
const releaseBytes = Math.max(blockBytes + MIB, finiteNumber(env.TP_MEMORY_RELEASE_MIB, DEFAULT_RELEASE_BYTES / MIB) * MIB)
|
|
37
|
+
const availableBytes = Number.isFinite(snapshot.availableBytes) ? snapshot.availableBytes : null
|
|
38
|
+
const totalBytes = Number.isFinite(snapshot.totalBytes) && snapshot.totalBytes > 0 ? snapshot.totalBytes : null
|
|
39
|
+
const availablePercent = Number.isFinite(snapshot.availablePercent)
|
|
40
|
+
? snapshot.availablePercent
|
|
41
|
+
: (availableBytes != null && totalBytes ? (availableBytes / totalBytes) * 100 : null)
|
|
42
|
+
|
|
43
|
+
// macOS' memory_pressure percentage already accounts for reclaimable/compressed
|
|
44
|
+
// memory. Generic os.freemem() does not, so a healthy Mac can look ~97% full.
|
|
45
|
+
// Other platforms need BOTH a low ratio and a low absolute reserve to avoid
|
|
46
|
+
// rejecting large-memory hosts merely because their page cache is busy.
|
|
47
|
+
const pressureSignal = snapshot.source === 'macos-memory-pressure'
|
|
48
|
+
const shouldBlock = pressureSignal
|
|
49
|
+
? availablePercent != null && availablePercent <= blockPercent
|
|
50
|
+
: availablePercent != null && availableBytes != null && availablePercent <= blockPercent && availableBytes <= blockBytes
|
|
51
|
+
const recovered = pressureSignal
|
|
52
|
+
? availablePercent != null && availablePercent >= releasePercent
|
|
53
|
+
: availablePercent != null && availableBytes != null && (availablePercent >= releasePercent || availableBytes >= releaseBytes)
|
|
54
|
+
const nextBlocked = blocked ? !recovered : shouldBlock
|
|
55
|
+
|
|
56
|
+
return {
|
|
57
|
+
ok: !nextBlocked,
|
|
58
|
+
blocked: nextBlocked,
|
|
59
|
+
source: snapshot.source || 'unknown',
|
|
60
|
+
availablePercent,
|
|
61
|
+
availableBytes,
|
|
62
|
+
blockPercent,
|
|
63
|
+
releasePercent,
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function createHostMemoryGuard({
|
|
68
|
+
platform = process.platform,
|
|
69
|
+
env = process.env,
|
|
70
|
+
execFile = execFileSync,
|
|
71
|
+
readFile = fs.readFileSync,
|
|
72
|
+
freeMemory = os.freemem,
|
|
73
|
+
totalMemory = os.totalmem,
|
|
74
|
+
now = Date.now,
|
|
75
|
+
cacheMs = 5000,
|
|
76
|
+
} = {}) {
|
|
77
|
+
let cached = null
|
|
78
|
+
let cachedAt = 0
|
|
79
|
+
let blocked = false
|
|
80
|
+
|
|
81
|
+
const sample = () => {
|
|
82
|
+
if (platform === 'darwin') {
|
|
83
|
+
try {
|
|
84
|
+
const output = execFile('/usr/bin/memory_pressure', ['-Q'], { encoding: 'utf8', timeout: 1500, stdio: ['ignore', 'pipe', 'ignore'] })
|
|
85
|
+
const availablePercent = parseMacMemoryPressure(output)
|
|
86
|
+
if (availablePercent != null) return { availablePercent, source: 'macos-memory-pressure' }
|
|
87
|
+
} catch { /* fall through to a portable snapshot */ }
|
|
88
|
+
} else if (platform === 'linux') {
|
|
89
|
+
try {
|
|
90
|
+
const parsed = parseLinuxMemAvailable(readFile('/proc/meminfo', 'utf8'))
|
|
91
|
+
if (parsed) return parsed
|
|
92
|
+
} catch { /* fall through to a portable snapshot */ }
|
|
93
|
+
}
|
|
94
|
+
try {
|
|
95
|
+
return { availableBytes: freeMemory(), totalBytes: totalMemory(), source: 'os-freemem' }
|
|
96
|
+
} catch { return null }
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return function admitHostMemory(activity = 'start new work') {
|
|
100
|
+
const at = now()
|
|
101
|
+
if (!cached || at - cachedAt >= cacheMs) {
|
|
102
|
+
const decision = memoryDecision(sample(), { blocked, env })
|
|
103
|
+
blocked = decision.blocked
|
|
104
|
+
cached = decision
|
|
105
|
+
cachedAt = at
|
|
106
|
+
}
|
|
107
|
+
if (cached.ok) return cached
|
|
108
|
+
const pct = cached.availablePercent == null ? 'critically low' : `${Math.round(cached.availablePercent)}% available`
|
|
109
|
+
return {
|
|
110
|
+
...cached,
|
|
111
|
+
reason: `Host memory is critically low (${pct}). ThinkPool did not ${activity}. Existing lanes stay connected; close a lane or free memory, then retry.`,
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export const hostMemoryAdmission = createHostMemoryGuard()
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "thinkpool-pair",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.277",
|
|
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": {
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
},
|
|
9
9
|
"files": [
|
|
10
10
|
"bridge.mjs",
|
|
11
|
+
"host-memory.mjs",
|
|
11
12
|
"sdk-smoke.mjs",
|
|
12
13
|
"sdk-admission.mjs",
|
|
13
14
|
"sdk-admission.mjs",
|
package/service.mjs
CHANGED
|
@@ -22,6 +22,7 @@ import os from 'node:os'
|
|
|
22
22
|
import fs from 'node:fs'
|
|
23
23
|
import path from 'node:path'
|
|
24
24
|
import { execSync } from 'node:child_process'
|
|
25
|
+
import { hostMemoryAdmission } from './host-memory.mjs'
|
|
25
26
|
|
|
26
27
|
// Service identity. Account mode has no room → a single stable id so there's
|
|
27
28
|
// exactly one account service per machine (a second install replaces it).
|
|
@@ -85,11 +86,13 @@ function runtimeVersion(entry) {
|
|
|
85
86
|
} catch { return null }
|
|
86
87
|
}
|
|
87
88
|
|
|
88
|
-
export function provisionRuntime(version, { exec = execSync, root = path.join(os.homedir(), '.thinkpool-pair', 'runtimes') } = {}) {
|
|
89
|
+
export function provisionRuntime(version, { exec = execSync, root = path.join(os.homedir(), '.thinkpool-pair', 'runtimes'), admit = hostMemoryAdmission } = {}) {
|
|
89
90
|
if (!/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/.test(String(version))) throw new Error(`invalid runtime version: ${version}`)
|
|
90
91
|
const dir = path.join(root, String(version))
|
|
91
92
|
const entry = path.join(dir, 'node_modules', 'thinkpool-pair', 'bridge.mjs')
|
|
92
93
|
if (fs.existsSync(entry) && runtimeVersion(entry) === String(version)) return entry
|
|
94
|
+
const memory = admit('stage a bridge runtime update')
|
|
95
|
+
if (!memory?.ok) throw new Error(memory?.reason || 'host memory is critically low')
|
|
93
96
|
|
|
94
97
|
// Install beside the target and only swap it into place after BOTH the entrypoint
|
|
95
98
|
// and package version prove the payload. A killed/poisoned npm install must never
|