@sna-sdk/core 0.1.1 → 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);
@@ -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) {
@@ -355,6 +398,11 @@ var ClaudeCodeProcess = class {
355
398
  logger.log("stdin", msg.slice(0, 200));
356
399
  this.proc.stdin.write(msg + "\n");
357
400
  }
401
+ interrupt() {
402
+ if (this._alive) {
403
+ this.proc.kill("SIGINT");
404
+ }
405
+ }
358
406
  kill() {
359
407
  if (this._alive) {
360
408
  this._alive = false;
@@ -452,7 +500,7 @@ var ClaudeCodeProcess = class {
452
500
  timestamp: Date.now()
453
501
  };
454
502
  }
455
- if (msg.subtype === "error" || msg.is_error) {
503
+ if (msg.subtype?.startsWith("error") || msg.is_error) {
456
504
  return {
457
505
  type: "error",
458
506
  message: msg.result ?? msg.error ?? "Unknown error",
@@ -484,13 +532,14 @@ var ClaudeCodeProvider = class {
484
532
  }
485
533
  spawn(options) {
486
534
  const claudePath = resolveClaudePath(options.cwd);
487
- 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";
488
537
  const sdkSettings = {};
489
538
  if (options.permissionMode !== "bypassPermissions") {
490
539
  sdkSettings.hooks = {
491
540
  PreToolUse: [{
492
541
  matcher: ".*",
493
- hooks: [{ type: "command", command: `node "${hookScript}"` }]
542
+ hooks: [{ type: "command", command: `node "${hookScript}" --session=${sessionId}` }]
494
543
  }]
495
544
  };
496
545
  }
@@ -577,6 +626,58 @@ function getProvider(name = "claude-code") {
577
626
  function getSessionId(c) {
578
627
  return c.req.query("session") ?? "default";
579
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
+ }
580
681
  function createAgentRoutes(sessionManager2) {
581
682
  const app = new Hono();
582
683
  app.post("/sessions", async (c) => {
@@ -587,22 +688,15 @@ function createAgentRoutes(sessionManager2) {
587
688
  cwd: body.cwd,
588
689
  meta: body.meta
589
690
  });
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
691
  logger.log("route", `POST /sessions \u2192 created "${session.id}"`);
598
- return c.json({ status: "created", sessionId: session.id, label: session.label, meta: session.meta });
692
+ return httpJson(c, "sessions.create", { status: "created", sessionId: session.id, label: session.label, meta: session.meta });
599
693
  } catch (e) {
600
694
  logger.err("err", `POST /sessions \u2192 ${e.message}`);
601
695
  return c.json({ status: "error", message: e.message }, 409);
602
696
  }
603
697
  });
604
698
  app.get("/sessions", (c) => {
605
- return c.json({ sessions: sessionManager2.listSessions() });
699
+ return httpJson(c, "sessions.list", { sessions: sessionManager2.listSessions() });
606
700
  });
607
701
  app.delete("/sessions/:id", (c) => {
608
702
  const id = c.req.param("id");
@@ -614,18 +708,33 @@ function createAgentRoutes(sessionManager2) {
614
708
  return c.json({ status: "error", message: "Session not found" }, 404);
615
709
  }
616
710
  logger.log("route", `DELETE /sessions/${id} \u2192 removed`);
617
- 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
+ }
618
725
  });
619
726
  app.post("/start", async (c) => {
620
727
  const sessionId = getSessionId(c);
621
728
  const body = await c.req.json().catch(() => ({}));
622
- const session = sessionManager2.getOrCreateSession(sessionId);
729
+ const session = sessionManager2.getOrCreateSession(sessionId, {
730
+ cwd: body.cwd
731
+ });
623
732
  if (session.process?.alive && !body.force) {
624
733
  logger.log("route", `POST /start?session=${sessionId} \u2192 already_running`);
625
- return c.json({
734
+ return httpJson(c, "agent.start", {
626
735
  status: "already_running",
627
736
  provider: "claude-code",
628
- sessionId: session.process.sessionId
737
+ sessionId: session.process.sessionId ?? session.id
629
738
  });
630
739
  }
631
740
  if (session.process?.alive) {
@@ -647,18 +756,23 @@ function createAgentRoutes(sessionManager2) {
647
756
  }
648
757
  } catch {
649
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;
650
763
  try {
651
764
  const proc = provider2.spawn({
652
765
  cwd: session.cwd,
653
766
  prompt: body.prompt,
654
- model: body.model ?? "claude-sonnet-4-6",
655
- permissionMode: body.permissionMode ?? "acceptEdits",
767
+ model,
768
+ permissionMode: permissionMode2,
656
769
  env: { SNA_SESSION_ID: sessionId },
657
- extraArgs: body.extraArgs
770
+ extraArgs
658
771
  });
659
772
  sessionManager2.setProcess(sessionId, proc);
773
+ sessionManager2.saveStartConfig(sessionId, { provider: providerName, model, permissionMode: permissionMode2, extraArgs });
660
774
  logger.log("route", `POST /start?session=${sessionId} \u2192 started`);
661
- return c.json({
775
+ return httpJson(c, "agent.start", {
662
776
  status: "started",
663
777
  provider: provider2.name,
664
778
  sessionId: session.id
@@ -693,7 +807,7 @@ function createAgentRoutes(sessionManager2) {
693
807
  sessionManager2.touch(sessionId);
694
808
  logger.log("route", `POST /send?session=${sessionId} \u2192 "${body.message.slice(0, 80)}"`);
695
809
  session.process.send(body.message);
696
- return c.json({ status: "sent" });
810
+ return httpJson(c, "agent.send", { status: "sent" });
697
811
  });
698
812
  app.get("/events", (c) => {
699
813
  const sessionId = getSessionId(c);
@@ -728,76 +842,75 @@ function createAgentRoutes(sessionManager2) {
728
842
  }
729
843
  });
730
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
+ });
731
875
  app.post("/kill", async (c) => {
732
876
  const sessionId = getSessionId(c);
733
877
  const killed = sessionManager2.killSession(sessionId);
734
- return c.json({ status: killed ? "killed" : "no_session" });
878
+ return httpJson(c, "agent.kill", { status: killed ? "killed" : "no_session" });
735
879
  });
736
880
  app.get("/status", (c) => {
737
881
  const sessionId = getSessionId(c);
738
882
  const session = sessionManager2.getSession(sessionId);
739
- return c.json({
883
+ return httpJson(c, "agent.status", {
740
884
  alive: session?.process?.alive ?? false,
741
885
  sessionId: session?.process?.sessionId ?? null,
742
886
  eventCount: session?.eventCounter ?? 0
743
887
  });
744
888
  });
745
- const pendingPermissions = /* @__PURE__ */ new Map();
746
889
  app.post("/permission-request", async (c) => {
747
890
  const sessionId = getSessionId(c);
748
891
  const body = await c.req.json().catch(() => ({}));
749
892
  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
- });
893
+ const result = await sessionManager2.createPendingPermission(sessionId, body);
765
894
  return c.json({ approved: result });
766
895
  });
767
896
  app.post("/permission-respond", async (c) => {
768
897
  const sessionId = getSessionId(c);
769
898
  const body = await c.req.json().catch(() => ({}));
770
899
  const approved = body.approved ?? false;
771
- const pending = pendingPermissions.get(sessionId);
772
- if (!pending) {
900
+ const resolved = sessionManager2.resolvePendingPermission(sessionId, approved);
901
+ if (!resolved) {
773
902
  return c.json({ status: "error", message: "No pending permission request" }, 404);
774
903
  }
775
- pending.resolve(approved);
776
- pendingPermissions.delete(sessionId);
777
- const session = sessionManager2.getSession(sessionId);
778
- if (session) session.state = "processing";
779
904
  logger.log("route", `POST /permission-respond?session=${sessionId} \u2192 ${approved ? "approved" : "denied"}`);
780
- return c.json({ status: approved ? "approved" : "denied" });
905
+ return httpJson(c, "permission.respond", { status: approved ? "approved" : "denied" });
781
906
  });
782
907
  app.get("/permission-pending", (c) => {
783
908
  const sessionId = c.req.query("session");
784
909
  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
- });
910
+ const pending = sessionManager2.getPendingPermission(sessionId);
911
+ return httpJson(c, "permission.pending", { pending: pending ? [{ sessionId, ...pending }] : [] });
794
912
  }
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 });
913
+ return httpJson(c, "permission.pending", { pending: sessionManager2.getAllPendingPermissions() });
801
914
  });
802
915
  return app;
803
916
  }
@@ -810,13 +923,13 @@ function createChatRoutes() {
810
923
  try {
811
924
  const db = getDb();
812
925
  const rows = db.prepare(
813
- `SELECT id, label, type, meta, created_at FROM chat_sessions ORDER BY created_at DESC`
926
+ `SELECT id, label, type, meta, cwd, created_at FROM chat_sessions ORDER BY created_at DESC`
814
927
  ).all();
815
928
  const sessions = rows.map((r) => ({
816
929
  ...r,
817
930
  meta: r.meta ? JSON.parse(r.meta) : null
818
931
  }));
819
- return c.json({ sessions });
932
+ return httpJson(c, "chat.sessions.list", { sessions });
820
933
  } catch (e) {
821
934
  return c.json({ status: "error", message: e.message, stack: e.stack }, 500);
822
935
  }
@@ -829,7 +942,7 @@ function createChatRoutes() {
829
942
  db.prepare(
830
943
  `INSERT OR IGNORE INTO chat_sessions (id, label, type, meta) VALUES (?, ?, ?, ?)`
831
944
  ).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 });
945
+ return httpJson(c, "chat.sessions.create", { status: "created", id, meta: body.meta ?? null });
833
946
  } catch (e) {
834
947
  return c.json({ status: "error", message: e.message }, 500);
835
948
  }
@@ -842,7 +955,7 @@ function createChatRoutes() {
842
955
  try {
843
956
  const db = getDb();
844
957
  db.prepare(`DELETE FROM chat_sessions WHERE id = ?`).run(id);
845
- return c.json({ status: "deleted" });
958
+ return httpJson(c, "chat.sessions.remove", { status: "deleted" });
846
959
  } catch (e) {
847
960
  return c.json({ status: "error", message: e.message }, 500);
848
961
  }
@@ -854,7 +967,7 @@ function createChatRoutes() {
854
967
  const db = getDb();
855
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`);
856
969
  const messages = sinceParam ? query.all(id, parseInt(sinceParam, 10)) : query.all(id);
857
- return c.json({ messages });
970
+ return httpJson(c, "chat.messages.list", { messages });
858
971
  } catch (e) {
859
972
  return c.json({ status: "error", message: e.message, stack: e.stack }, 500);
860
973
  }
@@ -877,7 +990,7 @@ function createChatRoutes() {
877
990
  body.skill_name ?? null,
878
991
  body.meta ? JSON.stringify(body.meta) : null
879
992
  );
880
- return c.json({ status: "created", id: result.lastInsertRowid });
993
+ return httpJson(c, "chat.messages.create", { status: "created", id: Number(result.lastInsertRowid) });
881
994
  } catch (e) {
882
995
  return c.json({ status: "error", message: e.message }, 500);
883
996
  }
@@ -887,7 +1000,7 @@ function createChatRoutes() {
887
1000
  try {
888
1001
  const db = getDb();
889
1002
  db.prepare(`DELETE FROM chat_messages WHERE session_id = ?`).run(id);
890
- return c.json({ status: "cleared" });
1003
+ return httpJson(c, "chat.messages.clear", { status: "cleared" });
891
1004
  } catch (e) {
892
1005
  return c.json({ status: "error", message: e.message }, 500);
893
1006
  }
@@ -898,16 +1011,80 @@ function createChatRoutes() {
898
1011
  // src/server/session-manager.ts
899
1012
  var DEFAULT_MAX_SESSIONS = 5;
900
1013
  var MAX_EVENT_BUFFER = 500;
1014
+ var PERMISSION_TIMEOUT_MS = 3e5;
901
1015
  var SessionManager = class {
902
1016
  constructor(options = {}) {
903
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();
904
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
+ }
905
1067
  }
906
1068
  /** Create a new session. Throws if max sessions reached. */
907
1069
  createSession(opts = {}) {
908
1070
  const id = opts.id ?? crypto.randomUUID().slice(0, 8);
909
1071
  if (this.sessions.has(id)) {
910
- 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;
911
1088
  }
912
1089
  const aliveCount = Array.from(this.sessions.values()).filter((s) => s.process?.alive).length;
913
1090
  if (aliveCount >= this.maxSessions) {
@@ -922,10 +1099,12 @@ var SessionManager = class {
922
1099
  cwd: opts.cwd ?? process.cwd(),
923
1100
  meta: opts.meta ?? null,
924
1101
  state: "idle",
1102
+ lastStartConfig: null,
925
1103
  createdAt: Date.now(),
926
1104
  lastActivityAt: Date.now()
927
1105
  };
928
1106
  this.sessions.set(id, session);
1107
+ this.persistSession(session);
929
1108
  return session;
930
1109
  }
931
1110
  /** Get a session by ID. */
@@ -935,7 +1114,13 @@ var SessionManager = class {
935
1114
  /** Get or create a session (used for "default" backward compat). */
936
1115
  getOrCreateSession(id, opts) {
937
1116
  const existing = this.sessions.get(id);
938
- 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
+ }
939
1124
  return this.createSession({ id, ...opts });
940
1125
  }
941
1126
  /** Set the agent process for a session. Subscribes to events. */
@@ -955,13 +1140,144 @@ var SessionManager = class {
955
1140
  session.state = "waiting";
956
1141
  }
957
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 });
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);
958
1212
  });
959
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;
1274
+ }
960
1275
  /** Kill the agent process in a session (session stays, can be restarted). */
961
1276
  killSession(id) {
962
1277
  const session = this.sessions.get(id);
963
1278
  if (!session?.process?.alive) return false;
964
1279
  session.process.kill();
1280
+ this.emitLifecycle({ session: id, state: "killed" });
965
1281
  return true;
966
1282
  }
967
1283
  /** Remove a session entirely. Cannot remove "default". */
@@ -970,6 +1286,8 @@ var SessionManager = class {
970
1286
  const session = this.sessions.get(id);
971
1287
  if (!session) return false;
972
1288
  if (session.process?.alive) session.process.kill();
1289
+ this.eventListeners.delete(id);
1290
+ this.pendingPermissions.delete(id);
973
1291
  this.sessions.delete(id);
974
1292
  return true;
975
1293
  }
@@ -1038,13 +1356,492 @@ var SessionManager = class {
1038
1356
  }
1039
1357
  };
1040
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
+
1041
1838
  // src/server/index.ts
1042
1839
  function createSnaApp(options = {}) {
1043
1840
  const sessionManager2 = options.sessionManager ?? new SessionManager();
1044
1841
  const app = new Hono3();
1045
1842
  app.get("/health", (c) => c.json({ ok: true, name: "sna", version: "1" }));
1046
1843
  app.get("/events", eventsRoute);
1047
- app.post("/emit", emitRoute);
1844
+ app.post("/emit", createEmitRoute(sessionManager2));
1048
1845
  app.route("/agent", createAgentRoutes(sessionManager2));
1049
1846
  app.route("/chat", createChatRoutes());
1050
1847
  if (options.runCommands) {
@@ -1060,7 +1857,8 @@ try {
1060
1857
  if (err2.message?.includes("NODE_MODULE_VERSION")) {
1061
1858
  console.error(`
1062
1859
  \u2717 better-sqlite3 was compiled for a different Node.js version.`);
1063
- 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/
1064
1862
  `);
1065
1863
  } else {
1066
1864
  console.error(`
@@ -1130,8 +1928,10 @@ process.on("uncaughtException", (err2) => {
1130
1928
  server = serve({ fetch: root.fetch, port }, () => {
1131
1929
  console.log("");
1132
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`));
1133
1932
  console.log("");
1134
1933
  });
1934
+ attachWebSocket(server, sessionManager);
1135
1935
  agentProcess.on("event", (e) => {
1136
1936
  if (e.type === "init") {
1137
1937
  logger.log("agent", chalk2.green(`agent ready (session=${e.data?.sessionId ?? "?"})`));