@sna-sdk/core 0.3.0 → 0.5.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,33 +446,44 @@ 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);
406
455
  }
407
456
  }
457
+ /**
458
+ * Split completed assistant text into chunks and emit assistant_delta events
459
+ * at a fixed rate (~270 chars/sec), followed by the final assistant event.
460
+ *
461
+ * CHUNK_SIZE chars every CHUNK_DELAY_MS → natural TPS feel regardless of length.
462
+ */
463
+ emitTextAsDeltas(text) {
464
+ const CHUNK_SIZE = 4;
465
+ const CHUNK_DELAY_MS = 15;
466
+ let t = 0;
467
+ for (let i = 0; i < text.length; i += CHUNK_SIZE) {
468
+ const chunk = text.slice(i, i + CHUNK_SIZE);
469
+ setTimeout(() => {
470
+ this.emitter.emit("event", {
471
+ type: "assistant_delta",
472
+ delta: chunk,
473
+ index: 0,
474
+ timestamp: Date.now()
475
+ });
476
+ }, t);
477
+ t += CHUNK_DELAY_MS;
478
+ }
479
+ setTimeout(() => {
480
+ this.emitter.emit("event", {
481
+ type: "assistant",
482
+ message: text,
483
+ timestamp: Date.now()
484
+ });
485
+ }, t);
486
+ }
408
487
  get alive() {
409
488
  return this._alive;
410
489
  }
