@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.js CHANGED
@@ -360,6 +360,31 @@ function validatePublicUrl(raw) {
360
360
  function stripTrailingSlash(url) {
361
361
  return url.endsWith("/") ? url.slice(0, -1) : url;
362
362
  }
363
+ function setApiKey(key) {
364
+ mkdirSync(CONFIG_DIR, { recursive: true });
365
+ let content = "";
366
+ try {
367
+ content = readFileSync(CONFIG_FILE, "utf-8");
368
+ } catch (err) {
369
+ if (err.code !== "ENOENT") throw err;
370
+ }
371
+ const apiKeyLine = `api_key: ${key}`;
372
+ let updated;
373
+ if (/^api_key:\s*.+$/m.test(content)) {
374
+ updated = content.replace(/^api_key:\s*.+$/m, apiKeyLine);
375
+ } else if (content.length === 0 || content.endsWith("\n")) {
376
+ updated = `${content}${apiKeyLine}
377
+ `;
378
+ } else {
379
+ updated = `${content}
380
+ ${apiKeyLine}
381
+ `;
382
+ }
383
+ const tmpFile = `${CONFIG_FILE}.tmp`;
384
+ writeFileSync(tmpFile, updated, { encoding: "utf-8", mode: 384 });
385
+ chmodSync(tmpFile, 384);
386
+ renameSync(tmpFile, CONFIG_FILE);
387
+ }
363
388
 
364
389
  // src/db/config.ts
365
390
  function isDbEnabled() {
@@ -1458,7 +1483,7 @@ import {
1458
1483
  createReadStream,
1459
1484
  existsSync as existsSync6,
1460
1485
  watch as fsWatch,
1461
- readdirSync as readdirSync3,
1486
+ readdirSync as readdirSync4,
1462
1487
  readFileSync as readFileSync6,
1463
1488
  statSync as statSync5
1464
1489
  } from "fs";
@@ -1766,13 +1791,23 @@ var authMiddleware = (deps) => async (c, next) => {
1766
1791
  };
1767
1792
 
1768
1793
  // src/api/middleware/cors.middleware.ts
1794
+ var ALLOWED_ORIGINS = /* @__PURE__ */ new Set([
1795
+ "http://localhost:8081",
1796
+ "http://localhost:19006",
1797
+ "http://localhost:3000"
1798
+ ]);
1769
1799
  var corsMiddleware = () => async (c, next) => {
1770
- c.res.headers.set("Access-Control-Allow-Origin", "*");
1771
- c.res.headers.set("Access-Control-Allow-Methods", "GET, POST, PATCH, OPTIONS");
1772
- c.res.headers.set("Access-Control-Allow-Headers", "Authorization, Content-Type, If-None-Match");
1773
- c.res.headers.set("Access-Control-Expose-Headers", "ETag");
1800
+ const origin = c.req.header("origin");
1801
+ const allowedOrigin = origin && ALLOWED_ORIGINS.has(origin) ? origin : null;
1802
+ if (allowedOrigin) {
1803
+ c.res.headers.set("Access-Control-Allow-Origin", allowedOrigin);
1804
+ c.res.headers.set("Vary", "Origin");
1805
+ c.res.headers.set("Access-Control-Allow-Methods", "GET, POST, PATCH, OPTIONS");
1806
+ c.res.headers.set("Access-Control-Allow-Headers", "Authorization, Content-Type, If-None-Match");
1807
+ c.res.headers.set("Access-Control-Expose-Headers", "ETag");
1808
+ }
1774
1809
  if (c.req.method === "OPTIONS") {
1775
- return c.newResponse(null, 204);
1810
+ return c.newResponse(null, allowedOrigin ? 204 : 403);
1776
1811
  }
1777
1812
  await next();
1778
1813
  };
@@ -1959,6 +1994,19 @@ var createMiscRoutes = (deps) => {
1959
1994
  });
1960
1995
  });
1961
1996
  app.get("/api/profiles", (c) => c.json([]));
1997
+ app.post("/api/auth/rotate", (c) => {
1998
+ if (deps.localNoAuth) {
1999
+ return c.json({ error: "key rotation is disabled while localNoAuth is active" }, 403);
2000
+ }
2001
+ const { newKey, persisted } = deps.rotateApiKey();
2002
+ return c.json({
2003
+ apiKey: newKey,
2004
+ persisted,
2005
+ ...persisted ? {} : {
2006
+ 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."
2007
+ }
2008
+ });
2009
+ });
1962
2010
  app.post("/api/push/register", (c) => c.json({ ok: true }));
