@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/cli.cjs +361 -160
- package/dist/cli.cjs.map +1 -1
- package/dist/index.cjs +214 -15
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +13 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.js +214 -15
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
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,
|
|
8455
|
+
constructor(cache, wsHub, log10, cacheDir, rescan, runDuringReset) {
|
|
8456
8456
|
this.cache = cache;
|
|
8457
8457
|
this.wsHub = wsHub;
|
|
8458
|
-
this.log =
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|