@threadbase-sh/streamer 1.37.0 → 1.39.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
@@ -350,6 +350,10 @@ var DANGEROUS_PERMISSION_MODES = [
350
350
  function isDangerousPermissionMode(mode) {
351
351
  return DANGEROUS_PERMISSION_MODES.includes(mode);
352
352
  }
353
+ var EFFORT_LEVELS = ["low", "medium", "high", "xhigh", "max"];
354
+ function isEffortLevel(value) {
355
+ return typeof value === "string" && EFFORT_LEVELS.includes(value);
356
+ }
353
357
  var CLAUDE_FLAGS = [
354
358
  {
355
359
  id: "permissionMode",
@@ -361,9 +365,10 @@ var CLAUDE_FLAGS = [
361
365
  { id: "addDir", flag: "--add-dir", valueType: "list", risk: "elevated" },
362
366
  { id: "allowedTools", flag: "--allowedTools", valueType: "list", risk: "elevated" },
363
367
  { id: "disallowedTools", flag: "--disallowedTools", valueType: "list", risk: "low" },
364
- { id: "maxBudgetUsd", flag: "--max-budget-usd", valueType: "string", risk: "low" },
365
- { id: "fallbackModel", flag: "--fallback-model", valueType: "string", risk: "low" }
368
+ { id: "model", flag: "--model", valueType: "string", risk: "low" },
369
+ { id: "effort", flag: "--effort", valueType: "enum", enumValues: EFFORT_LEVELS, risk: "low" }
366
370
  ];
371
+ var SPAWN_POSITIONAL_FLAG_IDS = /* @__PURE__ */ new Set(["permissionMode", "model", "effort"]);
367
372
  function findFlag(id) {
368
373
  return CLAUDE_FLAGS.find((f) => f.id === id);
369
374
  }
@@ -428,7 +433,7 @@ function buildFlagArgs(values, extraArgs) {
428
433
  const args = [];
429
434
  const safe = validateFlagValues(values ?? {});
430
435
  for (const def of CLAUDE_FLAGS) {
431
- if (def.id === "permissionMode") continue;
436
+ if (SPAWN_POSITIONAL_FLAG_IDS.has(def.id)) continue;
432
437
  const value = safe[def.id];
433
438
  if (value === void 0) continue;
434
439
  if (def.valueType === "boolean") {
@@ -488,6 +493,58 @@ function getLogger(component) {
488
493
  }
489
494
  var logger = build(baseLogger);
490
495
 
496
+ // src/feature-flags.ts
497
+ var FEATURE_FLAGS = [
498
+ {
499
+ id: "codexSystemPrompt",
500
+ 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.",
501
+ default: false,
502
+ env: "THREADBASE_FEATURE_CODEX_SYSTEM_PROMPT"
503
+ }
504
+ ];
505
+ function findFeatureFlag(id) {
506
+ return FEATURE_FLAGS.find((f) => f.id === id);
507
+ }
508
+ function parseBooleanEnv(raw) {
509
+ if (raw === void 0) return void 0;
510
+ const v = raw.trim().toLowerCase();
511
+ if (v === "") return false;
512
+ return !(v === "0" || v === "false" || v === "no" || v === "off");
513
+ }
514
+ function validateFeatureFlagValues(raw) {
515
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {};
516
+ const out = {};
517
+ const dropped = [];
518
+ for (const [id, value] of Object.entries(raw)) {
519
+ if (!findFeatureFlag(id) || typeof value !== "boolean") {
520
+ dropped.push(id);
521
+ continue;
522
+ }
523
+ out[id] = value;
524
+ }
525
+ if (dropped.length > 0) {
526
+ getLogger("feature-flags").warn(
527
+ `Ignoring unknown or non-boolean feature flags: ${dropped.join(", ")}`,
528
+ {
529
+ event: "config.feature_flags_dropped",
530
+ dropped
531
+ }
532
+ );
533
+ }
534
+ return out;
535
+ }
536
+ function resolveFeatureFlags(opts) {
537
+ const env = opts?.env ?? process.env;
538
+ const out = {};
539
+ for (const def of FEATURE_FLAGS) {
540
+ out[def.id] = parseBooleanEnv(env[def.env]) ?? opts?.cli?.[def.id] ?? opts?.yaml?.[def.id] ?? def.default;
541
+ }
542
+ return out;
543
+ }
544
+ function nonDefaultFeatureFlags(values) {
545
+ return FEATURE_FLAGS.filter((f) => values[f.id] !== f.default).map((f) => f.id);
546
+ }
547
+
491
548
  // src/auth.ts
492
549
  function configDir() {
493
550
  return process.env.THREADBASE_CONFIG_DIR ?? (0, import_path.join)((0, import_os.homedir)(), ".threadbase");
@@ -640,6 +697,21 @@ function setClaudeExtraArgs(text) {
640
697
  }
641
698
  setConfigValue("claude_extra_args", trimmed && trimmed.length > 0 ? trimmed : void 0);
642
699
  }
700
+ function loadFeatureFlags() {
701
+ try {
702
+ const content = (0, import_fs.readFileSync)(configFile(), "utf-8");
703
+ const match = content.match(/^feature_flags:\s*(.+)$/m);
704
+ if (!match?.[1]) return {};
705
+ return validateFeatureFlagValues(JSON.parse(match[1].trim()));
706
+ } catch (err) {
707
+ if (err.code !== "ENOENT") {
708
+ getLogger("auth").warn(`Ignoring unreadable feature_flags in server.yaml: ${String(err)}`, {
709
+ event: "config.feature_flags_parse_failed"
710
+ });
711
+ }
712
+ return {};
713
+ }
714
+ }
643
715
  function validatePublicUrl(raw) {
644
716
  let parsed;
645
717
  try {
@@ -3674,6 +3746,7 @@ function readRawBody3(req) {
3674
3746
  var createConfigRoutes = (deps) => {
3675
3747
  const app = new import_hono4.Hono();
3676
3748
  app.get("/claude-flags", (c) => c.json(deps.claudeFlagsConfig()));
3749
+ app.get("/feature-flags", (c) => c.json(deps.featureFlagsConfig()));
3677
3750
  app.put("/claude-flags", async (c) => {
3678
3751
  if (deps.localNoAuth) {
3679
3752
  return c.json({ error: "claude flag changes are disabled while localNoAuth is active" }, 403);
@@ -3979,7 +4052,264 @@ function loadUpdateConfig(opts = {}) {
3979
4052
  return UpdateConfigSchema.parse(parsed);
3980
4053
  }
3981
4054
 
4055
+ // src/db/repositories/push.repository.ts
4056
+ var FAILURE_STREAK_LIMIT = 5;
4057
+ var PUSH_TOKEN_KINDS = ["expo", "liveactivity_start", "liveactivity_update"];
4058
+ var DEFAULT_PUSH_TOKEN_KIND = "expo";
4059
+ function isPushTokenKind(value) {
4060
+ return typeof value === "string" && PUSH_TOKEN_KINDS.includes(value);
4061
+ }
4062
+ function tokenState(row, now = Date.now()) {
4063
+ if (row.revoked_at != null) return "revoked";
4064
+ if (row.expires_at != null && row.expires_at <= now) return "expired";
4065
+ if (row.failure_streak >= FAILURE_STREAK_LIMIT) return "dead";
4066
+ if (row.failure_streak > 0) return "failing";
4067
+ if (row.last_success_at == null) return "never-delivered";
4068
+ return "healthy";
4069
+ }
4070
+ function toHealth(row, now = Date.now()) {
4071
+ return {
4072
+ platform: row.platform,
4073
+ deviceId: row.device_id,
4074
+ registeredAt: row.registered_at,
4075
+ lastSuccessAt: row.last_success_at,
4076
+ lastFailureAt: row.last_failure_at,
4077
+ lastFailureCode: row.last_failure_code,
4078
+ failureStreak: row.failure_streak,
4079
+ revokedAt: row.revoked_at,
4080
+ state: tokenState(row, now),
4081
+ kind: row.kind,
4082
+ activityId: row.activity_id,
4083
+ sessionId: row.session_id,
4084
+ expiresAt: row.expires_at
4085
+ };
4086
+ }
4087
+ var PushRepository = class {
4088
+ upsertStmt;
4089
+ getStmt;
4090
+ listActiveStmt;
4091
+ listAllStmt;
4092
+ successStmt;
4093
+ failureStmt;
4094
+ revokeStmt;
4095
+ claimEventStmt;
4096
+ markDeliveredStmt;
4097
+ listByKindSessionStmt;
4098
+ listByKindStmt;
4099
+ listRenewableStmt;
4100
+ claimRenewalStmt;
4101
+ expireStmt;
4102
+ expireSessionActivitiesStmt;
4103
+ constructor(db) {
4104
+ this.upsertStmt = db.prepare(`
4105
+ INSERT INTO push_tokens (
4106
+ token, platform, device_id, registered_at,
4107
+ kind, activity_id, session_id, expires_at, stale_date, started_at
4108
+ )
4109
+ VALUES (
4110
+ @token, @platform, @device_id, @registered_at,
4111
+ @kind, @activity_id, @session_id, @expires_at, @stale_date, @started_at
4112
+ )
4113
+ ON CONFLICT(token) DO UPDATE SET
4114
+ platform = excluded.platform,
4115
+ device_id = COALESCE(excluded.device_id, push_tokens.device_id),
4116
+ registered_at = excluded.registered_at,
4117
+ kind = excluded.kind,
4118
+ activity_id = COALESCE(excluded.activity_id, push_tokens.activity_id),
4119
+ session_id = COALESCE(excluded.session_id, push_tokens.session_id),
4120
+ expires_at = excluded.expires_at,
4121
+ stale_date = excluded.stale_date,
4122
+ -- Preserve the ORIGINAL start across a re-registration. iOS renders its
4123
+ -- own ticking timer from started_at, so overwriting it with a fresh
4124
+ -- value visibly resets the user's elapsed time to zero.
4125
+ started_at = COALESCE(push_tokens.started_at, excluded.started_at),
4126
+ -- A fresh registration clears prior failure state and any revocation:
4127
+ -- the client is telling us this token is live again. renewed_at clears
4128
+ -- too \u2014 this is a new activity generation, so it is renewable again.
4129
+ failure_streak = 0,
4130
+ last_failure_at = NULL,
4131
+ last_failure_code = NULL,
4132
+ revoked_at = NULL,
4133
+ renewed_at = NULL
4134
+ `);
4135
+ this.getStmt = db.prepare("SELECT * FROM push_tokens WHERE token = ?");
4136
+ this.listActiveStmt = db.prepare(`
4137
+ SELECT * FROM push_tokens
4138
+ WHERE revoked_at IS NULL AND failure_streak < ${FAILURE_STREAK_LIMIT}
4139
+ AND kind = 'expo'
4140
+ ORDER BY registered_at ASC
4141
+ `);
4142
+ this.listAllStmt = db.prepare("SELECT * FROM push_tokens ORDER BY registered_at ASC");
4143
+ this.successStmt = db.prepare(`
4144
+ UPDATE push_tokens
4145
+ SET last_success_at = @at, failure_streak = 0,
4146
+ last_failure_code = NULL
4147
+ WHERE token = @token
4148
+ `);
4149
+ this.failureStmt = db.prepare(`
4150
+ UPDATE push_tokens
4151
+ SET last_failure_at = @at, last_failure_code = @code,
4152
+ failure_streak = failure_streak + 1
4153
+ WHERE token = @token
4154
+ `);
4155
+ this.revokeStmt = db.prepare("UPDATE push_tokens SET revoked_at = ? WHERE token = ?");
4156
+ this.listByKindSessionStmt = db.prepare(`
4157
+ SELECT * FROM push_tokens
4158
+ WHERE kind = @kind AND session_id = @session_id
4159
+ AND revoked_at IS NULL AND failure_streak < ${FAILURE_STREAK_LIMIT}
4160
+ AND (expires_at IS NULL OR expires_at > @now)
4161
+ ORDER BY registered_at ASC
4162
+ `);
4163
+ this.listByKindStmt = db.prepare(`
4164
+ SELECT * FROM push_tokens
4165
+ WHERE kind = @kind
4166
+ AND revoked_at IS NULL AND failure_streak < ${FAILURE_STREAK_LIMIT}
4167
+ AND (expires_at IS NULL OR expires_at > @now)
4168
+ ORDER BY registered_at ASC
4169
+ `);
4170
+ this.listRenewableStmt = db.prepare(`
4171
+ SELECT * FROM push_tokens
4172
+ WHERE kind = 'liveactivity_update'
4173
+ AND stale_date IS NOT NULL AND renewed_at IS NULL
4174
+ AND revoked_at IS NULL AND failure_streak < ${FAILURE_STREAK_LIMIT}
4175
+ ORDER BY stale_date ASC
4176
+ `);
4177
+ this.claimRenewalStmt = db.prepare(`
4178
+ UPDATE push_tokens SET renewed_at = @at
4179
+ WHERE token = @token AND renewed_at IS NULL
4180
+ `);
4181
+ this.expireStmt = db.prepare("UPDATE push_tokens SET expires_at = ? WHERE token = ?");
4182
+ this.expireSessionActivitiesStmt = db.prepare(`
4183
+ UPDATE push_tokens SET expires_at = @at
4184
+ WHERE session_id = @session_id AND kind = 'liveactivity_update'
4185
+ AND (expires_at IS NULL OR expires_at > @at)
4186
+ `);
4187
+ this.claimEventStmt = db.prepare(`
4188
+ INSERT OR IGNORE INTO push_events (event_id, session_id, created_at)
4189
+ VALUES (@event_id, @session_id, @created_at)
4190
+ `);
4191
+ this.markDeliveredStmt = db.prepare(
4192
+ "UPDATE push_events SET delivered_at = ? WHERE event_id = ?"
4193
+ );
4194
+ }
4195
+ /**
4196
+ * Register or refresh a token.
4197
+ *
4198
+ * `kind` defaults to Expo so a released client posting `{ token, platform }`
4199
+ * keeps working — tb-mobile cannot be force-updated, and every client
4200
+ * predating Live Activities is registering an Expo relay token.
4201
+ *
4202
+ * Several rows per device is normal and intended: a device runs one activity
4203
+ * per live session, each with its own update token. The token itself is the
4204
+ * primary key, so distinct activities never collide.
4205
+ */
4206
+ register(args) {
4207
+ this.upsertStmt.run({
4208
+ token: args.token,
4209
+ platform: args.platform,
4210
+ device_id: args.deviceId ?? null,
4211
+ registered_at: args.now ?? Date.now(),
4212
+ kind: args.kind ?? DEFAULT_PUSH_TOKEN_KIND,
4213
+ activity_id: args.activityId ?? null,
4214
+ session_id: args.sessionId ?? null,
4215
+ expires_at: args.expiresAt ?? null,
4216
+ stale_date: args.staleDate ?? null,
4217
+ started_at: args.startedAt ?? null
4218
+ });
4219
+ }
4220
+ get(token) {
4221
+ return this.getStmt.get(token) ?? null;
4222
+ }
4223
+ /**
4224
+ * Expo tokens eligible for delivery — not revoked, not past the failure limit.
4225
+ *
4226
+ * Deliberately Expo-only. ActivityKit tokens go over direct APNs with a
4227
+ * different topic and are rejected by Expo's relay, so the ordinary
4228
+ * notification fan-out must not see them.
4229
+ */
4230
+ listDeliverable() {
4231
+ return this.listActiveStmt.all();
4232
+ }
4233
+ /** Live-activity tokens for one session, eligible for delivery. */
4234
+ listForSession(kind, sessionId, now = Date.now()) {
4235
+ return this.listByKindSessionStmt.all({
4236
+ kind,
4237
+ session_id: sessionId,
4238
+ now
4239
+ });
4240
+ }
4241
+ /**
4242
+ * Every deliverable token of one kind.
4243
+ *
4244
+ * Used for push-to-start, which is app-wide rather than session-scoped: the
4245
+ * activity does not exist yet, so there is no per-activity token to look up.
4246
+ */
4247
+ listByKind(kind, now = Date.now()) {
4248
+ return this.listByKindStmt.all({ kind, now });
4249
+ }
4250
+ /** Unrenewed activities with a renewal deadline, soonest first. */
4251
+ listRenewable() {
4252
+ return this.listRenewableStmt.all();
4253
+ }
4254
+ /**
4255
+ * Claim a row for renewal.
4256
+ *
4257
+ * Returns true exactly once per row. A restart re-arms timers from the
4258
+ * persisted deadline, so the same renewal can be attempted twice; the loser
4259
+ * gets false and must not send. Doing this as a conditional UPDATE rather
4260
+ * than read-then-write avoids the race where both attempts observe
4261
+ * "not yet renewed".
4262
+ */
4263
+ claimRenewal(token, now = Date.now()) {
4264
+ return this.claimRenewalStmt.run({ token, at: now }).changes > 0;
4265
+ }
4266
+ /** Mark one token expired, so it stops being a delivery target. */
4267
+ expire(token, now = Date.now()) {
4268
+ this.expireStmt.run(now, token);
4269
+ }
4270
+ /**
4271
+ * Expire every live activity for a session.
4272
+ *
4273
+ * Called when the session ends. Without this, a per-activity token outlives
4274
+ * its session and a later renewal sweep would resurrect an activity for a
4275
+ * session that is already gone.
4276
+ */
4277
+ expireSessionActivities(sessionId, now = Date.now()) {
4278
+ this.expireSessionActivitiesStmt.run({ session_id: sessionId, at: now });
4279
+ }
4280
+ /** Every token, including dead and revoked ones, for the health report. */
4281
+ listHealth(now = Date.now()) {
4282
+ return this.listAllStmt.all().map((r) => toHealth(r, now));
4283
+ }
4284
+ recordSuccess(token, now = Date.now()) {
4285
+ this.successStmt.run({ token, at: now });
4286
+ }
4287
+ recordFailure(token, code, now = Date.now()) {
4288
+ this.failureStmt.run({ token, at: now, code });
4289
+ }
4290
+ revoke(token, now = Date.now()) {
4291
+ return this.revokeStmt.run(now, token).changes > 0;
4292
+ }
4293
+ /**
4294
+ * Claim an event id for delivery.
4295
+ *
4296
+ * Returns true exactly once per event id. A retry, a reconnect
4297
+ * reconciliation, or two triggers firing for the same underlying event all
4298
+ * get false and must not notify — the user should never be told twice about
4299
+ * one thing.
4300
+ */
4301
+ claimEvent(eventId, sessionId, now = Date.now()) {
4302
+ return this.claimEventStmt.run({ event_id: eventId, session_id: sessionId, created_at: now }).changes > 0;
4303
+ }
4304
+ markDelivered(eventId, now = Date.now()) {
4305
+ this.markDeliveredStmt.run(now, eventId);
4306
+ }
4307
+ };
4308
+
3982
4309
  // src/api/routes/misc.routes.ts
4310
+ function numberOrNull(value) {
4311
+ return typeof value === "number" && Number.isFinite(value) ? value : null;
4312
+ }
3983
4313
  function readJsonBody(req) {
3984
4314
  return new Promise((resolve2, reject) => {
3985
4315
  const chunks = [];
@@ -4026,7 +4356,11 @@ var createMiscRoutes = (deps) => {
4026
4356
  // Capability flag: this server serves /api/config/claude-flags. Additive —
4027
4357
  // older clients ignore it, and clients talking to an older server see it
4028
4358
  // absent and hide the UI rather than 404ing.
4029
- claudeFlags: true
4359
+ claudeFlags: true,
4360
+ // Same contract: this server serves GET /api/config/feature-flags. Lives
4361
+ // here rather than behind /api/config (admin-only) so a read-only client
4362
+ // still learns the server supports flags even if it can't read values.
4363
+ featureFlags: true
4030
4364
  });
4031
4365
  });
4032
4366
  app.get("/api/profiles", (c) => c.json([]));
@@ -4053,6 +4387,22 @@ var createMiscRoutes = (deps) => {
4053
4387
  if (platform3 !== "ios" && platform3 !== "android") {
4054
4388
  return c.json({ error: "platform must be 'ios' or 'android'" }, 400);
4055
4389
  }
4390
+ const kind = body?.kind === void 0 ? DEFAULT_PUSH_TOKEN_KIND : body.kind;
4391
+ if (!isPushTokenKind(kind)) {
4392
+ return c.json(
4393
+ { error: `kind must be one of ${PUSH_TOKEN_KINDS.join(", ")}`, code: "INVALID_KIND" },
4394
+ 400
4395
+ );
4396
+ }
4397
+ if (kind === "liveactivity_update" && typeof body?.activityId !== "string") {
4398
+ return c.json(
4399
+ {
4400
+ error: "activityId is required for kind 'liveactivity_update'",
4401
+ code: "MISSING_ACTIVITY"
4402
+ },
4403
+ 400
4404
+ );
4405
+ }
4056
4406
  const repo = deps.pushRepo();
4057
4407
  if (!repo) {
4058
4408
  return c.json({ error: "Push registration is unavailable", code: "STORE_UNAVAILABLE" }, 503);
@@ -4060,7 +4410,13 @@ var createMiscRoutes = (deps) => {
4060
4410
  repo.register({
4061
4411
  token,
4062
4412
  platform: platform3,
4063
- deviceId: typeof body?.deviceId === "string" ? body.deviceId : null
4413
+ deviceId: typeof body?.deviceId === "string" ? body.deviceId : null,
4414
+ kind,
4415
+ activityId: typeof body?.activityId === "string" ? body.activityId : null,
4416
+ sessionId: typeof body?.sessionId === "string" ? body.sessionId : null,
4417
+ expiresAt: numberOrNull(body?.expiresAt),
4418
+ staleDate: numberOrNull(body?.staleDate),
4419
+ startedAt: numberOrNull(body?.startedAt)
4064
4420
  });
4065
4421
  return c.json({ ok: true });
4066
4422
  });
@@ -4359,6 +4715,14 @@ var createSessionRoutes = (deps) => {
4359
4715
  await deps.handleSetSessionName(c.req.param("id"), c.env.incoming, c.env.outgoing);
4360
4716
  return alreadyHandled6();
4361
4717
  });
4718
+ app.patch("/:id/model", async (c) => {
4719
+ await deps.handleSetSessionModel(c.req.param("id"), c.env.incoming, c.env.outgoing);
4720
+ return alreadyHandled6();
4721
+ });
4722
+ app.patch("/:id/effort", async (c) => {
4723
+ await deps.handleSetSessionEffort(c.req.param("id"), c.env.incoming, c.env.outgoing);
4724
+ return alreadyHandled6();
4725
+ });
4362
4726
  app.post("/:id/adopt", async (c) => {
4363
4727
  await deps.handleAdopt(c.req.param("id"), c.env.outgoing);
4364
4728
  return alreadyHandled6();
@@ -4694,6 +5058,7 @@ CREATE TABLE IF NOT EXISTS conversation_meta (
4694
5058
  );
4695
5059
  CREATE INDEX IF NOT EXISTS idx_meta_last_activity ON conversation_meta(last_activity DESC);
4696
5060
  CREATE INDEX IF NOT EXISTS idx_meta_project ON conversation_meta(project_path);
5061
+ CREATE INDEX IF NOT EXISTS idx_meta_file_path ON conversation_meta(file_path);
4697
5062
 
4698
5063
  CREATE TABLE IF NOT EXISTS conversation_tail (
4699
5064
  conversation_id TEXT PRIMARY KEY REFERENCES conversation_meta(id) ON DELETE CASCADE,
@@ -6155,164 +6520,45 @@ function deriveNameFromPath(path) {
6155
6520
  return parts.length > 0 ? parts[parts.length - 1] : null;
6156
6521
  }
6157
6522
 
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";
6523
+ // src/db/repositories/sessions.repository.ts
6524
+ var SessionsRepository = class {
6525
+ constructor(store) {
6526
+ this.store = store;
6527
+ }
6528
+ store;
6529
+ updateSessionProjectId(args) {
6530
+ this.store.updateManaged(args.sessionId, { projectId: args.projectId });
6531
+ }
6532
+ listManagedSessions() {
6533
+ return this.store.listManaged();
6534
+ }
6535
+ };
6536
+
6537
+ // src/db/upload-records.ts
6538
+ async function recordUpload(pool2, instanceId, row) {
6539
+ if (!pool2) return;
6540
+ await pool2.query(
6541
+ `INSERT INTO session_uploads
6542
+ (id, session_id, instance_id, file_path, original_name, mime_type, size_bytes)
6543
+ VALUES ($1, $2, $3, $4, $5, $6, $7)`,
6544
+ [
6545
+ row.id,
6546
+ row.sessionId,
6547
+ instanceId,
6548
+ row.filePath,
6549
+ row.originalName,
6550
+ row.mimeType,
6551
+ row.sizeBytes
6552
+ ]
6553
+ );
6166
6554
  }
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
- // src/db/repositories/sessions.repository.ts
6278
- var SessionsRepository = class {
6279
- constructor(store) {
6280
- this.store = store;
6281
- }
6282
- store;
6283
- updateSessionProjectId(args) {
6284
- this.store.updateManaged(args.sessionId, { projectId: args.projectId });
6285
- }
6286
- listManagedSessions() {
6287
- return this.store.listManaged();
6288
- }
6289
- };
6290
-
6291
- // src/db/upload-records.ts
6292
- async function recordUpload(pool2, instanceId, row) {
6293
- if (!pool2) return;
6294
- await pool2.query(
6295
- `INSERT INTO session_uploads
6296
- (id, session_id, instance_id, file_path, original_name, mime_type, size_bytes)
6297
- VALUES ($1, $2, $3, $4, $5, $6, $7)`,
6298
- [
6299
- row.id,
6300
- row.sessionId,
6301
- instanceId,
6302
- row.filePath,
6303
- row.originalName,
6304
- row.mimeType,
6305
- row.sizeBytes
6306
- ]
6307
- );
6308
- }
6309
-
6310
- // src/handlers/handleListProjects.ts
6311
- var import_fs10 = require("fs");
6312
- var import_os6 = require("os");
6313
- var import_path13 = require("path");
6314
- function decodeProjectPath(dirName) {
6315
- return dirName.replace(/-/g, "/");
6555
+
6556
+ // src/handlers/handleListProjects.ts
6557
+ var import_fs10 = require("fs");
6558
+ var import_os6 = require("os");
6559
+ var import_path13 = require("path");
6560
+ function decodeProjectPath(dirName) {
6561
+ return dirName.replace(/-/g, "/");
6316
6562
  }
6317
6563
  function handleListProjects(url, res) {
6318
6564
  const limit = Math.max(1, parseInt(url.searchParams.get("limit") ?? "50", 10) || 50);
@@ -6506,10 +6752,10 @@ function fingerprintOf(ids) {
6506
6752
  return `sha256:${(0, import_crypto9.createHash)("sha256").update(sorted.join("\n")).digest("hex")}`;
6507
6753
  }
6508
6754
  var CacheIntegrityMonitor = class {
6509
- constructor(cache, wsHub, log3, cacheDir, rescan, runDuringReset) {
6755
+ constructor(cache, wsHub, log7, cacheDir, rescan, runDuringReset) {
6510
6756
  this.cache = cache;
6511
6757
  this.wsHub = wsHub;
6512
- this.log = log3;
6758
+ this.log = log7;
6513
6759
  this.cacheDir = cacheDir;
6514
6760
  this.rescan = rescan;
6515
6761
  this.runDuringReset = runDuringReset;
@@ -7122,6 +7368,577 @@ function deriveProjectChatTitle(input) {
7122
7368
  return `Untitled \xB7 ${input.id.slice(0, 8)}`;
7123
7369
  }
7124
7370
 
7371
+ // src/services/push/apnsClient.ts
7372
+ var import_node_crypto3 = require("crypto");
7373
+ var import_node_http2 = require("http2");
7374
+ var log3 = getLogger("apns");
7375
+ var APNS_HOST_SANDBOX = "api.sandbox.push.apple.com";
7376
+ var APNS_MAX_PAYLOAD_BYTES = 4096;
7377
+ var JWT_TTL_SECONDS = 3e3;
7378
+ var DEAD_TOKEN_REASONS = /* @__PURE__ */ new Set([
7379
+ "BadDeviceToken",
7380
+ "DeviceTokenNotForTopic",
7381
+ "Unregistered",
7382
+ "ExpiredToken"
7383
+ ]);
7384
+ function base64url(input) {
7385
+ return Buffer.from(input).toString("base64url");
7386
+ }
7387
+ function readApnsCredentialsFromEnv(env = process.env) {
7388
+ const key = env.APNS_KEY;
7389
+ if (!key || key.trim().length === 0) return null;
7390
+ const keyId = env.APNS_KEY_ID?.trim();
7391
+ const teamId = env.APNS_TEAM_ID?.trim();
7392
+ const bundleId = env.APNS_BUNDLE_ID?.trim();
7393
+ if (!keyId || !teamId || !bundleId) return null;
7394
+ const host = env.APNS_HOST ?? APNS_HOST_SANDBOX;
7395
+ return { key, keyId, teamId, bundleId, host };
7396
+ }
7397
+ function describeMissingApnsCredentials(env = process.env) {
7398
+ if (!env.APNS_KEY || env.APNS_KEY.trim().length === 0) {
7399
+ 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.";
7400
+ }
7401
+ const missing = [
7402
+ ["APNS_KEY_ID", env.APNS_KEY_ID],
7403
+ ["APNS_TEAM_ID", env.APNS_TEAM_ID],
7404
+ ["APNS_BUNDLE_ID", env.APNS_BUNDLE_ID]
7405
+ ].filter(([, value]) => !value || value.trim().length === 0).map(([name]) => name);
7406
+ if (missing.length === 0) return null;
7407
+ 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.`;
7408
+ }
7409
+ var ApnsClient = class {
7410
+ constructor(creds) {
7411
+ this.creds = creds;
7412
+ }
7413
+ creds;
7414
+ session = null;
7415
+ cachedJwt = null;
7416
+ /**
7417
+ * The `apns-topic` for Live Activity pushes.
7418
+ *
7419
+ * The `.push-type.liveactivity` suffix is mandatory and is why the signing key
7420
+ * must be Team Scoped (All Topics) — a key scoped to the bundle id alone
7421
+ * cannot sign this topic.
7422
+ */
7423
+ get topic() {
7424
+ return `${this.creds.bundleId}.push-type.liveactivity`;
7425
+ }
7426
+ /**
7427
+ * Mint or reuse the provider JWT.
7428
+ *
7429
+ * ES256 over the p8 key. Cached until shortly before expiry: Apple rejects a
7430
+ * token older than an hour, but minting one per request is wasteful and can
7431
+ * trip APNs' provider-token-update throttle.
7432
+ */
7433
+ getJwt(now = Date.now()) {
7434
+ const nowSeconds = Math.floor(now / 1e3);
7435
+ if (this.cachedJwt && this.cachedJwt.expiresAt > nowSeconds + 60) {
7436
+ return this.cachedJwt.token;
7437
+ }
7438
+ const header = base64url(JSON.stringify({ alg: "ES256", kid: this.creds.keyId, typ: "JWT" }));
7439
+ const payload = base64url(JSON.stringify({ iss: this.creds.teamId, iat: nowSeconds }));
7440
+ const signingInput = `${header}.${payload}`;
7441
+ const signature = (0, import_node_crypto3.createSign)("SHA256").update(signingInput).sign({ key: this.creds.key, dsaEncoding: "ieee-p1363" });
7442
+ const token = `${signingInput}.${base64url(signature)}`;
7443
+ this.cachedJwt = { token, expiresAt: nowSeconds + JWT_TTL_SECONDS };
7444
+ return token;
7445
+ }
7446
+ /**
7447
+ * Reuse one HTTP/2 session across sends.
7448
+ *
7449
+ * APNs expects a long-lived connection; a fresh TLS handshake per push is slow
7450
+ * and Apple treats connection churn as abuse.
7451
+ */
7452
+ getSession() {
7453
+ if (this.session && !this.session.closed && !this.session.destroyed) {
7454
+ return this.session;
7455
+ }
7456
+ const session = (0, import_node_http2.connect)(`https://${this.creds.host}`);
7457
+ session.on("error", (err) => {
7458
+ log3.warn("apns.session_error", { event: "apns.session_error", err: String(err) });
7459
+ });
7460
+ this.session = session;
7461
+ return session;
7462
+ }
7463
+ /**
7464
+ * Send one push.
7465
+ *
7466
+ * Resolves with a result rather than rejecting on an APNs rejection: a
7467
+ * rejected push is an expected outcome the caller must act on (retire the
7468
+ * token), not an exception. Only a genuinely unexpected local failure throws,
7469
+ * and the caller logs it.
7470
+ */
7471
+ async send(args) {
7472
+ const body = Buffer.from(JSON.stringify(args.payload), "utf-8");
7473
+ if (body.byteLength > APNS_MAX_PAYLOAD_BYTES) {
7474
+ throw new Error(
7475
+ `APNs payload is ${body.byteLength} bytes, over the ${APNS_MAX_PAYLOAD_BYTES} byte limit`
7476
+ );
7477
+ }
7478
+ const session = this.getSession();
7479
+ const headers = {
7480
+ [import_node_http2.constants.HTTP2_HEADER_METHOD]: "POST",
7481
+ [import_node_http2.constants.HTTP2_HEADER_PATH]: `/3/device/${args.deviceToken}`,
7482
+ [import_node_http2.constants.HTTP2_HEADER_AUTHORIZATION]: `bearer ${this.getJwt()}`,
7483
+ "apns-push-type": "liveactivity",
7484
+ "apns-topic": this.topic,
7485
+ "apns-priority": String(args.priority ?? 10),
7486
+ ...args.expirationSeconds != null && {
7487
+ "apns-expiration": String(args.expirationSeconds)
7488
+ },
7489
+ [import_node_http2.constants.HTTP2_HEADER_CONTENT_TYPE]: "application/json",
7490
+ [import_node_http2.constants.HTTP2_HEADER_CONTENT_LENGTH]: String(body.byteLength)
7491
+ };
7492
+ return new Promise((resolve2, reject) => {
7493
+ const req = session.request(headers);
7494
+ req.setTimeout(args.timeoutMs ?? 1e4, () => {
7495
+ req.close(import_node_http2.constants.NGHTTP2_CANCEL);
7496
+ resolve2({ ok: false, status: 0, reason: "Timeout", tokenDead: false });
7497
+ });
7498
+ let status = 0;
7499
+ req.on("response", (resHeaders) => {
7500
+ status = Number(resHeaders[import_node_http2.constants.HTTP2_HEADER_STATUS] ?? 0);
7501
+ });
7502
+ const chunks = [];
7503
+ req.on("data", (chunk) => chunks.push(chunk));
7504
+ req.on("error", reject);
7505
+ req.on("end", () => {
7506
+ const raw = Buffer.concat(chunks).toString("utf-8");
7507
+ let reason;
7508
+ if (raw.length > 0) {
7509
+ try {
7510
+ reason = JSON.parse(raw).reason;
7511
+ } catch {
7512
+ reason = raw.slice(0, 200);
7513
+ }
7514
+ }
7515
+ resolve2({
7516
+ ok: status === 200,
7517
+ status,
7518
+ reason,
7519
+ tokenDead: reason != null && DEAD_TOKEN_REASONS.has(reason)
7520
+ });
7521
+ });
7522
+ req.end(body);
7523
+ });
7524
+ }
7525
+ /** Close the shared connection. Called on server shutdown. */
7526
+ close() {
7527
+ this.session?.close();
7528
+ this.session = null;
7529
+ }
7530
+ };
7531
+
7532
+ // src/services/push/liveActivityContentState.ts
7533
+ var LAST_OUTPUT_MAX_LENGTH = 90;
7534
+ function toLiveActivityStatus(status) {
7535
+ return status === "running" || status === "waiting_input" ? status : null;
7536
+ }
7537
+ function truncateLastOutput(raw) {
7538
+ const oneLine = raw.replace(/\s+/g, " ").trim();
7539
+ return oneLine.length <= LAST_OUTPUT_MAX_LENGTH ? oneLine : oneLine.slice(0, LAST_OUTPUT_MAX_LENGTH);
7540
+ }
7541
+
7542
+ // src/services/push/liveActivityNotifier.ts
7543
+ var log4 = getLogger("live-activity");
7544
+ function contentStateForSession(args) {
7545
+ const status = toLiveActivityStatus(args.session.status);
7546
+ if (!status) return null;
7547
+ return {
7548
+ sessionId: args.session.id,
7549
+ serverId: args.serverId,
7550
+ projectName: args.session.projectName,
7551
+ status,
7552
+ startedAt: args.startedAtOverride ?? args.session.startedAt.getTime(),
7553
+ lastOutput: truncateLastOutput(args.session.lastOutput ?? ""),
7554
+ ...args.serverLabel != null && { serverLabel: args.serverLabel }
7555
+ };
7556
+ }
7557
+ var LiveActivityNotifier = class {
7558
+ constructor(sender, serverId, serverLabel) {
7559
+ this.sender = sender;
7560
+ this.serverId = serverId;
7561
+ this.serverLabel = serverLabel;
7562
+ }
7563
+ sender;
7564
+ serverId;
7565
+ serverLabel;
7566
+ /**
7567
+ * Last status pushed per session.
7568
+ *
7569
+ * Live Activity pushes are rate-limited by iOS and the surface only renders
7570
+ * `running` vs `waiting_input`, so re-pushing an unchanged status is pure
7571
+ * budget spend for no visible change. This is what makes the notifier
7572
+ * edge-triggered rather than level-triggered.
7573
+ */
7574
+ lastPushed = /* @__PURE__ */ new Map();
7575
+ /**
7576
+ * React to a session status change.
7577
+ *
7578
+ * Fire-and-forget by design: a push must never delay or fail a session
7579
+ * transition, so this returns a promise the caller may ignore and every error
7580
+ * is logged rather than propagated.
7581
+ */
7582
+ async onStatusChange(session) {
7583
+ const status = toLiveActivityStatus(session.status);
7584
+ try {
7585
+ if (!status) {
7586
+ await this.endFor(session);
7587
+ return;
7588
+ }
7589
+ if (this.lastPushed.get(session.id) === status) return;
7590
+ const contentState = contentStateForSession({
7591
+ session,
7592
+ serverId: this.serverId,
7593
+ serverLabel: this.serverLabel
7594
+ });
7595
+ if (!contentState) return;
7596
+ const outcome = await this.sender.send({
7597
+ sessionId: session.id,
7598
+ event: "update",
7599
+ contentState
7600
+ });
7601
+ this.lastPushed.set(session.id, status);
7602
+ if (outcome.attempted > 0) {
7603
+ log4.info("live_activity.updated", {
7604
+ event: "live_activity.updated",
7605
+ sessionId: session.id,
7606
+ status,
7607
+ ...outcome
7608
+ });
7609
+ }
7610
+ } catch (err) {
7611
+ log4.error("live_activity.notify_failed", {
7612
+ event: "live_activity.notify_failed",
7613
+ sessionId: session.id,
7614
+ status: session.status,
7615
+ err: String(err)
7616
+ });
7617
+ }
7618
+ }
7619
+ async endFor(session) {
7620
+ const lastStatus = this.lastPushed.get(session.id);
7621
+ this.lastPushed.delete(session.id);
7622
+ const contentState = contentStateForSession({
7623
+ session: {
7624
+ ...session,
7625
+ status: lastStatus === "waiting_input" ? "waiting_input" : "running"
7626
+ },
7627
+ serverId: this.serverId,
7628
+ serverLabel: this.serverLabel
7629
+ });
7630
+ if (!contentState) return;
7631
+ const outcome = await this.sender.end({ sessionId: session.id, contentState });
7632
+ if (outcome.attempted > 0) {
7633
+ log4.info("live_activity.ended", {
7634
+ event: "live_activity.ended",
7635
+ sessionId: session.id,
7636
+ ...outcome
7637
+ });
7638
+ }
7639
+ }
7640
+ /** Drop cached state for a session, so a resume re-pushes its first status. */
7641
+ forget(sessionId) {
7642
+ this.lastPushed.delete(sessionId);
7643
+ }
7644
+ };
7645
+
7646
+ // src/services/push/liveActivitySender.ts
7647
+ var log5 = getLogger("live-activity");
7648
+ var ACTIVITY_MAX_LIFETIME_MS = 8 * 60 * 60 * 1e3;
7649
+ function buildActivityKitPayload(args) {
7650
+ return {
7651
+ aps: {
7652
+ timestamp: Math.floor(args.now / 1e3),
7653
+ event: args.event,
7654
+ "content-state": args.contentState,
7655
+ ...args.staleDate != null && { "stale-date": Math.floor(args.staleDate / 1e3) },
7656
+ ...args.dismissalDate != null && {
7657
+ "dismissal-date": Math.floor(args.dismissalDate / 1e3)
7658
+ }
7659
+ }
7660
+ };
7661
+ }
7662
+ var LiveActivitySender = class {
7663
+ constructor(apns, repo) {
7664
+ this.apns = apns;
7665
+ this.repo = repo;
7666
+ }
7667
+ apns;
7668
+ repo;
7669
+ /**
7670
+ * Push to every live activity of a session.
7671
+ *
7672
+ * Sends are independent: one rejected token must not stop the others, because
7673
+ * a single dead device would otherwise silence every other device watching the
7674
+ * same session.
7675
+ */
7676
+ async send(args) {
7677
+ const now = args.now ?? Date.now();
7678
+ return this.sendToTokens({
7679
+ tokens: this.repo.listForSession("liveactivity_update", args.sessionId, now),
7680
+ sessionId: args.sessionId,
7681
+ event: args.event,
7682
+ contentState: args.contentState,
7683
+ now,
7684
+ priority: args.priority
7685
+ });
7686
+ }
7687
+ /**
7688
+ * Push to an explicit token list.
7689
+ *
7690
+ * Renewal needs this: a replacement activity does not exist yet, so it is
7691
+ * started via the app-wide push-to-start token rather than any per-session
7692
+ * lookup. Shares one fan-out body with `send()` so failure handling cannot
7693
+ * drift between the two paths.
7694
+ */
7695
+ async sendToTokens(args) {
7696
+ const now = args.now ?? Date.now();
7697
+ const tokens = args.tokens;
7698
+ const outcome = {
7699
+ attempted: tokens.length,
7700
+ succeeded: 0,
7701
+ retired: 0
7702
+ };
7703
+ if (tokens.length === 0) return outcome;
7704
+ const results = await Promise.all(
7705
+ tokens.map(
7706
+ (row) => this.sendToToken(row, args.event, args.contentState, now, args.priority, args.staleDate)
7707
+ )
7708
+ );
7709
+ for (const { row, result, error } of results) {
7710
+ if (error) {
7711
+ log5.error("live_activity.send_failed", {
7712
+ event: "live_activity.send_failed",
7713
+ sessionId: args.sessionId,
7714
+ activityId: row.activity_id,
7715
+ apnsEvent: args.event,
7716
+ err: String(error)
7717
+ });
7718
+ this.repo.recordFailure(row.token, "SendError", now);
7719
+ continue;
7720
+ }
7721
+ if (!result) continue;
7722
+ if (result.ok) {
7723
+ this.repo.recordSuccess(row.token, now);
7724
+ outcome.succeeded += 1;
7725
+ continue;
7726
+ }
7727
+ this.repo.recordFailure(row.token, result.reason ?? `HTTP_${result.status}`, now);
7728
+ if (result.tokenDead) {
7729
+ this.repo.expire(row.token, now);
7730
+ outcome.retired += 1;
7731
+ }
7732
+ log5.warn("live_activity.send_rejected", {
7733
+ event: "live_activity.send_rejected",
7734
+ sessionId: args.sessionId,
7735
+ activityId: row.activity_id,
7736
+ apnsEvent: args.event,
7737
+ status: result.status,
7738
+ reason: result.reason,
7739
+ tokenDead: result.tokenDead
7740
+ });
7741
+ }
7742
+ return outcome;
7743
+ }
7744
+ /**
7745
+ * End every live activity for a session and stop tracking them.
7746
+ *
7747
+ * Expiring locally is what stops the renewal sweep from later resurrecting an
7748
+ * activity for a session that has already finished.
7749
+ */
7750
+ async end(args) {
7751
+ const now = args.now ?? Date.now();
7752
+ const outcome = await this.send({
7753
+ sessionId: args.sessionId,
7754
+ event: "end",
7755
+ contentState: args.contentState,
7756
+ now
7757
+ });
7758
+ this.repo.expireSessionActivities(args.sessionId, now);
7759
+ return outcome;
7760
+ }
7761
+ async sendToToken(row, event, contentState, now, priority, staleDateOverride) {
7762
+ const staleDate = event === "update" ? staleDateOverride ?? row.stale_date ?? contentState.startedAt + ACTIVITY_MAX_LIFETIME_MS : null;
7763
+ try {
7764
+ const result = await this.apns.send({
7765
+ deviceToken: row.token,
7766
+ payload: buildActivityKitPayload({ event, contentState, now, staleDate }),
7767
+ priority
7768
+ });
7769
+ return { row, result };
7770
+ } catch (error) {
7771
+ return { row, error };
7772
+ }
7773
+ }
7774
+ };
7775
+
7776
+ // src/services/push/liveActivityRenewal.ts
7777
+ var log6 = getLogger("live-activity");
7778
+ var RENEWAL_LEAD_MS = 30 * 60 * 1e3;
7779
+ var MAX_TIMER_MS = 60 * 60 * 1e3;
7780
+ function renewalDueAt(row) {
7781
+ return row.stale_date == null ? null : row.stale_date - RENEWAL_LEAD_MS;
7782
+ }
7783
+ var LiveActivityRenewalScheduler = class {
7784
+ constructor(deps) {
7785
+ this.deps = deps;
7786
+ this.now = deps.now ?? (() => Date.now());
7787
+ }
7788
+ deps;
7789
+ timer = null;
7790
+ stopped = false;
7791
+ now;
7792
+ /**
7793
+ * Arm the scheduler from persisted state.
7794
+ *
7795
+ * Called on boot, which is what makes a renewal survive a restart: the
7796
+ * deadlines were never in memory to begin with.
7797
+ */
7798
+ start() {
7799
+ this.stopped = false;
7800
+ void this.tick();
7801
+ }
7802
+ stop() {
7803
+ this.stopped = true;
7804
+ if (this.timer) {
7805
+ clearTimeout(this.timer);
7806
+ this.timer = null;
7807
+ }
7808
+ }
7809
+ /**
7810
+ * Renew everything due, then sleep until the next deadline.
7811
+ *
7812
+ * Re-reads from the DB every tick rather than caching a schedule in memory, so
7813
+ * an activity registered after boot is picked up without re-arming anything.
7814
+ */
7815
+ async tick() {
7816
+ if (this.stopped) return;
7817
+ const now = this.now();
7818
+ try {
7819
+ for (const row of this.deps.repo.listRenewable()) {
7820
+ const dueAt = renewalDueAt(row);
7821
+ if (dueAt == null || dueAt > now) continue;
7822
+ await this.renew(row, now);
7823
+ }
7824
+ } catch (err) {
7825
+ log6.error("live_activity.renewal_sweep_failed", {
7826
+ event: "live_activity.renewal_sweep_failed",
7827
+ err: String(err)
7828
+ });
7829
+ }
7830
+ this.scheduleNext();
7831
+ }
7832
+ scheduleNext() {
7833
+ if (this.stopped) return;
7834
+ const now = this.now();
7835
+ const pending = this.deps.repo.listRenewable().map(renewalDueAt).filter((d) => d != null);
7836
+ const nextDue = pending.length > 0 ? Math.min(...pending) : now + MAX_TIMER_MS;
7837
+ const delay = Math.min(Math.max(nextDue - now, 0), MAX_TIMER_MS);
7838
+ this.timer = setTimeout(() => void this.tick(), delay);
7839
+ this.timer.unref?.();
7840
+ }
7841
+ /**
7842
+ * Renew one activity.
7843
+ *
7844
+ * Claims first: `claimRenewal()` succeeds exactly once per row, so a timer
7845
+ * re-armed after a restart mid-window cannot send a second time.
7846
+ */
7847
+ async renew(row, now) {
7848
+ if (!row.session_id) return;
7849
+ const session = this.deps.sessionStore.getManaged(row.session_id);
7850
+ const status = session ? toLiveActivityStatus(session.status) : null;
7851
+ if (!session || !status) {
7852
+ this.deps.repo.claimRenewal(row.token, now);
7853
+ this.deps.repo.expire(row.token, now);
7854
+ log6.info("live_activity.renewal_skipped", {
7855
+ event: "live_activity.renewal_skipped",
7856
+ sessionId: row.session_id,
7857
+ activityId: row.activity_id,
7858
+ reason: session ? `status_${session.status}` : "session_gone"
7859
+ });
7860
+ return;
7861
+ }
7862
+ if (!this.deps.repo.claimRenewal(row.token, now)) {
7863
+ return;
7864
+ }
7865
+ const startedAt = row.started_at ?? session.startedAt.getTime();
7866
+ const contentState = {
7867
+ sessionId: session.id,
7868
+ serverId: this.deps.serverId,
7869
+ projectName: session.projectName,
7870
+ status,
7871
+ startedAt,
7872
+ lastOutput: session.lastOutput ?? "",
7873
+ ...this.deps.serverLabel != null && { serverLabel: this.deps.serverLabel }
7874
+ };
7875
+ try {
7876
+ await this.deps.sender.send({
7877
+ sessionId: session.id,
7878
+ event: "end",
7879
+ contentState: { ...contentState, lastOutput: truncateLastOutput(contentState.lastOutput) },
7880
+ now
7881
+ });
7882
+ this.deps.repo.expire(row.token, now);
7883
+ const started = await this.startReplacement({
7884
+ sessionId: session.id,
7885
+ startedAt,
7886
+ now
7887
+ });
7888
+ log6.info("live_activity.renewed", {
7889
+ event: "live_activity.renewed",
7890
+ sessionId: session.id,
7891
+ activityId: row.activity_id,
7892
+ // Logged because a regression here is invisible on the server and only
7893
+ // shows up as a reset timer on someone's Lock Screen.
7894
+ startedAt,
7895
+ replacementRequested: started
7896
+ });
7897
+ } catch (err) {
7898
+ log6.error("live_activity.renewal_failed", {
7899
+ event: "live_activity.renewal_failed",
7900
+ sessionId: session.id,
7901
+ activityId: row.activity_id,
7902
+ err: String(err)
7903
+ });
7904
+ }
7905
+ }
7906
+ /**
7907
+ * Ask the device to start a replacement activity.
7908
+ *
7909
+ * Uses the app-wide push-to-start token, because the replacement does not
7910
+ * exist yet and therefore has no per-activity token. Returns false when the
7911
+ * device never registered one, which is not an error: the app simply cannot be
7912
+ * asked to start an activity remotely, and the next foreground WS update
7913
+ * recreates it.
7914
+ */
7915
+ async startReplacement(args) {
7916
+ const starters = this.deps.repo.listByKind("liveactivity_start", args.now);
7917
+ if (starters.length === 0) return false;
7918
+ const session = this.deps.sessionStore.getManaged(args.sessionId);
7919
+ const status = session ? toLiveActivityStatus(session.status) : null;
7920
+ if (!session || !status) return false;
7921
+ await this.deps.sender.sendToTokens({
7922
+ tokens: starters,
7923
+ event: "update",
7924
+ sessionId: args.sessionId,
7925
+ contentState: {
7926
+ sessionId: session.id,
7927
+ serverId: this.deps.serverId,
7928
+ projectName: session.projectName,
7929
+ status,
7930
+ // Carried through unchanged — the whole point of the renewal.
7931
+ startedAt: args.startedAt,
7932
+ lastOutput: truncateLastOutput(session.lastOutput ?? ""),
7933
+ ...this.deps.serverLabel != null && { serverLabel: this.deps.serverLabel }
7934
+ },
7935
+ now: args.now,
7936
+ staleDate: args.startedAt + ACTIVITY_MAX_LIFETIME_MS
7937
+ });
7938
+ return true;
7939
+ }
7940
+ };
7941
+
7125
7942
  // src/services/questions/parseStatusLine.ts
7126
7943
  var MODEL_RE = /(Opus|Sonnet|Haiku|Fable)\s+[\d.]+(?:\s*\([^)]*\))?/;
7127
7944
  var EFFORT_RE = /●\s*([A-Za-z]+)\s*·\s*\/effort/;
@@ -7804,13 +8621,13 @@ function hashPrefix(text) {
7804
8621
  }
7805
8622
 
7806
8623
  // src/utils/conversationEtag.ts
7807
- var import_node_crypto3 = require("crypto");
8624
+ var import_node_crypto4 = require("crypto");
7808
8625
  function computeConversationEtag({
7809
8626
  filePath,
7810
8627
  messageCount,
7811
8628
  timestamp: timestamp2
7812
8629
  }) {
7813
- const digest = (0, import_node_crypto3.createHash)("sha1").update(`${filePath}:${messageCount}:${timestamp2}`).digest("hex").slice(0, 16);
8630
+ const digest = (0, import_node_crypto4.createHash)("sha1").update(`${filePath}:${messageCount}:${timestamp2}`).digest("hex").slice(0, 16);
7814
8631
  return `"${digest}"`;
7815
8632
  }
7816
8633
 
@@ -7899,6 +8716,24 @@ var WSHub = class {
7899
8716
  this.clients.delete(client);
7900
8717
  }
7901
8718
  }
8719
+ // Scoped broadcast for high-frequency per-session messages (terminal_output,
8720
+ // user_message). Sending to every connected client for every PTY output
8721
+ // chunk made broadcast() cost scale with connections x active sessions;
8722
+ // this bounds it to only that session's subscribers.
8723
+ broadcastToClients(clients, message) {
8724
+ const data = JSON.stringify(message);
8725
+ for (const client of clients) {
8726
+ try {
8727
+ if (client.readyState === client.OPEN) {
8728
+ client.send(data);
8729
+ } else {
8730
+ this.clients.delete(client);
8731
+ }
8732
+ } catch {
8733
+ this.clients.delete(client);
8734
+ }
8735
+ }
8736
+ }
7902
8737
  unicast(ws, message) {
7903
8738
  try {
7904
8739
  if (ws.readyState === ws.OPEN) {
@@ -7961,6 +8796,7 @@ var ADOPT_KILL_TIMEOUT_MS = 5e3;
7961
8796
  var ADOPT_KILL_POLL_MS = 100;
7962
8797
  var REFRESH_TTL_MS = 2e3;
7963
8798
  var START_READY_TIMEOUT_MS = 1e4;
8799
+ var MODEL_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
7964
8800
  var EXTERNAL_TAIL_RECENCY_MS = RESUME_BUSY_WINDOW_MS;
7965
8801
  var EXTERNAL_TAIL_MAX = 32;
7966
8802
  var EXTERNAL_TAIL_IDLE_MS = 3e5;
@@ -8064,6 +8900,8 @@ var StreamerServer = class {
8064
8900
  dbPool = null;
8065
8901
  dbInstanceId = null;
8066
8902
  disableDb = false;
8903
+ // Skip the startup warm-up scan (test hook; see ServerConfig.skipStartupWarmup).
8904
+ skipStartupWarmup;
8067
8905
  browseRoot = null;
8068
8906
  publicUrl = null;
8069
8907
  browserCors;
@@ -8073,6 +8911,12 @@ var StreamerServer = class {
8073
8911
  sessionInputAttempts = /* @__PURE__ */ new Map();
8074
8912
  ptyGracePeriodMs;
8075
8913
  defaultSystemPrompt;
8914
+ // Resolved once at boot; see src/feature-flags.ts. Total map — every registry
8915
+ // id is present, so indexing it never yields undefined.
8916
+ featureFlags;
8917
+ // Derived from featureFlags.codexSystemPrompt. Kept as its own field so the
8918
+ // read site in startFresh() is unchanged.
8919
+ codexSystemPromptEnabled;
8076
8920
  defaultPermissionMode;
8077
8921
  defaultModel;
8078
8922
  defaultEffort;
@@ -8097,6 +8941,11 @@ var StreamerServer = class {
8097
8941
  // every provider; read only by the idle reaper. Entries are dropped when the
8098
8942
  // session leaves the runner (reap/exit/hold).
8099
8943
  lastAgentChunkAt = /* @__PURE__ */ new Map();
8944
+ // sessionId → last terminal_output seq broadcast (starts at 1, per session).
8945
+ // Stamped on every terminal_output/terminal_replay so a client can detect a
8946
+ // stale chunk delivered after a reconnect race instead of trusting raw WS
8947
+ // arrival order. Entries dropped alongside lastAgentChunkAt.
8948
+ terminalSeq = /* @__PURE__ */ new Map();
8100
8949
  // Recently accepted input idempotency keys (C4). A retried POST replays its
8101
8950
  // original outcome instead of submitting the prompt to the agent twice.
8102
8951
  idempotency = new IdempotencyStore();
@@ -8128,6 +8977,12 @@ var StreamerServer = class {
8128
8977
  // Paired-device registry (C5). Null when the cache DB failed to open — auth
8129
8978
  // then falls back to the shared API key alone, which is the pre-C5 behaviour.
8130
8979
  devicesRepo = null;
8980
+ // Live Activity push (Feature 12). Null when APNS_KEY is unset — the ordinary
8981
+ // case on a dev machine and in CI, where the feature is simply off. Missing an
8982
+ // optional push credential must never stop the server from booting.
8983
+ apnsClient = null;
8984
+ liveActivityNotifier = null;
8985
+ liveActivityRenewal = null;
8131
8986
  discoveryCache = null;
8132
8987
  cacheDir;
8133
8988
  tailSize;
@@ -8158,11 +9013,17 @@ var StreamerServer = class {
8158
9013
  }
8159
9014
  this.verbose = config.verbose ?? false;
8160
9015
  this.disableDb = config.disableDb ?? false;
9016
+ this.skipStartupWarmup = config.skipStartupWarmup ?? false;
8161
9017
  this.scannerPersistenceDisabled = config.scannerPersistent === false;
8162
9018
  this.scanProfiles = config.scanProfiles;
8163
9019
  this.codexRoots = config.codexRoots ?? [(0, import_path18.join)((0, import_os9.homedir)(), ".codex", "sessions")];
8164
9020
  this.ptyGracePeriodMs = config.ptyGracePeriodMs ?? DEFAULT_PTY_GRACE_PERIOD_MS;
8165
9021
  this.defaultSystemPrompt = config.defaultSystemPrompt ?? DEFAULT_SYSTEM_PROMPT;
9022
+ this.featureFlags = resolveFeatureFlags({ cli: config.featureFlags, yaml: loadFeatureFlags() });
9023
+ if (config.codexSystemPromptEnabled !== void 0) {
9024
+ this.featureFlags.codexSystemPrompt = config.codexSystemPromptEnabled;
9025
+ }
9026
+ this.codexSystemPromptEnabled = this.featureFlags.codexSystemPrompt;
8166
9027
  this.defaultPermissionMode = config.defaultPermissionMode ?? loadDefaultPermissionMode() ?? "acceptEdits";
8167
9028
  this.defaultModel = config.defaultModel ?? "sonnet";
8168
9029
  this.defaultEffort = config.defaultEffort ?? "low";
@@ -8178,6 +9039,13 @@ var StreamerServer = class {
8178
9039
  }, this.directoryDebounceMs);
8179
9040
  this.includeAgents = parseIncludeAgentsEnv(process.env.THREADBASE_INCLUDE_AGENTS);
8180
9041
  this.agentEntrypoints = parseAgentEntrypointsEnv(process.env.THREADBASE_AGENT_ENTRYPOINTS);
9042
+ const enabledFlags = nonDefaultFeatureFlags(this.featureFlags);
9043
+ if (enabledFlags.length > 0) {
9044
+ this.log.info(`Feature flags active: ${enabledFlags.join(", ")}`, {
9045
+ event: "config.feature_flags_active",
9046
+ flags: enabledFlags
9047
+ });
9048
+ }
8181
9049
  const rawRoot = process.env.THREADBASE_BROWSE_ROOT ?? loadBrowseRoot() ?? config.browseRoot;
8182
9050
  if (rawRoot) {
8183
9051
  (0, import_promises7.realpath)(rawRoot).then((resolved) => {
@@ -8294,10 +9162,22 @@ var StreamerServer = class {
8294
9162
  logger: getLogger("pty"),
8295
9163
  onOutput: (sessionId, data) => {
8296
9164
  this.lastAgentChunkAt.set(sessionId, Date.now());
8297
- this.wsHub.broadcast({ type: "terminal_output", sessionId, data });
9165
+ const seq = (this.terminalSeq.get(sessionId) ?? 0) + 1;
9166
+ this.terminalSeq.set(sessionId, seq);
9167
+ this.wsHub.broadcastToClients(this.sessionSubscribers.get(sessionId) ?? [], {
9168
+ type: "terminal_output",
9169
+ sessionId,
9170
+ data,
9171
+ seq
9172
+ });
8298
9173
  },
8299
9174
  onUserMessage: (sessionId, text, ts) => {
8300
- this.wsHub.broadcast({ type: "user_message", sessionId, text, ts });
9175
+ this.wsHub.broadcastToClients(this.sessionSubscribers.get(sessionId) ?? [], {
9176
+ type: "user_message",
9177
+ sessionId,
9178
+ text,
9179
+ ts
9180
+ });
8301
9181
  },
8302
9182
  onPermissionChange: (sessionId, gate) => {
8303
9183
  this.handlePermissionChange(sessionId, gate);
@@ -8371,6 +9251,7 @@ var StreamerServer = class {
8371
9251
  if (resp) {
8372
9252
  this.wsHub.broadcast({ type: "session_update", session: resp });
8373
9253
  }
9254
+ void this.liveActivityNotifier?.onStatusChange(session);
8374
9255
  this.sessionStatusBus.emit(`status:${session.id}`, session.status);
8375
9256
  }
8376
9257
  });
@@ -8405,6 +9286,7 @@ var StreamerServer = class {
8405
9286
  logMenubarRequests: this.logMenubarRequests,
8406
9287
  rotateApiKey: () => this.rotateApiKey(),
8407
9288
  claudeFlagsConfig: () => this.getClaudeFlagsConfig(),
9289
+ featureFlagsConfig: () => this.getFeatureFlagsConfig(),
8408
9290
  setClaudeFlagsConfig: (values, extraArgs) => this.setClaudeFlagsConfig(values, extraArgs),
8409
9291
  publicUrl: this.publicUrl,
8410
9292
  browseRoot: this.browseRoot,
@@ -8432,6 +9314,8 @@ var StreamerServer = class {
8432
9314
  handleCancel: (id, res) => this.handleCancel(id, res),
8433
9315
  handleStopSession: (id, res) => this.handleStopSession(id, res),
8434
9316
  handleSetSessionName: (id, req, res) => this.handleSetSessionName(id, req, res),
9317
+ handleSetSessionModel: (id, req, res) => this.applyLiveSessionSetting(id, req, res, "model"),
9318
+ handleSetSessionEffort: (id, req, res) => this.applyLiveSessionSetting(id, req, res, "effort"),
8435
9319
  handleUploadFile: (id, req, res) => this.handleUploadFile(id, req, res),
8436
9320
  handleAdopt: (id, res) => this.handleAdopt(id, res),
8437
9321
  handleResume: (req, res) => this.handleResume(req, res),
@@ -8478,7 +9362,8 @@ var StreamerServer = class {
8478
9362
  type: "terminal_replay",
8479
9363
  sessionId: msg.sessionId,
8480
9364
  lines,
8481
- userMessages
9365
+ userMessages,
9366
+ seq: this.terminalSeq.get(msg.sessionId)
8482
9367
  })
8483
9368
  );
8484
9369
  }
@@ -8636,6 +9521,41 @@ var StreamerServer = class {
8636
9521
  }
8637
9522
  this.ptyGraceDeferCounts.delete(sessionId);
8638
9523
  }
9524
+ /**
9525
+ * Bring up Live Activity push, if credentials are present (Feature 12).
9526
+ *
9527
+ * APNS_KEY absent is the ordinary case on a dev machine and in CI, so this
9528
+ * logs once at info and leaves the feature off rather than failing: the server
9529
+ * must not refuse to boot over a missing optional push credential.
9530
+ *
9531
+ * The key is read from the environment as PEM contents and never from a path
9532
+ * on disk; neither it nor any device token is ever logged.
9533
+ */
9534
+ initLiveActivityPush(pushRepo) {
9535
+ const creds = readApnsCredentialsFromEnv();
9536
+ if (!creds) {
9537
+ const why = describeMissingApnsCredentials();
9538
+ if (why) this.log.info(why, { event: "live_activity.disabled" });
9539
+ return;
9540
+ }
9541
+ this.apnsClient = new ApnsClient(creds);
9542
+ const sender = new LiveActivitySender(this.apnsClient, pushRepo);
9543
+ const serverId = process.env.THREADBASE_INSTANCE_ID ?? (0, import_os9.hostname)();
9544
+ this.liveActivityNotifier = new LiveActivityNotifier(sender, serverId, (0, import_os9.hostname)());
9545
+ this.liveActivityRenewal = new LiveActivityRenewalScheduler({
9546
+ repo: pushRepo,
9547
+ sender,
9548
+ sessionStore: this.sessionStore,
9549
+ serverId,
9550
+ serverLabel: (0, import_os9.hostname)()
9551
+ });
9552
+ this.liveActivityRenewal.start();
9553
+ this.log.info("Live Activity push enabled", {
9554
+ event: "live_activity.enabled",
9555
+ host: creds.host,
9556
+ topic: `${creds.bundleId}.push-type.liveactivity`
9557
+ });
9558
+ }
8639
9559
  /**
8640
9560
  * Classify sessions left behind by previous streamer runs (C1 Phase 3a).
8641
9561
  *
@@ -8803,6 +9723,7 @@ var StreamerServer = class {
8803
9723
  );
8804
9724
  this.ptyManager.putOnHold(session.id);
8805
9725
  this.lastAgentChunkAt.delete(session.id);
9726
+ this.terminalSeq.delete(session.id);
8806
9727
  this.idempotency.clear(session.id);
8807
9728
  this.sessionSubscribers.delete(session.id);
8808
9729
  reaped.push(session.id);
@@ -8945,6 +9866,7 @@ var StreamerServer = class {
8945
9866
  this.cacheMetadataRepo = new CacheMetadataRepository(db);
8946
9867
  this.pushRepo = new PushRepository(db);
8947
9868
  this.devicesRepo = new DevicesRepository(db);
9869
+ this.initLiveActivityPush(this.pushRepo);
8948
9870
  this.cacheMonitor = new CacheIntegrityMonitor(
8949
9871
  this.cache,
8950
9872
  this.wsHub,
@@ -8976,6 +9898,14 @@ var StreamerServer = class {
8976
9898
  );
8977
9899
  this.scannerPersistenceDisabled = true;
8978
9900
  }
9901
+ if (this.skipStartupWarmup) {
9902
+ this.log.debug?.("startup warm-up scan skipped (skipStartupWarmup)", {
9903
+ event: "cache.warmup_skipped"
9904
+ });
9905
+ this.finishWarmup(0);
9906
+ resolveWarm();
9907
+ return;
9908
+ }
8979
9909
  const warmupStatCache = this.buildStatCache(null);
8980
9910
  const warmupScanner = this.newScanner(warmupStatCache ? { persistent: false } : void 0);
8981
9911
  this.allScanners.add(warmupScanner);
@@ -9167,6 +10097,7 @@ var StreamerServer = class {
9167
10097
  this.idleReaperTimer = null;
9168
10098
  }
9169
10099
  this.lastAgentChunkAt.clear();
10100
+ this.terminalSeq.clear();
9170
10101
  this.recordShutdownState();
9171
10102
  this.markScannerStaleDebounced.cancel();
9172
10103
  await Promise.all([...this.inFlightCacheWrites]);
@@ -9179,6 +10110,8 @@ var StreamerServer = class {
9179
10110
  this.externalTails.clear();
9180
10111
  this.wsHub.dispose();
9181
10112
  this.pairTokens.dispose();
10113
+ this.liveActivityRenewal?.stop();
10114
+ this.apnsClient?.close();
9182
10115
  if (this.dbPool) {
9183
10116
  await this.dbPool.end();
9184
10117
  }
@@ -9250,7 +10183,6 @@ var StreamerServer = class {
9250
10183
  json(res, 400, { error: message });
9251
10184
  return;
9252
10185
  }
9253
- const { hostname: hostname2 } = require("os");
9254
10186
  const ts = (/* @__PURE__ */ new Date()).toISOString();
9255
10187
  this.log.info(`[pair] token exchanged from ${ip} at ${ts}`, {
9256
10188
  event: "pair.token_exchanged",
@@ -9273,7 +10205,7 @@ var StreamerServer = class {
9273
10205
  nonce: sealed.nonce,
9274
10206
  ephemeralPublicKey: sealed.ephemeralPublicKey,
9275
10207
  publicUrl: this.publicUrl,
9276
- machineName: hostname2(),
10208
+ machineName: (0, import_os9.hostname)(),
9277
10209
  ...device && {
9278
10210
  deviceId: device.deviceId,
9279
10211
  deviceToken: device.deviceToken,
@@ -9295,6 +10227,16 @@ var StreamerServer = class {
9295
10227
  });
9296
10228
  return { newKey, persisted };
9297
10229
  }
10230
+ /**
10231
+ * The registry ships with the values so a client renders the list from one
10232
+ * round-trip, same as getClaudeFlagsConfig().
10233
+ *
10234
+ * Deliberately no `persisted` field: unlike claude-flags there is no PUT, and
10235
+ * the absence of that field is the signal that this endpoint is read-only.
10236
+ */
10237
+ getFeatureFlagsConfig() {
10238
+ return { registry: FEATURE_FLAGS, values: this.featureFlags };
10239
+ }
9298
10240
  getClaudeFlagsConfig() {
9299
10241
  return {
9300
10242
  registry: CLAUDE_FLAGS,
@@ -9337,6 +10279,30 @@ var StreamerServer = class {
9337
10279
  persisted: this.claudeFlagsPersistable
9338
10280
  };
9339
10281
  }
10282
+ /**
10283
+ * The three spawn options that a configured claude-flag can override, with
10284
+ * the boot-time CLI/yaml default as the fallback. Spread into every
10285
+ * start/resume/adopt call so all three paths agree.
10286
+ *
10287
+ * These ids are excluded from buildFlagArgs (SPAWN_POSITIONAL_FLAG_IDS)
10288
+ * precisely because they arrive here instead — the PTY spawn paths pass them
10289
+ * as explicit positionals, so emitting them from the allowlist too would
10290
+ * duplicate the flag.
10291
+ *
10292
+ * Narrowed with the type guards rather than cast: ClaudeFlagValues is a loose
10293
+ * Record by design, and while validateFlagValues already guarantees the shape
10294
+ * on the way in, TypeScript cannot see that through the record.
10295
+ */
10296
+ spawnFlagOverrides() {
10297
+ const mode = this.claudeFlags.permissionMode;
10298
+ const model = this.claudeFlags.model;
10299
+ const effort = this.claudeFlags.effort;
10300
+ return {
10301
+ permissionMode: isPermissionMode(mode) ? mode : this.defaultPermissionMode,
10302
+ model: typeof model === "string" ? model : this.defaultModel,
10303
+ effort: isEffortLevel(effort) ? effort : this.defaultEffort
10304
+ };
10305
+ }
9340
10306
  checkRateLimit(map, key, limit, windowMs) {
9341
10307
  const now = Date.now();
9342
10308
  const arr = (map.get(key) ?? []).filter((t) => now - t < windowMs);
@@ -10486,11 +11452,9 @@ var StreamerServer = class {
10486
11452
  projectPath,
10487
11453
  projectName: body.projectName,
10488
11454
  branch: body.branch,
10489
- permissionMode: this.defaultPermissionMode,
10490
11455
  claudeFlags: this.claudeFlags,
10491
11456
  claudeExtraArgs: this.claudeExtraArgs,
10492
- model: this.defaultModel,
10493
- effort: this.defaultEffort
11457
+ ...this.spawnFlagOverrides()
10494
11458
  });
10495
11459
  this.sessionStore.addManaged(session);
10496
11460
  this.recordSessionSpawn(session);
@@ -10937,11 +11901,9 @@ var StreamerServer = class {
10937
11901
  projectPath,
10938
11902
  projectName,
10939
11903
  branch,
10940
- permissionMode: this.defaultPermissionMode,
10941
11904
  claudeFlags: this.claudeFlags,
10942
11905
  claudeExtraArgs: this.claudeExtraArgs,
10943
- model: this.defaultModel,
10944
- effort: this.defaultEffort
11906
+ ...this.spawnFlagOverrides()
10945
11907
  });
10946
11908
  this.sessionStore.addManaged(session);
10947
11909
  this.recordSessionSpawn(session);
@@ -11007,17 +11969,16 @@ var StreamerServer = class {
11007
11969
  BROWSE_SYSTEM_PROMPT(this.browseRoot),
11008
11970
  typeof clientPrompt === "string" ? clientPrompt : null
11009
11971
  ].filter(Boolean);
11972
+ const includeSystemPrompt = provider !== CODEX_CLI_PROVIDER || this.codexSystemPromptEnabled;
11010
11973
  try {
11011
11974
  const session = await this.ptyManager.startFresh({
11012
11975
  provider,
11013
11976
  projectPath: resolvedPath,
11014
11977
  projectName: body.projectName,
11015
- systemPrompt: systemPromptParts.join("\n"),
11016
- permissionMode: this.defaultPermissionMode,
11978
+ ...includeSystemPrompt && { systemPrompt: systemPromptParts.join("\n") },
11017
11979
  claudeFlags: this.claudeFlags,
11018
11980
  claudeExtraArgs: this.claudeExtraArgs,
11019
- model: this.defaultModel,
11020
- effort: this.defaultEffort
11981
+ ...this.spawnFlagOverrides()
11021
11982
  });
11022
11983
  this.sessionStore.addManaged(session);
11023
11984
  this.recordSessionSpawn(session);
@@ -11377,6 +12338,87 @@ var StreamerServer = class {
11377
12338
  this.cache.upsertSessionName(sessionId, name);
11378
12339
  json(res, 200, { ok: true });
11379
12340
  }
12341
+ /**
12342
+ * Retarget a LIVE session's model or effort by typing the corresponding
12343
+ * Claude Code slash command into its PTY.
12344
+ *
12345
+ * There is no CLI or IPC channel for this — `--model`/`--effort` are spawn
12346
+ * arguments — so the interactive `/model <x>` / `/effort <y>` commands are the
12347
+ * only way to change a session already running. Both accept an argument and
12348
+ * apply it without opening the picker (verified against Claude Code v2.1.220).
12349
+ *
12350
+ * Answers 202, not 200: the value is applied by the TUI on its next render, so
12351
+ * there is nothing truthful to echo back synchronously. Clients confirm with
12352
+ * `GET /api/sessions/:id`, which scrapes the applied value off the live status
12353
+ * line.
12354
+ */
12355
+ async applyLiveSessionSetting(sessionId, req, res, setting) {
12356
+ const session = this.ptyManager.getSession(sessionId);
12357
+ if (!session) {
12358
+ const known = this.sessionStore.getManaged(sessionId);
12359
+ if (known) {
12360
+ json(res, 409, {
12361
+ error: "Session has no live PTY; resume it first",
12362
+ code: "SESSION_IDLE"
12363
+ });
12364
+ return;
12365
+ }
12366
+ json(res, 404, { error: "Session not found" });
12367
+ return;
12368
+ }
12369
+ if ((session.provider ?? CLAUDE_CODE_PROVIDER) !== CLAUDE_CODE_PROVIDER) {
12370
+ json(res, 501, {
12371
+ error: `Setting ${setting} on a ${session.provider} session is not supported`,
12372
+ code: "UNSUPPORTED_PROVIDER"
12373
+ });
12374
+ return;
12375
+ }
12376
+ if (session.status === "running") {
12377
+ json(res, 409, {
12378
+ error: "Session is mid-turn; retry once it is waiting for input",
12379
+ code: "SESSION_BUSY"
12380
+ });
12381
+ return;
12382
+ }
12383
+ let parsed;
12384
+ try {
12385
+ parsed = await readBody(req);
12386
+ } catch {
12387
+ json(res, 400, { error: "Invalid JSON" });
12388
+ return;
12389
+ }
12390
+ let value;
12391
+ if (setting === "effort") {
12392
+ if (!isEffortLevel(parsed.effort)) {
12393
+ json(res, 400, {
12394
+ error: `effort must be one of ${EFFORT_LEVELS.join(", ")}`
12395
+ });
12396
+ return;
12397
+ }
12398
+ value = parsed.effort;
12399
+ } else {
12400
+ if (typeof parsed.model !== "string" || !MODEL_NAME_RE.test(parsed.model)) {
12401
+ json(res, 400, {
12402
+ error: "model must be an alias or full model name (letters, digits, dot, dash, underscore)"
12403
+ });
12404
+ return;
12405
+ }
12406
+ value = parsed.model;
12407
+ }
12408
+ try {
12409
+ this.ptyManager.sendKeys(sessionId, `/${setting} ${value}\r`);
12410
+ } catch (err) {
12411
+ json(res, 400, { error: err instanceof Error ? err.message : "Failed to write to session" });
12412
+ return;
12413
+ }
12414
+ this.log.info(`Live session ${setting} set to ${value}`, {
12415
+ event: "session.setting_applied",
12416
+ sessionId,
12417
+ setting,
12418
+ value
12419
+ });
12420
+ json(res, 202, { id: sessionId, [setting]: value });
12421
+ }
11380
12422
  handleGetSessionNames(res) {
11381
12423
  if (!this.cache) {
11382
12424
  json(res, 200, {});