@tstax/coding-tab 0.1.1 → 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.js CHANGED
@@ -162,7 +162,11 @@ function makeAuthRouter(opts) {
162
162
 
163
163
  // src/server/agentRoutes.ts
164
164
  import { Router as Router2 } from "express";
165
- import { Agent, CursorAgentError } from "@cursor/sdk";
165
+ import {
166
+ Agent,
167
+ CursorAgentError
168
+ } from "@cursor/sdk";
169
+ import { randomUUID as randomUUID2 } from "crypto";
166
170
 
167
171
  // src/server/models.ts
168
172
  import { Cursor } from "@cursor/sdk";
@@ -203,7 +207,6 @@ async function resolveModel(apiKey, choice) {
203
207
  }
204
208
 
205
209
  // src/server/sessions.ts
206
- import { randomUUID } from "crypto";
207
210
  var SESSION_IDLE_TTL_MS = 30 * 60 * 1e3;
208
211
  var sessions = /* @__PURE__ */ new Map();
209
212
  var sweeperStarted = false;
@@ -214,35 +217,41 @@ function startSweeper() {
214
217
  const now = Date.now();
215
218
  for (const [id, s] of sessions) {
216
219
  if (now - s.lastUsedAt > SESSION_IDLE_TTL_MS) {
217
- disposeSession(id).catch((e) => console.error("[coding-tab] session sweep dispose failed", e));
220
+ disposeSessionsForChat(id).catch(
221
+ (e) => console.error("[coding-tab] session sweep dispose failed", e)
222
+ );
218
223
  }
219
224
  }
220
225
  }, 5 * 60 * 1e3).unref?.();
221
226
  }
