@threadbase-sh/streamer 1.61.0 → 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/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
  }
@@ -10570,7 +10578,9 @@ var SessionHandlers = class {
10570
10578
  res.end(JSON.stringify({ error: "Session not found" }));
10571
10579
  return;
10572
10580
  }
10581
+ const shouldForget = this.shouldForgetEmptySession(session);
10573
10582
  if (session.status === "idle") {
10583
+ if (shouldForget) this.forgetEmptyStoppedSession(sessionId);
10574
10584
  res.writeHead(200, { "Content-Type": "application/json" });
10575
10585
  res.end(JSON.stringify({ status: "already_idle", sessionId }));
10576
10586
  return;
@@ -10598,6 +10608,7 @@ var SessionHandlers = class {
10598
10608
  this.ptyManager.putOnHold(sessionId);
10599
10609
  this.discoveryCache = null;
10600
10610
  const outcome = await Promise.race([idlePromise, timeoutPromise]);
10611
+ if (shouldForget) this.forgetEmptyStoppedSession(sessionId);
10601
10612
  if (outcome === "idle") {
10602
10613
  res.write(`${JSON.stringify({ event: "stopped", sessionId })}
10603
10614
  `);
@@ -10610,6 +10621,42 @@ var SessionHandlers = class {
10610
10621
  }
10611
10622
  res.end();
10612
10623
  }
10624
+ /**
10625
+ * An unused start: the user never submitted a prompt, and the conversation
10626
+ * cache has no row for this id (empty Codex/Claude often never write a JSONL).
10627
+ * `conversationId === sessionId` is not evidence of history — only the cache
10628
+ * is. promptCount > 0 or a cache hit keeps today's hold path.
10629
+ */
10630
+ shouldForgetEmptySession(session) {
10631
+ const stored = this.sessionStore.getManaged(session.id);
10632
+ const promptCount = Math.max(session.promptCount, stored?.promptCount ?? 0);
10633
+ if (promptCount > 0) return false;
10634
+ return !this.hasCachedConversationFor(session, stored);
10635
+ }
10636
+ hasCachedConversationFor(session, stored) {
10637
+ const cache = this.cache;
10638
+ if (!cache) return false;
10639
+ const ids = /* @__PURE__ */ new Set([session.id]);
10640
+ if (session.boundConversationId) ids.add(session.boundConversationId);
10641
+ if (session.resumedFromConversationId) ids.add(session.resumedFromConversationId);
10642
+ if (stored?.boundConversationId) ids.add(stored.boundConversationId);
10643
+ if (stored?.resumedFromConversationId) ids.add(stored.resumedFromConversationId);
10644
+ for (const id of ids) {
10645
+ if (cache.hasConversation(id)) return true;
10646
+ }
10647
+ return false;
10648
+ }
10649
+ forgetEmptyStoppedSession(sessionId) {
10650
+ this.log.info(`[stop] forgetting empty session ${sessionId.slice(0, 8)}`, {
10651
+ event: "session.forget_empty",
10652
+ sessionId
10653
+ });
10654
+ this.deps.forgetSession(sessionId);
10655
+ this.wsHub.broadcast({
10656
+ type: "session_list",
10657
+ sessions: this.sessionStore.list(this.deps.ptyAttachedIds())
10658
+ });
10659
+ }
10613
10660
  async handleAdopt(sessionId, res) {
10614
10661
  const discovered = await discoverClaudeProcesses();
10615
10662
  this.sessionStore.setDiscovered(discovered);
@@ -15791,6 +15838,7 @@ var StreamerServer = class {
15791
15838
  spawnFlagOverrides: () => this.spawnFlagOverrides(),
15792
15839
  resolveConversationTarget: (sessionId) => this.resolveConversationTarget(sessionId),
15793
15840
  waitForStartupOutcome: (sessionId, timeoutMs) => this.waitForStartupOutcome(sessionId, timeoutMs),
15841
+ forgetSession: (sessionId) => this.forgetSession(sessionId),
15794
15842
  abandonFailedStart: (sessionId) => this.abandonFailedStart(sessionId),
15795
15843
  enrichResumedSessionAsync: (sessionId, projectPath, conv) => this.enrichResumedSessionAsync(sessionId, projectPath, conv),
15796
15844
  findJsonlPath: (uuid) => this.conversationHandlers.findJsonlPath(uuid),
@@ -16613,13 +16661,16 @@ var StreamerServer = class {
16613
16661
  json(res, 400, { error: "Missing token or clientPublicKey" });
16614
16662
  return;
16615
16663
  }
16616
- let e2eeRequest;
16617
- try {
16618
- e2eeRequest = parseE2eeRequest(body?.e2ee);
16619
- } catch (err) {
16620
- const e = err;
16621
- json(res, 400, { error: e.message, code: e.code });
16622
- return;
16664
+ const e2eeEnabled = describeE2eeCapability(this.featureFlags.e2ee).enabled;
16665
+ let e2eeRequest = null;
16666
+ if (e2eeEnabled) {
16667
+ try {
16668
+ e2eeRequest = parseE2eeRequest(body?.e2ee);
16669
+ } catch (err) {
16670
+ const e = err;
16671
+ json(res, 400, { error: e.message, code: e.code });
16672
+ return;
16673
+ }
16623
16674
  }
16624
16675
  const precheck = this.pairTokens.wouldConsume(token);
16625
16676
  if (!precheck.ok) {
@@ -16779,7 +16830,7 @@ var StreamerServer = class {
16779
16830
  */
16780
16831
  getFeatureFlagsConfig() {
16781
16832
  return {
16782
- registry: FEATURE_FLAGS,
16833
+ registry: FEATURE_FLAG_LIST,
16783
16834
  values: this.featureFlags,
16784
16835
  sources: this.featureFlagSources
16785
16836
  };
@@ -17013,29 +17064,44 @@ var StreamerServer = class {
17013
17064
  });
17014
17065
  }
17015
17066
  /**
17016
- * Drop every trace of a session that never became usable, and hand back what
17017
- * it failed with.
17067
+ * Drop every trace of a managed session: in-memory store, durable registry
17068
+ * row, and the collision-probe markers that would otherwise outlive it.
17018
17069
  *
17019
- * The runner has already torn itself down (failStartup / handleExit); what
17020
- * remains is server-side bookkeeping that would otherwise leave a dead
17021
- * session in the list, a registry row claiming a spawn, and a `selfPtyEndedAt`
17022
- * marker that would suppress the mtime collision signal on the NEXT resume —
17023
- * i.e. it would help hide the very owner we just collided with.
17070
+ * Used when a start never became usable (`abandonFailedStart`) and when stop
17071
+ * is asked to discard an empty session that has no cached conversation. The
17072
+ * registry delete is load-bearing `rehydrateSessions` will bring the row
17073
+ * back on the next boot if it remains.
17074
+ *
17075
+ * Callers that kill the PTY (`putOnHold`) must do that *first*: onStatusChange
17076
+ * on idle writes `selfPtyEndedAt` and a registry status, and those have to
17077
+ * be cleared here afterwards.
17024
17078
  */
17025
- abandonFailedStart(sessionId) {
17079
+ forgetSession(sessionId) {
17026
17080
  this.sessionStore.removeManaged(sessionId);
17027
17081
  this.selfPtyEndedAt.delete(sessionId);
17028
17082
  this.contendedSessions.delete(sessionId);
17029
17083
  try {
17030
17084
  this.managedSessionsRepo?.delete(sessionId);
17031
17085
  } catch (err) {
17032
- this.log.warn("[registry] failed to drop a failed start", {
17086
+ this.log.warn("[registry] failed to drop a session", {
17033
17087
  event: "registry.forget_failed",
17034
17088
  sessionId,
17035
17089
  err
17036
17090
  });
17037
17091
  }
17038
17092
  }
17093
+ /**
17094
+ * Drop every trace of a session that never became usable.
17095
+ *
17096
+ * The runner has already torn itself down (failStartup / handleExit); what
17097
+ * remains is server-side bookkeeping that would otherwise leave a dead
17098
+ * session in the list, a registry row claiming a spawn, and a `selfPtyEndedAt`
17099
+ * marker that would suppress the mtime collision signal on the NEXT resume —
17100
+ * i.e. it would help hide the very owner we just collided with.
17101
+ */
17102
+ abandonFailedStart(sessionId) {
17103
+ this.forgetSession(sessionId);
17104
+ }
17039
17105
  enrichResumedSessionAsync(sessionId, projectPath, conv) {
17040
17106
  try {
17041
17107
  if (!this.sessionStore.getManaged(sessionId)) return;