thinkpool-pair 0.7.274 → 0.7.276
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 +17 -8
- package/bridge.mjs +32 -5
- package/claude-session.mjs +11 -3
- package/codex-session.mjs +12 -1
- package/hermes-session.mjs +10 -0
- package/host-memory.mjs +116 -0
- package/package.json +2 -1
- package/service.mjs +16 -16
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
|
|
|
@@ -501,12 +502,11 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
|
|
|
501
502
|
acct.on('broadcast', { event: 'restart' }, () => {
|
|
502
503
|
restarting = true
|
|
503
504
|
// The dashboard "Restart bridge" button ALSO updates to the newest published version
|
|
504
|
-
// (Max, 2026-07-03).
|
|
505
|
-
//
|
|
506
|
-
//
|
|
507
|
-
//
|
|
508
|
-
//
|
|
509
|
-
// PATH would NOT (the 2026-07-03 gotcha that made a manual updater silently no-op).
|
|
505
|
+
// (Max, 2026-07-03). Use service.mjs's authoritative registry → immutable runtime →
|
|
506
|
+
// one-shot reload transaction directly. Spawning `npx install-service` here used a
|
|
507
|
+
// disposable cache that legacy services could delete underneath the updater. The
|
|
508
|
+
// service primitive arms an independent launchd handoff before this process is booted
|
|
509
|
+
// out, so the reload survives us without a second package execution tree.
|
|
510
510
|
// The reload IS the bounce; sessions resume from disk on the new version. Best-effort:
|
|
511
511
|
// if there's no service or npm is unreachable, we fall through to the plain bounce, so
|
|
512
512
|
// the button is never dead — and even a failed update degrades to today's behaviour.
|
|
@@ -525,8 +525,7 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
|
|
|
525
525
|
try {
|
|
526
526
|
const svc = await import('./service.mjs')
|
|
527
527
|
if (svc.serviceActive(null)) {
|
|
528
|
-
|
|
529
|
-
updating = true
|
|
528
|
+
updating = svc.updateService(null) !== false
|
|
530
529
|
}
|
|
531
530
|
} catch { /* no service / spawn failed → plain bounce below */ }
|
|
532
531
|
process.stderr.write(updating
|
|
@@ -894,6 +893,16 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
|
|
|
894
893
|
|
|
895
894
|
// ── supervisor child_spawn logging (cascade brg-instrument) ─────────────────
|
|
896
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}`)
|
|
897
906
|
const child = spawn(process.execPath, [BRIDGE, room, '--headless', '--auto=claude'], { cwd: dir, stdio: ['inherit', 'inherit', 'inherit', 'ipc'], env })
|
|
898
907
|
console.log(`child_spawn sup=${SUP_ID} room=${room} pid=${child.pid} dir=${dir} had_prior=${alreadyHad}`)
|
|
899
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
|
@@ -352,7 +352,7 @@ function appServerItemForMapper(item) {
|
|
|
352
352
|
* @param {string} [o.providerConfig] optional -c overrides / provider block (M2: from the provider registry)
|
|
353
353
|
* @returns {{ sendTurn(text, options?), abort(), end(), readonly sessionId }}
|
|
354
354
|
*/
|
|
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 }) {
|
|
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, admitStart = null }) {
|
|
356
356
|
let activeMode = CODEX_MODE_CONFIG[mode] ? mode : 'default'
|
|
357
357
|
let modeConfig = codexConfigForMode(activeMode)
|
|
358
358
|
sandbox = normalizeCodexSandbox(sandbox || modeConfig.sandbox)
|
|
@@ -764,6 +764,17 @@ export function startCodexSession({ cwd, model, effort: initialEffort = 'high',
|
|
|
764
764
|
get started() { return turnNo > 0 || turnActive },
|
|
765
765
|
sendTurn(text, options = {}) {
|
|
766
766
|
if (ended) return false
|
|
767
|
+
// App Server stays resident between turns. Only gate when an idle turn
|
|
768
|
+
// would have to create a new host process (fresh/resumed cold lane or
|
|
769
|
+
// exec fallback after App Server failure). Steering an existing process
|
|
770
|
+
// and reading a restored transcript remain available under pressure.
|
|
771
|
+
if (!turnActive && queue.length === 0 && !appServerThreadReady && !child) {
|
|
772
|
+
const gate = typeof admitStart === 'function' ? admitStart() : { ok: true }
|
|
773
|
+
if (gate?.ok === false) {
|
|
774
|
+
try { onEvent?.({ kind: 'error', message: gate.reason || 'Host memory is critically low. This Codex runtime was not started.', recoverable: true }) } catch { /* noop */ }
|
|
775
|
+
return false
|
|
776
|
+
}
|
|
777
|
+
}
|
|
767
778
|
const promptIndex = userPromptNo++
|
|
768
779
|
const thisTurnForceFull = forceFullReminder
|
|
769
780
|
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.276",
|
|
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).
|
|
@@ -46,16 +47,12 @@ export function darwinServiceRunning(output) {
|
|
|
46
47
|
const text = String(output || '')
|
|
47
48
|
return /(^|\n)[ \t]*state = running[ \t]*($|\n)/.test(text) && /(^|\n)[ \t]*pid = \d+[ \t]*($|\n)/.test(text)
|
|
48
49
|
}
|
|
49
|
-
// Legacy auto-update services still use npx.
|
|
50
|
-
//
|
|
51
|
-
//
|
|
52
|
-
//
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
// state where `npm view` sees a version but `npx` reports ETARGET or no executable.
|
|
56
|
-
// The whole _npx directory is disposable; clear its children so the next launch is a
|
|
57
|
-
// genuinely clean install.
|
|
58
|
-
const cacheWipe = (home) => `rm -rf ${home}/.npm/_npx/* 2>/dev/null`
|
|
50
|
+
// Legacy auto-update services still use npx. They MUST use a bridge-owned cache:
|
|
51
|
+
// deleting ~/.npm/_npx corrupts unrelated interactive `npx thinkpool-pair` processes
|
|
52
|
+
// (0.7.274 removed account.mjs underneath a live launcher). The service cache is
|
|
53
|
+
// disposable and isolated, so clearing it cannot mutate a person's npm execution tree.
|
|
54
|
+
const serviceNpmCache = () => path.join(os.homedir(), '.thinkpool-pair', 'npm-cache')
|
|
55
|
+
const cacheWipe = (cacheRoot) => `rm -rf ${shq(path.join(cacheRoot, '_npx'))}/* 2>/dev/null`
|
|
59
56
|
|
|
60
57
|
// The version installing the service. By DEFAULT the service is pinned to this exact
|
|
61
58
|
// version (not @latest) so a future bad npm publish can't auto-roll to every machine's
|
|
@@ -89,11 +86,13 @@ function runtimeVersion(entry) {
|
|
|
89
86
|
} catch { return null }
|
|
90
87
|
}
|
|
91
88
|
|
|
92
|
-
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 } = {}) {
|
|
93
90
|
if (!/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/.test(String(version))) throw new Error(`invalid runtime version: ${version}`)
|
|
94
91
|
const dir = path.join(root, String(version))
|
|
95
92
|
const entry = path.join(dir, 'node_modules', 'thinkpool-pair', 'bridge.mjs')
|
|
96
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')
|
|
97
96
|
|
|
98
97
|
// Install beside the target and only swap it into place after BOTH the entrypoint
|
|
99
98
|
// and package version prove the payload. A killed/poisoned npm install must never
|
|
@@ -130,6 +129,7 @@ export function buildArtifact(platform, { room = null, cmdArgs = [], autoUpdate
|
|
|
130
129
|
const logDir = path.join(os.homedir(), '.thinkpool-pair')
|
|
131
130
|
const log = path.join(logDir, `${id}.log`)
|
|
132
131
|
const servicePath = sanitizeServicePath(process.env.PATH || '', platform)
|
|
132
|
+
const npmCache = serviceNpmCache()
|
|
133
133
|
const tail = cmdArgs.length ? ['--', ...cmdArgs] : []
|
|
134
134
|
const desc = room ? `bridge (${room})` : 'account bridge'
|
|
135
135
|
// OS-supervised tiers (launchd KeepAlive / systemd Restart) don't need --supervise.
|
|
@@ -156,7 +156,7 @@ export function buildArtifact(platform, { room = null, cmdArgs = [], autoUpdate
|
|
|
156
156
|
const args = runtimeEntry
|
|
157
157
|
? [process.execPath, runtimeEntry, ...(room ? [room] : []), ...tail]
|
|
158
158
|
: staleProof
|
|
159
|
-
? ['/bin/bash', '-lc', `${cacheWipe(
|
|
159
|
+
? ['/bin/bash', '-lc', `${cacheWipe(npmCache)}; export NPM_CONFIG_CACHE=${shq(npmCache)}; exec ${[npx, ...pkgArgs, ...tail].map(shq).join(' ')}`]
|
|
160
160
|
: [npx, ...pkgArgs, ...tail]
|
|
161
161
|
const file = path.join(os.homedir(), 'Library', 'LaunchAgents', `${label(room)}.plist`)
|
|
162
162
|
const content = `<?xml version="1.0" encoding="UTF-8"?>
|
|
@@ -175,7 +175,7 @@ export function buildArtifact(platform, { room = null, cmdArgs = [], autoUpdate
|
|
|
175
175
|
<key>WorkingDirectory</key><string>${xml(cwd)}</string>
|
|
176
176
|
<key>StandardOutPath</key><string>${xml(log)}</string>
|
|
177
177
|
<key>StandardErrorPath</key><string>${xml(log)}</string>
|
|
178
|
-
<key>EnvironmentVariables</key><dict><key>PATH</key><string>${xml(servicePath)}</string>${autoUpdate ? '<key>THINKPOOL_PAIR_AUTOUPDATE</key><string>1</string>' : ''}</dict>
|
|
178
|
+
<key>EnvironmentVariables</key><dict><key>PATH</key><string>${xml(servicePath)}</string>${runtimeEntry ? '' : `<key>NPM_CONFIG_CACHE</key><string>${xml(npmCache)}</string>`}${autoUpdate ? '<key>THINKPOOL_PAIR_AUTOUPDATE</key><string>1</string>' : ''}</dict>
|
|
179
179
|
</dict></plist>\n`
|
|
180
180
|
// The destructive reload is intentionally NOT represented as an inline `post`
|
|
181
181
|
// command. installService stages this plist and hands the transaction to an
|
|
@@ -193,7 +193,7 @@ export function buildArtifact(platform, { room = null, cmdArgs = [], autoUpdate
|
|
|
193
193
|
const execStart = runtimeEntry
|
|
194
194
|
? args.map((a) => `'${a.replace(/'/g, "'\\''")}'`).join(' ')
|
|
195
195
|
: staleProof
|
|
196
|
-
? `/bin/bash -lc "${cacheWipe(
|
|
196
|
+
? `/bin/bash -lc "${cacheWipe(npmCache)}; export NPM_CONFIG_CACHE=${shq(npmCache)}; exec ${[npx, ...pkgArgs, ...tail].join(' ')}"`
|
|
197
197
|
: args.map((a) => `'${a.replace(/'/g, "'\\''")}'`).join(' ')
|
|
198
198
|
const content = `[Unit]
|
|
199
199
|
Description=ThinkPool Code ${desc}
|
|
@@ -211,7 +211,7 @@ RestartSec=2
|
|
|
211
211
|
RestartPreventExitStatus=0
|
|
212
212
|
WorkingDirectory=${cwd}
|
|
213
213
|
Environment=PATH=${servicePath}
|
|
214
|
-
${autoUpdate ? 'Environment=THINKPOOL_PAIR_AUTOUPDATE=1\n' : ''}StandardOutput=append:${log}
|
|
214
|
+
${runtimeEntry ? '' : `Environment=NPM_CONFIG_CACHE=${npmCache}\n`}${autoUpdate ? 'Environment=THINKPOOL_PAIR_AUTOUPDATE=1\n' : ''}StandardOutput=append:${log}
|
|
215
215
|
StandardError=append:${log}
|
|
216
216
|
|
|
217
217
|
[Install]
|
|
@@ -232,7 +232,7 @@ WantedBy=default.target
|
|
|
232
232
|
: room
|
|
233
233
|
? ['npx', '-y', ...onlineFlag, verSpec, room, '--supervise', ...tail].join(' ')
|
|
234
234
|
: ['npx', '-y', ...onlineFlag, verSpec].join(' ')
|
|
235
|
-
const content = `@echo off\r\ntitle thinkpool-pair ${id}\r\n${inner}\r\n`
|
|
235
|
+
const content = `@echo off\r\ntitle thinkpool-pair ${id}\r\n${runtimeEntry ? '' : `set "NPM_CONFIG_CACHE=${npmCache}"\r\n`}${inner}\r\n`
|
|
236
236
|
return { file, content, logDir, post: [], note: 'Installed to the Startup folder — runs at login' + (room ? ' with --supervise (auto-restart on crash).' : ' (account mode).') + ' Start it now without rebooting by double-clicking the .cmd, or run it from a terminal.' }
|
|
237
237
|
}
|
|
238
238
|
|