@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.
- package/README.md +15 -7
- package/dist/core/providers/claude-code.js +9 -3
- package/dist/core/providers/types.d.ts +2 -0
- package/dist/db/schema.d.ts +1 -0
- package/dist/db/schema.js +19 -2
- package/dist/lib/logger.d.ts +1 -0
- package/dist/lib/logger.js +2 -0
- package/dist/scripts/hook.js +1 -1
- package/dist/scripts/sna.js +50 -0
- package/dist/server/api-types.d.ts +105 -0
- package/dist/server/api-types.js +13 -0
- package/dist/server/index.d.ts +5 -2
- package/dist/server/index.js +7 -4
- package/dist/server/routes/agent.d.ts +21 -1
- package/dist/server/routes/agent.js +125 -59
- package/dist/server/routes/chat.js +8 -7
- package/dist/server/routes/emit.d.ts +11 -1
- package/dist/server/routes/emit.js +26 -0
- package/dist/server/session-manager.d.ts +58 -1
- package/dist/server/session-manager.js +207 -2
- package/dist/server/standalone.js +884 -84
- package/dist/server/ws.d.ts +55 -0
- package/dist/server/ws.js +485 -0
- package/package.json +4 -2
|
@@ -1,16 +1,80 @@
|
|
|
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();
|
|
7
13
|
this.maxSessions = options.maxSessions ?? DEFAULT_MAX_SESSIONS;
|
|
14
|
+
this.restoreFromDb();
|
|
15
|
+
}
|
|
16
|
+
/** Restore session metadata from DB (cwd, label, meta). Process state is not restored. */
|
|
17
|
+
restoreFromDb() {
|
|
18
|
+
try {
|
|
19
|
+
const db = getDb();
|
|
20
|
+
const rows = db.prepare(
|
|
21
|
+
`SELECT id, label, meta, cwd, last_start_config, created_at FROM chat_sessions`
|
|
22
|
+
).all();
|
|
23
|
+
for (const row of rows) {
|
|
24
|
+
if (this.sessions.has(row.id)) continue;
|
|
25
|
+
this.sessions.set(row.id, {
|
|
26
|
+
id: row.id,
|
|
27
|
+
process: null,
|
|
28
|
+
eventBuffer: [],
|
|
29
|
+
eventCounter: 0,
|
|
30
|
+
label: row.label,
|
|
31
|
+
cwd: row.cwd ?? process.cwd(),
|
|
32
|
+
meta: row.meta ? JSON.parse(row.meta) : null,
|
|
33
|
+
state: "idle",
|
|
34
|
+
lastStartConfig: row.last_start_config ? JSON.parse(row.last_start_config) : null,
|
|
35
|
+
createdAt: new Date(row.created_at).getTime() || Date.now(),
|
|
36
|
+
lastActivityAt: Date.now()
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
} catch {
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
/** Persist session metadata to DB. */
|
|
43
|
+
persistSession(session) {
|
|
44
|
+
try {
|
|
45
|
+
const db = getDb();
|
|
46
|
+
db.prepare(
|
|
47
|
+
`INSERT OR REPLACE INTO chat_sessions (id, label, type, meta, cwd, last_start_config) VALUES (?, ?, 'main', ?, ?, ?)`
|
|
48
|
+
).run(
|
|
49
|
+
session.id,
|
|
50
|
+
session.label,
|
|
51
|
+
session.meta ? JSON.stringify(session.meta) : null,
|
|
52
|
+
session.cwd,
|
|
53
|
+
session.lastStartConfig ? JSON.stringify(session.lastStartConfig) : null
|
|
54
|
+
);
|
|
55
|
+
} catch {
|
|
56
|
+
}
|
|
8
57
|
}
|
|
9
58
|
/** Create a new session. Throws if max sessions reached. */
|
|
10
59
|
createSession(opts = {}) {
|
|
11
60
|
const id = opts.id ?? crypto.randomUUID().slice(0, 8);
|
|
12
61
|
if (this.sessions.has(id)) {
|
|
13
|
-
|
|
62
|
+
const existing = this.sessions.get(id);
|
|
63
|
+
let changed = false;
|
|
64
|
+
if (opts.cwd && opts.cwd !== existing.cwd) {
|
|
65
|
+
existing.cwd = opts.cwd;
|
|
66
|
+
changed = true;
|
|
67
|
+
}
|
|
68
|
+
if (opts.label && opts.label !== existing.label) {
|
|
69
|
+
existing.label = opts.label;
|
|
70
|
+
changed = true;
|
|
71
|
+
}
|
|
72
|
+
if (opts.meta !== void 0 && opts.meta !== existing.meta) {
|
|
73
|
+
existing.meta = opts.meta ?? null;
|
|
74
|
+
changed = true;
|
|
75
|
+
}
|
|
76
|
+
if (changed) this.persistSession(existing);
|
|
77
|
+
return existing;
|
|
14
78
|
}
|
|
15
79
|
const aliveCount = Array.from(this.sessions.values()).filter((s) => s.process?.alive).length;
|
|
16
80
|
if (aliveCount >= this.maxSessions) {
|
|
@@ -25,10 +89,12 @@ class SessionManager {
|
|
|
25
89
|
cwd: opts.cwd ?? process.cwd(),
|
|
26
90
|
meta: opts.meta ?? null,
|
|
27
91
|
state: "idle",
|
|
92
|
+
lastStartConfig: null,
|
|
28
93
|
createdAt: Date.now(),
|
|
29
94
|
lastActivityAt: Date.now()
|
|
30
95
|
};
|
|
31
96
|
this.sessions.set(id, session);
|
|
97
|
+
this.persistSession(session);
|
|
32
98
|
return session;
|
|
33
99
|
}
|
|
34
100
|
/** Get a session by ID. */
|
|
@@ -38,7 +104,13 @@ class SessionManager {
|
|
|
38
104
|
/** Get or create a session (used for "default" backward compat). */
|
|
39
105
|
getOrCreateSession(id, opts) {
|
|
40
106
|
const existing = this.sessions.get(id);
|
|
41
|
-
if (existing)
|
|
107
|
+
if (existing) {
|
|
108
|
+
if (opts?.cwd && opts.cwd !== existing.cwd) {
|
|
109
|
+
existing.cwd = opts.cwd;
|
|
110
|
+
this.persistSession(existing);
|
|
111
|
+
}
|
|
112
|
+
return existing;
|
|
113
|
+
}
|
|
42
114
|
return this.createSession({ id, ...opts });
|
|
43
115
|
}
|
|
44
116
|
/** Set the agent process for a session. Subscribes to events. */
|
|
@@ -58,13 +130,144 @@ class SessionManager {
|
|
|
58
130
|
session.state = "waiting";
|
|
59
131
|
}
|
|
60
132
|
this.persistEvent(sessionId, e);
|
|
133
|
+
const listeners = this.eventListeners.get(sessionId);
|
|
134
|
+
if (listeners) {
|
|
135
|
+
for (const cb of listeners) cb(session.eventCounter, e);
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
proc.on("exit", (code) => {
|
|
139
|
+
session.state = "idle";
|
|
140
|
+
this.emitLifecycle({ session: sessionId, state: code != null ? "exited" : "crashed", code });
|
|
141
|
+
});
|
|
142
|
+
proc.on("error", () => {
|
|
143
|
+
session.state = "idle";
|
|
144
|
+
this.emitLifecycle({ session: sessionId, state: "crashed" });
|
|
145
|
+
});
|
|
146
|
+
this.emitLifecycle({ session: sessionId, state: "started" });
|
|
147
|
+
}
|
|
148
|
+
// ── Event pub/sub (for WebSocket) ─────────────────────────────
|
|
149
|
+
/** Subscribe to real-time events for a session. Returns unsubscribe function. */
|
|
150
|
+
onSessionEvent(sessionId, cb) {
|
|
151
|
+
let set = this.eventListeners.get(sessionId);
|
|
152
|
+
if (!set) {
|
|
153
|
+
set = /* @__PURE__ */ new Set();
|
|
154
|
+
this.eventListeners.set(sessionId, set);
|
|
155
|
+
}
|
|
156
|
+
set.add(cb);
|
|
157
|
+
return () => {
|
|
158
|
+
set.delete(cb);
|
|
159
|
+
if (set.size === 0) this.eventListeners.delete(sessionId);
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
// ── Skill event pub/sub ────────────────────────────────────────
|
|
163
|
+
/** Subscribe to skill events broadcast. Returns unsubscribe function. */
|
|
164
|
+
onSkillEvent(cb) {
|
|
165
|
+
this.skillEventListeners.add(cb);
|
|
166
|
+
return () => this.skillEventListeners.delete(cb);
|
|
167
|
+
}
|
|
168
|
+
/** Broadcast a skill event to all subscribers (called after DB insert). */
|
|
169
|
+
broadcastSkillEvent(event) {
|
|
170
|
+
for (const cb of this.skillEventListeners) cb(event);
|
|
171
|
+
}
|
|
172
|
+
// ── Permission pub/sub ────────────────────────────────────────
|
|
173
|
+
/** Subscribe to permission request notifications. Returns unsubscribe function. */
|
|
174
|
+
onPermissionRequest(cb) {
|
|
175
|
+
this.permissionRequestListeners.add(cb);
|
|
176
|
+
return () => this.permissionRequestListeners.delete(cb);
|
|
177
|
+
}
|
|
178
|
+
// ── Session lifecycle pub/sub ──────────────────────────────────
|
|
179
|
+
/** Subscribe to session lifecycle events (started/killed/exited/crashed). Returns unsubscribe function. */
|
|
180
|
+
onSessionLifecycle(cb) {
|
|
181
|
+
this.lifecycleListeners.add(cb);
|
|
182
|
+
return () => this.lifecycleListeners.delete(cb);
|
|
183
|
+
}
|
|
184
|
+
emitLifecycle(event) {
|
|
185
|
+
for (const cb of this.lifecycleListeners) cb(event);
|
|
186
|
+
}
|
|
187
|
+
// ── Permission management ─────────────────────────────────────
|
|
188
|
+
/** Create a pending permission request. Returns a promise that resolves when approved/denied. */
|
|
189
|
+
createPendingPermission(sessionId, request) {
|
|
190
|
+
const session = this.sessions.get(sessionId);
|
|
191
|
+
if (session) session.state = "permission";
|
|
192
|
+
return new Promise((resolve) => {
|
|
193
|
+
const createdAt = Date.now();
|
|
194
|
+
this.pendingPermissions.set(sessionId, { resolve, request, createdAt });
|
|
195
|
+
for (const cb of this.permissionRequestListeners) cb(sessionId, request, createdAt);
|
|
196
|
+
setTimeout(() => {
|
|
197
|
+
if (this.pendingPermissions.has(sessionId)) {
|
|
198
|
+
this.pendingPermissions.delete(sessionId);
|
|
199
|
+
resolve(false);
|
|
200
|
+
}
|
|
201
|
+
}, PERMISSION_TIMEOUT_MS);
|
|
61
202
|
});
|
|
62
203
|
}
|
|
204
|
+
/** Resolve a pending permission request. Returns false if no pending request. */
|
|
205
|
+
resolvePendingPermission(sessionId, approved) {
|
|
206
|
+
const pending = this.pendingPermissions.get(sessionId);
|
|
207
|
+
if (!pending) return false;
|
|
208
|
+
pending.resolve(approved);
|
|
209
|
+
this.pendingPermissions.delete(sessionId);
|
|
210
|
+
const session = this.sessions.get(sessionId);
|
|
211
|
+
if (session) session.state = "processing";
|
|
212
|
+
return true;
|
|
213
|
+
}
|
|
214
|
+
/** Get a pending permission for a specific session. */
|
|
215
|
+
getPendingPermission(sessionId) {
|
|
216
|
+
const p = this.pendingPermissions.get(sessionId);
|
|
217
|
+
return p ? { request: p.request, createdAt: p.createdAt } : null;
|
|
218
|
+
}
|
|
219
|
+
/** Get all pending permissions across sessions. */
|
|
220
|
+
getAllPendingPermissions() {
|
|
221
|
+
return Array.from(this.pendingPermissions.entries()).map(([id, p]) => ({
|
|
222
|
+
sessionId: id,
|
|
223
|
+
request: p.request,
|
|
224
|
+
createdAt: p.createdAt
|
|
225
|
+
}));
|
|
226
|
+
}
|
|
227
|
+
// ── Session lifecycle ─────────────────────────────────────────
|
|
228
|
+
/** Kill the agent process in a session (session stays, can be restarted). */
|
|
229
|
+
/** Save the start config for a session (called by start handlers). */
|
|
230
|
+
saveStartConfig(id, config) {
|
|
231
|
+
const session = this.sessions.get(id);
|
|
232
|
+
if (!session) return;
|
|
233
|
+
session.lastStartConfig = config;
|
|
234
|
+
this.persistSession(session);
|
|
235
|
+
}
|
|
236
|
+
/** Restart session: kill → re-spawn with merged config + --resume. */
|
|
237
|
+
restartSession(id, overrides, spawnFn) {
|
|
238
|
+
const session = this.sessions.get(id);
|
|
239
|
+
if (!session) throw new Error(`Session "${id}" not found`);
|
|
240
|
+
const base = session.lastStartConfig;
|
|
241
|
+
if (!base) throw new Error(`Session "${id}" has no previous start config`);
|
|
242
|
+
const config = {
|
|
243
|
+
provider: overrides.provider ?? base.provider,
|
|
244
|
+
model: overrides.model ?? base.model,
|
|
245
|
+
permissionMode: overrides.permissionMode ?? base.permissionMode,
|
|
246
|
+
extraArgs: overrides.extraArgs ?? base.extraArgs
|
|
247
|
+
};
|
|
248
|
+
if (session.process?.alive) session.process.kill();
|
|
249
|
+
session.eventBuffer.length = 0;
|
|
250
|
+
const proc = spawnFn(config);
|
|
251
|
+
this.setProcess(id, proc);
|
|
252
|
+
session.lastStartConfig = config;
|
|
253
|
+
this.persistSession(session);
|
|
254
|
+
this.emitLifecycle({ session: id, state: "restarted" });
|
|
255
|
+
return { config };
|
|
256
|
+
}
|
|
257
|
+
/** Interrupt the current turn (SIGINT). Process stays alive, returns to waiting. */
|
|
258
|
+
interruptSession(id) {
|
|
259
|
+
const session = this.sessions.get(id);
|
|
260
|
+
if (!session?.process?.alive) return false;
|
|
261
|
+
session.process.interrupt();
|
|
262
|
+
session.state = "waiting";
|
|
263
|
+
return true;
|
|
264
|
+
}
|
|
63
265
|
/** Kill the agent process in a session (session stays, can be restarted). */
|
|
64
266
|
killSession(id) {
|
|
65
267
|
const session = this.sessions.get(id);
|
|
66
268
|
if (!session?.process?.alive) return false;
|
|
67
269
|
session.process.kill();
|
|
270
|
+
this.emitLifecycle({ session: id, state: "killed" });
|
|
68
271
|
return true;
|
|
69
272
|
}
|
|
70
273
|
/** Remove a session entirely. Cannot remove "default". */
|
|
@@ -73,6 +276,8 @@ class SessionManager {
|
|
|
73
276
|
const session = this.sessions.get(id);
|
|
74
277
|
if (!session) return false;
|
|
75
278
|
if (session.process?.alive) session.process.kill();
|
|
279
|
+
this.eventListeners.delete(id);
|
|
280
|
+
this.pendingPermissions.delete(id);
|
|
76
281
|
this.sessions.delete(id);
|
|
77
282
|
return true;
|
|
78
283
|
}
|