@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.js CHANGED
@@ -4926,7 +4926,7 @@ function redactPath(path) {
4926
4926
  function worstStatus(checks) {
4927
4927
  const rank = { ok: 0, unknown: 1, degraded: 2, failed: 3 };
4928
4928
  return checks.reduce(
4929
- (worst, c) => rank[c.status] > rank[worst] ? c.status : worst,
4929
+ (worst2, c) => rank[c.status] > rank[worst2] ? c.status : worst2,
4930
4930
  "ok"
4931
4931
  );
4932
4932
  }
@@ -5946,7 +5946,12 @@ var createMiscRoutes = (deps) => {
5946
5946
  // Whether to encrypt to this server. Additive, same contract as `push`:
5947
5947
  // absent means an older server, which a client must read as "unknown" and
5948
5948
  // resolve as today's plaintext path — never as a reason to fail.
5949
- e2ee: describeE2eeCapability(deps.featureFlagsConfig().values.e2ee)
5949
+ e2ee: describeE2eeCapability(deps.featureFlagsConfig().values.e2ee),
5950
+ // This build samples cheap host signals and pushes `host_pressure` when
5951
+ // the box is starved. Additive capability flag only — live readings stay
5952
+ // off this polled endpoint. Absent means an older server that never
5953
+ // samples. Informational: pressure never holds, kills, or refuses sessions.
5954
+ hostPressure: true
5950
5955
  });
5951
5956
  });
5952
5957
  app.get("/api/profiles", (c) => c.json([]));
@@ -10620,6 +10625,19 @@ var SessionHandlers = class {
10620
10625
  sessions: this.sessionStore.list(this.deps.ptyAttachedIds())
10621
10626
  });
10622
10627
  }
10628
+ /**
10629
+ * Same empty-unused check stop uses (promptCount === 0 and no cache row,
10630
+ * including boundConversationId / resumedFromConversationId). Safe after
10631
+ * putOnHold: the runner may already have dropped the live session.
10632
+ */
10633
+ forgetIfEmptyUnused(sessionId) {
10634
+ const live = this.ptyManager.getSession(sessionId);
10635
+ const stored = this.sessionStore.getManaged(sessionId);
10636
+ const session = live ?? stored;
10637
+ if (!session) return;
10638
+ if (!this.shouldForgetEmptySession(session)) return;
10639
+ this.forgetEmptyStoppedSession(sessionId);
10640
+ }
10623
10641
  async handleAdopt(sessionId, res) {
10624
10642
  const discovered = await discoverClaudeProcesses();
10625
10643
  this.sessionStore.setDiscovered(discovered);
@@ -12814,6 +12832,7 @@ function createLiveSessionOptions(deps) {
12814
12832
  void deps.liveActivityNotifier()?.onStatusChange(session, previousStatus);
12815
12833
  void deps.waitingInputNotifier()?.onStatusChange(session, previousStatus);
12816
12834
  deps.sessionStatusBus.emit(`status:${session.id}`, session.status, session);
12835
+ deps.maybeFireHoldWhenIdle?.(session);
12817
12836
  }
12818
12837
  };
12819
12838
  }
@@ -12837,6 +12856,7 @@ function createApiDeps(deps) {
12837
12856
  wsHub: deps.wsHub,
12838
12857
  cache: () => deps.cache(),
12839
12858
  cacheMonitor: () => deps.cacheMonitor(),
12859
+ hostPressureMonitor: () => deps.hostPressureMonitor(),
12840
12860
  pushRepo: () => deps.pushRepo(),
12841
12861
  liveActivityPushEnabled: () => deps.liveActivityPushEnabled(),
12842
12862
  devicesRepo: () => deps.devicesRepo(),
@@ -12887,6 +12907,8 @@ function createApiDeps(deps) {
12887
12907
  }
12888
12908
  const alertMsg = deps.cacheMonitor()?.wsMessage();
12889
12909
  if (alertMsg) deps.wsHub.unicast(ws, alertMsg);
12910
+ const pressureMsg = deps.hostPressureMonitor()?.wsMessage();
12911
+ if (pressureMsg) deps.wsHub.unicast(ws, pressureMsg);
12890
12912
  },
