@threadbase-sh/streamer 1.64.0 → 1.66.0

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/dist/index.cjs CHANGED
@@ -4136,7 +4136,7 @@ var import_events = require("events");
4136
4136
  var import_fs28 = require("fs");
4137
4137
  var import_promises7 = require("fs/promises");
4138
4138
  var import_http = require("http");
4139
- var import_os14 = require("os");
4139
+ var import_os15 = require("os");
4140
4140
  var import_path24 = require("path");
4141
4141
 
4142
4142
  // src/api/app.ts
@@ -4979,7 +4979,7 @@ function redactPath(path) {
4979
4979
  function worstStatus(checks) {
4980
4980
  const rank = { ok: 0, unknown: 1, degraded: 2, failed: 3 };
4981
4981
  return checks.reduce(
4982
- (worst, c) => rank[c.status] > rank[worst] ? c.status : worst,
4982
+ (worst2, c) => rank[c.status] > rank[worst2] ? c.status : worst2,
4983
4983
  "ok"
4984
4984
  );
4985
4985
  }
@@ -5994,7 +5994,12 @@ var createMiscRoutes = (deps) => {
5994
5994
  // Whether to encrypt to this server. Additive, same contract as `push`:
5995
5995
  // absent means an older server, which a client must read as "unknown" and
5996
5996
  // resolve as today's plaintext path — never as a reason to fail.
5997
- e2ee: describeE2eeCapability(deps.featureFlagsConfig().values.e2ee)
5997
+ e2ee: describeE2eeCapability(deps.featureFlagsConfig().values.e2ee),
5998
+ // This build samples cheap host signals and pushes `host_pressure` when
5999
+ // the box is starved. Additive capability flag only — live readings stay
6000
+ // off this polled endpoint. Absent means an older server that never
6001
+ // samples. Informational: pressure never holds, kills, or refuses sessions.
6002
+ hostPressure: true
5998
6003
  });
5999
6004
  });
6000
6005
  app.get("/api/profiles", (c) => c.json([]));
@@ -10660,6 +10665,19 @@ var SessionHandlers = class {
10660
10665
  sessions: this.sessionStore.list(this.deps.ptyAttachedIds())
10661
10666
  });
10662
10667
  }
10668
+ /**
10669
+ * Same empty-unused check stop uses (promptCount === 0 and no cache row,
10670
+ * including boundConversationId / resumedFromConversationId). Safe after
10671
+ * putOnHold: the runner may already have dropped the live session.
10672
+ */
10673
+ forgetIfEmptyUnused(sessionId) {
10674
+ const live = this.ptyManager.getSession(sessionId);
10675
+ const stored = this.sessionStore.getManaged(sessionId);
10676
+ const session = live ?? stored;
10677
+ if (!session) return;
10678
+ if (!this.shouldForgetEmptySession(session)) return;
10679
+ this.forgetEmptyStoppedSession(sessionId);
10680
+ }
10663
10681
  async handleAdopt(sessionId, res) {
10664
10682
  const discovered = await discoverClaudeProcesses();
10665
10683
  this.sessionStore.setDiscovered(discovered);
@@ -12843,6 +12861,7 @@ function createLiveSessionOptions(deps) {
12843
12861
  void deps.liveActivityNotifier()?.onStatusChange(session, previousStatus);
12844
12862
  void deps.waitingInputNotifier()?.onStatusChange(session, previousStatus);
12845
12863
  deps.sessionStatusBus.emit(`status:${session.id}`, session.status, session);
12864
+ deps.maybeFireHoldWhenIdle?.(session);
12846
12865
  }
12847
12866
  };
12848
12867
  }
