@sna-sdk/core 0.2.3 → 0.3.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.
@@ -1,6 +1,8 @@
1
1
  import { Hono } from "hono";
2
+ import fs from "fs";
2
3
  import { getDb } from "../../db/schema.js";
3
4
  import { httpJson } from "../api-types.js";
5
+ import { resolveImagePath } from "../image-store.js";
4
6
  function createChatRoutes() {
5
7
  const app = new Hono();
6
8
  app.get("/sessions", (c) => {
@@ -89,6 +91,26 @@ function createChatRoutes() {
89
91
  return c.json({ status: "error", message: e.message }, 500);
90
92
  }
91
93
  });
94
+ app.get("/images/:sessionId/:filename", (c) => {
95
+ const sessionId = c.req.param("sessionId");
96
+ const filename = c.req.param("filename");
97
+ const filePath = resolveImagePath(sessionId, filename);
98
+ if (!filePath) {
99
+ return c.json({ status: "error", message: "Image not found" }, 404);
100
+ }
101
+ const ext = filename.split(".").pop()?.toLowerCase();
102
+ const mimeMap = {
103
+ png: "image/png",
104
+ jpg: "image/jpeg",
105
+ jpeg: "image/jpeg",
106
+ gif: "image/gif",
107
+ webp: "image/webp",
108
+ svg: "image/svg+xml"
109
+ };
110
+ const contentType = mimeMap[ext ?? ""] ?? "application/octet-stream";
111
+ const data = fs.readFileSync(filePath);
112
+ return new Response(data, { headers: { "Content-Type": contentType, "Cache-Control": "public, max-age=31536000, immutable" } });
113
+ });
92
114
  return app;
93
115
  }
