@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/index.cjs CHANGED
@@ -488,6 +488,58 @@ function getLogger(component) {
488
488
  }
489
489
  var logger = build(baseLogger);
490
490
 
491
+ // src/feature-flags.ts
492
+ var FEATURE_FLAGS = [
493
+ {
494
+ id: "codexSystemPrompt",
495
+ 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.",
496
+ default: false,
497
+ env: "THREADBASE_FEATURE_CODEX_SYSTEM_PROMPT"
498
+ }
499
+ ];
500
+ function findFeatureFlag(id) {
501
+ return FEATURE_FLAGS.find((f) => f.id === id);
502
+ }
503
+ function parseBooleanEnv(raw) {
504
+ if (raw === void 0) return void 0;
505
+ const v = raw.trim().toLowerCase();
506
+ if (v === "") return false;
507
+ return !(v === "0" || v === "false" || v === "no" || v === "off");
508
+ }
509
+ function validateFeatureFlagValues(raw) {
510
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {};
511
+ const out = {};
512
+ const dropped = [];
513
+ for (const [id, value] of Object.entries(raw)) {
514
+ if (!findFeatureFlag(id) || typeof value !== "boolean") {
515
+ dropped.push(id);
516
+ continue;
517
+ }
518
+ out[id] = value;
519
+ }
520
+ if (dropped.length > 0) {
521
+ getLogger("feature-flags").warn(
522
+ `Ignoring unknown or non-boolean feature flags: ${dropped.join(", ")}`,
523
+ {
524
+ event: "config.feature_flags_dropped",
525
+ dropped
526
+ }
527
+ );
528
+ }
529
+ return out;
530
+ }
531
+ function resolveFeatureFlags(opts) {
532
+ const env = opts?.env ?? process.env;
533
+ const out = {};
534
+ for (const def of FEATURE_FLAGS) {
535
+ out[def.id] = parseBooleanEnv(env[def.env]) ?? opts?.cli?.[def.id] ?? opts?.yaml?.[def.id] ?? def.default;
536
+ }
537
+ return out;
538
+ }
539
+ function nonDefaultFeatureFlags(values) {
540
+ return FEATURE_FLAGS.filter((f) => values[f.id] !== f.default).map((f) => f.id);
541
+ }
542
+
491
543
  // src/auth.ts
492
544
  function configDir() {
493
545
  return process.env.THREADBASE_CONFIG_DIR ?? (0, import_path.join)((0, import_os.homedir)(), ".threadbase");
@@ -640,6 +692,21 @@ function setClaudeExtraArgs(text) {
640
692
  }
641
693
  setConfigValue("claude_extra_args", trimmed && trimmed.length > 0 ? trimmed : void 0);
642
694
  }