@@ -12866,6 +12885,7 @@ function createApiDeps(deps) {
12866
12885
  wsHub: deps.wsHub,
12867
12886
  cache: () => deps.cache(),
12868
12887
  cacheMonitor: () => deps.cacheMonitor(),
12888
+ hostPressureMonitor: () => deps.hostPressureMonitor(),
12869
12889
  pushRepo: () => deps.pushRepo(),
12870
12890
  liveActivityPushEnabled: () => deps.liveActivityPushEnabled(),
12871
12891
  devicesRepo: () => deps.devicesRepo(),
@@ -12916,6 +12936,8 @@ function createApiDeps(deps) {
12916
12936
  }
12917
12937
  const alertMsg = deps.cacheMonitor()?.wsMessage();
12918
12938
  if (alertMsg) deps.wsHub.unicast(ws, alertMsg);
12939
+ const pressureMsg = deps.hostPressureMonitor()?.wsMessage();
12940
+ if (pressureMsg) deps.wsHub.unicast(ws, pressureMsg);
12919
12941
  },
12920
12942
  handleWsMessage: async (ws, raw, principal) => {
12921
12943
  const deny = (type, required) => {
@@ -12991,7 +13013,20 @@ function createApiDeps(deps) {
12991
13013
  deny(msg.type, "session:control");
12992
13014
  return;
12993
13015
  }
12994
- deps.startGraceTimer(msg.sessionId, deps.ptyGracePeriodMs);
13016
+ const when = msg.when;
13017
+ if (when === void 0 || when === "grace") {
13018
+ deps.startGraceTimer(msg.sessionId, deps.ptyGracePeriodMs);
13019
+ return;
13020
+ }
13021
+ if (when === "waiting_input") {
13022
+ deps.armHoldWhenIdle(msg.sessionId);
13023
+ return;
13024
+ }
13025
+ deps.log().warn(`[pty.hold_when_unknown] hold_session when=${String(when)}`, {
13026
+ event: "pty.hold_when_unknown",
13027
+ sessionId: msg.sessionId,
13028
+ when
13029
+ });
12995
13030
  }
12996
13031
  } catch {
12997
13032
  }
@@ -13528,6 +13563,221 @@ function pruneAgentConversations(cache) {
13528
13563
  return { scanned: rows.length, pruned, missing };
13529
13564
  }
13530
13565
 
