@threadbase-sh/streamer 1.19.1 → 1.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -406,6 +406,31 @@ function validatePublicUrl(raw) {
406
406
  function stripTrailingSlash(url) {
407
407
  return url.endsWith("/") ? url.slice(0, -1) : url;
408
408
  }
409
+ function setApiKey(key) {
410
+ (0, import_fs.mkdirSync)(CONFIG_DIR, { recursive: true });
411
+ let content = "";
412
+ try {
413
+ content = (0, import_fs.readFileSync)(CONFIG_FILE, "utf-8");
414
+ } catch (err) {
415
+ if (err.code !== "ENOENT") throw err;
416
+ }
417
+ const apiKeyLine = `api_key: ${key}`;
418
+ let updated;
419
+ if (/^api_key:\s*.+$/m.test(content)) {
420
+ updated = content.replace(/^api_key:\s*.+$/m, apiKeyLine);
421
+ } else if (content.length === 0 || content.endsWith("\n")) {
422
+ updated = `${content}${apiKeyLine}
423
+ `;
424
+ } else {
425
+ updated = `${content}
426
+ ${apiKeyLine}
427
+ `;
428
+ }
429
+ const tmpFile = `${CONFIG_FILE}.tmp`;
430
+ (0, import_fs.writeFileSync)(tmpFile, updated, { encoding: "utf-8", mode: 384 });
431
+ (0, import_fs.chmodSync)(tmpFile, 384);
432
+ (0, import_fs.renameSync)(tmpFile, CONFIG_FILE);
433
+ }
409
434
 
410
435
  // src/db/config.ts
411
436
  function isDbEnabled() {
@@ -1799,13 +1824,23 @@ var authMiddleware = (deps) => async (c, next) => {
1799
1824
  };
1800
1825
 
1801
1826
  // src/api/middleware/cors.middleware.ts
1827
+ var ALLOWED_ORIGINS = /* @__PURE__ */ new Set([
1828
+ "http://localhost:8081",
1829
+ "http://localhost:19006",
1830
+ "http://localhost:3000"
1831
+ ]);
1802
1832
  var corsMiddleware = () => async (c, next) => {
1803
- c.res.headers.set("Access-Control-Allow-Origin", "*");
1804
- c.res.headers.set("Access-Control-Allow-Methods", "GET, POST, PATCH, OPTIONS");
1805
- c.res.headers.set("Access-Control-Allow-Headers", "Authorization, Content-Type, If-None-Match");
1806
- c.res.headers.set("Access-Control-Expose-Headers", "ETag");
1833
+ const origin = c.req.header("origin");
1834
+ const allowedOrigin = origin && ALLOWED_ORIGINS.has(origin) ? origin : null;
1835
+ if (allowedOrigin) {
1836
+ c.res.headers.set("Access-Control-Allow-Origin", allowedOrigin);
1837
+ c.res.headers.set("Vary", "Origin");
1838
+ c.res.headers.set("Access-Control-Allow-Methods", "GET, POST, PATCH, OPTIONS");
1839
+ c.res.headers.set("Access-Control-Allow-Headers", "Authorization, Content-Type, If-None-Match");
1840
+ c.res.headers.set("Access-Control-Expose-Headers", "ETag");
1841
+ }
1807
1842
  if (c.req.method === "OPTIONS") {
1808
- return c.newResponse(null, 204);
1843
+ return c.newResponse(null, allowedOrigin ? 204 : 403);
1809
1844
  }
1810
1845
  await next();
1811
1846
  };
@@ -1992,6 +2027,19 @@ var createMiscRoutes = (deps) => {
1992
2027
  });
1993
2028
  });
1994
2029
  app.get("/api/profiles", (c) => c.json([]));
2030
+ app.post("/api/auth/rotate", (c) => {
2031
+ if (deps.localNoAuth) {
2032
+ return c.json({ error: "key rotation is disabled while localNoAuth is active" }, 403);
2033
+ }
2034
+ const { newKey, persisted } = deps.rotateApiKey();
2035
+ return c.json({
2036
+ apiKey: newKey,
2037
+ persisted,
2038
+ ...persisted ? {} : {
2039
+ warning: "Key rotated in memory only. The server was started with --api-key, so the old key will be restored on restart. Remove --api-key and let the server manage the key via ~/.threadbase/server.yaml for rotation to survive restarts."
2040
+ }
2041
+ });
2042
+ });
1995
2043
  app.post("/api/push/register", (c) => c.json({ ok: true }));
