@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 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
- - **Lifecycle CLI** — `sna api:up`, `sna api:down`, `sna dispatch`, `sna validate`
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: input }
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
- this.proc.kill("SIGINT");
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 claudePath = resolveClaudePath(options.cwd);
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
- const proc = spawn(claudePath, args, {
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 ${claudePath} ${args.join(" ")}`);
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 (SIGINT). Process stays alive. */
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';
@@ -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
- Task IDs are 10-digit timestamps (MMDDHHmmss), e.g. 0317143052
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
+ };
@@ -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: extraArgs.length > 0 ? extraArgs : void 0
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 is required" }, 400);
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, body.message, body.meta ? JSON.stringify(body.meta) : null);
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
- logger.log("route", `POST /send?session=${sessionId} \u2192 "${body.message.slice(0, 80)}"`);
192
- session.process.send(body.message);
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 ?? [], "--resume"]
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
- eventCount: session?.eventCounter ?? 0
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) => {