13566
+ // src/services/host-pressure/hostPressure.ts
13567
+ var import_os13 = require("os");
13568
+ var import_perf_hooks = require("perf_hooks");
13569
+ var HOST_PRESSURE_SAMPLE_MS = 5e3;
13570
+ var HOST_PRESSURE_BARS = {
13571
+ memFreeRatio: {
13572
+ enterElevated: 0.15,
13573
+ leaveElevated: 0.17,
13574
+ enterCritical: 0.08,
13575
+ leaveCritical: 0.15
13576
+ },
13577
+ eventLoopP99Ms: {
13578
+ enterElevated: 100,
13579
+ leaveElevated: 80,
13580
+ enterCritical: 250,
13581
+ leaveCritical: 100
13582
+ },
13583
+ loadPerCpu: {
13584
+ enterElevated: 1.25,
13585
+ leaveElevated: 1.05,
13586
+ enterCritical: 2,
13587
+ leaveCritical: 1.25
13588
+ },
13589
+ // win32 has no loadavg. Busy ratio from os.cpus()[].times deltas is 0–1, so
13590
+ // it cannot reuse loadPerCpu's 2.0 critical bar. Reason on the wire stays `load`.
13591
+ cpuBusy: {
13592
+ enterElevated: 0.85,
13593
+ leaveElevated: 0.7,
13594
+ enterCritical: 0.97,
13595
+ leaveCritical: 0.85
13596
+ },
13597
+ liveAgentsPair: 4
13598
+ };
13599
+ var REASON_ORDER = ["memory", "event_loop", "load", "agents"];
13600
+ var RANK = { ok: 0, elevated: 1, critical: 2 };
13601
+ function worst(levels) {
13602
+ return levels.reduce(
13603
+ (acc, level) => RANK[level] > RANK[acc] ? level : acc,
13604
+ "ok"
13605
+ );
13606
+ }
13607
+ function schmittLowIsWorse(value, previous, enterElevated, leaveElevated, enterCritical, leaveCritical) {
13608
+ if (previous === "critical") {
13609
+ if (value < leaveCritical) return "critical";
13610
+ if (value < leaveElevated) return "elevated";
13611
+ return "ok";
13612
+ }
13613
+ if (previous === "elevated") {
13614
+ if (value < enterCritical) return "critical";
13615
+ if (value < leaveElevated) return "elevated";
13616
+ return "ok";
13617
+ }
13618
+ if (value < enterCritical) return "critical";
13619
+ if (value < enterElevated) return "elevated";
13620
+ return "ok";
13621
+ }
13622
+ function schmittHighIsWorse(value, previous, enterElevated, leaveElevated, enterCritical, leaveCritical) {
13623
+ if (previous === "critical") {
13624
+ if (value > leaveCritical) return "critical";
13625
+ if (value > leaveElevated) return "elevated";
13626
+ return "ok";
13627
+ }
13628
+ if (previous === "elevated") {
13629
+ if (value > enterCritical) return "critical";
13630
+ if (value > leaveElevated) return "elevated";
13631
+ return "ok";
13632
+ }
13633
+ if (value > enterCritical) return "critical";
13634
+ if (value > enterElevated) return "elevated";
13635
+ return "ok";
13636
+ }
13637
+ function timesTotal(times) {
13638
+ return times.user + times.nice + times.sys + times.idle + times.irq;
13639
+ }
13640
+ function hostPressureOs(platform3) {
13641
+ if (platform3 === "darwin" || platform3 === "linux" || platform3 === "win32") return platform3;
13642
+ return void 0;
13643
+ }
13644
+ function cpuBusyRatio(previous, next) {
13645
+ if (!previous || previous.length === 0 || next.length === 0 || previous.length !== next.length) {
13646
+ return 0;
13647
+ }
13648
+ let idle = 0;
13649
+ let total = 0;
13650
+ for (let i = 0; i < next.length; i++) {
13651
+ const dt = timesTotal(next[i]) - timesTotal(previous[i]);
13652
+ if (dt <= 0) continue;
13653
+ total += dt;
13654
+ idle += Math.max(0, next[i].idle - previous[i].idle);
13655
+ }
13656
+ if (total <= 0) return 0;
13657
+ return 1 - idle / total;
13658
+ }
13659
+ function classifyHostPressure(sample, previous, platform3) {
13660
+ const memRaw = schmittLowIsWorse(
13661
+ sample.memFreeRatio,
13662
+ previous,
13663
+ HOST_PRESSURE_BARS.memFreeRatio.enterElevated,
13664
+ HOST_PRESSURE_BARS.memFreeRatio.leaveElevated,
13665
+ HOST_PRESSURE_BARS.memFreeRatio.enterCritical,
13666
+ HOST_PRESSURE_BARS.memFreeRatio.leaveCritical
13667
+ );
13668
+ const mem = platform3 === "darwin" && memRaw === "critical" ? "elevated" : memRaw;
13669
+ const eventLoop = schmittHighIsWorse(
13670
+ sample.eventLoopP99Ms,
13671
+ previous,
13672
+ HOST_PRESSURE_BARS.eventLoopP99Ms.enterElevated,
13673
+ HOST_PRESSURE_BARS.eventLoopP99Ms.leaveElevated,
13674
+ HOST_PRESSURE_BARS.eventLoopP99Ms.enterCritical,
13675
+ HOST_PRESSURE_BARS.eventLoopP99Ms.leaveCritical
13676
+ );
13677
+ const ncpu = sample.ncpu > 0 ? sample.ncpu : 1;
13678
+ const windows = platform3 === "win32";
13679
+ const loadValue = windows ? sample.cpuBusyRatio ?? 0 : sample.load1 / ncpu;
13680
+ const loadBars = windows ? HOST_PRESSURE_BARS.cpuBusy : HOST_PRESSURE_BARS.loadPerCpu;
13681
+ const load = schmittHighIsWorse(
13682
+ loadValue,
13683
+ previous,
13684
+ loadBars.enterElevated,
13685
+ loadBars.leaveElevated,
13686
+ loadBars.enterCritical,
13687
+ loadBars.leaveCritical
13688
+ );
13689
+ const resourceLevel = worst([mem, eventLoop, load]);
13690
+ const agentsPair = sample.liveAgents >= HOST_PRESSURE_BARS.liveAgentsPair && resourceLevel !== "ok";
13691
+ const level = resourceLevel;
13692
+ const firing = [];
13693
+ if (mem !== "ok") firing.push("memory");
13694
+ if (eventLoop !== "ok") firing.push("event_loop");
13695
+ if (load !== "ok") firing.push("load");
13696
+ if (agentsPair) firing.push("agents");
13697
+ const reasons = REASON_ORDER.filter((reason) => firing.includes(reason));
13698
+ return { level, reasons };
13699
+ }
13700
+ var HostPressureMonitor = class {
13701
+ constructor(opts) {
13702
+ this.opts = opts;
13703
+ }
13704
+ opts;
13705
+ timer = null;
13706
+ level = "ok";
13707
+ lastWarning = null;
13708
+ start() {
13709
+ this.opts.histogram?.enable();
13710
+ if (this.timer) return;
13711
+ const intervalMs = this.opts.intervalMs ?? HOST_PRESSURE_SAMPLE_MS;
13712
+ this.timer = setInterval(() => this.tick(), intervalMs);
13713
+ this.timer.unref?.();
13714
+ }
13715
+ tick() {
13716
+ const sample = this.opts.readSample();
13717
+ const platform3 = this.opts.platform ?? process.platform;
13718
+ const classified = classifyHostPressure(sample, this.level, platform3);
13719
+ if (classified.level === this.level) return;
13720
+ this.level = classified.level;
13721
+ const updatedAt = (this.opts.now ?? (() => /* @__PURE__ */ new Date()))().toISOString();
13722
+ if (classified.level === "ok") {
13723
+ this.lastWarning = null;
13724
+ this.opts.wsHub.broadcast({ type: "host_pressure_cleared", updatedAt });
13725
+ return;
13726
+ }
13727
+ const os2 = hostPressureOs(platform3);
13728
+ const message = {
13729
+ type: "host_pressure",
13730
+ level: classified.level,
13731
+ reasons: classified.reasons,
13732
+ liveAgents: sample.liveAgents,
13733
+ updatedAt,
13734
+ ...os2 ? { os: os2 } : {}
13735
+ };
13736
+ this.lastWarning = message;
13737
+ this.opts.wsHub.broadcast(message);
13738
+ }
13739
+ wsMessage() {
13740
+ return this.lastWarning;
13741
+ }
13742
+ dispose() {
13743
+ if (this.timer) {
13744
+ clearInterval(this.timer);
13745
+ this.timer = null;
13746
+ }
13747
+ this.opts.histogram?.disable();
13748
+ }
13749
+ };
13750
+ function createHostPressureMonitor(wsHub, liveAgents) {
13751
+ const histogram = (0, import_perf_hooks.monitorEventLoopDelay)({ resolution: 20 });
13752
+ const windows = process.platform === "win32";
13753
+ let prevCpuTimes = null;
13754
+ const monitor = new HostPressureMonitor({
13755
+ wsHub,
13756
+ histogram,
13757
+ readSample: () => {
13758
+ const eventLoopP99Ms = histogram.percentile(99) / 1e6;
13759
+ histogram.reset();
13760
+ const total = (0, import_os13.totalmem)();
13761
+ const cpuList = (0, import_os13.cpus)();
13762
+ const sample = {
13763
+ liveAgents: liveAgents(),
13764
+ memFreeRatio: total > 0 ? (0, import_os13.freemem)() / total : 1,
13765
+ eventLoopP99Ms,
13766
+ load1: (0, import_os13.loadavg)()[0],
13767
+ ncpu: cpuList.length
13768
+ };
13769
+ if (windows) {
13770
+ const cpuTimes = cpuList.map((cpu) => cpu.times);
13771
+ sample.cpuBusyRatio = cpuBusyRatio(prevCpuTimes, cpuTimes);
13772
+ prevCpuTimes = cpuTimes;
13773
+ }
13774
+ return sample;
13775
+ }
13776
+ });
13777
+ monitor.start();
13778
+ return monitor;
13779
+ }
13780
+
13531
13781
  // src/services/push/expoPushSender.ts
