@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.js CHANGED
@@ -298,6 +298,10 @@ var DANGEROUS_PERMISSION_MODES = [
298
298
  function isDangerousPermissionMode(mode) {
299
299
  return DANGEROUS_PERMISSION_MODES.includes(mode);
300
300
  }
301
+ var EFFORT_LEVELS = ["low", "medium", "high", "xhigh", "max"];
302
+ function isEffortLevel(value) {
303
+ return typeof value === "string" && EFFORT_LEVELS.includes(value);
304
+ }
301
305
  var CLAUDE_FLAGS = [
302
306
  {
303
307
  id: "permissionMode",
@@ -309,9 +313,10 @@ var CLAUDE_FLAGS = [
309
313
  { id: "addDir", flag: "--add-dir", valueType: "list", risk: "elevated" },
310
314
  { id: "allowedTools", flag: "--allowedTools", valueType: "list", risk: "elevated" },
311
315
  { id: "disallowedTools", flag: "--disallowedTools", valueType: "list", risk: "low" },
312
- { id: "maxBudgetUsd", flag: "--max-budget-usd", valueType: "string", risk: "low" },
313
- { id: "fallbackModel", flag: "--fallback-model", valueType: "string", risk: "low" }
316
+ { id: "model", flag: "--model", valueType: "string", risk: "low" },
317
+ { id: "effort", flag: "--effort", valueType: "enum", enumValues: EFFORT_LEVELS, risk: "low" }
314
318
  ];
319
+ var SPAWN_POSITIONAL_FLAG_IDS = /* @__PURE__ */ new Set(["permissionMode", "model", "effort"]);
315
320
  function findFlag(id) {
316
321
  return CLAUDE_FLAGS.find((f) => f.id === id);
317
322
  }
@@ -376,7 +381,7 @@ function buildFlagArgs(values, extraArgs) {
376
381
  const args = [];
377
382
  const safe = validateFlagValues(values ?? {});
378
383
  for (const def of CLAUDE_FLAGS) {
379
- if (def.id === "permissionMode") continue;
384
+ if (SPAWN_POSITIONAL_FLAG_IDS.has(def.id)) continue;
380
385
  const value = safe[def.id];
381
386
  if (value === void 0) continue;
382
387
  if (def.valueType === "boolean") {
@@ -4671,6 +4676,14 @@ var createSessionRoutes = (deps) => {
4671
4676
  await deps.handleSetSessionName(c.req.param("id"), c.env.incoming, c.env.outgoing);
4672
4677
  return alreadyHandled6();
4673
4678
  });
4679
+ app.patch("/:id/model", async (c) => {
4680
+ await deps.handleSetSessionModel(c.req.param("id"), c.env.incoming, c.env.outgoing);
4681
+ return alreadyHandled6();
4682
+ });
4683
+ app.patch("/:id/effort", async (c) => {
4684
+ await deps.handleSetSessionEffort(c.req.param("id"), c.env.incoming, c.env.outgoing);
4685
+ return alreadyHandled6();
4686
+ });
4674
4687
  app.post("/:id/adopt", async (c) => {
4675
4688
  await deps.handleAdopt(c.req.param("id"), c.env.outgoing);
4676
4689
  return alreadyHandled6();
@@ -5008,6 +5021,7 @@ CREATE TABLE IF NOT EXISTS conversation_meta (
5008
5021
  );
5009
5022
  CREATE INDEX IF NOT EXISTS idx_meta_last_activity ON conversation_meta(last_activity DESC);
5010
5023
  CREATE INDEX IF NOT EXISTS idx_meta_project ON conversation_meta(project_path);
5024
+ CREATE INDEX IF NOT EXISTS idx_meta_file_path ON conversation_meta(file_path);
5011
5025
 
5012
5026
  CREATE TABLE IF NOT EXISTS conversation_tail (
5013
5027
  conversation_id TEXT PRIMARY KEY REFERENCES conversation_meta(id) ON DELETE CASCADE,
@@ -8665,6 +8679,24 @@ var WSHub = class {
8665
8679
  this.clients.delete(client);
8666
8680
  }
8667
8681
  }
8682
+ // Scoped broadcast for high-frequency per-session messages (terminal_output,
8683
+ // user_message). Sending to every connected client for every PTY output
8684
+ // chunk made broadcast() cost scale with connections x active sessions;
8685
+ // this bounds it to only that session's subscribers.
8686
+ broadcastToClients(clients, message) {
8687
+ const data = JSON.stringify(message);
8688
+ for (const client of clients) {
8689
+ try {
8690
+ if (client.readyState === client.OPEN) {
8691
+ client.send(data);
8692
+ } else {
8693
+ this.clients.delete(client);
8694
+ }
8695
+ } catch {
8696
+ this.clients.delete(client);
8697
+ }
8698
+ }
8699
+ }
8668
8700
  unicast(ws, message) {
8669
8701
  try {
8670
8702
  if (ws.readyState === ws.OPEN) {
@@ -8727,6 +8759,7 @@ var ADOPT_KILL_TIMEOUT_MS = 5e3;
8727
8759
  var ADOPT_KILL_POLL_MS = 100;
8728
8760
  var REFRESH_TTL_MS = 2e3;
8729
8761
  var START_READY_TIMEOUT_MS = 1e4;
8762
+ var MODEL_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
8730
8763
  var EXTERNAL_TAIL_RECENCY_MS = RESUME_BUSY_WINDOW_MS;
8731
8764
  var EXTERNAL_TAIL_MAX = 32;
8732
8765
  var EXTERNAL_TAIL_IDLE_MS = 3e5;
@@ -8797,6 +8830,20 @@ var StreamerServer = class {
8797
8830
  // Set by onConversationChanged while a scan is in-flight; getScanner() does
8798
8831
  // a single rescan after the current one completes instead of restarting it.
8799
8832
  scannerStale = false;
8833
+ // WHICH files scannerStale is about. A directory event names exactly one
8834
+ // JSONL, and the only correct response is refreshFile() on that one file —
8835
+ // but scannerStale alone carries no identity, so honoring it used to mean a
8836
+ // full-tree rescan. On the non-persistent scanner this server actually runs
8837
+ // (buildStatCache => persistent:false, see listen()), scan() opens by
8838
+ // clearing metadataCache AND conversationLRU — so one live session appending
8839
+ // to its own transcript threw away every OTHER conversation's parsed
8840
+ // snapshot, and the next full fetch of an unrelated conversation re-parsed
8841
+ // it from disk (745-2877ms on a 5MB/1112-message history) while the
8842
+ // per-file paginated path stayed at ~20ms throughout. Populated alongside
8843
+ // scannerStale and drained with it by takeStaleFiles(); an armed flag with
8844
+ // an EMPTY set means "stale, source unknown" and still falls back to the
8845
+ // full rescan.
8846
+ staleFiles = /* @__PURE__ */ new Set();
8800
8847
  // Single-flight guard for the background disk reconcile: a burst of list
8801
8848
  // polls during active session writes shares one rescan instead of queueing
8802
8849
  // a full rescan per request.
@@ -8830,6 +8877,8 @@ var StreamerServer = class {
8830
8877
  dbPool = null;
8831
8878
  dbInstanceId = null;
8832
8879
  disableDb = false;
8880
+ // Skip the startup warm-up scan (test hook; see ServerConfig.skipStartupWarmup).
8881
+ skipStartupWarmup;
8833
8882
  browseRoot = null;
8834
8883
  publicUrl = null;
8835
8884
  browserCors;
@@ -8869,6 +8918,11 @@ var StreamerServer = class {
8869
8918
  // every provider; read only by the idle reaper. Entries are dropped when the
8870
8919
  // session leaves the runner (reap/exit/hold).
8871
8920
  lastAgentChunkAt = /* @__PURE__ */ new Map();
8921
+ // sessionId → last terminal_output seq broadcast (starts at 1, per session).
8922
+ // Stamped on every terminal_output/terminal_replay so a client can detect a
8923
+ // stale chunk delivered after a reconnect race instead of trusting raw WS
8924
+ // arrival order. Entries dropped alongside lastAgentChunkAt.
8925
+ terminalSeq = /* @__PURE__ */ new Map();
8872
8926
  // Recently accepted input idempotency keys (C4). A retried POST replays its
8873
8927
  // original outcome instead of submitting the prompt to the agent twice.
8874
8928
  idempotency = new IdempotencyStore();
@@ -8936,6 +8990,7 @@ var StreamerServer = class {
8936
8990
  }
8937
8991
  this.verbose = config.verbose ?? false;
8938
8992
  this.disableDb = config.disableDb ?? false;
8993
+ this.skipStartupWarmup = config.skipStartupWarmup ?? false;
8939
8994
  this.scannerPersistenceDisabled = config.scannerPersistent === false;
8940
8995
  this.scanProfiles = config.scanProfiles;
8941
8996
  this.codexRoots = config.codexRoots ?? [join18(homedir9(), ".codex", "sessions")];
@@ -8957,7 +9012,10 @@ var StreamerServer = class {
8957
9012
  this.directoryDebounceMs = parseDirScanDebounceEnv(process.env.THREADBASE_DIR_SCAN_DEBOUNCE_MS) ?? config.directoryScanDebounceMs ?? 1e3;
8958
9013
  this.markScannerStaleDebounced = debounce(() => {
8959
9014
  if (this.scannerReady) this.scannerStale = true;
8960
- else this.scanner = null;
9015
+ else {
9016
+ this.scanner = null;
9017
+ this.staleFiles.clear();
9018
+ }
8961
9019
  }, this.directoryDebounceMs);
8962
9020
  this.includeAgents = parseIncludeAgentsEnv(process.env.THREADBASE_INCLUDE_AGENTS);
8963
9021
  this.agentEntrypoints = parseAgentEntrypointsEnv(process.env.THREADBASE_AGENT_ENTRYPOINTS);
@@ -9050,6 +9108,7 @@ var StreamerServer = class {
9050
9108
  if (!tailed) this.maybeAttachExternalTail(filePath);
9051
9109
  this.sweepIdleExternalTails();
9052
9110
  this.cache?.invalidateByFilePath(filePath, { skipIfTailed: true });
9111
+ this.staleFiles.add(filePath);
9053
9112
  this.markScannerStaleDebounced();
9054
9113
  this.log.debug?.(`Scanner invalidated by directory event: ${filePath}`, {
9055
9114
  filePath,
@@ -9084,10 +9143,22 @@ var StreamerServer = class {
9084
9143
  logger: getLogger("pty"),
9085
9144
  onOutput: (sessionId, data) => {
9086
9145
  this.lastAgentChunkAt.set(sessionId, Date.now());
9087
- this.wsHub.broadcast({ type: "terminal_output", sessionId, data });
9146
+ const seq = (this.terminalSeq.get(sessionId) ?? 0) + 1;
9147
+ this.terminalSeq.set(sessionId, seq);
9148
+ this.wsHub.broadcastToClients(this.sessionSubscribers.get(sessionId) ?? [], {
9149
+ type: "terminal_output",
9150
+ sessionId,
9151
+ data,
9152
+ seq
9153
+ });
9088
9154
  },
9089
9155
  onUserMessage: (sessionId, text, ts) => {
9090
- this.wsHub.broadcast({ type: "user_message", sessionId, text, ts });
9156
+ this.wsHub.broadcastToClients(this.sessionSubscribers.get(sessionId) ?? [], {
9157
+ type: "user_message",
9158
+ sessionId,
9159
+ text,
9160
+ ts
9161
+ });
9091
9162
  },
9092
9163
  onPermissionChange: (sessionId, gate) => {
9093
9164
  this.handlePermissionChange(sessionId, gate);
@@ -9224,6 +9295,8 @@ var StreamerServer = class {
9224
9295
  handleCancel: (id, res) => this.handleCancel(id, res),
9225
9296
  handleStopSession: (id, res) => this.handleStopSession(id, res),
9226
9297
  handleSetSessionName: (id, req, res) => this.handleSetSessionName(id, req, res),
9298
+ handleSetSessionModel: (id, req, res) => this.applyLiveSessionSetting(id, req, res, "model"),
9299
+ handleSetSessionEffort: (id, req, res) => this.applyLiveSessionSetting(id, req, res, "effort"),
9227
9300
  handleUploadFile: (id, req, res) => this.handleUploadFile(id, req, res),
9228
9301
  handleAdopt: (id, res) => this.handleAdopt(id, res),
9229
9302
  handleResume: (req, res) => this.handleResume(req, res),
@@ -9270,7 +9343,8 @@ var StreamerServer = class {
9270
9343
  type: "terminal_replay",
9271
9344
  sessionId: msg.sessionId,
9272
9345
  lines,
9273
- userMessages
9346
+ userMessages,
9347
+ seq: this.terminalSeq.get(msg.sessionId)
9274
9348
  })
9275
9349
  );
9276
9350
  }
@@ -9630,6 +9704,7 @@ var StreamerServer = class {
9630
9704
  );
9631
9705
  this.ptyManager.putOnHold(session.id);
9632
9706
  this.lastAgentChunkAt.delete(session.id);
9707
+ this.terminalSeq.delete(session.id);
9633
9708
  this.idempotency.clear(session.id);
9634
9709
  this.sessionSubscribers.delete(session.id);
9635
9710
  reaped.push(session.id);
@@ -9804,6 +9879,14 @@ var StreamerServer = class {
9804
9879
  );
9805
9880
  this.scannerPersistenceDisabled = true;
9806
9881
  }
9882
+ if (this.skipStartupWarmup) {
9883
+ this.log.debug?.("startup warm-up scan skipped (skipStartupWarmup)", {
9884
+ event: "cache.warmup_skipped"
9885
+ });
9886
+ this.finishWarmup(0);
9887
+ resolveWarm();
9888
+ return;
9889
+ }
9807
9890
  const warmupStatCache = this.buildStatCache(null);
9808
9891
  const warmupScanner = this.newScanner(warmupStatCache ? { persistent: false } : void 0);
9809
9892
  this.allScanners.add(warmupScanner);
@@ -9995,6 +10078,7 @@ var StreamerServer = class {
9995
10078
  this.idleReaperTimer = null;
9996
10079
  }
9997
10080
  this.lastAgentChunkAt.clear();
10081
+ this.terminalSeq.clear();
9998
10082
  this.recordShutdownState();
9999
10083
  this.markScannerStaleDebounced.cancel();
10000
10084
  await Promise.all([...this.inFlightCacheWrites]);
@@ -10176,6 +10260,30 @@ var StreamerServer = class {
10176
10260
  persisted: this.claudeFlagsPersistable
10177
10261
  };
10178
10262
  }
10263
+ /**
10264
+ * The three spawn options that a configured claude-flag can override, with
10265
+ * the boot-time CLI/yaml default as the fallback. Spread into every
10266
+ * start/resume/adopt call so all three paths agree.
10267
+ *
10268
+ * These ids are excluded from buildFlagArgs (SPAWN_POSITIONAL_FLAG_IDS)
10269
+ * precisely because they arrive here instead — the PTY spawn paths pass them
10270
+ * as explicit positionals, so emitting them from the allowlist too would
10271
+ * duplicate the flag.
10272
+ *
10273
+ * Narrowed with the type guards rather than cast: ClaudeFlagValues is a loose
10274
+ * Record by design, and while validateFlagValues already guarantees the shape
10275
+ * on the way in, TypeScript cannot see that through the record.
10276
+ */
10277
+ spawnFlagOverrides() {
10278
+ const mode = this.claudeFlags.permissionMode;
10279
+ const model = this.claudeFlags.model;
10280
+ const effort = this.claudeFlags.effort;
10281
+ return {
10282
+ permissionMode: isPermissionMode(mode) ? mode : this.defaultPermissionMode,
10283
+ model: typeof model === "string" ? model : this.defaultModel,
10284
+ effort: isEffortLevel(effort) ? effort : this.defaultEffort
10285
+ };
10286
+ }
10179
10287
  checkRateLimit(map, key, limit, windowMs) {
10180
10288
  const now = Date.now();
10181
10289
  const arr = (map.get(key) ?? []).filter((t) => now - t < windowMs);
@@ -10247,21 +10355,46 @@ var StreamerServer = class {
10247
10355
  // so a burst of list polls during active session writes shares one rescan
10248
10356
  // rather than queueing a full rescan each; tracked so close() awaits the
10249
10357
  // in-flight cache write before shutting the DB.
10250
- startBackgroundConversationReconcile() {
10358
+ startBackgroundConversationReconcile(mode = "full") {
10251
10359
  if (this.conversationReconcileInFlight) return;
10252
- const task = this.reconcileConversationsCacheFromDisk().finally(() => {
10360
+ const paths = mode === "files" ? this.takeStaleFiles() : [];
10361
+ const task = (paths.length > 0 ? this.reconcileStaleFilesFromDisk(paths) : this.reconcileConversationsCacheFromDisk()).finally(() => {
10253
10362
  this.conversationReconcileInFlight = null;
10254
10363
  });
10255
10364
  this.conversationReconcileInFlight = task;
10256
10365
  this.trackCacheWrite(task);
10257
10366
  }
10258
- shouldAutoReconcileConversationList() {
10259
- if (!this.cache) return false;
10260
- if (this.scannerStale) return true;
10261
- if (!this.conversationsRepo || !this.cacheMetadataRepo) return false;
10367
+ // "files": a directory event named specific JSONLs, so refresh only those.
10368
+ // "full": disk drifted in ways a per-file refresh can't see (a project dir
10369
+ // appeared, rows vanished), so walk the tree. Order matters — the staleness
10370
+ // check short-circuits first so the HDD freshness probe stays off the hot
10371
+ // poll path, exactly as it did when this returned a boolean.
10372
+ conversationReconcileMode() {
10373
+ if (!this.cache) return null;
10374
+ if (this.scannerStale) return "files";
10375
+ if (!this.conversationsRepo || !this.cacheMetadataRepo) return null;
10262
10376
  return shouldRefreshProjectsFromHdd(this.conversationsRepo, this.cacheMetadataRepo, {
10263
10377
  projectsDirs: this.projectsDirsForFreshnessCheck()
10264
- });
10378
+ }) ? "full" : null;
10379
+ }
10380
+ // The per-file half of reconcileConversationsCacheFromDisk: re-index just the
10381
+ // changed JSONLs and upsert their rows. No reconcileDeletions here — that
10382
+ // needs the whole live-path set, and deletions already have their own path
10383
+ // (onFileDeleted -> invalidateByFilePath). New projects still arrive via the
10384
+ // HDD-freshness "full" mode.
10385
+ async reconcileStaleFilesFromDisk(paths) {
10386
+ if (!this.cache) return;
10387
+ const scanner = await this.getScanner(true);
10388
+ const metas = await this.refreshStaleFiles(scanner, paths);
10389
+ if (metas.length === 0) return;
10390
+ try {
10391
+ this.cache.upsertFromScannerMeta(metas);
10392
+ } catch (err) {
10393
+ this.log.warn(
10394
+ `stale-file reconcile failed: ${err instanceof Error ? err.message : String(err)}`,
10395
+ { event: "conversations.reconcile_failed" }
10396
+ );
10397
+ }
10265
10398
  }
10266
10399
  async handleListConversations(url, res) {
10267
10400
  if (this.rejectIfWarmingUp(res)) return;
@@ -10271,10 +10404,11 @@ var StreamerServer = class {
10271
10404
  const project = url.searchParams.get("project") ?? void 0;
10272
10405
  const providerFilter = url.searchParams.get("provider") ?? void 0;
10273
10406
  const bustCache = url.searchParams.get("refresh") === "1";
10274
- if (this.cache && (bustCache || this.shouldAutoReconcileConversationList())) {
10407
+ const reconcileMode = this.conversationReconcileMode();
10408
+ if (this.cache && (bustCache || reconcileMode)) {
10275
10409
  const canServeStale = !bustCache && this.cache.listConversations({ limit: 0, offset: 0 }).total > 0;
10276
10410
  if (canServeStale) {
10277
- this.startBackgroundConversationReconcile();
10411
+ this.startBackgroundConversationReconcile(reconcileMode ?? "full");
10278
10412
  } else {
10279
10413
  const shouldEmitProgress = createScanProgressThrottle();
10280
10414
  await this.withWarmup(
@@ -10474,21 +10608,54 @@ var StreamerServer = class {
10474
10608
  options ?? (this.scannerPersistenceDisabled ? { persistent: false } : void 0)
10475
10609
  );
10476
10610
  }
10611
+ // Drain the stale set and disarm the flag together. The caller owns the
10612
+ // returned paths: clearing before the refresh means events that land DURING
10613
+ // it re-arm the flag and get their own pass instead of being swallowed.
10614
+ takeStaleFiles() {
10615
+ const paths = [...this.staleFiles];
10616
+ this.staleFiles.clear();
10617
+ this.scannerStale = false;
10618
+ return paths;
10619
+ }
10620
+ // Reconcile exactly the JSONLs a directory event named. Failures are logged
10621
+ // and swallowed per file: one unreadable transcript must not abort the
10622
+ // others, and the file simply stays on its previous snapshot until the next
10623
+ // event — the same outcome the full rescan gave on a parse failure.
10624
+ async refreshStaleFiles(scanner, paths) {
10625
+ const metas = await Promise.all(
10626
+ paths.map(
10627
+ (filePath) => scanner.refreshFile(filePath).catch((err) => {
10628
+ this.log.warn("scanner.refreshFile: failed", {
10629
+ event: "scanner.refresh_failed",
10630
+ filePath,
10631
+ trigger: "directory-event",
10632
+ err
10633
+ });
10634
+ return null;
10635
+ })
10636
+ )
10637
+ );
10638
+ return metas.filter((m) => m !== null);
10639
+ }
10477
10640
  async getScanner(skipStaleRescan = false) {
10478
10641
  if (this.scannerReady) {
10479
10642
  await this.scannerReady;
10480
10643
  if (this.scanner) {
10481
10644
  if (skipStaleRescan) return this.scanner;
10482
10645
  if (this.scannerStale) {
10483
- this.scannerStale = false;
10484
- this.scanner = null;
10485
- this.scannerReady = null;
10486
- return this.getScanner();
10646
+ const paths = this.takeStaleFiles();
10647
+ if (paths.length === 0) {
10648
+ this.scanner = null;
10649
+ this.scannerReady = null;
10650
+ return this.getScanner();
10651
+ }
10652
+ await this.refreshStaleFiles(this.scanner, paths);
10653
+ return this.scanner ?? this.getScanner();
10487
10654
  }
10488
10655
  return this.scanner;
10489
10656
  }
10490
10657
  }
10491
- this.scannerStale = false;
10658
+ this.takeStaleFiles();
10492
10659
  const statCache = this.buildStatCache(this.scanner);
10493
10660
  this.scanner = this.newScanner(statCache ? { persistent: false } : void 0);
10494
10661
  this.allScanners.add(this.scanner);
@@ -10522,7 +10689,7 @@ var StreamerServer = class {
10522
10689
  // getScanner() anti-infinite-loop guard is preserved.
10523
10690
  async rescanForRefresh(onProgress) {
10524
10691
  if (this.scannerReady) await this.scannerReady;
10525
- this.scannerStale = false;
10692
+ this.takeStaleFiles();
10526
10693
  if (!this.scanner) {
10527
10694
  this.scanner = new ConversationScanner();
10528
10695
  this.allScanners.add(this.scanner);
@@ -11325,11 +11492,9 @@ var StreamerServer = class {
11325
11492
  projectPath,
11326
11493
  projectName: body.projectName,
11327
11494
  branch: body.branch,
11328
- permissionMode: this.defaultPermissionMode,
11329
11495
  claudeFlags: this.claudeFlags,
11330
11496
  claudeExtraArgs: this.claudeExtraArgs,
11331
- model: this.defaultModel,
11332
- effort: this.defaultEffort
11497
+ ...this.spawnFlagOverrides()
11333
11498
  });
11334
11499
  this.sessionStore.addManaged(session);
11335
11500
  this.recordSessionSpawn(session);
@@ -11776,11 +11941,9 @@ var StreamerServer = class {
11776
11941
  projectPath,
11777
11942
  projectName,
11778
11943
  branch,
11779
- permissionMode: this.defaultPermissionMode,
11780
11944
  claudeFlags: this.claudeFlags,
11781
11945
  claudeExtraArgs: this.claudeExtraArgs,
11782
- model: this.defaultModel,
11783
- effort: this.defaultEffort
11946
+ ...this.spawnFlagOverrides()
11784
11947
  });
11785
11948
  this.sessionStore.addManaged(session);
11786
11949
  this.recordSessionSpawn(session);
@@ -11853,11 +12016,9 @@ var StreamerServer = class {
11853
12016
  projectPath: resolvedPath,
11854
12017
  projectName: body.projectName,
11855
12018
  ...includeSystemPrompt && { systemPrompt: systemPromptParts.join("\n") },
11856
- permissionMode: this.defaultPermissionMode,
11857
12019
  claudeFlags: this.claudeFlags,
11858
12020
  claudeExtraArgs: this.claudeExtraArgs,
11859
- model: this.defaultModel,
11860
- effort: this.defaultEffort
12021
+ ...this.spawnFlagOverrides()
11861
12022
  });
11862
12023
  this.sessionStore.addManaged(session);
11863
12024
  this.recordSessionSpawn(session);
@@ -12217,6 +12378,87 @@ var StreamerServer = class {
12217
12378
  this.cache.upsertSessionName(sessionId, name);
12218
12379
  json(res, 200, { ok: true });
12219
12380
  }
12381
+ /**
12382
+ * Retarget a LIVE session's model or effort by typing the corresponding
12383
+ * Claude Code slash command into its PTY.
12384
+ *
12385
+ * There is no CLI or IPC channel for this — `--model`/`--effort` are spawn
12386
+ * arguments — so the interactive `/model <x>` / `/effort <y>` commands are the
12387
+ * only way to change a session already running. Both accept an argument and
12388
+ * apply it without opening the picker (verified against Claude Code v2.1.220).
12389
+ *
12390
+ * Answers 202, not 200: the value is applied by the TUI on its next render, so
12391
+ * there is nothing truthful to echo back synchronously. Clients confirm with
12392
+ * `GET /api/sessions/:id`, which scrapes the applied value off the live status
12393
+ * line.
12394
+ */
12395
+ async applyLiveSessionSetting(sessionId, req, res, setting) {
12396
+ const session = this.ptyManager.getSession(sessionId);
12397
+ if (!session) {
12398
+ const known = this.sessionStore.getManaged(sessionId);
12399
+ if (known) {
12400
+ json(res, 409, {
12401
+ error: "Session has no live PTY; resume it first",
12402
+ code: "SESSION_IDLE"
12403
+ });
12404
+ return;
12405
+ }
12406
+ json(res, 404, { error: "Session not found" });
12407
+ return;
12408
+ }
12409
+ if ((session.provider ?? CLAUDE_CODE_PROVIDER) !== CLAUDE_CODE_PROVIDER) {
12410
+ json(res, 501, {
12411
+ error: `Setting ${setting} on a ${session.provider} session is not supported`,
12412
+ code: "UNSUPPORTED_PROVIDER"
12413
+ });
12414
+ return;
12415
+ }
12416
+ if (session.status === "running") {
12417
+ json(res, 409, {
12418
+ error: "Session is mid-turn; retry once it is waiting for input",
12419
+ code: "SESSION_BUSY"
12420
+ });
12421
+ return;
12422
+ }
12423
+ let parsed;
12424
+ try {
12425
+ parsed = await readBody(req);
12426
+ } catch {
12427
+ json(res, 400, { error: "Invalid JSON" });
12428
+ return;
12429
+ }
12430
+ let value;
12431
+ if (setting === "effort") {
12432
+ if (!isEffortLevel(parsed.effort)) {
12433
+ json(res, 400, {
12434
+ error: `effort must be one of ${EFFORT_LEVELS.join(", ")}`
12435
+ });
12436
+ return;
12437
+ }
12438
+ value = parsed.effort;
12439
+ } else {
12440
+ if (typeof parsed.model !== "string" || !MODEL_NAME_RE.test(parsed.model)) {
12441
+ json(res, 400, {
12442
+ error: "model must be an alias or full model name (letters, digits, dot, dash, underscore)"
12443
+ });
12444
+ return;
12445
+ }
12446
+ value = parsed.model;
12447
+ }
12448
+ try {
12449
+ this.ptyManager.sendKeys(sessionId, `/${setting} ${value}\r`);
12450
+ } catch (err) {
12451
+ json(res, 400, { error: err instanceof Error ? err.message : "Failed to write to session" });
12452
+ return;
12453
+ }
12454
+ this.log.info(`Live session ${setting} set to ${value}`, {
12455
+ event: "session.setting_applied",
12456
+ sessionId,
12457
+ setting,
12458
+ value
12459
+ });
12460
+ json(res, 202, { id: sessionId, [setting]: value });
12461
+ }
12220
12462
  handleGetSessionNames(res) {
12221
12463
  if (!this.cache) {
12222
12464
  json(res, 200, {});