@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/dist/index.cjs CHANGED
@@ -497,40 +497,47 @@ function getLogger(component) {
497
497
  var logger = build(baseLogger);
498
498
 
499
499
  // src/feature-flags.ts
500
- var FEATURE_FLAGS = [
501
- {
502
- id: "codexSystemPrompt",
500
+ var FEATURE_FLAGS = {
501
+ codexSystemPrompt: {
503
502
  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.",
504
503
  default: false,
505
504
  env: "THREADBASE_FEATURE_CODEX_SYSTEM_PROMPT"
506
505
  },
507
- {
508
- id: "sessionRehydration",
506
+ sessionRehydration: {
509
507
  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.",
510
508
  default: true,
511
509
  env: "THREADBASE_FEATURE_SESSION_REHYDRATION"
512
510
  },
513
- {
514
- id: "liveActivityPush",
511
+ liveActivityPush: {
515
512
  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.",
516
513
  default: false,
517
514
  env: "THREADBASE_FEATURE_LIVE_ACTIVITY_PUSH"
518
515
  },
519
- {
520
- id: "e2ee",
516
+ e2ee: {
521
517
  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.",
522
518
  default: false,
523
519
  env: "THREADBASE_FEATURE_E2EE"
524
520
  },
525
- {
526
- id: "ptyHost",
521
+ ptyHost: {
527
522
  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.",
528
523
  default: false,
529
524
  env: "THREADBASE_FEATURE_PTY_HOST"
530
525
  }
531
- ];
526
+ };
527
+ var FEATURE_FLAG_IDS = Object.keys(FEATURE_FLAGS);
528
+ var FEATURE_FLAG_LIST = FEATURE_FLAG_IDS.map((id) => ({
529
+ id,
530
+ ...FEATURE_FLAGS[id]
531
+ }));
532
+ function isFeatureFlagId(id) {
533
+ return Object.hasOwn(FEATURE_FLAGS, id);
534
+ }
535
+ function getFeatureFlag(id) {
536
+ return { id, ...FEATURE_FLAGS[id] };
537
+ }
532
538
  function findFeatureFlag(id) {
533
- return FEATURE_FLAGS.find((f) => f.id === id);
539
+ if (!isFeatureFlagId(id)) return void 0;
540
+ return getFeatureFlag(id);
534
541
  }
535
542
  function parseBooleanEnv(raw) {
536
543
  if (raw === void 0) return void 0;
@@ -552,10 +559,11 @@ function validateFeatureFlagValues(raw) {
552
559
  }
553
560
  if (dropped.length > 0) {
554
561
  getLogger("feature-flags").warn(
555
- `Ignoring unknown or non-boolean feature flags: ${dropped.join(", ")}`,
562
+ `Ignoring unknown or non-boolean feature flags: ${dropped.join(", ")}. Known ids (FEATURE_FLAGS keys, not env names): ${FEATURE_FLAG_IDS.join(", ")}`,
556
563
  {
557
564
  event: "config.feature_flags_dropped",
558
- dropped
565
+ dropped,
566
+ known: FEATURE_FLAG_IDS
559
567
  }
560
568
  );
561
569
  }
@@ -565,7 +573,7 @@ function resolveFeatureFlags(opts) {
565
573
  const env = opts?.env ?? process.env;
566
574
  const values = {};
567
575
  const sources = {};
568
- for (const def of FEATURE_FLAGS) {
576
+ for (const def of FEATURE_FLAG_LIST) {
569
577
  const rungs = [
570
578
  ["override", opts?.override?.[def.id]],
571
579
  ["env", parseBooleanEnv(env[def.env])],
@@ -579,10 +587,10 @@ function resolveFeatureFlags(opts) {
579
587
  return { values, sources };
580
588
  }
581
589
  function nonDefaultFeatureFlags(values) {
582
- return FEATURE_FLAGS.filter((f) => values[f.id] !== f.default).map((f) => f.id);
590
+ return FEATURE_FLAG_LIST.filter((f) => values[f.id] !== f.default).map((f) => f.id);
583
591
  }
584
592
  function describeFeatureFlags(resolution) {
585
- return FEATURE_FLAGS.map(
593
+ return FEATURE_FLAG_LIST.map(
586
594
  (f) => `${f.id}=${resolution.values[f.id]}(${resolution.sources[f.id]})`
587
595
  ).join(" ");
588
596
  }
@@ -9142,7 +9150,10 @@ var ConversationHandlers = class {
9142
9150
  score: r.score,
9143
9151
  matches: Array.isArray(r.matches) ? r.matches.map((m) => ({
9144
9152
  field: m.field,
9145
- snippet: m.snippet
9153
+ snippet: m.snippet,
9154
+ // Offsets into `snippet` for the matched tokens. Absent on metadata
9155
+ // hits, and on scanners older than the one that added them.
9156
+ highlights: m.highlights
9146
9157
  })) : []
9147
9158
  }));
9148
9159
  const page = paginate(applyFilters(adapted, filters), offset, limit);
@@ -10570,7 +10581,9 @@ var SessionHandlers = class {
10570
10581
  res.end(JSON.stringify({ error: "Session not found" }));
10571
10582
  return;
10572
10583
  }
10584
+ const shouldForget = this.shouldForgetEmptySession(session);
10573
10585
  if (session.status === "idle") {
10586
+ if (shouldForget) this.forgetEmptyStoppedSession(sessionId);
10574
10587
  res.writeHead(200, { "Content-Type": "application/json" });
10575
10588
  res.end(JSON.stringify({ status: "already_idle", sessionId }));
10576
10589
  return;
@@ -10598,6 +10611,7 @@ var SessionHandlers = class {
10598
10611
  this.ptyManager.putOnHold(sessionId);
10599
10612
  this.discoveryCache = null;
10600
10613
  const outcome = await Promise.race([idlePromise, timeoutPromise]);
10614
+ if (shouldForget) this.forgetEmptyStoppedSession(sessionId);
10601
10615
  if (outcome === "idle") {
10602
10616
  res.write(`${JSON.stringify({ event: "stopped", sessionId })}
10603
10617
  `);
@@ -10610,6 +10624,42 @@ var SessionHandlers = class {
10610
10624
  }
10611
10625
  res.end();
10612
10626
  }
10627
+ /**
10628
+ * An unused start: the user never submitted a prompt, and the conversation
10629
+ * cache has no row for this id (empty Codex/Claude often never write a JSONL).
10630
+ * `conversationId === sessionId` is not evidence of history — only the cache
10631
+ * is. promptCount > 0 or a cache hit keeps today's hold path.
10632
+ */
10633
+ shouldForgetEmptySession(session) {
10634
+ const stored = this.sessionStore.getManaged(session.id);
10635
+ const promptCount = Math.max(session.promptCount, stored?.promptCount ?? 0);
10636
+ if (promptCount > 0) return false;
10637
+ return !this.hasCachedConversationFor(session, stored);
10638
+ }
10639
+ hasCachedConversationFor(session, stored) {
10640
+ const cache = this.cache;
10641
+ if (!cache) return false;
10642
+ const ids = /* @__PURE__ */ new Set([session.id]);
10643
+ if (session.boundConversationId) ids.add(session.boundConversationId);
10644
+ if (session.resumedFromConversationId) ids.add(session.resumedFromConversationId);
10645
+ if (stored?.boundConversationId) ids.add(stored.boundConversationId);
10646
+ if (stored?.resumedFromConversationId) ids.add(stored.resumedFromConversationId);
10647
+ for (const id of ids) {
10648
+ if (cache.hasConversation(id)) return true;
10649
+ }
10650
+ return false;
10651
+ }
10652
+ forgetEmptyStoppedSession(sessionId) {
10653
+ this.log.info(`[stop] forgetting empty session ${sessionId.slice(0, 8)}`, {
10654
+ event: "session.forget_empty",
10655
+ sessionId
10656
+ });
10657
+ this.deps.forgetSession(sessionId);
10658
+ this.wsHub.broadcast({
10659
+ type: "session_list",
10660
+ sessions: this.sessionStore.list(this.deps.ptyAttachedIds())
10661
+ });
10662
+ }
10613
10663
  async handleAdopt(sessionId, res) {
10614
10664
  const discovered = await discoverClaudeProcesses();
10615
10665
  this.sessionStore.setDiscovered(discovered);
@@ -15791,6 +15841,7 @@ var StreamerServer = class {
15791
15841
  spawnFlagOverrides: () => this.spawnFlagOverrides(),
15792
15842
  resolveConversationTarget: (sessionId) => this.resolveConversationTarget(sessionId),
15793
15843
  waitForStartupOutcome: (sessionId, timeoutMs) => this.waitForStartupOutcome(sessionId, timeoutMs),
15844
+ forgetSession: (sessionId) => this.forgetSession(sessionId),
15794
15845
  abandonFailedStart: (sessionId) => this.abandonFailedStart(sessionId),
15795
15846
  enrichResumedSessionAsync: (sessionId, projectPath, conv) => this.enrichResumedSessionAsync(sessionId, projectPath, conv),
15796
15847
  findJsonlPath: (uuid) => this.conversationHandlers.findJsonlPath(uuid),
@@ -16782,7 +16833,7 @@ var StreamerServer = class {
16782
16833
  */
16783
16834
  getFeatureFlagsConfig() {
16784
16835
  return {
16785
- registry: FEATURE_FLAGS,
16836
+ registry: FEATURE_FLAG_LIST,
16786
16837
  values: this.featureFlags,
16787
16838
  sources: this.featureFlagSources
16788
16839
  };
@@ -17016,29 +17067,44 @@ var StreamerServer = class {
17016
17067
  });
17017
17068
  }
17018
17069
  /**
17019
- * Drop every trace of a session that never became usable, and hand back what
17020
- * it failed with.
17070
+ * Drop every trace of a managed session: in-memory store, durable registry
17071
+ * row, and the collision-probe markers that would otherwise outlive it.
17021
17072
  *
17022
- * The runner has already torn itself down (failStartup / handleExit); what
17023
- * remains is server-side bookkeeping that would otherwise leave a dead
17024
- * session in the list, a registry row claiming a spawn, and a `selfPtyEndedAt`
17025
- * marker that would suppress the mtime collision signal on the NEXT resume —
17026
- * i.e. it would help hide the very owner we just collided with.
17073
+ * Used when a start never became usable (`abandonFailedStart`) and when stop
17074
+ * is asked to discard an empty session that has no cached conversation. The
17075
+ * registry delete is load-bearing `rehydrateSessions` will bring the row
17076
+ * back on the next boot if it remains.
17077
+ *
17078
+ * Callers that kill the PTY (`putOnHold`) must do that *first*: onStatusChange
17079
+ * on idle writes `selfPtyEndedAt` and a registry status, and those have to
17080
+ * be cleared here afterwards.
17027
17081
  */
17028
- abandonFailedStart(sessionId) {
17082
+ forgetSession(sessionId) {
17029
17083
  this.sessionStore.removeManaged(sessionId);
17030
17084
  this.selfPtyEndedAt.delete(sessionId);
17031
17085
  this.contendedSessions.delete(sessionId);
17032
17086
  try {
17033
17087
  this.managedSessionsRepo?.delete(sessionId);
17034
17088
  } catch (err) {
17035
- this.log.warn("[registry] failed to drop a failed start", {
17089
+ this.log.warn("[registry] failed to drop a session", {
17036
17090
  event: "registry.forget_failed",
17037
17091
  sessionId,
17038
17092
  err
17039
17093
  });
17040
17094
  }
17041
17095
  }
17096
+ /**
17097
+ * Drop every trace of a session that never became usable.
17098
+ *
17099
+ * The runner has already torn itself down (failStartup / handleExit); what
17100
+ * remains is server-side bookkeeping that would otherwise leave a dead
17101
+ * session in the list, a registry row claiming a spawn, and a `selfPtyEndedAt`
17102
+ * marker that would suppress the mtime collision signal on the NEXT resume —
17103
+ * i.e. it would help hide the very owner we just collided with.
17104
+ */
17105
+ abandonFailedStart(sessionId) {
17106
+ this.forgetSession(sessionId);
17107
+ }
17042
17108
  enrichResumedSessionAsync(sessionId, projectPath, conv) {
17043
17109
  try {
17044
17110
  if (!this.sessionStore.getManaged(sessionId)) return;