94
116
  export {
@@ -24,6 +24,8 @@ interface Session {
24
24
  meta: Record<string, unknown> | null;
25
25
  state: SessionState;
26
26
  lastStartConfig: StartConfig | null;
27
+ /** Claude Code's own session ID (from system.init event). Used for --resume. */
28
+ ccSessionId: string | null;
27
29
  createdAt: number;
28
30
  lastActivityAt: number;
29
31
  }
@@ -34,6 +36,8 @@ interface SessionInfo {
34
36
  state: SessionState;
35
37
  cwd: string;
36
38
  meta: Record<string, unknown> | null;
39
+ config: StartConfig | null;
40
+ ccSessionId: string | null;
37
41
  eventCount: number;
38
42
  createdAt: number;
39
43
  lastActivityAt: number;
@@ -47,6 +51,10 @@ interface SessionLifecycleEvent {
47
51
  state: SessionLifecycleState;
48
52
  code?: number | null;
49
53
  }
54
+ interface SessionConfigChangedEvent {
55
+ session: string;
56
+ config: StartConfig;
57
+ }
50
58
  declare class SessionManager {
51
59
  private sessions;
52
60
  private maxSessions;
@@ -55,6 +63,7 @@ declare class SessionManager {
55
63
  private skillEventListeners;
56
64
  private permissionRequestListeners;
57
65
  private lifecycleListeners;
66
+ private configChangedListeners;
58
67
  constructor(options?: SessionManagerOptions);
59
68
  /** Restore session metadata from DB (cwd, label, meta). Process state is not restored. */
60
69
  private restoreFromDb;
@@ -87,6 +96,9 @@ declare class SessionManager {
87
96
  /** Subscribe to session lifecycle events (started/killed/exited/crashed). Returns unsubscribe function. */
88
97
  onSessionLifecycle(cb: (event: SessionLifecycleEvent) => void): () => void;
89
98
  private emitLifecycle;
99
+ /** Subscribe to session config changes. Returns unsubscribe function. */
100
+ onConfigChanged(cb: (event: SessionConfigChangedEvent) => void): () => void;
101
+ private emitConfigChanged;
90
102
  /** Create a pending permission request. Returns a promise that resolves when approved/denied. */
91
103
  createPendingPermission(sessionId: string, request: Record<string, unknown>): Promise<boolean>;
92
104
  /** Resolve a pending permission request. Returns false if no pending request. */
@@ -109,8 +121,12 @@ declare class SessionManager {
109
121
  restartSession(id: string, overrides: Partial<StartConfig>, spawnFn: (config: StartConfig) => AgentProcess): {
110
122
  config: StartConfig;
111
123
  };
112
- /** Interrupt the current turn (SIGINT). Process stays alive, returns to waiting. */
124
+ /** Interrupt the current turn. Process stays alive, returns to waiting. */
113
125
  interruptSession(id: string): boolean;
126
+ /** Change model. Sends control message if alive, always persists to config. */
127
+ setSessionModel(id: string, model: string): boolean;
128
+ /** Change permission mode. Sends control message if alive, always persists to config. */
129
+ setSessionPermissionMode(id: string, mode: string): boolean;
114
130
  /** Kill the agent process in a session (session stays, can be restarted). */
115
131
  killSession(id: string): boolean;
116
132
  /** Remove a session entirely. Cannot remove "default". */
@@ -126,4 +142,4 @@ declare class SessionManager {
126
142
  get size(): number;
127
143
  }
128
144
 
129
- export { type Session, type SessionInfo, type SessionLifecycleEvent, type SessionLifecycleState, SessionManager, type SessionManagerOptions, type SessionState, type StartConfig };
145
+ export { type Session, type SessionConfigChangedEvent, type SessionInfo, type SessionLifecycleEvent, type SessionLifecycleState, SessionManager, type SessionManagerOptions, type SessionState, type StartConfig };
@@ -10,6 +10,7 @@ class SessionManager {
10
10
  this.skillEventListeners = /* @__PURE__ */ new Set();
11
11
  this.permissionRequestListeners = /* @__PURE__ */ new Set();
12
12
  this.lifecycleListeners = /* @__PURE__ */ new Set();
13
+ this.configChangedListeners = /* @__PURE__ */ new Set();
13
14
  this.maxSessions = options.maxSessions ?? DEFAULT_MAX_SESSIONS;
14
15
  this.restoreFromDb();
15
16
  }
@@ -32,6 +33,7 @@ class SessionManager {
32
33
  meta: row.meta ? JSON.parse(row.meta) : null,
33
34
  state: "idle",
34
35
  lastStartConfig: row.last_start_config ? JSON.parse(row.last_start_config) : null,
36
+ ccSessionId: null,
35
37
  createdAt: new Date(row.created_at).getTime() || Date.now(),
36
38
  lastActivityAt: Date.now()
37
39
  });
@@ -44,7 +46,13 @@ class SessionManager {
44
46
  try {
45
47
  const db = getDb();
46
48
  db.prepare(
47
- `INSERT OR REPLACE INTO chat_sessions (id, label, type, meta, cwd, last_start_config) VALUES (?, ?, 'main', ?, ?, ?)`
49
+ `INSERT INTO chat_sessions (id, label, type, meta, cwd, last_start_config)
50
+ VALUES (?, ?, 'main', ?, ?, ?)
51
+ ON CONFLICT(id) DO UPDATE SET
52
+ label = excluded.label,
53
+ meta = excluded.meta,
54
+ cwd = excluded.cwd,
55
+ last_start_config = excluded.last_start_config`
48
56
  ).run(
49
57
  session.id,
50
58
  session.label,
@@ -90,6 +98,7 @@ class SessionManager {
90
98
  meta: opts.meta ?? null,
91
99
  state: "idle",
92
100
  lastStartConfig: null,
101
+ ccSessionId: null,
93
102
  createdAt: Date.now(),
94
103
  lastActivityAt: Date.now()
95
104
  };
@@ -121,12 +130,16 @@ class SessionManager {
121
130
  session.state = "processing";
122
131
  session.lastActivityAt = Date.now();
123
132
  proc.on("event", (e) => {
133
+ if (e.type === "init" && e.data?.sessionId && !session.ccSessionId) {
134
+ session.ccSessionId = e.data.sessionId;
135
+ this.persistSession(session);
136
+ }
124
137
  session.eventBuffer.push(e);
125
138
  session.eventCounter++;
126
139
  if (session.eventBuffer.length > MAX_EVENT_BUFFER) {
127
140
  session.eventBuffer.splice(0, session.eventBuffer.length - MAX_EVENT_BUFFER);
128
141
  }
129
- if (e.type === "complete" || e.type === "error") {
142
+ if (e.type === "complete" || e.type === "error" || e.type === "interrupted") {
130
143
  session.state = "waiting";
131
144
  }
132
145
  this.persistEvent(sessionId, e);
@@ -184,6 +197,15 @@ class SessionManager {
184
197
  emitLifecycle(event) {
185
198
  for (const cb of this.lifecycleListeners) cb(event);
186
199
  }
200
+ // ── Config changed pub/sub ────────────────────────────────────
201
+ /** Subscribe to session config changes. Returns unsubscribe function. */
202
+ onConfigChanged(cb) {
203
+ this.configChangedListeners.add(cb);
204
+ return () => this.configChangedListeners.delete(cb);
205
+ }
206
+ emitConfigChanged(sessionId, config) {
207
+ for (const cb of this.configChangedListeners) cb({ session: sessionId, config });
208
+ }
187
209
  // ── Permission management ─────────────────────────────────────
188
210
  /** Create a pending permission request. Returns a promise that resolves when approved/denied. */
189
211
  createPendingPermission(sessionId, request) {
@@ -252,9 +274,10 @@ class SessionManager {
252
274
  session.lastStartConfig = config;
253
275
  this.persistSession(session);
254
276
  this.emitLifecycle({ session: id, state: "restarted" });
277
+ this.emitConfigChanged(id, config);
255
278
  return { config };
256
279
  }
257
- /** Interrupt the current turn (SIGINT). Process stays alive, returns to waiting. */
280
+ /** Interrupt the current turn. Process stays alive, returns to waiting. */
258
281
  interruptSession(id) {
259
282
  const session = this.sessions.get(id);
260
283
  if (!session?.process?.alive) return false;
@@ -262,6 +285,34 @@ class SessionManager {
262
285
  session.state = "waiting";
263
286
  return true;
264
287
  }
288
+ /** Change model. Sends control message if alive, always persists to config. */
289
+ setSessionModel(id, model) {
290
+ const session = this.sessions.get(id);
291
+ if (!session) return false;
292
+ if (session.process?.alive) session.process.setModel(model);
293
+ if (session.lastStartConfig) {
294
+ session.lastStartConfig.model = model;
295
+ } else {
296
+ session.lastStartConfig = { provider: "claude-code", model, permissionMode: "acceptEdits" };
297
+ }
298
+ this.persistSession(session);
299
+ this.emitConfigChanged(id, session.lastStartConfig);
300
+ return true;
301
+ }
302
+ /** Change permission mode. Sends control message if alive, always persists to config. */
303
+ setSessionPermissionMode(id, mode) {
304
+ const session = this.sessions.get(id);
305
+ if (!session) return false;
306
+ if (session.process?.alive) session.process.setPermissionMode(mode);
307
+ if (session.lastStartConfig) {
308
+ session.lastStartConfig.permissionMode = mode;
309
+ } else {
310
+ session.lastStartConfig = { provider: "claude-code", model: "claude-sonnet-4-6", permissionMode: mode };
311
+ }
312
+ this.persistSession(session);
313
+ this.emitConfigChanged(id, session.lastStartConfig);
314
+ return true;
315
+ }
265
316
  /** Kill the agent process in a session (session stays, can be restarted). */
266
317
  killSession(id) {
267
318
  const session = this.sessions.get(id);
@@ -290,6 +341,8 @@ class SessionManager {
290
341
  state: s.state,
291
342
  cwd: s.cwd,
292
343
  meta: s.meta,
344
+ config: s.lastStartConfig,
345
+ ccSessionId: s.ccSessionId,
293
346
  eventCount: s.eventCounter,
294
347
  createdAt: s.createdAt,
295
348
  lastActivityAt: s.lastActivityAt