@tstax/coding-tab 0.1.2 → 0.2.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/server.cjs CHANGED
@@ -30,6 +30,8 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
30
30
  // src/server/index.ts
31
31
  var server_exports = {};
32
32
  __export(server_exports, {
33
+ FileChatStorage: () => FileChatStorage,
34
+ createDefaultStorage: () => createDefaultStorage,
33
35
  mountCodingTab: () => mountCodingTab
34
36
  });
35
37
  module.exports = __toCommonJS(server_exports);
@@ -39,7 +41,7 @@ var getImportMetaUrl = () => typeof document === "undefined" ? new URL(`file:${_
39
41
  var importMetaUrl = /* @__PURE__ */ getImportMetaUrl();
40
42
 
41
43
  // src/server/index.ts
42
- var import_express5 = __toESM(require("express"), 1);
44
+ var import_express6 = __toESM(require("express"), 1);
43
45
 
44
46
  // src/server/authRoutes.ts
45
47
  var import_express = require("express");
@@ -203,6 +205,7 @@ function makeAuthRouter(opts) {
203
205
  // src/server/agentRoutes.ts
204
206
  var import_express2 = require("express");
205
207
  var import_sdk2 = require("@cursor/sdk");
208
+ var import_node_crypto3 = require("crypto");
206
209
 
207
210
  // src/server/models.ts
208
211
  var import_sdk = require("@cursor/sdk");
@@ -243,7 +246,6 @@ async function resolveModel(apiKey, choice) {
243
246
  }
244
247
 
245
248
  // src/server/sessions.ts
246
- var import_node_crypto2 = require("crypto");
247
249
  var SESSION_IDLE_TTL_MS = 30 * 60 * 1e3;
248
250
  var sessions = /* @__PURE__ */ new Map();
249
251
  var sweeperStarted = false;
@@ -254,35 +256,41 @@ function startSweeper() {
254
256
  const now = Date.now();
255
257
  for (const [id, s] of sessions) {
256
258
  if (now - s.lastUsedAt > SESSION_IDLE_TTL_MS) {
257
- disposeSession(id).catch((e) => console.error("[coding-tab] session sweep dispose failed", e));
259
+ disposeSessionsForChat(id).catch(
260
+ (e) => console.error("[coding-tab] session sweep dispose failed", e)
261
+ );
258
262
  }
259
263
  }
260
264
  }, 5 * 60 * 1e3).unref?.();
261
265
  }
262
- function registerSession(opts) {
266
+ function registerLiveSession(opts) {
263
267
  startSweeper();
264
- const id = (0, import_node_crypto2.randomUUID)();
268
+ const prev = sessions.get(opts.chatId);
269
+ if (prev) {
270
+ sessions.delete(opts.chatId);
271
+ prev.agent[Symbol.asyncDispose]().catch(() => {
272
+ });
273
+ }
265
274
  const session = {
266
- id,
275
+ chatId: opts.chatId,
267
276
  githubLogin: opts.githubLogin,
268
277
  agent: opts.agent,
269
- repoUrl: opts.repoUrl,
270
- startingRef: opts.startingRef,
278
+ agentId: opts.agentId,
271
279
  createdAt: Date.now(),
272
280
  lastUsedAt: Date.now()
273
281
  };
274
- sessions.set(id, session);
282
+ sessions.set(opts.chatId, session);
275
283
  return session;
276
284
  }
277
- function getSession2(id) {
278
- const s = sessions.get(id);
285
+ function getLiveSession(chatId) {
286
+ const s = sessions.get(chatId);
279
287
  if (s) s.lastUsedAt = Date.now();
280
288
  return s;
281
289
  }
282
- async function disposeSession(id) {
283
- const s = sessions.get(id);
290
+ async function disposeSessionsForChat(chatId) {
291
+ const s = sessions.get(chatId);
284
292
  if (!s) return;
285
- sessions.delete(id);
293
+ sessions.delete(chatId);
286
294
  try {
287
295
  await s.agent[Symbol.asyncDispose]();
288
296
  } catch (err) {
@@ -290,6 +298,235 @@ async function disposeSession(id) {
290
298
  }
291
299
  }
292
300
 
301
+ // src/server/storage.ts
302
+ var import_promises = require("fs/promises");
303
+ var import_node_path = require("path");
304
+ var import_node_crypto2 = require("crypto");
305
+ function safeId(s) {
306
+ return s.replace(/[^a-zA-Z0-9_.-]/g, "_");
307
+ }
308
+ async function ensureDir(p) {
309
+ await (0, import_promises.mkdir)(p, { recursive: true });
310
+ }
311
+ async function readJson(path) {
312
+ try {
313
+ const raw = await (0, import_promises.readFile)(path, "utf8");
314
+ return JSON.parse(raw);
315
+ } catch (err) {
316
+ if (err.code === "ENOENT") return null;
317
+ throw err;
318
+ }
319
+ }
320
+ async function writeJsonAtomic(path, data) {
321
+ await ensureDir((0, import_node_path.dirname)(path));
322
+ const tmp = `${path}.${(0, import_node_crypto2.randomUUID)()}.tmp`;
323
+ await (0, import_promises.writeFile)(tmp, JSON.stringify(data, null, 2), "utf8");
324
+ await (0, import_promises.rename)(tmp, path);
325
+ }
326
+ var MAX_BLOB_BYTES = 32 * 1024;
327
+ function truncateBlob(value) {
328
+ if (value === void 0 || value === null) return value;
329
+ try {
330
+ const str = typeof value === "string" ? value : JSON.stringify(value);
331
+ if (str.length <= MAX_BLOB_BYTES) return value;
332
+ const head = str.slice(0, MAX_BLOB_BYTES);
333
+ return `${head}
334
+
335
+ \u2026[truncated ${str.length - MAX_BLOB_BYTES} chars]`;
336
+ } catch {
337
+ return "[unserializable]";
338
+ }
339
+ }
340
+ function sanitizeEvent(evt) {
341
+ if (evt.kind === "tool") {
342
+ return {
343
+ ...evt,
344
+ args: truncateBlob(evt.args),
345
+ result: truncateBlob(evt.result)
346
+ };
347
+ }
348
+ return evt;
349
+ }
350
+ var FileChatStorage = class {
351
+ dataDir;
352
+ /** Per-chat write mutex chain to keep concurrent appends consistent. */
353
+ chains = /* @__PURE__ */ new Map();
354
+ constructor(opts) {
355
+ this.dataDir = (0, import_node_path.resolve)(opts.dataDir);
356
+ }
357
+ chatPath(id) {
358
+ return (0, import_node_path.join)(this.dataDir, "chats", `${safeId(id)}.json`);
359
+ }
360
+ indexPath(login) {
361
+ return (0, import_node_path.join)(this.dataDir, "index", `${safeId(login.toLowerCase())}.json`);
362
+ }
363
+ /** Serialize all reads/writes for a single chat through a chained promise. */
364
+ withChat(id, fn) {
365
+ const prev = this.chains.get(id) ?? Promise.resolve();
366
+ const next = prev.then(fn, fn);
367
+ this.chains.set(
368
+ id,
369
+ next.finally(() => {
370
+ if (this.chains.get(id) === next) this.chains.delete(id);
371
+ })
372
+ );
373
+ return next;
374
+ }
375
+ async readChat(id) {
376
+ return readJson(this.chatPath(id));
377
+ }
378
+ async writeChat(file) {
379
+ await writeJsonAtomic(this.chatPath(file.chat.id), file);
380
+ }
381
+ async readIndex(login) {
382
+ const list = await readJson(this.indexPath(login));
383
+ return list ?? [];
384
+ }
385
+ async writeIndex(login, items) {
386
+ items.sort((a, b) => b.updatedAt - a.updatedAt);
387
+ await writeJsonAtomic(this.indexPath(login), items);
388
+ }
389
+ /**
390
+ * Rebuild the index for a user by scanning every chat file. Used as a
391
+ * self-heal path when the index gets out of sync (e.g. crash mid-write).
392
+ */
393
+ async rebuildIndex(login) {
394
+ const dir = (0, import_node_path.join)(this.dataDir, "chats");
395
+ let entries = [];
396
+ try {
397
+ entries = await (0, import_promises.readdir)(dir);
398
+ } catch (err) {
399
+ if (err.code !== "ENOENT") throw err;
400
+ }
401
+ const items = [];
402
+ const target = login.toLowerCase();
403
+ for (const name of entries) {
404
+ if (!name.endsWith(".json")) continue;
405
+ const file = await readJson((0, import_node_path.join)(dir, name));
406
+ if (!file?.chat) continue;
407
+ if (file.chat.githubLogin.toLowerCase() !== target) continue;
408
+ items.push(this.toListItem(file.chat));
409
+ }
410
+ await this.writeIndex(login, items);
411
+ return items;
412
+ }
413
+ toListItem(c) {
414
+ return {
415
+ id: c.id,
416
+ title: c.title,
417
+ mode: c.mode,
418
+ model: c.model,
419
+ createdAt: c.createdAt,
420
+ updatedAt: c.updatedAt
421
+ };
422
+ }
423
+ async upsertIndex(chat) {
424
+ const items = await this.readIndex(chat.githubLogin);
425
+ const idx = items.findIndex((c) => c.id === chat.id);
426
+ const item = this.toListItem(chat);
427
+ if (idx >= 0) items[idx] = item;
428
+ else items.push(item);
429
+ await this.writeIndex(chat.githubLogin, items);
430
+ }
431
+ async removeFromIndex(login, chatId) {
432
+ const items = await this.readIndex(login);
433
+ const filtered = items.filter((c) => c.id !== chatId);
434
+ if (filtered.length !== items.length) {
435
+ await this.writeIndex(login, filtered);
436
+ }
437
+ }
438
+ async listChats(login) {
439
+ const items = await this.readIndex(login);
440
+ if (items.length > 0) return items;
441
+ return this.rebuildIndex(login);
442
+ }
443
+ async loadChat(id, login) {
444
+ return this.withChat(id, async () => {
445
+ const file = await this.readChat(id);
446
+ if (!file) return null;
447
+ if (file.chat.githubLogin.toLowerCase() !== login.toLowerCase()) {
448
+ return null;
449
+ }
450
+ const turns = [...file.turns].sort((a, b) => a.seq - b.seq);
451
+ return { ...file.chat, turns };
452
+ });
453
+ }
454
+ async createChat(chat) {
455
+ await this.withChat(chat.id, async () => {
456
+ await this.writeChat({ chat, turns: [] });
457
+ });
458
+ await this.upsertIndex(chat);
459
+ }
460
+ async patchChat(id, login, patch) {
461
+ const updated = await this.withChat(id, async () => {
462
+ const file = await this.readChat(id);
463
+ if (!file) return null;
464
+ if (file.chat.githubLogin.toLowerCase() !== login.toLowerCase()) {
465
+ return null;
466
+ }
467
+ const next = {
468
+ ...file.chat,
469
+ ...patch,
470
+ updatedAt: Date.now()
471
+ };
472
+ await this.writeChat({ chat: next, turns: file.turns });
473
+ return next;
474
+ });
475
+ if (updated) await this.upsertIndex(updated);
476
+ return updated;
477
+ }
478
+ async appendTurn(turn) {
479
+ const sanitized = {
480
+ ...turn,
481
+ events: turn.events.map(sanitizeEvent)
482
+ };
483
+ let chat = null;
484
+ await this.withChat(turn.chatId, async () => {
485
+ const file = await this.readChat(turn.chatId);
486
+ if (!file) throw new Error(`chat_not_found:${turn.chatId}`);
487
+ file.turns = file.turns.filter((t) => t.id !== sanitized.id);
488
+ file.turns.push(sanitized);
489
+ file.chat = { ...file.chat, updatedAt: Date.now() };
490
+ chat = file.chat;
491
+ await this.writeChat(file);
492
+ });
493
+ if (chat) await this.upsertIndex(chat);
494
+ }
495
+ async patchTurn(chatId, turnId, patch) {
496
+ let chat = null;
497
+ await this.withChat(chatId, async () => {
498
+ const file = await this.readChat(chatId);
499
+ if (!file) return;
500
+ const idx = file.turns.findIndex((t) => t.id === turnId);
501
+ if (idx < 0) return;
502
+ const current = file.turns[idx];
503
+ const events = patch.events ? patch.events.map(sanitizeEvent) : current.events;
504
+ file.turns[idx] = { ...current, ...patch, events };
505
+ file.chat = { ...file.chat, updatedAt: Date.now() };
506
+ chat = file.chat;
507
+ await this.writeChat(file);
508
+ });
509
+ if (chat) await this.upsertIndex(chat);
510
+ }
511
+ async deleteChat(id, login) {
512
+ const removed = await this.withChat(id, async () => {
513
+ const file = await this.readChat(id);
514
+ if (!file) return false;
515
+ if (file.chat.githubLogin.toLowerCase() !== login.toLowerCase()) {
516
+ return false;
517
+ }
518
+ await (0, import_promises.rm)(this.chatPath(id), { force: true });
519
+ return true;
520
+ });
521
+ if (removed) await this.removeFromIndex(login, id);
522
+ return removed;
523
+ }
524
+ };
525
+ function createDefaultStorage(dataDir) {
526
+ const dir = dataDir ?? (0, import_node_path.join)(process.cwd(), ".coding-tab-data");
527
+ return new FileChatStorage({ dataDir: dir });
528
+ }
529
+
293
530
  // src/server/agentRoutes.ts
294
531
  var PLAN_INSTRUCTION = `You are operating in PLAN MODE.
295
532
 
@@ -331,39 +568,221 @@ function setupSseHeaders(res) {
331
568
  });
