@sna-sdk/core 0.3.0 → 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) {
@@ -305,9 +373,9 @@ var logger = { log, err };
305
373
  var SHELL = process.env.SHELL || "/bin/zsh";
306
374
  function resolveClaudePath(cwd) {
307
375
  if (process.env.SNA_CLAUDE_COMMAND) return process.env.SNA_CLAUDE_COMMAND;
308
- const cached = path3.join(cwd, ".sna/claude-path");
309
- if (fs3.existsSync(cached)) {
310
- const p = fs3.readFileSync(cached, "utf8").trim();
376
+ const cached = path4.join(cwd, ".sna/claude-path");
377
+ if (fs4.existsSync(cached)) {
378
+ const p = fs4.readFileSync(cached, "utf8").trim();
311
379
  if (p) {
312
380
  try {
313
381
  execSync(`test -x "${p}"`, { stdio: "pipe" });
@@ -378,28 +446,9 @@ var ClaudeCodeProcess = class {
378
446
  this._alive = false;
379
447
  this.emitter.emit("error", err2);
380
448
  });
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
- }
449
+ if (options.history?.length && !options._historyViaResume) {
450
+ const line = buildRecalledConversation(options.history);
451
+ this.proc.stdin.write(line + "\n");
403
452
  }
404
453
  if (options.prompt) {
405
454
  this.send(options.prompt);
@@ -640,6 +689,14 @@ var ClaudeCodeProvider = class {
640
689
  if (options.permissionMode) {
641
690
  args.push("--permission-mode", options.permissionMode);
642
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
+ }
643
700
  if (extraArgsClean.length > 0) {
644
701
  args.push(...extraArgsClean);
645
702
  }
@@ -682,11 +739,34 @@ function getProvider(name = "claude-code") {
682
739
  return provider2;
683
740
  }
684
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
+
685
765
  // src/server/image-store.ts
686
- import fs4 from "fs";
687
- import path4 from "path";
766
+ import fs5 from "fs";
767
+ import path5 from "path";
688
768
  import { createHash } from "crypto";
689
- var IMAGE_DIR = path4.join(process.cwd(), "data/images");
769
+ var IMAGE_DIR = path5.join(process.cwd(), "data/images");
690
770
  var MIME_TO_EXT = {
691
771
  "image/png": "png",
692
772
  "image/jpeg": "jpg",
@@ -695,23 +775,23 @@ var MIME_TO_EXT = {
695
775
  "image/svg+xml": "svg"
696
776
  };
697
777
  function saveImages(sessionId, images) {
698
- const dir = path4.join(IMAGE_DIR, sessionId);
699
- fs4.mkdirSync(dir, { recursive: true });
778
+ const dir = path5.join(IMAGE_DIR, sessionId);
779
+ fs5.mkdirSync(dir, { recursive: true });
700
780
  return images.map((img) => {
701
781
  const ext = MIME_TO_EXT[img.mimeType] ?? "bin";
702
782
  const hash = createHash("sha256").update(img.base64).digest("hex").slice(0, 12);
703
783
  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"));
784
+ const filePath = path5.join(dir, filename);
785
+ if (!fs5.existsSync(filePath)) {
786
+ fs5.writeFileSync(filePath, Buffer.from(img.base64, "base64"));
707
787
  }
708
788
  return filename;
709
789
  });
710
790
  }
711
791
  function resolveImagePath(sessionId, filename) {
712
792
  if (filename.includes("..") || filename.includes("/")) return null;
713
- const filePath = path4.join(IMAGE_DIR, sessionId, filename);
714
- return fs4.existsSync(filePath) ? filePath : null;
793
+ const filePath = path5.join(IMAGE_DIR, sessionId, filename);
794
+ return fs5.existsSync(filePath) ? filePath : null;
715
795
  }
716
796
 
717
797
  // src/server/routes/agent.ts
@@ -902,7 +982,7 @@ function createAgentRoutes(sessionManager2) {
902
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);
903
983
  } catch {
904
984
  }
905
- session.state = "processing";
985
+ sessionManager2.updateSessionState(sessionId, "processing");
906
986
  sessionManager2.touch(sessionId);
907
987
  if (body.images?.length) {
908
988
  const content = [
@@ -980,6 +1060,46 @@ function createAgentRoutes(sessionManager2) {
980
1060
  return c.json({ status: "error", message: e.message }, 500);
981
1061
  }
982
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
+ });
983
1103
  app.post("/interrupt", async (c) => {
984
1104
  const sessionId = getSessionId(c);
985
1105
  const interrupted = sessionManager2.interruptSession(sessionId);
@@ -1007,8 +1127,10 @@ function createAgentRoutes(sessionManager2) {
1007
1127
  app.get("/status", (c) => {
1008
1128
  const sessionId = getSessionId(c);
1009
1129
  const session = sessionManager2.getSession(sessionId);
1130
+ const alive = session?.process?.alive ?? false;
1010
1131
  return httpJson(c, "agent.status", {
1011
- alive: session?.process?.alive ?? false,
1132
+ alive,
1133
+ agentStatus: !alive ? "disconnected" : session?.state === "processing" ? "busy" : "idle",
1012
1134
  sessionId: session?.process?.sessionId ?? null,
1013
1135
  ccSessionId: session?.ccSessionId ?? null,
1014
1136
  eventCount: session?.eventCounter ?? 0,
@@ -1046,7 +1168,7 @@ function createAgentRoutes(sessionManager2) {
1046
1168
 
1047
1169
  // src/server/routes/chat.ts
1048
1170
  import { Hono as Hono2 } from "hono";
1049
- import fs5 from "fs";
1171
+ import fs6 from "fs";
1050
1172
  function createChatRoutes() {
1051
1173
  const app = new Hono2();
1052
1174
  app.get("/sessions", (c) => {
@@ -1152,7 +1274,7 @@ function createChatRoutes() {
1152
1274
  svg: "image/svg+xml"
1153
1275
  };
1154
1276
  const contentType = mimeMap[ext ?? ""] ?? "application/octet-stream";
1155
- const data = fs5.readFileSync(filePath);
1277
+ const data = fs6.readFileSync(filePath);
1156
1278
  return new Response(data, { headers: { "Content-Type": contentType, "Cache-Control": "public, max-age=31536000, immutable" } });
1157
1279
  });
1158
1280
  return app;
@@ -1171,6 +1293,7 @@ var SessionManager = class {
1171
1293
  this.permissionRequestListeners = /* @__PURE__ */ new Set();
1172
1294
  this.lifecycleListeners = /* @__PURE__ */ new Set();
1173
1295
  this.configChangedListeners = /* @__PURE__ */ new Set();
1296
+ this.stateChangedListeners = /* @__PURE__ */ new Set();
1174
1297
  this.maxSessions = options.maxSessions ?? DEFAULT_MAX_SESSIONS;
1175
1298
  this.restoreFromDb();
1176
1299
  }
@@ -1283,11 +1406,11 @@ var SessionManager = class {
1283
1406
  return this.createSession({ id, ...opts });
1284
1407
  }
1285
1408
  /** Set the agent process for a session. Subscribes to events. */
1286
- setProcess(sessionId, proc) {
1409
+ setProcess(sessionId, proc, lifecycleState) {
1287
1410
  const session = this.sessions.get(sessionId);
1288
1411
  if (!session) throw new Error(`Session "${sessionId}" not found`);
1289
1412
  session.process = proc;
1290
- session.state = "processing";
1413
+ this.setSessionState(sessionId, session, "processing");
1291
1414
  session.lastActivityAt = Date.now();
1292
1415
  proc.on("event", (e) => {
1293
1416
  if (e.type === "init" && e.data?.sessionId && !session.ccSessionId) {
@@ -1300,7 +1423,7 @@ var SessionManager = class {
1300
1423
  session.eventBuffer.splice(0, session.eventBuffer.length - MAX_EVENT_BUFFER);
1301
1424
  }
1302
1425
  if (e.type === "complete" || e.type === "error" || e.type === "interrupted") {
1303
- session.state = "waiting";
1426
+ this.setSessionState(sessionId, session, "waiting");
1304
1427
  }
1305
1428
  this.persistEvent(sessionId, e);
1306
1429
  const listeners = this.eventListeners.get(sessionId);
@@ -1309,14 +1432,14 @@ var SessionManager = class {
1309
1432
  }
1310
1433
  });
1311
1434
  proc.on("exit", (code) => {
1312
- session.state = "idle";
1435
+ this.setSessionState(sessionId, session, "idle");
1313
1436
  this.emitLifecycle({ session: sessionId, state: code != null ? "exited" : "crashed", code });
1314
1437
  });
1315
1438
  proc.on("error", () => {
1316
- session.state = "idle";
1439
+ this.setSessionState(sessionId, session, "idle");
1317
1440
  this.emitLifecycle({ session: sessionId, state: "crashed" });
1318
1441
  });
1319
- this.emitLifecycle({ session: sessionId, state: "started" });
1442
+ this.emitLifecycle({ session: sessionId, state: lifecycleState ?? "started" });
1320
1443
  }
1321
1444
  // ── Event pub/sub (for WebSocket) ─────────────────────────────
1322
1445
  /** Subscribe to real-time events for a session. Returns unsubscribe function. */
@@ -1366,11 +1489,29 @@ var SessionManager = class {
1366
1489
  emitConfigChanged(sessionId, config) {
1367
1490
  for (const cb of this.configChangedListeners) cb({ session: sessionId, config });
1368
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
+ }
1369
1510
  // ── Permission management ─────────────────────────────────────
1370
1511
  /** Create a pending permission request. Returns a promise that resolves when approved/denied. */
1371
1512
  createPendingPermission(sessionId, request) {
1372
1513
  const session = this.sessions.get(sessionId);
1373
- if (session) session.state = "permission";
1514
+ if (session) this.setSessionState(sessionId, session, "permission");
1374
1515
  return new Promise((resolve) => {
1375
1516
  const createdAt = Date.now();
1376
1517
  this.pendingPermissions.set(sessionId, { resolve, request, createdAt });
@@ -1390,7 +1531,7 @@ var SessionManager = class {
1390
1531
  pending.resolve(approved);
1391
1532
  this.pendingPermissions.delete(sessionId);
1392
1533
  const session = this.sessions.get(sessionId);
1393
- if (session) session.state = "processing";
1534
+ if (session) this.setSessionState(sessionId, session, "processing");
1394
1535
  return true;
1395
1536
  }
1396
1537
  /** Get a pending permission for a specific session. */
@@ -1442,7 +1583,7 @@ var SessionManager = class {
1442
1583
  const session = this.sessions.get(id);
1443
1584
  if (!session?.process?.alive) return false;
1444
1585
  session.process.interrupt();
1445
- session.state = "waiting";
1586
+ this.setSessionState(id, session, "waiting");
1446
1587
  return true;
1447
1588
  }
1448
1589
  /** Change model. Sends control message if alive, always persists to config. */
@@ -1499,6 +1640,7 @@ var SessionManager = class {
1499
1640
  label: s.label,
1500
1641
  alive: s.process?.alive ?? false,
1501
1642
  state: s.state,
1643
+ agentStatus: !s.process?.alive ? "disconnected" : s.state === "processing" ? "busy" : "idle",
1502
1644
  cwd: s.cwd,
1503
1645
  meta: s.meta,
1504
1646
  config: s.lastStartConfig,
@@ -1586,13 +1728,16 @@ function attachWebSocket(server2, sessionManager2) {
1586
1728
  });
1587
1729
  wss.on("connection", (ws) => {
1588
1730
  logger.log("ws", "client connected");
1589
- const state = { agentUnsubs: /* @__PURE__ */ new Map(), skillEventUnsub: null, skillPollTimer: null, permissionUnsub: null, lifecycleUnsub: null, configChangedUnsub: null };
1731
+ const state = { agentUnsubs: /* @__PURE__ */ new Map(), skillEventUnsub: null, skillPollTimer: null, permissionUnsub: null, lifecycleUnsub: null, configChangedUnsub: null, stateChangedUnsub: null };
1590
1732
  state.lifecycleUnsub = sessionManager2.onSessionLifecycle((event) => {
1591
1733
  send(ws, { type: "session.lifecycle", ...event });
1592
1734
  });
1593
1735
  state.configChangedUnsub = sessionManager2.onConfigChanged((event) => {
1594
1736
  send(ws, { type: "session.config-changed", ...event });
1595
1737
  });
1738
+ state.stateChangedUnsub = sessionManager2.onStateChanged((event) => {
1739
+ send(ws, { type: "session.state-changed", ...event });
1740
+ });
1596
1741
  ws.on("message", (raw) => {
1597
1742
  let msg;
1598
1743
  try {
@@ -1623,6 +1768,8 @@ function attachWebSocket(server2, sessionManager2) {
1623
1768
  state.lifecycleUnsub = null;
1624
1769
  state.configChangedUnsub?.();
1625
1770
  state.configChangedUnsub = null;
1771
+ state.stateChangedUnsub?.();
1772
+ state.stateChangedUnsub = null;
1626
1773
  });
1627
1774
  });
1628
1775
  return wss;
@@ -1641,6 +1788,8 @@ function handleMessage(ws, msg, sm, state) {
1641
1788
  return handleAgentStart(ws, msg, sm);
1642
1789
  case "agent.send":
1643
1790
  return handleAgentSend(ws, msg, sm);
1791
+ case "agent.resume":
1792
+ return handleAgentResume(ws, msg, sm);
1644
1793
  case "agent.restart":
1645
1794
  return handleAgentRestart(ws, msg, sm);
1646
1795
  case "agent.interrupt":
@@ -1782,7 +1931,7 @@ function handleAgentSend(ws, msg, sm) {
1782
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);
1783
1932
  } catch {
1784
1933
  }
1785
- session.state = "processing";
1934
+ sm.updateSessionState(sessionId, "processing");
1786
1935
  sm.touch(sessionId);
1787
1936
  if (images?.length) {
1788
1937
  const content = [
@@ -1798,6 +1947,43 @@ function handleAgentSend(ws, msg, sm) {
1798
1947
  }
1799
1948
  wsReply(ws, msg, { status: "sent" });
1800
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
+ }
1801
1987
  function handleAgentRestart(ws, msg, sm) {
1802
1988
  const sessionId = msg.session ?? "default";
1803
1989
  try {
@@ -1854,8 +2040,10 @@ function handleAgentKill(ws, msg, sm) {
1854
2040
  function handleAgentStatus(ws, msg, sm) {
1855
2041
  const sessionId = msg.session ?? "default";
1856
2042
  const session = sm.getSession(sessionId);
2043
+ const alive = session?.process?.alive ?? false;
1857
2044
  wsReply(ws, msg, {
1858
- alive: session?.process?.alive ?? false,
2045
+ alive,
2046
+ agentStatus: !alive ? "disconnected" : session?.state === "processing" ? "busy" : "idle",
1859
2047
  sessionId: session?.process?.sessionId ?? null,
1860
2048
  ccSessionId: session?.ccSessionId ?? null,
1861
2049
  eventCount: session?.eventCounter ?? 0,
@@ -2135,8 +2323,8 @@ var methodColor = {
2135
2323
  root.use("*", async (c, next) => {
2136
2324
  const m = c.req.method;
2137
2325
  const colorFn = methodColor[m] ?? chalk2.white;
2138
- const path5 = new URL(c.req.url).pathname;
2139
- logger.log("req", `${colorFn(m.padEnd(6))} ${path5}`);
2326
+ const path6 = new URL(c.req.url).pathname;
2327
+ logger.log("req", `${colorFn(m.padEnd(6))} ${path6}`);
2140
2328
  await next();
2141
2329
  });
2142
2330
  var sessionManager = new SessionManager({ maxSessions });
package/dist/server/ws.js CHANGED
@@ -4,6 +4,7 @@ import { getDb } from "../db/schema.js";
4
4
  import { logger } from "../lib/logger.js";
5
5
  import { runOnce } from "./routes/agent.js";
6
6
  import { wsReply } from "./api-types.js";
7
+ import { buildHistoryFromDb } from "./history-builder.js";
7
8
  import { saveImages } from "./image-store.js";
8
9
  function send(ws, data) {
9
10
  if (ws.readyState === ws.OPEN) {
@@ -30,13 +31,16 @@ function attachWebSocket(server, sessionManager) {
30
31
  });
31
32
  wss.on("connection", (ws) => {
32
33
  logger.log("ws", "client connected");
33
- const state = { agentUnsubs: /* @__PURE__ */ new Map(), skillEventUnsub: null, skillPollTimer: null, permissionUnsub: null, lifecycleUnsub: null, configChangedUnsub: null };
34
+ const state = { agentUnsubs: /* @__PURE__ */ new Map(), skillEventUnsub: null, skillPollTimer: null, permissionUnsub: null, lifecycleUnsub: null, configChangedUnsub: null, stateChangedUnsub: null };
34
35
  state.lifecycleUnsub = sessionManager.onSessionLifecycle((event) => {
35
36
  send(ws, { type: "session.lifecycle", ...event });
36
37
  });
37
38
  state.configChangedUnsub = sessionManager.onConfigChanged((event) => {
38
39
  send(ws, { type: "session.config-changed", ...event });
39
40
  });
41
+ state.stateChangedUnsub = sessionManager.onStateChanged((event) => {
42
+ send(ws, { type: "session.state-changed", ...event });
43
+ });
40
44
  ws.on("message", (raw) => {
41
45
  let msg;
42
46
  try {
@@ -67,6 +71,8 @@ function attachWebSocket(server, sessionManager) {
67
71
  state.lifecycleUnsub = null;
68
72
  state.configChangedUnsub?.();
69
73
  state.configChangedUnsub = null;
74
+ state.stateChangedUnsub?.();
75
+ state.stateChangedUnsub = null;
70
76
  });
71
77
  });
72
78
  return wss;
@@ -85,6 +91,8 @@ function handleMessage(ws, msg, sm, state) {
85
91
  return handleAgentStart(ws, msg, sm);
86
92
  case "agent.send":
87
93
  return handleAgentSend(ws, msg, sm);
94
+ case "agent.resume":
95
+ return handleAgentResume(ws, msg, sm);
88
96
  case "agent.restart":
89
97
  return handleAgentRestart(ws, msg, sm);
90
98
  case "agent.interrupt":
@@ -226,7 +234,7 @@ function handleAgentSend(ws, msg, sm) {
226
234
  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);
227
235
  } catch {
228
236
  }
229
- session.state = "processing";
237
+ sm.updateSessionState(sessionId, "processing");
230
238
  sm.touch(sessionId);
231
239
  if (images?.length) {
232
240
  const content = [
@@ -242,6 +250,43 @@ function handleAgentSend(ws, msg, sm) {
242
250
  }
243
251
  wsReply(ws, msg, { status: "sent" });
244
252
  }
253
+ function handleAgentResume(ws, msg, sm) {
254
+ const sessionId = msg.session ?? "default";
255
+ const session = sm.getOrCreateSession(sessionId);
256
+ if (session.process?.alive) {
257
+ return replyError(ws, msg, "Session already running. Use agent.send instead.");
258
+ }
259
+ const history = buildHistoryFromDb(sessionId);
260
+ if (history.length === 0 && !msg.prompt) {
261
+ return replyError(ws, msg, "No history in DB \u2014 nothing to resume.");
262
+ }
263
+ const providerName = msg.provider ?? session.lastStartConfig?.provider ?? "claude-code";
264
+ const model = msg.model ?? session.lastStartConfig?.model ?? "claude-sonnet-4-6";
265
+ const permissionMode = msg.permissionMode ?? session.lastStartConfig?.permissionMode ?? "acceptEdits";
266
+ const extraArgs = msg.extraArgs ?? session.lastStartConfig?.extraArgs;
267
+ const provider = getProvider(providerName);
268
+ try {
269
+ const proc = provider.spawn({
270
+ cwd: session.cwd,
271
+ prompt: msg.prompt,
272
+ model,
273
+ permissionMode,
274
+ env: { SNA_SESSION_ID: sessionId },
275
+ history: history.length > 0 ? history : void 0,
276
+ extraArgs
277
+ });
278
+ sm.setProcess(sessionId, proc, "resumed");
279
+ sm.saveStartConfig(sessionId, { provider: providerName, model, permissionMode, extraArgs });
280
+ wsReply(ws, msg, {
281
+ status: "resumed",
282
+ provider: providerName,
283
+ sessionId: session.id,
284
+ historyCount: history.length
285
+ });
286
+ } catch (e) {
287
+ replyError(ws, msg, e.message);
288
+ }
289
+ }
245
290
  function handleAgentRestart(ws, msg, sm) {
246
291
  const sessionId = msg.session ?? "default";
247
292
  try {
@@ -298,8 +343,10 @@ function handleAgentKill(ws, msg, sm) {
298
343
  function handleAgentStatus(ws, msg, sm) {
299
344
  const sessionId = msg.session ?? "default";
300
345
  const session = sm.getSession(sessionId);
346
+ const alive = session?.process?.alive ?? false;
301
347
  wsReply(ws, msg, {
302
- alive: session?.process?.alive ?? false,
348
+ alive,
349
+ agentStatus: !alive ? "disconnected" : session?.state === "processing" ? "busy" : "idle",
303
350
  sessionId: session?.process?.sessionId ?? null,
304
351
  ccSessionId: session?.ccSessionId ?? null,
305
352
  eventCount: session?.eventCounter ?? 0,
@@ -1,4 +1,6 @@
1
1
  import http from "http";
2
+ import fs from "fs";
3
+ import path from "path";
2
4
  function ts() {
3
5
  return (/* @__PURE__ */ new Date()).toISOString().slice(11, 23);
4
6
  }
@@ -35,6 +37,24 @@ async function startMockAnthropicServer() {
35
37
  const realText = textBlocks.find((t) => !t.startsWith("<system-reminder>"));
36
38
  userText = realText ?? textBlocks[textBlocks.length - 1] ?? "(no text)";
37
39
  }
40
+ console.log(`[${ts()}] BODY KEYS: ${Object.keys(body).join(", ")}`);
41
+ try {
42
+ const dumpPath = path.join(process.cwd(), ".sna/mock-api-last-request.json");
43
+ fs.writeFileSync(dumpPath, JSON.stringify(body, null, 2));
44
+ console.log(`[${ts()}] FULL BODY dumped to .sna/mock-api-last-request.json`);
45
+ } catch {
46
+ }
47
+ if (body.system) {
48
+ const sysText = typeof body.system === "string" ? body.system : JSON.stringify(body.system);
49
+ console.log(`[${ts()}] SYSTEM PROMPT (${sysText.length} chars): ${sysText.slice(0, 300)}...`);
50
+ if (sysText.includes("\uC720\uB2C8") || sysText.includes("\uCEE4\uD53C") || sysText.includes("\uAE30\uC5B5")) {
51
+ console.log(`[${ts()}] *** HISTORY FOUND IN SYSTEM PROMPT ***`);
52
+ for (const keyword of ["\uC720\uB2C8", "\uCEE4\uD53C", "\uAE30\uC5B5"]) {
53
+ const idx = sysText.indexOf(keyword);
54
+ if (idx >= 0) console.log(`[${ts()}] "${keyword}" at pos ${idx}: ...${sysText.slice(Math.max(0, idx - 50), idx + 80)}...`);
55
+ }
56
+ }
57
+ }
38
58
  console.log(`[${ts()}] REQ model=${body.model} stream=${body.stream} messages=${body.messages?.length} user="${userText.slice(0, 120)}"`);
39
59
  for (let mi = 0; mi < body.messages.length; mi++) {
40
60
  const m = body.messages[mi];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sna-sdk/core",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Skills-Native Application runtime — server, providers, session management, database, and CLI",
5
5
  "type": "module",
6
6
  "bin": {