@threadbase-sh/streamer 1.38.0 → 1.39.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
@@ -350,6 +350,10 @@ var DANGEROUS_PERMISSION_MODES = [
350
350
  function isDangerousPermissionMode(mode) {
351
351
  return DANGEROUS_PERMISSION_MODES.includes(mode);
352
352
  }
353
+ var EFFORT_LEVELS = ["low", "medium", "high", "xhigh", "max"];
354
+ function isEffortLevel(value) {
355
+ return typeof value === "string" && EFFORT_LEVELS.includes(value);
356
+ }
353
357
  var CLAUDE_FLAGS = [
354
358
  {
355
359
  id: "permissionMode",
@@ -361,9 +365,10 @@ var CLAUDE_FLAGS = [
361
365
  { id: "addDir", flag: "--add-dir", valueType: "list", risk: "elevated" },
362
366
  { id: "allowedTools", flag: "--allowedTools", valueType: "list", risk: "elevated" },
363
367
  { id: "disallowedTools", flag: "--disallowedTools", valueType: "list", risk: "low" },
364
- { id: "maxBudgetUsd", flag: "--max-budget-usd", valueType: "string", risk: "low" },
365
- { id: "fallbackModel", flag: "--fallback-model", valueType: "string", risk: "low" }
368
+ { id: "model", flag: "--model", valueType: "string", risk: "low" },
369
+ { id: "effort", flag: "--effort", valueType: "enum", enumValues: EFFORT_LEVELS, risk: "low" }
366
370
  ];
371
+ var SPAWN_POSITIONAL_FLAG_IDS = /* @__PURE__ */ new Set(["permissionMode", "model", "effort"]);
367
372
  function findFlag(id) {
368
373
  return CLAUDE_FLAGS.find((f) => f.id === id);
369
374
  }
@@ -428,7 +433,7 @@ function buildFlagArgs(values, extraArgs) {
428
433
  const args = [];
429
434
  const safe = validateFlagValues(values ?? {});
430
435
  for (const def of CLAUDE_FLAGS) {
431
- if (def.id === "permissionMode") continue;
436
+ if (SPAWN_POSITIONAL_FLAG_IDS.has(def.id)) continue;
432
437
  const value = safe[def.id];
433
438
  if (value === void 0) continue;
434
439
  if (def.valueType === "boolean") {
@@ -4710,6 +4715,14 @@ var createSessionRoutes = (deps) => {
4710
4715
  await deps.handleSetSessionName(c.req.param("id"), c.env.incoming, c.env.outgoing);
4711
4716
  return alreadyHandled6();
4712
4717
  });
4718
+ app.patch("/:id/model", async (c) => {
4719
+ await deps.handleSetSessionModel(c.req.param("id"), c.env.incoming, c.env.outgoing);
4720
+ return alreadyHandled6();
4721
+ });
4722
+ app.patch("/:id/effort", async (c) => {
4723
+ await deps.handleSetSessionEffort(c.req.param("id"), c.env.incoming, c.env.outgoing);
4724
+ return alreadyHandled6();
4725
+ });
4713
4726
  app.post("/:id/adopt", async (c) => {
4714
4727
  await deps.handleAdopt(c.req.param("id"), c.env.outgoing);
4715
4728
  return alreadyHandled6();
@@ -5045,6 +5058,7 @@ CREATE TABLE IF NOT EXISTS conversation_meta (
5045
5058
  );
5046
5059
  CREATE INDEX IF NOT EXISTS idx_meta_last_activity ON conversation_meta(last_activity DESC);
5047
5060
  CREATE INDEX IF NOT EXISTS idx_meta_project ON conversation_meta(project_path);
5061
+ CREATE INDEX IF NOT EXISTS idx_meta_file_path ON conversation_meta(file_path);
5048
5062
 
5049
5063
  CREATE TABLE IF NOT EXISTS conversation_tail (
5050
5064
  conversation_id TEXT PRIMARY KEY REFERENCES conversation_meta(id) ON DELETE CASCADE,
@@ -8702,6 +8716,24 @@ var WSHub = class {
8702
8716
  this.clients.delete(client);
8703
8717
  }
8704
8718
  }
8719
+ // Scoped broadcast for high-frequency per-session messages (terminal_output,
8720
+ // user_message). Sending to every connected client for every PTY output
8721
+ // chunk made broadcast() cost scale with connections x active sessions;
8722
+ // this bounds it to only that session's subscribers.
8723
+ broadcastToClients(clients, message) {
8724
+ const data = JSON.stringify(message);
8725
+ for (const client of clients) {
8726
+ try {
8727
+ if (client.readyState === client.OPEN) {
8728
+ client.send(data);
8729
+ } else {
8730
+ this.clients.delete(client);
8731
+ }
8732
+ } catch {
8733
+ this.clients.delete(client);
8734
+ }
8735
+ }
8736
+ }
8705
8737
  unicast(ws, message) {
8706
8738
  try {
8707
8739
  if (ws.readyState === ws.OPEN) {
@@ -8764,6 +8796,7 @@ var ADOPT_KILL_TIMEOUT_MS = 5e3;
8764
8796
  var ADOPT_KILL_POLL_MS = 100;
8765
8797
  var REFRESH_TTL_MS = 2e3;
8766
8798
  var START_READY_TIMEOUT_MS = 1e4;
8799
+ var MODEL_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
8767
8800
  var EXTERNAL_TAIL_RECENCY_MS = RESUME_BUSY_WINDOW_MS;
8768
8801
  var EXTERNAL_TAIL_MAX = 32;
8769
8802
  var EXTERNAL_TAIL_IDLE_MS = 3e5;
@@ -8867,6 +8900,8 @@ var StreamerServer = class {
8867
8900
  dbPool = null;
8868
8901
  dbInstanceId = null;
8869
8902
  disableDb = false;
8903
+ // Skip the startup warm-up scan (test hook; see ServerConfig.skipStartupWarmup).
8904
+ skipStartupWarmup;
8870
8905
  browseRoot = null;
8871
8906
  publicUrl = null;
8872
8907
  browserCors;
@@ -8906,6 +8941,11 @@ var StreamerServer = class {
8906
8941
  // every provider; read only by the idle reaper. Entries are dropped when the
8907
8942
  // session leaves the runner (reap/exit/hold).
8908
8943
  lastAgentChunkAt = /* @__PURE__ */ new Map();
8944
+ // sessionId → last terminal_output seq broadcast (starts at 1, per session).
8945
+ // Stamped on every terminal_output/terminal_replay so a client can detect a
8946
+ // stale chunk delivered after a reconnect race instead of trusting raw WS
8947
+ // arrival order. Entries dropped alongside lastAgentChunkAt.
8948
+ terminalSeq = /* @__PURE__ */ new Map();
8909
8949
  // Recently accepted input idempotency keys (C4). A retried POST replays its
8910
8950
  // original outcome instead of submitting the prompt to the agent twice.
8911
8951
  idempotency = new IdempotencyStore();
@@ -8973,6 +9013,7 @@ var StreamerServer = class {
8973
9013
  }
8974
9014
  this.verbose = config.verbose ?? false;
8975
9015
  this.disableDb = config.disableDb ?? false;
9016
+ this.skipStartupWarmup = config.skipStartupWarmup ?? false;
8976
9017
  this.scannerPersistenceDisabled = config.scannerPersistent === false;
8977
9018
  this.scanProfiles = config.scanProfiles;
8978
9019
  this.codexRoots = config.codexRoots ?? [(0, import_path18.join)((0, import_os9.homedir)(), ".codex", "sessions")];
@@ -9121,10 +9162,22 @@ var StreamerServer = class {
9121
9162
  logger: getLogger("pty"),
9122
9163
  onOutput: (sessionId, data) => {
9123
9164
  this.lastAgentChunkAt.set(sessionId, Date.now());
9124
- this.wsHub.broadcast({ type: "terminal_output", sessionId, data });
9165
+ const seq = (this.terminalSeq.get(sessionId) ?? 0) + 1;
9166
+ this.terminalSeq.set(sessionId, seq);
9167
+ this.wsHub.broadcastToClients(this.sessionSubscribers.get(sessionId) ?? [], {
9168
+ type: "terminal_output",
9169
+ sessionId,
9170
+ data,
9171
+ seq
9172
+ });
9125
9173
  },
9126
9174
  onUserMessage: (sessionId, text, ts) => {
9127
- this.wsHub.broadcast({ type: "user_message", sessionId, text, ts });
9175
+ this.wsHub.broadcastToClients(this.sessionSubscribers.get(sessionId) ?? [], {
9176
+ type: "user_message",
9177
+ sessionId,
9178
+ text,
9179
+ ts
9180
+ });
9128
9181
  },
9129
9182
  onPermissionChange: (sessionId, gate) => {
9130
9183
  this.handlePermissionChange(sessionId, gate);
@@ -9261,6 +9314,8 @@ var StreamerServer = class {
9261
9314
  handleCancel: (id, res) => this.handleCancel(id, res),
9262
9315
  handleStopSession: (id, res) => this.handleStopSession(id, res),
9263
9316
  handleSetSessionName: (id, req, res) => this.handleSetSessionName(id, req, res),
9317
+ handleSetSessionModel: (id, req, res) => this.applyLiveSessionSetting(id, req, res, "model"),
9318
+ handleSetSessionEffort: (id, req, res) => this.applyLiveSessionSetting(id, req, res, "effort"),
9264
9319
  handleUploadFile: (id, req, res) => this.handleUploadFile(id, req, res),
9265
9320
  handleAdopt: (id, res) => this.handleAdopt(id, res),
9266
9321
  handleResume: (req, res) => this.handleResume(req, res),
@@ -9307,7 +9362,8 @@ var StreamerServer = class {
9307
9362
  type: "terminal_replay",
9308
9363
  sessionId: msg.sessionId,
9309
9364
  lines,
9310
- userMessages
9365
+ userMessages,
9366
+ seq: this.terminalSeq.get(msg.sessionId)
9311
9367
  })
9312
9368
  );
9313
9369
  }
@@ -9667,6 +9723,7 @@ var StreamerServer = class {
9667
9723
  );
9668
9724
  this.ptyManager.putOnHold(session.id);
9669
9725
  this.lastAgentChunkAt.delete(session.id);
9726
+ this.terminalSeq.delete(session.id);
9670
9727
  this.idempotency.clear(session.id);
9671
9728
  this.sessionSubscribers.delete(session.id);
9672
9729
  reaped.push(session.id);
@@ -9841,6 +9898,14 @@ var StreamerServer = class {
9841
9898
  );
9842
9899
  this.scannerPersistenceDisabled = true;
9843
9900
  }
9901
+ if (this.skipStartupWarmup) {
9902
+ this.log.debug?.("startup warm-up scan skipped (skipStartupWarmup)", {
9903
+ event: "cache.warmup_skipped"
9904
+ });
9905
+ this.finishWarmup(0);
9906
+ resolveWarm();
9907
+ return;
9908
+ }
9844
9909
  const warmupStatCache = this.buildStatCache(null);
9845
9910
  const warmupScanner = this.newScanner(warmupStatCache ? { persistent: false } : void 0);
9846
9911
  this.allScanners.add(warmupScanner);
@@ -10032,6 +10097,7 @@ var StreamerServer = class {
10032
10097
  this.idleReaperTimer = null;
10033
10098
  }
10034
10099
  this.lastAgentChunkAt.clear();
10100
+ this.terminalSeq.clear();
10035
10101
  this.recordShutdownState();
10036
10102
  this.markScannerStaleDebounced.cancel();
10037
10103
  await Promise.all([...this.inFlightCacheWrites]);
@@ -10213,6 +10279,30 @@ var StreamerServer = class {
10213
10279
  persisted: this.claudeFlagsPersistable
10214
10280
  };
10215
10281
  }
10282
+ /**
10283
+ * The three spawn options that a configured claude-flag can override, with
10284
+ * the boot-time CLI/yaml default as the fallback. Spread into every
10285
+ * start/resume/adopt call so all three paths agree.
10286
+ *
10287
+ * These ids are excluded from buildFlagArgs (SPAWN_POSITIONAL_FLAG_IDS)
10288
+ * precisely because they arrive here instead — the PTY spawn paths pass them
10289
+ * as explicit positionals, so emitting them from the allowlist too would
10290
+ * duplicate the flag.
10291
+ *
10292
+ * Narrowed with the type guards rather than cast: ClaudeFlagValues is a loose
10293
+ * Record by design, and while validateFlagValues already guarantees the shape
10294
+ * on the way in, TypeScript cannot see that through the record.
10295
+ */
10296
+ spawnFlagOverrides() {
10297
+ const mode = this.claudeFlags.permissionMode;
10298
+ const model = this.claudeFlags.model;
10299
+ const effort = this.claudeFlags.effort;
10300
+ return {
10301
+ permissionMode: isPermissionMode(mode) ? mode : this.defaultPermissionMode,
10302
+ model: typeof model === "string" ? model : this.defaultModel,
10303
+ effort: isEffortLevel(effort) ? effort : this.defaultEffort
10304
+ };
10305
+ }
10216
10306
  checkRateLimit(map, key, limit, windowMs) {
10217
10307
  const now = Date.now();
10218
10308
  const arr = (map.get(key) ?? []).filter((t) => now - t < windowMs);
@@ -11362,11 +11452,9 @@ var StreamerServer = class {
11362
11452
  projectPath,
11363
11453
  projectName: body.projectName,
11364
11454
  branch: body.branch,
11365
- permissionMode: this.defaultPermissionMode,
11366
11455
  claudeFlags: this.claudeFlags,
11367
11456
  claudeExtraArgs: this.claudeExtraArgs,
11368
- model: this.defaultModel,
11369
- effort: this.defaultEffort
11457
+ ...this.spawnFlagOverrides()
11370
11458
  });
11371
11459
  this.sessionStore.addManaged(session);
11372
11460
  this.recordSessionSpawn(session);
@@ -11813,11 +11901,9 @@ var StreamerServer = class {
11813
11901
  projectPath,
11814
11902
  projectName,
11815
11903
  branch,
11816
- permissionMode: this.defaultPermissionMode,
11817
11904
  claudeFlags: this.claudeFlags,
11818
11905
  claudeExtraArgs: this.claudeExtraArgs,
11819
- model: this.defaultModel,
11820
- effort: this.defaultEffort
11906
+ ...this.spawnFlagOverrides()
11821
11907
  });
11822
11908
  this.sessionStore.addManaged(session);
11823
11909
  this.recordSessionSpawn(session);
@@ -11890,11 +11976,9 @@ var StreamerServer = class {
11890
11976
  projectPath: resolvedPath,
11891
11977
  projectName: body.projectName,
11892
11978
  ...includeSystemPrompt && { systemPrompt: systemPromptParts.join("\n") },
11893
- permissionMode: this.defaultPermissionMode,
11894
11979
  claudeFlags: this.claudeFlags,
11895
11980
  claudeExtraArgs: this.claudeExtraArgs,
11896
- model: this.defaultModel,
11897
- effort: this.defaultEffort
11981
+ ...this.spawnFlagOverrides()
11898
11982
  });
11899
11983
  this.sessionStore.addManaged(session);
11900
11984
  this.recordSessionSpawn(session);
@@ -12254,6 +12338,87 @@ var StreamerServer = class {
12254
12338
  this.cache.upsertSessionName(sessionId, name);
12255
12339
  json(res, 200, { ok: true });
12256
12340
  }
12341
+ /**
12342
+ * Retarget a LIVE session's model or effort by typing the corresponding
12343
+ * Claude Code slash command into its PTY.
12344
+ *
12345
+ * There is no CLI or IPC channel for this — `--model`/`--effort` are spawn
12346
+ * arguments — so the interactive `/model <x>` / `/effort <y>` commands are the
12347
+ * only way to change a session already running. Both accept an argument and
12348
+ * apply it without opening the picker (verified against Claude Code v2.1.220).
12349
+ *
12350
+ * Answers 202, not 200: the value is applied by the TUI on its next render, so
12351
+ * there is nothing truthful to echo back synchronously. Clients confirm with
12352
+ * `GET /api/sessions/:id`, which scrapes the applied value off the live status
12353
+ * line.
12354
+ */
12355
+ async applyLiveSessionSetting(sessionId, req, res, setting) {
12356
+ const session = this.ptyManager.getSession(sessionId);
12357
+ if (!session) {
12358
+ const known = this.sessionStore.getManaged(sessionId);
12359
+ if (known) {
12360
+ json(res, 409, {
12361
+ error: "Session has no live PTY; resume it first",
12362
+ code: "SESSION_IDLE"
12363
+ });
12364
+ return;
12365
+ }
12366
+ json(res, 404, { error: "Session not found" });
12367
+ return;
12368
+ }
12369
+ if ((session.provider ?? CLAUDE_CODE_PROVIDER) !== CLAUDE_CODE_PROVIDER) {
12370
+ json(res, 501, {
12371
+ error: `Setting ${setting} on a ${session.provider} session is not supported`,
12372
+ code: "UNSUPPORTED_PROVIDER"
12373
+ });
12374
+ return;
12375
+ }
12376
+ if (session.status === "running") {
12377
+ json(res, 409, {
12378
+ error: "Session is mid-turn; retry once it is waiting for input",
12379
+ code: "SESSION_BUSY"
12380
+ });
12381
+ return;
12382
+ }
12383
+ let parsed;
12384
+ try {
12385
+ parsed = await readBody(req);
12386
+ } catch {
12387
+ json(res, 400, { error: "Invalid JSON" });
12388
+ return;
12389
+ }
12390
+ let value;
12391
+ if (setting === "effort") {
12392
+ if (!isEffortLevel(parsed.effort)) {
12393
+ json(res, 400, {
12394
+ error: `effort must be one of ${EFFORT_LEVELS.join(", ")}`
12395
+ });
12396
+ return;
12397
+ }
12398
+ value = parsed.effort;
12399
+ } else {
12400
+ if (typeof parsed.model !== "string" || !MODEL_NAME_RE.test(parsed.model)) {
12401
+ json(res, 400, {
12402
+ error: "model must be an alias or full model name (letters, digits, dot, dash, underscore)"
12403
+ });
12404
+ return;
12405
+ }
12406
+ value = parsed.model;
12407
+ }
12408
+ try {
12409
+ this.ptyManager.sendKeys(sessionId, `/${setting} ${value}\r`);
12410
+ } catch (err) {
12411
+ json(res, 400, { error: err instanceof Error ? err.message : "Failed to write to session" });
12412
+ return;
12413
+ }
12414
+ this.log.info(`Live session ${setting} set to ${value}`, {
12415
+ event: "session.setting_applied",
12416
+ sessionId,
12417
+ setting,
12418
+ value
12419
+ });
12420
+ json(res, 202, { id: sessionId, [setting]: value });
12421
+ }
12257
12422
  handleGetSessionNames(res) {
12258
12423
  if (!this.cache) {
12259
12424
  json(res, 200, {});