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 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). If an OS service manages this bridge, spawn a DETACHED updater
505
- // that re-pins the service to @latest and reloads it (install-service resolves the
506
- // newest version → rewrites the plist/unit → bootout+bootstrap). Detached → its own
507
- // session, so it SURVIVES this process being booted out (the reload kills us); it
508
- // inherits our env, whose PATH carries nvm's bin, so npx/node resolve — a bare launchd
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
- spawn('npx', ['-y', 'thinkpool-pair@latest', 'install-service'], { detached: true, stdio: 'ignore', env: process.env }).unref()
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.
@@ -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.274",
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. Wipe its disposable execution project
50
- // before resolving the package so an @latest restart cannot re-serve a stale cache.
51
- // `home` is the platform-correct, runtime-expanded home token ("$HOME" on darwin under
52
- // /bin/bash; "$$HOME" inside a systemd unit so systemd emits a literal $HOME to bash).
53
- // npx caches the temporary execution project as a unit. Removing only the package can
54
- // leave its generated .bin/package-lock metadata behind, producing the contradictory
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('"$HOME"')}; exec ${[npx, ...pkgArgs, ...tail].map(shq).join(' ')}`]
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('$$HOME')}; exec ${[npx, ...pkgArgs, ...tail].join(' ')}"`
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