@threadbase-sh/streamer 1.47.3 → 1.49.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
@@ -5295,7 +5295,7 @@ function createLogsRoutes() {
5295
5295
 
5296
5296
  // src/api/routes/misc.routes.ts
5297
5297
  var import_node_child_process = require("child_process");
5298
- var import_node_crypto2 = require("crypto");
5298
+ var import_node_crypto3 = require("crypto");
5299
5299
  var import_hono11 = require("hono");
5300
5300
  var import_os6 = require("os");
5301
5301
 
@@ -5589,6 +5589,167 @@ var PushRepository = class {
5589
5589
  }
5590
5590
  };
5591
5591
 
5592
+ // src/services/push/apnsClient.ts
5593
+ var import_node_crypto2 = require("crypto");
5594
+ var import_node_http2 = require("http2");
5595
+ var log3 = getLogger("apns");
5596
+ var APNS_HOST_SANDBOX = "api.sandbox.push.apple.com";
5597
+ var APNS_MAX_PAYLOAD_BYTES = 4096;
5598
+ var JWT_TTL_SECONDS = 3e3;
5599
+ var DEAD_TOKEN_REASONS = /* @__PURE__ */ new Set([
5600
+ "BadDeviceToken",
5601
+ "DeviceTokenNotForTopic",
5602
+ "Unregistered",
5603
+ "ExpiredToken"
5604
+ ]);
5605
+ function base64url(input) {
5606
+ return Buffer.from(input).toString("base64url");
5607
+ }
5608
+ function readApnsCredentialsFromEnv(env = process.env) {
5609
+ const key = env.APNS_KEY;
5610
+ if (!key || key.trim().length === 0) return null;
5611
+ const keyId = env.APNS_KEY_ID?.trim();
5612
+ const teamId = env.APNS_TEAM_ID?.trim();
5613
+ const bundleId = env.APNS_BUNDLE_ID?.trim();
5614
+ if (!keyId || !teamId || !bundleId) return null;
5615
+ const host = env.APNS_HOST ?? APNS_HOST_SANDBOX;
5616
+ return { key, keyId, teamId, bundleId, host };
5617
+ }
5618
+ function describeMissingApnsCredentials(env = process.env) {
5619
+ if (!env.APNS_KEY || env.APNS_KEY.trim().length === 0) {
5620
+ return "APNS_KEY is not set, so Live Activity push is disabled. Set it to the p8 key contents (not a path) to enable it.";
5621
+ }
5622
+ const missing = [
5623
+ ["APNS_KEY_ID", env.APNS_KEY_ID],
5624
+ ["APNS_TEAM_ID", env.APNS_TEAM_ID],
5625
+ ["APNS_BUNDLE_ID", env.APNS_BUNDLE_ID]
5626
+ ].filter(([, value]) => !value || value.trim().length === 0).map(([name]) => name);
5627
+ if (missing.length === 0) return null;
5628
+ return `APNS_KEY is set but ${missing.join(", ")} ${missing.length === 1 ? "is" : "are"} not, so Live Activity push is disabled. Under launchd, APNS_KEY_ID is derived from the AuthKey_<keyId>.p8 filename; the team and bundle ids must be set explicitly.`;
5629
+ }
5630
+ var ApnsClient = class {
5631
+ constructor(creds) {
5632
+ this.creds = creds;
5633
+ }
5634
+ creds;
5635
+ session = null;
5636
+ cachedJwt = null;
5637
+ /**
5638
+ * The `apns-topic` for Live Activity pushes.
5639
+ *
5640
+ * The `.push-type.liveactivity` suffix is mandatory and is why the signing key
5641
+ * must be Team Scoped (All Topics) — a key scoped to the bundle id alone
5642
+ * cannot sign this topic.
5643
+ */
5644
+ get topic() {
5645
+ return `${this.creds.bundleId}.push-type.liveactivity`;
5646
+ }
5647
+ /**
5648
+ * Mint or reuse the provider JWT.
5649
+ *
5650
+ * ES256 over the p8 key. Cached until shortly before expiry: Apple rejects a
5651
+ * token older than an hour, but minting one per request is wasteful and can
5652
+ * trip APNs' provider-token-update throttle.
5653
+ */
5654
+ getJwt(now = Date.now()) {
5655
+ const nowSeconds = Math.floor(now / 1e3);
5656
+ if (this.cachedJwt && this.cachedJwt.expiresAt > nowSeconds + 60) {
5657
+ return this.cachedJwt.token;
5658
+ }
5659
+ const header = base64url(JSON.stringify({ alg: "ES256", kid: this.creds.keyId, typ: "JWT" }));
5660
+ const payload = base64url(JSON.stringify({ iss: this.creds.teamId, iat: nowSeconds }));
5661
+ const signingInput = `${header}.${payload}`;
5662
+ const signature = (0, import_node_crypto2.createSign)("SHA256").update(signingInput).sign({ key: this.creds.key, dsaEncoding: "ieee-p1363" });
5663
+ const token = `${signingInput}.${base64url(signature)}`;
5664
+ this.cachedJwt = { token, expiresAt: nowSeconds + JWT_TTL_SECONDS };
5665
+ return token;
5666
+ }
5667
+ /**
5668
+ * Reuse one HTTP/2 session across sends.
5669
+ *
5670
+ * APNs expects a long-lived connection; a fresh TLS handshake per push is slow
5671
+ * and Apple treats connection churn as abuse.
5672
+ */
5673
+ getSession() {
5674
+ if (this.session && !this.session.closed && !this.session.destroyed) {
5675
+ return this.session;
5676
+ }
5677
+ const session = (0, import_node_http2.connect)(`https://${this.creds.host}`);
5678
+ session.on("error", (err) => {
5679
+ log3.warn("apns.session_error", { event: "apns.session_error", err: String(err) });
5680
+ });
5681
+ this.session = session;
5682
+ return session;
5683
+ }
5684
+ /**
5685
+ * Send one push.
5686
+ *
5687
+ * Resolves with a result rather than rejecting on an APNs rejection: a
5688
+ * rejected push is an expected outcome the caller must act on (retire the
5689
+ * token), not an exception. Only a genuinely unexpected local failure throws,
5690
+ * and the caller logs it.
5691
+ */
5692
+ async send(args) {
5693
+ const body = Buffer.from(JSON.stringify(args.payload), "utf-8");
5694
+ if (body.byteLength > APNS_MAX_PAYLOAD_BYTES) {
5695
+ throw new Error(
5696
+ `APNs payload is ${body.byteLength} bytes, over the ${APNS_MAX_PAYLOAD_BYTES} byte limit`
5697
+ );
5698
+ }
5699
+ const session = this.getSession();
5700
+ const headers = {
5701
+ [import_node_http2.constants.HTTP2_HEADER_METHOD]: "POST",
5702
+ [import_node_http2.constants.HTTP2_HEADER_PATH]: `/3/device/${args.deviceToken}`,
5703
+ [import_node_http2.constants.HTTP2_HEADER_AUTHORIZATION]: `bearer ${this.getJwt()}`,
5704
+ "apns-push-type": "liveactivity",
5705
+ "apns-topic": this.topic,
5706
+ "apns-priority": String(args.priority ?? 10),
5707
+ ...args.expirationSeconds != null && {
5708
+ "apns-expiration": String(args.expirationSeconds)
5709
+ },
5710
+ [import_node_http2.constants.HTTP2_HEADER_CONTENT_TYPE]: "application/json",
5711
+ [import_node_http2.constants.HTTP2_HEADER_CONTENT_LENGTH]: String(body.byteLength)
5712
+ };
5713
+ return new Promise((resolve2, reject) => {
5714
+ const req = session.request(headers);
5715
+ req.setTimeout(args.timeoutMs ?? 1e4, () => {
5716
+ req.close(import_node_http2.constants.NGHTTP2_CANCEL);
5717
+ resolve2({ ok: false, status: 0, reason: "Timeout", tokenDead: false });
5718
+ });
5719
+ let status = 0;
5720
+ req.on("response", (resHeaders) => {
5721
+ status = Number(resHeaders[import_node_http2.constants.HTTP2_HEADER_STATUS] ?? 0);
5722
+ });
5723
+ const chunks = [];
5724
+ req.on("data", (chunk) => chunks.push(chunk));
5725
+ req.on("error", reject);
5726
+ req.on("end", () => {
5727
+ const raw = Buffer.concat(chunks).toString("utf-8");
5728
+ let reason;
5729
+ if (raw.length > 0) {
5730
+ try {
5731
+ reason = JSON.parse(raw).reason;
5732
+ } catch {
5733
+ reason = raw.slice(0, 200);
5734
+ }
5735
+ }
5736
+ resolve2({
5737
+ ok: status === 200,
5738
+ status,
5739
+ reason,
5740
+ tokenDead: reason != null && DEAD_TOKEN_REASONS.has(reason)
5741
+ });
5742
+ });
5743
+ req.end(body);
5744
+ });
5745
+ }
5746
+ /** Close the shared connection. Called on server shutdown. */
5747
+ close() {
5748
+ this.session?.close();
5749
+ this.session = null;
5750
+ }
5751
+ };
5752
+
5592
5753
  // src/api/routes/misc.routes.ts
