@sna-sdk/core 0.2.3 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -1
- package/dist/core/providers/claude-code.js +67 -7
- package/dist/core/providers/types.d.ts +30 -5
- package/dist/index.d.ts +1 -1
- package/dist/scripts/sna.js +175 -1
- package/dist/server/api-types.d.ts +15 -0
- package/dist/server/image-store.d.ts +23 -0
- package/dist/server/image-store.js +34 -0
- package/dist/server/index.d.ts +1 -1
- package/dist/server/routes/agent.js +46 -8
- package/dist/server/routes/chat.js +22 -0
- package/dist/server/session-manager.d.ts +18 -2
- package/dist/server/session-manager.js +56 -3
- package/dist/server/standalone.js +276 -27
- package/dist/server/ws.js +54 -7
- package/dist/testing/mock-api.d.ts +35 -0
- package/dist/testing/mock-api.js +140 -0
- package/package.json +6 -1
package/dist/server/ws.js
CHANGED
|
@@ -4,6 +4,7 @@ import { getDb } from "../db/schema.js";
|
|
|
4
4
|
import { logger } from "../lib/logger.js";
|
|
5
5
|
import { runOnce } from "./routes/agent.js";
|
|
6
6
|
import { wsReply } from "./api-types.js";
|
|
7
|
+
import { saveImages } from "./image-store.js";
|
|
7
8
|
function send(ws, data) {
|
|
8
9
|
if (ws.readyState === ws.OPEN) {
|
|
9
10
|
ws.send(JSON.stringify(data));
|
|
@@ -29,10 +30,13 @@ function attachWebSocket(server, sessionManager) {
|
|
|
29
30
|
});
|
|
30
31
|
wss.on("connection", (ws) => {
|
|
31
32
|
logger.log("ws", "client connected");
|
|
32
|
-
const state = { agentUnsubs: /* @__PURE__ */ new Map(), skillEventUnsub: null, skillPollTimer: null, permissionUnsub: null, lifecycleUnsub: null };
|
|
33
|
+
const state = { agentUnsubs: /* @__PURE__ */ new Map(), skillEventUnsub: null, skillPollTimer: null, permissionUnsub: null, lifecycleUnsub: null, configChangedUnsub: null };
|
|
33
34
|
state.lifecycleUnsub = sessionManager.onSessionLifecycle((event) => {
|
|
34
35
|
send(ws, { type: "session.lifecycle", ...event });
|
|
35
36
|
});
|
|
37
|
+
state.configChangedUnsub = sessionManager.onConfigChanged((event) => {
|
|
38
|
+
send(ws, { type: "session.config-changed", ...event });
|
|
39
|
+
});
|
|
36
40
|
ws.on("message", (raw) => {
|
|
37
41
|
let msg;
|
|
38
42
|
try {
|
|
@@ -61,6 +65,8 @@ function attachWebSocket(server, sessionManager) {
|
|
|
61
65
|
state.permissionUnsub = null;
|
|
62
66
|
state.lifecycleUnsub?.();
|
|
63
67
|
state.lifecycleUnsub = null;
|
|
68
|
+
state.configChangedUnsub?.();
|
|
69
|
+
state.configChangedUnsub = null;
|
|
64
70
|
});
|
|
65
71
|
});
|
|
66
72
|
return wss;
|
|
@@ -83,6 +89,10 @@ function handleMessage(ws, msg, sm, state) {
|
|
|
83
89
|
return handleAgentRestart(ws, msg, sm);
|
|
84
90
|
case "agent.interrupt":
|
|
85
91
|
return handleAgentInterrupt(ws, msg, sm);
|
|
92
|
+
case "agent.set-model":
|
|
93
|
+
return handleAgentSetModel(ws, msg, sm);
|
|
94
|
+
case "agent.set-permission-mode":
|
|
95
|
+
return handleAgentSetPermissionMode(ws, msg, sm);
|
|
86
96
|
case "agent.kill":
|
|
87
97
|
return handleAgentKill(ws, msg, sm);
|
|
88
98
|
case "agent.status":
|
|
@@ -184,6 +194,7 @@ function handleAgentStart(ws, msg, sm) {
|
|
|
184
194
|
model,
|
|
185
195
|
permissionMode,
|
|
186
196
|
env: { SNA_SESSION_ID: sessionId },
|
|
197
|
+
history: msg.history,
|
|
187
198
|
extraArgs
|
|
188
199
|
});
|
|
189
200
|
sm.setProcess(sessionId, proc);
|
|
@@ -199,23 +210,42 @@ function handleAgentSend(ws, msg, sm) {
|
|
|
199
210
|
if (!session?.process?.alive) {
|
|
200
211
|
return replyError(ws, msg, `No active agent session "${sessionId}". Start first.`);
|
|
201
212
|
}
|
|
202
|
-
|
|
203
|
-
|
|
213
|
+
const images = msg.images;
|
|
214
|
+
if (!msg.message && !images?.length) {
|
|
215
|
+
return replyError(ws, msg, "message or images required");
|
|
216
|
+
}
|
|
217
|
+
const textContent = msg.message ?? "(image)";
|
|
218
|
+
let meta = msg.meta ? { ...msg.meta } : {};
|
|
219
|
+
if (images?.length) {
|
|
220
|
+
const filenames = saveImages(sessionId, images);
|
|
221
|
+
meta.images = filenames;
|
|
204
222
|
}
|
|
205
223
|
try {
|
|
206
224
|
const db = getDb();
|
|
207
225
|
db.prepare(`INSERT OR IGNORE INTO chat_sessions (id, label, type) VALUES (?, ?, 'main')`).run(sessionId, session.label ?? sessionId);
|
|
208
|
-
db.prepare(`INSERT INTO chat_messages (session_id, role, content, meta) VALUES (?, 'user', ?, ?)`).run(sessionId,
|
|
226
|
+
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);
|
|
209
227
|
} catch {
|
|
210
228
|
}
|
|
211
229
|
session.state = "processing";
|
|
212
230
|
sm.touch(sessionId);
|
|
213
|
-
|
|
231
|
+
if (images?.length) {
|
|
232
|
+
const content = [
|
|
233
|
+
...images.map((img) => ({
|
|
234
|
+
type: "image",
|
|
235
|
+
source: { type: "base64", media_type: img.mimeType, data: img.base64 }
|
|
236
|
+
})),
|
|
237
|
+
...msg.message ? [{ type: "text", text: msg.message }] : []
|
|
238
|
+
];
|
|
239
|
+
session.process.send(content);
|
|
240
|
+
} else {
|
|
241
|
+
session.process.send(msg.message);
|
|
242
|
+
}
|
|
214
243
|
wsReply(ws, msg, { status: "sent" });
|
|
215
244
|
}
|
|
216
245
|
function handleAgentRestart(ws, msg, sm) {
|
|
217
246
|
const sessionId = msg.session ?? "default";
|
|
218
247
|
try {
|
|
248
|
+
const ccSessionId = sm.getSession(sessionId)?.ccSessionId;
|
|
219
249
|
const { config } = sm.restartSession(
|
|
220
250
|
sessionId,
|
|
221
251
|
{
|
|
@@ -226,12 +256,13 @@ function handleAgentRestart(ws, msg, sm) {
|
|
|
226
256
|
},
|
|
227
257
|
(cfg) => {
|
|
228
258
|
const prov = getProvider(cfg.provider);
|
|
259
|
+
const resumeArgs = ccSessionId ? ["--resume", ccSessionId] : ["--resume"];
|
|
229
260
|
return prov.spawn({
|
|
230
261
|
cwd: sm.getSession(sessionId).cwd,
|
|
231
262
|
model: cfg.model,
|
|
232
263
|
permissionMode: cfg.permissionMode,
|
|
233
264
|
env: { SNA_SESSION_ID: sessionId },
|
|
234
|
-
extraArgs: [...cfg.extraArgs ?? [],
|
|
265
|
+
extraArgs: [...cfg.extraArgs ?? [], ...resumeArgs]
|
|
235
266
|
});
|
|
236
267
|
}
|
|
237
268
|
);
|
|
@@ -245,6 +276,20 @@ function handleAgentInterrupt(ws, msg, sm) {
|
|
|
245
276
|
const interrupted = sm.interruptSession(sessionId);
|
|
246
277
|
wsReply(ws, msg, { status: interrupted ? "interrupted" : "no_session" });
|
|
247
278
|
}
|
|
279
|
+
function handleAgentSetModel(ws, msg, sm) {
|
|
280
|
+
const sessionId = msg.session ?? "default";
|
|
281
|
+
const model = msg.model;
|
|
282
|
+
if (!model) return replyError(ws, msg, "model is required");
|
|
283
|
+
const updated = sm.setSessionModel(sessionId, model);
|
|
284
|
+
wsReply(ws, msg, { status: updated ? "updated" : "no_session", model });
|
|
285
|
+
}
|
|
286
|
+
function handleAgentSetPermissionMode(ws, msg, sm) {
|
|
287
|
+
const sessionId = msg.session ?? "default";
|
|
288
|
+
const permissionMode = msg.permissionMode;
|
|
289
|
+
if (!permissionMode) return replyError(ws, msg, "permissionMode is required");
|
|
290
|
+
const updated = sm.setSessionPermissionMode(sessionId, permissionMode);
|
|
291
|
+
wsReply(ws, msg, { status: updated ? "updated" : "no_session", permissionMode });
|
|
292
|
+
}
|
|
248
293
|
function handleAgentKill(ws, msg, sm) {
|
|
249
294
|
const sessionId = msg.session ?? "default";
|
|
250
295
|
const killed = sm.killSession(sessionId);
|
|
@@ -256,7 +301,9 @@ function handleAgentStatus(ws, msg, sm) {
|
|
|
256
301
|
wsReply(ws, msg, {
|
|
257
302
|
alive: session?.process?.alive ?? false,
|
|
258
303
|
sessionId: session?.process?.sessionId ?? null,
|
|
259
|
-
|
|
304
|
+
ccSessionId: session?.ccSessionId ?? null,
|
|
305
|
+
eventCount: session?.eventCounter ?? 0,
|
|
306
|
+
config: session?.lastStartConfig ?? null
|
|
260
307
|
});
|
|
261
308
|
}
|
|
262
309
|
async function handleAgentRunOnce(ws, msg, sm) {
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import http from 'http';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Mock Anthropic Messages API server for testing.
|
|
5
|
+
*
|
|
6
|
+
* Implements POST /v1/messages with streaming SSE responses.
|
|
7
|
+
* Set ANTHROPIC_BASE_URL=http://localhost:<port> and
|
|
8
|
+
* ANTHROPIC_API_KEY=any-string to redirect Claude Code here.
|
|
9
|
+
*
|
|
10
|
+
* All requests and responses are logged to stdout (captured by sna tu api:up → .sna/mock-api.log).
|
|
11
|
+
*
|
|
12
|
+
* Usage:
|
|
13
|
+
* import { startMockAnthropicServer } from "@sna-sdk/core/testing";
|
|
14
|
+
* const mock = await startMockAnthropicServer();
|
|
15
|
+
* process.env.ANTHROPIC_BASE_URL = `http://localhost:${mock.port}`;
|
|
16
|
+
* process.env.ANTHROPIC_API_KEY = "test-key";
|
|
17
|
+
* // ... spawn claude code, run tests ...
|
|
18
|
+
* mock.close();
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
interface MockServer {
|
|
22
|
+
port: number;
|
|
23
|
+
server: http.Server;
|
|
24
|
+
close: () => void;
|
|
25
|
+
/** Messages received by the mock server */
|
|
26
|
+
requests: Array<{
|
|
27
|
+
model: string;
|
|
28
|
+
messages: any[];
|
|
29
|
+
stream: boolean;
|
|
30
|
+
timestamp: string;
|
|
31
|
+
}>;
|
|
32
|
+
}
|
|
33
|
+
declare function startMockAnthropicServer(): Promise<MockServer>;
|
|
34
|
+
|
|
35
|
+
export { type MockServer, startMockAnthropicServer };
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import http from "http";
|
|
2
|
+
function ts() {
|
|
3
|
+
return (/* @__PURE__ */ new Date()).toISOString().slice(11, 23);
|
|
4
|
+
}
|
|
5
|
+
async function startMockAnthropicServer() {
|
|
6
|
+
const requests = [];
|
|
7
|
+
const server = http.createServer(async (req, res) => {
|
|
8
|
+
console.log(`[${ts()}] ${req.method} ${req.url} ${req.headers["content-type"] ?? ""}`);
|
|
9
|
+
if (req.method === "OPTIONS") {
|
|
10
|
+
res.writeHead(200, { "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Headers": "*" });
|
|
11
|
+
res.end();
|
|
12
|
+
return;
|
|
13
|
+
}
|
|
14
|
+
if (req.method === "POST" && req.url?.startsWith("/v1/messages")) {
|
|
15
|
+
const chunks = [];
|
|
16
|
+
for await (const chunk of req) chunks.push(chunk);
|
|
17
|
+
const rawBody = Buffer.concat(chunks).toString();
|
|
18
|
+
let body;
|
|
19
|
+
try {
|
|
20
|
+
body = JSON.parse(rawBody);
|
|
21
|
+
} catch (e) {
|
|
22
|
+
console.log(`[${ts()}] ERROR: invalid JSON body: ${rawBody.slice(0, 200)}`);
|
|
23
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
24
|
+
res.end(JSON.stringify({ error: "invalid JSON" }));
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
const entry = { model: body.model, messages: body.messages, stream: body.stream, timestamp: (/* @__PURE__ */ new Date()).toISOString() };
|
|
28
|
+
requests.push(entry);
|
|
29
|
+
const lastUser = body.messages?.filter((m) => m.role === "user").pop();
|
|
30
|
+
let userText = "(no text)";
|
|
31
|
+
if (typeof lastUser?.content === "string") {
|
|
32
|
+
userText = lastUser.content;
|
|
33
|
+
} else if (Array.isArray(lastUser?.content)) {
|
|
34
|
+
const textBlocks = lastUser.content.filter((b) => b.type === "text").map((b) => b.text);
|
|
35
|
+
const realText = textBlocks.find((t) => !t.startsWith("<system-reminder>"));
|
|
36
|
+
userText = realText ?? textBlocks[textBlocks.length - 1] ?? "(no text)";
|
|
37
|
+
}
|
|
38
|
+
console.log(`[${ts()}] REQ model=${body.model} stream=${body.stream} messages=${body.messages?.length} user="${userText.slice(0, 120)}"`);
|
|
39
|
+
for (let mi = 0; mi < body.messages.length; mi++) {
|
|
40
|
+
const m = body.messages[mi];
|
|
41
|
+
const role = m.role;
|
|
42
|
+
let preview = "";
|
|
43
|
+
if (typeof m.content === "string") {
|
|
44
|
+
preview = m.content.slice(0, 150);
|
|
45
|
+
} else if (Array.isArray(m.content)) {
|
|
46
|
+
preview = m.content.map((b) => {
|
|
47
|
+
if (b.type === "text") return `text:"${b.text.slice(0, 100)}"`;
|
|
48
|
+
if (b.type === "image") return `image:${b.source?.media_type}`;
|
|
49
|
+
return b.type;
|
|
50
|
+
}).join(" | ");
|
|
51
|
+
}
|
|
52
|
+
console.log(`[${ts()}] [${mi}] ${role}: ${preview}`);
|
|
53
|
+
}
|
|
54
|
+
const replyText = [...userText].reverse().join("");
|
|
55
|
+
const messageId = `msg_mock_${Date.now()}`;
|
|
56
|
+
if (body.stream) {
|
|
57
|
+
res.writeHead(200, {
|
|
58
|
+
"Content-Type": "text/event-stream",
|
|
59
|
+
"Cache-Control": "no-cache",
|
|
60
|
+
"Connection": "keep-alive"
|
|
61
|
+
});
|
|
62
|
+
const send = (event, data) => {
|
|
63
|
+
res.write(`event: ${event}
|
|
64
|
+
data: ${JSON.stringify(data)}
|
|
65
|
+
|
|
66
|
+
`);
|
|
67
|
+
};
|
|
68
|
+
send("message_start", {
|
|
69
|
+
type: "message_start",
|
|
70
|
+
message: {
|
|
71
|
+
id: messageId,
|
|
72
|
+
type: "message",
|
|
73
|
+
role: "assistant",
|
|
74
|
+
model: body.model,
|
|
75
|
+
content: [],
|
|
76
|
+
stop_reason: null,
|
|
77
|
+
usage: { input_tokens: 100, output_tokens: 0, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 }
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
send("content_block_start", {
|
|
81
|
+
type: "content_block_start",
|
|
82
|
+
index: 0,
|
|
83
|
+
content_block: { type: "text", text: "" }
|
|
84
|
+
});
|
|
85
|
+
const words = replyText.split(" ");
|
|
86
|
+
for (const word of words) {
|
|
87
|
+
send("content_block_delta", {
|
|
88
|
+
type: "content_block_delta",
|
|
89
|
+
index: 0,
|
|
90
|
+
delta: { type: "text_delta", text: word + " " }
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
send("content_block_stop", { type: "content_block_stop", index: 0 });
|
|
94
|
+
send("message_delta", {
|
|
95
|
+
type: "message_delta",
|
|
96
|
+
delta: { stop_reason: "end_turn", stop_sequence: null },
|
|
97
|
+
usage: { output_tokens: words.length * 2 }
|
|
98
|
+
});
|
|
99
|
+
send("message_stop", { type: "message_stop" });
|
|
100
|
+
res.end();
|
|
101
|
+
console.log(`[${ts()}] RES stream complete reply="${replyText.slice(0, 80)}"`);
|
|
102
|
+
} else {
|
|
103
|
+
const response = {
|
|
104
|
+
id: messageId,
|
|
105
|
+
type: "message",
|
|
106
|
+
role: "assistant",
|
|
107
|
+
model: body.model,
|
|
108
|
+
content: [{ type: "text", text: replyText }],
|
|
109
|
+
stop_reason: "end_turn",
|
|
110
|
+
usage: { input_tokens: 100, output_tokens: 20, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 }
|
|
111
|
+
};
|
|
112
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
113
|
+
res.end(JSON.stringify(response));
|
|
114
|
+
console.log(`[${ts()}] RES json reply="${replyText.slice(0, 80)}"`);
|
|
115
|
+
}
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
console.log(`[${ts()}] 404 ${req.method} ${req.url}`);
|
|
119
|
+
res.writeHead(404);
|
|
120
|
+
res.end(JSON.stringify({ error: "Not found" }));
|
|
121
|
+
});
|
|
122
|
+
return new Promise((resolve) => {
|
|
123
|
+
server.listen(0, () => {
|
|
124
|
+
const port = server.address().port;
|
|
125
|
+
console.log(`[${ts()}] Mock Anthropic API server listening on :${port}`);
|
|
126
|
+
resolve({
|
|
127
|
+
port,
|
|
128
|
+
server,
|
|
129
|
+
close: () => {
|
|
130
|
+
console.log(`[${ts()}] Mock API server shutting down`);
|
|
131
|
+
server.close();
|
|
132
|
+
},
|
|
133
|
+
requests
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
export {
|
|
139
|
+
startMockAnthropicServer
|
|
140
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sna-sdk/core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Skills-Native Application runtime — server, providers, session management, database, and CLI",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -63,6 +63,11 @@
|
|
|
63
63
|
"source": "./src/lib/sna-run.ts",
|
|
64
64
|
"types": "./dist/lib/sna-run.d.ts",
|
|
65
65
|
"default": "./dist/lib/sna-run.js"
|
|
66
|
+
},
|
|
67
|
+
"./testing": {
|
|
68
|
+
"source": "./src/testing/mock-api.ts",
|
|
69
|
+
"types": "./dist/testing/mock-api.d.ts",
|
|
70
|
+
"default": "./dist/testing/mock-api.js"
|
|
66
71
|
}
|
|
67
72
|
},
|
|
68
73
|
"engines": {
|