12891
12913
  handleWsMessage: async (ws, raw, principal) => {
12892
12914
  const deny = (type, required) => {
@@ -12962,7 +12984,20 @@ function createApiDeps(deps) {
12962
12984
  deny(msg.type, "session:control");
12963
12985
  return;
12964
12986
  }
12965
- deps.startGraceTimer(msg.sessionId, deps.ptyGracePeriodMs);
12987
+ const when = msg.when;
12988
+ if (when === void 0 || when === "grace") {
12989
+ deps.startGraceTimer(msg.sessionId, deps.ptyGracePeriodMs);
12990
+ return;
12991
+ }
12992
+ if (when === "waiting_input") {
12993
+ deps.armHoldWhenIdle(msg.sessionId);
12994
+ return;
12995
+ }
12996
+ deps.log().warn(`[pty.hold_when_unknown] hold_session when=${String(when)}`, {
12997
+ event: "pty.hold_when_unknown",
12998
+ sessionId: msg.sessionId,
12999
+ when
13000
+ });
12966
13001
  }
12967
13002
  } catch {
12968
13003
  }
@@ -13499,6 +13534,221 @@ function pruneAgentConversations(cache) {
13499
13534
  return { scanned: rows.length, pruned, missing };
13500
13535
  }
13501
13536
 
