@threadbase-sh/streamer 1.61.1 → 1.61.2

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/README.md CHANGED
@@ -160,6 +160,14 @@ Three layers: **core engine** (`src/*.ts`) → **API layer** (`src/api/` + `src/
160
160
 
161
161
  More detail: [docs/how-it-works.md](docs/how-it-works.md) and [docs/architecture/](docs/architecture/README.md).
162
162
 
163
+ ## Feature flags
164
+
165
+ Streamer experiments are the `FEATURE_FLAGS` object in `src/feature-flags.ts`, keyed by name (`ptyHost`, `liveActivityPush`, …). `--feature <id=bool>` and `feature_flags:` in `server.yaml` use **those same camelCase keys**. Env vars are a different spelling (`THREADBASE_FEATURE_PTY_HOST`) and outrank both.
166
+
167
+ Unknown yaml keys (typos, snake_case, the env name) are dropped with a warning; they do not stop the boot. Flags resolve once at boot — a change needs a restart. List and provenance: `GET /api/config/feature-flags`.
168
+
169
+ Operator detail: [docs/guides/feature-flags.md](docs/guides/feature-flags.md). Implementation: `CLAUDE.md` § Feature flags.
170
+
163
171
  ## REST API
164
172
 
165
173
  Full endpoint reference: [docs/api-reference.md](docs/api-reference.md).
package/dist/cli.cjs CHANGED
@@ -6146,8 +6146,15 @@ var init_logger = __esm({
6146
6146
  });
6147
6147
 
6148
6148
  // src/feature-flags.ts
6149
+ function isFeatureFlagId(id) {
6150
+ return Object.hasOwn(FEATURE_FLAGS, id);
6151
+ }
6152
+ function getFeatureFlag(id) {
6153
+ return { id, ...FEATURE_FLAGS[id] };
6154
+ }
6149
6155
  function findFeatureFlag(id) {
6150
- return FEATURE_FLAGS.find((f2) => f2.id === id);
6156
+ if (!isFeatureFlagId(id)) return void 0;
6157
+ return getFeatureFlag(id);
6151
6158
  }
6152
6159
  function parseBooleanEnv(raw2) {
6153
6160
  if (raw2 === void 0) return void 0;
@@ -6169,10 +6176,11 @@ function validateFeatureFlagValues(raw2) {
6169
6176
  }
6170
6177
  if (dropped.length > 0) {
6171
6178
  getLogger("feature-flags").warn(
6172
- `Ignoring unknown or non-boolean feature flags: ${dropped.join(", ")}`,
6179
+ `Ignoring unknown or non-boolean feature flags: ${dropped.join(", ")}. Known ids (FEATURE_FLAGS keys, not env names): ${FEATURE_FLAG_IDS.join(", ")}`,
6173
6180
  {
6174
6181
  event: "config.feature_flags_dropped",
6175
- dropped
6182
+ dropped,
6183
+ known: FEATURE_FLAG_IDS
6176
6184
  }
6177
6185
  );
6178
6186
  }
@@ -6188,7 +6196,7 @@ function parseFeatureFlagArgs(entries) {
6188
6196
  const def = findFeatureFlag(id);
6189
6197
  if (!def) {
6190
6198
  errors.push(
6191
- `Unknown feature flag "${id}". Known flags: ${FEATURE_FLAGS.map((f2) => f2.id).join(", ")}`
6199
+ `Unknown feature flag "${id}". Known flags (FEATURE_FLAGS keys, not env names): ${FEATURE_FLAG_IDS.join(", ")}`
6192
6200
  );
6193
6201
  continue;
6194
6202
  }
@@ -6204,7 +6212,7 @@ function resolveFeatureFlags(opts) {
6204
6212
  const env = opts?.env ?? process.env;
6205
6213
  const values = {};
6206
6214
  const sources = {};
6207
- for (const def of FEATURE_FLAGS) {
6215
+ for (const def of FEATURE_FLAG_LIST) {
6208
6216
  const rungs = [
6209
6217
  ["override", opts?.override?.[def.id]],
6210
6218
  ["env", parseBooleanEnv(env[def.env])],
@@ -6218,50 +6226,50 @@ function resolveFeatureFlags(opts) {
6218
6226
  return { values, sources };
6219
6227
  }
6220
6228
  function nonDefaultFeatureFlags(values) {
6221
- return FEATURE_FLAGS.filter((f2) => values[f2.id] !== f2.default).map((f2) => f2.id);
6229
+ return FEATURE_FLAG_LIST.filter((f2) => values[f2.id] !== f2.default).map((f2) => f2.id);
6222
6230
  }
6223
6231
  function describeFeatureFlags(resolution) {
6224
- return FEATURE_FLAGS.map(
6232
+ return FEATURE_FLAG_LIST.map(
6225
6233
  (f2) => `${f2.id}=${resolution.values[f2.id]}(${resolution.sources[f2.id]})`
6226
6234
  ).join(" ");
6227
6235
  }
6228
- var FEATURE_FLAGS;
6236
+ var FEATURE_FLAGS, FEATURE_FLAG_IDS, FEATURE_FLAG_LIST;
6229
6237
  var init_feature_flags = __esm({
6230
6238
  "src/feature-flags.ts"() {
6231
6239
  "use strict";
6232
6240
  init_logger();
6233
- FEATURE_FLAGS = [
6234
- {
6235
- id: "codexSystemPrompt",
6241
+ FEATURE_FLAGS = {
6242
+ codexSystemPrompt: {
6236
6243
  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.",
6237
6244
  default: false,
6238
6245
  env: "THREADBASE_FEATURE_CODEX_SYSTEM_PROMPT"
6239
6246
  },
6240
- {
6241
- id: "sessionRehydration",
6247
+ sessionRehydration: {
6242
6248
  description: "Seed the session list at boot with sessions a previous streamer run left behind, so a restart leaves them one tap from resuming instead of silently gone. On by default, with a kill switch: it changes what GET /api/sessions contains.",
6243
6249
  default: true,
6244
6250
  env: "THREADBASE_FEATURE_SESSION_REHYDRATION"
6245
6251
  },
6246
- {
6247
- id: "liveActivityPush",
6252
+ liveActivityPush: {
6248
6253
  description: "Drive iOS Live Activity surfaces for running sessions. Off by default: the streamer half needs an APNs p8 and a registered push-to-start token, and without both, mobile falls back to starting the activity locally \u2014 which freezes the moment the app backgrounds and expires silently after ~8h. Mobile reads this flag and skips its local path too, so one switch turns the whole surface off.",
6249
6254
  default: false,
6250
6255
  env: "THREADBASE_FEATURE_LIVE_ACTIVITY_PUSH"
6251
6256
  },
6252
- {
6253
- id: "e2ee",
6257
+ e2ee: {
6254
6258
  description: "Application-layer encryption between a paired device and this server, independent of TLS, so a tunnel or a LAN observer on the path carries ciphertext. Off by default: it is negotiated per device, never forced, because released mobile builds cannot be force-updated and a server that demanded it would break every one of them.",
6255
6259
  default: false,
6256
6260
  env: "THREADBASE_FEATURE_E2EE"
6257
6261
  },
6258
- {
6259
- id: "ptyHost",
6262
+ ptyHost: {
6260
6263
  description: "Keep live PTYs in a separate host process so a streamer restart can reconnect without restarting the agents. Off by default until cross-platform behavior is qualified.",
6261
6264
  default: false,
6262
6265
  env: "THREADBASE_FEATURE_PTY_HOST"
6263
6266
  }
6264
- ];
6267
+ };
6268
+ FEATURE_FLAG_IDS = Object.keys(FEATURE_FLAGS);
6269
+ FEATURE_FLAG_LIST = FEATURE_FLAG_IDS.map((id) => ({
6270
+ id,
6271
+ ...FEATURE_FLAGS[id]
6272
+ }));
6265
6273
  }
6266
6274
  });
6267
6275
 
@@ -142534,7 +142542,13 @@ function openDatabase(dbPath) {
142534
142542
  if (dbPath !== ":memory:") {
142535
142543
  (0, import_fs15.mkdirSync)((0, import_path12.dirname)(dbPath), { recursive: true });
142536
142544
  }
142537
- const db = new import_better_sqlite3.default(dbPath);
142545
+ let db;
142546
+ try {
142547
+ db = new import_better_sqlite3.default(dbPath);
142548
+ } catch (err) {
142549
+ if (isNativeBindingFailure(err)) throw nativeBindingError(err, dbPath);
142550
+ throw err;
142551
+ }
142538
142552
  db.pragma("journal_mode = WAL");
142539
142553
  db.pragma("synchronous = NORMAL");
142540
142554
  db.pragma("temp_store = MEMORY");
@@ -142544,6 +142558,30 @@ function openDatabase(dbPath) {
142544
142558
  getLogger2().debug({ dbPath }, "db: opened");
142545
142559
  return db;
142546
142560
  }
142561
+ function isNativeBindingFailure(err) {
142562
+ const message = err instanceof Error ? err.message : String(err);
142563
+ return /could not locate the bindings file|NODE_MODULE_VERSION|was compiled against|\.node['"\s]/i.test(
142564
+ message
142565
+ );
142566
+ }
142567
+ function nativeBindingError(err, dbPath) {
142568
+ const detail = err instanceof Error ? err.message : String(err);
142569
+ return new Error(
142570
+ [
142571
+ `Could not load better-sqlite3's native binary, so the persistent index at ${dbPath} cannot be opened.`,
142572
+ "This usually means the binary was never built or downloaded \u2014 not that your Node version is wrong.",
142573
+ "",
142574
+ "Try, in order:",
142575
+ " 1. npm rebuild better-sqlite3",
142576
+ " 2. rm -rf node_modules && npm install",
142577
+ " (npm 12 blocks package install scripts by default; approve better-sqlite3 if asked)",
142578
+ " 3. Run without SQLite entirely: new ConversationScanner({ persistent: false })",
142579
+ " or pass --no-persist on the CLI. Search falls back to the in-memory index.",
142580
+ "",
142581
+ `Original error: ${detail}`
142582
+ ].join("\n")
142583
+ );
142584
+ }
142547
142585
  var FULL_RECONCILE_EVERY_N_SCANS = 20;
