@sna-sdk/core 0.4.0 → 0.6.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
  /**
package/dist/db/schema.js CHANGED
@@ -18,7 +18,8 @@ function getDb() {
18
18
  const BetterSqlite3 = loadBetterSqlite3();
19
19
  const dir = path.dirname(DB_PATH);
20
20
  if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
21
- _db = new BetterSqlite3(DB_PATH);
21
+ const nativeBinding = process.env.SNA_SQLITE_NATIVE_BINDING || void 0;
22
+ _db = nativeBinding ? new BetterSqlite3(DB_PATH, { nativeBinding }) : new BetterSqlite3(DB_PATH);
22
23
  _db.pragma("journal_mode = WAL");
23
24
  initSchema(_db);
24
25
  }
@@ -0,0 +1,172 @@
1
+ "use strict";
2
+ var __create = Object.create;
3
+ var __defProp = Object.defineProperty;
4
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
5
+ var __getOwnPropNames = Object.getOwnPropertyNames;
6
+ var __getProtoOf = Object.getPrototypeOf;
7
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
8
+ var __export = (target, all) => {
9
+ for (var name in all)
10
+ __defProp(target, name, { get: all[name], enumerable: true });
11
+ };
12
+ var __copyProps = (to, from, except, desc) => {
13
+ if (from && typeof from === "object" || typeof from === "function") {
14
+ for (let key of __getOwnPropNames(from))
15
+ if (!__hasOwnProp.call(to, key) && key !== except)
16
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
17
+ }
18
+ return to;
19
+ };
20
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
21
+ // If the importer is in node compatibility mode or this is not an ESM
22
+ // file that has been converted to a CommonJS file using a Babel-
23
+ // compatible transform (i.e. "__esModule" has not been set), then set
24
+ // "default" to the CommonJS "module.exports" for node compatibility.
25
+ isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
26
+ mod
27
+ ));
28
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
29
+
30
+ // src/electron/index.ts
31
+ var electron_exports = {};
32
+ __export(electron_exports, {
33
+ startSnaServer: () => startSnaServer
34
+ });
35
+ module.exports = __toCommonJS(electron_exports);
36
+
37
+ // ../../node_modules/.pnpm/tsup@8.5.1_jiti@2.6.1_postcss@8.5.8_tsx@4.21.0_typescript@5.9.3/node_modules/tsup/assets/cjs_shims.js
38
+ var getImportMetaUrl = () => typeof document === "undefined" ? new URL(`file:${__filename}`).href : document.currentScript && document.currentScript.tagName.toUpperCase() === "SCRIPT" ? document.currentScript.src : new URL("main.js", document.baseURI).href;
39
+ var importMetaUrl = /* @__PURE__ */ getImportMetaUrl();
40
+
41
+ // src/electron/index.ts
42
+ var import_child_process = require("child_process");
43
+ var import_url = require("url");
44
+ var import_fs = __toESM(require("fs"), 1);
45
+ var import_path = __toESM(require("path"), 1);
46
+ function resolveStandaloneScript() {
47
+ const selfPath = (0, import_url.fileURLToPath)(importMetaUrl);
48
+ let script = import_path.default.resolve(import_path.default.dirname(selfPath), "../server/standalone.js");
49
+ if (script.includes(".asar") && !script.includes(".asar.unpacked")) {
50
+ script = script.replace(/(\.asar)([/\\])/, ".asar.unpacked$2");
51
+ }
52
+ if (!import_fs.default.existsSync(script)) {
53
+ throw new Error(
54
+ `SNA standalone script not found: ${script}
55
+ Ensure "@sna-sdk/core" is listed in asarUnpack in your electron-builder config.`
56
+ );
57
+ }
58
+ return script;
59
+ }
60
+ function resolveNativeBinding(override) {
61
+ if (override) {
62
+ if (!import_fs.default.existsSync(override)) {
63
+ console.warn(`[sna] SNA nativeBinding override not found: ${override}`);
64
+ return void 0;
65
+ }
66
+ return override;
67
+ }
68
+ const BINDING_REL = import_path.default.join("better-sqlite3", "build", "Release", "better_sqlite3.node");
69
+ const resourcesPath = process.resourcesPath;
70
+ if (resourcesPath) {
71
+ const unpackedBase = import_path.default.join(resourcesPath, "app.asar.unpacked", "node_modules");
72
+ const candidates = [
73
+ import_path.default.join(unpackedBase, BINDING_REL),
74
+ // nested under @sna-sdk/core if hoisting differs
75
+ import_path.default.join(unpackedBase, "@sna-sdk", "core", "node_modules", BINDING_REL)
76
+ ];
77
+ for (const c of candidates) {
78
+ if (import_fs.default.existsSync(c)) return c;
79
+ }
80
+ }
81
+ const selfPath = (0, import_url.fileURLToPath)(importMetaUrl);
82
+ const local = import_path.default.resolve(import_path.default.dirname(selfPath), "../../node_modules", BINDING_REL);
83
+ if (import_fs.default.existsSync(local)) return local;
84
+ return void 0;
85
+ }
86
+ function buildNodePath() {
87
+ const resourcesPath = process.resourcesPath;
88
+ if (!resourcesPath) return void 0;
89
+ const unpacked = import_path.default.join(resourcesPath, "app.asar.unpacked", "node_modules");
90
+ if (!import_fs.default.existsSync(unpacked)) return void 0;
91
+ const existing = process.env.NODE_PATH;
92
+ return existing ? `${unpacked}${import_path.default.delimiter}${existing}` : unpacked;
93
+ }
94
+ async function startSnaServer(options) {
95
+ const port = options.port ?? 3099;
96
+ const cwd = options.cwd ?? import_path.default.dirname(options.dbPath);
97
+ const readyTimeout = options.readyTimeout ?? 15e3;
98
+ const { onLog } = options;
99
+ const standaloneScript = resolveStandaloneScript();
100
+ const nativeBinding = resolveNativeBinding(options.nativeBinding);
101
+ const nodePath = buildNodePath();
102
+ const env = {
103
+ ...process.env,
104
+ SNA_PORT: String(port),
105
+ SNA_DB_PATH: options.dbPath,
106
+ ...options.maxSessions != null ? { SNA_MAX_SESSIONS: String(options.maxSessions) } : {},
107
+ ...options.permissionMode ? { SNA_PERMISSION_MODE: options.permissionMode } : {},
108
+ ...options.model ? { SNA_MODEL: options.model } : {},
109
+ ...nativeBinding ? { SNA_SQLITE_NATIVE_BINDING: nativeBinding } : {},
110
+ ...nodePath ? { NODE_PATH: nodePath } : {},
111
+ // Consumer overrides last so they can always win
112
+ ...options.env ?? {}
113
+ };
114
+ const proc = (0, import_child_process.fork)(standaloneScript, [], {
115
+ cwd,
116
+ env,
117
+ stdio: "pipe"
118
+ });
119
+ let stdoutBuf = "";
120
+ let isReady = false;
121
+ const readyListeners = [];
122
+ proc.stdout?.on("data", (chunk) => {
123
+ stdoutBuf += chunk.toString();
124
+ const lines = stdoutBuf.split("\n");
125
+ stdoutBuf = lines.pop() ?? "";
126
+ for (const line of lines) {
127
+ if (onLog) onLog(line);
128
+ if (!isReady && line.includes("API server ready")) {
129
+ isReady = true;
130
+ readyListeners.splice(0).forEach((cb) => cb());
131
+ }
132
+ }
133
+ });
134
+ proc.stderr?.on("data", (chunk) => {
135
+ if (onLog) {
136
+ chunk.toString().split("\n").filter(Boolean).forEach(onLog);
137
+ }
138
+ });
139
+ await new Promise((resolve, reject) => {
140
+ if (isReady) return resolve();
141
+ const timer = setTimeout(() => {
142
+ reject(new Error(`SNA server did not become ready within ${readyTimeout}ms`));
143
+ }, readyTimeout);
144
+ readyListeners.push(() => {
145
+ clearTimeout(timer);
146
+ resolve();
147
+ });
148
+ proc.on("exit", (code) => {
149
+ if (!isReady) {
150
+ clearTimeout(timer);
151
+ reject(new Error(`SNA server process exited (code=${code ?? "null"}) before becoming ready`));
152
+ }
153
+ });
154
+ proc.on("error", (err) => {
155
+ if (!isReady) {
156
+ clearTimeout(timer);
157
+ reject(err);
158
+ }
159
+ });
160
+ });
161
+ return {
162
+ process: proc,
163
+ port,
164
+ stop() {
165
+ proc.kill("SIGTERM");
166
+ }
167
+ };
168
+ }
169
+ // Annotate the CommonJS export names for ESM import in node:
170
+ 0 && (module.exports = {
171
+ startSnaServer
172
+ });
@@ -0,0 +1,99 @@
1
+ import { ChildProcess } from 'child_process';
2
+
3
+ /**
4
+ * @sna-sdk/core/electron — Electron launcher API
5
+ *
6
+ * Provides startSnaServer() to launch the SNA standalone server as a forked
7
+ * child process from an Electron main process. Handles asar path resolution,
8
+ * native module binding detection, env construction, and ready detection
9
+ * automatically.
10
+ *
11
+ * @example
12
+ * const { startSnaServer } = require("@sna-sdk/core/electron");
13
+ *
14
+ * const sna = await startSnaServer({
15
+ * port: 3099,
16
+ * dbPath: path.join(app.getPath("userData"), "sna.db"),
17
+ * maxSessions: 20,
18
+ * permissionMode: "acceptEdits",
19
+ * onLog: (line) => console.log("[sna]", line),
20
+ * });
21
+ *
22
+ * // sna.process — ChildProcess ref
23
+ * // sna.port — actual port
24
+ * // sna.stop() — graceful shutdown
25
+ *
26
+ * @remarks
27
+ * **asarUnpack requirement**: for the fork to work, @sna-sdk/core must be
28
+ * outside the asar bundle. Add to your electron-builder config:
29
+ *
30
+ * asarUnpack: ["node_modules/@sna-sdk/core/**"]
31
+ *
32
+ * The better-sqlite3 native binding used by the forked process must be
33
+ * compiled for the system Node.js (not Electron). If your app uses
34
+ * electron-rebuild, set options.nativeBinding to a Node.js-compiled
35
+ * .node file, or let SNA manage its own native install via `sna api:up`.
36
+ */
37
+
38
+ interface SnaServerOptions {
39
+ /** Port for the SNA API server. Default: 3099 */
40
+ port?: number;
41
+ /** Absolute path to the SQLite database file. Required. */
42
+ dbPath: string;
43
+ /**
44
+ * Working directory for the server process.
45
+ * Default: dirname(dbPath)
46
+ */
47
+ cwd?: string;
48
+ /** Maximum concurrent agent sessions. Default: 5 */
49
+ maxSessions?: number;
50
+ /**
51
+ * Permission mode for Claude Code.
52
+ * Default: "acceptEdits"
53
+ */
54
+ permissionMode?: "acceptEdits" | "bypassPermissions" | "default";
55
+ /** Claude model to use. Default: SDK default (claude-sonnet-4-6) */
56
+ model?: string;
57
+ /**
58
+ * Explicit path to the better-sqlite3 native .node binding.
59
+ *
60
+ * When omitted, the launcher auto-detects from:
61
+ * 1. app.asar.unpacked/node_modules/better-sqlite3/build/Release/...
62
+ * 2. The SDK's local node_modules (dev / non-packaged)
63
+ *
64
+ * Set this if you have a custom Node.js-compiled binary at a known location.
65
+ */
66
+ nativeBinding?: string;
67
+ /**
68
+ * Extra env vars merged into the server process environment.
69
+ * These take precedence over the launcher's defaults.
70
+ */
71
+ env?: Record<string, string>;
72
+ /**
73
+ * How long to wait for the server to become ready, in milliseconds.
74
+ * Default: 15000 (15 seconds)
75
+ */
76
+ readyTimeout?: number;
77
+ /**
78
+ * Called with each log line emitted by the server process (stdout + stderr).
79
+ * Useful for forwarding to your app's logger.
80
+ */
81
+ onLog?: (line: string) => void;
82
+ }
83
+ interface SnaServerHandle {
84
+ /** The forked child process. */
85
+ process: ChildProcess;
86
+ /** The port the server is listening on. */
87
+ port: number;
88
+ /** Send SIGTERM to the server process for graceful shutdown. */
89
+ stop(): void;
90
+ }
91
+ /**
92
+ * Launch the SNA standalone API server in a forked child process.
93
+ *
94
+ * Returns a handle once the server is ready to accept requests.
95
+ * Throws if the server fails to start within `options.readyTimeout`.
96
+ */
97
+ declare function startSnaServer(options: SnaServerOptions): Promise<SnaServerHandle>;
98
+
99
+ export { type SnaServerHandle, type SnaServerOptions, startSnaServer };
@@ -0,0 +1,130 @@
1
+ import { fork } from "child_process";
2
+ import { fileURLToPath } from "url";
3
+ import fs from "fs";
4
+ import path from "path";
5
+ function resolveStandaloneScript() {
6
+ const selfPath = fileURLToPath(import.meta.url);
7
+ let script = path.resolve(path.dirname(selfPath), "../server/standalone.js");
8
+ if (script.includes(".asar") && !script.includes(".asar.unpacked")) {
9
+ script = script.replace(/(\.asar)([/\\])/, ".asar.unpacked$2");
10
+ }
11
+ if (!fs.existsSync(script)) {
12
+ throw new Error(
13
+ `SNA standalone script not found: ${script}
14
+ Ensure "@sna-sdk/core" is listed in asarUnpack in your electron-builder config.`
15
+ );
16
+ }
17
+ return script;
18
+ }
19
+ function resolveNativeBinding(override) {
20
+ if (override) {
21
+ if (!fs.existsSync(override)) {
22
+ console.warn(`[sna] SNA nativeBinding override not found: ${override}`);
23
+ return void 0;
24
+ }
25
+ return override;
26
+ }
27
+ const BINDING_REL = path.join("better-sqlite3", "build", "Release", "better_sqlite3.node");
28
+ const resourcesPath = process.resourcesPath;
29
+ if (resourcesPath) {
30
+ const unpackedBase = path.join(resourcesPath, "app.asar.unpacked", "node_modules");
31
+ const candidates = [
32
+ path.join(unpackedBase, BINDING_REL),
33
+ // nested under @sna-sdk/core if hoisting differs
34
+ path.join(unpackedBase, "@sna-sdk", "core", "node_modules", BINDING_REL)
35
+ ];
36
+ for (const c of candidates) {
37
+ if (fs.existsSync(c)) return c;
38
+ }
39
+ }
40
+ const selfPath = fileURLToPath(import.meta.url);
41
+ const local = path.resolve(path.dirname(selfPath), "../../node_modules", BINDING_REL);
42
+ if (fs.existsSync(local)) return local;
43
+ return void 0;
44
+ }
45
+ function buildNodePath() {
46
+ const resourcesPath = process.resourcesPath;
47
+ if (!resourcesPath) return void 0;
48
+ const unpacked = path.join(resourcesPath, "app.asar.unpacked", "node_modules");
49
+ if (!fs.existsSync(unpacked)) return void 0;
50
+ const existing = process.env.NODE_PATH;
51
+ return existing ? `${unpacked}${path.delimiter}${existing}` : unpacked;
52
+ }
53
+ async function startSnaServer(options) {
54
+ const port = options.port ?? 3099;
55
+ const cwd = options.cwd ?? path.dirname(options.dbPath);
56
+ const readyTimeout = options.readyTimeout ?? 15e3;
57
+ const { onLog } = options;
58
+ const standaloneScript = resolveStandaloneScript();
59
+ const nativeBinding = resolveNativeBinding(options.nativeBinding);
60
+ const nodePath = buildNodePath();
61
+ const env = {
62
+ ...process.env,
63
+ SNA_PORT: String(port),
64
+ SNA_DB_PATH: options.dbPath,
65
+ ...options.maxSessions != null ? { SNA_MAX_SESSIONS: String(options.maxSessions) } : {},
66
+ ...options.permissionMode ? { SNA_PERMISSION_MODE: options.permissionMode } : {},
67
+ ...options.model ? { SNA_MODEL: options.model } : {},
68
+ ...nativeBinding ? { SNA_SQLITE_NATIVE_BINDING: nativeBinding } : {},
69
+ ...nodePath ? { NODE_PATH: nodePath } : {},
70
+ // Consumer overrides last so they can always win
71
+ ...options.env ?? {}
72
+ };
73
+ const proc = fork(standaloneScript, [], {
74
+ cwd,
75
+ env,
76
+ stdio: "pipe"
77
+ });
78
+ let stdoutBuf = "";
79
+ let isReady = false;
80
+ const readyListeners = [];
81
+ proc.stdout?.on("data", (chunk) => {
82
+ stdoutBuf += chunk.toString();
83
+ const lines = stdoutBuf.split("\n");
84
+ stdoutBuf = lines.pop() ?? "";
85
+ for (const line of lines) {
86
+ if (onLog) onLog(line);
87
+ if (!isReady && line.includes("API server ready")) {
88
+ isReady = true;
89
+ readyListeners.splice(0).forEach((cb) => cb());
90
+ }
91
+ }
92
+ });
93
+ proc.stderr?.on("data", (chunk) => {
94
+ if (onLog) {
95
+ chunk.toString().split("\n").filter(Boolean).forEach(onLog);
96
+ }
97
+ });
98
+ await new Promise((resolve, reject) => {
99
+ if (isReady) return resolve();
100
+ const timer = setTimeout(() => {
101
+ reject(new Error(`SNA server did not become ready within ${readyTimeout}ms`));
102
+ }, readyTimeout);
103
+ readyListeners.push(() => {
104
+ clearTimeout(timer);
105
+ resolve();
106
+ });
107
+ proc.on("exit", (code) => {
108
+ if (!isReady) {
109
+ clearTimeout(timer);
110
+ reject(new Error(`SNA server process exited (code=${code ?? "null"}) before becoming ready`));
111
+ }
112
+ });
113
+ proc.on("error", (err) => {
114
+ if (!isReady) {
115
+ clearTimeout(timer);
116
+ reject(err);
117
+ }
118
+ });
119
+ });
120
+ return {
121
+ process: proc,
122
+ port,
123
+ stop() {
124
+ proc.kill("SIGTERM");
125
+ }
126
+ };
127
+ }
128
+ export {
129
+ startSnaServer
130
+ };
@@ -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();
@@ -31,7 +31,8 @@ function getDb() {
31
31
  const BetterSqlite3 = loadBetterSqlite3();
32
32
  const dir = path.dirname(DB_PATH);
33
33
  if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
34
- _db = new BetterSqlite3(DB_PATH);
34
+ const nativeBinding = process.env.SNA_SQLITE_NATIVE_BINDING || void 0;
35
+ _db = nativeBinding ? new BetterSqlite3(DB_PATH, { nativeBinding }) : new BetterSqlite3(DB_PATH);
35
36
  _db.pragma("journal_mode = WAL");
36
37
  initSchema(_db);
37
38
  }