1963
2011
  app.post("/api/__update", async (c) => {
1964
2012
  const cfg = loadUpdateConfig();
@@ -2032,6 +2080,11 @@ var ALREADY_HANDLED4 = 597;
2032
2080
  var alreadyHandled4 = () => new Response(null, { status: ALREADY_HANDLED4 });
2033
2081
  var createProjectRoutes = (deps) => {
2034
2082
  const app = new Hono7();
2083
+ app.get("/", (c) => {
2084
+ const url = new URL(c.req.url);
2085
+ deps.handleListProjects(url, c.env.outgoing);
2086
+ return alreadyHandled4();
2087
+ });
2035
2088
  app.get("/popular", (c) => {
2036
2089
  const url = new URL(c.req.url);
2037
2090
  deps.handleGetPopularProjects(url, c.env.outgoing);
@@ -2051,11 +2104,6 @@ var createScannerRoutes = (deps) => {
2051
2104
  await deps.handleSearch(url, c.env.outgoing);
2052
2105
  return alreadyHandled5();
2053
2106
  });
2054
- app.get("/project-chats", async (c) => {
2055
- const url = new URL(c.req.url);
2056
- await deps.handleListProjectChats(url, c.env.outgoing);
2057
- return alreadyHandled5();
2058
- });
2059
2107
  return app;
2060
2108
  };
2061
2109
 
@@ -3227,297 +3275,39 @@ async function recordUpload(pool2, instanceId, row) {
3227
3275
  );
3228
3276
  }
3229
3277
 
3230
- // src/schemas/queryParams.schema.ts
3231
- import { z as z2 } from "zod";
3232
- var ListProjectChatsQuerySchema = z2.object({
3233
- refresh: z2.enum(["1"]).optional(),
3234
- refreshConversations: z2.enum(["1"]).optional()
3235
- });
3236
-
3237
- // src/services/cache/cacheMetadata.ts
3238
- function getCacheMetadata(repo, key) {
3239
- return repo.getCacheMetadata(key);
3240
- }
3241
- function setCacheMetadata(repo, key, value) {
3242
- repo.setCacheMetadata(key, value);
3243
- }
3244
-
3245
- // src/utils/dates.ts
3246
- import { compareDesc, isValid, parseISO } from "date-fns";
3247
- function parseIsoDateOrNull(value) {
3248
- if (!value) return null;
3249
- const parsed = parseISO(value);
3250
- return isValid(parsed) ? parsed : null;
3251
- }
3252
- function compareIsoDesc(a, b) {
3253
- const dateA = parseIsoDateOrNull(a);
3254
- const dateB = parseIsoDateOrNull(b);
3255
- if (!dateA && !dateB) return 0;
3256
- if (!dateA) return 1;
3257
- if (!dateB) return -1;
3258
- return compareDesc(dateA, dateB);
3259
- }
3260
-
3261
- // src/services/projects/ensureProjectsForConversations.ts
3262
- function ensureProjectsForConversations(repo, conversations) {
3263
- const conversationsByPath = /* @__PURE__ */ new Map();
3264
- for (const conversation of conversations) {
3265
- if (!conversation.projectPath) continue;
3266
- const canonical = canonicalizeProjectPath(conversation.projectPath);
3267
- if (!canonical) continue;
3268
- const existing = conversationsByPath.get(canonical) ?? [];
3269
- existing.push(conversation);
3270
- conversationsByPath.set(canonical, existing);
3271
- }
3272
- const pathToProjectId = /* @__PURE__ */ new Map();
3273
- for (const [path, projectConversations] of conversationsByPath) {
3274
- const latest = pickLatestConversation(projectConversations);
3275
- const project = repo.upsertProjectByPath(path, {
3276
- lastConversationId: latest?.id ?? null,
3277
- lastConversationCreatedAt: latest?.createdAt ?? null,
3278
- latestMessageAt: latest?.latestMessageAt ?? null
3279
- });
3280
- pathToProjectId.set(path, project.id);
3281
- }
3282
- return pathToProjectId;
3283
- }
3284
- function pickLatestConversation(conversations) {
3285
- if (conversations.length === 0) return void 0;
3286
- return [...conversations].sort((a, b) => {
3287
- const cmp = compareIsoDesc(a.latestMessageAt ?? null, b.latestMessageAt ?? null);
3288
- if (cmp !== 0) return cmp;
3289
- return compareIsoDesc(a.createdAt ?? null, b.createdAt ?? null);
3290
- })[0];
3291
- }
3292
-
3293
- // src/services/conversations/refreshConversationCache.ts
3294
- function refreshConversationCache(deps) {
3295
- const { projectsRepo, conversationsRepo, cacheMetadataRepo } = deps;
3296
- const conversations = conversationsRepo.listConversationsForProjectBackfill();
3297
- const pathToProjectId = ensureProjectsForConversations(
3298
- projectsRepo,
3299
- conversations.map((c) => ({
3300
- id: c.id,
3301
- projectPath: c.projectPath,
3302
- latestMessageAt: c.lastActivity ?? null,
3303
- createdAt: c.lastActivity ?? null
3304
- }))
3305
- );
3306
- let conversationsBackfilled = 0;
3307
- for (const conversation of conversations) {
3308
- if (!conversation.projectPath) continue;
3309
- if (conversation.projectId) continue;
3310
- const projectId = pathToProjectId.get(canonicalizeProjectPath(conversation.projectPath));
3311
- if (!projectId) continue;
3312
- conversationsRepo.updateConversationProjectId({
3313
- conversationId: conversation.id,
3314
- projectId
3315
- });
3316
- conversationsBackfilled += 1;
3317
- }
3318
- const latest = conversationsRepo.getLatestConversation();
3319
- if (latest) {
3320
- setCacheMetadata(cacheMetadataRepo, "last_conversation_id", latest.id);
3321
- if (latest.lastActivity) {
3322
- setCacheMetadata(cacheMetadataRepo, "last_conversation_created_at", latest.lastActivity);
3323
- }
3324
- }
3325
- setCacheMetadata(cacheMetadataRepo, "conversations_last_indexed_at", (/* @__PURE__ */ new Date()).toISOString());
3326
- return {
3327
- projectsTouched: pathToProjectId.size,
3328
- conversationsBackfilled,
3329
- latestConversationId: latest?.id ?? null
3330
- };
3331
- }
3332
-
3333
- // src/services/conversations/shouldRefreshProjectsFromHdd.ts
3334
- import { statSync as statSync3 } from "fs";
3278
+ // src/handlers/handleListProjects.ts
3279
+ import { readdirSync as readdirSync3, statSync as statSync3 } from "fs";
3335
3280
  import { homedir as homedir4 } from "os";
3336
3281
  import { join as join10 } from "path";
3337
- var DEFAULT_PROJECTS_DIR = join10(homedir4(), ".claude", "projects");
3338
- function shouldRefreshProjectsFromHdd(conversationsRepo, cacheMetadataRepo, opts = {}) {
3339
- if (conversationsRepo.hasOrphanRows()) return true;
3340
- const projectsDir = opts.projectsDir ?? DEFAULT_PROJECTS_DIR;
3341
- let dirMtimeMs;
3282
+ function decodeProjectPath(dirName) {
3283
+ return dirName.replace(/-/g, "/");
3284
+ }
3285
+ function handleListProjects(url, res) {
3286
+ const limit = Math.max(1, parseInt(url.searchParams.get("limit") ?? "50", 10) || 50);
3287
+ const offset = Math.max(0, parseInt(url.searchParams.get("offset") ?? "0", 10) || 0);
3288
+ const projectsDir = join10(homedir4(), ".claude", "projects");
3289
+ let entries;
3342
3290
  try {
3343
- dirMtimeMs = statSync3(projectsDir).mtimeMs;
3291
+ entries = readdirSync3(projectsDir).map((dirName) => {
3292
+ const fullPath = join10(projectsDir, dirName);
3293
+ let mtime = 0;
3294
+ try {
3295
+ mtime = statSync3(fullPath).mtimeMs;
3296
+ } catch {
3297
+ }
3298
+ const path = decodeProjectPath(dirName);
3299
+ const name = path.split("/").filter(Boolean).pop() ?? dirName;
3300
+ return { name, path, dirName, mtime };
3301
+ }).sort((a, b) => b.mtime - a.mtime);
3344
3302
  } catch {
3345
- return false;
3346
- }
3347
- const lastIndexedIso = getCacheMetadata(cacheMetadataRepo, "conversations_last_indexed_at");
3348
- if (!lastIndexedIso) return true;
3349
- const lastIndexedMs = Date.parse(lastIndexedIso);
3350
- if (Number.isNaN(lastIndexedMs)) return true;
3351
- return dirMtimeMs > lastIndexedMs;
3352
- }
3353
-
3354
- // src/services/sessions/ensureSessionProjectIdsFromExistingProjects.ts
3355
- function ensureSessionProjectIdsFromExistingProjects(projectsRepo, sessionsRepo) {
3356
- let linked = 0;
3357
- let missing = 0;
3358
- for (const session of sessionsRepo.listManagedSessions()) {
3359
- if (session.projectId) continue;
3360
- const canonical = canonicalizeProjectPath(session.projectPath);
3361
- if (!canonical) continue;
3362
- const project = projectsRepo.getProjectByPath(canonical);
3363
- if (!project) {
3364
- missing += 1;
3365
- continue;
3366
- }
3367
- sessionsRepo.updateSessionProjectId({ sessionId: session.id, projectId: project.id });
3368
- linked += 1;
3369
- }
3370
- return { linked, missing };
3371
- }
3372
-
3373
- // src/services/projectChats/sortProjectChats.ts
3374
- function sortProjectChats(a, b) {
3375
- const byLatest = compareIsoDesc(a.latestMessageAt, b.latestMessageAt);
3376
- if (byLatest !== 0) return byLatest;
3377
- const byUpdated = compareIsoDesc(a.updatedAt ?? null, b.updatedAt ?? null);
3378
- if (byUpdated !== 0) return byUpdated;
3379
- const byCreated = compareIsoDesc(a.createdAt ?? null, b.createdAt ?? null);
3380
- if (byCreated !== 0) return byCreated;
3381
- return a.title.localeCompare(b.title);
3382
- }
3383
-
3384
- // src/services/projectChats/mergeProjectChats.ts
3385
- function mergeProjectChats(args) {
3386
- const { sessions, conversations } = args;
3387
- const resumedConversationIds = new Set(
3388
- sessions.filter((c) => c.type === "session").map((c) => c.resumedFromConversationId).filter((id) => Boolean(id))
3389
- );
3390
- const visibleConversations = conversations.filter((c) => {
3391
- if (c.type !== "conversation") return true;
3392
- return !resumedConversationIds.has(c.id);
3393
- });
3394
- return [...sessions, ...visibleConversations].sort(sortProjectChats);
3395
- }
3396
-
3397
- // src/services/projectChats/deriveProjectChatTitle.ts
3398
- function deriveProjectChatTitle(input) {
3399
- const trimmed = input.title?.trim();
3400
- if (trimmed) return trimmed;
3401
- const name = input.projectName?.trim();
3402
- if (name) return name;
3403
- const pathSuffix = input.projectPath ? input.projectPath.split(/[/\\]/).filter(Boolean).slice(-2).join("/") : "";
3404
- if (pathSuffix) return pathSuffix;
3405
- return `Untitled \xB7 ${input.id.slice(0, 8)}`;
3406
- }
3407
-
3408
- // src/services/projectChats/normalizeConversationToProjectChat.ts
3409
- function normalizeConversationToProjectChat(conversation) {
3410
- if (!conversation.projectId) {
3411
- throw new Error(
3412
- `normalizeConversationToProjectChat: conversation ${conversation.id} has no projectId \u2014 resolve it before normalizing`
3413
- );
3414
- }
3415
- return {
3416
- type: "conversation",
3417
- id: conversation.id,
3418
- projectId: conversation.projectId,
3419
- projectPath: conversation.projectPath ?? null,
3420
- title: deriveProjectChatTitle({
3421
- title: conversation.title,
3422
- projectName: conversation.projectName,
3423
- projectPath: conversation.projectPath,
3424
- id: conversation.id
3425
- }),
3426
- latestMessageAt: conversation.lastActivity ?? null,
3427
- updatedAt: conversation.lastActivity ?? null,
3428
- createdAt: null,
3429
- status: "resumable",
3430
- source: "hdd-cache",
3431
- provider: conversation.provider ?? CLAUDE_CODE_PROVIDER,
3432
- indexedAt: null,
3433
- fileMtime: null,
3434
- filePath: conversation.filePath ?? null,
3435
- sourceHash: null
3436
- };
3437
- }
3438
-
3439
- // src/services/projectChats/normalizeSessionToProjectChat.ts
3440
- function normalizeSessionToProjectChat(session) {
3441
- if (!session.projectId) {
3442
- throw new Error(
3443
- `normalizeSessionToProjectChat: session ${session.id} has no projectId \u2014 resolve it before normalizing`
3444
- );
3445
- }
3446
- return {
3447
- type: "session",
3448
- id: session.id,
3449
- projectId: session.projectId,
3450
- projectPath: session.projectPath ?? null,
3451
- title: deriveProjectChatTitle({
3452
- title: session.sessionName,
3453
- projectName: session.projectName,
3454
- projectPath: session.projectPath,
3455
- id: session.id
3456
- }),
3457
- latestMessageAt: session.lastMessageAt ?? session.lastActivityAt ?? null,
3458
- updatedAt: session.lastActivityAt ?? null,
3459
- createdAt: session.startedAt ?? null,
3460
- status: "active",
3461
- source: "session-store",
3462
- resumedFromConversationId: session.resumedFromConversationId ?? null
3463
- };
3464
- }
3465
-
3466
- // src/services/projectChats/listProjectChats.ts
3467
- async function listProjectChats(deps, args) {
3468
- const {
3469
- cache,
3470
- projectsRepo,
3471
- conversationsRepo,
3472
- sessionsRepo,
3473
- cacheMetadataRepo,
3474
- getSessionResponses,
3475
- getFreshScanner,
3476
- projectsDir
3477
- } = deps;
3478
- const needsRefresh = args.refreshConversations || shouldRefreshProjectsFromHdd(conversationsRepo, cacheMetadataRepo, { projectsDir });
3479
- if (needsRefresh) {
3480
- const scanner = await getFreshScanner();
3481
- const metas = [...scanner.getMetadataCache().values()];
3482
- if (metas.length > 0) {
3483
- cache.upsertFromScannerMeta(metas);
3484
- }
3485
- refreshConversationCache({ cache, projectsRepo, conversationsRepo, cacheMetadataRepo });
3486
- }
3487
- ensureSessionProjectIdsFromExistingProjects(projectsRepo, sessionsRepo);
3488
- const sessionResponses = getSessionResponses();
3489
- const sessionChats = [];
3490
- for (const s of sessionResponses) {
3491
- if (!s.projectId) continue;
3492
- sessionChats.push(normalizeSessionToProjectChat(s));
3493
- }
3494
- const conversationChats = [];
3495
- const { conversations } = cache.listConversations({ limit: 1e3, offset: 0 });
3496
- for (const c of conversations) {
3497
- if (!c.projectId) continue;
3498
- conversationChats.push(normalizeConversationToProjectChat(c));
3499
- }
3500
- return mergeProjectChats({ sessions: sessionChats, conversations: conversationChats });
3501
- }
3502
-
3503
- // src/handlers/handleListProjectChats.ts
3504
- async function handleListProjectChats(url, res, deps) {
3505
- const queryObj = Object.fromEntries(url.searchParams.entries());
3506
- const parsed = ListProjectChatsQuerySchema.safeParse(queryObj);
3507
- if (!parsed.success) {
3508
- res.writeHead(400, { "Content-Type": "application/json" });
3509
- res.end(
3510
- JSON.stringify({
3511
- error: "Invalid query parameters",
3512
- details: parsed.error.flatten()
3513
- })
3514
- );
3303
+ res.writeHead(200, { "Content-Type": "application/json" });
3304
+ res.end(JSON.stringify({ projects: [], total: 0 }));
3515
3305
  return;
3516
3306
  }
3517
- const refreshConversations = parsed.data.refreshConversations === "1" || parsed.data.refresh === "1";
3518
- const chats = await listProjectChats(deps, { refreshConversations });
3307
+ const total = entries.length;
3308
+ const page = entries.slice(offset, offset + limit).map(({ name, path, dirName }) => ({ name, path, dirName }));
3519
3309
  res.writeHead(200, { "Content-Type": "application/json" });
3520
- res.end(JSON.stringify({ projectChats: chats }));
3310
+ res.end(JSON.stringify({ projects: page, total }));
3521
3311
  }
3522
3312
 
3523
3313
  // src/pair-store.ts
@@ -3746,6 +3536,17 @@ function pruneAgentConversations(cache) {
3746
3536
  return { scanned: rows.length, pruned, missing };
3747
3537
  }
3748
3538
 
3539
+ // src/services/projectChats/deriveProjectChatTitle.ts
3540
+ function deriveProjectChatTitle(input) {
3541
+ const trimmed = input.title?.trim();
3542
+ if (trimmed) return trimmed;
3543
+ const name = input.projectName?.trim();
3544
+ if (name) return name;
3545
+ const pathSuffix = input.projectPath ? input.projectPath.split(/[/\\]/).filter(Boolean).slice(-2).join("/") : "";
3546
+ if (pathSuffix) return pathSuffix;
3547
+ return `Untitled \xB7 ${input.id.slice(0, 8)}`;
3548
+ }
3549
+
3749
3550
  // src/services/questions/detectAskUserQuestion.ts
3750
3551
  function normalizeContent2(raw) {
3751
3552
  if (Array.isArray(raw)) return raw;
@@ -4149,6 +3950,14 @@ function debounce(fn, waitMs) {
4149
3950
  return debounced;
4150
3951
  }
4151
3952
 
3953
+ // src/utils/dates.ts
3954
+ import { compareDesc, isValid, parseISO } from "date-fns";
3955
+ function parseIsoDateOrNull(value) {
3956
+ if (!value) return null;
3957
+ const parsed = parseISO(value);
3958
+ return isValid(parsed) ? parsed : null;
3959
+ }
3960
+
4152
3961
  // src/utils/isScannedSnapshotStale.ts
4153
3962
  var STALENESS_TOLERANCE_MS = 1e3;
4154
3963
  function isScannedSnapshotStale(snapshotTimestamp, fileMtimeMs) {
@@ -4285,6 +4094,7 @@ var WSHub = class {
4285
4094
 
4286
4095
  // src/server.ts
4287
4096
  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.`;
4097
+ var DEFAULT_SYSTEM_PROMPT = "When presenting options or choices to the user, limit the options to at most 3.";
4288
4098
  var DEFAULT_PTY_GRACE_PERIOD_MS = 27e4;
4289
4099
  function parseIncludeAgentsEnv(raw) {
4290
4100
  if (raw === void 0) return false;
@@ -4323,6 +4133,7 @@ var StreamerServer = class {
4323
4133
  binding = false;
4324
4134
  cacheReady = false;
4325
4135
  apiKey;
4136
+ apiKeySource;
4326
4137
  localNoAuth;
4327
4138
  logMenubarRequests;
4328
4139
  verbose;
@@ -4334,7 +4145,10 @@ var StreamerServer = class {
4334
4145
  publicUrl = null;
4335
4146
  pairTokens = new PairTokenStore();
4336
4147
  exchangeAttempts = /* @__PURE__ */ new Map();
4148
+ sessionStartAttempts = /* @__PURE__ */ new Map();
4149
+ sessionInputAttempts = /* @__PURE__ */ new Map();
4337
4150
  ptyGracePeriodMs;
4151
+ defaultSystemPrompt;
4338
4152
  // Map of sessionId → grace timer; fires to kill PTY after WS disconnect
4339
4153
  ptyGraceTimers = /* @__PURE__ */ new Map();
4340
4154
  // Map of sessionId → set of subscribed WS clients
@@ -4368,13 +4182,20 @@ var StreamerServer = class {
4368
4182
  constructor(config) {
4369
4183
  this.sessionStatusBus.setMaxListeners(0);
4370
4184
  this.apiKey = config.apiKey;
4185
+ this.apiKeySource = config.apiKeySource ?? "config";
4371
4186
  this.localNoAuth = config.localNoAuth ?? false;
4372
4187
  this.logMenubarRequests = config.logMenubarRequests ?? false;
4188
+ if (this.localNoAuth) {
4189
+ console.warn(
4190
+ "[WARN] localNoAuth is ENABLED \u2014 all requests from localhost bypass authentication. Do not run with --local-no-auth in shared or production environments."
4191
+ );
4192
+ }
4373
4193
  this.verbose = config.verbose ?? false;
4374
4194
  this.disableDb = config.disableDb ?? false;
4375
4195
  this.scanProfiles = config.scanProfiles;
4376
4196
  this.codexRoots = config.codexRoots ?? [join12(homedir5(), ".codex", "sessions")];
4377
4197
  this.ptyGracePeriodMs = config.ptyGracePeriodMs ?? DEFAULT_PTY_GRACE_PERIOD_MS;
4198
+ this.defaultSystemPrompt = config.defaultSystemPrompt ?? DEFAULT_SYSTEM_PROMPT;
4378
4199
  this.cacheDir = config.cacheDir ?? loadCacheDir() ?? join12(homedir5(), ".threadbase", "cache");
4379
4200
  this.tailSize = config.tailSize ?? loadTailSize() ?? 10;
4380
4201
  this.directoryDebounceMs = parseDirScanDebounceEnv(process.env.THREADBASE_DIR_SCAN_DEBOUNCE_MS) ?? config.directoryScanDebounceMs ?? 1e3;
@@ -4546,10 +4367,15 @@ var StreamerServer = class {
4546
4367
  });
4547
4368
  }
4548
4369
  const agentClient = this.agentClient;
4370
+ const self = this;
4549
4371
  const apiDeps = {
4550
- apiKey: this.apiKey,
4372
+ // ponytail: getter so rotateApiKey() takes effect without restarting the server
4373
+ get apiKey() {
4374
+ return self.apiKey;
4375
+ },
4551
4376
  localNoAuth: this.localNoAuth,
4552
4377
  logMenubarRequests: this.logMenubarRequests,
4378
+ rotateApiKey: () => this.rotateApiKey(),
4553
4379
  publicUrl: this.publicUrl,
4554
4380
  browseRoot: this.browseRoot,
4555
4381
  ptyManager: this.ptyManager,
@@ -4580,8 +4406,8 @@ var StreamerServer = class {
4580
4406
  handleConversationsCount: (url, res) => this.handleConversationsCount(url, res),
4581
4407
  handleGetConversation: (id, url, res, ifNoneMatch) => this.handleGetConversation(id, url, res, ifNoneMatch),
4582
4408
  handleSearch: (url, res) => this.handleSearch(url, res),
4409
+ handleListProjects: (url, res) => handleListProjects(url, res),
4583
4410
  handleGetPopularProjects: (url, res) => this.handleGetPopularProjects(url, res),
4584
- handleListProjectChats: (url, res) => this.handleListProjectChats(url, res),
4585
4411
  handlePairStart: (res) => this.handlePairStart(res),
4586
4412
  handlePairExchange: (req, res) => this.handlePairExchange(req, res),
4587
4413
  handleBrowse: (url, res) => this.handleBrowse(url, res),
@@ -5016,19 +4842,38 @@ var StreamerServer = class {
5016
4842
  machineName: hostname2()
5017
4843
  });
5018
4844
  }
5019
- checkExchangeRateLimit(ip) {
4845
+ rotateApiKey() {
4846
+ const newKey = generateApiKey();
4847
+ const persisted = this.apiKeySource === "config";
4848
+ if (persisted) setApiKey(newKey);
4849
+ this.apiKey = newKey;
4850
+ return { newKey, persisted };
4851
+ }
4852
+ checkRateLimit(map, key, limit, windowMs) {
5020
4853
  const now = Date.now();
5021
- const windowMs = 6e4;
5022
- const limit = 5;
5023
- const arr = (this.exchangeAttempts.get(ip) ?? []).filter((t) => now - t < windowMs);
4854
+ const arr = (map.get(key) ?? []).filter((t) => now - t < windowMs);
5024
4855
  if (arr.length >= limit) {
5025
- this.exchangeAttempts.set(ip, arr);
4856
+ map.set(key, arr);
5026
4857
  return false;
5027
4858
  }
5028
4859
  arr.push(now);
5029
- this.exchangeAttempts.set(ip, arr);
4860
+ map.set(key, arr);
4861
+ setTimeout(() => {
4862
+ const remaining = (map.get(key) ?? []).filter((t) => Date.now() - t < windowMs);
4863
+ if (remaining.length === 0) map.delete(key);
4864
+ else map.set(key, remaining);
4865
+ }, windowMs);
5030
4866
  return true;
5031
4867
  }
4868
+ checkExchangeRateLimit(ip) {
4869
+ return this.checkRateLimit(this.exchangeAttempts, ip, 5, 6e4);
4870
+ }
4871
+ checkSessionStartRateLimit(ip) {
4872
+ return this.checkRateLimit(this.sessionStartAttempts, ip, 10, 6e4);
4873
+ }
4874
+ checkSessionInputRateLimit(sessionId) {
4875
+ return this.checkRateLimit(this.sessionInputAttempts, sessionId, 500, 6e4);
4876
+ }
5032
4877
  async handleListConversations(url, res) {
5033
4878
  const limit = intParam(url, "limit", 50);
5034
4879
  const offset = intParam(url, "offset", 0);
@@ -5190,21 +5035,6 @@ var StreamerServer = class {
5190
5035
  const projects = this.cache.getPopularProjects(limit);
5191
5036
  json(res, 200, { projects, total: projects.length });
5192
5037
  }
5193
- async handleListProjectChats(url, res) {
5194
- if (!this.cache || !this.projectsRepo || !this.conversationsRepo || !this.sessionsRepo || !this.cacheMetadataRepo) {
5195
- json(res, 503, { error: "Cache not available" });
5196
- return;
5197
- }
5198
- await handleListProjectChats(url, res, {
5199
- cache: this.cache,
5200
- projectsRepo: this.projectsRepo,
5201
- conversationsRepo: this.conversationsRepo,
5202
- sessionsRepo: this.sessionsRepo,
5203
- cacheMetadataRepo: this.cacheMetadataRepo,
5204
- getSessionResponses: () => this.sessionStore.list(this.ptyAttachedIds()),
5205
- getFreshScanner: () => this.getFreshScanner()
5206
- });
5207
- }
5208
5038
  buildStatCache(previousScanner) {
5209
5039
  if (!this.cache) return void 0;
5210
5040
  const dbStats = this.cache.getFileStats();
@@ -5273,12 +5103,12 @@ var StreamerServer = class {
5273
5103
  const projectsDir = join12(homedir5(), ".claude", "projects");
5274
5104
  if (!existsSync6(projectsDir)) return null;
5275
5105
  const filename = `${uuid}.jsonl`;
5276
- for (const dir of readdirSync3(projectsDir)) {
5106
+ for (const dir of readdirSync4(projectsDir)) {
5277
5107
  const fp = join12(projectsDir, dir, filename);
5278
5108
  if (existsSync6(fp)) return fp;
5279
5109
  const projectDir = join12(projectsDir, dir);
5280
5110
  try {
5281
- for (const sub of readdirSync3(projectDir)) {
5111
+ for (const sub of readdirSync4(projectDir)) {
5282
5112
  const subagentPath = join12(projectDir, sub, "subagents", filename);
5283
5113
  if (existsSync6(subagentPath)) return subagentPath;
5284
5114
  }
@@ -5681,6 +5511,10 @@ var StreamerServer = class {
5681
5511
  }
5682
5512
  }
5683
5513
  async handleSendInput(sessionId, req, res) {
5514
+ if (!this.checkSessionInputRateLimit(sessionId)) {
5515
+ json(res, 429, { error: "Too many input requests for this session. Please slow down." });
5516
+ return;
5517
+ }
5684
5518
  if (this.agentConfig.enabled) {
5685
5519
  const body2 = await readBody(req);
5686
5520
  const cache = this.cache;
@@ -5962,6 +5796,13 @@ var StreamerServer = class {
5962
5796
  json(res, 201, { sessionId: session.id });
5963
5797
  }
5964
5798
  async handleStartSession(req, res) {
5799
+ const ip = req.socket?.remoteAddress ?? "unknown";
5800
+ if (!this.checkSessionStartRateLimit(ip)) {
5801
+ json(res, 429, {
5802
+ error: "Too many session start requests. Please wait before trying again."
5803
+ });
5804
+ return;
5805
+ }
5965
5806
  if (this.agentConfig.enabled) {
5966
5807
  const body2 = await readBody(req);
5967
5808
  const result = await handleStartAgentSession(body2, {
@@ -5985,7 +5826,7 @@ var StreamerServer = class {
5985
5826
  return;
5986
5827
  }
5987
5828
  const body = await readBody(req);
5988
- const { path: relativePath } = body;
5829
+ const { path: relativePath, systemPrompt: clientPrompt } = body;
5989
5830
  if (typeof relativePath !== "string") {
5990
5831
  json(res, 400, { error: "Missing path field" });
5991
5832
  return;
@@ -5999,11 +5840,16 @@ var StreamerServer = class {
5999
5840
  return;
6000
5841
  }
6001
5842
  this.discoveryCache = null;
5843
+ const systemPromptParts = [
5844
+ this.defaultSystemPrompt,
5845
+ BROWSE_SYSTEM_PROMPT(this.browseRoot),
5846
+ typeof clientPrompt === "string" ? clientPrompt : null
5847
+ ].filter(Boolean);
6002
5848
  try {
6003
5849
  const session = await this.ptyManager.startFresh({
6004
5850
  projectPath: resolvedPath,
6005
5851
  projectName: body.projectName,
6006
- systemPrompt: BROWSE_SYSTEM_PROMPT(this.browseRoot)
5852
+ systemPrompt: systemPromptParts.join("\n")
6007
5853
  });
6008
5854
  this.sessionStore.addManaged(session);
6009
5855
  json(res, 202, { id: session.id, status: "pending" });
@@ -6092,7 +5938,7 @@ var StreamerServer = class {
6092
5938
  if (!resolvedFilePath && existsSync6(projectsDir)) {
6093
5939
  try {
6094
5940
  const now = Date.now();
6095
- const recent = readdirSync3(projectsDir).filter((f) => f.endsWith(".jsonl")).map((f) => ({ f, mtime: statSync5(join12(projectsDir, f)).mtimeMs })).filter(({ mtime }) => now - mtime < 5e3).sort((a, b) => b.mtime - a.mtime)[0];
5941
+ const recent = readdirSync4(projectsDir).filter((f) => f.endsWith(".jsonl")).map((f) => ({ f, mtime: statSync5(join12(projectsDir, f)).mtimeMs })).filter(({ mtime }) => now - mtime < 5e3).sort((a, b) => b.mtime - a.mtime)[0];
6096
5942
  if (recent) resolvedFilePath = join12(projectsDir, recent.f);
6097
5943
  } catch {
6098
5944
  }