5593
5754
  function numberOrNull(value) {
5594
5755
  return typeof value === "number" && Number.isFinite(value) ? value : null;
@@ -5619,11 +5780,22 @@ function readRawBody4(req) {
5619
5780
  function verifyWebhookSignature(body, header, secret) {
5620
5781
  if (!header) return false;
5621
5782
  const provided = header.startsWith("sha256=") ? header.slice(7) : header;
5622
- const expected = (0, import_node_crypto2.createHmac)("sha256", secret).update(body).digest("hex");
5783
+ const expected = (0, import_node_crypto3.createHmac)("sha256", secret).update(body).digest("hex");
5623
5784
  const a = Buffer.from(provided, "utf-8");
5624
5785
  const b = Buffer.from(expected, "utf-8");
5625
5786
  if (a.length !== b.length) return false;
5626
- return (0, import_node_crypto2.timingSafeEqual)(a, b);
5787
+ return (0, import_node_crypto3.timingSafeEqual)(a, b);
5788
+ }
5789
+ function describePushCapability(liveActivityEnabled, env = process.env) {
5790
+ if (liveActivityEnabled) return { liveActivity: true, notifications: false };
5791
+ return {
5792
+ liveActivity: false,
5793
+ notifications: false,
5794
+ // describeMissingApnsCredentials only explains a *credential* gap and
5795
+ // returns null once the credentials are complete — reachable here, because
5796
+ // an unavailable token store disables the feature with the key still set.
5797
+ liveActivityReason: describeMissingApnsCredentials(env) ?? "APNs credentials are set but the push token store is unavailable, so Live Activity push is disabled."
5798
+ };
5627
5799
  }
5628
5800
  var clientLog = getLogger("client");
5629
5801
  var createMiscRoutes = (deps) => {
@@ -5646,7 +5818,12 @@ var createMiscRoutes = (deps) => {
5646
5818
  featureFlags: true,
5647
5819
  // Same contract: this server serves GET /api/projects/summary, which the
5648
5820
  // Hub's grouped views need before they can draw a tree.
5649
- projectSummary: true
5821
+ projectSummary: true,
5822
+ // Delivery capability, not endpoint support: whether this server can
5823
+ // actually send a push, so mobile can hide an affordance instead of
5824
+ // registering tokens nothing will ever send to. Absent on older servers,
5825
+ // which a client should read as "unknown", not "unavailable".
5826
+ push: describePushCapability(deps.liveActivityPushEnabled())
5650
5827
  });
5651
5828
  });
