@threadbase-sh/streamer 1.47.3 → 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.d.cts CHANGED
@@ -1954,6 +1954,7 @@ declare class StreamerServer {
1954
1954
  private apnsClient;
1955
1955
  private liveActivityNotifier;
1956
1956
  private liveActivityRenewal;
1957
+ private waitingInputNotifier;
1957
1958
  private discoveryCache;
1958
1959
  private discoveryInFlight;
1959
1960
  private cacheDir;
@@ -2008,6 +2009,18 @@ declare class StreamerServer {
2008
2009
  * on disk; neither it nor any device token is ever logged.
2009
2010
  */
2010
2011
  private initLiveActivityPush;
2012
+ /**
2013
+ * Bring up "your turn" notifications over Expo's relay (#528).
2014
+ *
2015
+ * Unconditional, unlike Live Activity push: Expo holds the app's APNs and FCM
2016
+ * credentials, so a self-hosted streamer needs no credential of its own. The
2017
+ * access token is optional and only relevant if the Expo project has enhanced
2018
+ * security enabled — requiring one would lock out every self-hoster, since
2019
+ * they do not own the project. It is never logged.
2020
+ */
2021
+ private initWaitingInputPush;
2022
+ /** Whether any live socket is subscribed to this session — "someone is looking". */
2023
+ private hasSessionSubscriber;
2011
2024
  /**
2012
2025
  * Classify sessions left behind by previous streamer runs (C1 Phase 3a).
2013
2026
  *
package/dist/index.d.ts CHANGED
@@ -1954,6 +1954,7 @@ declare class StreamerServer {
1954
1954
  private apnsClient;
1955
1955
  private liveActivityNotifier;
1956
1956
  private liveActivityRenewal;
1957
+ private waitingInputNotifier;
1957
1958
  private discoveryCache;
1958
1959
  private discoveryInFlight;
1959
1960
  private cacheDir;
@@ -2008,6 +2009,18 @@ declare class StreamerServer {
2008
2009
  * on disk; neither it nor any device token is ever logged.
2009
2010
  */
2010
2011
  private initLiveActivityPush;
2012
+ /**
2013
+ * Bring up "your turn" notifications over Expo's relay (#528).
2014
+ *
2015
+ * Unconditional, unlike Live Activity push: Expo holds the app's APNs and FCM
2016
+ * credentials, so a self-hosted streamer needs no credential of its own. The
2017
+ * access token is optional and only relevant if the Expo project has enhanced
2018
+ * security enabled — requiring one would lock out every self-hoster, since
2019
+ * they do not own the project. It is never logged.
2020
+ */
2021
+ private initWaitingInputPush;
2022
+ /** Whether any live socket is subscribed to this session — "someone is looking". */
2023
+ private hasSessionSubscriber;
2011
2024
  /**
2012
2025
  * Classify sessions left behind by previous streamer runs (C1 Phase 3a).
2013
2026
  *
package/dist/index.js CHANGED
@@ -8415,10 +8415,10 @@ function fingerprintOf(ids) {
8415
8415
  return `sha256:${createHash3("sha256").update(sorted.join("\n")).digest("hex")}`;
8416
8416
  }
8417
8417
  var CacheIntegrityMonitor = class {
8418
- constructor(cache, wsHub, log8, cacheDir, rescan, runDuringReset) {
8418
+ constructor(cache, wsHub, log10, cacheDir, rescan, runDuringReset) {
8419
8419
  this.cache = cache;
8420
8420
  this.wsHub = wsHub;
8421
- this.log = log8;
8421
+ this.log = log10;
8422
8422
  this.cacheDir = cacheDir;
8423
8423
  this.rescan = rescan;
8424
8424
  this.runDuringReset = runDuringReset;
@@ -9217,6 +9217,101 @@ var ApnsClient = class {
9217
9217
  }
9218
9218
  };
9219
9219
 
9220
+ // src/services/push/expoPushSender.ts
9221
+ var log5 = getLogger("expo-push");
9222
+ var EXPO_PUSH_ENDPOINT = "https://exp.host/--/api/v2/push/send";
9223
+ var EXPO_PUSH_BATCH_SIZE = 100;
9224
+ var DEAD_TOKEN_ERROR = "DeviceNotRegistered";
9225
+ var ExpoPushSender = class {
9226
+ /**
9227
+ * @param accessToken Expo access token, when the project has enhanced
9228
+ * security enabled. Optional on purpose: a self-hoster does not own the Expo
9229
+ * project and cannot obtain one, so requiring it would break the deployment
9230
+ * this transport exists to serve.
9231
+ */
9232
+ constructor(repo, accessToken) {
9233
+ this.repo = repo;
9234
+ this.accessToken = accessToken;
9235
+ }
9236
+ repo;
9237
+ accessToken;
9238
+ /**
9239
+ * Send one message to every deliverable Expo token.
9240
+ *
9241
+ * Sends are independent: Expo returns a ticket per token in one response, so
9242
+ * a dead device is recorded against its own row and never silences the other
9243
+ * devices in the batch.
9244
+ */
9245
+ async send(message, now = Date.now()) {
9246
+ const rows = this.repo.listDeliverable();
9247
+ const outcome = { attempted: rows.length, succeeded: 0, retired: 0 };
9248
+ if (rows.length === 0) return outcome;
9249
+ for (let i = 0; i < rows.length; i += EXPO_PUSH_BATCH_SIZE) {
9250
+ const chunk = rows.slice(i, i + EXPO_PUSH_BATCH_SIZE);
9251
+ await this.sendChunk(chunk, message, now, outcome);
9252
+ }
9253
+ return outcome;
9254
+ }
9255
+ async sendChunk(rows, message, now, outcome) {
9256
+ let payload;
9257
+ try {
9258
+ const res = await fetch(EXPO_PUSH_ENDPOINT, {
9259
+ method: "POST",
9260
+ headers: {
9261
+ "content-type": "application/json",
9262
+ accept: "application/json",
9263
+ ...this.accessToken && { authorization: `Bearer ${this.accessToken}` }
9264
+ },
9265
+ body: JSON.stringify(rows.map((row) => ({ to: row.token, ...message })))
9266
+ });
9267
+ if (!res.ok) {
9268
+ const code = `HTTP_${res.status}`;
9269
+ for (const row of rows) this.repo.recordFailure(row.token, code, now);
9270
+ log5.warn("expo_push.request_rejected", {
9271
+ event: "expo_push.request_rejected",
9272
+ status: res.status,
9273
+ tokens: rows.length
9274
+ });
9275
+ return;
9276
+ }
9277
+ payload = await res.json();
9278
+ } catch (err) {
9279
+ for (const row of rows) this.repo.recordFailure(row.token, "SendError", now);
9280
+ log5.error("expo_push.send_failed", {
9281
+ event: "expo_push.send_failed",
9282
+ tokens: rows.length,
9283
+ err: String(err)
9284
+ });
9285
+ return;
9286
+ }
9287
+ const tickets = payload?.data;
9288
+ rows.forEach((row, index) => {
9289
+ const ticket = Array.isArray(tickets) ? tickets[index] : void 0;
9290
+ if (!ticket) {
9291
+ this.repo.recordFailure(row.token, "NoTicket", now);
9292
+ return;
9293
+ }
9294
+ if (ticket.status === "ok") {
9295
+ this.repo.recordSuccess(row.token, now);
9296
+ outcome.succeeded += 1;
9297
+ return;
9298
+ }
9299
+ const code = ticket.details?.error ?? "PushError";
9300
+ this.repo.recordFailure(row.token, code, now);
9301
+ if (code === DEAD_TOKEN_ERROR) {
9302
+ this.repo.revoke(row.token, now);
9303
+ outcome.retired += 1;
9304
+ }
9305
+ log5.warn("expo_push.send_rejected", {
9306
+ event: "expo_push.send_rejected",
9307
+ code,
9308
+ message: ticket.message,
9309
+ retired: code === DEAD_TOKEN_ERROR
9310
+ });
9311
+ });
9312
+ }
9313
+ };
9314
+
9220
9315
  // src/services/push/liveActivityContentState.ts
9221
9316
  var LAST_OUTPUT_MAX_LENGTH = 90;
9222
9317
  function toLiveActivityStatus(status) {
@@ -9228,7 +9323,7 @@ function truncateLastOutput(raw) {
9228
9323
  }
9229
9324
 
9230
9325
  // src/services/push/liveActivityNotifier.ts
9231
- var log5 = getLogger("live-activity");
9326
+ var log6 = getLogger("live-activity");
9232
9327
  function contentStateForSession(args) {
9233
9328
  const status = toLiveActivityStatus(args.session.status);
9234
9329
  if (!status) return null;
@@ -9288,7 +9383,7 @@ var LiveActivityNotifier = class {
9288
9383
  }
9289
9384
  await this.maybeSendName(session);
9290
9385
  } catch (err) {
9291
- log5.error("live_activity.notify_failed", {
9386
+ log6.error("live_activity.notify_failed", {
9292
9387
  event: "live_activity.notify_failed",
9293
9388
  sessionId: session.id,
9294
9389
  status: session.status,
@@ -9310,7 +9405,7 @@ var LiveActivityNotifier = class {
9310
9405
  });
9311
9406
  this.openActivity.set(session.id, { sessionNameSent: session.sessionName != null });
9312
9407
  if (outcome.attempted > 0) {
9313
- log5.info("live_activity.updated", {
9408
+ log6.info("live_activity.updated", {
9314
9409
  event: "live_activity.updated",
9315
9410
  sessionId: session.id,
9316
9411
  status: contentState.status,
@@ -9334,7 +9429,7 @@ var LiveActivityNotifier = class {
9334
9429
  });
9335
9430
  open2.sessionNameSent = true;
9336
9431
  if (outcome.attempted > 0) {
9337
- log5.info("live_activity.updated", {
9432
+ log6.info("live_activity.updated", {
9338
9433
  event: "live_activity.updated",
9339
9434
  sessionId: session.id,
9340
9435
  status: contentState.status,
@@ -9353,7 +9448,7 @@ var LiveActivityNotifier = class {
9353
9448
  if (!contentState) return;
9354
9449
  const outcome = await this.sender.end({ sessionId: session.id, contentState });
9355
9450
  if (outcome.attempted > 0) {
9356
- log5.info("live_activity.ended", {
9451
+ log6.info("live_activity.ended", {
9357
9452
  event: "live_activity.ended",
9358
9453
  sessionId: session.id,
9359
9454
  ...outcome
@@ -9367,7 +9462,7 @@ var LiveActivityNotifier = class {
9367
9462
  };
9368
9463
 
9369
9464
  // src/services/push/liveActivitySender.ts
9370
- var log6 = getLogger("live-activity");
9465
+ var log7 = getLogger("live-activity");
9371
9466
  var ACTIVITY_MAX_LIFETIME_MS = 8 * 60 * 60 * 1e3;
9372
9467
  function buildActivityKitPayload(args) {
9373
9468
  return {
@@ -9431,7 +9526,7 @@ var LiveActivitySender = class {
9431
9526
  );
9432
9527
  for (const { row, result, error } of results) {
9433
9528
  if (error) {
9434
- log6.error("live_activity.send_failed", {
9529
+ log7.error("live_activity.send_failed", {
9435
9530
  event: "live_activity.send_failed",
9436
9531
  sessionId: args.sessionId,
9437
9532
  activityId: row.activity_id,
@@ -9452,7 +9547,7 @@ var LiveActivitySender = class {
9452
9547
  this.repo.expire(row.token, now);
9453
9548
  outcome.retired += 1;
9454
9549
  }
9455
- log6.warn("live_activity.send_rejected", {
9550
+ log7.warn("live_activity.send_rejected", {
9456
9551
  event: "live_activity.send_rejected",
9457
9552
  sessionId: args.sessionId,
9458
9553
  activityId: row.activity_id,
@@ -9497,7 +9592,7 @@ var LiveActivitySender = class {
9497
9592
  };
9498
9593
 
9499
9594
  // src/services/push/liveActivityRenewal.ts
9500
- var log7 = getLogger("live-activity");
9595
+ var log8 = getLogger("live-activity");
9501
9596
  var RENEWAL_LEAD_MS = 30 * 60 * 1e3;
9502
9597
  var MAX_TIMER_MS = 60 * 60 * 1e3;
9503
9598
  function renewalDueAt(row) {
@@ -9545,7 +9640,7 @@ var LiveActivityRenewalScheduler = class {
9545
9640
  await this.renew(row, now);
9546
9641
  }
9547
9642
  } catch (err) {
9548
- log7.error("live_activity.renewal_sweep_failed", {
9643
+ log8.error("live_activity.renewal_sweep_failed", {
9549
9644
  event: "live_activity.renewal_sweep_failed",
9550
9645
  err: String(err)
9551
9646
  });
@@ -9574,7 +9669,7 @@ var LiveActivityRenewalScheduler = class {
9574
9669
  if (!session || !status) {
9575
9670
  this.deps.repo.claimRenewal(row.token, now);
9576
9671
  this.deps.repo.expire(row.token, now);
9577
- log7.info("live_activity.renewal_skipped", {
9672
+ log8.info("live_activity.renewal_skipped", {
9578
9673
  event: "live_activity.renewal_skipped",
9579
9674
  sessionId: row.session_id,
9580
9675
  activityId: row.activity_id,
@@ -9609,7 +9704,7 @@ var LiveActivityRenewalScheduler = class {
9609
9704
  startedAt,
9610
9705
  now
9611
9706
  });
9612
- log7.info("live_activity.renewed", {
9707
+ log8.info("live_activity.renewed", {
9613
9708
  event: "live_activity.renewed",
9614
9709
  sessionId: session.id,
9615
9710
  activityId: row.activity_id,
@@ -9619,7 +9714,7 @@ var LiveActivityRenewalScheduler = class {
9619
9714
  replacementRequested: started
9620
9715
  });
9621
9716
  } catch (err) {
9622
- log7.error("live_activity.renewal_failed", {
9717
+ log8.error("live_activity.renewal_failed", {
9623
9718
  event: "live_activity.renewal_failed",
9624
9719
  sessionId: session.id,
9625
9720
  activityId: row.activity_id,
@@ -9664,6 +9759,77 @@ var LiveActivityRenewalScheduler = class {
9664
9759
  }
9665
9760
  };
9666
9761
 
9762
+ // src/services/push/waitingInputNotifier.ts
9763
+ var log9 = getLogger("expo-push");
9764
+ function waitingInputMessage(session, serverId) {
9765
+ return {
9766
+ title: session.projectName || "Threadbase",
9767
+ body: "Waiting for your input",
9768
+ data: { sessionId: session.id, serverId }
9769
+ };
9770
+ }
9771
+ var WaitingInputNotifier = class {
9772
+ /**
9773
+ * @param isWatched Whether a client is currently subscribed to this session
9774
+ * over WebSocket. Mobile subscribes while the session screen is open and the
9775
+ * socket dies when the app is backgrounded, so this is the available signal
9776
+ * for "the user is already looking" — and a push to someone already reading
9777
+ * the output is pure noise.
9778
+ */
9779
+ constructor(sender, serverId, isWatched) {
9780
+ this.sender = sender;
9781
+ this.serverId = serverId;
9782
+ this.isWatched = isWatched;
9783
+ }
9784
+ sender;
9785
+ serverId;
9786
+ isWatched;
9787
+ /** Sessions with a turn the user started that has not yet been answered. */
9788
+ openTurn = /* @__PURE__ */ new Set();
9789
+ /**
9790
+ * React to a session status change.
9791
+ *
9792
+ * Fire-and-forget by design: a push must never delay or fail a session
9793
+ * transition, so this returns a promise the caller may ignore and every error
9794
+ * is logged rather than propagated.
9795
+ */
9796
+ async onStatusChange(session, previousStatus) {
9797
+ try {
9798
+ if (session.status === "running") {
9799
+ if (previousStatus === "waiting_input") this.openTurn.add(session.id);
9800
+ return;
9801
+ }
9802
+ if (session.status !== "waiting_input") {
9803
+ this.openTurn.delete(session.id);
9804
+ return;
9805
+ }
9806
+ if (!this.openTurn.delete(session.id)) return;
9807
+ if (this.isWatched(session.id)) {
9808
+ log9.debug("expo_push.suppressed_watched", {
9809
+ event: "expo_push.suppressed_watched",
9810
+ sessionId: session.id
9811
+ });
9812
+ return;
9813
+ }
9814
+ const outcome = await this.sender.send(waitingInputMessage(session, this.serverId));
9815
+ if (outcome.attempted > 0) {
9816
+ log9.info("expo_push.waiting_input", {
9817
+ event: "expo_push.waiting_input",
9818
+ sessionId: session.id,
9819
+ ...outcome
9820
+ });
9821
+ }
9822
+ } catch (err) {
9823
+ log9.error("expo_push.notify_failed", {
9824
+ event: "expo_push.notify_failed",
9825
+ sessionId: session.id,
9826
+ status: session.status,
9827
+ err: String(err)
9828
+ });
9829
+ }
9830
+ }
9831
+ };
9832
+
9667
9833
  // src/services/questions/parseStatusLine.ts
9668
9834
  var MODEL_RE = /(Opus|Sonnet|Haiku|Fable)\s+[\d.]+(?:\s*\([^)]*\))?/;
9669
9835
  var EFFORT_RE = /●\s*([A-Za-z]+)\s*·\s*\/effort/;
@@ -10974,6 +11140,10 @@ var StreamerServer = class {
10974
11140
  apnsClient = null;
10975
11141
  liveActivityNotifier = null;
10976
11142
  liveActivityRenewal = null;
11143
+ // "Your turn" notifications over Expo's relay (#528). Needs no credential of
11144
+ // its own, so unlike the Live Activity path it is on wherever the cache DB
11145
+ // opened — with no registered device it simply sends nothing.
11146
+ waitingInputNotifier = null;
10977
11147
  discoveryCache = null;
10978
11148
  // Single-flight for process discovery. Mobile polls GET /api/sessions and
10979
11149
  // retries on timeout; without this, every concurrent request starts its own
@@ -11273,6 +11443,7 @@ var StreamerServer = class {
11273
11443
  this.wsHub.broadcast({ type: "session_update", session: resp });
11274
11444
  }
11275
11445
  void this.liveActivityNotifier?.onStatusChange(session, previousStatus);
11446
+ void this.waitingInputNotifier?.onStatusChange(session, previousStatus);
11276
11447
  this.sessionStatusBus.emit(`status:${session.id}`, session.status, session);
11277
11448
  }
11278
11449
  });
@@ -11585,6 +11756,33 @@ var StreamerServer = class {
11585
11756
  topic: `${creds.bundleId}.push-type.liveactivity`
11586
11757
  });
11587
11758
  }
11759
+ /**
11760
+ * Bring up "your turn" notifications over Expo's relay (#528).
11761
+ *
11762
+ * Unconditional, unlike Live Activity push: Expo holds the app's APNs and FCM
11763
+ * credentials, so a self-hosted streamer needs no credential of its own. The
11764
+ * access token is optional and only relevant if the Expo project has enhanced
11765
+ * security enabled — requiring one would lock out every self-hoster, since
11766
+ * they do not own the project. It is never logged.
11767
+ */
11768
+ initWaitingInputPush(pushRepo) {
11769
+ const sender = new ExpoPushSender(pushRepo, process.env.THREADBASE_EXPO_ACCESS_TOKEN);
11770
+ const serverId = process.env.THREADBASE_INSTANCE_ID ?? hostname3();
11771
+ this.waitingInputNotifier = new WaitingInputNotifier(
11772
+ sender,
11773
+ serverId,
11774
+ (id) => this.hasSessionSubscriber(id)
11775
+ );
11776
+ }
11777
+ /** Whether any live socket is subscribed to this session — "someone is looking". */
11778
+ hasSessionSubscriber(sessionId) {
11779
+ const subs = this.sessionSubscribers.get(sessionId);
11780
+ if (!subs) return false;
11781
+ for (const ws of subs) {
11782
+ if (ws.readyState === ws.OPEN) return true;
11783
+ }
11784
+ return false;
11785
+ }
11588
11786
  /**
11589
11787
  * Classify sessions left behind by previous streamer runs (C1 Phase 3a).
11590
11788
  *
@@ -12187,6 +12385,7 @@ var StreamerServer = class {
12187
12385
  this.pushRepo = new PushRepository(db);
12188
12386
  this.devicesRepo = new DevicesRepository(db);
12189
12387
  this.initLiveActivityPush(this.pushRepo);
12388
+ this.initWaitingInputPush(this.pushRepo);
12190
12389
  this.cacheMonitor = new CacheIntegrityMonitor(
12191
12390
  this.cache,
12192
12391
  this.wsHub,