@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/README.md
CHANGED
|
@@ -10,7 +10,7 @@ Server runtime for [Skills-Native Applications](https://github.com/neuradex/sna)
|
|
|
10
10
|
- **Hono server factory** — `createSnaApp()` with events, emit, agent, chat, and run routes
|
|
11
11
|
- **WebSocket API** — `attachWebSocket()` wrapping all HTTP routes over a single WS connection
|
|
12
12
|
- **One-shot execution** — `POST /agent/run-once` for single-request LLM calls
|
|
13
|
-
- **
|
|
13
|
+
- **CLI** — `sna up/down/status`, `sna dispatch`, `sna gen client`, `sna tu` (mock API testing)
|
|
14
14
|
- **Agent providers** — Claude Code and Codex process management
|
|
15
15
|
- **Multi-session** — `SessionManager` with event pub/sub, permission management, and session metadata
|
|
16
16
|
|
|
@@ -84,6 +84,7 @@ const db = getDb(); // SQLite instance (data/sna.db)
|
|
|
84
84
|
| `@sna-sdk/core/db/schema` | `getDb()`, `ChatSession`, `ChatMessage`, `SkillEvent` types |
|
|
85
85
|
| `@sna-sdk/core/providers` | Agent provider factory, `ClaudeCodeProvider` |
|
|
86
86
|
| `@sna-sdk/core/lib/sna-run` | `snaRun()` helper for spawning Claude Code |
|
|
87
|
+
| `@sna-sdk/core/testing` | `startMockAnthropicServer()` for testing without real API calls |
|
|
87
88
|
|
|
88
89
|
## Documentation
|
|
89
90
|
|
|
@@ -5,6 +5,7 @@ import path from "path";
|
|
|
5
5
|
import { logger } from "../../lib/logger.js";
|
|
6
6
|
const SHELL = process.env.SHELL || "/bin/zsh";
|
|
7
7
|
function resolveClaudePath(cwd) {
|
|
8
|
+
if (process.env.SNA_CLAUDE_COMMAND) return process.env.SNA_CLAUDE_COMMAND;
|
|
8
9
|
const cached = path.join(cwd, ".sna/claude-path");
|
|
9
10
|
if (fs.existsSync(cached)) {
|
|
10
11
|
const p = fs.readFileSync(cached, "utf8").trim();
|
|
@@ -38,6 +39,7 @@ class ClaudeCodeProcess {
|
|
|
38
39
|
this.emitter = new EventEmitter();
|
|
39
40
|
this._alive = true;
|
|
40
41
|
this._sessionId = null;
|
|
42
|
+
this._initEmitted = false;
|
|
41
43
|
this.buffer = "";
|
|
42
44
|
this.proc = proc;
|
|
43
45
|
proc.stdout.on("data", (chunk) => {
|
|
@@ -77,6 +79,29 @@ class ClaudeCodeProcess {
|
|
|
77
79
|
this._alive = false;
|
|
78
80
|
this.emitter.emit("error", err);
|
|
79
81
|
});
|
|
82
|
+
if (options.history?.length) {
|
|
83
|
+
if (!options.prompt) {
|
|
84
|
+
throw new Error("history requires a prompt \u2014 the last stdin message must be a user message");
|
|
85
|
+
}
|
|
86
|
+
for (const msg of options.history) {
|
|
87
|
+
if (msg.role === "user") {
|
|
88
|
+
const line = JSON.stringify({
|
|
89
|
+
type: "user",
|
|
90
|
+
message: { role: "user", content: msg.content }
|
|
91
|
+
});
|
|
92
|
+
this.proc.stdin.write(line + "\n");
|
|
93
|
+
} else if (msg.role === "assistant") {
|
|
94
|
+
const line = JSON.stringify({
|
|
95
|
+
type: "assistant",
|
|
96
|
+
message: {
|
|
97
|
+
role: "assistant",
|
|
98
|
+
content: [{ type: "text", text: msg.content }]
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
this.proc.stdin.write(line + "\n");
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
80
105
|
if (options.prompt) {
|
|
81
106
|
this.send(options.prompt);
|
|
82
107
|
}
|
|
@@ -89,20 +114,41 @@ class ClaudeCodeProcess {
|
|
|
89
114
|
}
|
|
90
115
|
/**
|
|
91
116
|
* Send a user message to the persistent Claude process via stdin.
|
|
117
|
+
* Accepts plain string or content block array (text + images).
|
|
92
118
|
*/
|
|
93
119
|
send(input) {
|
|
94
120
|
if (!this._alive || !this.proc.stdin.writable) return;
|
|
121
|
+
const content = typeof input === "string" ? input : input;
|
|
95
122
|
const msg = JSON.stringify({
|
|
96
123
|
type: "user",
|
|
97
|
-
message: { role: "user", content
|
|
124
|
+
message: { role: "user", content }
|
|
98
125
|
});
|
|
99
126
|
logger.log("stdin", msg.slice(0, 200));
|
|
100
127
|
this.proc.stdin.write(msg + "\n");
|
|
101
128
|
}
|
|
102
129
|
interrupt() {
|
|
103
|
-
if (this._alive)
|
|
104
|
-
|
|
105
|
-
|
|
130
|
+
if (!this._alive || !this.proc.stdin.writable) return;
|
|
131
|
+
const msg = JSON.stringify({
|
|
132
|
+
type: "control_request",
|
|
133
|
+
request: { subtype: "interrupt" }
|
|
134
|
+
});
|
|
135
|
+
this.proc.stdin.write(msg + "\n");
|
|
136
|
+
}
|
|
137
|
+
setModel(model) {
|
|
138
|
+
if (!this._alive || !this.proc.stdin.writable) return;
|
|
139
|
+
const msg = JSON.stringify({
|
|
140
|
+
type: "control_request",
|
|
141
|
+
request: { subtype: "set_model", model }
|
|
142
|
+
});
|
|
143
|
+
this.proc.stdin.write(msg + "\n");
|
|
144
|
+
}
|
|
145
|
+
setPermissionMode(mode) {
|
|
146
|
+
if (!this._alive || !this.proc.stdin.writable) return;
|
|
147
|
+
const msg = JSON.stringify({
|
|
148
|
+
type: "control_request",
|
|
149
|
+
request: { subtype: "set_permission_mode", permission_mode: mode }
|
|
150
|
+
});
|
|
151
|
+
this.proc.stdin.write(msg + "\n");
|
|
106
152
|
}
|
|
107
153
|
kill() {
|
|
108
154
|
if (this._alive) {
|
|
@@ -120,6 +166,8 @@ class ClaudeCodeProcess {
|
|
|
120
166
|
switch (msg.type) {
|
|
121
167
|
case "system": {
|
|
122
168
|
if (msg.subtype === "init") {
|
|
169
|
+
if (this._initEmitted) return null;
|
|
170
|
+
this._initEmitted = true;
|
|
123
171
|
return {
|
|
124
172
|
type: "init",
|
|
125
173
|
message: `Agent ready (${msg.model ?? "unknown"})`,
|
|
@@ -201,6 +249,14 @@ class ClaudeCodeProcess {
|
|
|
201
249
|
timestamp: Date.now()
|
|
202
250
|
};
|
|
203
251
|
}
|
|
252
|
+
if (msg.subtype === "error_during_execution" && msg.is_error === false) {
|
|
253
|
+
return {
|
|
254
|
+
type: "interrupted",
|
|
255
|
+
message: "Turn interrupted by user",
|
|
256
|
+
data: { durationMs: msg.duration_ms, costUsd: msg.total_cost_usd },
|
|
257
|
+
timestamp: Date.now()
|
|
258
|
+
};
|
|
259
|
+
}
|
|
204
260
|
if (msg.subtype?.startsWith("error") || msg.is_error) {
|
|
205
261
|
return {
|
|
206
262
|
type: "error",
|
|
@@ -232,7 +288,10 @@ class ClaudeCodeProvider {
|
|
|
232
288
|
}
|
|
233
289
|
}
|
|
234
290
|
spawn(options) {
|
|
235
|
-
const
|
|
291
|
+
const claudeCommand = resolveClaudePath(options.cwd);
|
|
292
|
+
const claudeParts = claudeCommand.split(/\s+/);
|
|
293
|
+
const claudePath = claudeParts[0];
|
|
294
|
+
const claudePrefix = claudeParts.slice(1);
|
|
236
295
|
const hookScript = new URL("../../scripts/hook.js", import.meta.url).pathname;
|
|
237
296
|
const sessionId = options.env?.SNA_SESSION_ID ?? "default";
|
|
238
297
|
const sdkSettings = {};
|
|
@@ -289,12 +348,13 @@ class ClaudeCodeProvider {
|
|
|
289
348
|
delete cleanEnv.CLAUDECODE;
|
|
290
349
|
delete cleanEnv.CLAUDE_CODE_ENTRYPOINT;
|
|
291
350
|
delete cleanEnv.CLAUDE_CODE_SESSION_ACCESS_TOKEN;
|
|
292
|
-
|
|
351
|
+
delete cleanEnv.CLAUDE_CODE_OAUTH_TOKEN;
|
|
352
|
+
const proc = spawn(claudePath, [...claudePrefix, ...args], {
|
|
293
353
|
cwd: options.cwd,
|
|
294
354
|
env: cleanEnv,
|
|
295
355
|
stdio: ["pipe", "pipe", "pipe"]
|
|
296
356
|
});
|
|
297
|
-
logger.log("agent", `spawned claude-code (pid=${proc.pid}) \u2192 ${
|
|
357
|
+
logger.log("agent", `spawned claude-code (pid=${proc.pid}) \u2192 ${claudeCommand} ${args.join(" ")}`);
|
|
298
358
|
return new ClaudeCodeProcess(proc, options);
|
|
299
359
|
}
|
|
300
360
|
}
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* Codex JSONL, etc.) into these common types.
|
|
6
6
|
*/
|
|
7
7
|
interface AgentEvent {
|
|
8
|
-
type: "init" | "thinking" | "text_delta" | "assistant" | "tool_use" | "tool_result" | "permission_needed" | "milestone" | "error" | "complete";
|
|
8
|
+
type: "init" | "thinking" | "text_delta" | "assistant" | "tool_use" | "tool_result" | "permission_needed" | "milestone" | "interrupted" | "error" | "complete";
|
|
9
9
|
message?: string;
|
|
10
10
|
data?: Record<string, unknown>;
|
|
11
11
|
timestamp: number;
|
|
@@ -14,10 +14,14 @@ interface AgentEvent {
|
|
|
14
14
|
* A running agent process. Wraps a child_process with typed event handlers.
|
|
15
15
|
*/
|
|
16
16
|
interface AgentProcess {
|
|
17
|
-
/** Send a user message to the agent's stdin. */
|
|
18
|
-
send(input: string): void;
|
|
19
|
-
/** Interrupt the current turn
|
|
17
|
+
/** Send a user message to the agent's stdin. Accepts string or content blocks (text + images). */
|
|
18
|
+
send(input: string | ContentBlock[]): void;
|
|
19
|
+
/** Interrupt the current turn. Process stays alive. */
|
|
20
20
|
interrupt(): void;
|
|
21
|
+
/** Change model at runtime via control message. No restart needed. */
|
|
22
|
+
setModel(model: string): void;
|
|
23
|
+
/** Change permission mode at runtime via control message. No restart needed. */
|
|
24
|
+
setPermissionMode(mode: string): void;
|
|
21
25
|
/** Kill the agent process. */
|
|
22
26
|
kill(): void;
|
|
23
27
|
/** Whether the process is still running. */
|
|
@@ -32,12 +36,33 @@ interface AgentProcess {
|
|
|
32
36
|
/**
|
|
33
37
|
* Options for spawning an agent session.
|
|
34
38
|
*/
|
|
39
|
+
type ContentBlock = {
|
|
40
|
+
type: "text";
|
|
41
|
+
text: string;
|
|
42
|
+
} | {
|
|
43
|
+
type: "image";
|
|
44
|
+
source: {
|
|
45
|
+
type: "base64";
|
|
46
|
+
media_type: string;
|
|
47
|
+
data: string;
|
|
48
|
+
};
|
|
49
|
+
};
|
|
50
|
+
interface HistoryMessage {
|
|
51
|
+
role: "user" | "assistant";
|
|
52
|
+
content: string;
|
|
53
|
+
}
|
|
35
54
|
interface SpawnOptions {
|
|
36
55
|
cwd: string;
|
|
37
56
|
prompt?: string;
|
|
38
57
|
model?: string;
|
|
39
58
|
permissionMode?: "default" | "acceptEdits" | "bypassPermissions" | "plan";
|
|
40
59
|
env?: Record<string, string>;
|
|
60
|
+
/**
|
|
61
|
+
* Conversation history to inject before the first prompt.
|
|
62
|
+
* Written to stdin as NDJSON — Claude Code treats these as prior conversation turns.
|
|
63
|
+
* Must alternate user→assistant. Assistant content is auto-wrapped in array format.
|
|
64
|
+
*/
|
|
65
|
+
history?: HistoryMessage[];
|
|
41
66
|
/**
|
|
42
67
|
* Additional CLI flags passed directly to the agent binary.
|
|
43
68
|
* e.g. ["--system-prompt", "You are...", "--append-system-prompt", "Also...", "--mcp-config", "path"]
|
|
@@ -56,4 +81,4 @@ interface AgentProvider {
|
|
|
56
81
|
spawn(options: SpawnOptions): AgentProcess;
|
|
57
82
|
}
|
|
58
83
|
|
|
59
|
-
export type { AgentEvent, AgentProcess, AgentProvider, SpawnOptions };
|
|
84
|
+
export type { AgentEvent, AgentProcess, AgentProvider, ContentBlock, HistoryMessage, SpawnOptions };
|
package/dist/index.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export { ChatMessage, ChatSession, SkillEvent } from './db/schema.js';
|
|
2
|
-
export { AgentEvent, AgentProcess, AgentProvider, SpawnOptions } from './core/providers/types.js';
|
|
2
|
+
export { AgentEvent, AgentProcess, AgentProvider, ContentBlock, HistoryMessage, SpawnOptions } from './core/providers/types.js';
|
|
3
3
|
export { Session, SessionInfo, SessionManagerOptions, SessionState } from './server/session-manager.js';
|
|
4
4
|
export { DispatchCloseOptions, DispatchEventType, DispatchOpenOptions, DispatchOpenResult, DispatchSendOptions, createHandle as createDispatchHandle, close as dispatchClose, open as dispatchOpen, send as dispatchSend } from './lib/dispatch.js';
|
|
5
5
|
import 'better-sqlite3';
|
package/dist/scripts/sna.js
CHANGED
|
@@ -17,6 +17,9 @@ const PORT = process.env.PORT ?? "3000";
|
|
|
17
17
|
const CLAUDE_PATH_FILE = path.join(STATE_DIR, "claude-path");
|
|
18
18
|
const SNA_CORE_DIR = path.join(ROOT, "node_modules/@sna-sdk/core");
|
|
19
19
|
const NATIVE_DIR = path.join(STATE_DIR, "native");
|
|
20
|
+
const MOCK_API_PID_FILE = path.join(STATE_DIR, "mock-api.pid");
|
|
21
|
+
const MOCK_API_PORT_FILE = path.join(STATE_DIR, "mock-api.port");
|
|
22
|
+
const MOCK_API_LOG_FILE = path.join(STATE_DIR, "mock-api.log");
|
|
20
23
|
function ensureStateDir() {
|
|
21
24
|
if (!fs.existsSync(STATE_DIR)) fs.mkdirSync(STATE_DIR, { recursive: true });
|
|
22
25
|
}
|
|
@@ -194,6 +197,161 @@ function cmdApiDown() {
|
|
|
194
197
|
}
|
|
195
198
|
clearSnaApiState();
|
|
196
199
|
}
|
|
200
|
+
function cmdTu(args2) {
|
|
201
|
+
const sub = args2[0];
|
|
202
|
+
switch (sub) {
|
|
203
|
+
case "api:up":
|
|
204
|
+
cmdTuApiUp();
|
|
205
|
+
break;
|
|
206
|
+
case "api:down":
|
|
207
|
+
cmdTuApiDown();
|
|
208
|
+
break;
|
|
209
|
+
case "api:log":
|
|
210
|
+
cmdTuApiLog(args2.slice(1));
|
|
211
|
+
break;
|
|
212
|
+
case "claude":
|
|
213
|
+
cmdTuClaude(args2.slice(1));
|
|
214
|
+
break;
|
|
215
|
+
default:
|
|
216
|
+
console.log(`
|
|
217
|
+
sna tu \u2014 Test utilities (mock Anthropic API)
|
|
218
|
+
|
|
219
|
+
Commands:
|
|
220
|
+
sna tu api:up Start mock Anthropic API server
|
|
221
|
+
sna tu api:down Stop mock API server
|
|
222
|
+
sna tu api:log Show mock API request/response log
|
|
223
|
+
sna tu api:log -f Follow log in real-time (tail -f)
|
|
224
|
+
sna tu claude ... Run claude with mock API env vars (proxy)
|
|
225
|
+
|
|
226
|
+
Flow:
|
|
227
|
+
1. sna tu api:up \u2192 mock server on random port
|
|
228
|
+
2. sna tu claude "say hi" \u2192 real claude \u2192 mock API \u2192 mock response
|
|
229
|
+
3. sna tu api:log -f \u2192 watch requests/responses in real-time
|
|
230
|
+
4. sna tu api:down \u2192 cleanup
|
|
231
|
+
|
|
232
|
+
All requests/responses are logged to .sna/mock-api.log
|
|
233
|
+
`);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
function cmdTuApiUp() {
|
|
237
|
+
ensureStateDir();
|
|
238
|
+
const existingPid = readPidFile(MOCK_API_PID_FILE);
|
|
239
|
+
const existingPort = readPortFile(MOCK_API_PORT_FILE);
|
|
240
|
+
if (existingPid && isProcessRunning(existingPid)) {
|
|
241
|
+
step(`Mock API already running on :${existingPort} (pid=${existingPid})`);
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
const scriptDir = path.dirname(new URL(import.meta.url).pathname);
|
|
245
|
+
const mockEntry = path.join(scriptDir, "../testing/mock-api.js");
|
|
246
|
+
const mockEntrySrc = path.join(scriptDir, "../testing/mock-api.ts");
|
|
247
|
+
const resolvedMockEntry = fs.existsSync(mockEntry) ? mockEntry : mockEntrySrc;
|
|
248
|
+
if (!fs.existsSync(resolvedMockEntry)) {
|
|
249
|
+
console.error("\u2717 Mock API server not found. Run pnpm build first.");
|
|
250
|
+
process.exit(1);
|
|
251
|
+
}
|
|
252
|
+
const logStream = fs.openSync(MOCK_API_LOG_FILE, "w");
|
|
253
|
+
const startScript = `
|
|
254
|
+
import { startMockAnthropicServer } from "${resolvedMockEntry.replace(/\\/g, "/")}";
|
|
255
|
+
const mock = await startMockAnthropicServer();
|
|
256
|
+
const fs = await import("fs");
|
|
257
|
+
fs.writeFileSync("${MOCK_API_PORT_FILE.replace(/\\/g, "/")}", String(mock.port));
|
|
258
|
+
console.log("Mock Anthropic API ready on :" + mock.port);
|
|
259
|
+
// Keep alive
|
|
260
|
+
process.on("SIGTERM", () => { mock.close(); process.exit(0); });
|
|
261
|
+
`;
|
|
262
|
+
const child = spawn("node", ["--import", "tsx", "-e", startScript], {
|
|
263
|
+
cwd: ROOT,
|
|
264
|
+
detached: true,
|
|
265
|
+
stdio: ["ignore", logStream, logStream]
|
|
266
|
+
});
|
|
267
|
+
child.unref();
|
|
268
|
+
fs.writeFileSync(MOCK_API_PID_FILE, String(child.pid));
|
|
269
|
+
for (let i = 0; i < 20; i++) {
|
|
270
|
+
if (fs.existsSync(MOCK_API_PORT_FILE) && fs.readFileSync(MOCK_API_PORT_FILE, "utf8").trim()) break;
|
|
271
|
+
execSync("sleep 0.3", { stdio: "pipe" });
|
|
272
|
+
}
|
|
273
|
+
const port = readPortFile(MOCK_API_PORT_FILE);
|
|
274
|
+
if (port) {
|
|
275
|
+
step(`Mock Anthropic API \u2192 http://localhost:${port} (log: .sna/mock-api.log)`);
|
|
276
|
+
} else {
|
|
277
|
+
console.error("\u2717 Mock API failed to start. Check .sna/mock-api.log");
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
function cmdTuApiDown() {
|
|
281
|
+
const pid = readPidFile(MOCK_API_PID_FILE);
|
|
282
|
+
if (pid && isProcessRunning(pid)) {
|
|
283
|
+
try {
|
|
284
|
+
process.kill(pid, "SIGTERM");
|
|
285
|
+
} catch {
|
|
286
|
+
}
|
|
287
|
+
console.log(` Mock API \u2713 stopped (pid=${pid})`);
|
|
288
|
+
} else {
|
|
289
|
+
console.log(` Mock API \u2014 not running`);
|
|
290
|
+
}
|
|
291
|
+
try {
|
|
292
|
+
fs.unlinkSync(MOCK_API_PID_FILE);
|
|
293
|
+
} catch {
|
|
294
|
+
}
|
|
295
|
+
try {
|
|
296
|
+
fs.unlinkSync(MOCK_API_PORT_FILE);
|
|
297
|
+
} catch {
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
function cmdTuApiLog(args2) {
|
|
301
|
+
if (!fs.existsSync(MOCK_API_LOG_FILE)) {
|
|
302
|
+
console.log("No log file. Start mock API with: sna tu api:up");
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
const follow = args2.includes("-f") || args2.includes("--follow");
|
|
306
|
+
if (follow) {
|
|
307
|
+
execSync(`tail -f "${MOCK_API_LOG_FILE}"`, { stdio: "inherit" });
|
|
308
|
+
} else {
|
|
309
|
+
execSync(`cat "${MOCK_API_LOG_FILE}"`, { stdio: "inherit" });
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
function cmdTuClaude(args2) {
|
|
313
|
+
const port = readPortFile(MOCK_API_PORT_FILE);
|
|
314
|
+
if (!port) {
|
|
315
|
+
console.error("\u2717 Mock API not running. Start with: sna tu api:up");
|
|
316
|
+
process.exit(1);
|
|
317
|
+
}
|
|
318
|
+
const claudePath = resolveAndCacheClaudePath();
|
|
319
|
+
const mockConfigDir = path.join(STATE_DIR, "mock-claude-config");
|
|
320
|
+
fs.mkdirSync(mockConfigDir, { recursive: true });
|
|
321
|
+
const env = {
|
|
322
|
+
PATH: process.env.PATH ?? "",
|
|
323
|
+
HOME: process.env.HOME ?? "",
|
|
324
|
+
SHELL: process.env.SHELL ?? "/bin/zsh",
|
|
325
|
+
TERM: process.env.TERM ?? "xterm-256color",
|
|
326
|
+
LANG: process.env.LANG ?? "en_US.UTF-8",
|
|
327
|
+
ANTHROPIC_BASE_URL: `http://localhost:${port}`,
|
|
328
|
+
ANTHROPIC_API_KEY: "sk-test-mock-sna",
|
|
329
|
+
CLAUDE_CONFIG_DIR: mockConfigDir
|
|
330
|
+
};
|
|
331
|
+
try {
|
|
332
|
+
execSync(`"${claudePath}" ${args2.map((a) => `"${a}"`).join(" ")}`, {
|
|
333
|
+
stdio: "inherit",
|
|
334
|
+
env,
|
|
335
|
+
cwd: ROOT
|
|
336
|
+
});
|
|
337
|
+
} catch (e) {
|
|
338
|
+
process.exit(e.status ?? 1);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
function readPidFile(filePath) {
|
|
342
|
+
try {
|
|
343
|
+
return parseInt(fs.readFileSync(filePath, "utf8").trim(), 10) || null;
|
|
344
|
+
} catch {
|
|
345
|
+
return null;
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
function readPortFile(filePath) {
|
|
349
|
+
try {
|
|
350
|
+
return fs.readFileSync(filePath, "utf8").trim() || null;
|
|
351
|
+
} catch {
|
|
352
|
+
return null;
|
|
353
|
+
}
|
|
354
|
+
}
|
|
197
355
|
function isPortInUse(port) {
|
|
198
356
|
try {
|
|
199
357
|
execSync(`lsof -ti:${port}`, { stdio: "pipe" });
|
|
@@ -606,7 +764,12 @@ Lifecycle:
|
|
|
606
764
|
sna restart Stop + start
|
|
607
765
|
sna init [--force] Initialize .claude/settings.json and skills
|
|
608
766
|
sna validate Check project setup (skills.json, hooks, deps)
|
|
767
|
+
|
|
768
|
+
Tools:
|
|
609
769
|
sna dispatch Unified event dispatcher (open/send/close)
|
|
770
|
+
sna gen client Generate typed skill client + .sna/skills.json
|
|
771
|
+
sna api:up Start standalone SNA API server
|
|
772
|
+
sna api:down Stop SNA API server
|
|
610
773
|
|
|
611
774
|
Workflow:
|
|
612
775
|
sna new <skill> [--param val ...] Create a task from a workflow.yml
|
|
@@ -616,7 +779,15 @@ Workflow:
|
|
|
616
779
|
sna <task-id> cancel Cancel a running task
|
|
617
780
|
sna tasks List all tasks with status
|
|
618
781
|
|
|
619
|
-
|
|
782
|
+
Testing:
|
|
783
|
+
sna tu api:up Start mock Anthropic API server
|
|
784
|
+
sna tu api:down Stop mock API server
|
|
785
|
+
sna tu api:log Show mock API request/response log
|
|
786
|
+
sna tu api:log -f Follow log in real-time
|
|
787
|
+
sna tu claude ... Run claude with mock API (isolated env, no account pollution)
|
|
788
|
+
|
|
789
|
+
Set SNA_CLAUDE_COMMAND to override claude binary in SDK.
|
|
790
|
+
See: docs/testing.md
|
|
620
791
|
|
|
621
792
|
Run "sna help workflow" for workflow.yml specification.
|
|
622
793
|
Run "sna help submit" for data submission patterns.`);
|
|
@@ -816,6 +987,9 @@ Run "sna help submit" for data submission patterns.`);
|
|
|
816
987
|
cmdApiDown();
|
|
817
988
|
cmdApiUp();
|
|
818
989
|
break;
|
|
990
|
+
case "tu":
|
|
991
|
+
cmdTu(args);
|
|
992
|
+
break;
|
|
819
993
|
case "restart":
|
|
820
994
|
cmdDown();
|
|
821
995
|
console.log();
|
|
@@ -33,13 +33,28 @@ interface ApiResponses {
|
|
|
33
33
|
"agent.interrupt": {
|
|
34
34
|
status: "interrupted" | "no_session";
|
|
35
35
|
};
|
|
36
|
+
"agent.set-model": {
|
|
37
|
+
status: "updated" | "no_session";
|
|
38
|
+
model: string;
|
|
39
|
+
};
|
|
40
|
+
"agent.set-permission-mode": {
|
|
41
|
+
status: "updated" | "no_session";
|
|
42
|
+
permissionMode: string;
|
|
43
|
+
};
|
|
36
44
|
"agent.kill": {
|
|
37
45
|
status: "killed" | "no_session";
|
|
38
46
|
};
|
|
39
47
|
"agent.status": {
|
|
40
48
|
alive: boolean;
|
|
41
49
|
sessionId: string | null;
|
|
50
|
+
ccSessionId: string | null;
|
|
42
51
|
eventCount: number;
|
|
52
|
+
config: {
|
|
53
|
+
provider: string;
|
|
54
|
+
model: string;
|
|
55
|
+
permissionMode: string;
|
|
56
|
+
extraArgs?: string[];
|
|
57
|
+
} | null;
|
|
43
58
|
};
|
|
44
59
|
"agent.run-once": {
|
|
45
60
|
result: string;
|
|
@@ -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,7 +1,7 @@
|
|
|
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 { 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';
|
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
import { logger } from "../../lib/logger.js";
|
|
7
7
|
import { getDb } from "../../db/schema.js";
|
|
8
8
|
import { httpJson } from "../api-types.js";
|
|
9
|
+
import { saveImages } from "../image-store.js";
|
|
9
10
|
function getSessionId(c) {
|
|
10
11
|
return c.req.query("session") ?? "default";
|
|
11
12
|
}
|
|
@@ -28,7 +29,7 @@ async function runOnce(sessionManager, opts) {
|
|
|
28
29
|
model: opts.model ?? "claude-sonnet-4-6",
|
|
29
30
|
permissionMode: opts.permissionMode ?? "bypassPermissions",
|
|
30
31
|
env: { SNA_SESSION_ID: sessionId },
|
|
31
|
-
extraArgs
|
|
32
|
+
extraArgs
|
|
32
33
|
});
|
|
33
34
|
sessionManager.setProcess(sessionId, proc);
|
|
34
35
|
try {
|
|
@@ -150,6 +151,7 @@ function createAgentRoutes(sessionManager) {
|
|
|
150
151
|
model,
|
|
151
152
|
permissionMode,
|
|
152
153
|
env: { SNA_SESSION_ID: sessionId },
|
|
154
|
+
history: body.history,
|
|
153
155
|
extraArgs
|
|
154
156
|
});
|
|
155
157
|
sessionManager.setProcess(sessionId, proc);
|
|
@@ -176,20 +178,38 @@ function createAgentRoutes(sessionManager) {
|
|
|
176
178
|
);
|
|
177
179
|
}
|
|
178
180
|
const body = await c.req.json().catch(() => ({}));
|
|
179
|
-
if (!body.message) {
|
|
181
|
+
if (!body.message && !body.images?.length) {
|
|
180
182
|
logger.err("err", `POST /send?session=${sessionId} \u2192 empty message`);
|
|
181
|
-
return c.json({ status: "error", message: "message
|
|
183
|
+
return c.json({ status: "error", message: "message or images required" }, 400);
|
|
184
|
+
}
|
|
185
|
+
const textContent = body.message ?? "(image)";
|
|
186
|
+
let meta = body.meta ? { ...body.meta } : {};
|
|
187
|
+
if (body.images?.length) {
|
|
188
|
+
const filenames = saveImages(sessionId, body.images);
|
|
189
|
+
meta.images = filenames;
|
|
182
190
|
}
|
|
183
191
|
try {
|
|
184
192
|
const db = getDb();
|
|
185
193
|
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,
|
|
194
|
+
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
195
|
} catch {
|
|
188
196
|
}
|
|
189
197
|
session.state = "processing";
|
|
190
198
|
sessionManager.touch(sessionId);
|
|
191
|
-
|
|
192
|
-
|
|
199
|
+
if (body.images?.length) {
|
|
200
|
+
const content = [
|
|
201
|
+
...body.images.map((img) => ({
|
|
202
|
+
type: "image",
|
|
203
|
+
source: { type: "base64", media_type: img.mimeType, data: img.base64 }
|
|
204
|
+
})),
|
|
205
|
+
...body.message ? [{ type: "text", text: body.message }] : []
|
|
206
|
+
];
|
|
207
|
+
logger.log("route", `POST /send?session=${sessionId} \u2192 ${body.images.length} image(s) + "${(body.message ?? "").slice(0, 40)}"`);
|
|
208
|
+
session.process.send(content);
|
|
209
|
+
} else {
|
|
210
|
+
logger.log("route", `POST /send?session=${sessionId} \u2192 "${body.message.slice(0, 80)}"`);
|
|
211
|
+
session.process.send(body.message);
|
|
212
|
+
}
|
|
193
213
|
return httpJson(c, "agent.send", { status: "sent" });
|
|
194
214
|
});
|
|
195
215
|
app.get("/events", (c) => {
|
|
@@ -229,14 +249,16 @@ function createAgentRoutes(sessionManager) {
|
|
|
229
249
|
const sessionId = getSessionId(c);
|
|
230
250
|
const body = await c.req.json().catch(() => ({}));
|
|
231
251
|
try {
|
|
252
|
+
const ccSessionId = sessionManager.getSession(sessionId)?.ccSessionId;
|
|
232
253
|
const { config } = sessionManager.restartSession(sessionId, body, (cfg) => {
|
|
233
254
|
const prov = getProvider(cfg.provider);
|
|
255
|
+
const resumeArgs = ccSessionId ? ["--resume", ccSessionId] : ["--resume"];
|
|
234
256
|
return prov.spawn({
|
|
235
257
|
cwd: sessionManager.getSession(sessionId).cwd,
|
|
236
258
|
model: cfg.model,
|
|
237
259
|
permissionMode: cfg.permissionMode,
|
|
238
260
|
env: { SNA_SESSION_ID: sessionId },
|
|
239
|
-
extraArgs: [...cfg.extraArgs ?? [],
|
|
261
|
+
extraArgs: [...cfg.extraArgs ?? [], ...resumeArgs]
|
|
240
262
|
});
|
|
241
263
|
});
|
|
242
264
|
logger.log("route", `POST /restart?session=${sessionId} \u2192 restarted`);
|
|
@@ -255,6 +277,20 @@ function createAgentRoutes(sessionManager) {
|
|
|
255
277
|
const interrupted = sessionManager.interruptSession(sessionId);
|
|
256
278
|
return httpJson(c, "agent.interrupt", { status: interrupted ? "interrupted" : "no_session" });
|
|
257
279
|
});
|
|
280
|
+
app.post("/set-model", async (c) => {
|
|
281
|
+
const sessionId = getSessionId(c);
|
|
282
|
+
const body = await c.req.json().catch(() => ({}));
|
|
283
|
+
if (!body.model) return c.json({ status: "error", message: "model is required" }, 400);
|
|
284
|
+
const updated = sessionManager.setSessionModel(sessionId, body.model);
|
|
285
|
+
return httpJson(c, "agent.set-model", { status: updated ? "updated" : "no_session", model: body.model });
|
|
286
|
+
});
|
|
287
|
+
app.post("/set-permission-mode", async (c) => {
|
|
288
|
+
const sessionId = getSessionId(c);
|
|
289
|
+
const body = await c.req.json().catch(() => ({}));
|
|
290
|
+
if (!body.permissionMode) return c.json({ status: "error", message: "permissionMode is required" }, 400);
|
|
291
|
+
const updated = sessionManager.setSessionPermissionMode(sessionId, body.permissionMode);
|
|
292
|
+
return httpJson(c, "agent.set-permission-mode", { status: updated ? "updated" : "no_session", permissionMode: body.permissionMode });
|
|
293
|
+
});
|
|
258
294
|
app.post("/kill", async (c) => {
|
|
259
295
|
const sessionId = getSessionId(c);
|
|
260
296
|
const killed = sessionManager.killSession(sessionId);
|
|
@@ -266,7 +302,9 @@ function createAgentRoutes(sessionManager) {
|
|
|
266
302
|
return httpJson(c, "agent.status", {
|
|
267
303
|
alive: session?.process?.alive ?? false,
|
|
268
304
|
sessionId: session?.process?.sessionId ?? null,
|
|
269
|
-
|
|
305
|
+
ccSessionId: session?.ccSessionId ?? null,
|
|
306
|
+
eventCount: session?.eventCounter ?? 0,
|
|
307
|
+
config: session?.lastStartConfig ?? null
|
|
270
308
|
});
|
|
271
309
|
});
|
|
272
310
|
app.post("/permission-request", async (c) => {
|