@sna-sdk/core 0.1.1 → 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,18 +1,21 @@
1
1
  import { Hono } from "hono";
2
+ import fs from "fs";
2
3
  import { getDb } from "../../db/schema.js";
4
+ import { httpJson } from "../api-types.js";
5
+ import { resolveImagePath } from "../image-store.js";
3
6
  function createChatRoutes() {
4
7
  const app = new Hono();
5
8
  app.get("/sessions", (c) => {
6
9
  try {
7
10
  const db = getDb();
8
11
  const rows = db.prepare(
9
- `SELECT id, label, type, meta, created_at FROM chat_sessions ORDER BY created_at DESC`
12
+ `SELECT id, label, type, meta, cwd, created_at FROM chat_sessions ORDER BY created_at DESC`
10
13
  ).all();
11
14
  const sessions = rows.map((r) => ({
12
15
  ...r,
13
16
  meta: r.meta ? JSON.parse(r.meta) : null
14
17
  }));
15
- return c.json({ sessions });
18
+ return httpJson(c, "chat.sessions.list", { sessions });
16
19
  } catch (e) {
17
20
  return c.json({ status: "error", message: e.message, stack: e.stack }, 500);
18
21
  }
@@ -25,7 +28,7 @@ function createChatRoutes() {
25
28
  db.prepare(
26
29
  `INSERT OR IGNORE INTO chat_sessions (id, label, type, meta) VALUES (?, ?, ?, ?)`
27
30
  ).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 });
31
+ return httpJson(c, "chat.sessions.create", { status: "created", id, meta: body.meta ?? null });
29
32
  } catch (e) {
30
33
  return c.json({ status: "error", message: e.message }, 500);
31
34
  }
@@ -38,7 +41,7 @@ function createChatRoutes() {
38
41
  try {
39
42
  const db = getDb();
40
43
  db.prepare(`DELETE FROM chat_sessions WHERE id = ?`).run(id);
41
- return c.json({ status: "deleted" });
44
+ return httpJson(c, "chat.sessions.remove", { status: "deleted" });
42
45
  } catch (e) {
43
46
  return c.json({ status: "error", message: e.message }, 500);
44
47
  }
@@ -50,7 +53,7 @@ function createChatRoutes() {
50
53
  const db = getDb();
51
54
  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
55
  const messages = sinceParam ? query.all(id, parseInt(sinceParam, 10)) : query.all(id);
53
- return c.json({ messages });
56
+ return httpJson(c, "chat.messages.list", { messages });
54
57
  } catch (e) {
55
58
  return c.json({ status: "error", message: e.message, stack: e.stack }, 500);
56
59
  }
@@ -73,7 +76,7 @@ function createChatRoutes() {
73
76
  body.skill_name ?? null,
74
77
  body.meta ? JSON.stringify(body.meta) : null
75
78
  );
76
- return c.json({ status: "created", id: result.lastInsertRowid });
79
+ return httpJson(c, "chat.messages.create", { status: "created", id: Number(result.lastInsertRowid) });
77
80
  } catch (e) {
78
81
  return c.json({ status: "error", message: e.message }, 500);
79
82
  }
@@ -83,11 +86,31 @@ function createChatRoutes() {
83
86
  try {
84
87
  const db = getDb();
85
88
  db.prepare(`DELETE FROM chat_messages WHERE session_id = ?`).run(id);
86
- return c.json({ status: "cleared" });
89
+ return httpJson(c, "chat.messages.clear", { status: "cleared" });
87
90
  } catch (e) {
88
91
  return c.json({ status: "error", message: e.message }, 500);
89
92
  }
90
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
+ });
91
114
  return app;
92
115
  }
