@threadbase-sh/streamer 1.52.2 → 1.52.4

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
@@ -35,7 +35,7 @@ node dist/cli.cjs serve --verbose --local-no-auth
35
35
 
36
36
  #### Server address
37
37
 
38
- The server listens on `http://localhost:8766` (WebSocket at `ws://localhost:8766/ws`).
38
+ By default, the server listens on port `8766` on all interfaces (WebSocket path: `/ws`).
39
39
 
40
40
  #### Automatic updates
41
41
 
@@ -43,7 +43,7 @@ npm and Homebrew installs can auto-update: [docs/guides/auto-update.md](docs/gui
43
43
 
44
44
  ## Remote Access
45
45
 
46
- The server listens on port 8766 on all interfaces, so devices on the same LAN can already reach it. To let the mobile app reach it from *outside* your LAN, expose it via a tunnel — the fastest is a Cloudflare quick-tunnel (no account needed):
46
+ The server listens on port 8766 on all interfaces by default, so devices on the same LAN can already reach it. To restrict it to this machine, start it with `tb-streamer serve --host 127.0.0.1`. To let the mobile app reach it from *outside* your LAN, expose it via a tunnel — the fastest is a Cloudflare quick-tunnel (no account needed):
47
47
 
48
48
  > **Before you do this, read [Security](#security--read-this-before-exposing-the-server).** A tunnel makes the server reachable from the public internet, and the API key is the only thing between a stranger and a shell on this machine.
49
49
 
@@ -72,11 +72,15 @@ npm run db:validate # check for missing/duplicate/orphaned project_id data
72
72
 
73
73
  The key lives at `~/.threadbase/server.yaml` as plaintext (`chmod 0600`), which is the same posture as `~/.aws/credentials` or `~/.npmrc`. Rotate it with `POST /api/auth/rotate` if it leaks; re-pair your devices afterwards.
74
74
 
75
- **The server listens on all network interfaces**, not just loopback — `httpServer.listen(port)` is called without a host, which is Node's listen-everywhere default, and there is currently no flag to narrow it. So every device on your LAN can already reach port 8766, and the API key is the only thing stopping them. On a home network that is usually fine; on café Wi-Fi, a co-working space, or a corporate VLAN it is not.
75
+ **The server listens on all network interfaces by default**, not just loopback. So every device on your LAN can already reach port 8766, and the API key is the only thing stopping them. On a home network that is usually fine; on café Wi-Fi, a co-working space, or a corporate VLAN it is not. To bind only to loopback, run:
76
+
77
+ ```bash
78
+ tb-streamer serve --host 127.0.0.1
79
+ ```
76
80
 
77
81
  **A tunnel extends that to the entire internet.** From that moment the key is the only thing between a stranger and your filesystem.
78
82
 
79
- If you need it strictly local today, bind it yourself at the firewall or run it inside a network namespace — the server will not do it for you. Tracked as [#517](https://github.com/RonenMars/threadbase-streamer/issues/517).
83
+ For stricter network policies, also use your firewall or a network namespace.
80
84
 
81
85
  ### There is no spend limit
82
86
 
package/dist/cli.cjs CHANGED
@@ -6160,11 +6160,12 @@ function validateFeatureFlagValues(raw2) {
6160
6160
  const out = {};
6161
6161
  const dropped = [];
6162
6162
  for (const [id, value] of Object.entries(raw2)) {
6163
- if (!findFeatureFlag(id) || typeof value !== "boolean") {
6163
+ const def = findFeatureFlag(id);
6164
+ if (!def || typeof value !== "boolean") {
6164
6165
  dropped.push(id);
6165
6166
  continue;
6166
6167
  }
6167
- out[id] = value;
6168
+ out[def.id] = value;
6168
6169
  }
6169
6170
  if (dropped.length > 0) {
6170
6171
  getLogger("feature-flags").warn(
@@ -6184,7 +6185,8 @@ function parseFeatureFlagArgs(entries) {
6184
6185
  const eq = entry.indexOf("=");
6185
6186
  const id = (eq === -1 ? entry : entry.slice(0, eq)).trim();
6186
6187
  const rawValue = eq === -1 ? "true" : entry.slice(eq + 1).trim().toLowerCase();
6187
- if (!findFeatureFlag(id)) {
6188
+ const def = findFeatureFlag(id);
6189
+ if (!def) {
6188
6190
  errors.push(
6189
6191
  `Unknown feature flag "${id}". Known flags: ${FEATURE_FLAGS.map((f2) => f2.id).join(", ")}`
6190
6192
  );
@@ -6194,21 +6196,35 @@ function parseFeatureFlagArgs(entries) {
6194
6196
  errors.push(`Invalid value "${rawValue}" for feature flag "${id}" \u2014 expected true/false`);
6195
6197
  continue;
6196
6198
  }
6197
- values[id] = rawValue === "true";
6199
+ values[def.id] = rawValue === "true";
6198
6200
  }
6199
6201
  return { values, errors };
6200
6202
  }
6201
6203
  function resolveFeatureFlags(opts) {
6202
6204
  const env = opts?.env ?? process.env;
6203
- const out = {};
6205
+ const values = {};
6206
+ const sources = {};
6204
6207
  for (const def of FEATURE_FLAGS) {
6205
- out[def.id] = parseBooleanEnv(env[def.env]) ?? opts?.cli?.[def.id] ?? opts?.yaml?.[def.id] ?? def.default;
6208
+ const rungs = [
6209
+ ["override", opts?.override?.[def.id]],
6210
+ ["env", parseBooleanEnv(env[def.env])],
6211
+ ["cli", opts?.cli?.[def.id]],
6212
+ ["yaml", opts?.yaml?.[def.id]]
6213
+ ];
6214
+ const won = rungs.find(([, v2]) => v2 !== void 0);
6215
+ values[def.id] = won ? won[1] : def.default;
6216
+ sources[def.id] = won ? won[0] : "default";
6206
6217
  }
6207
- return out;
6218
+ return { values, sources };
6208
6219
  }
6209
6220
  function nonDefaultFeatureFlags(values) {
6210
6221
  return FEATURE_FLAGS.filter((f2) => values[f2.id] !== f2.default).map((f2) => f2.id);
6211
6222
  }
6223
+ function describeFeatureFlags(resolution) {
6224
+ return FEATURE_FLAGS.map(
6225
+ (f2) => `${f2.id}=${resolution.values[f2.id]}(${resolution.sources[f2.id]})`
6226
+ ).join(" ");
6227
+ }
6212
6228
  var FEATURE_FLAGS;
6213
6229
  var init_feature_flags = __esm({
6214
6230
  "src/feature-flags.ts"() {
@@ -152274,6 +152290,7 @@ var StreamerServer = class {
152274
152290
  dbPool = null;
152275
152291
  dbInstanceId = null;
152276
152292
  disableDb = false;
152293
+ host;
152277
152294
  // Skip the startup warm-up scan (test hook; see ServerConfig.skipStartupWarmup).
152278
152295
  skipStartupWarmup;
152279
152296
  autoResumeOnBoot;
@@ -152289,6 +152306,10 @@ var StreamerServer = class {
152289
152306
  // Resolved once at boot; see src/feature-flags.ts. Total map — every registry
152290
152307
  // id is present, so indexing it never yields undefined.
152291
152308
  featureFlags;
152309
+ // Which rung of the precedence chain decided each flag. Reported at boot and
152310
+ // over GET /api/config/feature-flags — the resolved boolean alone cannot say
152311
+ // whether a value came from the environment, the CLI, server.yaml or nowhere.
152312
+ featureFlagSources;
152292
152313
  // Derived from featureFlags.codexSystemPrompt. Kept as its own field so the
152293
152314
  // read site in startFresh() is unchanged.
152294
152315
  codexSystemPromptEnabled;
@@ -152395,16 +152416,20 @@ var StreamerServer = class {
152395
152416
  }
152396
152417
  this.verbose = config2.verbose ?? false;
152397
152418
  this.disableDb = config2.disableDb ?? false;
152419
+ this.host = config2.host;
152398
152420
  this.skipStartupWarmup = config2.skipStartupWarmup ?? false;
152399
152421
  this.autoResumeOnBoot = config2.autoResumeOnBoot ?? false;
152400
152422
  this.scanProfiles = config2.scanProfiles;
152401
152423
  this.codexRoots = config2.codexRoots ?? [(0, import_path33.join)((0, import_os15.homedir)(), ".codex", "sessions")];
152402
152424
  this.ptyGracePeriodMs = config2.ptyGracePeriodMs ?? DEFAULT_PTY_GRACE_PERIOD_MS;
152403
152425
  this.defaultSystemPrompt = config2.defaultSystemPrompt ?? DEFAULT_SYSTEM_PROMPT;
152404
- this.featureFlags = resolveFeatureFlags({ cli: config2.featureFlags, yaml: loadFeatureFlags() });
152405
- if (config2.codexSystemPromptEnabled !== void 0) {
152406
- this.featureFlags.codexSystemPrompt = config2.codexSystemPromptEnabled;
152407
- }
152426
+ const flagResolution = resolveFeatureFlags({
152427
+ override: config2.codexSystemPromptEnabled === void 0 ? void 0 : { codexSystemPrompt: config2.codexSystemPromptEnabled },
152428
+ cli: config2.featureFlags,
152429
+ yaml: loadFeatureFlags()
152430
+ });
152431
+ this.featureFlags = flagResolution.values;
152432
+ this.featureFlagSources = flagResolution.sources;
152408
152433
  this.codexSystemPromptEnabled = this.featureFlags.codexSystemPrompt;
152409
152434
  this.defaultPermissionMode = config2.defaultPermissionMode ?? loadDefaultPermissionMode() ?? "acceptEdits";
152410
152435
  this.defaultModel = config2.defaultModel ?? "sonnet";
@@ -152449,13 +152474,12 @@ var StreamerServer = class {
152449
152474
  });
152450
152475
  this.includeAgents = parseIncludeAgentsEnv(process.env.THREADBASE_INCLUDE_AGENTS);
152451
152476
  this.agentEntrypoints = parseAgentEntrypointsEnv(process.env.THREADBASE_AGENT_ENTRYPOINTS);
152452
- const enabledFlags = nonDefaultFeatureFlags(this.featureFlags);
152453
- if (enabledFlags.length > 0) {
152454
- this.log.info(`Feature flags active: ${enabledFlags.join(", ")}`, {
152455
- event: "config.feature_flags_active",
152456
- flags: enabledFlags
152457
- });
152458
- }
152477
+ this.log.info(`Feature flags: ${describeFeatureFlags(flagResolution)}`, {
152478
+ event: "config.feature_flags",
152479
+ values: this.featureFlags,
152480
+ sources: this.featureFlagSources,
152481
+ nonDefault: nonDefaultFeatureFlags(this.featureFlags)
152482
+ });
152459
152483
  const rawRoot = process.env.THREADBASE_BROWSE_ROOT ?? loadBrowseRoot() ?? config2.browseRoot;
152460
152484
  if (rawRoot) {
152461
152485
  (0, import_promises16.realpath)(rawRoot).then((resolved) => {
@@ -153047,7 +153071,7 @@ var StreamerServer = class {
153047
153071
  await runMigrations2(this.dbPool);
153048
153072
  this.log.info("Database migrations applied", { event: "db.migrations_applied" });
153049
153073
  }
153050
- await this.bindWithRetry(port);
153074
+ await this.bindWithRetry(port, this.host);
153051
153075
  if (!this.ptyManager.isRemote()) {
153052
153076
  this.idleReaperTimer = setInterval(() => this.reapIdleSessions(), IDLE_REAP_SWEEP_MS);
153053
153077
  this.idleReaperTimer.unref?.();
@@ -153056,7 +153080,8 @@ var StreamerServer = class {
153056
153080
  {
153057
153081
  this.log.info(`Streamer server listening on port ${port}`, {
153058
153082
  port,
153059
- event: "server.listening"
153083
+ event: "server.listening",
153084
+ ...this.host !== void 0 && { host: this.host }
153060
153085
  });
153061
153086
  try {
153062
153087
  this.runtimeStore = RuntimeStore.open(this.runtimeDbPath);
@@ -153270,15 +153295,15 @@ var StreamerServer = class {
153270
153295
  // Bind the HTTP listener, retrying on a transient EADDRINUSE. See the call
153271
153296
  // site in listen() for why the race exists (kickstart -k relaunch). Total
153272
153297
  // worst case ≈ 6 × 500 ms = 3 s before the final attempt rethrows.
153273
- async bindWithRetry(port, attempts = 6, delayMs = 500) {
153298
+ async bindWithRetry(port, host, attempts = 6, delayMs = 500) {
153274
153299
  this.binding = true;
153275
153300
  try {
153276
- await this.bindWithRetryLoop(port, attempts, delayMs);
153301
+ await this.bindWithRetryLoop(port, host, attempts, delayMs);
153277
153302
  } finally {
153278
153303
  this.binding = false;
153279
153304
  }
153280
153305
  }
153281
- async bindWithRetryLoop(port, attempts, delayMs) {
153306
+ async bindWithRetryLoop(port, host, attempts, delayMs) {
153282
153307
  for (let attempt = 1; attempt <= attempts; attempt++) {
153283
153308
  try {
153284
153309
  await new Promise((resolve4, reject) => {
@@ -153292,7 +153317,11 @@ var StreamerServer = class {
153292
153317
  };
153293
153318
  this.httpServer.once("error", onError);
153294
153319
  this.httpServer.once("listening", onListening);
153295
- this.httpServer.listen(port);
153320
+ if (host === void 0) {
153321
+ this.httpServer.listen(port);
153322
+ } else {
153323
+ this.httpServer.listen(port, host);
153324
+ }
153296
153325
  });
153297
153326
  return;
153298
153327
  } catch (err) {
@@ -153300,13 +153329,18 @@ var StreamerServer = class {
153300
153329
  if (e.code === "EADDRINUSE" && attempt === attempts) {
153301
153330
  this.log.error(
153302
153331
  `port ${port} still busy (EADDRINUSE) after ${attempts} attempts; giving up`,
153303
- { port, attempts, event: "server.bind_failed" }
153332
+ {
153333
+ port,
153334
+ attempts,
153335
+ event: "server.bind_failed",
153336
+ ...host !== void 0 && { host }
153337
+ }
153304
153338
  );
153305
153339
  }
153306
153340
  if (e.code !== "EADDRINUSE" || attempt === attempts) throw err;
153307
153341
  this.log.debug?.(
153308
153342
  `port ${port} busy (EADDRINUSE), retry ${attempt}/${attempts - 1} in ${delayMs}ms`,
153309
- { port, attempt, event: "server.bind_retry" }
153343
+ { port, attempt, event: "server.bind_retry", ...host !== void 0 && { host } }
153310
153344
  );
153311
153345
  await new Promise((r) => setTimeout(r, delayMs));
153312
153346
  }
@@ -153468,7 +153502,11 @@ var StreamerServer = class {
153468
153502
  * the absence of that field is the signal that this endpoint is read-only.
153469
153503
  */
153470
153504
  getFeatureFlagsConfig() {
153471
- return { registry: FEATURE_FLAGS, values: this.featureFlags };
153505
+ return {
153506
+ registry: FEATURE_FLAGS,
153507
+ values: this.featureFlags,
153508
+ sources: this.featureFlagSources
153509
+ };
153472
153510
  }
153473
153511
  getClaudeFlagsConfig() {
153474
153512
  return {
@@ -157672,7 +157710,7 @@ async function runProdDoctor(opts, deps = {}) {
157672
157710
  );
157673
157711
  }
157674
157712
  }
157675
- const featureFlags = deps.featureFlags ?? resolveFeatureFlags({ yaml: loadFeatureFlags() });
157713
+ const featureFlags = deps.featureFlags ?? resolveFeatureFlags({ yaml: loadFeatureFlags() }).values;
157676
157714
  if (featureFlags.ptyHost) {
157677
157715
  const probe = deps.probePtyHost ?? (() => probePtyHostStatus(hostSocketPath(getInstanceId())));
157678
157716
  try {
@@ -157693,6 +157731,33 @@ async function runProdDoctor(opts, deps = {}) {
157693
157731
  function toPowerShellLiteral(value) {
157694
157732
  return `'${value.replace(/'/g, "''")}'`;
157695
157733
  }
157734
+ function windowsFollowScript(paths, lines) {
157735
+ const list = `@(${paths.map(toPowerShellLiteral).join(", ")})`;
157736
+ return [
157737
+ "$ErrorActionPreference = 'Stop'",
157738
+ `$paths = ${list}`,
157739
+ `foreach ($f in $paths) { Get-Content -LiteralPath $f -Tail ${lines} }`,
157740
+ "$offsets = @{}",
157741
+ "foreach ($f in $paths) { $offsets[$f] = (Get-Item -LiteralPath $f).Length }",
157742
+ "while ($true) {",
157743
+ " foreach ($f in $paths) {",
157744
+ " try {",
157745
+ " $len = (Get-Item -LiteralPath $f).Length",
157746
+ " if ($len -lt $offsets[$f]) { $offsets[$f] = 0 }",
157747
+ " if ($len -gt $offsets[$f]) {",
157748
+ " $stream = [IO.File]::Open($f, 'Open', 'Read', 'ReadWrite')",
157749
+ " [void]$stream.Seek($offsets[$f], 'Begin')",
157750
+ " $reader = New-Object IO.StreamReader($stream)",
157751
+ " $reader.ReadToEnd()",
157752
+ " $reader.Dispose(); $stream.Dispose()",
157753
+ " $offsets[$f] = $len",
157754
+ " }",
157755
+ " } catch { }",
157756
+ " }",
157757
+ " Start-Sleep -Milliseconds 400",
157758
+ "}"
157759
+ ].join("; ");
157760
+ }
157696
157761
  var defaultSpawnTail = ({ files, lines, follow }) => {
157697
157762
  const existing = files.filter((f2) => (0, import_node_fs23.existsSync)(f2));
157698
157763
  if (existing.length === 0) {
@@ -157707,13 +157772,17 @@ var defaultSpawnTail = ({ files, lines, follow }) => {
157707
157772
  "-NoProfile",
157708
157773
  "-NonInteractive",
157709
157774
  "-Command",
157710
- `Get-Content -LiteralPath @(${existing.map(toPowerShellLiteral).join(", ")}) -Tail ${lines}${follow ? " -Wait" : ""}`
157775
+ follow ? windowsFollowScript(existing, lines) : `$ErrorActionPreference = 'Stop'; Get-Content -LiteralPath @(${existing.map(toPowerShellLiteral).join(", ")}) -Tail ${lines}`
157711
157776
  ] : ["-n", String(lines), ...follow ? ["-F"] : [], ...existing];
157777
+ const program3 = isWindows3 ? "powershell.exe" : "tail";
157712
157778
  return new Promise((resolve4) => {
157713
- const child = (0, import_node_child_process8.spawn)(isWindows3 ? "powershell.exe" : "tail", args, {
157779
+ const child = (0, import_node_child_process8.spawn)(program3, args, {
157714
157780
  stdio: ["ignore", "inherit", "inherit"]
157715
157781
  });
157716
- child.on("error", (err) => resolve4({ ok: false, message: `tail failed: ${err.message}` }));
157782
+ child.on(
157783
+ "error",
157784
+ (err) => resolve4({ ok: false, message: `${program3} failed: ${err.message}` })
157785
+ );
157717
157786
  child.on("exit", (code) => resolve4({ ok: code === 0 }));
157718
157787
  });
157719
157788
  };
@@ -157841,7 +157910,7 @@ function registerProdCommands(program3) {
157841
157910
  var log14 = getLogger("cli");
157842
157911
  var program2 = new Command();
157843
157912
  program2.name("threadbase-streamer").description("PTY session management, WebSocket streaming, and REST API server for Claude Code").version(getVersion());
157844
- program2.command("serve").description("Start the streamer server").option("-p, --port <number>", "Port to listen on", "8766").option("--api-key <key>", "API key for authentication").option("--local-no-auth", "Skip auth for localhost requests", false).option("-v, --verbose", "Verbose output", false).option("--log-menubar-requests", "Log /healthz requests from the menubar app", false).option("--browse-root <path>", "Root directory for file browsing").option(
157913
+ program2.command("serve").description("Start the streamer server").option("-p, --port <number>", "Port to listen on", "8766").option("--host <address>", "Host/address to bind the server to (default: all interfaces)").option("--api-key <key>", "API key for authentication").option("--local-no-auth", "Skip auth for localhost requests", false).option("-v, --verbose", "Verbose output", false).option("--log-menubar-requests", "Log /healthz requests from the menubar app", false).option("--browse-root <path>", "Root directory for file browsing").option(
157845
157914
  "--public-url <url>",
157846
157915
  "Public URL clients should use to reach this server (https:// required, except localhost). Falls back to THREADBASE_PUBLIC_URL env or public_url: in ~/.threadbase/server.yaml."
157847
157916
  ).option(
@@ -158029,6 +158098,7 @@ program2.command("serve").description("Start the streamer server").option("-p, -
158029
158098
  }
158030
158099
  const server = new StreamerServer({
158031
158100
  port: resolvedPort,
158101
+ host: opts.host,
158032
158102
  apiKey,
158033
158103
  apiKeySource: opts.apiKey ? "cli" : "config",
158034
158104
  localNoAuth: opts.localNoAuth,