332
569
  res.flushHeaders?.();
333
570
  }
334
- async function streamRun(res, run) {
571
+ function deriveTitle(prompt) {
572
+ const trimmed = prompt.trim().replace(/\s+/g, " ");
573
+ if (trimmed.length <= 60) return trimmed || "New chat";
574
+ return `${trimmed.slice(0, 57)}\u2026`;
575
+ }
576
+ var TurnBuffer = class {
577
+ events = [];
578
+ lastWasText = false;
579
+ pushText(text) {
580
+ if (this.lastWasText) {
581
+ const last = this.events[this.events.length - 1];
582
+ if (last.kind === "text") {
583
+ last.text += text;
584
+ return last;
585
+ }
586
+ }
587
+ const evt = { kind: "text", id: (0, import_node_crypto3.randomUUID)(), text };
588
+ this.events.push(evt);
589
+ this.lastWasText = true;
590
+ return evt;
591
+ }
592
+ /**
593
+ * Mark the next text event as starting a new block (called when the SDK
594
+ * emits a non-streaming-delta boundary, e.g. a tool call or a new assistant
595
+ * message). The next `pushText` will start a fresh paragraph.
596
+ */
597
+ endTextBlock() {
598
+ this.lastWasText = false;
599
+ }
600
+ upsertTool(args) {
601
+ this.endTextBlock();
602
+ const existing = this.events.find(
603
+ (e) => e.kind === "tool" && e.callId === args.callId
604
+ );
605
+ if (existing) {
606
+ existing.status = args.status;
607
+ if (args.args !== void 0) existing.args = args.args;
608
+ if (args.result !== void 0) existing.result = args.result;
609
+ return existing;
610
+ }
611
+ const evt = {
612
+ kind: "tool",
613
+ id: (0, import_node_crypto3.randomUUID)(),
614
+ callId: args.callId,
615
+ name: args.name,
616
+ status: args.status,
617
+ args: args.args,
618
+ result: args.result
619
+ };
620
+ this.events.push(evt);
621
+ return evt;
622
+ }
623
+ snapshot() {
624
+ return this.events.map(sanitizeEvent);
625
+ }
626
+ };
627
+ async function streamRun(res, run, ctx) {
628
+ const flushDebounceMs = 750;
629
+ let flushPending = false;
630
+ let lastFlush = 0;
631
+ const persist = async (status, pr) => {
632
+ try {
633
+ await ctx.storage.patchTurn(ctx.chat.id, ctx.turn.id, {
634
+ events: ctx.buffer.snapshot(),
635
+ ...status ? { status } : {},
636
+ ...pr ? { pr } : {}
637
+ });
638
+ lastFlush = Date.now();
639
+ } catch (err) {
640
+ console.error("[coding-tab] patchTurn failed", err);
641
+ }
642
+ };
643
+ const scheduleFlush = () => {
644
+ if (flushPending) return;
645
+ flushPending = true;
646
+ setTimeout(async () => {
647
+ flushPending = false;
648
+ const elapsed = Date.now() - lastFlush;
649
+ if (elapsed < flushDebounceMs) return;
650
+ await persist();
651
+ }, flushDebounceMs).unref?.();
652
+ };
653
+ let finalStatus = "finished";
654
+ let finalPr;
335
655
  try {
336
656
  for await (const message of run.stream()) {
337
657
  if (res.writableEnded) break;
338
658
  if (message.type === "assistant") {
659
+ ctx.buffer.endTextBlock();
339
660
  for (const block of message.message.content) {
340
- if (block.type === "text" && block.text) sse(res, { kind: "text", text: block.text });
341
- else if (block.type === "tool_use") sse(res, { kind: "tool", name: block.name, status: "running", callId: block.id, args: block.input });
661
+ if (block.type === "text" && block.text) {
662
+ ctx.buffer.pushText(block.text);
663
+ ctx.buffer.endTextBlock();
664
+ sse(res, { kind: "text", text: block.text });
665
+ } else if (block.type === "tool_use") {
666
+ ctx.buffer.upsertTool({
667
+ callId: block.id,
668
+ name: block.name,
669
+ status: "running",
670
+ args: block.input
671
+ });
672
+ sse(res, {
673
+ kind: "tool",
674
+ name: block.name,
675
+ status: "running",
676
+ callId: block.id,
677
+ args: block.input
678
+ });
679
+ }
342
680
  }
343
681
  } else if (message.type === "thinking") {
344
682
  sse(res, { kind: "thinking", text: message.text });
345
683
  } else if (message.type === "tool_call") {
346
- sse(res, { kind: "tool", name: message.name, status: message.status, callId: message.call_id, args: message.args, result: message.result });
684
+ ctx.buffer.upsertTool({
685
+ callId: message.call_id,
686
+ name: message.name,
687
+ status: message.status,
688
+ args: message.args,
689
+ result: message.result
690
+ });
691
+ sse(res, {
692
+ kind: "tool",
693
+ name: message.name,
694
+ status: message.status,
695
+ callId: message.call_id,
696
+ args: message.args,
697
+ result: message.result
698
+ });
347
699
  } else if (message.type === "status") {
348
700
  sse(res, { kind: "status", status: message.status, message: message.message });
349
701
  }
702
+ scheduleFlush();
350
703
  }
351
704
  const result = await run.wait();
352
705
  const prUrl = result.git?.branches?.find((b) => b.prUrl)?.prUrl;
706
+ finalPr = parsePrUrl(prUrl);
707
+ finalStatus = result.status ?? "finished";
353
708
  sse(res, {
354
709
  kind: "result",
355
710
  status: result.status,
356
- pr: parsePrUrl(prUrl),
711
+ pr: finalPr,
357
712
  durationMs: result.durationMs
358
713
  });
359
714
  } catch (err) {
715
+ finalStatus = "error";
360
716
  const message = err instanceof Error ? err.message : String(err);
361
717
  const retryable = err instanceof import_sdk2.CursorAgentError ? Boolean(err.isRetryable) : false;
362
718
  sse(res, { kind: "error", message, retryable });
363
719
  } finally {
720
+ await persist(finalStatus, finalPr);
364
721
  if (!res.writableEnded) res.end();
365
722
  }
366
723
  }
724
+ async function ensureAgentForChat(storage, chat, user, opts, modelChoice) {
725
+ const existing = getLiveSession(chat.id);
726
+ if (existing && existing.githubLogin.toLowerCase() === user.githubLogin.toLowerCase()) {
727
+ return { agent: existing.agent, agentId: existing.agentId, resumed: true };
728
+ }
729
+ const resolved = await resolveModel(opts.cursorApiKey, modelChoice);
730
+ const cloud = {
731
+ repos: [
732
+ {
733
+ url: chat.repoUrl,
734
+ startingRef: chat.startingRef
735
+ }
736
+ ],
737
+ autoCreatePR: chat.mode === "agent",
738
+ skipReviewerRequest: opts.skipReviewerRequest ?? true,
739
+ envVars: { GITHUB_TOKEN: user.accessToken },
740
+ ...opts.envName ? { env: { type: "cloud", name: opts.envName } } : {}
741
+ };
742
+ if (chat.agentId) {
743
+ try {
744
+ const resumed = await import_sdk2.Agent.resume(chat.agentId, {
745
+ apiKey: opts.cursorApiKey,
746
+ model: { id: resolved.cursorModelId },
747
+ cloud
748
+ });
749
+ registerLiveSession({
750
+ chatId: chat.id,
751
+ githubLogin: user.githubLogin,
752
+ agent: resumed,
753
+ agentId: resumed.agentId
754
+ });
755
+ if (resumed.agentId !== chat.agentId) {
756
+ await storage.patchChat(chat.id, user.githubLogin, { agentId: resumed.agentId });
757
+ }
758
+ return { agent: resumed, agentId: resumed.agentId, resumed: true };
759
+ } catch (err) {
760
+ console.warn(
761
+ `[coding-tab] resume failed for chat=${chat.id} agentId=${chat.agentId}; creating fresh agent`,
762
+ err instanceof Error ? err.message : err
763
+ );
764
+ }
765
+ }
766
+ const fresh = await import_sdk2.Agent.create({
767
+ apiKey: opts.cursorApiKey,
768
+ model: { id: resolved.cursorModelId },
769
+ cloud
770
+ });
771
+ registerLiveSession({
772
+ chatId: chat.id,
773
+ githubLogin: user.githubLogin,
774
+ agent: fresh,
775
+ agentId: fresh.agentId
776
+ });
777
+ await storage.patchChat(chat.id, user.githubLogin, { agentId: fresh.agentId });
778
+ return { agent: fresh, agentId: fresh.agentId, resumed: false };
779
+ }
780
+ async function nextSeq(storage, chatId, login) {
781
+ const full = await storage.loadChat(chatId, login);
782
+ if (!full) throw Object.assign(new Error("chat_not_found"), { status: 404 });
783
+ const max = full.turns.reduce((acc, t) => Math.max(acc, t.seq), -1);
784
+ return max + 1;
785
+ }
367
786
  function makeAgentRouter(opts) {
368
787
  const router = (0, import_express2.Router)();
369
788
  router.get("/models", async (_req, res) => {
@@ -375,144 +794,236 @@ function makeAgentRouter(opts) {
375
794
  res.status(500).json({ error: err instanceof Error ? err.message : "models_failed" });
376
795
  }
377
796
  });
378
- router.post("/agent/start", async (req, res) => {
797
+ const handleSend = async (req, res, isExecute) => {
379
798
  const user = req.user;
380
- const { prompt, mode, model, repoUrl, startingRef } = req.body ?? {};
381
- if (!prompt || mode !== "plan" && mode !== "agent" || model !== "sonnet" && model !== "opus") {
799
+ const body = req.body ?? {};
800
+ if (!body.chatId) {
801
+ res.status(400).json({ error: "missing_chatId" });
802
+ return;
803
+ }
804
+ if (!isExecute && (!body.prompt || body.mode !== "plan" && body.mode !== "agent")) {
382
805
  res.status(400).json({ error: "invalid_request" });
383
806
  return;
384
807
  }
808
+ const chat = await opts.storage.loadChat(body.chatId, user.githubLogin);
809
+ if (!chat) {
810
+ res.status(404).json({ error: "chat_not_found" });
811
+ return;
812
+ }
385
813
  setupSseHeaders(res);
386
- let agent;
814
+ let assistantTurn = null;
387
815
  try {
388
- const resolved = await resolveModel(opts.cursorApiKey, model);
389
- agent = await import_sdk2.Agent.create({
390
- apiKey: opts.cursorApiKey,
391
- model: { id: resolved.cursorModelId },
392
- cloud: {
393
- repos: [
394
- {
395
- url: repoUrl ?? opts.defaultRepo.url,
396
- startingRef: startingRef ?? opts.defaultRepo.ref
397
- }
398
- ],
399
- autoCreatePR: mode === "agent",
400
- skipReviewerRequest: opts.skipReviewerRequest ?? true,
401
- envVars: { GITHUB_TOKEN: user.accessToken },
402
- ...opts.envName ? { env: { type: "cloud", name: opts.envName } } : {}
816
+ if (!isExecute && body.mode && body.mode !== chat.mode) {
817
+ chat.mode = body.mode;
818
+ await opts.storage.patchChat(chat.id, user.githubLogin, { mode: body.mode });
819
+ }
820
+ let seq = await nextSeq(opts.storage, chat.id, user.githubLogin);
821
+ if (!isExecute) {
822
+ const userTurn = {
823
+ id: (0, import_node_crypto3.randomUUID)(),
824
+ chatId: chat.id,
825
+ seq: seq++,
826
+ role: "user",
827
+ isPlan: chat.mode === "plan",
828
+ status: "finished",
829
+ events: [{ kind: "text", id: (0, import_node_crypto3.randomUUID)(), text: body.prompt }],
830
+ prompt: body.prompt,
831
+ createdAt: Date.now()
832
+ };
833
+ await opts.storage.appendTurn(userTurn);
834
+ if (chat.title === "New chat") {
835
+ const title = deriveTitle(body.prompt);
836
+ chat.title = title;
837
+ await opts.storage.patchChat(chat.id, user.githubLogin, { title });
403
838
  }
839
+ }
840
+ assistantTurn = {
841
+ id: (0, import_node_crypto3.randomUUID)(),
842
+ chatId: chat.id,
843
+ seq,
844
+ role: "assistant",
845
+ isPlan: !isExecute && chat.mode === "plan",
846
+ status: "running",
847
+ events: [],
848
+ createdAt: Date.now()
849
+ };
850
+ await opts.storage.appendTurn(assistantTurn);
851
+ const { agent } = await ensureAgentForChat(opts.storage, chat, user, opts, chat.model);
852
+ const sendText = isExecute ? EXECUTE_INSTRUCTION : modeInstructionPrefix(body.mode, body.prompt);
853
+ const run = await agent.send(sendText);
854
+ sse(res, {
855
+ kind: "ready",
856
+ chatId: chat.id,
857
+ turnId: assistantTurn.id,
858
+ agentId: agent.agentId,
859
+ runId: run.id
404
860
  });
405
- const session = registerSession({
406
- githubLogin: user.githubLogin,
407
- agent,
408
- repoUrl: repoUrl ?? opts.defaultRepo.url,
409
- startingRef: startingRef ?? opts.defaultRepo.ref
861
+ console.log(
862
+ `[coding-tab] ${isExecute ? "execute" : "send"} agent=${agent.agentId} run=${run.id} chat=${chat.id} login=${user.githubLogin}`
863
+ );
864
+ const buffer = new TurnBuffer();
865
+ await streamRun(res, run, {
866
+ storage: opts.storage,
867
+ chat,
868
+ turn: assistantTurn,
869
+ buffer
410
870
  });
411
- const run = await agent.send(modeInstructionPrefix(mode, prompt));
412
- sse(res, { kind: "ready", sessionId: session.id, agentId: agent.agentId, runId: run.id });
413
- console.log(`[coding-tab] start agent=${agent.agentId} run=${run.id} session=${session.id} login=${user.githubLogin}`);
414
- await streamRun(res, run);
415
871
  } catch (err) {
416
872
  const message = err instanceof Error ? err.message : String(err);
417
873
  const retryable = err instanceof import_sdk2.CursorAgentError ? Boolean(err.isRetryable) : false;
418
- console.error("[coding-tab] /agent/start failed", err);
874
+ console.error(`[coding-tab] /agent/${isExecute ? "execute" : "send"} failed`, err);
419
875
  sse(res, { kind: "error", message, retryable });
876
+ if (assistantTurn) {
877
+ await opts.storage.patchTurn(assistantTurn.chatId, assistantTurn.id, {
878
+ status: "error",
879
+ events: [
880
+ ...assistantTurn.events,
881
+ { kind: "text", id: (0, import_node_crypto3.randomUUID)(), text: `[error] ${message}` }
882
+ ]
883
+ }).catch(() => {
884
+ });
885
+ }
420
886
  if (!res.writableEnded) res.end();
421
- if (agent) await agent[Symbol.asyncDispose]().catch(() => {
422
- });
423
887
  }
424
888
  req.on("close", () => {
425
889
  if (!res.writableEnded) res.end();
426
890
  });
427
- });
428
- router.post("/agent/send", async (req, res) => {
429
- const { sessionId, prompt, mode } = req.body ?? {};
430
- if (!sessionId || !prompt || mode !== "plan" && mode !== "agent") {
891
+ };
892
+ router.post("/agent/start", (req, res) => handleSend(req, res, false));
893
+ router.post("/agent/send", (req, res) => handleSend(req, res, false));
894
+ router.post("/agent/execute", (req, res) => handleSend(req, res, true));
895
+ router.post("/agent/cancel", async (req, res) => {
896
+ const { chatId, runId } = req.body ?? {};
897
+ if (!chatId || !runId) {
431
898
  res.status(400).json({ error: "invalid_request" });
432
899
  return;
433
900
  }
434
- const session = getSession2(sessionId);
435
- if (!session) {
901
+ const live = getLiveSession(chatId);
902
+ if (!live) {
436
903
  res.status(404).json({ error: "session_not_found" });
437
904
  return;
438
905
  }
439
- setupSseHeaders(res);
440
906
  try {
441
- const run = await session.agent.send(modeInstructionPrefix(mode, prompt));
442
- sse(res, { kind: "ready", sessionId: session.id, agentId: session.agent.agentId, runId: run.id });
443
- console.log(`[coding-tab] send agent=${session.agent.agentId} run=${run.id} session=${session.id}`);
444
- await streamRun(res, run);
907
+ await import_sdk2.Agent.cancelRun(runId, {
908
+ runtime: "cloud",
909
+ agentId: live.agentId,
910
+ apiKey: opts.cursorApiKey
911
+ });
912
+ res.json({ ok: true });
445
913
  } catch (err) {
446
914
  const message = err instanceof Error ? err.message : String(err);
447
- console.error("[coding-tab] /agent/send failed", err);
448
- sse(res, { kind: "error", message });
449
- if (!res.writableEnded) res.end();
915
+ console.error("[coding-tab] /agent/cancel failed", err);
916
+ res.status(500).json({ error: message });
450
917
  }
451
- req.on("close", () => {
452
- if (!res.writableEnded) res.end();
453
- });
454
918
  });
455
- router.post("/agent/execute", async (req, res) => {
456
- const { sessionId } = req.body ?? {};
457
- if (!sessionId) {
919
+ router.post("/agent/dispose", async (req, res) => {
920
+ const { chatId } = req.body ?? {};
921
+ if (!chatId) {
458
922
  res.status(400).json({ error: "invalid_request" });
459
923
  return;
460
924
  }
461
- const session = getSession2(sessionId);
462
- if (!session) {
463
- res.status(404).json({ error: "session_not_found" });
464
- return;
465
- }
466
- setupSseHeaders(res);
925
+ await disposeSessionsForChat(chatId);
926
+ res.json({ ok: true });
927
+ });
928
+ return router;
929
+ }
930
+
931
+ // src/server/chatRoutes.ts
932
+ var import_express3 = require("express");
933
+ var import_node_crypto4 = require("crypto");
934
+ var VALID_MODES = ["plan", "agent"];
935
+ var VALID_MODELS = ["sonnet", "opus"];
936
+ function makeChatRouter(opts) {
937
+ const router = (0, import_express3.Router)();
938
+ router.get("/chats", async (req, res) => {
939
+ const user = req.user;
467
940
  try {
468
- const run = await session.agent.send(EXECUTE_INSTRUCTION);
469
- sse(res, { kind: "ready", sessionId: session.id, agentId: session.agent.agentId, runId: run.id });
470
- console.log(`[coding-tab] execute agent=${session.agent.agentId} run=${run.id} session=${session.id}`);
471
- await streamRun(res, run);
941
+ const items = await opts.storage.listChats(user.githubLogin);
942
+ res.json({ chats: items });
472
943
  } catch (err) {
473
- const message = err instanceof Error ? err.message : String(err);
474
- console.error("[coding-tab] /agent/execute failed", err);
475
- sse(res, { kind: "error", message });
476
- if (!res.writableEnded) res.end();
944
+ console.error("[coding-tab] /chats list failed", err);
945
+ res.status(500).json({ error: err instanceof Error ? err.message : "list_failed" });
477
946
  }
478
- req.on("close", () => {
479
- if (!res.writableEnded) res.end();
480
- });
481
947
  });
482
- router.post("/agent/cancel", async (req, res) => {
483
- const { sessionId, runId } = req.body ?? {};
484
- if (!sessionId || !runId) {
485
- res.status(400).json({ error: "invalid_request" });
486
- return;
948
+ router.post("/chats", async (req, res) => {
949
+ const user = req.user;
950
+ const body = req.body ?? {};
951
+ const mode = VALID_MODES.includes(body.mode) ? body.mode : "plan";
952
+ const model = VALID_MODELS.includes(body.model) ? body.model : "sonnet";
953
+ const now = Date.now();
954
+ const chat = {
955
+ id: (0, import_node_crypto4.randomUUID)(),
956
+ githubLogin: user.githubLogin,
957
+ title: (body.title ?? "").trim() || "New chat",
958
+ mode,
959
+ model,
960
+ repoUrl: body.repoUrl?.trim() || opts.defaultRepo.url,
961
+ startingRef: body.startingRef ?? opts.defaultRepo.ref,
962
+ createdAt: now,
963
+ updatedAt: now
964
+ };
965
+ try {
966
+ await opts.storage.createChat(chat);
967
+ res.status(201).json({ chat });
968
+ } catch (err) {
969
+ console.error("[coding-tab] /chats create failed", err);
970
+ res.status(500).json({ error: err instanceof Error ? err.message : "create_failed" });
487
971
  }
488
- const session = getSession2(sessionId);
489
- if (!session) {
490
- res.status(404).json({ error: "session_not_found" });
491
- return;
972
+ });
973
+ router.get("/chats/:id", async (req, res) => {
974
+ const user = req.user;
975
+ try {
976
+ const full = await opts.storage.loadChat(req.params.id, user.githubLogin);
977
+ if (!full) {
978
+ res.status(404).json({ error: "chat_not_found" });
979
+ return;
980
+ }
981
+ res.json({ chat: full });
982
+ } catch (err) {
983
+ console.error("[coding-tab] /chats/:id load failed", err);
984
+ res.status(500).json({ error: err instanceof Error ? err.message : "load_failed" });
492
985
  }
986
+ });
987
+ router.patch("/chats/:id", async (req, res) => {
988
+ const user = req.user;
989
+ const body = req.body ?? {};
990
+ const patch = {};
991
+ if (typeof body.title === "string") patch.title = body.title.trim() || "Untitled";
992
+ if (body.mode && VALID_MODES.includes(body.mode)) patch.mode = body.mode;
993
+ if (body.model && VALID_MODELS.includes(body.model)) patch.model = body.model;
493
994
  try {
494
- await import_sdk2.Agent.cancelRun(runId, { runtime: "cloud", agentId: session.agent.agentId, apiKey: opts.cursorApiKey });
495
- res.json({ ok: true });
995
+ const updated = await opts.storage.patchChat(req.params.id, user.githubLogin, patch);
996
+ if (!updated) {
997
+ res.status(404).json({ error: "chat_not_found" });
998
+ return;
999
+ }
1000
+ res.json({ chat: updated });
496
1001
  } catch (err) {
497
- const message = err instanceof Error ? err.message : String(err);
498
- console.error("[coding-tab] /agent/cancel failed", err);
499
- res.status(500).json({ error: message });
1002
+ console.error("[coding-tab] /chats/:id patch failed", err);
1003
+ res.status(500).json({ error: err instanceof Error ? err.message : "patch_failed" });
500
1004
  }
501
1005
  });
502
- router.post("/agent/dispose", async (req, res) => {
503
- const { sessionId } = req.body ?? {};
504
- if (!sessionId) {
505
- res.status(400).json({ error: "invalid_request" });
506
- return;
1006
+ router.delete("/chats/:id", async (req, res) => {
1007
+ const user = req.user;
1008
+ try {
1009
+ const removed = await opts.storage.deleteChat(req.params.id, user.githubLogin);
1010
+ if (!removed) {
1011
+ res.status(404).json({ error: "chat_not_found" });
1012
+ return;
1013
+ }
1014
+ await disposeSessionsForChat(req.params.id).catch(() => {
1015
+ });
1016
+ res.json({ ok: true });
1017
+ } catch (err) {
1018
+ console.error("[coding-tab] /chats/:id delete failed", err);
1019
+ res.status(500).json({ error: err instanceof Error ? err.message : "delete_failed" });
507
1020
  }
508
- await disposeSession(sessionId);
509
- res.json({ ok: true });
510
1021
  });
511
1022
  return router;
512
1023
  }
513
1024
 
514
1025
  // src/server/githubRoutes.ts
515
- var import_express3 = require("express");
1026
+ var import_express4 = require("express");
516
1027
  var import_rest = require("@octokit/rest");
517
1028
  function parsePrUrl2(prUrl) {
518
1029
  const match = prUrl.match(/github\.com\/([^/]+)\/([^/]+)\/pull\/(\d+)/);
@@ -520,7 +1031,7 @@ function parsePrUrl2(prUrl) {
520
1031
  return { owner: match[1], repo: match[2], number: Number(match[3]) };
521
1032
  }
522
1033
  function makeGitHubRouter() {
523
- const router = (0, import_express3.Router)();
1034
+ const router = (0, import_express4.Router)();
524
1035
  router.get("/pr/status", async (req, res) => {
525
1036
  const prUrl = typeof req.query.prUrl === "string" ? req.query.prUrl : null;
526
1037
  if (!prUrl) {
@@ -599,26 +1110,26 @@ function makeGitHubRouter() {
599
1110
  }
600
1111
 
601
1112
  // src/server/staticAssets.ts
602
- var import_express4 = require("express");
603
- var import_promises = require("fs/promises");
604
- var import_node_path = require("path");
1113
+ var import_express5 = require("express");
1114
+ var import_promises2 = require("fs/promises");
1115
+ var import_node_path2 = require("path");
605
1116
  var import_node_url = require("url");
606
- var here = (0, import_node_path.dirname)((0, import_node_url.fileURLToPath)(importMetaUrl));
1117
+ var here = (0, import_node_path2.dirname)((0, import_node_url.fileURLToPath)(importMetaUrl));
607
1118
  var ASSET_CANDIDATES = [
608
- (0, import_node_path.resolve)(here, "browser.js"),
609
- (0, import_node_path.resolve)(here, "..", "dist", "browser.js"),
610
- (0, import_node_path.resolve)(here, "..", "browser.js")
1119
+ (0, import_node_path2.resolve)(here, "browser.js"),
1120
+ (0, import_node_path2.resolve)(here, "..", "dist", "browser.js"),
1121
+ (0, import_node_path2.resolve)(here, "..", "browser.js")
611
1122
  ];
612
1123
  var STYLE_CANDIDATES = [
613
- (0, import_node_path.resolve)(here, "style.css"),
614
- (0, import_node_path.resolve)(here, "..", "dist", "style.css"),
615
- (0, import_node_path.resolve)(here, "..", "style.css")
1124
+ (0, import_node_path2.resolve)(here, "style.css"),
1125
+ (0, import_node_path2.resolve)(here, "..", "dist", "style.css"),
1126
+ (0, import_node_path2.resolve)(here, "..", "style.css")
616
1127
  ];
617
1128
  async function readFirst(paths) {
618
1129
  let lastErr;
619
1130
  for (const p of paths) {
620
1131
  try {
621
- const data = await (0, import_promises.readFile)(p);
1132
+ const data = await (0, import_promises2.readFile)(p);
622
1133
  return { path: p, data };
623
1134
  } catch (err) {
624
1135
  lastErr = err;
@@ -627,7 +1138,7 @@ async function readFirst(paths) {
627
1138
  throw lastErr instanceof Error ? lastErr : new Error("asset not found");
628
1139
  }
629
1140
  function makeAssetRouter(basePath) {
630
- const router = (0, import_express4.Router)();
1141
+ const router = (0, import_express5.Router)();
631
1142
  router.get("/browser.js", async (_req, res) => {
632
1143
  try {
633
1144
  const { data } = await readFirst(ASSET_CANDIDATES);
@@ -694,8 +1205,9 @@ function mountCodingTab(app, options) {
694
1205
  if (!options.githubOAuth.allowedLogins || options.githubOAuth.allowedLogins.length === 0) {
695
1206
  console.warn("[coding-tab] WARNING: allowedLogins is empty \u2014 anyone with a GitHub account can sign in.");
696
1207
  }
697
- const router = import_express5.default.Router();
698
- router.use(import_express5.default.json({ limit: "1mb" }));
1208
+ const storage = options.storage ?? createDefaultStorage(options.dataDir);
1209
+ const router = import_express6.default.Router();
1210
+ router.use(import_express6.default.json({ limit: "1mb" }));
699
1211
  const assetRouter = makeAssetRouter(basePath);
700
1212
  router.use(assetRouter);
701
1213
  const authRouter = makeAuthRouter({
@@ -712,11 +1224,14 @@ function mountCodingTab(app, options) {
712
1224
  secure,
713
1225
  allowedLogins: options.githubOAuth.allowedLogins
714
1226
  });
1227
+ const chatRouter = makeChatRouter({ storage, defaultRepo: options.defaultRepo });
1228
+ router.use(requireAuth, chatRouter);
715
1229
  const agentRouter = makeAgentRouter({
716
1230
  cursorApiKey: options.cursorApiKey,
717
1231
  defaultRepo: options.defaultRepo,
718
1232
  envName: options.envName,
719
- skipReviewerRequest: options.skipReviewerRequest
1233
+ skipReviewerRequest: options.skipReviewerRequest,
1234
+ storage
720
1235
  });
721
1236
  router.use(requireAuth, agentRouter);
722
1237
  const githubRouter = makeGitHubRouter();
@@ -727,6 +1242,8 @@ function mountCodingTab(app, options) {
727
1242
  }
728
1243
  // Annotate the CommonJS export names for ESM import in node:
729
1244
  0 && (module.exports = {
1245
+ FileChatStorage,
1246
+ createDefaultStorage,
730
1247
  mountCodingTab
731
1248
  });
732
1249
  //# sourceMappingURL=server.cjs.map