@threadbase-sh/streamer 1.37.0 → 1.38.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 +1304 -368
- package/dist/cli.cjs.map +1 -1
- package/dist/index.cjs +1005 -128
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +131 -5
- package/dist/index.d.ts +131 -5
- package/dist/index.js +1003 -126
- package/dist/index.js.map +1 -1
- package/dist/launchd-entry.cjs +113 -30
- package/dist/launchd-entry.cjs.map +1 -1
- package/dist/migrations/013_add_push_token_kind.sql +63 -0
- package/dist/pg-migrations/007_create_push_tokens.sql +93 -0
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -436,6 +436,58 @@ function getLogger(component) {
|
|
|
436
436
|
}
|
|
437
437
|
var logger = build(baseLogger);
|
|
438
438
|
|
|
439
|
+
// src/feature-flags.ts
|
|
440
|
+
var FEATURE_FLAGS = [
|
|
441
|
+
{
|
|
442
|
+
id: "codexSystemPrompt",
|
|
443
|
+
description: "Send the built system prompt to fresh Codex sessions. Off by default: Codex has no --system-prompt flag, so the prompt goes in the positional [PROMPT] argument, which Codex treats as the user's opening turn rather than a system-level instruction.",
|
|
444
|
+
default: false,
|
|
445
|
+
env: "THREADBASE_FEATURE_CODEX_SYSTEM_PROMPT"
|
|
446
|
+
}
|
|
447
|
+
];
|
|
448
|
+
function findFeatureFlag(id) {
|
|
449
|
+
return FEATURE_FLAGS.find((f) => f.id === id);
|
|
450
|
+
}
|
|
451
|
+
function parseBooleanEnv(raw) {
|
|
452
|
+
if (raw === void 0) return void 0;
|
|
453
|
+
const v = raw.trim().toLowerCase();
|
|
454
|
+
if (v === "") return false;
|
|
455
|
+
return !(v === "0" || v === "false" || v === "no" || v === "off");
|
|
456
|
+
}
|
|
457
|
+
function validateFeatureFlagValues(raw) {
|
|
458
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {};
|
|
459
|
+
const out = {};
|
|
460
|
+
const dropped = [];
|
|
461
|
+
for (const [id, value] of Object.entries(raw)) {
|
|
462
|
+
if (!findFeatureFlag(id) || typeof value !== "boolean") {
|
|
463
|
+
dropped.push(id);
|
|
464
|
+
continue;
|
|
465
|
+
}
|
|
466
|
+
out[id] = value;
|
|
467
|
+
}
|
|
468
|
+
if (dropped.length > 0) {
|
|
469
|
+
getLogger("feature-flags").warn(
|
|
470
|
+
`Ignoring unknown or non-boolean feature flags: ${dropped.join(", ")}`,
|
|
471
|
+
{
|
|
472
|
+
event: "config.feature_flags_dropped",
|
|
473
|
+
dropped
|
|
474
|
+
}
|
|
475
|
+
);
|
|
476
|
+
}
|
|
477
|
+
return out;
|
|
478
|
+
}
|
|
479
|
+
function resolveFeatureFlags(opts) {
|
|
480
|
+
const env = opts?.env ?? process.env;
|
|
481
|
+
const out = {};
|
|
482
|
+
for (const def of FEATURE_FLAGS) {
|
|
483
|
+
out[def.id] = parseBooleanEnv(env[def.env]) ?? opts?.cli?.[def.id] ?? opts?.yaml?.[def.id] ?? def.default;
|
|
484
|
+
}
|
|
485
|
+
return out;
|
|
486
|
+
}
|
|
487
|
+
function nonDefaultFeatureFlags(values) {
|
|
488
|
+
return FEATURE_FLAGS.filter((f) => values[f.id] !== f.default).map((f) => f.id);
|
|
489
|
+
}
|
|
490
|
+
|
|
439
491
|
// src/auth.ts
|
|
440
492
|
function configDir() {
|
|
441
493
|
return process.env.THREADBASE_CONFIG_DIR ?? join2(homedir(), ".threadbase");
|
|
@@ -588,6 +640,21 @@ function setClaudeExtraArgs(text) {
|
|
|
588
640
|
}
|
|
589
641
|
setConfigValue("claude_extra_args", trimmed && trimmed.length > 0 ? trimmed : void 0);
|
|
590
642
|
}
|
|
643
|
+
function loadFeatureFlags() {
|
|
644
|
+
try {
|
|
645
|
+
const content = readFileSync(configFile(), "utf-8");
|
|
646
|
+
const match = content.match(/^feature_flags:\s*(.+)$/m);
|
|
647
|
+
if (!match?.[1]) return {};
|
|
648
|
+
return validateFeatureFlagValues(JSON.parse(match[1].trim()));
|
|
649
|
+
} catch (err) {
|
|
650
|
+
if (err.code !== "ENOENT") {
|
|
651
|
+
getLogger("auth").warn(`Ignoring unreadable feature_flags in server.yaml: ${String(err)}`, {
|
|
652
|
+
event: "config.feature_flags_parse_failed"
|
|
653
|
+
});
|
|
654
|
+
}
|
|
655
|
+
return {};
|
|
656
|
+
}
|
|
657
|
+
}
|
|
591
658
|
function validatePublicUrl(raw) {
|
|
592
659
|
let parsed;
|
|
593
660
|
try {
|
|
@@ -2953,7 +3020,7 @@ import {
|
|
|
2953
3020
|
} from "fs";
|
|
2954
3021
|
import { realpath as realpath2 } from "fs/promises";
|
|
2955
3022
|
import { createServer } from "http";
|
|
2956
|
-
import { homedir as homedir9 } from "os";
|
|
3023
|
+
import { homedir as homedir9, hostname as hostname2 } from "os";
|
|
2957
3024
|
import { basename as basename5, dirname as dirname9, join as join18 } from "path";
|
|
2958
3025
|
import { createInterface } from "readline";
|
|
2959
3026
|
|
|
@@ -3635,6 +3702,7 @@ function readRawBody3(req) {
|
|
|
3635
3702
|
var createConfigRoutes = (deps) => {
|
|
3636
3703
|
const app = new Hono4();
|
|
3637
3704
|
app.get("/claude-flags", (c) => c.json(deps.claudeFlagsConfig()));
|
|
3705
|
+
app.get("/feature-flags", (c) => c.json(deps.featureFlagsConfig()));
|
|
3638
3706
|
app.put("/claude-flags", async (c) => {
|
|
3639
3707
|
if (deps.localNoAuth) {
|
|
3640
3708
|
return c.json({ error: "claude flag changes are disabled while localNoAuth is active" }, 403);
|
|
@@ -3940,7 +4008,264 @@ function loadUpdateConfig(opts = {}) {
|
|
|
3940
4008
|
return UpdateConfigSchema.parse(parsed);
|
|
3941
4009
|
}
|
|
3942
4010
|
|
|
4011
|
+
// src/db/repositories/push.repository.ts
|
|
4012
|
+
var FAILURE_STREAK_LIMIT = 5;
|
|
4013
|
+
var PUSH_TOKEN_KINDS = ["expo", "liveactivity_start", "liveactivity_update"];
|
|
4014
|
+
var DEFAULT_PUSH_TOKEN_KIND = "expo";
|
|
4015
|
+
function isPushTokenKind(value) {
|
|
4016
|
+
return typeof value === "string" && PUSH_TOKEN_KINDS.includes(value);
|
|
4017
|
+
}
|
|
4018
|
+
function tokenState(row, now = Date.now()) {
|
|
4019
|
+
if (row.revoked_at != null) return "revoked";
|
|
4020
|
+
if (row.expires_at != null && row.expires_at <= now) return "expired";
|
|
4021
|
+
if (row.failure_streak >= FAILURE_STREAK_LIMIT) return "dead";
|
|
4022
|
+
if (row.failure_streak > 0) return "failing";
|
|
4023
|
+
if (row.last_success_at == null) return "never-delivered";
|
|
4024
|
+
return "healthy";
|
|
4025
|
+
}
|
|
4026
|
+
function toHealth(row, now = Date.now()) {
|
|
4027
|
+
return {
|
|
4028
|
+
platform: row.platform,
|
|
4029
|
+
deviceId: row.device_id,
|
|
4030
|
+
registeredAt: row.registered_at,
|
|
4031
|
+
lastSuccessAt: row.last_success_at,
|
|
4032
|
+
lastFailureAt: row.last_failure_at,
|
|
4033
|
+
lastFailureCode: row.last_failure_code,
|
|
4034
|
+
failureStreak: row.failure_streak,
|
|
4035
|
+
revokedAt: row.revoked_at,
|
|
4036
|
+
state: tokenState(row, now),
|
|
4037
|
+
kind: row.kind,
|
|
4038
|
+
activityId: row.activity_id,
|
|
4039
|
+
sessionId: row.session_id,
|
|
4040
|
+
expiresAt: row.expires_at
|
|
4041
|
+
};
|
|
4042
|
+
}
|
|
4043
|
+
var PushRepository = class {
|
|
4044
|
+
upsertStmt;
|
|
4045
|
+
getStmt;
|
|
4046
|
+
listActiveStmt;
|
|
4047
|
+
listAllStmt;
|
|
4048
|
+
successStmt;
|
|
4049
|
+
failureStmt;
|
|
4050
|
+
revokeStmt;
|
|
4051
|
+
claimEventStmt;
|
|
4052
|
+
markDeliveredStmt;
|
|
4053
|
+
listByKindSessionStmt;
|
|
4054
|
+
listByKindStmt;
|
|
4055
|
+
listRenewableStmt;
|
|
4056
|
+
claimRenewalStmt;
|
|
4057
|
+
expireStmt;
|
|
4058
|
+
expireSessionActivitiesStmt;
|
|
4059
|
+
constructor(db) {
|
|
4060
|
+
this.upsertStmt = db.prepare(`
|
|
4061
|
+
INSERT INTO push_tokens (
|
|
4062
|
+
token, platform, device_id, registered_at,
|
|
4063
|
+
kind, activity_id, session_id, expires_at, stale_date, started_at
|
|
4064
|
+
)
|
|
4065
|
+
VALUES (
|
|
4066
|
+
@token, @platform, @device_id, @registered_at,
|
|
4067
|
+
@kind, @activity_id, @session_id, @expires_at, @stale_date, @started_at
|
|
4068
|
+
)
|
|
4069
|
+
ON CONFLICT(token) DO UPDATE SET
|
|
4070
|
+
platform = excluded.platform,
|
|
4071
|
+
device_id = COALESCE(excluded.device_id, push_tokens.device_id),
|
|
4072
|
+
registered_at = excluded.registered_at,
|
|
4073
|
+
kind = excluded.kind,
|
|
4074
|
+
activity_id = COALESCE(excluded.activity_id, push_tokens.activity_id),
|
|
4075
|
+
session_id = COALESCE(excluded.session_id, push_tokens.session_id),
|
|
4076
|
+
expires_at = excluded.expires_at,
|
|
4077
|
+
stale_date = excluded.stale_date,
|
|
4078
|
+
-- Preserve the ORIGINAL start across a re-registration. iOS renders its
|
|
4079
|
+
-- own ticking timer from started_at, so overwriting it with a fresh
|
|
4080
|
+
-- value visibly resets the user's elapsed time to zero.
|
|
4081
|
+
started_at = COALESCE(push_tokens.started_at, excluded.started_at),
|
|
4082
|
+
-- A fresh registration clears prior failure state and any revocation:
|
|
4083
|
+
-- the client is telling us this token is live again. renewed_at clears
|
|
4084
|
+
-- too \u2014 this is a new activity generation, so it is renewable again.
|
|
4085
|
+
failure_streak = 0,
|
|
4086
|
+
last_failure_at = NULL,
|
|
4087
|
+
last_failure_code = NULL,
|
|
4088
|
+
revoked_at = NULL,
|
|
4089
|
+
renewed_at = NULL
|
|
4090
|
+
`);
|
|
4091
|
+
this.getStmt = db.prepare("SELECT * FROM push_tokens WHERE token = ?");
|
|
4092
|
+
this.listActiveStmt = db.prepare(`
|
|
4093
|
+
SELECT * FROM push_tokens
|
|
4094
|
+
WHERE revoked_at IS NULL AND failure_streak < ${FAILURE_STREAK_LIMIT}
|
|
4095
|
+
AND kind = 'expo'
|
|
4096
|
+
ORDER BY registered_at ASC
|
|
4097
|
+
`);
|
|
4098
|
+
this.listAllStmt = db.prepare("SELECT * FROM push_tokens ORDER BY registered_at ASC");
|
|
4099
|
+
this.successStmt = db.prepare(`
|
|
4100
|
+
UPDATE push_tokens
|
|
4101
|
+
SET last_success_at = @at, failure_streak = 0,
|
|
4102
|
+
last_failure_code = NULL
|
|
4103
|
+
WHERE token = @token
|
|
4104
|
+
`);
|
|
4105
|
+
this.failureStmt = db.prepare(`
|
|
4106
|
+
UPDATE push_tokens
|
|
4107
|
+
SET last_failure_at = @at, last_failure_code = @code,
|
|
4108
|
+
failure_streak = failure_streak + 1
|
|
4109
|
+
WHERE token = @token
|
|
4110
|
+
`);
|
|
4111
|
+
this.revokeStmt = db.prepare("UPDATE push_tokens SET revoked_at = ? WHERE token = ?");
|
|
4112
|
+
this.listByKindSessionStmt = db.prepare(`
|
|
4113
|
+
SELECT * FROM push_tokens
|
|
4114
|
+
WHERE kind = @kind AND session_id = @session_id
|
|
4115
|
+
AND revoked_at IS NULL AND failure_streak < ${FAILURE_STREAK_LIMIT}
|
|
4116
|
+
AND (expires_at IS NULL OR expires_at > @now)
|
|
4117
|
+
ORDER BY registered_at ASC
|
|
4118
|
+
`);
|
|
4119
|
+
this.listByKindStmt = db.prepare(`
|
|
4120
|
+
SELECT * FROM push_tokens
|
|
4121
|
+
WHERE kind = @kind
|
|
4122
|
+
AND revoked_at IS NULL AND failure_streak < ${FAILURE_STREAK_LIMIT}
|
|
4123
|
+
AND (expires_at IS NULL OR expires_at > @now)
|
|
4124
|
+
ORDER BY registered_at ASC
|
|
4125
|
+
`);
|
|
4126
|
+
this.listRenewableStmt = db.prepare(`
|
|
4127
|
+
SELECT * FROM push_tokens
|
|
4128
|
+
WHERE kind = 'liveactivity_update'
|
|
4129
|
+
AND stale_date IS NOT NULL AND renewed_at IS NULL
|
|
4130
|
+
AND revoked_at IS NULL AND failure_streak < ${FAILURE_STREAK_LIMIT}
|
|
4131
|
+
ORDER BY stale_date ASC
|
|
4132
|
+
`);
|
|
4133
|
+
this.claimRenewalStmt = db.prepare(`
|
|
4134
|
+
UPDATE push_tokens SET renewed_at = @at
|
|
4135
|
+
WHERE token = @token AND renewed_at IS NULL
|
|
4136
|
+
`);
|
|
4137
|
+
this.expireStmt = db.prepare("UPDATE push_tokens SET expires_at = ? WHERE token = ?");
|
|
4138
|
+
this.expireSessionActivitiesStmt = db.prepare(`
|
|
4139
|
+
UPDATE push_tokens SET expires_at = @at
|
|
4140
|
+
WHERE session_id = @session_id AND kind = 'liveactivity_update'
|
|
4141
|
+
AND (expires_at IS NULL OR expires_at > @at)
|
|
4142
|
+
`);
|
|
4143
|
+
this.claimEventStmt = db.prepare(`
|
|
4144
|
+
INSERT OR IGNORE INTO push_events (event_id, session_id, created_at)
|
|
4145
|
+
VALUES (@event_id, @session_id, @created_at)
|
|
4146
|
+
`);
|
|
4147
|
+
this.markDeliveredStmt = db.prepare(
|
|
4148
|
+
"UPDATE push_events SET delivered_at = ? WHERE event_id = ?"
|
|
4149
|
+
);
|
|
4150
|
+
}
|
|
4151
|
+
/**
|
|
4152
|
+
* Register or refresh a token.
|
|
4153
|
+
*
|
|
4154
|
+
* `kind` defaults to Expo so a released client posting `{ token, platform }`
|
|
4155
|
+
* keeps working — tb-mobile cannot be force-updated, and every client
|
|
4156
|
+
* predating Live Activities is registering an Expo relay token.
|
|
4157
|
+
*
|
|
4158
|
+
* Several rows per device is normal and intended: a device runs one activity
|
|
4159
|
+
* per live session, each with its own update token. The token itself is the
|
|
4160
|
+
* primary key, so distinct activities never collide.
|
|
4161
|
+
*/
|
|
4162
|
+
register(args) {
|
|
4163
|
+
this.upsertStmt.run({
|
|
4164
|
+
token: args.token,
|
|
4165
|
+
platform: args.platform,
|
|
4166
|
+
device_id: args.deviceId ?? null,
|
|
4167
|
+
registered_at: args.now ?? Date.now(),
|
|
4168
|
+
kind: args.kind ?? DEFAULT_PUSH_TOKEN_KIND,
|
|
4169
|
+
activity_id: args.activityId ?? null,
|
|
4170
|
+
session_id: args.sessionId ?? null,
|
|
4171
|
+
expires_at: args.expiresAt ?? null,
|
|
4172
|
+
stale_date: args.staleDate ?? null,
|
|
4173
|
+
started_at: args.startedAt ?? null
|
|
4174
|
+
});
|
|
4175
|
+
}
|
|
4176
|
+
get(token) {
|
|
4177
|
+
return this.getStmt.get(token) ?? null;
|
|
4178
|
+
}
|
|
4179
|
+
/**
|
|
4180
|
+
* Expo tokens eligible for delivery — not revoked, not past the failure limit.
|
|
4181
|
+
*
|
|
4182
|
+
* Deliberately Expo-only. ActivityKit tokens go over direct APNs with a
|
|
4183
|
+
* different topic and are rejected by Expo's relay, so the ordinary
|
|
4184
|
+
* notification fan-out must not see them.
|
|
4185
|
+
*/
|
|
4186
|
+
listDeliverable() {
|
|
4187
|
+
return this.listActiveStmt.all();
|
|
4188
|
+
}
|
|
4189
|
+
/** Live-activity tokens for one session, eligible for delivery. */
|
|
4190
|
+
listForSession(kind, sessionId, now = Date.now()) {
|
|
4191
|
+
return this.listByKindSessionStmt.all({
|
|
4192
|
+
kind,
|
|
4193
|
+
session_id: sessionId,
|
|
4194
|
+
now
|
|
4195
|
+
});
|
|
4196
|
+
}
|
|
4197
|
+
/**
|
|
4198
|
+
* Every deliverable token of one kind.
|
|
4199
|
+
*
|
|
4200
|
+
* Used for push-to-start, which is app-wide rather than session-scoped: the
|
|
4201
|
+
* activity does not exist yet, so there is no per-activity token to look up.
|
|
4202
|
+
*/
|
|
4203
|
+
listByKind(kind, now = Date.now()) {
|
|
4204
|
+
return this.listByKindStmt.all({ kind, now });
|
|
4205
|
+
}
|
|
4206
|
+
/** Unrenewed activities with a renewal deadline, soonest first. */
|
|
4207
|
+
listRenewable() {
|
|
4208
|
+
return this.listRenewableStmt.all();
|
|
4209
|
+
}
|
|
4210
|
+
/**
|
|
4211
|
+
* Claim a row for renewal.
|
|
4212
|
+
*
|
|
4213
|
+
* Returns true exactly once per row. A restart re-arms timers from the
|
|
4214
|
+
* persisted deadline, so the same renewal can be attempted twice; the loser
|
|
4215
|
+
* gets false and must not send. Doing this as a conditional UPDATE rather
|
|
4216
|
+
* than read-then-write avoids the race where both attempts observe
|
|
4217
|
+
* "not yet renewed".
|
|
4218
|
+
*/
|
|
4219
|
+
claimRenewal(token, now = Date.now()) {
|
|
4220
|
+
return this.claimRenewalStmt.run({ token, at: now }).changes > 0;
|
|
4221
|
+
}
|
|
4222
|
+
/** Mark one token expired, so it stops being a delivery target. */
|
|
4223
|
+
expire(token, now = Date.now()) {
|
|
4224
|
+
this.expireStmt.run(now, token);
|
|
4225
|
+
}
|
|
4226
|
+
/**
|
|
4227
|
+
* Expire every live activity for a session.
|
|
4228
|
+
*
|
|
4229
|
+
* Called when the session ends. Without this, a per-activity token outlives
|
|
4230
|
+
* its session and a later renewal sweep would resurrect an activity for a
|
|
4231
|
+
* session that is already gone.
|
|
4232
|
+
*/
|
|
4233
|
+
expireSessionActivities(sessionId, now = Date.now()) {
|
|
4234
|
+
this.expireSessionActivitiesStmt.run({ session_id: sessionId, at: now });
|
|
4235
|
+
}
|
|
4236
|
+
/** Every token, including dead and revoked ones, for the health report. */
|
|
4237
|
+
listHealth(now = Date.now()) {
|
|
4238
|
+
return this.listAllStmt.all().map((r) => toHealth(r, now));
|
|
4239
|
+
}
|
|
4240
|
+
recordSuccess(token, now = Date.now()) {
|
|
4241
|
+
this.successStmt.run({ token, at: now });
|
|
4242
|
+
}
|
|
4243
|
+
recordFailure(token, code, now = Date.now()) {
|
|
4244
|
+
this.failureStmt.run({ token, at: now, code });
|
|
4245
|
+
}
|
|
4246
|
+
revoke(token, now = Date.now()) {
|
|
4247
|
+
return this.revokeStmt.run(now, token).changes > 0;
|
|
4248
|
+
}
|
|
4249
|
+
/**
|
|
4250
|
+
* Claim an event id for delivery.
|
|
4251
|
+
*
|
|
4252
|
+
* Returns true exactly once per event id. A retry, a reconnect
|
|
4253
|
+
* reconciliation, or two triggers firing for the same underlying event all
|
|
4254
|
+
* get false and must not notify — the user should never be told twice about
|
|
4255
|
+
* one thing.
|
|
4256
|
+
*/
|
|
4257
|
+
claimEvent(eventId, sessionId, now = Date.now()) {
|
|
4258
|
+
return this.claimEventStmt.run({ event_id: eventId, session_id: sessionId, created_at: now }).changes > 0;
|
|
4259
|
+
}
|
|
4260
|
+
markDelivered(eventId, now = Date.now()) {
|
|
4261
|
+
this.markDeliveredStmt.run(now, eventId);
|
|
4262
|
+
}
|
|
4263
|
+
};
|
|
4264
|
+
|
|
3943
4265
|
// src/api/routes/misc.routes.ts
|
|
4266
|
+
function numberOrNull(value) {
|
|
4267
|
+
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
|
4268
|
+
}
|
|
3944
4269
|
function readJsonBody(req) {
|
|
3945
4270
|
return new Promise((resolve2, reject) => {
|
|
3946
4271
|
const chunks = [];
|
|
@@ -3987,7 +4312,11 @@ var createMiscRoutes = (deps) => {
|
|
|
3987
4312
|
// Capability flag: this server serves /api/config/claude-flags. Additive —
|
|
3988
4313
|
// older clients ignore it, and clients talking to an older server see it
|
|
3989
4314
|
// absent and hide the UI rather than 404ing.
|
|
3990
|
-
claudeFlags: true
|
|
4315
|
+
claudeFlags: true,
|
|
4316
|
+
// Same contract: this server serves GET /api/config/feature-flags. Lives
|
|
4317
|
+
// here rather than behind /api/config (admin-only) so a read-only client
|
|
4318
|
+
// still learns the server supports flags even if it can't read values.
|
|
4319
|
+
featureFlags: true
|
|
3991
4320
|
});
|
|
3992
4321
|
});
|
|
3993
4322
|
app.get("/api/profiles", (c) => c.json([]));
|
|
@@ -4014,6 +4343,22 @@ var createMiscRoutes = (deps) => {
|
|
|
4014
4343
|
if (platform3 !== "ios" && platform3 !== "android") {
|
|
4015
4344
|
return c.json({ error: "platform must be 'ios' or 'android'" }, 400);
|
|
4016
4345
|
}
|
|
4346
|
+
const kind = body?.kind === void 0 ? DEFAULT_PUSH_TOKEN_KIND : body.kind;
|
|
4347
|
+
if (!isPushTokenKind(kind)) {
|
|
4348
|
+
return c.json(
|
|
4349
|
+
{ error: `kind must be one of ${PUSH_TOKEN_KINDS.join(", ")}`, code: "INVALID_KIND" },
|
|
4350
|
+
400
|
|
4351
|
+
);
|
|
4352
|
+
}
|
|
4353
|
+
if (kind === "liveactivity_update" && typeof body?.activityId !== "string") {
|
|
4354
|
+
return c.json(
|
|
4355
|
+
{
|
|
4356
|
+
error: "activityId is required for kind 'liveactivity_update'",
|
|
4357
|
+
code: "MISSING_ACTIVITY"
|
|
4358
|
+
},
|
|
4359
|
+
400
|
|
4360
|
+
);
|
|
4361
|
+
}
|
|
4017
4362
|
const repo = deps.pushRepo();
|
|
4018
4363
|
if (!repo) {
|
|
4019
4364
|
return c.json({ error: "Push registration is unavailable", code: "STORE_UNAVAILABLE" }, 503);
|
|
@@ -4021,7 +4366,13 @@ var createMiscRoutes = (deps) => {
|
|
|
4021
4366
|
repo.register({
|
|
4022
4367
|
token,
|
|
4023
4368
|
platform: platform3,
|
|
4024
|
-
deviceId: typeof body?.deviceId === "string" ? body.deviceId : null
|
|
4369
|
+
deviceId: typeof body?.deviceId === "string" ? body.deviceId : null,
|
|
4370
|
+
kind,
|
|
4371
|
+
activityId: typeof body?.activityId === "string" ? body.activityId : null,
|
|
4372
|
+
sessionId: typeof body?.sessionId === "string" ? body.sessionId : null,
|
|
4373
|
+
expiresAt: numberOrNull(body?.expiresAt),
|
|
4374
|
+
staleDate: numberOrNull(body?.staleDate),
|
|
4375
|
+
startedAt: numberOrNull(body?.startedAt)
|
|
4025
4376
|
});
|
|
4026
4377
|
return c.json({ ok: true });
|
|
4027
4378
|
});
|
|
@@ -6118,125 +6469,6 @@ function deriveNameFromPath(path) {
|
|
|
6118
6469
|
return parts.length > 0 ? parts[parts.length - 1] : null;
|
|
6119
6470
|
}
|
|
6120
6471
|
|
|
6121
|
-
// src/db/repositories/push.repository.ts
|
|
6122
|
-
var FAILURE_STREAK_LIMIT = 5;
|
|
6123
|
-
function tokenState(row) {
|
|
6124
|
-
if (row.revoked_at != null) return "revoked";
|
|
6125
|
-
if (row.failure_streak >= FAILURE_STREAK_LIMIT) return "dead";
|
|
6126
|
-
if (row.failure_streak > 0) return "failing";
|
|
6127
|
-
if (row.last_success_at == null) return "never-delivered";
|
|
6128
|
-
return "healthy";
|
|
6129
|
-
}
|
|
6130
|
-
function toHealth(row) {
|
|
6131
|
-
return {
|
|
6132
|
-
platform: row.platform,
|
|
6133
|
-
deviceId: row.device_id,
|
|
6134
|
-
registeredAt: row.registered_at,
|
|
6135
|
-
lastSuccessAt: row.last_success_at,
|
|
6136
|
-
lastFailureAt: row.last_failure_at,
|
|
6137
|
-
lastFailureCode: row.last_failure_code,
|
|
6138
|
-
failureStreak: row.failure_streak,
|
|
6139
|
-
revokedAt: row.revoked_at,
|
|
6140
|
-
state: tokenState(row)
|
|
6141
|
-
};
|
|
6142
|
-
}
|
|
6143
|
-
var PushRepository = class {
|
|
6144
|
-
upsertStmt;
|
|
6145
|
-
getStmt;
|
|
6146
|
-
listActiveStmt;
|
|
6147
|
-
listAllStmt;
|
|
6148
|
-
successStmt;
|
|
6149
|
-
failureStmt;
|
|
6150
|
-
revokeStmt;
|
|
6151
|
-
claimEventStmt;
|
|
6152
|
-
markDeliveredStmt;
|
|
6153
|
-
constructor(db) {
|
|
6154
|
-
this.upsertStmt = db.prepare(`
|
|
6155
|
-
INSERT INTO push_tokens (token, platform, device_id, registered_at)
|
|
6156
|
-
VALUES (@token, @platform, @device_id, @registered_at)
|
|
6157
|
-
ON CONFLICT(token) DO UPDATE SET
|
|
6158
|
-
platform = excluded.platform,
|
|
6159
|
-
device_id = COALESCE(excluded.device_id, push_tokens.device_id),
|
|
6160
|
-
registered_at = excluded.registered_at,
|
|
6161
|
-
-- A fresh registration clears prior failure state and any revocation:
|
|
6162
|
-
-- the client is telling us this token is live again.
|
|
6163
|
-
failure_streak = 0,
|
|
6164
|
-
last_failure_at = NULL,
|
|
6165
|
-
last_failure_code = NULL,
|
|
6166
|
-
revoked_at = NULL
|
|
6167
|
-
`);
|
|
6168
|
-
this.getStmt = db.prepare("SELECT * FROM push_tokens WHERE token = ?");
|
|
6169
|
-
this.listActiveStmt = db.prepare(`
|
|
6170
|
-
SELECT * FROM push_tokens
|
|
6171
|
-
WHERE revoked_at IS NULL AND failure_streak < ${FAILURE_STREAK_LIMIT}
|
|
6172
|
-
ORDER BY registered_at ASC
|
|
6173
|
-
`);
|
|
6174
|
-
this.listAllStmt = db.prepare("SELECT * FROM push_tokens ORDER BY registered_at ASC");
|
|
6175
|
-
this.successStmt = db.prepare(`
|
|
6176
|
-
UPDATE push_tokens
|
|
6177
|
-
SET last_success_at = @at, failure_streak = 0,
|
|
6178
|
-
last_failure_code = NULL
|
|
6179
|
-
WHERE token = @token
|
|
6180
|
-
`);
|
|
6181
|
-
this.failureStmt = db.prepare(`
|
|
6182
|
-
UPDATE push_tokens
|
|
6183
|
-
SET last_failure_at = @at, last_failure_code = @code,
|
|
6184
|
-
failure_streak = failure_streak + 1
|
|
6185
|
-
WHERE token = @token
|
|
6186
|
-
`);
|
|
6187
|
-
this.revokeStmt = db.prepare("UPDATE push_tokens SET revoked_at = ? WHERE token = ?");
|
|
6188
|
-
this.claimEventStmt = db.prepare(`
|
|
6189
|
-
INSERT OR IGNORE INTO push_events (event_id, session_id, created_at)
|
|
6190
|
-
VALUES (@event_id, @session_id, @created_at)
|
|
6191
|
-
`);
|
|
6192
|
-
this.markDeliveredStmt = db.prepare(
|
|
6193
|
-
"UPDATE push_events SET delivered_at = ? WHERE event_id = ?"
|
|
6194
|
-
);
|
|
6195
|
-
}
|
|
6196
|
-
register(args) {
|
|
6197
|
-
this.upsertStmt.run({
|
|
6198
|
-
token: args.token,
|
|
6199
|
-
platform: args.platform,
|
|
6200
|
-
device_id: args.deviceId ?? null,
|
|
6201
|
-
registered_at: args.now ?? Date.now()
|
|
6202
|
-
});
|
|
6203
|
-
}
|
|
6204
|
-
get(token) {
|
|
6205
|
-
return this.getStmt.get(token) ?? null;
|
|
6206
|
-
}
|
|
6207
|
-
/** Tokens eligible for delivery — not revoked, not past the failure limit. */
|
|
6208
|
-
listDeliverable() {
|
|
6209
|
-
return this.listActiveStmt.all();
|
|
6210
|
-
}
|
|
6211
|
-
/** Every token, including dead and revoked ones, for the health report. */
|
|
6212
|
-
listHealth() {
|
|
6213
|
-
return this.listAllStmt.all().map(toHealth);
|
|
6214
|
-
}
|
|
6215
|
-
recordSuccess(token, now = Date.now()) {
|
|
6216
|
-
this.successStmt.run({ token, at: now });
|
|
6217
|
-
}
|
|
6218
|
-
recordFailure(token, code, now = Date.now()) {
|
|
6219
|
-
this.failureStmt.run({ token, at: now, code });
|
|
6220
|
-
}
|
|
6221
|
-
revoke(token, now = Date.now()) {
|
|
6222
|
-
return this.revokeStmt.run(now, token).changes > 0;
|
|
6223
|
-
}
|
|
6224
|
-
/**
|
|
6225
|
-
* Claim an event id for delivery.
|
|
6226
|
-
*
|
|
6227
|
-
* Returns true exactly once per event id. A retry, a reconnect
|
|
6228
|
-
* reconciliation, or two triggers firing for the same underlying event all
|
|
6229
|
-
* get false and must not notify — the user should never be told twice about
|
|
6230
|
-
* one thing.
|
|
6231
|
-
*/
|
|
6232
|
-
claimEvent(eventId, sessionId, now = Date.now()) {
|
|
6233
|
-
return this.claimEventStmt.run({ event_id: eventId, session_id: sessionId, created_at: now }).changes > 0;
|
|
6234
|
-
}
|
|
6235
|
-
markDelivered(eventId, now = Date.now()) {
|
|
6236
|
-
this.markDeliveredStmt.run(now, eventId);
|
|
6237
|
-
}
|
|
6238
|
-
};
|
|
6239
|
-
|
|
6240
6472
|
// src/db/repositories/sessions.repository.ts
|
|
6241
6473
|
var SessionsRepository = class {
|
|
6242
6474
|
constructor(store) {
|
|
@@ -6469,10 +6701,10 @@ function fingerprintOf(ids) {
|
|
|
6469
6701
|
return `sha256:${createHash3("sha256").update(sorted.join("\n")).digest("hex")}`;
|
|
6470
6702
|
}
|
|
6471
6703
|
var CacheIntegrityMonitor = class {
|
|
6472
|
-
constructor(cache, wsHub,
|
|
6704
|
+
constructor(cache, wsHub, log7, cacheDir, rescan, runDuringReset) {
|
|
6473
6705
|
this.cache = cache;
|
|
6474
6706
|
this.wsHub = wsHub;
|
|
6475
|
-
this.log =
|
|
6707
|
+
this.log = log7;
|
|
6476
6708
|
this.cacheDir = cacheDir;
|
|
6477
6709
|
this.rescan = rescan;
|
|
6478
6710
|
this.runDuringReset = runDuringReset;
|
|
@@ -7085,6 +7317,577 @@ function deriveProjectChatTitle(input) {
|
|
|
7085
7317
|
return `Untitled \xB7 ${input.id.slice(0, 8)}`;
|
|
7086
7318
|
}
|
|
7087
7319
|
|
|
7320
|
+
// src/services/push/apnsClient.ts
|
|
7321
|
+
import { createSign } from "crypto";
|
|
7322
|
+
import { connect, constants } from "http2";
|
|
7323
|
+
var log3 = getLogger("apns");
|
|
7324
|
+
var APNS_HOST_SANDBOX = "api.sandbox.push.apple.com";
|
|
7325
|
+
var APNS_MAX_PAYLOAD_BYTES = 4096;
|
|
7326
|
+
var JWT_TTL_SECONDS = 3e3;
|
|
7327
|
+
var DEAD_TOKEN_REASONS = /* @__PURE__ */ new Set([
|
|
7328
|
+
"BadDeviceToken",
|
|
7329
|
+
"DeviceTokenNotForTopic",
|
|
7330
|
+
"Unregistered",
|
|
7331
|
+
"ExpiredToken"
|
|
7332
|
+
]);
|
|
7333
|
+
function base64url(input) {
|
|
7334
|
+
return Buffer.from(input).toString("base64url");
|
|
7335
|
+
}
|
|
7336
|
+
function readApnsCredentialsFromEnv(env = process.env) {
|
|
7337
|
+
const key = env.APNS_KEY;
|
|
7338
|
+
if (!key || key.trim().length === 0) return null;
|
|
7339
|
+
const keyId = env.APNS_KEY_ID?.trim();
|
|
7340
|
+
const teamId = env.APNS_TEAM_ID?.trim();
|
|
7341
|
+
const bundleId = env.APNS_BUNDLE_ID?.trim();
|
|
7342
|
+
if (!keyId || !teamId || !bundleId) return null;
|
|
7343
|
+
const host = env.APNS_HOST ?? APNS_HOST_SANDBOX;
|
|
7344
|
+
return { key, keyId, teamId, bundleId, host };
|
|
7345
|
+
}
|
|
7346
|
+
function describeMissingApnsCredentials(env = process.env) {
|
|
7347
|
+
if (!env.APNS_KEY || env.APNS_KEY.trim().length === 0) {
|
|
7348
|
+
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.";
|
|
7349
|
+
}
|
|
7350
|
+
const missing = [
|
|
7351
|
+
["APNS_KEY_ID", env.APNS_KEY_ID],
|
|
7352
|
+
["APNS_TEAM_ID", env.APNS_TEAM_ID],
|
|
7353
|
+
["APNS_BUNDLE_ID", env.APNS_BUNDLE_ID]
|
|
7354
|
+
].filter(([, value]) => !value || value.trim().length === 0).map(([name]) => name);
|
|
7355
|
+
if (missing.length === 0) return null;
|
|
7356
|
+
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.`;
|
|
7357
|
+
}
|
|
7358
|
+
var ApnsClient = class {
|
|
7359
|
+
constructor(creds) {
|
|
7360
|
+
this.creds = creds;
|
|
7361
|
+
}
|
|
7362
|
+
creds;
|
|
7363
|
+
session = null;
|
|
7364
|
+
cachedJwt = null;
|
|
7365
|
+
/**
|
|
7366
|
+
* The `apns-topic` for Live Activity pushes.
|
|
7367
|
+
*
|
|
7368
|
+
* The `.push-type.liveactivity` suffix is mandatory and is why the signing key
|
|
7369
|
+
* must be Team Scoped (All Topics) — a key scoped to the bundle id alone
|
|
7370
|
+
* cannot sign this topic.
|
|
7371
|
+
*/
|
|
7372
|
+
get topic() {
|
|
7373
|
+
return `${this.creds.bundleId}.push-type.liveactivity`;
|
|
7374
|
+
}
|
|
7375
|
+
/**
|
|
7376
|
+
* Mint or reuse the provider JWT.
|
|
7377
|
+
*
|
|
7378
|
+
* ES256 over the p8 key. Cached until shortly before expiry: Apple rejects a
|
|
7379
|
+
* token older than an hour, but minting one per request is wasteful and can
|
|
7380
|
+
* trip APNs' provider-token-update throttle.
|
|
7381
|
+
*/
|
|
7382
|
+
getJwt(now = Date.now()) {
|
|
7383
|
+
const nowSeconds = Math.floor(now / 1e3);
|
|
7384
|
+
if (this.cachedJwt && this.cachedJwt.expiresAt > nowSeconds + 60) {
|
|
7385
|
+
return this.cachedJwt.token;
|
|
7386
|
+
}
|
|
7387
|
+
const header = base64url(JSON.stringify({ alg: "ES256", kid: this.creds.keyId, typ: "JWT" }));
|
|
7388
|
+
const payload = base64url(JSON.stringify({ iss: this.creds.teamId, iat: nowSeconds }));
|
|
7389
|
+
const signingInput = `${header}.${payload}`;
|
|
7390
|
+
const signature = createSign("SHA256").update(signingInput).sign({ key: this.creds.key, dsaEncoding: "ieee-p1363" });
|
|
7391
|
+
const token = `${signingInput}.${base64url(signature)}`;
|
|
7392
|
+
this.cachedJwt = { token, expiresAt: nowSeconds + JWT_TTL_SECONDS };
|
|
7393
|
+
return token;
|
|
7394
|
+
}
|
|
7395
|
+
/**
|
|
7396
|
+
* Reuse one HTTP/2 session across sends.
|
|
7397
|
+
*
|
|
7398
|
+
* APNs expects a long-lived connection; a fresh TLS handshake per push is slow
|
|
7399
|
+
* and Apple treats connection churn as abuse.
|
|
7400
|
+
*/
|
|
7401
|
+
getSession() {
|
|
7402
|
+
if (this.session && !this.session.closed && !this.session.destroyed) {
|
|
7403
|
+
return this.session;
|
|
7404
|
+
}
|
|
7405
|
+
const session = connect(`https://${this.creds.host}`);
|
|
7406
|
+
session.on("error", (err) => {
|
|
7407
|
+
log3.warn("apns.session_error", { event: "apns.session_error", err: String(err) });
|
|
7408
|
+
});
|
|
7409
|
+
this.session = session;
|
|
7410
|
+
return session;
|
|
7411
|
+
}
|
|
7412
|
+
/**
|
|
7413
|
+
* Send one push.
|
|
7414
|
+
*
|
|
7415
|
+
* Resolves with a result rather than rejecting on an APNs rejection: a
|
|
7416
|
+
* rejected push is an expected outcome the caller must act on (retire the
|
|
7417
|
+
* token), not an exception. Only a genuinely unexpected local failure throws,
|
|
7418
|
+
* and the caller logs it.
|
|
7419
|
+
*/
|
|
7420
|
+
async send(args) {
|
|
7421
|
+
const body = Buffer.from(JSON.stringify(args.payload), "utf-8");
|
|
7422
|
+
if (body.byteLength > APNS_MAX_PAYLOAD_BYTES) {
|
|
7423
|
+
throw new Error(
|
|
7424
|
+
`APNs payload is ${body.byteLength} bytes, over the ${APNS_MAX_PAYLOAD_BYTES} byte limit`
|
|
7425
|
+
);
|
|
7426
|
+
}
|
|
7427
|
+
const session = this.getSession();
|
|
7428
|
+
const headers = {
|
|
7429
|
+
[constants.HTTP2_HEADER_METHOD]: "POST",
|
|
7430
|
+
[constants.HTTP2_HEADER_PATH]: `/3/device/${args.deviceToken}`,
|
|
7431
|
+
[constants.HTTP2_HEADER_AUTHORIZATION]: `bearer ${this.getJwt()}`,
|
|
7432
|
+
"apns-push-type": "liveactivity",
|
|
7433
|
+
"apns-topic": this.topic,
|
|
7434
|
+
"apns-priority": String(args.priority ?? 10),
|
|
7435
|
+
...args.expirationSeconds != null && {
|
|
7436
|
+
"apns-expiration": String(args.expirationSeconds)
|
|
7437
|
+
},
|
|
7438
|
+
[constants.HTTP2_HEADER_CONTENT_TYPE]: "application/json",
|
|
7439
|
+
[constants.HTTP2_HEADER_CONTENT_LENGTH]: String(body.byteLength)
|
|
7440
|
+
};
|
|
7441
|
+
return new Promise((resolve2, reject) => {
|
|
7442
|
+
const req = session.request(headers);
|
|
7443
|
+
req.setTimeout(args.timeoutMs ?? 1e4, () => {
|
|
7444
|
+
req.close(constants.NGHTTP2_CANCEL);
|
|
7445
|
+
resolve2({ ok: false, status: 0, reason: "Timeout", tokenDead: false });
|
|
7446
|
+
});
|
|
7447
|
+
let status = 0;
|
|
7448
|
+
req.on("response", (resHeaders) => {
|
|
7449
|
+
status = Number(resHeaders[constants.HTTP2_HEADER_STATUS] ?? 0);
|
|
7450
|
+
});
|
|
7451
|
+
const chunks = [];
|
|
7452
|
+
req.on("data", (chunk) => chunks.push(chunk));
|
|
7453
|
+
req.on("error", reject);
|
|
7454
|
+
req.on("end", () => {
|
|
7455
|
+
const raw = Buffer.concat(chunks).toString("utf-8");
|
|
7456
|
+
let reason;
|
|
7457
|
+
if (raw.length > 0) {
|
|
7458
|
+
try {
|
|
7459
|
+
reason = JSON.parse(raw).reason;
|
|
7460
|
+
} catch {
|
|
7461
|
+
reason = raw.slice(0, 200);
|
|
7462
|
+
}
|
|
7463
|
+
}
|
|
7464
|
+
resolve2({
|
|
7465
|
+
ok: status === 200,
|
|
7466
|
+
status,
|
|
7467
|
+
reason,
|
|
7468
|
+
tokenDead: reason != null && DEAD_TOKEN_REASONS.has(reason)
|
|
7469
|
+
});
|
|
7470
|
+
});
|
|
7471
|
+
req.end(body);
|
|
7472
|
+
});
|
|
7473
|
+
}
|
|
7474
|
+
/** Close the shared connection. Called on server shutdown. */
|
|
7475
|
+
close() {
|
|
7476
|
+
this.session?.close();
|
|
7477
|
+
this.session = null;
|
|
7478
|
+
}
|
|
7479
|
+
};
|
|
7480
|
+
|
|
7481
|
+
// src/services/push/liveActivityContentState.ts
|
|
7482
|
+
var LAST_OUTPUT_MAX_LENGTH = 90;
|
|
7483
|
+
function toLiveActivityStatus(status) {
|
|
7484
|
+
return status === "running" || status === "waiting_input" ? status : null;
|
|
7485
|
+
}
|
|
7486
|
+
function truncateLastOutput(raw) {
|
|
7487
|
+
const oneLine = raw.replace(/\s+/g, " ").trim();
|
|
7488
|
+
return oneLine.length <= LAST_OUTPUT_MAX_LENGTH ? oneLine : oneLine.slice(0, LAST_OUTPUT_MAX_LENGTH);
|
|
7489
|
+
}
|
|
7490
|
+
|
|
7491
|
+
// src/services/push/liveActivityNotifier.ts
|
|
7492
|
+
var log4 = getLogger("live-activity");
|
|
7493
|
+
function contentStateForSession(args) {
|
|
7494
|
+
const status = toLiveActivityStatus(args.session.status);
|
|
7495
|
+
if (!status) return null;
|
|
7496
|
+
return {
|
|
7497
|
+
sessionId: args.session.id,
|
|
7498
|
+
serverId: args.serverId,
|
|
7499
|
+
projectName: args.session.projectName,
|
|
7500
|
+
status,
|
|
7501
|
+
startedAt: args.startedAtOverride ?? args.session.startedAt.getTime(),
|
|
7502
|
+
lastOutput: truncateLastOutput(args.session.lastOutput ?? ""),
|
|
7503
|
+
...args.serverLabel != null && { serverLabel: args.serverLabel }
|
|
7504
|
+
};
|
|
7505
|
+
}
|
|
7506
|
+
var LiveActivityNotifier = class {
|
|
7507
|
+
constructor(sender, serverId, serverLabel) {
|
|
7508
|
+
this.sender = sender;
|
|
7509
|
+
this.serverId = serverId;
|
|
7510
|
+
this.serverLabel = serverLabel;
|
|
7511
|
+
}
|
|
7512
|
+
sender;
|
|
7513
|
+
serverId;
|
|
7514
|
+
serverLabel;
|
|
7515
|
+
/**
|
|
7516
|
+
* Last status pushed per session.
|
|
7517
|
+
*
|
|
7518
|
+
* Live Activity pushes are rate-limited by iOS and the surface only renders
|
|
7519
|
+
* `running` vs `waiting_input`, so re-pushing an unchanged status is pure
|
|
7520
|
+
* budget spend for no visible change. This is what makes the notifier
|
|
7521
|
+
* edge-triggered rather than level-triggered.
|
|
7522
|
+
*/
|
|
7523
|
+
lastPushed = /* @__PURE__ */ new Map();
|
|
7524
|
+
/**
|
|
7525
|
+
* React to a session status change.
|
|
7526
|
+
*
|
|
7527
|
+
* Fire-and-forget by design: a push must never delay or fail a session
|
|
7528
|
+
* transition, so this returns a promise the caller may ignore and every error
|
|
7529
|
+
* is logged rather than propagated.
|
|
7530
|
+
*/
|
|
7531
|
+
async onStatusChange(session) {
|
|
7532
|
+
const status = toLiveActivityStatus(session.status);
|
|
7533
|
+
try {
|
|
7534
|
+
if (!status) {
|
|
7535
|
+
await this.endFor(session);
|
|
7536
|
+
return;
|
|
7537
|
+
}
|
|
7538
|
+
if (this.lastPushed.get(session.id) === status) return;
|
|
7539
|
+
const contentState = contentStateForSession({
|
|
7540
|
+
session,
|
|
7541
|
+
serverId: this.serverId,
|
|
7542
|
+
serverLabel: this.serverLabel
|
|
7543
|
+
});
|
|
7544
|
+
if (!contentState) return;
|
|
7545
|
+
const outcome = await this.sender.send({
|
|
7546
|
+
sessionId: session.id,
|
|
7547
|
+
event: "update",
|
|
7548
|
+
contentState
|
|
7549
|
+
});
|
|
7550
|
+
this.lastPushed.set(session.id, status);
|
|
7551
|
+
if (outcome.attempted > 0) {
|
|
7552
|
+
log4.info("live_activity.updated", {
|
|
7553
|
+
event: "live_activity.updated",
|
|
7554
|
+
sessionId: session.id,
|
|
7555
|
+
status,
|
|
7556
|
+
...outcome
|
|
7557
|
+
});
|
|
7558
|
+
}
|
|
7559
|
+
} catch (err) {
|
|
7560
|
+
log4.error("live_activity.notify_failed", {
|
|
7561
|
+
event: "live_activity.notify_failed",
|
|
7562
|
+
sessionId: session.id,
|
|
7563
|
+
status: session.status,
|
|
7564
|
+
err: String(err)
|
|
7565
|
+
});
|
|
7566
|
+
}
|
|
7567
|
+
}
|
|
7568
|
+
async endFor(session) {
|
|
7569
|
+
const lastStatus = this.lastPushed.get(session.id);
|
|
7570
|
+
this.lastPushed.delete(session.id);
|
|
7571
|
+
const contentState = contentStateForSession({
|
|
7572
|
+
session: {
|
|
7573
|
+
...session,
|
|
7574
|
+
status: lastStatus === "waiting_input" ? "waiting_input" : "running"
|
|
7575
|
+
},
|
|
7576
|
+
serverId: this.serverId,
|
|
7577
|
+
serverLabel: this.serverLabel
|
|
7578
|
+
});
|
|
7579
|
+
if (!contentState) return;
|
|
7580
|
+
const outcome = await this.sender.end({ sessionId: session.id, contentState });
|
|
7581
|
+
if (outcome.attempted > 0) {
|
|
7582
|
+
log4.info("live_activity.ended", {
|
|
7583
|
+
event: "live_activity.ended",
|
|
7584
|
+
sessionId: session.id,
|
|
7585
|
+
...outcome
|
|
7586
|
+
});
|
|
7587
|
+
}
|
|
7588
|
+
}
|
|
7589
|
+
/** Drop cached state for a session, so a resume re-pushes its first status. */
|
|
7590
|
+
forget(sessionId) {
|
|
7591
|
+
this.lastPushed.delete(sessionId);
|
|
7592
|
+
}
|
|
7593
|
+
};
|
|
7594
|
+
|
|
7595
|
+
// src/services/push/liveActivitySender.ts
|
|
7596
|
+
var log5 = getLogger("live-activity");
|
|
7597
|
+
var ACTIVITY_MAX_LIFETIME_MS = 8 * 60 * 60 * 1e3;
|
|
7598
|
+
function buildActivityKitPayload(args) {
|
|
7599
|
+
return {
|
|
7600
|
+
aps: {
|
|
7601
|
+
timestamp: Math.floor(args.now / 1e3),
|
|
7602
|
+
event: args.event,
|
|
7603
|
+
"content-state": args.contentState,
|
|
7604
|
+
...args.staleDate != null && { "stale-date": Math.floor(args.staleDate / 1e3) },
|
|
7605
|
+
...args.dismissalDate != null && {
|
|
7606
|
+
"dismissal-date": Math.floor(args.dismissalDate / 1e3)
|
|
7607
|
+
}
|
|
7608
|
+
}
|
|
7609
|
+
};
|
|
7610
|
+
}
|
|
7611
|
+
var LiveActivitySender = class {
|
|
7612
|
+
constructor(apns, repo) {
|
|
7613
|
+
this.apns = apns;
|
|
7614
|
+
this.repo = repo;
|
|
7615
|
+
}
|
|
7616
|
+
apns;
|
|
7617
|
+
repo;
|
|
7618
|
+
/**
|
|
7619
|
+
* Push to every live activity of a session.
|
|
7620
|
+
*
|
|
7621
|
+
* Sends are independent: one rejected token must not stop the others, because
|
|
7622
|
+
* a single dead device would otherwise silence every other device watching the
|
|
7623
|
+
* same session.
|
|
7624
|
+
*/
|
|
7625
|
+
async send(args) {
|
|
7626
|
+
const now = args.now ?? Date.now();
|
|
7627
|
+
return this.sendToTokens({
|
|
7628
|
+
tokens: this.repo.listForSession("liveactivity_update", args.sessionId, now),
|
|
7629
|
+
sessionId: args.sessionId,
|
|
7630
|
+
event: args.event,
|
|
7631
|
+
contentState: args.contentState,
|
|
7632
|
+
now,
|
|
7633
|
+
priority: args.priority
|
|
7634
|
+
});
|
|
7635
|
+
}
|
|
7636
|
+
/**
|
|
7637
|
+
* Push to an explicit token list.
|
|
7638
|
+
*
|
|
7639
|
+
* Renewal needs this: a replacement activity does not exist yet, so it is
|
|
7640
|
+
* started via the app-wide push-to-start token rather than any per-session
|
|
7641
|
+
* lookup. Shares one fan-out body with `send()` so failure handling cannot
|
|
7642
|
+
* drift between the two paths.
|
|
7643
|
+
*/
|
|
7644
|
+
async sendToTokens(args) {
|
|
7645
|
+
const now = args.now ?? Date.now();
|
|
7646
|
+
const tokens = args.tokens;
|
|
7647
|
+
const outcome = {
|
|
7648
|
+
attempted: tokens.length,
|
|
7649
|
+
succeeded: 0,
|
|
7650
|
+
retired: 0
|
|
7651
|
+
};
|
|
7652
|
+
if (tokens.length === 0) return outcome;
|
|
7653
|
+
const results = await Promise.all(
|
|
7654
|
+
tokens.map(
|
|
7655
|
+
(row) => this.sendToToken(row, args.event, args.contentState, now, args.priority, args.staleDate)
|
|
7656
|
+
)
|
|
7657
|
+
);
|
|
7658
|
+
for (const { row, result, error } of results) {
|
|
7659
|
+
if (error) {
|
|
7660
|
+
log5.error("live_activity.send_failed", {
|
|
7661
|
+
event: "live_activity.send_failed",
|
|
7662
|
+
sessionId: args.sessionId,
|
|
7663
|
+
activityId: row.activity_id,
|
|
7664
|
+
apnsEvent: args.event,
|
|
7665
|
+
err: String(error)
|
|
7666
|
+
});
|
|
7667
|
+
this.repo.recordFailure(row.token, "SendError", now);
|
|
7668
|
+
continue;
|
|
7669
|
+
}
|
|
7670
|
+
if (!result) continue;
|
|
7671
|
+
if (result.ok) {
|
|
7672
|
+
this.repo.recordSuccess(row.token, now);
|
|
7673
|
+
outcome.succeeded += 1;
|
|
7674
|
+
continue;
|
|
7675
|
+
}
|
|
7676
|
+
this.repo.recordFailure(row.token, result.reason ?? `HTTP_${result.status}`, now);
|
|
7677
|
+
if (result.tokenDead) {
|
|
7678
|
+
this.repo.expire(row.token, now);
|
|
7679
|
+
outcome.retired += 1;
|
|
7680
|
+
}
|
|
7681
|
+
log5.warn("live_activity.send_rejected", {
|
|
7682
|
+
event: "live_activity.send_rejected",
|
|
7683
|
+
sessionId: args.sessionId,
|
|
7684
|
+
activityId: row.activity_id,
|
|
7685
|
+
apnsEvent: args.event,
|
|
7686
|
+
status: result.status,
|
|
7687
|
+
reason: result.reason,
|
|
7688
|
+
tokenDead: result.tokenDead
|
|
7689
|
+
});
|
|
7690
|
+
}
|
|
7691
|
+
return outcome;
|
|
7692
|
+
}
|
|
7693
|
+
/**
|
|
7694
|
+
* End every live activity for a session and stop tracking them.
|
|
7695
|
+
*
|
|
7696
|
+
* Expiring locally is what stops the renewal sweep from later resurrecting an
|
|
7697
|
+
* activity for a session that has already finished.
|
|
7698
|
+
*/
|
|
7699
|
+
async end(args) {
|
|
7700
|
+
const now = args.now ?? Date.now();
|
|
7701
|
+
const outcome = await this.send({
|
|
7702
|
+
sessionId: args.sessionId,
|
|
7703
|
+
event: "end",
|
|
7704
|
+
contentState: args.contentState,
|
|
7705
|
+
now
|
|
7706
|
+
});
|
|
7707
|
+
this.repo.expireSessionActivities(args.sessionId, now);
|
|
7708
|
+
return outcome;
|
|
7709
|
+
}
|
|
7710
|
+
async sendToToken(row, event, contentState, now, priority, staleDateOverride) {
|
|
7711
|
+
const staleDate = event === "update" ? staleDateOverride ?? row.stale_date ?? contentState.startedAt + ACTIVITY_MAX_LIFETIME_MS : null;
|
|
7712
|
+
try {
|
|
7713
|
+
const result = await this.apns.send({
|
|
7714
|
+
deviceToken: row.token,
|
|
7715
|
+
payload: buildActivityKitPayload({ event, contentState, now, staleDate }),
|
|
7716
|
+
priority
|
|
7717
|
+
});
|
|
7718
|
+
return { row, result };
|
|
7719
|
+
} catch (error) {
|
|
7720
|
+
return { row, error };
|
|
7721
|
+
}
|
|
7722
|
+
}
|
|
7723
|
+
};
|
|
7724
|
+
|
|
7725
|
+
// src/services/push/liveActivityRenewal.ts
|
|
7726
|
+
var log6 = getLogger("live-activity");
|
|
7727
|
+
var RENEWAL_LEAD_MS = 30 * 60 * 1e3;
|
|
7728
|
+
var MAX_TIMER_MS = 60 * 60 * 1e3;
|
|
7729
|
+
function renewalDueAt(row) {
|
|
7730
|
+
return row.stale_date == null ? null : row.stale_date - RENEWAL_LEAD_MS;
|
|
7731
|
+
}
|
|
7732
|
+
var LiveActivityRenewalScheduler = class {
|
|
7733
|
+
constructor(deps) {
|
|
7734
|
+
this.deps = deps;
|
|
7735
|
+
this.now = deps.now ?? (() => Date.now());
|
|
7736
|
+
}
|
|
7737
|
+
deps;
|
|
7738
|
+
timer = null;
|
|
7739
|
+
stopped = false;
|
|
7740
|
+
now;
|
|
7741
|
+
/**
|
|
7742
|
+
* Arm the scheduler from persisted state.
|
|
7743
|
+
*
|
|
7744
|
+
* Called on boot, which is what makes a renewal survive a restart: the
|
|
7745
|
+
* deadlines were never in memory to begin with.
|
|
7746
|
+
*/
|
|
7747
|
+
start() {
|
|
7748
|
+
this.stopped = false;
|
|
7749
|
+
void this.tick();
|
|
7750
|
+
}
|
|
7751
|
+
stop() {
|
|
7752
|
+
this.stopped = true;
|
|
7753
|
+
if (this.timer) {
|
|
7754
|
+
clearTimeout(this.timer);
|
|
7755
|
+
this.timer = null;
|
|
7756
|
+
}
|
|
7757
|
+
}
|
|
7758
|
+
/**
|
|
7759
|
+
* Renew everything due, then sleep until the next deadline.
|
|
7760
|
+
*
|
|
7761
|
+
* Re-reads from the DB every tick rather than caching a schedule in memory, so
|
|
7762
|
+
* an activity registered after boot is picked up without re-arming anything.
|
|
7763
|
+
*/
|
|
7764
|
+
async tick() {
|
|
7765
|
+
if (this.stopped) return;
|
|
7766
|
+
const now = this.now();
|
|
7767
|
+
try {
|
|
7768
|
+
for (const row of this.deps.repo.listRenewable()) {
|
|
7769
|
+
const dueAt = renewalDueAt(row);
|
|
7770
|
+
if (dueAt == null || dueAt > now) continue;
|
|
7771
|
+
await this.renew(row, now);
|
|
7772
|
+
}
|
|
7773
|
+
} catch (err) {
|
|
7774
|
+
log6.error("live_activity.renewal_sweep_failed", {
|
|
7775
|
+
event: "live_activity.renewal_sweep_failed",
|
|
7776
|
+
err: String(err)
|
|
7777
|
+
});
|
|
7778
|
+
}
|
|
7779
|
+
this.scheduleNext();
|
|
7780
|
+
}
|
|
7781
|
+
scheduleNext() {
|
|
7782
|
+
if (this.stopped) return;
|
|
7783
|
+
const now = this.now();
|
|
7784
|
+
const pending = this.deps.repo.listRenewable().map(renewalDueAt).filter((d) => d != null);
|
|
7785
|
+
const nextDue = pending.length > 0 ? Math.min(...pending) : now + MAX_TIMER_MS;
|
|
7786
|
+
const delay = Math.min(Math.max(nextDue - now, 0), MAX_TIMER_MS);
|
|
7787
|
+
this.timer = setTimeout(() => void this.tick(), delay);
|
|
7788
|
+
this.timer.unref?.();
|
|
7789
|
+
}
|
|
7790
|
+
/**
|
|
7791
|
+
* Renew one activity.
|
|
7792
|
+
*
|
|
7793
|
+
* Claims first: `claimRenewal()` succeeds exactly once per row, so a timer
|
|
7794
|
+
* re-armed after a restart mid-window cannot send a second time.
|
|
7795
|
+
*/
|
|
7796
|
+
async renew(row, now) {
|
|
7797
|
+
if (!row.session_id) return;
|
|
7798
|
+
const session = this.deps.sessionStore.getManaged(row.session_id);
|
|
7799
|
+
const status = session ? toLiveActivityStatus(session.status) : null;
|
|
7800
|
+
if (!session || !status) {
|
|
7801
|
+
this.deps.repo.claimRenewal(row.token, now);
|
|
7802
|
+
this.deps.repo.expire(row.token, now);
|
|
7803
|
+
log6.info("live_activity.renewal_skipped", {
|
|
7804
|
+
event: "live_activity.renewal_skipped",
|
|
7805
|
+
sessionId: row.session_id,
|
|
7806
|
+
activityId: row.activity_id,
|
|
7807
|
+
reason: session ? `status_${session.status}` : "session_gone"
|
|
7808
|
+
});
|
|
7809
|
+
return;
|
|
7810
|
+
}
|
|
7811
|
+
if (!this.deps.repo.claimRenewal(row.token, now)) {
|
|
7812
|
+
return;
|
|
7813
|
+
}
|
|
7814
|
+
const startedAt = row.started_at ?? session.startedAt.getTime();
|
|
7815
|
+
const contentState = {
|
|
7816
|
+
sessionId: session.id,
|
|
7817
|
+
serverId: this.deps.serverId,
|
|
7818
|
+
projectName: session.projectName,
|
|
7819
|
+
status,
|
|
7820
|
+
startedAt,
|
|
7821
|
+
lastOutput: session.lastOutput ?? "",
|
|
7822
|
+
...this.deps.serverLabel != null && { serverLabel: this.deps.serverLabel }
|
|
7823
|
+
};
|
|
7824
|
+
try {
|
|
7825
|
+
await this.deps.sender.send({
|
|
7826
|
+
sessionId: session.id,
|
|
7827
|
+
event: "end",
|
|
7828
|
+
contentState: { ...contentState, lastOutput: truncateLastOutput(contentState.lastOutput) },
|
|
7829
|
+
now
|
|
7830
|
+
});
|
|
7831
|
+
this.deps.repo.expire(row.token, now);
|
|
7832
|
+
const started = await this.startReplacement({
|
|
7833
|
+
sessionId: session.id,
|
|
7834
|
+
startedAt,
|
|
7835
|
+
now
|
|
7836
|
+
});
|
|
7837
|
+
log6.info("live_activity.renewed", {
|
|
7838
|
+
event: "live_activity.renewed",
|
|
7839
|
+
sessionId: session.id,
|
|
7840
|
+
activityId: row.activity_id,
|
|
7841
|
+
// Logged because a regression here is invisible on the server and only
|
|
7842
|
+
// shows up as a reset timer on someone's Lock Screen.
|
|
7843
|
+
startedAt,
|
|
7844
|
+
replacementRequested: started
|
|
7845
|
+
});
|
|
7846
|
+
} catch (err) {
|
|
7847
|
+
log6.error("live_activity.renewal_failed", {
|
|
7848
|
+
event: "live_activity.renewal_failed",
|
|
7849
|
+
sessionId: session.id,
|
|
7850
|
+
activityId: row.activity_id,
|
|
7851
|
+
err: String(err)
|
|
7852
|
+
});
|
|
7853
|
+
}
|
|
7854
|
+
}
|
|
7855
|
+
/**
|
|
7856
|
+
* Ask the device to start a replacement activity.
|
|
7857
|
+
*
|
|
7858
|
+
* Uses the app-wide push-to-start token, because the replacement does not
|
|
7859
|
+
* exist yet and therefore has no per-activity token. Returns false when the
|
|
7860
|
+
* device never registered one, which is not an error: the app simply cannot be
|
|
7861
|
+
* asked to start an activity remotely, and the next foreground WS update
|
|
7862
|
+
* recreates it.
|
|
7863
|
+
*/
|
|
7864
|
+
async startReplacement(args) {
|
|
7865
|
+
const starters = this.deps.repo.listByKind("liveactivity_start", args.now);
|
|
7866
|
+
if (starters.length === 0) return false;
|
|
7867
|
+
const session = this.deps.sessionStore.getManaged(args.sessionId);
|
|
7868
|
+
const status = session ? toLiveActivityStatus(session.status) : null;
|
|
7869
|
+
if (!session || !status) return false;
|
|
7870
|
+
await this.deps.sender.sendToTokens({
|
|
7871
|
+
tokens: starters,
|
|
7872
|
+
event: "update",
|
|
7873
|
+
sessionId: args.sessionId,
|
|
7874
|
+
contentState: {
|
|
7875
|
+
sessionId: session.id,
|
|
7876
|
+
serverId: this.deps.serverId,
|
|
7877
|
+
projectName: session.projectName,
|
|
7878
|
+
status,
|
|
7879
|
+
// Carried through unchanged — the whole point of the renewal.
|
|
7880
|
+
startedAt: args.startedAt,
|
|
7881
|
+
lastOutput: truncateLastOutput(session.lastOutput ?? ""),
|
|
7882
|
+
...this.deps.serverLabel != null && { serverLabel: this.deps.serverLabel }
|
|
7883
|
+
},
|
|
7884
|
+
now: args.now,
|
|
7885
|
+
staleDate: args.startedAt + ACTIVITY_MAX_LIFETIME_MS
|
|
7886
|
+
});
|
|
7887
|
+
return true;
|
|
7888
|
+
}
|
|
7889
|
+
};
|
|
7890
|
+
|
|
7088
7891
|
// src/services/questions/parseStatusLine.ts
|
|
7089
7892
|
var MODEL_RE = /(Opus|Sonnet|Haiku|Fable)\s+[\d.]+(?:\s*\([^)]*\))?/;
|
|
7090
7893
|
var EFFORT_RE = /●\s*([A-Za-z]+)\s*·\s*\/effort/;
|
|
@@ -8036,6 +8839,12 @@ var StreamerServer = class {
|
|
|
8036
8839
|
sessionInputAttempts = /* @__PURE__ */ new Map();
|
|
8037
8840
|
ptyGracePeriodMs;
|
|
8038
8841
|
defaultSystemPrompt;
|
|
8842
|
+
// Resolved once at boot; see src/feature-flags.ts. Total map — every registry
|
|
8843
|
+
// id is present, so indexing it never yields undefined.
|
|
8844
|
+
featureFlags;
|
|
8845
|
+
// Derived from featureFlags.codexSystemPrompt. Kept as its own field so the
|
|
8846
|
+
// read site in startFresh() is unchanged.
|
|
8847
|
+
codexSystemPromptEnabled;
|
|
8039
8848
|
defaultPermissionMode;
|
|
8040
8849
|
defaultModel;
|
|
8041
8850
|
defaultEffort;
|
|
@@ -8091,6 +8900,12 @@ var StreamerServer = class {
|
|
|
8091
8900
|
// Paired-device registry (C5). Null when the cache DB failed to open — auth
|
|
8092
8901
|
// then falls back to the shared API key alone, which is the pre-C5 behaviour.
|
|
8093
8902
|
devicesRepo = null;
|
|
8903
|
+
// Live Activity push (Feature 12). Null when APNS_KEY is unset — the ordinary
|
|
8904
|
+
// case on a dev machine and in CI, where the feature is simply off. Missing an
|
|
8905
|
+
// optional push credential must never stop the server from booting.
|
|
8906
|
+
apnsClient = null;
|
|
8907
|
+
liveActivityNotifier = null;
|
|
8908
|
+
liveActivityRenewal = null;
|
|
8094
8909
|
discoveryCache = null;
|
|
8095
8910
|
cacheDir;
|
|
8096
8911
|
tailSize;
|
|
@@ -8126,6 +8941,11 @@ var StreamerServer = class {
|
|
|
8126
8941
|
this.codexRoots = config.codexRoots ?? [join18(homedir9(), ".codex", "sessions")];
|
|
8127
8942
|
this.ptyGracePeriodMs = config.ptyGracePeriodMs ?? DEFAULT_PTY_GRACE_PERIOD_MS;
|
|
8128
8943
|
this.defaultSystemPrompt = config.defaultSystemPrompt ?? DEFAULT_SYSTEM_PROMPT;
|
|
8944
|
+
this.featureFlags = resolveFeatureFlags({ cli: config.featureFlags, yaml: loadFeatureFlags() });
|
|
8945
|
+
if (config.codexSystemPromptEnabled !== void 0) {
|
|
8946
|
+
this.featureFlags.codexSystemPrompt = config.codexSystemPromptEnabled;
|
|
8947
|
+
}
|
|
8948
|
+
this.codexSystemPromptEnabled = this.featureFlags.codexSystemPrompt;
|
|
8129
8949
|
this.defaultPermissionMode = config.defaultPermissionMode ?? loadDefaultPermissionMode() ?? "acceptEdits";
|
|
8130
8950
|
this.defaultModel = config.defaultModel ?? "sonnet";
|
|
8131
8951
|
this.defaultEffort = config.defaultEffort ?? "low";
|
|
@@ -8141,6 +8961,13 @@ var StreamerServer = class {
|
|
|
8141
8961
|
}, this.directoryDebounceMs);
|
|
8142
8962
|
this.includeAgents = parseIncludeAgentsEnv(process.env.THREADBASE_INCLUDE_AGENTS);
|
|
8143
8963
|
this.agentEntrypoints = parseAgentEntrypointsEnv(process.env.THREADBASE_AGENT_ENTRYPOINTS);
|
|
8964
|
+
const enabledFlags = nonDefaultFeatureFlags(this.featureFlags);
|
|
8965
|
+
if (enabledFlags.length > 0) {
|
|
8966
|
+
this.log.info(`Feature flags active: ${enabledFlags.join(", ")}`, {
|
|
8967
|
+
event: "config.feature_flags_active",
|
|
8968
|
+
flags: enabledFlags
|
|
8969
|
+
});
|
|
8970
|
+
}
|
|
8144
8971
|
const rawRoot = process.env.THREADBASE_BROWSE_ROOT ?? loadBrowseRoot() ?? config.browseRoot;
|
|
8145
8972
|
if (rawRoot) {
|
|
8146
8973
|
realpath2(rawRoot).then((resolved) => {
|
|
@@ -8334,6 +9161,7 @@ var StreamerServer = class {
|
|
|
8334
9161
|
if (resp) {
|
|
8335
9162
|
this.wsHub.broadcast({ type: "session_update", session: resp });
|
|
8336
9163
|
}
|
|
9164
|
+
void this.liveActivityNotifier?.onStatusChange(session);
|
|
8337
9165
|
this.sessionStatusBus.emit(`status:${session.id}`, session.status);
|
|
8338
9166
|
}
|
|
8339
9167
|
});
|
|
@@ -8368,6 +9196,7 @@ var StreamerServer = class {
|
|
|
8368
9196
|
logMenubarRequests: this.logMenubarRequests,
|
|
8369
9197
|
rotateApiKey: () => this.rotateApiKey(),
|
|
8370
9198
|
claudeFlagsConfig: () => this.getClaudeFlagsConfig(),
|
|
9199
|
+
featureFlagsConfig: () => this.getFeatureFlagsConfig(),
|
|
8371
9200
|
setClaudeFlagsConfig: (values, extraArgs) => this.setClaudeFlagsConfig(values, extraArgs),
|
|
8372
9201
|
publicUrl: this.publicUrl,
|
|
8373
9202
|
browseRoot: this.browseRoot,
|
|
@@ -8599,6 +9428,41 @@ var StreamerServer = class {
|
|
|
8599
9428
|
}
|
|
8600
9429
|
this.ptyGraceDeferCounts.delete(sessionId);
|
|
8601
9430
|
}
|
|
9431
|
+
/**
|
|
9432
|
+
* Bring up Live Activity push, if credentials are present (Feature 12).
|
|
9433
|
+
*
|
|
9434
|
+
* APNS_KEY absent is the ordinary case on a dev machine and in CI, so this
|
|
9435
|
+
* logs once at info and leaves the feature off rather than failing: the server
|
|
9436
|
+
* must not refuse to boot over a missing optional push credential.
|
|
9437
|
+
*
|
|
9438
|
+
* The key is read from the environment as PEM contents and never from a path
|
|
9439
|
+
* on disk; neither it nor any device token is ever logged.
|
|
9440
|
+
*/
|
|
9441
|
+
initLiveActivityPush(pushRepo) {
|
|
9442
|
+
const creds = readApnsCredentialsFromEnv();
|
|
9443
|
+
if (!creds) {
|
|
9444
|
+
const why = describeMissingApnsCredentials();
|
|
9445
|
+
if (why) this.log.info(why, { event: "live_activity.disabled" });
|
|
9446
|
+
return;
|
|
9447
|
+
}
|
|
9448
|
+
this.apnsClient = new ApnsClient(creds);
|
|
9449
|
+
const sender = new LiveActivitySender(this.apnsClient, pushRepo);
|
|
9450
|
+
const serverId = process.env.THREADBASE_INSTANCE_ID ?? hostname2();
|
|
9451
|
+
this.liveActivityNotifier = new LiveActivityNotifier(sender, serverId, hostname2());
|
|
9452
|
+
this.liveActivityRenewal = new LiveActivityRenewalScheduler({
|
|
9453
|
+
repo: pushRepo,
|
|
9454
|
+
sender,
|
|
9455
|
+
sessionStore: this.sessionStore,
|
|
9456
|
+
serverId,
|
|
9457
|
+
serverLabel: hostname2()
|
|
9458
|
+
});
|
|
9459
|
+
this.liveActivityRenewal.start();
|
|
9460
|
+
this.log.info("Live Activity push enabled", {
|
|
9461
|
+
event: "live_activity.enabled",
|
|
9462
|
+
host: creds.host,
|
|
9463
|
+
topic: `${creds.bundleId}.push-type.liveactivity`
|
|
9464
|
+
});
|
|
9465
|
+
}
|
|
8602
9466
|
/**
|
|
8603
9467
|
* Classify sessions left behind by previous streamer runs (C1 Phase 3a).
|
|
8604
9468
|
*
|
|
@@ -8908,6 +9772,7 @@ var StreamerServer = class {
|
|
|
8908
9772
|
this.cacheMetadataRepo = new CacheMetadataRepository(db);
|
|
8909
9773
|
this.pushRepo = new PushRepository(db);
|
|
8910
9774
|
this.devicesRepo = new DevicesRepository(db);
|
|
9775
|
+
this.initLiveActivityPush(this.pushRepo);
|
|
8911
9776
|
this.cacheMonitor = new CacheIntegrityMonitor(
|
|
8912
9777
|
this.cache,
|
|
8913
9778
|
this.wsHub,
|
|
@@ -9142,6 +10007,8 @@ var StreamerServer = class {
|
|
|
9142
10007
|
this.externalTails.clear();
|
|
9143
10008
|
this.wsHub.dispose();
|
|
9144
10009
|
this.pairTokens.dispose();
|
|
10010
|
+
this.liveActivityRenewal?.stop();
|
|
10011
|
+
this.apnsClient?.close();
|
|
9145
10012
|
if (this.dbPool) {
|
|
9146
10013
|
await this.dbPool.end();
|
|
9147
10014
|
}
|
|
@@ -9213,7 +10080,6 @@ var StreamerServer = class {
|
|
|
9213
10080
|
json(res, 400, { error: message });
|
|
9214
10081
|
return;
|
|
9215
10082
|
}
|
|
9216
|
-
const { hostname: hostname2 } = __require("os");
|
|
9217
10083
|
const ts = (/* @__PURE__ */ new Date()).toISOString();
|
|
9218
10084
|
this.log.info(`[pair] token exchanged from ${ip} at ${ts}`, {
|
|
9219
10085
|
event: "pair.token_exchanged",
|
|
@@ -9258,6 +10124,16 @@ var StreamerServer = class {
|
|
|
9258
10124
|
});
|
|
9259
10125
|
return { newKey, persisted };
|
|
9260
10126
|
}
|
|
10127
|
+
/**
|
|
10128
|
+
* The registry ships with the values so a client renders the list from one
|
|
10129
|
+
* round-trip, same as getClaudeFlagsConfig().
|
|
10130
|
+
*
|
|
10131
|
+
* Deliberately no `persisted` field: unlike claude-flags there is no PUT, and
|
|
10132
|
+
* the absence of that field is the signal that this endpoint is read-only.
|
|
10133
|
+
*/
|
|
10134
|
+
getFeatureFlagsConfig() {
|
|
10135
|
+
return { registry: FEATURE_FLAGS, values: this.featureFlags };
|
|
10136
|
+
}
|
|
9261
10137
|
getClaudeFlagsConfig() {
|
|
9262
10138
|
return {
|
|
9263
10139
|
registry: CLAUDE_FLAGS,
|
|
@@ -10970,12 +11846,13 @@ var StreamerServer = class {
|
|
|
10970
11846
|
BROWSE_SYSTEM_PROMPT(this.browseRoot),
|
|
10971
11847
|
typeof clientPrompt === "string" ? clientPrompt : null
|
|
10972
11848
|
].filter(Boolean);
|
|
11849
|
+
const includeSystemPrompt = provider !== CODEX_CLI_PROVIDER || this.codexSystemPromptEnabled;
|
|
10973
11850
|
try {
|
|
10974
11851
|
const session = await this.ptyManager.startFresh({
|
|
10975
11852
|
provider,
|
|
10976
11853
|
projectPath: resolvedPath,
|
|
10977
11854
|
projectName: body.projectName,
|
|
10978
|
-
systemPrompt: systemPromptParts.join("\n"),
|
|
11855
|
+
...includeSystemPrompt && { systemPrompt: systemPromptParts.join("\n") },
|
|
10979
11856
|
permissionMode: this.defaultPermissionMode,
|
|
10980
11857
|
claudeFlags: this.claudeFlags,
|
|
10981
11858
|
claudeExtraArgs: this.claudeExtraArgs,
|