5652
5829
  app.get("/api/profiles", (c) => c.json([]));
@@ -5707,9 +5884,10 @@ var createMiscRoutes = (deps) => {
5707
5884
  return c.json({ ok: true });
5708
5885
  });
5709
5886
  app.get("/api/push/health", (c) => {
5887
+ const push = describePushCapability(deps.liveActivityPushEnabled());
5710
5888
  const repo = deps.pushRepo();
5711
- if (!repo) return c.json({ tokens: [], available: false });
5712
- return c.json({ tokens: repo.listHealth(), available: true });
5889
+ if (!repo) return c.json({ tokens: [], available: false, push });
5890
+ return c.json({ tokens: repo.listHealth(), available: true, push });
5713
5891
  });
5714
5892
  app.post("/api/__update", async (c) => {
5715
5893
  const cfg = loadUpdateConfig();
@@ -6208,7 +6386,7 @@ var import_path12 = require("path");
6208
6386
  var import_promises4 = require("timers/promises");
6209
6387
 
6210
6388
  // src/db/query-timing.ts
6211
- var log3 = getLogger("db");
6389
+ var log4 = getLogger("db");
6212
6390
  var DEFAULT_SLOW_QUERY_MS = 35;
6213
6391
  var LABEL = /* @__PURE__ */ Symbol("tbQueryLabel");
6214
6392
  function deriveLabel(sql) {
@@ -6224,15 +6402,15 @@ function resolveSlowMs() {
6224
6402
  }
6225
6403
  function record(label, ms, rows, slowMs) {
6226
6404
  if (slowMs > 0 && ms >= slowMs) {
6227
- log3.warn(
6405
+ log4.warn(
6228
6406
  `[db] slow query ${label} ${ms.toFixed(1)}ms rows=${rows}`,
6229
6407
  { event: "db.slow_query", stmt: label, ms: Math.round(ms * 100) / 100, rows },
6230
6408
  "pino"
6231
6409
  );
6232
6410
  return;
6233
6411
  }
6234
- if (log3.pino.isLevelEnabled("debug")) {
6235
- log3.debug(
6412
+ if (log4.pino.isLevelEnabled("debug")) {
6413
+ log4.debug(
6236
6414
  `[db] ${label} ${ms.toFixed(2)}ms rows=${rows}`,
6237
6415
  { event: "db.query", stmt: label, ms: Math.round(ms * 100) / 100, rows },
6238
6416
  "pino"
@@ -8452,10 +8630,10 @@ function fingerprintOf(ids) {
8452
8630
  return `sha256:${(0, import_crypto9.createHash)("sha256").update(sorted.join("\n")).digest("hex")}`;
8453
8631
  }
8454
8632
  var CacheIntegrityMonitor = class {
8455
- constructor(cache, wsHub, log8, cacheDir, rescan, runDuringReset) {
8633
+ constructor(cache, wsHub, log10, cacheDir, rescan, runDuringReset) {
8456
8634
  this.cache = cache;
8457
8635
  this.wsHub = wsHub;
8458
- this.log = log8;
8636
+ this.log = log10;
8459
8637
  this.cacheDir = cacheDir;
8460
8638
  this.rescan = rescan;
8461
8639
  this.runDuringReset = runDuringReset;
@@ -9093,165 +9271,99 @@ function deriveProjectChatTitle(input) {
9093
9271
  return `Untitled \xB7 ${input.id.slice(0, 8)}`;
9094
9272
  }
9095
9273
 
9096
- // src/services/push/apnsClient.ts
9097
- var import_node_crypto3 = require("crypto");
9098
- var import_node_http2 = require("http2");
9099
- var log4 = getLogger("apns");
9100
- var APNS_HOST_SANDBOX = "api.sandbox.push.apple.com";
9101
- var APNS_MAX_PAYLOAD_BYTES = 4096;
9102
- var JWT_TTL_SECONDS = 3e3;
9103
- var DEAD_TOKEN_REASONS = /* @__PURE__ */ new Set([
9104
- "BadDeviceToken",
9105
- "DeviceTokenNotForTopic",
9106
- "Unregistered",
9107
- "ExpiredToken"
9108
- ]);
9109
- function base64url(input) {
9110
- return Buffer.from(input).toString("base64url");
9111
- }
9112
- function readApnsCredentialsFromEnv(env = process.env) {
9113
- const key = env.APNS_KEY;
9114
- if (!key || key.trim().length === 0) return null;
9115
- const keyId = env.APNS_KEY_ID?.trim();
9116
- const teamId = env.APNS_TEAM_ID?.trim();
9117
- const bundleId = env.APNS_BUNDLE_ID?.trim();
9118
- if (!keyId || !teamId || !bundleId) return null;
9119
- const host = env.APNS_HOST ?? APNS_HOST_SANDBOX;
9120
- return { key, keyId, teamId, bundleId, host };
9121
- }
9122
- function describeMissingApnsCredentials(env = process.env) {
9123
- if (!env.APNS_KEY || env.APNS_KEY.trim().length === 0) {
9124
- return "APNS_KEY is not set, so Live Activity push is disabled. Set it to the p8 key contents (not a path) to enable it.";
9125
- }
9126
- const missing = [
9127
- ["APNS_KEY_ID", env.APNS_KEY_ID],
9128
- ["APNS_TEAM_ID", env.APNS_TEAM_ID],
9129
- ["APNS_BUNDLE_ID", env.APNS_BUNDLE_ID]
9130
- ].filter(([, value]) => !value || value.trim().length === 0).map(([name]) => name);
9131
- if (missing.length === 0) return null;
9132
- return `APNS_KEY is set but ${missing.join(", ")} ${missing.length === 1 ? "is" : "are"} not, so Live Activity push is disabled. Under launchd, APNS_KEY_ID is derived from the AuthKey_<keyId>.p8 filename; the team and bundle ids must be set explicitly.`;
9133
- }
9134
- var ApnsClient = class {
9135
- constructor(creds) {
9136
- this.creds = creds;
9137
- }
9138
- creds;
9139
- session = null;
9140
- cachedJwt = null;
9274
+ // src/services/push/expoPushSender.ts
9275
+ var log5 = getLogger("expo-push");
9276
+ var EXPO_PUSH_ENDPOINT = "https://exp.host/--/api/v2/push/send";
9277
+ var EXPO_PUSH_BATCH_SIZE = 100;
9278
+ var DEAD_TOKEN_ERROR = "DeviceNotRegistered";
9279
+ var ExpoPushSender = class {
9141
9280
  /**
9142
- * The `apns-topic` for Live Activity pushes.
9143
- *
9144
- * The `.push-type.liveactivity` suffix is mandatory and is why the signing key
9145
- * must be Team Scoped (All Topics) — a key scoped to the bundle id alone
9146
- * cannot sign this topic.
9281
+ * @param accessToken Expo access token, when the project has enhanced
9282
+ * security enabled. Optional on purpose: a self-hoster does not own the Expo
9283
+ * project and cannot obtain one, so requiring it would break the deployment
9284
+ * this transport exists to serve.
9147
9285
  */
9148
- get topic() {
9149
- return `${this.creds.bundleId}.push-type.liveactivity`;
9286
+ constructor(repo, accessToken) {
9287
+ this.repo = repo;
9288
+ this.accessToken = accessToken;
9150
9289
  }
9290
+ repo;
9291
+ accessToken;
9151
9292
  /**
9152
- * Mint or reuse the provider JWT.
9293
+ * Send one message to every deliverable Expo token.
9153
9294
  *
9154
- * ES256 over the p8 key. Cached until shortly before expiry: Apple rejects a
9155
- * token older than an hour, but minting one per request is wasteful and can
9156
- * trip APNs' provider-token-update throttle.
9295
+ * Sends are independent: Expo returns a ticket per token in one response, so
9296
+ * a dead device is recorded against its own row and never silences the other
9297
+ * devices in the batch.
9157
9298
  */
9158
- getJwt(now = Date.now()) {
9159
- const nowSeconds = Math.floor(now / 1e3);
9160
- if (this.cachedJwt && this.cachedJwt.expiresAt > nowSeconds + 60) {
9161
- return this.cachedJwt.token;
9299
+ async send(message, now = Date.now()) {
9300
+ const rows = this.repo.listDeliverable();
9301
+ const outcome = { attempted: rows.length, succeeded: 0, retired: 0 };
9302
+ if (rows.length === 0) return outcome;
9303
+ for (let i = 0; i < rows.length; i += EXPO_PUSH_BATCH_SIZE) {
9304
+ const chunk = rows.slice(i, i + EXPO_PUSH_BATCH_SIZE);
9305
+ await this.sendChunk(chunk, message, now, outcome);
9162
9306
  }
9163
- const header = base64url(JSON.stringify({ alg: "ES256", kid: this.creds.keyId, typ: "JWT" }));
9164
- const payload = base64url(JSON.stringify({ iss: this.creds.teamId, iat: nowSeconds }));
9165
- const signingInput = `${header}.${payload}`;
9166
- const signature = (0, import_node_crypto3.createSign)("SHA256").update(signingInput).sign({ key: this.creds.key, dsaEncoding: "ieee-p1363" });
9167
- const token = `${signingInput}.${base64url(signature)}`;
9168
- this.cachedJwt = { token, expiresAt: nowSeconds + JWT_TTL_SECONDS };
9169
- return token;
9170
- }
9171
- /**
9172
- * Reuse one HTTP/2 session across sends.
9173
- *
9174
- * APNs expects a long-lived connection; a fresh TLS handshake per push is slow
9175
- * and Apple treats connection churn as abuse.
9176
- */
9177
- getSession() {
9178
- if (this.session && !this.session.closed && !this.session.destroyed) {
9179
- return this.session;
9180
- }
9181
- const session = (0, import_node_http2.connect)(`https://${this.creds.host}`);
9182
- session.on("error", (err) => {
9183
- log4.warn("apns.session_error", { event: "apns.session_error", err: String(err) });
9184
- });
9185
- this.session = session;
9186
- return session;
9307
+ return outcome;
9187
9308
  }
9188
- /**
9189
- * Send one push.
9190
- *
9191
- * Resolves with a result rather than rejecting on an APNs rejection: a
9192
- * rejected push is an expected outcome the caller must act on (retire the
9193
- * token), not an exception. Only a genuinely unexpected local failure throws,
9194
- * and the caller logs it.
9195
- */
9196
- async send(args) {
9197
- const body = Buffer.from(JSON.stringify(args.payload), "utf-8");
9198
- if (body.byteLength > APNS_MAX_PAYLOAD_BYTES) {
9199
- throw new Error(
9200
- `APNs payload is ${body.byteLength} bytes, over the ${APNS_MAX_PAYLOAD_BYTES} byte limit`
9201
- );
9202
- }
9203
- const session = this.getSession();
9204
- const headers = {
9205
- [import_node_http2.constants.HTTP2_HEADER_METHOD]: "POST",
9206
- [import_node_http2.constants.HTTP2_HEADER_PATH]: `/3/device/${args.deviceToken}`,
9207
- [import_node_http2.constants.HTTP2_HEADER_AUTHORIZATION]: `bearer ${this.getJwt()}`,
9208
- "apns-push-type": "liveactivity",
9209
- "apns-topic": this.topic,
9210
- "apns-priority": String(args.priority ?? 10),
9211
- ...args.expirationSeconds != null && {
9212
- "apns-expiration": String(args.expirationSeconds)
9213
- },
9214
- [import_node_http2.constants.HTTP2_HEADER_CONTENT_TYPE]: "application/json",
9215
- [import_node_http2.constants.HTTP2_HEADER_CONTENT_LENGTH]: String(body.byteLength)
9216
- };
9217
- return new Promise((resolve2, reject) => {
9218
- const req = session.request(headers);
9219
- req.setTimeout(args.timeoutMs ?? 1e4, () => {
9220
- req.close(import_node_http2.constants.NGHTTP2_CANCEL);
9221
- resolve2({ ok: false, status: 0, reason: "Timeout", tokenDead: false });
9222
- });
9223
- let status = 0;
9224
- req.on("response", (resHeaders) => {
9225
- status = Number(resHeaders[import_node_http2.constants.HTTP2_HEADER_STATUS] ?? 0);
9309
+ async sendChunk(rows, message, now, outcome) {
9310
+ let payload;
9311
+ try {
9312
+ const res = await fetch(EXPO_PUSH_ENDPOINT, {
9313
+ method: "POST",
9314
+ headers: {
9315
+ "content-type": "application/json",
9316
+ accept: "application/json",
9317
+ ...this.accessToken && { authorization: `Bearer ${this.accessToken}` }
9318
+ },
9319
+ body: JSON.stringify(rows.map((row) => ({ to: row.token, ...message })))
9226
9320
  });
9227
- const chunks = [];
9228
- req.on("data", (chunk) => chunks.push(chunk));
9229
- req.on("error", reject);
9230
- req.on("end", () => {
9231
- const raw = Buffer.concat(chunks).toString("utf-8");
9232
- let reason;
9233
- if (raw.length > 0) {
9234
- try {
9235
- reason = JSON.parse(raw).reason;
9236
- } catch {
9237
- reason = raw.slice(0, 200);
9238
- }
9239
- }
9240
- resolve2({
9241
- ok: status === 200,
9242
- status,
9243
- reason,
9244
- tokenDead: reason != null && DEAD_TOKEN_REASONS.has(reason)
9321
+ if (!res.ok) {
9322
+ const code = `HTTP_${res.status}`;
9323
+ for (const row of rows) this.repo.recordFailure(row.token, code, now);
9324
+ log5.warn("expo_push.request_rejected", {
9325
+ event: "expo_push.request_rejected",
9326
+ status: res.status,
9327
+ tokens: rows.length
9245
9328
  });
9329
+ return;
9330
+ }
9331
+ payload = await res.json();
9332
+ } catch (err) {
9333
+ for (const row of rows) this.repo.recordFailure(row.token, "SendError", now);
9334
+ log5.error("expo_push.send_failed", {
9335
+ event: "expo_push.send_failed",
9336
+ tokens: rows.length,
9337
+ err: String(err)
9338
+ });
9339
+ return;
9340
+ }
9341
+ const tickets = payload?.data;
9342
+ rows.forEach((row, index) => {
9343
+ const ticket = Array.isArray(tickets) ? tickets[index] : void 0;
9344
+ if (!ticket) {
9345
+ this.repo.recordFailure(row.token, "NoTicket", now);
9346
+ return;
9347
+ }
9348
+ if (ticket.status === "ok") {
9349
+ this.repo.recordSuccess(row.token, now);
9350
+ outcome.succeeded += 1;
9351
+ return;
9352
+ }
9353
+ const code = ticket.details?.error ?? "PushError";
9354
+ this.repo.recordFailure(row.token, code, now);
9355
+ if (code === DEAD_TOKEN_ERROR) {
9356
+ this.repo.revoke(row.token, now);
9357
+ outcome.retired += 1;
9358
+ }
9359
+ log5.warn("expo_push.send_rejected", {
9360
+ event: "expo_push.send_rejected",
9361
+ code,
9362
+ message: ticket.message,
9363
+ retired: code === DEAD_TOKEN_ERROR
9246
9364
  });
9247
- req.end(body);
9248
9365
  });
9249
9366
  }
9250
- /** Close the shared connection. Called on server shutdown. */
9251
- close() {
9252
- this.session?.close();
9253
- this.session = null;
9254
- }
9255
9367
  };
9256
9368
 
9257
9369
  // src/services/push/liveActivityContentState.ts
@@ -9265,7 +9377,7 @@ function truncateLastOutput(raw) {
9265
9377
  }
9266
9378
 
9267
9379
  // src/services/push/liveActivityNotifier.ts
9268
- var log5 = getLogger("live-activity");
9380
+ var log6 = getLogger("live-activity");
9269
9381
  function contentStateForSession(args) {
9270
9382
  const status = toLiveActivityStatus(args.session.status);
9271
9383
  if (!status) return null;
@@ -9325,7 +9437,7 @@ var LiveActivityNotifier = class {
9325
9437
  }
9326
9438
  await this.maybeSendName(session);
9327
9439
  } catch (err) {
9328
- log5.error("live_activity.notify_failed", {
9440
+ log6.error("live_activity.notify_failed", {
9329
9441
  event: "live_activity.notify_failed",
9330
9442
  sessionId: session.id,
9331
9443
  status: session.status,
@@ -9347,7 +9459,7 @@ var LiveActivityNotifier = class {
9347
9459
  });
9348
9460
  this.openActivity.set(session.id, { sessionNameSent: session.sessionName != null });
9349
9461
  if (outcome.attempted > 0) {
9350
- log5.info("live_activity.updated", {
9462
+ log6.info("live_activity.updated", {
9351
9463
  event: "live_activity.updated",
9352
9464
  sessionId: session.id,
9353
9465
  status: contentState.status,
@@ -9371,7 +9483,7 @@ var LiveActivityNotifier = class {
9371
9483
  });
9372
9484
  open2.sessionNameSent = true;
9373
9485
  if (outcome.attempted > 0) {
9374
- log5.info("live_activity.updated", {
9486
+ log6.info("live_activity.updated", {
9375
9487
  event: "live_activity.updated",
9376
9488
  sessionId: session.id,
9377
9489
  status: contentState.status,
@@ -9390,7 +9502,7 @@ var LiveActivityNotifier = class {
9390
9502
  if (!contentState) return;
9391
9503
  const outcome = await this.sender.end({ sessionId: session.id, contentState });
9392
9504
  if (outcome.attempted > 0) {
9393
- log5.info("live_activity.ended", {
9505
+ log6.info("live_activity.ended", {
9394
9506
  event: "live_activity.ended",
9395
9507
  sessionId: session.id,
9396
9508
  ...outcome
@@ -9404,7 +9516,7 @@ var LiveActivityNotifier = class {
9404
9516
  };
9405
9517
 
9406
9518
  // src/services/push/liveActivitySender.ts
9407
- var log6 = getLogger("live-activity");
9519
+ var log7 = getLogger("live-activity");
9408
9520
  var ACTIVITY_MAX_LIFETIME_MS = 8 * 60 * 60 * 1e3;
9409
9521
  function buildActivityKitPayload(args) {
9410
9522
  return {
@@ -9468,7 +9580,7 @@ var LiveActivitySender = class {
9468
9580
  );
9469
9581
  for (const { row, result, error } of results) {
9470
9582
  if (error) {
9471
- log6.error("live_activity.send_failed", {
9583
+ log7.error("live_activity.send_failed", {
9472
9584
  event: "live_activity.send_failed",
9473
9585
  sessionId: args.sessionId,
9474
9586
  activityId: row.activity_id,
@@ -9489,7 +9601,7 @@ var LiveActivitySender = class {
9489
9601
  this.repo.expire(row.token, now);
9490
9602
  outcome.retired += 1;
9491
9603
  }
9492
- log6.warn("live_activity.send_rejected", {
9604
+ log7.warn("live_activity.send_rejected", {
9493
9605
  event: "live_activity.send_rejected",
9494
9606
  sessionId: args.sessionId,
9495
9607
  activityId: row.activity_id,
@@ -9534,7 +9646,7 @@ var LiveActivitySender = class {
9534
9646
  };
9535
9647
 
9536
9648
  // src/services/push/liveActivityRenewal.ts
9537
- var log7 = getLogger("live-activity");
9649
+ var log8 = getLogger("live-activity");
9538
9650
  var RENEWAL_LEAD_MS = 30 * 60 * 1e3;
9539
9651
  var MAX_TIMER_MS = 60 * 60 * 1e3;
9540
9652
  function renewalDueAt(row) {
@@ -9582,7 +9694,7 @@ var LiveActivityRenewalScheduler = class {
9582
9694
  await this.renew(row, now);
9583
9695
  }
9584
9696
  } catch (err) {
9585
- log7.error("live_activity.renewal_sweep_failed", {
9697
+ log8.error("live_activity.renewal_sweep_failed", {
9586
9698
  event: "live_activity.renewal_sweep_failed",
9587
9699
  err: String(err)
9588
9700
  });
@@ -9611,7 +9723,7 @@ var LiveActivityRenewalScheduler = class {
9611
9723
  if (!session || !status) {
9612
9724
  this.deps.repo.claimRenewal(row.token, now);
9613
9725
  this.deps.repo.expire(row.token, now);
9614
- log7.info("live_activity.renewal_skipped", {
9726
+ log8.info("live_activity.renewal_skipped", {
9615
9727
  event: "live_activity.renewal_skipped",
9616
9728
  sessionId: row.session_id,
9617
9729
  activityId: row.activity_id,
@@ -9646,7 +9758,7 @@ var LiveActivityRenewalScheduler = class {
9646
9758
  startedAt,
9647
9759
  now
9648
9760
  });
9649
- log7.info("live_activity.renewed", {
9761
+ log8.info("live_activity.renewed", {
9650
9762
  event: "live_activity.renewed",
9651
9763
  sessionId: session.id,
9652
9764
  activityId: row.activity_id,
@@ -9656,7 +9768,7 @@ var LiveActivityRenewalScheduler = class {
9656
9768
  replacementRequested: started
9657
9769
  });
9658
9770
  } catch (err) {
9659
- log7.error("live_activity.renewal_failed", {
9771
+ log8.error("live_activity.renewal_failed", {
9660
9772
  event: "live_activity.renewal_failed",
9661
9773
  sessionId: session.id,
9662
9774
  activityId: row.activity_id,
@@ -9701,6 +9813,77 @@ var LiveActivityRenewalScheduler = class {
9701
9813
  }
9702
9814
  };
9703
9815
 
9816
+ // src/services/push/waitingInputNotifier.ts
9817
+ var log9 = getLogger("expo-push");
9818
+ function waitingInputMessage(session, serverId) {
9819
+ return {
9820
+ title: session.projectName || "Threadbase",
9821
+ body: "Waiting for your input",
9822
+ data: { sessionId: session.id, serverId }
9823
+ };
9824
+ }
9825
+ var WaitingInputNotifier = class {
9826
+ /**
9827
+ * @param isWatched Whether a client is currently subscribed to this session
9828
+ * over WebSocket. Mobile subscribes while the session screen is open and the
9829
+ * socket dies when the app is backgrounded, so this is the available signal
9830
+ * for "the user is already looking" — and a push to someone already reading
9831
+ * the output is pure noise.
9832
+ */
9833
+ constructor(sender, serverId, isWatched) {
9834
+ this.sender = sender;
9835
+ this.serverId = serverId;
9836
+ this.isWatched = isWatched;
9837
+ }
9838
+ sender;
9839
+ serverId;
9840
+ isWatched;
9841
+ /** Sessions with a turn the user started that has not yet been answered. */
9842
+ openTurn = /* @__PURE__ */ new Set();
9843
+ /**
9844
+ * React to a session status change.
9845
+ *
9846
+ * Fire-and-forget by design: a push must never delay or fail a session
9847
+ * transition, so this returns a promise the caller may ignore and every error
9848
+ * is logged rather than propagated.
9849
+ */
9850
+ async onStatusChange(session, previousStatus) {
9851
+ try {
9852
+ if (session.status === "running") {
9853
+ if (previousStatus === "waiting_input") this.openTurn.add(session.id);
9854
+ return;
9855
+ }
9856
+ if (session.status !== "waiting_input") {
9857
+ this.openTurn.delete(session.id);
9858
+ return;
9859
+ }
9860
+ if (!this.openTurn.delete(session.id)) return;
9861
+ if (this.isWatched(session.id)) {
9862
+ log9.debug("expo_push.suppressed_watched", {
9863
+ event: "expo_push.suppressed_watched",
9864
+ sessionId: session.id
9865
+ });
9866
+ return;
9867
+ }
9868
+ const outcome = await this.sender.send(waitingInputMessage(session, this.serverId));
9869
+ if (outcome.attempted > 0) {
9870
+ log9.info("expo_push.waiting_input", {
9871
+ event: "expo_push.waiting_input",
9872
+ sessionId: session.id,
9873
+ ...outcome
9874
+ });
9875
+ }
9876
+ } catch (err) {
9877
+ log9.error("expo_push.notify_failed", {
9878
+ event: "expo_push.notify_failed",
9879
+ sessionId: session.id,
9880
+ status: session.status,
9881
+ err: String(err)
9882
+ });
9883
+ }
9884
+ }
9885
+ };
9886
+
9704
9887
  // src/services/questions/parseStatusLine.ts
9705
9888
  var MODEL_RE = /(Opus|Sonnet|Haiku|Fable)\s+[\d.]+(?:\s*\([^)]*\))?/;
9706
9889
  var EFFORT_RE = /●\s*([A-Za-z]+)\s*·\s*\/effort/;
@@ -11011,6 +11194,10 @@ var StreamerServer = class {
11011
11194
  apnsClient = null;
11012
11195
  liveActivityNotifier = null;
11013
11196
  liveActivityRenewal = null;
11197
+ // "Your turn" notifications over Expo's relay (#528). Needs no credential of
11198
+ // its own, so unlike the Live Activity path it is on wherever the cache DB
11199
+ // opened — with no registered device it simply sends nothing.
11200
+ waitingInputNotifier = null;
11014
11201
  discoveryCache = null;
11015
11202
  // Single-flight for process discovery. Mobile polls GET /api/sessions and
11016
11203
  // retries on timeout; without this, every concurrent request starts its own
@@ -11310,6 +11497,7 @@ var StreamerServer = class {
11310
11497
  this.wsHub.broadcast({ type: "session_update", session: resp });
11311
11498
  }
11312
11499
  void this.liveActivityNotifier?.onStatusChange(session, previousStatus);
11500
+ void this.waitingInputNotifier?.onStatusChange(session, previousStatus);
11313
11501
  this.sessionStatusBus.emit(`status:${session.id}`, session.status, session);
11314
11502
  }
11315
11503
  });
@@ -11355,6 +11543,7 @@ var StreamerServer = class {
11355
11543
  cache: () => this.cache,
11356
11544
  cacheMonitor: () => this.cacheMonitor,
11357
11545
  pushRepo: () => this.pushRepo,
11546
+ liveActivityPushEnabled: () => this.liveActivityNotifier !== null,
11358
11547
  devicesRepo: () => this.devicesRepo,
11359
11548
  projectsRepo: () => this.projectsRepo,
11360
11549
  conversationsRepo: () => this.conversationsRepo,
@@ -11622,6 +11811,33 @@ var StreamerServer = class {
11622
11811
  topic: `${creds.bundleId}.push-type.liveactivity`
11623
11812
  });
11624
11813
  }
11814
+ /**
11815
+ * Bring up "your turn" notifications over Expo's relay (#528).
11816
+ *
11817
+ * Unconditional, unlike Live Activity push: Expo holds the app's APNs and FCM
11818
+ * credentials, so a self-hosted streamer needs no credential of its own. The
11819
+ * access token is optional and only relevant if the Expo project has enhanced
11820
+ * security enabled — requiring one would lock out every self-hoster, since
11821
+ * they do not own the project. It is never logged.
11822
+ */
11823
+ initWaitingInputPush(pushRepo) {
11824
+ const sender = new ExpoPushSender(pushRepo, process.env.THREADBASE_EXPO_ACCESS_TOKEN);
11825
+ const serverId = process.env.THREADBASE_INSTANCE_ID ?? (0, import_os10.hostname)();
11826
+ this.waitingInputNotifier = new WaitingInputNotifier(
11827
+ sender,
11828
+ serverId,
11829
+ (id) => this.hasSessionSubscriber(id)
11830
+ );
11831
+ }
11832
+ /** Whether any live socket is subscribed to this session — "someone is looking". */
11833
+ hasSessionSubscriber(sessionId) {
11834
+ const subs = this.sessionSubscribers.get(sessionId);
11835
+ if (!subs) return false;
11836
+ for (const ws of subs) {
11837
+ if (ws.readyState === ws.OPEN) return true;
11838
+ }
11839
+ return false;
11840
+ }
11625
11841
  /**
11626
11842
  * Classify sessions left behind by previous streamer runs (C1 Phase 3a).
11627
11843
  *
@@ -12224,6 +12440,7 @@ var StreamerServer = class {
12224
12440
  this.pushRepo = new PushRepository(db);
12225
12441
  this.devicesRepo = new DevicesRepository(db);
12226
12442
  this.initLiveActivityPush(this.pushRepo);
12443
+ this.initWaitingInputPush(this.pushRepo);
12227
12444
  this.cacheMonitor = new CacheIntegrityMonitor(
12228
12445
  this.cache,
12229
12446
  this.wsHub,