13537
+ // src/services/host-pressure/hostPressure.ts
13538
+ import { cpus, freemem, loadavg, totalmem } from "os";
13539
+ import { monitorEventLoopDelay } from "perf_hooks";
13540
+ var HOST_PRESSURE_SAMPLE_MS = 5e3;
13541
+ var HOST_PRESSURE_BARS = {
13542
+ memFreeRatio: {
13543
+ enterElevated: 0.15,
13544
+ leaveElevated: 0.17,
13545
+ enterCritical: 0.08,
13546
+ leaveCritical: 0.15
13547
+ },
13548
+ eventLoopP99Ms: {
13549
+ enterElevated: 100,
13550
+ leaveElevated: 80,
13551
+ enterCritical: 250,
13552
+ leaveCritical: 100
13553
+ },
13554
+ loadPerCpu: {
13555
+ enterElevated: 1.25,
13556
+ leaveElevated: 1.05,
13557
+ enterCritical: 2,
13558
+ leaveCritical: 1.25
13559
+ },
13560
+ // win32 has no loadavg. Busy ratio from os.cpus()[].times deltas is 0–1, so
13561
+ // it cannot reuse loadPerCpu's 2.0 critical bar. Reason on the wire stays `load`.
13562
+ cpuBusy: {
13563
+ enterElevated: 0.85,
13564
+ leaveElevated: 0.7,
13565
+ enterCritical: 0.97,
13566
+ leaveCritical: 0.85
13567
+ },
13568
+ liveAgentsPair: 4
13569
+ };
13570
+ var REASON_ORDER = ["memory", "event_loop", "load", "agents"];
13571
+ var RANK = { ok: 0, elevated: 1, critical: 2 };
13572
+ function worst(levels) {
13573
+ return levels.reduce(
13574
+ (acc, level) => RANK[level] > RANK[acc] ? level : acc,
13575
+ "ok"
13576
+ );
13577
+ }
13578
+ function schmittLowIsWorse(value, previous, enterElevated, leaveElevated, enterCritical, leaveCritical) {
13579
+ if (previous === "critical") {
13580
+ if (value < leaveCritical) return "critical";
13581
+ if (value < leaveElevated) return "elevated";
13582
+ return "ok";
13583
+ }
13584
+ if (previous === "elevated") {
13585
+ if (value < enterCritical) return "critical";
13586
+ if (value < leaveElevated) return "elevated";
13587
+ return "ok";
13588
+ }
13589
+ if (value < enterCritical) return "critical";
13590
+ if (value < enterElevated) return "elevated";
13591
+ return "ok";
13592
+ }
13593
+ function schmittHighIsWorse(value, previous, enterElevated, leaveElevated, enterCritical, leaveCritical) {
13594
+ if (previous === "critical") {
13595
+ if (value > leaveCritical) return "critical";
13596
+ if (value > leaveElevated) return "elevated";
13597
+ return "ok";
13598
+ }
13599
+ if (previous === "elevated") {
13600
+ if (value > enterCritical) return "critical";
13601
+ if (value > leaveElevated) return "elevated";
13602
+ return "ok";
13603
+ }
13604
+ if (value > enterCritical) return "critical";
13605
+ if (value > enterElevated) return "elevated";
13606
+ return "ok";
13607
+ }
13608
+ function timesTotal(times) {
13609
+ return times.user + times.nice + times.sys + times.idle + times.irq;
13610
+ }
13611
+ function hostPressureOs(platform3) {
13612
+ if (platform3 === "darwin" || platform3 === "linux" || platform3 === "win32") return platform3;
13613
+ return void 0;
13614
+ }
13615
+ function cpuBusyRatio(previous, next) {
13616
+ if (!previous || previous.length === 0 || next.length === 0 || previous.length !== next.length) {
13617
+ return 0;
13618
+ }
13619
+ let idle = 0;
13620
+ let total = 0;
13621
+ for (let i = 0; i < next.length; i++) {
13622
+ const dt = timesTotal(next[i]) - timesTotal(previous[i]);
13623
+ if (dt <= 0) continue;
13624
+ total += dt;
13625
+ idle += Math.max(0, next[i].idle - previous[i].idle);
13626
+ }
13627
+ if (total <= 0) return 0;
13628
+ return 1 - idle / total;
13629
+ }
13630
+ function classifyHostPressure(sample, previous, platform3) {
13631
+ const memRaw = schmittLowIsWorse(
13632
+ sample.memFreeRatio,
13633
+ previous,
13634
+ HOST_PRESSURE_BARS.memFreeRatio.enterElevated,
13635
+ HOST_PRESSURE_BARS.memFreeRatio.leaveElevated,
13636
+ HOST_PRESSURE_BARS.memFreeRatio.enterCritical,
13637
+ HOST_PRESSURE_BARS.memFreeRatio.leaveCritical
13638
+ );
13639
+ const mem = platform3 === "darwin" && memRaw === "critical" ? "elevated" : memRaw;
13640
+ const eventLoop = schmittHighIsWorse(
13641
+ sample.eventLoopP99Ms,
13642
+ previous,
13643
+ HOST_PRESSURE_BARS.eventLoopP99Ms.enterElevated,
13644
+ HOST_PRESSURE_BARS.eventLoopP99Ms.leaveElevated,
13645
+ HOST_PRESSURE_BARS.eventLoopP99Ms.enterCritical,
13646
+ HOST_PRESSURE_BARS.eventLoopP99Ms.leaveCritical
13647
+ );
13648
+ const ncpu = sample.ncpu > 0 ? sample.ncpu : 1;
13649
+ const windows = platform3 === "win32";
13650
+ const loadValue = windows ? sample.cpuBusyRatio ?? 0 : sample.load1 / ncpu;
13651
+ const loadBars = windows ? HOST_PRESSURE_BARS.cpuBusy : HOST_PRESSURE_BARS.loadPerCpu;
13652
+ const load = schmittHighIsWorse(
13653
+ loadValue,
13654
+ previous,
13655
+ loadBars.enterElevated,
13656
+ loadBars.leaveElevated,
13657
+ loadBars.enterCritical,
13658
+ loadBars.leaveCritical
13659
+ );
13660
+ const resourceLevel = worst([mem, eventLoop, load]);
13661
+ const agentsPair = sample.liveAgents >= HOST_PRESSURE_BARS.liveAgentsPair && resourceLevel !== "ok";
13662
+ const level = resourceLevel;
13663
+ const firing = [];
13664
+ if (mem !== "ok") firing.push("memory");
13665
+ if (eventLoop !== "ok") firing.push("event_loop");
13666
+ if (load !== "ok") firing.push("load");
13667
+ if (agentsPair) firing.push("agents");
13668
+ const reasons = REASON_ORDER.filter((reason) => firing.includes(reason));
13669
+ return { level, reasons };
13670
+ }
13671
+ var HostPressureMonitor = class {
13672
+ constructor(opts) {
13673
+ this.opts = opts;
13674
+ }
13675
+ opts;
13676
+ timer = null;
13677
+ level = "ok";
13678
+ lastWarning = null;
13679
+ start() {
13680
+ this.opts.histogram?.enable();
13681
+ if (this.timer) return;
13682
+ const intervalMs = this.opts.intervalMs ?? HOST_PRESSURE_SAMPLE_MS;
13683
+ this.timer = setInterval(() => this.tick(), intervalMs);
13684
+ this.timer.unref?.();
13685
+ }
13686
+ tick() {
13687
+ const sample = this.opts.readSample();
13688
+ const platform3 = this.opts.platform ?? process.platform;
13689
+ const classified = classifyHostPressure(sample, this.level, platform3);
13690
+ if (classified.level === this.level) return;
13691
+ this.level = classified.level;
13692
+ const updatedAt = (this.opts.now ?? (() => /* @__PURE__ */ new Date()))().toISOString();
13693
+ if (classified.level === "ok") {
13694
+ this.lastWarning = null;
13695
+ this.opts.wsHub.broadcast({ type: "host_pressure_cleared", updatedAt });
13696
+ return;
13697
+ }
13698
+ const os2 = hostPressureOs(platform3);
13699
+ const message = {
13700
+ type: "host_pressure",
13701
+ level: classified.level,
13702
+ reasons: classified.reasons,
13703
+ liveAgents: sample.liveAgents,
13704
+ updatedAt,
13705
+ ...os2 ? { os: os2 } : {}
13706
+ };
13707
+ this.lastWarning = message;
13708
+ this.opts.wsHub.broadcast(message);
13709
+ }
13710
+ wsMessage() {
13711
+ return this.lastWarning;
13712
+ }
13713
+ dispose() {
13714
+ if (this.timer) {
13715
+ clearInterval(this.timer);
13716
+ this.timer = null;
13717
+ }
13718
+ this.opts.histogram?.disable();
13719
+ }
13720
+ };
13721
+ function createHostPressureMonitor(wsHub, liveAgents) {
13722
+ const histogram = monitorEventLoopDelay({ resolution: 20 });
13723
+ const windows = process.platform === "win32";
13724
+ let prevCpuTimes = null;
13725
+ const monitor = new HostPressureMonitor({
13726
+ wsHub,
13727
+ histogram,
13728
+ readSample: () => {
13729
+ const eventLoopP99Ms = histogram.percentile(99) / 1e6;
13730
+ histogram.reset();
13731
+ const total = totalmem();
13732
+ const cpuList = cpus();
13733
+ const sample = {
13734
+ liveAgents: liveAgents(),
13735
+ memFreeRatio: total > 0 ? freemem() / total : 1,
13736
+ eventLoopP99Ms,
13737
+ load1: loadavg()[0],
13738
+ ncpu: cpuList.length
13739
+ };
13740
+ if (windows) {
13741
+ const cpuTimes = cpuList.map((cpu) => cpu.times);
13742
+ sample.cpuBusyRatio = cpuBusyRatio(prevCpuTimes, cpuTimes);
13743
+ prevCpuTimes = cpuTimes;
13744
+ }
13745
+ return sample;
13746
+ }
13747
+ });
13748
+ monitor.start();
13749
+ return monitor;
13750
+ }
13751
+
13502
13752
  // src/services/push/expoPushSender.ts
