@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 +276 -35
- package/dist/cli.cjs.map +1 -1
- package/dist/index.cjs +273 -31
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +49 -4
- package/dist/index.d.ts +49 -4
- package/dist/index.js +273 -31
- package/dist/index.js.map +1 -1
- package/dist/migrations/014_add_conversation_meta_file_path_index.sql +5 -0
- package/package.json +1 -1
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: "
|
|
313
|
-
{ id: "
|
|
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
|
|
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
|
|
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.
|
|
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.
|
|
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
|
|
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
|
-
|
|
10259
|
-
|
|
10260
|
-
|
|
10261
|
-
|
|
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
|
-
|
|
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
|
-
|
|
10484
|
-
|
|
10485
|
-
|
|
10486
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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, {});
|