@sna-sdk/core 0.1.0 → 0.2.3

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
@@ -6,10 +6,13 @@ Server runtime for [Skills-Native Applications](https://github.com/neuradex/sna)
6
6
 
7
7
  - **Skill event pipeline** — emit, SSE streaming, and hook scripts
8
8
  - **Dispatch** — unified event dispatcher with validation, session lifecycle, and cleanup (`sna dispatch` CLI + programmatic API)
9
- - **SQLite database** — schema and `getDb()` for `skill_events`
10
- - **Hono server factory** — `createSnaApp()` with events, emit, agent, and run routes
9
+ - **SQLite database** — schema and `getDb()` for `skill_events`, `chat_sessions`, `chat_messages`
10
+ - **Hono server factory** — `createSnaApp()` with events, emit, agent, chat, and run routes
11
+ - **WebSocket API** — `attachWebSocket()` wrapping all HTTP routes over a single WS connection
12
+ - **One-shot execution** — `POST /agent/run-once` for single-request LLM calls
11
13
  - **Lifecycle CLI** — `sna api:up`, `sna api:down`, `sna dispatch`, `sna validate`
12
14
  - **Agent providers** — Claude Code and Codex process management
15
+ - **Multi-session** — `SessionManager` with event pub/sub, permission management, and session metadata
13
16
 
14
17
  ## Install
15
18
 
@@ -53,10 +56,14 @@ Event types: `start` | `progress` | `milestone` | `complete` | `error`
53
56
  ### Mount server routes
54
57
 
55
58
  ```ts
56
- import { createSnaApp } from "@sna-sdk/core/server";
59
+ import { createSnaApp, attachWebSocket } from "@sna-sdk/core/server";
60
+ import { serve } from "@hono/node-server";
57
61
 
58
62
  const sna = createSnaApp();
59
- // Provides: GET /events (SSE), POST /emit, GET /health, POST /agent/start
63
+ // HTTP: GET /health, GET /events (SSE), POST /emit, /agent/*, /chat/*
64
+ const server = serve({ fetch: sna.fetch, port: 3099 });
65
+ // WS: ws://localhost:3099/ws — all routes available over WebSocket
66
+ attachWebSocket(server, sessionManager);
60
67
  ```
61
68
 
62
69
  ### Access the database
@@ -71,9 +78,10 @@ const db = getDb(); // SQLite instance (data/sna.db)
71
78
 
72
79
  | Import path | Contents |
73
80
  |-------------|----------|
74
- | `@sna-sdk/core` | `DEFAULT_SNA_PORT`, `DEFAULT_SNA_URL`, `dispatchOpen`, `dispatchSend`, `dispatchClose`, `createDispatchHandle`, `SEND_TYPES`, `loadSkillsManifest`, types |
75
- | `@sna-sdk/core/server` | `createSnaApp()`, route handlers, `SessionManager` |
76
- | `@sna-sdk/core/db/schema` | `getDb()`, `SkillEvent` type |
81
+ | `@sna-sdk/core` | `DEFAULT_SNA_PORT`, `DEFAULT_SNA_URL`, `dispatchOpen`, `dispatchSend`, `dispatchClose`, `createDispatchHandle`, types (`AgentEvent`, `Session`, `SessionInfo`, `ChatSession`, `ChatMessage`, `SkillEvent`, etc.) |
82
+ | `@sna-sdk/core/server` | `createSnaApp()`, `attachWebSocket()`, route handlers, `SessionManager` |
83
+ | `@sna-sdk/core/server/routes/agent` | `createAgentRoutes()`, `runOnce()` |
84
+ | `@sna-sdk/core/db/schema` | `getDb()`, `ChatSession`, `ChatMessage`, `SkillEvent` types |
77
85
  | `@sna-sdk/core/providers` | Agent provider factory, `ClaudeCodeProvider` |
78
86
  | `@sna-sdk/core/lib/sna-run` | `snaRun()` helper for spawning Claude Code |
79
87
 
@@ -99,6 +99,11 @@ class ClaudeCodeProcess {
99
99
  logger.log("stdin", msg.slice(0, 200));
100
100
  this.proc.stdin.write(msg + "\n");
101
101
  }
102
+ interrupt() {
103
+ if (this._alive) {
104
+ this.proc.kill("SIGINT");
105
+ }
106
+ }
102
107
  kill() {
103
108
  if (this._alive) {
104
109
  this._alive = false;
@@ -196,7 +201,7 @@ class ClaudeCodeProcess {
196
201
  timestamp: Date.now()
197
202
  };
198
203
  }
199
- if (msg.subtype === "error" || msg.is_error) {
204
+ if (msg.subtype?.startsWith("error") || msg.is_error) {
200
205
  return {
201
206
  type: "error",
202
207
  message: msg.result ?? msg.error ?? "Unknown error",
@@ -228,13 +233,14 @@ class ClaudeCodeProvider {
228
233
  }
229
234
  spawn(options) {
230
235
  const claudePath = resolveClaudePath(options.cwd);
231
- const hookScript = path.join(options.cwd, "node_modules/@sna-sdk/core/dist/scripts/hook.js");
236
+ const hookScript = new URL("../../scripts/hook.js", import.meta.url).pathname;
237
+ const sessionId = options.env?.SNA_SESSION_ID ?? "default";
232
238
  const sdkSettings = {};
233
239
  if (options.permissionMode !== "bypassPermissions") {
234
240
  sdkSettings.hooks = {
235
241
  PreToolUse: [{
236
242
  matcher: ".*",
237
- hooks: [{ type: "command", command: `node "${hookScript}"` }]
243
+ hooks: [{ type: "command", command: `node "${hookScript}" --session=${sessionId}` }]
238
244
  }]
239
245
  };
240
246
  }
@@ -16,6 +16,8 @@ interface AgentEvent {
16
16
  interface AgentProcess {
17
17
  /** Send a user message to the agent's stdin. */
18
18
  send(input: string): void;
19
+ /** Interrupt the current turn (SIGINT). Process stays alive. */
20
+ interrupt(): void;
19
21
  /** Kill the agent process. */
20
22
  kill(): void;
21
23
  /** Whether the process is still running. */
@@ -5,6 +5,8 @@ interface ChatSession {
5
5
  id: string;
6
6
  label: string;
7
7
  type: "main" | "background";
8
+ meta: string | null;
9
+ cwd: string | null;
8
10
  created_at: string;
9
11
  }
10
12
  interface ChatMessage {
package/dist/db/schema.js CHANGED
@@ -2,11 +2,20 @@ import { createRequire } from "node:module";
2
2
  import fs from "fs";
3
3
  import path from "path";
4
4
  const DB_PATH = path.join(process.cwd(), "data/sna.db");
5
+ const NATIVE_DIR = path.join(process.cwd(), ".sna/native");
5
6
  let _db = null;
7
+ function loadBetterSqlite3() {
8
+ const nativeEntry = path.join(NATIVE_DIR, "node_modules", "better-sqlite3");
9
+ if (fs.existsSync(nativeEntry)) {
10
+ const req2 = createRequire(path.join(NATIVE_DIR, "noop.js"));
11
+ return req2("better-sqlite3");
12
+ }
13
+ const req = createRequire(import.meta.url);
14
+ return req("better-sqlite3");
15
+ }
6
16
  function getDb() {
7
17
  if (!_db) {
8
- const req = createRequire(import.meta.url);
9
- const BetterSqlite3 = req("better-sqlite3");
18
+ const BetterSqlite3 = loadBetterSqlite3();
10
19
  const dir = path.dirname(DB_PATH);
11
20
  if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
12
21
  _db = new BetterSqlite3(DB_PATH);
@@ -23,13 +32,29 @@ function migrateSkillEvents(db) {
23
32
  db.exec("DROP TABLE IF EXISTS skill_events");
24
33
  }
25
34
  }
35
+ function migrateChatSessionsMeta(db) {
36
+ const cols = db.prepare("PRAGMA table_info(chat_sessions)").all();
37
+ if (cols.length > 0 && !cols.some((c) => c.name === "meta")) {
38
+ db.exec("ALTER TABLE chat_sessions ADD COLUMN meta TEXT");
39
+ }
40
+ if (cols.length > 0 && !cols.some((c) => c.name === "cwd")) {
41
+ db.exec("ALTER TABLE chat_sessions ADD COLUMN cwd TEXT");
42
+ }
43
+ if (cols.length > 0 && !cols.some((c) => c.name === "last_start_config")) {
44
+ db.exec("ALTER TABLE chat_sessions ADD COLUMN last_start_config TEXT");
45
+ }
46
+ }
26
47
  function initSchema(db) {
27
48
  migrateSkillEvents(db);
49
+ migrateChatSessionsMeta(db);
28
50
  db.exec(`
29
51
  CREATE TABLE IF NOT EXISTS chat_sessions (
30
52
  id TEXT PRIMARY KEY,
31
53
  label TEXT NOT NULL DEFAULT '',
32
54
  type TEXT NOT NULL DEFAULT 'main',
55
+ meta TEXT,
56
+ cwd TEXT,
57
+ last_start_config TEXT,
33
58
  created_at TEXT NOT NULL DEFAULT (datetime('now'))
34
59
  );
35
60
 
@@ -5,6 +5,7 @@ declare const tags: {
5
5
  readonly stdin: string;
6
6
  readonly stdout: string;
7
7
  readonly route: string;
8
+ readonly ws: string;
8
9
  readonly err: string;
9
10
  };
10
11
  type Tag = keyof typeof tags;
@@ -19,6 +19,7 @@ const tags = {
19
19
  stdin: chalk.bold.green(" IN "),
20
20
  stdout: chalk.bold.yellow(" OUT "),
21
21
  route: chalk.bold.blue(" API "),
22
+ ws: chalk.bold.green(" WS "),
22
23
  err: chalk.bold.red(" ERR ")
23
24
  };
24
25
  const tagPlain = {
@@ -28,6 +29,7 @@ const tagPlain = {
28
29
  stdin: " IN ",
29
30
  stdout: " OUT ",
30
31
  route: " API ",
32
+ ws: " WS ",
31
33
  err: " ERR "
32
34
  };
33
35
  function appendFile(tag, args) {
@@ -24,7 +24,7 @@ process.stdin.on("end", async () => {
24
24
  allow();
25
25
  return;
26
26
  }
27
- const sessionId = process.env.SNA_SESSION_ID ?? "default";
27
+ const sessionId = process.argv.find((a) => a.startsWith("--session="))?.slice(10) ?? process.env.SNA_SESSION_ID ?? "default";
28
28
  const apiUrl = `http://localhost:${port}`;
29
29
  const res = await fetch(`${apiUrl}/agent/permission-request?session=${encodeURIComponent(sessionId)}`, {
30
30
  method: "POST",
@@ -16,6 +16,7 @@ const SNA_API_LOG_FILE = path.join(STATE_DIR, "sna-api.log");
16
16
  const PORT = process.env.PORT ?? "3000";
17
17
  const CLAUDE_PATH_FILE = path.join(STATE_DIR, "claude-path");
18
18
  const SNA_CORE_DIR = path.join(ROOT, "node_modules/@sna-sdk/core");
19
+ const NATIVE_DIR = path.join(STATE_DIR, "native");
19
20
  function ensureStateDir() {
20
21
  if (!fs.existsSync(STATE_DIR)) fs.mkdirSync(STATE_DIR, { recursive: true });
21
22
  }
@@ -76,8 +77,57 @@ async function checkSnaApiHealth(port) {
76
77
  return false;
77
78
  }
78
79
  }
80
+ function ensureNativeDeps() {
81
+ const marker = path.join(NATIVE_DIR, "node_modules", "better-sqlite3", "build", "Release", "better_sqlite3.node");
82
+ if (fs.existsSync(marker)) {
83
+ try {
84
+ const { createRequire } = require("module");
85
+ const req = createRequire(path.join(NATIVE_DIR, "noop.js"));
86
+ const BS3 = req("better-sqlite3");
87
+ new BS3(":memory:").close();
88
+ return;
89
+ } catch (err) {
90
+ if (!err.message?.includes("NODE_MODULE_VERSION")) return;
91
+ step("Native binary version mismatch \u2014 reinstalling...");
92
+ }
93
+ }
94
+ let version;
95
+ try {
96
+ const pkgPath = require.resolve("better-sqlite3/package.json", { paths: [SNA_CORE_DIR, ROOT] });
97
+ version = JSON.parse(fs.readFileSync(pkgPath, "utf8")).version;
98
+ } catch {
99
+ version = "^12.0.0";
100
+ }
101
+ step(`Installing isolated better-sqlite3@${version} in .sna/native/`);
102
+ fs.mkdirSync(NATIVE_DIR, { recursive: true });
103
+ fs.writeFileSync(path.join(NATIVE_DIR, "package.json"), JSON.stringify({
104
+ name: "sna-native-deps",
105
+ private: true,
106
+ dependencies: { "better-sqlite3": version }
107
+ }));
108
+ try {
109
+ execSync("npm install --no-package-lock --ignore-scripts", { cwd: NATIVE_DIR, stdio: "pipe" });
110
+ execSync("npx --yes prebuild-install -r napi", {
111
+ cwd: path.join(NATIVE_DIR, "node_modules", "better-sqlite3"),
112
+ stdio: "pipe"
113
+ });
114
+ step("Native deps ready");
115
+ } catch (err) {
116
+ try {
117
+ execSync("npm rebuild better-sqlite3", { cwd: NATIVE_DIR, stdio: "pipe" });
118
+ step("Native deps ready (compiled from source)");
119
+ } catch {
120
+ console.error(`
121
+ \u2717 Failed to install isolated better-sqlite3: ${err.message}`);
122
+ console.error(` Try manually: cd .sna/native && npm install
123
+ `);
124
+ process.exit(1);
125
+ }
126
+ }
127
+ }
79
128
  async function cmdApiUp() {
80
129
  const standaloneEntry = path.join(SNA_CORE_DIR, "dist/server/standalone.js");
130
+ ensureNativeDeps();
81
131
  const existingPort = process.env.SNA_PORT ?? readSnaApiPort();
82
132
  if (existingPort && isPortInUse(existingPort)) {
83
133
  const healthy = await checkSnaApiHealth(existingPort);
@@ -0,0 +1,105 @@
1
+ import * as hono from 'hono';
2
+ import { Context } from 'hono';
3
+ import { WebSocket } from 'ws';
4
+ import { SessionInfo } from './session-manager.js';
5
+ import '../core/providers/types.js';
6
+
7
+ interface ApiResponses {
8
+ "sessions.create": {
9
+ status: "created";
10
+ sessionId: string;
11
+ label: string;
12
+ meta: Record<string, unknown> | null;
13
+ };
14
+ "sessions.list": {
15
+ sessions: SessionInfo[];
16
+ };
17
+ "sessions.remove": {
18
+ status: "removed";
19
+ };
20
+ "agent.start": {
21
+ status: "started" | "already_running";
22
+ provider: string;
23
+ sessionId: string;
24
+ };
25
+ "agent.send": {
26
+ status: "sent";
27
+ };
28
+ "agent.restart": {
29
+ status: "restarted";
30
+ provider: string;
31
+ sessionId: string;
32
+ };
33
+ "agent.interrupt": {
34
+ status: "interrupted" | "no_session";
35
+ };
36
+ "agent.kill": {
37
+ status: "killed" | "no_session";
38
+ };
39
+ "agent.status": {
40
+ alive: boolean;
41
+ sessionId: string | null;
42
+ eventCount: number;
43
+ };
44
+ "agent.run-once": {
45
+ result: string;
46
+ usage: Record<string, unknown> | null;
47
+ };
48
+ "emit": {
49
+ id: number;
50
+ };
51
+ "permission.respond": {
52
+ status: "approved" | "denied";
53
+ };
54
+ "permission.pending": {
55
+ pending: Array<{
56
+ sessionId: string;
57
+ request: Record<string, unknown>;
58
+ createdAt: number;
59
+ }>;
60
+ };
61
+ "chat.sessions.list": {
62
+ sessions: Array<{
63
+ id: string;
64
+ label: string;
65
+ type: string;
66
+ meta: Record<string, unknown> | null;
67
+ cwd: string | null;
68
+ created_at: string;
69
+ }>;
70
+ };
71
+ "chat.sessions.create": {
72
+ status: "created";
73
+ id: string;
74
+ meta: Record<string, unknown> | null;
75
+ };
76
+ "chat.sessions.remove": {
77
+ status: "deleted";
78
+ };
79
+ "chat.messages.list": {
80
+ messages: unknown[];
81
+ };
82
+ "chat.messages.create": {
83
+ status: "created";
84
+ id: number;
85
+ };
86
+ "chat.messages.clear": {
87
+ status: "cleared";
88
+ };
89
+ }
90
+ type ApiOp = keyof ApiResponses;
91
+ /**
92
+ * Type-safe JSON response for HTTP routes.
93
+ * Ensures the response body matches the defined shape for the operation.
94
+ */
95
+ declare function httpJson<K extends ApiOp>(c: Context, _op: K, data: ApiResponses[K], status?: number): Response & hono.TypedResponse<any, any, "json">;
96
+ /**
97
+ * Type-safe reply for WS handlers.
98
+ * Ensures the response data matches the defined shape for the operation.
99
+ */
100
+ declare function wsReply<K extends ApiOp>(ws: WebSocket, msg: {
101
+ type: string;
102
+ rid?: string;
103
+ }, data: ApiResponses[K]): void;
104
+
105
+ export { type ApiOp, type ApiResponses, httpJson, wsReply };
@@ -0,0 +1,13 @@
1
+ function httpJson(c, _op, data, status) {
2
+ return c.json(data, status);
3
+ }
4
+ function wsReply(ws, msg, data) {
5
+ if (ws.readyState !== ws.OPEN) return;
6
+ const out = { ...data, type: msg.type };
7
+ if (msg.rid != null) out.rid = msg.rid;
8
+ ws.send(JSON.stringify(out));
9
+ }
10
+ export {
11
+ httpJson,
12
+ wsReply
13
+ };
@@ -1,14 +1,17 @@
1
1
  import * as hono_types from 'hono/types';
2
2
  import { Hono } from 'hono';
3
3
  import { SessionManager } from './session-manager.js';
4
- export { Session, SessionInfo, SessionManagerOptions } from './session-manager.js';
4
+ export { Session, SessionInfo, SessionLifecycleEvent, SessionLifecycleState, SessionManagerOptions, StartConfig } from './session-manager.js';
5
5
  export { eventsRoute } from './routes/events.js';
6
- export { emitRoute } from './routes/emit.js';
6
+ export { createEmitRoute, emitRoute } from './routes/emit.js';
7
7
  export { createRunRoute } from './routes/run.js';
8
8
  export { createAgentRoutes } from './routes/agent.js';
9
9
  export { createChatRoutes } from './routes/chat.js';
10
+ export { attachWebSocket } from './ws.js';
10
11
  import '../core/providers/types.js';
11
12
  import 'hono/utils/http-status';
13
+ import 'ws';
14
+ import 'http';
12
15
 
13
16
  interface SnaAppOptions {
14
17
  /** Commands available via GET /run?skill=<name> */
@@ -2,7 +2,7 @@ import _fs from "fs";
2
2
  import _path from "path";
3
3
  import { Hono } from "hono";
4
4
  import { eventsRoute } from "./routes/events.js";
5
- import { emitRoute } from "./routes/emit.js";
5
+ import { createEmitRoute } from "./routes/emit.js";
6
6
  import { createRunRoute } from "./routes/run.js";
7
7
  import { createAgentRoutes } from "./routes/agent.js";
8
8
  import { createChatRoutes } from "./routes/chat.js";
@@ -12,7 +12,7 @@ function createSnaApp(options = {}) {
12
12
  const app = new Hono();
13
13
  app.get("/health", (c) => c.json({ ok: true, name: "sna", version: "1" }));
14
14
  app.get("/events", eventsRoute);
15
- app.post("/emit", emitRoute);
15
+ app.post("/emit", createEmitRoute(sessionManager));
16
16
  app.route("/agent", createAgentRoutes(sessionManager));
17
17
  app.route("/chat", createChatRoutes());
18
18
  if (options.runCommands) {
@@ -21,11 +21,12 @@ function createSnaApp(options = {}) {
21
21
  return app;
22
22
  }
23
23
  import { eventsRoute as eventsRoute2 } from "./routes/events.js";
24
- import { emitRoute as emitRoute2 } from "./routes/emit.js";
24
+ import { emitRoute, createEmitRoute as createEmitRoute2 } from "./routes/emit.js";
25
25
  import { createRunRoute as createRunRoute2 } from "./routes/run.js";
26
26
  import { createAgentRoutes as createAgentRoutes2 } from "./routes/agent.js";
27
27
  import { createChatRoutes as createChatRoutes2 } from "./routes/chat.js";
28
28
  import { SessionManager as SessionManager2 } from "./session-manager.js";
29
+ import { attachWebSocket } from "./ws.js";
29
30
  function snaPortRoute(c) {
30
31
  const portFile = _path.join(process.cwd(), ".sna/sna-api.port");
31
32
  try {
@@ -37,11 +38,13 @@ function snaPortRoute(c) {
37
38
  }
38
39
  export {
39
40
  SessionManager2 as SessionManager,
41
+ attachWebSocket,
40
42
  createAgentRoutes2 as createAgentRoutes,
41
43
  createChatRoutes2 as createChatRoutes,
44
+ createEmitRoute2 as createEmitRoute,
42
45
  createRunRoute2 as createRunRoute,
43
46
  createSnaApp,
44
- emitRoute2 as emitRoute,
47
+ emitRoute,
45
48
  eventsRoute2 as eventsRoute,
46
49
  snaPortRoute
47
50
  };
@@ -3,6 +3,26 @@ import { Hono } from 'hono';
3
3
  import { SessionManager } from '../session-manager.js';
4
4
  import '../../core/providers/types.js';
5
5
 
6
+ interface RunOnceOptions {
7
+ message: string;
8
+ model?: string;
9
+ systemPrompt?: string;
10
+ appendSystemPrompt?: string;
11
+ permissionMode?: string;
12
+ cwd?: string;
13
+ timeout?: number;
14
+ provider?: string;
15
+ extraArgs?: string[];
16
+ }
17
+ interface RunOnceResult {
18
+ result: string;
19
+ usage: Record<string, unknown> | null;
20
+ }
21
+ /**
22
+ * One-shot agent execution: create temp session → spawn → wait for result → cleanup.
23
+ * Used by both HTTP POST /run-once and WS agent.run-once.
24
+ */
25
+ declare function runOnce(sessionManager: SessionManager, opts: RunOnceOptions): Promise<RunOnceResult>;
6
26
  declare function createAgentRoutes(sessionManager: SessionManager): Hono<hono_types.BlankEnv, hono_types.BlankSchema, "/">;
7
27
 
8
- export { createAgentRoutes };
28
+ export { type RunOnceOptions, type RunOnceResult, createAgentRoutes, runOnce };