93
116
  export {
@@ -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,9 @@ interface Session {
17
23
  cwd: string;
18
24
  meta: Record<string, unknown> | null;
19
25
  state: SessionState;
26
+ lastStartConfig: StartConfig | null;
27
+ /** Claude Code's own session ID (from system.init event). Used for --resume. */
28
+ ccSessionId: string | null;
20
29
  createdAt: number;
21
30
  lastActivityAt: number;
22
31
  }
@@ -27,6 +36,8 @@ interface SessionInfo {
27
36
  state: SessionState;
28
37
  cwd: string;
29
38
  meta: Record<string, unknown> | null;
39
+ config: StartConfig | null;
40
+ ccSessionId: string | null;
30
41
  eventCount: number;
31
42
  createdAt: number;
32
43
  lastActivityAt: number;
@@ -34,10 +45,30 @@ interface SessionInfo {
34
45
  interface SessionManagerOptions {
35
46
  maxSessions?: number;
36
47
  }
48
+ type SessionLifecycleState = "started" | "killed" | "exited" | "crashed" | "restarted";
49
+ interface SessionLifecycleEvent {
50
+ session: string;
51
+ state: SessionLifecycleState;
52
+ code?: number | null;
53
+ }
54
+ interface SessionConfigChangedEvent {
55
+ session: string;
56
+ config: StartConfig;
57
+ }
37
58
  declare class SessionManager {
38
59
  private sessions;
39
60
  private maxSessions;
61
+ private eventListeners;
62
+ private pendingPermissions;
63
+ private skillEventListeners;
64
+ private permissionRequestListeners;
65
+ private lifecycleListeners;
66
+ private configChangedListeners;
40
67
  constructor(options?: SessionManagerOptions);
68
+ /** Restore session metadata from DB (cwd, label, meta). Process state is not restored. */
69
+ private restoreFromDb;
70
+ /** Persist session metadata to DB. */
71
+ private persistSession;
41
72
  /** Create a new session. Throws if max sessions reached. */
42
73
  createSession(opts?: {
43
74
  id?: string;
@@ -54,6 +85,48 @@ declare class SessionManager {
54
85
  }): Session;
55
86
  /** Set the agent process for a session. Subscribes to events. */
56
87
  setProcess(sessionId: string, proc: AgentProcess): void;
88
+ /** Subscribe to real-time events for a session. Returns unsubscribe function. */
89
+ onSessionEvent(sessionId: string, cb: (cursor: number, event: AgentEvent) => void): () => void;
90
+ /** Subscribe to skill events broadcast. Returns unsubscribe function. */
91
+ onSkillEvent(cb: (event: Record<string, unknown>) => void): () => void;
92
+ /** Broadcast a skill event to all subscribers (called after DB insert). */
93
+ broadcastSkillEvent(event: Record<string, unknown>): void;
94
+ /** Subscribe to permission request notifications. Returns unsubscribe function. */
95
+ onPermissionRequest(cb: (sessionId: string, request: Record<string, unknown>, createdAt: number) => void): () => void;
96
+ /** Subscribe to session lifecycle events (started/killed/exited/crashed). Returns unsubscribe function. */
97
+ onSessionLifecycle(cb: (event: SessionLifecycleEvent) => void): () => void;
98
+ private emitLifecycle;
99
+ /** Subscribe to session config changes. Returns unsubscribe function. */
100
+ onConfigChanged(cb: (event: SessionConfigChangedEvent) => void): () => void;
101
+ private emitConfigChanged;
102
+ /** Create a pending permission request. Returns a promise that resolves when approved/denied. */
103
+ createPendingPermission(sessionId: string, request: Record<string, unknown>): Promise<boolean>;
104
+ /** Resolve a pending permission request. Returns false if no pending request. */
105
+ resolvePendingPermission(sessionId: string, approved: boolean): boolean;
106
+ /** Get a pending permission for a specific session. */
107
+ getPendingPermission(sessionId: string): {
108
+ request: Record<string, unknown>;
109
+ createdAt: number;
110
+ } | null;
111
+ /** Get all pending permissions across sessions. */
112
+ getAllPendingPermissions(): Array<{
113
+ sessionId: string;
114
+ request: Record<string, unknown>;
115
+ createdAt: number;
116
+ }>;
117
+ /** Kill the agent process in a session (session stays, can be restarted). */
118
+ /** Save the start config for a session (called by start handlers). */
119
+ saveStartConfig(id: string, config: StartConfig): void;
120
+ /** Restart session: kill → re-spawn with merged config + --resume. */
121
+ restartSession(id: string, overrides: Partial<StartConfig>, spawnFn: (config: StartConfig) => AgentProcess): {
122
+ config: StartConfig;
123
+ };
124
+ /** Interrupt the current turn. Process stays alive, returns to waiting. */
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;
57
130
  /** Kill the agent process in a session (session stays, can be restarted). */
58
131
  killSession(id: string): boolean;
59
132
  /** Remove a session entirely. Cannot remove "default". */
@@ -69,4 +142,4 @@ declare class SessionManager {
69
142
  get size(): number;
70
143
  }
71
144
 
72
- export { type Session, type SessionInfo, SessionManager, type SessionManagerOptions, type SessionState };
145
+ export { type Session, type SessionConfigChangedEvent, type SessionInfo, type SessionLifecycleEvent, type SessionLifecycleState, SessionManager, type SessionManagerOptions, type SessionState, type StartConfig };
@@ -1,16 +1,88 @@
1
1
  import { getDb } from "../db/schema.js";
2
2
  const DEFAULT_MAX_SESSIONS = 5;
3
3
  const MAX_EVENT_BUFFER = 500;
4
+ const PERMISSION_TIMEOUT_MS = 3e5;
4
5
  class SessionManager {
5
6
  constructor(options = {}) {
6
7
  this.sessions = /* @__PURE__ */ new Map();
8
+ this.eventListeners = /* @__PURE__ */ new Map();
9
+ this.pendingPermissions = /* @__PURE__ */ new Map();
10
+ this.skillEventListeners = /* @__PURE__ */ new Set();
11
+ this.permissionRequestListeners = /* @__PURE__ */ new Set();
12
+ this.lifecycleListeners = /* @__PURE__ */ new Set();
13
+ this.configChangedListeners = /* @__PURE__ */ new Set();
7
14
  this.maxSessions = options.maxSessions ?? DEFAULT_MAX_SESSIONS;
15
+ this.restoreFromDb();
16
+ }
17
+ /** Restore session metadata from DB (cwd, label, meta). Process state is not restored. */
18
+ restoreFromDb() {
19
+ try {
20
+ const db = getDb();
21
+ const rows = db.prepare(
22
+ `SELECT id, label, meta, cwd, last_start_config, created_at FROM chat_sessions`
23
+ ).all();
24
+ for (const row of rows) {
25
+ if (this.sessions.has(row.id)) continue;
26
+ this.sessions.set(row.id, {
27
+ id: row.id,
28
+ process: null,
29
+ eventBuffer: [],
30
+ eventCounter: 0,
31
+ label: row.label,
32
+ cwd: row.cwd ?? process.cwd(),
33
+ meta: row.meta ? JSON.parse(row.meta) : null,
34
+ state: "idle",
35
+ lastStartConfig: row.last_start_config ? JSON.parse(row.last_start_config) : null,
36
+ ccSessionId: null,
37
+ createdAt: new Date(row.created_at).getTime() || Date.now(),
38
+ lastActivityAt: Date.now()
39
+ });
40
+ }
41
+ } catch {
42
+ }
43
+ }
44
+ /** Persist session metadata to DB. */
45
+ persistSession(session) {
46
+ try {
47
+ const db = getDb();
48
+ db.prepare(
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`
56
+ ).run(
57
+ session.id,
58
+ session.label,
59
+ session.meta ? JSON.stringify(session.meta) : null,
60
+ session.cwd,
61
+ session.lastStartConfig ? JSON.stringify(session.lastStartConfig) : null
62
+ );
63
+ } catch {
64
+ }
8
65
  }
9
66
  /** Create a new session. Throws if max sessions reached. */
10
67
  createSession(opts = {}) {
11
68
  const id = opts.id ?? crypto.randomUUID().slice(0, 8);
12
69
  if (this.sessions.has(id)) {
13
- return this.sessions.get(id);
70
+ const existing = this.sessions.get(id);
71
+ let changed = false;
72
+ if (opts.cwd && opts.cwd !== existing.cwd) {
73
+ existing.cwd = opts.cwd;
74
+ changed = true;
75
+ }
76
+ if (opts.label && opts.label !== existing.label) {
77
+ existing.label = opts.label;
78
+ changed = true;
79
+ }
80
+ if (opts.meta !== void 0 && opts.meta !== existing.meta) {
81
+ existing.meta = opts.meta ?? null;
82
+ changed = true;
83
+ }
84
+ if (changed) this.persistSession(existing);
85
+ return existing;
14
86
  }
15
87
  const aliveCount = Array.from(this.sessions.values()).filter((s) => s.process?.alive).length;
16
88
  if (aliveCount >= this.maxSessions) {
@@ -25,10 +97,13 @@ class SessionManager {
25
97
  cwd: opts.cwd ?? process.cwd(),
26
98
  meta: opts.meta ?? null,
27
99
  state: "idle",
100
+ lastStartConfig: null,
101
+ ccSessionId: null,
28
102
  createdAt: Date.now(),
29
103
  lastActivityAt: Date.now()
30
104
  };
31
105
  this.sessions.set(id, session);
106
+ this.persistSession(session);
32
107
  return session;
33
108
  }
34
109
  /** Get a session by ID. */
@@ -38,7 +113,13 @@ class SessionManager {
38
113
  /** Get or create a session (used for "default" backward compat). */
39
114
  getOrCreateSession(id, opts) {
40
115
  const existing = this.sessions.get(id);
41
- if (existing) return existing;
116
+ if (existing) {
117
+ if (opts?.cwd && opts.cwd !== existing.cwd) {
118
+ existing.cwd = opts.cwd;
119
+ this.persistSession(existing);
120
+ }
121
+ return existing;
122
+ }
42
123
  return this.createSession({ id, ...opts });
43
124
  }
44
125
  /** Set the agent process for a session. Subscribes to events. */
@@ -49,22 +130,195 @@ class SessionManager {
49
130
  session.state = "processing";
50
131
  session.lastActivityAt = Date.now();
51
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
+ }
52
137
  session.eventBuffer.push(e);
53
138
  session.eventCounter++;
54
139
  if (session.eventBuffer.length > MAX_EVENT_BUFFER) {
55
140
  session.eventBuffer.splice(0, session.eventBuffer.length - MAX_EVENT_BUFFER);
56
141
  }
57
- if (e.type === "complete" || e.type === "error") {
142
+ if (e.type === "complete" || e.type === "error" || e.type === "interrupted") {
58
143
  session.state = "waiting";
59
144
  }
60
145
  this.persistEvent(sessionId, e);
146
+ const listeners = this.eventListeners.get(sessionId);
147
+ if (listeners) {
148
+ for (const cb of listeners) cb(session.eventCounter, e);
149
+ }
150
+ });
151
+ proc.on("exit", (code) => {
152
+ session.state = "idle";
153
+ this.emitLifecycle({ session: sessionId, state: code != null ? "exited" : "crashed", code });
154
+ });
155
+ proc.on("error", () => {
156
+ session.state = "idle";
157
+ this.emitLifecycle({ session: sessionId, state: "crashed" });
61
158
  });
159
+ this.emitLifecycle({ session: sessionId, state: "started" });
160
+ }
161
+ // ── Event pub/sub (for WebSocket) ─────────────────────────────
162
+ /** Subscribe to real-time events for a session. Returns unsubscribe function. */
163
+ onSessionEvent(sessionId, cb) {
164
+ let set = this.eventListeners.get(sessionId);
165
+ if (!set) {
166
+ set = /* @__PURE__ */ new Set();
167
+ this.eventListeners.set(sessionId, set);
168
+ }
169
+ set.add(cb);
170
+ return () => {
171
+ set.delete(cb);
172
+ if (set.size === 0) this.eventListeners.delete(sessionId);
173
+ };
174
+ }
175
+ // ── Skill event pub/sub ────────────────────────────────────────
176
+ /** Subscribe to skill events broadcast. Returns unsubscribe function. */
177
+ onSkillEvent(cb) {
178
+ this.skillEventListeners.add(cb);
179
+ return () => this.skillEventListeners.delete(cb);
180
+ }
181
+ /** Broadcast a skill event to all subscribers (called after DB insert). */
182
+ broadcastSkillEvent(event) {
183
+ for (const cb of this.skillEventListeners) cb(event);
184
+ }
185
+ // ── Permission pub/sub ────────────────────────────────────────
186
+ /** Subscribe to permission request notifications. Returns unsubscribe function. */
187
+ onPermissionRequest(cb) {
188
+ this.permissionRequestListeners.add(cb);
189
+ return () => this.permissionRequestListeners.delete(cb);
190
+ }
191
+ // ── Session lifecycle pub/sub ──────────────────────────────────
192
+ /** Subscribe to session lifecycle events (started/killed/exited/crashed). Returns unsubscribe function. */
193
+ onSessionLifecycle(cb) {
194
+ this.lifecycleListeners.add(cb);
195
+ return () => this.lifecycleListeners.delete(cb);
196
+ }
197
+ emitLifecycle(event) {
198
+ for (const cb of this.lifecycleListeners) cb(event);
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
+ }
209
+ // ── Permission management ─────────────────────────────────────
210
+ /** Create a pending permission request. Returns a promise that resolves when approved/denied. */
211
+ createPendingPermission(sessionId, request) {
212
+ const session = this.sessions.get(sessionId);
213
+ if (session) session.state = "permission";
214
+ return new Promise((resolve) => {
215
+ const createdAt = Date.now();
216
+ this.pendingPermissions.set(sessionId, { resolve, request, createdAt });
217
+ for (const cb of this.permissionRequestListeners) cb(sessionId, request, createdAt);
218
+ setTimeout(() => {
219
+ if (this.pendingPermissions.has(sessionId)) {
220
+ this.pendingPermissions.delete(sessionId);
221
+ resolve(false);
222
+ }
223
+ }, PERMISSION_TIMEOUT_MS);
224
+ });
225
+ }
226
+ /** Resolve a pending permission request. Returns false if no pending request. */
227
+ resolvePendingPermission(sessionId, approved) {
228
+ const pending = this.pendingPermissions.get(sessionId);
229
+ if (!pending) return false;
230
+ pending.resolve(approved);
231
+ this.pendingPermissions.delete(sessionId);
232
+ const session = this.sessions.get(sessionId);
233
+ if (session) session.state = "processing";
234
+ return true;
235
+ }
236
+ /** Get a pending permission for a specific session. */
237
+ getPendingPermission(sessionId) {
238
+ const p = this.pendingPermissions.get(sessionId);
239
+ return p ? { request: p.request, createdAt: p.createdAt } : null;
240
+ }
241
+ /** Get all pending permissions across sessions. */
242
+ getAllPendingPermissions() {
243
+ return Array.from(this.pendingPermissions.entries()).map(([id, p]) => ({
244
+ sessionId: id,
245
+ request: p.request,
246
+ createdAt: p.createdAt
247
+ }));
248
+ }
249
+ // ── Session lifecycle ─────────────────────────────────────────
250
+ /** Kill the agent process in a session (session stays, can be restarted). */
251
+ /** Save the start config for a session (called by start handlers). */
252
+ saveStartConfig(id, config) {
253
+ const session = this.sessions.get(id);
254
+ if (!session) return;
255
+ session.lastStartConfig = config;
256
+ this.persistSession(session);
257
+ }
258
+ /** Restart session: kill → re-spawn with merged config + --resume. */
259
+ restartSession(id, overrides, spawnFn) {
260
+ const session = this.sessions.get(id);
261
+ if (!session) throw new Error(`Session "${id}" not found`);
262
+ const base = session.lastStartConfig;
263
+ if (!base) throw new Error(`Session "${id}" has no previous start config`);
264
+ const config = {
265
+ provider: overrides.provider ?? base.provider,
266
+ model: overrides.model ?? base.model,
267
+ permissionMode: overrides.permissionMode ?? base.permissionMode,
268
+ extraArgs: overrides.extraArgs ?? base.extraArgs
269
+ };
270
+ if (session.process?.alive) session.process.kill();
271
+ session.eventBuffer.length = 0;
272
+ const proc = spawnFn(config);
273
+ this.setProcess(id, proc);
274
+ session.lastStartConfig = config;
275
+ this.persistSession(session);
276
+ this.emitLifecycle({ session: id, state: "restarted" });
277
+ this.emitConfigChanged(id, config);
278
+ return { config };
279
+ }
280
+ /** Interrupt the current turn. Process stays alive, returns to waiting. */
281
+ interruptSession(id) {
282
+ const session = this.sessions.get(id);
283
+ if (!session?.process?.alive) return false;
284
+ session.process.interrupt();
285
+ session.state = "waiting";
286
+ return true;
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;
62
315
  }
63
316
  /** Kill the agent process in a session (session stays, can be restarted). */
64
317
  killSession(id) {
65
318
  const session = this.sessions.get(id);
66
319
  if (!session?.process?.alive) return false;
67
320
  session.process.kill();
321
+ this.emitLifecycle({ session: id, state: "killed" });
68
322
  return true;
69
323
  }
70
324
  /** Remove a session entirely. Cannot remove "default". */
@@ -73,6 +327,8 @@ class SessionManager {
73
327
  const session = this.sessions.get(id);
74
328
  if (!session) return false;
75
329
  if (session.process?.alive) session.process.kill();
330
+ this.eventListeners.delete(id);
331
+ this.pendingPermissions.delete(id);
76
332
  this.sessions.delete(id);
77
333
  return true;
78
334
  }
@@ -85,6 +341,8 @@ class SessionManager {
85
341
  state: s.state,
86
342
  cwd: s.cwd,
87
343
  meta: s.meta,
344
+ config: s.lastStartConfig,
345
+ ccSessionId: s.ccSessionId,
88
346
  eventCount: s.eventCounter,
89
347
  createdAt: s.createdAt,
90
348
  lastActivityAt: s.lastActivityAt