@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.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, log3, cacheDir, rescan, runDuringReset) {
6704
+ constructor(cache, wsHub, log7, cacheDir, rescan, runDuringReset) {
6473
6705
  this.cache = cache;
6474
6706
  this.wsHub = wsHub;
6475
- this.log = log3;
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,