142548
142586
  async function discoverJsonlFilesGated(dirs, files, scannedDirs, options = {}) {
142549
142587
  const log15 = getLogger2();
@@ -148616,7 +148654,9 @@ var SessionHandlers = class {
148616
148654
  res.end(JSON.stringify({ error: "Session not found" }));
148617
148655
  return;
148618
148656
  }
148657
+ const shouldForget = this.shouldForgetEmptySession(session);
148619
148658
  if (session.status === "idle") {
148659
+ if (shouldForget) this.forgetEmptyStoppedSession(sessionId);
148620
148660
  res.writeHead(200, { "Content-Type": "application/json" });
148621
148661
  res.end(JSON.stringify({ status: "already_idle", sessionId }));
148622
148662
  return;
@@ -148644,6 +148684,7 @@ var SessionHandlers = class {
148644
148684
  this.ptyManager.putOnHold(sessionId);
148645
148685
  this.discoveryCache = null;
148646
148686
  const outcome = await Promise.race([idlePromise, timeoutPromise]);
148687
+ if (shouldForget) this.forgetEmptyStoppedSession(sessionId);
148647
148688
  if (outcome === "idle") {
148648
148689
  res.write(`${JSON.stringify({ event: "stopped", sessionId })}
148649
148690
  `);
@@ -148656,6 +148697,42 @@ var SessionHandlers = class {
148656
148697
  }
148657
148698
  res.end();
148658
148699
  }
148700
+ /**
148701
+ * An unused start: the user never submitted a prompt, and the conversation
148702
+ * cache has no row for this id (empty Codex/Claude often never write a JSONL).
148703
+ * `conversationId === sessionId` is not evidence of history — only the cache
148704
+ * is. promptCount > 0 or a cache hit keeps today's hold path.
148705
+ */
148706
+ shouldForgetEmptySession(session) {
148707
+ const stored = this.sessionStore.getManaged(session.id);
148708
+ const promptCount = Math.max(session.promptCount, stored?.promptCount ?? 0);
148709
+ if (promptCount > 0) return false;
148710
+ return !this.hasCachedConversationFor(session, stored);
148711
+ }
148712
+ hasCachedConversationFor(session, stored) {
148713
+ const cache = this.cache;
148714
+ if (!cache) return false;
148715
+ const ids = /* @__PURE__ */ new Set([session.id]);
148716
+ if (session.boundConversationId) ids.add(session.boundConversationId);
148717
+ if (session.resumedFromConversationId) ids.add(session.resumedFromConversationId);
148718
+ if (stored?.boundConversationId) ids.add(stored.boundConversationId);
148719
+ if (stored?.resumedFromConversationId) ids.add(stored.resumedFromConversationId);
148720
+ for (const id of ids) {
148721
+ if (cache.hasConversation(id)) return true;
148722
+ }
148723
+ return false;
148724
+ }
148725
+ forgetEmptyStoppedSession(sessionId) {
148726
+ this.log.info(`[stop] forgetting empty session ${sessionId.slice(0, 8)}`, {
148727
+ event: "session.forget_empty",
148728
+ sessionId
148729
+ });
148730
+ this.deps.forgetSession(sessionId);
148731
+ this.wsHub.broadcast({
148732
+ type: "session_list",
148733
+ sessions: this.sessionStore.list(this.deps.ptyAttachedIds())
148734
+ });
148735
+ }
148659
148736
  async handleAdopt(sessionId, res) {
148660
148737
  const discovered = await discoverClaudeProcesses();
148661
148738
  this.sessionStore.setDiscovered(discovered);
@@ -154081,6 +154158,7 @@ var StreamerServer = class {
154081
154158
  spawnFlagOverrides: () => this.spawnFlagOverrides(),
154082
154159
  resolveConversationTarget: (sessionId) => this.resolveConversationTarget(sessionId),
154083
154160
  waitForStartupOutcome: (sessionId, timeoutMs) => this.waitForStartupOutcome(sessionId, timeoutMs),
154161
+ forgetSession: (sessionId) => this.forgetSession(sessionId),
154084
154162
  abandonFailedStart: (sessionId) => this.abandonFailedStart(sessionId),
154085
154163
  enrichResumedSessionAsync: (sessionId, projectPath, conv) => this.enrichResumedSessionAsync(sessionId, projectPath, conv),
154086
154164
  findJsonlPath: (uuid3) => this.conversationHandlers.findJsonlPath(uuid3),
@@ -155072,7 +155150,7 @@ var StreamerServer = class {
155072
155150
  */
155073
155151
  getFeatureFlagsConfig() {
155074
155152
  return {
155075
- registry: FEATURE_FLAGS,
155153
+ registry: FEATURE_FLAG_LIST,
155076
155154
  values: this.featureFlags,
155077
155155
  sources: this.featureFlagSources
155078
155156
  };
@@ -155306,29 +155384,44 @@ var StreamerServer = class {
155306
155384
  });
155307
155385
  }
155308
155386
  /**
155309
- * Drop every trace of a session that never became usable, and hand back what
155310
- * it failed with.
155387
+ * Drop every trace of a managed session: in-memory store, durable registry
155388
+ * row, and the collision-probe markers that would otherwise outlive it.
155311
155389
  *
155312
- * The runner has already torn itself down (failStartup / handleExit); what
155313
- * remains is server-side bookkeeping that would otherwise leave a dead
155314
- * session in the list, a registry row claiming a spawn, and a `selfPtyEndedAt`
155315
- * marker that would suppress the mtime collision signal on the NEXT resume —
155316
- * i.e. it would help hide the very owner we just collided with.
155390
+ * Used when a start never became usable (`abandonFailedStart`) and when stop
155391
+ * is asked to discard an empty session that has no cached conversation. The
155392
+ * registry delete is load-bearing `rehydrateSessions` will bring the row
155393
+ * back on the next boot if it remains.
155394
+ *
155395
+ * Callers that kill the PTY (`putOnHold`) must do that *first*: onStatusChange
155396
+ * on idle writes `selfPtyEndedAt` and a registry status, and those have to
155397
+ * be cleared here afterwards.
155317
155398
  */
155318
- abandonFailedStart(sessionId) {
155399
+ forgetSession(sessionId) {
155319
155400
  this.sessionStore.removeManaged(sessionId);
155320
155401
  this.selfPtyEndedAt.delete(sessionId);
155321
155402
  this.contendedSessions.delete(sessionId);
155322
155403
  try {
155323
155404
  this.managedSessionsRepo?.delete(sessionId);
155324
155405
  } catch (err) {
155325
- this.log.warn("[registry] failed to drop a failed start", {
155406
+ this.log.warn("[registry] failed to drop a session", {
155326
155407
  event: "registry.forget_failed",
155327
155408
  sessionId,
155328
155409
  err
155329
155410
  });
155330
155411
  }
155331
155412
  }
155413
+ /**
155414
+ * Drop every trace of a session that never became usable.
155415
+ *
155416
+ * The runner has already torn itself down (failStartup / handleExit); what
155417
+ * remains is server-side bookkeeping that would otherwise leave a dead
155418
+ * session in the list, a registry row claiming a spawn, and a `selfPtyEndedAt`
155419
+ * marker that would suppress the mtime collision signal on the NEXT resume —
155420
+ * i.e. it would help hide the very owner we just collided with.
155421
+ */
155422
+ abandonFailedStart(sessionId) {
155423
+ this.forgetSession(sessionId);
155424
+ }
155332
155425
  enrichResumedSessionAsync(sessionId, projectPath, conv) {
155333
155426
  try {
155334
155427
  if (!this.sessionStore.getManaged(sessionId)) return;
@@ -159524,7 +159617,7 @@ program2.command("serve").description("Start the streamer server").option("-p, -
159524
159617
  (value, previous = []) => [...previous, value]
159525
159618
  ).option(
159526
159619
  "--feature <id=bool>",
159527
- "Enable or disable a server feature flag, e.g. --feature codexSystemPrompt=true. Repeatable. Overridden by the flag's THREADBASE_FEATURE_* env var; overrides feature_flags: in ~/.threadbase/server.yaml.",
159620
+ `Enable or disable a server feature flag. Ids are the FEATURE_FLAGS keys (${FEATURE_FLAG_IDS.join(", ")}), e.g. --feature ptyHost=true \u2014 not the THREADBASE_FEATURE_* env names. Repeatable. Overridden by the flag's env var; overrides feature_flags: in ~/.threadbase/server.yaml.`,
159528
159621
  (value, previous = []) => [...previous, value]
159529
159622
  ).option(
159530
159623
  "--claude-extra-args <args>",