@sna-sdk/core 0.2.3 → 0.4.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.
@@ -25,6 +25,12 @@ interface ApiResponses {
25
25
  "agent.send": {
26
26
  status: "sent";
27
27
  };
28
+ "agent.resume": {
29
+ status: "resumed";
30
+ provider: string;
31
+ sessionId: string;
32
+ historyCount: number;
33
+ };
28
34
  "agent.restart": {
29
35
  status: "restarted";
30
36
  provider: string;
@@ -33,13 +39,29 @@ interface ApiResponses {
33
39
  "agent.interrupt": {
34
40
  status: "interrupted" | "no_session";
35
41
  };
42
+ "agent.set-model": {
43
+ status: "updated" | "no_session";
44
+ model: string;
45
+ };
46
+ "agent.set-permission-mode": {
47
+ status: "updated" | "no_session";
48
+ permissionMode: string;
49
+ };
36
50
  "agent.kill": {
37
51
  status: "killed" | "no_session";
38
52
  };
39
53
  "agent.status": {
40
54
  alive: boolean;
55
+ agentStatus: "idle" | "busy" | "disconnected";
41
56
  sessionId: string | null;
57
+ ccSessionId: string | null;
42
58
  eventCount: number;
59
+ config: {
60
+ provider: string;
61
+ model: string;
62
+ permissionMode: string;
63
+ extraArgs?: string[];
64
+ } | null;
43
65
  };
44
66
  "agent.run-once": {
45
67
  result: string;
@@ -0,0 +1,16 @@
1
+ import { HistoryMessage } from '../core/providers/types.js';
2
+
3
+ /**
4
+ * Build HistoryMessage[] from chat_messages DB records.
5
+ *
6
+ * Filters to user/assistant roles, ensures alternation,
7
+ * and merges consecutive same-role messages.
8
+ */
9
+
10
+ /**
11
+ * Load conversation history from DB for a session.
12
+ * Returns alternating user↔assistant messages ready for JSONL injection.
13
+ */
14
+ declare function buildHistoryFromDb(sessionId: string): HistoryMessage[];
15
+
16
+ export { buildHistoryFromDb };
@@ -0,0 +1,25 @@
1
+ import { getDb } from "../db/schema.js";
2
+ function buildHistoryFromDb(sessionId) {
3
+ const db = getDb();
4
+ const rows = db.prepare(
5
+ `SELECT role, content FROM chat_messages
6
+ WHERE session_id = ? AND role IN ('user', 'assistant')
7
+ ORDER BY id ASC`
8
+ ).all(sessionId);
9
+ if (rows.length === 0) return [];
10
+ const merged = [];
11
+ for (const row of rows) {
12
+ const role = row.role;
13
+ if (!row.content?.trim()) continue;
14
+ const last = merged[merged.length - 1];
15
+ if (last && last.role === role) {
16
+ last.content += "\n\n" + row.content;
17
+ } else {
18
+ merged.push({ role, content: row.content });
19
+ }
20
+ }
21
+ return merged;
22
+ }
23
+ export {
24
+ buildHistoryFromDb
25
+ };
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Image storage — saves base64 images to disk and serves them.
3
+ *
4
+ * Storage path: data/images/{sessionId}/{hash}.{ext}
5
+ * Retrieve via: GET /chat/images/:sessionId/:filename
6
+ */
7
+ interface SavedImage {
8
+ filename: string;
9
+ path: string;
10
+ }
11
+ /**
12
+ * Save base64 images to disk. Returns filenames for meta storage.
13
+ */
14
+ declare function saveImages(sessionId: string, images: Array<{
15
+ base64: string;
16
+ mimeType: string;
17
+ }>): string[];
18
+ /**
19
+ * Resolve an image file path. Returns null if not found.
20
+ */
21
+ declare function resolveImagePath(sessionId: string, filename: string): string | null;
22
+
23
+ export { type SavedImage, resolveImagePath, saveImages };
@@ -0,0 +1,34 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+ import { createHash } from "crypto";
4
+ const IMAGE_DIR = path.join(process.cwd(), "data/images");
5
+ const MIME_TO_EXT = {
6
+ "image/png": "png",
7
+ "image/jpeg": "jpg",
8
+ "image/gif": "gif",
9
+ "image/webp": "webp",
10
+ "image/svg+xml": "svg"
11
+ };
12
+ function saveImages(sessionId, images) {
13
+ const dir = path.join(IMAGE_DIR, sessionId);
14
+ fs.mkdirSync(dir, { recursive: true });
15
+ return images.map((img) => {
16
+ const ext = MIME_TO_EXT[img.mimeType] ?? "bin";
17
+ const hash = createHash("sha256").update(img.base64).digest("hex").slice(0, 12);
18
+ const filename = `${hash}.${ext}`;
19
+ const filePath = path.join(dir, filename);
20
+ if (!fs.existsSync(filePath)) {
21
+ fs.writeFileSync(filePath, Buffer.from(img.base64, "base64"));
22
+ }
23
+ return filename;
24
+ });
25
+ }
26
+ function resolveImagePath(sessionId, filename) {
27
+ if (filename.includes("..") || filename.includes("/")) return null;
28
+ const filePath = path.join(IMAGE_DIR, sessionId, filename);
29
+ return fs.existsSync(filePath) ? filePath : null;
30
+ }
31
+ export {
32
+ resolveImagePath,
33
+ saveImages
34
+ };
@@ -1,13 +1,14 @@
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, SessionLifecycleEvent, SessionLifecycleState, SessionManagerOptions, StartConfig } from './session-manager.js';
4
+ export { AgentStatus, Session, SessionConfigChangedEvent, SessionInfo, SessionLifecycleEvent, SessionLifecycleState, SessionManagerOptions, StartConfig } from './session-manager.js';
5
5
  export { eventsRoute } from './routes/events.js';
6
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
10
  export { attachWebSocket } from './ws.js';
11
+ export { buildHistoryFromDb } from './history-builder.js';
11
12
  import '../core/providers/types.js';
12
13
  import 'hono/utils/http-status';
13
14
  import 'ws';
@@ -27,6 +27,7 @@ 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
29
  import { attachWebSocket } from "./ws.js";
30
+ import { buildHistoryFromDb } from "./history-builder.js";
30
31
  function snaPortRoute(c) {
31
32
  const portFile = _path.join(process.cwd(), ".sna/sna-api.port");
32
33
  try {
@@ -39,6 +40,7 @@ function snaPortRoute(c) {
39
40
  export {
40
41
  SessionManager2 as SessionManager,
41
42
  attachWebSocket,
43
+ buildHistoryFromDb,
42
44
  createAgentRoutes2 as createAgentRoutes,
43
45
  createChatRoutes2 as createChatRoutes,
44
46
  createEmitRoute2 as createEmitRoute,
@@ -5,7 +5,9 @@ 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";
10
+ import { saveImages } from "../image-store.js";
9
11
  function getSessionId(c) {
10
12
  return c.req.query("session") ?? "default";
11
13
  }
@@ -28,7 +30,7 @@ async function runOnce(sessionManager, opts) {
28
30
  model: opts.model ?? "claude-sonnet-4-6",
29
31
  permissionMode: opts.permissionMode ?? "bypassPermissions",
30
32
  env: { SNA_SESSION_ID: sessionId },
31
- extraArgs: extraArgs.length > 0 ? extraArgs : void 0
33
+ extraArgs
32
34
  });
33
35
  sessionManager.setProcess(sessionId, proc);
34
36
  try {
@@ -150,6 +152,7 @@ function createAgentRoutes(sessionManager) {
150
152
  model,
151
153
  permissionMode,
152
154
  env: { SNA_SESSION_ID: sessionId },
155
+ history: body.history,
153
156
  extraArgs
154
157
  });
155
158
  sessionManager.setProcess(sessionId, proc);
@@ -176,20 +179,38 @@ function createAgentRoutes(sessionManager) {
176
179
  );
177
180
  }
178
181
  const body = await c.req.json().catch(() => ({}));
179
- if (!body.message) {
182
+ if (!body.message && !body.images?.length) {
180
183
  logger.err("err", `POST /send?session=${sessionId} \u2192 empty message`);
181
- return c.json({ status: "error", message: "message is required" }, 400);
184
+ return c.json({ status: "error", message: "message or images required" }, 400);
185
+ }
186
+ const textContent = body.message ?? "(image)";
187
+ let meta = body.meta ? { ...body.meta } : {};
188
+ if (body.images?.length) {
189
+ const filenames = saveImages(sessionId, body.images);
190
+ meta.images = filenames;
182
191
  }
183
192
  try {
184
193
  const db = getDb();
185
194
  db.prepare(`INSERT OR IGNORE INTO chat_sessions (id, label, type) VALUES (?, ?, 'main')`).run(sessionId, session.label ?? sessionId);
186
- db.prepare(`INSERT INTO chat_messages (session_id, role, content, meta) VALUES (?, 'user', ?, ?)`).run(sessionId, body.message, body.meta ? JSON.stringify(body.meta) : null);
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);
187
196
  } catch {
188
197
  }
189
- session.state = "processing";
198
+ sessionManager.updateSessionState(sessionId, "processing");
190
199
  sessionManager.touch(sessionId);
191
- logger.log("route", `POST /send?session=${sessionId} \u2192 "${body.message.slice(0, 80)}"`);
192
- session.process.send(body.message);
200
+ if (body.images?.length) {
201
+ const content = [
202
+ ...body.images.map((img) => ({
203
+ type: "image",
204
+ source: { type: "base64", media_type: img.mimeType, data: img.base64 }
205
+ })),
206
+ ...body.message ? [{ type: "text", text: body.message }] : []
207
+ ];
208
+ logger.log("route", `POST /send?session=${sessionId} \u2192 ${body.images.length} image(s) + "${(body.message ?? "").slice(0, 40)}"`);
209
+ session.process.send(content);
210
+ } else {
211
+ logger.log("route", `POST /send?session=${sessionId} \u2192 "${body.message.slice(0, 80)}"`);
212
+ session.process.send(body.message);
213
+ }
193
214
  return httpJson(c, "agent.send", { status: "sent" });
194
215
  });
195
216
  app.get("/events", (c) => {
@@ -229,14 +250,16 @@ function createAgentRoutes(sessionManager) {
229
250
  const sessionId = getSessionId(c);
230
251
  const body = await c.req.json().catch(() => ({}));
231
252
  try {
253
+ const ccSessionId = sessionManager.getSession(sessionId)?.ccSessionId;
232
254
  const { config } = sessionManager.restartSession(sessionId, body, (cfg) => {
233
255
  const prov = getProvider(cfg.provider);
256
+ const resumeArgs = ccSessionId ? ["--resume", ccSessionId] : ["--resume"];
234
257
  return prov.spawn({
235
258
  cwd: sessionManager.getSession(sessionId).cwd,
236
259
  model: cfg.model,
237
260
  permissionMode: cfg.permissionMode,
238
261
  env: { SNA_SESSION_ID: sessionId },
239
- extraArgs: [...cfg.extraArgs ?? [], "--resume"]
262
+ extraArgs: [...cfg.extraArgs ?? [], ...resumeArgs]
240
263
  });
241
264
  });
242
265
  logger.log("route", `POST /restart?session=${sessionId} \u2192 restarted`);
@@ -250,11 +273,65 @@ function createAgentRoutes(sessionManager) {
250
273
  return c.json({ status: "error", message: e.message }, 500);
251
274
  }
252
275
  });
276
+ app.post("/resume", async (c) => {
277
+ const sessionId = getSessionId(c);
278
+ const body = await c.req.json().catch(() => ({}));
279
+ const session = sessionManager.getOrCreateSession(sessionId);
280
+ if (session.process?.alive) {
281
+ return c.json({ status: "error", message: "Session already running. Use agent.send instead." }, 400);
282
+ }
283
+ const history = buildHistoryFromDb(sessionId);
284
+ if (history.length === 0 && !body.prompt) {
285
+ return c.json({ status: "error", message: "No history in DB \u2014 nothing to resume." }, 400);
286
+ }
287
+ const providerName = body.provider ?? "claude-code";
288
+ const model = body.model ?? session.lastStartConfig?.model ?? "claude-sonnet-4-6";
289
+ const permissionMode = body.permissionMode ?? session.lastStartConfig?.permissionMode ?? "acceptEdits";
290
+ const extraArgs = body.extraArgs ?? session.lastStartConfig?.extraArgs;
291
+ const provider = getProvider(providerName);
292
+ try {
293
+ const proc = provider.spawn({
294
+ cwd: session.cwd,
295
+ prompt: body.prompt,
296
+ model,
297
+ permissionMode,
298
+ env: { SNA_SESSION_ID: sessionId },
299
+ history: history.length > 0 ? history : void 0,
300
+ extraArgs
301
+ });
302
+ sessionManager.setProcess(sessionId, proc, "resumed");
303
+ sessionManager.saveStartConfig(sessionId, { provider: providerName, model, permissionMode, extraArgs });
304
+ logger.log("route", `POST /resume?session=${sessionId} \u2192 resumed (${history.length} history msgs)`);
305
+ return httpJson(c, "agent.resume", {
306
+ status: "resumed",
307
+ provider: providerName,
308
+ sessionId: session.id,
309
+ historyCount: history.length
310
+ });
311
+ } catch (e) {
312
+ logger.err("err", `POST /resume?session=${sessionId} \u2192 ${e.message}`);
313
+ return c.json({ status: "error", message: e.message }, 500);
314
+ }
315
+ });
253
316
  app.post("/interrupt", async (c) => {
254
317
  const sessionId = getSessionId(c);
255
318
  const interrupted = sessionManager.interruptSession(sessionId);
256
319
  return httpJson(c, "agent.interrupt", { status: interrupted ? "interrupted" : "no_session" });
257
320
  });
321
+ app.post("/set-model", async (c) => {
322
+ const sessionId = getSessionId(c);
323
+ const body = await c.req.json().catch(() => ({}));
324
+ if (!body.model) return c.json({ status: "error", message: "model is required" }, 400);
325
+ const updated = sessionManager.setSessionModel(sessionId, body.model);
326
+ return httpJson(c, "agent.set-model", { status: updated ? "updated" : "no_session", model: body.model });
327
+ });
328
+ app.post("/set-permission-mode", async (c) => {
329
+ const sessionId = getSessionId(c);
330
+ const body = await c.req.json().catch(() => ({}));
331
+ if (!body.permissionMode) return c.json({ status: "error", message: "permissionMode is required" }, 400);
332
+ const updated = sessionManager.setSessionPermissionMode(sessionId, body.permissionMode);
333
+ return httpJson(c, "agent.set-permission-mode", { status: updated ? "updated" : "no_session", permissionMode: body.permissionMode });
334
+ });
258
335
  app.post("/kill", async (c) => {
259
336
  const sessionId = getSessionId(c);
260
337
  const killed = sessionManager.killSession(sessionId);
@@ -263,10 +340,14 @@ function createAgentRoutes(sessionManager) {
263
340
  app.get("/status", (c) => {
264
341
  const sessionId = getSessionId(c);
265
342
  const session = sessionManager.getSession(sessionId);
343
+ const alive = session?.process?.alive ?? false;
266
344
  return httpJson(c, "agent.status", {
267
- alive: session?.process?.alive ?? false,
345
+ alive,
346
+ agentStatus: !alive ? "disconnected" : session?.state === "processing" ? "busy" : "idle",
268
347
  sessionId: session?.process?.sessionId ?? null,
269
- eventCount: session?.eventCounter ?? 0
348
+ ccSessionId: session?.ccSessionId ?? null,
349
+ eventCount: session?.eventCounter ?? 0,
350
+ config: session?.lastStartConfig ?? null
270
351
  });
271
352
  });
272
353
  app.post("/permission-request", async (c) => {
@@ -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,16 +24,22 @@ 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
  }
32
+ type AgentStatus = "idle" | "busy" | "disconnected";
30
33
  interface SessionInfo {
31
34
  id: string;
32
35
  label: string;
33
36
  alive: boolean;
34
37
  state: SessionState;
38
+ agentStatus: AgentStatus;
35
39
  cwd: string;
36
40
  meta: Record<string, unknown> | null;
41
+ config: StartConfig | null;
42
+ ccSessionId: string | null;
37
43
  eventCount: number;
38
44
  createdAt: number;
39
45
  lastActivityAt: number;
@@ -41,12 +47,16 @@ interface SessionInfo {
41
47
  interface SessionManagerOptions {
42
48
  maxSessions?: number;
43
49
  }
44
- type SessionLifecycleState = "started" | "killed" | "exited" | "crashed" | "restarted";
50
+ type SessionLifecycleState = "started" | "resumed" | "killed" | "exited" | "crashed" | "restarted";
45
51
  interface SessionLifecycleEvent {
46
52
  session: string;
47
53
  state: SessionLifecycleState;
48
54
  code?: number | null;
49
55
  }
56
+ interface SessionConfigChangedEvent {
57
+ session: string;
58
+ config: StartConfig;
59
+ }
50
60
  declare class SessionManager {
51
61
  private sessions;
52
62
  private maxSessions;
@@ -55,6 +65,8 @@ declare class SessionManager {
55
65
  private skillEventListeners;
56
66
  private permissionRequestListeners;
57
67
  private lifecycleListeners;
68
+ private configChangedListeners;
69
+ private stateChangedListeners;
58
70
  constructor(options?: SessionManagerOptions);
59
71
  /** Restore session metadata from DB (cwd, label, meta). Process state is not restored. */
60
72
  private restoreFromDb;
@@ -75,7 +87,7 @@ declare class SessionManager {
75
87
  cwd?: string;
76
88
  }): Session;
77
89
  /** Set the agent process for a session. Subscribes to events. */
78
- setProcess(sessionId: string, proc: AgentProcess): void;
90
+ setProcess(sessionId: string, proc: AgentProcess, lifecycleState?: SessionLifecycleState): void;
79
91
  /** Subscribe to real-time events for a session. Returns unsubscribe function. */
80
92
  onSessionEvent(sessionId: string, cb: (cursor: number, event: AgentEvent) => void): () => void;
81
93
  /** Subscribe to skill events broadcast. Returns unsubscribe function. */
@@ -87,6 +99,17 @@ declare class SessionManager {
87
99
  /** Subscribe to session lifecycle events (started/killed/exited/crashed). Returns unsubscribe function. */
88
100
  onSessionLifecycle(cb: (event: SessionLifecycleEvent) => void): () => void;
89
101
  private emitLifecycle;
102
+ /** Subscribe to session config changes. Returns unsubscribe function. */
103
+ onConfigChanged(cb: (event: SessionConfigChangedEvent) => void): () => void;
104
+ private emitConfigChanged;
105
+ onStateChanged(cb: (event: {
106
+ session: string;
107
+ agentStatus: AgentStatus;
108
+ state: SessionState;
109
+ }) => void): () => void;
110
+ /** Update session state and push agentStatus change to subscribers. */
111
+ updateSessionState(sessionId: string, newState: SessionState): void;
112
+ private setSessionState;
90
113
  /** Create a pending permission request. Returns a promise that resolves when approved/denied. */
91
114
  createPendingPermission(sessionId: string, request: Record<string, unknown>): Promise<boolean>;
92
115
  /** Resolve a pending permission request. Returns false if no pending request. */
@@ -109,8 +132,12 @@ declare class SessionManager {
109
132
  restartSession(id: string, overrides: Partial<StartConfig>, spawnFn: (config: StartConfig) => AgentProcess): {
110
133
  config: StartConfig;
111
134
  };
112
- /** Interrupt the current turn (SIGINT). Process stays alive, returns to waiting. */
135
+ /** Interrupt the current turn. Process stays alive, returns to waiting. */
113
136
  interruptSession(id: string): boolean;
137
+ /** Change model. Sends control message if alive, always persists to config. */
138
+ setSessionModel(id: string, model: string): boolean;
139
+ /** Change permission mode. Sends control message if alive, always persists to config. */
140
+ setSessionPermissionMode(id: string, mode: string): boolean;
114
141
  /** Kill the agent process in a session (session stays, can be restarted). */
115
142
  killSession(id: string): boolean;
116
143
  /** Remove a session entirely. Cannot remove "default". */
@@ -126,4 +153,4 @@ declare class SessionManager {
126
153
  get size(): number;
127
154
  }
128
155
 
129
- export { type Session, type SessionInfo, type SessionLifecycleEvent, type SessionLifecycleState, SessionManager, type SessionManagerOptions, type SessionState, type StartConfig };
156
+ export { type AgentStatus, type Session, type SessionConfigChangedEvent, type SessionInfo, type SessionLifecycleEvent, type SessionLifecycleState, SessionManager, type SessionManagerOptions, type SessionState, type StartConfig };
@@ -10,6 +10,8 @@ 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();
14
+ this.stateChangedListeners = /* @__PURE__ */ new Set();
13
15
  this.maxSessions = options.maxSessions ?? DEFAULT_MAX_SESSIONS;
14
16
  this.restoreFromDb();
15
17
  }
@@ -32,6 +34,7 @@ class SessionManager {
32
34
  meta: row.meta ? JSON.parse(row.meta) : null,
33
35
  state: "idle",
34
36
  lastStartConfig: row.last_start_config ? JSON.parse(row.last_start_config) : null,
37
+ ccSessionId: null,
35
38
  createdAt: new Date(row.created_at).getTime() || Date.now(),
36
39
  lastActivityAt: Date.now()
37
40
  });
@@ -44,7 +47,13 @@ class SessionManager {
44
47
  try {
45
48
  const db = getDb();
46
49
  db.prepare(
47
- `INSERT OR REPLACE INTO chat_sessions (id, label, type, meta, cwd, last_start_config) VALUES (?, ?, 'main', ?, ?, ?)`
50
+ `INSERT INTO chat_sessions (id, label, type, meta, cwd, last_start_config)
51
+ VALUES (?, ?, 'main', ?, ?, ?)
52
+ ON CONFLICT(id) DO UPDATE SET
53
+ label = excluded.label,
54
+ meta = excluded.meta,
55
+ cwd = excluded.cwd,
56
+ last_start_config = excluded.last_start_config`
48
57
  ).run(
49
58
  session.id,
50
59
  session.label,
@@ -90,6 +99,7 @@ class SessionManager {
90
99
  meta: opts.meta ?? null,
91
100
  state: "idle",
92
101
  lastStartConfig: null,
102
+ ccSessionId: null,
93
103
  createdAt: Date.now(),
94
104
  lastActivityAt: Date.now()
95
105
  };
@@ -114,20 +124,24 @@ class SessionManager {
114
124
  return this.createSession({ id, ...opts });
115
125
  }
116
126
  /** Set the agent process for a session. Subscribes to events. */
117
- setProcess(sessionId, proc) {
127
+ setProcess(sessionId, proc, lifecycleState) {
118
128
  const session = this.sessions.get(sessionId);
119
129
  if (!session) throw new Error(`Session "${sessionId}" not found`);
120
130
  session.process = proc;
121
- session.state = "processing";
131
+ this.setSessionState(sessionId, session, "processing");
122
132
  session.lastActivityAt = Date.now();
123
133
  proc.on("event", (e) => {
134
+ if (e.type === "init" && e.data?.sessionId && !session.ccSessionId) {
135
+ session.ccSessionId = e.data.sessionId;
136
+ this.persistSession(session);
137
+ }
124
138
  session.eventBuffer.push(e);
125
139
  session.eventCounter++;
126
140
  if (session.eventBuffer.length > MAX_EVENT_BUFFER) {
127
141
  session.eventBuffer.splice(0, session.eventBuffer.length - MAX_EVENT_BUFFER);
128
142
  }
129
- if (e.type === "complete" || e.type === "error") {
130
- session.state = "waiting";
143
+ if (e.type === "complete" || e.type === "error" || e.type === "interrupted") {
144
+ this.setSessionState(sessionId, session, "waiting");
131
145
  }
132
146
  this.persistEvent(sessionId, e);
133
147
  const listeners = this.eventListeners.get(sessionId);
@@ -136,14 +150,14 @@ class SessionManager {
136
150
  }
137
151
  });
138
152
  proc.on("exit", (code) => {
139
- session.state = "idle";
153
+ this.setSessionState(sessionId, session, "idle");
140
154
  this.emitLifecycle({ session: sessionId, state: code != null ? "exited" : "crashed", code });
141
155
  });
142
156
  proc.on("error", () => {
143
- session.state = "idle";
157
+ this.setSessionState(sessionId, session, "idle");
144
158
  this.emitLifecycle({ session: sessionId, state: "crashed" });
145
159
  });
146
- this.emitLifecycle({ session: sessionId, state: "started" });
160
+ this.emitLifecycle({ session: sessionId, state: lifecycleState ?? "started" });
147
161
  }
148
162
  // ── Event pub/sub (for WebSocket) ─────────────────────────────
149
163
  /** Subscribe to real-time events for a session. Returns unsubscribe function. */
@@ -184,11 +198,38 @@ class SessionManager {
184
198
  emitLifecycle(event) {
185
199
  for (const cb of this.lifecycleListeners) cb(event);
186
200
  }
201
+ // ── Config changed pub/sub ────────────────────────────────────
202
+ /** Subscribe to session config changes. Returns unsubscribe function. */
203
+ onConfigChanged(cb) {
204
+ this.configChangedListeners.add(cb);
205
+ return () => this.configChangedListeners.delete(cb);
206
+ }
207
+ emitConfigChanged(sessionId, config) {
208
+ for (const cb of this.configChangedListeners) cb({ session: sessionId, config });
209
+ }
210
+ // ── Agent status change pub/sub ────────────────────────────────
211
+ onStateChanged(cb) {
212
+ this.stateChangedListeners.add(cb);
213
+ return () => this.stateChangedListeners.delete(cb);
214
+ }
215
+ /** Update session state and push agentStatus change to subscribers. */
216
+ updateSessionState(sessionId, newState) {
217
+ const session = this.sessions.get(sessionId);
218
+ if (session) this.setSessionState(sessionId, session, newState);
219
+ }
220
+ setSessionState(sessionId, session, newState) {
221
+ const oldState = session.state;
222
+ session.state = newState;
223
+ const newStatus = !session.process?.alive ? "disconnected" : newState === "processing" ? "busy" : "idle";
224
+ if (oldState !== newState) {
225
+ for (const cb of this.stateChangedListeners) cb({ session: sessionId, agentStatus: newStatus, state: newState });
226
+ }
227
+ }
187
228
  // ── Permission management ─────────────────────────────────────
188
229
  /** Create a pending permission request. Returns a promise that resolves when approved/denied. */
189
230
  createPendingPermission(sessionId, request) {
190
231
  const session = this.sessions.get(sessionId);
191
- if (session) session.state = "permission";
232
+ if (session) this.setSessionState(sessionId, session, "permission");
192
233
  return new Promise((resolve) => {
193
234
  const createdAt = Date.now();
194
235
  this.pendingPermissions.set(sessionId, { resolve, request, createdAt });
@@ -208,7 +249,7 @@ class SessionManager {
208
249
  pending.resolve(approved);
209
250
  this.pendingPermissions.delete(sessionId);
210
251
  const session = this.sessions.get(sessionId);
211
- if (session) session.state = "processing";
252
+ if (session) this.setSessionState(sessionId, session, "processing");
212
253
  return true;
213
254
  }
214
255
  /** Get a pending permission for a specific session. */
@@ -252,14 +293,43 @@ class SessionManager {
252
293
  session.lastStartConfig = config;
253
294
  this.persistSession(session);
254
295
  this.emitLifecycle({ session: id, state: "restarted" });
296
+ this.emitConfigChanged(id, config);
255
297
  return { config };
256
298
  }
257
- /** Interrupt the current turn (SIGINT). Process stays alive, returns to waiting. */
299
+ /** Interrupt the current turn. Process stays alive, returns to waiting. */
258
300
  interruptSession(id) {
259
301
  const session = this.sessions.get(id);
260
302
  if (!session?.process?.alive) return false;
261
303
  session.process.interrupt();
262
- session.state = "waiting";
304
+ this.setSessionState(id, session, "waiting");
305
+ return true;
306
+ }
307
+ /** Change model. Sends control message if alive, always persists to config. */
308
+ setSessionModel(id, model) {
309
+ const session = this.sessions.get(id);
310
+ if (!session) return false;
311
+ if (session.process?.alive) session.process.setModel(model);
312
+ if (session.lastStartConfig) {
313
+ session.lastStartConfig.model = model;
314
+ } else {
315
+ session.lastStartConfig = { provider: "claude-code", model, permissionMode: "acceptEdits" };
316
+ }
317
+ this.persistSession(session);
318
+ this.emitConfigChanged(id, session.lastStartConfig);
319
+ return true;
320
+ }
321
+ /** Change permission mode. Sends control message if alive, always persists to config. */
322
+ setSessionPermissionMode(id, mode) {
323
+ const session = this.sessions.get(id);
324
+ if (!session) return false;
325
+ if (session.process?.alive) session.process.setPermissionMode(mode);
326
+ if (session.lastStartConfig) {
327
+ session.lastStartConfig.permissionMode = mode;
328
+ } else {
329
+ session.lastStartConfig = { provider: "claude-code", model: "claude-sonnet-4-6", permissionMode: mode };
330
+ }
331
+ this.persistSession(session);
332
+ this.emitConfigChanged(id, session.lastStartConfig);
263
333
  return true;
264
334
  }
265
335
  /** Kill the agent process in a session (session stays, can be restarted). */
@@ -288,8 +358,11 @@ class SessionManager {
288
358
  label: s.label,
289
359
  alive: s.process?.alive ?? false,
290
360
  state: s.state,
361
+ agentStatus: !s.process?.alive ? "disconnected" : s.state === "processing" ? "busy" : "idle",
291
362
  cwd: s.cwd,
292
363
  meta: s.meta,
364
+ config: s.lastStartConfig,
365
+ ccSessionId: s.ccSessionId,
293
366
  eventCount: s.eventCounter,
294
367
  createdAt: s.createdAt,
295
368
  lastActivityAt: s.lastActivityAt