13503
13753
  var log5 = getLogger("expo-push");
13504
13754
  var EXPO_PUSH_ENDPOINT = "https://exp.host/--/api/v2/push/send";
@@ -15518,6 +15768,10 @@ var StreamerServer = class {
15518
15768
  // Consecutive grace-timer defers for a still-`running` session (see
15519
15769
  // GRACE_MAX_DEFERS). Reset when a subscriber reconnects or the PTY settles.
15520
15770
  ptyGraceDeferCounts = /* @__PURE__ */ new Map();
15771
+ // Session ids that should be putOnHold at the next waiting_input/idle.
15772
+ // Same lifetime as ptyGraceTimers: in-memory, dropped on close/restart.
15773
+ // Last writer wins against the grace timer — never both armed.
15774
+ holdWhenIdle = /* @__PURE__ */ new Set();
15521
15775
  // Map of sessionId → set of subscribed WS clients
15522
15776
  sessionSubscribers = /* @__PURE__ */ new Map();
15523
15777
  // sessionId → wall-clock ms of the last PTY chunk. Written from onOutput for
@@ -15543,6 +15797,7 @@ var StreamerServer = class {
15543
15797
  wsToClientId = /* @__PURE__ */ new Map();
15544
15798
  cache = null;
15545
15799
  cacheMonitor = null;
15800
+ hostPressureMonitor = null;
15546
15801
  projectsRepo = null;
15547
15802
  conversationsRepo = null;
15548
15803
  sessionsRepo = null;
@@ -15745,7 +16000,8 @@ var StreamerServer = class {
15745
16000
  waitingInputNotifier: () => this.waitingInputNotifier,
15746
16001
  ptyAttachedIds: () => this.ptyAttachedIds(),
15747
16002
  cancelPendingQuestion: (sessionId) => this.cancelPendingQuestion(sessionId),
15748
- rememberSelfPtyEnded: (conversationId) => this.rememberSelfPtyEnded(conversationId)
16003
+ rememberSelfPtyEnded: (conversationId) => this.rememberSelfPtyEnded(conversationId),
16004
+ maybeFireHoldWhenIdle: (session) => this.maybeFireHoldWhenIdle(session)
15749
16005
  })
15750
16006
  );
