thinkpool-pair 0.7.275 → 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 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.
@@ -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 runQuery = async () => {
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 || ''))
@@ -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 || ''))
@@ -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.275",
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).
@@ -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