@sna-sdk/core 0.1.0 → 0.2.3

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);
@@ -36,13 +45,29 @@ function migrateSkillEvents(db) {
36
45
  db.exec("DROP TABLE IF EXISTS skill_events");
37
46
  }
38
47
  }
48
+ function migrateChatSessionsMeta(db) {
49
+ const cols = db.prepare("PRAGMA table_info(chat_sessions)").all();
50
+ if (cols.length > 0 && !cols.some((c) => c.name === "meta")) {
51
+ db.exec("ALTER TABLE chat_sessions ADD COLUMN meta TEXT");
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
+ }
59
+ }
39
60
  function initSchema(db) {
40
61
  migrateSkillEvents(db);
62
+ migrateChatSessionsMeta(db);
41
63
  db.exec(`
42
64
  CREATE TABLE IF NOT EXISTS chat_sessions (
43
65
  id TEXT PRIMARY KEY,
44
66
  label TEXT NOT NULL DEFAULT '',
45
67
  type TEXT NOT NULL DEFAULT 'main',
68
+ meta TEXT,
69
+ cwd TEXT,
70
+ last_start_config TEXT,
46
71
  created_at TEXT NOT NULL DEFAULT (datetime('now'))
47
72
  );
48
73
 
@@ -128,17 +153,41 @@ function eventsRoute(c) {
128
153
  });
129
154
  }
130
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
+
131
167
  // src/server/routes/emit.ts
132
- async function emitRoute(c) {
133
- const { skill, type, message, data } = await c.req.json();
134
- if (!skill || !type || !message) {
135
- return c.json({ error: "missing fields" }, 400);
136
- }
137
- const db = getDb();
138
- const result = db.prepare(
139
- `INSERT INTO skill_events (skill, type, message, data) VALUES (?, ?, ?, ?)`
140
- ).run(skill, type, message, data ?? null);
141
- 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
+ };
142
191
  }
143
192
 
144
193
  // src/server/routes/run.ts
@@ -223,6 +272,7 @@ var tags = {
223
272
  stdin: chalk.bold.green(" IN "),
224
273
  stdout: chalk.bold.yellow(" OUT "),
225
274
  route: chalk.bold.blue(" API "),
275
+ ws: chalk.bold.green(" WS "),
226
276
  err: chalk.bold.red(" ERR ")
227
277
  };
228
278
  var tagPlain = {
@@ -232,6 +282,7 @@ var tagPlain = {
232
282
  stdin: " IN ",
233
283
  stdout: " OUT ",
234
284
  route: " API ",
285
+ ws: " WS ",
235
286
  err: " ERR "
236
287
  };
237
288
  function appendFile(tag, args) {
@@ -347,6 +398,11 @@ var ClaudeCodeProcess = class {
347
398
  logger.log("stdin", msg.slice(0, 200));
348
399
  this.proc.stdin.write(msg + "\n");
349
400
  }
401
+ interrupt() {
402
+ if (this._alive) {
403
+ this.proc.kill("SIGINT");
404
+ }
405
+ }
350
406
  kill() {
351
407
  if (this._alive) {
352
408
  this._alive = false;
@@ -444,7 +500,7 @@ var ClaudeCodeProcess = class {
444
500
  timestamp: Date.now()
445
501
  };
446
502
  }
447
- if (msg.subtype === "error" || msg.is_error) {
503
+ if (msg.subtype?.startsWith("error") || msg.is_error) {
448
504
  return {
449
505
  type: "error",
450
506
  message: msg.result ?? msg.error ?? "Unknown error",
@@ -476,13 +532,14 @@ var ClaudeCodeProvider = class {
476
532
  }
477
533
  spawn(options) {
478
534
  const claudePath = resolveClaudePath(options.cwd);
479
- const hookScript = path3.join(options.cwd, "node_modules/@sna-sdk/core/dist/scripts/hook.js");
535
+ const hookScript = new URL("../../scripts/hook.js", import.meta.url).pathname;
536
+ const sessionId = options.env?.SNA_SESSION_ID ?? "default";
480
537
  const sdkSettings = {};
481
538
  if (options.permissionMode !== "bypassPermissions") {
482
539
  sdkSettings.hooks = {
483
540
  PreToolUse: [{
484
541
  matcher: ".*",
485
- hooks: [{ type: "command", command: `node "${hookScript}"` }]
542
+ hooks: [{ type: "command", command: `node "${hookScript}" --session=${sessionId}` }]
486
543
  }]
487
544
  };
488
545
  }
@@ -569,6 +626,58 @@ function getProvider(name = "claude-code") {
569
626
  function getSessionId(c) {
570
627
  return c.req.query("session") ?? "default";
571
628
  }
629
+ var DEFAULT_RUN_ONCE_TIMEOUT = 12e4;
630
+ async function runOnce(sessionManager2, opts) {
631
+ const sessionId = `run-once-${crypto.randomUUID().slice(0, 8)}`;
632
+ const timeout = opts.timeout ?? DEFAULT_RUN_ONCE_TIMEOUT;
633
+ const session = sessionManager2.createSession({
634
+ id: sessionId,
635
+ label: "run-once",
636
+ cwd: opts.cwd ?? process.cwd()
637
+ });
638
+ const provider2 = getProvider(opts.provider ?? "claude-code");
639
+ const extraArgs = opts.extraArgs ? [...opts.extraArgs] : [];
640
+ if (opts.systemPrompt) extraArgs.push("--system-prompt", opts.systemPrompt);
641
+ if (opts.appendSystemPrompt) extraArgs.push("--append-system-prompt", opts.appendSystemPrompt);
642
+ const proc = provider2.spawn({
643
+ cwd: session.cwd,
644
+ prompt: opts.message,
645
+ model: opts.model ?? "claude-sonnet-4-6",
646
+ permissionMode: opts.permissionMode ?? "bypassPermissions",
647
+ env: { SNA_SESSION_ID: sessionId },
648
+ extraArgs: extraArgs.length > 0 ? extraArgs : void 0
649
+ });
650
+ sessionManager2.setProcess(sessionId, proc);
651
+ try {
652
+ const result = await new Promise((resolve, reject) => {
653
+ const texts = [];
654
+ let usage = null;
655
+ const timer = setTimeout(() => {
656
+ reject(new Error(`run-once timed out after ${timeout}ms`));
657
+ }, timeout);
658
+ const unsub = sessionManager2.onSessionEvent(sessionId, (_cursor, e) => {
659
+ if (e.type === "assistant" && e.message) {
660
+ texts.push(e.message);
661
+ }
662
+ if (e.type === "complete") {
663
+ clearTimeout(timer);
664
+ unsub();
665
+ usage = e.data ?? null;
666
+ resolve({ result: texts.join("\n"), usage });
667
+ }
668
+ if (e.type === "error") {
669
+ clearTimeout(timer);
670
+ unsub();
671
+ reject(new Error(e.message ?? "Agent error"));
672
+ }
673
+ });
674
+ });
675
+ return result;
676
+ } finally {
677
+ sessionManager2.killSession(sessionId);
678
+ sessionManager2.removeSession(sessionId);
679
+ }
680
+ }
572
681
  function createAgentRoutes(sessionManager2) {
573
682
  const app = new Hono();
574
683
  app.post("/sessions", async (c) => {
@@ -576,17 +685,18 @@ function createAgentRoutes(sessionManager2) {
576
685
  try {
577
686
  const session = sessionManager2.createSession({
578
687
  label: body.label,
579
- cwd: body.cwd
688
+ cwd: body.cwd,
689
+ meta: body.meta
580
690
  });
581
691
  logger.log("route", `POST /sessions \u2192 created "${session.id}"`);
582
- return c.json({ status: "created", sessionId: session.id, label: session.label });
692
+ return httpJson(c, "sessions.create", { status: "created", sessionId: session.id, label: session.label, meta: session.meta });
583
693
  } catch (e) {
584
694
  logger.err("err", `POST /sessions \u2192 ${e.message}`);
585
695
  return c.json({ status: "error", message: e.message }, 409);
586
696
  }
587
697
  });
588
698
  app.get("/sessions", (c) => {
589
- return c.json({ sessions: sessionManager2.listSessions() });
699
+ return httpJson(c, "sessions.list", { sessions: sessionManager2.listSessions() });
590
700
  });
591
701
  app.delete("/sessions/:id", (c) => {
592
702
  const id = c.req.param("id");
@@ -598,18 +708,33 @@ function createAgentRoutes(sessionManager2) {
598
708
  return c.json({ status: "error", message: "Session not found" }, 404);
599
709
  }
600
710
  logger.log("route", `DELETE /sessions/${id} \u2192 removed`);
601
- return c.json({ status: "removed" });
711
+ return httpJson(c, "sessions.remove", { status: "removed" });
712
+ });
713
+ app.post("/run-once", async (c) => {
714
+ const body = await c.req.json().catch(() => ({}));
715
+ if (!body.message) {
716
+ return c.json({ status: "error", message: "message is required" }, 400);
717
+ }
718
+ try {
719
+ const result = await runOnce(sessionManager2, body);
720
+ return httpJson(c, "agent.run-once", result);
721
+ } catch (e) {
722
+ logger.err("err", `POST /run-once \u2192 ${e.message}`);
723
+ return c.json({ status: "error", message: e.message }, 500);
724
+ }
602
725
  });
603
726
  app.post("/start", async (c) => {
604
727
  const sessionId = getSessionId(c);
605
728
  const body = await c.req.json().catch(() => ({}));
606
- const session = sessionManager2.getOrCreateSession(sessionId);
729
+ const session = sessionManager2.getOrCreateSession(sessionId, {
730
+ cwd: body.cwd
731
+ });
607
732
  if (session.process?.alive && !body.force) {
608
733
  logger.log("route", `POST /start?session=${sessionId} \u2192 already_running`);
609
- return c.json({
734
+ return httpJson(c, "agent.start", {
610
735
  status: "already_running",
611
736
  provider: "claude-code",
612
- sessionId: session.process.sessionId
737
+ sessionId: session.process.sessionId ?? session.id
613
738
  });
614
739
  }
615
740
  if (session.process?.alive) {
@@ -631,18 +756,23 @@ function createAgentRoutes(sessionManager2) {
631
756
  }
632
757
  } catch {
633
758
  }
759
+ const providerName = body.provider ?? "claude-code";
760
+ const model = body.model ?? "claude-sonnet-4-6";
761
+ const permissionMode2 = body.permissionMode ?? "acceptEdits";
762
+ const extraArgs = body.extraArgs;
634
763
  try {
635
764
  const proc = provider2.spawn({
636
765
  cwd: session.cwd,
637
766
  prompt: body.prompt,
638
- model: body.model ?? "claude-sonnet-4-6",
639
- permissionMode: body.permissionMode ?? "acceptEdits",
767
+ model,
768
+ permissionMode: permissionMode2,
640
769
  env: { SNA_SESSION_ID: sessionId },
641
- extraArgs: body.extraArgs
770
+ extraArgs
642
771
  });
643
772
  sessionManager2.setProcess(sessionId, proc);
773
+ sessionManager2.saveStartConfig(sessionId, { provider: providerName, model, permissionMode: permissionMode2, extraArgs });
644
774
  logger.log("route", `POST /start?session=${sessionId} \u2192 started`);
645
- return c.json({
775
+ return httpJson(c, "agent.start", {
646
776
  status: "started",
647
777
  provider: provider2.name,
648
778
  sessionId: session.id
@@ -677,7 +807,7 @@ function createAgentRoutes(sessionManager2) {
677
807
  sessionManager2.touch(sessionId);
678
808
  logger.log("route", `POST /send?session=${sessionId} \u2192 "${body.message.slice(0, 80)}"`);
679
809
  session.process.send(body.message);
680
- return c.json({ status: "sent" });
810
+ return httpJson(c, "agent.send", { status: "sent" });
681
811
  });
682
812
  app.get("/events", (c) => {
683
813
  const sessionId = getSessionId(c);
@@ -712,76 +842,75 @@ function createAgentRoutes(sessionManager2) {
712
842
  }
713
843
  });
714
844
  });
845
+ app.post("/restart", async (c) => {
846
+ const sessionId = getSessionId(c);
847
+ const body = await c.req.json().catch(() => ({}));
848
+ try {
849
+ const { config } = sessionManager2.restartSession(sessionId, body, (cfg) => {
850
+ const prov = getProvider(cfg.provider);
851
+ return prov.spawn({
852
+ cwd: sessionManager2.getSession(sessionId).cwd,
853
+ model: cfg.model,
854
+ permissionMode: cfg.permissionMode,
855
+ env: { SNA_SESSION_ID: sessionId },
856
+ extraArgs: [...cfg.extraArgs ?? [], "--resume"]
857
+ });
858
+ });
859
+ logger.log("route", `POST /restart?session=${sessionId} \u2192 restarted`);
860
+ return httpJson(c, "agent.restart", {
861
+ status: "restarted",
862
+ provider: config.provider,
863
+ sessionId
864
+ });
865
+ } catch (e) {
866
+ logger.err("err", `POST /restart?session=${sessionId} \u2192 ${e.message}`);
867
+ return c.json({ status: "error", message: e.message }, 500);
868
+ }
869
+ });
870
+ app.post("/interrupt", async (c) => {
871
+ const sessionId = getSessionId(c);
872
+ const interrupted = sessionManager2.interruptSession(sessionId);
873
+ return httpJson(c, "agent.interrupt", { status: interrupted ? "interrupted" : "no_session" });
874
+ });
715
875
  app.post("/kill", async (c) => {
716
876
  const sessionId = getSessionId(c);
717
877
  const killed = sessionManager2.killSession(sessionId);
718
- return c.json({ status: killed ? "killed" : "no_session" });
878
+ return httpJson(c, "agent.kill", { status: killed ? "killed" : "no_session" });
719
879
  });
720
880
  app.get("/status", (c) => {
721
881
  const sessionId = getSessionId(c);
722
882
  const session = sessionManager2.getSession(sessionId);
723
- return c.json({
883
+ return httpJson(c, "agent.status", {
724
884
  alive: session?.process?.alive ?? false,
725
885
  sessionId: session?.process?.sessionId ?? null,
726
886
  eventCount: session?.eventCounter ?? 0
727
887
  });
728
888
  });
729
- const pendingPermissions = /* @__PURE__ */ new Map();
730
889
  app.post("/permission-request", async (c) => {
731
890
  const sessionId = getSessionId(c);
732
891
  const body = await c.req.json().catch(() => ({}));
733
892
  logger.log("route", `POST /permission-request?session=${sessionId} \u2192 ${body.tool_name}`);
734
- const session = sessionManager2.getSession(sessionId);
735
- if (session) session.state = "permission";
736
- const result = await new Promise((resolve) => {
737
- pendingPermissions.set(sessionId, {
738
- resolve,
739
- request: body,
740
- createdAt: Date.now()
741
- });
742
- setTimeout(() => {
743
- if (pendingPermissions.has(sessionId)) {
744
- pendingPermissions.delete(sessionId);
745
- resolve(false);
746
- }
747
- }, 3e5);
748
- });
893
+ const result = await sessionManager2.createPendingPermission(sessionId, body);
749
894
  return c.json({ approved: result });
750
895
  });
751
896
  app.post("/permission-respond", async (c) => {
752
897
  const sessionId = getSessionId(c);
753
898
  const body = await c.req.json().catch(() => ({}));
754
899
  const approved = body.approved ?? false;
755
- const pending = pendingPermissions.get(sessionId);
756
- if (!pending) {
900
+ const resolved = sessionManager2.resolvePendingPermission(sessionId, approved);
901
+ if (!resolved) {
757
902
  return c.json({ status: "error", message: "No pending permission request" }, 404);
758
903
  }
759
- pending.resolve(approved);
760
- pendingPermissions.delete(sessionId);
761
- const session = sessionManager2.getSession(sessionId);
762
- if (session) session.state = "processing";
763
904
  logger.log("route", `POST /permission-respond?session=${sessionId} \u2192 ${approved ? "approved" : "denied"}`);
764
- return c.json({ status: approved ? "approved" : "denied" });
905
+ return httpJson(c, "permission.respond", { status: approved ? "approved" : "denied" });
765
906
  });
766
907
  app.get("/permission-pending", (c) => {
767
908
  const sessionId = c.req.query("session");
768
909
  if (sessionId) {
769
- const pending = pendingPermissions.get(sessionId);
770
- if (!pending) return c.json({ pending: null });
771
- return c.json({
772
- pending: {
773
- sessionId,
774
- request: pending.request,
775
- createdAt: pending.createdAt
776
- }
777
- });
910
+ const pending = sessionManager2.getPendingPermission(sessionId);
911
+ return httpJson(c, "permission.pending", { pending: pending ? [{ sessionId, ...pending }] : [] });
778
912
  }
779
- const all = Array.from(pendingPermissions.entries()).map(([id, p]) => ({
780
- sessionId: id,
781
- request: p.request,
782
- createdAt: p.createdAt
783
- }));
784
- return c.json({ pending: all });
913
+ return httpJson(c, "permission.pending", { pending: sessionManager2.getAllPendingPermissions() });
785
914
  });
786
915
  return app;
787
916
  }
@@ -793,10 +922,14 @@ function createChatRoutes() {
793
922
  app.get("/sessions", (c) => {
794
923
  try {
795
924
  const db = getDb();
796
- const sessions = db.prepare(
797
- `SELECT id, label, type, created_at FROM chat_sessions ORDER BY created_at DESC`
925
+ const rows = db.prepare(
926
+ `SELECT id, label, type, meta, cwd, created_at FROM chat_sessions ORDER BY created_at DESC`
798
927
  ).all();
799
- return c.json({ sessions });
928
+ const sessions = rows.map((r) => ({
929
+ ...r,
930
+ meta: r.meta ? JSON.parse(r.meta) : null
931
+ }));
932
+ return httpJson(c, "chat.sessions.list", { sessions });
800
933
  } catch (e) {
801
934
  return c.json({ status: "error", message: e.message, stack: e.stack }, 500);
802
935
  }
@@ -807,9 +940,9 @@ function createChatRoutes() {
807
940
  try {
808
941
  const db = getDb();
809
942
  db.prepare(
810
- `INSERT OR IGNORE INTO chat_sessions (id, label, type) VALUES (?, ?, ?)`
811
- ).run(id, body.label ?? id, body.type ?? "background");
812
- return c.json({ status: "created", id });
943
+ `INSERT OR IGNORE INTO chat_sessions (id, label, type, meta) VALUES (?, ?, ?, ?)`
944
+ ).run(id, body.label ?? id, body.type ?? "background", body.meta ? JSON.stringify(body.meta) : null);
945
+ return httpJson(c, "chat.sessions.create", { status: "created", id, meta: body.meta ?? null });
813
946
  } catch (e) {
814
947
  return c.json({ status: "error", message: e.message }, 500);
815
948
  }
@@ -822,7 +955,7 @@ function createChatRoutes() {
822
955
  try {
823
956
  const db = getDb();
824
957
  db.prepare(`DELETE FROM chat_sessions WHERE id = ?`).run(id);
825
- return c.json({ status: "deleted" });
958
+ return httpJson(c, "chat.sessions.remove", { status: "deleted" });
826
959
  } catch (e) {
827
960
  return c.json({ status: "error", message: e.message }, 500);
828
961
  }
@@ -834,7 +967,7 @@ function createChatRoutes() {
834
967
  const db = getDb();
835
968
  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`);
836
969
  const messages = sinceParam ? query.all(id, parseInt(sinceParam, 10)) : query.all(id);
837
- return c.json({ messages });
970
+ return httpJson(c, "chat.messages.list", { messages });
838
971
  } catch (e) {
839
972
  return c.json({ status: "error", message: e.message, stack: e.stack }, 500);
840
973
  }
