@sna-sdk/core 0.4.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.
package/README.md CHANGED
@@ -9,6 +9,7 @@ Server runtime for [Skills-Native Applications](https://github.com/neuradex/sna)
9
9
  - **SQLite database** — schema and `getDb()` for `skill_events`, `chat_sessions`, `chat_messages`
10
10
  - **Hono server factory** — `createSnaApp()` with events, emit, agent, chat, and run routes
11
11
  - **WebSocket API** — `attachWebSocket()` wrapping all HTTP routes over a single WS connection
12
+ - **History management** — `agent.resume` auto-loads DB history, `agent.subscribe({ since: 0 })` unified history+realtime channel
12
13
  - **One-shot execution** — `POST /agent/run-once` for single-request LLM calls
13
14
  - **CLI** — `sna up/down/status`, `sna dispatch`, `sna gen client`, `sna tu` (mock API testing)
14
15
  - **Agent providers** — Claude Code and Codex process management
@@ -88,6 +88,36 @@ class ClaudeCodeProcess {
88
88
  this.send(options.prompt);
89
89
  }
90
90
  }
91
+ /**
92
+ * Split completed assistant text into chunks and emit assistant_delta events
93
+ * at a fixed rate (~270 chars/sec), followed by the final assistant event.
94
+ *
95
+ * CHUNK_SIZE chars every CHUNK_DELAY_MS → natural TPS feel regardless of length.
96
+ */
97
+ emitTextAsDeltas(text) {
98
+ const CHUNK_SIZE = 4;
99
+ const CHUNK_DELAY_MS = 15;
100
+ let t = 0;
101
+ for (let i = 0; i < text.length; i += CHUNK_SIZE) {
102
+ const chunk = text.slice(i, i + CHUNK_SIZE);
103
+ setTimeout(() => {
104
+ this.emitter.emit("event", {
105
+ type: "assistant_delta",
106
+ delta: chunk,
107
+ index: 0,
108
+ timestamp: Date.now()
109
+ });
110
+ }, t);
111
+ t += CHUNK_DELAY_MS;
112
+ }
113
+ setTimeout(() => {
114
+ this.emitter.emit("event", {
115
+ type: "assistant",
116
+ message: text,
117
+ timestamp: Date.now()
118
+ });
119
+ }, t);
120
+ }
91
121
  get alive() {
92
122
  return this._alive;
93
123
  }