695
+ function loadFeatureFlags() {
696
+ try {
697
+ const content = (0, import_fs.readFileSync)(configFile(), "utf-8");
698
+ const match = content.match(/^feature_flags:\s*(.+)$/m);
699
+ if (!match?.[1]) return {};
700
+ return validateFeatureFlagValues(JSON.parse(match[1].trim()));
701
+ } catch (err) {
702
+ if (err.code !== "ENOENT") {
703
+ getLogger("auth").warn(`Ignoring unreadable feature_flags in server.yaml: ${String(err)}`, {
704
+ event: "config.feature_flags_parse_failed"
705
+ });
706
+ }
707
+ return {};
708
+ }
709
+ }
643
710
  function validatePublicUrl(raw) {
644
711
  let parsed;
645
712
  try {
@@ -3674,6 +3741,7 @@ function readRawBody3(req) {
3674
3741
  var createConfigRoutes = (deps) => {
3675
3742
  const app = new import_hono4.Hono();
3676
3743
  app.get("/claude-flags", (c) => c.json(deps.claudeFlagsConfig()));
3744
+ app.get("/feature-flags", (c) => c.json(deps.featureFlagsConfig()));
3677
3745
  app.put("/claude-flags", async (c) => {
3678
3746
  if (deps.localNoAuth) {
3679
3747
  return c.json({ error: "claude flag changes are disabled while localNoAuth is active" }, 403);
@@ -3979,7 +4047,264 @@ function loadUpdateConfig(opts = {}) {
3979
4047
  return UpdateConfigSchema.parse(parsed);
3980
4048
  }
3981
4049
 
4050
+ // src/db/repositories/push.repository.ts
4051
+ var FAILURE_STREAK_LIMIT = 5;
4052
+ var PUSH_TOKEN_KINDS = ["expo", "liveactivity_start", "liveactivity_update"];
4053
+ var DEFAULT_PUSH_TOKEN_KIND = "expo";
4054
+ function isPushTokenKind(value) {
4055
+ return typeof value === "string" && PUSH_TOKEN_KINDS.includes(value);
4056
+ }
4057
+ function tokenState(row, now = Date.now()) {
4058
+ if (row.revoked_at != null) return "revoked";
4059
+ if (row.expires_at != null && row.expires_at <= now) return "expired";
4060
+ if (row.failure_streak >= FAILURE_STREAK_LIMIT) return "dead";
4061
+ if (row.failure_streak > 0) return "failing";
4062
+ if (row.last_success_at == null) return "never-delivered";
4063
+ return "healthy";
4064
+ }
4065
+ function toHealth(row, now = Date.now()) {
4066
+ return {
4067
+ platform: row.platform,
4068
+ deviceId: row.device_id,
4069
+ registeredAt: row.registered_at,
4070
+ lastSuccessAt: row.last_success_at,
4071
+ lastFailureAt: row.last_failure_at,
4072
+ lastFailureCode: row.last_failure_code,
4073
+ failureStreak: row.failure_streak,
4074
+ revokedAt: row.revoked_at,
4075
+ state: tokenState(row, now),
4076
+ kind: row.kind,
4077
+ activityId: row.activity_id,
4078
+ sessionId: row.session_id,
4079
+ expiresAt: row.expires_at
4080
+ };
4081
+ }
4082
+ var PushRepository = class {
4083
+ upsertStmt;
4084
+ getStmt;
4085
+ listActiveStmt;
4086
+ listAllStmt;
4087
+ successStmt;
4088
+ failureStmt;
4089
+ revokeStmt;
4090
+ claimEventStmt;
4091
+ markDeliveredStmt;
4092
+ listByKindSessionStmt;
4093
+ listByKindStmt;
4094
+ listRenewableStmt;
4095
+ claimRenewalStmt;
4096
+ expireStmt;
4097
+ expireSessionActivitiesStmt;
4098
+ constructor(db) {
4099
+ this.upsertStmt = db.prepare(`
4100
+ INSERT INTO push_tokens (
4101
+ token, platform, device_id, registered_at,
4102
+ kind, activity_id, session_id, expires_at, stale_date, started_at
4103
+ )
4104
+ VALUES (
4105
+ @token, @platform, @device_id, @registered_at,
4106
+ @kind, @activity_id, @session_id, @expires_at, @stale_date, @started_at
4107
+ )
4108
+ ON CONFLICT(token) DO UPDATE SET
4109
+ platform = excluded.platform,
4110
+ device_id = COALESCE(excluded.device_id, push_tokens.device_id),
4111
+ registered_at = excluded.registered_at,
4112
+ kind = excluded.kind,
4113
+ activity_id = COALESCE(excluded.activity_id, push_tokens.activity_id),
4114
+ session_id = COALESCE(excluded.session_id, push_tokens.session_id),
4115
+ expires_at = excluded.expires_at,
4116
+ stale_date = excluded.stale_date,
4117
+ -- Preserve the ORIGINAL start across a re-registration. iOS renders its
4118
+ -- own ticking timer from started_at, so overwriting it with a fresh
4119
+ -- value visibly resets the user's elapsed time to zero.
4120
+ started_at = COALESCE(push_tokens.started_at, excluded.started_at),
4121
+ -- A fresh registration clears prior failure state and any revocation:
4122
+ -- the client is telling us this token is live again. renewed_at clears
4123
+ -- too \u2014 this is a new activity generation, so it is renewable again.
4124
+ failure_streak = 0,
4125
+ last_failure_at = NULL,
4126
+ last_failure_code = NULL,
4127
+ revoked_at = NULL,
4128
+ renewed_at = NULL
4129
+ `);
4130
+ this.getStmt = db.prepare("SELECT * FROM push_tokens WHERE token = ?");
4131
+ this.listActiveStmt = db.prepare(`
4132
+ SELECT * FROM push_tokens
4133
+ WHERE revoked_at IS NULL AND failure_streak < ${FAILURE_STREAK_LIMIT}
4134
+ AND kind = 'expo'
4135
+ ORDER BY registered_at ASC
4136
+ `);
4137
+ this.listAllStmt = db.prepare("SELECT * FROM push_tokens ORDER BY registered_at ASC");
4138
+ this.successStmt = db.prepare(`
4139
+ UPDATE push_tokens
4140
+ SET last_success_at = @at, failure_streak = 0,
4141
+ last_failure_code = NULL
4142
+ WHERE token = @token
4143
+ `);
4144
+ this.failureStmt = db.prepare(`
4145
+ UPDATE push_tokens
4146
+ SET last_failure_at = @at, last_failure_code = @code,
4147
+ failure_streak = failure_streak + 1
4148
+ WHERE token = @token
4149
+ `);
4150
+ this.revokeStmt = db.prepare("UPDATE push_tokens SET revoked_at = ? WHERE token = ?");
4151
+ this.listByKindSessionStmt = db.prepare(`
4152
+ SELECT * FROM push_tokens
4153
+ WHERE kind = @kind AND session_id = @session_id
4154
+ AND revoked_at IS NULL AND failure_streak < ${FAILURE_STREAK_LIMIT}
4155
+ AND (expires_at IS NULL OR expires_at > @now)
4156
+ ORDER BY registered_at ASC
4157
+ `);
4158
+ this.listByKindStmt = db.prepare(`
4159
+ SELECT * FROM push_tokens
4160
+ WHERE kind = @kind
4161
+ AND revoked_at IS NULL AND failure_streak < ${FAILURE_STREAK_LIMIT}
4162
+ AND (expires_at IS NULL OR expires_at > @now)
4163
+ ORDER BY registered_at ASC
4164
+ `);
4165
+ this.listRenewableStmt = db.prepare(`
4166
+ SELECT * FROM push_tokens
4167
+ WHERE kind = 'liveactivity_update'
4168
+ AND stale_date IS NOT NULL AND renewed_at IS NULL
4169
+ AND revoked_at IS NULL AND failure_streak < ${FAILURE_STREAK_LIMIT}
4170
+ ORDER BY stale_date ASC
4171
+ `);
4172
+ this.claimRenewalStmt = db.prepare(`
4173
+ UPDATE push_tokens SET renewed_at = @at
4174
+ WHERE token = @token AND renewed_at IS NULL
4175
+ `);
4176
+ this.expireStmt = db.prepare("UPDATE push_tokens SET expires_at = ? WHERE token = ?");
4177
+ this.expireSessionActivitiesStmt = db.prepare(`
4178
+ UPDATE push_tokens SET expires_at = @at
4179
+ WHERE session_id = @session_id AND kind = 'liveactivity_update'
4180
+ AND (expires_at IS NULL OR expires_at > @at)
4181
+ `);
4182
+ this.claimEventStmt = db.prepare(`
4183
+ INSERT OR IGNORE INTO push_events (event_id, session_id, created_at)
4184
+ VALUES (@event_id, @session_id, @created_at)
4185
+ `);
4186
+ this.markDeliveredStmt = db.prepare(
4187
+ "UPDATE push_events SET delivered_at = ? WHERE event_id = ?"
4188
+ );
4189
+ }
4190
+ /**
4191
+ * Register or refresh a token.
4192
+ *
4193
+ * `kind` defaults to Expo so a released client posting `{ token, platform }`
4194
+ * keeps working — tb-mobile cannot be force-updated, and every client
4195
+ * predating Live Activities is registering an Expo relay token.
4196
+ *
4197
+ * Several rows per device is normal and intended: a device runs one activity
4198
+ * per live session, each with its own update token. The token itself is the
4199
+ * primary key, so distinct activities never collide.
4200
+ */
4201
+ register(args) {
4202
+ this.upsertStmt.run({
4203
+ token: args.token,
4204
+ platform: args.platform,
4205
+ device_id: args.deviceId ?? null,
4206
+ registered_at: args.now ?? Date.now(),
4207
+ kind: args.kind ?? DEFAULT_PUSH_TOKEN_KIND,
4208
+ activity_id: args.activityId ?? null,
4209
+ session_id: args.sessionId ?? null,
4210
+ expires_at: args.expiresAt ?? null,
4211
+ stale_date: args.staleDate ?? null,
4212
+ started_at: args.startedAt ?? null
4213
+ });
4214
+ }
4215
+ get(token) {
4216
+ return this.getStmt.get(token) ?? null;
4217
+ }
4218
+ /**
4219
+ * Expo tokens eligible for delivery — not revoked, not past the failure limit.
4220
+ *
4221
+ * Deliberately Expo-only. ActivityKit tokens go over direct APNs with a
4222
+ * different topic and are rejected by Expo's relay, so the ordinary
4223
+ * notification fan-out must not see them.
4224
+ */
4225
+ listDeliverable() {
4226
+ return this.listActiveStmt.all();
4227
+ }
4228
+ /** Live-activity tokens for one session, eligible for delivery. */
4229
+ listForSession(kind, sessionId, now = Date.now()) {
4230
+ return this.listByKindSessionStmt.all({
4231
+ kind,
4232
+ session_id: sessionId,
4233
+ now
4234
+ });
4235
+ }
4236
+ /**
4237
+ * Every deliverable token of one kind.
4238
+ *
4239
+ * Used for push-to-start, which is app-wide rather than session-scoped: the
4240
+ * activity does not exist yet, so there is no per-activity token to look up.
4241
+ */
4242
+ listByKind(kind, now = Date.now()) {
4243
+ return this.listByKindStmt.all({ kind, now });
4244
+ }
4245
+ /** Unrenewed activities with a renewal deadline, soonest first. */
4246
+ listRenewable() {
4247
+ return this.listRenewableStmt.all();
4248
+ }
4249
+ /**
4250
+ * Claim a row for renewal.
4251
+ *
4252
+ * Returns true exactly once per row. A restart re-arms timers from the
4253
+ * persisted deadline, so the same renewal can be attempted twice; the loser
4254
+ * gets false and must not send. Doing this as a conditional UPDATE rather
4255
+ * than read-then-write avoids the race where both attempts observe
4256
+ * "not yet renewed".
4257
+ */
4258
+ claimRenewal(token, now = Date.now()) {
4259
+ return this.claimRenewalStmt.run({ token, at: now }).changes > 0;
4260
+ }
4261
+ /** Mark one token expired, so it stops being a delivery target. */
4262
+ expire(token, now = Date.now()) {
4263
+ this.expireStmt.run(now, token);
4264
+ }
4265
+ /**
4266
+ * Expire every live activity for a session.
4267
+ *
4268
+ * Called when the session ends. Without this, a per-activity token outlives
4269
+ * its session and a later renewal sweep would resurrect an activity for a
4270
+ * session that is already gone.
4271
+ */
4272
+ expireSessionActivities(sessionId, now = Date.now()) {
4273
+ this.expireSessionActivitiesStmt.run({ session_id: sessionId, at: now });
4274
+ }
4275
+ /** Every token, including dead and revoked ones, for the health report. */
4276
+ listHealth(now = Date.now()) {
4277
+ return this.listAllStmt.all().map((r) => toHealth(r, now));
4278
+ }
4279
+ recordSuccess(token, now = Date.now()) {
4280
+ this.successStmt.run({ token, at: now });
4281
+ }
4282
+ recordFailure(token, code, now = Date.now()) {
4283
+ this.failureStmt.run({ token, at: now, code });
4284
+ }
4285
+ revoke(token, now = Date.now()) {
4286
+ return this.revokeStmt.run(now, token).changes > 0;
4287
+ }
4288
+ /**
4289
+ * Claim an event id for delivery.
4290
+ *
4291
+ * Returns true exactly once per event id. A retry, a reconnect
4292
+ * reconciliation, or two triggers firing for the same underlying event all
4293
+ * get false and must not notify — the user should never be told twice about
4294
+ * one thing.
4295
+ */
4296
+ claimEvent(eventId, sessionId, now = Date.now()) {
4297
+ return this.claimEventStmt.run({ event_id: eventId, session_id: sessionId, created_at: now }).changes > 0;
4298
+ }
4299
+ markDelivered(eventId, now = Date.now()) {
4300
+ this.markDeliveredStmt.run(now, eventId);
4301
+ }
4302
+ };
4303
+
3982
4304
  // src/api/routes/misc.routes.ts
4305
+ function numberOrNull(value) {
4306
+ return typeof value === "number" && Number.isFinite(value) ? value : null;
4307
+ }
3983
4308
  function readJsonBody(req) {
3984
4309
  return new Promise((resolve2, reject) => {
3985
4310
  const chunks = [];
@@ -4026,7 +4351,11 @@ var createMiscRoutes = (deps) => {
4026
4351
  // Capability flag: this server serves /api/config/claude-flags. Additive —
4027
4352
  // older clients ignore it, and clients talking to an older server see it
4028
4353
  // absent and hide the UI rather than 404ing.
4029
- claudeFlags: true
4354
+ claudeFlags: true,
4355
+ // Same contract: this server serves GET /api/config/feature-flags. Lives
4356
+ // here rather than behind /api/config (admin-only) so a read-only client
4357
+ // still learns the server supports flags even if it can't read values.
4358
+ featureFlags: true
4030
4359
  });
4031
4360
  });
4032
4361
  app.get("/api/profiles", (c) => c.json([]));
@@ -4053,6 +4382,22 @@ var createMiscRoutes = (deps) => {
4053
4382
  if (platform3 !== "ios" && platform3 !== "android") {
4054
4383
  return c.json({ error: "platform must be 'ios' or 'android'" }, 400);
4055
4384
  }
4385
+ const kind = body?.kind === void 0 ? DEFAULT_PUSH_TOKEN_KIND : body.kind;
4386
+ if (!isPushTokenKind(kind)) {
4387
+ return c.json(
4388
+ { error: `kind must be one of ${PUSH_TOKEN_KINDS.join(", ")}`, code: "INVALID_KIND" },
4389
+ 400
4390
+ );
4391
+ }
4392
+ if (kind === "liveactivity_update" && typeof body?.activityId !== "string") {
4393
+ return c.json(
4394
+ {
4395
+ error: "activityId is required for kind 'liveactivity_update'",
4396
+ code: "MISSING_ACTIVITY"
4397
+ },
4398
+ 400
4399
+ );
4400
+ }
4056
4401
  const repo = deps.pushRepo();
4057
4402
  if (!repo) {
4058
4403
  return c.json({ error: "Push registration is unavailable", code: "STORE_UNAVAILABLE" }, 503);
@@ -4060,7 +4405,13 @@ var createMiscRoutes = (deps) => {
4060
4405
  repo.register({
4061
4406
  token,
4062
4407
  platform: platform3,
4063
- deviceId: typeof body?.deviceId === "string" ? body.deviceId : null
4408
+ deviceId: typeof body?.deviceId === "string" ? body.deviceId : null,
4409
+ kind,
4410
+ activityId: typeof body?.activityId === "string" ? body.activityId : null,
4411
+ sessionId: typeof body?.sessionId === "string" ? body.sessionId : null,
4412
+ expiresAt: numberOrNull(body?.expiresAt),
4413
+ staleDate: numberOrNull(body?.staleDate),
4414
+ startedAt: numberOrNull(body?.startedAt)
4064
4415
  });
4065
4416
  return c.json({ ok: true });
4066
4417
  });
@@ -6155,125 +6506,6 @@ function deriveNameFromPath(path) {
6155
6506
  return parts.length > 0 ? parts[parts.length - 1] : null;
6156
6507
  }
6157
6508
 
6158
- // src/db/repositories/push.repository.ts
6159
- var FAILURE_STREAK_LIMIT = 5;
6160
- function tokenState(row) {
6161
- if (row.revoked_at != null) return "revoked";
6162
- if (row.failure_streak >= FAILURE_STREAK_LIMIT) return "dead";
6163
- if (row.failure_streak > 0) return "failing";
6164
- if (row.last_success_at == null) return "never-delivered";
6165
- return "healthy";
6166
- }
6167
- function toHealth(row) {
6168
- return {
6169
- platform: row.platform,
6170
- deviceId: row.device_id,
6171
- registeredAt: row.registered_at,
6172
- lastSuccessAt: row.last_success_at,
6173
- lastFailureAt: row.last_failure_at,
6174
- lastFailureCode: row.last_failure_code,
6175
- failureStreak: row.failure_streak,
6176
- revokedAt: row.revoked_at,
6177
- state: tokenState(row)
6178
- };
6179
- }
6180
- var PushRepository = class {
6181
- upsertStmt;
6182
- getStmt;
6183
- listActiveStmt;
6184
- listAllStmt;
6185
- successStmt;
6186
- failureStmt;
6187
- revokeStmt;
6188
- claimEventStmt;
6189
- markDeliveredStmt;
6190
- constructor(db) {
6191
- this.upsertStmt = db.prepare(`
6192
- INSERT INTO push_tokens (token, platform, device_id, registered_at)
6193
- VALUES (@token, @platform, @device_id, @registered_at)
6194
- ON CONFLICT(token) DO UPDATE SET
6195
- platform = excluded.platform,
6196
- device_id = COALESCE(excluded.device_id, push_tokens.device_id),
6197
- registered_at = excluded.registered_at,
6198
- -- A fresh registration clears prior failure state and any revocation:
6199
- -- the client is telling us this token is live again.
6200
- failure_streak = 0,
6201
- last_failure_at = NULL,
6202
- last_failure_code = NULL,
6203
- revoked_at = NULL
6204
- `);
6205
- this.getStmt = db.prepare("SELECT * FROM push_tokens WHERE token = ?");
6206
- this.listActiveStmt = db.prepare(`
6207
- SELECT * FROM push_tokens
6208
- WHERE revoked_at IS NULL AND failure_streak < ${FAILURE_STREAK_LIMIT}
6209
- ORDER BY registered_at ASC
6210
- `);
6211
- this.listAllStmt = db.prepare("SELECT * FROM push_tokens ORDER BY registered_at ASC");
6212
- this.successStmt = db.prepare(`
6213
- UPDATE push_tokens
6214
- SET last_success_at = @at, failure_streak = 0,
6215
- last_failure_code = NULL
6216
- WHERE token = @token
6217
- `);
6218
- this.failureStmt = db.prepare(`
6219
- UPDATE push_tokens
6220
- SET last_failure_at = @at, last_failure_code = @code,
6221
- failure_streak = failure_streak + 1
6222
- WHERE token = @token
6223
- `);
6224
- this.revokeStmt = db.prepare("UPDATE push_tokens SET revoked_at = ? WHERE token = ?");
6225
- this.claimEventStmt = db.prepare(`
6226
- INSERT OR IGNORE INTO push_events (event_id, session_id, created_at)
6227
- VALUES (@event_id, @session_id, @created_at)
6228
- `);
6229
- this.markDeliveredStmt = db.prepare(
6230
- "UPDATE push_events SET delivered_at = ? WHERE event_id = ?"
6231
- );
6232
- }
6233
- register(args) {
6234
- this.upsertStmt.run({
6235
- token: args.token,
6236
- platform: args.platform,
6237
- device_id: args.deviceId ?? null,
6238
- registered_at: args.now ?? Date.now()
6239
- });
6240
- }
6241
- get(token) {
6242
- return this.getStmt.get(token) ?? null;
6243
- }
6244
- /** Tokens eligible for delivery — not revoked, not past the failure limit. */
6245
- listDeliverable() {
6246
- return this.listActiveStmt.all();
6247
- }
6248
- /** Every token, including dead and revoked ones, for the health report. */
6249
- listHealth() {
6250
- return this.listAllStmt.all().map(toHealth);
6251
- }
6252
- recordSuccess(token, now = Date.now()) {
6253
- this.successStmt.run({ token, at: now });
6254
- }
6255
- recordFailure(token, code, now = Date.now()) {
6256
- this.failureStmt.run({ token, at: now, code });
6257
- }
6258
- revoke(token, now = Date.now()) {
6259
- return this.revokeStmt.run(now, token).changes > 0;
6260
- }
6261
- /**
6262
- * Claim an event id for delivery.
6263
- *
6264
- * Returns true exactly once per event id. A retry, a reconnect
6265
- * reconciliation, or two triggers firing for the same underlying event all
6266
- * get false and must not notify — the user should never be told twice about
6267
- * one thing.
6268
- */
6269
- claimEvent(eventId, sessionId, now = Date.now()) {
6270
- return this.claimEventStmt.run({ event_id: eventId, session_id: sessionId, created_at: now }).changes > 0;
6271
- }
6272
- markDelivered(eventId, now = Date.now()) {
6273
- this.markDeliveredStmt.run(now, eventId);
6274
- }
6275
- };
6276
-
6277
6509
  // src/db/repositories/sessions.repository.ts
6278
6510
  var SessionsRepository = class {
6279
6511
  constructor(store) {
@@ -6506,10 +6738,10 @@ function fingerprintOf(ids) {
6506
6738
  return `sha256:${(0, import_crypto9.createHash)("sha256").update(sorted.join("\n")).digest("hex")}`;
6507
6739
  }
6508
6740
  var CacheIntegrityMonitor = class {
6509
- constructor(cache, wsHub, log3, cacheDir, rescan, runDuringReset) {
6741
+ constructor(cache, wsHub, log7, cacheDir, rescan, runDuringReset) {
6510
6742
  this.cache = cache;
6511
6743
  this.wsHub = wsHub;
6512
- this.log = log3;
6744
+ this.log = log7;
6513
6745
  this.cacheDir = cacheDir;
6514
6746
  this.rescan = rescan;
6515
6747
  this.runDuringReset = runDuringReset;
@@ -7122,6 +7354,577 @@ function deriveProjectChatTitle(input) {
7122
7354
  return `Untitled \xB7 ${input.id.slice(0, 8)}`;
7123
7355
  }
7124
7356
 
7357
+ // src/services/push/apnsClient.ts
7358
+ var import_node_crypto3 = require("crypto");
7359
+ var import_node_http2 = require("http2");
7360
+ var log3 = getLogger("apns");
7361
+ var APNS_HOST_SANDBOX = "api.sandbox.push.apple.com";
7362
+ var APNS_MAX_PAYLOAD_BYTES = 4096;
7363
+ var JWT_TTL_SECONDS = 3e3;
7364
+ var DEAD_TOKEN_REASONS = /* @__PURE__ */ new Set([
7365
+ "BadDeviceToken",
7366
+ "DeviceTokenNotForTopic",
7367
+ "Unregistered",
7368
+ "ExpiredToken"
7369
+ ]);
7370
+ function base64url(input) {
7371
+ return Buffer.from(input).toString("base64url");
7372
+ }
7373
+ function readApnsCredentialsFromEnv(env = process.env) {
7374
+ const key = env.APNS_KEY;
7375
+ if (!key || key.trim().length === 0) return null;
7376
+ const keyId = env.APNS_KEY_ID?.trim();
7377
+ const teamId = env.APNS_TEAM_ID?.trim();
7378
+ const bundleId = env.APNS_BUNDLE_ID?.trim();
7379
+ if (!keyId || !teamId || !bundleId) return null;
7380
+ const host = env.APNS_HOST ?? APNS_HOST_SANDBOX;
7381
+ return { key, keyId, teamId, bundleId, host };
7382
+ }
7383
+ function describeMissingApnsCredentials(env = process.env) {
7384
+ if (!env.APNS_KEY || env.APNS_KEY.trim().length === 0) {
7385
+ 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.";
7386
+ }
7387
+ const missing = [
7388
+ ["APNS_KEY_ID", env.APNS_KEY_ID],
7389
+ ["APNS_TEAM_ID", env.APNS_TEAM_ID],
7390
+ ["APNS_BUNDLE_ID", env.APNS_BUNDLE_ID]
7391
+ ].filter(([, value]) => !value || value.trim().length === 0).map(([name]) => name);
7392
+ if (missing.length === 0) return null;
7393
+ 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.`;
7394
+ }
7395
+ var ApnsClient = class {
7396
+ constructor(creds) {
7397
+ this.creds = creds;
7398
+ }
7399
+ creds;
7400
+ session = null;
7401
+ cachedJwt = null;
7402
+ /**
7403
+ * The `apns-topic` for Live Activity pushes.
7404
+ *
7405
+ * The `.push-type.liveactivity` suffix is mandatory and is why the signing key
7406
+ * must be Team Scoped (All Topics) — a key scoped to the bundle id alone
7407
+ * cannot sign this topic.
7408
+ */
7409
+ get topic() {
7410
+ return `${this.creds.bundleId}.push-type.liveactivity`;
7411
+ }
7412
+ /**
7413
+ * Mint or reuse the provider JWT.
7414
+ *
7415
+ * ES256 over the p8 key. Cached until shortly before expiry: Apple rejects a
7416
+ * token older than an hour, but minting one per request is wasteful and can
7417
+ * trip APNs' provider-token-update throttle.
7418
+ */
7419
+ getJwt(now = Date.now()) {
7420
+ const nowSeconds = Math.floor(now / 1e3);
7421
+ if (this.cachedJwt && this.cachedJwt.expiresAt > nowSeconds + 60) {
7422
+ return this.cachedJwt.token;
7423
+ }
7424
+ const header = base64url(JSON.stringify({ alg: "ES256", kid: this.creds.keyId, typ: "JWT" }));
7425
+ const payload = base64url(JSON.stringify({ iss: this.creds.teamId, iat: nowSeconds }));
7426
+ const signingInput = `${header}.${payload}`;
7427
+ const signature = (0, import_node_crypto3.createSign)("SHA256").update(signingInput).sign({ key: this.creds.key, dsaEncoding: "ieee-p1363" });
7428
+ const token = `${signingInput}.${base64url(signature)}`;
7429
+ this.cachedJwt = { token, expiresAt: nowSeconds + JWT_TTL_SECONDS };
7430
+ return token;
7431
+ }
7432
+ /**
7433
+ * Reuse one HTTP/2 session across sends.
7434
+ *
7435
+ * APNs expects a long-lived connection; a fresh TLS handshake per push is slow
7436
+ * and Apple treats connection churn as abuse.
7437
+ */
7438
+ getSession() {
7439
+ if (this.session && !this.session.closed && !this.session.destroyed) {
7440
+ return this.session;
7441
+ }
7442
+ const session = (0, import_node_http2.connect)(`https://${this.creds.host}`);
7443
+ session.on("error", (err) => {
7444
+ log3.warn("apns.session_error", { event: "apns.session_error", err: String(err) });
7445
+ });
7446
+ this.session = session;
7447
+ return session;
7448
+ }
7449
+ /**
7450
+ * Send one push.
7451
+ *
7452
+ * Resolves with a result rather than rejecting on an APNs rejection: a
7453
+ * rejected push is an expected outcome the caller must act on (retire the
7454
+ * token), not an exception. Only a genuinely unexpected local failure throws,
7455
+ * and the caller logs it.
7456
+ */
7457
+ async send(args) {
7458
+ const body = Buffer.from(JSON.stringify(args.payload), "utf-8");
7459
+ if (body.byteLength > APNS_MAX_PAYLOAD_BYTES) {
7460
+ throw new Error(
7461
+ `APNs payload is ${body.byteLength} bytes, over the ${APNS_MAX_PAYLOAD_BYTES} byte limit`
7462
+ );
7463
+ }
7464
+ const session = this.getSession();
7465
+ const headers = {
7466
+ [import_node_http2.constants.HTTP2_HEADER_METHOD]: "POST",
7467
+ [import_node_http2.constants.HTTP2_HEADER_PATH]: `/3/device/${args.deviceToken}`,
7468
+ [import_node_http2.constants.HTTP2_HEADER_AUTHORIZATION]: `bearer ${this.getJwt()}`,
7469
+ "apns-push-type": "liveactivity",
7470
+ "apns-topic": this.topic,
7471
+ "apns-priority": String(args.priority ?? 10),
7472
+ ...args.expirationSeconds != null && {
7473
+ "apns-expiration": String(args.expirationSeconds)
7474
+ },
7475
+ [import_node_http2.constants.HTTP2_HEADER_CONTENT_TYPE]: "application/json",
7476
+ [import_node_http2.constants.HTTP2_HEADER_CONTENT_LENGTH]: String(body.byteLength)
7477
+ };
7478
+ return new Promise((resolve2, reject) => {
7479
+ const req = session.request(headers);
7480
+ req.setTimeout(args.timeoutMs ?? 1e4, () => {
7481
+ req.close(import_node_http2.constants.NGHTTP2_CANCEL);
7482
+ resolve2({ ok: false, status: 0, reason: "Timeout", tokenDead: false });
7483
+ });
7484
+ let status = 0;
7485
+ req.on("response", (resHeaders) => {
7486
+ status = Number(resHeaders[import_node_http2.constants.HTTP2_HEADER_STATUS] ?? 0);
7487
+ });
7488
+ const chunks = [];
7489
+ req.on("data", (chunk) => chunks.push(chunk));
7490
+ req.on("error", reject);
7491
+ req.on("end", () => {
7492
+ const raw = Buffer.concat(chunks).toString("utf-8");
7493
+ let reason;
7494
+ if (raw.length > 0) {
7495
+ try {
7496
+ reason = JSON.parse(raw).reason;
7497
+ } catch {
7498
+ reason = raw.slice(0, 200);
7499
+ }
7500
+ }
7501
+ resolve2({
7502
+ ok: status === 200,
7503
+ status,
7504
+ reason,
7505
+ tokenDead: reason != null && DEAD_TOKEN_REASONS.has(reason)
7506
+ });
7507
+ });
7508
+ req.end(body);
7509
+ });
7510
+ }
7511
+ /** Close the shared connection. Called on server shutdown. */
7512
+ close() {
7513
+ this.session?.close();
7514
+ this.session = null;
7515
+ }
7516
+ };
7517
+
7518
+ // src/services/push/liveActivityContentState.ts
7519
+ var LAST_OUTPUT_MAX_LENGTH = 90;
7520
+ function toLiveActivityStatus(status) {
7521
+ return status === "running" || status === "waiting_input" ? status : null;
7522
+ }
7523
+ function truncateLastOutput(raw) {
7524
+ const oneLine = raw.replace(/\s+/g, " ").trim();
7525
+ return oneLine.length <= LAST_OUTPUT_MAX_LENGTH ? oneLine : oneLine.slice(0, LAST_OUTPUT_MAX_LENGTH);
7526
+ }
7527
+
7528
+ // src/services/push/liveActivityNotifier.ts
7529
+ var log4 = getLogger("live-activity");
7530
+ function contentStateForSession(args) {
7531
+ const status = toLiveActivityStatus(args.session.status);
7532
+ if (!status) return null;
7533
+ return {
7534
+ sessionId: args.session.id,
7535
+ serverId: args.serverId,
7536
+ projectName: args.session.projectName,
7537
+ status,
7538
+ startedAt: args.startedAtOverride ?? args.session.startedAt.getTime(),
7539
+ lastOutput: truncateLastOutput(args.session.lastOutput ?? ""),
7540
+ ...args.serverLabel != null && { serverLabel: args.serverLabel }
7541
+ };
7542
+ }
7543
+ var LiveActivityNotifier = class {
7544
+ constructor(sender, serverId, serverLabel) {
7545
+ this.sender = sender;
7546
+ this.serverId = serverId;
7547
+ this.serverLabel = serverLabel;
7548
+ }
7549
+ sender;
7550
+ serverId;
7551
+ serverLabel;
7552
+ /**
7553
+ * Last status pushed per session.
7554
+ *
7555
+ * Live Activity pushes are rate-limited by iOS and the surface only renders
7556
+ * `running` vs `waiting_input`, so re-pushing an unchanged status is pure
7557
+ * budget spend for no visible change. This is what makes the notifier
7558
+ * edge-triggered rather than level-triggered.
7559
+ */
7560
+ lastPushed = /* @__PURE__ */ new Map();
7561
+ /**
7562
+ * React to a session status change.
7563
+ *
7564
+ * Fire-and-forget by design: a push must never delay or fail a session
7565
+ * transition, so this returns a promise the caller may ignore and every error
7566
+ * is logged rather than propagated.
7567
+ */
7568
+ async onStatusChange(session) {
7569
+ const status = toLiveActivityStatus(session.status);
7570
+ try {
7571
+ if (!status) {
7572
+ await this.endFor(session);
7573
+ return;
7574
+ }
7575
+ if (this.lastPushed.get(session.id) === status) return;
7576
+ const contentState = contentStateForSession({
7577
+ session,
7578
+ serverId: this.serverId,
7579
+ serverLabel: this.serverLabel
7580
+ });
7581
+ if (!contentState) return;
7582
+ const outcome = await this.sender.send({
7583
+ sessionId: session.id,
7584
+ event: "update",
7585
+ contentState
7586
+ });
7587
+ this.lastPushed.set(session.id, status);
7588
+ if (outcome.attempted > 0) {
7589
+ log4.info("live_activity.updated", {
7590
+ event: "live_activity.updated",
7591
+ sessionId: session.id,
7592
+ status,
7593
+ ...outcome
7594
+ });
7595
+ }
7596
+ } catch (err) {
7597
+ log4.error("live_activity.notify_failed", {
7598
+ event: "live_activity.notify_failed",
7599
+ sessionId: session.id,
7600
+ status: session.status,
7601
+ err: String(err)
7602
+ });
7603
+ }
7604
+ }
7605
+ async endFor(session) {
7606
+ const lastStatus = this.lastPushed.get(session.id);
7607
+ this.lastPushed.delete(session.id);
7608
+ const contentState = contentStateForSession({
7609
+ session: {
7610
+ ...session,
7611
+ status: lastStatus === "waiting_input" ? "waiting_input" : "running"
7612
+ },
7613
+ serverId: this.serverId,
7614
+ serverLabel: this.serverLabel
7615
+ });
7616
+ if (!contentState) return;
7617
+ const outcome = await this.sender.end({ sessionId: session.id, contentState });
7618
+ if (outcome.attempted > 0) {
7619
+ log4.info("live_activity.ended", {
7620
+ event: "live_activity.ended",
7621
+ sessionId: session.id,
7622
+ ...outcome
7623
+ });
7624
+ }
7625
+ }
7626
+ /** Drop cached state for a session, so a resume re-pushes its first status. */
7627
+ forget(sessionId) {
7628
+ this.lastPushed.delete(sessionId);
7629
+ }
7630
+ };
7631
+
7632
+ // src/services/push/liveActivitySender.ts
7633
+ var log5 = getLogger("live-activity");
7634
+ var ACTIVITY_MAX_LIFETIME_MS = 8 * 60 * 60 * 1e3;
7635
+ function buildActivityKitPayload(args) {
7636
+ return {
7637
+ aps: {
7638
+ timestamp: Math.floor(args.now / 1e3),
7639
+ event: args.event,
7640
+ "content-state": args.contentState,
7641
+ ...args.staleDate != null && { "stale-date": Math.floor(args.staleDate / 1e3) },
7642
+ ...args.dismissalDate != null && {
7643
+ "dismissal-date": Math.floor(args.dismissalDate / 1e3)
7644
+ }
7645
+ }
7646
+ };
7647
+ }
7648
+ var LiveActivitySender = class {
7649
+ constructor(apns, repo) {
7650
+ this.apns = apns;
7651
+ this.repo = repo;
7652
+ }
7653
+ apns;
7654
+ repo;
7655
+ /**
7656
+ * Push to every live activity of a session.
7657
+ *
7658
+ * Sends are independent: one rejected token must not stop the others, because
7659
+ * a single dead device would otherwise silence every other device watching the
7660
+ * same session.
7661
+ */
7662
+ async send(args) {
7663
+ const now = args.now ?? Date.now();
7664
+ return this.sendToTokens({
7665
+ tokens: this.repo.listForSession("liveactivity_update", args.sessionId, now),
7666
+ sessionId: args.sessionId,
7667
+ event: args.event,
7668
+ contentState: args.contentState,
7669
+ now,
7670
+ priority: args.priority
7671
+ });
7672
+ }
7673
+ /**
7674
+ * Push to an explicit token list.
7675
+ *
7676
+ * Renewal needs this: a replacement activity does not exist yet, so it is
7677
+ * started via the app-wide push-to-start token rather than any per-session
7678
+ * lookup. Shares one fan-out body with `send()` so failure handling cannot
7679
+ * drift between the two paths.
7680
+ */
7681
+ async sendToTokens(args) {
7682
+ const now = args.now ?? Date.now();
7683
+ const tokens = args.tokens;
7684
+ const outcome = {
7685
+ attempted: tokens.length,
7686
+ succeeded: 0,
7687
+ retired: 0
7688
+ };
7689
+ if (tokens.length === 0) return outcome;
7690
+ const results = await Promise.all(
7691
+ tokens.map(
7692
+ (row) => this.sendToToken(row, args.event, args.contentState, now, args.priority, args.staleDate)
7693
+ )
7694
+ );
7695
+ for (const { row, result, error } of results) {
7696
+ if (error) {
7697
+ log5.error("live_activity.send_failed", {
7698
+ event: "live_activity.send_failed",
7699
+ sessionId: args.sessionId,
7700
+ activityId: row.activity_id,
7701
+ apnsEvent: args.event,
7702
+ err: String(error)
7703
+ });
7704
+ this.repo.recordFailure(row.token, "SendError", now);
7705
+ continue;
7706
+ }
7707
+ if (!result) continue;
7708
+ if (result.ok) {
7709
+ this.repo.recordSuccess(row.token, now);
7710
+ outcome.succeeded += 1;
7711
+ continue;
7712
+ }
7713
+ this.repo.recordFailure(row.token, result.reason ?? `HTTP_${result.status}`, now);
7714
+ if (result.tokenDead) {
7715
+ this.repo.expire(row.token, now);
7716
+ outcome.retired += 1;
7717
+ }
7718
+ log5.warn("live_activity.send_rejected", {
7719
+ event: "live_activity.send_rejected",
7720
+ sessionId: args.sessionId,
7721
+ activityId: row.activity_id,
7722
+ apnsEvent: args.event,
7723
+ status: result.status,
7724
+ reason: result.reason,
7725
+ tokenDead: result.tokenDead
7726
+ });
7727
+ }
7728
+ return outcome;
7729
+ }
7730
+ /**
7731
+ * End every live activity for a session and stop tracking them.
7732
+ *
7733
+ * Expiring locally is what stops the renewal sweep from later resurrecting an
7734
+ * activity for a session that has already finished.
7735
+ */
7736
+ async end(args) {
7737
+ const now = args.now ?? Date.now();
7738
+ const outcome = await this.send({
7739
+ sessionId: args.sessionId,
7740
+ event: "end",
7741
+ contentState: args.contentState,
7742
+ now
7743
+ });
7744
+ this.repo.expireSessionActivities(args.sessionId, now);
7745
+ return outcome;
7746
+ }
7747
+ async sendToToken(row, event, contentState, now, priority, staleDateOverride) {
7748
+ const staleDate = event === "update" ? staleDateOverride ?? row.stale_date ?? contentState.startedAt + ACTIVITY_MAX_LIFETIME_MS : null;
7749
+ try {
7750
+ const result = await this.apns.send({
7751
+ deviceToken: row.token,
7752
+ payload: buildActivityKitPayload({ event, contentState, now, staleDate }),
7753
+ priority
7754
+ });
7755
+ return { row, result };
7756
+ } catch (error) {
7757
+ return { row, error };
7758
+ }
7759
+ }
7760
+ };
7761
+
7762
+ // src/services/push/liveActivityRenewal.ts
7763
+ var log6 = getLogger("live-activity");
7764
+ var RENEWAL_LEAD_MS = 30 * 60 * 1e3;
7765
+ var MAX_TIMER_MS = 60 * 60 * 1e3;
7766
+ function renewalDueAt(row) {
7767
+ return row.stale_date == null ? null : row.stale_date - RENEWAL_LEAD_MS;
7768
+ }
7769
+ var LiveActivityRenewalScheduler = class {
7770
+ constructor(deps) {
7771
+ this.deps = deps;
7772
+ this.now = deps.now ?? (() => Date.now());
7773
+ }
7774
+ deps;
7775
+ timer = null;
7776
+ stopped = false;
7777
+ now;
7778
+ /**
7779
+ * Arm the scheduler from persisted state.
7780
+ *
7781
+ * Called on boot, which is what makes a renewal survive a restart: the
7782
+ * deadlines were never in memory to begin with.
7783
+ */
7784
+ start() {
7785
+ this.stopped = false;
7786
+ void this.tick();
7787
+ }
7788
+ stop() {
7789
+ this.stopped = true;
7790
+ if (this.timer) {
7791
+ clearTimeout(this.timer);
7792
+ this.timer = null;
7793
+ }
7794
+ }
7795
+ /**
7796
+ * Renew everything due, then sleep until the next deadline.
7797
+ *
7798
+ * Re-reads from the DB every tick rather than caching a schedule in memory, so
7799
+ * an activity registered after boot is picked up without re-arming anything.
7800
+ */
7801
+ async tick() {
7802
+ if (this.stopped) return;
7803
+ const now = this.now();
7804
+ try {
7805
+ for (const row of this.deps.repo.listRenewable()) {
7806
+ const dueAt = renewalDueAt(row);
7807
+ if (dueAt == null || dueAt > now) continue;
7808
+ await this.renew(row, now);
7809
+ }
7810
+ } catch (err) {
7811
+ log6.error("live_activity.renewal_sweep_failed", {
7812
+ event: "live_activity.renewal_sweep_failed",
7813
+ err: String(err)
7814
+ });
7815
+ }
7816
+ this.scheduleNext();
7817
+ }
7818
+ scheduleNext() {
7819
+ if (this.stopped) return;
7820
+ const now = this.now();
7821
+ const pending = this.deps.repo.listRenewable().map(renewalDueAt).filter((d) => d != null);
7822
+ const nextDue = pending.length > 0 ? Math.min(...pending) : now + MAX_TIMER_MS;
7823
+ const delay = Math.min(Math.max(nextDue - now, 0), MAX_TIMER_MS);
7824
+ this.timer = setTimeout(() => void this.tick(), delay);
7825
+ this.timer.unref?.();
7826
+ }
7827
+ /**
7828
+ * Renew one activity.
7829
+ *
7830
+ * Claims first: `claimRenewal()` succeeds exactly once per row, so a timer
7831
+ * re-armed after a restart mid-window cannot send a second time.
7832
+ */
7833
+ async renew(row, now) {
7834
+ if (!row.session_id) return;
7835
+ const session = this.deps.sessionStore.getManaged(row.session_id);
7836
+ const status = session ? toLiveActivityStatus(session.status) : null;
7837
+ if (!session || !status) {
7838
+ this.deps.repo.claimRenewal(row.token, now);
7839
+ this.deps.repo.expire(row.token, now);
7840
+ log6.info("live_activity.renewal_skipped", {
7841
+ event: "live_activity.renewal_skipped",
7842
+ sessionId: row.session_id,
7843
+ activityId: row.activity_id,
7844
+ reason: session ? `status_${session.status}` : "session_gone"
7845
+ });
7846
+ return;
7847
+ }
7848
+ if (!this.deps.repo.claimRenewal(row.token, now)) {
7849
+ return;
7850
+ }
7851
+ const startedAt = row.started_at ?? session.startedAt.getTime();
7852
+ const contentState = {
7853
+ sessionId: session.id,
7854
+ serverId: this.deps.serverId,
7855
+ projectName: session.projectName,
7856
+ status,
7857
+ startedAt,
7858
+ lastOutput: session.lastOutput ?? "",
7859
+ ...this.deps.serverLabel != null && { serverLabel: this.deps.serverLabel }
7860
+ };
7861
+ try {
7862
+ await this.deps.sender.send({
7863
+ sessionId: session.id,
7864
+ event: "end",
7865
+ contentState: { ...contentState, lastOutput: truncateLastOutput(contentState.lastOutput) },
7866
+ now
7867
+ });
7868
+ this.deps.repo.expire(row.token, now);
7869
+ const started = await this.startReplacement({
7870
+ sessionId: session.id,
7871
+ startedAt,
7872
+ now
7873
+ });
7874
+ log6.info("live_activity.renewed", {
7875
+ event: "live_activity.renewed",
7876
+ sessionId: session.id,
7877
+ activityId: row.activity_id,
7878
+ // Logged because a regression here is invisible on the server and only
7879
+ // shows up as a reset timer on someone's Lock Screen.
7880
+ startedAt,
7881
+ replacementRequested: started
7882
+ });
7883
+ } catch (err) {
7884
+ log6.error("live_activity.renewal_failed", {
7885
+ event: "live_activity.renewal_failed",
7886
+ sessionId: session.id,
7887
+ activityId: row.activity_id,
7888
+ err: String(err)
7889
+ });
7890
+ }
7891
+ }
7892
+ /**
7893
+ * Ask the device to start a replacement activity.
7894
+ *
7895
+ * Uses the app-wide push-to-start token, because the replacement does not
7896
+ * exist yet and therefore has no per-activity token. Returns false when the
7897
+ * device never registered one, which is not an error: the app simply cannot be
7898
+ * asked to start an activity remotely, and the next foreground WS update
7899
+ * recreates it.
7900
+ */
7901
+ async startReplacement(args) {
7902
+ const starters = this.deps.repo.listByKind("liveactivity_start", args.now);
7903
+ if (starters.length === 0) return false;
7904
+ const session = this.deps.sessionStore.getManaged(args.sessionId);
7905
+ const status = session ? toLiveActivityStatus(session.status) : null;
7906
+ if (!session || !status) return false;
7907
+ await this.deps.sender.sendToTokens({
7908
+ tokens: starters,
7909
+ event: "update",
7910
+ sessionId: args.sessionId,
7911
+ contentState: {
7912
+ sessionId: session.id,
7913
+ serverId: this.deps.serverId,
7914
+ projectName: session.projectName,
7915
+ status,
7916
+ // Carried through unchanged — the whole point of the renewal.
7917
+ startedAt: args.startedAt,
7918
+ lastOutput: truncateLastOutput(session.lastOutput ?? ""),
7919
+ ...this.deps.serverLabel != null && { serverLabel: this.deps.serverLabel }
7920
+ },
7921
+ now: args.now,
7922
+ staleDate: args.startedAt + ACTIVITY_MAX_LIFETIME_MS
7923
+ });
7924
+ return true;
7925
+ }
7926
+ };
7927
+
7125
7928
  // src/services/questions/parseStatusLine.ts
7126
7929
  var MODEL_RE = /(Opus|Sonnet|Haiku|Fable)\s+[\d.]+(?:\s*\([^)]*\))?/;
7127
7930
  var EFFORT_RE = /●\s*([A-Za-z]+)\s*·\s*\/effort/;
@@ -7804,13 +8607,13 @@ function hashPrefix(text) {
7804
8607
  }
7805
8608
 
7806
8609
  // src/utils/conversationEtag.ts
7807
- var import_node_crypto3 = require("crypto");
8610
+ var import_node_crypto4 = require("crypto");
7808
8611
  function computeConversationEtag({
7809
8612
  filePath,
7810
8613
  messageCount,
7811
8614
  timestamp: timestamp2
7812
8615
  }) {
7813
- const digest = (0, import_node_crypto3.createHash)("sha1").update(`${filePath}:${messageCount}:${timestamp2}`).digest("hex").slice(0, 16);
8616
+ const digest = (0, import_node_crypto4.createHash)("sha1").update(`${filePath}:${messageCount}:${timestamp2}`).digest("hex").slice(0, 16);
7814
8617
  return `"${digest}"`;
7815
8618
  }
7816
8619
 
@@ -8073,6 +8876,12 @@ var StreamerServer = class {
8073
8876
  sessionInputAttempts = /* @__PURE__ */ new Map();
8074
8877
  ptyGracePeriodMs;
8075
8878
  defaultSystemPrompt;
8879
+ // Resolved once at boot; see src/feature-flags.ts. Total map — every registry
8880
+ // id is present, so indexing it never yields undefined.
8881
+ featureFlags;
8882
+ // Derived from featureFlags.codexSystemPrompt. Kept as its own field so the
8883
+ // read site in startFresh() is unchanged.
8884
+ codexSystemPromptEnabled;
8076
8885
  defaultPermissionMode;
8077
8886
  defaultModel;
8078
8887
  defaultEffort;
@@ -8128,6 +8937,12 @@ var StreamerServer = class {
8128
8937
  // Paired-device registry (C5). Null when the cache DB failed to open — auth
8129
8938
  // then falls back to the shared API key alone, which is the pre-C5 behaviour.
8130
8939
  devicesRepo = null;
8940
+ // Live Activity push (Feature 12). Null when APNS_KEY is unset — the ordinary
8941
+ // case on a dev machine and in CI, where the feature is simply off. Missing an
8942
+ // optional push credential must never stop the server from booting.
8943
+ apnsClient = null;
8944
+ liveActivityNotifier = null;
8945
+ liveActivityRenewal = null;
8131
8946
  discoveryCache = null;
8132
8947
  cacheDir;
8133
8948
  tailSize;
@@ -8163,6 +8978,11 @@ var StreamerServer = class {
8163
8978
  this.codexRoots = config.codexRoots ?? [(0, import_path18.join)((0, import_os9.homedir)(), ".codex", "sessions")];
8164
8979
  this.ptyGracePeriodMs = config.ptyGracePeriodMs ?? DEFAULT_PTY_GRACE_PERIOD_MS;
8165
8980
  this.defaultSystemPrompt = config.defaultSystemPrompt ?? DEFAULT_SYSTEM_PROMPT;
8981
+ this.featureFlags = resolveFeatureFlags({ cli: config.featureFlags, yaml: loadFeatureFlags() });
8982
+ if (config.codexSystemPromptEnabled !== void 0) {
8983
+ this.featureFlags.codexSystemPrompt = config.codexSystemPromptEnabled;
8984
+ }
8985
+ this.codexSystemPromptEnabled = this.featureFlags.codexSystemPrompt;
8166
8986
  this.defaultPermissionMode = config.defaultPermissionMode ?? loadDefaultPermissionMode() ?? "acceptEdits";
8167
8987
  this.defaultModel = config.defaultModel ?? "sonnet";
8168
8988
  this.defaultEffort = config.defaultEffort ?? "low";
@@ -8178,6 +8998,13 @@ var StreamerServer = class {
8178
8998
  }, this.directoryDebounceMs);
8179
8999
  this.includeAgents = parseIncludeAgentsEnv(process.env.THREADBASE_INCLUDE_AGENTS);
8180
9000
  this.agentEntrypoints = parseAgentEntrypointsEnv(process.env.THREADBASE_AGENT_ENTRYPOINTS);
9001
+ const enabledFlags = nonDefaultFeatureFlags(this.featureFlags);
9002
+ if (enabledFlags.length > 0) {
9003
+ this.log.info(`Feature flags active: ${enabledFlags.join(", ")}`, {
9004
+ event: "config.feature_flags_active",
9005
+ flags: enabledFlags
9006
+ });
9007
+ }
8181
9008
  const rawRoot = process.env.THREADBASE_BROWSE_ROOT ?? loadBrowseRoot() ?? config.browseRoot;
8182
9009
  if (rawRoot) {
8183
9010
  (0, import_promises7.realpath)(rawRoot).then((resolved) => {
@@ -8371,6 +9198,7 @@ var StreamerServer = class {
8371
9198
  if (resp) {
8372
9199
  this.wsHub.broadcast({ type: "session_update", session: resp });
8373
9200
  }
9201
+ void this.liveActivityNotifier?.onStatusChange(session);
8374
9202
  this.sessionStatusBus.emit(`status:${session.id}`, session.status);
8375
9203
  }
8376
9204
  });
@@ -8405,6 +9233,7 @@ var StreamerServer = class {
8405
9233
  logMenubarRequests: this.logMenubarRequests,
8406
9234
  rotateApiKey: () => this.rotateApiKey(),
8407
9235
  claudeFlagsConfig: () => this.getClaudeFlagsConfig(),
9236
+ featureFlagsConfig: () => this.getFeatureFlagsConfig(),
8408
9237
  setClaudeFlagsConfig: (values, extraArgs) => this.setClaudeFlagsConfig(values, extraArgs),
8409
9238
  publicUrl: this.publicUrl,
8410
9239
  browseRoot: this.browseRoot,
@@ -8636,6 +9465,41 @@ var StreamerServer = class {
8636
9465
  }
8637
9466
  this.ptyGraceDeferCounts.delete(sessionId);
8638
9467
  }
9468
+ /**
9469
+ * Bring up Live Activity push, if credentials are present (Feature 12).
9470
+ *
9471
+ * APNS_KEY absent is the ordinary case on a dev machine and in CI, so this
9472
+ * logs once at info and leaves the feature off rather than failing: the server
9473
+ * must not refuse to boot over a missing optional push credential.
9474
+ *
9475
+ * The key is read from the environment as PEM contents and never from a path
9476
+ * on disk; neither it nor any device token is ever logged.
9477
+ */
9478
+ initLiveActivityPush(pushRepo) {
9479
+ const creds = readApnsCredentialsFromEnv();
9480
+ if (!creds) {
9481
+ const why = describeMissingApnsCredentials();
9482
+ if (why) this.log.info(why, { event: "live_activity.disabled" });
9483
+ return;
9484
+ }
9485
+ this.apnsClient = new ApnsClient(creds);
9486
+ const sender = new LiveActivitySender(this.apnsClient, pushRepo);
9487
+ const serverId = process.env.THREADBASE_INSTANCE_ID ?? (0, import_os9.hostname)();
9488
+ this.liveActivityNotifier = new LiveActivityNotifier(sender, serverId, (0, import_os9.hostname)());
9489
+ this.liveActivityRenewal = new LiveActivityRenewalScheduler({
9490
+ repo: pushRepo,
9491
+ sender,
9492
+ sessionStore: this.sessionStore,
9493
+ serverId,
9494
+ serverLabel: (0, import_os9.hostname)()
9495
+ });
9496
+ this.liveActivityRenewal.start();
9497
+ this.log.info("Live Activity push enabled", {
9498
+ event: "live_activity.enabled",
9499
+ host: creds.host,
9500
+ topic: `${creds.bundleId}.push-type.liveactivity`
9501
+ });
9502
+ }
8639
9503
  /**
8640
9504
  * Classify sessions left behind by previous streamer runs (C1 Phase 3a).
8641
9505
  *
@@ -8945,6 +9809,7 @@ var StreamerServer = class {
8945
9809
  this.cacheMetadataRepo = new CacheMetadataRepository(db);
8946
9810
  this.pushRepo = new PushRepository(db);
8947
9811
  this.devicesRepo = new DevicesRepository(db);
9812
+ this.initLiveActivityPush(this.pushRepo);
8948
9813
  this.cacheMonitor = new CacheIntegrityMonitor(
8949
9814
  this.cache,
8950
9815
  this.wsHub,
@@ -9179,6 +10044,8 @@ var StreamerServer = class {
9179
10044
  this.externalTails.clear();
9180
10045
  this.wsHub.dispose();
9181
10046
  this.pairTokens.dispose();
10047
+ this.liveActivityRenewal?.stop();
10048
+ this.apnsClient?.close();
9182
10049
  if (this.dbPool) {
9183
10050
  await this.dbPool.end();
9184
10051
  }
@@ -9250,7 +10117,6 @@ var StreamerServer = class {
9250
10117
  json(res, 400, { error: message });
9251
10118
  return;
9252
10119
  }
9253
- const { hostname: hostname2 } = require("os");
9254
10120
  const ts = (/* @__PURE__ */ new Date()).toISOString();
9255
10121
  this.log.info(`[pair] token exchanged from ${ip} at ${ts}`, {
9256
10122
  event: "pair.token_exchanged",
@@ -9273,7 +10139,7 @@ var StreamerServer = class {
9273
10139
  nonce: sealed.nonce,
9274
10140
  ephemeralPublicKey: sealed.ephemeralPublicKey,
9275
10141
  publicUrl: this.publicUrl,
9276
- machineName: hostname2(),
10142
+ machineName: (0, import_os9.hostname)(),
9277
10143
  ...device && {
9278
10144
  deviceId: device.deviceId,
9279
10145
  deviceToken: device.deviceToken,
@@ -9295,6 +10161,16 @@ var StreamerServer = class {
9295
10161
  });
9296
10162
  return { newKey, persisted };
9297
10163
  }
10164
+ /**
10165
+ * The registry ships with the values so a client renders the list from one
10166
+ * round-trip, same as getClaudeFlagsConfig().
10167
+ *
10168
+ * Deliberately no `persisted` field: unlike claude-flags there is no PUT, and
10169
+ * the absence of that field is the signal that this endpoint is read-only.
10170
+ */
10171
+ getFeatureFlagsConfig() {
10172
+ return { registry: FEATURE_FLAGS, values: this.featureFlags };
10173
+ }
9298
10174
  getClaudeFlagsConfig() {
9299
10175
  return {
9300
10176
  registry: CLAUDE_FLAGS,
@@ -11007,12 +11883,13 @@ var StreamerServer = class {
11007
11883
  BROWSE_SYSTEM_PROMPT(this.browseRoot),
11008
11884
  typeof clientPrompt === "string" ? clientPrompt : null
11009
11885
  ].filter(Boolean);
11886
+ const includeSystemPrompt = provider !== CODEX_CLI_PROVIDER || this.codexSystemPromptEnabled;
11010
11887
  try {
11011
11888
  const session = await this.ptyManager.startFresh({
11012
11889
  provider,
11013
11890
  projectPath: resolvedPath,
11014
11891
  projectName: body.projectName,
11015
- systemPrompt: systemPromptParts.join("\n"),
11892
+ ...includeSystemPrompt && { systemPrompt: systemPromptParts.join("\n") },
11016
11893
  permissionMode: this.defaultPermissionMode,
11017
11894
  claudeFlags: this.claudeFlags,
11018
11895
  claudeExtraArgs: this.claudeExtraArgs,