@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 +8 -4
- package/dist/cli.cjs +102 -32
- package/dist/cli.cjs.map +1 -1
- package/dist/index.cjs +62 -25
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +52 -2
- package/dist/index.d.ts +52 -2
- package/dist/index.js +62 -25
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
6205
|
+
const values = {};
|
|
6206
|
+
const sources = {};
|
|
6204
6207
|
for (const def of FEATURE_FLAGS) {
|
|
6205
|
-
|
|
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
|
|
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
|
-
|
|
152405
|
-
|
|
152406
|
-
|
|
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
|
-
|
|
152453
|
-
|
|
152454
|
-
|
|
152455
|
-
|
|
152456
|
-
|
|
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
|
-
|
|
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
|
-
{
|
|
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 {
|
|
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
|
-
|
|
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)(
|
|
157779
|
+
const child = (0, import_node_child_process8.spawn)(program3, args, {
|
|
157714
157780
|
stdio: ["ignore", "inherit", "inherit"]
|
|
157715
157781
|
});
|
|
157716
|
-
child.on(
|
|
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,
|