@threadbase-sh/streamer 1.61.1 → 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/README.md +8 -0
- package/dist/cli.cjs +125 -32
- package/dist/cli.cjs.map +1 -1
- package/dist/index.cjs +91 -28
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +59 -32
- package/dist/index.d.ts +59 -32
- package/dist/index.js +91 -28
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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();
|
|
@@ -148616,7 +148654,9 @@ var SessionHandlers = class {
|
|
|
148616
148654
|
res.end(JSON.stringify({ error: "Session not found" }));
|
|
148617
148655
|
return;
|
|
148618
148656
|
}
|
|
148657
|
+
const shouldForget = this.shouldForgetEmptySession(session);
|
|
148619
148658
|
if (session.status === "idle") {
|
|
148659
|
+
if (shouldForget) this.forgetEmptyStoppedSession(sessionId);
|
|
148620
148660
|
res.writeHead(200, { "Content-Type": "application/json" });
|
|
148621
148661
|
res.end(JSON.stringify({ status: "already_idle", sessionId }));
|
|
148622
148662
|
return;
|
|
@@ -148644,6 +148684,7 @@ var SessionHandlers = class {
|
|
|
148644
148684
|
this.ptyManager.putOnHold(sessionId);
|
|
148645
148685
|
this.discoveryCache = null;
|
|
148646
148686
|
const outcome = await Promise.race([idlePromise, timeoutPromise]);
|
|
148687
|
+
if (shouldForget) this.forgetEmptyStoppedSession(sessionId);
|
|
148647
148688
|
if (outcome === "idle") {
|
|
148648
148689
|
res.write(`${JSON.stringify({ event: "stopped", sessionId })}
|
|
148649
148690
|
`);
|
|
@@ -148656,6 +148697,42 @@ var SessionHandlers = class {
|
|
|
148656
148697
|
}
|
|
148657
148698
|
res.end();
|
|
148658
148699
|
}
|
|
148700
|
+
/**
|
|
148701
|
+
* An unused start: the user never submitted a prompt, and the conversation
|
|
148702
|
+
* cache has no row for this id (empty Codex/Claude often never write a JSONL).
|
|
148703
|
+
* `conversationId === sessionId` is not evidence of history — only the cache
|
|
148704
|
+
* is. promptCount > 0 or a cache hit keeps today's hold path.
|
|
148705
|
+
*/
|
|
148706
|
+
shouldForgetEmptySession(session) {
|
|
148707
|
+
const stored = this.sessionStore.getManaged(session.id);
|
|
148708
|
+
const promptCount = Math.max(session.promptCount, stored?.promptCount ?? 0);
|
|
148709
|
+
if (promptCount > 0) return false;
|
|
148710
|
+
return !this.hasCachedConversationFor(session, stored);
|
|
148711
|
+
}
|
|
148712
|
+
hasCachedConversationFor(session, stored) {
|
|
148713
|
+
const cache = this.cache;
|
|
148714
|
+
if (!cache) return false;
|
|
148715
|
+
const ids = /* @__PURE__ */ new Set([session.id]);
|
|
148716
|
+
if (session.boundConversationId) ids.add(session.boundConversationId);
|
|
148717
|
+
if (session.resumedFromConversationId) ids.add(session.resumedFromConversationId);
|
|
148718
|
+
if (stored?.boundConversationId) ids.add(stored.boundConversationId);
|
|
148719
|
+
if (stored?.resumedFromConversationId) ids.add(stored.resumedFromConversationId);
|
|
148720
|
+
for (const id of ids) {
|
|
148721
|
+
if (cache.hasConversation(id)) return true;
|
|
148722
|
+
}
|
|
148723
|
+
return false;
|
|
148724
|
+
}
|
|
148725
|
+
forgetEmptyStoppedSession(sessionId) {
|
|
148726
|
+
this.log.info(`[stop] forgetting empty session ${sessionId.slice(0, 8)}`, {
|
|
148727
|
+
event: "session.forget_empty",
|
|
148728
|
+
sessionId
|
|
148729
|
+
});
|
|
148730
|
+
this.deps.forgetSession(sessionId);
|
|
148731
|
+
this.wsHub.broadcast({
|
|
148732
|
+
type: "session_list",
|
|
148733
|
+
sessions: this.sessionStore.list(this.deps.ptyAttachedIds())
|
|
148734
|
+
});
|
|
148735
|
+
}
|
|
148659
148736
|
async handleAdopt(sessionId, res) {
|
|
148660
148737
|
const discovered = await discoverClaudeProcesses();
|
|
148661
148738
|
this.sessionStore.setDiscovered(discovered);
|
|
@@ -154081,6 +154158,7 @@ var StreamerServer = class {
|
|
|
154081
154158
|
spawnFlagOverrides: () => this.spawnFlagOverrides(),
|
|
154082
154159
|
resolveConversationTarget: (sessionId) => this.resolveConversationTarget(sessionId),
|
|
154083
154160
|
waitForStartupOutcome: (sessionId, timeoutMs) => this.waitForStartupOutcome(sessionId, timeoutMs),
|
|
154161
|
+
forgetSession: (sessionId) => this.forgetSession(sessionId),
|
|
154084
154162
|
abandonFailedStart: (sessionId) => this.abandonFailedStart(sessionId),
|
|
154085
154163
|
enrichResumedSessionAsync: (sessionId, projectPath, conv) => this.enrichResumedSessionAsync(sessionId, projectPath, conv),
|
|
154086
154164
|
findJsonlPath: (uuid3) => this.conversationHandlers.findJsonlPath(uuid3),
|
|
@@ -155072,7 +155150,7 @@ var StreamerServer = class {
|
|
|
155072
155150
|
*/
|
|
155073
155151
|
getFeatureFlagsConfig() {
|
|
155074
155152
|
return {
|
|
155075
|
-
registry:
|
|
155153
|
+
registry: FEATURE_FLAG_LIST,
|
|
155076
155154
|
values: this.featureFlags,
|
|
155077
155155
|
sources: this.featureFlagSources
|
|
155078
155156
|
};
|
|
@@ -155306,29 +155384,44 @@ var StreamerServer = class {
|
|
|
155306
155384
|
});
|
|
155307
155385
|
}
|
|
155308
155386
|
/**
|
|
155309
|
-
* Drop every trace of a session
|
|
155310
|
-
*
|
|
155387
|
+
* Drop every trace of a managed session: in-memory store, durable registry
|
|
155388
|
+
* row, and the collision-probe markers that would otherwise outlive it.
|
|
155311
155389
|
*
|
|
155312
|
-
*
|
|
155313
|
-
*
|
|
155314
|
-
*
|
|
155315
|
-
*
|
|
155316
|
-
*
|
|
155390
|
+
* Used when a start never became usable (`abandonFailedStart`) and when stop
|
|
155391
|
+
* is asked to discard an empty session that has no cached conversation. The
|
|
155392
|
+
* registry delete is load-bearing — `rehydrateSessions` will bring the row
|
|
155393
|
+
* back on the next boot if it remains.
|
|
155394
|
+
*
|
|
155395
|
+
* Callers that kill the PTY (`putOnHold`) must do that *first*: onStatusChange
|
|
155396
|
+
* on idle writes `selfPtyEndedAt` and a registry status, and those have to
|
|
155397
|
+
* be cleared here afterwards.
|
|
155317
155398
|
*/
|
|
155318
|
-
|
|
155399
|
+
forgetSession(sessionId) {
|
|
155319
155400
|
this.sessionStore.removeManaged(sessionId);
|
|
155320
155401
|
this.selfPtyEndedAt.delete(sessionId);
|
|
155321
155402
|
this.contendedSessions.delete(sessionId);
|
|
155322
155403
|
try {
|
|
155323
155404
|
this.managedSessionsRepo?.delete(sessionId);
|
|
155324
155405
|
} catch (err) {
|
|
155325
|
-
this.log.warn("[registry] failed to drop a
|
|
155406
|
+
this.log.warn("[registry] failed to drop a session", {
|
|
155326
155407
|
event: "registry.forget_failed",
|
|
155327
155408
|
sessionId,
|
|
155328
155409
|
err
|
|
155329
155410
|
});
|
|
155330
155411
|
}
|
|
155331
155412
|
}
|
|
155413
|
+
/**
|
|
155414
|
+
* Drop every trace of a session that never became usable.
|
|
155415
|
+
*
|
|
155416
|
+
* The runner has already torn itself down (failStartup / handleExit); what
|
|
155417
|
+
* remains is server-side bookkeeping that would otherwise leave a dead
|
|
155418
|
+
* session in the list, a registry row claiming a spawn, and a `selfPtyEndedAt`
|
|
155419
|
+
* marker that would suppress the mtime collision signal on the NEXT resume —
|
|
155420
|
+
* i.e. it would help hide the very owner we just collided with.
|
|
155421
|
+
*/
|
|
155422
|
+
abandonFailedStart(sessionId) {
|
|
155423
|
+
this.forgetSession(sessionId);
|
|
155424
|
+
}
|
|
155332
155425
|
enrichResumedSessionAsync(sessionId, projectPath, conv) {
|
|
155333
155426
|
try {
|
|
155334
155427
|
if (!this.sessionStore.getManaged(sessionId)) return;
|
|
@@ -159524,7 +159617,7 @@ program2.command("serve").description("Start the streamer server").option("-p, -
|
|
|
159524
159617
|
(value, previous = []) => [...previous, value]
|
|
159525
159618
|
).option(
|
|
159526
159619
|
"--feature <id=bool>",
|
|
159527
|
-
|
|
159620
|
+
`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
159621
|
(value, previous = []) => [...previous, value]
|
|
159529
159622
|
).option(
|
|
159530
159623
|
"--claude-extra-args <args>",
|