@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 +1 -0
- package/dist/core/providers/claude-code.js +38 -5
- package/dist/core/providers/types.d.ts +5 -1
- package/dist/db/schema.js +2 -1
- package/dist/electron/index.cjs +172 -0
- package/dist/electron/index.d.ts +99 -0
- package/dist/electron/index.js +130 -0
- package/dist/server/api-types.d.ts +6 -0
- package/dist/server/routes/agent.js +60 -15
- package/dist/server/session-manager.d.ts +9 -0
- package/dist/server/session-manager.js +38 -4
- package/dist/server/standalone.js +195 -27
- package/dist/server/ws.js +57 -2
- package/package.json +8 -1
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
|
-
|
|
214
|
+
textBlocks.push(text);
|
|
184
215
|
}
|
|
185
216
|
}
|
|
186
217
|
}
|
|
187
|
-
if (events.length > 0) {
|
|
188
|
-
for (
|
|
189
|
-
this.emitter.emit("event",
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
225
|
-
|
|
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
|
|
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
|
-
|
|
242
|
-
|
|
243
|
-
|
|
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
|
-
|
|
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
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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
|
-
|
|
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
|
-
|
|
581
|
+
textBlocks.push(text);
|
|
550
582
|
}
|
|
551
583
|
}
|
|
552
584
|
}
|
|
553
|
-
if (events.length > 0) {
|
|
554
|
-
for (
|
|
555
|
-
this.emitter.emit("event",
|
|
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
|
-
|
|
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
|
-
|
|
1012
|
-
|
|
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
|
|
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
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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": {
|