@sna-sdk/core 0.1.1 → 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.
@@ -5,9 +5,62 @@ import {
5
5
  } from "../../core/providers/index.js";
6
6
  import { logger } from "../../lib/logger.js";
7
7
  import { getDb } from "../../db/schema.js";
8
+ import { httpJson } from "../api-types.js";
8
9
  function getSessionId(c) {
9
10
  return c.req.query("session") ?? "default";
10
11
  }
12
+ const DEFAULT_RUN_ONCE_TIMEOUT = 12e4;
13
+ async function runOnce(sessionManager, opts) {
14
+ const sessionId = `run-once-${crypto.randomUUID().slice(0, 8)}`;
15
+ const timeout = opts.timeout ?? DEFAULT_RUN_ONCE_TIMEOUT;
16
+ const session = sessionManager.createSession({
17
+ id: sessionId,
18
+ label: "run-once",
19
+ cwd: opts.cwd ?? process.cwd()
20
+ });
21
+ const provider = getProvider(opts.provider ?? "claude-code");
22
+ const extraArgs = opts.extraArgs ? [...opts.extraArgs] : [];
23
+ if (opts.systemPrompt) extraArgs.push("--system-prompt", opts.systemPrompt);
24
+ if (opts.appendSystemPrompt) extraArgs.push("--append-system-prompt", opts.appendSystemPrompt);
25
+ const proc = provider.spawn({
26
+ cwd: session.cwd,
27
+ prompt: opts.message,
28
+ model: opts.model ?? "claude-sonnet-4-6",
29
+ permissionMode: opts.permissionMode ?? "bypassPermissions",
30
+ env: { SNA_SESSION_ID: sessionId },
31
+ extraArgs: extraArgs.length > 0 ? extraArgs : void 0
32
+ });
33
+ sessionManager.setProcess(sessionId, proc);
34
+ try {
35
+ const result = await new Promise((resolve, reject) => {
36
+ const texts = [];
37
+ let usage = null;
38
+ const timer = setTimeout(() => {
39
+ reject(new Error(`run-once timed out after ${timeout}ms`));
40
+ }, timeout);
41
+ const unsub = sessionManager.onSessionEvent(sessionId, (_cursor, e) => {
42
+ if (e.type === "assistant" && e.message) {
43
+ texts.push(e.message);
44
+ }
45
+ if (e.type === "complete") {
46
+ clearTimeout(timer);
47
+ unsub();
48
+ usage = e.data ?? null;
49
+ resolve({ result: texts.join("\n"), usage });
50
+ }
51
+ if (e.type === "error") {
52
+ clearTimeout(timer);
53
+ unsub();
54
+ reject(new Error(e.message ?? "Agent error"));
55
+ }
56
+ });
57
+ });
58
+ return result;
59
+ } finally {
60
+ sessionManager.killSession(sessionId);
61
+ sessionManager.removeSession(sessionId);
62
+ }
63
+ }
11
64
  function createAgentRoutes(sessionManager) {
12
65
  const app = new Hono();
13
66
  app.post("/sessions", async (c) => {
@@ -18,22 +71,15 @@ function createAgentRoutes(sessionManager) {
18
71
  cwd: body.cwd,
19
72
  meta: body.meta
20
73
  });
21
- try {
22
- const db = getDb();
23
- db.prepare(
24
- `INSERT OR IGNORE INTO chat_sessions (id, label, type, meta) VALUES (?, ?, 'main', ?)`
25
- ).run(session.id, session.label, session.meta ? JSON.stringify(session.meta) : null);
26
- } catch {
27
- }
28
74
  logger.log("route", `POST /sessions \u2192 created "${session.id}"`);
29
- return c.json({ status: "created", sessionId: session.id, label: session.label, meta: session.meta });
75
+ return httpJson(c, "sessions.create", { status: "created", sessionId: session.id, label: session.label, meta: session.meta });
30
76
  } catch (e) {
31
77
  logger.err("err", `POST /sessions \u2192 ${e.message}`);
32
78
  return c.json({ status: "error", message: e.message }, 409);
33
79
  }
34
80
  });
35
81
  app.get("/sessions", (c) => {
36
- return c.json({ sessions: sessionManager.listSessions() });
82
+ return httpJson(c, "sessions.list", { sessions: sessionManager.listSessions() });
37
83
  });