@@ -163,6 +193,7 @@ class ClaudeCodeProcess {
163
193
  const content = msg.message?.content;
164
194
  if (!Array.isArray(content)) return null;
165
195
  const events = [];
196
+ const textBlocks = [];
166
197
  for (const block of content) {
167
198
  if (block.type === "thinking") {
168
199
  events.push({
@@ -180,15 +211,17 @@ class ClaudeCodeProcess {
180
211
  } else if (block.type === "text") {
181
212
  const text = (block.text ?? "").trim();
182
213
  if (text) {
183
- events.push({ type: "assistant", message: text, timestamp: Date.now() });
214
+ textBlocks.push(text);
184
215
  }
185
216
  }
186
217
  }
187
- if (events.length > 0) {
188
- for (let i = 1; i < events.length; i++) {
189
- this.emitter.emit("event", events[i]);
218
+ if (events.length > 0 || textBlocks.length > 0) {
219
+ for (const e of events) {
220
+ this.emitter.emit("event", e);
221
+ }
222
+ for (const text of textBlocks) {
223
+ this.emitTextAsDeltas(text);
190
224
  }
191
- return events[0];
192
225
  }
193
226
  return null;
194
227
  }
@@ -5,9 +5,13 @@
5
5
  * Codex JSONL, etc.) into these common types.
6
6
  */
7
7
  interface AgentEvent {
8
- type: "init" | "thinking" | "text_delta" | "assistant" | "tool_use" | "tool_result" | "permission_needed" | "milestone" | "interrupted" | "error" | "complete";
8
+ type: "init" | "thinking" | "text_delta" | "assistant_delta" | "assistant" | "tool_use" | "tool_result" | "permission_needed" | "milestone" | "user_message" | "interrupted" | "error" | "complete";
9
9
  message?: string;
10
10
  data?: Record<string, unknown>;
11
+ /** Streaming text delta (for assistant_delta events only) */
12
+ delta?: string;
13
+ /** Content block index (for assistant_delta events only) */
14
+ index?: number;
11
15
  timestamp: number;
12
16
  }
13
17
  /**
@@ -56,6 +56,12 @@ interface ApiResponses {
56
56
  sessionId: string | null;
57
57
  ccSessionId: string | null;
58
58
  eventCount: number;
59
+ messageCount: number;
60
+ lastMessage: {
61
+ role: string;
62
+ content: string;
63
+ created_at: string;
64
+ } | null;
59
65
  config: {
60
66
  provider: string;
61
67
  model: string;
@@ -195,6 +195,12 @@ function createAgentRoutes(sessionManager) {
195
195
  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);
196
196
  } catch {
197
197
  }
198
+ sessionManager.pushEvent(sessionId, {
199
+ type: "user_message",
200
+ message: textContent,
201
+ data: Object.keys(meta).length > 0 ? meta : void 0,
202
+ timestamp: Date.now()
203
+ });
198
204
  sessionManager.updateSessionState(sessionId, "processing");
199
205
  sessionManager.touch(sessionId);
200
206
  if (body.images?.length) {
@@ -217,32 +223,59 @@ function createAgentRoutes(sessionManager) {
217
223
  const sessionId = getSessionId(c);
218
224
  const session = sessionManager.getOrCreateSession(sessionId);
219
225
  const sinceParam = c.req.query("since");
220
- let cursor = sinceParam ? parseInt(sinceParam, 10) : session.eventCounter;
226
+ const sinceCursor = sinceParam ? parseInt(sinceParam, 10) : session.eventCounter;
221
227
  return streamSSE(c, async (stream) => {
222
- const POLL_MS = 300;
223
228
  const KEEPALIVE_MS = 15e3;
224
- let lastSend = Date.now();
225
- while (true) {
229
+ const signal = c.req.raw.signal;
230
+ const queue = [];
231
+ let wakeUp = null;
232
+ const unsub = sessionManager.onSessionEvent(sessionId, (eventCursor, event) => {
233
+ queue.push({ cursor: eventCursor, event });
234
+ const fn = wakeUp;
235
+ wakeUp = null;
236
+ fn?.();
237
+ });
238
+ signal.addEventListener("abort", () => {
239
+ const fn = wakeUp;
240
+ wakeUp = null;
241
+ fn?.();
242
+ });
243
+ try {
244
+ let cursor = sinceCursor;
226
245
  if (cursor < session.eventCounter) {
227
246
  const startIdx = Math.max(
228
247
  0,
229
248
  session.eventBuffer.length - (session.eventCounter - cursor)
230
249
  );
231
- const newEvents = session.eventBuffer.slice(startIdx);
232
- for (const event of newEvents) {
250
+ for (const event of session.eventBuffer.slice(startIdx)) {
233
251
  cursor++;
234
- await stream.writeSSE({
235
- id: String(cursor),
236
- data: JSON.stringify(event)
237
- });
238
- lastSend = Date.now();
252
+ await stream.writeSSE({ id: String(cursor), data: JSON.stringify(event) });
239
253
  }
254
+ } else {
255
+ cursor = session.eventCounter;
240
256
  }
241
- if (Date.now() - lastSend > KEEPALIVE_MS) {
242
- await stream.writeSSE({ data: "" });
243
- lastSend = Date.now();
257
+ while (queue.length > 0 && queue[0].cursor <= cursor) queue.shift();
258
+ while (!signal.aborted) {
259
+ if (queue.length === 0) {
260
+ await Promise.race([
261
+ new Promise((r) => {
262
+ wakeUp = r;
263
+ }),
264
+ new Promise((r) => setTimeout(r, KEEPALIVE_MS))
265
+ ]);
266
+ }
267
+ if (signal.aborted) break;
268
+ if (queue.length > 0) {
269
+ while (queue.length > 0) {
270
+ const item = queue.shift();
271
+ await stream.writeSSE({ id: String(item.cursor), data: JSON.stringify(item.event) });
272
+ }
273
+ } else {
274
+ await stream.writeSSE({ data: "" });
275
+ }
244
276
  }
245
- await new Promise((r) => setTimeout(r, POLL_MS));
277
+ } finally {
278
+ unsub();
246
279
  }
247
280
  });
248
281
  });
@@ -341,12 +374,24 @@ function createAgentRoutes(sessionManager) {
341
374
  const sessionId = getSessionId(c);
342
375
  const session = sessionManager.getSession(sessionId);
343
376
  const alive = session?.process?.alive ?? false;
377
+ let messageCount = 0;
378
+ let lastMessage = null;
379
+ try {
380
+ const db = getDb();
381
+ const count = db.prepare("SELECT COUNT(*) as c FROM chat_messages WHERE session_id = ?").get(sessionId);
382
+ messageCount = count?.c ?? 0;
383
+ const last = db.prepare("SELECT role, content, created_at FROM chat_messages WHERE session_id = ? ORDER BY id DESC LIMIT 1").get(sessionId);
384
+ if (last) lastMessage = { role: last.role, content: last.content, created_at: last.created_at };
385
+ } catch {
386
+ }
344
387
  return httpJson(c, "agent.status", {
345
388
  alive,
346
389
  agentStatus: !alive ? "disconnected" : session?.state === "processing" ? "busy" : "idle",
347
390
  sessionId: session?.process?.sessionId ?? null,
348
391
  ccSessionId: session?.ccSessionId ?? null,
349
392
  eventCount: session?.eventCounter ?? 0,
393
+ messageCount,
394
+ lastMessage,
350
395
  config: session?.lastStartConfig ?? null
351
396
  });
352
397
  });
@@ -41,6 +41,12 @@ interface SessionInfo {
41
41
  config: StartConfig | null;
42
42
  ccSessionId: string | null;
43
43
  eventCount: number;
44
+ messageCount: number;
45
+ lastMessage: {
46
+ role: string;
47
+ content: string;
48
+ created_at: string;
49
+ } | null;
44
50
  createdAt: number;
45
51
  lastActivityAt: number;
46
52
  }
@@ -94,6 +100,8 @@ declare class SessionManager {
94
100
  onSkillEvent(cb: (event: Record<string, unknown>) => void): () => void;
95
101
  /** Broadcast a skill event to all subscribers (called after DB insert). */
96
102
  broadcastSkillEvent(event: Record<string, unknown>): void;
103
+ /** Push a synthetic event into a session's event stream (for user message broadcast). */
104
+ pushEvent(sessionId: string, event: AgentEvent): void;
97
105
  /** Subscribe to permission request notifications. Returns unsubscribe function. */
98
106
  onPermissionRequest(cb: (sessionId: string, request: Record<string, unknown>, createdAt: number) => void): () => void;
99
107
  /** Subscribe to session lifecycle events (started/killed/exited/crashed). Returns unsubscribe function. */
@@ -147,6 +155,7 @@ declare class SessionManager {
147
155
  /** Touch a session's lastActivityAt timestamp. */
148
156
  touch(id: string): void;
149
157
  /** Persist an agent event to chat_messages. */
158
+ private getMessageStats;
150
159
  private persistEvent;
151
160
  /** Kill all sessions. Used during shutdown. */
152
161
  killAll(): void;
@@ -135,11 +135,13 @@ class SessionManager {
135
135
  session.ccSessionId = e.data.sessionId;
136
136
  this.persistSession(session);
137
137
  }
138
- session.eventBuffer.push(e);
139
- session.eventCounter++;
140
- if (session.eventBuffer.length > MAX_EVENT_BUFFER) {
141
- session.eventBuffer.splice(0, session.eventBuffer.length - MAX_EVENT_BUFFER);
138
+ if (e.type !== "assistant_delta") {
139
+ session.eventBuffer.push(e);
140
+ if (session.eventBuffer.length > MAX_EVENT_BUFFER) {
141
+ session.eventBuffer.splice(0, session.eventBuffer.length - MAX_EVENT_BUFFER);
142
+ }
142
143
  }
144
+ session.eventCounter++;
143
145
  if (e.type === "complete" || e.type === "error" || e.type === "interrupted") {
144
146
  this.setSessionState(sessionId, session, "waiting");
145
147
  }
@@ -183,6 +185,20 @@ class SessionManager {
183
185
  broadcastSkillEvent(event) {
184
186
  for (const cb of this.skillEventListeners) cb(event);
185
187
  }
188
+ /** Push a synthetic event into a session's event stream (for user message broadcast). */
189
+ pushEvent(sessionId, event) {
190
+ const session = this.sessions.get(sessionId);
191
+ if (!session) return;
192
+ session.eventBuffer.push(event);
193
+ session.eventCounter++;
194
+ if (session.eventBuffer.length > MAX_EVENT_BUFFER) {
195
+ session.eventBuffer.splice(0, session.eventBuffer.length - MAX_EVENT_BUFFER);
196
+ }
197
+ const listeners = this.eventListeners.get(sessionId);
198
+ if (listeners) {
199
+ for (const cb of listeners) cb(session.eventCounter, event);
200
+ }
201
+ }
186
202
  // ── Permission pub/sub ────────────────────────────────────────
187
203
  /** Subscribe to permission request notifications. Returns unsubscribe function. */
188
204
  onPermissionRequest(cb) {
@@ -364,6 +380,7 @@ class SessionManager {
364
380
  config: s.lastStartConfig,
365
381
  ccSessionId: s.ccSessionId,
366
382
  eventCount: s.eventCounter,
383
+ ...this.getMessageStats(s.id),
367
384
  createdAt: s.createdAt,
368
385
  lastActivityAt: s.lastActivityAt
369
386
  }));
@@ -374,6 +391,23 @@ class SessionManager {
374
391
  if (session) session.lastActivityAt = Date.now();
375
392
  }
376
393
  /** Persist an agent event to chat_messages. */
394
+ getMessageStats(sessionId) {
395
+ try {
396
+ const db = getDb();
397
+ const count = db.prepare(
398
+ `SELECT COUNT(*) as c FROM chat_messages WHERE session_id = ?`
399
+ ).get(sessionId);
400
+ const last = db.prepare(
401
+ `SELECT role, content, created_at FROM chat_messages WHERE session_id = ? ORDER BY id DESC LIMIT 1`
402
+ ).get(sessionId);
403
+ return {
404
+ messageCount: count.c,
405
+ lastMessage: last ? { role: last.role, content: last.content, created_at: last.created_at } : null
406
+ };
407
+ } catch {
408
+ return { messageCount: 0, lastMessage: null };
409
+ }
410
+ }
377
411
  persistEvent(sessionId, e) {
378
412
  try {
379
413
  const db = getDb();
@@ -454,6 +454,36 @@ var ClaudeCodeProcess = class {
454
454
  this.send(options.prompt);
455
455
  }
456
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
+ }
457
487
  get alive() {
458
488
  return this._alive;
459
489
  }
@@ -529,6 +559,7 @@ var ClaudeCodeProcess = class {
529
559
  const content = msg.message?.content;
530
560
  if (!Array.isArray(content)) return null;
531
561
  const events = [];
562
+ const textBlocks = [];
532
563
  for (const block of content) {
533
564
  if (block.type === "thinking") {
534
565
  events.push({
@@ -546,15 +577,17 @@ var ClaudeCodeProcess = class {
546
577
  } else if (block.type === "text") {
547
578
  const text = (block.text ?? "").trim();
548
579
  if (text) {
549
- events.push({ type: "assistant", message: text, timestamp: Date.now() });
580
+ textBlocks.push(text);
550
581
  }
551
582
  }
552
583
  }
553
- if (events.length > 0) {
554
- for (let i = 1; i < events.length; i++) {
555
- 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);
556
590
  }
557
- return events[0];
558
591
  }
559
592
  return null;
560
593
  }
@@ -982,6 +1015,12 @@ function createAgentRoutes(sessionManager2) {
982
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);
983
1016
  } catch {
984
1017
  }
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
+ });
985
1024
  sessionManager2.updateSessionState(sessionId, "processing");
986
1025
  sessionManager2.touch(sessionId);
987
1026
  if (body.images?.length) {
@@ -1004,32 +1043,59 @@ function createAgentRoutes(sessionManager2) {
1004
1043
  const sessionId = getSessionId(c);
1005
1044
  const session = sessionManager2.getOrCreateSession(sessionId);
1006
1045
  const sinceParam = c.req.query("since");
1007
- let cursor = sinceParam ? parseInt(sinceParam, 10) : session.eventCounter;
1046
+ const sinceCursor = sinceParam ? parseInt(sinceParam, 10) : session.eventCounter;
1008
1047
  return streamSSE3(c, async (stream) => {
1009
- const POLL_MS = 300;
1010
1048
  const KEEPALIVE_MS = 15e3;
1011
- let lastSend = Date.now();
1012
- 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;
1013
1065
  if (cursor < session.eventCounter) {
1014
1066
  const startIdx = Math.max(
1015
1067
  0,
1016
1068
  session.eventBuffer.length - (session.eventCounter - cursor)
1017
1069
  );
1018
- const newEvents = session.eventBuffer.slice(startIdx);
1019
- for (const event of newEvents) {
1070
+ for (const event of session.eventBuffer.slice(startIdx)) {
1020
1071
  cursor++;
1021
- await stream.writeSSE({
1022
- id: String(cursor),
1023
- data: JSON.stringify(event)
1024
- });
1025
- lastSend = Date.now();
1072
+ await stream.writeSSE({ id: String(cursor), data: JSON.stringify(event) });
1026
1073
  }
1074
+ } else {
1075
+ cursor = session.eventCounter;
1027
1076
  }
1028
- if (Date.now() - lastSend > KEEPALIVE_MS) {
1029
- await stream.writeSSE({ data: "" });
1030
- 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
+ }
1031
1096
  }
1032
- await new Promise((r) => setTimeout(r, POLL_MS));
1097
+ } finally {
1098
+ unsub();
1033
1099
  }
1034
1100
  });
1035
1101
  });
@@ -1128,12 +1194,24 @@ function createAgentRoutes(sessionManager2) {
1128
1194
  const sessionId = getSessionId(c);
1129
1195
  const session = sessionManager2.getSession(sessionId);
1130
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
+ }
1131
1207
  return httpJson(c, "agent.status", {
1132
1208
  alive,
1133
1209
  agentStatus: !alive ? "disconnected" : session?.state === "processing" ? "busy" : "idle",
1134
1210
  sessionId: session?.process?.sessionId ?? null,
1135
1211
  ccSessionId: session?.ccSessionId ?? null,
1136
1212
  eventCount: session?.eventCounter ?? 0,
1213
+ messageCount,
1214
+ lastMessage,
1137
1215
  config: session?.lastStartConfig ?? null
1138
1216
  });
1139
1217
  });
@@ -1417,11 +1495,13 @@ var SessionManager = class {
1417
1495
  session.ccSessionId = e.data.sessionId;
1418
1496
  this.persistSession(session);
1419
1497
  }
1420
- session.eventBuffer.push(e);
1421
- session.eventCounter++;
1422
- if (session.eventBuffer.length > MAX_EVENT_BUFFER) {
1423
- 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
+ }
1424
1503
  }
1504
+ session.eventCounter++;
1425
1505
  if (e.type === "complete" || e.type === "error" || e.type === "interrupted") {
1426
1506
  this.setSessionState(sessionId, session, "waiting");
1427
1507
  }
@@ -1465,6 +1545,20 @@ var SessionManager = class {
1465
1545
  broadcastSkillEvent(event) {
1466
1546
  for (const cb of this.skillEventListeners) cb(event);
1467
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
+ }
1468
1562
  // ── Permission pub/sub ────────────────────────────────────────
1469
1563
  /** Subscribe to permission request notifications. Returns unsubscribe function. */
1470
1564
  onPermissionRequest(cb) {
@@ -1646,6 +1740,7 @@ var SessionManager = class {
1646
1740
  config: s.lastStartConfig,
1647
1741
  ccSessionId: s.ccSessionId,
1648
1742
  eventCount: s.eventCounter,
1743
+ ...this.getMessageStats(s.id),
1649
1744
  createdAt: s.createdAt,
1650
1745
  lastActivityAt: s.lastActivityAt
1651
1746
  }));
@@ -1656,6 +1751,23 @@ var SessionManager = class {
1656
1751
  if (session) session.lastActivityAt = Date.now();
1657
1752
  }
1658
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
+ }
1659
1771
  persistEvent(sessionId, e) {
1660
1772
  try {
1661
1773
  const db = getDb();
@@ -1931,6 +2043,12 @@ function handleAgentSend(ws, msg, sm) {
1931
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);
1932
2044
  } catch {
1933
2045
  }
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
+ });
1934
2052
  sm.updateSessionState(sessionId, "processing");
