@sna-sdk/core 0.2.3 → 0.4.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 +7 -1
- package/dist/core/providers/cc-history-adapter.d.ts +37 -0
- package/dist/core/providers/cc-history-adapter.js +70 -0
- package/dist/core/providers/claude-code.js +57 -7
- package/dist/core/providers/types.d.ts +32 -5
- package/dist/db/schema.js +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/scripts/sna.js +193 -1
- package/dist/scripts/tu-oneshot.d.ts +2 -0
- package/dist/scripts/tu-oneshot.js +66 -0
- package/dist/server/api-types.d.ts +22 -0
- package/dist/server/history-builder.d.ts +16 -0
- package/dist/server/history-builder.js +25 -0
- package/dist/server/image-store.d.ts +23 -0
- package/dist/server/image-store.js +34 -0
- package/dist/server/index.d.ts +2 -1
- package/dist/server/index.js +2 -0
- package/dist/server/routes/agent.js +91 -10
- package/dist/server/routes/chat.js +22 -0
- package/dist/server/session-manager.d.ts +31 -4
- package/dist/server/session-manager.js +85 -12
- package/dist/server/standalone.js +488 -51
- package/dist/server/ws.js +103 -9
- package/dist/testing/mock-api.d.ts +35 -0
- package/dist/testing/mock-api.js +160 -0
- package/package.json +6 -1
|
@@ -14,7 +14,7 @@ import { streamSSE } from "hono/streaming";
|
|
|
14
14
|
import { createRequire } from "module";
|
|
15
15
|
import fs from "fs";
|
|
16
16
|
import path from "path";
|
|
17
|
-
var DB_PATH = path.join(process.cwd(), "data/sna.db");
|
|
17
|
+
var DB_PATH = process.env.SNA_DB_PATH ?? path.join(process.cwd(), "data/sna.db");
|
|
18
18
|
var NATIVE_DIR = path.join(process.cwd(), ".sna/native");
|
|
19
19
|
var _db = null;
|
|
20
20
|
function loadBetterSqlite3() {
|
|
@@ -247,16 +247,84 @@ import { streamSSE as streamSSE3 } from "hono/streaming";
|
|
|
247
247
|
// src/core/providers/claude-code.ts
|
|
248
248
|
import { spawn as spawn2, execSync } from "child_process";
|
|
249
249
|
import { EventEmitter } from "events";
|
|
250
|
-
import
|
|
251
|
-
import
|
|
250
|
+
import fs4 from "fs";
|
|
251
|
+
import path4 from "path";
|
|
252
252
|
|
|
253
|
-
// src/
|
|
254
|
-
import chalk from "chalk";
|
|
253
|
+
// src/core/providers/cc-history-adapter.ts
|
|
255
254
|
import fs2 from "fs";
|
|
256
255
|
import path2 from "path";
|
|
257
|
-
|
|
256
|
+
function writeHistoryJsonl(history, opts) {
|
|
257
|
+
for (let i = 1; i < history.length; i++) {
|
|
258
|
+
if (history[i].role === history[i - 1].role) {
|
|
259
|
+
throw new Error(
|
|
260
|
+
`History validation failed: consecutive ${history[i].role} at index ${i - 1} and ${i}. Messages must alternate user\u2194assistant. Merge tool results into text before injecting.`
|
|
261
|
+
);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
try {
|
|
265
|
+
const dir = path2.join(opts.cwd, ".sna", "history");
|
|
266
|
+
fs2.mkdirSync(dir, { recursive: true });
|
|
267
|
+
const sessionId = crypto.randomUUID();
|
|
268
|
+
const filePath = path2.join(dir, `${sessionId}.jsonl`);
|
|
269
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
270
|
+
const lines = [];
|
|
271
|
+
let prevUuid = null;
|
|
272
|
+
for (const msg of history) {
|
|
273
|
+
const uuid = crypto.randomUUID();
|
|
274
|
+
if (msg.role === "user") {
|
|
275
|
+
lines.push(JSON.stringify({
|
|
276
|
+
parentUuid: prevUuid,
|
|
277
|
+
isSidechain: false,
|
|
278
|
+
type: "user",
|
|
279
|
+
uuid,
|
|
280
|
+
timestamp: now,
|
|
281
|
+
cwd: opts.cwd,
|
|
282
|
+
sessionId,
|
|
283
|
+
message: { role: "user", content: msg.content }
|
|
284
|
+
}));
|
|
285
|
+
} else {
|
|
286
|
+
lines.push(JSON.stringify({
|
|
287
|
+
parentUuid: prevUuid,
|
|
288
|
+
isSidechain: false,
|
|
289
|
+
type: "assistant",
|
|
290
|
+
uuid,
|
|
291
|
+
timestamp: now,
|
|
292
|
+
cwd: opts.cwd,
|
|
293
|
+
sessionId,
|
|
294
|
+
message: {
|
|
295
|
+
role: "assistant",
|
|
296
|
+
content: [{ type: "text", text: msg.content }]
|
|
297
|
+
}
|
|
298
|
+
}));
|
|
299
|
+
}
|
|
300
|
+
prevUuid = uuid;
|
|
301
|
+
}
|
|
302
|
+
fs2.writeFileSync(filePath, lines.join("\n") + "\n");
|
|
303
|
+
return { filePath, extraArgs: ["--resume", filePath] };
|
|
304
|
+
} catch {
|
|
305
|
+
return null;
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
function buildRecalledConversation(history) {
|
|
309
|
+
const xml = history.map((msg) => `<${msg.role}>${msg.content}</${msg.role}>`).join("\n");
|
|
310
|
+
return JSON.stringify({
|
|
311
|
+
type: "assistant",
|
|
312
|
+
message: {
|
|
313
|
+
role: "assistant",
|
|
314
|
+
content: [{ type: "text", text: `<recalled-conversation>
|
|
315
|
+
${xml}
|
|
316
|
+
</recalled-conversation>` }]
|
|
317
|
+
}
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// src/lib/logger.ts
|
|
322
|
+
import chalk from "chalk";
|
|
323
|
+
import fs3 from "fs";
|
|
324
|
+
import path3 from "path";
|
|
325
|
+
var LOG_PATH = path3.join(process.cwd(), ".dev.log");
|
|
258
326
|
try {
|
|
259
|
-
|
|
327
|
+
fs3.writeFileSync(LOG_PATH, "");
|
|
260
328
|
} catch {
|
|
261
329
|
}
|
|
262
330
|
function tsPlain() {
|
|
@@ -288,7 +356,7 @@ var tagPlain = {
|
|
|
288
356
|
function appendFile(tag, args) {
|
|
289
357
|
const line = `${tsPlain()} ${tag} ${args.map((a) => typeof a === "string" ? a : JSON.stringify(a)).join(" ")}
|
|
290
358
|
`;
|
|
291
|
-
|
|
359
|
+
fs3.appendFile(LOG_PATH, line, () => {
|
|
292
360
|
});
|
|
293
361
|
}
|
|
294
362
|
function log(tag, ...args) {
|
|
@@ -304,9 +372,10 @@ var logger = { log, err };
|
|
|
304
372
|
// src/core/providers/claude-code.ts
|
|
305
373
|
var SHELL = process.env.SHELL || "/bin/zsh";
|
|
306
374
|
function resolveClaudePath(cwd) {
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
375
|
+
if (process.env.SNA_CLAUDE_COMMAND) return process.env.SNA_CLAUDE_COMMAND;
|
|
376
|
+
const cached = path4.join(cwd, ".sna/claude-path");
|
|
377
|
+
if (fs4.existsSync(cached)) {
|
|
378
|
+
const p = fs4.readFileSync(cached, "utf8").trim();
|
|
310
379
|
if (p) {
|
|
311
380
|
try {
|
|
312
381
|
execSync(`test -x "${p}"`, { stdio: "pipe" });
|
|
@@ -337,6 +406,7 @@ var ClaudeCodeProcess = class {
|
|
|
337
406
|
this.emitter = new EventEmitter();
|
|
338
407
|
this._alive = true;
|
|
339
408
|
this._sessionId = null;
|
|
409
|
+
this._initEmitted = false;
|
|
340
410
|
this.buffer = "";
|
|
341
411
|
this.proc = proc;
|
|
342
412
|
proc.stdout.on("data", (chunk) => {
|
|
@@ -376,6 +446,10 @@ var ClaudeCodeProcess = class {
|
|
|
376
446
|
this._alive = false;
|
|
377
447
|
this.emitter.emit("error", err2);
|
|
378
448
|
});
|
|
449
|
+
if (options.history?.length && !options._historyViaResume) {
|
|
450
|
+
const line = buildRecalledConversation(options.history);
|
|
451
|
+
this.proc.stdin.write(line + "\n");
|
|
452
|
+
}
|
|
379
453
|
if (options.prompt) {
|
|
380
454
|
this.send(options.prompt);
|
|
381
455
|
}
|
|
@@ -388,20 +462,41 @@ var ClaudeCodeProcess = class {
|
|
|
388
462
|
}
|
|
389
463
|
/**
|
|
390
464
|
* Send a user message to the persistent Claude process via stdin.
|
|
465
|
+
* Accepts plain string or content block array (text + images).
|
|
391
466
|
*/
|
|
392
467
|
send(input) {
|
|
393
468
|
if (!this._alive || !this.proc.stdin.writable) return;
|
|
469
|
+
const content = typeof input === "string" ? input : input;
|
|
394
470
|
const msg = JSON.stringify({
|
|
395
471
|
type: "user",
|
|
396
|
-
message: { role: "user", content
|
|
472
|
+
message: { role: "user", content }
|
|
397
473
|
});
|
|
398
474
|
logger.log("stdin", msg.slice(0, 200));
|
|
399
475
|
this.proc.stdin.write(msg + "\n");
|
|
400
476
|
}
|
|
401
477
|
interrupt() {
|
|
402
|
-
if (this._alive)
|
|
403
|
-
|
|
404
|
-
|
|
478
|
+
if (!this._alive || !this.proc.stdin.writable) return;
|
|
479
|
+
const msg = JSON.stringify({
|
|
480
|
+
type: "control_request",
|
|
481
|
+
request: { subtype: "interrupt" }
|
|
482
|
+
});
|
|
483
|
+
this.proc.stdin.write(msg + "\n");
|
|
484
|
+
}
|
|
485
|
+
setModel(model) {
|
|
486
|
+
if (!this._alive || !this.proc.stdin.writable) return;
|
|
487
|
+
const msg = JSON.stringify({
|
|
488
|
+
type: "control_request",
|
|
489
|
+
request: { subtype: "set_model", model }
|
|
490
|
+
});
|
|
491
|
+
this.proc.stdin.write(msg + "\n");
|
|
492
|
+
}
|
|
493
|
+
setPermissionMode(mode) {
|
|
494
|
+
if (!this._alive || !this.proc.stdin.writable) return;
|
|
495
|
+
const msg = JSON.stringify({
|
|
496
|
+
type: "control_request",
|
|
497
|
+
request: { subtype: "set_permission_mode", permission_mode: mode }
|
|
498
|
+
});
|
|
499
|
+
this.proc.stdin.write(msg + "\n");
|
|
405
500
|
}
|
|
406
501
|
kill() {
|
|
407
502
|
if (this._alive) {
|
|
@@ -419,6 +514,8 @@ var ClaudeCodeProcess = class {
|
|
|
419
514
|
switch (msg.type) {
|
|
420
515
|
case "system": {
|
|
421
516
|
if (msg.subtype === "init") {
|
|
517
|
+
if (this._initEmitted) return null;
|
|
518
|
+
this._initEmitted = true;
|
|
422
519
|
return {
|
|
423
520
|
type: "init",
|
|
424
521
|
message: `Agent ready (${msg.model ?? "unknown"})`,
|
|
@@ -500,6 +597,14 @@ var ClaudeCodeProcess = class {
|
|
|
500
597
|
timestamp: Date.now()
|
|
501
598
|
};
|
|
502
599
|
}
|
|
600
|
+
if (msg.subtype === "error_during_execution" && msg.is_error === false) {
|
|
601
|
+
return {
|
|
602
|
+
type: "interrupted",
|
|
603
|
+
message: "Turn interrupted by user",
|
|
604
|
+
data: { durationMs: msg.duration_ms, costUsd: msg.total_cost_usd },
|
|
605
|
+
timestamp: Date.now()
|
|
606
|
+
};
|
|
607
|
+
}
|
|
503
608
|
if (msg.subtype?.startsWith("error") || msg.is_error) {
|
|
504
609
|
return {
|
|
505
610
|
type: "error",
|
|
@@ -531,7 +636,10 @@ var ClaudeCodeProvider = class {
|
|
|
531
636
|
}
|
|
532
637
|
}
|
|
533
638
|
spawn(options) {
|
|
534
|
-
const
|
|
639
|
+
const claudeCommand = resolveClaudePath(options.cwd);
|
|
640
|
+
const claudeParts = claudeCommand.split(/\s+/);
|
|
641
|
+
const claudePath = claudeParts[0];
|
|
642
|
+
const claudePrefix = claudeParts.slice(1);
|
|
535
643
|
const hookScript = new URL("../../scripts/hook.js", import.meta.url).pathname;
|
|
536
644
|
const sessionId = options.env?.SNA_SESSION_ID ?? "default";
|
|
537
645
|
const sdkSettings = {};
|
|
@@ -581,6 +689,14 @@ var ClaudeCodeProvider = class {
|
|
|
581
689
|
if (options.permissionMode) {
|
|
582
690
|
args.push("--permission-mode", options.permissionMode);
|
|
583
691
|
}
|
|
692
|
+
if (options.history?.length && options.prompt) {
|
|
693
|
+
const result = writeHistoryJsonl(options.history, { cwd: options.cwd });
|
|
694
|
+
if (result) {
|
|
695
|
+
args.push(...result.extraArgs);
|
|
696
|
+
options._historyViaResume = true;
|
|
697
|
+
logger.log("agent", `history via JSONL resume \u2192 ${result.filePath}`);
|
|
698
|
+
}
|
|
699
|
+
}
|
|
584
700
|
if (extraArgsClean.length > 0) {
|
|
585
701
|
args.push(...extraArgsClean);
|
|
586
702
|
}
|
|
@@ -588,12 +704,13 @@ var ClaudeCodeProvider = class {
|
|
|
588
704
|
delete cleanEnv.CLAUDECODE;
|
|
589
705
|
delete cleanEnv.CLAUDE_CODE_ENTRYPOINT;
|
|
590
706
|
delete cleanEnv.CLAUDE_CODE_SESSION_ACCESS_TOKEN;
|
|
591
|
-
|
|
707
|
+
delete cleanEnv.CLAUDE_CODE_OAUTH_TOKEN;
|
|
708
|
+
const proc = spawn2(claudePath, [...claudePrefix, ...args], {
|
|
592
709
|
cwd: options.cwd,
|
|
593
710
|
env: cleanEnv,
|
|
594
711
|
stdio: ["pipe", "pipe", "pipe"]
|
|
595
712
|
});
|
|
596
|
-
logger.log("agent", `spawned claude-code (pid=${proc.pid}) \u2192 ${
|
|
713
|
+
logger.log("agent", `spawned claude-code (pid=${proc.pid}) \u2192 ${claudeCommand} ${args.join(" ")}`);
|
|
597
714
|
return new ClaudeCodeProcess(proc, options);
|
|
598
715
|
}
|
|
599
716
|
};
|
|
@@ -622,6 +739,61 @@ function getProvider(name = "claude-code") {
|
|
|
622
739
|
return provider2;
|
|
623
740
|
}
|
|
624
741
|
|
|
742
|
+
// src/server/history-builder.ts
|
|
743
|
+
function buildHistoryFromDb(sessionId) {
|
|
744
|
+
const db = getDb();
|
|
745
|
+
const rows = db.prepare(
|
|
746
|
+
`SELECT role, content FROM chat_messages
|
|
747
|
+
WHERE session_id = ? AND role IN ('user', 'assistant')
|
|
748
|
+
ORDER BY id ASC`
|
|
749
|
+
).all(sessionId);
|
|
750
|
+
if (rows.length === 0) return [];
|
|
751
|
+
const merged = [];
|
|
752
|
+
for (const row of rows) {
|
|
753
|
+
const role = row.role;
|
|
754
|
+
if (!row.content?.trim()) continue;
|
|
755
|
+
const last = merged[merged.length - 1];
|
|
756
|
+
if (last && last.role === role) {
|
|
757
|
+
last.content += "\n\n" + row.content;
|
|
758
|
+
} else {
|
|
759
|
+
merged.push({ role, content: row.content });
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
return merged;
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
// src/server/image-store.ts
|
|
766
|
+
import fs5 from "fs";
|
|
767
|
+
import path5 from "path";
|
|
768
|
+
import { createHash } from "crypto";
|
|
769
|
+
var IMAGE_DIR = path5.join(process.cwd(), "data/images");
|
|
770
|
+
var MIME_TO_EXT = {
|
|
771
|
+
"image/png": "png",
|
|
772
|
+
"image/jpeg": "jpg",
|
|
773
|
+
"image/gif": "gif",
|
|
774
|
+
"image/webp": "webp",
|
|
775
|
+
"image/svg+xml": "svg"
|
|
776
|
+
};
|
|
777
|
+
function saveImages(sessionId, images) {
|
|
778
|
+
const dir = path5.join(IMAGE_DIR, sessionId);
|
|
779
|
+
fs5.mkdirSync(dir, { recursive: true });
|
|
780
|
+
return images.map((img) => {
|
|
781
|
+
const ext = MIME_TO_EXT[img.mimeType] ?? "bin";
|
|
782
|
+
const hash = createHash("sha256").update(img.base64).digest("hex").slice(0, 12);
|
|
783
|
+
const filename = `${hash}.${ext}`;
|
|
784
|
+
const filePath = path5.join(dir, filename);
|
|
785
|
+
if (!fs5.existsSync(filePath)) {
|
|
786
|
+
fs5.writeFileSync(filePath, Buffer.from(img.base64, "base64"));
|
|
787
|
+
}
|
|
788
|
+
return filename;
|
|
789
|
+
});
|
|
790
|
+
}
|
|
791
|
+
function resolveImagePath(sessionId, filename) {
|
|
792
|
+
if (filename.includes("..") || filename.includes("/")) return null;
|
|
793
|
+
const filePath = path5.join(IMAGE_DIR, sessionId, filename);
|
|
794
|
+
return fs5.existsSync(filePath) ? filePath : null;
|
|
795
|
+
}
|
|
796
|
+
|
|
625
797
|
// src/server/routes/agent.ts
|
|
626
798
|
function getSessionId(c) {
|
|
627
799
|
return c.req.query("session") ?? "default";
|
|
@@ -645,7 +817,7 @@ async function runOnce(sessionManager2, opts) {
|
|
|
645
817
|
model: opts.model ?? "claude-sonnet-4-6",
|
|
646
818
|
permissionMode: opts.permissionMode ?? "bypassPermissions",
|
|
647
819
|
env: { SNA_SESSION_ID: sessionId },
|
|
648
|
-
extraArgs
|
|
820
|
+
extraArgs
|
|
649
821
|
});
|
|
650
822
|
sessionManager2.setProcess(sessionId, proc);
|
|
651
823
|
try {
|
|
@@ -767,6 +939,7 @@ function createAgentRoutes(sessionManager2) {
|
|
|
767
939
|
model,
|
|
768
940
|
permissionMode: permissionMode2,
|
|
769
941
|
env: { SNA_SESSION_ID: sessionId },
|
|
942
|
+
history: body.history,
|
|
770
943
|
extraArgs
|
|
771
944
|
});
|
|
772
945
|
sessionManager2.setProcess(sessionId, proc);
|
|
@@ -793,20 +966,38 @@ function createAgentRoutes(sessionManager2) {
|
|
|
793
966
|
);
|
|
794
967
|
}
|
|
795
968
|
const body = await c.req.json().catch(() => ({}));
|
|
796
|
-
if (!body.message) {
|
|
969
|
+
if (!body.message && !body.images?.length) {
|
|
797
970
|
logger.err("err", `POST /send?session=${sessionId} \u2192 empty message`);
|
|
798
|
-
return c.json({ status: "error", message: "message
|
|
971
|
+
return c.json({ status: "error", message: "message or images required" }, 400);
|
|
972
|
+
}
|
|
973
|
+
const textContent = body.message ?? "(image)";
|
|
974
|
+
let meta = body.meta ? { ...body.meta } : {};
|
|
975
|
+
if (body.images?.length) {
|
|
976
|
+
const filenames = saveImages(sessionId, body.images);
|
|
977
|
+
meta.images = filenames;
|
|
799
978
|
}
|
|
800
979
|
try {
|
|
801
980
|
const db = getDb();
|
|
802
981
|
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,
|
|
982
|
+
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
983
|
} catch {
|
|
805
984
|
}
|
|
806
|
-
|
|
985
|
+
sessionManager2.updateSessionState(sessionId, "processing");
|
|
807
986
|
sessionManager2.touch(sessionId);
|
|
808
|
-
|
|
809
|
-
|
|
987
|
+
if (body.images?.length) {
|
|
988
|
+
const content = [
|
|
989
|
+
...body.images.map((img) => ({
|
|
990
|
+
type: "image",
|
|
991
|
+
source: { type: "base64", media_type: img.mimeType, data: img.base64 }
|
|
992
|
+
})),
|
|
993
|
+
...body.message ? [{ type: "text", text: body.message }] : []
|
|
994
|
+
];
|
|
995
|
+
logger.log("route", `POST /send?session=${sessionId} \u2192 ${body.images.length} image(s) + "${(body.message ?? "").slice(0, 40)}"`);
|
|
996
|
+
session.process.send(content);
|
|
997
|
+
} else {
|
|
998
|
+
logger.log("route", `POST /send?session=${sessionId} \u2192 "${body.message.slice(0, 80)}"`);
|
|
999
|
+
session.process.send(body.message);
|
|
1000
|
+
}
|
|
810
1001
|
return httpJson(c, "agent.send", { status: "sent" });
|
|
811
1002
|
});
|
|
812
1003
|
app.get("/events", (c) => {
|
|
@@ -846,14 +1037,16 @@ function createAgentRoutes(sessionManager2) {
|
|
|
846
1037
|
const sessionId = getSessionId(c);
|
|
847
1038
|
const body = await c.req.json().catch(() => ({}));
|
|
848
1039
|
try {
|
|
1040
|
+
const ccSessionId = sessionManager2.getSession(sessionId)?.ccSessionId;
|
|
849
1041
|
const { config } = sessionManager2.restartSession(sessionId, body, (cfg) => {
|
|
850
1042
|
const prov = getProvider(cfg.provider);
|
|
1043
|
+
const resumeArgs = ccSessionId ? ["--resume", ccSessionId] : ["--resume"];
|
|
851
1044
|
return prov.spawn({
|
|
852
1045
|
cwd: sessionManager2.getSession(sessionId).cwd,
|
|
853
1046
|
model: cfg.model,
|
|
854
1047
|
permissionMode: cfg.permissionMode,
|
|
855
1048
|
env: { SNA_SESSION_ID: sessionId },
|
|
856
|
-
extraArgs: [...cfg.extraArgs ?? [],
|
|
1049
|
+
extraArgs: [...cfg.extraArgs ?? [], ...resumeArgs]
|
|
857
1050
|
});
|
|
858
1051
|
});
|
|
859
1052
|
logger.log("route", `POST /restart?session=${sessionId} \u2192 restarted`);
|
|
@@ -867,11 +1060,65 @@ function createAgentRoutes(sessionManager2) {
|
|
|
867
1060
|
return c.json({ status: "error", message: e.message }, 500);
|
|
868
1061
|
}
|
|
869
1062
|
});
|
|
1063
|
+
app.post("/resume", async (c) => {
|
|
1064
|
+
const sessionId = getSessionId(c);
|
|
1065
|
+
const body = await c.req.json().catch(() => ({}));
|
|
1066
|
+
const session = sessionManager2.getOrCreateSession(sessionId);
|
|
1067
|
+
if (session.process?.alive) {
|
|
1068
|
+
return c.json({ status: "error", message: "Session already running. Use agent.send instead." }, 400);
|
|
1069
|
+
}
|
|
1070
|
+
const history = buildHistoryFromDb(sessionId);
|
|
1071
|
+
if (history.length === 0 && !body.prompt) {
|
|
1072
|
+
return c.json({ status: "error", message: "No history in DB \u2014 nothing to resume." }, 400);
|
|
1073
|
+
}
|
|
1074
|
+
const providerName = body.provider ?? "claude-code";
|
|
1075
|
+
const model = body.model ?? session.lastStartConfig?.model ?? "claude-sonnet-4-6";
|
|
1076
|
+
const permissionMode2 = body.permissionMode ?? session.lastStartConfig?.permissionMode ?? "acceptEdits";
|
|
1077
|
+
const extraArgs = body.extraArgs ?? session.lastStartConfig?.extraArgs;
|
|
1078
|
+
const provider2 = getProvider(providerName);
|
|
1079
|
+
try {
|
|
1080
|
+
const proc = provider2.spawn({
|
|
1081
|
+
cwd: session.cwd,
|
|
1082
|
+
prompt: body.prompt,
|
|
1083
|
+
model,
|
|
1084
|
+
permissionMode: permissionMode2,
|
|
1085
|
+
env: { SNA_SESSION_ID: sessionId },
|
|
1086
|
+
history: history.length > 0 ? history : void 0,
|
|
1087
|
+
extraArgs
|
|
1088
|
+
});
|
|
1089
|
+
sessionManager2.setProcess(sessionId, proc, "resumed");
|
|
1090
|
+
sessionManager2.saveStartConfig(sessionId, { provider: providerName, model, permissionMode: permissionMode2, extraArgs });
|
|
1091
|
+
logger.log("route", `POST /resume?session=${sessionId} \u2192 resumed (${history.length} history msgs)`);
|
|
1092
|
+
return httpJson(c, "agent.resume", {
|
|
1093
|
+
status: "resumed",
|
|
1094
|
+
provider: providerName,
|
|
1095
|
+
sessionId: session.id,
|
|
1096
|
+
historyCount: history.length
|
|
1097
|
+
});
|
|
1098
|
+
} catch (e) {
|
|
1099
|
+
logger.err("err", `POST /resume?session=${sessionId} \u2192 ${e.message}`);
|
|
1100
|
+
return c.json({ status: "error", message: e.message }, 500);
|
|
1101
|
+
}
|
|
1102
|
+
});
|
|
870
1103
|
app.post("/interrupt", async (c) => {
|
|
871
1104
|
const sessionId = getSessionId(c);
|
|
872
1105
|
const interrupted = sessionManager2.interruptSession(sessionId);
|
|
873
1106
|
return httpJson(c, "agent.interrupt", { status: interrupted ? "interrupted" : "no_session" });
|
|
874
1107
|
});
|
|
1108
|
+
app.post("/set-model", async (c) => {
|
|
1109
|
+
const sessionId = getSessionId(c);
|
|
1110
|
+
const body = await c.req.json().catch(() => ({}));
|
|
1111
|
+
if (!body.model) return c.json({ status: "error", message: "model is required" }, 400);
|
|
1112
|
+
const updated = sessionManager2.setSessionModel(sessionId, body.model);
|
|
1113
|
+
return httpJson(c, "agent.set-model", { status: updated ? "updated" : "no_session", model: body.model });
|
|
1114
|
+
});
|
|
1115
|
+
app.post("/set-permission-mode", async (c) => {
|
|
1116
|
+
const sessionId = getSessionId(c);
|
|
1117
|
+
const body = await c.req.json().catch(() => ({}));
|
|
1118
|
+
if (!body.permissionMode) return c.json({ status: "error", message: "permissionMode is required" }, 400);
|
|
1119
|
+
const updated = sessionManager2.setSessionPermissionMode(sessionId, body.permissionMode);
|
|
1120
|
+
return httpJson(c, "agent.set-permission-mode", { status: updated ? "updated" : "no_session", permissionMode: body.permissionMode });
|
|
1121
|
+
});
|
|
875
1122
|
app.post("/kill", async (c) => {
|
|
876
1123
|
const sessionId = getSessionId(c);
|
|
877
1124
|
const killed = sessionManager2.killSession(sessionId);
|
|
@@ -880,10 +1127,14 @@ function createAgentRoutes(sessionManager2) {
|
|
|
880
1127
|
app.get("/status", (c) => {
|
|
881
1128
|
const sessionId = getSessionId(c);
|
|
882
1129
|
const session = sessionManager2.getSession(sessionId);
|
|
1130
|
+
const alive = session?.process?.alive ?? false;
|
|
883
1131
|
return httpJson(c, "agent.status", {
|
|
884
|
-
alive
|
|
1132
|
+
alive,
|
|
1133
|
+
agentStatus: !alive ? "disconnected" : session?.state === "processing" ? "busy" : "idle",
|
|
885
1134
|
sessionId: session?.process?.sessionId ?? null,
|
|
886
|
-
|
|
1135
|
+
ccSessionId: session?.ccSessionId ?? null,
|
|
1136
|
+
eventCount: session?.eventCounter ?? 0,
|
|
1137
|
+
config: session?.lastStartConfig ?? null
|
|
887
1138
|
});
|
|
888
1139
|
});
|
|
889
1140
|
app.post("/permission-request", async (c) => {
|
|
@@ -917,6 +1168,7 @@ function createAgentRoutes(sessionManager2) {
|
|
|
917
1168
|
|
|
918
1169
|
// src/server/routes/chat.ts
|
|
919
1170
|
import { Hono as Hono2 } from "hono";
|
|
1171
|
+
import fs6 from "fs";
|
|
920
1172
|
function createChatRoutes() {
|
|
921
1173
|
const app = new Hono2();
|
|
922
1174
|
app.get("/sessions", (c) => {
|
|
@@ -1005,6 +1257,26 @@ function createChatRoutes() {
|
|
|
1005
1257
|
return c.json({ status: "error", message: e.message }, 500);
|
|
1006
1258
|
}
|
|
1007
1259
|
});
|
|
1260
|
+
app.get("/images/:sessionId/:filename", (c) => {
|
|
1261
|
+
const sessionId = c.req.param("sessionId");
|
|
1262
|
+
const filename = c.req.param("filename");
|
|
1263
|
+
const filePath = resolveImagePath(sessionId, filename);
|
|
1264
|
+
if (!filePath) {
|
|
1265
|
+
return c.json({ status: "error", message: "Image not found" }, 404);
|
|
1266
|
+
}
|
|
1267
|
+
const ext = filename.split(".").pop()?.toLowerCase();
|
|
1268
|
+
const mimeMap = {
|
|
1269
|
+
png: "image/png",
|
|
1270
|
+
jpg: "image/jpeg",
|
|
1271
|
+
jpeg: "image/jpeg",
|
|
1272
|
+
gif: "image/gif",
|
|
1273
|
+
webp: "image/webp",
|
|
1274
|
+
svg: "image/svg+xml"
|
|
1275
|
+
};
|
|
1276
|
+
const contentType = mimeMap[ext ?? ""] ?? "application/octet-stream";
|
|
1277
|
+
const data = fs6.readFileSync(filePath);
|
|
1278
|
+
return new Response(data, { headers: { "Content-Type": contentType, "Cache-Control": "public, max-age=31536000, immutable" } });
|
|
1279
|
+
});
|
|
1008
1280
|
return app;
|
|
1009
1281
|
}
|
|
1010
1282
|
|
|
@@ -1020,6 +1292,8 @@ var SessionManager = class {
|
|
|
1020
1292
|
this.skillEventListeners = /* @__PURE__ */ new Set();
|
|
1021
1293
|
this.permissionRequestListeners = /* @__PURE__ */ new Set();
|
|
1022
1294
|
this.lifecycleListeners = /* @__PURE__ */ new Set();
|
|
1295
|
+
this.configChangedListeners = /* @__PURE__ */ new Set();
|
|
1296
|
+
this.stateChangedListeners = /* @__PURE__ */ new Set();
|
|
1023
1297
|
this.maxSessions = options.maxSessions ?? DEFAULT_MAX_SESSIONS;
|
|
1024
1298
|
this.restoreFromDb();
|
|
1025
1299
|
}
|
|
@@ -1042,6 +1316,7 @@ var SessionManager = class {
|
|
|
1042
1316
|
meta: row.meta ? JSON.parse(row.meta) : null,
|
|
1043
1317
|
state: "idle",
|
|
1044
1318
|
lastStartConfig: row.last_start_config ? JSON.parse(row.last_start_config) : null,
|
|
1319
|
+
ccSessionId: null,
|
|
1045
1320
|
createdAt: new Date(row.created_at).getTime() || Date.now(),
|
|
1046
1321
|
lastActivityAt: Date.now()
|
|
1047
1322
|
});
|
|
@@ -1054,7 +1329,13 @@ var SessionManager = class {
|
|
|
1054
1329
|
try {
|
|
1055
1330
|
const db = getDb();
|
|
1056
1331
|
db.prepare(
|
|
1057
|
-
`INSERT
|
|
1332
|
+
`INSERT INTO chat_sessions (id, label, type, meta, cwd, last_start_config)
|
|
1333
|
+
VALUES (?, ?, 'main', ?, ?, ?)
|
|
1334
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
1335
|
+
label = excluded.label,
|
|
1336
|
+
meta = excluded.meta,
|
|
1337
|
+
cwd = excluded.cwd,
|
|
1338
|
+
last_start_config = excluded.last_start_config`
|
|
1058
1339
|
).run(
|
|
1059
1340
|
session.id,
|
|
1060
1341
|
session.label,
|
|
@@ -1100,6 +1381,7 @@ var SessionManager = class {
|
|
|
1100
1381
|
meta: opts.meta ?? null,
|
|
1101
1382
|
state: "idle",
|
|
1102
1383
|
lastStartConfig: null,
|
|
1384
|
+
ccSessionId: null,
|
|
1103
1385
|
createdAt: Date.now(),
|
|
1104
1386
|
lastActivityAt: Date.now()
|
|
1105
1387
|
};
|
|
@@ -1124,20 +1406,24 @@ var SessionManager = class {
|
|
|
1124
1406
|
return this.createSession({ id, ...opts });
|
|
1125
1407
|
}
|
|
1126
1408
|
/** Set the agent process for a session. Subscribes to events. */
|
|
1127
|
-
setProcess(sessionId, proc) {
|
|
1409
|
+
setProcess(sessionId, proc, lifecycleState) {
|
|
1128
1410
|
const session = this.sessions.get(sessionId);
|
|
1129
1411
|
if (!session) throw new Error(`Session "${sessionId}" not found`);
|
|
1130
1412
|
session.process = proc;
|
|
1131
|
-
|
|
1413
|
+
this.setSessionState(sessionId, session, "processing");
|
|
1132
1414
|
session.lastActivityAt = Date.now();
|
|
1133
1415
|
proc.on("event", (e) => {
|
|
1416
|
+
if (e.type === "init" && e.data?.sessionId && !session.ccSessionId) {
|
|
1417
|
+
session.ccSessionId = e.data.sessionId;
|
|
1418
|
+
this.persistSession(session);
|
|
1419
|
+
}
|
|
1134
1420
|
session.eventBuffer.push(e);
|
|
1135
1421
|
session.eventCounter++;
|
|
1136
1422
|
if (session.eventBuffer.length > MAX_EVENT_BUFFER) {
|
|
1137
1423
|
session.eventBuffer.splice(0, session.eventBuffer.length - MAX_EVENT_BUFFER);
|
|
1138
1424
|
}
|
|
1139
|
-
if (e.type === "complete" || e.type === "error") {
|
|
1140
|
-
|
|
1425
|
+
if (e.type === "complete" || e.type === "error" || e.type === "interrupted") {
|
|
1426
|
+
this.setSessionState(sessionId, session, "waiting");
|
|
1141
1427
|
}
|
|
1142
1428
|
this.persistEvent(sessionId, e);
|
|
1143
1429
|
const listeners = this.eventListeners.get(sessionId);
|
|
@@ -1146,14 +1432,14 @@ var SessionManager = class {
|
|
|
1146
1432
|
}
|
|
1147
1433
|
});
|
|
1148
1434
|
proc.on("exit", (code) => {
|
|
1149
|
-
|
|
1435
|
+
this.setSessionState(sessionId, session, "idle");
|
|
1150
1436
|
this.emitLifecycle({ session: sessionId, state: code != null ? "exited" : "crashed", code });
|
|
1151
1437
|
});
|
|
1152
1438
|
proc.on("error", () => {
|
|
1153
|
-
|
|
1439
|
+
this.setSessionState(sessionId, session, "idle");
|
|
1154
1440
|
this.emitLifecycle({ session: sessionId, state: "crashed" });
|
|
1155
1441
|
});
|
|
1156
|
-
this.emitLifecycle({ session: sessionId, state: "started" });
|
|
1442
|
+
this.emitLifecycle({ session: sessionId, state: lifecycleState ?? "started" });
|
|
1157
1443
|
}
|
|
1158
1444
|
// ── Event pub/sub (for WebSocket) ─────────────────────────────
|
|
1159
1445
|
/** Subscribe to real-time events for a session. Returns unsubscribe function. */
|
|
@@ -1194,11 +1480,38 @@ var SessionManager = class {
|
|
|
1194
1480
|
emitLifecycle(event) {
|
|
1195
1481
|
for (const cb of this.lifecycleListeners) cb(event);
|
|
1196
1482
|
}
|
|
1483
|
+
// ── Config changed pub/sub ────────────────────────────────────
|
|
1484
|
+
/** Subscribe to session config changes. Returns unsubscribe function. */
|
|
1485
|
+
onConfigChanged(cb) {
|
|
1486
|
+
this.configChangedListeners.add(cb);
|
|
1487
|
+
return () => this.configChangedListeners.delete(cb);
|
|
1488
|
+
}
|
|
1489
|
+
emitConfigChanged(sessionId, config) {
|
|
1490
|
+
for (const cb of this.configChangedListeners) cb({ session: sessionId, config });
|
|
1491
|
+
}
|
|
1492
|
+
// ── Agent status change pub/sub ────────────────────────────────
|
|
1493
|
+
onStateChanged(cb) {
|
|
1494
|
+
this.stateChangedListeners.add(cb);
|
|
1495
|
+
return () => this.stateChangedListeners.delete(cb);
|
|
1496
|
+
}
|
|
1497
|
+
/** Update session state and push agentStatus change to subscribers. */
|
|
1498
|
+
updateSessionState(sessionId, newState) {
|
|
1499
|
+
const session = this.sessions.get(sessionId);
|
|
1500
|
+
if (session) this.setSessionState(sessionId, session, newState);
|
|
1501
|
+
}
|
|
1502
|
+
setSessionState(sessionId, session, newState) {
|
|
1503
|
+
const oldState = session.state;
|
|
1504
|
+
session.state = newState;
|
|
1505
|
+
const newStatus = !session.process?.alive ? "disconnected" : newState === "processing" ? "busy" : "idle";
|
|
1506
|
+
if (oldState !== newState) {
|
|
1507
|
+
for (const cb of this.stateChangedListeners) cb({ session: sessionId, agentStatus: newStatus, state: newState });
|
|
1508
|
+
}
|
|
1509
|
+
}
|
|
1197
1510
|
// ── Permission management ─────────────────────────────────────
|
|
1198
1511
|
/** Create a pending permission request. Returns a promise that resolves when approved/denied. */
|
|
1199
1512
|
createPendingPermission(sessionId, request) {
|
|
1200
1513
|
const session = this.sessions.get(sessionId);
|
|
1201
|
-
if (session)
|
|
1514
|
+
if (session) this.setSessionState(sessionId, session, "permission");
|
|
1202
1515
|
return new Promise((resolve) => {
|
|
1203
1516
|
const createdAt = Date.now();
|
|
1204
1517
|
this.pendingPermissions.set(sessionId, { resolve, request, createdAt });
|
|
@@ -1218,7 +1531,7 @@ var SessionManager = class {
|
|
|
1218
1531
|
pending.resolve(approved);
|
|
1219
1532
|
this.pendingPermissions.delete(sessionId);
|
|
1220
1533
|
const session = this.sessions.get(sessionId);
|
|
1221
|
-
if (session)
|
|
1534
|
+
if (session) this.setSessionState(sessionId, session, "processing");
|
|
1222
1535
|
return true;
|
|
1223
1536
|
}
|
|
1224
1537
|
/** Get a pending permission for a specific session. */
|
|
@@ -1262,14 +1575,43 @@ var SessionManager = class {
|
|
|
1262
1575
|
session.lastStartConfig = config;
|
|
1263
1576
|
this.persistSession(session);
|
|
1264
1577
|
this.emitLifecycle({ session: id, state: "restarted" });
|
|
1578
|
+
this.emitConfigChanged(id, config);
|
|
1265
1579
|
return { config };
|
|
1266
1580
|
}
|
|
1267
|
-
/** Interrupt the current turn
|
|
1581
|
+
/** Interrupt the current turn. Process stays alive, returns to waiting. */
|
|
1268
1582
|
interruptSession(id) {
|
|
1269
1583
|
const session = this.sessions.get(id);
|
|
1270
1584
|
if (!session?.process?.alive) return false;
|
|
1271
1585
|
session.process.interrupt();
|
|
1272
|
-
|
|
1586
|
+
this.setSessionState(id, session, "waiting");
|
|
1587
|
+
return true;
|
|
1588
|
+
}
|
|
1589
|
+
/** Change model. Sends control message if alive, always persists to config. */
|
|
1590
|
+
setSessionModel(id, model) {
|
|
1591
|
+
const session = this.sessions.get(id);
|
|
1592
|
+
if (!session) return false;
|
|
1593
|
+
if (session.process?.alive) session.process.setModel(model);
|
|
1594
|
+
if (session.lastStartConfig) {
|
|
1595
|
+
session.lastStartConfig.model = model;
|
|
1596
|
+
} else {
|
|
1597
|
+
session.lastStartConfig = { provider: "claude-code", model, permissionMode: "acceptEdits" };
|
|
1598
|
+
}
|
|
1599
|
+
this.persistSession(session);
|
|
1600
|
+
this.emitConfigChanged(id, session.lastStartConfig);
|
|
1601
|
+
return true;
|
|
1602
|
+
}
|
|
1603
|
+
/** Change permission mode. Sends control message if alive, always persists to config. */
|
|
1604
|
+
setSessionPermissionMode(id, mode) {
|
|
1605
|
+
const session = this.sessions.get(id);
|
|
1606
|
+
if (!session) return false;
|
|
1607
|
+
if (session.process?.alive) session.process.setPermissionMode(mode);
|
|
1608
|
+
if (session.lastStartConfig) {
|
|
1609
|
+
session.lastStartConfig.permissionMode = mode;
|
|
1610
|
+
} else {
|
|
1611
|
+
session.lastStartConfig = { provider: "claude-code", model: "claude-sonnet-4-6", permissionMode: mode };
|
|
1612
|
+
}
|
|
1613
|
+
this.persistSession(session);
|
|
1614
|
+
this.emitConfigChanged(id, session.lastStartConfig);
|
|
1273
1615
|
return true;
|
|
1274
1616
|
}
|
|
1275
1617
|
/** Kill the agent process in a session (session stays, can be restarted). */
|
|
@@ -1298,8 +1640,11 @@ var SessionManager = class {
|
|
|
1298
1640
|
label: s.label,
|
|
1299
1641
|
alive: s.process?.alive ?? false,
|
|
1300
1642
|
state: s.state,
|
|
1643
|
+
agentStatus: !s.process?.alive ? "disconnected" : s.state === "processing" ? "busy" : "idle",
|
|
1301
1644
|
cwd: s.cwd,
|
|
1302
1645
|
meta: s.meta,
|
|
1646
|
+
config: s.lastStartConfig,
|
|
1647
|
+
ccSessionId: s.ccSessionId,
|
|
1303
1648
|
eventCount: s.eventCounter,
|
|
1304
1649
|
createdAt: s.createdAt,
|
|
1305
1650
|
lastActivityAt: s.lastActivityAt
|
|
@@ -1383,10 +1728,16 @@ function attachWebSocket(server2, sessionManager2) {
|
|
|
1383
1728
|
});
|
|
1384
1729
|
wss.on("connection", (ws) => {
|
|
1385
1730
|
logger.log("ws", "client connected");
|
|
1386
|
-
const state = { agentUnsubs: /* @__PURE__ */ new Map(), skillEventUnsub: null, skillPollTimer: null, permissionUnsub: null, lifecycleUnsub: null };
|
|
1731
|
+
const state = { agentUnsubs: /* @__PURE__ */ new Map(), skillEventUnsub: null, skillPollTimer: null, permissionUnsub: null, lifecycleUnsub: null, configChangedUnsub: null, stateChangedUnsub: null };
|
|
1387
1732
|
state.lifecycleUnsub = sessionManager2.onSessionLifecycle((event) => {
|
|
1388
1733
|
send(ws, { type: "session.lifecycle", ...event });
|
|
1389
1734
|
});
|
|
1735
|
+
state.configChangedUnsub = sessionManager2.onConfigChanged((event) => {
|
|
1736
|
+
send(ws, { type: "session.config-changed", ...event });
|
|
1737
|
+
});
|
|
1738
|
+
state.stateChangedUnsub = sessionManager2.onStateChanged((event) => {
|
|
1739
|
+
send(ws, { type: "session.state-changed", ...event });
|
|
1740
|
+
});
|
|
1390
1741
|
ws.on("message", (raw) => {
|
|
1391
1742
|
let msg;
|
|
1392
1743
|
try {
|
|
@@ -1415,6 +1766,10 @@ function attachWebSocket(server2, sessionManager2) {
|
|
|
1415
1766
|
state.permissionUnsub = null;
|
|
1416
1767
|
state.lifecycleUnsub?.();
|
|
1417
1768
|
state.lifecycleUnsub = null;
|
|
1769
|
+
state.configChangedUnsub?.();
|
|
1770
|
+
state.configChangedUnsub = null;
|
|
1771
|
+
state.stateChangedUnsub?.();
|
|
1772
|
+
state.stateChangedUnsub = null;
|
|
1418
1773
|
});
|
|
1419
1774
|
});
|
|
1420
1775
|
return wss;
|
|
@@ -1433,10 +1788,16 @@ function handleMessage(ws, msg, sm, state) {
|
|
|
1433
1788
|
return handleAgentStart(ws, msg, sm);
|
|
1434
1789
|
case "agent.send":
|
|
1435
1790
|
return handleAgentSend(ws, msg, sm);
|
|
1791
|
+
case "agent.resume":
|
|
1792
|
+
return handleAgentResume(ws, msg, sm);
|
|
1436
1793
|
case "agent.restart":
|
|
1437
1794
|
return handleAgentRestart(ws, msg, sm);
|
|
1438
1795
|
case "agent.interrupt":
|
|
1439
1796
|
return handleAgentInterrupt(ws, msg, sm);
|
|
1797
|
+
case "agent.set-model":
|
|
1798
|
+
return handleAgentSetModel(ws, msg, sm);
|
|
1799
|
+
case "agent.set-permission-mode":
|
|
1800
|
+
return handleAgentSetPermissionMode(ws, msg, sm);
|
|
1440
1801
|
case "agent.kill":
|
|
1441
1802
|
return handleAgentKill(ws, msg, sm);
|
|
1442
1803
|
case "agent.status":
|
|
@@ -1538,6 +1899,7 @@ function handleAgentStart(ws, msg, sm) {
|
|
|
1538
1899
|
model,
|
|
1539
1900
|
permissionMode: permissionMode2,
|
|
1540
1901
|
env: { SNA_SESSION_ID: sessionId },
|
|
1902
|
+
history: msg.history,
|
|
1541
1903
|
extraArgs
|
|
1542
1904
|
});
|
|
1543
1905
|
sm.setProcess(sessionId, proc);
|
|
@@ -1553,23 +1915,79 @@ function handleAgentSend(ws, msg, sm) {
|
|
|
1553
1915
|
if (!session?.process?.alive) {
|
|
1554
1916
|
return replyError(ws, msg, `No active agent session "${sessionId}". Start first.`);
|
|
1555
1917
|
}
|
|
1556
|
-
|
|
1557
|
-
|
|
1918
|
+
const images = msg.images;
|
|
1919
|
+
if (!msg.message && !images?.length) {
|
|
1920
|
+
return replyError(ws, msg, "message or images required");
|
|
1921
|
+
}
|
|
1922
|
+
const textContent = msg.message ?? "(image)";
|
|
1923
|
+
let meta = msg.meta ? { ...msg.meta } : {};
|
|
1924
|
+
if (images?.length) {
|
|
1925
|
+
const filenames = saveImages(sessionId, images);
|
|
1926
|
+
meta.images = filenames;
|
|
1558
1927
|
}
|
|
1559
1928
|
try {
|
|
1560
1929
|
const db = getDb();
|
|
1561
1930
|
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,
|
|
1931
|
+
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
1932
|
} catch {
|
|
1564
1933
|
}
|
|
1565
|
-
|
|
1934
|
+
sm.updateSessionState(sessionId, "processing");
|
|
1566
1935
|
sm.touch(sessionId);
|
|
1567
|
-
|
|
1936
|
+
if (images?.length) {
|
|
1937
|
+
const content = [
|
|
1938
|
+
...images.map((img) => ({
|
|
1939
|
+
type: "image",
|
|
1940
|
+
source: { type: "base64", media_type: img.mimeType, data: img.base64 }
|
|
1941
|
+
})),
|
|
1942
|
+
...msg.message ? [{ type: "text", text: msg.message }] : []
|
|
1943
|
+
];
|
|
1944
|
+
session.process.send(content);
|
|
1945
|
+
} else {
|
|
1946
|
+
session.process.send(msg.message);
|
|
1947
|
+
}
|
|
1568
1948
|
wsReply(ws, msg, { status: "sent" });
|
|
1569
1949
|
}
|
|
1950
|
+
function handleAgentResume(ws, msg, sm) {
|
|
1951
|
+
const sessionId = msg.session ?? "default";
|
|
1952
|
+
const session = sm.getOrCreateSession(sessionId);
|
|
1953
|
+
if (session.process?.alive) {
|
|
1954
|
+
return replyError(ws, msg, "Session already running. Use agent.send instead.");
|
|
1955
|
+
}
|
|
1956
|
+
const history = buildHistoryFromDb(sessionId);
|
|
1957
|
+
if (history.length === 0 && !msg.prompt) {
|
|
1958
|
+
return replyError(ws, msg, "No history in DB \u2014 nothing to resume.");
|
|
1959
|
+
}
|
|
1960
|
+
const providerName = msg.provider ?? session.lastStartConfig?.provider ?? "claude-code";
|
|
1961
|
+
const model = msg.model ?? session.lastStartConfig?.model ?? "claude-sonnet-4-6";
|
|
1962
|
+
const permissionMode2 = msg.permissionMode ?? session.lastStartConfig?.permissionMode ?? "acceptEdits";
|
|
1963
|
+
const extraArgs = msg.extraArgs ?? session.lastStartConfig?.extraArgs;
|
|
1964
|
+
const provider2 = getProvider(providerName);
|
|
1965
|
+
try {
|
|
1966
|
+
const proc = provider2.spawn({
|
|
1967
|
+
cwd: session.cwd,
|
|
1968
|
+
prompt: msg.prompt,
|
|
1969
|
+
model,
|
|
1970
|
+
permissionMode: permissionMode2,
|
|
1971
|
+
env: { SNA_SESSION_ID: sessionId },
|
|
1972
|
+
history: history.length > 0 ? history : void 0,
|
|
1973
|
+
extraArgs
|
|
1974
|
+
});
|
|
1975
|
+
sm.setProcess(sessionId, proc, "resumed");
|
|
1976
|
+
sm.saveStartConfig(sessionId, { provider: providerName, model, permissionMode: permissionMode2, extraArgs });
|
|
1977
|
+
wsReply(ws, msg, {
|
|
1978
|
+
status: "resumed",
|
|
1979
|
+
provider: providerName,
|
|
1980
|
+
sessionId: session.id,
|
|
1981
|
+
historyCount: history.length
|
|
1982
|
+
});
|
|
1983
|
+
} catch (e) {
|
|
1984
|
+
replyError(ws, msg, e.message);
|
|
1985
|
+
}
|
|
1986
|
+
}
|
|
1570
1987
|
function handleAgentRestart(ws, msg, sm) {
|
|
1571
1988
|
const sessionId = msg.session ?? "default";
|
|
1572
1989
|
try {
|
|
1990
|
+
const ccSessionId = sm.getSession(sessionId)?.ccSessionId;
|
|
1573
1991
|
const { config } = sm.restartSession(
|
|
1574
1992
|
sessionId,
|
|
1575
1993
|
{
|
|
@@ -1580,12 +1998,13 @@ function handleAgentRestart(ws, msg, sm) {
|
|
|
1580
1998
|
},
|
|
1581
1999
|
(cfg) => {
|
|
1582
2000
|
const prov = getProvider(cfg.provider);
|
|
2001
|
+
const resumeArgs = ccSessionId ? ["--resume", ccSessionId] : ["--resume"];
|
|
1583
2002
|
return prov.spawn({
|
|
1584
2003
|
cwd: sm.getSession(sessionId).cwd,
|
|
1585
2004
|
model: cfg.model,
|
|
1586
2005
|
permissionMode: cfg.permissionMode,
|
|
1587
2006
|
env: { SNA_SESSION_ID: sessionId },
|
|
1588
|
-
extraArgs: [...cfg.extraArgs ?? [],
|
|
2007
|
+
extraArgs: [...cfg.extraArgs ?? [], ...resumeArgs]
|
|
1589
2008
|
});
|
|
1590
2009
|
}
|
|
1591
2010
|
);
|
|
@@ -1599,6 +2018,20 @@ function handleAgentInterrupt(ws, msg, sm) {
|
|
|
1599
2018
|
const interrupted = sm.interruptSession(sessionId);
|
|
1600
2019
|
wsReply(ws, msg, { status: interrupted ? "interrupted" : "no_session" });
|
|
1601
2020
|
}
|
|
2021
|
+
function handleAgentSetModel(ws, msg, sm) {
|
|
2022
|
+
const sessionId = msg.session ?? "default";
|
|
2023
|
+
const model = msg.model;
|
|
2024
|
+
if (!model) return replyError(ws, msg, "model is required");
|
|
2025
|
+
const updated = sm.setSessionModel(sessionId, model);
|
|
2026
|
+
wsReply(ws, msg, { status: updated ? "updated" : "no_session", model });
|
|
2027
|
+
}
|
|
2028
|
+
function handleAgentSetPermissionMode(ws, msg, sm) {
|
|
2029
|
+
const sessionId = msg.session ?? "default";
|
|
2030
|
+
const permissionMode2 = msg.permissionMode;
|
|
2031
|
+
if (!permissionMode2) return replyError(ws, msg, "permissionMode is required");
|
|
2032
|
+
const updated = sm.setSessionPermissionMode(sessionId, permissionMode2);
|
|
2033
|
+
wsReply(ws, msg, { status: updated ? "updated" : "no_session", permissionMode: permissionMode2 });
|
|
2034
|
+
}
|
|
1602
2035
|
function handleAgentKill(ws, msg, sm) {
|
|
1603
2036
|
const sessionId = msg.session ?? "default";
|
|
1604
2037
|
const killed = sm.killSession(sessionId);
|
|
@@ -1607,10 +2040,14 @@ function handleAgentKill(ws, msg, sm) {
|
|
|
1607
2040
|
function handleAgentStatus(ws, msg, sm) {
|
|
1608
2041
|
const sessionId = msg.session ?? "default";
|
|
1609
2042
|
const session = sm.getSession(sessionId);
|
|
2043
|
+
const alive = session?.process?.alive ?? false;
|
|
1610
2044
|
wsReply(ws, msg, {
|
|
1611
|
-
alive
|
|
2045
|
+
alive,
|
|
2046
|
+
agentStatus: !alive ? "disconnected" : session?.state === "processing" ? "busy" : "idle",
|
|
1612
2047
|
sessionId: session?.process?.sessionId ?? null,
|
|
1613
|
-
|
|
2048
|
+
ccSessionId: session?.ccSessionId ?? null,
|
|
2049
|
+
eventCount: session?.eventCounter ?? 0,
|
|
2050
|
+
config: session?.lastStartConfig ?? null
|
|
1614
2051
|
});
|
|
1615
2052
|
}
|
|
1616
2053
|
async function handleAgentRunOnce(ws, msg, sm) {
|
|
@@ -1886,8 +2323,8 @@ var methodColor = {
|
|
|
1886
2323
|
root.use("*", async (c, next) => {
|
|
1887
2324
|
const m = c.req.method;
|
|
1888
2325
|
const colorFn = methodColor[m] ?? chalk2.white;
|
|
1889
|
-
const
|
|
1890
|
-
logger.log("req", `${colorFn(m.padEnd(6))} ${
|
|
2326
|
+
const path6 = new URL(c.req.url).pathname;
|
|
2327
|
+
logger.log("req", `${colorFn(m.padEnd(6))} ${path6}`);
|
|
1891
2328
|
await next();
|
|
1892
2329
|
});
|
|
1893
2330
|
var sessionManager = new SessionManager({ maxSessions });
|