@@ -454,6 +455,36 @@ var ClaudeCodeProcess = class {
454
455
  this.send(options.prompt);
455
456
  }
456
457
  }
458
+ /**
459
+ * Split completed assistant text into chunks and emit assistant_delta events
460
+ * at a fixed rate (~270 chars/sec), followed by the final assistant event.
461
+ *
462
+ * CHUNK_SIZE chars every CHUNK_DELAY_MS → natural TPS feel regardless of length.
463
+ */
464
+ emitTextAsDeltas(text) {
465
+ const CHUNK_SIZE = 4;
466
+ const CHUNK_DELAY_MS = 15;
467
+ let t = 0;
468
+ for (let i = 0; i < text.length; i += CHUNK_SIZE) {
469
+ const chunk = text.slice(i, i + CHUNK_SIZE);
470
+ setTimeout(() => {
471
+ this.emitter.emit("event", {
472
+ type: "assistant_delta",
473
+ delta: chunk,
474
+ index: 0,
475
+ timestamp: Date.now()
476
+ });
477
+ }, t);
478
+ t += CHUNK_DELAY_MS;
479
+ }
480
+ setTimeout(() => {
481
+ this.emitter.emit("event", {
482
+ type: "assistant",
483
+ message: text,
484
+ timestamp: Date.now()
485
+ });
486
+ }, t);
487
+ }
457
488
  get alive() {
458
489
  return this._alive;
459
490
  }