1935
2053
  sm.touch(sessionId);
1936
2054
  if (images?.length) {
@@ -2041,12 +2159,24 @@ function handleAgentStatus(ws, msg, sm) {
2041
2159
  const sessionId = msg.session ?? "default";
2042
2160
  const session = sm.getSession(sessionId);
2043
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
+ }
2044
2172
  wsReply(ws, msg, {
2045
2173
  alive,
2046
2174
  agentStatus: !alive ? "disconnected" : session?.state === "processing" ? "busy" : "idle",
2047
2175
  sessionId: session?.process?.sessionId ?? null,
2048
2176
  ccSessionId: session?.ccSessionId ?? null,
2049
2177
  eventCount: session?.eventCounter ?? 0,
2178
+ messageCount,
2179
+ lastMessage,
2050
2180
  config: session?.lastStartConfig ?? null
2051
2181
  });
2052
2182
  }
@@ -2063,7 +2193,38 @@ function handleAgentSubscribe(ws, msg, sm, state) {
2063
2193
  const sessionId = msg.session ?? "default";
2064
2194
  const session = sm.getOrCreateSession(sessionId);
2065
2195
  state.agentUnsubs.get(sessionId)?.();
2066
- 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;
2067
2228
  if (cursor < session.eventCounter) {
2068
2229
  const startIdx = Math.max(0, session.eventBuffer.length - (session.eventCounter - cursor));
2069
2230
  const events = session.eventBuffer.slice(startIdx);
@@ -2071,6 +2232,8 @@ function handleAgentSubscribe(ws, msg, sm, state) {
2071
2232
  cursor++;
2072
2233
  send(ws, { type: "agent.event", session: sessionId, cursor, event });
2073
2234
  }
2235
+ } else {
2236
+ cursor = session.eventCounter;
2074
2237
  }
2075
2238
  const unsub = sm.onSessionEvent(sessionId, (eventCursor, event) => {
2076
2239
  send(ws, { type: "agent.event", session: sessionId, cursor: eventCursor, event });
@@ -2183,10 +2346,14 @@ function handlePermissionPending(ws, msg, sm) {
2183
2346
  }
2184
2347
  function handlePermissionSubscribe(ws, msg, sm, state) {
2185
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
+ }
2186
2353
  state.permissionUnsub = sm.onPermissionRequest((sessionId, request, createdAt) => {
2187
2354
  send(ws, { type: "permission.request", session: sessionId, request, createdAt });
2188
2355
  });
2189
- reply(ws, msg, {});
2356
+ reply(ws, msg, { pendingCount: pending.length });
2190
2357
  }
2191
2358
  function handlePermissionUnsubscribe(ws, msg, state) {
2192
2359
  state.permissionUnsub?.();
package/dist/server/ws.js CHANGED
@@ -234,6 +234,12 @@ function handleAgentSend(ws, msg, sm) {
234
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);
235
235
  } catch {
236
236
  }
237
+ sm.pushEvent(sessionId, {
238
+ type: "user_message",
239
+ message: textContent,
240
+ data: Object.keys(meta).length > 0 ? meta : void 0,
241
+ timestamp: Date.now()
242
+ });
237
243
  sm.updateSessionState(sessionId, "processing");
238
244
  sm.touch(sessionId);
239
245
  if (images?.length) {
@@ -344,12 +350,24 @@ function handleAgentStatus(ws, msg, sm) {
344
350
  const sessionId = msg.session ?? "default";
345
351
  const session = sm.getSession(sessionId);
346
352
  const alive = session?.process?.alive ?? false;
353
+ let messageCount = 0;
354
+ let lastMessage = null;
355
+ try {
356
+ const db = getDb();
357
+ const count = db.prepare("SELECT COUNT(*) as c FROM chat_messages WHERE session_id = ?").get(sessionId);
358
+ messageCount = count?.c ?? 0;
359
+ const last = db.prepare("SELECT role, content, created_at FROM chat_messages WHERE session_id = ? ORDER BY id DESC LIMIT 1").get(sessionId);
360
+ if (last) lastMessage = { role: last.role, content: last.content, created_at: last.created_at };
361
+ } catch {
362
+ }
347
363
  wsReply(ws, msg, {
348
364
  alive,
349
365
  agentStatus: !alive ? "disconnected" : session?.state === "processing" ? "busy" : "idle",
350
366
  sessionId: session?.process?.sessionId ?? null,
351
367
  ccSessionId: session?.ccSessionId ?? null,
352
368
  eventCount: session?.eventCounter ?? 0,
369
+ messageCount,
370
+ lastMessage,
353
371
  config: session?.lastStartConfig ?? null
354
372
  });
355
373
  }
@@ -366,7 +384,38 @@ function handleAgentSubscribe(ws, msg, sm, state) {
366
384
  const sessionId = msg.session ?? "default";
367
385
  const session = sm.getOrCreateSession(sessionId);
368
386
  state.agentUnsubs.get(sessionId)?.();
369
- let cursor = typeof msg.since === "number" ? msg.since : session.eventCounter;
387
+ const includeHistory = msg.since === 0 || msg.includeHistory === true;
388
+ let cursor = 0;
389
+ if (includeHistory) {
390
+ try {
391
+ const db = getDb();
392
+ const rows = db.prepare(
393
+ `SELECT role, content, meta, created_at FROM chat_messages
394
+ WHERE session_id = ? ORDER BY id ASC`
395
+ ).all(sessionId);
396
+ for (const row of rows) {
397
+ cursor++;
398
+ 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;
399
+ if (!eventType) continue;
400
+ const meta = row.meta ? JSON.parse(row.meta) : void 0;
401
+ send(ws, {
402
+ type: "agent.event",
403
+ session: sessionId,
404
+ cursor,
405
+ isHistory: true,
406
+ event: {
407
+ type: eventType,
408
+ message: row.content,
409
+ data: meta,
410
+ timestamp: new Date(row.created_at).getTime()
411
+ }
412
+ });
413
+ }
414
+ } catch {
415
+ }
416
+ }
417
+ const bufferStart = typeof msg.since === "number" && msg.since > 0 ? msg.since : session.eventCounter;
418
+ if (!includeHistory) cursor = bufferStart;
370
419
  if (cursor < session.eventCounter) {
371
420
  const startIdx = Math.max(0, session.eventBuffer.length - (session.eventCounter - cursor));
372
421
  const events = session.eventBuffer.slice(startIdx);
@@ -374,6 +423,8 @@ function handleAgentSubscribe(ws, msg, sm, state) {
374
423
  cursor++;
375
424
  send(ws, { type: "agent.event", session: sessionId, cursor, event });
376
425
  }
426
+ } else {
427
+ cursor = session.eventCounter;
377
428
  }
378
429
  const unsub = sm.onSessionEvent(sessionId, (eventCursor, event) => {
379
430
  send(ws, { type: "agent.event", session: sessionId, cursor: eventCursor, event });
@@ -486,10 +537,14 @@ function handlePermissionPending(ws, msg, sm) {
486
537
  }
487
538
  function handlePermissionSubscribe(ws, msg, sm, state) {
488
539
  state.permissionUnsub?.();
540
+ const pending = sm.getAllPendingPermissions();
541
+ for (const p of pending) {
542
+ send(ws, { type: "permission.request", session: p.sessionId, request: p.request, createdAt: p.createdAt, isHistory: true });
543
+ }
489
544
  state.permissionUnsub = sm.onPermissionRequest((sessionId, request, createdAt) => {
490
545
  send(ws, { type: "permission.request", session: sessionId, request, createdAt });
491
546
  });
492
- reply(ws, msg, {});
547
+ reply(ws, msg, { pendingCount: pending.length });
493
548
  }
494
549
  function handlePermissionUnsubscribe(ws, msg, state) {
495
550
  state.permissionUnsub?.();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sna-sdk/core",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "Skills-Native Application runtime — server, providers, session management, database, and CLI",
5
5
  "type": "module",
6
6
  "bin": {