15751
16007
  this.sessionWatchers = new SessionWatchers({
@@ -15892,6 +16148,7 @@ var StreamerServer = class {
15892
16148
  // reset-and-rescan, and tests swap methods on the server instance.
15893
16149
  cache: () => this.cache,
15894
16150
  cacheMonitor: () => this.cacheMonitor,
16151
+ hostPressureMonitor: () => this.hostPressureMonitor,
15895
16152
  pushRepo: () => this.pushRepo,
15896
16153
  liveActivityPushEnabled: () => this.liveActivityNotifier !== null,
15897
16154
  devicesRepo: () => this.devicesRepo,
@@ -15908,6 +16165,7 @@ var StreamerServer = class {
15908
16165
  currentWarmupState: () => this.currentWarmupState(),
15909
16166
  addSessionSubscriber: (sessionId, ws) => this.addSessionSubscriber(sessionId, ws),
15910
16167
  startGraceTimer: (sessionId, delayMs) => this.startGraceTimer(sessionId, delayMs),
16168
+ armHoldWhenIdle: (sessionId) => this.armHoldWhenIdle(sessionId),
15911
16169
  handleSessionsCount: (res) => this.handleSessionsCount(res),
15912
16170
  applyLiveSessionSetting: (id, req, res, setting) => this.applyLiveSessionSetting(id, req, res, setting),
15913
16171
  handlePairStart: (res) => this.handlePairStart(res),
@@ -16027,6 +16285,13 @@ var StreamerServer = class {
16027
16285
  this.ptyGraceTimers.delete(sessionId);
16028
16286
  }
16029
16287
  this.ptyGraceDeferCounts.delete(sessionId);
16288
+ if (this.holdWhenIdle.delete(sessionId)) {
16289
+ this.log.info(
16290
+ `[hold-when-idle] cancelled ${sessionId} (subscribe)`,
16291
+ { sessionId, event: "pty.hold_when_idle_cancel", reason: "subscribe" },
16292
+ "pino"
16293
+ );
16294
+ }
16030
16295
  }
16031
16296
  /**
16032
16297
  * Bring up Live Activity push, if credentials are present (Feature 12).
@@ -16134,6 +16399,7 @@ var StreamerServer = class {
16134
16399
  return reaped;
16135
16400
  }
16136
16401
  startGraceTimer(sessionId, delayMs) {
16402
+ this.holdWhenIdle.delete(sessionId);
16137
16403
  const existing = this.ptyGraceTimers.get(sessionId);
16138
16404
  if (existing) clearTimeout(existing);
16139
16405
  const timer = setTimeout(() => {
@@ -16175,6 +16441,63 @@ var StreamerServer = class {
16175
16441
  }, delayMs);
16176
16442
  this.ptyGraceTimers.set(sessionId, timer);
16177
16443
  }
16444
+ clearGrace(sessionId) {
16445
+ const existing = this.ptyGraceTimers.get(sessionId);
16446
+ if (existing) {
16447
+ clearTimeout(existing);
16448
+ this.ptyGraceTimers.delete(sessionId);
16449
+ }
16450
+ this.ptyGraceDeferCounts.delete(sessionId);
16451
+ }
16452
+ /**
16453
+ * Arm the in-app "Kill on idle" latch: hold now if already settled, otherwise
16454
+ * on the next running → waiting_input (or idle). No grace delay, no defer cap.
16455
+ * A subscribed leaving socket must not block an immediate hold.
16456
+ */
16457
+ armHoldWhenIdle(sessionId) {
16458
+ if (!this.ptyManager.hasSession(sessionId)) return;
16459
+ this.clearGrace(sessionId);
16460
+ const status = this.ptyManager.getSession(sessionId)?.status ?? this.sessionStore.getManaged(sessionId)?.status;
16461
+ if (status === "waiting_input" || status === "idle") {
16462
+ this.holdWhenIdle.delete(sessionId);
16463
+ this.ptyManager.putOnHold(sessionId);
16464
+ this.forgetIfEmptyUnused(sessionId);
16465
+ return;
16466
+ }
16467
+ this.holdWhenIdle.add(sessionId);
16468
+ this.log.info(
16469
+ `[hold-when-idle] armed ${sessionId}`,
16470
+ { sessionId, event: "pty.hold_when_idle_armed" },
16471
+ "pino"
16472
+ );
16473
+ }
16474
+ /**
16475
+ * Fire the Kill-on-idle latch from the shared onStatusChange funnel.
16476
+ * Delete first so the ensuing idle transition cannot re-enter.
16477
+ */
16478
+ maybeFireHoldWhenIdle(session) {
16479
+ if (session.status !== "waiting_input" && session.status !== "idle") return;
16480
+ if (!this.holdWhenIdle.has(session.id)) return;
16481
+ this.holdWhenIdle.delete(session.id);
16482
+ if (this.hasSessionSubscriber(session.id)) {
16483
+ this.log.info(
16484
+ `[hold-when-idle] cancelled ${session.id} (subscriber)`,
16485
+ { sessionId: session.id, event: "pty.hold_when_idle_cancel", reason: "subscriber" },
16486
+ "pino"
16487
+ );
16488
+ return;
16489
+ }
16490
+ this.log.info(
16491
+ `[hold-when-idle] holding ${session.id}`,
16492
+ { sessionId: session.id, event: "pty.hold_when_idle_fire" },
16493
+ "pino"
16494
+ );
16495
+ this.ptyManager.putOnHold(session.id);
16496
+ this.forgetIfEmptyUnused(session.id);
16497
+ }
16498
+ forgetIfEmptyUnused(sessionId) {
16499
+ this.sessionHandlers.forgetIfEmptyUnused(sessionId);
16500
+ }
16178
16501
  get port() {
16179
16502
  const addr = this.httpServer.address();
16180
16503
  return typeof addr === "object" && addr ? addr.port : 0;
@@ -16300,6 +16623,10 @@ var StreamerServer = class {
16300
16623
  this.idleReaperTimer = setInterval(() => this.reapIdleSessions(), IDLE_REAP_SWEEP_MS);
16301
16624
  this.idleReaperTimer.unref?.();
16302
16625
  }
16626
+ this.hostPressureMonitor = createHostPressureMonitor(
16627
+ this.wsHub,
16628
+ () => this.ptyAttachedIds().size
16629
+ );
16303
16630
  const warmUp = new Promise((resolveWarm) => {
16304
16631
  {
16305
16632
  this.log.info(`Streamer server listening on port ${port}`, {
@@ -16607,10 +16934,13 @@ var StreamerServer = class {
16607
16934
  async close() {
16608
16935
  for (const timer of this.ptyGraceTimers.values()) clearTimeout(timer);
16609
16936
  this.ptyGraceTimers.clear();
16937
+ this.holdWhenIdle.clear();
16610
16938
  if (this.idleReaperTimer) {
16611
16939
  clearInterval(this.idleReaperTimer);
16612
16940
  this.idleReaperTimer = null;
16613
16941
  }
16942
+ this.hostPressureMonitor?.dispose();
16943
+ this.hostPressureMonitor = null;
16614
16944
  this.lastAgentChunkAt.clear();
16615
16945
  this.terminalSeq.clear();
16616
16946
  if (this.ptyManager.isRemote()) this.ptyManager.dispose();