@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.js CHANGED
@@ -298,6 +298,10 @@ var DANGEROUS_PERMISSION_MODES = [
298
298
  function isDangerousPermissionMode(mode) {
299
299
  return DANGEROUS_PERMISSION_MODES.includes(mode);
300
300
  }
301
+ var EFFORT_LEVELS = ["low", "medium", "high", "xhigh", "max"];
302
+ function isEffortLevel(value) {
303
+ return typeof value === "string" && EFFORT_LEVELS.includes(value);
304
+ }
301
305
  var CLAUDE_FLAGS = [
302
306
  {
303
307
  id: "permissionMode",
@@ -309,9 +313,10 @@ var CLAUDE_FLAGS = [
309
313
  { id: "addDir", flag: "--add-dir", valueType: "list", risk: "elevated" },
310
314
  { id: "allowedTools", flag: "--allowedTools", valueType: "list", risk: "elevated" },
311
315
  { id: "disallowedTools", flag: "--disallowedTools", valueType: "list", risk: "low" },
312
- { id: "maxBudgetUsd", flag: "--max-budget-usd", valueType: "string", risk: "low" },
313
- { id: "fallbackModel", flag: "--fallback-model", valueType: "string", risk: "low" }
316
+ { id: "model", flag: "--model", valueType: "string", risk: "low" },
317
+ { id: "effort", flag: "--effort", valueType: "enum", enumValues: EFFORT_LEVELS, risk: "low" }
314
318
  ];
319
+ var SPAWN_POSITIONAL_FLAG_IDS = /* @__PURE__ */ new Set(["permissionMode", "model", "effort"]);
315
320
  function findFlag(id) {
316
321
  return CLAUDE_FLAGS.find((f) => f.id === id);
317
322
  }
@@ -376,7 +381,7 @@ function buildFlagArgs(values, extraArgs) {
376
381
  const args = [];
377
382
  const safe = validateFlagValues(values ?? {});
378
383
  for (const def of CLAUDE_FLAGS) {
379
- if (def.id === "permissionMode") continue;
384
+ if (SPAWN_POSITIONAL_FLAG_IDS.has(def.id)) continue;
380
385
  const value = safe[def.id];
381
386
  if (value === void 0) continue;
382
387
  if (def.valueType === "boolean") {
@@ -436,6 +441,58 @@ function getLogger(component) {
436
441
  }
437
442
  var logger = build(baseLogger);
438
443
 
444
+ // src/feature-flags.ts
445
+ var FEATURE_FLAGS = [
446
+ {
447
+ id: "codexSystemPrompt",
448
+ 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.",
449
+ default: false,
450
+ env: "THREADBASE_FEATURE_CODEX_SYSTEM_PROMPT"
451
+ }
452
+ ];
453
+ function findFeatureFlag(id) {
454
+ return FEATURE_FLAGS.find((f) => f.id === id);
455
+ }
456
+ function parseBooleanEnv(raw) {
457
+ if (raw === void 0) return void 0;
458
+ const v = raw.trim().toLowerCase();
459
+ if (v === "") return false;
460
+ return !(v === "0" || v === "false" || v === "no" || v === "off");
461
+ }
462
+ function validateFeatureFlagValues(raw) {
463
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {};
464
+ const out = {};
465
+ const dropped = [];
466
+ for (const [id, value] of Object.entries(raw)) {
467
+ if (!findFeatureFlag(id) || typeof value !== "boolean") {
468
+ dropped.push(id);
469
+ continue;
470
+ }
471
+ out[id] = value;
472
+ }
473
+ if (dropped.length > 0) {
474
+ getLogger("feature-flags").warn(
475
+ `Ignoring unknown or non-boolean feature flags: ${dropped.join(", ")}`,
476
+ {
477
+ event: "config.feature_flags_dropped",
478
+ dropped
479
+ }
480
+ );
481
+ }
482
+ return out;
483
+ }
484
+ function resolveFeatureFlags(opts) {
485
+ const env = opts?.env ?? process.env;
486
+ const out = {};
487
+ for (const def of FEATURE_FLAGS) {
488
+ out[def.id] = parseBooleanEnv(env[def.env]) ?? opts?.cli?.[def.id] ?? opts?.yaml?.[def.id] ?? def.default;
489
+ }
490
+ return out;
491
+ }
492
+ function nonDefaultFeatureFlags(values) {
493
+ return FEATURE_FLAGS.filter((f) => values[f.id] !== f.default).map((f) => f.id);
494
+ }
495
+
439
496
  // src/auth.ts
440
497
  function configDir() {
441
498
  return process.env.THREADBASE_CONFIG_DIR ?? join2(homedir(), ".threadbase");
@@ -588,6 +645,21 @@ function setClaudeExtraArgs(text) {
588
645
  }
589
646
  setConfigValue("claude_extra_args", trimmed && trimmed.length > 0 ? trimmed : void 0);
590
647
  }
648
+ function loadFeatureFlags() {
649
+ try {
650
+ const content = readFileSync(configFile(), "utf-8");
651
+ const match = content.match(/^feature_flags:\s*(.+)$/m);
652
+ if (!match?.[1]) return {};
653
+ return validateFeatureFlagValues(JSON.parse(match[1].trim()));
654
+ } catch (err) {
655
+ if (err.code !== "ENOENT") {
656
+ getLogger("auth").warn(`Ignoring unreadable feature_flags in server.yaml: ${String(err)}`, {
657
+ event: "config.feature_flags_parse_failed"
658
+ });
659
+ }
660
+ return {};
661
+ }
662
+ }
591
663
  function validatePublicUrl(raw) {
592
664
  let parsed;
593
665
  try {
@@ -2953,7 +3025,7 @@ import {
2953
3025
  } from "fs";
2954
3026
  import { realpath as realpath2 } from "fs/promises";
2955
3027
  import { createServer } from "http";
2956
- import { homedir as homedir9 } from "os";
3028
+ import { homedir as homedir9, hostname as hostname2 } from "os";
2957
3029
  import { basename as basename5, dirname as dirname9, join as join18 } from "path";
2958
3030
  import { createInterface } from "readline";
2959
3031
 
@@ -3635,6 +3707,7 @@ function readRawBody3(req) {
3635
3707
  var createConfigRoutes = (deps) => {
3636
3708
  const app = new Hono4();
3637
3709
  app.get("/claude-flags", (c) => c.json(deps.claudeFlagsConfig()));
3710
+ app.get("/feature-flags", (c) => c.json(deps.featureFlagsConfig()));
3638
3711
  app.put("/claude-flags", async (c) => {
3639
3712
  if (deps.localNoAuth) {
3640
3713
  return c.json({ error: "claude flag changes are disabled while localNoAuth is active" }, 403);
@@ -3940,7 +4013,264 @@ function loadUpdateConfig(opts = {}) {
3940
4013
  return UpdateConfigSchema.parse(parsed);
3941
4014
  }
3942
4015
 
4016
+ // src/db/repositories/push.repository.ts
4017
+ var FAILURE_STREAK_LIMIT = 5;
4018
+ var PUSH_TOKEN_KINDS = ["expo", "liveactivity_start", "liveactivity_update"];
4019
+ var DEFAULT_PUSH_TOKEN_KIND = "expo";
4020
+ function isPushTokenKind(value) {
4021
+ return typeof value === "string" && PUSH_TOKEN_KINDS.includes(value);
4022
+ }
4023
+ function tokenState(row, now = Date.now()) {
4024
+ if (row.revoked_at != null) return "revoked";
4025
+ if (row.expires_at != null && row.expires_at <= now) return "expired";
4026
+ if (row.failure_streak >= FAILURE_STREAK_LIMIT) return "dead";
4027
+ if (row.failure_streak > 0) return "failing";
4028
+ if (row.last_success_at == null) return "never-delivered";
4029
+ return "healthy";
4030
+ }
4031
+ function toHealth(row, now = Date.now()) {
4032
+ return {
4033
+ platform: row.platform,
4034
+ deviceId: row.device_id,
4035
+ registeredAt: row.registered_at,
4036
+ lastSuccessAt: row.last_success_at,
4037
+ lastFailureAt: row.last_failure_at,
4038
+ lastFailureCode: row.last_failure_code,
4039
+ failureStreak: row.failure_streak,
4040
+ revokedAt: row.revoked_at,
4041
+ state: tokenState(row, now),
4042
+ kind: row.kind,
4043
+ activityId: row.activity_id,
4044
+ sessionId: row.session_id,
4045
+ expiresAt: row.expires_at
4046
+ };
4047
+ }
4048
+ var PushRepository = class {
4049
+ upsertStmt;
4050
+ getStmt;
4051
+ listActiveStmt;
4052
+ listAllStmt;
4053
+ successStmt;
4054
+ failureStmt;
4055
+ revokeStmt;
4056
+ claimEventStmt;
4057
+ markDeliveredStmt;
4058
+ listByKindSessionStmt;
4059
+ listByKindStmt;
4060
+ listRenewableStmt;
4061
+ claimRenewalStmt;
4062
+ expireStmt;
4063
+ expireSessionActivitiesStmt;
4064
+ constructor(db) {
4065
+ this.upsertStmt = db.prepare(`
4066
+ INSERT INTO push_tokens (
4067
+ token, platform, device_id, registered_at,
4068
+ kind, activity_id, session_id, expires_at, stale_date, started_at
4069
+ )
4070
+ VALUES (
4071
+ @token, @platform, @device_id, @registered_at,
4072
+ @kind, @activity_id, @session_id, @expires_at, @stale_date, @started_at
4073
+ )
4074
+ ON CONFLICT(token) DO UPDATE SET
4075
+ platform = excluded.platform,
4076
+ device_id = COALESCE(excluded.device_id, push_tokens.device_id),
4077
+ registered_at = excluded.registered_at,
4078
+ kind = excluded.kind,
4079
+ activity_id = COALESCE(excluded.activity_id, push_tokens.activity_id),
4080
+ session_id = COALESCE(excluded.session_id, push_tokens.session_id),
4081
+ expires_at = excluded.expires_at,
4082
+ stale_date = excluded.stale_date,
4083
+ -- Preserve the ORIGINAL start across a re-registration. iOS renders its
4084
+ -- own ticking timer from started_at, so overwriting it with a fresh
4085
+ -- value visibly resets the user's elapsed time to zero.
4086
+ started_at = COALESCE(push_tokens.started_at, excluded.started_at),
4087
+ -- A fresh registration clears prior failure state and any revocation:
4088
+ -- the client is telling us this token is live again. renewed_at clears
4089
+ -- too \u2014 this is a new activity generation, so it is renewable again.
4090
+ failure_streak = 0,
4091
+ last_failure_at = NULL,
4092
+ last_failure_code = NULL,
4093
+ revoked_at = NULL,
4094
+ renewed_at = NULL
4095
+ `);
4096
+ this.getStmt = db.prepare("SELECT * FROM push_tokens WHERE token = ?");
4097
+ this.listActiveStmt = db.prepare(`
4098
+ SELECT * FROM push_tokens
4099
+ WHERE revoked_at IS NULL AND failure_streak < ${FAILURE_STREAK_LIMIT}
4100
+ AND kind = 'expo'
4101
+ ORDER BY registered_at ASC
4102
+ `);
4103
+ this.listAllStmt = db.prepare("SELECT * FROM push_tokens ORDER BY registered_at ASC");
4104
+ this.successStmt = db.prepare(`
4105
+ UPDATE push_tokens
4106
+ SET last_success_at = @at, failure_streak = 0,
4107
+ last_failure_code = NULL
4108
+ WHERE token = @token
4109
+ `);
4110
+ this.failureStmt = db.prepare(`
4111
+ UPDATE push_tokens
4112
+ SET last_failure_at = @at, last_failure_code = @code,
4113
+ failure_streak = failure_streak + 1
4114
+ WHERE token = @token
4115
+ `);
4116
+ this.revokeStmt = db.prepare("UPDATE push_tokens SET revoked_at = ? WHERE token = ?");
4117
+ this.listByKindSessionStmt = db.prepare(`
4118
+ SELECT * FROM push_tokens
4119
+ WHERE kind = @kind AND session_id = @session_id
4120
+ AND revoked_at IS NULL AND failure_streak < ${FAILURE_STREAK_LIMIT}
4121
+ AND (expires_at IS NULL OR expires_at > @now)
4122
+ ORDER BY registered_at ASC
4123
+ `);
4124
+ this.listByKindStmt = db.prepare(`
4125
+ SELECT * FROM push_tokens
4126
+ WHERE kind = @kind
4127
+ AND revoked_at IS NULL AND failure_streak < ${FAILURE_STREAK_LIMIT}
4128
+ AND (expires_at IS NULL OR expires_at > @now)
4129
+ ORDER BY registered_at ASC
4130
+ `);
4131
+ this.listRenewableStmt = db.prepare(`
4132
+ SELECT * FROM push_tokens
4133
+ WHERE kind = 'liveactivity_update'
4134
+ AND stale_date IS NOT NULL AND renewed_at IS NULL
4135
+ AND revoked_at IS NULL AND failure_streak < ${FAILURE_STREAK_LIMIT}
4136
+ ORDER BY stale_date ASC
4137
+ `);
4138
+ this.claimRenewalStmt = db.prepare(`
4139
+ UPDATE push_tokens SET renewed_at = @at
4140
+ WHERE token = @token AND renewed_at IS NULL
4141
+ `);
4142
+ this.expireStmt = db.prepare("UPDATE push_tokens SET expires_at = ? WHERE token = ?");
4143
+ this.expireSessionActivitiesStmt = db.prepare(`
4144
+ UPDATE push_tokens SET expires_at = @at
4145
+ WHERE session_id = @session_id AND kind = 'liveactivity_update'
4146
+ AND (expires_at IS NULL OR expires_at > @at)
4147
+ `);
4148
+ this.claimEventStmt = db.prepare(`
4149
+ INSERT OR IGNORE INTO push_events (event_id, session_id, created_at)
4150
+ VALUES (@event_id, @session_id, @created_at)
4151
+ `);
4152
+ this.markDeliveredStmt = db.prepare(
4153
+ "UPDATE push_events SET delivered_at = ? WHERE event_id = ?"
4154
+ );
4155
+ }
4156
+ /**
4157
+ * Register or refresh a token.
4158
+ *
4159
+ * `kind` defaults to Expo so a released client posting `{ token, platform }`
4160
+ * keeps working — tb-mobile cannot be force-updated, and every client
4161
+ * predating Live Activities is registering an Expo relay token.
4162
+ *
4163
+ * Several rows per device is normal and intended: a device runs one activity
4164
+ * per live session, each with its own update token. The token itself is the
4165
+ * primary key, so distinct activities never collide.
4166
+ */
4167
+ register(args) {
4168
+ this.upsertStmt.run({
4169
+ token: args.token,
4170
+ platform: args.platform,
4171
+ device_id: args.deviceId ?? null,
4172
+ registered_at: args.now ?? Date.now(),
4173
+ kind: args.kind ?? DEFAULT_PUSH_TOKEN_KIND,
4174
+ activity_id: args.activityId ?? null,
4175
+ session_id: args.sessionId ?? null,
4176
+ expires_at: args.expiresAt ?? null,
4177
+ stale_date: args.staleDate ?? null,
4178
+ started_at: args.startedAt ?? null
4179
+ });
4180
+ }
4181
+ get(token) {
4182
+ return this.getStmt.get(token) ?? null;
4183
+ }
4184
+ /**
4185
+ * Expo tokens eligible for delivery — not revoked, not past the failure limit.
4186
+ *
4187
+ * Deliberately Expo-only. ActivityKit tokens go over direct APNs with a
4188
+ * different topic and are rejected by Expo's relay, so the ordinary
4189
+ * notification fan-out must not see them.
4190
+ */
4191
+ listDeliverable() {
4192
+ return this.listActiveStmt.all();
4193
+ }
4194
+ /** Live-activity tokens for one session, eligible for delivery. */
4195
+ listForSession(kind, sessionId, now = Date.now()) {
4196
+ return this.listByKindSessionStmt.all({
4197
+ kind,
4198
+ session_id: sessionId,
4199
+ now
4200
+ });
4201
+ }
4202
+ /**
4203
+ * Every deliverable token of one kind.
4204
+ *
4205
+ * Used for push-to-start, which is app-wide rather than session-scoped: the
4206
+ * activity does not exist yet, so there is no per-activity token to look up.
4207
+ */
4208
+ listByKind(kind, now = Date.now()) {
4209
+ return this.listByKindStmt.all({ kind, now });
4210
+ }
4211
+ /** Unrenewed activities with a renewal deadline, soonest first. */
4212
+ listRenewable() {
4213
+ return this.listRenewableStmt.all();
4214
+ }
4215
+ /**
4216
+ * Claim a row for renewal.
4217
+ *
4218
+ * Returns true exactly once per row. A restart re-arms timers from the
4219
+ * persisted deadline, so the same renewal can be attempted twice; the loser
4220
+ * gets false and must not send. Doing this as a conditional UPDATE rather
4221
+ * than read-then-write avoids the race where both attempts observe
4222
+ * "not yet renewed".
4223
+ */
4224
+ claimRenewal(token, now = Date.now()) {
4225
+ return this.claimRenewalStmt.run({ token, at: now }).changes > 0;
4226
+ }
4227
+ /** Mark one token expired, so it stops being a delivery target. */
4228
+ expire(token, now = Date.now()) {
4229
+ this.expireStmt.run(now, token);
4230
+ }
4231
+ /**
4232
+ * Expire every live activity for a session.
4233
+ *
4234
+ * Called when the session ends. Without this, a per-activity token outlives
4235
+ * its session and a later renewal sweep would resurrect an activity for a
4236
+ * session that is already gone.
4237
+ */
4238
+ expireSessionActivities(sessionId, now = Date.now()) {
4239
+ this.expireSessionActivitiesStmt.run({ session_id: sessionId, at: now });
4240
+ }
4241
+ /** Every token, including dead and revoked ones, for the health report. */
4242
+ listHealth(now = Date.now()) {
4243
+ return this.listAllStmt.all().map((r) => toHealth(r, now));
4244
+ }
4245
+ recordSuccess(token, now = Date.now()) {
4246
+ this.successStmt.run({ token, at: now });
4247
+ }
4248
+ recordFailure(token, code, now = Date.now()) {
4249
+ this.failureStmt.run({ token, at: now, code });
4250
+ }
4251
+ revoke(token, now = Date.now()) {
4252
+ return this.revokeStmt.run(now, token).changes > 0;
4253
+ }
4254
+ /**
4255
+ * Claim an event id for delivery.
4256
+ *
4257
+ * Returns true exactly once per event id. A retry, a reconnect
4258
+ * reconciliation, or two triggers firing for the same underlying event all
4259
+ * get false and must not notify — the user should never be told twice about
4260
+ * one thing.
4261
+ */
4262
+ claimEvent(eventId, sessionId, now = Date.now()) {
4263
+ return this.claimEventStmt.run({ event_id: eventId, session_id: sessionId, created_at: now }).changes > 0;
4264
+ }
4265
+ markDelivered(eventId, now = Date.now()) {
4266
+ this.markDeliveredStmt.run(now, eventId);
4267
+ }
4268
+ };
4269
+
3943
4270
  // src/api/routes/misc.routes.ts
4271
+ function numberOrNull(value) {
4272
+ return typeof value === "number" && Number.isFinite(value) ? value : null;
4273
+ }
3944
4274
  function readJsonBody(req) {
3945
4275
  return new Promise((resolve2, reject) => {
3946
4276
  const chunks = [];
@@ -3987,7 +4317,11 @@ var createMiscRoutes = (deps) => {
3987
4317
  // Capability flag: this server serves /api/config/claude-flags. Additive —
3988
4318
  // older clients ignore it, and clients talking to an older server see it
3989
4319
  // absent and hide the UI rather than 404ing.
3990
- claudeFlags: true
4320
+ claudeFlags: true,
4321
+ // Same contract: this server serves GET /api/config/feature-flags. Lives
4322
+ // here rather than behind /api/config (admin-only) so a read-only client
4323
+ // still learns the server supports flags even if it can't read values.
4324
+ featureFlags: true
3991
4325
  });
3992
4326
  });
3993
4327
  app.get("/api/profiles", (c) => c.json([]));
@@ -4014,6 +4348,22 @@ var createMiscRoutes = (deps) => {
4014
4348
  if (platform3 !== "ios" && platform3 !== "android") {
4015
4349
  return c.json({ error: "platform must be 'ios' or 'android'" }, 400);
4016
4350
  }
4351
+ const kind = body?.kind === void 0 ? DEFAULT_PUSH_TOKEN_KIND : body.kind;
4352
+ if (!isPushTokenKind(kind)) {
4353
+ return c.json(
4354
+ { error: `kind must be one of ${PUSH_TOKEN_KINDS.join(", ")}`, code: "INVALID_KIND" },
4355
+ 400
4356
+ );
4357
+ }
4358
+ if (kind === "liveactivity_update" && typeof body?.activityId !== "string") {
4359
+ return c.json(
4360
+ {
4361
+ error: "activityId is required for kind 'liveactivity_update'",
4362
+ code: "MISSING_ACTIVITY"
4363
+ },
4364
+ 400
4365
+ );
4366
+ }
4017
4367
  const repo = deps.pushRepo();
4018
4368
  if (!repo) {
4019
4369
  return c.json({ error: "Push registration is unavailable", code: "STORE_UNAVAILABLE" }, 503);
@@ -4021,7 +4371,13 @@ var createMiscRoutes = (deps) => {
4021
4371
  repo.register({
4022
4372
  token,
4023
4373
  platform: platform3,
4024
- deviceId: typeof body?.deviceId === "string" ? body.deviceId : null
4374
+ deviceId: typeof body?.deviceId === "string" ? body.deviceId : null,
4375
+ kind,
4376
+ activityId: typeof body?.activityId === "string" ? body.activityId : null,
4377
+ sessionId: typeof body?.sessionId === "string" ? body.sessionId : null,
4378
+ expiresAt: numberOrNull(body?.expiresAt),
4379
+ staleDate: numberOrNull(body?.staleDate),
4380
+ startedAt: numberOrNull(body?.startedAt)
4025
4381
  });
4026
4382
  return c.json({ ok: true });
4027
4383
  });
@@ -4320,6 +4676,14 @@ var createSessionRoutes = (deps) => {
4320
4676
  await deps.handleSetSessionName(c.req.param("id"), c.env.incoming, c.env.outgoing);
4321
4677
  return alreadyHandled6();
4322
4678
  });
4679
+ app.patch("/:id/model", async (c) => {
4680
+ await deps.handleSetSessionModel(c.req.param("id"), c.env.incoming, c.env.outgoing);
4681
+ return alreadyHandled6();
4682
+ });
4683
+ app.patch("/:id/effort", async (c) => {
4684
+ await deps.handleSetSessionEffort(c.req.param("id"), c.env.incoming, c.env.outgoing);
4685
+ return alreadyHandled6();
4686
+ });
4323
4687
  app.post("/:id/adopt", async (c) => {
4324
4688
  await deps.handleAdopt(c.req.param("id"), c.env.outgoing);
4325
4689
  return alreadyHandled6();
@@ -4657,6 +5021,7 @@ CREATE TABLE IF NOT EXISTS conversation_meta (
4657
5021
  );
4658
5022
  CREATE INDEX IF NOT EXISTS idx_meta_last_activity ON conversation_meta(last_activity DESC);
4659
5023
  CREATE INDEX IF NOT EXISTS idx_meta_project ON conversation_meta(project_path);
5024
+ CREATE INDEX IF NOT EXISTS idx_meta_file_path ON conversation_meta(file_path);
4660
5025
 
4661
5026
  CREATE TABLE IF NOT EXISTS conversation_tail (
4662
5027
  conversation_id TEXT PRIMARY KEY REFERENCES conversation_meta(id) ON DELETE CASCADE,
@@ -6118,164 +6483,45 @@ function deriveNameFromPath(path) {
6118
6483
  return parts.length > 0 ? parts[parts.length - 1] : null;
6119
6484
  }
6120
6485
 
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";
6486
+ // src/db/repositories/sessions.repository.ts
6487
+ var SessionsRepository = class {
6488
+ constructor(store) {
6489
+ this.store = store;
6490
+ }
6491
+ store;
6492
+ updateSessionProjectId(args) {
6493
+ this.store.updateManaged(args.sessionId, { projectId: args.projectId });
6494
+ }
6495
+ listManagedSessions() {
6496
+ return this.store.listManaged();
6497
+ }
6498
+ };
6499
+
6500
+ // src/db/upload-records.ts
6501
+ async function recordUpload(pool2, instanceId, row) {
6502
+ if (!pool2) return;
6503
+ await pool2.query(
6504
+ `INSERT INTO session_uploads
6505
+ (id, session_id, instance_id, file_path, original_name, mime_type, size_bytes)
6506
+ VALUES ($1, $2, $3, $4, $5, $6, $7)`,
6507
+ [
6508
+ row.id,
6509
+ row.sessionId,
6510
+ instanceId,
6511
+ row.filePath,
6512
+ row.originalName,
6513
+ row.mimeType,
6514
+ row.sizeBytes
6515
+ ]
6516
+ );
6129
6517
  }
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
- // src/db/repositories/sessions.repository.ts
6241
- var SessionsRepository = class {
6242
- constructor(store) {
6243
- this.store = store;
6244
- }
6245
- store;
6246
- updateSessionProjectId(args) {
6247
- this.store.updateManaged(args.sessionId, { projectId: args.projectId });
6248
- }
6249
- listManagedSessions() {
6250
- return this.store.listManaged();
6251
- }
6252
- };
6253
-
6254
- // src/db/upload-records.ts
6255
- async function recordUpload(pool2, instanceId, row) {
6256
- if (!pool2) return;
6257
- await pool2.query(
6258
- `INSERT INTO session_uploads
6259
- (id, session_id, instance_id, file_path, original_name, mime_type, size_bytes)
6260
- VALUES ($1, $2, $3, $4, $5, $6, $7)`,
6261
- [
6262
- row.id,
6263
- row.sessionId,
6264
- instanceId,
6265
- row.filePath,
6266
- row.originalName,
6267
- row.mimeType,
6268
- row.sizeBytes
6269
- ]
6270
- );
6271
- }
6272
-
6273
- // src/handlers/handleListProjects.ts
6274
- import { readdirSync as readdirSync3, statSync as statSync4 } from "fs";
6275
- import { homedir as homedir6 } from "os";
6276
- import { join as join13 } from "path";
6277
- function decodeProjectPath(dirName) {
6278
- return dirName.replace(/-/g, "/");
6518
+
6519
+ // src/handlers/handleListProjects.ts
6520
+ import { readdirSync as readdirSync3, statSync as statSync4 } from "fs";
6521
+ import { homedir as homedir6 } from "os";
6522
+ import { join as join13 } from "path";
6523
+ function decodeProjectPath(dirName) {
6524
+ return dirName.replace(/-/g, "/");
6279
6525
  }
6280
6526
  function handleListProjects(url, res) {
6281
6527
  const limit = Math.max(1, parseInt(url.searchParams.get("limit") ?? "50", 10) || 50);
@@ -6469,10 +6715,10 @@ function fingerprintOf(ids) {
6469
6715
  return `sha256:${createHash3("sha256").update(sorted.join("\n")).digest("hex")}`;
6470
6716
  }
6471
6717
  var CacheIntegrityMonitor = class {
6472
- constructor(cache, wsHub, log3, cacheDir, rescan, runDuringReset) {
6718
+ constructor(cache, wsHub, log7, cacheDir, rescan, runDuringReset) {
6473
6719
  this.cache = cache;
6474
6720
  this.wsHub = wsHub;
6475
- this.log = log3;
6721
+ this.log = log7;
6476
6722
  this.cacheDir = cacheDir;
6477
6723
  this.rescan = rescan;
6478
6724
  this.runDuringReset = runDuringReset;
@@ -7085,6 +7331,577 @@ function deriveProjectChatTitle(input) {
7085
7331
  return `Untitled \xB7 ${input.id.slice(0, 8)}`;
7086
7332
  }
7087
7333
 
7334
+ // src/services/push/apnsClient.ts
7335
+ import { createSign } from "crypto";
7336
+ import { connect, constants } from "http2";
7337
+ var log3 = getLogger("apns");
7338
+ var APNS_HOST_SANDBOX = "api.sandbox.push.apple.com";
7339
+ var APNS_MAX_PAYLOAD_BYTES = 4096;
7340
+ var JWT_TTL_SECONDS = 3e3;
7341
+ var DEAD_TOKEN_REASONS = /* @__PURE__ */ new Set([
7342
+ "BadDeviceToken",
7343
+ "DeviceTokenNotForTopic",
7344
+ "Unregistered",
7345
+ "ExpiredToken"
7346
+ ]);
7347
+ function base64url(input) {
7348
+ return Buffer.from(input).toString("base64url");
7349
+ }
7350
+ function readApnsCredentialsFromEnv(env = process.env) {
7351
+ const key = env.APNS_KEY;
7352
+ if (!key || key.trim().length === 0) return null;
7353
+ const keyId = env.APNS_KEY_ID?.trim();
7354
+ const teamId = env.APNS_TEAM_ID?.trim();
7355
+ const bundleId = env.APNS_BUNDLE_ID?.trim();
7356
+ if (!keyId || !teamId || !bundleId) return null;
7357
+ const host = env.APNS_HOST ?? APNS_HOST_SANDBOX;
7358
+ return { key, keyId, teamId, bundleId, host };
7359
+ }
7360
+ function describeMissingApnsCredentials(env = process.env) {
7361
+ if (!env.APNS_KEY || env.APNS_KEY.trim().length === 0) {
7362
+ 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.";
7363
+ }
7364
+ const missing = [
7365
+ ["APNS_KEY_ID", env.APNS_KEY_ID],
7366
+ ["APNS_TEAM_ID", env.APNS_TEAM_ID],
7367
+ ["APNS_BUNDLE_ID", env.APNS_BUNDLE_ID]
7368
+ ].filter(([, value]) => !value || value.trim().length === 0).map(([name]) => name);
7369
+ if (missing.length === 0) return null;
7370
+ 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.`;
7371
+ }
7372
+ var ApnsClient = class {
7373
+ constructor(creds) {
7374
+ this.creds = creds;
7375
+ }
7376
+ creds;
7377
+ session = null;
7378
+ cachedJwt = null;
7379
+ /**
7380
+ * The `apns-topic` for Live Activity pushes.
7381
+ *
7382
+ * The `.push-type.liveactivity` suffix is mandatory and is why the signing key
7383
+ * must be Team Scoped (All Topics) — a key scoped to the bundle id alone
7384
+ * cannot sign this topic.
7385
+ */
7386
+ get topic() {
7387
+ return `${this.creds.bundleId}.push-type.liveactivity`;
7388
+ }
7389
+ /**
7390
+ * Mint or reuse the provider JWT.
7391
+ *
7392
+ * ES256 over the p8 key. Cached until shortly before expiry: Apple rejects a
7393
+ * token older than an hour, but minting one per request is wasteful and can
7394
+ * trip APNs' provider-token-update throttle.
7395
+ */
7396
+ getJwt(now = Date.now()) {
7397
+ const nowSeconds = Math.floor(now / 1e3);
7398
+ if (this.cachedJwt && this.cachedJwt.expiresAt > nowSeconds + 60) {
7399
+ return this.cachedJwt.token;
7400
+ }
7401
+ const header = base64url(JSON.stringify({ alg: "ES256", kid: this.creds.keyId, typ: "JWT" }));
7402
+ const payload = base64url(JSON.stringify({ iss: this.creds.teamId, iat: nowSeconds }));
7403
+ const signingInput = `${header}.${payload}`;
7404
+ const signature = createSign("SHA256").update(signingInput).sign({ key: this.creds.key, dsaEncoding: "ieee-p1363" });
7405
+ const token = `${signingInput}.${base64url(signature)}`;
7406
+ this.cachedJwt = { token, expiresAt: nowSeconds + JWT_TTL_SECONDS };
7407
+ return token;
7408
+ }
7409
+ /**
7410
+ * Reuse one HTTP/2 session across sends.
7411
+ *
7412
+ * APNs expects a long-lived connection; a fresh TLS handshake per push is slow
7413
+ * and Apple treats connection churn as abuse.
7414
+ */
7415
+ getSession() {
7416
+ if (this.session && !this.session.closed && !this.session.destroyed) {
7417
+ return this.session;
7418
+ }
7419
+ const session = connect(`https://${this.creds.host}`);
7420
+ session.on("error", (err) => {
7421
+ log3.warn("apns.session_error", { event: "apns.session_error", err: String(err) });
7422
+ });
7423
+ this.session = session;
7424
+ return session;
7425
+ }
7426
+ /**
7427
+ * Send one push.
7428
+ *
7429
+ * Resolves with a result rather than rejecting on an APNs rejection: a
7430
+ * rejected push is an expected outcome the caller must act on (retire the
7431
+ * token), not an exception. Only a genuinely unexpected local failure throws,
7432
+ * and the caller logs it.
7433
+ */
7434
+ async send(args) {
7435
+ const body = Buffer.from(JSON.stringify(args.payload), "utf-8");
7436
+ if (body.byteLength > APNS_MAX_PAYLOAD_BYTES) {
7437
+ throw new Error(
7438
+ `APNs payload is ${body.byteLength} bytes, over the ${APNS_MAX_PAYLOAD_BYTES} byte limit`
7439
+ );
7440
+ }
7441
+ const session = this.getSession();
7442
+ const headers = {
7443
+ [constants.HTTP2_HEADER_METHOD]: "POST",
7444
+ [constants.HTTP2_HEADER_PATH]: `/3/device/${args.deviceToken}`,
7445
+ [constants.HTTP2_HEADER_AUTHORIZATION]: `bearer ${this.getJwt()}`,
7446
+ "apns-push-type": "liveactivity",
7447
+ "apns-topic": this.topic,
7448
+ "apns-priority": String(args.priority ?? 10),
7449
+ ...args.expirationSeconds != null && {
7450
+ "apns-expiration": String(args.expirationSeconds)
7451
+ },
7452
+ [constants.HTTP2_HEADER_CONTENT_TYPE]: "application/json",
7453
+ [constants.HTTP2_HEADER_CONTENT_LENGTH]: String(body.byteLength)
7454
+ };
7455
+ return new Promise((resolve2, reject) => {
7456
+ const req = session.request(headers);
7457
+ req.setTimeout(args.timeoutMs ?? 1e4, () => {
7458
+ req.close(constants.NGHTTP2_CANCEL);
7459
+ resolve2({ ok: false, status: 0, reason: "Timeout", tokenDead: false });
7460
+ });
7461
+ let status = 0;
7462
+ req.on("response", (resHeaders) => {
7463
+ status = Number(resHeaders[constants.HTTP2_HEADER_STATUS] ?? 0);
7464
+ });
7465
+ const chunks = [];
7466
+ req.on("data", (chunk) => chunks.push(chunk));
7467
+ req.on("error", reject);
7468
+ req.on("end", () => {
7469
+ const raw = Buffer.concat(chunks).toString("utf-8");
7470
+ let reason;
7471
+ if (raw.length > 0) {
7472
+ try {
7473
+ reason = JSON.parse(raw).reason;
7474
+ } catch {
7475
+ reason = raw.slice(0, 200);
7476
+ }
7477
+ }
7478
+ resolve2({
7479
+ ok: status === 200,
7480
+ status,
7481
+ reason,
7482
+ tokenDead: reason != null && DEAD_TOKEN_REASONS.has(reason)
7483
+ });
7484
+ });
7485
+ req.end(body);
7486
+ });
7487
+ }
7488
+ /** Close the shared connection. Called on server shutdown. */
7489
+ close() {
7490
+ this.session?.close();
7491
+ this.session = null;
7492
+ }
7493
+ };
7494
+
7495
+ // src/services/push/liveActivityContentState.ts
7496
+ var LAST_OUTPUT_MAX_LENGTH = 90;
7497
+ function toLiveActivityStatus(status) {
7498
+ return status === "running" || status === "waiting_input" ? status : null;
7499
+ }
7500
+ function truncateLastOutput(raw) {
7501
+ const oneLine = raw.replace(/\s+/g, " ").trim();
7502
+ return oneLine.length <= LAST_OUTPUT_MAX_LENGTH ? oneLine : oneLine.slice(0, LAST_OUTPUT_MAX_LENGTH);
7503
+ }
7504
+
7505
+ // src/services/push/liveActivityNotifier.ts
7506
+ var log4 = getLogger("live-activity");
7507
+ function contentStateForSession(args) {
7508
+ const status = toLiveActivityStatus(args.session.status);
7509
+ if (!status) return null;
7510
+ return {
7511
+ sessionId: args.session.id,
7512
+ serverId: args.serverId,
7513
+ projectName: args.session.projectName,
7514
+ status,
7515
+ startedAt: args.startedAtOverride ?? args.session.startedAt.getTime(),
7516
+ lastOutput: truncateLastOutput(args.session.lastOutput ?? ""),
7517
+ ...args.serverLabel != null && { serverLabel: args.serverLabel }
7518
+ };
7519
+ }
7520
+ var LiveActivityNotifier = class {
7521
+ constructor(sender, serverId, serverLabel) {
7522
+ this.sender = sender;
7523
+ this.serverId = serverId;
7524
+ this.serverLabel = serverLabel;
7525
+ }
7526
+ sender;
7527
+ serverId;
7528
+ serverLabel;
7529
+ /**
7530
+ * Last status pushed per session.
7531
+ *
7532
+ * Live Activity pushes are rate-limited by iOS and the surface only renders
7533
+ * `running` vs `waiting_input`, so re-pushing an unchanged status is pure
7534
+ * budget spend for no visible change. This is what makes the notifier
7535
+ * edge-triggered rather than level-triggered.
7536
+ */
7537
+ lastPushed = /* @__PURE__ */ new Map();
7538
+ /**
7539
+ * React to a session status change.
7540
+ *
7541
+ * Fire-and-forget by design: a push must never delay or fail a session
7542
+ * transition, so this returns a promise the caller may ignore and every error
7543
+ * is logged rather than propagated.
7544
+ */
7545
+ async onStatusChange(session) {
7546
+ const status = toLiveActivityStatus(session.status);
7547
+ try {
7548
+ if (!status) {
7549
+ await this.endFor(session);
7550
+ return;
7551
+ }
7552
+ if (this.lastPushed.get(session.id) === status) return;
7553
+ const contentState = contentStateForSession({
7554
+ session,
7555
+ serverId: this.serverId,
7556
+ serverLabel: this.serverLabel
7557
+ });
7558
+ if (!contentState) return;
7559
+ const outcome = await this.sender.send({
7560
+ sessionId: session.id,
7561
+ event: "update",
7562
+ contentState
7563
+ });
7564
+ this.lastPushed.set(session.id, status);
7565
+ if (outcome.attempted > 0) {
7566
+ log4.info("live_activity.updated", {
7567
+ event: "live_activity.updated",
7568
+ sessionId: session.id,
7569
+ status,
7570
+ ...outcome
7571
+ });
7572
+ }
7573
+ } catch (err) {
7574
+ log4.error("live_activity.notify_failed", {
7575
+ event: "live_activity.notify_failed",
7576
+ sessionId: session.id,
7577
+ status: session.status,
7578
+ err: String(err)
7579
+ });
7580
+ }
7581
+ }
7582
+ async endFor(session) {
7583
+ const lastStatus = this.lastPushed.get(session.id);
7584
+ this.lastPushed.delete(session.id);
7585
+ const contentState = contentStateForSession({
7586
+ session: {
7587
+ ...session,
7588
+ status: lastStatus === "waiting_input" ? "waiting_input" : "running"
7589
+ },
7590
+ serverId: this.serverId,
7591
+ serverLabel: this.serverLabel
7592
+ });
7593
+ if (!contentState) return;
7594
+ const outcome = await this.sender.end({ sessionId: session.id, contentState });
7595
+ if (outcome.attempted > 0) {
7596
+ log4.info("live_activity.ended", {
7597
+ event: "live_activity.ended",
7598
+ sessionId: session.id,
7599
+ ...outcome
7600
+ });
7601
+ }
7602
+ }
7603
+ /** Drop cached state for a session, so a resume re-pushes its first status. */
7604
+ forget(sessionId) {
7605
+ this.lastPushed.delete(sessionId);
7606
+ }
7607
+ };
7608
+
7609
+ // src/services/push/liveActivitySender.ts
7610
+ var log5 = getLogger("live-activity");
7611
+ var ACTIVITY_MAX_LIFETIME_MS = 8 * 60 * 60 * 1e3;
7612
+ function buildActivityKitPayload(args) {
7613
+ return {
7614
+ aps: {
7615
+ timestamp: Math.floor(args.now / 1e3),
7616
+ event: args.event,
7617
+ "content-state": args.contentState,
7618
+ ...args.staleDate != null && { "stale-date": Math.floor(args.staleDate / 1e3) },
7619
+ ...args.dismissalDate != null && {
7620
+ "dismissal-date": Math.floor(args.dismissalDate / 1e3)
7621
+ }
7622
+ }
7623
+ };
7624
+ }
7625
+ var LiveActivitySender = class {
7626
+ constructor(apns, repo) {
7627
+ this.apns = apns;
7628
+ this.repo = repo;
7629
+ }
7630
+ apns;
7631
+ repo;
7632
+ /**
7633
+ * Push to every live activity of a session.
7634
+ *
7635
+ * Sends are independent: one rejected token must not stop the others, because
7636
+ * a single dead device would otherwise silence every other device watching the
7637
+ * same session.
7638
+ */
7639
+ async send(args) {
7640
+ const now = args.now ?? Date.now();
7641
+ return this.sendToTokens({
7642
+ tokens: this.repo.listForSession("liveactivity_update", args.sessionId, now),
7643
+ sessionId: args.sessionId,
7644
+ event: args.event,
7645
+ contentState: args.contentState,
7646
+ now,
7647
+ priority: args.priority
7648
+ });
7649
+ }
7650
+ /**
7651
+ * Push to an explicit token list.
7652
+ *
7653
+ * Renewal needs this: a replacement activity does not exist yet, so it is
7654
+ * started via the app-wide push-to-start token rather than any per-session
7655
+ * lookup. Shares one fan-out body with `send()` so failure handling cannot
7656
+ * drift between the two paths.
7657
+ */
7658
+ async sendToTokens(args) {
7659
+ const now = args.now ?? Date.now();
7660
+ const tokens = args.tokens;
7661
+ const outcome = {
7662
+ attempted: tokens.length,
7663
+ succeeded: 0,
7664
+ retired: 0
7665
+ };
7666
+ if (tokens.length === 0) return outcome;
7667
+ const results = await Promise.all(
7668
+ tokens.map(
7669
+ (row) => this.sendToToken(row, args.event, args.contentState, now, args.priority, args.staleDate)
7670
+ )
7671
+ );
7672
+ for (const { row, result, error } of results) {
7673
+ if (error) {
7674
+ log5.error("live_activity.send_failed", {
7675
+ event: "live_activity.send_failed",
7676
+ sessionId: args.sessionId,
7677
+ activityId: row.activity_id,
7678
+ apnsEvent: args.event,
7679
+ err: String(error)
7680
+ });
7681
+ this.repo.recordFailure(row.token, "SendError", now);
7682
+ continue;
7683
+ }
7684
+ if (!result) continue;
7685
+ if (result.ok) {
7686
+ this.repo.recordSuccess(row.token, now);
7687
+ outcome.succeeded += 1;
7688
+ continue;
7689
+ }
7690
+ this.repo.recordFailure(row.token, result.reason ?? `HTTP_${result.status}`, now);
7691
+ if (result.tokenDead) {
7692
+ this.repo.expire(row.token, now);
7693
+ outcome.retired += 1;
7694
+ }
7695
+ log5.warn("live_activity.send_rejected", {
7696
+ event: "live_activity.send_rejected",
7697
+ sessionId: args.sessionId,
7698
+ activityId: row.activity_id,
7699
+ apnsEvent: args.event,
7700
+ status: result.status,
7701
+ reason: result.reason,
7702
+ tokenDead: result.tokenDead
7703
+ });
7704
+ }
7705
+ return outcome;
7706
+ }
7707
+ /**
7708
+ * End every live activity for a session and stop tracking them.
7709
+ *
7710
+ * Expiring locally is what stops the renewal sweep from later resurrecting an
7711
+ * activity for a session that has already finished.
7712
+ */
7713
+ async end(args) {
7714
+ const now = args.now ?? Date.now();
7715
+ const outcome = await this.send({
7716
+ sessionId: args.sessionId,
7717
+ event: "end",
7718
+ contentState: args.contentState,
7719
+ now
7720
+ });
7721
+ this.repo.expireSessionActivities(args.sessionId, now);
7722
+ return outcome;
7723
+ }
7724
+ async sendToToken(row, event, contentState, now, priority, staleDateOverride) {
7725
+ const staleDate = event === "update" ? staleDateOverride ?? row.stale_date ?? contentState.startedAt + ACTIVITY_MAX_LIFETIME_MS : null;
7726
+ try {
7727
+ const result = await this.apns.send({
7728
+ deviceToken: row.token,
7729
+ payload: buildActivityKitPayload({ event, contentState, now, staleDate }),
7730
+ priority
7731
+ });
7732
+ return { row, result };
7733
+ } catch (error) {
7734
+ return { row, error };
7735
+ }
7736
+ }
7737
+ };
7738
+
7739
+ // src/services/push/liveActivityRenewal.ts
7740
+ var log6 = getLogger("live-activity");
7741
+ var RENEWAL_LEAD_MS = 30 * 60 * 1e3;
7742
+ var MAX_TIMER_MS = 60 * 60 * 1e3;
7743
+ function renewalDueAt(row) {
7744
+ return row.stale_date == null ? null : row.stale_date - RENEWAL_LEAD_MS;
7745
+ }
7746
+ var LiveActivityRenewalScheduler = class {
7747
+ constructor(deps) {
7748
+ this.deps = deps;
7749
+ this.now = deps.now ?? (() => Date.now());
7750
+ }
7751
+ deps;
7752
+ timer = null;
7753
+ stopped = false;
7754
+ now;
7755
+ /**
7756
+ * Arm the scheduler from persisted state.
7757
+ *
7758
+ * Called on boot, which is what makes a renewal survive a restart: the
7759
+ * deadlines were never in memory to begin with.
7760
+ */
7761
+ start() {
7762
+ this.stopped = false;
7763
+ void this.tick();
7764
+ }
7765
+ stop() {
7766
+ this.stopped = true;
7767
+ if (this.timer) {
7768
+ clearTimeout(this.timer);
7769
+ this.timer = null;
7770
+ }
7771
+ }
7772
+ /**
7773
+ * Renew everything due, then sleep until the next deadline.
7774
+ *
7775
+ * Re-reads from the DB every tick rather than caching a schedule in memory, so
7776
+ * an activity registered after boot is picked up without re-arming anything.
7777
+ */
7778
+ async tick() {
7779
+ if (this.stopped) return;
7780
+ const now = this.now();
7781
+ try {
7782
+ for (const row of this.deps.repo.listRenewable()) {
7783
+ const dueAt = renewalDueAt(row);
7784
+ if (dueAt == null || dueAt > now) continue;
7785
+ await this.renew(row, now);
7786
+ }
7787
+ } catch (err) {
7788
+ log6.error("live_activity.renewal_sweep_failed", {
7789
+ event: "live_activity.renewal_sweep_failed",
7790
+ err: String(err)
7791
+ });
7792
+ }
7793
+ this.scheduleNext();
7794
+ }
7795
+ scheduleNext() {
7796
+ if (this.stopped) return;
7797
+ const now = this.now();
7798
+ const pending = this.deps.repo.listRenewable().map(renewalDueAt).filter((d) => d != null);
7799
+ const nextDue = pending.length > 0 ? Math.min(...pending) : now + MAX_TIMER_MS;
7800
+ const delay = Math.min(Math.max(nextDue - now, 0), MAX_TIMER_MS);
7801
+ this.timer = setTimeout(() => void this.tick(), delay);
7802
+ this.timer.unref?.();
7803
+ }
7804
+ /**
7805
+ * Renew one activity.
7806
+ *
7807
+ * Claims first: `claimRenewal()` succeeds exactly once per row, so a timer
7808
+ * re-armed after a restart mid-window cannot send a second time.
7809
+ */
7810
+ async renew(row, now) {
7811
+ if (!row.session_id) return;
7812
+ const session = this.deps.sessionStore.getManaged(row.session_id);
7813
+ const status = session ? toLiveActivityStatus(session.status) : null;
7814
+ if (!session || !status) {
7815
+ this.deps.repo.claimRenewal(row.token, now);
7816
+ this.deps.repo.expire(row.token, now);
7817
+ log6.info("live_activity.renewal_skipped", {
7818
+ event: "live_activity.renewal_skipped",
7819
+ sessionId: row.session_id,
7820
+ activityId: row.activity_id,
7821
+ reason: session ? `status_${session.status}` : "session_gone"
7822
+ });
7823
+ return;
7824
+ }
7825
+ if (!this.deps.repo.claimRenewal(row.token, now)) {
7826
+ return;
7827
+ }
7828
+ const startedAt = row.started_at ?? session.startedAt.getTime();
7829
+ const contentState = {
7830
+ sessionId: session.id,
7831
+ serverId: this.deps.serverId,
7832
+ projectName: session.projectName,
7833
+ status,
7834
+ startedAt,
7835
+ lastOutput: session.lastOutput ?? "",
7836
+ ...this.deps.serverLabel != null && { serverLabel: this.deps.serverLabel }
7837
+ };
7838
+ try {
7839
+ await this.deps.sender.send({
7840
+ sessionId: session.id,
7841
+ event: "end",
7842
+ contentState: { ...contentState, lastOutput: truncateLastOutput(contentState.lastOutput) },
7843
+ now
7844
+ });
7845
+ this.deps.repo.expire(row.token, now);
7846
+ const started = await this.startReplacement({
7847
+ sessionId: session.id,
7848
+ startedAt,
7849
+ now
7850
+ });
7851
+ log6.info("live_activity.renewed", {
7852
+ event: "live_activity.renewed",
7853
+ sessionId: session.id,
7854
+ activityId: row.activity_id,
7855
+ // Logged because a regression here is invisible on the server and only
7856
+ // shows up as a reset timer on someone's Lock Screen.
7857
+ startedAt,
7858
+ replacementRequested: started
7859
+ });
7860
+ } catch (err) {
7861
+ log6.error("live_activity.renewal_failed", {
7862
+ event: "live_activity.renewal_failed",
7863
+ sessionId: session.id,
7864
+ activityId: row.activity_id,
7865
+ err: String(err)
7866
+ });
7867
+ }
7868
+ }
7869
+ /**
7870
+ * Ask the device to start a replacement activity.
7871
+ *
7872
+ * Uses the app-wide push-to-start token, because the replacement does not
7873
+ * exist yet and therefore has no per-activity token. Returns false when the
7874
+ * device never registered one, which is not an error: the app simply cannot be
7875
+ * asked to start an activity remotely, and the next foreground WS update
7876
+ * recreates it.
7877
+ */
7878
+ async startReplacement(args) {
7879
+ const starters = this.deps.repo.listByKind("liveactivity_start", args.now);
7880
+ if (starters.length === 0) return false;
7881
+ const session = this.deps.sessionStore.getManaged(args.sessionId);
7882
+ const status = session ? toLiveActivityStatus(session.status) : null;
7883
+ if (!session || !status) return false;
7884
+ await this.deps.sender.sendToTokens({
7885
+ tokens: starters,
7886
+ event: "update",
7887
+ sessionId: args.sessionId,
7888
+ contentState: {
7889
+ sessionId: session.id,
7890
+ serverId: this.deps.serverId,
7891
+ projectName: session.projectName,
7892
+ status,
7893
+ // Carried through unchanged — the whole point of the renewal.
7894
+ startedAt: args.startedAt,
7895
+ lastOutput: truncateLastOutput(session.lastOutput ?? ""),
7896
+ ...this.deps.serverLabel != null && { serverLabel: this.deps.serverLabel }
7897
+ },
7898
+ now: args.now,
7899
+ staleDate: args.startedAt + ACTIVITY_MAX_LIFETIME_MS
7900
+ });
7901
+ return true;
7902
+ }
7903
+ };
7904
+
7088
7905
  // src/services/questions/parseStatusLine.ts
7089
7906
  var MODEL_RE = /(Opus|Sonnet|Haiku|Fable)\s+[\d.]+(?:\s*\([^)]*\))?/;
7090
7907
  var EFFORT_RE = /●\s*([A-Za-z]+)\s*·\s*\/effort/;
@@ -7862,6 +8679,24 @@ var WSHub = class {
7862
8679
  this.clients.delete(client);
7863
8680
  }
7864
8681
  }
8682
+ // Scoped broadcast for high-frequency per-session messages (terminal_output,
8683
+ // user_message). Sending to every connected client for every PTY output
8684
+ // chunk made broadcast() cost scale with connections x active sessions;
8685
+ // this bounds it to only that session's subscribers.
8686
+ broadcastToClients(clients, message) {
8687
+ const data = JSON.stringify(message);
8688
+ for (const client of clients) {
8689
+ try {
8690
+ if (client.readyState === client.OPEN) {
8691
+ client.send(data);
8692
+ } else {
8693
+ this.clients.delete(client);
8694
+ }
8695
+ } catch {
8696
+ this.clients.delete(client);
8697
+ }
8698
+ }
8699
+ }
7865
8700
  unicast(ws, message) {
7866
8701
  try {
7867
8702
  if (ws.readyState === ws.OPEN) {
@@ -7924,6 +8759,7 @@ var ADOPT_KILL_TIMEOUT_MS = 5e3;
7924
8759
  var ADOPT_KILL_POLL_MS = 100;
7925
8760
  var REFRESH_TTL_MS = 2e3;
7926
8761
  var START_READY_TIMEOUT_MS = 1e4;
8762
+ var MODEL_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
7927
8763
  var EXTERNAL_TAIL_RECENCY_MS = RESUME_BUSY_WINDOW_MS;
7928
8764
  var EXTERNAL_TAIL_MAX = 32;
7929
8765
  var EXTERNAL_TAIL_IDLE_MS = 3e5;
@@ -8027,6 +8863,8 @@ var StreamerServer = class {
8027
8863
  dbPool = null;
8028
8864
  dbInstanceId = null;
8029
8865
  disableDb = false;
8866
+ // Skip the startup warm-up scan (test hook; see ServerConfig.skipStartupWarmup).
8867
+ skipStartupWarmup;
8030
8868
  browseRoot = null;
8031
8869
  publicUrl = null;
8032
8870
  browserCors;
@@ -8036,6 +8874,12 @@ var StreamerServer = class {
8036
8874
  sessionInputAttempts = /* @__PURE__ */ new Map();
8037
8875
  ptyGracePeriodMs;
8038
8876
  defaultSystemPrompt;
8877
+ // Resolved once at boot; see src/feature-flags.ts. Total map — every registry
8878
+ // id is present, so indexing it never yields undefined.
8879
+ featureFlags;
8880
+ // Derived from featureFlags.codexSystemPrompt. Kept as its own field so the
8881
+ // read site in startFresh() is unchanged.
8882
+ codexSystemPromptEnabled;
8039
8883
  defaultPermissionMode;
8040
8884
  defaultModel;
8041
8885
  defaultEffort;
@@ -8060,6 +8904,11 @@ var StreamerServer = class {
8060
8904
  // every provider; read only by the idle reaper. Entries are dropped when the
8061
8905
  // session leaves the runner (reap/exit/hold).
8062
8906
  lastAgentChunkAt = /* @__PURE__ */ new Map();
8907
+ // sessionId → last terminal_output seq broadcast (starts at 1, per session).
8908
+ // Stamped on every terminal_output/terminal_replay so a client can detect a
8909
+ // stale chunk delivered after a reconnect race instead of trusting raw WS
8910
+ // arrival order. Entries dropped alongside lastAgentChunkAt.
8911
+ terminalSeq = /* @__PURE__ */ new Map();
8063
8912
  // Recently accepted input idempotency keys (C4). A retried POST replays its
8064
8913
  // original outcome instead of submitting the prompt to the agent twice.
8065
8914
  idempotency = new IdempotencyStore();
@@ -8091,6 +8940,12 @@ var StreamerServer = class {
8091
8940
  // Paired-device registry (C5). Null when the cache DB failed to open — auth
8092
8941
  // then falls back to the shared API key alone, which is the pre-C5 behaviour.
8093
8942
  devicesRepo = null;
8943
+ // Live Activity push (Feature 12). Null when APNS_KEY is unset — the ordinary
8944
+ // case on a dev machine and in CI, where the feature is simply off. Missing an
8945
+ // optional push credential must never stop the server from booting.
8946
+ apnsClient = null;
8947
+ liveActivityNotifier = null;
8948
+ liveActivityRenewal = null;
8094
8949
  discoveryCache = null;
8095
8950
  cacheDir;
8096
8951
  tailSize;
@@ -8121,11 +8976,17 @@ var StreamerServer = class {
8121
8976
  }
8122
8977
  this.verbose = config.verbose ?? false;
8123
8978
  this.disableDb = config.disableDb ?? false;
8979
+ this.skipStartupWarmup = config.skipStartupWarmup ?? false;
8124
8980
  this.scannerPersistenceDisabled = config.scannerPersistent === false;
8125
8981
  this.scanProfiles = config.scanProfiles;
8126
8982
  this.codexRoots = config.codexRoots ?? [join18(homedir9(), ".codex", "sessions")];
8127
8983
  this.ptyGracePeriodMs = config.ptyGracePeriodMs ?? DEFAULT_PTY_GRACE_PERIOD_MS;
8128
8984
  this.defaultSystemPrompt = config.defaultSystemPrompt ?? DEFAULT_SYSTEM_PROMPT;
8985
+ this.featureFlags = resolveFeatureFlags({ cli: config.featureFlags, yaml: loadFeatureFlags() });
8986
+ if (config.codexSystemPromptEnabled !== void 0) {
8987
+ this.featureFlags.codexSystemPrompt = config.codexSystemPromptEnabled;
8988
+ }
8989
+ this.codexSystemPromptEnabled = this.featureFlags.codexSystemPrompt;
8129
8990
  this.defaultPermissionMode = config.defaultPermissionMode ?? loadDefaultPermissionMode() ?? "acceptEdits";
8130
8991
  this.defaultModel = config.defaultModel ?? "sonnet";
8131
8992
  this.defaultEffort = config.defaultEffort ?? "low";
@@ -8141,6 +9002,13 @@ var StreamerServer = class {
8141
9002
  }, this.directoryDebounceMs);
8142
9003
  this.includeAgents = parseIncludeAgentsEnv(process.env.THREADBASE_INCLUDE_AGENTS);
8143
9004
  this.agentEntrypoints = parseAgentEntrypointsEnv(process.env.THREADBASE_AGENT_ENTRYPOINTS);
9005
+ const enabledFlags = nonDefaultFeatureFlags(this.featureFlags);
9006
+ if (enabledFlags.length > 0) {
9007
+ this.log.info(`Feature flags active: ${enabledFlags.join(", ")}`, {
9008
+ event: "config.feature_flags_active",
9009
+ flags: enabledFlags
9010
+ });
9011
+ }
8144
9012
  const rawRoot = process.env.THREADBASE_BROWSE_ROOT ?? loadBrowseRoot() ?? config.browseRoot;
8145
9013
  if (rawRoot) {
8146
9014
  realpath2(rawRoot).then((resolved) => {
@@ -8257,10 +9125,22 @@ var StreamerServer = class {
8257
9125
  logger: getLogger("pty"),
8258
9126
  onOutput: (sessionId, data) => {
8259
9127
  this.lastAgentChunkAt.set(sessionId, Date.now());
8260
- this.wsHub.broadcast({ type: "terminal_output", sessionId, data });
9128
+ const seq = (this.terminalSeq.get(sessionId) ?? 0) + 1;
9129
+ this.terminalSeq.set(sessionId, seq);
9130
+ this.wsHub.broadcastToClients(this.sessionSubscribers.get(sessionId) ?? [], {
9131
+ type: "terminal_output",
9132
+ sessionId,
9133
+ data,
9134
+ seq
9135
+ });
8261
9136
  },
8262
9137
  onUserMessage: (sessionId, text, ts) => {
8263
- this.wsHub.broadcast({ type: "user_message", sessionId, text, ts });
9138
+ this.wsHub.broadcastToClients(this.sessionSubscribers.get(sessionId) ?? [], {
9139
+ type: "user_message",
9140
+ sessionId,
9141
+ text,
9142
+ ts
9143
+ });
8264
9144
  },
8265
9145
  onPermissionChange: (sessionId, gate) => {
8266
9146
  this.handlePermissionChange(sessionId, gate);
@@ -8334,6 +9214,7 @@ var StreamerServer = class {
8334
9214
  if (resp) {
8335
9215
  this.wsHub.broadcast({ type: "session_update", session: resp });
8336
9216
  }
9217
+ void this.liveActivityNotifier?.onStatusChange(session);
8337
9218
  this.sessionStatusBus.emit(`status:${session.id}`, session.status);
8338
9219
  }
8339
9220
  });
@@ -8368,6 +9249,7 @@ var StreamerServer = class {
8368
9249
  logMenubarRequests: this.logMenubarRequests,
8369
9250
  rotateApiKey: () => this.rotateApiKey(),
8370
9251
  claudeFlagsConfig: () => this.getClaudeFlagsConfig(),
9252
+ featureFlagsConfig: () => this.getFeatureFlagsConfig(),
8371
9253
  setClaudeFlagsConfig: (values, extraArgs) => this.setClaudeFlagsConfig(values, extraArgs),
8372
9254
  publicUrl: this.publicUrl,
8373
9255
  browseRoot: this.browseRoot,
@@ -8395,6 +9277,8 @@ var StreamerServer = class {
8395
9277
  handleCancel: (id, res) => this.handleCancel(id, res),
8396
9278
  handleStopSession: (id, res) => this.handleStopSession(id, res),
8397
9279
  handleSetSessionName: (id, req, res) => this.handleSetSessionName(id, req, res),
9280
+ handleSetSessionModel: (id, req, res) => this.applyLiveSessionSetting(id, req, res, "model"),
9281
+ handleSetSessionEffort: (id, req, res) => this.applyLiveSessionSetting(id, req, res, "effort"),
8398
9282
  handleUploadFile: (id, req, res) => this.handleUploadFile(id, req, res),
8399
9283
  handleAdopt: (id, res) => this.handleAdopt(id, res),
8400
9284
  handleResume: (req, res) => this.handleResume(req, res),
@@ -8441,7 +9325,8 @@ var StreamerServer = class {
8441
9325
  type: "terminal_replay",
8442
9326
  sessionId: msg.sessionId,
8443
9327
  lines,
8444
- userMessages
9328
+ userMessages,
9329
+ seq: this.terminalSeq.get(msg.sessionId)
8445
9330
  })
8446
9331
  );
8447
9332
  }
@@ -8599,6 +9484,41 @@ var StreamerServer = class {
8599
9484
  }
8600
9485
  this.ptyGraceDeferCounts.delete(sessionId);
8601
9486
  }
9487
+ /**
9488
+ * Bring up Live Activity push, if credentials are present (Feature 12).
9489
+ *
9490
+ * APNS_KEY absent is the ordinary case on a dev machine and in CI, so this
9491
+ * logs once at info and leaves the feature off rather than failing: the server
9492
+ * must not refuse to boot over a missing optional push credential.
9493
+ *
9494
+ * The key is read from the environment as PEM contents and never from a path
9495
+ * on disk; neither it nor any device token is ever logged.
9496
+ */
9497
+ initLiveActivityPush(pushRepo) {
9498
+ const creds = readApnsCredentialsFromEnv();
9499
+ if (!creds) {
9500
+ const why = describeMissingApnsCredentials();
9501
+ if (why) this.log.info(why, { event: "live_activity.disabled" });
9502
+ return;
9503
+ }
9504
+ this.apnsClient = new ApnsClient(creds);
9505
+ const sender = new LiveActivitySender(this.apnsClient, pushRepo);
9506
+ const serverId = process.env.THREADBASE_INSTANCE_ID ?? hostname2();
9507
+ this.liveActivityNotifier = new LiveActivityNotifier(sender, serverId, hostname2());
9508
+ this.liveActivityRenewal = new LiveActivityRenewalScheduler({
9509
+ repo: pushRepo,
9510
+ sender,
9511
+ sessionStore: this.sessionStore,
9512
+ serverId,
9513
+ serverLabel: hostname2()
9514
+ });
9515
+ this.liveActivityRenewal.start();
9516
+ this.log.info("Live Activity push enabled", {
9517
+ event: "live_activity.enabled",
9518
+ host: creds.host,
9519
+ topic: `${creds.bundleId}.push-type.liveactivity`
9520
+ });
9521
+ }
8602
9522
  /**
8603
9523
  * Classify sessions left behind by previous streamer runs (C1 Phase 3a).
8604
9524
  *
@@ -8766,6 +9686,7 @@ var StreamerServer = class {
8766
9686
  );
8767
9687
  this.ptyManager.putOnHold(session.id);
8768
9688
  this.lastAgentChunkAt.delete(session.id);
9689
+ this.terminalSeq.delete(session.id);
8769
9690
  this.idempotency.clear(session.id);
8770
9691
  this.sessionSubscribers.delete(session.id);
8771
9692
  reaped.push(session.id);
@@ -8908,6 +9829,7 @@ var StreamerServer = class {
8908
9829
  this.cacheMetadataRepo = new CacheMetadataRepository(db);
8909
9830
  this.pushRepo = new PushRepository(db);
8910
9831
  this.devicesRepo = new DevicesRepository(db);
9832
+ this.initLiveActivityPush(this.pushRepo);
8911
9833
  this.cacheMonitor = new CacheIntegrityMonitor(
8912
9834
  this.cache,
8913
9835
  this.wsHub,
@@ -8939,6 +9861,14 @@ var StreamerServer = class {
8939
9861
  );
8940
9862
  this.scannerPersistenceDisabled = true;
8941
9863
  }
9864
+ if (this.skipStartupWarmup) {
9865
+ this.log.debug?.("startup warm-up scan skipped (skipStartupWarmup)", {
9866
+ event: "cache.warmup_skipped"
9867
+ });
9868
+ this.finishWarmup(0);
9869
+ resolveWarm();
9870
+ return;
9871
+ }
8942
9872
  const warmupStatCache = this.buildStatCache(null);
8943
9873
  const warmupScanner = this.newScanner(warmupStatCache ? { persistent: false } : void 0);
8944
9874
  this.allScanners.add(warmupScanner);
@@ -9130,6 +10060,7 @@ var StreamerServer = class {
9130
10060
  this.idleReaperTimer = null;
9131
10061
  }
9132
10062
  this.lastAgentChunkAt.clear();
10063
+ this.terminalSeq.clear();
9133
10064
  this.recordShutdownState();
9134
10065
  this.markScannerStaleDebounced.cancel();
9135
10066
  await Promise.all([...this.inFlightCacheWrites]);
@@ -9142,6 +10073,8 @@ var StreamerServer = class {
9142
10073
  this.externalTails.clear();
9143
10074
  this.wsHub.dispose();
9144
10075
  this.pairTokens.dispose();
10076
+ this.liveActivityRenewal?.stop();
10077
+ this.apnsClient?.close();
9145
10078
  if (this.dbPool) {
9146
10079
  await this.dbPool.end();
9147
10080
  }
@@ -9213,7 +10146,6 @@ var StreamerServer = class {
9213
10146
  json(res, 400, { error: message });
9214
10147
  return;
9215
10148
  }
9216
- const { hostname: hostname2 } = __require("os");
9217
10149
  const ts = (/* @__PURE__ */ new Date()).toISOString();
9218
10150
  this.log.info(`[pair] token exchanged from ${ip} at ${ts}`, {
9219
10151
  event: "pair.token_exchanged",
@@ -9258,6 +10190,16 @@ var StreamerServer = class {
9258
10190
  });
9259
10191
  return { newKey, persisted };
9260
10192
  }
10193
+ /**
10194
+ * The registry ships with the values so a client renders the list from one
10195
+ * round-trip, same as getClaudeFlagsConfig().
10196
+ *
10197
+ * Deliberately no `persisted` field: unlike claude-flags there is no PUT, and
10198
+ * the absence of that field is the signal that this endpoint is read-only.
10199
+ */
10200
+ getFeatureFlagsConfig() {
10201
+ return { registry: FEATURE_FLAGS, values: this.featureFlags };
10202
+ }
9261
10203
  getClaudeFlagsConfig() {
9262
10204
  return {
9263
10205
  registry: CLAUDE_FLAGS,
@@ -9300,6 +10242,30 @@ var StreamerServer = class {
9300
10242
  persisted: this.claudeFlagsPersistable
9301
10243
  };
9302
10244
  }
10245
+ /**
10246
+ * The three spawn options that a configured claude-flag can override, with
10247
+ * the boot-time CLI/yaml default as the fallback. Spread into every
10248
+ * start/resume/adopt call so all three paths agree.
10249
+ *
10250
+ * These ids are excluded from buildFlagArgs (SPAWN_POSITIONAL_FLAG_IDS)
10251
+ * precisely because they arrive here instead — the PTY spawn paths pass them
10252
+ * as explicit positionals, so emitting them from the allowlist too would
10253
+ * duplicate the flag.
10254
+ *
10255
+ * Narrowed with the type guards rather than cast: ClaudeFlagValues is a loose
10256
+ * Record by design, and while validateFlagValues already guarantees the shape
10257
+ * on the way in, TypeScript cannot see that through the record.
10258
+ */
10259
+ spawnFlagOverrides() {
10260
+ const mode = this.claudeFlags.permissionMode;
10261
+ const model = this.claudeFlags.model;
10262
+ const effort = this.claudeFlags.effort;
10263
+ return {
10264
+ permissionMode: isPermissionMode(mode) ? mode : this.defaultPermissionMode,
10265
+ model: typeof model === "string" ? model : this.defaultModel,
10266
+ effort: isEffortLevel(effort) ? effort : this.defaultEffort
10267
+ };
10268
+ }
9303
10269
  checkRateLimit(map, key, limit, windowMs) {
9304
10270
  const now = Date.now();
9305
10271
  const arr = (map.get(key) ?? []).filter((t) => now - t < windowMs);
@@ -10449,11 +11415,9 @@ var StreamerServer = class {
10449
11415
  projectPath,
10450
11416
  projectName: body.projectName,
10451
11417
  branch: body.branch,
10452
- permissionMode: this.defaultPermissionMode,
10453
11418
  claudeFlags: this.claudeFlags,
10454
11419
  claudeExtraArgs: this.claudeExtraArgs,
10455
- model: this.defaultModel,
10456
- effort: this.defaultEffort
11420
+ ...this.spawnFlagOverrides()
10457
11421
  });
10458
11422
  this.sessionStore.addManaged(session);
10459
11423
  this.recordSessionSpawn(session);
@@ -10900,11 +11864,9 @@ var StreamerServer = class {
10900
11864
  projectPath,
10901
11865
  projectName,
10902
11866
  branch,
10903
- permissionMode: this.defaultPermissionMode,
10904
11867
  claudeFlags: this.claudeFlags,
10905
11868
  claudeExtraArgs: this.claudeExtraArgs,
10906
- model: this.defaultModel,
10907
- effort: this.defaultEffort
11869
+ ...this.spawnFlagOverrides()
10908
11870
  });
10909
11871
  this.sessionStore.addManaged(session);
10910
11872
  this.recordSessionSpawn(session);
@@ -10970,17 +11932,16 @@ var StreamerServer = class {
10970
11932
  BROWSE_SYSTEM_PROMPT(this.browseRoot),
10971
11933
  typeof clientPrompt === "string" ? clientPrompt : null
10972
11934
  ].filter(Boolean);
11935
+ const includeSystemPrompt = provider !== CODEX_CLI_PROVIDER || this.codexSystemPromptEnabled;
10973
11936
  try {
10974
11937
  const session = await this.ptyManager.startFresh({
10975
11938
  provider,
10976
11939
  projectPath: resolvedPath,
10977
11940
  projectName: body.projectName,
10978
- systemPrompt: systemPromptParts.join("\n"),
10979
- permissionMode: this.defaultPermissionMode,
11941
+ ...includeSystemPrompt && { systemPrompt: systemPromptParts.join("\n") },
10980
11942
  claudeFlags: this.claudeFlags,
10981
11943
  claudeExtraArgs: this.claudeExtraArgs,
10982
- model: this.defaultModel,
10983
- effort: this.defaultEffort
11944
+ ...this.spawnFlagOverrides()
10984
11945
  });
10985
11946
  this.sessionStore.addManaged(session);
10986
11947
  this.recordSessionSpawn(session);
@@ -11340,6 +12301,87 @@ var StreamerServer = class {
11340
12301
  this.cache.upsertSessionName(sessionId, name);
11341
12302
  json(res, 200, { ok: true });
11342
12303
  }
12304
+ /**
12305
+ * Retarget a LIVE session's model or effort by typing the corresponding
12306
+ * Claude Code slash command into its PTY.
12307
+ *
12308
+ * There is no CLI or IPC channel for this — `--model`/`--effort` are spawn
12309
+ * arguments — so the interactive `/model <x>` / `/effort <y>` commands are the
12310
+ * only way to change a session already running. Both accept an argument and
12311
+ * apply it without opening the picker (verified against Claude Code v2.1.220).
12312
+ *
12313
+ * Answers 202, not 200: the value is applied by the TUI on its next render, so
12314
+ * there is nothing truthful to echo back synchronously. Clients confirm with
12315
+ * `GET /api/sessions/:id`, which scrapes the applied value off the live status
12316
+ * line.
12317
+ */
12318
+ async applyLiveSessionSetting(sessionId, req, res, setting) {
12319
+ const session = this.ptyManager.getSession(sessionId);
12320
+ if (!session) {
12321
+ const known = this.sessionStore.getManaged(sessionId);
12322
+ if (known) {
12323
+ json(res, 409, {
12324
+ error: "Session has no live PTY; resume it first",
12325
+ code: "SESSION_IDLE"
12326
+ });
12327
+ return;
12328
+ }
12329
+ json(res, 404, { error: "Session not found" });
12330
+ return;
12331
+ }
12332
+ if ((session.provider ?? CLAUDE_CODE_PROVIDER) !== CLAUDE_CODE_PROVIDER) {
12333
+ json(res, 501, {
12334
+ error: `Setting ${setting} on a ${session.provider} session is not supported`,
12335
+ code: "UNSUPPORTED_PROVIDER"
12336
+ });
12337
+ return;
12338
+ }
12339
+ if (session.status === "running") {
12340
+ json(res, 409, {
12341
+ error: "Session is mid-turn; retry once it is waiting for input",
12342
+ code: "SESSION_BUSY"
12343
+ });
12344
+ return;
12345
+ }
12346
+ let parsed;
12347
+ try {
12348
+ parsed = await readBody(req);
12349
+ } catch {
12350
+ json(res, 400, { error: "Invalid JSON" });
12351
+ return;
12352
+ }
12353
+ let value;
12354
+ if (setting === "effort") {
12355
+ if (!isEffortLevel(parsed.effort)) {
12356
+ json(res, 400, {
12357
+ error: `effort must be one of ${EFFORT_LEVELS.join(", ")}`
12358
+ });
12359
+ return;
12360
+ }
12361
+ value = parsed.effort;
12362
+ } else {
12363
+ if (typeof parsed.model !== "string" || !MODEL_NAME_RE.test(parsed.model)) {
12364
+ json(res, 400, {
12365
+ error: "model must be an alias or full model name (letters, digits, dot, dash, underscore)"
12366
+ });
12367
+ return;
12368
+ }
12369
+ value = parsed.model;
12370
+ }
12371
+ try {
12372
+ this.ptyManager.sendKeys(sessionId, `/${setting} ${value}\r`);
12373
+ } catch (err) {
12374
+ json(res, 400, { error: err instanceof Error ? err.message : "Failed to write to session" });
12375
+ return;
12376
+ }
12377
+ this.log.info(`Live session ${setting} set to ${value}`, {
12378
+ event: "session.setting_applied",
12379
+ sessionId,
12380
+ setting,
12381
+ value
12382
+ });
12383
+ json(res, 202, { id: sessionId, [setting]: value });
12384
+ }
11343
12385
  handleGetSessionNames(res) {
11344
12386
  if (!this.cache) {
11345
12387
  json(res, 200, {});