@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.
- package/README.md +15 -7
- package/dist/core/providers/claude-code.js +9 -3
- package/dist/core/providers/types.d.ts +2 -0
- package/dist/db/schema.d.ts +1 -0
- package/dist/db/schema.js +19 -2
- package/dist/lib/logger.d.ts +1 -0
- package/dist/lib/logger.js +2 -0
- package/dist/scripts/hook.js +1 -1
- package/dist/scripts/sna.js +50 -0
- package/dist/server/api-types.d.ts +105 -0
- package/dist/server/api-types.js +13 -0
- package/dist/server/index.d.ts +5 -2
- package/dist/server/index.js +7 -4
- package/dist/server/routes/agent.d.ts +21 -1
- package/dist/server/routes/agent.js +125 -59
- package/dist/server/routes/chat.js +8 -7
- package/dist/server/routes/emit.d.ts +11 -1
- package/dist/server/routes/emit.js +26 -0
- package/dist/server/session-manager.d.ts +58 -1
- package/dist/server/session-manager.js +207 -2
- package/dist/server/standalone.js +884 -84
- package/dist/server/ws.d.ts +55 -0
- package/dist/server/ws.js +485 -0
- package/package.json +4 -2
|
@@ -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
|
|
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
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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
|
|
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 =
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
655
|
-
permissionMode:
|
|
767
|
+
model,
|
|
768
|
+
permissionMode: permissionMode2,
|
|
656
769
|
env: { SNA_SESSION_ID: sessionId },
|
|
657
|
-
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
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
|
|
772
|
-
if (!
|
|
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.
|
|
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 =
|
|
786
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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)
|
|
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",
|
|
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(`
|
|
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 ?? "?"})`));
|