@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.
- package/README.md +7 -1
- package/dist/core/providers/cc-history-adapter.d.ts +37 -0
- package/dist/core/providers/cc-history-adapter.js +70 -0
- package/dist/core/providers/claude-code.js +57 -7
- package/dist/core/providers/types.d.ts +32 -5
- package/dist/db/schema.js +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/scripts/sna.js +193 -1
- package/dist/scripts/tu-oneshot.d.ts +2 -0
- package/dist/scripts/tu-oneshot.js +66 -0
- package/dist/server/api-types.d.ts +22 -0
- package/dist/server/history-builder.d.ts +16 -0
- package/dist/server/history-builder.js +25 -0
- package/dist/server/image-store.d.ts +23 -0
- package/dist/server/image-store.js +34 -0
- package/dist/server/index.d.ts +2 -1
- package/dist/server/index.js +2 -0
- package/dist/server/routes/agent.js +91 -10
- package/dist/server/routes/chat.js +22 -0
- package/dist/server/session-manager.d.ts +31 -4
- package/dist/server/session-manager.js +85 -12
- package/dist/server/standalone.js +488 -51
- package/dist/server/ws.js +103 -9
- package/dist/testing/mock-api.d.ts +35 -0
- package/dist/testing/mock-api.js +160 -0
- package/package.json +6 -1
|
@@ -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
|
+
};
|
package/dist/server/index.d.ts
CHANGED
|
@@ -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';
|
package/dist/server/index.js
CHANGED
|
@@ -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
|
|
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
|
|
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,
|
|
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
|
-
|
|
198
|
+
sessionManager.updateSessionState(sessionId, "processing");
|
|
190
199
|
sessionManager.touch(sessionId);
|
|
191
|
-
|
|
192
|
-
|
|
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 ?? [],
|
|
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
|
|
345
|
+
alive,
|
|
346
|
+
agentStatus: !alive ? "disconnected" : session?.state === "processing" ? "busy" : "idle",
|
|
268
347
|
sessionId: session?.process?.sessionId ?? null,
|
|
269
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
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)
|
|
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
|
|
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
|
-
|
|
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
|