@@ -529,6 +560,7 @@ var ClaudeCodeProcess = class {
529
560
  const content = msg.message?.content;
530
561
  if (!Array.isArray(content)) return null;
531
562
  const events = [];
563
+ const textBlocks = [];
532
564
  for (const block of content) {
533
565
  if (block.type === "thinking") {
534
566
  events.push({
@@ -546,15 +578,17 @@ var ClaudeCodeProcess = class {
546
578
  } else if (block.type === "text") {
547
579
  const text = (block.text ?? "").trim();
548
580
  if (text) {
549
- events.push({ type: "assistant", message: text, timestamp: Date.now() });
581
+ textBlocks.push(text);
550
582
  }
551
583
  }
552
584
  }
553
- if (events.length > 0) {
554
- for (let i = 1; i < events.length; i++) {
555
- this.emitter.emit("event", events[i]);
585
+ if (events.length > 0 || textBlocks.length > 0) {
586
+ for (const e of events) {
587
+ this.emitter.emit("event", e);
588
+ }
589
+ for (const text of textBlocks) {
590
+ this.emitTextAsDeltas(text);
556
591
  }
557
- return events[0];
558
592
  }
559
593
  return null;
560
594
  }
@@ -982,6 +1016,12 @@ function createAgentRoutes(sessionManager2) {
982
1016
  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
1017
  } catch {
984
1018
  }
1019
+ sessionManager2.pushEvent(sessionId, {
1020
+ type: "user_message",
1021
+ message: textContent,
1022
+ data: Object.keys(meta).length > 0 ? meta : void 0,
1023
+ timestamp: Date.now()
1024
+ });
985
1025
  sessionManager2.updateSessionState(sessionId, "processing");
986
1026
  sessionManager2.touch(sessionId);
987
1027
  if (body.images?.length) {
@@ -1004,32 +1044,59 @@ function createAgentRoutes(sessionManager2) {
1004
1044
  const sessionId = getSessionId(c);
1005
1045
  const session = sessionManager2.getOrCreateSession(sessionId);
1006
1046
  const sinceParam = c.req.query("since");
1007
- let cursor = sinceParam ? parseInt(sinceParam, 10) : session.eventCounter;
1047
+ const sinceCursor = sinceParam ? parseInt(sinceParam, 10) : session.eventCounter;
1008
1048
  return streamSSE3(c, async (stream) => {
1009
- const POLL_MS = 300;
1010
1049
  const KEEPALIVE_MS = 15e3;
1011
- let lastSend = Date.now();
1012
- while (true) {
1050
+ const signal = c.req.raw.signal;
1051
+ const queue = [];
1052
+ let wakeUp = null;
1053
+ const unsub = sessionManager2.onSessionEvent(sessionId, (eventCursor, event) => {
1054
+ queue.push({ cursor: eventCursor, event });
1055
+ const fn = wakeUp;
1056
+ wakeUp = null;
1057
+ fn?.();
1058
+ });
1059
+ signal.addEventListener("abort", () => {
1060
+ const fn = wakeUp;
1061
+ wakeUp = null;
1062
+ fn?.();
1063
+ });
1064
+ try {
1065
+ let cursor = sinceCursor;
1013
1066
  if (cursor < session.eventCounter) {
1014
1067
  const startIdx = Math.max(
1015
1068
  0,
1016
1069
  session.eventBuffer.length - (session.eventCounter - cursor)
1017
1070
  );
1018
- const newEvents = session.eventBuffer.slice(startIdx);
1019
- for (const event of newEvents) {
1071
+ for (const event of session.eventBuffer.slice(startIdx)) {
1020
1072
  cursor++;
1021
- await stream.writeSSE({
1022
- id: String(cursor),
1023
- data: JSON.stringify(event)
1024
- });
1025
- lastSend = Date.now();
1073
+ await stream.writeSSE({ id: String(cursor), data: JSON.stringify(event) });
1026
1074
  }
1075
+ } else {
1076
+ cursor = session.eventCounter;
1027
1077
  }
1028
- if (Date.now() - lastSend > KEEPALIVE_MS) {
1029
- await stream.writeSSE({ data: "" });
1030
- lastSend = Date.now();
1078
+ while (queue.length > 0 && queue[0].cursor <= cursor) queue.shift();
1079
+ while (!signal.aborted) {
1080
+ if (queue.length === 0) {
1081
+ await Promise.race([
1082
+ new Promise((r) => {
1083
+ wakeUp = r;
1084
+ }),
1085
+ new Promise((r) => setTimeout(r, KEEPALIVE_MS))
1086
+ ]);
1087
+ }
1088
+ if (signal.aborted) break;
1089
+ if (queue.length > 0) {
1090
+ while (queue.length > 0) {
1091
+ const item = queue.shift();
1092
+ await stream.writeSSE({ id: String(item.cursor), data: JSON.stringify(item.event) });
1093
+ }
1094
+ } else {
1095
+ await stream.writeSSE({ data: "" });
1096
+ }
1031
1097
  }
1032
- await new Promise((r) => setTimeout(r, POLL_MS));
1098
+ } finally {
1099
+ unsub();
1033
1100
  }
1034
1101
  });
1035
1102
  });
@@ -1128,12 +1195,24 @@ function createAgentRoutes(sessionManager2) {
1128
1195
  const sessionId = getSessionId(c);
1129
1196
  const session = sessionManager2.getSession(sessionId);
1130
1197
  const alive = session?.process?.alive ?? false;
1198
+ let messageCount = 0;
1199
+ let lastMessage = null;
1200
+ try {
1201
+ const db = getDb();
1202
+ const count = db.prepare("SELECT COUNT(*) as c FROM chat_messages WHERE session_id = ?").get(sessionId);
1203
+ messageCount = count?.c ?? 0;
1204
+ const last = db.prepare("SELECT role, content, created_at FROM chat_messages WHERE session_id = ? ORDER BY id DESC LIMIT 1").get(sessionId);
1205
+ if (last) lastMessage = { role: last.role, content: last.content, created_at: last.created_at };
1206
+ } catch {
1207
+ }
1131
1208
  return httpJson(c, "agent.status", {
1132
1209
  alive,
1133
1210
  agentStatus: !alive ? "disconnected" : session?.state === "processing" ? "busy" : "idle",
1134
1211
  sessionId: session?.process?.sessionId ?? null,
1135
1212
  ccSessionId: session?.ccSessionId ?? null,
1136
1213
  eventCount: session?.eventCounter ?? 0,
1214
+ messageCount,
1215
+ lastMessage,
1137
1216
  config: session?.lastStartConfig ?? null
1138
1217
  });
1139
1218
  });
@@ -1417,11 +1496,13 @@ var SessionManager = class {
1417
1496
  session.ccSessionId = e.data.sessionId;
1418
1497
  this.persistSession(session);
1419
1498
  }
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);
1499
+ if (e.type !== "assistant_delta") {
1500
+ session.eventBuffer.push(e);
1501
+ if (session.eventBuffer.length > MAX_EVENT_BUFFER) {
1502
+ session.eventBuffer.splice(0, session.eventBuffer.length - MAX_EVENT_BUFFER);
1503
+ }
1424
1504
  }
1505
+ session.eventCounter++;
1425
1506
  if (e.type === "complete" || e.type === "error" || e.type === "interrupted") {
1426
1507
  this.setSessionState(sessionId, session, "waiting");
1427
1508
  }
@@ -1465,6 +1546,20 @@ var SessionManager = class {
1465
1546
  broadcastSkillEvent(event) {
1466
1547
  for (const cb of this.skillEventListeners) cb(event);
1467
1548
  }
1549
+ /** Push a synthetic event into a session's event stream (for user message broadcast). */
1550
+ pushEvent(sessionId, event) {
1551
+ const session = this.sessions.get(sessionId);
1552
+ if (!session) return;
1553
+ session.eventBuffer.push(event);
1554
+ session.eventCounter++;
1555
+ if (session.eventBuffer.length > MAX_EVENT_BUFFER) {
1556
+ session.eventBuffer.splice(0, session.eventBuffer.length - MAX_EVENT_BUFFER);
1557
+ }
1558
+ const listeners = this.eventListeners.get(sessionId);
1559
+ if (listeners) {
1560
+ for (const cb of listeners) cb(session.eventCounter, event);
1561
+ }
1562
+ }
1468
1563
  // ── Permission pub/sub ────────────────────────────────────────
1469
1564
  /** Subscribe to permission request notifications. Returns unsubscribe function. */
1470
1565
  onPermissionRequest(cb) {
@@ -1646,6 +1741,7 @@ var SessionManager = class {
1646
1741
  config: s.lastStartConfig,
1647
1742
  ccSessionId: s.ccSessionId,
1648
1743
  eventCount: s.eventCounter,
1744
+ ...this.getMessageStats(s.id),
1649
1745
  createdAt: s.createdAt,
1650
1746
  lastActivityAt: s.lastActivityAt
1651
1747
  }));
@@ -1656,6 +1752,23 @@ var SessionManager = class {
1656
1752
  if (session) session.lastActivityAt = Date.now();
1657
1753
  }
1658
1754
  /** Persist an agent event to chat_messages. */
1755
+ getMessageStats(sessionId) {
1756
+ try {
1757
+ const db = getDb();
1758
+ const count = db.prepare(
1759
+ `SELECT COUNT(*) as c FROM chat_messages WHERE session_id = ?`
1760
+ ).get(sessionId);
1761
+ const last = db.prepare(
1762
+ `SELECT role, content, created_at FROM chat_messages WHERE session_id = ? ORDER BY id DESC LIMIT 1`
1763
+ ).get(sessionId);
1764
+ return {
1765
+ messageCount: count.c,
1766
+ lastMessage: last ? { role: last.role, content: last.content, created_at: last.created_at } : null
1767
+ };
1768
+ } catch {
1769
+ return { messageCount: 0, lastMessage: null };
1770
+ }
1771
+ }
1659
1772
  persistEvent(sessionId, e) {
1660
1773
  try {
1661
1774
  const db = getDb();
@@ -1931,6 +2044,12 @@ function handleAgentSend(ws, msg, sm) {
1931
2044
  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
2045
  } catch {
1933
2046
  }
2047
+ sm.pushEvent(sessionId, {
2048
+ type: "user_message",
2049
+ message: textContent,
2050
+ data: Object.keys(meta).length > 0 ? meta : void 0,
2051
+ timestamp: Date.now()
2052
+ });
1934
2053
  sm.updateSessionState(sessionId, "processing");
1935
2054
  sm.touch(sessionId);
1936
2055
  if (images?.length) {
@@ -2041,12 +2160,24 @@ function handleAgentStatus(ws, msg, sm) {
2041
2160
  const sessionId = msg.session ?? "default";
2042
2161
  const session = sm.getSession(sessionId);
2043
2162
  const alive = session?.process?.alive ?? false;
2163
+ let messageCount = 0;
2164
+ let lastMessage = null;
2165
+ try {
2166
+ const db = getDb();
2167
+ const count = db.prepare("SELECT COUNT(*) as c FROM chat_messages WHERE session_id = ?").get(sessionId);
2168
+ messageCount = count?.c ?? 0;
2169
+ const last = db.prepare("SELECT role, content, created_at FROM chat_messages WHERE session_id = ? ORDER BY id DESC LIMIT 1").get(sessionId);
2170
+ if (last) lastMessage = { role: last.role, content: last.content, created_at: last.created_at };
2171
+ } catch {
2172
+ }
2044
2173
  wsReply(ws, msg, {
2045
2174
  alive,
2046
2175
  agentStatus: !alive ? "disconnected" : session?.state === "processing" ? "busy" : "idle",
2047
2176
  sessionId: session?.process?.sessionId ?? null,
2048
2177
  ccSessionId: session?.ccSessionId ?? null,
2049
2178
  eventCount: session?.eventCounter ?? 0,
2179
+ messageCount,
2180
+ lastMessage,
2050
2181
  config: session?.lastStartConfig ?? null
2051
2182
  });
2052
2183
  }
@@ -2063,7 +2194,38 @@ function handleAgentSubscribe(ws, msg, sm, state) {
2063
2194
  const sessionId = msg.session ?? "default";
2064
2195
  const session = sm.getOrCreateSession(sessionId);
2065
2196
  state.agentUnsubs.get(sessionId)?.();
2066
- let cursor = typeof msg.since === "number" ? msg.since : session.eventCounter;
2197
+ const includeHistory = msg.since === 0 || msg.includeHistory === true;
2198
+ let cursor = 0;
2199
+ if (includeHistory) {
2200
+ try {
2201
+ const db = getDb();
2202
+ const rows = db.prepare(
2203
+ `SELECT role, content, meta, created_at FROM chat_messages
2204
+ WHERE session_id = ? ORDER BY id ASC`
2205
+ ).all(sessionId);
2206
+ for (const row of rows) {
2207
+ cursor++;
2208
+ 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;
2209
+ if (!eventType) continue;
2210
+ const meta = row.meta ? JSON.parse(row.meta) : void 0;
2211
+ send(ws, {
2212
+ type: "agent.event",
2213
+ session: sessionId,
2214
+ cursor,
2215
+ isHistory: true,
2216
+ event: {
2217
+ type: eventType,
2218
+ message: row.content,
2219
+ data: meta,
2220
+ timestamp: new Date(row.created_at).getTime()
2221
+ }
2222
+ });
2223
+ }
2224
+ } catch {
2225
+ }
2226
+ }
2227
+ const bufferStart = typeof msg.since === "number" && msg.since > 0 ? msg.since : session.eventCounter;
2228
+ if (!includeHistory) cursor = bufferStart;
2067
2229
  if (cursor < session.eventCounter) {
2068
2230
  const startIdx = Math.max(0, session.eventBuffer.length - (session.eventCounter - cursor));
2069
2231
  const events = session.eventBuffer.slice(startIdx);
@@ -2071,6 +2233,8 @@ function handleAgentSubscribe(ws, msg, sm, state) {
2071
2233
  cursor++;
2072
2234
  send(ws, { type: "agent.event", session: sessionId, cursor, event });
2073
2235
  }
2236
+ } else {
2237
+ cursor = session.eventCounter;
2074
2238
  }
2075
2239
  const unsub = sm.onSessionEvent(sessionId, (eventCursor, event) => {
2076
2240
  send(ws, { type: "agent.event", session: sessionId, cursor: eventCursor, event });
@@ -2183,10 +2347,14 @@ function handlePermissionPending(ws, msg, sm) {
2183
2347
  }
2184
2348
  function handlePermissionSubscribe(ws, msg, sm, state) {
2185
2349
  state.permissionUnsub?.();
2350
+ const pending = sm.getAllPendingPermissions();
2351
+ for (const p of pending) {
2352
+ send(ws, { type: "permission.request", session: p.sessionId, request: p.request, createdAt: p.createdAt, isHistory: true });
2353
+ }
2186
2354
  state.permissionUnsub = sm.onPermissionRequest((sessionId, request, createdAt) => {
2187
2355
  send(ws, { type: "permission.request", session: sessionId, request, createdAt });
2188
2356
  });
2189
- reply(ws, msg, {});
2357
+ reply(ws, msg, { pendingCount: pending.length });
2190
2358
  }
2191
2359
  function handlePermissionUnsubscribe(ws, msg, state) {
2192
2360
  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.6.0",
4
4
  "description": "Skills-Native Application runtime — server, providers, session management, database, and CLI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -68,6 +68,13 @@
68
68
  "source": "./src/testing/mock-api.ts",
69
69
  "types": "./dist/testing/mock-api.d.ts",
70
70
  "default": "./dist/testing/mock-api.js"
71
+ },
72
+ "./electron": {
73
+ "source": "./src/electron/index.ts",
74
+ "types": "./dist/electron/index.d.ts",
75
+ "require": "./dist/electron/index.cjs",
76
+ "import": "./dist/electron/index.js",
77
+ "default": "./dist/electron/index.js"
71
78
  }
72
79
  },
73
80
  "engines": {