222
- function registerSession(opts) {
227
+ function registerLiveSession(opts) {
223
228
  startSweeper();
224
- const id = randomUUID();
229
+ const prev = sessions.get(opts.chatId);
230
+ if (prev) {
231
+ sessions.delete(opts.chatId);
232
+ prev.agent[Symbol.asyncDispose]().catch(() => {
233
+ });
234
+ }
225
235
  const session = {
226
- id,
236
+ chatId: opts.chatId,
227
237
  githubLogin: opts.githubLogin,
228
238
  agent: opts.agent,
229
- repoUrl: opts.repoUrl,
230
- startingRef: opts.startingRef,
239
+ agentId: opts.agentId,
231
240
  createdAt: Date.now(),
232
241
  lastUsedAt: Date.now()
233
242
  };
234
- sessions.set(id, session);
243
+ sessions.set(opts.chatId, session);
235
244
  return session;
236
245
  }
237
- function getSession2(id) {
238
- const s = sessions.get(id);
246
+ function getLiveSession(chatId) {
247
+ const s = sessions.get(chatId);
239
248
  if (s) s.lastUsedAt = Date.now();
240
249
  return s;
241
250
  }
242
- async function disposeSession(id) {
243
- const s = sessions.get(id);
251
+ async function disposeSessionsForChat(chatId) {
252
+ const s = sessions.get(chatId);
244
253
  if (!s) return;
245
- sessions.delete(id);
254
+ sessions.delete(chatId);
246
255
  try {
247
256
  await s.agent[Symbol.asyncDispose]();
248
257
  } catch (err) {
@@ -250,6 +259,235 @@ async function disposeSession(id) {
250
259
  }
251
260
  }
252
261
 
262
+ // src/server/storage.ts
263
+ import { mkdir, readFile, readdir, rename, rm, writeFile } from "fs/promises";
264
+ import { dirname, join, resolve } from "path";
265
+ import { randomUUID } from "crypto";
266
+ function safeId(s) {
267
+ return s.replace(/[^a-zA-Z0-9_.-]/g, "_");
268
+ }
269
+ async function ensureDir(p) {
270
+ await mkdir(p, { recursive: true });
271
+ }
272
+ async function readJson(path) {
273
+ try {
274
+ const raw = await readFile(path, "utf8");
275
+ return JSON.parse(raw);
276
+ } catch (err) {
277
+ if (err.code === "ENOENT") return null;
278
+ throw err;
279
+ }
280
+ }
281
+ async function writeJsonAtomic(path, data) {
282
+ await ensureDir(dirname(path));
283
+ const tmp = `${path}.${randomUUID()}.tmp`;
284
+ await writeFile(tmp, JSON.stringify(data, null, 2), "utf8");
285
+ await rename(tmp, path);
286
+ }
287
+ var MAX_BLOB_BYTES = 32 * 1024;
288
+ function truncateBlob(value) {
289
+ if (value === void 0 || value === null) return value;
290
+ try {
291
+ const str = typeof value === "string" ? value : JSON.stringify(value);
292
+ if (str.length <= MAX_BLOB_BYTES) return value;
293
+ const head = str.slice(0, MAX_BLOB_BYTES);
294
+ return `${head}
295
+
296
+ \u2026[truncated ${str.length - MAX_BLOB_BYTES} chars]`;
297
+ } catch {
298
+ return "[unserializable]";
299
+ }
300
+ }
301
+ function sanitizeEvent(evt) {
302
+ if (evt.kind === "tool") {
303
+ return {
304
+ ...evt,
305
+ args: truncateBlob(evt.args),
306
+ result: truncateBlob(evt.result)
307
+ };
308
+ }
309
+ return evt;
310
+ }
311
+ var FileChatStorage = class {
312
+ dataDir;
313
+ /** Per-chat write mutex chain to keep concurrent appends consistent. */
314
+ chains = /* @__PURE__ */ new Map();
315
+ constructor(opts) {
316
+ this.dataDir = resolve(opts.dataDir);
317
+ }
318
+ chatPath(id) {
319
+ return join(this.dataDir, "chats", `${safeId(id)}.json`);
320
+ }
321
+ indexPath(login) {
322
+ return join(this.dataDir, "index", `${safeId(login.toLowerCase())}.json`);
323
+ }
324
+ /** Serialize all reads/writes for a single chat through a chained promise. */
325
+ withChat(id, fn) {
326
+ const prev = this.chains.get(id) ?? Promise.resolve();
327
+ const next = prev.then(fn, fn);
328
+ this.chains.set(
329
+ id,
330
+ next.finally(() => {
331
+ if (this.chains.get(id) === next) this.chains.delete(id);
332
+ })
333
+ );
334
+ return next;
335
+ }
336
+ async readChat(id) {
337
+ return readJson(this.chatPath(id));
338
+ }
339
+ async writeChat(file) {
340
+ await writeJsonAtomic(this.chatPath(file.chat.id), file);
341
+ }
342
+ async readIndex(login) {
343
+ const list = await readJson(this.indexPath(login));
344
+ return list ?? [];
345
+ }
346
+ async writeIndex(login, items) {
347
+ items.sort((a, b) => b.updatedAt - a.updatedAt);
348
+ await writeJsonAtomic(this.indexPath(login), items);
349
+ }
350
+ /**
351
+ * Rebuild the index for a user by scanning every chat file. Used as a
352
+ * self-heal path when the index gets out of sync (e.g. crash mid-write).
353
+ */
354
+ async rebuildIndex(login) {
355
+ const dir = join(this.dataDir, "chats");
356
+ let entries = [];
357
+ try {
358
+ entries = await readdir(dir);
359
+ } catch (err) {
360
+ if (err.code !== "ENOENT") throw err;
361
+ }
362
+ const items = [];
363
+ const target = login.toLowerCase();
364
+ for (const name of entries) {
365
+ if (!name.endsWith(".json")) continue;
366
+ const file = await readJson(join(dir, name));
367
+ if (!file?.chat) continue;
368
+ if (file.chat.githubLogin.toLowerCase() !== target) continue;
369
+ items.push(this.toListItem(file.chat));
370
+ }
371
+ await this.writeIndex(login, items);
372
+ return items;
373
+ }
374
+ toListItem(c) {
375
+ return {
376
+ id: c.id,
377
+ title: c.title,
378
+ mode: c.mode,
379
+ model: c.model,
380
+ createdAt: c.createdAt,
381
+ updatedAt: c.updatedAt
382
+ };
383
+ }
384
+ async upsertIndex(chat) {
385
+ const items = await this.readIndex(chat.githubLogin);
386
+ const idx = items.findIndex((c) => c.id === chat.id);
387
+ const item = this.toListItem(chat);
388
+ if (idx >= 0) items[idx] = item;
389
+ else items.push(item);
390
+ await this.writeIndex(chat.githubLogin, items);
391
+ }
392
+ async removeFromIndex(login, chatId) {
393
+ const items = await this.readIndex(login);
394
+ const filtered = items.filter((c) => c.id !== chatId);
395
+ if (filtered.length !== items.length) {
396
+ await this.writeIndex(login, filtered);
397
+ }
398
+ }
399
+ async listChats(login) {
400
+ const items = await this.readIndex(login);
401
+ if (items.length > 0) return items;
402
+ return this.rebuildIndex(login);
403
+ }
404
+ async loadChat(id, login) {
405
+ return this.withChat(id, async () => {
406
+ const file = await this.readChat(id);
407
+ if (!file) return null;
408
+ if (file.chat.githubLogin.toLowerCase() !== login.toLowerCase()) {
409
+ return null;
410
+ }
411
+ const turns = [...file.turns].sort((a, b) => a.seq - b.seq);
412
+ return { ...file.chat, turns };
413
+ });
414
+ }
415
+ async createChat(chat) {
416
+ await this.withChat(chat.id, async () => {
417
+ await this.writeChat({ chat, turns: [] });
418
+ });
419
+ await this.upsertIndex(chat);
420
+ }
421
+ async patchChat(id, login, patch) {
422
+ const updated = await this.withChat(id, async () => {
423
+ const file = await this.readChat(id);
424
+ if (!file) return null;
425
+ if (file.chat.githubLogin.toLowerCase() !== login.toLowerCase()) {
426
+ return null;
427
+ }
428
+ const next = {
429
+ ...file.chat,
430
+ ...patch,
431
+ updatedAt: Date.now()
432
+ };
433
+ await this.writeChat({ chat: next, turns: file.turns });
434
+ return next;
435
+ });
436
+ if (updated) await this.upsertIndex(updated);
437
+ return updated;
438
+ }
439
+ async appendTurn(turn) {
440
+ const sanitized = {
441
+ ...turn,
442
+ events: turn.events.map(sanitizeEvent)
443
+ };
444
+ let chat = null;
445
+ await this.withChat(turn.chatId, async () => {
446
+ const file = await this.readChat(turn.chatId);
447
+ if (!file) throw new Error(`chat_not_found:${turn.chatId}`);
448
+ file.turns = file.turns.filter((t) => t.id !== sanitized.id);
449
+ file.turns.push(sanitized);
450
+ file.chat = { ...file.chat, updatedAt: Date.now() };
451
+ chat = file.chat;
452
+ await this.writeChat(file);
453
+ });
454
+ if (chat) await this.upsertIndex(chat);
455
+ }
456
+ async patchTurn(chatId, turnId, patch) {
457
+ let chat = null;
458
+ await this.withChat(chatId, async () => {
459
+ const file = await this.readChat(chatId);
460
+ if (!file) return;
461
+ const idx = file.turns.findIndex((t) => t.id === turnId);
462
+ if (idx < 0) return;
463
+ const current = file.turns[idx];
464
+ const events = patch.events ? patch.events.map(sanitizeEvent) : current.events;
465
+ file.turns[idx] = { ...current, ...patch, events };
466
+ file.chat = { ...file.chat, updatedAt: Date.now() };
467
+ chat = file.chat;
468
+ await this.writeChat(file);
469
+ });
470
+ if (chat) await this.upsertIndex(chat);
471
+ }
472
+ async deleteChat(id, login) {
473
+ const removed = await this.withChat(id, async () => {
474
+ const file = await this.readChat(id);
475
+ if (!file) return false;
476
+ if (file.chat.githubLogin.toLowerCase() !== login.toLowerCase()) {
477
+ return false;
478
+ }
479
+ await rm(this.chatPath(id), { force: true });
480
+ return true;
481
+ });
482
+ if (removed) await this.removeFromIndex(login, id);
483
+ return removed;
484
+ }
485
+ };
486
+ function createDefaultStorage(dataDir) {
487
+ const dir = dataDir ?? join(process.cwd(), ".coding-tab-data");
488
+ return new FileChatStorage({ dataDir: dir });
489
+ }
490
+
253
491
  // src/server/agentRoutes.ts
254
492
  var PLAN_INSTRUCTION = `You are operating in PLAN MODE.
255
493
 
@@ -291,39 +529,221 @@ function setupSseHeaders(res) {
291
529
  });
292
530
  res.flushHeaders?.();
293
531
  }
294
- async function streamRun(res, run) {
532
+ function deriveTitle(prompt) {
533
+ const trimmed = prompt.trim().replace(/\s+/g, " ");
534
+ if (trimmed.length <= 60) return trimmed || "New chat";
535
+ return `${trimmed.slice(0, 57)}\u2026`;
536
+ }
537
+ var TurnBuffer = class {
538
+ events = [];
539
+ lastWasText = false;
540
+ pushText(text) {
541
+ if (this.lastWasText) {
542
+ const last = this.events[this.events.length - 1];
543
+ if (last.kind === "text") {
544
+ last.text += text;
545
+ return last;
546
+ }
547
+ }
548
+ const evt = { kind: "text", id: randomUUID2(), text };
549
+ this.events.push(evt);
550
+ this.lastWasText = true;
551
+ return evt;
552
+ }
553
+ /**
554
+ * Mark the next text event as starting a new block (called when the SDK
555
+ * emits a non-streaming-delta boundary, e.g. a tool call or a new assistant
556
+ * message). The next `pushText` will start a fresh paragraph.
557
+ */
558
+ endTextBlock() {
559
+ this.lastWasText = false;
560
+ }
561
+ upsertTool(args) {
562
+ this.endTextBlock();
563
+ const existing = this.events.find(
564
+ (e) => e.kind === "tool" && e.callId === args.callId
565
+ );
566
+ if (existing) {
567
+ existing.status = args.status;
568
+ if (args.args !== void 0) existing.args = args.args;
569
+ if (args.result !== void 0) existing.result = args.result;
570
+ return existing;
571
+ }
572
+ const evt = {
573
+ kind: "tool",
574
+ id: randomUUID2(),
575
+ callId: args.callId,
576
+ name: args.name,
577
+ status: args.status,
578
+ args: args.args,
579
+ result: args.result
580
+ };
581
+ this.events.push(evt);
582
+ return evt;
583
+ }
584
+ snapshot() {
585
+ return this.events.map(sanitizeEvent);
586
+ }
587
+ };
588
+ async function streamRun(res, run, ctx) {
589
+ const flushDebounceMs = 750;
590
+ let flushPending = false;
591
+ let lastFlush = 0;
592
+ const persist = async (status, pr) => {
593
+ try {
594
+ await ctx.storage.patchTurn(ctx.chat.id, ctx.turn.id, {
595
+ events: ctx.buffer.snapshot(),
596
+ ...status ? { status } : {},
597
+ ...pr ? { pr } : {}
598
+ });
599
+ lastFlush = Date.now();
600
+ } catch (err) {
601
+ console.error("[coding-tab] patchTurn failed", err);
602
+ }
603
+ };
604
+ const scheduleFlush = () => {
605
+ if (flushPending) return;
606
+ flushPending = true;
607
+ setTimeout(async () => {
608
+ flushPending = false;
609
+ const elapsed = Date.now() - lastFlush;
610
+ if (elapsed < flushDebounceMs) return;
611
+ await persist();
612
+ }, flushDebounceMs).unref?.();
613
+ };
614
+ let finalStatus = "finished";
615
+ let finalPr;
295
616
  try {
296
617
  for await (const message of run.stream()) {
297
618
  if (res.writableEnded) break;
298
619
  if (message.type === "assistant") {
620
+ ctx.buffer.endTextBlock();
299
621
  for (const block of message.message.content) {
300
- if (block.type === "text" && block.text) sse(res, { kind: "text", text: block.text });
301
- else if (block.type === "tool_use") sse(res, { kind: "tool", name: block.name, status: "running", callId: block.id, args: block.input });
622
+ if (block.type === "text" && block.text) {
623
+ ctx.buffer.pushText(block.text);
624
+ ctx.buffer.endTextBlock();
625
+ sse(res, { kind: "text", text: block.text });
626
+ } else if (block.type === "tool_use") {
627
+ ctx.buffer.upsertTool({
628
+ callId: block.id,
629
+ name: block.name,
630
+ status: "running",
631
+ args: block.input
632
+ });
633
+ sse(res, {
634
+ kind: "tool",
635
+ name: block.name,
636
+ status: "running",
637
+ callId: block.id,
638
+ args: block.input
639
+ });
640
+ }
302
641
  }
303
642
  } else if (message.type === "thinking") {
304
643
  sse(res, { kind: "thinking", text: message.text });
305
644
  } else if (message.type === "tool_call") {
306
- sse(res, { kind: "tool", name: message.name, status: message.status, callId: message.call_id, args: message.args, result: message.result });
645
+ ctx.buffer.upsertTool({
646
+ callId: message.call_id,
647
+ name: message.name,
648
+ status: message.status,
649
+ args: message.args,
650
+ result: message.result
651
+ });
652
+ sse(res, {
653
+ kind: "tool",
654
+ name: message.name,
655
+ status: message.status,
656
+ callId: message.call_id,
657
+ args: message.args,
658
+ result: message.result
659
+ });
307
660
  } else if (message.type === "status") {
308
661
  sse(res, { kind: "status", status: message.status, message: message.message });
309
662
  }
663
+ scheduleFlush();
310
664
  }
311
665
  const result = await run.wait();
312
666
  const prUrl = result.git?.branches?.find((b) => b.prUrl)?.prUrl;
667
+ finalPr = parsePrUrl(prUrl);
668
+ finalStatus = result.status ?? "finished";
313
669
  sse(res, {
314
670
  kind: "result",
315
671
  status: result.status,
316
- pr: parsePrUrl(prUrl),
672
+ pr: finalPr,
317
673
  durationMs: result.durationMs
318
674
  });
319
675
  } catch (err) {
676
+ finalStatus = "error";
320
677
  const message = err instanceof Error ? err.message : String(err);
321
678
  const retryable = err instanceof CursorAgentError ? Boolean(err.isRetryable) : false;
322
679
  sse(res, { kind: "error", message, retryable });
323
680
  } finally {
681
+ await persist(finalStatus, finalPr);
324
682
  if (!res.writableEnded) res.end();
325
683
  }
326
684
  }
685
+ async function ensureAgentForChat(storage, chat, user, opts, modelChoice) {
686
+ const existing = getLiveSession(chat.id);
687
+ if (existing && existing.githubLogin.toLowerCase() === user.githubLogin.toLowerCase()) {
688
+ return { agent: existing.agent, agentId: existing.agentId, resumed: true };
689
+ }
690
+ const resolved = await resolveModel(opts.cursorApiKey, modelChoice);
691
+ const cloud = {
692
+ repos: [
693
+ {
694
+ url: chat.repoUrl,
695
+ startingRef: chat.startingRef
696
+ }
697
+ ],
698
+ autoCreatePR: chat.mode === "agent",
699
+ skipReviewerRequest: opts.skipReviewerRequest ?? true,
700
+ envVars: { GITHUB_TOKEN: user.accessToken },
701
+ ...opts.envName ? { env: { type: "cloud", name: opts.envName } } : {}
702
+ };
703
+ if (chat.agentId) {
704
+ try {
705
+ const resumed = await Agent.resume(chat.agentId, {
706
+ apiKey: opts.cursorApiKey,
707
+ model: { id: resolved.cursorModelId },
708
+ cloud
709
+ });
710
+ registerLiveSession({
711
+ chatId: chat.id,
712
+ githubLogin: user.githubLogin,
713
+ agent: resumed,
714
+ agentId: resumed.agentId
715
+ });
716
+ if (resumed.agentId !== chat.agentId) {
717
+ await storage.patchChat(chat.id, user.githubLogin, { agentId: resumed.agentId });
718
+ }
719
+ return { agent: resumed, agentId: resumed.agentId, resumed: true };
720
+ } catch (err) {
721
+ console.warn(
722
+ `[coding-tab] resume failed for chat=${chat.id} agentId=${chat.agentId}; creating fresh agent`,
723
+ err instanceof Error ? err.message : err
724
+ );
725
+ }
726
+ }
727
+ const fresh = await Agent.create({
728
+ apiKey: opts.cursorApiKey,
729
+ model: { id: resolved.cursorModelId },
730
+ cloud
731
+ });
732
+ registerLiveSession({
733
+ chatId: chat.id,
734
+ githubLogin: user.githubLogin,
735
+ agent: fresh,
736
+ agentId: fresh.agentId
737
+ });
738
+ await storage.patchChat(chat.id, user.githubLogin, { agentId: fresh.agentId });
739
+ return { agent: fresh, agentId: fresh.agentId, resumed: false };
740
+ }
741
+ async function nextSeq(storage, chatId, login) {
742
+ const full = await storage.loadChat(chatId, login);
743
+ if (!full) throw Object.assign(new Error("chat_not_found"), { status: 404 });
744
+ const max = full.turns.reduce((acc, t) => Math.max(acc, t.seq), -1);
745
+ return max + 1;
746
+ }
327
747
  function makeAgentRouter(opts) {
328
748
  const router = Router2();
329
749
  router.get("/models", async (_req, res) => {
@@ -335,144 +755,236 @@ function makeAgentRouter(opts) {
335
755
  res.status(500).json({ error: err instanceof Error ? err.message : "models_failed" });
336
756
  }
337
757
  });
338
- router.post("/agent/start", async (req, res) => {
758
+ const handleSend = async (req, res, isExecute) => {
339
759
  const user = req.user;
340
- const { prompt, mode, model, repoUrl, startingRef } = req.body ?? {};
341
- if (!prompt || mode !== "plan" && mode !== "agent" || model !== "sonnet" && model !== "opus") {
760
+ const body = req.body ?? {};
761
+ if (!body.chatId) {
762
+ res.status(400).json({ error: "missing_chatId" });
763
+ return;
764
+ }
765
+ if (!isExecute && (!body.prompt || body.mode !== "plan" && body.mode !== "agent")) {
342
766
  res.status(400).json({ error: "invalid_request" });
343
767
  return;
344
768
  }
769
+ const chat = await opts.storage.loadChat(body.chatId, user.githubLogin);
770
+ if (!chat) {
771
+ res.status(404).json({ error: "chat_not_found" });
772
+ return;
773
+ }
345
774
  setupSseHeaders(res);
346
- let agent;
775
+ let assistantTurn = null;
347
776
  try {
348
- const resolved = await resolveModel(opts.cursorApiKey, model);
349
- agent = await Agent.create({
350
- apiKey: opts.cursorApiKey,
351
- model: { id: resolved.cursorModelId },
352
- cloud: {
353
- repos: [
354
- {
355
- url: repoUrl ?? opts.defaultRepo.url,
356
- startingRef: startingRef ?? opts.defaultRepo.ref
357
- }
358
- ],
359
- autoCreatePR: mode === "agent",
360
- skipReviewerRequest: opts.skipReviewerRequest ?? true,
361
- envVars: { GITHUB_TOKEN: user.accessToken },
362
- ...opts.envName ? { env: { type: "cloud", name: opts.envName } } : {}
777
+ if (!isExecute && body.mode && body.mode !== chat.mode) {
778
+ chat.mode = body.mode;
779
+ await opts.storage.patchChat(chat.id, user.githubLogin, { mode: body.mode });
780
+ }
781
+ let seq = await nextSeq(opts.storage, chat.id, user.githubLogin);
782
+ if (!isExecute) {
783
+ const userTurn = {
784
+ id: randomUUID2(),
785
+ chatId: chat.id,
786
+ seq: seq++,
787
+ role: "user",
788
+ isPlan: chat.mode === "plan",
789
+ status: "finished",
790
+ events: [{ kind: "text", id: randomUUID2(), text: body.prompt }],
791
+ prompt: body.prompt,
792
+ createdAt: Date.now()
793
+ };
794
+ await opts.storage.appendTurn(userTurn);
795
+ if (chat.title === "New chat") {
796
+ const title = deriveTitle(body.prompt);
797
+ chat.title = title;
798
+ await opts.storage.patchChat(chat.id, user.githubLogin, { title });
363
799
  }
800
+ }
801
+ assistantTurn = {
802
+ id: randomUUID2(),
803
+ chatId: chat.id,
804
+ seq,
805
+ role: "assistant",
806
+ isPlan: !isExecute && chat.mode === "plan",
807
+ status: "running",
808
+ events: [],
809
+ createdAt: Date.now()
810
+ };
811
+ await opts.storage.appendTurn(assistantTurn);
812
+ const { agent } = await ensureAgentForChat(opts.storage, chat, user, opts, chat.model);
813
+ const sendText = isExecute ? EXECUTE_INSTRUCTION : modeInstructionPrefix(body.mode, body.prompt);
814
+ const run = await agent.send(sendText);
815
+ sse(res, {
816
+ kind: "ready",
817
+ chatId: chat.id,
818
+ turnId: assistantTurn.id,
819
+ agentId: agent.agentId,
820
+ runId: run.id
364
821
  });
365
- const session = registerSession({
366
- githubLogin: user.githubLogin,
367
- agent,
368
- repoUrl: repoUrl ?? opts.defaultRepo.url,
369
- startingRef: startingRef ?? opts.defaultRepo.ref
822
+ console.log(
823
+ `[coding-tab] ${isExecute ? "execute" : "send"} agent=${agent.agentId} run=${run.id} chat=${chat.id} login=${user.githubLogin}`
824
+ );
825
+ const buffer = new TurnBuffer();
826
+ await streamRun(res, run, {
827
+ storage: opts.storage,
828
+ chat,
829
+ turn: assistantTurn,
830
+ buffer
370
831
  });
371
- const run = await agent.send(modeInstructionPrefix(mode, prompt));
372
- sse(res, { kind: "ready", sessionId: session.id, agentId: agent.agentId, runId: run.id });
373
- console.log(`[coding-tab] start agent=${agent.agentId} run=${run.id} session=${session.id} login=${user.githubLogin}`);
374
- await streamRun(res, run);
375
832
  } catch (err) {
376
833
  const message = err instanceof Error ? err.message : String(err);
377
834
  const retryable = err instanceof CursorAgentError ? Boolean(err.isRetryable) : false;
378
- console.error("[coding-tab] /agent/start failed", err);
835
+ console.error(`[coding-tab] /agent/${isExecute ? "execute" : "send"} failed`, err);
379
836
  sse(res, { kind: "error", message, retryable });
837
+ if (assistantTurn) {
838
+ await opts.storage.patchTurn(assistantTurn.chatId, assistantTurn.id, {
839
+ status: "error",
840
+ events: [
841
+ ...assistantTurn.events,
842
+ { kind: "text", id: randomUUID2(), text: `[error] ${message}` }
843
+ ]
844
+ }).catch(() => {
845
+ });
846
+ }
380
847
  if (!res.writableEnded) res.end();
381
- if (agent) await agent[Symbol.asyncDispose]().catch(() => {
382
- });
383
848
  }
384
849
  req.on("close", () => {
385
850
  if (!res.writableEnded) res.end();
386
851
  });
387
- });
388
- router.post("/agent/send", async (req, res) => {
389
- const { sessionId, prompt, mode } = req.body ?? {};
390
- if (!sessionId || !prompt || mode !== "plan" && mode !== "agent") {
852
+ };
853
+ router.post("/agent/start", (req, res) => handleSend(req, res, false));
854
+ router.post("/agent/send", (req, res) => handleSend(req, res, false));
855
+ router.post("/agent/execute", (req, res) => handleSend(req, res, true));
856
+ router.post("/agent/cancel", async (req, res) => {
857
+ const { chatId, runId } = req.body ?? {};
858
+ if (!chatId || !runId) {
391
859
  res.status(400).json({ error: "invalid_request" });
392
860
  return;
393
861
  }
394
- const session = getSession2(sessionId);
395
- if (!session) {
862
+ const live = getLiveSession(chatId);
863
+ if (!live) {
396
864
  res.status(404).json({ error: "session_not_found" });
397
865
  return;
398
866
  }
399
- setupSseHeaders(res);
400
867
  try {
401
- const run = await session.agent.send(modeInstructionPrefix(mode, prompt));
402
- sse(res, { kind: "ready", sessionId: session.id, agentId: session.agent.agentId, runId: run.id });
403
- console.log(`[coding-tab] send agent=${session.agent.agentId} run=${run.id} session=${session.id}`);
404
- await streamRun(res, run);
868
+ await Agent.cancelRun(runId, {
869
+ runtime: "cloud",
870
+ agentId: live.agentId,
871
+ apiKey: opts.cursorApiKey
872
+ });
873
+ res.json({ ok: true });
405
874
  } catch (err) {
406
875
  const message = err instanceof Error ? err.message : String(err);
407
- console.error("[coding-tab] /agent/send failed", err);
408
- sse(res, { kind: "error", message });
409
- if (!res.writableEnded) res.end();
876
+ console.error("[coding-tab] /agent/cancel failed", err);
877
+ res.status(500).json({ error: message });
410
878
  }
411
- req.on("close", () => {
412
- if (!res.writableEnded) res.end();
413
- });
414
879
  });
415
- router.post("/agent/execute", async (req, res) => {
416
- const { sessionId } = req.body ?? {};
417
- if (!sessionId) {
880
+ router.post("/agent/dispose", async (req, res) => {
881
+ const { chatId } = req.body ?? {};
882
+ if (!chatId) {
418
883
  res.status(400).json({ error: "invalid_request" });
419
884
  return;
420
885
  }
421
- const session = getSession2(sessionId);
422
- if (!session) {
423
- res.status(404).json({ error: "session_not_found" });
424
- return;
425
- }
426
- setupSseHeaders(res);
886
+ await disposeSessionsForChat(chatId);
887
+ res.json({ ok: true });
888
+ });
889
+ return router;
890
+ }
891
+
892
+ // src/server/chatRoutes.ts
893
+ import { Router as Router3 } from "express";
894
+ import { randomUUID as randomUUID3 } from "crypto";
895
+ var VALID_MODES = ["plan", "agent"];
896
+ var VALID_MODELS = ["sonnet", "opus"];
897
+ function makeChatRouter(opts) {
898
+ const router = Router3();
899
+ router.get("/chats", async (req, res) => {
900
+ const user = req.user;
427
901
  try {
428
- const run = await session.agent.send(EXECUTE_INSTRUCTION);
429
- sse(res, { kind: "ready", sessionId: session.id, agentId: session.agent.agentId, runId: run.id });
430
- console.log(`[coding-tab] execute agent=${session.agent.agentId} run=${run.id} session=${session.id}`);
431
- await streamRun(res, run);
902
+ const items = await opts.storage.listChats(user.githubLogin);
903
+ res.json({ chats: items });
432
904
  } catch (err) {
433
- const message = err instanceof Error ? err.message : String(err);
434
- console.error("[coding-tab] /agent/execute failed", err);
435
- sse(res, { kind: "error", message });
436
- if (!res.writableEnded) res.end();
905
+ console.error("[coding-tab] /chats list failed", err);
906
+ res.status(500).json({ error: err instanceof Error ? err.message : "list_failed" });
437
907
  }
438
- req.on("close", () => {
439
- if (!res.writableEnded) res.end();
440
- });
441
908
  });
442
- router.post("/agent/cancel", async (req, res) => {
443
- const { sessionId, runId } = req.body ?? {};
444
- if (!sessionId || !runId) {
445
- res.status(400).json({ error: "invalid_request" });
446
- return;
909
+ router.post("/chats", async (req, res) => {
910
+ const user = req.user;
911
+ const body = req.body ?? {};
912
+ const mode = VALID_MODES.includes(body.mode) ? body.mode : "plan";
913
+ const model = VALID_MODELS.includes(body.model) ? body.model : "sonnet";
914
+ const now = Date.now();
915
+ const chat = {
916
+ id: randomUUID3(),
917
+ githubLogin: user.githubLogin,
918
+ title: (body.title ?? "").trim() || "New chat",
919
+ mode,
920
+ model,
921
+ repoUrl: body.repoUrl?.trim() || opts.defaultRepo.url,
922
+ startingRef: body.startingRef ?? opts.defaultRepo.ref,
923
+ createdAt: now,
924
+ updatedAt: now
925
+ };
926
+ try {
927
+ await opts.storage.createChat(chat);
928
+ res.status(201).json({ chat });
929
+ } catch (err) {
930
+ console.error("[coding-tab] /chats create failed", err);
931
+ res.status(500).json({ error: err instanceof Error ? err.message : "create_failed" });
447
932
  }
448
- const session = getSession2(sessionId);
449
- if (!session) {
450
- res.status(404).json({ error: "session_not_found" });
451
- return;
933
+ });
934
+ router.get("/chats/:id", async (req, res) => {
935
+ const user = req.user;
936
+ try {
937
+ const full = await opts.storage.loadChat(req.params.id, user.githubLogin);
938
+ if (!full) {
939
+ res.status(404).json({ error: "chat_not_found" });
940
+ return;
941
+ }
942
+ res.json({ chat: full });
943
+ } catch (err) {
944
+ console.error("[coding-tab] /chats/:id load failed", err);
945
+ res.status(500).json({ error: err instanceof Error ? err.message : "load_failed" });
452
946
  }
947
+ });
948
+ router.patch("/chats/:id", async (req, res) => {
949
+ const user = req.user;
950
+ const body = req.body ?? {};
951
+ const patch = {};
952
+ if (typeof body.title === "string") patch.title = body.title.trim() || "Untitled";
953
+ if (body.mode && VALID_MODES.includes(body.mode)) patch.mode = body.mode;
954
+ if (body.model && VALID_MODELS.includes(body.model)) patch.model = body.model;
453
955
  try {
454
- await Agent.cancelRun(runId, { runtime: "cloud", agentId: session.agent.agentId, apiKey: opts.cursorApiKey });
455
- res.json({ ok: true });
956
+ const updated = await opts.storage.patchChat(req.params.id, user.githubLogin, patch);
957
+ if (!updated) {
958
+ res.status(404).json({ error: "chat_not_found" });
959
+ return;
960
+ }
961
+ res.json({ chat: updated });
456
962
  } catch (err) {
457
- const message = err instanceof Error ? err.message : String(err);
458
- console.error("[coding-tab] /agent/cancel failed", err);
459
- res.status(500).json({ error: message });
963
+ console.error("[coding-tab] /chats/:id patch failed", err);
964
+ res.status(500).json({ error: err instanceof Error ? err.message : "patch_failed" });
460
965
  }
461
966
  });
462
- router.post("/agent/dispose", async (req, res) => {
463
- const { sessionId } = req.body ?? {};
464
- if (!sessionId) {
465
- res.status(400).json({ error: "invalid_request" });
466
- return;
967
+ router.delete("/chats/:id", async (req, res) => {
968
+ const user = req.user;
969
+ try {
970
+ const removed = await opts.storage.deleteChat(req.params.id, user.githubLogin);
971
+ if (!removed) {
972
+ res.status(404).json({ error: "chat_not_found" });
973
+ return;
974
+ }
975
+ await disposeSessionsForChat(req.params.id).catch(() => {
976
+ });
977
+ res.json({ ok: true });
978
+ } catch (err) {
979
+ console.error("[coding-tab] /chats/:id delete failed", err);
980
+ res.status(500).json({ error: err instanceof Error ? err.message : "delete_failed" });
467
981
  }
468
- await disposeSession(sessionId);
469
- res.json({ ok: true });
470
982
  });
471
983
  return router;
472
984
  }
473
985
 
474
986
  // src/server/githubRoutes.ts
475
- import { Router as Router3 } from "express";
987
+ import { Router as Router4 } from "express";
476
988
  import { Octokit } from "@octokit/rest";
477
989
  function parsePrUrl2(prUrl) {
478
990
  const match = prUrl.match(/github\.com\/([^/]+)\/([^/]+)\/pull\/(\d+)/);
@@ -480,7 +992,7 @@ function parsePrUrl2(prUrl) {
480
992
  return { owner: match[1], repo: match[2], number: Number(match[3]) };
481
993
  }
482
994
  function makeGitHubRouter() {
483
- const router = Router3();
995
+ const router = Router4();
484
996
  router.get("/pr/status", async (req, res) => {
485
997
  const prUrl = typeof req.query.prUrl === "string" ? req.query.prUrl : null;
486
998
  if (!prUrl) {
@@ -526,6 +1038,21 @@ function makeGitHubRouter() {
526
1038
  const user = req.user;
527
1039
  const octokit = new Octokit({ auth: user.accessToken });
528
1040
  try {
1041
+ const pr = await octokit.pulls.get({
1042
+ owner: parsed.owner,
1043
+ repo: parsed.repo,
1044
+ pull_number: parsed.number
1045
+ });
1046
+ if (pr.data.draft) {
1047
+ await octokit.graphql(
1048
+ `mutation($id: ID!) {
1049
+ markPullRequestReadyForReview(input: { pullRequestId: $id }) {
1050
+ pullRequest { id }
1051
+ }
1052
+ }`,
1053
+ { id: pr.data.node_id }
1054
+ );
1055
+ }
529
1056
  const merge = await octokit.pulls.merge({
530
1057
  owner: parsed.owner,
531
1058
  repo: parsed.repo,
@@ -535,34 +1062,35 @@ function makeGitHubRouter() {
535
1062
  res.json({ sha: merge.data.sha, merged: merge.data.merged });
536
1063
  } catch (err) {
537
1064
  const message = err instanceof Error ? err.message : String(err);
1065
+ const status = err.status ?? 500;
538
1066
  console.error("[coding-tab] /pr/merge failed", err);
539
- res.status(500).json({ error: message });
1067
+ res.status(status >= 400 && status < 600 ? status : 500).json({ error: message });
540
1068
  }
541
1069
  });
542
1070
  return router;
543
1071
  }
544
1072
 
545
1073
  // src/server/staticAssets.ts
546
- import { Router as Router4 } from "express";
547
- import { readFile } from "fs/promises";
548
- import { resolve, dirname } from "path";
1074
+ import { Router as Router5 } from "express";
1075
+ import { readFile as readFile2 } from "fs/promises";
1076
+ import { resolve as resolve2, dirname as dirname2 } from "path";
549
1077
  import { fileURLToPath } from "url";
550
- var here = dirname(fileURLToPath(import.meta.url));
1078
+ var here = dirname2(fileURLToPath(import.meta.url));
551
1079
  var ASSET_CANDIDATES = [
552
- resolve(here, "browser.js"),
553
- resolve(here, "..", "dist", "browser.js"),
554
- resolve(here, "..", "browser.js")
1080
+ resolve2(here, "browser.js"),
1081
+ resolve2(here, "..", "dist", "browser.js"),
1082
+ resolve2(here, "..", "browser.js")
555
1083
  ];
556
1084
  var STYLE_CANDIDATES = [
557
- resolve(here, "style.css"),
558
- resolve(here, "..", "dist", "style.css"),
559
- resolve(here, "..", "style.css")
1085
+ resolve2(here, "style.css"),
1086
+ resolve2(here, "..", "dist", "style.css"),
1087
+ resolve2(here, "..", "style.css")
560
1088
  ];
561
1089
  async function readFirst(paths) {
562
1090
  let lastErr;
563
1091
  for (const p of paths) {
564
1092
  try {
565
- const data = await readFile(p);
1093
+ const data = await readFile2(p);
566
1094
  return { path: p, data };
567
1095
  } catch (err) {
568
1096
  lastErr = err;
@@ -571,12 +1099,12 @@ async function readFirst(paths) {
571
1099
  throw lastErr instanceof Error ? lastErr : new Error("asset not found");
572
1100
  }
573
1101
  function makeAssetRouter(basePath) {
574
- const router = Router4();
1102
+ const router = Router5();
575
1103
  router.get("/browser.js", async (_req, res) => {
576
1104
  try {
577
1105
  const { data } = await readFirst(ASSET_CANDIDATES);
578
1106
  res.set("Content-Type", "application/javascript; charset=utf-8");
579
- res.set("Cache-Control", "public, max-age=300");
1107
+ res.set("Cache-Control", "public, max-age=0, must-revalidate");
580
1108
  res.send(data);
581
1109
  } catch (err) {
582
1110
  res.status(500).send("// coding-tab browser bundle missing \u2014 did you run `npm run build`?");
@@ -586,7 +1114,7 @@ function makeAssetRouter(basePath) {
586
1114
  try {
587
1115
  const { data } = await readFirst(STYLE_CANDIDATES);
588
1116
  res.set("Content-Type", "text/css; charset=utf-8");
589
- res.set("Cache-Control", "public, max-age=300");
1117
+ res.set("Cache-Control", "public, max-age=0, must-revalidate");
590
1118
  res.send(data);
591
1119
  } catch {
592
1120
  res.status(404).send("/* style.css missing */");
@@ -638,6 +1166,7 @@ function mountCodingTab(app, options) {
638
1166
  if (!options.githubOAuth.allowedLogins || options.githubOAuth.allowedLogins.length === 0) {
639
1167
  console.warn("[coding-tab] WARNING: allowedLogins is empty \u2014 anyone with a GitHub account can sign in.");
640
1168
  }
1169
+ const storage = options.storage ?? createDefaultStorage(options.dataDir);
641
1170
  const router = express.Router();
642
1171
  router.use(express.json({ limit: "1mb" }));
643
1172
  const assetRouter = makeAssetRouter(basePath);
@@ -656,11 +1185,14 @@ function mountCodingTab(app, options) {
656
1185
  secure,
657
1186
  allowedLogins: options.githubOAuth.allowedLogins
658
1187
  });
1188
+ const chatRouter = makeChatRouter({ storage, defaultRepo: options.defaultRepo });
1189
+ router.use(requireAuth, chatRouter);
659
1190
  const agentRouter = makeAgentRouter({
660
1191
  cursorApiKey: options.cursorApiKey,
661
1192
  defaultRepo: options.defaultRepo,
662
1193
  envName: options.envName,
663
- skipReviewerRequest: options.skipReviewerRequest
1194
+ skipReviewerRequest: options.skipReviewerRequest,
1195
+ storage
664
1196
  });
665
1197
  router.use(requireAuth, agentRouter);
666
1198
  const githubRouter = makeGitHubRouter();
@@ -670,6 +1202,8 @@ function mountCodingTab(app, options) {
670
1202
  return router;
671
1203
  }
672
1204
  export {
1205
+ FileChatStorage,
1206
+ createDefaultStorage,
673
1207
  mountCodingTab
674
1208
  };
675
1209
  //# sourceMappingURL=server.js.map