@threadbase-sh/streamer 1.38.0 → 1.39.1

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;
@@ -8834,6 +8867,20 @@ var StreamerServer = class {
8834
8867
  // Set by onConversationChanged while a scan is in-flight; getScanner() does
8835
8868
  // a single rescan after the current one completes instead of restarting it.
8836
8869
  scannerStale = false;
8870
+ // WHICH files scannerStale is about. A directory event names exactly one
8871
+ // JSONL, and the only correct response is refreshFile() on that one file —
8872
+ // but scannerStale alone carries no identity, so honoring it used to mean a
8873
+ // full-tree rescan. On the non-persistent scanner this server actually runs
8874
+ // (buildStatCache => persistent:false, see listen()), scan() opens by
8875
+ // clearing metadataCache AND conversationLRU — so one live session appending
8876
+ // to its own transcript threw away every OTHER conversation's parsed
8877
+ // snapshot, and the next full fetch of an unrelated conversation re-parsed
8878
+ // it from disk (745-2877ms on a 5MB/1112-message history) while the
8879
+ // per-file paginated path stayed at ~20ms throughout. Populated alongside
8880
+ // scannerStale and drained with it by takeStaleFiles(); an armed flag with
8881
+ // an EMPTY set means "stale, source unknown" and still falls back to the
8882
+ // full rescan.
8883
+ staleFiles = /* @__PURE__ */ new Set();
8837
8884
  // Single-flight guard for the background disk reconcile: a burst of list
8838
8885
  // polls during active session writes shares one rescan instead of queueing
8839
8886
  // a full rescan per request.
@@ -8867,6 +8914,8 @@ var StreamerServer = class {
8867
8914
  dbPool = null;
8868
8915
  dbInstanceId = null;
8869
8916
  disableDb = false;
8917
+ // Skip the startup warm-up scan (test hook; see ServerConfig.skipStartupWarmup).
8918
+ skipStartupWarmup;
8870
8919
  browseRoot = null;
8871
8920
  publicUrl = null;
8872
8921
  browserCors;
@@ -8906,6 +8955,11 @@ var StreamerServer = class {
8906
8955
  // every provider; read only by the idle reaper. Entries are dropped when the
8907
8956
  // session leaves the runner (reap/exit/hold).
8908
8957
  lastAgentChunkAt = /* @__PURE__ */ new Map();
8958
+ // sessionId → last terminal_output seq broadcast (starts at 1, per session).
8959
+ // Stamped on every terminal_output/terminal_replay so a client can detect a
8960
+ // stale chunk delivered after a reconnect race instead of trusting raw WS
8961
+ // arrival order. Entries dropped alongside lastAgentChunkAt.
8962
+ terminalSeq = /* @__PURE__ */ new Map();
8909
8963
  // Recently accepted input idempotency keys (C4). A retried POST replays its
8910
8964
  // original outcome instead of submitting the prompt to the agent twice.
8911
8965
  idempotency = new IdempotencyStore();
@@ -8973,6 +9027,7 @@ var StreamerServer = class {
8973
9027
  }
8974
9028
  this.verbose = config.verbose ?? false;
8975
9029
  this.disableDb = config.disableDb ?? false;
9030
+ this.skipStartupWarmup = config.skipStartupWarmup ?? false;
8976
9031
  this.scannerPersistenceDisabled = config.scannerPersistent === false;
8977
9032
  this.scanProfiles = config.scanProfiles;
8978
9033
  this.codexRoots = config.codexRoots ?? [(0, import_path18.join)((0, import_os9.homedir)(), ".codex", "sessions")];
@@ -8994,7 +9049,10 @@ var StreamerServer = class {
8994
9049
  this.directoryDebounceMs = parseDirScanDebounceEnv(process.env.THREADBASE_DIR_SCAN_DEBOUNCE_MS) ?? config.directoryScanDebounceMs ?? 1e3;
8995
9050
  this.markScannerStaleDebounced = debounce(() => {
8996
9051
  if (this.scannerReady) this.scannerStale = true;
8997
- else this.scanner = null;
9052
+ else {
9053
+ this.scanner = null;
9054
+ this.staleFiles.clear();
9055
+ }
8998
9056
  }, this.directoryDebounceMs);
8999
9057
  this.includeAgents = parseIncludeAgentsEnv(process.env.THREADBASE_INCLUDE_AGENTS);
9000
9058
  this.agentEntrypoints = parseAgentEntrypointsEnv(process.env.THREADBASE_AGENT_ENTRYPOINTS);
@@ -9087,6 +9145,7 @@ var StreamerServer = class {
9087
9145
  if (!tailed) this.maybeAttachExternalTail(filePath);
9088
9146
  this.sweepIdleExternalTails();
9089
9147
  this.cache?.invalidateByFilePath(filePath, { skipIfTailed: true });
9148
+ this.staleFiles.add(filePath);
9090
9149
  this.markScannerStaleDebounced();
9091
9150
  this.log.debug?.(`Scanner invalidated by directory event: ${filePath}`, {
9092
9151
  filePath,
@@ -9121,10 +9180,22 @@ var StreamerServer = class {
9121
9180
  logger: getLogger("pty"),
9122
9181
  onOutput: (sessionId, data) => {
9123
9182
  this.lastAgentChunkAt.set(sessionId, Date.now());
9124
- this.wsHub.broadcast({ type: "terminal_output", sessionId, data });
9183
+ const seq = (this.terminalSeq.get(sessionId) ?? 0) + 1;
9184
+ this.terminalSeq.set(sessionId, seq);
9185
+ this.wsHub.broadcastToClients(this.sessionSubscribers.get(sessionId) ?? [], {
9186
+ type: "terminal_output",
9187
+ sessionId,
9188
+ data,
9189
+ seq
9190
+ });
9125
9191
  },
9126
9192
  onUserMessage: (sessionId, text, ts) => {
9127
- this.wsHub.broadcast({ type: "user_message", sessionId, text, ts });
9193
+ this.wsHub.broadcastToClients(this.sessionSubscribers.get(sessionId) ?? [], {
9194
+ type: "user_message",
9195
+ sessionId,
9196
+ text,
9197
+ ts
9198
+ });
9128
9199
  },
9129
9200
  onPermissionChange: (sessionId, gate) => {
9130
9201
  this.handlePermissionChange(sessionId, gate);
@@ -9261,6 +9332,8 @@ var StreamerServer = class {
9261
9332
  handleCancel: (id, res) => this.handleCancel(id, res),
9262
9333
  handleStopSession: (id, res) => this.handleStopSession(id, res),
9263
9334
  handleSetSessionName: (id, req, res) => this.handleSetSessionName(id, req, res),
9335
+ handleSetSessionModel: (id, req, res) => this.applyLiveSessionSetting(id, req, res, "model"),
9336
+ handleSetSessionEffort: (id, req, res) => this.applyLiveSessionSetting(id, req, res, "effort"),
9264
9337
  handleUploadFile: (id, req, res) => this.handleUploadFile(id, req, res),
9265
9338
  handleAdopt: (id, res) => this.handleAdopt(id, res),
9266
9339
  handleResume: (req, res) => this.handleResume(req, res),
@@ -9307,7 +9380,8 @@ var StreamerServer = class {
9307
9380
  type: "terminal_replay",
9308
9381
  sessionId: msg.sessionId,
9309
9382
  lines,
9310
- userMessages
9383
+ userMessages,
9384
+ seq: this.terminalSeq.get(msg.sessionId)
9311
9385
  })
9312
9386
  );
9313
9387
  }
@@ -9667,6 +9741,7 @@ var StreamerServer = class {
9667
9741
  );
9668
9742
  this.ptyManager.putOnHold(session.id);
9669
9743
  this.lastAgentChunkAt.delete(session.id);
9744
+ this.terminalSeq.delete(session.id);
9670
9745
  this.idempotency.clear(session.id);
9671
9746
  this.sessionSubscribers.delete(session.id);
9672
9747
  reaped.push(session.id);
@@ -9841,6 +9916,14 @@ var StreamerServer = class {
9841
9916
  );
9842
9917
  this.scannerPersistenceDisabled = true;
9843
9918
  }
9919
+ if (this.skipStartupWarmup) {
9920
+ this.log.debug?.("startup warm-up scan skipped (skipStartupWarmup)", {
9921
+ event: "cache.warmup_skipped"
9922
+ });
9923
+ this.finishWarmup(0);
9924
+ resolveWarm();
9925
+ return;
9926
+ }
9844
9927
  const warmupStatCache = this.buildStatCache(null);
9845
9928
  const warmupScanner = this.newScanner(warmupStatCache ? { persistent: false } : void 0);
9846
9929
  this.allScanners.add(warmupScanner);
@@ -10032,6 +10115,7 @@ var StreamerServer = class {
10032
10115
  this.idleReaperTimer = null;
10033
10116
  }
10034
10117
  this.lastAgentChunkAt.clear();
10118
+ this.terminalSeq.clear();
10035
10119
  this.recordShutdownState();
10036
10120
  this.markScannerStaleDebounced.cancel();
10037
10121
  await Promise.all([...this.inFlightCacheWrites]);
@@ -10213,6 +10297,30 @@ var StreamerServer = class {
10213
10297
  persisted: this.claudeFlagsPersistable
10214
10298
  };
10215
10299
  }
10300
+ /**
10301
+ * The three spawn options that a configured claude-flag can override, with
10302
+ * the boot-time CLI/yaml default as the fallback. Spread into every
10303
+ * start/resume/adopt call so all three paths agree.
10304
+ *
10305
+ * These ids are excluded from buildFlagArgs (SPAWN_POSITIONAL_FLAG_IDS)
10306
+ * precisely because they arrive here instead — the PTY spawn paths pass them
10307
+ * as explicit positionals, so emitting them from the allowlist too would
10308
+ * duplicate the flag.
10309
+ *
10310
+ * Narrowed with the type guards rather than cast: ClaudeFlagValues is a loose
10311
+ * Record by design, and while validateFlagValues already guarantees the shape
10312
+ * on the way in, TypeScript cannot see that through the record.
10313
+ */
10314
+ spawnFlagOverrides() {
10315
+ const mode = this.claudeFlags.permissionMode;
10316
+ const model = this.claudeFlags.model;
10317
+ const effort = this.claudeFlags.effort;
10318
+ return {
10319
+ permissionMode: isPermissionMode(mode) ? mode : this.defaultPermissionMode,
10320
+ model: typeof model === "string" ? model : this.defaultModel,
10321
+ effort: isEffortLevel(effort) ? effort : this.defaultEffort
10322
+ };
10323
+ }
10216
10324
  checkRateLimit(map, key, limit, windowMs) {
10217
10325
  const now = Date.now();
10218
10326
  const arr = (map.get(key) ?? []).filter((t) => now - t < windowMs);
@@ -10284,21 +10392,46 @@ var StreamerServer = class {
10284
10392
  // so a burst of list polls during active session writes shares one rescan
10285
10393
  // rather than queueing a full rescan each; tracked so close() awaits the
10286
10394
  // in-flight cache write before shutting the DB.
10287
- startBackgroundConversationReconcile() {
10395
+ startBackgroundConversationReconcile(mode = "full") {
10288
10396
  if (this.conversationReconcileInFlight) return;
10289
- const task = this.reconcileConversationsCacheFromDisk().finally(() => {
10397
+ const paths = mode === "files" ? this.takeStaleFiles() : [];
10398
+ const task = (paths.length > 0 ? this.reconcileStaleFilesFromDisk(paths) : this.reconcileConversationsCacheFromDisk()).finally(() => {
10290
10399
  this.conversationReconcileInFlight = null;
10291
10400
  });
10292
10401
  this.conversationReconcileInFlight = task;
10293
10402
  this.trackCacheWrite(task);
10294
10403
  }
10295
- shouldAutoReconcileConversationList() {
10296
- if (!this.cache) return false;
10297
- if (this.scannerStale) return true;
10298
- if (!this.conversationsRepo || !this.cacheMetadataRepo) return false;
10404
+ // "files": a directory event named specific JSONLs, so refresh only those.
10405
+ // "full": disk drifted in ways a per-file refresh can't see (a project dir
10406
+ // appeared, rows vanished), so walk the tree. Order matters — the staleness
10407
+ // check short-circuits first so the HDD freshness probe stays off the hot
10408
+ // poll path, exactly as it did when this returned a boolean.
10409
+ conversationReconcileMode() {
10410
+ if (!this.cache) return null;
10411
+ if (this.scannerStale) return "files";
10412
+ if (!this.conversationsRepo || !this.cacheMetadataRepo) return null;
10299
10413
  return shouldRefreshProjectsFromHdd(this.conversationsRepo, this.cacheMetadataRepo, {
10300
10414
  projectsDirs: this.projectsDirsForFreshnessCheck()
10301
- });
10415
+ }) ? "full" : null;
10416
+ }
10417
+ // The per-file half of reconcileConversationsCacheFromDisk: re-index just the
10418
+ // changed JSONLs and upsert their rows. No reconcileDeletions here — that
10419
+ // needs the whole live-path set, and deletions already have their own path
10420
+ // (onFileDeleted -> invalidateByFilePath). New projects still arrive via the
10421
+ // HDD-freshness "full" mode.
10422
+ async reconcileStaleFilesFromDisk(paths) {
10423
+ if (!this.cache) return;
10424
+ const scanner = await this.getScanner(true);
10425
+ const metas = await this.refreshStaleFiles(scanner, paths);
10426
+ if (metas.length === 0) return;
10427
+ try {
10428
+ this.cache.upsertFromScannerMeta(metas);
10429
+ } catch (err) {
10430
+ this.log.warn(
10431
+ `stale-file reconcile failed: ${err instanceof Error ? err.message : String(err)}`,
10432
+ { event: "conversations.reconcile_failed" }
10433
+ );
10434
+ }
10302
10435
  }
10303
10436
  async handleListConversations(url, res) {
10304
10437
  if (this.rejectIfWarmingUp(res)) return;
@@ -10308,10 +10441,11 @@ var StreamerServer = class {
10308
10441
  const project = url.searchParams.get("project") ?? void 0;
10309
10442
  const providerFilter = url.searchParams.get("provider") ?? void 0;
10310
10443
  const bustCache = url.searchParams.get("refresh") === "1";
10311
- if (this.cache && (bustCache || this.shouldAutoReconcileConversationList())) {
10444
+ const reconcileMode = this.conversationReconcileMode();
10445
+ if (this.cache && (bustCache || reconcileMode)) {
10312
10446
  const canServeStale = !bustCache && this.cache.listConversations({ limit: 0, offset: 0 }).total > 0;
10313
10447
  if (canServeStale) {
10314
- this.startBackgroundConversationReconcile();
10448
+ this.startBackgroundConversationReconcile(reconcileMode ?? "full");
10315
10449
  } else {
10316
10450
  const shouldEmitProgress = createScanProgressThrottle();
10317
10451
  await this.withWarmup(
@@ -10511,21 +10645,54 @@ var StreamerServer = class {
10511
10645
  options ?? (this.scannerPersistenceDisabled ? { persistent: false } : void 0)
10512
10646
  );
10513
10647
  }
10648
+ // Drain the stale set and disarm the flag together. The caller owns the
10649
+ // returned paths: clearing before the refresh means events that land DURING
10650
+ // it re-arm the flag and get their own pass instead of being swallowed.
10651
+ takeStaleFiles() {
10652
+ const paths = [...this.staleFiles];
10653
+ this.staleFiles.clear();
10654
+ this.scannerStale = false;
10655
+ return paths;
10656
+ }
10657
+ // Reconcile exactly the JSONLs a directory event named. Failures are logged
10658
+ // and swallowed per file: one unreadable transcript must not abort the
10659
+ // others, and the file simply stays on its previous snapshot until the next
10660
+ // event — the same outcome the full rescan gave on a parse failure.
10661
+ async refreshStaleFiles(scanner, paths) {
10662
+ const metas = await Promise.all(
10663
+ paths.map(
10664
+ (filePath) => scanner.refreshFile(filePath).catch((err) => {
10665
+ this.log.warn("scanner.refreshFile: failed", {
10666
+ event: "scanner.refresh_failed",
10667
+ filePath,
10668
+ trigger: "directory-event",
10669
+ err
10670
+ });
10671
+ return null;
10672
+ })
10673
+ )
10674
+ );
10675
+ return metas.filter((m) => m !== null);
10676
+ }
10514
10677
  async getScanner(skipStaleRescan = false) {
10515
10678
  if (this.scannerReady) {
10516
10679
  await this.scannerReady;
10517
10680
  if (this.scanner) {
10518
10681
  if (skipStaleRescan) return this.scanner;
10519
10682
  if (this.scannerStale) {
10520
- this.scannerStale = false;
10521
- this.scanner = null;
10522
- this.scannerReady = null;
10523
- return this.getScanner();
10683
+ const paths = this.takeStaleFiles();
10684
+ if (paths.length === 0) {
10685
+ this.scanner = null;
10686
+ this.scannerReady = null;
10687
+ return this.getScanner();
10688
+ }
10689
+ await this.refreshStaleFiles(this.scanner, paths);
10690
+ return this.scanner ?? this.getScanner();
10524
10691
  }
10525
10692
  return this.scanner;
10526
10693
  }
10527
10694
  }
10528
- this.scannerStale = false;
10695
+ this.takeStaleFiles();
10529
10696
  const statCache = this.buildStatCache(this.scanner);
10530
10697
  this.scanner = this.newScanner(statCache ? { persistent: false } : void 0);
10531
10698
  this.allScanners.add(this.scanner);
@@ -10559,7 +10726,7 @@ var StreamerServer = class {
10559
10726
  // getScanner() anti-infinite-loop guard is preserved.
10560
10727
  async rescanForRefresh(onProgress) {
10561
10728
  if (this.scannerReady) await this.scannerReady;
10562
- this.scannerStale = false;
10729
+ this.takeStaleFiles();
10563
10730
  if (!this.scanner) {
10564
10731
  this.scanner = new import_scanner3.ConversationScanner();
10565
10732
  this.allScanners.add(this.scanner);
@@ -11362,11 +11529,9 @@ var StreamerServer = class {
11362
11529
  projectPath,
11363
11530
  projectName: body.projectName,
11364
11531
  branch: body.branch,
11365
- permissionMode: this.defaultPermissionMode,
11366
11532
  claudeFlags: this.claudeFlags,
11367
11533
  claudeExtraArgs: this.claudeExtraArgs,
11368
- model: this.defaultModel,
11369
- effort: this.defaultEffort
11534
+ ...this.spawnFlagOverrides()
11370
11535
  });
11371
11536
  this.sessionStore.addManaged(session);
11372
11537
  this.recordSessionSpawn(session);
@@ -11813,11 +11978,9 @@ var StreamerServer = class {
11813
11978
  projectPath,
11814
11979
  projectName,
11815
11980
  branch,
11816
- permissionMode: this.defaultPermissionMode,
11817
11981
  claudeFlags: this.claudeFlags,
11818
11982
  claudeExtraArgs: this.claudeExtraArgs,
11819
- model: this.defaultModel,
11820
- effort: this.defaultEffort
11983
+ ...this.spawnFlagOverrides()
11821
11984
  });
11822
11985
  this.sessionStore.addManaged(session);
11823
11986
  this.recordSessionSpawn(session);
@@ -11890,11 +12053,9 @@ var StreamerServer = class {
11890
12053
  projectPath: resolvedPath,
11891
12054
  projectName: body.projectName,
11892
12055
  ...includeSystemPrompt && { systemPrompt: systemPromptParts.join("\n") },
11893
- permissionMode: this.defaultPermissionMode,
11894
12056
  claudeFlags: this.claudeFlags,
11895
12057
  claudeExtraArgs: this.claudeExtraArgs,
11896
- model: this.defaultModel,
11897
- effort: this.defaultEffort
12058
+ ...this.spawnFlagOverrides()
11898
12059
  });
11899
12060
  this.sessionStore.addManaged(session);
11900
12061
  this.recordSessionSpawn(session);
@@ -12254,6 +12415,87 @@ var StreamerServer = class {
12254
12415
  this.cache.upsertSessionName(sessionId, name);
12255
12416
  json(res, 200, { ok: true });
12256
12417
  }
12418
+ /**
12419
+ * Retarget a LIVE session's model or effort by typing the corresponding
12420
+ * Claude Code slash command into its PTY.
12421
+ *
12422
+ * There is no CLI or IPC channel for this — `--model`/`--effort` are spawn
12423
+ * arguments — so the interactive `/model <x>` / `/effort <y>` commands are the
12424
+ * only way to change a session already running. Both accept an argument and
12425
+ * apply it without opening the picker (verified against Claude Code v2.1.220).
12426
+ *
12427
+ * Answers 202, not 200: the value is applied by the TUI on its next render, so
12428
+ * there is nothing truthful to echo back synchronously. Clients confirm with
12429
+ * `GET /api/sessions/:id`, which scrapes the applied value off the live status
12430
+ * line.
12431
+ */
12432
+ async applyLiveSessionSetting(sessionId, req, res, setting) {
12433
+ const session = this.ptyManager.getSession(sessionId);
12434
+ if (!session) {
12435
+ const known = this.sessionStore.getManaged(sessionId);
12436
+ if (known) {
12437
+ json(res, 409, {
12438
+ error: "Session has no live PTY; resume it first",
12439
+ code: "SESSION_IDLE"
12440
+ });
12441
+ return;
12442
+ }
12443
+ json(res, 404, { error: "Session not found" });
12444
+ return;
12445
+ }
12446
+ if ((session.provider ?? CLAUDE_CODE_PROVIDER) !== CLAUDE_CODE_PROVIDER) {
12447
+ json(res, 501, {
12448
+ error: `Setting ${setting} on a ${session.provider} session is not supported`,
12449
+ code: "UNSUPPORTED_PROVIDER"
12450
+ });
12451
+ return;
12452
+ }
12453
+ if (session.status === "running") {
12454
+ json(res, 409, {
12455
+ error: "Session is mid-turn; retry once it is waiting for input",
12456
+ code: "SESSION_BUSY"
12457
+ });
12458
+ return;
12459
+ }
12460
+ let parsed;
12461
+ try {
12462
+ parsed = await readBody(req);
12463
+ } catch {
12464
+ json(res, 400, { error: "Invalid JSON" });
12465
+ return;
12466
+ }
12467
+ let value;
12468
+ if (setting === "effort") {
12469
+ if (!isEffortLevel(parsed.effort)) {
12470
+ json(res, 400, {
12471
+ error: `effort must be one of ${EFFORT_LEVELS.join(", ")}`
12472
+ });
12473
+ return;
12474
+ }
12475
+ value = parsed.effort;
12476
+ } else {
12477
+ if (typeof parsed.model !== "string" || !MODEL_NAME_RE.test(parsed.model)) {
12478
+ json(res, 400, {
12479
+ error: "model must be an alias or full model name (letters, digits, dot, dash, underscore)"
12480
+ });
12481
+ return;
12482
+ }
12483
+ value = parsed.model;
12484
+ }
12485
+ try {
12486
+ this.ptyManager.sendKeys(sessionId, `/${setting} ${value}\r`);
12487
+ } catch (err) {
12488
+ json(res, 400, { error: err instanceof Error ? err.message : "Failed to write to session" });
12489
+ return;
12490
+ }
12491
+ this.log.info(`Live session ${setting} set to ${value}`, {
12492
+ event: "session.setting_applied",
12493
+ sessionId,
12494
+ setting,
12495
+ value
12496
+ });
12497
+ json(res, 202, { id: sessionId, [setting]: value });
12498
+ }
12257
12499
  handleGetSessionNames(res) {
12258
12500
  if (!this.cache) {
12259
12501
  json(res, 200, {});