@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.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: "
|
|
365
|
-
{ id: "
|
|
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
|
|
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
|
|
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.
|
|
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.
|
|
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
|
|
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
|
-
|
|
10296
|
-
|
|
10297
|
-
|
|
10298
|
-
|
|
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
|
-
|
|
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
|
-
|
|
10521
|
-
|
|
10522
|
-
|
|
10523
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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, {});
|