@@ -480,6 +559,7 @@ var ClaudeCodeProcess = class {
480
559
  const content = msg.message?.content;
481
560
  if (!Array.isArray(content)) return null;
482
561
  const events = [];
562
+ const textBlocks = [];
483
563
  for (const block of content) {
484
564
  if (block.type === "thinking") {
485
565
  events.push({
@@ -497,15 +577,17 @@ var ClaudeCodeProcess = class {
497
577
  } else if (block.type === "text") {
498
578
  const text = (block.text ?? "").trim();
499
579
  if (text) {
500
- events.push({ type: "assistant", message: text, timestamp: Date.now() });
580
+ textBlocks.push(text);
501
581
  }
502
582
  }
503
583
  }
504
- if (events.length > 0) {
505
- for (let i = 1; i < events.length; i++) {
506
- this.emitter.emit("event", events[i]);
584
+ if (events.length > 0 || textBlocks.length > 0) {
585
+ for (const e of events) {
586
+ this.emitter.emit("event", e);
587
+ }
588
+ for (const text of textBlocks) {
589
+ this.emitTextAsDeltas(text);
507
590
  }
508
- return events[0];
509
591
  }
510
592
  return null;
511
593
  }
@@ -640,6 +722,14 @@ var ClaudeCodeProvider = class {
640
722
  if (options.permissionMode) {
641
723
  args.push("--permission-mode", options.permissionMode);
642
724
  }
725
+ if (options.history?.length && options.prompt) {
726
+ const result = writeHistoryJsonl(options.history, { cwd: options.cwd });
727
+ if (result) {
728
+ args.push(...result.extraArgs);
729
+ options._historyViaResume = true;
730
+ logger.log("agent", `history via JSONL resume \u2192 ${result.filePath}`);
731
+ }
732
+ }
643
733
  if (extraArgsClean.length > 0) {
644
734
  args.push(...extraArgsClean);
645
735
  }
@@ -682,11 +772,34 @@ function getProvider(name = "claude-code") {
682
772
  return provider2;
683
773
  }
684
774
 
775
+ // src/server/history-builder.ts
776
+ function buildHistoryFromDb(sessionId) {
777
+ const db = getDb();
778
+ const rows = db.prepare(
779
+ `SELECT role, content FROM chat_messages
780
+ WHERE session_id = ? AND role IN ('user', 'assistant')
781
+ ORDER BY id ASC`
782
+ ).all(sessionId);
783
+ if (rows.length === 0) return [];
784
+ const merged = [];
785
+ for (const row of rows) {
786
+ const role = row.role;
787
+ if (!row.content?.trim()) continue;
788
+ const last = merged[merged.length - 1];
789
+ if (last && last.role === role) {
790
+ last.content += "\n\n" + row.content;
791
+ } else {
792
+ merged.push({ role, content: row.content });
793
+ }
794
+ }
795
+ return merged;
796
+ }
797
+
685
798
  // src/server/image-store.ts
686
- import fs4 from "fs";
687
- import path4 from "path";
799
+ import fs5 from "fs";
800
+ import path5 from "path";
688
801
  import { createHash } from "crypto";
689
- var IMAGE_DIR = path4.join(process.cwd(), "data/images");
802
+ var IMAGE_DIR = path5.join(process.cwd(), "data/images");
690
803
  var MIME_TO_EXT = {
691
804
  "image/png": "png",
692
805
  "image/jpeg": "jpg",
@@ -695,23 +808,23 @@ var MIME_TO_EXT = {
695
808
  "image/svg+xml": "svg"
696
809
  };
697
810
  function saveImages(sessionId, images) {
698
- const dir = path4.join(IMAGE_DIR, sessionId);
699
- fs4.mkdirSync(dir, { recursive: true });
811
+ const dir = path5.join(IMAGE_DIR, sessionId);
812
+ fs5.mkdirSync(dir, { recursive: true });
700
813
  return images.map((img) => {
701
814
  const ext = MIME_TO_EXT[img.mimeType] ?? "bin";
702
815
  const hash = createHash("sha256").update(img.base64).digest("hex").slice(0, 12);
703
816
  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"));
817
+ const filePath = path5.join(dir, filename);
818
+ if (!fs5.existsSync(filePath)) {
819
+ fs5.writeFileSync(filePath, Buffer.from(img.base64, "base64"));
707
820
  }
708
821
  return filename;
709
822
  });
710
823
  }
711
824
  function resolveImagePath(sessionId, filename) {
712
825
  if (filename.includes("..") || filename.includes("/")) return null;
713
- const filePath = path4.join(IMAGE_DIR, sessionId, filename);
714
- return fs4.existsSync(filePath) ? filePath : null;
826
+ const filePath = path5.join(IMAGE_DIR, sessionId, filename);
827
+ return fs5.existsSync(filePath) ? filePath : null;
715
828
  }
716
829
 
717
830
  // src/server/routes/agent.ts
@@ -902,7 +1015,13 @@ function createAgentRoutes(sessionManager2) {
902
1015
  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
1016
  } catch {
904
1017
  }
905
- session.state = "processing";
1018
+ sessionManager2.pushEvent(sessionId, {
1019
+ type: "user_message",
1020
+ message: textContent,
1021
+ data: Object.keys(meta).length > 0 ? meta : void 0,
1022
+ timestamp: Date.now()
1023
+ });
1024
+ sessionManager2.updateSessionState(sessionId, "processing");
906
1025
  sessionManager2.touch(sessionId);
907
1026
  if (body.images?.length) {
908
1027
  const content = [
@@ -924,32 +1043,59 @@ function createAgentRoutes(sessionManager2) {
924
1043
  const sessionId = getSessionId(c);
925
1044
  const session = sessionManager2.getOrCreateSession(sessionId);
926
1045
  const sinceParam = c.req.query("since");
927
- let cursor = sinceParam ? parseInt(sinceParam, 10) : session.eventCounter;
1046
+ const sinceCursor = sinceParam ? parseInt(sinceParam, 10) : session.eventCounter;
928
1047
  return streamSSE3(c, async (stream) => {
929
- const POLL_MS = 300;
930
1048
  const KEEPALIVE_MS = 15e3;
931
- let lastSend = Date.now();
932
- while (true) {
1049
+ const signal = c.req.raw.signal;
1050
+ const queue = [];
1051
+ let wakeUp = null;
1052
+ const unsub = sessionManager2.onSessionEvent(sessionId, (eventCursor, event) => {
1053
+ queue.push({ cursor: eventCursor, event });
1054
+ const fn = wakeUp;
1055
+ wakeUp = null;
1056
+ fn?.();
1057
+ });
1058
+ signal.addEventListener("abort", () => {
1059
+ const fn = wakeUp;
1060
+ wakeUp = null;
1061
+ fn?.();
1062
+ });
1063
+ try {
1064
+ let cursor = sinceCursor;
933
1065
  if (cursor < session.eventCounter) {
934
1066
  const startIdx = Math.max(
935
1067
  0,
936
1068
  session.eventBuffer.length - (session.eventCounter - cursor)
937
1069
  );
938
- const newEvents = session.eventBuffer.slice(startIdx);
939
- for (const event of newEvents) {
1070
+ for (const event of session.eventBuffer.slice(startIdx)) {
940
1071
  cursor++;
941
- await stream.writeSSE({
942
- id: String(cursor),
943
- data: JSON.stringify(event)
944
- });
945
- lastSend = Date.now();
1072
+ await stream.writeSSE({ id: String(cursor), data: JSON.stringify(event) });
946
1073
  }
1074
+ } else {
1075
+ cursor = session.eventCounter;
947
1076
  }
948
- if (Date.now() - lastSend > KEEPALIVE_MS) {
949
- await stream.writeSSE({ data: "" });
950
- lastSend = Date.now();
1077
+ while (queue.length > 0 && queue[0].cursor <= cursor) queue.shift();
1078
+ while (!signal.aborted) {
1079
+ if (queue.length === 0) {
1080
+ await Promise.race([
1081
+ new Promise((r) => {
1082
+ wakeUp = r;
1083
+ }),
1084
+ new Promise((r) => setTimeout(r, KEEPALIVE_MS))
1085
+ ]);
1086
+ }
1087
+ if (signal.aborted) break;
1088
+ if (queue.length > 0) {
1089
+ while (queue.length > 0) {
1090
+ const item = queue.shift();
1091
+ await stream.writeSSE({ id: String(item.cursor), data: JSON.stringify(item.event) });
1092
+ }
1093
+ } else {
1094
+ await stream.writeSSE({ data: "" });
1095
+ }
951
1096
  }
952
- await new Promise((r) => setTimeout(r, POLL_MS));
1097
+ } finally {
1098
+ unsub();
953
1099
  }
954
1100
  });
955
1101
  });
@@ -980,6 +1126,46 @@ function createAgentRoutes(sessionManager2) {
980
1126
  return c.json({ status: "error", message: e.message }, 500);
981
1127
  }
982
1128
  });
1129
+ app.post("/resume", async (c) => {
1130
+ const sessionId = getSessionId(c);
1131
+ const body = await c.req.json().catch(() => ({}));
1132
+ const session = sessionManager2.getOrCreateSession(sessionId);
1133
+ if (session.process?.alive) {
1134
+ return c.json({ status: "error", message: "Session already running. Use agent.send instead." }, 400);
1135
+ }
1136
+ const history = buildHistoryFromDb(sessionId);
1137
+ if (history.length === 0 && !body.prompt) {
1138
+ return c.json({ status: "error", message: "No history in DB \u2014 nothing to resume." }, 400);
1139
+ }
1140
+ const providerName = body.provider ?? "claude-code";
1141
+ const model = body.model ?? session.lastStartConfig?.model ?? "claude-sonnet-4-6";
1142
+ const permissionMode2 = body.permissionMode ?? session.lastStartConfig?.permissionMode ?? "acceptEdits";
1143
+ const extraArgs = body.extraArgs ?? session.lastStartConfig?.extraArgs;
1144
+ const provider2 = getProvider(providerName);
1145
+ try {
1146
+ const proc = provider2.spawn({
1147
+ cwd: session.cwd,
1148
+ prompt: body.prompt,
1149
+ model,
1150
+ permissionMode: permissionMode2,
1151
+ env: { SNA_SESSION_ID: sessionId },
1152
+ history: history.length > 0 ? history : void 0,
1153
+ extraArgs
1154
+ });
1155
+ sessionManager2.setProcess(sessionId, proc, "resumed");
1156
+ sessionManager2.saveStartConfig(sessionId, { provider: providerName, model, permissionMode: permissionMode2, extraArgs });
1157
+ logger.log("route", `POST /resume?session=${sessionId} \u2192 resumed (${history.length} history msgs)`);
1158
+ return httpJson(c, "agent.resume", {
1159
+ status: "resumed",
1160
+ provider: providerName,
1161
+ sessionId: session.id,
1162
+ historyCount: history.length
1163
+ });
1164
+ } catch (e) {
1165
+ logger.err("err", `POST /resume?session=${sessionId} \u2192 ${e.message}`);
1166
+ return c.json({ status: "error", message: e.message }, 500);
1167
+ }
1168
+ });
983
1169
  app.post("/interrupt", async (c) => {
984
1170
  const sessionId = getSessionId(c);
985
1171
  const interrupted = sessionManager2.interruptSession(sessionId);
@@ -1007,11 +1193,25 @@ function createAgentRoutes(sessionManager2) {
1007
1193
  app.get("/status", (c) => {
1008
1194
  const sessionId = getSessionId(c);
1009
1195
  const session = sessionManager2.getSession(sessionId);
1196
+ const alive = session?.process?.alive ?? false;
1197
+ let messageCount = 0;
1198
+ let lastMessage = null;
1199
+ try {
1200
+ const db = getDb();
1201
+ const count = db.prepare("SELECT COUNT(*) as c FROM chat_messages WHERE session_id = ?").get(sessionId);
1202
+ messageCount = count?.c ?? 0;
1203
+ const last = db.prepare("SELECT role, content, created_at FROM chat_messages WHERE session_id = ? ORDER BY id DESC LIMIT 1").get(sessionId);
1204
+ if (last) lastMessage = { role: last.role, content: last.content, created_at: last.created_at };
1205
+ } catch {
1206
+ }
1010
1207
  return httpJson(c, "agent.status", {
1011
- alive: session?.process?.alive ?? false,
1208
+ alive,
1209
+ agentStatus: !alive ? "disconnected" : session?.state === "processing" ? "busy" : "idle",
1012
1210
  sessionId: session?.process?.sessionId ?? null,
1013
1211
  ccSessionId: session?.ccSessionId ?? null,
1014
1212
  eventCount: session?.eventCounter ?? 0,
1213
+ messageCount,
1214
+ lastMessage,
1015
1215
  config: session?.lastStartConfig ?? null
1016
1216
  });
1017
1217
  });
@@ -1046,7 +1246,7 @@ function createAgentRoutes(sessionManager2) {
1046
1246
 
1047
1247
  // src/server/routes/chat.ts
1048
1248
  import { Hono as Hono2 } from "hono";
1049
- import fs5 from "fs";
1249
+ import fs6 from "fs";
1050
1250
  function createChatRoutes() {
1051
1251
  const app = new Hono2();
1052
1252
  app.get("/sessions", (c) => {
@@ -1152,7 +1352,7 @@ function createChatRoutes() {
1152
1352
  svg: "image/svg+xml"
1153
1353
  };
1154
1354
  const contentType = mimeMap[ext ?? ""] ?? "application/octet-stream";
1155
- const data = fs5.readFileSync(filePath);
1355
+ const data = fs6.readFileSync(filePath);
1156
1356
  return new Response(data, { headers: { "Content-Type": contentType, "Cache-Control": "public, max-age=31536000, immutable" } });
1157
1357
  });
1158
1358
  return app;
@@ -1171,6 +1371,7 @@ var SessionManager = class {
1171
1371
  this.permissionRequestListeners = /* @__PURE__ */ new Set();
1172
1372
  this.lifecycleListeners = /* @__PURE__ */ new Set();
1173
1373
  this.configChangedListeners = /* @__PURE__ */ new Set();
1374
+ this.stateChangedListeners = /* @__PURE__ */ new Set();
1174
1375
  this.maxSessions = options.maxSessions ?? DEFAULT_MAX_SESSIONS;
1175
1376
  this.restoreFromDb();
1176
1377
  }
@@ -1283,24 +1484,26 @@ var SessionManager = class {
1283
1484
  return this.createSession({ id, ...opts });
1284
1485
  }
1285
1486
  /** Set the agent process for a session. Subscribes to events. */
1286
- setProcess(sessionId, proc) {
1487
+ setProcess(sessionId, proc, lifecycleState) {
1287
1488
  const session = this.sessions.get(sessionId);
1288
1489
  if (!session) throw new Error(`Session "${sessionId}" not found`);
1289
1490
  session.process = proc;
1290
- session.state = "processing";
1491
+ this.setSessionState(sessionId, session, "processing");
1291
1492
  session.lastActivityAt = Date.now();
1292
1493
  proc.on("event", (e) => {
1293
1494
  if (e.type === "init" && e.data?.sessionId && !session.ccSessionId) {
1294
1495
  session.ccSessionId = e.data.sessionId;
1295
1496
  this.persistSession(session);
1296
1497
  }
1297
- session.eventBuffer.push(e);
1298
- session.eventCounter++;
1299
- if (session.eventBuffer.length > MAX_EVENT_BUFFER) {
1300
- session.eventBuffer.splice(0, session.eventBuffer.length - MAX_EVENT_BUFFER);
1498
+ if (e.type !== "assistant_delta") {
1499
+ session.eventBuffer.push(e);
1500
+ if (session.eventBuffer.length > MAX_EVENT_BUFFER) {
1501
+ session.eventBuffer.splice(0, session.eventBuffer.length - MAX_EVENT_BUFFER);
1502
+ }
1301
1503
  }
1504
+ session.eventCounter++;
1302
1505
  if (e.type === "complete" || e.type === "error" || e.type === "interrupted") {
1303
- session.state = "waiting";
1506
+ this.setSessionState(sessionId, session, "waiting");
1304
1507
  }
1305
1508
  this.persistEvent(sessionId, e);
1306
1509
  const listeners = this.eventListeners.get(sessionId);
@@ -1309,14 +1512,14 @@ var SessionManager = class {
1309
1512
  }
1310
1513
  });
1311
1514
  proc.on("exit", (code) => {
1312
- session.state = "idle";
1515
+ this.setSessionState(sessionId, session, "idle");
1313
1516
  this.emitLifecycle({ session: sessionId, state: code != null ? "exited" : "crashed", code });
1314
1517
  });
1315
1518
  proc.on("error", () => {
1316
- session.state = "idle";
1519
+ this.setSessionState(sessionId, session, "idle");
1317
1520
  this.emitLifecycle({ session: sessionId, state: "crashed" });
1318
1521
  });
1319
- this.emitLifecycle({ session: sessionId, state: "started" });
1522
+ this.emitLifecycle({ session: sessionId, state: lifecycleState ?? "started" });
1320
1523
  }
1321
1524
  // ── Event pub/sub (for WebSocket) ─────────────────────────────
1322
1525
  /** Subscribe to real-time events for a session. Returns unsubscribe function. */
@@ -1342,6 +1545,20 @@ var SessionManager = class {
1342
1545
  broadcastSkillEvent(event) {
1343
1546
  for (const cb of this.skillEventListeners) cb(event);
1344
1547
  }
1548
+ /** Push a synthetic event into a session's event stream (for user message broadcast). */
1549
+ pushEvent(sessionId, event) {
1550
+ const session = this.sessions.get(sessionId);
1551
+ if (!session) return;
1552
+ session.eventBuffer.push(event);
1553
+ session.eventCounter++;
1554
+ if (session.eventBuffer.length > MAX_EVENT_BUFFER) {
1555
+ session.eventBuffer.splice(0, session.eventBuffer.length - MAX_EVENT_BUFFER);
1556
+ }
1557
+ const listeners = this.eventListeners.get(sessionId);
1558
+ if (listeners) {
1559
+ for (const cb of listeners) cb(session.eventCounter, event);
1560
+ }
1561
+ }
1345
1562
  // ── Permission pub/sub ────────────────────────────────────────
1346
1563
  /** Subscribe to permission request notifications. Returns unsubscribe function. */
1347
1564
  onPermissionRequest(cb) {
@@ -1366,11 +1583,29 @@ var SessionManager = class {
1366
1583
  emitConfigChanged(sessionId, config) {
1367
1584
  for (const cb of this.configChangedListeners) cb({ session: sessionId, config });
1368
1585
  }
1586
+ // ── Agent status change pub/sub ────────────────────────────────
1587
+ onStateChanged(cb) {
1588
+ this.stateChangedListeners.add(cb);
1589
+ return () => this.stateChangedListeners.delete(cb);
1590
+ }
1591
+ /** Update session state and push agentStatus change to subscribers. */
1592
+ updateSessionState(sessionId, newState) {
1593
+ const session = this.sessions.get(sessionId);
1594
+ if (session) this.setSessionState(sessionId, session, newState);
1595
+ }
1596
+ setSessionState(sessionId, session, newState) {
1597
+ const oldState = session.state;
1598
+ session.state = newState;
1599
+ const newStatus = !session.process?.alive ? "disconnected" : newState === "processing" ? "busy" : "idle";
1600
+ if (oldState !== newState) {
1601
+ for (const cb of this.stateChangedListeners) cb({ session: sessionId, agentStatus: newStatus, state: newState });
1602
+ }
1603
+ }
1369
1604
  // ── Permission management ─────────────────────────────────────
1370
1605
  /** Create a pending permission request. Returns a promise that resolves when approved/denied. */
1371
1606
  createPendingPermission(sessionId, request) {
1372
1607
  const session = this.sessions.get(sessionId);
1373
- if (session) session.state = "permission";
1608
+ if (session) this.setSessionState(sessionId, session, "permission");
1374
1609
  return new Promise((resolve) => {
1375
1610
  const createdAt = Date.now();
1376
1611
  this.pendingPermissions.set(sessionId, { resolve, request, createdAt });
@@ -1390,7 +1625,7 @@ var SessionManager = class {
1390
1625
  pending.resolve(approved);
1391
1626
  this.pendingPermissions.delete(sessionId);
1392
1627
  const session = this.sessions.get(sessionId);
1393
- if (session) session.state = "processing";
1628
+ if (session) this.setSessionState(sessionId, session, "processing");
1394
1629
  return true;
1395
1630
  }
1396
1631
  /** Get a pending permission for a specific session. */
@@ -1442,7 +1677,7 @@ var SessionManager = class {
1442
1677
  const session = this.sessions.get(id);
1443
1678
  if (!session?.process?.alive) return false;
1444
1679
  session.process.interrupt();
1445
- session.state = "waiting";
1680
+ this.setSessionState(id, session, "waiting");
1446
1681
  return true;
1447
1682
  }
1448
1683
  /** Change model. Sends control message if alive, always persists to config. */
@@ -1499,11 +1734,13 @@ var SessionManager = class {
1499
1734
  label: s.label,
1500
1735
  alive: s.process?.alive ?? false,
1501
1736
  state: s.state,
1737
+ agentStatus: !s.process?.alive ? "disconnected" : s.state === "processing" ? "busy" : "idle",
1502
1738
  cwd: s.cwd,
1503
1739
  meta: s.meta,
1504
1740
  config: s.lastStartConfig,
1505
1741
  ccSessionId: s.ccSessionId,
1506
1742
  eventCount: s.eventCounter,
1743
+ ...this.getMessageStats(s.id),
1507
1744
  createdAt: s.createdAt,
1508
1745
  lastActivityAt: s.lastActivityAt
1509
1746
  }));
@@ -1514,6 +1751,23 @@ var SessionManager = class {
1514
1751
  if (session) session.lastActivityAt = Date.now();
1515
1752
  }
1516
1753
  /** Persist an agent event to chat_messages. */
1754
+ getMessageStats(sessionId) {
1755
+ try {
1756
+ const db = getDb();
1757
+ const count = db.prepare(
1758
+ `SELECT COUNT(*) as c FROM chat_messages WHERE session_id = ?`
1759
+ ).get(sessionId);
1760
+ const last = db.prepare(
1761
+ `SELECT role, content, created_at FROM chat_messages WHERE session_id = ? ORDER BY id DESC LIMIT 1`
1762
+ ).get(sessionId);
1763
+ return {
1764
+ messageCount: count.c,
1765
+ lastMessage: last ? { role: last.role, content: last.content, created_at: last.created_at } : null
1766
+ };
1767
+ } catch {
1768
+ return { messageCount: 0, lastMessage: null };
1769
+ }
1770
+ }
1517
1771
  persistEvent(sessionId, e) {
1518
1772
  try {
1519
1773
  const db = getDb();
@@ -1586,13 +1840,16 @@ function attachWebSocket(server2, sessionManager2) {
1586
1840
  });
1587
1841
  wss.on("connection", (ws) => {
1588
1842
  logger.log("ws", "client connected");
1589
- const state = { agentUnsubs: /* @__PURE__ */ new Map(), skillEventUnsub: null, skillPollTimer: null, permissionUnsub: null, lifecycleUnsub: null, configChangedUnsub: null };
1843
+ const state = { agentUnsubs: /* @__PURE__ */ new Map(), skillEventUnsub: null, skillPollTimer: null, permissionUnsub: null, lifecycleUnsub: null, configChangedUnsub: null, stateChangedUnsub: null };
1590
1844
  state.lifecycleUnsub = sessionManager2.onSessionLifecycle((event) => {
1591
1845
  send(ws, { type: "session.lifecycle", ...event });
1592
1846
  });
1593
1847
  state.configChangedUnsub = sessionManager2.onConfigChanged((event) => {
1594
1848
  send(ws, { type: "session.config-changed", ...event });
1595
1849
  });
1850
+ state.stateChangedUnsub = sessionManager2.onStateChanged((event) => {
1851
+ send(ws, { type: "session.state-changed", ...event });
1852
+ });
1596
1853
  ws.on("message", (raw) => {
1597
1854
  let msg;
1598
1855
  try {
@@ -1623,6 +1880,8 @@ function attachWebSocket(server2, sessionManager2) {
1623
1880
  state.lifecycleUnsub = null;
1624
1881
  state.configChangedUnsub?.();
1625
1882
  state.configChangedUnsub = null;
1883
+ state.stateChangedUnsub?.();
1884
+ state.stateChangedUnsub = null;
1626
1885
  });
1627
1886
  });
1628
1887
  return wss;
@@ -1641,6 +1900,8 @@ function handleMessage(ws, msg, sm, state) {
1641
1900
  return handleAgentStart(ws, msg, sm);
1642
1901
  case "agent.send":
1643
1902
  return handleAgentSend(ws, msg, sm);
1903
+ case "agent.resume":
1904
+ return handleAgentResume(ws, msg, sm);
1644
1905
  case "agent.restart":
1645
1906
  return handleAgentRestart(ws, msg, sm);
1646
1907
  case "agent.interrupt":
@@ -1782,7 +2043,13 @@ function handleAgentSend(ws, msg, sm) {
1782
2043
  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
2044
  } catch {
1784
2045
  }
1785
- session.state = "processing";
2046
+ sm.pushEvent(sessionId, {
2047
+ type: "user_message",
2048
+ message: textContent,
2049
+ data: Object.keys(meta).length > 0 ? meta : void 0,
2050
+ timestamp: Date.now()
2051
+ });
2052
+ sm.updateSessionState(sessionId, "processing");
1786
2053
  sm.touch(sessionId);
1787
2054
  if (images?.length) {
1788
2055
  const content = [
@@ -1798,6 +2065,43 @@ function handleAgentSend(ws, msg, sm) {
1798
2065
  }
1799
2066
  wsReply(ws, msg, { status: "sent" });
1800
2067
  }
2068
+ function handleAgentResume(ws, msg, sm) {
2069
+ const sessionId = msg.session ?? "default";
2070
+ const session = sm.getOrCreateSession(sessionId);
2071
+ if (session.process?.alive) {
2072
+ return replyError(ws, msg, "Session already running. Use agent.send instead.");
2073
+ }
2074
+ const history = buildHistoryFromDb(sessionId);
2075
+ if (history.length === 0 && !msg.prompt) {
2076
+ return replyError(ws, msg, "No history in DB \u2014 nothing to resume.");
2077
+ }
2078
+ const providerName = msg.provider ?? session.lastStartConfig?.provider ?? "claude-code";
2079
+ const model = msg.model ?? session.lastStartConfig?.model ?? "claude-sonnet-4-6";
2080
+ const permissionMode2 = msg.permissionMode ?? session.lastStartConfig?.permissionMode ?? "acceptEdits";
2081
+ const extraArgs = msg.extraArgs ?? session.lastStartConfig?.extraArgs;
2082
+ const provider2 = getProvider(providerName);
2083
+ try {
2084
+ const proc = provider2.spawn({
2085
+ cwd: session.cwd,
2086
+ prompt: msg.prompt,
2087
+ model,
2088
+ permissionMode: permissionMode2,
2089
+ env: { SNA_SESSION_ID: sessionId },
2090
+ history: history.length > 0 ? history : void 0,
2091
+ extraArgs
2092
+ });
2093
+ sm.setProcess(sessionId, proc, "resumed");
2094
+ sm.saveStartConfig(sessionId, { provider: providerName, model, permissionMode: permissionMode2, extraArgs });
2095
+ wsReply(ws, msg, {
2096
+ status: "resumed",
2097
+ provider: providerName,
2098
+ sessionId: session.id,
2099
+ historyCount: history.length
2100
+ });
2101
+ } catch (e) {
2102
+ replyError(ws, msg, e.message);
2103
+ }
2104
+ }
1801
2105
  function handleAgentRestart(ws, msg, sm) {
1802
2106
  const sessionId = msg.session ?? "default";
1803
2107
  try {
@@ -1854,11 +2158,25 @@ function handleAgentKill(ws, msg, sm) {
1854
2158
  function handleAgentStatus(ws, msg, sm) {
1855
2159
  const sessionId = msg.session ?? "default";
1856
2160
  const session = sm.getSession(sessionId);
2161
+ const alive = session?.process?.alive ?? false;
2162
+ let messageCount = 0;
2163
+ let lastMessage = null;
2164
+ try {
2165
+ const db = getDb();
2166
+ const count = db.prepare("SELECT COUNT(*) as c FROM chat_messages WHERE session_id = ?").get(sessionId);
2167
+ messageCount = count?.c ?? 0;
2168
+ const last = db.prepare("SELECT role, content, created_at FROM chat_messages WHERE session_id = ? ORDER BY id DESC LIMIT 1").get(sessionId);
2169
+ if (last) lastMessage = { role: last.role, content: last.content, created_at: last.created_at };
2170
+ } catch {
2171
+ }
1857
2172
  wsReply(ws, msg, {
1858
- alive: session?.process?.alive ?? false,
2173
+ alive,
2174
+ agentStatus: !alive ? "disconnected" : session?.state === "processing" ? "busy" : "idle",
1859
2175
  sessionId: session?.process?.sessionId ?? null,
1860
2176
  ccSessionId: session?.ccSessionId ?? null,
1861
2177
  eventCount: session?.eventCounter ?? 0,
2178
+ messageCount,
2179
+ lastMessage,
1862
2180
  config: session?.lastStartConfig ?? null
1863
2181
  });
1864
2182
  }
@@ -1875,7 +2193,38 @@ function handleAgentSubscribe(ws, msg, sm, state) {
1875
2193
  const sessionId = msg.session ?? "default";
1876
2194
  const session = sm.getOrCreateSession(sessionId);
1877
2195
  state.agentUnsubs.get(sessionId)?.();
1878
- let cursor = typeof msg.since === "number" ? msg.since : session.eventCounter;
2196
+ const includeHistory = msg.since === 0 || msg.includeHistory === true;
2197
+ let cursor = 0;
2198
+ if (includeHistory) {
2199
+ try {
2200
+ const db = getDb();
2201
+ const rows = db.prepare(
2202
+ `SELECT role, content, meta, created_at FROM chat_messages
2203
+ WHERE session_id = ? ORDER BY id ASC`
2204
+ ).all(sessionId);
2205
+ for (const row of rows) {
2206
+ cursor++;
2207
+ const eventType = row.role === "user" ? "user_message" : row.role === "assistant" ? "assistant" : row.role === "thinking" ? "thinking" : row.role === "tool" ? "tool_use" : row.role === "tool_result" ? "tool_result" : row.role === "error" ? "error" : null;
2208
+ if (!eventType) continue;
2209
+ const meta = row.meta ? JSON.parse(row.meta) : void 0;
2210
+ send(ws, {
2211
+ type: "agent.event",
2212
+ session: sessionId,
2213
+ cursor,
2214
+ isHistory: true,
2215
+ event: {
2216
+ type: eventType,
2217
+ message: row.content,
2218
+ data: meta,
2219
+ timestamp: new Date(row.created_at).getTime()
2220
+ }
2221
+ });
2222
+ }
2223
+ } catch {
2224
+ }
2225
+ }
2226
+ const bufferStart = typeof msg.since === "number" && msg.since > 0 ? msg.since : session.eventCounter;
2227
+ if (!includeHistory) cursor = bufferStart;
1879
2228
  if (cursor < session.eventCounter) {
1880
2229
  const startIdx = Math.max(0, session.eventBuffer.length - (session.eventCounter - cursor));
1881
2230
  const events = session.eventBuffer.slice(startIdx);
@@ -1883,6 +2232,8 @@ function handleAgentSubscribe(ws, msg, sm, state) {
1883
2232
  cursor++;
1884
2233
  send(ws, { type: "agent.event", session: sessionId, cursor, event });
1885
2234
  }
2235
+ } else {
2236
+ cursor = session.eventCounter;
1886
2237
  }
1887
2238
  const unsub = sm.onSessionEvent(sessionId, (eventCursor, event) => {
1888
2239
  send(ws, { type: "agent.event", session: sessionId, cursor: eventCursor, event });
@@ -1995,10 +2346,14 @@ function handlePermissionPending(ws, msg, sm) {
1995
2346
  }
1996
2347
  function handlePermissionSubscribe(ws, msg, sm, state) {
1997
2348
  state.permissionUnsub?.();
2349
+ const pending = sm.getAllPendingPermissions();
2350
+ for (const p of pending) {
2351
+ send(ws, { type: "permission.request", session: p.sessionId, request: p.request, createdAt: p.createdAt, isHistory: true });
2352
+ }
1998
2353
  state.permissionUnsub = sm.onPermissionRequest((sessionId, request, createdAt) => {
1999
2354
  send(ws, { type: "permission.request", session: sessionId, request, createdAt });
2000
2355
  });
2001
- reply(ws, msg, {});
2356
+ reply(ws, msg, { pendingCount: pending.length });
2002
2357
  }
2003
2358
  function handlePermissionUnsubscribe(ws, msg, state) {
2004
2359
  state.permissionUnsub?.();
@@ -2135,8 +2490,8 @@ var methodColor = {
2135
2490
  root.use("*", async (c, next) => {
2136
2491
  const m = c.req.method;
2137
2492
  const colorFn = methodColor[m] ?? chalk2.white;
2138
- const path5 = new URL(c.req.url).pathname;
2139
- logger.log("req", `${colorFn(m.padEnd(6))} ${path5}`);
2493
+ const path6 = new URL(c.req.url).pathname;
2494
+ logger.log("req", `${colorFn(m.padEnd(6))} ${path6}`);
2140
2495
  await next();
2141
2496
  });
2142
2497
  var sessionManager = new SessionManager({ maxSessions });