@threadbase-sh/streamer 1.47.2 → 1.48.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
@@ -8452,10 +8452,10 @@ function fingerprintOf(ids) {
8452
8452
  return `sha256:${(0, import_crypto9.createHash)("sha256").update(sorted.join("\n")).digest("hex")}`;
8453
8453
  }
8454
8454
  var CacheIntegrityMonitor = class {
8455
- constructor(cache, wsHub, log8, cacheDir, rescan, runDuringReset) {
8455
+ constructor(cache, wsHub, log10, cacheDir, rescan, runDuringReset) {
8456
8456
  this.cache = cache;
8457
8457
  this.wsHub = wsHub;
8458
- this.log = log8;
8458
+ this.log = log10;
8459
8459
  this.cacheDir = cacheDir;
8460
8460
  this.rescan = rescan;
8461
8461
  this.runDuringReset = runDuringReset;
@@ -9254,6 +9254,101 @@ var ApnsClient = class {
9254
9254
  }
9255
9255
  };
9256
9256
 
9257
+ // src/services/push/expoPushSender.ts
9258
+ var log5 = getLogger("expo-push");
9259
+ var EXPO_PUSH_ENDPOINT = "https://exp.host/--/api/v2/push/send";
9260
+ var EXPO_PUSH_BATCH_SIZE = 100;
9261
+ var DEAD_TOKEN_ERROR = "DeviceNotRegistered";
9262
+ var ExpoPushSender = class {
9263
+ /**
9264
+ * @param accessToken Expo access token, when the project has enhanced
9265
+ * security enabled. Optional on purpose: a self-hoster does not own the Expo
9266
+ * project and cannot obtain one, so requiring it would break the deployment
9267
+ * this transport exists to serve.
9268
+ */
9269
+ constructor(repo, accessToken) {
9270
+ this.repo = repo;
9271
+ this.accessToken = accessToken;
9272
+ }
9273
+ repo;
9274
+ accessToken;
9275
+ /**
9276
+ * Send one message to every deliverable Expo token.
9277
+ *
9278
+ * Sends are independent: Expo returns a ticket per token in one response, so
9279
+ * a dead device is recorded against its own row and never silences the other
9280
+ * devices in the batch.
9281
+ */
9282
+ async send(message, now = Date.now()) {
9283
+ const rows = this.repo.listDeliverable();
9284
+ const outcome = { attempted: rows.length, succeeded: 0, retired: 0 };
9285
+ if (rows.length === 0) return outcome;
9286
+ for (let i = 0; i < rows.length; i += EXPO_PUSH_BATCH_SIZE) {
9287
+ const chunk = rows.slice(i, i + EXPO_PUSH_BATCH_SIZE);
9288
+ await this.sendChunk(chunk, message, now, outcome);
9289
+ }
9290
+ return outcome;
9291
+ }
9292
+ async sendChunk(rows, message, now, outcome) {
9293
+ let payload;
9294
+ try {
9295
+ const res = await fetch(EXPO_PUSH_ENDPOINT, {
9296
+ method: "POST",
9297
+ headers: {
9298
+ "content-type": "application/json",
9299
+ accept: "application/json",
9300
+ ...this.accessToken && { authorization: `Bearer ${this.accessToken}` }
9301
+ },
9302
+ body: JSON.stringify(rows.map((row) => ({ to: row.token, ...message })))
9303
+ });
9304
+ if (!res.ok) {
9305
+ const code = `HTTP_${res.status}`;
9306
+ for (const row of rows) this.repo.recordFailure(row.token, code, now);
9307
+ log5.warn("expo_push.request_rejected", {
9308
+ event: "expo_push.request_rejected",
9309
+ status: res.status,
9310
+ tokens: rows.length
9311
+ });
9312
+ return;
9313
+ }
9314
+ payload = await res.json();
9315
+ } catch (err) {
9316
+ for (const row of rows) this.repo.recordFailure(row.token, "SendError", now);
9317
+ log5.error("expo_push.send_failed", {
9318
+ event: "expo_push.send_failed",
9319
+ tokens: rows.length,
9320
+ err: String(err)
9321
+ });
9322
+ return;
9323
+ }
9324
+ const tickets = payload?.data;
9325
+ rows.forEach((row, index) => {
9326
+ const ticket = Array.isArray(tickets) ? tickets[index] : void 0;
9327
+ if (!ticket) {
9328
+ this.repo.recordFailure(row.token, "NoTicket", now);
9329
+ return;
9330
+ }
9331
+ if (ticket.status === "ok") {
9332
+ this.repo.recordSuccess(row.token, now);
9333
+ outcome.succeeded += 1;
9334
+ return;
9335
+ }
9336
+ const code = ticket.details?.error ?? "PushError";
9337
+ this.repo.recordFailure(row.token, code, now);
9338
+ if (code === DEAD_TOKEN_ERROR) {
9339
+ this.repo.revoke(row.token, now);
9340
+ outcome.retired += 1;
9341
+ }
9342
+ log5.warn("expo_push.send_rejected", {
9343
+ event: "expo_push.send_rejected",
9344
+ code,
9345
+ message: ticket.message,
9346
+ retired: code === DEAD_TOKEN_ERROR
9347
+ });
9348
+ });
9349
+ }
9350
+ };
9351
+
9257
9352
  // src/services/push/liveActivityContentState.ts
9258
9353
  var LAST_OUTPUT_MAX_LENGTH = 90;
9259
9354
  function toLiveActivityStatus(status) {
@@ -9265,7 +9360,7 @@ function truncateLastOutput(raw) {
9265
9360
  }
9266
9361
 
9267
9362
  // src/services/push/liveActivityNotifier.ts
9268
- var log5 = getLogger("live-activity");
9363
+ var log6 = getLogger("live-activity");
9269
9364
  function contentStateForSession(args) {
9270
9365
  const status = toLiveActivityStatus(args.session.status);
9271
9366
  if (!status) return null;
@@ -9325,7 +9420,7 @@ var LiveActivityNotifier = class {
9325
9420
  }
9326
9421
  await this.maybeSendName(session);
9327
9422
  } catch (err) {
9328
- log5.error("live_activity.notify_failed", {
9423
+ log6.error("live_activity.notify_failed", {
9329
9424
  event: "live_activity.notify_failed",
9330
9425
  sessionId: session.id,
9331
9426
  status: session.status,
@@ -9347,7 +9442,7 @@ var LiveActivityNotifier = class {
9347
9442
  });
9348
9443
  this.openActivity.set(session.id, { sessionNameSent: session.sessionName != null });
9349
9444
  if (outcome.attempted > 0) {
9350
- log5.info("live_activity.updated", {
9445
+ log6.info("live_activity.updated", {
9351
9446
  event: "live_activity.updated",
9352
9447
  sessionId: session.id,
9353
9448
  status: contentState.status,
@@ -9371,7 +9466,7 @@ var LiveActivityNotifier = class {
9371
9466
  });
9372
9467
  open2.sessionNameSent = true;
9373
9468
  if (outcome.attempted > 0) {
9374
- log5.info("live_activity.updated", {
9469
+ log6.info("live_activity.updated", {
9375
9470
  event: "live_activity.updated",
9376
9471
  sessionId: session.id,
9377
9472
  status: contentState.status,
@@ -9390,7 +9485,7 @@ var LiveActivityNotifier = class {
9390
9485
  if (!contentState) return;
9391
9486
  const outcome = await this.sender.end({ sessionId: session.id, contentState });
9392
9487
  if (outcome.attempted > 0) {
9393
- log5.info("live_activity.ended", {
9488
+ log6.info("live_activity.ended", {
9394
9489
  event: "live_activity.ended",
9395
9490
  sessionId: session.id,
9396
9491
  ...outcome
@@ -9404,7 +9499,7 @@ var LiveActivityNotifier = class {
9404
9499
  };
9405
9500
 
9406
9501
  // src/services/push/liveActivitySender.ts
9407
- var log6 = getLogger("live-activity");
9502
+ var log7 = getLogger("live-activity");
9408
9503
  var ACTIVITY_MAX_LIFETIME_MS = 8 * 60 * 60 * 1e3;
9409
9504
  function buildActivityKitPayload(args) {
9410
9505
  return {
@@ -9468,7 +9563,7 @@ var LiveActivitySender = class {
9468
9563
  );
9469
9564
  for (const { row, result, error } of results) {
9470
9565
  if (error) {
9471
- log6.error("live_activity.send_failed", {
9566
+ log7.error("live_activity.send_failed", {
9472
9567
  event: "live_activity.send_failed",
9473
9568
  sessionId: args.sessionId,
9474
9569
  activityId: row.activity_id,
@@ -9489,7 +9584,7 @@ var LiveActivitySender = class {
9489
9584
  this.repo.expire(row.token, now);
9490
9585
  outcome.retired += 1;
9491
9586
  }
9492
- log6.warn("live_activity.send_rejected", {
9587
+ log7.warn("live_activity.send_rejected", {
9493
9588
  event: "live_activity.send_rejected",
9494
9589
  sessionId: args.sessionId,
9495
9590
  activityId: row.activity_id,
@@ -9534,7 +9629,7 @@ var LiveActivitySender = class {
9534
9629
  };
9535
9630
 
9536
9631
  // src/services/push/liveActivityRenewal.ts
9537
- var log7 = getLogger("live-activity");
9632
+ var log8 = getLogger("live-activity");
9538
9633
  var RENEWAL_LEAD_MS = 30 * 60 * 1e3;
9539
9634
  var MAX_TIMER_MS = 60 * 60 * 1e3;
9540
9635
  function renewalDueAt(row) {
@@ -9582,7 +9677,7 @@ var LiveActivityRenewalScheduler = class {
9582
9677
  await this.renew(row, now);
9583
9678
  }
9584
9679
  } catch (err) {
9585
- log7.error("live_activity.renewal_sweep_failed", {
9680
+ log8.error("live_activity.renewal_sweep_failed", {
9586
9681
  event: "live_activity.renewal_sweep_failed",
9587
9682
  err: String(err)
9588
9683
  });
@@ -9611,7 +9706,7 @@ var LiveActivityRenewalScheduler = class {
9611
9706
  if (!session || !status) {
9612
9707
  this.deps.repo.claimRenewal(row.token, now);
9613
9708
  this.deps.repo.expire(row.token, now);
9614
- log7.info("live_activity.renewal_skipped", {
9709
+ log8.info("live_activity.renewal_skipped", {
9615
9710
  event: "live_activity.renewal_skipped",
9616
9711
  sessionId: row.session_id,
9617
9712
  activityId: row.activity_id,
@@ -9646,7 +9741,7 @@ var LiveActivityRenewalScheduler = class {
9646
9741
  startedAt,
9647
9742
  now
9648
9743
  });
9649
- log7.info("live_activity.renewed", {
9744
+ log8.info("live_activity.renewed", {
9650
9745
  event: "live_activity.renewed",
9651
9746
  sessionId: session.id,
9652
9747
  activityId: row.activity_id,
@@ -9656,7 +9751,7 @@ var LiveActivityRenewalScheduler = class {
9656
9751
  replacementRequested: started
9657
9752
  });
9658
9753
  } catch (err) {
9659
- log7.error("live_activity.renewal_failed", {
9754
+ log8.error("live_activity.renewal_failed", {
9660
9755
  event: "live_activity.renewal_failed",
9661
9756
  sessionId: session.id,
9662
9757
  activityId: row.activity_id,
@@ -9701,6 +9796,77 @@ var LiveActivityRenewalScheduler = class {
9701
9796
  }
9702
9797
  };
9703
9798
 
9799
+ // src/services/push/waitingInputNotifier.ts
9800
+ var log9 = getLogger("expo-push");
9801
+ function waitingInputMessage(session, serverId) {
9802
+ return {
9803
+ title: session.projectName || "Threadbase",
9804
+ body: "Waiting for your input",
9805
+ data: { sessionId: session.id, serverId }
9806
+ };
9807
+ }
9808
+ var WaitingInputNotifier = class {
9809
+ /**
9810
+ * @param isWatched Whether a client is currently subscribed to this session
9811
+ * over WebSocket. Mobile subscribes while the session screen is open and the
9812
+ * socket dies when the app is backgrounded, so this is the available signal
9813
+ * for "the user is already looking" — and a push to someone already reading
9814
+ * the output is pure noise.
9815
+ */
9816
+ constructor(sender, serverId, isWatched) {
9817
+ this.sender = sender;
9818
+ this.serverId = serverId;
9819
+ this.isWatched = isWatched;
9820
+ }
9821
+ sender;
9822
+ serverId;
9823
+ isWatched;
9824
+ /** Sessions with a turn the user started that has not yet been answered. */
9825
+ openTurn = /* @__PURE__ */ new Set();
9826
+ /**
9827
+ * React to a session status change.
9828
+ *
9829
+ * Fire-and-forget by design: a push must never delay or fail a session
9830
+ * transition, so this returns a promise the caller may ignore and every error
9831
+ * is logged rather than propagated.
9832
+ */
9833
+ async onStatusChange(session, previousStatus) {
9834
+ try {
9835
+ if (session.status === "running") {
9836
+ if (previousStatus === "waiting_input") this.openTurn.add(session.id);
9837
+ return;
9838
+ }
9839
+ if (session.status !== "waiting_input") {
9840
+ this.openTurn.delete(session.id);
9841
+ return;
9842
+ }
9843
+ if (!this.openTurn.delete(session.id)) return;
9844
+ if (this.isWatched(session.id)) {
9845
+ log9.debug("expo_push.suppressed_watched", {
9846
+ event: "expo_push.suppressed_watched",
9847
+ sessionId: session.id
9848
+ });
9849
+ return;
9850
+ }
9851
+ const outcome = await this.sender.send(waitingInputMessage(session, this.serverId));
9852
+ if (outcome.attempted > 0) {
9853
+ log9.info("expo_push.waiting_input", {
9854
+ event: "expo_push.waiting_input",
9855
+ sessionId: session.id,
9856
+ ...outcome
9857
+ });
9858
+ }
9859
+ } catch (err) {
9860
+ log9.error("expo_push.notify_failed", {
9861
+ event: "expo_push.notify_failed",
9862
+ sessionId: session.id,
9863
+ status: session.status,
9864
+ err: String(err)
9865
+ });
9866
+ }
9867
+ }
9868
+ };
9869
+
9704
9870
  // src/services/questions/parseStatusLine.ts
9705
9871
  var MODEL_RE = /(Opus|Sonnet|Haiku|Fable)\s+[\d.]+(?:\s*\([^)]*\))?/;
9706
9872
  var EFFORT_RE = /●\s*([A-Za-z]+)\s*·\s*\/effort/;
@@ -11011,6 +11177,10 @@ var StreamerServer = class {
11011
11177
  apnsClient = null;
11012
11178
  liveActivityNotifier = null;
11013
11179
  liveActivityRenewal = null;
11180
+ // "Your turn" notifications over Expo's relay (#528). Needs no credential of
11181
+ // its own, so unlike the Live Activity path it is on wherever the cache DB
11182
+ // opened — with no registered device it simply sends nothing.
11183
+ waitingInputNotifier = null;
11014
11184
  discoveryCache = null;
11015
11185
  // Single-flight for process discovery. Mobile polls GET /api/sessions and
11016
11186
  // retries on timeout; without this, every concurrent request starts its own
@@ -11310,6 +11480,7 @@ var StreamerServer = class {
11310
11480
  this.wsHub.broadcast({ type: "session_update", session: resp });
11311
11481
  }
11312
11482
  void this.liveActivityNotifier?.onStatusChange(session, previousStatus);
11483
+ void this.waitingInputNotifier?.onStatusChange(session, previousStatus);
11313
11484
  this.sessionStatusBus.emit(`status:${session.id}`, session.status, session);
11314
11485
  }
11315
11486
  });
@@ -11622,6 +11793,33 @@ var StreamerServer = class {
11622
11793
  topic: `${creds.bundleId}.push-type.liveactivity`
11623
11794
  });
11624
11795
  }
11796
+ /**
11797
+ * Bring up "your turn" notifications over Expo's relay (#528).
11798
+ *
11799
+ * Unconditional, unlike Live Activity push: Expo holds the app's APNs and FCM
11800
+ * credentials, so a self-hosted streamer needs no credential of its own. The
11801
+ * access token is optional and only relevant if the Expo project has enhanced
11802
+ * security enabled — requiring one would lock out every self-hoster, since
11803
+ * they do not own the project. It is never logged.
11804
+ */
11805
+ initWaitingInputPush(pushRepo) {
11806
+ const sender = new ExpoPushSender(pushRepo, process.env.THREADBASE_EXPO_ACCESS_TOKEN);
11807
+ const serverId = process.env.THREADBASE_INSTANCE_ID ?? (0, import_os10.hostname)();
11808
+ this.waitingInputNotifier = new WaitingInputNotifier(
11809
+ sender,
11810
+ serverId,
11811
+ (id) => this.hasSessionSubscriber(id)
11812
+ );
11813
+ }
11814
+ /** Whether any live socket is subscribed to this session — "someone is looking". */
11815
+ hasSessionSubscriber(sessionId) {
11816
+ const subs = this.sessionSubscribers.get(sessionId);
11817
+ if (!subs) return false;
11818
+ for (const ws of subs) {
11819
+ if (ws.readyState === ws.OPEN) return true;
11820
+ }
11821
+ return false;
11822
+ }
11625
11823
  /**
11626
11824
  * Classify sessions left behind by previous streamer runs (C1 Phase 3a).
11627
11825
  *
@@ -12224,6 +12422,7 @@ var StreamerServer = class {
12224
12422
  this.pushRepo = new PushRepository(db);
12225
12423
  this.devicesRepo = new DevicesRepository(db);
12226
12424
  this.initLiveActivityPush(this.pushRepo);
12425
+ this.initWaitingInputPush(this.pushRepo);
12227
12426
  this.cacheMonitor = new CacheIntegrityMonitor(
12228
12427
  this.cache,
12229
12428
  this.wsHub,