@sna-sdk/core 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -5,6 +5,7 @@ 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 { buildHistoryFromDb } from "../history-builder.js";
8
9
  import { httpJson } from "../api-types.js";
9
10
  import { saveImages } from "../image-store.js";
10
11
  function getSessionId(c) {
@@ -194,7 +195,13 @@ function createAgentRoutes(sessionManager) {
194
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);
195
196
  } catch {
196
197
  }
197
- session.state = "processing";
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
+ });
204
+ sessionManager.updateSessionState(sessionId, "processing");
198
205
  sessionManager.touch(sessionId);
199
206
  if (body.images?.length) {
200
207
  const content = [
@@ -216,32 +223,59 @@ function createAgentRoutes(sessionManager) {
216
223
  const sessionId = getSessionId(c);
217
224
  const session = sessionManager.getOrCreateSession(sessionId);
218
225
  const sinceParam = c.req.query("since");
219
- let cursor = sinceParam ? parseInt(sinceParam, 10) : session.eventCounter;
226
+ const sinceCursor = sinceParam ? parseInt(sinceParam, 10) : session.eventCounter;
220
227
  return streamSSE(c, async (stream) => {
221
- const POLL_MS = 300;
222
228
  const KEEPALIVE_MS = 15e3;
223
- let lastSend = Date.now();
224
- while (true) {
229
+ const signal = c.req.raw.signal;
230
+ const queue = [];
231
+ let wakeUp = null;
232
+ const unsub = sessionManager.onSessionEvent(sessionId, (eventCursor, event) => {
233
+ queue.push({ cursor: eventCursor, event });
234
+ const fn = wakeUp;
235
+ wakeUp = null;
236
+ fn?.();
237
+ });
238
+ signal.addEventListener("abort", () => {
239
+ const fn = wakeUp;
240
+ wakeUp = null;
241
+ fn?.();
242
+ });
243
+ try {
244
+ let cursor = sinceCursor;
225
245
  if (cursor < session.eventCounter) {
226
246
  const startIdx = Math.max(
227
247
  0,
228
248
  session.eventBuffer.length - (session.eventCounter - cursor)
229
249
  );
230
- const newEvents = session.eventBuffer.slice(startIdx);
231
- for (const event of newEvents) {
250
+ for (const event of session.eventBuffer.slice(startIdx)) {
232
251
  cursor++;
233
- await stream.writeSSE({
234
- id: String(cursor),
235
- data: JSON.stringify(event)
236
- });
237
- lastSend = Date.now();
252
+ await stream.writeSSE({ id: String(cursor), data: JSON.stringify(event) });
238
253
  }
254
+ } else {
255
+ cursor = session.eventCounter;
239
256
  }
240
- if (Date.now() - lastSend > KEEPALIVE_MS) {
241
- await stream.writeSSE({ data: "" });
242
- lastSend = Date.now();
257
+ while (queue.length > 0 && queue[0].cursor <= cursor) queue.shift();
258
+ while (!signal.aborted) {
259
+ if (queue.length === 0) {
260
+ await Promise.race([
261
+ new Promise((r) => {
262
+ wakeUp = r;
263
+ }),
264
+ new Promise((r) => setTimeout(r, KEEPALIVE_MS))
265
+ ]);
266
+ }
267
+ if (signal.aborted) break;
268
+ if (queue.length > 0) {
269
+ while (queue.length > 0) {
270
+ const item = queue.shift();
271
+ await stream.writeSSE({ id: String(item.cursor), data: JSON.stringify(item.event) });
272
+ }
273
+ } else {
274
+ await stream.writeSSE({ data: "" });
275
+ }
243
276
  }
244
- await new Promise((r) => setTimeout(r, POLL_MS));
277
+ } finally {
278
+ unsub();
245
279
  }
246
280
  });
247
281
  });
@@ -272,6 +306,46 @@ function createAgentRoutes(sessionManager) {
272
306
  return c.json({ status: "error", message: e.message }, 500);
273
307
  }
274
308
  });
309
+ app.post("/resume", async (c) => {
310
+ const sessionId = getSessionId(c);
311
+ const body = await c.req.json().catch(() => ({}));
312
+ const session = sessionManager.getOrCreateSession(sessionId);
313
+ if (session.process?.alive) {
314
+ return c.json({ status: "error", message: "Session already running. Use agent.send instead." }, 400);
315
+ }
316
+ const history = buildHistoryFromDb(sessionId);
317
+ if (history.length === 0 && !body.prompt) {
318
+ return c.json({ status: "error", message: "No history in DB \u2014 nothing to resume." }, 400);
319
+ }
320
+ const providerName = body.provider ?? "claude-code";
321
+ const model = body.model ?? session.lastStartConfig?.model ?? "claude-sonnet-4-6";
322
+ const permissionMode = body.permissionMode ?? session.lastStartConfig?.permissionMode ?? "acceptEdits";
323
+ const extraArgs = body.extraArgs ?? session.lastStartConfig?.extraArgs;
324
+ const provider = getProvider(providerName);
325
+ try {
326
+ const proc = provider.spawn({
327
+ cwd: session.cwd,
328
+ prompt: body.prompt,
329
+ model,
330
+ permissionMode,
331
+ env: { SNA_SESSION_ID: sessionId },
332
+ history: history.length > 0 ? history : void 0,
333
+ extraArgs
334
+ });
335
+ sessionManager.setProcess(sessionId, proc, "resumed");
336
+ sessionManager.saveStartConfig(sessionId, { provider: providerName, model, permissionMode, extraArgs });
337
+ logger.log("route", `POST /resume?session=${sessionId} \u2192 resumed (${history.length} history msgs)`);
338
+ return httpJson(c, "agent.resume", {
339
+ status: "resumed",
340
+ provider: providerName,
341
+ sessionId: session.id,
342
+ historyCount: history.length
343
+ });
344
+ } catch (e) {
345
+ logger.err("err", `POST /resume?session=${sessionId} \u2192 ${e.message}`);
346
+ return c.json({ status: "error", message: e.message }, 500);
347
+ }
348
+ });
275
349
  app.post("/interrupt", async (c) => {
276
350
  const sessionId = getSessionId(c);
277
351
  const interrupted = sessionManager.interruptSession(sessionId);
@@ -299,11 +373,25 @@ function createAgentRoutes(sessionManager) {
299
373
  app.get("/status", (c) => {
300
374
  const sessionId = getSessionId(c);
301
375
  const session = sessionManager.getSession(sessionId);
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
+ }
302
387
  return httpJson(c, "agent.status", {
303
- alive: session?.process?.alive ?? false,
388
+ alive,
389
+ agentStatus: !alive ? "disconnected" : session?.state === "processing" ? "busy" : "idle",
304
390
  sessionId: session?.process?.sessionId ?? null,
305
391
  ccSessionId: session?.ccSessionId ?? null,
306
392
  eventCount: session?.eventCounter ?? 0,
393
+ messageCount,
394
+ lastMessage,
307
395
  config: session?.lastStartConfig ?? null
308
396
  });
309
397
  });
@@ -29,23 +29,31 @@ interface Session {
29
29
  createdAt: number;
30
30
  lastActivityAt: number;
31
31
  }
32
+ type AgentStatus = "idle" | "busy" | "disconnected";
32
33
  interface SessionInfo {
33
34
  id: string;
34
35
  label: string;
35
36
  alive: boolean;
36
37
  state: SessionState;
38
+ agentStatus: AgentStatus;
37
39
  cwd: string;
38
40
  meta: Record<string, unknown> | null;
39
41
  config: StartConfig | null;
40
42
  ccSessionId: string | null;
41
43
  eventCount: number;
44
+ messageCount: number;
45
+ lastMessage: {
46
+ role: string;
47
+ content: string;
48
+ created_at: string;
49
+ } | null;
42
50
  createdAt: number;
43
51
  lastActivityAt: number;
44
52
  }
45
53
  interface SessionManagerOptions {
46
54
  maxSessions?: number;
47
55
  }
48
- type SessionLifecycleState = "started" | "killed" | "exited" | "crashed" | "restarted";
56
+ type SessionLifecycleState = "started" | "resumed" | "killed" | "exited" | "crashed" | "restarted";
49
57
  interface SessionLifecycleEvent {
50
58
  session: string;
51
59
  state: SessionLifecycleState;
@@ -64,6 +72,7 @@ declare class SessionManager {
64
72
  private permissionRequestListeners;
65
73
  private lifecycleListeners;
66
74
  private configChangedListeners;
75
+ private stateChangedListeners;
67
76
  constructor(options?: SessionManagerOptions);
68
77
  /** Restore session metadata from DB (cwd, label, meta). Process state is not restored. */
69
78
  private restoreFromDb;
@@ -84,13 +93,15 @@ declare class SessionManager {
84
93
  cwd?: string;
85
94
  }): Session;
86
95
  /** Set the agent process for a session. Subscribes to events. */
87
- setProcess(sessionId: string, proc: AgentProcess): void;
96
+ setProcess(sessionId: string, proc: AgentProcess, lifecycleState?: SessionLifecycleState): void;
88
97
  /** Subscribe to real-time events for a session. Returns unsubscribe function. */
89
98
  onSessionEvent(sessionId: string, cb: (cursor: number, event: AgentEvent) => void): () => void;
90
99
  /** Subscribe to skill events broadcast. Returns unsubscribe function. */
91
100
  onSkillEvent(cb: (event: Record<string, unknown>) => void): () => void;
92
101
  /** Broadcast a skill event to all subscribers (called after DB insert). */
93
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;
94
105
  /** Subscribe to permission request notifications. Returns unsubscribe function. */
95
106
  onPermissionRequest(cb: (sessionId: string, request: Record<string, unknown>, createdAt: number) => void): () => void;
96
107
  /** Subscribe to session lifecycle events (started/killed/exited/crashed). Returns unsubscribe function. */
@@ -99,6 +110,14 @@ declare class SessionManager {
99
110
  /** Subscribe to session config changes. Returns unsubscribe function. */
100
111
  onConfigChanged(cb: (event: SessionConfigChangedEvent) => void): () => void;
101
112
  private emitConfigChanged;
113
+ onStateChanged(cb: (event: {
114
+ session: string;
115
+ agentStatus: AgentStatus;
116
+ state: SessionState;
117
+ }) => void): () => void;
118
+ /** Update session state and push agentStatus change to subscribers. */
119
+ updateSessionState(sessionId: string, newState: SessionState): void;
120
+ private setSessionState;
102
121
  /** Create a pending permission request. Returns a promise that resolves when approved/denied. */
103
122
  createPendingPermission(sessionId: string, request: Record<string, unknown>): Promise<boolean>;
104
123
  /** Resolve a pending permission request. Returns false if no pending request. */
@@ -136,10 +155,11 @@ declare class SessionManager {
136
155
  /** Touch a session's lastActivityAt timestamp. */
137
156
  touch(id: string): void;
138
157
  /** Persist an agent event to chat_messages. */
158
+ private getMessageStats;
139
159
  private persistEvent;
140
160
  /** Kill all sessions. Used during shutdown. */
141
161
  killAll(): void;
142
162
  get size(): number;
143
163
  }
144
164
 
145
- export { type Session, type SessionConfigChangedEvent, type SessionInfo, type SessionLifecycleEvent, type SessionLifecycleState, SessionManager, type SessionManagerOptions, type SessionState, type StartConfig };
165
+ export { type AgentStatus, type Session, type SessionConfigChangedEvent, type SessionInfo, type SessionLifecycleEvent, type SessionLifecycleState, SessionManager, type SessionManagerOptions, type SessionState, type StartConfig };
@@ -11,6 +11,7 @@ class SessionManager {
11
11
  this.permissionRequestListeners = /* @__PURE__ */ new Set();
12
12
  this.lifecycleListeners = /* @__PURE__ */ new Set();
13
13
  this.configChangedListeners = /* @__PURE__ */ new Set();
14
+ this.stateChangedListeners = /* @__PURE__ */ new Set();
14
15
  this.maxSessions = options.maxSessions ?? DEFAULT_MAX_SESSIONS;
15
16
  this.restoreFromDb();
16
17
  }
@@ -123,24 +124,26 @@ class SessionManager {
123
124
  return this.createSession({ id, ...opts });
124
125
  }
125
126
  /** Set the agent process for a session. Subscribes to events. */
126
- setProcess(sessionId, proc) {
127
+ setProcess(sessionId, proc, lifecycleState) {
127
128
  const session = this.sessions.get(sessionId);
128
129
  if (!session) throw new Error(`Session "${sessionId}" not found`);
129
130
  session.process = proc;
130
- session.state = "processing";
131
+ this.setSessionState(sessionId, session, "processing");
131
132
  session.lastActivityAt = Date.now();
132
133
  proc.on("event", (e) => {
133
134
  if (e.type === "init" && e.data?.sessionId && !session.ccSessionId) {
134
135
  session.ccSessionId = e.data.sessionId;
135
136
  this.persistSession(session);
136
137
  }
137
- session.eventBuffer.push(e);
138
- session.eventCounter++;
139
- if (session.eventBuffer.length > MAX_EVENT_BUFFER) {
140
- session.eventBuffer.splice(0, session.eventBuffer.length - MAX_EVENT_BUFFER);
138
+ if (e.type !== "assistant_delta") {
139
+ session.eventBuffer.push(e);
140
+ if (session.eventBuffer.length > MAX_EVENT_BUFFER) {
141
+ session.eventBuffer.splice(0, session.eventBuffer.length - MAX_EVENT_BUFFER);
142
+ }
141
143
  }
144
+ session.eventCounter++;
142
145
  if (e.type === "complete" || e.type === "error" || e.type === "interrupted") {
143
- session.state = "waiting";
146
+ this.setSessionState(sessionId, session, "waiting");
144
147
  }
145
148
  this.persistEvent(sessionId, e);
146
149
  const listeners = this.eventListeners.get(sessionId);
@@ -149,14 +152,14 @@ class SessionManager {
149
152
  }
150
153
  });
151
154
  proc.on("exit", (code) => {
152
- session.state = "idle";
155
+ this.setSessionState(sessionId, session, "idle");
153
156
  this.emitLifecycle({ session: sessionId, state: code != null ? "exited" : "crashed", code });
154
157
  });
155
158
  proc.on("error", () => {
156
- session.state = "idle";
159
+ this.setSessionState(sessionId, session, "idle");
157
160
  this.emitLifecycle({ session: sessionId, state: "crashed" });
158
161
  });
159
- this.emitLifecycle({ session: sessionId, state: "started" });
162
+ this.emitLifecycle({ session: sessionId, state: lifecycleState ?? "started" });
160
163
  }
161
164
  // ── Event pub/sub (for WebSocket) ─────────────────────────────
162
165
  /** Subscribe to real-time events for a session. Returns unsubscribe function. */
@@ -182,6 +185,20 @@ class SessionManager {
182
185
  broadcastSkillEvent(event) {
183
186
  for (const cb of this.skillEventListeners) cb(event);
184
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
+ }
185
202
  // ── Permission pub/sub ────────────────────────────────────────
186
203
  /** Subscribe to permission request notifications. Returns unsubscribe function. */
187
204
  onPermissionRequest(cb) {
@@ -206,11 +223,29 @@ class SessionManager {
206
223
  emitConfigChanged(sessionId, config) {
207
224
  for (const cb of this.configChangedListeners) cb({ session: sessionId, config });
208
225
  }
226
+ // ── Agent status change pub/sub ────────────────────────────────
227
+ onStateChanged(cb) {
228
+ this.stateChangedListeners.add(cb);
229
+ return () => this.stateChangedListeners.delete(cb);
230
+ }
231
+ /** Update session state and push agentStatus change to subscribers. */
232
+ updateSessionState(sessionId, newState) {
233
+ const session = this.sessions.get(sessionId);
234
+ if (session) this.setSessionState(sessionId, session, newState);
235
+ }
236
+ setSessionState(sessionId, session, newState) {
237
+ const oldState = session.state;
238
+ session.state = newState;
239
+ const newStatus = !session.process?.alive ? "disconnected" : newState === "processing" ? "busy" : "idle";
240
+ if (oldState !== newState) {
241
+ for (const cb of this.stateChangedListeners) cb({ session: sessionId, agentStatus: newStatus, state: newState });
242
+ }
243
+ }
209
244
  // ── Permission management ─────────────────────────────────────
210
245
  /** Create a pending permission request. Returns a promise that resolves when approved/denied. */
211
246
  createPendingPermission(sessionId, request) {
212
247
  const session = this.sessions.get(sessionId);
213
- if (session) session.state = "permission";
248
+ if (session) this.setSessionState(sessionId, session, "permission");
214
249
  return new Promise((resolve) => {
215
250
  const createdAt = Date.now();
216
251
  this.pendingPermissions.set(sessionId, { resolve, request, createdAt });
@@ -230,7 +265,7 @@ class SessionManager {
230
265
  pending.resolve(approved);
231
266
  this.pendingPermissions.delete(sessionId);
232
267
  const session = this.sessions.get(sessionId);
233
- if (session) session.state = "processing";
268
+ if (session) this.setSessionState(sessionId, session, "processing");
234
269
  return true;
235
270
  }
236
271
  /** Get a pending permission for a specific session. */
@@ -282,7 +317,7 @@ class SessionManager {
282
317
  const session = this.sessions.get(id);
283
318
  if (!session?.process?.alive) return false;
284
319
  session.process.interrupt();
285
- session.state = "waiting";
320
+ this.setSessionState(id, session, "waiting");
286
321
  return true;
287
322
  }
288
323
  /** Change model. Sends control message if alive, always persists to config. */
@@ -339,11 +374,13 @@ class SessionManager {
339
374
  label: s.label,
340
375
  alive: s.process?.alive ?? false,
341
376
  state: s.state,
377
+ agentStatus: !s.process?.alive ? "disconnected" : s.state === "processing" ? "busy" : "idle",
342
378
  cwd: s.cwd,
343
379
  meta: s.meta,
344
380
  config: s.lastStartConfig,
345
381
  ccSessionId: s.ccSessionId,
346
382
  eventCount: s.eventCounter,
383
+ ...this.getMessageStats(s.id),
347
384
  createdAt: s.createdAt,
348
385
  lastActivityAt: s.lastActivityAt
349
386
  }));
@@ -354,6 +391,23 @@ class SessionManager {
354
391
  if (session) session.lastActivityAt = Date.now();
355
392
  }
356
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
+ }
357
411
  persistEvent(sessionId, e) {
358
412
  try {
359
413
  const db = getDb();