@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.
@@ -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 fs3 from "fs";
251
- import path3 from "path";
250
+ import fs4 from "fs";
251
+ import path4 from "path";
252
252
 
253
- // src/lib/logger.ts
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
- var LOG_PATH = path2.join(process.cwd(), ".dev.log");
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
- fs2.writeFileSync(LOG_PATH, "");
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
- fs2.appendFile(LOG_PATH, line, () => {
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
- const cached = path3.join(cwd, ".sna/claude-path");
308
- if (fs3.existsSync(cached)) {
309
- const p = fs3.readFileSync(cached, "utf8").trim();
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: input }
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
- this.proc.kill("SIGINT");
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 claudePath = resolveClaudePath(options.cwd);
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
- const proc = spawn2(claudePath, args, {
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 ${claudePath} ${args.join(" ")}`);
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: extraArgs.length > 0 ? extraArgs : void 0
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 is required" }, 400);
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, body.message, body.meta ? JSON.stringify(body.meta) : null);
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
- session.state = "processing";
985
+ sessionManager2.updateSessionState(sessionId, "processing");
807
986
  sessionManager2.touch(sessionId);
808
- logger.log("route", `POST /send?session=${sessionId} \u2192 "${body.message.slice(0, 80)}"`);
809
- session.process.send(body.message);
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 ?? [], "--resume"]
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: session?.process?.alive ?? false,
1132
+ alive,
1133
+ agentStatus: !alive ? "disconnected" : session?.state === "processing" ? "busy" : "idle",
885
1134
  sessionId: session?.process?.sessionId ?? null,
886
- eventCount: session?.eventCounter ?? 0
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 OR REPLACE INTO chat_sessions (id, label, type, meta, cwd, last_start_config) VALUES (?, ?, 'main', ?, ?, ?)`
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
- session.state = "processing";
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
- session.state = "waiting";
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
- session.state = "idle";
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
- session.state = "idle";
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) session.state = "permission";
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) session.state = "processing";
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 (SIGINT). Process stays alive, returns to waiting. */
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
- session.state = "waiting";
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
- if (!msg.message) {
1557
- return replyError(ws, msg, "message is required");
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, msg.message, msg.meta ? JSON.stringify(msg.meta) : null);
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
- session.state = "processing";
1934
+ sm.updateSessionState(sessionId, "processing");
1566
1935
  sm.touch(sessionId);
1567
- session.process.send(msg.message);
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 ?? [], "--resume"]
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: session?.process?.alive ?? false,
2045
+ alive,
2046
+ agentStatus: !alive ? "disconnected" : session?.state === "processing" ? "busy" : "idle",
1612
2047
  sessionId: session?.process?.sessionId ?? null,
1613
- eventCount: session?.eventCounter ?? 0
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 path4 = new URL(c.req.url).pathname;
1890
- logger.log("req", `${colorFn(m.padEnd(6))} ${path4}`);
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 });