@threadbase-sh/streamer 1.65.0 → 1.67.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([]));
@@ -12880,6 +12885,7 @@ function createApiDeps(deps) {
12880
12885
  wsHub: deps.wsHub,
12881
12886
  cache: () => deps.cache(),
12882
12887
  cacheMonitor: () => deps.cacheMonitor(),
12888
+ hostPressureMonitor: () => deps.hostPressureMonitor(),
12883
12889
  pushRepo: () => deps.pushRepo(),
12884
12890
  liveActivityPushEnabled: () => deps.liveActivityPushEnabled(),
12885
12891
  devicesRepo: () => deps.devicesRepo(),
@@ -12930,6 +12936,8 @@ function createApiDeps(deps) {
12930
12936
  }
12931
12937
  const alertMsg = deps.cacheMonitor()?.wsMessage();
12932
12938
  if (alertMsg) deps.wsHub.unicast(ws, alertMsg);
12939
+ const pressureMsg = deps.hostPressureMonitor()?.wsMessage();
12940
+ if (pressureMsg) deps.wsHub.unicast(ws, pressureMsg);
12933
12941
  },
12934
12942
  handleWsMessage: async (ws, raw, principal) => {
12935
12943
  const deny = (type, required) => {
@@ -13000,6 +13008,13 @@ function createApiDeps(deps) {
13000
13008
  );
13001
13009
  }
13002
13010
  }