@@ -857,7 +990,7 @@ function createChatRoutes() {
857
990
  body.skill_name ?? null,
858
991
  body.meta ? JSON.stringify(body.meta) : null
859
992
  );
860
- return c.json({ status: "created", id: result.lastInsertRowid });
993
+ return httpJson(c, "chat.messages.create", { status: "created", id: Number(result.lastInsertRowid) });
861
994
  } catch (e) {
862
995
  return c.json({ status: "error", message: e.message }, 500);
863
996
  }
@@ -867,7 +1000,7 @@ function createChatRoutes() {
867
1000
  try {
868
1001
  const db = getDb();
869
1002
  db.prepare(`DELETE FROM chat_messages WHERE session_id = ?`).run(id);
870
- return c.json({ status: "cleared" });
1003
+ return httpJson(c, "chat.messages.clear", { status: "cleared" });
871
1004
  } catch (e) {
872
1005
  return c.json({ status: "error", message: e.message }, 500);
873
1006
  }
@@ -878,16 +1011,80 @@ function createChatRoutes() {
878
1011
  // src/server/session-manager.ts
879
1012
  var DEFAULT_MAX_SESSIONS = 5;
880
1013
  var MAX_EVENT_BUFFER = 500;
1014
+ var PERMISSION_TIMEOUT_MS = 3e5;
881
1015
  var SessionManager = class {
882
1016
  constructor(options = {}) {
883
1017
  this.sessions = /* @__PURE__ */ new Map();
1018
+ this.eventListeners = /* @__PURE__ */ new Map();
1019
+ this.pendingPermissions = /* @__PURE__ */ new Map();
1020
+ this.skillEventListeners = /* @__PURE__ */ new Set();
1021
+ this.permissionRequestListeners = /* @__PURE__ */ new Set();
1022
+ this.lifecycleListeners = /* @__PURE__ */ new Set();
884
1023
  this.maxSessions = options.maxSessions ?? DEFAULT_MAX_SESSIONS;
1024
+ this.restoreFromDb();
1025
+ }
1026
+ /** Restore session metadata from DB (cwd, label, meta). Process state is not restored. */
1027
+ restoreFromDb() {
1028
+ try {
1029
+ const db = getDb();
1030
+ const rows = db.prepare(
1031
+ `SELECT id, label, meta, cwd, last_start_config, created_at FROM chat_sessions`
1032
+ ).all();
1033
+ for (const row of rows) {
1034
+ if (this.sessions.has(row.id)) continue;
1035
+ this.sessions.set(row.id, {
1036
+ id: row.id,
1037
+ process: null,
1038
+ eventBuffer: [],
1039
+ eventCounter: 0,
1040
+ label: row.label,
1041
+ cwd: row.cwd ?? process.cwd(),
1042
+ meta: row.meta ? JSON.parse(row.meta) : null,
1043
+ state: "idle",
1044
+ lastStartConfig: row.last_start_config ? JSON.parse(row.last_start_config) : null,
1045
+ createdAt: new Date(row.created_at).getTime() || Date.now(),
1046
+ lastActivityAt: Date.now()
1047
+ });
1048
+ }
1049
+ } catch {
1050
+ }
1051
+ }
1052
+ /** Persist session metadata to DB. */
1053
+ persistSession(session) {
1054
+ try {
1055
+ const db = getDb();
1056
+ db.prepare(
1057
+ `INSERT OR REPLACE INTO chat_sessions (id, label, type, meta, cwd, last_start_config) VALUES (?, ?, 'main', ?, ?, ?)`
1058
+ ).run(
1059
+ session.id,
1060
+ session.label,
1061
+ session.meta ? JSON.stringify(session.meta) : null,
1062
+ session.cwd,
1063
+ session.lastStartConfig ? JSON.stringify(session.lastStartConfig) : null
1064
+ );
1065
+ } catch {
1066
+ }
885
1067
  }
886
1068
  /** Create a new session. Throws if max sessions reached. */
887
1069
  createSession(opts = {}) {
888
1070
  const id = opts.id ?? crypto.randomUUID().slice(0, 8);
889
1071
  if (this.sessions.has(id)) {
890
- return this.sessions.get(id);
1072
+ const existing = this.sessions.get(id);
1073
+ let changed = false;
1074
+ if (opts.cwd && opts.cwd !== existing.cwd) {
1075
+ existing.cwd = opts.cwd;
1076
+ changed = true;
1077
+ }
1078
+ if (opts.label && opts.label !== existing.label) {
1079
+ existing.label = opts.label;
1080
+ changed = true;
1081
+ }
1082
+ if (opts.meta !== void 0 && opts.meta !== existing.meta) {
1083
+ existing.meta = opts.meta ?? null;
1084
+ changed = true;
1085
+ }
1086
+ if (changed) this.persistSession(existing);
1087
+ return existing;
891
1088
  }
892
1089
  const aliveCount = Array.from(this.sessions.values()).filter((s) => s.process?.alive).length;
893
1090
  if (aliveCount >= this.maxSessions) {
@@ -900,11 +1097,14 @@ var SessionManager = class {
900
1097
  eventCounter: 0,
901
1098
  label: opts.label ?? id,
902
1099
  cwd: opts.cwd ?? process.cwd(),
1100
+ meta: opts.meta ?? null,
903
1101
  state: "idle",
1102
+ lastStartConfig: null,
904
1103
  createdAt: Date.now(),
905
1104
  lastActivityAt: Date.now()
906
1105
  };
907
1106
  this.sessions.set(id, session);
1107
+ this.persistSession(session);
908
1108
  return session;
909
1109
  }
910
1110
  /** Get a session by ID. */
@@ -914,7 +1114,13 @@ var SessionManager = class {
914
1114
  /** Get or create a session (used for "default" backward compat). */
915
1115
  getOrCreateSession(id, opts) {
916
1116
  const existing = this.sessions.get(id);
917
- if (existing) return existing;
1117
+ if (existing) {
1118
+ if (opts?.cwd && opts.cwd !== existing.cwd) {
1119
+ existing.cwd = opts.cwd;
1120
+ this.persistSession(existing);
1121
+ }
1122
+ return existing;
1123
+ }
918
1124
  return this.createSession({ id, ...opts });
919
1125
  }
920
1126
  /** Set the agent process for a session. Subscribes to events. */
@@ -934,13 +1140,144 @@ var SessionManager = class {
934
1140
  session.state = "waiting";
935
1141
  }
936
1142
  this.persistEvent(sessionId, e);
1143
+ const listeners = this.eventListeners.get(sessionId);
1144
+ if (listeners) {
1145
+ for (const cb of listeners) cb(session.eventCounter, e);
1146
+ }
1147
+ });
1148
+ proc.on("exit", (code) => {
1149
+ session.state = "idle";
1150
+ this.emitLifecycle({ session: sessionId, state: code != null ? "exited" : "crashed", code });
937
1151
  });
1152
+ proc.on("error", () => {
1153
+ session.state = "idle";
1154
+ this.emitLifecycle({ session: sessionId, state: "crashed" });
1155
+ });
1156
+ this.emitLifecycle({ session: sessionId, state: "started" });
1157
+ }
1158
+ // ── Event pub/sub (for WebSocket) ─────────────────────────────
1159
+ /** Subscribe to real-time events for a session. Returns unsubscribe function. */
1160
+ onSessionEvent(sessionId, cb) {
1161
+ let set = this.eventListeners.get(sessionId);
1162
+ if (!set) {
1163
+ set = /* @__PURE__ */ new Set();
1164
+ this.eventListeners.set(sessionId, set);
1165
+ }
1166
+ set.add(cb);
1167
+ return () => {
1168
+ set.delete(cb);
1169
+ if (set.size === 0) this.eventListeners.delete(sessionId);
1170
+ };
1171
+ }
1172
+ // ── Skill event pub/sub ────────────────────────────────────────
1173
+ /** Subscribe to skill events broadcast. Returns unsubscribe function. */
1174
+ onSkillEvent(cb) {
1175
+ this.skillEventListeners.add(cb);
1176
+ return () => this.skillEventListeners.delete(cb);
1177
+ }
1178
+ /** Broadcast a skill event to all subscribers (called after DB insert). */
1179
+ broadcastSkillEvent(event) {
1180
+ for (const cb of this.skillEventListeners) cb(event);
1181
+ }
1182
+ // ── Permission pub/sub ────────────────────────────────────────
1183
+ /** Subscribe to permission request notifications. Returns unsubscribe function. */
1184
+ onPermissionRequest(cb) {
1185
+ this.permissionRequestListeners.add(cb);
1186
+ return () => this.permissionRequestListeners.delete(cb);
1187
+ }
1188
+ // ── Session lifecycle pub/sub ──────────────────────────────────
1189
+ /** Subscribe to session lifecycle events (started/killed/exited/crashed). Returns unsubscribe function. */
1190
+ onSessionLifecycle(cb) {
1191
+ this.lifecycleListeners.add(cb);
1192
+ return () => this.lifecycleListeners.delete(cb);
1193
+ }
1194
+ emitLifecycle(event) {
1195
+ for (const cb of this.lifecycleListeners) cb(event);
1196
+ }
1197
+ // ── Permission management ─────────────────────────────────────
1198
+ /** Create a pending permission request. Returns a promise that resolves when approved/denied. */
1199
+ createPendingPermission(sessionId, request) {
1200
+ const session = this.sessions.get(sessionId);
1201
+ if (session) session.state = "permission";
1202
+ return new Promise((resolve) => {
1203
+ const createdAt = Date.now();
1204
+ this.pendingPermissions.set(sessionId, { resolve, request, createdAt });
1205
+ for (const cb of this.permissionRequestListeners) cb(sessionId, request, createdAt);
1206
+ setTimeout(() => {
1207
+ if (this.pendingPermissions.has(sessionId)) {
1208
+ this.pendingPermissions.delete(sessionId);
1209
+ resolve(false);
1210
+ }
1211
+ }, PERMISSION_TIMEOUT_MS);
1212
+ });
1213
+ }
1214
+ /** Resolve a pending permission request. Returns false if no pending request. */
1215
+ resolvePendingPermission(sessionId, approved) {
1216
+ const pending = this.pendingPermissions.get(sessionId);
1217
+ if (!pending) return false;
1218
+ pending.resolve(approved);
1219
+ this.pendingPermissions.delete(sessionId);
1220
+ const session = this.sessions.get(sessionId);
1221
+ if (session) session.state = "processing";
1222
+ return true;
1223
+ }
1224
+ /** Get a pending permission for a specific session. */
1225
+ getPendingPermission(sessionId) {
1226
+ const p = this.pendingPermissions.get(sessionId);
1227
+ return p ? { request: p.request, createdAt: p.createdAt } : null;
1228
+ }
1229
+ /** Get all pending permissions across sessions. */
1230
+ getAllPendingPermissions() {
1231
+ return Array.from(this.pendingPermissions.entries()).map(([id, p]) => ({
1232
+ sessionId: id,
1233
+ request: p.request,
1234
+ createdAt: p.createdAt
1235
+ }));
1236
+ }
1237
+ // ── Session lifecycle ─────────────────────────────────────────
1238
+ /** Kill the agent process in a session (session stays, can be restarted). */
1239
+ /** Save the start config for a session (called by start handlers). */
1240
+ saveStartConfig(id, config) {
1241
+ const session = this.sessions.get(id);
1242
+ if (!session) return;
1243
+ session.lastStartConfig = config;
1244
+ this.persistSession(session);
1245
+ }
1246
+ /** Restart session: kill → re-spawn with merged config + --resume. */
1247
+ restartSession(id, overrides, spawnFn) {
1248
+ const session = this.sessions.get(id);
1249
+ if (!session) throw new Error(`Session "${id}" not found`);
1250
+ const base = session.lastStartConfig;
1251
+ if (!base) throw new Error(`Session "${id}" has no previous start config`);
1252
+ const config = {
1253
+ provider: overrides.provider ?? base.provider,
1254
+ model: overrides.model ?? base.model,
1255
+ permissionMode: overrides.permissionMode ?? base.permissionMode,
1256
+ extraArgs: overrides.extraArgs ?? base.extraArgs
1257
+ };
1258
+ if (session.process?.alive) session.process.kill();
1259
+ session.eventBuffer.length = 0;
1260
+ const proc = spawnFn(config);
1261
+ this.setProcess(id, proc);
1262
+ session.lastStartConfig = config;
1263
+ this.persistSession(session);
1264
+ this.emitLifecycle({ session: id, state: "restarted" });
1265
+ return { config };
1266
+ }
1267
+ /** Interrupt the current turn (SIGINT). Process stays alive, returns to waiting. */
1268
+ interruptSession(id) {
1269
+ const session = this.sessions.get(id);
1270
+ if (!session?.process?.alive) return false;
1271
+ session.process.interrupt();
1272
+ session.state = "waiting";
1273
+ return true;
938
1274
  }
939
1275
  /** Kill the agent process in a session (session stays, can be restarted). */
940
1276
  killSession(id) {
941
1277
  const session = this.sessions.get(id);
942
1278
  if (!session?.process?.alive) return false;
943
1279
  session.process.kill();
1280
+ this.emitLifecycle({ session: id, state: "killed" });
944
1281
  return true;
945
1282
  }
946
1283
  /** Remove a session entirely. Cannot remove "default". */
@@ -949,6 +1286,8 @@ var SessionManager = class {
949
1286
  const session = this.sessions.get(id);
950
1287
  if (!session) return false;
951
1288
  if (session.process?.alive) session.process.kill();
1289
+ this.eventListeners.delete(id);
1290
+ this.pendingPermissions.delete(id);
952
1291
  this.sessions.delete(id);
953
1292
  return true;
954
1293
  }
@@ -960,6 +1299,7 @@ var SessionManager = class {
960
1299
  alive: s.process?.alive ?? false,
961
1300
  state: s.state,
962
1301
  cwd: s.cwd,
1302
+ meta: s.meta,
963
1303
  eventCount: s.eventCounter,
964
1304
  createdAt: s.createdAt,
965
1305
  lastActivityAt: s.lastActivityAt
@@ -1016,13 +1356,492 @@ var SessionManager = class {
1016
1356
  }
1017
1357
  };
1018
1358
 
1359
+ // src/server/ws.ts
1360
+ import { WebSocketServer } from "ws";
1361
+ function send(ws, data) {
1362
+ if (ws.readyState === ws.OPEN) {
1363
+ ws.send(JSON.stringify(data));
1364
+ }
1365
+ }
1366
+ function reply(ws, msg, data) {
1367
+ send(ws, { ...data, type: msg.type, ...msg.rid != null ? { rid: msg.rid } : {} });
1368
+ }
1369
+ function replyError(ws, msg, message) {
1370
+ send(ws, { type: "error", ...msg.rid != null ? { rid: msg.rid } : {}, message });
1371
+ }
1372
+ function attachWebSocket(server2, sessionManager2) {
1373
+ const wss = new WebSocketServer({ noServer: true });
1374
+ server2.on("upgrade", (req, socket, head) => {
1375
+ const url = new URL(req.url ?? "/", `http://${req.headers.host}`);
1376
+ if (url.pathname === "/ws") {
1377
+ wss.handleUpgrade(req, socket, head, (ws) => {
1378
+ wss.emit("connection", ws, req);
1379
+ });
1380
+ } else {
1381
+ socket.destroy();
1382
+ }
1383
+ });
1384
+ wss.on("connection", (ws) => {
1385
+ logger.log("ws", "client connected");
1386
+ const state = { agentUnsubs: /* @__PURE__ */ new Map(), skillEventUnsub: null, skillPollTimer: null, permissionUnsub: null, lifecycleUnsub: null };
1387
+ state.lifecycleUnsub = sessionManager2.onSessionLifecycle((event) => {
1388
+ send(ws, { type: "session.lifecycle", ...event });
1389
+ });
1390
+ ws.on("message", (raw) => {
1391
+ let msg;
1392
+ try {
1393
+ msg = JSON.parse(raw.toString());
1394
+ } catch {
1395
+ send(ws, { type: "error", message: "invalid JSON" });
1396
+ return;
1397
+ }
1398
+ if (!msg.type) {
1399
+ send(ws, { type: "error", message: "type is required" });
1400
+ return;
1401
+ }
1402
+ handleMessage(ws, msg, sessionManager2, state);
1403
+ });
1404
+ ws.on("close", () => {
1405
+ logger.log("ws", "client disconnected");
1406
+ for (const unsub of state.agentUnsubs.values()) unsub();
1407
+ state.agentUnsubs.clear();
1408
+ state.skillEventUnsub?.();
1409
+ state.skillEventUnsub = null;
1410
+ if (state.skillPollTimer) {
1411
+ clearInterval(state.skillPollTimer);
1412
+ state.skillPollTimer = null;
1413
+ }
1414
+ state.permissionUnsub?.();
1415
+ state.permissionUnsub = null;
1416
+ state.lifecycleUnsub?.();
1417
+ state.lifecycleUnsub = null;
1418
+ });
1419
+ });
1420
+ return wss;
1421
+ }
1422
+ function handleMessage(ws, msg, sm, state) {
1423
+ switch (msg.type) {
1424
+ // ── Session CRUD ──────────────────────────────────
1425
+ case "sessions.create":
1426
+ return handleSessionsCreate(ws, msg, sm);
1427
+ case "sessions.list":
1428
+ return wsReply(ws, msg, { sessions: sm.listSessions() });
1429
+ case "sessions.remove":
1430
+ return handleSessionsRemove(ws, msg, sm);
1431
+ // ── Agent lifecycle ───────────────────────────────
1432
+ case "agent.start":
1433
+ return handleAgentStart(ws, msg, sm);
1434
+ case "agent.send":
1435
+ return handleAgentSend(ws, msg, sm);
1436
+ case "agent.restart":
1437
+ return handleAgentRestart(ws, msg, sm);
1438
+ case "agent.interrupt":
1439
+ return handleAgentInterrupt(ws, msg, sm);
1440
+ case "agent.kill":
1441
+ return handleAgentKill(ws, msg, sm);
1442
+ case "agent.status":
1443
+ return handleAgentStatus(ws, msg, sm);
1444
+ case "agent.run-once":
1445
+ handleAgentRunOnce(ws, msg, sm);
1446
+ return;
1447
+ // ── Agent event subscription ──────────────────────
1448
+ case "agent.subscribe":
1449
+ return handleAgentSubscribe(ws, msg, sm, state);
1450
+ case "agent.unsubscribe":
1451
+ return handleAgentUnsubscribe(ws, msg, state);
1452
+ // ── Skill events ──────────────────────────────────
1453
+ case "events.subscribe":
1454
+ return handleEventsSubscribe(ws, msg, sm, state);
1455
+ case "events.unsubscribe":
1456
+ return handleEventsUnsubscribe(ws, msg, state);
1457
+ case "emit":
1458
+ return handleEmit(ws, msg, sm);
1459
+ // ── Permission ────────────────────────────────────
1460
+ case "permission.respond":
1461
+ return handlePermissionRespond(ws, msg, sm);
1462
+ case "permission.pending":
1463
+ return handlePermissionPending(ws, msg, sm);
1464
+ case "permission.subscribe":
1465
+ return handlePermissionSubscribe(ws, msg, sm, state);
1466
+ case "permission.unsubscribe":
1467
+ return handlePermissionUnsubscribe(ws, msg, state);
1468
+ // ── Chat sessions ─────────────────────────────────
1469
+ case "chat.sessions.list":
1470
+ return handleChatSessionsList(ws, msg);
1471
+ case "chat.sessions.create":
1472
+ return handleChatSessionsCreate(ws, msg);
1473
+ case "chat.sessions.remove":
1474
+ return handleChatSessionsRemove(ws, msg);
1475
+ // ── Chat messages ─────────────────────────────────
1476
+ case "chat.messages.list":
1477
+ return handleChatMessagesList(ws, msg);
1478
+ case "chat.messages.create":
1479
+ return handleChatMessagesCreate(ws, msg);
1480
+ case "chat.messages.clear":
1481
+ return handleChatMessagesClear(ws, msg);
1482
+ default:
1483
+ replyError(ws, msg, `Unknown message type: ${msg.type}`);
1484
+ }
1485
+ }
1486
+ function handleSessionsCreate(ws, msg, sm) {
1487
+ try {
1488
+ const session = sm.createSession({
1489
+ label: msg.label,
1490
+ cwd: msg.cwd,
1491
+ meta: msg.meta
1492
+ });
1493
+ wsReply(ws, msg, { status: "created", sessionId: session.id, label: session.label, meta: session.meta });
1494
+ } catch (e) {
1495
+ replyError(ws, msg, e.message);
1496
+ }
1497
+ }
1498
+ function handleSessionsRemove(ws, msg, sm) {
1499
+ const id = msg.session;
1500
+ if (!id) return replyError(ws, msg, "session is required");
1501
+ if (id === "default") return replyError(ws, msg, "Cannot remove default session");
1502
+ const removed = sm.removeSession(id);
1503
+ if (!removed) return replyError(ws, msg, "Session not found");
1504
+ wsReply(ws, msg, { status: "removed" });
1505
+ }
1506
+ function handleAgentStart(ws, msg, sm) {
1507
+ const sessionId = msg.session ?? "default";
1508
+ const session = sm.getOrCreateSession(sessionId, {
1509
+ cwd: msg.cwd
1510
+ });
1511
+ if (session.process?.alive && !msg.force) {
1512
+ wsReply(ws, msg, { status: "already_running", provider: "claude-code", sessionId: session.id });
1513
+ return;
1514
+ }
1515
+ if (session.process?.alive) session.process.kill();
1516
+ session.eventBuffer.length = 0;
1517
+ const provider2 = getProvider(msg.provider ?? "claude-code");
1518
+ try {
1519
+ const db = getDb();
1520
+ db.prepare(`INSERT OR IGNORE INTO chat_sessions (id, label, type) VALUES (?, ?, 'main')`).run(sessionId, session.label ?? sessionId);
1521
+ if (msg.prompt) {
1522
+ db.prepare(`INSERT INTO chat_messages (session_id, role, content, meta) VALUES (?, 'user', ?, ?)`).run(sessionId, msg.prompt, msg.meta ? JSON.stringify(msg.meta) : null);
1523
+ }
1524
+ const skillMatch = msg.prompt?.match(/^Execute the skill:\s*(\S+)/);
1525
+ if (skillMatch) {
1526
+ db.prepare(`INSERT INTO skill_events (session_id, skill, type, message) VALUES (?, ?, 'invoked', ?)`).run(sessionId, skillMatch[1], `Skill ${skillMatch[1]} invoked`);
1527
+ }
1528
+ } catch {
1529
+ }
1530
+ const providerName = msg.provider ?? "claude-code";
1531
+ const model = msg.model ?? "claude-sonnet-4-6";
1532
+ const permissionMode2 = msg.permissionMode ?? "acceptEdits";
1533
+ const extraArgs = msg.extraArgs;
1534
+ try {
1535
+ const proc = provider2.spawn({
1536
+ cwd: session.cwd,
1537
+ prompt: msg.prompt,
1538
+ model,
1539
+ permissionMode: permissionMode2,
1540
+ env: { SNA_SESSION_ID: sessionId },
1541
+ extraArgs
1542
+ });
1543
+ sm.setProcess(sessionId, proc);
1544
+ sm.saveStartConfig(sessionId, { provider: providerName, model, permissionMode: permissionMode2, extraArgs });
1545
+ wsReply(ws, msg, { status: "started", provider: provider2.name, sessionId: session.id });
1546
+ } catch (e) {
1547
+ replyError(ws, msg, e.message);
1548
+ }
1549
+ }
1550
+ function handleAgentSend(ws, msg, sm) {
1551
+ const sessionId = msg.session ?? "default";
1552
+ const session = sm.getSession(sessionId);
1553
+ if (!session?.process?.alive) {
1554
+ return replyError(ws, msg, `No active agent session "${sessionId}". Start first.`);
1555
+ }
1556
+ if (!msg.message) {
1557
+ return replyError(ws, msg, "message is required");
1558
+ }
1559
+ try {
1560
+ const db = getDb();
1561
+ db.prepare(`INSERT OR IGNORE INTO chat_sessions (id, label, type) VALUES (?, ?, 'main')`).run(sessionId, session.label ?? sessionId);
1562
+ db.prepare(`INSERT INTO chat_messages (session_id, role, content, meta) VALUES (?, 'user', ?, ?)`).run(sessionId, msg.message, msg.meta ? JSON.stringify(msg.meta) : null);
1563
+ } catch {
1564
+ }
1565
+ session.state = "processing";
1566
+ sm.touch(sessionId);
1567
+ session.process.send(msg.message);
1568
+ wsReply(ws, msg, { status: "sent" });
1569
+ }
1570
+ function handleAgentRestart(ws, msg, sm) {
1571
+ const sessionId = msg.session ?? "default";
1572
+ try {
1573
+ const { config } = sm.restartSession(
1574
+ sessionId,
1575
+ {
1576
+ provider: msg.provider,
1577
+ model: msg.model,
1578
+ permissionMode: msg.permissionMode,
1579
+ extraArgs: msg.extraArgs
1580
+ },
1581
+ (cfg) => {
1582
+ const prov = getProvider(cfg.provider);
1583
+ return prov.spawn({
1584
+ cwd: sm.getSession(sessionId).cwd,
1585
+ model: cfg.model,
1586
+ permissionMode: cfg.permissionMode,
1587
+ env: { SNA_SESSION_ID: sessionId },
1588
+ extraArgs: [...cfg.extraArgs ?? [], "--resume"]
1589
+ });
1590
+ }
1591
+ );
1592
+ wsReply(ws, msg, { status: "restarted", provider: config.provider, sessionId });
1593
+ } catch (e) {
1594
+ replyError(ws, msg, e.message);
1595
+ }
1596
+ }
1597
+ function handleAgentInterrupt(ws, msg, sm) {
1598
+ const sessionId = msg.session ?? "default";
1599
+ const interrupted = sm.interruptSession(sessionId);
1600
+ wsReply(ws, msg, { status: interrupted ? "interrupted" : "no_session" });
1601
+ }
1602
+ function handleAgentKill(ws, msg, sm) {
1603
+ const sessionId = msg.session ?? "default";
1604
+ const killed = sm.killSession(sessionId);
1605
+ wsReply(ws, msg, { status: killed ? "killed" : "no_session" });
1606
+ }
1607
+ function handleAgentStatus(ws, msg, sm) {
1608
+ const sessionId = msg.session ?? "default";
1609
+ const session = sm.getSession(sessionId);
1610
+ wsReply(ws, msg, {
1611
+ alive: session?.process?.alive ?? false,
1612
+ sessionId: session?.process?.sessionId ?? null,
1613
+ eventCount: session?.eventCounter ?? 0
1614
+ });
1615
+ }
1616
+ async function handleAgentRunOnce(ws, msg, sm) {
1617
+ if (!msg.message) return replyError(ws, msg, "message is required");
1618
+ try {
1619
+ const { result, usage } = await runOnce(sm, msg);
1620
+ wsReply(ws, msg, { result, usage });
1621
+ } catch (e) {
1622
+ replyError(ws, msg, e.message);
1623
+ }
1624
+ }
1625
+ function handleAgentSubscribe(ws, msg, sm, state) {
1626
+ const sessionId = msg.session ?? "default";
1627
+ const session = sm.getOrCreateSession(sessionId);
1628
+ state.agentUnsubs.get(sessionId)?.();
1629
+ let cursor = typeof msg.since === "number" ? msg.since : session.eventCounter;
1630
+ if (cursor < session.eventCounter) {
1631
+ const startIdx = Math.max(0, session.eventBuffer.length - (session.eventCounter - cursor));
1632
+ const events = session.eventBuffer.slice(startIdx);
1633
+ for (const event of events) {
1634
+ cursor++;
1635
+ send(ws, { type: "agent.event", session: sessionId, cursor, event });
1636
+ }
1637
+ }
1638
+ const unsub = sm.onSessionEvent(sessionId, (eventCursor, event) => {
1639
+ send(ws, { type: "agent.event", session: sessionId, cursor: eventCursor, event });
1640
+ });
1641
+ state.agentUnsubs.set(sessionId, unsub);
1642
+ reply(ws, msg, { cursor });
1643
+ }
1644
+ function handleAgentUnsubscribe(ws, msg, state) {
1645
+ const sessionId = msg.session ?? "default";
1646
+ state.agentUnsubs.get(sessionId)?.();
1647
+ state.agentUnsubs.delete(sessionId);
1648
+ reply(ws, msg, {});
1649
+ }
1650
+ var SKILL_POLL_MS = 2e3;
1651
+ function handleEventsSubscribe(ws, msg, sm, state) {
1652
+ state.skillEventUnsub?.();
1653
+ state.skillEventUnsub = null;
1654
+ if (state.skillPollTimer) {
1655
+ clearInterval(state.skillPollTimer);
1656
+ state.skillPollTimer = null;
1657
+ }
1658
+ let lastId = typeof msg.since === "number" ? msg.since : -1;
1659
+ if (lastId <= 0) {
1660
+ try {
1661
+ const db = getDb();
1662
+ const row = db.prepare("SELECT MAX(id) as maxId FROM skill_events").get();
1663
+ lastId = row.maxId ?? 0;
1664
+ } catch {
1665
+ lastId = 0;
1666
+ }
1667
+ }
1668
+ state.skillEventUnsub = sm.onSkillEvent((event) => {
1669
+ const eventId = event.id;
1670
+ if (eventId > lastId) {
1671
+ lastId = eventId;
1672
+ send(ws, { type: "skill.event", data: event });
1673
+ }
1674
+ });
1675
+ state.skillPollTimer = setInterval(() => {
1676
+ try {
1677
+ const db = getDb();
1678
+ const rows = db.prepare(
1679
+ `SELECT id, session_id, skill, type, message, data, created_at
1680
+ FROM skill_events WHERE id > ? ORDER BY id ASC LIMIT 50`
1681
+ ).all(lastId);
1682
+ for (const row of rows) {
1683
+ if (row.id > lastId) {
1684
+ lastId = row.id;
1685
+ send(ws, { type: "skill.event", data: row });
1686
+ }
1687
+ }
1688
+ } catch {
1689
+ }
1690
+ }, SKILL_POLL_MS);
1691
+ reply(ws, msg, { lastId });
1692
+ }
1693
+ function handleEventsUnsubscribe(ws, msg, state) {
1694
+ state.skillEventUnsub?.();
1695
+ state.skillEventUnsub = null;
1696
+ if (state.skillPollTimer) {
1697
+ clearInterval(state.skillPollTimer);
1698
+ state.skillPollTimer = null;
1699
+ }
1700
+ reply(ws, msg, {});
1701
+ }
1702
+ function handleEmit(ws, msg, sm) {
1703
+ const skill = msg.skill;
1704
+ const eventType = msg.eventType;
1705
+ const emitMessage = msg.message;
1706
+ const data = msg.data;
1707
+ const sessionId = msg.session;
1708
+ if (!skill || !eventType || !emitMessage) {
1709
+ return replyError(ws, msg, "skill, eventType, message are required");
1710
+ }
1711
+ try {
1712
+ const db = getDb();
1713
+ const result = db.prepare(
1714
+ `INSERT INTO skill_events (session_id, skill, type, message, data) VALUES (?, ?, ?, ?, ?)`
1715
+ ).run(sessionId ?? null, skill, eventType, emitMessage, data ?? null);
1716
+ const id = Number(result.lastInsertRowid);
1717
+ sm.broadcastSkillEvent({
1718
+ id,
1719
+ session_id: sessionId ?? null,
1720
+ skill,
1721
+ type: eventType,
1722
+ message: emitMessage,
1723
+ data: data ?? null,
1724
+ created_at: (/* @__PURE__ */ new Date()).toISOString()
1725
+ });
1726
+ wsReply(ws, msg, { id });
1727
+ } catch (e) {
1728
+ replyError(ws, msg, e.message);
1729
+ }
1730
+ }
1731
+ function handlePermissionRespond(ws, msg, sm) {
1732
+ const sessionId = msg.session ?? "default";
1733
+ const approved = msg.approved === true;
1734
+ const resolved = sm.resolvePendingPermission(sessionId, approved);
1735
+ if (!resolved) return replyError(ws, msg, "No pending permission request");
1736
+ wsReply(ws, msg, { status: approved ? "approved" : "denied" });
1737
+ }
1738
+ function handlePermissionPending(ws, msg, sm) {
1739
+ const sessionId = msg.session;
1740
+ if (sessionId) {
1741
+ const pending = sm.getPendingPermission(sessionId);
1742
+ wsReply(ws, msg, { pending: pending ? [{ sessionId, ...pending }] : [] });
1743
+ } else {
1744
+ wsReply(ws, msg, { pending: sm.getAllPendingPermissions() });
1745
+ }
1746
+ }
1747
+ function handlePermissionSubscribe(ws, msg, sm, state) {
1748
+ state.permissionUnsub?.();
1749
+ state.permissionUnsub = sm.onPermissionRequest((sessionId, request, createdAt) => {
1750
+ send(ws, { type: "permission.request", session: sessionId, request, createdAt });
1751
+ });
1752
+ reply(ws, msg, {});
1753
+ }
1754
+ function handlePermissionUnsubscribe(ws, msg, state) {
1755
+ state.permissionUnsub?.();
1756
+ state.permissionUnsub = null;
1757
+ reply(ws, msg, {});
1758
+ }
1759
+ function handleChatSessionsList(ws, msg) {
1760
+ try {
1761
+ const db = getDb();
1762
+ const rows = db.prepare(
1763
+ `SELECT id, label, type, meta, cwd, created_at FROM chat_sessions ORDER BY created_at DESC`
1764
+ ).all();
1765
+ const sessions = rows.map((r) => ({ ...r, meta: r.meta ? JSON.parse(r.meta) : null }));
1766
+ wsReply(ws, msg, { sessions });
1767
+ } catch (e) {
1768
+ replyError(ws, msg, e.message);
1769
+ }
1770
+ }
1771
+ function handleChatSessionsCreate(ws, msg) {
1772
+ const id = msg.id ?? crypto.randomUUID().slice(0, 8);
1773
+ try {
1774
+ const db = getDb();
1775
+ 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);
1776
+ wsReply(ws, msg, { status: "created", id, meta: msg.meta ?? null });
1777
+ } catch (e) {
1778
+ replyError(ws, msg, e.message);
1779
+ }
1780
+ }
1781
+ function handleChatSessionsRemove(ws, msg) {
1782
+ const id = msg.session;
1783
+ if (!id) return replyError(ws, msg, "session is required");
1784
+ if (id === "default") return replyError(ws, msg, "Cannot delete default session");
1785
+ try {
1786
+ const db = getDb();
1787
+ db.prepare(`DELETE FROM chat_sessions WHERE id = ?`).run(id);
1788
+ wsReply(ws, msg, { status: "deleted" });
1789
+ } catch (e) {
1790
+ replyError(ws, msg, e.message);
1791
+ }
1792
+ }
1793
+ function handleChatMessagesList(ws, msg) {
1794
+ const id = msg.session;
1795
+ if (!id) return replyError(ws, msg, "session is required");
1796
+ try {
1797
+ const db = getDb();
1798
+ 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`);
1799
+ const messages = msg.since != null ? query.all(id, msg.since) : query.all(id);
1800
+ wsReply(ws, msg, { messages });
1801
+ } catch (e) {
1802
+ replyError(ws, msg, e.message);
1803
+ }
1804
+ }
1805
+ function handleChatMessagesCreate(ws, msg) {
1806
+ const sessionId = msg.session;
1807
+ if (!sessionId) return replyError(ws, msg, "session is required");
1808
+ if (!msg.role) return replyError(ws, msg, "role is required");
1809
+ try {
1810
+ const db = getDb();
1811
+ db.prepare(`INSERT OR IGNORE INTO chat_sessions (id, label, type) VALUES (?, ?, 'main')`).run(sessionId, sessionId);
1812
+ const result = db.prepare(
1813
+ `INSERT INTO chat_messages (session_id, role, content, skill_name, meta) VALUES (?, ?, ?, ?, ?)`
1814
+ ).run(
1815
+ sessionId,
1816
+ msg.role,
1817
+ msg.content ?? "",
1818
+ msg.skill_name ?? null,
1819
+ msg.meta ? JSON.stringify(msg.meta) : null
1820
+ );
1821
+ wsReply(ws, msg, { status: "created", id: Number(result.lastInsertRowid) });
1822
+ } catch (e) {
1823
+ replyError(ws, msg, e.message);
1824
+ }
1825
+ }
1826
+ function handleChatMessagesClear(ws, msg) {
1827
+ const id = msg.session;
1828
+ if (!id) return replyError(ws, msg, "session is required");
1829
+ try {
1830
+ const db = getDb();
1831
+ db.prepare(`DELETE FROM chat_messages WHERE session_id = ?`).run(id);
1832
+ wsReply(ws, msg, { status: "cleared" });
1833
+ } catch (e) {
1834
+ replyError(ws, msg, e.message);
1835
+ }
1836
+ }
1837
+
1019
1838
  // src/server/index.ts
1020
1839
  function createSnaApp(options = {}) {
1021
1840
  const sessionManager2 = options.sessionManager ?? new SessionManager();
1022
1841
  const app = new Hono3();
1023
1842
  app.get("/health", (c) => c.json({ ok: true, name: "sna", version: "1" }));
1024
1843
  app.get("/events", eventsRoute);
1025
- app.post("/emit", emitRoute);
1844
+ app.post("/emit", createEmitRoute(sessionManager2));
1026
1845
  app.route("/agent", createAgentRoutes(sessionManager2));
1027
1846
  app.route("/chat", createChatRoutes());
1028
1847
  if (options.runCommands) {
@@ -1038,7 +1857,8 @@ try {
1038
1857
  if (err2.message?.includes("NODE_MODULE_VERSION")) {
1039
1858
  console.error(`
1040
1859
  \u2717 better-sqlite3 was compiled for a different Node.js version.`);
1041
- console.error(` Run: pnpm rebuild better-sqlite3
1860
+ console.error(` This usually happens when electron-rebuild overwrites the native binary.`);
1861
+ console.error(` Fix: run "sna api:up" which auto-installs an isolated copy in .sna/native/
1042
1862
  `);
1043
1863
  } else {
1044
1864
  console.error(`
@@ -1108,8 +1928,10 @@ process.on("uncaughtException", (err2) => {
1108
1928
  server = serve({ fetch: root.fetch, port }, () => {
1109
1929
  console.log("");
1110
1930
  logger.log("sna", chalk2.green.bold(`API server ready \u2192 http://localhost:${port}`));
1931
+ logger.log("sna", chalk2.dim(`WebSocket endpoint \u2192 ws://localhost:${port}/ws`));
1111
1932
  console.log("");
1112
1933
  });
1934
+ attachWebSocket(server, sessionManager);
1113
1935
  agentProcess.on("event", (e) => {
1114
1936
  if (e.type === "init") {
1115
1937
  logger.log("agent", chalk2.green(`agent ready (session=${e.data?.sessionId ?? "?"})`));