@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/cli.cjs CHANGED
@@ -1490,6 +1490,9 @@ function isPermissionMode(value) {
1490
1490
  function isDangerousPermissionMode(mode) {
1491
1491
  return DANGEROUS_PERMISSION_MODES.includes(mode);
1492
1492
  }
1493
+ function isEffortLevel(value) {
1494
+ return typeof value === "string" && EFFORT_LEVELS.includes(value);
1495
+ }
1493
1496
  function findFlag(id) {
1494
1497
  return CLAUDE_FLAGS.find((f2) => f2.id === id);
1495
1498
  }
@@ -1554,7 +1557,7 @@ function buildFlagArgs(values, extraArgs) {
1554
1557
  const args = [];
1555
1558
  const safe = validateFlagValues(values ?? {});
1556
1559
  for (const def of CLAUDE_FLAGS) {
1557
- if (def.id === "permissionMode") continue;
1560
+ if (SPAWN_POSITIONAL_FLAG_IDS.has(def.id)) continue;
1558
1561
  const value = safe[def.id];
1559
1562
  if (value === void 0) continue;
1560
1563
  if (def.valueType === "boolean") {
@@ -1577,7 +1580,7 @@ function buildSettingsJson(permissionMode) {
1577
1580
  }
1578
1581
  return JSON.stringify(settings);
1579
1582
  }
1580
- var PERMISSION_MODES, DANGEROUS_PERMISSION_MODES, CLAUDE_FLAGS;
1583
+ var PERMISSION_MODES, DANGEROUS_PERMISSION_MODES, EFFORT_LEVELS, CLAUDE_FLAGS, SPAWN_POSITIONAL_FLAG_IDS;
1581
1584
  var init_claude_flags = __esm({
1582
1585
  "src/claude-flags.ts"() {
1583
1586
  "use strict";
@@ -1593,6 +1596,7 @@ var init_claude_flags = __esm({
1593
1596
  "bypassPermissions",
1594
1597
  "dontAsk"
1595
1598
  ];
1599
+ EFFORT_LEVELS = ["low", "medium", "high", "xhigh", "max"];
1596
1600
  CLAUDE_FLAGS = [
1597
1601
  {
1598
1602
  id: "permissionMode",
@@ -1604,9 +1608,10 @@ var init_claude_flags = __esm({
1604
1608
  { id: "addDir", flag: "--add-dir", valueType: "list", risk: "elevated" },
1605
1609
  { id: "allowedTools", flag: "--allowedTools", valueType: "list", risk: "elevated" },
1606
1610
  { id: "disallowedTools", flag: "--disallowedTools", valueType: "list", risk: "low" },
1607
- { id: "maxBudgetUsd", flag: "--max-budget-usd", valueType: "string", risk: "low" },
1608
- { id: "fallbackModel", flag: "--fallback-model", valueType: "string", risk: "low" }
1611
+ { id: "model", flag: "--model", valueType: "string", risk: "low" },
1612
+ { id: "effort", flag: "--effort", valueType: "enum", enumValues: EFFORT_LEVELS, risk: "low" }
1609
1613
  ];
1614
+ SPAWN_POSITIONAL_FLAG_IDS = /* @__PURE__ */ new Set(["permissionMode", "model", "effort"]);
1610
1615
  }
1611
1616
  });
1612
1617
 
@@ -139499,6 +139504,14 @@ var createSessionRoutes = (deps) => {
139499
139504
  await deps.handleSetSessionName(c.req.param("id"), c.env.incoming, c.env.outgoing);
139500
139505
  return alreadyHandled6();
139501
139506
  });
139507
+ app.patch("/:id/model", async (c) => {
139508
+ await deps.handleSetSessionModel(c.req.param("id"), c.env.incoming, c.env.outgoing);
139509
+ return alreadyHandled6();
139510
+ });
139511
+ app.patch("/:id/effort", async (c) => {
139512
+ await deps.handleSetSessionEffort(c.req.param("id"), c.env.incoming, c.env.outgoing);
139513
+ return alreadyHandled6();
139514
+ });
139502
139515
  app.post("/:id/adopt", async (c) => {
139503
139516
  await deps.handleAdopt(c.req.param("id"), c.env.outgoing);
139504
139517
  return alreadyHandled6();
@@ -139838,6 +139851,7 @@ CREATE TABLE IF NOT EXISTS conversation_meta (
139838
139851
  );
139839
139852
  CREATE INDEX IF NOT EXISTS idx_meta_last_activity ON conversation_meta(last_activity DESC);
139840
139853
  CREATE INDEX IF NOT EXISTS idx_meta_project ON conversation_meta(project_path);
139854
+ CREATE INDEX IF NOT EXISTS idx_meta_file_path ON conversation_meta(file_path);
139841
139855
 
139842
139856
  CREATE TABLE IF NOT EXISTS conversation_tail (
139843
139857
  conversation_id TEXT PRIMARY KEY REFERENCES conversation_meta(id) ON DELETE CASCADE,
@@ -145915,6 +145929,24 @@ var WSHub = class {
145915
145929
  this.clients.delete(client);
145916
145930
  }
145917
145931
  }
145932
+ // Scoped broadcast for high-frequency per-session messages (terminal_output,
145933
+ // user_message). Sending to every connected client for every PTY output
145934
+ // chunk made broadcast() cost scale with connections x active sessions;
145935
+ // this bounds it to only that session's subscribers.
145936
+ broadcastToClients(clients, message) {
145937
+ const data = JSON.stringify(message);
145938
+ for (const client of clients) {
145939
+ try {
145940
+ if (client.readyState === client.OPEN) {
145941
+ client.send(data);
145942
+ } else {
145943
+ this.clients.delete(client);
145944
+ }
145945
+ } catch {
145946
+ this.clients.delete(client);
145947
+ }
145948
+ }
145949
+ }
145918
145950
  unicast(ws2, message) {
145919
145951
  try {
145920
145952
  if (ws2.readyState === ws2.OPEN) {
@@ -145977,6 +146009,7 @@ var ADOPT_KILL_TIMEOUT_MS = 5e3;
145977
146009
  var ADOPT_KILL_POLL_MS = 100;
145978
146010
  var REFRESH_TTL_MS = 2e3;
145979
146011
  var START_READY_TIMEOUT_MS = 1e4;
146012
+ var MODEL_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
145980
146013
  var EXTERNAL_TAIL_RECENCY_MS = RESUME_BUSY_WINDOW_MS;
145981
146014
  var EXTERNAL_TAIL_MAX = 32;
145982
146015
  var EXTERNAL_TAIL_IDLE_MS = 3e5;
@@ -146047,6 +146080,20 @@ var StreamerServer = class {
146047
146080
  // Set by onConversationChanged while a scan is in-flight; getScanner() does
146048
146081
  // a single rescan after the current one completes instead of restarting it.
146049
146082
  scannerStale = false;
146083
+ // WHICH files scannerStale is about. A directory event names exactly one
146084
+ // JSONL, and the only correct response is refreshFile() on that one file —
146085
+ // but scannerStale alone carries no identity, so honoring it used to mean a
146086
+ // full-tree rescan. On the non-persistent scanner this server actually runs
146087
+ // (buildStatCache => persistent:false, see listen()), scan() opens by
146088
+ // clearing metadataCache AND conversationLRU — so one live session appending
146089
+ // to its own transcript threw away every OTHER conversation's parsed
146090
+ // snapshot, and the next full fetch of an unrelated conversation re-parsed
146091
+ // it from disk (745-2877ms on a 5MB/1112-message history) while the
146092
+ // per-file paginated path stayed at ~20ms throughout. Populated alongside
146093
+ // scannerStale and drained with it by takeStaleFiles(); an armed flag with
146094
+ // an EMPTY set means "stale, source unknown" and still falls back to the
146095
+ // full rescan.
146096
+ staleFiles = /* @__PURE__ */ new Set();
146050
146097
  // Single-flight guard for the background disk reconcile: a burst of list
146051
146098
  // polls during active session writes shares one rescan instead of queueing
146052
146099
  // a full rescan per request.
@@ -146080,6 +146127,8 @@ var StreamerServer = class {
146080
146127
  dbPool = null;
146081
146128
  dbInstanceId = null;
146082
146129
  disableDb = false;
146130
+ // Skip the startup warm-up scan (test hook; see ServerConfig.skipStartupWarmup).
146131
+ skipStartupWarmup;
146083
146132
  browseRoot = null;
146084
146133
  publicUrl = null;
146085
146134
  browserCors;
@@ -146119,6 +146168,11 @@ var StreamerServer = class {
146119
146168
  // every provider; read only by the idle reaper. Entries are dropped when the
146120
146169
  // session leaves the runner (reap/exit/hold).
146121
146170
  lastAgentChunkAt = /* @__PURE__ */ new Map();
146171
+ // sessionId → last terminal_output seq broadcast (starts at 1, per session).
146172
+ // Stamped on every terminal_output/terminal_replay so a client can detect a
146173
+ // stale chunk delivered after a reconnect race instead of trusting raw WS
146174
+ // arrival order. Entries dropped alongside lastAgentChunkAt.
146175
+ terminalSeq = /* @__PURE__ */ new Map();
146122
146176
  // Recently accepted input idempotency keys (C4). A retried POST replays its
146123
146177
  // original outcome instead of submitting the prompt to the agent twice.
146124
146178
  idempotency = new IdempotencyStore();
@@ -146186,6 +146240,7 @@ var StreamerServer = class {
146186
146240
  }
146187
146241
  this.verbose = config2.verbose ?? false;
146188
146242
  this.disableDb = config2.disableDb ?? false;
146243
+ this.skipStartupWarmup = config2.skipStartupWarmup ?? false;
146189
146244
  this.scannerPersistenceDisabled = config2.scannerPersistent === false;
146190
146245
  this.scanProfiles = config2.scanProfiles;
146191
146246
  this.codexRoots = config2.codexRoots ?? [(0, import_path29.join)((0, import_os12.homedir)(), ".codex", "sessions")];
@@ -146207,7 +146262,10 @@ var StreamerServer = class {
146207
146262
  this.directoryDebounceMs = parseDirScanDebounceEnv(process.env.THREADBASE_DIR_SCAN_DEBOUNCE_MS) ?? config2.directoryScanDebounceMs ?? 1e3;
146208
146263
  this.markScannerStaleDebounced = debounce(() => {
146209
146264
  if (this.scannerReady) this.scannerStale = true;
146210
- else this.scanner = null;
146265
+ else {
146266
+ this.scanner = null;
146267
+ this.staleFiles.clear();
146268
+ }
146211
146269
  }, this.directoryDebounceMs);
146212
146270
  this.includeAgents = parseIncludeAgentsEnv(process.env.THREADBASE_INCLUDE_AGENTS);
146213
146271
  this.agentEntrypoints = parseAgentEntrypointsEnv(process.env.THREADBASE_AGENT_ENTRYPOINTS);
@@ -146300,6 +146358,7 @@ var StreamerServer = class {
146300
146358
  if (!tailed) this.maybeAttachExternalTail(filePath);
146301
146359
  this.sweepIdleExternalTails();
146302
146360
  this.cache?.invalidateByFilePath(filePath, { skipIfTailed: true });
146361
+ this.staleFiles.add(filePath);
146303
146362
  this.markScannerStaleDebounced();
146304
146363
  this.log.debug?.(`Scanner invalidated by directory event: ${filePath}`, {
146305
146364
  filePath,
@@ -146334,10 +146393,22 @@ var StreamerServer = class {
146334
146393
  logger: getLogger("pty"),
146335
146394
  onOutput: (sessionId, data) => {
146336
146395
  this.lastAgentChunkAt.set(sessionId, Date.now());
146337
- this.wsHub.broadcast({ type: "terminal_output", sessionId, data });
146396
+ const seq = (this.terminalSeq.get(sessionId) ?? 0) + 1;
146397
+ this.terminalSeq.set(sessionId, seq);
146398
+ this.wsHub.broadcastToClients(this.sessionSubscribers.get(sessionId) ?? [], {
146399
+ type: "terminal_output",
146400
+ sessionId,
146401
+ data,
146402
+ seq
146403
+ });
146338
146404
  },
146339
146405
  onUserMessage: (sessionId, text, ts2) => {
146340
- this.wsHub.broadcast({ type: "user_message", sessionId, text, ts: ts2 });
146406
+ this.wsHub.broadcastToClients(this.sessionSubscribers.get(sessionId) ?? [], {
146407
+ type: "user_message",
146408
+ sessionId,
146409
+ text,
146410
+ ts: ts2
146411
+ });
146341
146412
  },
146342
146413
  onPermissionChange: (sessionId, gate) => {
146343
146414
  this.handlePermissionChange(sessionId, gate);
@@ -146474,6 +146545,8 @@ var StreamerServer = class {
146474
146545
  handleCancel: (id, res) => this.handleCancel(id, res),
146475
146546
  handleStopSession: (id, res) => this.handleStopSession(id, res),
146476
146547
  handleSetSessionName: (id, req, res) => this.handleSetSessionName(id, req, res),
146548
+ handleSetSessionModel: (id, req, res) => this.applyLiveSessionSetting(id, req, res, "model"),
146549
+ handleSetSessionEffort: (id, req, res) => this.applyLiveSessionSetting(id, req, res, "effort"),
146477
146550
  handleUploadFile: (id, req, res) => this.handleUploadFile(id, req, res),
146478
146551
  handleAdopt: (id, res) => this.handleAdopt(id, res),
146479
146552
  handleResume: (req, res) => this.handleResume(req, res),
@@ -146520,7 +146593,8 @@ var StreamerServer = class {
146520
146593
  type: "terminal_replay",
146521
146594
  sessionId: msg.sessionId,
146522
146595
  lines,
146523
- userMessages
146596
+ userMessages,
146597
+ seq: this.terminalSeq.get(msg.sessionId)
146524
146598
  })
146525
146599
  );
146526
146600
  }
@@ -146880,6 +146954,7 @@ var StreamerServer = class {
146880
146954
  );
146881
146955
  this.ptyManager.putOnHold(session.id);
146882
146956
  this.lastAgentChunkAt.delete(session.id);
146957
+ this.terminalSeq.delete(session.id);
146883
146958
  this.idempotency.clear(session.id);
146884
146959
  this.sessionSubscribers.delete(session.id);
146885
146960
  reaped.push(session.id);
@@ -147054,6 +147129,14 @@ var StreamerServer = class {
147054
147129
  );
147055
147130
  this.scannerPersistenceDisabled = true;
147056
147131
  }
147132
+ if (this.skipStartupWarmup) {
147133
+ this.log.debug?.("startup warm-up scan skipped (skipStartupWarmup)", {
147134
+ event: "cache.warmup_skipped"
147135
+ });
147136
+ this.finishWarmup(0);
147137
+ resolveWarm();
147138
+ return;
147139
+ }
147057
147140
  const warmupStatCache = this.buildStatCache(null);
147058
147141
  const warmupScanner = this.newScanner(warmupStatCache ? { persistent: false } : void 0);
147059
147142
  this.allScanners.add(warmupScanner);
@@ -147245,6 +147328,7 @@ var StreamerServer = class {
147245
147328
  this.idleReaperTimer = null;
147246
147329
  }
147247
147330
  this.lastAgentChunkAt.clear();
147331
+ this.terminalSeq.clear();
147248
147332
  this.recordShutdownState();
147249
147333
  this.markScannerStaleDebounced.cancel();
147250
147334
  await Promise.all([...this.inFlightCacheWrites]);
@@ -147426,6 +147510,30 @@ var StreamerServer = class {
147426
147510
  persisted: this.claudeFlagsPersistable
147427
147511
  };
147428
147512
  }
147513
+ /**
147514
+ * The three spawn options that a configured claude-flag can override, with
147515
+ * the boot-time CLI/yaml default as the fallback. Spread into every
147516
+ * start/resume/adopt call so all three paths agree.
147517
+ *
147518
+ * These ids are excluded from buildFlagArgs (SPAWN_POSITIONAL_FLAG_IDS)
147519
+ * precisely because they arrive here instead — the PTY spawn paths pass them
147520
+ * as explicit positionals, so emitting them from the allowlist too would
147521
+ * duplicate the flag.
147522
+ *
147523
+ * Narrowed with the type guards rather than cast: ClaudeFlagValues is a loose
147524
+ * Record by design, and while validateFlagValues already guarantees the shape
147525
+ * on the way in, TypeScript cannot see that through the record.
147526
+ */
147527
+ spawnFlagOverrides() {
147528
+ const mode = this.claudeFlags.permissionMode;
147529
+ const model = this.claudeFlags.model;
147530
+ const effort = this.claudeFlags.effort;
147531
+ return {
147532
+ permissionMode: isPermissionMode(mode) ? mode : this.defaultPermissionMode,
147533
+ model: typeof model === "string" ? model : this.defaultModel,
147534
+ effort: isEffortLevel(effort) ? effort : this.defaultEffort
147535
+ };
147536
+ }
147429
147537
  checkRateLimit(map2, key, limit, windowMs) {
147430
147538
  const now = Date.now();
147431
147539
  const arr = (map2.get(key) ?? []).filter((t) => now - t < windowMs);
@@ -147497,21 +147605,46 @@ var StreamerServer = class {
147497
147605
  // so a burst of list polls during active session writes shares one rescan
147498
147606
  // rather than queueing a full rescan each; tracked so close() awaits the
147499
147607
  // in-flight cache write before shutting the DB.
147500
- startBackgroundConversationReconcile() {
147608
+ startBackgroundConversationReconcile(mode = "full") {
147501
147609
  if (this.conversationReconcileInFlight) return;
147502
- const task = this.reconcileConversationsCacheFromDisk().finally(() => {
147610
+ const paths = mode === "files" ? this.takeStaleFiles() : [];
147611
+ const task = (paths.length > 0 ? this.reconcileStaleFilesFromDisk(paths) : this.reconcileConversationsCacheFromDisk()).finally(() => {
147503
147612
  this.conversationReconcileInFlight = null;
147504
147613
  });
147505
147614
  this.conversationReconcileInFlight = task;
147506
147615
  this.trackCacheWrite(task);
147507
147616
  }
147508
- shouldAutoReconcileConversationList() {
147509
- if (!this.cache) return false;
147510
- if (this.scannerStale) return true;
147511
- if (!this.conversationsRepo || !this.cacheMetadataRepo) return false;
147617
+ // "files": a directory event named specific JSONLs, so refresh only those.
147618
+ // "full": disk drifted in ways a per-file refresh can't see (a project dir
147619
+ // appeared, rows vanished), so walk the tree. Order matters — the staleness
147620
+ // check short-circuits first so the HDD freshness probe stays off the hot
147621
+ // poll path, exactly as it did when this returned a boolean.
147622
+ conversationReconcileMode() {
147623
+ if (!this.cache) return null;
147624
+ if (this.scannerStale) return "files";
147625
+ if (!this.conversationsRepo || !this.cacheMetadataRepo) return null;
147512
147626
  return shouldRefreshProjectsFromHdd(this.conversationsRepo, this.cacheMetadataRepo, {
147513
147627
  projectsDirs: this.projectsDirsForFreshnessCheck()
147514
- });
147628
+ }) ? "full" : null;
147629
+ }
147630
+ // The per-file half of reconcileConversationsCacheFromDisk: re-index just the
147631
+ // changed JSONLs and upsert their rows. No reconcileDeletions here — that
147632
+ // needs the whole live-path set, and deletions already have their own path
147633
+ // (onFileDeleted -> invalidateByFilePath). New projects still arrive via the
147634
+ // HDD-freshness "full" mode.
147635
+ async reconcileStaleFilesFromDisk(paths) {
147636
+ if (!this.cache) return;
147637
+ const scanner = await this.getScanner(true);
147638
+ const metas = await this.refreshStaleFiles(scanner, paths);
147639
+ if (metas.length === 0) return;
147640
+ try {
147641
+ this.cache.upsertFromScannerMeta(metas);
147642
+ } catch (err) {
147643
+ this.log.warn(
147644
+ `stale-file reconcile failed: ${err instanceof Error ? err.message : String(err)}`,
147645
+ { event: "conversations.reconcile_failed" }
147646
+ );
147647
+ }
147515
147648
  }
147516
147649
  async handleListConversations(url2, res) {
147517
147650
  if (this.rejectIfWarmingUp(res)) return;
@@ -147521,10 +147654,11 @@ var StreamerServer = class {
147521
147654
  const project = url2.searchParams.get("project") ?? void 0;
147522
147655
  const providerFilter = url2.searchParams.get("provider") ?? void 0;
147523
147656
  const bustCache = url2.searchParams.get("refresh") === "1";
147524
- if (this.cache && (bustCache || this.shouldAutoReconcileConversationList())) {
147657
+ const reconcileMode = this.conversationReconcileMode();
147658
+ if (this.cache && (bustCache || reconcileMode)) {
147525
147659
  const canServeStale = !bustCache && this.cache.listConversations({ limit: 0, offset: 0 }).total > 0;
147526
147660
  if (canServeStale) {
147527
- this.startBackgroundConversationReconcile();
147661
+ this.startBackgroundConversationReconcile(reconcileMode ?? "full");
147528
147662
  } else {
147529
147663
  const shouldEmitProgress = createScanProgressThrottle();
147530
147664
  await this.withWarmup(
@@ -147724,21 +147858,54 @@ var StreamerServer = class {
147724
147858
  options ?? (this.scannerPersistenceDisabled ? { persistent: false } : void 0)
147725
147859
  );
147726
147860
  }
147861
+ // Drain the stale set and disarm the flag together. The caller owns the
147862
+ // returned paths: clearing before the refresh means events that land DURING
147863
+ // it re-arm the flag and get their own pass instead of being swallowed.
147864
+ takeStaleFiles() {
147865
+ const paths = [...this.staleFiles];
147866
+ this.staleFiles.clear();
147867
+ this.scannerStale = false;
147868
+ return paths;
147869
+ }
147870
+ // Reconcile exactly the JSONLs a directory event named. Failures are logged
147871
+ // and swallowed per file: one unreadable transcript must not abort the
147872
+ // others, and the file simply stays on its previous snapshot until the next
147873
+ // event — the same outcome the full rescan gave on a parse failure.
147874
+ async refreshStaleFiles(scanner, paths) {
147875
+ const metas = await Promise.all(
147876
+ paths.map(
147877
+ (filePath) => scanner.refreshFile(filePath).catch((err) => {
147878
+ this.log.warn("scanner.refreshFile: failed", {
147879
+ event: "scanner.refresh_failed",
147880
+ filePath,
147881
+ trigger: "directory-event",
147882
+ err
147883
+ });
147884
+ return null;
147885
+ })
147886
+ )
147887
+ );
147888
+ return metas.filter((m2) => m2 !== null);
147889
+ }
147727
147890
  async getScanner(skipStaleRescan = false) {
147728
147891
  if (this.scannerReady) {
147729
147892
  await this.scannerReady;
147730
147893
  if (this.scanner) {
147731
147894
  if (skipStaleRescan) return this.scanner;
147732
147895
  if (this.scannerStale) {
147733
- this.scannerStale = false;
147734
- this.scanner = null;
147735
- this.scannerReady = null;
147736
- return this.getScanner();
147896
+ const paths = this.takeStaleFiles();
147897
+ if (paths.length === 0) {
147898
+ this.scanner = null;
147899
+ this.scannerReady = null;
147900
+ return this.getScanner();
147901
+ }
147902
+ await this.refreshStaleFiles(this.scanner, paths);
147903
+ return this.scanner ?? this.getScanner();
147737
147904
  }
147738
147905
  return this.scanner;
147739
147906
  }
147740
147907
  }
147741
- this.scannerStale = false;
147908
+ this.takeStaleFiles();
147742
147909
  const statCache = this.buildStatCache(this.scanner);
147743
147910
  this.scanner = this.newScanner(statCache ? { persistent: false } : void 0);
147744
147911
  this.allScanners.add(this.scanner);
@@ -147772,7 +147939,7 @@ var StreamerServer = class {
147772
147939
  // getScanner() anti-infinite-loop guard is preserved.
147773
147940
  async rescanForRefresh(onProgress) {
147774
147941
  if (this.scannerReady) await this.scannerReady;
147775
- this.scannerStale = false;
147942
+ this.takeStaleFiles();
147776
147943
  if (!this.scanner) {
147777
147944
  this.scanner = new ConversationScanner();
147778
147945
  this.allScanners.add(this.scanner);
@@ -148575,11 +148742,9 @@ var StreamerServer = class {
148575
148742
  projectPath,
148576
148743
  projectName: body.projectName,
148577
148744
  branch: body.branch,
148578
- permissionMode: this.defaultPermissionMode,
148579
148745
  claudeFlags: this.claudeFlags,
148580
148746
  claudeExtraArgs: this.claudeExtraArgs,
148581
- model: this.defaultModel,
148582
- effort: this.defaultEffort
148747
+ ...this.spawnFlagOverrides()
148583
148748
  });
148584
148749
  this.sessionStore.addManaged(session);
148585
148750
  this.recordSessionSpawn(session);
@@ -149026,11 +149191,9 @@ var StreamerServer = class {
149026
149191
  projectPath,
149027
149192
  projectName,
149028
149193
  branch,
149029
- permissionMode: this.defaultPermissionMode,
149030
149194
  claudeFlags: this.claudeFlags,
149031
149195
  claudeExtraArgs: this.claudeExtraArgs,
149032
- model: this.defaultModel,
149033
- effort: this.defaultEffort
149196
+ ...this.spawnFlagOverrides()
149034
149197
  });
149035
149198
  this.sessionStore.addManaged(session);
149036
149199
  this.recordSessionSpawn(session);
@@ -149103,11 +149266,9 @@ var StreamerServer = class {
149103
149266
  projectPath: resolvedPath,
149104
149267
  projectName: body.projectName,
149105
149268
  ...includeSystemPrompt && { systemPrompt: systemPromptParts.join("\n") },
149106
- permissionMode: this.defaultPermissionMode,
149107
149269
  claudeFlags: this.claudeFlags,
149108
149270
  claudeExtraArgs: this.claudeExtraArgs,
149109
- model: this.defaultModel,
149110
- effort: this.defaultEffort
149271
+ ...this.spawnFlagOverrides()
149111
149272
  });
149112
149273
  this.sessionStore.addManaged(session);
149113
149274
  this.recordSessionSpawn(session);
@@ -149467,6 +149628,87 @@ var StreamerServer = class {
149467
149628
  this.cache.upsertSessionName(sessionId, name);
149468
149629
  json2(res, 200, { ok: true });
149469
149630
  }
149631
+ /**
149632
+ * Retarget a LIVE session's model or effort by typing the corresponding
149633
+ * Claude Code slash command into its PTY.
149634
+ *
149635
+ * There is no CLI or IPC channel for this — `--model`/`--effort` are spawn
149636
+ * arguments — so the interactive `/model <x>` / `/effort <y>` commands are the
149637
+ * only way to change a session already running. Both accept an argument and
149638
+ * apply it without opening the picker (verified against Claude Code v2.1.220).
149639
+ *
149640
+ * Answers 202, not 200: the value is applied by the TUI on its next render, so
149641
+ * there is nothing truthful to echo back synchronously. Clients confirm with
149642
+ * `GET /api/sessions/:id`, which scrapes the applied value off the live status
149643
+ * line.
149644
+ */
149645
+ async applyLiveSessionSetting(sessionId, req, res, setting) {
149646
+ const session = this.ptyManager.getSession(sessionId);
149647
+ if (!session) {
149648
+ const known = this.sessionStore.getManaged(sessionId);
149649
+ if (known) {
149650
+ json2(res, 409, {
149651
+ error: "Session has no live PTY; resume it first",
149652
+ code: "SESSION_IDLE"
149653
+ });
149654
+ return;
149655
+ }
149656
+ json2(res, 404, { error: "Session not found" });
149657
+ return;
149658
+ }
149659
+ if ((session.provider ?? CLAUDE_CODE_PROVIDER2) !== CLAUDE_CODE_PROVIDER2) {
149660
+ json2(res, 501, {
149661
+ error: `Setting ${setting} on a ${session.provider} session is not supported`,
149662
+ code: "UNSUPPORTED_PROVIDER"
149663
+ });
149664
+ return;
149665
+ }
149666
+ if (session.status === "running") {
149667
+ json2(res, 409, {
149668
+ error: "Session is mid-turn; retry once it is waiting for input",
149669
+ code: "SESSION_BUSY"
149670
+ });
149671
+ return;
149672
+ }
149673
+ let parsed;
149674
+ try {
149675
+ parsed = await readBody(req);
149676
+ } catch {
149677
+ json2(res, 400, { error: "Invalid JSON" });
149678
+ return;
149679
+ }
149680
+ let value;
149681
+ if (setting === "effort") {
149682
+ if (!isEffortLevel(parsed.effort)) {
149683
+ json2(res, 400, {
149684
+ error: `effort must be one of ${EFFORT_LEVELS.join(", ")}`
149685
+ });
149686
+ return;
149687
+ }
149688
+ value = parsed.effort;
149689
+ } else {
149690
+ if (typeof parsed.model !== "string" || !MODEL_NAME_RE.test(parsed.model)) {
149691
+ json2(res, 400, {
149692
+ error: "model must be an alias or full model name (letters, digits, dot, dash, underscore)"
149693
+ });
149694
+ return;
149695
+ }
149696
+ value = parsed.model;
149697
+ }
149698
+ try {
149699
+ this.ptyManager.sendKeys(sessionId, `/${setting} ${value}\r`);
149700
+ } catch (err) {
149701
+ json2(res, 400, { error: err instanceof Error ? err.message : "Failed to write to session" });
149702
+ return;
149703
+ }
149704
+ this.log.info(`Live session ${setting} set to ${value}`, {
149705
+ event: "session.setting_applied",
149706
+ sessionId,
149707
+ setting,
149708
+ value
149709
+ });
149710
+ json2(res, 202, { id: sessionId, [setting]: value });
149711
+ }
149470
149712
  handleGetSessionNames(res) {
149471
149713
  if (!this.cache) {
149472
149714
  json2(res, 200, {});
@@ -153557,10 +153799,9 @@ program2.command("serve").description("Start the streamer server").option("-p, -
153557
153799
  }
153558
153800
  featureFlags = parsed.values;
153559
153801
  }
153560
- const validEfforts = ["low", "medium", "high", "xhigh", "max"];
153561
- if (opts.defaultEffort !== void 0 && !validEfforts.includes(opts.defaultEffort)) {
153802
+ if (opts.defaultEffort !== void 0 && !isEffortLevel(opts.defaultEffort)) {
153562
153803
  log11.error(
153563
- `Invalid --default-effort: ${opts.defaultEffort} (expected one of ${validEfforts.join(", ")})`,
153804
+ `Invalid --default-effort: ${opts.defaultEffort} (expected one of ${EFFORT_LEVELS.join(", ")})`,
153564
153805
  void 0,
153565
153806
  "console"
153566
153807
  );