13011
+ if (msg.type === "unsubscribe_session" && typeof msg.sessionId === "string") {
13012
+ if (!wsAllows(principal, "history:read")) {
13013
+ deny(msg.type, "history:read");
13014
+ return;
13015
+ }
13016
+ deps.removeSessionSubscriber(msg.sessionId, ws);
13017
+ }
13003
13018
  if (msg.type === "hold_session" && typeof msg.sessionId === "string") {
13004
13019
  if (!wsAllows(principal, "session:control")) {
13005
13020
  deny(msg.type, "session:control");
@@ -13555,6 +13570,221 @@ function pruneAgentConversations(cache) {
13555
13570
  return { scanned: rows.length, pruned, missing };
13556
13571
  }
13557
13572
 
13573
+ // src/services/host-pressure/hostPressure.ts
13574
+ var import_os13 = require("os");
13575
+ var import_perf_hooks = require("perf_hooks");
13576
+ var HOST_PRESSURE_SAMPLE_MS = 5e3;
13577
+ var HOST_PRESSURE_BARS = {
13578
+ memFreeRatio: {
13579
+ enterElevated: 0.15,
13580
+ leaveElevated: 0.17,
13581
+ enterCritical: 0.08,
13582
+ leaveCritical: 0.15
13583
+ },
13584
+ eventLoopP99Ms: {
13585
+ enterElevated: 100,
13586
+ leaveElevated: 80,
13587
+ enterCritical: 250,
13588
+ leaveCritical: 100
13589
+ },
13590
+ loadPerCpu: {
13591
+ enterElevated: 1.25,
13592
+ leaveElevated: 1.05,
13593
+ enterCritical: 2,
13594
+ leaveCritical: 1.25
13595
+ },
13596
+ // win32 has no loadavg. Busy ratio from os.cpus()[].times deltas is 0–1, so
13597
+ // it cannot reuse loadPerCpu's 2.0 critical bar. Reason on the wire stays `load`.
13598
+ cpuBusy: {
13599
+ enterElevated: 0.85,
13600
+ leaveElevated: 0.7,
13601
+ enterCritical: 0.97,
13602
+ leaveCritical: 0.85
13603
+ },
13604
+ liveAgentsPair: 4
13605
+ };
13606
+ var REASON_ORDER = ["memory", "event_loop", "load", "agents"];
13607
+ var RANK = { ok: 0, elevated: 1, critical: 2 };
13608
+ function worst(levels) {
13609
+ return levels.reduce(
13610
+ (acc, level) => RANK[level] > RANK[acc] ? level : acc,
13611
+ "ok"
13612
+ );
13613
+ }
13614
+ function schmittLowIsWorse(value, previous, enterElevated, leaveElevated, enterCritical, leaveCritical) {
13615
+ if (previous === "critical") {
13616
+ if (value < leaveCritical) return "critical";
13617
+ if (value < leaveElevated) return "elevated";
13618
+ return "ok";
13619
+ }
13620
+ if (previous === "elevated") {
13621
+ if (value < enterCritical) return "critical";
13622
+ if (value < leaveElevated) return "elevated";
13623
+ return "ok";
13624
+ }
13625
+ if (value < enterCritical) return "critical";
13626
+ if (value < enterElevated) return "elevated";
13627
+ return "ok";
13628
+ }
13629
+ function schmittHighIsWorse(value, previous, enterElevated, leaveElevated, enterCritical, leaveCritical) {
13630
+ if (previous === "critical") {
13631
+ if (value > leaveCritical) return "critical";
13632
+ if (value > leaveElevated) return "elevated";
13633
+ return "ok";
13634
+ }
13635
+ if (previous === "elevated") {
13636
+ if (value > enterCritical) return "critical";
13637
+ if (value > leaveElevated) return "elevated";
13638
+ return "ok";
13639
+ }
13640
+ if (value > enterCritical) return "critical";
13641
+ if (value > enterElevated) return "elevated";
13642
+ return "ok";
13643
+ }
13644
+ function timesTotal(times) {
13645
+ return times.user + times.nice + times.sys + times.idle + times.irq;
13646
+ }
13647
+ function hostPressureOs(platform3) {
13648
+ if (platform3 === "darwin" || platform3 === "linux" || platform3 === "win32") return platform3;
13649
+ return void 0;
13650
+ }
13651
+ function cpuBusyRatio(previous, next) {
13652
+ if (!previous || previous.length === 0 || next.length === 0 || previous.length !== next.length) {
13653
+ return 0;
13654
+ }
13655
+ let idle = 0;
13656
+ let total = 0;
13657
+ for (let i = 0; i < next.length; i++) {
13658
+ const dt = timesTotal(next[i]) - timesTotal(previous[i]);
13659
+ if (dt <= 0) continue;
13660
+ total += dt;
13661
+ idle += Math.max(0, next[i].idle - previous[i].idle);
13662
+ }
13663
+ if (total <= 0) return 0;
13664
+ return 1 - idle / total;
13665
+ }
13666
+ function classifyHostPressure(sample, previous, platform3) {
13667
+ const memRaw = schmittLowIsWorse(
13668
+ sample.memFreeRatio,
13669
+ previous,
13670
+ HOST_PRESSURE_BARS.memFreeRatio.enterElevated,
13671
+ HOST_PRESSURE_BARS.memFreeRatio.leaveElevated,
13672
+ HOST_PRESSURE_BARS.memFreeRatio.enterCritical,
13673
+ HOST_PRESSURE_BARS.memFreeRatio.leaveCritical
13674
+ );
13675
+ const mem = platform3 === "darwin" && memRaw === "critical" ? "elevated" : memRaw;
13676
+ const eventLoop = schmittHighIsWorse(
13677
+ sample.eventLoopP99Ms,
13678
+ previous,
13679
+ HOST_PRESSURE_BARS.eventLoopP99Ms.enterElevated,
13680
+ HOST_PRESSURE_BARS.eventLoopP99Ms.leaveElevated,
13681
+ HOST_PRESSURE_BARS.eventLoopP99Ms.enterCritical,
13682
+ HOST_PRESSURE_BARS.eventLoopP99Ms.leaveCritical
13683
+ );
13684
+ const ncpu = sample.ncpu > 0 ? sample.ncpu : 1;
13685
+ const windows = platform3 === "win32";
13686
+ const loadValue = windows ? sample.cpuBusyRatio ?? 0 : sample.load1 / ncpu;
13687
+ const loadBars = windows ? HOST_PRESSURE_BARS.cpuBusy : HOST_PRESSURE_BARS.loadPerCpu;
13688
+ const load = schmittHighIsWorse(
13689
+ loadValue,
13690
+ previous,
13691
+ loadBars.enterElevated,
13692
+ loadBars.leaveElevated,
13693
+ loadBars.enterCritical,
13694
+ loadBars.leaveCritical
13695
+ );
13696
+ const resourceLevel = worst([mem, eventLoop, load]);
13697
+ const agentsPair = sample.liveAgents >= HOST_PRESSURE_BARS.liveAgentsPair && resourceLevel !== "ok";
13698
+ const level = resourceLevel;
13699
+ const firing = [];
13700
+ if (mem !== "ok") firing.push("memory");
13701
+ if (eventLoop !== "ok") firing.push("event_loop");
13702
+ if (load !== "ok") firing.push("load");
13703
+ if (agentsPair) firing.push("agents");
13704
+ const reasons = REASON_ORDER.filter((reason) => firing.includes(reason));
13705
+ return { level, reasons };
13706
+ }
13707
+ var HostPressureMonitor = class {
13708
+ constructor(opts) {
13709
+ this.opts = opts;
13710
+ }
13711
+ opts;
13712
+ timer = null;
13713
+ level = "ok";
13714
+ lastWarning = null;
13715
+ start() {
13716
+ this.opts.histogram?.enable();
13717
+ if (this.timer) return;
13718
+ const intervalMs = this.opts.intervalMs ?? HOST_PRESSURE_SAMPLE_MS;
13719
+ this.timer = setInterval(() => this.tick(), intervalMs);
13720
+ this.timer.unref?.();
13721
+ }
13722
+ tick() {
13723
+ const sample = this.opts.readSample();
13724
+ const platform3 = this.opts.platform ?? process.platform;
13725
+ const classified = classifyHostPressure(sample, this.level, platform3);
13726
+ if (classified.level === this.level) return;
13727
+ this.level = classified.level;
13728
+ const updatedAt = (this.opts.now ?? (() => /* @__PURE__ */ new Date()))().toISOString();
13729
+ if (classified.level === "ok") {
13730
+ this.lastWarning = null;
13731
+ this.opts.wsHub.broadcast({ type: "host_pressure_cleared", updatedAt });
13732
+ return;
13733
+ }
13734
+ const os2 = hostPressureOs(platform3);
13735
+ const message = {
13736
+ type: "host_pressure",
13737
+ level: classified.level,
13738
+ reasons: classified.reasons,
13739
+ liveAgents: sample.liveAgents,
13740
+ updatedAt,
13741
+ ...os2 ? { os: os2 } : {}
13742
+ };
13743
+ this.lastWarning = message;
13744
+ this.opts.wsHub.broadcast(message);
13745
+ }
13746
+ wsMessage() {
13747
+ return this.lastWarning;
13748
+ }
13749
+ dispose() {
13750
+ if (this.timer) {
13751
+ clearInterval(this.timer);
13752
+ this.timer = null;
13753
+ }
13754
+ this.opts.histogram?.disable();
13755
+ }
13756
+ };
13757
+ function createHostPressureMonitor(wsHub, liveAgents) {
13758
+ const histogram = (0, import_perf_hooks.monitorEventLoopDelay)({ resolution: 20 });
13759
+ const windows = process.platform === "win32";
13760
+ let prevCpuTimes = null;
13761
+ const monitor = new HostPressureMonitor({
13762
+ wsHub,
13763
+ histogram,
13764
+ readSample: () => {
13765
+ const eventLoopP99Ms = histogram.percentile(99) / 1e6;
13766
+ histogram.reset();
13767
+ const total = (0, import_os13.totalmem)();
13768
+ const cpuList = (0, import_os13.cpus)();
13769
+ const sample = {
13770
+ liveAgents: liveAgents(),
13771
+ memFreeRatio: total > 0 ? (0, import_os13.freemem)() / total : 1,
13772
+ eventLoopP99Ms,
13773
+ load1: (0, import_os13.loadavg)()[0],
13774
+ ncpu: cpuList.length
13775
+ };
13776
+ if (windows) {
13777
+ const cpuTimes = cpuList.map((cpu) => cpu.times);
13778
+ sample.cpuBusyRatio = cpuBusyRatio(prevCpuTimes, cpuTimes);
13779
+ prevCpuTimes = cpuTimes;
13780
+ }
13781
+ return sample;
13782
+ }
13783
+ });
13784
+ monitor.start();
13785
+ return monitor;
13786
+ }
13787
+
13558
13788
  // src/services/push/expoPushSender.ts
13559
13789
  var log5 = getLogger("expo-push");
13560
13790
  var EXPO_PUSH_ENDPOINT = "https://exp.host/--/api/v2/push/send";
@@ -15054,7 +15284,7 @@ function discoveredToResponse(d, conversationId) {
15054
15284
 
15055
15285
  // src/session-watchers.ts
15056
15286
  var import_fs27 = require("fs");
15057
- var import_os13 = require("os");
15287
+ var import_os14 = require("os");
15058
15288
  var import_path23 = require("path");
15059
15289
  var SessionWatchers = class {
15060
15290
  constructor(deps) {
@@ -15136,7 +15366,7 @@ var SessionWatchers = class {
15136
15366
  // was passed to Claude via --session-id so the filename matches from the start.
15137
15367
  watchForJsonl(sessionId, projectPath) {
15138
15368
  const encoded = projectPath.replace(/[/\\:.]/g, "-");
15139
- const projectsDir = (0, import_path23.join)((0, import_os13.homedir)(), ".claude", "projects", encoded);
15369
+ const projectsDir = (0, import_path23.join)((0, import_os14.homedir)(), ".claude", "projects", encoded);
15140
15370
  const expectedFile = `${sessionId}.jsonl`;
15141
15371
  const filePath = (0, import_path23.join)(projectsDir, expectedFile);
15142
15372
  const deadline = Date.now() + 12e4;
@@ -15603,6 +15833,7 @@ var StreamerServer = class {
15603
15833
  wsToClientId = /* @__PURE__ */ new Map();
15604
15834
  cache = null;
15605
15835
  cacheMonitor = null;
15836
+ hostPressureMonitor = null;
15606
15837
  projectsRepo = null;
15607
15838
  conversationsRepo = null;
15608
15839
  sessionsRepo = null;
@@ -15667,7 +15898,7 @@ var StreamerServer = class {
15667
15898
  this.skipStartupWarmup = config.skipStartupWarmup ?? false;
15668
15899
  this.autoResumeOnBoot = config.autoResumeOnBoot ?? false;
15669
15900
  this.scanProfiles = config.scanProfiles;
15670
- this.codexRoots = config.codexRoots ?? [(0, import_path24.join)((0, import_os14.homedir)(), ".codex", "sessions")];
15901
+ this.codexRoots = config.codexRoots ?? [(0, import_path24.join)((0, import_os15.homedir)(), ".codex", "sessions")];
15671
15902
  this.ptyGracePeriodMs = config.ptyGracePeriodMs ?? DEFAULT_PTY_GRACE_PERIOD_MS;
15672
15903
  this.defaultSystemPrompt = config.defaultSystemPrompt ?? DEFAULT_SYSTEM_PROMPT;
15673
15904
  const flagResolution = resolveFeatureFlags({
@@ -15684,7 +15915,7 @@ var StreamerServer = class {
15684
15915
  this.claudeFlagsPersistable = config.claudeFlags === void 0;
15685
15916
  this.claudeFlags = config.claudeFlags ?? loadClaudeFlags();
15686
15917
  this.claudeExtraArgs = config.claudeExtraArgs ?? loadClaudeExtraArgs();
15687
- this.cacheDir = config.cacheDir ?? loadCacheDir() ?? (0, import_path24.join)((0, import_os14.homedir)(), ".threadbase", "cache");
15918
+ this.cacheDir = config.cacheDir ?? loadCacheDir() ?? (0, import_path24.join)((0, import_os15.homedir)(), ".threadbase", "cache");
15688
15919
  this.runtimeDbPath = resolveRuntimeDbPath(config.runtimeDbPath);
15689
15920
  this.tailSize = config.tailSize ?? loadTailSize() ?? 10;
15690
15921
  this.directoryDebounceMs = parseDirScanDebounceEnv(process.env.THREADBASE_DIR_SCAN_DEBOUNCE_MS) ?? config.directoryScanDebounceMs ?? 1e3;
@@ -15953,6 +16184,7 @@ var StreamerServer = class {
15953
16184
  // reset-and-rescan, and tests swap methods on the server instance.
15954
16185
  cache: () => this.cache,
15955
16186
  cacheMonitor: () => this.cacheMonitor,
16187
+ hostPressureMonitor: () => this.hostPressureMonitor,
15956
16188
  pushRepo: () => this.pushRepo,
15957
16189
  liveActivityPushEnabled: () => this.liveActivityNotifier !== null,
15958
16190
  devicesRepo: () => this.devicesRepo,
@@ -15968,6 +16200,7 @@ var StreamerServer = class {
15968
16200
  withReconciledLifecycle: (sessions) => this.withReconciledLifecycle(sessions),
15969
16201
  currentWarmupState: () => this.currentWarmupState(),
15970
16202
  addSessionSubscriber: (sessionId, ws) => this.addSessionSubscriber(sessionId, ws),
16203
+ removeSessionSubscriber: (sessionId, ws) => this.removeSessionSubscriber(sessionId, ws),
15971
16204
  startGraceTimer: (sessionId, delayMs) => this.startGraceTimer(sessionId, delayMs),
15972
16205
  armHoldWhenIdle: (sessionId) => this.armHoldWhenIdle(sessionId),
15973
16206
  handleSessionsCount: (res) => this.handleSessionsCount(res),
@@ -16097,6 +16330,12 @@ var StreamerServer = class {
16097
16330
  );
16098
16331
  }
16099
16332
  }
16333
+ removeSessionSubscriber(sessionId, ws) {
16334
+ const subs = this.sessionSubscribers.get(sessionId);
16335
+ if (!subs) return;
16336
+ subs.delete(ws);
16337
+ if (subs.size === 0) this.sessionSubscribers.delete(sessionId);
16338
+ }
16100
16339
  /**
16101
16340
  * Bring up Live Activity push, if credentials are present (Feature 12).
16102
16341
  *
@@ -16123,14 +16362,14 @@ var StreamerServer = class {
16123
16362
  }
16124
16363
  this.apnsClient = new ApnsClient(creds);
16125
16364
  const sender = new LiveActivitySender(this.apnsClient, pushRepo);
16126
- const serverId = process.env.THREADBASE_INSTANCE_ID ?? (0, import_os14.hostname)();
16127
- this.liveActivityNotifier = new LiveActivityNotifier(sender, serverId, (0, import_os14.hostname)());
16365
+ const serverId = process.env.THREADBASE_INSTANCE_ID ?? (0, import_os15.hostname)();
16366
+ this.liveActivityNotifier = new LiveActivityNotifier(sender, serverId, (0, import_os15.hostname)());
16128
16367
  this.liveActivityRenewal = new LiveActivityRenewalScheduler({
16129
16368
  repo: pushRepo,
16130
16369
  sender,
16131
16370
  sessionStore: this.sessionStore,
16132
16371
  serverId,
16133
- serverLabel: (0, import_os14.hostname)()
16372
+ serverLabel: (0, import_os15.hostname)()
16134
16373
  });
16135
16374
  this.liveActivityRenewal.start();
16136
16375
  this.log.info("Live Activity push enabled", {
@@ -16150,7 +16389,7 @@ var StreamerServer = class {
16150
16389
  */
16151
16390
  initWaitingInputPush(pushRepo) {
16152
16391
  const sender = new ExpoPushSender(pushRepo, process.env.THREADBASE_EXPO_ACCESS_TOKEN);
16153
- const serverId = process.env.THREADBASE_INSTANCE_ID ?? (0, import_os14.hostname)();
16392
+ const serverId = process.env.THREADBASE_INSTANCE_ID ?? (0, import_os15.hostname)();
16154
16393
  this.waitingInputNotifier = new WaitingInputNotifier(
16155
16394
  sender,
16156
16395
  serverId,
@@ -16380,7 +16619,7 @@ var StreamerServer = class {
16380
16619
  let sessions = null;
16381
16620
  for (let attempt = 0; attempt < 2; attempt += 1) {
16382
16621
  const transport = await connectOrSpawnHost({
16383
- instanceId: process.env.THREADBASE_INSTANCE_ID ?? (0, import_os14.hostname)()
16622
+ instanceId: process.env.THREADBASE_INSTANCE_ID ?? (0, import_os15.hostname)()
16384
16623
  });
16385
16624
  try {
16386
16625
  sessions = await this.ptyManager.useRemoteRunner(transport);
@@ -16427,6 +16666,10 @@ var StreamerServer = class {
16427
16666
  this.idleReaperTimer = setInterval(() => this.reapIdleSessions(), IDLE_REAP_SWEEP_MS);
16428
16667
  this.idleReaperTimer.unref?.();
16429
16668
  }
16669
+ this.hostPressureMonitor = createHostPressureMonitor(
16670
+ this.wsHub,
16671
+ () => this.ptyAttachedIds().size
16672
+ );
16430
16673
  const warmUp = new Promise((resolveWarm) => {
16431
16674
  {
16432
16675
  this.log.info(`Streamer server listening on port ${port}`, {
@@ -16739,6 +16982,8 @@ var StreamerServer = class {
16739
16982
  clearInterval(this.idleReaperTimer);
16740
16983
  this.idleReaperTimer = null;
16741
16984
  }
16985
+ this.hostPressureMonitor?.dispose();
16986
+ this.hostPressureMonitor = null;
16742
16987
  this.lastAgentChunkAt.clear();
16743
16988
  this.terminalSeq.clear();
16744
16989
  if (this.ptyManager.isRemote()) this.ptyManager.dispose();
@@ -16932,7 +17177,7 @@ var StreamerServer = class {
16932
17177
  deviceToken: device.deviceToken,
16933
17178
  capabilities: device.capabilities,
16934
17179
  publicUrl: this.publicUrl,
16935
- machineName: (0, import_os14.hostname)(),
17180
+ machineName: (0, import_os15.hostname)(),
16936
17181
  serverVersion: getVersion()
16937
17182
  })
16938
17183
  );
@@ -16966,7 +17211,7 @@ var StreamerServer = class {
16966
17211
  // Released builds that predate that fix DO still adopt it, so changing
16967
17212
  // this value moves where old devices talk. It is not a free field.
16968
17213
  publicUrl: this.publicUrl,
16969
- machineName: (0, import_os14.hostname)(),
17214
+ machineName: (0, import_os15.hostname)(),
16970
17215
  ...device && {
16971
17216
  deviceId: device.deviceId,
16972
17217
  deviceToken: device.deviceToken,