@threadbase-sh/streamer 1.61.1 → 1.62.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/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();
@@ -146911,7 +146949,10 @@ var ConversationHandlers = class {
146911
146949
  score: r.score,
146912
146950
  matches: Array.isArray(r.matches) ? r.matches.map((m2) => ({
146913
146951
  field: m2.field,
146914
- snippet: m2.snippet
146952
+ snippet: m2.snippet,
146953
+ // Offsets into `snippet` for the matched tokens. Absent on metadata
146954
+ // hits, and on scanners older than the one that added them.
146955
+ highlights: m2.highlights
146915
146956
  })) : []
146916
146957
  }));
146917
146958
  const page = paginate(applyFilters(adapted, filters), offset, limit);
@@ -148616,7 +148657,9 @@ var SessionHandlers = class {
148616
148657
  res.end(JSON.stringify({ error: "Session not found" }));
148617
148658
  return;
148618
148659
  }
148660
+ const shouldForget = this.shouldForgetEmptySession(session);
148619
148661
  if (session.status === "idle") {
148662
+ if (shouldForget) this.forgetEmptyStoppedSession(sessionId);
148620
148663
  res.writeHead(200, { "Content-Type": "application/json" });
148621
148664
  res.end(JSON.stringify({ status: "already_idle", sessionId }));
148622
148665
  return;
@@ -148644,6 +148687,7 @@ var SessionHandlers = class {
148644
148687
  this.ptyManager.putOnHold(sessionId);
148645
148688
  this.discoveryCache = null;
148646
148689
  const outcome = await Promise.race([idlePromise, timeoutPromise]);
148690
+ if (shouldForget) this.forgetEmptyStoppedSession(sessionId);
148647
148691
  if (outcome === "idle") {
148648
148692
  res.write(`${JSON.stringify({ event: "stopped", sessionId })}
148649
148693
  `);
@@ -148656,6 +148700,42 @@ var SessionHandlers = class {
148656
148700
  }
148657
148701
  res.end();
148658
148702
  }
148703
+ /**
148704
+ * An unused start: the user never submitted a prompt, and the conversation
148705
+ * cache has no row for this id (empty Codex/Claude often never write a JSONL).
148706
+ * `conversationId === sessionId` is not evidence of history — only the cache
148707
+ * is. promptCount > 0 or a cache hit keeps today's hold path.
148708
+ */
148709
+ shouldForgetEmptySession(session) {
148710
+ const stored = this.sessionStore.getManaged(session.id);
148711
+ const promptCount = Math.max(session.promptCount, stored?.promptCount ?? 0);
148712
+ if (promptCount > 0) return false;
148713
+ return !this.hasCachedConversationFor(session, stored);
148714
+ }
148715
+ hasCachedConversationFor(session, stored) {
148716
+ const cache = this.cache;
148717
+ if (!cache) return false;
148718
+ const ids = /* @__PURE__ */ new Set([session.id]);
148719
+ if (session.boundConversationId) ids.add(session.boundConversationId);
148720
+ if (session.resumedFromConversationId) ids.add(session.resumedFromConversationId);
148721
+ if (stored?.boundConversationId) ids.add(stored.boundConversationId);
148722
+ if (stored?.resumedFromConversationId) ids.add(stored.resumedFromConversationId);
148723
+ for (const id of ids) {
148724
+ if (cache.hasConversation(id)) return true;
148725
+ }
148726
+ return false;
148727
+ }
148728
+ forgetEmptyStoppedSession(sessionId) {
148729
+ this.log.info(`[stop] forgetting empty session ${sessionId.slice(0, 8)}`, {
148730
+ event: "session.forget_empty",
148731
+ sessionId
148732
+ });
148733
+ this.deps.forgetSession(sessionId);
148734
+ this.wsHub.broadcast({
148735
+ type: "session_list",
148736
+ sessions: this.sessionStore.list(this.deps.ptyAttachedIds())
148737
+ });
148738
+ }
148659
148739
  async handleAdopt(sessionId, res) {
148660
148740
  const discovered = await discoverClaudeProcesses();
148661
148741
  this.sessionStore.setDiscovered(discovered);
@@ -154081,6 +154161,7 @@ var StreamerServer = class {
154081
154161
  spawnFlagOverrides: () => this.spawnFlagOverrides(),
154082
154162
  resolveConversationTarget: (sessionId) => this.resolveConversationTarget(sessionId),
154083
154163
  waitForStartupOutcome: (sessionId, timeoutMs) => this.waitForStartupOutcome(sessionId, timeoutMs),
154164
+ forgetSession: (sessionId) => this.forgetSession(sessionId),
154084
154165
  abandonFailedStart: (sessionId) => this.abandonFailedStart(sessionId),
154085
154166
  enrichResumedSessionAsync: (sessionId, projectPath, conv) => this.enrichResumedSessionAsync(sessionId, projectPath, conv),
154086
154167
  findJsonlPath: (uuid3) => this.conversationHandlers.findJsonlPath(uuid3),
@@ -155072,7 +155153,7 @@ var StreamerServer = class {
155072
155153
  */
155073
155154
  getFeatureFlagsConfig() {
155074
155155
  return {
155075
- registry: FEATURE_FLAGS,
155156
+ registry: FEATURE_FLAG_LIST,
155076
155157
  values: this.featureFlags,
155077
155158
  sources: this.featureFlagSources
155078
155159
  };
@@ -155306,29 +155387,44 @@ var StreamerServer = class {
155306
155387
  });
155307
155388
  }
155308
155389
  /**
155309
- * Drop every trace of a session that never became usable, and hand back what
155310
- * it failed with.
155390
+ * Drop every trace of a managed session: in-memory store, durable registry
155391
+ * row, and the collision-probe markers that would otherwise outlive it.
155311
155392
  *
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.
155393
+ * Used when a start never became usable (`abandonFailedStart`) and when stop
155394
+ * is asked to discard an empty session that has no cached conversation. The
155395
+ * registry delete is load-bearing `rehydrateSessions` will bring the row
155396
+ * back on the next boot if it remains.
155397
+ *
155398
+ * Callers that kill the PTY (`putOnHold`) must do that *first*: onStatusChange
155399
+ * on idle writes `selfPtyEndedAt` and a registry status, and those have to
155400
+ * be cleared here afterwards.
155317
155401
  */
155318
- abandonFailedStart(sessionId) {
155402
+ forgetSession(sessionId) {
155319
155403
  this.sessionStore.removeManaged(sessionId);
155320
155404
  this.selfPtyEndedAt.delete(sessionId);
155321
155405
  this.contendedSessions.delete(sessionId);
155322
155406
  try {
155323
155407
  this.managedSessionsRepo?.delete(sessionId);
155324
155408
  } catch (err) {
155325
- this.log.warn("[registry] failed to drop a failed start", {
155409
+ this.log.warn("[registry] failed to drop a session", {
155326
155410
  event: "registry.forget_failed",
155327
155411
  sessionId,
155328
155412
  err
155329
155413
  });
155330
155414
  }
155331
155415
  }
155416
+ /**
155417
+ * Drop every trace of a session that never became usable.
155418
+ *
155419
+ * The runner has already torn itself down (failStartup / handleExit); what
155420
+ * remains is server-side bookkeeping that would otherwise leave a dead
155421
+ * session in the list, a registry row claiming a spawn, and a `selfPtyEndedAt`
155422
+ * marker that would suppress the mtime collision signal on the NEXT resume —
155423
+ * i.e. it would help hide the very owner we just collided with.
155424
+ */
155425
+ abandonFailedStart(sessionId) {
155426
+ this.forgetSession(sessionId);
155427
+ }
155332
155428
  enrichResumedSessionAsync(sessionId, projectPath, conv) {
155333
155429
  try {
155334
155430
  if (!this.sessionStore.getManaged(sessionId)) return;
@@ -159524,7 +159620,7 @@ program2.command("serve").description("Start the streamer server").option("-p, -
159524
159620
  (value, previous = []) => [...previous, value]
159525
159621
  ).option(
159526
159622
  "--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.",
159623
+ `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
159624
  (value, previous = []) => [...previous, value]
159529
159625
  ).option(
159530
159626
  "--claude-extra-args <args>",