38
84
  app.delete("/sessions/:id", (c) => {
39
85
  const id = c.req.param("id");
@@ -45,18 +91,33 @@ function createAgentRoutes(sessionManager) {
45
91
  return c.json({ status: "error", message: "Session not found" }, 404);
46
92
  }
47
93
  logger.log("route", `DELETE /sessions/${id} \u2192 removed`);
48
- return c.json({ status: "removed" });
94
+ return httpJson(c, "sessions.remove", { status: "removed" });
95
+ });
96
+ app.post("/run-once", async (c) => {
97
+ const body = await c.req.json().catch(() => ({}));
98
+ if (!body.message) {
99
+ return c.json({ status: "error", message: "message is required" }, 400);
100
+ }
101
+ try {
102
+ const result = await runOnce(sessionManager, body);
103
+ return httpJson(c, "agent.run-once", result);
104
+ } catch (e) {
105
+ logger.err("err", `POST /run-once \u2192 ${e.message}`);
106
+ return c.json({ status: "error", message: e.message }, 500);
107
+ }
49
108
  });
50
109
  app.post("/start", async (c) => {
51
110
  const sessionId = getSessionId(c);
52
111
  const body = await c.req.json().catch(() => ({}));
53
- const session = sessionManager.getOrCreateSession(sessionId);
112
+ const session = sessionManager.getOrCreateSession(sessionId, {
113
+ cwd: body.cwd
114
+ });
54
115
  if (session.process?.alive && !body.force) {
55
116
  logger.log("route", `POST /start?session=${sessionId} \u2192 already_running`);
56
- return c.json({
117
+ return httpJson(c, "agent.start", {
57
118
  status: "already_running",
58
119
  provider: "claude-code",
59
- sessionId: session.process.sessionId
120
+ sessionId: session.process.sessionId ?? session.id
60
121
  });
61
122
  }
62
123
  if (session.process?.alive) {
@@ -78,18 +139,23 @@ function createAgentRoutes(sessionManager) {
78
139
  }
79
140
  } catch {
80
141
  }
142
+ const providerName = body.provider ?? "claude-code";
143
+ const model = body.model ?? "claude-sonnet-4-6";
144
+ const permissionMode = body.permissionMode ?? "acceptEdits";
145
+ const extraArgs = body.extraArgs;
81
146
  try {
82
147
  const proc = provider.spawn({
83
148
  cwd: session.cwd,
84
149
  prompt: body.prompt,
85
- model: body.model ?? "claude-sonnet-4-6",
86
- permissionMode: body.permissionMode ?? "acceptEdits",
150
+ model,
151
+ permissionMode,
87
152
  env: { SNA_SESSION_ID: sessionId },
88
- extraArgs: body.extraArgs
153
+ extraArgs
89
154
  });
90
155
  sessionManager.setProcess(sessionId, proc);
156
+ sessionManager.saveStartConfig(sessionId, { provider: providerName, model, permissionMode, extraArgs });
91
157
  logger.log("route", `POST /start?session=${sessionId} \u2192 started`);
92
- return c.json({
158
+ return httpJson(c, "agent.start", {
93
159
  status: "started",
94
160
  provider: provider.name,
95
161
  sessionId: session.id
@@ -124,7 +190,7 @@ function createAgentRoutes(sessionManager) {
124
190
  sessionManager.touch(sessionId);
125
191
  logger.log("route", `POST /send?session=${sessionId} \u2192 "${body.message.slice(0, 80)}"`);
126
192
  session.process.send(body.message);
127
- return c.json({ status: "sent" });
193
+ return httpJson(c, "agent.send", { status: "sent" });
128
194
  });
129
195
  app.get("/events", (c) => {
130
196
  const sessionId = getSessionId(c);
@@ -159,79 +225,79 @@ function createAgentRoutes(sessionManager) {
159
225
  }
160
226
  });
161
227
  });
228
+ app.post("/restart", async (c) => {
229
+ const sessionId = getSessionId(c);
230
+ const body = await c.req.json().catch(() => ({}));
231
+ try {
232
+ const { config } = sessionManager.restartSession(sessionId, body, (cfg) => {
233
+ const prov = getProvider(cfg.provider);
234
+ return prov.spawn({
235
+ cwd: sessionManager.getSession(sessionId).cwd,
236
+ model: cfg.model,
237
+ permissionMode: cfg.permissionMode,
238
+ env: { SNA_SESSION_ID: sessionId },
239
+ extraArgs: [...cfg.extraArgs ?? [], "--resume"]
240
+ });
241
+ });
242
+ logger.log("route", `POST /restart?session=${sessionId} \u2192 restarted`);
243
+ return httpJson(c, "agent.restart", {
244
+ status: "restarted",
245
+ provider: config.provider,
246
+ sessionId
247
+ });
248
+ } catch (e) {
249
+ logger.err("err", `POST /restart?session=${sessionId} \u2192 ${e.message}`);
250
+ return c.json({ status: "error", message: e.message }, 500);
251
+ }
252
+ });
253
+ app.post("/interrupt", async (c) => {
254
+ const sessionId = getSessionId(c);
255
+ const interrupted = sessionManager.interruptSession(sessionId);
256
+ return httpJson(c, "agent.interrupt", { status: interrupted ? "interrupted" : "no_session" });
257
+ });
162
258
  app.post("/kill", async (c) => {
163
259
  const sessionId = getSessionId(c);
164
260
  const killed = sessionManager.killSession(sessionId);
165
- return c.json({ status: killed ? "killed" : "no_session" });
261
+ return httpJson(c, "agent.kill", { status: killed ? "killed" : "no_session" });
166
262
  });
167
263
  app.get("/status", (c) => {
168
264
  const sessionId = getSessionId(c);
169
265
  const session = sessionManager.getSession(sessionId);
170
- return c.json({
266
+ return httpJson(c, "agent.status", {
171
267
  alive: session?.process?.alive ?? false,
172
268
  sessionId: session?.process?.sessionId ?? null,
173
269
  eventCount: session?.eventCounter ?? 0
174
270
  });
175
271
  });
176
- const pendingPermissions = /* @__PURE__ */ new Map();
177
272
  app.post("/permission-request", async (c) => {
178
273
  const sessionId = getSessionId(c);
179
274
  const body = await c.req.json().catch(() => ({}));
180
275
  logger.log("route", `POST /permission-request?session=${sessionId} \u2192 ${body.tool_name}`);
181
- const session = sessionManager.getSession(sessionId);
182
- if (session) session.state = "permission";
183
- const result = await new Promise((resolve) => {
184
- pendingPermissions.set(sessionId, {
185
- resolve,
186
- request: body,
187
- createdAt: Date.now()
188
- });
189
- setTimeout(() => {
190
- if (pendingPermissions.has(sessionId)) {
191
- pendingPermissions.delete(sessionId);
192
- resolve(false);
193
- }
194
- }, 3e5);
195
- });
276
+ const result = await sessionManager.createPendingPermission(sessionId, body);
196
277
  return c.json({ approved: result });
197
278
  });
198
279
  app.post("/permission-respond", async (c) => {
199
280
  const sessionId = getSessionId(c);
200
281
  const body = await c.req.json().catch(() => ({}));
201
282
  const approved = body.approved ?? false;
202
- const pending = pendingPermissions.get(sessionId);
203
- if (!pending) {
283
+ const resolved = sessionManager.resolvePendingPermission(sessionId, approved);
284
+ if (!resolved) {
204
285
  return c.json({ status: "error", message: "No pending permission request" }, 404);
205
286
  }
206
- pending.resolve(approved);
207
- pendingPermissions.delete(sessionId);
208
- const session = sessionManager.getSession(sessionId);
209
- if (session) session.state = "processing";
210
287
  logger.log("route", `POST /permission-respond?session=${sessionId} \u2192 ${approved ? "approved" : "denied"}`);
211
- return c.json({ status: approved ? "approved" : "denied" });
288
+ return httpJson(c, "permission.respond", { status: approved ? "approved" : "denied" });
212
289
  });
213
290
  app.get("/permission-pending", (c) => {
214
291
  const sessionId = c.req.query("session");
215
292
  if (sessionId) {
216
- const pending = pendingPermissions.get(sessionId);
217
- if (!pending) return c.json({ pending: null });
218
- return c.json({
219
- pending: {
220
- sessionId,
221
- request: pending.request,
222
- createdAt: pending.createdAt
223
- }
224
- });
293
+ const pending = sessionManager.getPendingPermission(sessionId);
294
+ return httpJson(c, "permission.pending", { pending: pending ? [{ sessionId, ...pending }] : [] });
225
295
  }
226
- const all = Array.from(pendingPermissions.entries()).map(([id, p]) => ({
227
- sessionId: id,
228
- request: p.request,
229
- createdAt: p.createdAt
230
- }));
231
- return c.json({ pending: all });
296
+ return httpJson(c, "permission.pending", { pending: sessionManager.getAllPendingPermissions() });
232
297
  });
233
298
  return app;
234
299
  }
235
300
  export {
236
- createAgentRoutes
301
+ createAgentRoutes,
302
+ runOnce
237
303
  };
@@ -1,18 +1,19 @@
1
1
  import { Hono } from "hono";
2
2
  import { getDb } from "../../db/schema.js";
3
+ import { httpJson } from "../api-types.js";
3
4
  function createChatRoutes() {
4
5
  const app = new Hono();
5
6
  app.get("/sessions", (c) => {
6
7
  try {
7
8
  const db = getDb();
8
9
  const rows = db.prepare(
9
- `SELECT id, label, type, meta, created_at FROM chat_sessions ORDER BY created_at DESC`
10
+ `SELECT id, label, type, meta, cwd, created_at FROM chat_sessions ORDER BY created_at DESC`
10
11
  ).all();
11
12
  const sessions = rows.map((r) => ({
12
13
  ...r,
13
14
  meta: r.meta ? JSON.parse(r.meta) : null
14
15
  }));
15
- return c.json({ sessions });
16
+ return httpJson(c, "chat.sessions.list", { sessions });
16
17
  } catch (e) {
17
18
  return c.json({ status: "error", message: e.message, stack: e.stack }, 500);
18
19
  }
@@ -25,7 +26,7 @@ function createChatRoutes() {
25
26
  db.prepare(
26
27
  `INSERT OR IGNORE INTO chat_sessions (id, label, type, meta) VALUES (?, ?, ?, ?)`
27
28
  ).run(id, body.label ?? id, body.type ?? "background", body.meta ? JSON.stringify(body.meta) : null);
28
- return c.json({ status: "created", id, meta: body.meta ?? null });
29
+ return httpJson(c, "chat.sessions.create", { status: "created", id, meta: body.meta ?? null });
29
30
  } catch (e) {
30
31
  return c.json({ status: "error", message: e.message }, 500);
31
32
  }
@@ -38,7 +39,7 @@ function createChatRoutes() {
38
39
  try {
39
40
  const db = getDb();
40
41
  db.prepare(`DELETE FROM chat_sessions WHERE id = ?`).run(id);
41
- return c.json({ status: "deleted" });
42
+ return httpJson(c, "chat.sessions.remove", { status: "deleted" });
42
43
  } catch (e) {
43
44
  return c.json({ status: "error", message: e.message }, 500);
44
45
  }
@@ -50,7 +51,7 @@ function createChatRoutes() {
50
51
  const db = getDb();
51
52
  const query = sinceParam ? db.prepare(`SELECT * FROM chat_messages WHERE session_id = ? AND id > ? ORDER BY id ASC`) : db.prepare(`SELECT * FROM chat_messages WHERE session_id = ? ORDER BY id ASC`);
52
53
  const messages = sinceParam ? query.all(id, parseInt(sinceParam, 10)) : query.all(id);
53
- return c.json({ messages });
54
+ return httpJson(c, "chat.messages.list", { messages });
54
55
  } catch (e) {
55
56
  return c.json({ status: "error", message: e.message, stack: e.stack }, 500);
56
57
  }
@@ -73,7 +74,7 @@ function createChatRoutes() {
73
74
  body.skill_name ?? null,
74
75
  body.meta ? JSON.stringify(body.meta) : null
75
76
  );
76
- return c.json({ status: "created", id: result.lastInsertRowid });
77
+ return httpJson(c, "chat.messages.create", { status: "created", id: Number(result.lastInsertRowid) });
77
78
  } catch (e) {
78
79
  return c.json({ status: "error", message: e.message }, 500);
79
80
  }
@@ -83,7 +84,7 @@ function createChatRoutes() {
83
84
  try {
84
85
  const db = getDb();
85
86
  db.prepare(`DELETE FROM chat_messages WHERE session_id = ?`).run(id);
86
- return c.json({ status: "cleared" });
87
+ return httpJson(c, "chat.messages.clear", { status: "cleared" });
87
88
  } catch (e) {
88
89
  return c.json({ status: "error", message: e.message }, 500);
89
90
  }
@@ -1,11 +1,21 @@
1
1
  import * as hono_utils_http_status from 'hono/utils/http-status';
2
2
  import * as hono from 'hono';
3
3
  import { Context } from 'hono';
4
+ import { SessionManager } from '../session-manager.js';
5
+ import '../../core/providers/types.js';
4
6
 
7
+ /**
8
+ * Create an emit route handler that broadcasts to WS subscribers.
9
+ */
10
+ declare function createEmitRoute(sessionManager: SessionManager): (c: Context) => Promise<Response & hono.TypedResponse<any, any, "json">>;
11
+ /**
12
+ * Legacy plain handler (no broadcast). Kept for backward compatibility
13
+ * when consumers import emitRoute directly without SessionManager.
14
+ */
5
15
  declare function emitRoute(c: Context): Promise<(Response & hono.TypedResponse<{
6
16
  error: string;
7
17
  }, 400, "json">) | (Response & hono.TypedResponse<{
8
18
  id: number;
9
19
  }, hono_utils_http_status.ContentfulStatusCode, "json">)>;
10
20
 
11
- export { emitRoute };
21
+ export { createEmitRoute, emitRoute };
@@ -1,4 +1,29 @@
1
1
  import { getDb } from "../../db/schema.js";
2
+ import { httpJson } from "../api-types.js";
3
+ function createEmitRoute(sessionManager) {
4
+ return async (c) => {
5
+ const body = await c.req.json();
6
+ const { skill, type, message, data, session_id } = body;
7
+ if (!skill || !type || !message) {
8
+ return c.json({ error: "missing fields" }, 400);
9
+ }
10
+ const db = getDb();
11
+ const result = db.prepare(
12
+ `INSERT INTO skill_events (session_id, skill, type, message, data) VALUES (?, ?, ?, ?, ?)`
13
+ ).run(session_id ?? null, skill, type, message, data ?? null);
14
+ const id = Number(result.lastInsertRowid);
15
+ sessionManager.broadcastSkillEvent({
16
+ id,
17
+ session_id: session_id ?? null,
18
+ skill,
19
+ type,
20
+ message,
21
+ data: data ?? null,
22
+ created_at: (/* @__PURE__ */ new Date()).toISOString()
23
+ });
24
+ return httpJson(c, "emit", { id });
25
+ };
26
+ }
2
27
  async function emitRoute(c) {
3
28
  const { skill, type, message, data } = await c.req.json();
4
29
  if (!skill || !type || !message) {
@@ -11,5 +36,6 @@ async function emitRoute(c) {
11
36
  return c.json({ id: result.lastInsertRowid });
12
37
  }
13
38
  export {
39
+ createEmitRoute,
14
40
  emitRoute
15
41
  };
@@ -8,6 +8,12 @@ import { AgentProcess, AgentEvent } from '../core/providers/types.js';
8
8
  */
9
9
 
10
10
  type SessionState = "idle" | "processing" | "waiting" | "permission";
11
+ interface StartConfig {
12
+ provider: string;
13
+ model: string;
14
+ permissionMode: string;
15
+ extraArgs?: string[];
16
+ }
11
17
  interface Session {
12
18
  id: string;
13
19
  process: AgentProcess | null;
@@ -17,6 +23,7 @@ interface Session {
17
23
  cwd: string;
18
24
  meta: Record<string, unknown> | null;
19
25
  state: SessionState;
26
+ lastStartConfig: StartConfig | null;
20
27
  createdAt: number;
21
28
  lastActivityAt: number;
22
29
  }
@@ -34,10 +41,25 @@ interface SessionInfo {
34
41
  interface SessionManagerOptions {
35
42
  maxSessions?: number;
36
43
  }
44
+ type SessionLifecycleState = "started" | "killed" | "exited" | "crashed" | "restarted";
45
+ interface SessionLifecycleEvent {
46
+ session: string;
47
+ state: SessionLifecycleState;
48
+ code?: number | null;
49
+ }
37
50
  declare class SessionManager {
38
51
  private sessions;
39
52
  private maxSessions;
53
+ private eventListeners;
54
+ private pendingPermissions;
55
+ private skillEventListeners;
56
+ private permissionRequestListeners;
57
+ private lifecycleListeners;
40
58
  constructor(options?: SessionManagerOptions);
59
+ /** Restore session metadata from DB (cwd, label, meta). Process state is not restored. */
60
+ private restoreFromDb;
61
+ /** Persist session metadata to DB. */
62
+ private persistSession;
41
63
  /** Create a new session. Throws if max sessions reached. */
42
64
  createSession(opts?: {
43
65
  id?: string;
@@ -54,6 +76,41 @@ declare class SessionManager {
54
76
  }): Session;
55
77
  /** Set the agent process for a session. Subscribes to events. */
56
78
  setProcess(sessionId: string, proc: AgentProcess): void;
79
+ /** Subscribe to real-time events for a session. Returns unsubscribe function. */
80
+ onSessionEvent(sessionId: string, cb: (cursor: number, event: AgentEvent) => void): () => void;
81
+ /** Subscribe to skill events broadcast. Returns unsubscribe function. */
82
+ onSkillEvent(cb: (event: Record<string, unknown>) => void): () => void;
83
+ /** Broadcast a skill event to all subscribers (called after DB insert). */
84
+ broadcastSkillEvent(event: Record<string, unknown>): void;
85
+ /** Subscribe to permission request notifications. Returns unsubscribe function. */
86
+ onPermissionRequest(cb: (sessionId: string, request: Record<string, unknown>, createdAt: number) => void): () => void;
87
+ /** Subscribe to session lifecycle events (started/killed/exited/crashed). Returns unsubscribe function. */
88
+ onSessionLifecycle(cb: (event: SessionLifecycleEvent) => void): () => void;
89
+ private emitLifecycle;
90
+ /** Create a pending permission request. Returns a promise that resolves when approved/denied. */
91
+ createPendingPermission(sessionId: string, request: Record<string, unknown>): Promise<boolean>;
92
+ /** Resolve a pending permission request. Returns false if no pending request. */
93
+ resolvePendingPermission(sessionId: string, approved: boolean): boolean;
94
+ /** Get a pending permission for a specific session. */
95
+ getPendingPermission(sessionId: string): {
96
+ request: Record<string, unknown>;
97
+ createdAt: number;
98
+ } | null;
99
+ /** Get all pending permissions across sessions. */
100
+ getAllPendingPermissions(): Array<{
101
+ sessionId: string;
102
+ request: Record<string, unknown>;
103
+ createdAt: number;
104
+ }>;
105
+ /** Kill the agent process in a session (session stays, can be restarted). */
106
+ /** Save the start config for a session (called by start handlers). */
107
+ saveStartConfig(id: string, config: StartConfig): void;
108
+ /** Restart session: kill → re-spawn with merged config + --resume. */
109
+ restartSession(id: string, overrides: Partial<StartConfig>, spawnFn: (config: StartConfig) => AgentProcess): {
110
+ config: StartConfig;
111
+ };
112
+ /** Interrupt the current turn (SIGINT). Process stays alive, returns to waiting. */
113
+ interruptSession(id: string): boolean;
57
114
  /** Kill the agent process in a session (session stays, can be restarted). */
58
115
  killSession(id: string): boolean;
59
116
  /** Remove a session entirely. Cannot remove "default". */
@@ -69,4 +126,4 @@ declare class SessionManager {
69
126
  get size(): number;
70
127
  }
71
128
 
72
- export { type Session, type SessionInfo, SessionManager, type SessionManagerOptions, type SessionState };
129
+ export { type Session, type SessionInfo, type SessionLifecycleEvent, type SessionLifecycleState, SessionManager, type SessionManagerOptions, type SessionState, type StartConfig };