1996
2044
  app.post("/api/__update", async (c) => {
1997
2045
  const cfg = loadUpdateConfig();
@@ -2065,6 +2113,11 @@ var ALREADY_HANDLED4 = 597;
2065
2113
  var alreadyHandled4 = () => new Response(null, { status: ALREADY_HANDLED4 });
2066
2114
  var createProjectRoutes = (deps) => {
2067
2115
  const app = new import_hono7.Hono();
2116
+ app.get("/", (c) => {
2117
+ const url = new URL(c.req.url);
2118
+ deps.handleListProjects(url, c.env.outgoing);
2119
+ return alreadyHandled4();
2120
+ });
2068
2121
  app.get("/popular", (c) => {
2069
2122
  const url = new URL(c.req.url);
2070
2123
  deps.handleGetPopularProjects(url, c.env.outgoing);
@@ -2084,11 +2137,6 @@ var createScannerRoutes = (deps) => {
2084
2137
  await deps.handleSearch(url, c.env.outgoing);
2085
2138
  return alreadyHandled5();
2086
2139
  });
2087
- app.get("/project-chats", async (c) => {
2088
- const url = new URL(c.req.url);
2089
- await deps.handleListProjectChats(url, c.env.outgoing);
2090
- return alreadyHandled5();
2091
- });
2092
2140
  return app;
2093
2141
  };
2094
2142
 
@@ -3261,297 +3309,39 @@ async function recordUpload(pool2, instanceId, row) {
3261
3309
  );
3262
3310
  }
3263
3311
 
3264
- // src/schemas/queryParams.schema.ts
3265
- var import_zod2 = require("zod");
3266
- var ListProjectChatsQuerySchema = import_zod2.z.object({
3267
- refresh: import_zod2.z.enum(["1"]).optional(),
3268
- refreshConversations: import_zod2.z.enum(["1"]).optional()
3269
- });
3270
-
3271
- // src/services/cache/cacheMetadata.ts
3272
- function getCacheMetadata(repo, key) {
3273
- return repo.getCacheMetadata(key);
3274
- }
3275
- function setCacheMetadata(repo, key, value) {
3276
- repo.setCacheMetadata(key, value);
3277
- }
3278
-
3279
- // src/utils/dates.ts
3280
- var import_date_fns = require("date-fns");
3281
- function parseIsoDateOrNull(value) {
3282
- if (!value) return null;
3283
- const parsed = (0, import_date_fns.parseISO)(value);
3284
- return (0, import_date_fns.isValid)(parsed) ? parsed : null;
3285
- }
3286
- function compareIsoDesc(a, b) {
3287
- const dateA = parseIsoDateOrNull(a);
3288
- const dateB = parseIsoDateOrNull(b);
3289
- if (!dateA && !dateB) return 0;
3290
- if (!dateA) return 1;
3291
- if (!dateB) return -1;
3292
- return (0, import_date_fns.compareDesc)(dateA, dateB);
3293
- }
3294
-
3295
- // src/services/projects/ensureProjectsForConversations.ts
3296
- function ensureProjectsForConversations(repo, conversations) {
3297
- const conversationsByPath = /* @__PURE__ */ new Map();
3298
- for (const conversation of conversations) {
3299
- if (!conversation.projectPath) continue;
3300
- const canonical = canonicalizeProjectPath(conversation.projectPath);
3301
- if (!canonical) continue;
3302
- const existing = conversationsByPath.get(canonical) ?? [];
3303
- existing.push(conversation);
3304
- conversationsByPath.set(canonical, existing);
3305
- }
3306
- const pathToProjectId = /* @__PURE__ */ new Map();
3307
- for (const [path, projectConversations] of conversationsByPath) {
3308
- const latest = pickLatestConversation(projectConversations);
3309
- const project = repo.upsertProjectByPath(path, {
3310
- lastConversationId: latest?.id ?? null,
3311
- lastConversationCreatedAt: latest?.createdAt ?? null,
3312
- latestMessageAt: latest?.latestMessageAt ?? null
3313
- });
3314
- pathToProjectId.set(path, project.id);
3315
- }
3316
- return pathToProjectId;
3317
- }
3318
- function pickLatestConversation(conversations) {
3319
- if (conversations.length === 0) return void 0;
3320
- return [...conversations].sort((a, b) => {
3321
- const cmp = compareIsoDesc(a.latestMessageAt ?? null, b.latestMessageAt ?? null);
3322
- if (cmp !== 0) return cmp;
3323
- return compareIsoDesc(a.createdAt ?? null, b.createdAt ?? null);
3324
- })[0];
3325
- }
3326
-
3327
- // src/services/conversations/refreshConversationCache.ts
3328
- function refreshConversationCache(deps) {
3329
- const { projectsRepo, conversationsRepo, cacheMetadataRepo } = deps;
3330
- const conversations = conversationsRepo.listConversationsForProjectBackfill();
3331
- const pathToProjectId = ensureProjectsForConversations(
3332
- projectsRepo,
3333
- conversations.map((c) => ({
3334
- id: c.id,
3335
- projectPath: c.projectPath,
3336
- latestMessageAt: c.lastActivity ?? null,
3337
- createdAt: c.lastActivity ?? null
3338
- }))
3339
- );
3340
- let conversationsBackfilled = 0;
3341
- for (const conversation of conversations) {
3342
- if (!conversation.projectPath) continue;
3343
- if (conversation.projectId) continue;
3344
- const projectId = pathToProjectId.get(canonicalizeProjectPath(conversation.projectPath));
3345
- if (!projectId) continue;
3346
- conversationsRepo.updateConversationProjectId({
3347
- conversationId: conversation.id,
3348
- projectId
3349
- });
3350
- conversationsBackfilled += 1;
3351
- }
3352
- const latest = conversationsRepo.getLatestConversation();
3353
- if (latest) {
3354
- setCacheMetadata(cacheMetadataRepo, "last_conversation_id", latest.id);
3355
- if (latest.lastActivity) {
3356
- setCacheMetadata(cacheMetadataRepo, "last_conversation_created_at", latest.lastActivity);
3357
- }
3358
- }
3359
- setCacheMetadata(cacheMetadataRepo, "conversations_last_indexed_at", (/* @__PURE__ */ new Date()).toISOString());
3360
- return {
3361
- projectsTouched: pathToProjectId.size,
3362
- conversationsBackfilled,
3363
- latestConversationId: latest?.id ?? null
3364
- };
3365
- }
3366
-
3367
- // src/services/conversations/shouldRefreshProjectsFromHdd.ts
3312
+ // src/handlers/handleListProjects.ts
3368
3313
  var import_fs8 = require("fs");
3369
3314
  var import_os5 = require("os");
3370
3315
  var import_path9 = require("path");
3371
- var DEFAULT_PROJECTS_DIR = (0, import_path9.join)((0, import_os5.homedir)(), ".claude", "projects");
3372
- function shouldRefreshProjectsFromHdd(conversationsRepo, cacheMetadataRepo, opts = {}) {
3373
- if (conversationsRepo.hasOrphanRows()) return true;
3374
- const projectsDir = opts.projectsDir ?? DEFAULT_PROJECTS_DIR;
3375
- let dirMtimeMs;
3316
+ function decodeProjectPath(dirName) {
3317
+ return dirName.replace(/-/g, "/");
3318
+ }
3319
+ function handleListProjects(url, res) {
3320
+ const limit = Math.max(1, parseInt(url.searchParams.get("limit") ?? "50", 10) || 50);
3321
+ const offset = Math.max(0, parseInt(url.searchParams.get("offset") ?? "0", 10) || 0);
3322
+ const projectsDir = (0, import_path9.join)((0, import_os5.homedir)(), ".claude", "projects");
3323
+ let entries;
3376
3324
  try {
3377
- dirMtimeMs = (0, import_fs8.statSync)(projectsDir).mtimeMs;
3325
+ entries = (0, import_fs8.readdirSync)(projectsDir).map((dirName) => {
3326
+ const fullPath = (0, import_path9.join)(projectsDir, dirName);
3327
+ let mtime = 0;
3328
+ try {
3329
+ mtime = (0, import_fs8.statSync)(fullPath).mtimeMs;
3330
+ } catch {
3331
+ }
3332
+ const path = decodeProjectPath(dirName);
3333
+ const name = path.split("/").filter(Boolean).pop() ?? dirName;
3334
+ return { name, path, dirName, mtime };
3335
+ }).sort((a, b) => b.mtime - a.mtime);
3378
3336
  } catch {
3379
- return false;
3380
- }
3381
- const lastIndexedIso = getCacheMetadata(cacheMetadataRepo, "conversations_last_indexed_at");
3382
- if (!lastIndexedIso) return true;
3383
- const lastIndexedMs = Date.parse(lastIndexedIso);
3384
- if (Number.isNaN(lastIndexedMs)) return true;
3385
- return dirMtimeMs > lastIndexedMs;
3386
- }
3387
-
3388
- // src/services/sessions/ensureSessionProjectIdsFromExistingProjects.ts
3389
- function ensureSessionProjectIdsFromExistingProjects(projectsRepo, sessionsRepo) {
3390
- let linked = 0;
3391
- let missing = 0;
3392
- for (const session of sessionsRepo.listManagedSessions()) {
3393
- if (session.projectId) continue;
3394
- const canonical = canonicalizeProjectPath(session.projectPath);
3395
- if (!canonical) continue;
3396
- const project = projectsRepo.getProjectByPath(canonical);
3397
- if (!project) {
3398
- missing += 1;
3399
- continue;
3400
- }
3401
- sessionsRepo.updateSessionProjectId({ sessionId: session.id, projectId: project.id });
3402
- linked += 1;
3403
- }
3404
- return { linked, missing };
3405
- }
3406
-
3407
- // src/services/projectChats/sortProjectChats.ts
3408
- function sortProjectChats(a, b) {
3409
- const byLatest = compareIsoDesc(a.latestMessageAt, b.latestMessageAt);
3410
- if (byLatest !== 0) return byLatest;
3411
- const byUpdated = compareIsoDesc(a.updatedAt ?? null, b.updatedAt ?? null);
3412
- if (byUpdated !== 0) return byUpdated;
3413
- const byCreated = compareIsoDesc(a.createdAt ?? null, b.createdAt ?? null);
3414
- if (byCreated !== 0) return byCreated;
3415
- return a.title.localeCompare(b.title);
3416
- }
3417
-
3418
- // src/services/projectChats/mergeProjectChats.ts
3419
- function mergeProjectChats(args) {
3420
- const { sessions, conversations } = args;
3421
- const resumedConversationIds = new Set(
3422
- sessions.filter((c) => c.type === "session").map((c) => c.resumedFromConversationId).filter((id) => Boolean(id))
3423
- );
3424
- const visibleConversations = conversations.filter((c) => {
3425
- if (c.type !== "conversation") return true;
3426
- return !resumedConversationIds.has(c.id);
3427
- });
3428
- return [...sessions, ...visibleConversations].sort(sortProjectChats);
3429
- }
3430
-
3431
- // src/services/projectChats/deriveProjectChatTitle.ts
3432
- function deriveProjectChatTitle(input) {
3433
- const trimmed = input.title?.trim();
3434
- if (trimmed) return trimmed;
3435
- const name = input.projectName?.trim();
3436
- if (name) return name;
3437
- const pathSuffix = input.projectPath ? input.projectPath.split(/[/\\]/).filter(Boolean).slice(-2).join("/") : "";
3438
- if (pathSuffix) return pathSuffix;
3439
- return `Untitled \xB7 ${input.id.slice(0, 8)}`;
3440
- }
3441
-
3442
- // src/services/projectChats/normalizeConversationToProjectChat.ts
3443
- function normalizeConversationToProjectChat(conversation) {
3444
- if (!conversation.projectId) {
3445
- throw new Error(
3446
- `normalizeConversationToProjectChat: conversation ${conversation.id} has no projectId \u2014 resolve it before normalizing`
3447
- );
3448
- }
3449
- return {
3450
- type: "conversation",
3451
- id: conversation.id,
3452
- projectId: conversation.projectId,
3453
- projectPath: conversation.projectPath ?? null,
3454
- title: deriveProjectChatTitle({
3455
- title: conversation.title,
3456
- projectName: conversation.projectName,
3457
- projectPath: conversation.projectPath,
3458
- id: conversation.id
3459
- }),
3460
- latestMessageAt: conversation.lastActivity ?? null,
3461
- updatedAt: conversation.lastActivity ?? null,
3462
- createdAt: null,
3463
- status: "resumable",
3464
- source: "hdd-cache",
3465
- provider: conversation.provider ?? CLAUDE_CODE_PROVIDER,
3466
- indexedAt: null,
3467
- fileMtime: null,
3468
- filePath: conversation.filePath ?? null,
3469
- sourceHash: null
3470
- };
3471
- }
3472
-
3473
- // src/services/projectChats/normalizeSessionToProjectChat.ts
3474
- function normalizeSessionToProjectChat(session) {
3475
- if (!session.projectId) {
3476
- throw new Error(
3477
- `normalizeSessionToProjectChat: session ${session.id} has no projectId \u2014 resolve it before normalizing`
3478
- );
3479
- }
3480
- return {
3481
- type: "session",
3482
- id: session.id,
3483
- projectId: session.projectId,
3484
- projectPath: session.projectPath ?? null,
3485
- title: deriveProjectChatTitle({
3486
- title: session.sessionName,
3487
- projectName: session.projectName,
3488
- projectPath: session.projectPath,
3489
- id: session.id
3490
- }),
3491
- latestMessageAt: session.lastMessageAt ?? session.lastActivityAt ?? null,
3492
- updatedAt: session.lastActivityAt ?? null,
3493
- createdAt: session.startedAt ?? null,
3494
- status: "active",
3495
- source: "session-store",
3496
- resumedFromConversationId: session.resumedFromConversationId ?? null
3497
- };
3498
- }
3499
-
3500
- // src/services/projectChats/listProjectChats.ts
3501
- async function listProjectChats(deps, args) {
3502
- const {
3503
- cache,
3504
- projectsRepo,
3505
- conversationsRepo,
3506
- sessionsRepo,
3507
- cacheMetadataRepo,
3508
- getSessionResponses,
3509
- getFreshScanner,
3510
- projectsDir
3511
- } = deps;
3512
- const needsRefresh = args.refreshConversations || shouldRefreshProjectsFromHdd(conversationsRepo, cacheMetadataRepo, { projectsDir });
3513
- if (needsRefresh) {
3514
- const scanner = await getFreshScanner();
3515
- const metas = [...scanner.getMetadataCache().values()];
3516
- if (metas.length > 0) {
3517
- cache.upsertFromScannerMeta(metas);
3518
- }
3519
- refreshConversationCache({ cache, projectsRepo, conversationsRepo, cacheMetadataRepo });
3520
- }
3521
- ensureSessionProjectIdsFromExistingProjects(projectsRepo, sessionsRepo);
3522
- const sessionResponses = getSessionResponses();
3523
- const sessionChats = [];
3524
- for (const s of sessionResponses) {
3525
- if (!s.projectId) continue;
3526
- sessionChats.push(normalizeSessionToProjectChat(s));
3527
- }
3528
- const conversationChats = [];
3529
- const { conversations } = cache.listConversations({ limit: 1e3, offset: 0 });
3530
- for (const c of conversations) {
3531
- if (!c.projectId) continue;
3532
- conversationChats.push(normalizeConversationToProjectChat(c));
3533
- }
3534
- return mergeProjectChats({ sessions: sessionChats, conversations: conversationChats });
3535
- }
3536
-
3537
- // src/handlers/handleListProjectChats.ts
3538
- async function handleListProjectChats(url, res, deps) {
3539
- const queryObj = Object.fromEntries(url.searchParams.entries());
3540
- const parsed = ListProjectChatsQuerySchema.safeParse(queryObj);
3541
- if (!parsed.success) {
3542
- res.writeHead(400, { "Content-Type": "application/json" });
3543
- res.end(
3544
- JSON.stringify({
3545
- error: "Invalid query parameters",
3546
- details: parsed.error.flatten()
3547
- })
3548
- );
3337
+ res.writeHead(200, { "Content-Type": "application/json" });
3338
+ res.end(JSON.stringify({ projects: [], total: 0 }));
3549
3339
  return;
3550
3340
  }
3551
- const refreshConversations = parsed.data.refreshConversations === "1" || parsed.data.refresh === "1";
3552
- const chats = await listProjectChats(deps, { refreshConversations });
3341
+ const total = entries.length;
3342
+ const page = entries.slice(offset, offset + limit).map(({ name, path, dirName }) => ({ name, path, dirName }));
3553
3343
  res.writeHead(200, { "Content-Type": "application/json" });
3554
- res.end(JSON.stringify({ projectChats: chats }));
3344
+ res.end(JSON.stringify({ projects: page, total }));
3555
3345
  }
3556
3346
 
3557
3347
  // src/pair-store.ts
@@ -3780,6 +3570,17 @@ function pruneAgentConversations(cache) {
3780
3570
  return { scanned: rows.length, pruned, missing };
3781
3571
  }
3782
3572
 
3573
+ // src/services/projectChats/deriveProjectChatTitle.ts
3574
+ function deriveProjectChatTitle(input) {
3575
+ const trimmed = input.title?.trim();
3576
+ if (trimmed) return trimmed;
3577
+ const name = input.projectName?.trim();
3578
+ if (name) return name;
3579
+ const pathSuffix = input.projectPath ? input.projectPath.split(/[/\\]/).filter(Boolean).slice(-2).join("/") : "";
3580
+ if (pathSuffix) return pathSuffix;
3581
+ return `Untitled \xB7 ${input.id.slice(0, 8)}`;
3582
+ }
3583
+
3783
3584
  // src/services/questions/detectAskUserQuestion.ts
3784
3585
  function normalizeContent2(raw) {
3785
3586
  if (Array.isArray(raw)) return raw;
@@ -4183,6 +3984,14 @@ function debounce(fn, waitMs) {
4183
3984
  return debounced;
4184
3985
  }
4185
3986
 
3987
+ // src/utils/dates.ts
3988
+ var import_date_fns = require("date-fns");
3989
+ function parseIsoDateOrNull(value) {
3990
+ if (!value) return null;
3991
+ const parsed = (0, import_date_fns.parseISO)(value);
3992
+ return (0, import_date_fns.isValid)(parsed) ? parsed : null;
3993
+ }
3994
+
4186
3995
  // src/utils/isScannedSnapshotStale.ts
4187
3996
  var STALENESS_TOLERANCE_MS = 1e3;
4188
3997
  function isScannedSnapshotStale(snapshotTimestamp, fileMtimeMs) {
@@ -4319,6 +4128,7 @@ var WSHub = class {
4319
4128
 
4320
4129
  // src/server.ts
4321
4130
  var BROWSE_SYSTEM_PROMPT = (browseRoot) => `You are working within the project boundary: ${browseRoot}. Do not read, write, or execute commands that access files or directories outside this boundary.`;
4131
+ var DEFAULT_SYSTEM_PROMPT = "When presenting options or choices to the user, limit the options to at most 3.";
4322
4132
  var DEFAULT_PTY_GRACE_PERIOD_MS = 27e4;
4323
4133
  function parseIncludeAgentsEnv(raw) {
4324
4134
  if (raw === void 0) return false;
@@ -4357,6 +4167,7 @@ var StreamerServer = class {
4357
4167
  binding = false;
4358
4168
  cacheReady = false;
4359
4169
  apiKey;
4170
+ apiKeySource;
4360
4171
  localNoAuth;
4361
4172
  logMenubarRequests;
4362
4173
  verbose;
@@ -4368,7 +4179,10 @@ var StreamerServer = class {
4368
4179
  publicUrl = null;
4369
4180
  pairTokens = new PairTokenStore();
4370
4181
  exchangeAttempts = /* @__PURE__ */ new Map();
4182
+ sessionStartAttempts = /* @__PURE__ */ new Map();
4183
+ sessionInputAttempts = /* @__PURE__ */ new Map();
4371
4184
  ptyGracePeriodMs;
4185
+ defaultSystemPrompt;
4372
4186
  // Map of sessionId → grace timer; fires to kill PTY after WS disconnect
4373
4187
  ptyGraceTimers = /* @__PURE__ */ new Map();
4374
4188
  // Map of sessionId → set of subscribed WS clients
@@ -4402,13 +4216,20 @@ var StreamerServer = class {
4402
4216
  constructor(config) {
4403
4217
  this.sessionStatusBus.setMaxListeners(0);
4404
4218
  this.apiKey = config.apiKey;
4219
+ this.apiKeySource = config.apiKeySource ?? "config";
4405
4220
  this.localNoAuth = config.localNoAuth ?? false;
4406
4221
  this.logMenubarRequests = config.logMenubarRequests ?? false;
4222
+ if (this.localNoAuth) {
4223
+ console.warn(
4224
+ "[WARN] localNoAuth is ENABLED \u2014 all requests from localhost bypass authentication. Do not run with --local-no-auth in shared or production environments."
4225
+ );
4226
+ }
4407
4227
  this.verbose = config.verbose ?? false;
4408
4228
  this.disableDb = config.disableDb ?? false;
4409
4229
  this.scanProfiles = config.scanProfiles;
4410
4230
  this.codexRoots = config.codexRoots ?? [(0, import_path11.join)((0, import_os6.homedir)(), ".codex", "sessions")];
4411
4231
  this.ptyGracePeriodMs = config.ptyGracePeriodMs ?? DEFAULT_PTY_GRACE_PERIOD_MS;
4232
+ this.defaultSystemPrompt = config.defaultSystemPrompt ?? DEFAULT_SYSTEM_PROMPT;
4412
4233
  this.cacheDir = config.cacheDir ?? loadCacheDir() ?? (0, import_path11.join)((0, import_os6.homedir)(), ".threadbase", "cache");
4413
4234
  this.tailSize = config.tailSize ?? loadTailSize() ?? 10;
4414
4235
  this.directoryDebounceMs = parseDirScanDebounceEnv(process.env.THREADBASE_DIR_SCAN_DEBOUNCE_MS) ?? config.directoryScanDebounceMs ?? 1e3;
@@ -4580,10 +4401,15 @@ var StreamerServer = class {
4580
4401
  });
4581
4402
  }
4582
4403
  const agentClient = this.agentClient;
4404
+ const self = this;
4583
4405
  const apiDeps = {
4584
- apiKey: this.apiKey,
4406
+ // ponytail: getter so rotateApiKey() takes effect without restarting the server
4407
+ get apiKey() {
4408
+ return self.apiKey;
4409
+ },
4585
4410
  localNoAuth: this.localNoAuth,
4586
4411
  logMenubarRequests: this.logMenubarRequests,
4412
+ rotateApiKey: () => this.rotateApiKey(),
4587
4413
  publicUrl: this.publicUrl,
4588
4414
  browseRoot: this.browseRoot,
4589
4415
  ptyManager: this.ptyManager,
@@ -4614,8 +4440,8 @@ var StreamerServer = class {
4614
4440
  handleConversationsCount: (url, res) => this.handleConversationsCount(url, res),
4615
4441
  handleGetConversation: (id, url, res, ifNoneMatch) => this.handleGetConversation(id, url, res, ifNoneMatch),
4616
4442
  handleSearch: (url, res) => this.handleSearch(url, res),
4443
+ handleListProjects: (url, res) => handleListProjects(url, res),
4617
4444
  handleGetPopularProjects: (url, res) => this.handleGetPopularProjects(url, res),
4618
- handleListProjectChats: (url, res) => this.handleListProjectChats(url, res),
4619
4445
  handlePairStart: (res) => this.handlePairStart(res),
4620
4446
  handlePairExchange: (req, res) => this.handlePairExchange(req, res),
4621
4447
  handleBrowse: (url, res) => this.handleBrowse(url, res),
@@ -5050,19 +4876,38 @@ var StreamerServer = class {
5050
4876
  machineName: hostname2()
5051
4877
  });
5052
4878
  }
5053
- checkExchangeRateLimit(ip) {
4879
+ rotateApiKey() {
4880
+ const newKey = generateApiKey();
4881
+ const persisted = this.apiKeySource === "config";
4882
+ if (persisted) setApiKey(newKey);
4883
+ this.apiKey = newKey;
4884
+ return { newKey, persisted };
4885
+ }
4886
+ checkRateLimit(map, key, limit, windowMs) {
5054
4887
  const now = Date.now();
5055
- const windowMs = 6e4;
5056
- const limit = 5;
5057
- const arr = (this.exchangeAttempts.get(ip) ?? []).filter((t) => now - t < windowMs);
4888
+ const arr = (map.get(key) ?? []).filter((t) => now - t < windowMs);
5058
4889
  if (arr.length >= limit) {
5059
- this.exchangeAttempts.set(ip, arr);
4890
+ map.set(key, arr);
5060
4891
  return false;
5061
4892
  }
5062
4893
  arr.push(now);
5063
- this.exchangeAttempts.set(ip, arr);
4894
+ map.set(key, arr);
4895
+ setTimeout(() => {
4896
+ const remaining = (map.get(key) ?? []).filter((t) => Date.now() - t < windowMs);
4897
+ if (remaining.length === 0) map.delete(key);
4898
+ else map.set(key, remaining);
4899
+ }, windowMs);
5064
4900
  return true;
5065
4901
  }
4902
+ checkExchangeRateLimit(ip) {
4903
+ return this.checkRateLimit(this.exchangeAttempts, ip, 5, 6e4);
4904
+ }
4905
+ checkSessionStartRateLimit(ip) {
4906
+ return this.checkRateLimit(this.sessionStartAttempts, ip, 10, 6e4);
4907
+ }
4908
+ checkSessionInputRateLimit(sessionId) {
4909
+ return this.checkRateLimit(this.sessionInputAttempts, sessionId, 500, 6e4);
4910
+ }
5066
4911
  async handleListConversations(url, res) {
5067
4912
  const limit = intParam(url, "limit", 50);
5068
4913
  const offset = intParam(url, "offset", 0);
@@ -5224,21 +5069,6 @@ var StreamerServer = class {
5224
5069
  const projects = this.cache.getPopularProjects(limit);
5225
5070
  json(res, 200, { projects, total: projects.length });
5226
5071
  }
5227
- async handleListProjectChats(url, res) {
5228
- if (!this.cache || !this.projectsRepo || !this.conversationsRepo || !this.sessionsRepo || !this.cacheMetadataRepo) {
5229
- json(res, 503, { error: "Cache not available" });
5230
- return;
5231
- }
5232
- await handleListProjectChats(url, res, {
5233
- cache: this.cache,
5234
- projectsRepo: this.projectsRepo,
5235
- conversationsRepo: this.conversationsRepo,
5236
- sessionsRepo: this.sessionsRepo,
5237
- cacheMetadataRepo: this.cacheMetadataRepo,
5238
- getSessionResponses: () => this.sessionStore.list(this.ptyAttachedIds()),
5239
- getFreshScanner: () => this.getFreshScanner()
5240
- });
5241
- }
5242
5072
  buildStatCache(previousScanner) {
5243
5073
  if (!this.cache) return void 0;
5244
5074
  const dbStats = this.cache.getFileStats();
@@ -5715,6 +5545,10 @@ var StreamerServer = class {
5715
5545
  }
5716
5546
  }
5717
5547
  async handleSendInput(sessionId, req, res) {
5548
+ if (!this.checkSessionInputRateLimit(sessionId)) {
5549
+ json(res, 429, { error: "Too many input requests for this session. Please slow down." });
5550
+ return;
5551
+ }
5718
5552
  if (this.agentConfig.enabled) {
5719
5553
  const body2 = await readBody(req);
5720
5554
  const cache = this.cache;
@@ -5996,6 +5830,13 @@ var StreamerServer = class {
5996
5830
  json(res, 201, { sessionId: session.id });
5997
5831
  }
5998
5832
  async handleStartSession(req, res) {
5833
+ const ip = req.socket?.remoteAddress ?? "unknown";
5834
+ if (!this.checkSessionStartRateLimit(ip)) {
5835
+ json(res, 429, {
5836
+ error: "Too many session start requests. Please wait before trying again."
5837
+ });
5838
+ return;
5839
+ }
5999
5840
  if (this.agentConfig.enabled) {
6000
5841
  const body2 = await readBody(req);
6001
5842
  const result = await handleStartAgentSession(body2, {
@@ -6019,7 +5860,7 @@ var StreamerServer = class {
6019
5860
  return;
6020
5861
  }
6021
5862
  const body = await readBody(req);
6022
- const { path: relativePath } = body;
5863
+ const { path: relativePath, systemPrompt: clientPrompt } = body;
6023
5864
  if (typeof relativePath !== "string") {
6024
5865
  json(res, 400, { error: "Missing path field" });
6025
5866
  return;
@@ -6033,11 +5874,16 @@ var StreamerServer = class {
6033
5874
  return;
6034
5875
  }
6035
5876
  this.discoveryCache = null;
5877
+ const systemPromptParts = [
5878
+ this.defaultSystemPrompt,
5879
+ BROWSE_SYSTEM_PROMPT(this.browseRoot),
5880
+ typeof clientPrompt === "string" ? clientPrompt : null
5881
+ ].filter(Boolean);
6036
5882
  try {
6037
5883
  const session = await this.ptyManager.startFresh({
6038
5884
  projectPath: resolvedPath,
6039
5885
  projectName: body.projectName,
6040
- systemPrompt: BROWSE_SYSTEM_PROMPT(this.browseRoot)
5886
+ systemPrompt: systemPromptParts.join("\n")
6041
5887
  });
6042
5888
  this.sessionStore.addManaged(session);
6043
5889
  json(res, 202, { id: session.id, status: "pending" });