13532
13782
  var log5 = getLogger("expo-push");
13533
13783
  var EXPO_PUSH_ENDPOINT = "https://exp.host/--/api/v2/push/send";
@@ -15027,7 +15277,7 @@ function discoveredToResponse(d, conversationId) {
15027
15277
 
15028
15278
  // src/session-watchers.ts
15029
15279
  var import_fs27 = require("fs");
15030
- var import_os13 = require("os");
15280
+ var import_os14 = require("os");
15031
15281
  var import_path23 = require("path");
15032
15282
  var SessionWatchers = class {
15033
15283
  constructor(deps) {
@@ -15109,7 +15359,7 @@ var SessionWatchers = class {
15109
15359
  // was passed to Claude via --session-id so the filename matches from the start.
15110
15360
  watchForJsonl(sessionId, projectPath) {
15111
15361
  const encoded = projectPath.replace(/[/\\:.]/g, "-");
15112
- const projectsDir = (0, import_path23.join)((0, import_os13.homedir)(), ".claude", "projects", encoded);
15362
+ const projectsDir = (0, import_path23.join)((0, import_os14.homedir)(), ".claude", "projects", encoded);
15113
15363
  const expectedFile = `${sessionId}.jsonl`;
15114
15364
  const filePath = (0, import_path23.join)(projectsDir, expectedFile);
15115
15365
  const deadline = Date.now() + 12e4;
@@ -15547,6 +15797,10 @@ var StreamerServer = class {
15547
15797
  // Consecutive grace-timer defers for a still-`running` session (see
15548
15798
  // GRACE_MAX_DEFERS). Reset when a subscriber reconnects or the PTY settles.
15549
15799
  ptyGraceDeferCounts = /* @__PURE__ */ new Map();
15800
+ // Session ids that should be putOnHold at the next waiting_input/idle.
15801
+ // Same lifetime as ptyGraceTimers: in-memory, dropped on close/restart.
15802
+ // Last writer wins against the grace timer — never both armed.
15803
+ holdWhenIdle = /* @__PURE__ */ new Set();
15550
15804
  // Map of sessionId → set of subscribed WS clients
15551
15805
  sessionSubscribers = /* @__PURE__ */ new Map();
15552
15806
  // sessionId → wall-clock ms of the last PTY chunk. Written from onOutput for
@@ -15572,6 +15826,7 @@ var StreamerServer = class {
15572
15826
  wsToClientId = /* @__PURE__ */ new Map();
15573
15827
  cache = null;
15574
15828
  cacheMonitor = null;
15829
+ hostPressureMonitor = null;
15575
15830
  projectsRepo = null;
15576
15831
  conversationsRepo = null;
15577
15832
  sessionsRepo = null;
@@ -15636,7 +15891,7 @@ var StreamerServer = class {
15636
15891
  this.skipStartupWarmup = config.skipStartupWarmup ?? false;
15637
15892
  this.autoResumeOnBoot = config.autoResumeOnBoot ?? false;
15638
15893
  this.scanProfiles = config.scanProfiles;
15639
- this.codexRoots = config.codexRoots ?? [(0, import_path24.join)((0, import_os14.homedir)(), ".codex", "sessions")];
15894
+ this.codexRoots = config.codexRoots ?? [(0, import_path24.join)((0, import_os15.homedir)(), ".codex", "sessions")];
15640
15895
  this.ptyGracePeriodMs = config.ptyGracePeriodMs ?? DEFAULT_PTY_GRACE_PERIOD_MS;
15641
15896
  this.defaultSystemPrompt = config.defaultSystemPrompt ?? DEFAULT_SYSTEM_PROMPT;
15642
15897
  const flagResolution = resolveFeatureFlags({
@@ -15653,7 +15908,7 @@ var StreamerServer = class {
15653
15908
  this.claudeFlagsPersistable = config.claudeFlags === void 0;
15654
15909
  this.claudeFlags = config.claudeFlags ?? loadClaudeFlags();
15655
15910
  this.claudeExtraArgs = config.claudeExtraArgs ?? loadClaudeExtraArgs();
15656
- this.cacheDir = config.cacheDir ?? loadCacheDir() ?? (0, import_path24.join)((0, import_os14.homedir)(), ".threadbase", "cache");
15911
+ this.cacheDir = config.cacheDir ?? loadCacheDir() ?? (0, import_path24.join)((0, import_os15.homedir)(), ".threadbase", "cache");
15657
15912
  this.runtimeDbPath = resolveRuntimeDbPath(config.runtimeDbPath);
15658
15913
  this.tailSize = config.tailSize ?? loadTailSize() ?? 10;
15659
15914
  this.directoryDebounceMs = parseDirScanDebounceEnv(process.env.THREADBASE_DIR_SCAN_DEBOUNCE_MS) ?? config.directoryScanDebounceMs ?? 1e3;
@@ -15774,7 +16029,8 @@ var StreamerServer = class {
15774
16029
  waitingInputNotifier: () => this.waitingInputNotifier,
15775
16030
  ptyAttachedIds: () => this.ptyAttachedIds(),
15776
16031
  cancelPendingQuestion: (sessionId) => this.cancelPendingQuestion(sessionId),
15777
- rememberSelfPtyEnded: (conversationId) => this.rememberSelfPtyEnded(conversationId)
16032
+ rememberSelfPtyEnded: (conversationId) => this.rememberSelfPtyEnded(conversationId),
16033
+ maybeFireHoldWhenIdle: (session) => this.maybeFireHoldWhenIdle(session)
15778
16034
  })
15779
16035
  );
15780
16036
  this.sessionWatchers = new SessionWatchers({
@@ -15921,6 +16177,7 @@ var StreamerServer = class {
15921
16177
  // reset-and-rescan, and tests swap methods on the server instance.
15922
16178
  cache: () => this.cache,
15923
16179
  cacheMonitor: () => this.cacheMonitor,
16180
+ hostPressureMonitor: () => this.hostPressureMonitor,
15924
16181
  pushRepo: () => this.pushRepo,
15925
16182
  liveActivityPushEnabled: () => this.liveActivityNotifier !== null,
15926
16183
  devicesRepo: () => this.devicesRepo,
@@ -15937,6 +16194,7 @@ var StreamerServer = class {
15937
16194
  currentWarmupState: () => this.currentWarmupState(),
15938
16195
  addSessionSubscriber: (sessionId, ws) => this.addSessionSubscriber(sessionId, ws),
15939
16196
  startGraceTimer: (sessionId, delayMs) => this.startGraceTimer(sessionId, delayMs),
16197
+ armHoldWhenIdle: (sessionId) => this.armHoldWhenIdle(sessionId),
15940
16198
  handleSessionsCount: (res) => this.handleSessionsCount(res),
15941
16199
  applyLiveSessionSetting: (id, req, res, setting) => this.applyLiveSessionSetting(id, req, res, setting),
15942
16200
  handlePairStart: (res) => this.handlePairStart(res),
@@ -16056,6 +16314,13 @@ var StreamerServer = class {
16056
16314
  this.ptyGraceTimers.delete(sessionId);
16057
16315
  }
16058
16316
  this.ptyGraceDeferCounts.delete(sessionId);
16317
+ if (this.holdWhenIdle.delete(sessionId)) {
16318
+ this.log.info(
16319
+ `[hold-when-idle] cancelled ${sessionId} (subscribe)`,
16320
+ { sessionId, event: "pty.hold_when_idle_cancel", reason: "subscribe" },
16321
+ "pino"
16322
+ );
16323
+ }
16059
16324
  }
16060
16325
  /**
16061
16326
  * Bring up Live Activity push, if credentials are present (Feature 12).
@@ -16083,14 +16348,14 @@ var StreamerServer = class {
16083
16348
  }
16084
16349
  this.apnsClient = new ApnsClient(creds);
16085
16350
  const sender = new LiveActivitySender(this.apnsClient, pushRepo);
16086
- const serverId = process.env.THREADBASE_INSTANCE_ID ?? (0, import_os14.hostname)();
16087
- this.liveActivityNotifier = new LiveActivityNotifier(sender, serverId, (0, import_os14.hostname)());
16351
+ const serverId = process.env.THREADBASE_INSTANCE_ID ?? (0, import_os15.hostname)();
16352
+ this.liveActivityNotifier = new LiveActivityNotifier(sender, serverId, (0, import_os15.hostname)());
16088
16353
  this.liveActivityRenewal = new LiveActivityRenewalScheduler({
16089
16354
  repo: pushRepo,
16090
16355
  sender,
16091
16356
  sessionStore: this.sessionStore,
16092
16357
  serverId,
16093
- serverLabel: (0, import_os14.hostname)()
16358
+ serverLabel: (0, import_os15.hostname)()
16094
16359
  });
16095
16360
  this.liveActivityRenewal.start();
16096
16361
  this.log.info("Live Activity push enabled", {
@@ -16110,7 +16375,7 @@ var StreamerServer = class {
16110
16375
  */
16111
16376
  initWaitingInputPush(pushRepo) {
16112
16377
  const sender = new ExpoPushSender(pushRepo, process.env.THREADBASE_EXPO_ACCESS_TOKEN);
16113
- const serverId = process.env.THREADBASE_INSTANCE_ID ?? (0, import_os14.hostname)();
16378
+ const serverId = process.env.THREADBASE_INSTANCE_ID ?? (0, import_os15.hostname)();
16114
16379
  this.waitingInputNotifier = new WaitingInputNotifier(
16115
16380
  sender,
16116
16381
  serverId,
@@ -16163,6 +16428,7 @@ var StreamerServer = class {
16163
16428
  return reaped;
16164
16429
  }
16165
16430
  startGraceTimer(sessionId, delayMs) {
16431
+ this.holdWhenIdle.delete(sessionId);
16166
16432
  const existing = this.ptyGraceTimers.get(sessionId);
16167
16433
  if (existing) clearTimeout(existing);
16168
16434
  const timer = setTimeout(() => {
@@ -16204,6 +16470,63 @@ var StreamerServer = class {
16204
16470
  }, delayMs);
16205
16471
  this.ptyGraceTimers.set(sessionId, timer);
16206
16472
  }
16473
+ clearGrace(sessionId) {
16474
+ const existing = this.ptyGraceTimers.get(sessionId);
16475
+ if (existing) {
16476
+ clearTimeout(existing);
16477
+ this.ptyGraceTimers.delete(sessionId);
16478
+ }
16479
+ this.ptyGraceDeferCounts.delete(sessionId);
16480
+ }
16481
+ /**
16482
+ * Arm the in-app "Kill on idle" latch: hold now if already settled, otherwise
16483
+ * on the next running → waiting_input (or idle). No grace delay, no defer cap.
16484
+ * A subscribed leaving socket must not block an immediate hold.
16485
+ */
16486
+ armHoldWhenIdle(sessionId) {
16487
+ if (!this.ptyManager.hasSession(sessionId)) return;
16488
+ this.clearGrace(sessionId);
16489
+ const status = this.ptyManager.getSession(sessionId)?.status ?? this.sessionStore.getManaged(sessionId)?.status;
16490
+ if (status === "waiting_input" || status === "idle") {
16491
+ this.holdWhenIdle.delete(sessionId);
16492
+ this.ptyManager.putOnHold(sessionId);
16493
+ this.forgetIfEmptyUnused(sessionId);
16494
+ return;
16495
+ }
16496
+ this.holdWhenIdle.add(sessionId);
16497
+ this.log.info(
16498
+ `[hold-when-idle] armed ${sessionId}`,
16499
+ { sessionId, event: "pty.hold_when_idle_armed" },
16500
+ "pino"
16501
+ );
16502
+ }
16503
+ /**
16504
+ * Fire the Kill-on-idle latch from the shared onStatusChange funnel.
16505
+ * Delete first so the ensuing idle transition cannot re-enter.
16506
+ */
16507
+ maybeFireHoldWhenIdle(session) {
16508
+ if (session.status !== "waiting_input" && session.status !== "idle") return;
16509
+ if (!this.holdWhenIdle.has(session.id)) return;
16510
+ this.holdWhenIdle.delete(session.id);
16511
+ if (this.hasSessionSubscriber(session.id)) {
16512
+ this.log.info(
16513
+ `[hold-when-idle] cancelled ${session.id} (subscriber)`,
16514
+ { sessionId: session.id, event: "pty.hold_when_idle_cancel", reason: "subscriber" },
16515
+ "pino"
16516
+ );
16517
+ return;
16518
+ }
16519
+ this.log.info(
16520
+ `[hold-when-idle] holding ${session.id}`,
16521
+ { sessionId: session.id, event: "pty.hold_when_idle_fire" },
16522
+ "pino"
16523
+ );
16524
+ this.ptyManager.putOnHold(session.id);
16525
+ this.forgetIfEmptyUnused(session.id);
16526
+ }
16527
+ forgetIfEmptyUnused(sessionId) {
16528
+ this.sessionHandlers.forgetIfEmptyUnused(sessionId);
16529
+ }
16207
16530
  get port() {
16208
16531
  const addr = this.httpServer.address();
16209
16532
  return typeof addr === "object" && addr ? addr.port : 0;
@@ -16282,7 +16605,7 @@ var StreamerServer = class {
16282
16605
  let sessions = null;
16283
16606
  for (let attempt = 0; attempt < 2; attempt += 1) {
16284
16607
  const transport = await connectOrSpawnHost({
16285
- instanceId: process.env.THREADBASE_INSTANCE_ID ?? (0, import_os14.hostname)()
16608
+ instanceId: process.env.THREADBASE_INSTANCE_ID ?? (0, import_os15.hostname)()
16286
16609
  });
16287
16610
  try {
16288
16611
  sessions = await this.ptyManager.useRemoteRunner(transport);
@@ -16329,6 +16652,10 @@ var StreamerServer = class {
16329
16652
  this.idleReaperTimer = setInterval(() => this.reapIdleSessions(), IDLE_REAP_SWEEP_MS);
16330
16653
  this.idleReaperTimer.unref?.();
16331
16654
  }
16655
+ this.hostPressureMonitor = createHostPressureMonitor(
16656
+ this.wsHub,
16657
+ () => this.ptyAttachedIds().size
16658
+ );
16332
16659
  const warmUp = new Promise((resolveWarm) => {
16333
16660
  {
16334
16661
  this.log.info(`Streamer server listening on port ${port}`, {
@@ -16636,10 +16963,13 @@ var StreamerServer = class {
16636
16963
  async close() {
16637
16964
  for (const timer of this.ptyGraceTimers.values()) clearTimeout(timer);
16638
16965
  this.ptyGraceTimers.clear();
16966
+ this.holdWhenIdle.clear();
16639
16967
  if (this.idleReaperTimer) {
16640
16968
  clearInterval(this.idleReaperTimer);
16641
16969
  this.idleReaperTimer = null;
16642
16970
  }
16971
+ this.hostPressureMonitor?.dispose();
16972
+ this.hostPressureMonitor = null;
16643
16973
  this.lastAgentChunkAt.clear();
16644
16974
  this.terminalSeq.clear();
16645
16975
  if (this.ptyManager.isRemote()) this.ptyManager.dispose();
@@ -16833,7 +17163,7 @@ var StreamerServer = class {
16833
17163
  deviceToken: device.deviceToken,
16834
17164
  capabilities: device.capabilities,
16835
17165
  publicUrl: this.publicUrl,
16836
- machineName: (0, import_os14.hostname)(),
17166
+ machineName: (0, import_os15.hostname)(),
16837
17167
  serverVersion: getVersion()
16838
17168
  })
16839
17169
  );
@@ -16867,7 +17197,7 @@ var StreamerServer = class {
16867
17197
  // Released builds that predate that fix DO still adopt it, so changing
16868
17198
  // this value moves where old devices talk. It is not a free field.
16869
17199
  publicUrl: this.publicUrl,
16870
- machineName: (0, import_os14.hostname)(),
17200
+ machineName: (0, import_os15.hostname)(),
16871
17201
  ...device && {
16872
17202
  deviceId: device.deviceId,
16873
17203
  deviceToken: device.deviceToken,