@sna-sdk/core 0.2.3 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -1
- package/dist/core/providers/claude-code.js +67 -7
- package/dist/core/providers/types.d.ts +30 -5
- package/dist/index.d.ts +1 -1
- package/dist/scripts/sna.js +175 -1
- package/dist/server/api-types.d.ts +15 -0
- package/dist/server/image-store.d.ts +23 -0
- package/dist/server/image-store.js +34 -0
- package/dist/server/index.d.ts +1 -1
- package/dist/server/routes/agent.js +46 -8
- package/dist/server/routes/chat.js +22 -0
- package/dist/server/session-manager.d.ts +18 -2
- package/dist/server/session-manager.js +56 -3
- package/dist/server/standalone.js +276 -27
- package/dist/server/ws.js +54 -7
- package/dist/testing/mock-api.d.ts +35 -0
- package/dist/testing/mock-api.js +140 -0
- package/package.json +6 -1
|
@@ -304,6 +304,7 @@ var logger = { log, err };
|
|
|
304
304
|
// src/core/providers/claude-code.ts
|
|
305
305
|
var SHELL = process.env.SHELL || "/bin/zsh";
|
|
306
306
|
function resolveClaudePath(cwd) {
|
|
307
|
+
if (process.env.SNA_CLAUDE_COMMAND) return process.env.SNA_CLAUDE_COMMAND;
|
|
307
308
|
const cached = path3.join(cwd, ".sna/claude-path");
|
|
308
309
|
if (fs3.existsSync(cached)) {
|
|
309
310
|
const p = fs3.readFileSync(cached, "utf8").trim();
|
|
@@ -337,6 +338,7 @@ var ClaudeCodeProcess = class {
|
|
|
337
338
|
this.emitter = new EventEmitter();
|
|
338
339
|
this._alive = true;
|
|
339
340
|
this._sessionId = null;
|
|
341
|
+
this._initEmitted = false;
|
|
340
342
|
this.buffer = "";
|
|
341
343
|
this.proc = proc;
|
|
342
344
|
proc.stdout.on("data", (chunk) => {
|
|
@@ -376,6 +378,29 @@ var ClaudeCodeProcess = class {
|
|
|
376
378
|
this._alive = false;
|
|
377
379
|
this.emitter.emit("error", err2);
|
|
378
380
|
});
|
|
381
|
+
if (options.history?.length) {
|
|
382
|
+
if (!options.prompt) {
|
|
383
|
+
throw new Error("history requires a prompt \u2014 the last stdin message must be a user message");
|
|
384
|
+
}
|
|
385
|
+
for (const msg of options.history) {
|
|
386
|
+
if (msg.role === "user") {
|
|
387
|
+
const line = JSON.stringify({
|
|
388
|
+
type: "user",
|
|
389
|
+
message: { role: "user", content: msg.content }
|
|
390
|
+
});
|
|
391
|
+
this.proc.stdin.write(line + "\n");
|
|
392
|
+
} else if (msg.role === "assistant") {
|
|
393
|
+
const line = JSON.stringify({
|
|
394
|
+
type: "assistant",
|
|
395
|
+
message: {
|
|
396
|
+
role: "assistant",
|
|
397
|
+
content: [{ type: "text", text: msg.content }]
|
|
398
|
+
}
|
|
399
|
+
});
|
|
400
|
+
this.proc.stdin.write(line + "\n");
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
}
|
|
379
404
|
if (options.prompt) {
|
|
380
405
|
this.send(options.prompt);
|
|
381
406
|
}
|
|
@@ -388,20 +413,41 @@ var ClaudeCodeProcess = class {
|
|
|
388
413
|
}
|
|
389
414
|
/**
|
|
390
415
|
* Send a user message to the persistent Claude process via stdin.
|
|
416
|
+
* Accepts plain string or content block array (text + images).
|
|
391
417
|
*/
|
|
392
418
|
send(input) {
|
|
393
419
|
if (!this._alive || !this.proc.stdin.writable) return;
|
|
420
|
+
const content = typeof input === "string" ? input : input;
|
|
394
421
|
const msg = JSON.stringify({
|
|
395
422
|
type: "user",
|
|
396
|
-
message: { role: "user", content
|
|
423
|
+
message: { role: "user", content }
|
|
397
424
|
});
|
|
398
425
|
logger.log("stdin", msg.slice(0, 200));
|
|
399
426
|
this.proc.stdin.write(msg + "\n");
|
|
400
427
|
}
|
|
401
428
|
interrupt() {
|
|
402
|
-
if (this._alive)
|
|
403
|
-
|
|
404
|
-
|
|
429
|
+
if (!this._alive || !this.proc.stdin.writable) return;
|
|
430
|
+
const msg = JSON.stringify({
|
|
431
|
+
type: "control_request",
|
|
432
|
+
request: { subtype: "interrupt" }
|
|
433
|
+
});
|
|
434
|
+
this.proc.stdin.write(msg + "\n");
|
|
435
|
+
}
|
|
436
|
+
setModel(model) {
|
|
437
|
+
if (!this._alive || !this.proc.stdin.writable) return;
|
|
438
|
+
const msg = JSON.stringify({
|
|
439
|
+
type: "control_request",
|
|
440
|
+
request: { subtype: "set_model", model }
|
|
441
|
+
});
|
|
442
|
+
this.proc.stdin.write(msg + "\n");
|
|
443
|
+
}
|
|
444
|
+
setPermissionMode(mode) {
|
|
445
|
+
if (!this._alive || !this.proc.stdin.writable) return;
|
|
446
|
+
const msg = JSON.stringify({
|
|
447
|
+
type: "control_request",
|
|
448
|
+
request: { subtype: "set_permission_mode", permission_mode: mode }
|
|
449
|
+
});
|
|
450
|
+
this.proc.stdin.write(msg + "\n");
|
|
405
451
|
}
|
|
406
452
|
kill() {
|
|
407
453
|
if (this._alive) {
|
|
@@ -419,6 +465,8 @@ var ClaudeCodeProcess = class {
|
|
|
419
465
|
switch (msg.type) {
|
|
420
466
|
case "system": {
|
|
421
467
|
if (msg.subtype === "init") {
|
|
468
|
+
if (this._initEmitted) return null;
|
|
469
|
+
this._initEmitted = true;
|
|
422
470
|
return {
|
|
423
471
|
type: "init",
|
|
424
472
|
message: `Agent ready (${msg.model ?? "unknown"})`,
|
|
@@ -500,6 +548,14 @@ var ClaudeCodeProcess = class {
|
|
|
500
548
|
timestamp: Date.now()
|
|
501
549
|
};
|
|
502
550
|
}
|
|
551
|
+
if (msg.subtype === "error_during_execution" && msg.is_error === false) {
|
|
552
|
+
return {
|
|
553
|
+
type: "interrupted",
|
|
554
|
+
message: "Turn interrupted by user",
|
|
555
|
+
data: { durationMs: msg.duration_ms, costUsd: msg.total_cost_usd },
|
|
556
|
+
timestamp: Date.now()
|
|
557
|
+
};
|
|
558
|
+
}
|
|
503
559
|
if (msg.subtype?.startsWith("error") || msg.is_error) {
|
|
504
560
|
return {
|
|
505
561
|
type: "error",
|
|
@@ -531,7 +587,10 @@ var ClaudeCodeProvider = class {
|
|
|
531
587
|
}
|
|
532
588
|
}
|
|
533
589
|
spawn(options) {
|
|
534
|
-
const
|
|
590
|
+
const claudeCommand = resolveClaudePath(options.cwd);
|
|
591
|
+
const claudeParts = claudeCommand.split(/\s+/);
|
|
592
|
+
const claudePath = claudeParts[0];
|
|
593
|
+
const claudePrefix = claudeParts.slice(1);
|
|
535
594
|
const hookScript = new URL("../../scripts/hook.js", import.meta.url).pathname;
|
|
536
595
|
const sessionId = options.env?.SNA_SESSION_ID ?? "default";
|
|
537
596
|
const sdkSettings = {};
|
|
@@ -588,12 +647,13 @@ var ClaudeCodeProvider = class {
|
|
|
588
647
|
delete cleanEnv.CLAUDECODE;
|
|
589
648
|
delete cleanEnv.CLAUDE_CODE_ENTRYPOINT;
|
|
590
649
|
delete cleanEnv.CLAUDE_CODE_SESSION_ACCESS_TOKEN;
|
|
591
|
-
|
|
650
|
+
delete cleanEnv.CLAUDE_CODE_OAUTH_TOKEN;
|
|
651
|
+
const proc = spawn2(claudePath, [...claudePrefix, ...args], {
|
|
592
652
|
cwd: options.cwd,
|
|
593
653
|
env: cleanEnv,
|
|
594
654
|
stdio: ["pipe", "pipe", "pipe"]
|
|
595
655
|
});
|
|
596
|
-
logger.log("agent", `spawned claude-code (pid=${proc.pid}) \u2192 ${
|
|
656
|
+
logger.log("agent", `spawned claude-code (pid=${proc.pid}) \u2192 ${claudeCommand} ${args.join(" ")}`);
|
|
597
657
|
return new ClaudeCodeProcess(proc, options);
|
|
598
658
|
}
|
|
599
659
|
};
|
|
@@ -622,6 +682,38 @@ function getProvider(name = "claude-code") {
|
|
|
622
682
|
return provider2;
|
|
623
683
|
}
|
|
624
684
|
|
|
685
|
+
// src/server/image-store.ts
|
|
686
|
+
import fs4 from "fs";
|
|
687
|
+
import path4 from "path";
|
|
688
|
+
import { createHash } from "crypto";
|
|
689
|
+
var IMAGE_DIR = path4.join(process.cwd(), "data/images");
|
|
690
|
+
var MIME_TO_EXT = {
|
|
691
|
+
"image/png": "png",
|
|
692
|
+
"image/jpeg": "jpg",
|
|
693
|
+
"image/gif": "gif",
|
|
694
|
+
"image/webp": "webp",
|
|
695
|
+
"image/svg+xml": "svg"
|
|
696
|
+
};
|
|
697
|
+
function saveImages(sessionId, images) {
|
|
698
|
+
const dir = path4.join(IMAGE_DIR, sessionId);
|
|
699
|
+
fs4.mkdirSync(dir, { recursive: true });
|
|
700
|
+
return images.map((img) => {
|
|
701
|
+
const ext = MIME_TO_EXT[img.mimeType] ?? "bin";
|
|
702
|
+
const hash = createHash("sha256").update(img.base64).digest("hex").slice(0, 12);
|
|
703
|
+
const filename = `${hash}.${ext}`;
|
|
704
|
+
const filePath = path4.join(dir, filename);
|
|
705
|
+
if (!fs4.existsSync(filePath)) {
|
|
706
|
+
fs4.writeFileSync(filePath, Buffer.from(img.base64, "base64"));
|
|
707
|
+
}
|
|
708
|
+
return filename;
|
|
709
|
+
});
|
|
710
|
+
}
|
|
711
|
+
function resolveImagePath(sessionId, filename) {
|
|
712
|
+
if (filename.includes("..") || filename.includes("/")) return null;
|
|
713
|
+
const filePath = path4.join(IMAGE_DIR, sessionId, filename);
|
|
714
|
+
return fs4.existsSync(filePath) ? filePath : null;
|
|
715
|
+
}
|
|
716
|
+
|
|
625
717
|
// src/server/routes/agent.ts
|
|
626
718
|
function getSessionId(c) {
|
|
627
719
|
return c.req.query("session") ?? "default";
|
|
@@ -645,7 +737,7 @@ async function runOnce(sessionManager2, opts) {
|
|
|
645
737
|
model: opts.model ?? "claude-sonnet-4-6",
|
|
646
738
|
permissionMode: opts.permissionMode ?? "bypassPermissions",
|
|
647
739
|
env: { SNA_SESSION_ID: sessionId },
|
|
648
|
-
extraArgs
|
|
740
|
+
extraArgs
|
|
649
741
|
});
|
|
650
742
|
sessionManager2.setProcess(sessionId, proc);
|
|
651
743
|
try {
|
|
@@ -767,6 +859,7 @@ function createAgentRoutes(sessionManager2) {
|
|
|
767
859
|
model,
|
|
768
860
|
permissionMode: permissionMode2,
|
|
769
861
|
env: { SNA_SESSION_ID: sessionId },
|
|
862
|
+
history: body.history,
|
|
770
863
|
extraArgs
|
|
771
864
|
});
|
|
772
865
|
sessionManager2.setProcess(sessionId, proc);
|
|
@@ -793,20 +886,38 @@ function createAgentRoutes(sessionManager2) {
|
|
|
793
886
|
);
|
|
794
887
|
}
|
|
795
888
|
const body = await c.req.json().catch(() => ({}));
|
|
796
|
-
if (!body.message) {
|
|
889
|
+
if (!body.message && !body.images?.length) {
|
|
797
890
|
logger.err("err", `POST /send?session=${sessionId} \u2192 empty message`);
|
|
798
|
-
return c.json({ status: "error", message: "message
|
|
891
|
+
return c.json({ status: "error", message: "message or images required" }, 400);
|
|
892
|
+
}
|
|
893
|
+
const textContent = body.message ?? "(image)";
|
|
894
|
+
let meta = body.meta ? { ...body.meta } : {};
|
|
895
|
+
if (body.images?.length) {
|
|
896
|
+
const filenames = saveImages(sessionId, body.images);
|
|
897
|
+
meta.images = filenames;
|
|
799
898
|
}
|
|
800
899
|
try {
|
|
801
900
|
const db = getDb();
|
|
802
901
|
db.prepare(`INSERT OR IGNORE INTO chat_sessions (id, label, type) VALUES (?, ?, 'main')`).run(sessionId, session.label ?? sessionId);
|
|
803
|
-
db.prepare(`INSERT INTO chat_messages (session_id, role, content, meta) VALUES (?, 'user', ?, ?)`).run(sessionId,
|
|
902
|
+
db.prepare(`INSERT INTO chat_messages (session_id, role, content, meta) VALUES (?, 'user', ?, ?)`).run(sessionId, textContent, Object.keys(meta).length > 0 ? JSON.stringify(meta) : null);
|
|
804
903
|
} catch {
|
|
805
904
|
}
|
|
806
905
|
session.state = "processing";
|
|
807
906
|
sessionManager2.touch(sessionId);
|
|
808
|
-
|
|
809
|
-
|
|
907
|
+
if (body.images?.length) {
|
|
908
|
+
const content = [
|
|
909
|
+
...body.images.map((img) => ({
|
|
910
|
+
type: "image",
|
|
911
|
+
source: { type: "base64", media_type: img.mimeType, data: img.base64 }
|
|
912
|
+
})),
|
|
913
|
+
...body.message ? [{ type: "text", text: body.message }] : []
|
|
914
|
+
];
|
|
915
|
+
logger.log("route", `POST /send?session=${sessionId} \u2192 ${body.images.length} image(s) + "${(body.message ?? "").slice(0, 40)}"`);
|
|
916
|
+
session.process.send(content);
|
|
917
|
+
} else {
|
|
918
|
+
logger.log("route", `POST /send?session=${sessionId} \u2192 "${body.message.slice(0, 80)}"`);
|
|
919
|
+
session.process.send(body.message);
|
|
920
|
+
}
|
|
810
921
|
return httpJson(c, "agent.send", { status: "sent" });
|
|
811
922
|
});
|
|
812
923
|
app.get("/events", (c) => {
|
|
@@ -846,14 +957,16 @@ function createAgentRoutes(sessionManager2) {
|
|
|
846
957
|
const sessionId = getSessionId(c);
|
|
847
958
|
const body = await c.req.json().catch(() => ({}));
|
|
848
959
|
try {
|
|
960
|
+
const ccSessionId = sessionManager2.getSession(sessionId)?.ccSessionId;
|
|
849
961
|
const { config } = sessionManager2.restartSession(sessionId, body, (cfg) => {
|
|
850
962
|
const prov = getProvider(cfg.provider);
|
|
963
|
+
const resumeArgs = ccSessionId ? ["--resume", ccSessionId] : ["--resume"];
|
|
851
964
|
return prov.spawn({
|
|
852
965
|
cwd: sessionManager2.getSession(sessionId).cwd,
|
|
853
966
|
model: cfg.model,
|
|
854
967
|
permissionMode: cfg.permissionMode,
|
|
855
968
|
env: { SNA_SESSION_ID: sessionId },
|
|
856
|
-
extraArgs: [...cfg.extraArgs ?? [],
|
|
969
|
+
extraArgs: [...cfg.extraArgs ?? [], ...resumeArgs]
|
|
857
970
|
});
|
|
858
971
|
});
|
|
859
972
|
logger.log("route", `POST /restart?session=${sessionId} \u2192 restarted`);
|
|
@@ -872,6 +985,20 @@ function createAgentRoutes(sessionManager2) {
|
|
|
872
985
|
const interrupted = sessionManager2.interruptSession(sessionId);
|
|
873
986
|
return httpJson(c, "agent.interrupt", { status: interrupted ? "interrupted" : "no_session" });
|
|
874
987
|
});
|
|
988
|
+
app.post("/set-model", async (c) => {
|
|
989
|
+
const sessionId = getSessionId(c);
|
|
990
|
+
const body = await c.req.json().catch(() => ({}));
|
|
991
|
+
if (!body.model) return c.json({ status: "error", message: "model is required" }, 400);
|
|
992
|
+
const updated = sessionManager2.setSessionModel(sessionId, body.model);
|
|
993
|
+
return httpJson(c, "agent.set-model", { status: updated ? "updated" : "no_session", model: body.model });
|
|
994
|
+
});
|
|
995
|
+
app.post("/set-permission-mode", async (c) => {
|
|
996
|
+
const sessionId = getSessionId(c);
|
|
997
|
+
const body = await c.req.json().catch(() => ({}));
|
|
998
|
+
if (!body.permissionMode) return c.json({ status: "error", message: "permissionMode is required" }, 400);
|
|
999
|
+
const updated = sessionManager2.setSessionPermissionMode(sessionId, body.permissionMode);
|
|
1000
|
+
return httpJson(c, "agent.set-permission-mode", { status: updated ? "updated" : "no_session", permissionMode: body.permissionMode });
|
|
1001
|
+
});
|
|
875
1002
|
app.post("/kill", async (c) => {
|
|
876
1003
|
const sessionId = getSessionId(c);
|
|
877
1004
|
const killed = sessionManager2.killSession(sessionId);
|
|
@@ -883,7 +1010,9 @@ function createAgentRoutes(sessionManager2) {
|
|
|
883
1010
|
return httpJson(c, "agent.status", {
|
|
884
1011
|
alive: session?.process?.alive ?? false,
|
|
885
1012
|
sessionId: session?.process?.sessionId ?? null,
|
|
886
|
-
|
|
1013
|
+
ccSessionId: session?.ccSessionId ?? null,
|
|
1014
|
+
eventCount: session?.eventCounter ?? 0,
|
|
1015
|
+
config: session?.lastStartConfig ?? null
|
|
887
1016
|
});
|
|
888
1017
|
});
|
|
889
1018
|
app.post("/permission-request", async (c) => {
|
|
@@ -917,6 +1046,7 @@ function createAgentRoutes(sessionManager2) {
|
|
|
917
1046
|
|
|
918
1047
|
// src/server/routes/chat.ts
|
|
919
1048
|
import { Hono as Hono2 } from "hono";
|
|
1049
|
+
import fs5 from "fs";
|
|
920
1050
|
function createChatRoutes() {
|
|
921
1051
|
const app = new Hono2();
|
|
922
1052
|
app.get("/sessions", (c) => {
|
|
@@ -1005,6 +1135,26 @@ function createChatRoutes() {
|
|
|
1005
1135
|
return c.json({ status: "error", message: e.message }, 500);
|
|
1006
1136
|
}
|
|
1007
1137
|
});
|
|
1138
|
+
app.get("/images/:sessionId/:filename", (c) => {
|
|
1139
|
+
const sessionId = c.req.param("sessionId");
|
|
1140
|
+
const filename = c.req.param("filename");
|
|
1141
|
+
const filePath = resolveImagePath(sessionId, filename);
|
|
1142
|
+
if (!filePath) {
|
|
1143
|
+
return c.json({ status: "error", message: "Image not found" }, 404);
|
|
1144
|
+
}
|
|
1145
|
+
const ext = filename.split(".").pop()?.toLowerCase();
|
|
1146
|
+
const mimeMap = {
|
|
1147
|
+
png: "image/png",
|
|
1148
|
+
jpg: "image/jpeg",
|
|
1149
|
+
jpeg: "image/jpeg",
|
|
1150
|
+
gif: "image/gif",
|
|
1151
|
+
webp: "image/webp",
|
|
1152
|
+
svg: "image/svg+xml"
|
|
1153
|
+
};
|
|
1154
|
+
const contentType = mimeMap[ext ?? ""] ?? "application/octet-stream";
|
|
1155
|
+
const data = fs5.readFileSync(filePath);
|
|
1156
|
+
return new Response(data, { headers: { "Content-Type": contentType, "Cache-Control": "public, max-age=31536000, immutable" } });
|
|
1157
|
+
});
|
|
1008
1158
|
return app;
|
|
1009
1159
|
}
|
|
1010
1160
|
|
|
@@ -1020,6 +1170,7 @@ var SessionManager = class {
|
|
|
1020
1170
|
this.skillEventListeners = /* @__PURE__ */ new Set();
|
|
1021
1171
|
this.permissionRequestListeners = /* @__PURE__ */ new Set();
|
|
1022
1172
|
this.lifecycleListeners = /* @__PURE__ */ new Set();
|
|
1173
|
+
this.configChangedListeners = /* @__PURE__ */ new Set();
|
|
1023
1174
|
this.maxSessions = options.maxSessions ?? DEFAULT_MAX_SESSIONS;
|
|
1024
1175
|
this.restoreFromDb();
|
|
1025
1176
|
}
|
|
@@ -1042,6 +1193,7 @@ var SessionManager = class {
|
|
|
1042
1193
|
meta: row.meta ? JSON.parse(row.meta) : null,
|
|
1043
1194
|
state: "idle",
|
|
1044
1195
|
lastStartConfig: row.last_start_config ? JSON.parse(row.last_start_config) : null,
|
|
1196
|
+
ccSessionId: null,
|
|
1045
1197
|
createdAt: new Date(row.created_at).getTime() || Date.now(),
|
|
1046
1198
|
lastActivityAt: Date.now()
|
|
1047
1199
|
});
|
|
@@ -1054,7 +1206,13 @@ var SessionManager = class {
|
|
|
1054
1206
|
try {
|
|
1055
1207
|
const db = getDb();
|
|
1056
1208
|
db.prepare(
|
|
1057
|
-
`INSERT
|
|
1209
|
+
`INSERT INTO chat_sessions (id, label, type, meta, cwd, last_start_config)
|
|
1210
|
+
VALUES (?, ?, 'main', ?, ?, ?)
|
|
1211
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
1212
|
+
label = excluded.label,
|
|
1213
|
+
meta = excluded.meta,
|
|
1214
|
+
cwd = excluded.cwd,
|
|
1215
|
+
last_start_config = excluded.last_start_config`
|
|
1058
1216
|
).run(
|
|
1059
1217
|
session.id,
|
|
1060
1218
|
session.label,
|
|
@@ -1100,6 +1258,7 @@ var SessionManager = class {
|
|
|
1100
1258
|
meta: opts.meta ?? null,
|
|
1101
1259
|
state: "idle",
|
|
1102
1260
|
lastStartConfig: null,
|
|
1261
|
+
ccSessionId: null,
|
|
1103
1262
|
createdAt: Date.now(),
|
|
1104
1263
|
lastActivityAt: Date.now()
|
|
1105
1264
|
};
|
|
@@ -1131,12 +1290,16 @@ var SessionManager = class {
|
|
|
1131
1290
|
session.state = "processing";
|
|
1132
1291
|
session.lastActivityAt = Date.now();
|
|
1133
1292
|
proc.on("event", (e) => {
|
|
1293
|
+
if (e.type === "init" && e.data?.sessionId && !session.ccSessionId) {
|
|
1294
|
+
session.ccSessionId = e.data.sessionId;
|
|
1295
|
+
this.persistSession(session);
|
|
1296
|
+
}
|
|
1134
1297
|
session.eventBuffer.push(e);
|
|
1135
1298
|
session.eventCounter++;
|
|
1136
1299
|
if (session.eventBuffer.length > MAX_EVENT_BUFFER) {
|
|
1137
1300
|
session.eventBuffer.splice(0, session.eventBuffer.length - MAX_EVENT_BUFFER);
|
|
1138
1301
|
}
|
|
1139
|
-
if (e.type === "complete" || e.type === "error") {
|
|
1302
|
+
if (e.type === "complete" || e.type === "error" || e.type === "interrupted") {
|
|
1140
1303
|
session.state = "waiting";
|
|
1141
1304
|
}
|
|
1142
1305
|
this.persistEvent(sessionId, e);
|
|
@@ -1194,6 +1357,15 @@ var SessionManager = class {
|
|
|
1194
1357
|
emitLifecycle(event) {
|
|
1195
1358
|
for (const cb of this.lifecycleListeners) cb(event);
|
|
1196
1359
|
}
|
|
1360
|
+
// ── Config changed pub/sub ────────────────────────────────────
|
|
1361
|
+
/** Subscribe to session config changes. Returns unsubscribe function. */
|
|
1362
|
+
onConfigChanged(cb) {
|
|
1363
|
+
this.configChangedListeners.add(cb);
|
|
1364
|
+
return () => this.configChangedListeners.delete(cb);
|
|
1365
|
+
}
|
|
1366
|
+
emitConfigChanged(sessionId, config) {
|
|
1367
|
+
for (const cb of this.configChangedListeners) cb({ session: sessionId, config });
|
|
1368
|
+
}
|
|
1197
1369
|
// ── Permission management ─────────────────────────────────────
|
|
1198
1370
|
/** Create a pending permission request. Returns a promise that resolves when approved/denied. */
|
|
1199
1371
|
createPendingPermission(sessionId, request) {
|
|
@@ -1262,9 +1434,10 @@ var SessionManager = class {
|
|
|
1262
1434
|
session.lastStartConfig = config;
|
|
1263
1435
|
this.persistSession(session);
|
|
1264
1436
|
this.emitLifecycle({ session: id, state: "restarted" });
|
|
1437
|
+
this.emitConfigChanged(id, config);
|
|
1265
1438
|
return { config };
|
|
1266
1439
|
}
|
|
1267
|
-
/** Interrupt the current turn
|
|
1440
|
+
/** Interrupt the current turn. Process stays alive, returns to waiting. */
|
|
1268
1441
|
interruptSession(id) {
|
|
1269
1442
|
const session = this.sessions.get(id);
|
|
1270
1443
|
if (!session?.process?.alive) return false;
|
|
@@ -1272,6 +1445,34 @@ var SessionManager = class {
|
|
|
1272
1445
|
session.state = "waiting";
|
|
1273
1446
|
return true;
|
|
1274
1447
|
}
|
|
1448
|
+
/** Change model. Sends control message if alive, always persists to config. */
|
|
1449
|
+
setSessionModel(id, model) {
|
|
1450
|
+
const session = this.sessions.get(id);
|
|
1451
|
+
if (!session) return false;
|
|
1452
|
+
if (session.process?.alive) session.process.setModel(model);
|
|
1453
|
+
if (session.lastStartConfig) {
|
|
1454
|
+
session.lastStartConfig.model = model;
|
|
1455
|
+
} else {
|
|
1456
|
+
session.lastStartConfig = { provider: "claude-code", model, permissionMode: "acceptEdits" };
|
|
1457
|
+
}
|
|
1458
|
+
this.persistSession(session);
|
|
1459
|
+
this.emitConfigChanged(id, session.lastStartConfig);
|
|
1460
|
+
return true;
|
|
1461
|
+
}
|
|
1462
|
+
/** Change permission mode. Sends control message if alive, always persists to config. */
|
|
1463
|
+
setSessionPermissionMode(id, mode) {
|
|
1464
|
+
const session = this.sessions.get(id);
|
|
1465
|
+
if (!session) return false;
|
|
1466
|
+
if (session.process?.alive) session.process.setPermissionMode(mode);
|
|
1467
|
+
if (session.lastStartConfig) {
|
|
1468
|
+
session.lastStartConfig.permissionMode = mode;
|
|
1469
|
+
} else {
|
|
1470
|
+
session.lastStartConfig = { provider: "claude-code", model: "claude-sonnet-4-6", permissionMode: mode };
|
|
1471
|
+
}
|
|
1472
|
+
this.persistSession(session);
|
|
1473
|
+
this.emitConfigChanged(id, session.lastStartConfig);
|
|
1474
|
+
return true;
|
|
1475
|
+
}
|
|
1275
1476
|
/** Kill the agent process in a session (session stays, can be restarted). */
|
|
1276
1477
|
killSession(id) {
|
|
1277
1478
|
const session = this.sessions.get(id);
|
|
@@ -1300,6 +1501,8 @@ var SessionManager = class {
|
|
|
1300
1501
|
state: s.state,
|
|
1301
1502
|
cwd: s.cwd,
|
|
1302
1503
|
meta: s.meta,
|
|
1504
|
+
config: s.lastStartConfig,
|
|
1505
|
+
ccSessionId: s.ccSessionId,
|
|
1303
1506
|
eventCount: s.eventCounter,
|
|
1304
1507
|
createdAt: s.createdAt,
|
|
1305
1508
|
lastActivityAt: s.lastActivityAt
|
|
@@ -1383,10 +1586,13 @@ function attachWebSocket(server2, sessionManager2) {
|
|
|
1383
1586
|
});
|
|
1384
1587
|
wss.on("connection", (ws) => {
|
|
1385
1588
|
logger.log("ws", "client connected");
|
|
1386
|
-
const state = { agentUnsubs: /* @__PURE__ */ new Map(), skillEventUnsub: null, skillPollTimer: null, permissionUnsub: null, lifecycleUnsub: null };
|
|
1589
|
+
const state = { agentUnsubs: /* @__PURE__ */ new Map(), skillEventUnsub: null, skillPollTimer: null, permissionUnsub: null, lifecycleUnsub: null, configChangedUnsub: null };
|
|
1387
1590
|
state.lifecycleUnsub = sessionManager2.onSessionLifecycle((event) => {
|
|
1388
1591
|
send(ws, { type: "session.lifecycle", ...event });
|
|
1389
1592
|
});
|
|
1593
|
+
state.configChangedUnsub = sessionManager2.onConfigChanged((event) => {
|
|
1594
|
+
send(ws, { type: "session.config-changed", ...event });
|
|
1595
|
+
});
|
|
1390
1596
|
ws.on("message", (raw) => {
|
|
1391
1597
|
let msg;
|
|
1392
1598
|
try {
|
|
@@ -1415,6 +1621,8 @@ function attachWebSocket(server2, sessionManager2) {
|
|
|
1415
1621
|
state.permissionUnsub = null;
|
|
1416
1622
|
state.lifecycleUnsub?.();
|
|
1417
1623
|
state.lifecycleUnsub = null;
|
|
1624
|
+
state.configChangedUnsub?.();
|
|
1625
|
+
state.configChangedUnsub = null;
|
|
1418
1626
|
});
|
|
1419
1627
|
});
|
|
1420
1628
|
return wss;
|
|
@@ -1437,6 +1645,10 @@ function handleMessage(ws, msg, sm, state) {
|
|
|
1437
1645
|
return handleAgentRestart(ws, msg, sm);
|
|
1438
1646
|
case "agent.interrupt":
|
|
1439
1647
|
return handleAgentInterrupt(ws, msg, sm);
|
|
1648
|
+
case "agent.set-model":
|
|
1649
|
+
return handleAgentSetModel(ws, msg, sm);
|
|
1650
|
+
case "agent.set-permission-mode":
|
|
1651
|
+
return handleAgentSetPermissionMode(ws, msg, sm);
|
|
1440
1652
|
case "agent.kill":
|
|
1441
1653
|
return handleAgentKill(ws, msg, sm);
|
|
1442
1654
|
case "agent.status":
|
|
@@ -1538,6 +1750,7 @@ function handleAgentStart(ws, msg, sm) {
|
|
|
1538
1750
|
model,
|
|
1539
1751
|
permissionMode: permissionMode2,
|
|
1540
1752
|
env: { SNA_SESSION_ID: sessionId },
|
|
1753
|
+
history: msg.history,
|
|
1541
1754
|
extraArgs
|
|
1542
1755
|
});
|
|
1543
1756
|
sm.setProcess(sessionId, proc);
|
|
@@ -1553,23 +1766,42 @@ function handleAgentSend(ws, msg, sm) {
|
|
|
1553
1766
|
if (!session?.process?.alive) {
|
|
1554
1767
|
return replyError(ws, msg, `No active agent session "${sessionId}". Start first.`);
|
|
1555
1768
|
}
|
|
1556
|
-
|
|
1557
|
-
|
|
1769
|
+
const images = msg.images;
|
|
1770
|
+
if (!msg.message && !images?.length) {
|
|
1771
|
+
return replyError(ws, msg, "message or images required");
|
|
1772
|
+
}
|
|
1773
|
+
const textContent = msg.message ?? "(image)";
|
|
1774
|
+
let meta = msg.meta ? { ...msg.meta } : {};
|
|
1775
|
+
if (images?.length) {
|
|
1776
|
+
const filenames = saveImages(sessionId, images);
|
|
1777
|
+
meta.images = filenames;
|
|
1558
1778
|
}
|
|
1559
1779
|
try {
|
|
1560
1780
|
const db = getDb();
|
|
1561
1781
|
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,
|
|
1782
|
+
db.prepare(`INSERT INTO chat_messages (session_id, role, content, meta) VALUES (?, 'user', ?, ?)`).run(sessionId, textContent, Object.keys(meta).length > 0 ? JSON.stringify(meta) : null);
|
|
1563
1783
|
} catch {
|
|
1564
1784
|
}
|
|
1565
1785
|
session.state = "processing";
|
|
1566
1786
|
sm.touch(sessionId);
|
|
1567
|
-
|
|
1787
|
+
if (images?.length) {
|
|
1788
|
+
const content = [
|
|
1789
|
+
...images.map((img) => ({
|
|
1790
|
+
type: "image",
|
|
1791
|
+
source: { type: "base64", media_type: img.mimeType, data: img.base64 }
|
|
1792
|
+
})),
|
|
1793
|
+
...msg.message ? [{ type: "text", text: msg.message }] : []
|
|
1794
|
+
];
|
|
1795
|
+
session.process.send(content);
|
|
1796
|
+
} else {
|
|
1797
|
+
session.process.send(msg.message);
|
|
1798
|
+
}
|
|
1568
1799
|
wsReply(ws, msg, { status: "sent" });
|
|
1569
1800
|
}
|
|
1570
1801
|
function handleAgentRestart(ws, msg, sm) {
|
|
1571
1802
|
const sessionId = msg.session ?? "default";
|
|
1572
1803
|
try {
|
|
1804
|
+
const ccSessionId = sm.getSession(sessionId)?.ccSessionId;
|
|
1573
1805
|
const { config } = sm.restartSession(
|
|
1574
1806
|
sessionId,
|
|
1575
1807
|
{
|
|
@@ -1580,12 +1812,13 @@ function handleAgentRestart(ws, msg, sm) {
|
|
|
1580
1812
|
},
|
|
1581
1813
|
(cfg) => {
|
|
1582
1814
|
const prov = getProvider(cfg.provider);
|
|
1815
|
+
const resumeArgs = ccSessionId ? ["--resume", ccSessionId] : ["--resume"];
|
|
1583
1816
|
return prov.spawn({
|
|
1584
1817
|
cwd: sm.getSession(sessionId).cwd,
|
|
1585
1818
|
model: cfg.model,
|
|
1586
1819
|
permissionMode: cfg.permissionMode,
|
|
1587
1820
|
env: { SNA_SESSION_ID: sessionId },
|
|
1588
|
-
extraArgs: [...cfg.extraArgs ?? [],
|
|
1821
|
+
extraArgs: [...cfg.extraArgs ?? [], ...resumeArgs]
|
|
1589
1822
|
});
|
|
1590
1823
|
}
|
|
1591
1824
|
);
|
|
@@ -1599,6 +1832,20 @@ function handleAgentInterrupt(ws, msg, sm) {
|
|
|
1599
1832
|
const interrupted = sm.interruptSession(sessionId);
|
|
1600
1833
|
wsReply(ws, msg, { status: interrupted ? "interrupted" : "no_session" });
|
|
1601
1834
|
}
|
|
1835
|
+
function handleAgentSetModel(ws, msg, sm) {
|
|
1836
|
+
const sessionId = msg.session ?? "default";
|
|
1837
|
+
const model = msg.model;
|
|
1838
|
+
if (!model) return replyError(ws, msg, "model is required");
|
|
1839
|
+
const updated = sm.setSessionModel(sessionId, model);
|
|
1840
|
+
wsReply(ws, msg, { status: updated ? "updated" : "no_session", model });
|
|
1841
|
+
}
|
|
1842
|
+
function handleAgentSetPermissionMode(ws, msg, sm) {
|
|
1843
|
+
const sessionId = msg.session ?? "default";
|
|
1844
|
+
const permissionMode2 = msg.permissionMode;
|
|
1845
|
+
if (!permissionMode2) return replyError(ws, msg, "permissionMode is required");
|
|
1846
|
+
const updated = sm.setSessionPermissionMode(sessionId, permissionMode2);
|
|
1847
|
+
wsReply(ws, msg, { status: updated ? "updated" : "no_session", permissionMode: permissionMode2 });
|
|
1848
|
+
}
|
|
1602
1849
|
function handleAgentKill(ws, msg, sm) {
|
|
1603
1850
|
const sessionId = msg.session ?? "default";
|
|
1604
1851
|
const killed = sm.killSession(sessionId);
|
|
@@ -1610,7 +1857,9 @@ function handleAgentStatus(ws, msg, sm) {
|
|
|
1610
1857
|
wsReply(ws, msg, {
|
|
1611
1858
|
alive: session?.process?.alive ?? false,
|
|
1612
1859
|
sessionId: session?.process?.sessionId ?? null,
|
|
1613
|
-
|
|
1860
|
+
ccSessionId: session?.ccSessionId ?? null,
|
|
1861
|
+
eventCount: session?.eventCounter ?? 0,
|
|
1862
|
+
config: session?.lastStartConfig ?? null
|
|
1614
1863
|
});
|
|
1615
1864
|
}
|
|
1616
1865
|
async function handleAgentRunOnce(ws, msg, sm) {
|
|
@@ -1886,8 +2135,8 @@ var methodColor = {
|
|
|
1886
2135
|
root.use("*", async (c, next) => {
|
|
1887
2136
|
const m = c.req.method;
|
|
1888
2137
|
const colorFn = methodColor[m] ?? chalk2.white;
|
|
1889
|
-
const
|
|
1890
|
-
logger.log("req", `${colorFn(m.padEnd(6))} ${
|
|
2138
|
+
const path5 = new URL(c.req.url).pathname;
|
|
2139
|
+
logger.log("req", `${colorFn(m.padEnd(6))} ${path5}`);
|
|
1891
2140
|
await next();
|
|
1892
2141
|
});
|
|
1893
2142
|
var sessionManager = new SessionManager({ maxSessions });
|