@sna-sdk/core 0.1.1 → 0.3.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.
@@ -15,11 +15,20 @@ import { createRequire } from "module";
15
15
  import fs from "fs";
16
16
  import path from "path";
17
17
  var DB_PATH = path.join(process.cwd(), "data/sna.db");
18
+ var NATIVE_DIR = path.join(process.cwd(), ".sna/native");
18
19
  var _db = null;
20
+ function loadBetterSqlite3() {
21
+ const nativeEntry = path.join(NATIVE_DIR, "node_modules", "better-sqlite3");
22
+ if (fs.existsSync(nativeEntry)) {
23
+ const req2 = createRequire(path.join(NATIVE_DIR, "noop.js"));
24
+ return req2("better-sqlite3");
25
+ }
26
+ const req = createRequire(import.meta.url);
27
+ return req("better-sqlite3");
28
+ }
19
29
  function getDb() {
20
30
  if (!_db) {
21
- const req = createRequire(import.meta.url);
22
- const BetterSqlite3 = req("better-sqlite3");
31
+ const BetterSqlite3 = loadBetterSqlite3();
23
32
  const dir = path.dirname(DB_PATH);
24
33
  if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
25
34
  _db = new BetterSqlite3(DB_PATH);
@@ -41,6 +50,12 @@ function migrateChatSessionsMeta(db) {
41
50
  if (cols.length > 0 && !cols.some((c) => c.name === "meta")) {
42
51
  db.exec("ALTER TABLE chat_sessions ADD COLUMN meta TEXT");
43
52
  }
53
+ if (cols.length > 0 && !cols.some((c) => c.name === "cwd")) {
54
+ db.exec("ALTER TABLE chat_sessions ADD COLUMN cwd TEXT");
55
+ }
56
+ if (cols.length > 0 && !cols.some((c) => c.name === "last_start_config")) {
57
+ db.exec("ALTER TABLE chat_sessions ADD COLUMN last_start_config TEXT");
58
+ }
44
59
  }
45
60
  function initSchema(db) {
46
61
  migrateSkillEvents(db);
@@ -51,6 +66,8 @@ function initSchema(db) {
51
66
  label TEXT NOT NULL DEFAULT '',
52
67
  type TEXT NOT NULL DEFAULT 'main',
53
68
  meta TEXT,
69
+ cwd TEXT,
70
+ last_start_config TEXT,
54
71
  created_at TEXT NOT NULL DEFAULT (datetime('now'))
55
72
  );
56
73
 
@@ -136,17 +153,41 @@ function eventsRoute(c) {
136
153
  });
137
154
  }
138
155
 
156
+ // src/server/api-types.ts
157
+ function httpJson(c, _op, data, status) {
158
+ return c.json(data, status);
159
+ }
160
+ function wsReply(ws, msg, data) {
161
+ if (ws.readyState !== ws.OPEN) return;
162
+ const out = { ...data, type: msg.type };
163
+ if (msg.rid != null) out.rid = msg.rid;
164
+ ws.send(JSON.stringify(out));
165
+ }
166
+
139
167
  // src/server/routes/emit.ts
140
- async function emitRoute(c) {
141
- const { skill, type, message, data } = await c.req.json();
142
- if (!skill || !type || !message) {
143
- return c.json({ error: "missing fields" }, 400);
144
- }
145
- const db = getDb();
146
- const result = db.prepare(
147
- `INSERT INTO skill_events (skill, type, message, data) VALUES (?, ?, ?, ?)`
148
- ).run(skill, type, message, data ?? null);
149
- return c.json({ id: result.lastInsertRowid });
168
+ function createEmitRoute(sessionManager2) {
169
+ return async (c) => {
170
+ const body = await c.req.json();
171
+ const { skill, type, message, data, session_id } = body;
172
+ if (!skill || !type || !message) {
173
+ return c.json({ error: "missing fields" }, 400);
174
+ }
175
+ const db = getDb();
176
+ const result = db.prepare(
177
+ `INSERT INTO skill_events (session_id, skill, type, message, data) VALUES (?, ?, ?, ?, ?)`
178
+ ).run(session_id ?? null, skill, type, message, data ?? null);
179
+ const id = Number(result.lastInsertRowid);
180
+ sessionManager2.broadcastSkillEvent({
181
+ id,
182
+ session_id: session_id ?? null,
183
+ skill,
184
+ type,
185
+ message,
186
+ data: data ?? null,
187
+ created_at: (/* @__PURE__ */ new Date()).toISOString()
188
+ });
189
+ return httpJson(c, "emit", { id });
190
+ };
150
191
  }
151
192
 
152
193
  // src/server/routes/run.ts
@@ -231,6 +272,7 @@ var tags = {
231
272
  stdin: chalk.bold.green(" IN "),
232
273
  stdout: chalk.bold.yellow(" OUT "),
233
274
  route: chalk.bold.blue(" API "),
275
+ ws: chalk.bold.green(" WS "),
234
276
  err: chalk.bold.red(" ERR ")
235
277
  };
236
278
  var tagPlain = {
@@ -240,6 +282,7 @@ var tagPlain = {
240
282
  stdin: " IN ",
241
283
  stdout: " OUT ",
242
284
  route: " API ",
285
+ ws: " WS ",
243
286
  err: " ERR "
244
287
  };
245
288
  function appendFile(tag, args) {
@@ -261,6 +304,7 @@ var logger = { log, err };
261
304
  // src/core/providers/claude-code.ts
262
305
  var SHELL = process.env.SHELL || "/bin/zsh";
263
306
  function resolveClaudePath(cwd) {
307
+ if (process.env.SNA_CLAUDE_COMMAND) return process.env.SNA_CLAUDE_COMMAND;
264
308
  const cached = path3.join(cwd, ".sna/claude-path");
265
309
  if (fs3.existsSync(cached)) {
266
310
  const p = fs3.readFileSync(cached, "utf8").trim();
@@ -294,6 +338,7 @@ var ClaudeCodeProcess = class {
294
338
  this.emitter = new EventEmitter();
295
339
  this._alive = true;
296
340
  this._sessionId = null;
341
+ this._initEmitted = false;
297
342
  this.buffer = "";
298
343
  this.proc = proc;
299
344
  proc.stdout.on("data", (chunk) => {
@@ -333,6 +378,29 @@ var ClaudeCodeProcess = class {
333
378
  this._alive = false;
334
379
  this.emitter.emit("error", err2);
335
380
  });
381
+ if (options.history?.length) {
382
+ if (!options.prompt) {
383
+ throw new Error("history requires a prompt \u2014 the last stdin message must be a user message");
384
+ }
385
+ for (const msg of options.history) {
386
+ if (msg.role === "user") {
387
+ const line = JSON.stringify({
388
+ type: "user",
389
+ message: { role: "user", content: msg.content }
390
+ });
391
+ this.proc.stdin.write(line + "\n");
392
+ } else if (msg.role === "assistant") {
393
+ const line = JSON.stringify({
394
+ type: "assistant",
395
+ message: {
396
+ role: "assistant",
397
+ content: [{ type: "text", text: msg.content }]
398
+ }
399
+ });
400
+ this.proc.stdin.write(line + "\n");
401
+ }
402
+ }
403
+ }
336
404
  if (options.prompt) {
337
405
  this.send(options.prompt);
338
406
  }
@@ -345,16 +413,42 @@ var ClaudeCodeProcess = class {
345
413
  }
346
414
  /**
347
415
  * Send a user message to the persistent Claude process via stdin.
416
+ * Accepts plain string or content block array (text + images).
348
417
  */
349
418
  send(input) {
350
419
  if (!this._alive || !this.proc.stdin.writable) return;
420
+ const content = typeof input === "string" ? input : input;
351
421
  const msg = JSON.stringify({
352
422
  type: "user",
353
- message: { role: "user", content: input }
423
+ message: { role: "user", content }
354
424
  });
355
425
  logger.log("stdin", msg.slice(0, 200));
356
426
  this.proc.stdin.write(msg + "\n");
357
427
  }
428
+ interrupt() {
429
+ if (!this._alive || !this.proc.stdin.writable) return;
430
+ const msg = JSON.stringify({
431
+ type: "control_request",
432
+ request: { subtype: "interrupt" }
433
+ });
434
+ this.proc.stdin.write(msg + "\n");
435
+ }
436
+ setModel(model) {
437
+ if (!this._alive || !this.proc.stdin.writable) return;
438
+ const msg = JSON.stringify({
439
+ type: "control_request",
440
+ request: { subtype: "set_model", model }
441
+ });
442
+ this.proc.stdin.write(msg + "\n");
443
+ }
444
+ setPermissionMode(mode) {
445
+ if (!this._alive || !this.proc.stdin.writable) return;
446
+ const msg = JSON.stringify({
447
+ type: "control_request",
448
+ request: { subtype: "set_permission_mode", permission_mode: mode }
449
+ });
450
+ this.proc.stdin.write(msg + "\n");
451
+ }
358
452
  kill() {
359
453
  if (this._alive) {
360
454
  this._alive = false;
@@ -371,6 +465,8 @@ var ClaudeCodeProcess = class {
371
465
  switch (msg.type) {
372
466
  case "system": {
373
467
  if (msg.subtype === "init") {
468
+ if (this._initEmitted) return null;
469
+ this._initEmitted = true;
374
470
  return {
375
471
  type: "init",
376
472
  message: `Agent ready (${msg.model ?? "unknown"})`,
@@ -452,7 +548,15 @@ var ClaudeCodeProcess = class {
452
548
  timestamp: Date.now()
453
549
  };
454
550
  }
455
- if (msg.subtype === "error" || msg.is_error) {
551
+ if (msg.subtype === "error_during_execution" && msg.is_error === false) {
552
+ return {
553
+ type: "interrupted",
554
+ message: "Turn interrupted by user",
555
+ data: { durationMs: msg.duration_ms, costUsd: msg.total_cost_usd },
556
+ timestamp: Date.now()
557
+ };
558
+ }
559
+ if (msg.subtype?.startsWith("error") || msg.is_error) {
456
560
  return {
457
561
  type: "error",
458
562
  message: msg.result ?? msg.error ?? "Unknown error",
@@ -483,14 +587,18 @@ var ClaudeCodeProvider = class {
483
587
  }
484
588
  }
485
589
  spawn(options) {
486
- const claudePath = resolveClaudePath(options.cwd);
487
- const hookScript = path3.join(options.cwd, "node_modules/@sna-sdk/core/dist/scripts/hook.js");
590
+ const claudeCommand = resolveClaudePath(options.cwd);
591
+ const claudeParts = claudeCommand.split(/\s+/);
592
+ const claudePath = claudeParts[0];
593
+ const claudePrefix = claudeParts.slice(1);
594
+ const hookScript = new URL("../../scripts/hook.js", import.meta.url).pathname;
595
+ const sessionId = options.env?.SNA_SESSION_ID ?? "default";
488
596
  const sdkSettings = {};
489
597
  if (options.permissionMode !== "bypassPermissions") {
490
598
  sdkSettings.hooks = {
491
599
  PreToolUse: [{
492
600
  matcher: ".*",
493
- hooks: [{ type: "command", command: `node "${hookScript}"` }]
601
+ hooks: [{ type: "command", command: `node "${hookScript}" --session=${sessionId}` }]
494
602
  }]
495
603
  };
496
604
  }
@@ -539,12 +647,13 @@ var ClaudeCodeProvider = class {
539
647
  delete cleanEnv.CLAUDECODE;
540
648
  delete cleanEnv.CLAUDE_CODE_ENTRYPOINT;
541
649
  delete cleanEnv.CLAUDE_CODE_SESSION_ACCESS_TOKEN;
542
- const proc = spawn2(claudePath, args, {
650
+ delete cleanEnv.CLAUDE_CODE_OAUTH_TOKEN;
651
+ const proc = spawn2(claudePath, [...claudePrefix, ...args], {
543
652
  cwd: options.cwd,
544
653
  env: cleanEnv,
545
654
  stdio: ["pipe", "pipe", "pipe"]
546
655
  });
547
- logger.log("agent", `spawned claude-code (pid=${proc.pid}) \u2192 ${claudePath} ${args.join(" ")}`);
656
+ logger.log("agent", `spawned claude-code (pid=${proc.pid}) \u2192 ${claudeCommand} ${args.join(" ")}`);
548
657
  return new ClaudeCodeProcess(proc, options);
549
658
  }
550
659
  };
@@ -573,10 +682,94 @@ function getProvider(name = "claude-code") {
573
682
  return provider2;
574
683
  }
575
684
 
685
+ // src/server/image-store.ts
686
+ import fs4 from "fs";
687
+ import path4 from "path";
688
+ import { createHash } from "crypto";
689
+ var IMAGE_DIR = path4.join(process.cwd(), "data/images");
690
+ var MIME_TO_EXT = {
691
+ "image/png": "png",
692
+ "image/jpeg": "jpg",
693
+ "image/gif": "gif",
694
+ "image/webp": "webp",
695
+ "image/svg+xml": "svg"
696
+ };
697
+ function saveImages(sessionId, images) {
698
+ const dir = path4.join(IMAGE_DIR, sessionId);
699
+ fs4.mkdirSync(dir, { recursive: true });
700
+ return images.map((img) => {
701
+ const ext = MIME_TO_EXT[img.mimeType] ?? "bin";
702
+ const hash = createHash("sha256").update(img.base64).digest("hex").slice(0, 12);
703
+ const filename = `${hash}.${ext}`;
704
+ const filePath = path4.join(dir, filename);
705
+ if (!fs4.existsSync(filePath)) {
706
+ fs4.writeFileSync(filePath, Buffer.from(img.base64, "base64"));
707
+ }
708
+ return filename;
709
+ });
710
+ }
711
+ function resolveImagePath(sessionId, filename) {
712
+ if (filename.includes("..") || filename.includes("/")) return null;
713
+ const filePath = path4.join(IMAGE_DIR, sessionId, filename);
714
+ return fs4.existsSync(filePath) ? filePath : null;
715
+ }
716
+
576
717
  // src/server/routes/agent.ts
577
718
  function getSessionId(c) {
578
719
  return c.req.query("session") ?? "default";
579
720
  }
721
+ var DEFAULT_RUN_ONCE_TIMEOUT = 12e4;
722
+ async function runOnce(sessionManager2, opts) {
723
+ const sessionId = `run-once-${crypto.randomUUID().slice(0, 8)}`;
724
+ const timeout = opts.timeout ?? DEFAULT_RUN_ONCE_TIMEOUT;
725
+ const session = sessionManager2.createSession({
726
+ id: sessionId,
727
+ label: "run-once",
728
+ cwd: opts.cwd ?? process.cwd()
729
+ });
730
+ const provider2 = getProvider(opts.provider ?? "claude-code");
731
+ const extraArgs = opts.extraArgs ? [...opts.extraArgs] : [];
732
+ if (opts.systemPrompt) extraArgs.push("--system-prompt", opts.systemPrompt);
733
+ if (opts.appendSystemPrompt) extraArgs.push("--append-system-prompt", opts.appendSystemPrompt);
734
+ const proc = provider2.spawn({
735
+ cwd: session.cwd,
736
+ prompt: opts.message,
737
+ model: opts.model ?? "claude-sonnet-4-6",
738
+ permissionMode: opts.permissionMode ?? "bypassPermissions",
739
+ env: { SNA_SESSION_ID: sessionId },
740
+ extraArgs
741
+ });
742
+ sessionManager2.setProcess(sessionId, proc);
743
+ try {
744
+ const result = await new Promise((resolve, reject) => {
745
+ const texts = [];
746
+ let usage = null;
747
+ const timer = setTimeout(() => {
748
+ reject(new Error(`run-once timed out after ${timeout}ms`));
749
+ }, timeout);
750
+ const unsub = sessionManager2.onSessionEvent(sessionId, (_cursor, e) => {
751
+ if (e.type === "assistant" && e.message) {
752
+ texts.push(e.message);
753
+ }
754
+ if (e.type === "complete") {
755
+ clearTimeout(timer);
756
+ unsub();
757
+ usage = e.data ?? null;
758
+ resolve({ result: texts.join("\n"), usage });
759
+ }
760
+ if (e.type === "error") {
761
+ clearTimeout(timer);
762
+ unsub();
763
+ reject(new Error(e.message ?? "Agent error"));
764
+ }
765
+ });
766
+ });
767
+ return result;
768
+ } finally {
769
+ sessionManager2.killSession(sessionId);
770
+ sessionManager2.removeSession(sessionId);
771
+ }
772
+ }
580
773
  function createAgentRoutes(sessionManager2) {
581
774
  const app = new Hono();
582
775
  app.post("/sessions", async (c) => {
@@ -587,22 +780,15 @@ function createAgentRoutes(sessionManager2) {
587
780
  cwd: body.cwd,
588
781
  meta: body.meta
589
782
  });
590
- try {
591
- const db = getDb();
592
- db.prepare(
593
- `INSERT OR IGNORE INTO chat_sessions (id, label, type, meta) VALUES (?, ?, 'main', ?)`
594
- ).run(session.id, session.label, session.meta ? JSON.stringify(session.meta) : null);
595
- } catch {
596
- }
597
783
  logger.log("route", `POST /sessions \u2192 created "${session.id}"`);
598
- return c.json({ status: "created", sessionId: session.id, label: session.label, meta: session.meta });
784
+ return httpJson(c, "sessions.create", { status: "created", sessionId: session.id, label: session.label, meta: session.meta });
599
785
  } catch (e) {
600
786
  logger.err("err", `POST /sessions \u2192 ${e.message}`);
601
787
  return c.json({ status: "error", message: e.message }, 409);
602
788
  }
603
789
  });
604
790
  app.get("/sessions", (c) => {
605
- return c.json({ sessions: sessionManager2.listSessions() });
791
+ return httpJson(c, "sessions.list", { sessions: sessionManager2.listSessions() });
606
792
  });
607
793
  app.delete("/sessions/:id", (c) => {
608
794
  const id = c.req.param("id");
@@ -614,18 +800,33 @@ function createAgentRoutes(sessionManager2) {
614
800
  return c.json({ status: "error", message: "Session not found" }, 404);
615
801
  }
616
802
  logger.log("route", `DELETE /sessions/${id} \u2192 removed`);
617
- return c.json({ status: "removed" });
803
+ return httpJson(c, "sessions.remove", { status: "removed" });
804
+ });
805
+ app.post("/run-once", async (c) => {
806
+ const body = await c.req.json().catch(() => ({}));
807
+ if (!body.message) {
808
+ return c.json({ status: "error", message: "message is required" }, 400);
809
+ }
810
+ try {
811
+ const result = await runOnce(sessionManager2, body);
812
+ return httpJson(c, "agent.run-once", result);
813
+ } catch (e) {
814
+ logger.err("err", `POST /run-once \u2192 ${e.message}`);
815
+ return c.json({ status: "error", message: e.message }, 500);
816
+ }
618
817
  });
619
818
  app.post("/start", async (c) => {
620
819
  const sessionId = getSessionId(c);
621
820
  const body = await c.req.json().catch(() => ({}));
622
- const session = sessionManager2.getOrCreateSession(sessionId);
821
+ const session = sessionManager2.getOrCreateSession(sessionId, {
822
+ cwd: body.cwd
823
+ });
623
824
  if (session.process?.alive && !body.force) {
624
825
  logger.log("route", `POST /start?session=${sessionId} \u2192 already_running`);
625
- return c.json({
826
+ return httpJson(c, "agent.start", {
626
827
  status: "already_running",
627
828
  provider: "claude-code",
628
- sessionId: session.process.sessionId
829
+ sessionId: session.process.sessionId ?? session.id
629
830
  });
630
831
  }
631
832
  if (session.process?.alive) {
@@ -647,18 +848,24 @@ function createAgentRoutes(sessionManager2) {
647
848
  }
648
849
  } catch {
649
850
  }
851
+ const providerName = body.provider ?? "claude-code";
852
+ const model = body.model ?? "claude-sonnet-4-6";
853
+ const permissionMode2 = body.permissionMode ?? "acceptEdits";
854
+ const extraArgs = body.extraArgs;
650
855
  try {
651
856
  const proc = provider2.spawn({
652
857
  cwd: session.cwd,
653
858
  prompt: body.prompt,
654
- model: body.model ?? "claude-sonnet-4-6",
655
- permissionMode: body.permissionMode ?? "acceptEdits",
859
+ model,
860
+ permissionMode: permissionMode2,
656
861
  env: { SNA_SESSION_ID: sessionId },
657
- extraArgs: body.extraArgs
862
+ history: body.history,
863
+ extraArgs
658
864
  });
659
865
  sessionManager2.setProcess(sessionId, proc);
866
+ sessionManager2.saveStartConfig(sessionId, { provider: providerName, model, permissionMode: permissionMode2, extraArgs });
660
867
  logger.log("route", `POST /start?session=${sessionId} \u2192 started`);
661
- return c.json({
868
+ return httpJson(c, "agent.start", {
662
869
  status: "started",
663
870
  provider: provider2.name,
664
871
  sessionId: session.id
@@ -679,21 +886,39 @@ function createAgentRoutes(sessionManager2) {
679
886
  );
680
887
  }
681
888
  const body = await c.req.json().catch(() => ({}));
682
- if (!body.message) {
889
+ if (!body.message && !body.images?.length) {
683
890
  logger.err("err", `POST /send?session=${sessionId} \u2192 empty message`);
684
- return c.json({ status: "error", message: "message is required" }, 400);
891
+ return c.json({ status: "error", message: "message or images required" }, 400);
892
+ }
893
+ const textContent = body.message ?? "(image)";
894
+ let meta = body.meta ? { ...body.meta } : {};
895
+ if (body.images?.length) {
896
+ const filenames = saveImages(sessionId, body.images);
897
+ meta.images = filenames;
685
898
  }
686
899
  try {
687
900
  const db = getDb();
688
901
  db.prepare(`INSERT OR IGNORE INTO chat_sessions (id, label, type) VALUES (?, ?, 'main')`).run(sessionId, session.label ?? sessionId);
689
- db.prepare(`INSERT INTO chat_messages (session_id, role, content, meta) VALUES (?, 'user', ?, ?)`).run(sessionId, body.message, body.meta ? JSON.stringify(body.meta) : null);
902
+ db.prepare(`INSERT INTO chat_messages (session_id, role, content, meta) VALUES (?, 'user', ?, ?)`).run(sessionId, textContent, Object.keys(meta).length > 0 ? JSON.stringify(meta) : null);
690
903
  } catch {
691
904
  }
692
905
  session.state = "processing";
693
906
  sessionManager2.touch(sessionId);
694
- logger.log("route", `POST /send?session=${sessionId} \u2192 "${body.message.slice(0, 80)}"`);
695
- session.process.send(body.message);
696
- return c.json({ status: "sent" });
907
+ if (body.images?.length) {
908
+ const content = [
909
+ ...body.images.map((img) => ({
910
+ type: "image",
911
+ source: { type: "base64", media_type: img.mimeType, data: img.base64 }
912
+ })),
913
+ ...body.message ? [{ type: "text", text: body.message }] : []
914
+ ];
915
+ logger.log("route", `POST /send?session=${sessionId} \u2192 ${body.images.length} image(s) + "${(body.message ?? "").slice(0, 40)}"`);
916
+ session.process.send(content);
917
+ } else {
918
+ logger.log("route", `POST /send?session=${sessionId} \u2192 "${body.message.slice(0, 80)}"`);
919
+ session.process.send(body.message);
920
+ }
921
+ return httpJson(c, "agent.send", { status: "sent" });
697
922
  });
698
923
  app.get("/events", (c) => {
699
924
  const sessionId = getSessionId(c);
@@ -728,95 +953,113 @@ function createAgentRoutes(sessionManager2) {
728
953
  }
729
954
  });
730
955
  });
956
+ app.post("/restart", async (c) => {
957
+ const sessionId = getSessionId(c);
958
+ const body = await c.req.json().catch(() => ({}));
959
+ try {
960
+ const ccSessionId = sessionManager2.getSession(sessionId)?.ccSessionId;
961
+ const { config } = sessionManager2.restartSession(sessionId, body, (cfg) => {
962
+ const prov = getProvider(cfg.provider);
963
+ const resumeArgs = ccSessionId ? ["--resume", ccSessionId] : ["--resume"];
964
+ return prov.spawn({
965
+ cwd: sessionManager2.getSession(sessionId).cwd,
966
+ model: cfg.model,
967
+ permissionMode: cfg.permissionMode,
968
+ env: { SNA_SESSION_ID: sessionId },
969
+ extraArgs: [...cfg.extraArgs ?? [], ...resumeArgs]
970
+ });
971
+ });
972
+ logger.log("route", `POST /restart?session=${sessionId} \u2192 restarted`);
973
+ return httpJson(c, "agent.restart", {
974
+ status: "restarted",
975
+ provider: config.provider,
976
+ sessionId
977
+ });
978
+ } catch (e) {
979
+ logger.err("err", `POST /restart?session=${sessionId} \u2192 ${e.message}`);
980
+ return c.json({ status: "error", message: e.message }, 500);
981
+ }
982
+ });
983
+ app.post("/interrupt", async (c) => {
984
+ const sessionId = getSessionId(c);
985
+ const interrupted = sessionManager2.interruptSession(sessionId);
986
+ return httpJson(c, "agent.interrupt", { status: interrupted ? "interrupted" : "no_session" });
987
+ });
988
+ app.post("/set-model", async (c) => {
989
+ const sessionId = getSessionId(c);
990
+ const body = await c.req.json().catch(() => ({}));
991
+ if (!body.model) return c.json({ status: "error", message: "model is required" }, 400);
992
+ const updated = sessionManager2.setSessionModel(sessionId, body.model);
993
+ return httpJson(c, "agent.set-model", { status: updated ? "updated" : "no_session", model: body.model });
994
+ });
995
+ app.post("/set-permission-mode", async (c) => {
996
+ const sessionId = getSessionId(c);
997
+ const body = await c.req.json().catch(() => ({}));
998
+ if (!body.permissionMode) return c.json({ status: "error", message: "permissionMode is required" }, 400);
999
+ const updated = sessionManager2.setSessionPermissionMode(sessionId, body.permissionMode);
1000
+ return httpJson(c, "agent.set-permission-mode", { status: updated ? "updated" : "no_session", permissionMode: body.permissionMode });
1001
+ });
731
1002
  app.post("/kill", async (c) => {
732
1003
  const sessionId = getSessionId(c);
733
1004
  const killed = sessionManager2.killSession(sessionId);
734
- return c.json({ status: killed ? "killed" : "no_session" });
1005
+ return httpJson(c, "agent.kill", { status: killed ? "killed" : "no_session" });
735
1006
  });
736
1007
  app.get("/status", (c) => {
737
1008
  const sessionId = getSessionId(c);
738
1009
  const session = sessionManager2.getSession(sessionId);
739
- return c.json({
1010
+ return httpJson(c, "agent.status", {
740
1011
  alive: session?.process?.alive ?? false,
741
1012
  sessionId: session?.process?.sessionId ?? null,
742
- eventCount: session?.eventCounter ?? 0
1013
+ ccSessionId: session?.ccSessionId ?? null,
1014
+ eventCount: session?.eventCounter ?? 0,
1015
+ config: session?.lastStartConfig ?? null
743
1016
  });
744
1017
  });
745
- const pendingPermissions = /* @__PURE__ */ new Map();
746
1018
  app.post("/permission-request", async (c) => {
747
1019
  const sessionId = getSessionId(c);
748
1020
  const body = await c.req.json().catch(() => ({}));
749
1021
  logger.log("route", `POST /permission-request?session=${sessionId} \u2192 ${body.tool_name}`);
750
- const session = sessionManager2.getSession(sessionId);
751
- if (session) session.state = "permission";
752
- const result = await new Promise((resolve) => {
753
- pendingPermissions.set(sessionId, {
754
- resolve,
755
- request: body,
756
- createdAt: Date.now()
757
- });
758
- setTimeout(() => {
759
- if (pendingPermissions.has(sessionId)) {
760
- pendingPermissions.delete(sessionId);
761
- resolve(false);
762
- }
763
- }, 3e5);
764
- });
1022
+ const result = await sessionManager2.createPendingPermission(sessionId, body);
765
1023
  return c.json({ approved: result });
766
1024
  });
767
1025
  app.post("/permission-respond", async (c) => {
768
1026
  const sessionId = getSessionId(c);
769
1027
  const body = await c.req.json().catch(() => ({}));
770
1028
  const approved = body.approved ?? false;
771
- const pending = pendingPermissions.get(sessionId);
772
- if (!pending) {
1029
+ const resolved = sessionManager2.resolvePendingPermission(sessionId, approved);
1030
+ if (!resolved) {
773
1031
  return c.json({ status: "error", message: "No pending permission request" }, 404);
774
1032
  }
775
- pending.resolve(approved);
776
- pendingPermissions.delete(sessionId);
777
- const session = sessionManager2.getSession(sessionId);
778
- if (session) session.state = "processing";
779
1033
  logger.log("route", `POST /permission-respond?session=${sessionId} \u2192 ${approved ? "approved" : "denied"}`);
780
- return c.json({ status: approved ? "approved" : "denied" });
1034
+ return httpJson(c, "permission.respond", { status: approved ? "approved" : "denied" });
781
1035
  });
782
1036
  app.get("/permission-pending", (c) => {
783
1037
  const sessionId = c.req.query("session");
784
1038
  if (sessionId) {
785
- const pending = pendingPermissions.get(sessionId);
786
- if (!pending) return c.json({ pending: null });
787
- return c.json({
788
- pending: {
789
- sessionId,
790
- request: pending.request,
791
- createdAt: pending.createdAt
792
- }
793
- });
1039
+ const pending = sessionManager2.getPendingPermission(sessionId);
1040
+ return httpJson(c, "permission.pending", { pending: pending ? [{ sessionId, ...pending }] : [] });
794
1041
  }
795
- const all = Array.from(pendingPermissions.entries()).map(([id, p]) => ({
796
- sessionId: id,
797
- request: p.request,
798
- createdAt: p.createdAt
799
- }));
800
- return c.json({ pending: all });
1042
+ return httpJson(c, "permission.pending", { pending: sessionManager2.getAllPendingPermissions() });
801
1043
  });
802
1044
  return app;
803
1045
  }
804
1046
 
805
1047
  // src/server/routes/chat.ts
806
1048
  import { Hono as Hono2 } from "hono";
1049
+ import fs5 from "fs";
807
1050
  function createChatRoutes() {
808
1051
  const app = new Hono2();
809
1052
  app.get("/sessions", (c) => {
810
1053
  try {
811
1054
  const db = getDb();
812
1055
  const rows = db.prepare(
813
- `SELECT id, label, type, meta, created_at FROM chat_sessions ORDER BY created_at DESC`
1056
+ `SELECT id, label, type, meta, cwd, created_at FROM chat_sessions ORDER BY created_at DESC`
814
1057
  ).all();
815
1058
  const sessions = rows.map((r) => ({
816
1059
  ...r,
817
1060
  meta: r.meta ? JSON.parse(r.meta) : null
818
1061
  }));
819
- return c.json({ sessions });
1062
+ return httpJson(c, "chat.sessions.list", { sessions });
820
1063
  } catch (e) {
821
1064
  return c.json({ status: "error", message: e.message, stack: e.stack }, 500);
822
1065
  }
@@ -829,7 +1072,7 @@ function createChatRoutes() {
829
1072
  db.prepare(
830
1073
  `INSERT OR IGNORE INTO chat_sessions (id, label, type, meta) VALUES (?, ?, ?, ?)`
831
1074
  ).run(id, body.label ?? id, body.type ?? "background", body.meta ? JSON.stringify(body.meta) : null);
832
- return c.json({ status: "created", id, meta: body.meta ?? null });
1075
+ return httpJson(c, "chat.sessions.create", { status: "created", id, meta: body.meta ?? null });
833
1076
  } catch (e) {
834
1077
  return c.json({ status: "error", message: e.message }, 500);
835
1078
  }
@@ -842,7 +1085,7 @@ function createChatRoutes() {
842
1085
  try {
843
1086
  const db = getDb();
844
1087
  db.prepare(`DELETE FROM chat_sessions WHERE id = ?`).run(id);
845
- return c.json({ status: "deleted" });
1088
+ return httpJson(c, "chat.sessions.remove", { status: "deleted" });
846
1089
  } catch (e) {
847
1090
  return c.json({ status: "error", message: e.message }, 500);
848
1091
  }
@@ -854,7 +1097,7 @@ function createChatRoutes() {
854
1097
  const db = getDb();
855
1098
  const query = sinceParam ? db.prepare(`SELECT * FROM chat_messages WHERE session_id = ? AND id > ? ORDER BY id ASC`) : db.prepare(`SELECT * FROM chat_messages WHERE session_id = ? ORDER BY id ASC`);
856
1099
  const messages = sinceParam ? query.all(id, parseInt(sinceParam, 10)) : query.all(id);
857
- return c.json({ messages });
1100
+ return httpJson(c, "chat.messages.list", { messages });
858
1101
  } catch (e) {
859
1102
  return c.json({ status: "error", message: e.message, stack: e.stack }, 500);
860
1103
  }
@@ -877,7 +1120,7 @@ function createChatRoutes() {
877
1120
  body.skill_name ?? null,
878
1121
  body.meta ? JSON.stringify(body.meta) : null
879
1122
  );
880
- return c.json({ status: "created", id: result.lastInsertRowid });
1123
+ return httpJson(c, "chat.messages.create", { status: "created", id: Number(result.lastInsertRowid) });
881
1124
  } catch (e) {
882
1125
  return c.json({ status: "error", message: e.message }, 500);
883
1126
  }
@@ -887,27 +1130,119 @@ function createChatRoutes() {
887
1130
  try {
888
1131
  const db = getDb();
889
1132
  db.prepare(`DELETE FROM chat_messages WHERE session_id = ?`).run(id);
890
- return c.json({ status: "cleared" });
1133
+ return httpJson(c, "chat.messages.clear", { status: "cleared" });
891
1134
  } catch (e) {
892
1135
  return c.json({ status: "error", message: e.message }, 500);
893
1136
  }
894
1137
  });
1138
+ app.get("/images/:sessionId/:filename", (c) => {
1139
+ const sessionId = c.req.param("sessionId");
1140
+ const filename = c.req.param("filename");
1141
+ const filePath = resolveImagePath(sessionId, filename);
1142
+ if (!filePath) {
1143
+ return c.json({ status: "error", message: "Image not found" }, 404);
1144
+ }
1145
+ const ext = filename.split(".").pop()?.toLowerCase();
1146
+ const mimeMap = {
1147
+ png: "image/png",
1148
+ jpg: "image/jpeg",
1149
+ jpeg: "image/jpeg",
1150
+ gif: "image/gif",
1151
+ webp: "image/webp",
1152
+ svg: "image/svg+xml"
1153
+ };
1154
+ const contentType = mimeMap[ext ?? ""] ?? "application/octet-stream";
1155
+ const data = fs5.readFileSync(filePath);
1156
+ return new Response(data, { headers: { "Content-Type": contentType, "Cache-Control": "public, max-age=31536000, immutable" } });
1157
+ });
895
1158
  return app;
896
1159
  }
897
1160
 
898
1161
  // src/server/session-manager.ts
899
1162
  var DEFAULT_MAX_SESSIONS = 5;
900
1163
  var MAX_EVENT_BUFFER = 500;
1164
+ var PERMISSION_TIMEOUT_MS = 3e5;
901
1165
  var SessionManager = class {
902
1166
  constructor(options = {}) {
903
1167
  this.sessions = /* @__PURE__ */ new Map();
1168
+ this.eventListeners = /* @__PURE__ */ new Map();
1169
+ this.pendingPermissions = /* @__PURE__ */ new Map();
1170
+ this.skillEventListeners = /* @__PURE__ */ new Set();
1171
+ this.permissionRequestListeners = /* @__PURE__ */ new Set();
1172
+ this.lifecycleListeners = /* @__PURE__ */ new Set();
1173
+ this.configChangedListeners = /* @__PURE__ */ new Set();
904
1174
  this.maxSessions = options.maxSessions ?? DEFAULT_MAX_SESSIONS;
1175
+ this.restoreFromDb();
1176
+ }
1177
+ /** Restore session metadata from DB (cwd, label, meta). Process state is not restored. */
1178
+ restoreFromDb() {
1179
+ try {
1180
+ const db = getDb();
1181
+ const rows = db.prepare(
1182
+ `SELECT id, label, meta, cwd, last_start_config, created_at FROM chat_sessions`
1183
+ ).all();
1184
+ for (const row of rows) {
1185
+ if (this.sessions.has(row.id)) continue;
1186
+ this.sessions.set(row.id, {
1187
+ id: row.id,
1188
+ process: null,
1189
+ eventBuffer: [],
1190
+ eventCounter: 0,
1191
+ label: row.label,
1192
+ cwd: row.cwd ?? process.cwd(),
1193
+ meta: row.meta ? JSON.parse(row.meta) : null,
1194
+ state: "idle",
1195
+ lastStartConfig: row.last_start_config ? JSON.parse(row.last_start_config) : null,
1196
+ ccSessionId: null,
1197
+ createdAt: new Date(row.created_at).getTime() || Date.now(),
1198
+ lastActivityAt: Date.now()
1199
+ });
1200
+ }
1201
+ } catch {
1202
+ }
1203
+ }
1204
+ /** Persist session metadata to DB. */
1205
+ persistSession(session) {
1206
+ try {
1207
+ const db = getDb();
1208
+ db.prepare(
1209
+ `INSERT INTO chat_sessions (id, label, type, meta, cwd, last_start_config)
1210
+ VALUES (?, ?, 'main', ?, ?, ?)
1211
+ ON CONFLICT(id) DO UPDATE SET
1212
+ label = excluded.label,
1213
+ meta = excluded.meta,
1214
+ cwd = excluded.cwd,
1215
+ last_start_config = excluded.last_start_config`
1216
+ ).run(
1217
+ session.id,
1218
+ session.label,
1219
+ session.meta ? JSON.stringify(session.meta) : null,
1220
+ session.cwd,
1221
+ session.lastStartConfig ? JSON.stringify(session.lastStartConfig) : null
1222
+ );
1223
+ } catch {
1224
+ }
905
1225
  }
906
1226
  /** Create a new session. Throws if max sessions reached. */
907
1227
  createSession(opts = {}) {
908
1228
  const id = opts.id ?? crypto.randomUUID().slice(0, 8);
909
1229
  if (this.sessions.has(id)) {
910
- return this.sessions.get(id);
1230
+ const existing = this.sessions.get(id);
1231
+ let changed = false;
1232
+ if (opts.cwd && opts.cwd !== existing.cwd) {
1233
+ existing.cwd = opts.cwd;
1234
+ changed = true;
1235
+ }
1236
+ if (opts.label && opts.label !== existing.label) {
1237
+ existing.label = opts.label;
1238
+ changed = true;
1239
+ }
1240
+ if (opts.meta !== void 0 && opts.meta !== existing.meta) {
1241
+ existing.meta = opts.meta ?? null;
1242
+ changed = true;
1243
+ }
1244
+ if (changed) this.persistSession(existing);
1245
+ return existing;
911
1246
  }
912
1247
  const aliveCount = Array.from(this.sessions.values()).filter((s) => s.process?.alive).length;
913
1248
  if (aliveCount >= this.maxSessions) {
@@ -922,10 +1257,13 @@ var SessionManager = class {
922
1257
  cwd: opts.cwd ?? process.cwd(),
923
1258
  meta: opts.meta ?? null,
924
1259
  state: "idle",
1260
+ lastStartConfig: null,
1261
+ ccSessionId: null,
925
1262
  createdAt: Date.now(),
926
1263
  lastActivityAt: Date.now()
927
1264
  };
928
1265
  this.sessions.set(id, session);
1266
+ this.persistSession(session);
929
1267
  return session;
930
1268
  }
931
1269
  /** Get a session by ID. */
@@ -935,7 +1273,13 @@ var SessionManager = class {
935
1273
  /** Get or create a session (used for "default" backward compat). */
936
1274
  getOrCreateSession(id, opts) {
937
1275
  const existing = this.sessions.get(id);
938
- if (existing) return existing;
1276
+ if (existing) {
1277
+ if (opts?.cwd && opts.cwd !== existing.cwd) {
1278
+ existing.cwd = opts.cwd;
1279
+ this.persistSession(existing);
1280
+ }
1281
+ return existing;
1282
+ }
939
1283
  return this.createSession({ id, ...opts });
940
1284
  }
941
1285
  /** Set the agent process for a session. Subscribes to events. */
@@ -946,22 +1290,195 @@ var SessionManager = class {
946
1290
  session.state = "processing";
947
1291
  session.lastActivityAt = Date.now();
948
1292
  proc.on("event", (e) => {
1293
+ if (e.type === "init" && e.data?.sessionId && !session.ccSessionId) {
1294
+ session.ccSessionId = e.data.sessionId;
1295
+ this.persistSession(session);
1296
+ }
949
1297
  session.eventBuffer.push(e);
950
1298
  session.eventCounter++;
951
1299
  if (session.eventBuffer.length > MAX_EVENT_BUFFER) {
952
1300
  session.eventBuffer.splice(0, session.eventBuffer.length - MAX_EVENT_BUFFER);
953
1301
  }
954
- if (e.type === "complete" || e.type === "error") {
1302
+ if (e.type === "complete" || e.type === "error" || e.type === "interrupted") {
955
1303
  session.state = "waiting";
956
1304
  }
957
1305
  this.persistEvent(sessionId, e);
1306
+ const listeners = this.eventListeners.get(sessionId);
1307
+ if (listeners) {
1308
+ for (const cb of listeners) cb(session.eventCounter, e);
1309
+ }
1310
+ });
1311
+ proc.on("exit", (code) => {
1312
+ session.state = "idle";
1313
+ this.emitLifecycle({ session: sessionId, state: code != null ? "exited" : "crashed", code });
1314
+ });
1315
+ proc.on("error", () => {
1316
+ session.state = "idle";
1317
+ this.emitLifecycle({ session: sessionId, state: "crashed" });
958
1318
  });
1319
+ this.emitLifecycle({ session: sessionId, state: "started" });
1320
+ }
1321
+ // ── Event pub/sub (for WebSocket) ─────────────────────────────
1322
+ /** Subscribe to real-time events for a session. Returns unsubscribe function. */
1323
+ onSessionEvent(sessionId, cb) {
1324
+ let set = this.eventListeners.get(sessionId);
1325
+ if (!set) {
1326
+ set = /* @__PURE__ */ new Set();
1327
+ this.eventListeners.set(sessionId, set);
1328
+ }
1329
+ set.add(cb);
1330
+ return () => {
1331
+ set.delete(cb);
1332
+ if (set.size === 0) this.eventListeners.delete(sessionId);
1333
+ };
1334
+ }
1335
+ // ── Skill event pub/sub ────────────────────────────────────────
1336
+ /** Subscribe to skill events broadcast. Returns unsubscribe function. */
1337
+ onSkillEvent(cb) {
1338
+ this.skillEventListeners.add(cb);
1339
+ return () => this.skillEventListeners.delete(cb);
1340
+ }
1341
+ /** Broadcast a skill event to all subscribers (called after DB insert). */
1342
+ broadcastSkillEvent(event) {
1343
+ for (const cb of this.skillEventListeners) cb(event);
1344
+ }
1345
+ // ── Permission pub/sub ────────────────────────────────────────
1346
+ /** Subscribe to permission request notifications. Returns unsubscribe function. */
1347
+ onPermissionRequest(cb) {
1348
+ this.permissionRequestListeners.add(cb);
1349
+ return () => this.permissionRequestListeners.delete(cb);
1350
+ }
1351
+ // ── Session lifecycle pub/sub ──────────────────────────────────
1352
+ /** Subscribe to session lifecycle events (started/killed/exited/crashed). Returns unsubscribe function. */
1353
+ onSessionLifecycle(cb) {
1354
+ this.lifecycleListeners.add(cb);
1355
+ return () => this.lifecycleListeners.delete(cb);
1356
+ }
1357
+ emitLifecycle(event) {
1358
+ for (const cb of this.lifecycleListeners) cb(event);
1359
+ }
1360
+ // ── Config changed pub/sub ────────────────────────────────────
1361
+ /** Subscribe to session config changes. Returns unsubscribe function. */
1362
+ onConfigChanged(cb) {
1363
+ this.configChangedListeners.add(cb);
1364
+ return () => this.configChangedListeners.delete(cb);
1365
+ }
1366
+ emitConfigChanged(sessionId, config) {
1367
+ for (const cb of this.configChangedListeners) cb({ session: sessionId, config });
1368
+ }
1369
+ // ── Permission management ─────────────────────────────────────
1370
+ /** Create a pending permission request. Returns a promise that resolves when approved/denied. */
1371
+ createPendingPermission(sessionId, request) {
1372
+ const session = this.sessions.get(sessionId);
1373
+ if (session) session.state = "permission";
1374
+ return new Promise((resolve) => {
1375
+ const createdAt = Date.now();
1376
+ this.pendingPermissions.set(sessionId, { resolve, request, createdAt });
1377
+ for (const cb of this.permissionRequestListeners) cb(sessionId, request, createdAt);
1378
+ setTimeout(() => {
1379
+ if (this.pendingPermissions.has(sessionId)) {
1380
+ this.pendingPermissions.delete(sessionId);
1381
+ resolve(false);
1382
+ }
1383
+ }, PERMISSION_TIMEOUT_MS);
1384
+ });
1385
+ }
1386
+ /** Resolve a pending permission request. Returns false if no pending request. */
1387
+ resolvePendingPermission(sessionId, approved) {
1388
+ const pending = this.pendingPermissions.get(sessionId);
1389
+ if (!pending) return false;
1390
+ pending.resolve(approved);
1391
+ this.pendingPermissions.delete(sessionId);
1392
+ const session = this.sessions.get(sessionId);
1393
+ if (session) session.state = "processing";
1394
+ return true;
1395
+ }
1396
+ /** Get a pending permission for a specific session. */
1397
+ getPendingPermission(sessionId) {
1398
+ const p = this.pendingPermissions.get(sessionId);
1399
+ return p ? { request: p.request, createdAt: p.createdAt } : null;
1400
+ }
1401
+ /** Get all pending permissions across sessions. */
1402
+ getAllPendingPermissions() {
1403
+ return Array.from(this.pendingPermissions.entries()).map(([id, p]) => ({
1404
+ sessionId: id,
1405
+ request: p.request,
1406
+ createdAt: p.createdAt
1407
+ }));
1408
+ }
1409
+ // ── Session lifecycle ─────────────────────────────────────────
1410
+ /** Kill the agent process in a session (session stays, can be restarted). */
1411
+ /** Save the start config for a session (called by start handlers). */
1412
+ saveStartConfig(id, config) {
1413
+ const session = this.sessions.get(id);
1414
+ if (!session) return;
1415
+ session.lastStartConfig = config;
1416
+ this.persistSession(session);
1417
+ }
1418
+ /** Restart session: kill → re-spawn with merged config + --resume. */
1419
+ restartSession(id, overrides, spawnFn) {
1420
+ const session = this.sessions.get(id);
1421
+ if (!session) throw new Error(`Session "${id}" not found`);
1422
+ const base = session.lastStartConfig;
1423
+ if (!base) throw new Error(`Session "${id}" has no previous start config`);
1424
+ const config = {
1425
+ provider: overrides.provider ?? base.provider,
1426
+ model: overrides.model ?? base.model,
1427
+ permissionMode: overrides.permissionMode ?? base.permissionMode,
1428
+ extraArgs: overrides.extraArgs ?? base.extraArgs
1429
+ };
1430
+ if (session.process?.alive) session.process.kill();
1431
+ session.eventBuffer.length = 0;
1432
+ const proc = spawnFn(config);
1433
+ this.setProcess(id, proc);
1434
+ session.lastStartConfig = config;
1435
+ this.persistSession(session);
1436
+ this.emitLifecycle({ session: id, state: "restarted" });
1437
+ this.emitConfigChanged(id, config);
1438
+ return { config };
1439
+ }
1440
+ /** Interrupt the current turn. Process stays alive, returns to waiting. */
1441
+ interruptSession(id) {
1442
+ const session = this.sessions.get(id);
1443
+ if (!session?.process?.alive) return false;
1444
+ session.process.interrupt();
1445
+ session.state = "waiting";
1446
+ return true;
1447
+ }
1448
+ /** Change model. Sends control message if alive, always persists to config. */
1449
+ setSessionModel(id, model) {
1450
+ const session = this.sessions.get(id);
1451
+ if (!session) return false;
1452
+ if (session.process?.alive) session.process.setModel(model);
1453
+ if (session.lastStartConfig) {
1454
+ session.lastStartConfig.model = model;
1455
+ } else {
1456
+ session.lastStartConfig = { provider: "claude-code", model, permissionMode: "acceptEdits" };
1457
+ }
1458
+ this.persistSession(session);
1459
+ this.emitConfigChanged(id, session.lastStartConfig);
1460
+ return true;
1461
+ }
1462
+ /** Change permission mode. Sends control message if alive, always persists to config. */
1463
+ setSessionPermissionMode(id, mode) {
1464
+ const session = this.sessions.get(id);
1465
+ if (!session) return false;
1466
+ if (session.process?.alive) session.process.setPermissionMode(mode);
1467
+ if (session.lastStartConfig) {
1468
+ session.lastStartConfig.permissionMode = mode;
1469
+ } else {
1470
+ session.lastStartConfig = { provider: "claude-code", model: "claude-sonnet-4-6", permissionMode: mode };
1471
+ }
1472
+ this.persistSession(session);
1473
+ this.emitConfigChanged(id, session.lastStartConfig);
1474
+ return true;
959
1475
  }
960
1476
  /** Kill the agent process in a session (session stays, can be restarted). */
961
1477
  killSession(id) {
962
1478
  const session = this.sessions.get(id);
963
1479
  if (!session?.process?.alive) return false;
964
1480
  session.process.kill();
1481
+ this.emitLifecycle({ session: id, state: "killed" });
965
1482
  return true;
966
1483
  }
967
1484
  /** Remove a session entirely. Cannot remove "default". */
@@ -970,6 +1487,8 @@ var SessionManager = class {
970
1487
  const session = this.sessions.get(id);
971
1488
  if (!session) return false;
972
1489
  if (session.process?.alive) session.process.kill();
1490
+ this.eventListeners.delete(id);
1491
+ this.pendingPermissions.delete(id);
973
1492
  this.sessions.delete(id);
974
1493
  return true;
975
1494
  }
@@ -982,6 +1501,8 @@ var SessionManager = class {
982
1501
  state: s.state,
983
1502
  cwd: s.cwd,
984
1503
  meta: s.meta,
1504
+ config: s.lastStartConfig,
1505
+ ccSessionId: s.ccSessionId,
985
1506
  eventCount: s.eventCounter,
986
1507
  createdAt: s.createdAt,
987
1508
  lastActivityAt: s.lastActivityAt
@@ -1038,13 +1559,538 @@ var SessionManager = class {
1038
1559
  }
1039
1560
  };
1040
1561
 
1562
+ // src/server/ws.ts
1563
+ import { WebSocketServer } from "ws";
1564
+ function send(ws, data) {
1565
+ if (ws.readyState === ws.OPEN) {
1566
+ ws.send(JSON.stringify(data));
1567
+ }
1568
+ }
1569
+ function reply(ws, msg, data) {
1570
+ send(ws, { ...data, type: msg.type, ...msg.rid != null ? { rid: msg.rid } : {} });
1571
+ }
1572
+ function replyError(ws, msg, message) {
1573
+ send(ws, { type: "error", ...msg.rid != null ? { rid: msg.rid } : {}, message });
1574
+ }
1575
+ function attachWebSocket(server2, sessionManager2) {
1576
+ const wss = new WebSocketServer({ noServer: true });
1577
+ server2.on("upgrade", (req, socket, head) => {
1578
+ const url = new URL(req.url ?? "/", `http://${req.headers.host}`);
1579
+ if (url.pathname === "/ws") {
1580
+ wss.handleUpgrade(req, socket, head, (ws) => {
1581
+ wss.emit("connection", ws, req);
1582
+ });
1583
+ } else {
1584
+ socket.destroy();
1585
+ }
1586
+ });
1587
+ wss.on("connection", (ws) => {
1588
+ logger.log("ws", "client connected");
1589
+ const state = { agentUnsubs: /* @__PURE__ */ new Map(), skillEventUnsub: null, skillPollTimer: null, permissionUnsub: null, lifecycleUnsub: null, configChangedUnsub: null };
1590
+ state.lifecycleUnsub = sessionManager2.onSessionLifecycle((event) => {
1591
+ send(ws, { type: "session.lifecycle", ...event });
1592
+ });
1593
+ state.configChangedUnsub = sessionManager2.onConfigChanged((event) => {
1594
+ send(ws, { type: "session.config-changed", ...event });
1595
+ });
1596
+ ws.on("message", (raw) => {
1597
+ let msg;
1598
+ try {
1599
+ msg = JSON.parse(raw.toString());
1600
+ } catch {
1601
+ send(ws, { type: "error", message: "invalid JSON" });
1602
+ return;
1603
+ }
1604
+ if (!msg.type) {
1605
+ send(ws, { type: "error", message: "type is required" });
1606
+ return;
1607
+ }
1608
+ handleMessage(ws, msg, sessionManager2, state);
1609
+ });
1610
+ ws.on("close", () => {
1611
+ logger.log("ws", "client disconnected");
1612
+ for (const unsub of state.agentUnsubs.values()) unsub();
1613
+ state.agentUnsubs.clear();
1614
+ state.skillEventUnsub?.();
1615
+ state.skillEventUnsub = null;
1616
+ if (state.skillPollTimer) {
1617
+ clearInterval(state.skillPollTimer);
1618
+ state.skillPollTimer = null;
1619
+ }
1620
+ state.permissionUnsub?.();
1621
+ state.permissionUnsub = null;
1622
+ state.lifecycleUnsub?.();
1623
+ state.lifecycleUnsub = null;
1624
+ state.configChangedUnsub?.();
1625
+ state.configChangedUnsub = null;
1626
+ });
1627
+ });
1628
+ return wss;
1629
+ }
1630
+ function handleMessage(ws, msg, sm, state) {
1631
+ switch (msg.type) {
1632
+ // ── Session CRUD ──────────────────────────────────
1633
+ case "sessions.create":
1634
+ return handleSessionsCreate(ws, msg, sm);
1635
+ case "sessions.list":
1636
+ return wsReply(ws, msg, { sessions: sm.listSessions() });
1637
+ case "sessions.remove":
1638
+ return handleSessionsRemove(ws, msg, sm);
1639
+ // ── Agent lifecycle ───────────────────────────────
1640
+ case "agent.start":
1641
+ return handleAgentStart(ws, msg, sm);
1642
+ case "agent.send":
1643
+ return handleAgentSend(ws, msg, sm);
1644
+ case "agent.restart":
1645
+ return handleAgentRestart(ws, msg, sm);
1646
+ case "agent.interrupt":
1647
+ return handleAgentInterrupt(ws, msg, sm);
1648
+ case "agent.set-model":
1649
+ return handleAgentSetModel(ws, msg, sm);
1650
+ case "agent.set-permission-mode":
1651
+ return handleAgentSetPermissionMode(ws, msg, sm);
1652
+ case "agent.kill":
1653
+ return handleAgentKill(ws, msg, sm);
1654
+ case "agent.status":
1655
+ return handleAgentStatus(ws, msg, sm);
1656
+ case "agent.run-once":
1657
+ handleAgentRunOnce(ws, msg, sm);
1658
+ return;
1659
+ // ── Agent event subscription ──────────────────────
1660
+ case "agent.subscribe":
1661
+ return handleAgentSubscribe(ws, msg, sm, state);
1662
+ case "agent.unsubscribe":
1663
+ return handleAgentUnsubscribe(ws, msg, state);
1664
+ // ── Skill events ──────────────────────────────────
1665
+ case "events.subscribe":
1666
+ return handleEventsSubscribe(ws, msg, sm, state);
1667
+ case "events.unsubscribe":
1668
+ return handleEventsUnsubscribe(ws, msg, state);
1669
+ case "emit":
1670
+ return handleEmit(ws, msg, sm);
1671
+ // ── Permission ────────────────────────────────────
1672
+ case "permission.respond":
1673
+ return handlePermissionRespond(ws, msg, sm);
1674
+ case "permission.pending":
1675
+ return handlePermissionPending(ws, msg, sm);
1676
+ case "permission.subscribe":
1677
+ return handlePermissionSubscribe(ws, msg, sm, state);
1678
+ case "permission.unsubscribe":
1679
+ return handlePermissionUnsubscribe(ws, msg, state);
1680
+ // ── Chat sessions ─────────────────────────────────
1681
+ case "chat.sessions.list":
1682
+ return handleChatSessionsList(ws, msg);
1683
+ case "chat.sessions.create":
1684
+ return handleChatSessionsCreate(ws, msg);
1685
+ case "chat.sessions.remove":
1686
+ return handleChatSessionsRemove(ws, msg);
1687
+ // ── Chat messages ─────────────────────────────────
1688
+ case "chat.messages.list":
1689
+ return handleChatMessagesList(ws, msg);
1690
+ case "chat.messages.create":
1691
+ return handleChatMessagesCreate(ws, msg);
1692
+ case "chat.messages.clear":
1693
+ return handleChatMessagesClear(ws, msg);
1694
+ default:
1695
+ replyError(ws, msg, `Unknown message type: ${msg.type}`);
1696
+ }
1697
+ }
1698
+ function handleSessionsCreate(ws, msg, sm) {
1699
+ try {
1700
+ const session = sm.createSession({
1701
+ label: msg.label,
1702
+ cwd: msg.cwd,
1703
+ meta: msg.meta
1704
+ });
1705
+ wsReply(ws, msg, { status: "created", sessionId: session.id, label: session.label, meta: session.meta });
1706
+ } catch (e) {
1707
+ replyError(ws, msg, e.message);
1708
+ }
1709
+ }
1710
+ function handleSessionsRemove(ws, msg, sm) {
1711
+ const id = msg.session;
1712
+ if (!id) return replyError(ws, msg, "session is required");
1713
+ if (id === "default") return replyError(ws, msg, "Cannot remove default session");
1714
+ const removed = sm.removeSession(id);
1715
+ if (!removed) return replyError(ws, msg, "Session not found");
1716
+ wsReply(ws, msg, { status: "removed" });
1717
+ }
1718
+ function handleAgentStart(ws, msg, sm) {
1719
+ const sessionId = msg.session ?? "default";
1720
+ const session = sm.getOrCreateSession(sessionId, {
1721
+ cwd: msg.cwd
1722
+ });
1723
+ if (session.process?.alive && !msg.force) {
1724
+ wsReply(ws, msg, { status: "already_running", provider: "claude-code", sessionId: session.id });
1725
+ return;
1726
+ }
1727
+ if (session.process?.alive) session.process.kill();
1728
+ session.eventBuffer.length = 0;
1729
+ const provider2 = getProvider(msg.provider ?? "claude-code");
1730
+ try {
1731
+ const db = getDb();
1732
+ db.prepare(`INSERT OR IGNORE INTO chat_sessions (id, label, type) VALUES (?, ?, 'main')`).run(sessionId, session.label ?? sessionId);
1733
+ if (msg.prompt) {
1734
+ db.prepare(`INSERT INTO chat_messages (session_id, role, content, meta) VALUES (?, 'user', ?, ?)`).run(sessionId, msg.prompt, msg.meta ? JSON.stringify(msg.meta) : null);
1735
+ }
1736
+ const skillMatch = msg.prompt?.match(/^Execute the skill:\s*(\S+)/);
1737
+ if (skillMatch) {
1738
+ db.prepare(`INSERT INTO skill_events (session_id, skill, type, message) VALUES (?, ?, 'invoked', ?)`).run(sessionId, skillMatch[1], `Skill ${skillMatch[1]} invoked`);
1739
+ }
1740
+ } catch {
1741
+ }
1742
+ const providerName = msg.provider ?? "claude-code";
1743
+ const model = msg.model ?? "claude-sonnet-4-6";
1744
+ const permissionMode2 = msg.permissionMode ?? "acceptEdits";
1745
+ const extraArgs = msg.extraArgs;
1746
+ try {
1747
+ const proc = provider2.spawn({
1748
+ cwd: session.cwd,
1749
+ prompt: msg.prompt,
1750
+ model,
1751
+ permissionMode: permissionMode2,
1752
+ env: { SNA_SESSION_ID: sessionId },
1753
+ history: msg.history,
1754
+ extraArgs
1755
+ });
1756
+ sm.setProcess(sessionId, proc);
1757
+ sm.saveStartConfig(sessionId, { provider: providerName, model, permissionMode: permissionMode2, extraArgs });
1758
+ wsReply(ws, msg, { status: "started", provider: provider2.name, sessionId: session.id });
1759
+ } catch (e) {
1760
+ replyError(ws, msg, e.message);
1761
+ }
1762
+ }
1763
+ function handleAgentSend(ws, msg, sm) {
1764
+ const sessionId = msg.session ?? "default";
1765
+ const session = sm.getSession(sessionId);
1766
+ if (!session?.process?.alive) {
1767
+ return replyError(ws, msg, `No active agent session "${sessionId}". Start first.`);
1768
+ }
1769
+ const images = msg.images;
1770
+ if (!msg.message && !images?.length) {
1771
+ return replyError(ws, msg, "message or images required");
1772
+ }
1773
+ const textContent = msg.message ?? "(image)";
1774
+ let meta = msg.meta ? { ...msg.meta } : {};
1775
+ if (images?.length) {
1776
+ const filenames = saveImages(sessionId, images);
1777
+ meta.images = filenames;
1778
+ }
1779
+ try {
1780
+ const db = getDb();
1781
+ db.prepare(`INSERT OR IGNORE INTO chat_sessions (id, label, type) VALUES (?, ?, 'main')`).run(sessionId, session.label ?? sessionId);
1782
+ db.prepare(`INSERT INTO chat_messages (session_id, role, content, meta) VALUES (?, 'user', ?, ?)`).run(sessionId, textContent, Object.keys(meta).length > 0 ? JSON.stringify(meta) : null);
1783
+ } catch {
1784
+ }
1785
+ session.state = "processing";
1786
+ sm.touch(sessionId);
1787
+ if (images?.length) {
1788
+ const content = [
1789
+ ...images.map((img) => ({
1790
+ type: "image",
1791
+ source: { type: "base64", media_type: img.mimeType, data: img.base64 }
1792
+ })),
1793
+ ...msg.message ? [{ type: "text", text: msg.message }] : []
1794
+ ];
1795
+ session.process.send(content);
1796
+ } else {
1797
+ session.process.send(msg.message);
1798
+ }
1799
+ wsReply(ws, msg, { status: "sent" });
1800
+ }
1801
+ function handleAgentRestart(ws, msg, sm) {
1802
+ const sessionId = msg.session ?? "default";
1803
+ try {
1804
+ const ccSessionId = sm.getSession(sessionId)?.ccSessionId;
1805
+ const { config } = sm.restartSession(
1806
+ sessionId,
1807
+ {
1808
+ provider: msg.provider,
1809
+ model: msg.model,
1810
+ permissionMode: msg.permissionMode,
1811
+ extraArgs: msg.extraArgs
1812
+ },
1813
+ (cfg) => {
1814
+ const prov = getProvider(cfg.provider);
1815
+ const resumeArgs = ccSessionId ? ["--resume", ccSessionId] : ["--resume"];
1816
+ return prov.spawn({
1817
+ cwd: sm.getSession(sessionId).cwd,
1818
+ model: cfg.model,
1819
+ permissionMode: cfg.permissionMode,
1820
+ env: { SNA_SESSION_ID: sessionId },
1821
+ extraArgs: [...cfg.extraArgs ?? [], ...resumeArgs]
1822
+ });
1823
+ }
1824
+ );
1825
+ wsReply(ws, msg, { status: "restarted", provider: config.provider, sessionId });
1826
+ } catch (e) {
1827
+ replyError(ws, msg, e.message);
1828
+ }
1829
+ }
1830
+ function handleAgentInterrupt(ws, msg, sm) {
1831
+ const sessionId = msg.session ?? "default";
1832
+ const interrupted = sm.interruptSession(sessionId);
1833
+ wsReply(ws, msg, { status: interrupted ? "interrupted" : "no_session" });
1834
+ }
1835
+ function handleAgentSetModel(ws, msg, sm) {
1836
+ const sessionId = msg.session ?? "default";
1837
+ const model = msg.model;
1838
+ if (!model) return replyError(ws, msg, "model is required");
1839
+ const updated = sm.setSessionModel(sessionId, model);
1840
+ wsReply(ws, msg, { status: updated ? "updated" : "no_session", model });
1841
+ }
1842
+ function handleAgentSetPermissionMode(ws, msg, sm) {
1843
+ const sessionId = msg.session ?? "default";
1844
+ const permissionMode2 = msg.permissionMode;
1845
+ if (!permissionMode2) return replyError(ws, msg, "permissionMode is required");
1846
+ const updated = sm.setSessionPermissionMode(sessionId, permissionMode2);
1847
+ wsReply(ws, msg, { status: updated ? "updated" : "no_session", permissionMode: permissionMode2 });
1848
+ }
1849
+ function handleAgentKill(ws, msg, sm) {
1850
+ const sessionId = msg.session ?? "default";
1851
+ const killed = sm.killSession(sessionId);
1852
+ wsReply(ws, msg, { status: killed ? "killed" : "no_session" });
1853
+ }
1854
+ function handleAgentStatus(ws, msg, sm) {
1855
+ const sessionId = msg.session ?? "default";
1856
+ const session = sm.getSession(sessionId);
1857
+ wsReply(ws, msg, {
1858
+ alive: session?.process?.alive ?? false,
1859
+ sessionId: session?.process?.sessionId ?? null,
1860
+ ccSessionId: session?.ccSessionId ?? null,
1861
+ eventCount: session?.eventCounter ?? 0,
1862
+ config: session?.lastStartConfig ?? null
1863
+ });
1864
+ }
1865
+ async function handleAgentRunOnce(ws, msg, sm) {
1866
+ if (!msg.message) return replyError(ws, msg, "message is required");
1867
+ try {
1868
+ const { result, usage } = await runOnce(sm, msg);
1869
+ wsReply(ws, msg, { result, usage });
1870
+ } catch (e) {
1871
+ replyError(ws, msg, e.message);
1872
+ }
1873
+ }
1874
+ function handleAgentSubscribe(ws, msg, sm, state) {
1875
+ const sessionId = msg.session ?? "default";
1876
+ const session = sm.getOrCreateSession(sessionId);
1877
+ state.agentUnsubs.get(sessionId)?.();
1878
+ let cursor = typeof msg.since === "number" ? msg.since : session.eventCounter;
1879
+ if (cursor < session.eventCounter) {
1880
+ const startIdx = Math.max(0, session.eventBuffer.length - (session.eventCounter - cursor));
1881
+ const events = session.eventBuffer.slice(startIdx);
1882
+ for (const event of events) {
1883
+ cursor++;
1884
+ send(ws, { type: "agent.event", session: sessionId, cursor, event });
1885
+ }
1886
+ }
1887
+ const unsub = sm.onSessionEvent(sessionId, (eventCursor, event) => {
1888
+ send(ws, { type: "agent.event", session: sessionId, cursor: eventCursor, event });
1889
+ });
1890
+ state.agentUnsubs.set(sessionId, unsub);
1891
+ reply(ws, msg, { cursor });
1892
+ }
1893
+ function handleAgentUnsubscribe(ws, msg, state) {
1894
+ const sessionId = msg.session ?? "default";
1895
+ state.agentUnsubs.get(sessionId)?.();
1896
+ state.agentUnsubs.delete(sessionId);
1897
+ reply(ws, msg, {});
1898
+ }
1899
+ var SKILL_POLL_MS = 2e3;
1900
+ function handleEventsSubscribe(ws, msg, sm, state) {
1901
+ state.skillEventUnsub?.();
1902
+ state.skillEventUnsub = null;
1903
+ if (state.skillPollTimer) {
1904
+ clearInterval(state.skillPollTimer);
1905
+ state.skillPollTimer = null;
1906
+ }
1907
+ let lastId = typeof msg.since === "number" ? msg.since : -1;
1908
+ if (lastId <= 0) {
1909
+ try {
1910
+ const db = getDb();
1911
+ const row = db.prepare("SELECT MAX(id) as maxId FROM skill_events").get();
1912
+ lastId = row.maxId ?? 0;
1913
+ } catch {
1914
+ lastId = 0;
1915
+ }
1916
+ }
1917
+ state.skillEventUnsub = sm.onSkillEvent((event) => {
1918
+ const eventId = event.id;
1919
+ if (eventId > lastId) {
1920
+ lastId = eventId;
1921
+ send(ws, { type: "skill.event", data: event });
1922
+ }
1923
+ });
1924
+ state.skillPollTimer = setInterval(() => {
1925
+ try {
1926
+ const db = getDb();
1927
+ const rows = db.prepare(
1928
+ `SELECT id, session_id, skill, type, message, data, created_at
1929
+ FROM skill_events WHERE id > ? ORDER BY id ASC LIMIT 50`
1930
+ ).all(lastId);
1931
+ for (const row of rows) {
1932
+ if (row.id > lastId) {
1933
+ lastId = row.id;
1934
+ send(ws, { type: "skill.event", data: row });
1935
+ }
1936
+ }
1937
+ } catch {
1938
+ }
1939
+ }, SKILL_POLL_MS);
1940
+ reply(ws, msg, { lastId });
1941
+ }
1942
+ function handleEventsUnsubscribe(ws, msg, state) {
1943
+ state.skillEventUnsub?.();
1944
+ state.skillEventUnsub = null;
1945
+ if (state.skillPollTimer) {
1946
+ clearInterval(state.skillPollTimer);
1947
+ state.skillPollTimer = null;
1948
+ }
1949
+ reply(ws, msg, {});
1950
+ }
1951
+ function handleEmit(ws, msg, sm) {
1952
+ const skill = msg.skill;
1953
+ const eventType = msg.eventType;
1954
+ const emitMessage = msg.message;
1955
+ const data = msg.data;
1956
+ const sessionId = msg.session;
1957
+ if (!skill || !eventType || !emitMessage) {
1958
+ return replyError(ws, msg, "skill, eventType, message are required");
1959
+ }
1960
+ try {
1961
+ const db = getDb();
1962
+ const result = db.prepare(
1963
+ `INSERT INTO skill_events (session_id, skill, type, message, data) VALUES (?, ?, ?, ?, ?)`
1964
+ ).run(sessionId ?? null, skill, eventType, emitMessage, data ?? null);
1965
+ const id = Number(result.lastInsertRowid);
1966
+ sm.broadcastSkillEvent({
1967
+ id,
1968
+ session_id: sessionId ?? null,
1969
+ skill,
1970
+ type: eventType,
1971
+ message: emitMessage,
1972
+ data: data ?? null,
1973
+ created_at: (/* @__PURE__ */ new Date()).toISOString()
1974
+ });
1975
+ wsReply(ws, msg, { id });
1976
+ } catch (e) {
1977
+ replyError(ws, msg, e.message);
1978
+ }
1979
+ }
1980
+ function handlePermissionRespond(ws, msg, sm) {
1981
+ const sessionId = msg.session ?? "default";
1982
+ const approved = msg.approved === true;
1983
+ const resolved = sm.resolvePendingPermission(sessionId, approved);
1984
+ if (!resolved) return replyError(ws, msg, "No pending permission request");
1985
+ wsReply(ws, msg, { status: approved ? "approved" : "denied" });
1986
+ }
1987
+ function handlePermissionPending(ws, msg, sm) {
1988
+ const sessionId = msg.session;
1989
+ if (sessionId) {
1990
+ const pending = sm.getPendingPermission(sessionId);
1991
+ wsReply(ws, msg, { pending: pending ? [{ sessionId, ...pending }] : [] });
1992
+ } else {
1993
+ wsReply(ws, msg, { pending: sm.getAllPendingPermissions() });
1994
+ }
1995
+ }
1996
+ function handlePermissionSubscribe(ws, msg, sm, state) {
1997
+ state.permissionUnsub?.();
1998
+ state.permissionUnsub = sm.onPermissionRequest((sessionId, request, createdAt) => {
1999
+ send(ws, { type: "permission.request", session: sessionId, request, createdAt });
2000
+ });
2001
+ reply(ws, msg, {});
2002
+ }
2003
+ function handlePermissionUnsubscribe(ws, msg, state) {
2004
+ state.permissionUnsub?.();
2005
+ state.permissionUnsub = null;
2006
+ reply(ws, msg, {});
2007
+ }
2008
+ function handleChatSessionsList(ws, msg) {
2009
+ try {
2010
+ const db = getDb();
2011
+ const rows = db.prepare(
2012
+ `SELECT id, label, type, meta, cwd, created_at FROM chat_sessions ORDER BY created_at DESC`
2013
+ ).all();
2014
+ const sessions = rows.map((r) => ({ ...r, meta: r.meta ? JSON.parse(r.meta) : null }));
2015
+ wsReply(ws, msg, { sessions });
2016
+ } catch (e) {
2017
+ replyError(ws, msg, e.message);
2018
+ }
2019
+ }
2020
+ function handleChatSessionsCreate(ws, msg) {
2021
+ const id = msg.id ?? crypto.randomUUID().slice(0, 8);
2022
+ try {
2023
+ const db = getDb();
2024
+ db.prepare(`INSERT OR IGNORE INTO chat_sessions (id, label, type, meta) VALUES (?, ?, ?, ?)`).run(id, msg.label ?? id, msg.chatType ?? "background", msg.meta ? JSON.stringify(msg.meta) : null);
2025
+ wsReply(ws, msg, { status: "created", id, meta: msg.meta ?? null });
2026
+ } catch (e) {
2027
+ replyError(ws, msg, e.message);
2028
+ }
2029
+ }
2030
+ function handleChatSessionsRemove(ws, msg) {
2031
+ const id = msg.session;
2032
+ if (!id) return replyError(ws, msg, "session is required");
2033
+ if (id === "default") return replyError(ws, msg, "Cannot delete default session");
2034
+ try {
2035
+ const db = getDb();
2036
+ db.prepare(`DELETE FROM chat_sessions WHERE id = ?`).run(id);
2037
+ wsReply(ws, msg, { status: "deleted" });
2038
+ } catch (e) {
2039
+ replyError(ws, msg, e.message);
2040
+ }
2041
+ }
2042
+ function handleChatMessagesList(ws, msg) {
2043
+ const id = msg.session;
2044
+ if (!id) return replyError(ws, msg, "session is required");
2045
+ try {
2046
+ const db = getDb();
2047
+ const query = msg.since != null ? db.prepare(`SELECT * FROM chat_messages WHERE session_id = ? AND id > ? ORDER BY id ASC`) : db.prepare(`SELECT * FROM chat_messages WHERE session_id = ? ORDER BY id ASC`);
2048
+ const messages = msg.since != null ? query.all(id, msg.since) : query.all(id);
2049
+ wsReply(ws, msg, { messages });
2050
+ } catch (e) {
2051
+ replyError(ws, msg, e.message);
2052
+ }
2053
+ }
2054
+ function handleChatMessagesCreate(ws, msg) {
2055
+ const sessionId = msg.session;
2056
+ if (!sessionId) return replyError(ws, msg, "session is required");
2057
+ if (!msg.role) return replyError(ws, msg, "role is required");
2058
+ try {
2059
+ const db = getDb();
2060
+ db.prepare(`INSERT OR IGNORE INTO chat_sessions (id, label, type) VALUES (?, ?, 'main')`).run(sessionId, sessionId);
2061
+ const result = db.prepare(
2062
+ `INSERT INTO chat_messages (session_id, role, content, skill_name, meta) VALUES (?, ?, ?, ?, ?)`
2063
+ ).run(
2064
+ sessionId,
2065
+ msg.role,
2066
+ msg.content ?? "",
2067
+ msg.skill_name ?? null,
2068
+ msg.meta ? JSON.stringify(msg.meta) : null
2069
+ );
2070
+ wsReply(ws, msg, { status: "created", id: Number(result.lastInsertRowid) });
2071
+ } catch (e) {
2072
+ replyError(ws, msg, e.message);
2073
+ }
2074
+ }
2075
+ function handleChatMessagesClear(ws, msg) {
2076
+ const id = msg.session;
2077
+ if (!id) return replyError(ws, msg, "session is required");
2078
+ try {
2079
+ const db = getDb();
2080
+ db.prepare(`DELETE FROM chat_messages WHERE session_id = ?`).run(id);
2081
+ wsReply(ws, msg, { status: "cleared" });
2082
+ } catch (e) {
2083
+ replyError(ws, msg, e.message);
2084
+ }
2085
+ }
2086
+
1041
2087
  // src/server/index.ts
1042
2088
  function createSnaApp(options = {}) {
1043
2089
  const sessionManager2 = options.sessionManager ?? new SessionManager();
1044
2090
  const app = new Hono3();
1045
2091
  app.get("/health", (c) => c.json({ ok: true, name: "sna", version: "1" }));
1046
2092
  app.get("/events", eventsRoute);
1047
- app.post("/emit", emitRoute);
2093
+ app.post("/emit", createEmitRoute(sessionManager2));
1048
2094
  app.route("/agent", createAgentRoutes(sessionManager2));
1049
2095
  app.route("/chat", createChatRoutes());
1050
2096
  if (options.runCommands) {
@@ -1060,7 +2106,8 @@ try {
1060
2106
  if (err2.message?.includes("NODE_MODULE_VERSION")) {
1061
2107
  console.error(`
1062
2108
  \u2717 better-sqlite3 was compiled for a different Node.js version.`);
1063
- console.error(` Run: pnpm rebuild better-sqlite3
2109
+ console.error(` This usually happens when electron-rebuild overwrites the native binary.`);
2110
+ console.error(` Fix: run "sna api:up" which auto-installs an isolated copy in .sna/native/
1064
2111
  `);
1065
2112
  } else {
1066
2113
  console.error(`
@@ -1088,8 +2135,8 @@ var methodColor = {
1088
2135
  root.use("*", async (c, next) => {
1089
2136
  const m = c.req.method;
1090
2137
  const colorFn = methodColor[m] ?? chalk2.white;
1091
- const path4 = new URL(c.req.url).pathname;
1092
- logger.log("req", `${colorFn(m.padEnd(6))} ${path4}`);
2138
+ const path5 = new URL(c.req.url).pathname;
2139
+ logger.log("req", `${colorFn(m.padEnd(6))} ${path5}`);
1093
2140
  await next();
1094
2141
  });
1095
2142
  var sessionManager = new SessionManager({ maxSessions });
@@ -1130,8 +2177,10 @@ process.on("uncaughtException", (err2) => {
1130
2177
  server = serve({ fetch: root.fetch, port }, () => {
1131
2178
  console.log("");
1132
2179
  logger.log("sna", chalk2.green.bold(`API server ready \u2192 http://localhost:${port}`));
2180
+ logger.log("sna", chalk2.dim(`WebSocket endpoint \u2192 ws://localhost:${port}/ws`));
1133
2181
  console.log("");
1134
2182
  });
2183
+ attachWebSocket(server, sessionManager);
1135
2184
  agentProcess.on("event", (e) => {
1136
2185
  if (e.type === "init") {
1137
2186
  logger.log("agent", chalk2.green(`agent ready (session=${e.data?.sessionId ?? "?"})`));