@sna-sdk/core 0.8.1 → 0.9.4

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.
@@ -0,0 +1,48 @@
1
+ /**
2
+ * config.ts — SNA SDK centralized configuration.
3
+ *
4
+ * All configurable values live here. No other file reads process.env.SNA_*
5
+ * or hardcodes policy defaults. Priority (later wins):
6
+ *
7
+ * 1. Hardcoded defaults (this file)
8
+ * 2. Environment variables (process.env.SNA_*)
9
+ * 3. App-level overrides (setConfig)
10
+ * 4. Per-call parameter overrides (function args)
11
+ */
12
+ interface SnaConfig {
13
+ /** SNA API server port. env: SNA_PORT */
14
+ port: number;
15
+ /** Default LLM model. env: SNA_MODEL */
16
+ model: string;
17
+ /** Default agent provider. */
18
+ defaultProvider: string;
19
+ /** Default permission mode for agent operations. env: SNA_PERMISSION_MODE */
20
+ defaultPermissionMode: "default" | "acceptEdits" | "bypassPermissions" | "plan";
21
+ /** Max concurrent sessions. env: SNA_MAX_SESSIONS */
22
+ maxSessions: number;
23
+ /** Max events buffered in memory per session. */
24
+ maxEventBuffer: number;
25
+ /**
26
+ * Permission request timeout (ms). 0 = no timeout (app controls).
27
+ * When > 0, auto-denies after this duration.
28
+ */
29
+ permissionTimeoutMs: number;
30
+ /** Run-once execution timeout (ms). */
31
+ runOnceTimeoutMs: number;
32
+ /** Skill event SSE poll interval (ms). */
33
+ pollIntervalMs: number;
34
+ /** SSE keepalive interval (ms). */
35
+ keepaliveIntervalMs: number;
36
+ /** WebSocket skill event poll interval (ms). */
37
+ skillPollMs: number;
38
+ /** SQLite database path. env: SNA_DB_PATH */
39
+ dbPath: string;
40
+ }
41
+ /** Get current config. Returns a frozen copy. */
42
+ declare function getConfig(): Readonly<SnaConfig>;
43
+ /** Override config values. Merges with existing (later wins). */
44
+ declare function setConfig(overrides: Partial<SnaConfig>): void;
45
+ /** Reset to defaults + env. Useful for testing. */
46
+ declare function resetConfig(): void;
47
+
48
+ export { type SnaConfig, getConfig, resetConfig, setConfig };
package/dist/config.js ADDED
@@ -0,0 +1,40 @@
1
+ const defaults = {
2
+ port: 3099,
3
+ model: "claude-sonnet-4-6",
4
+ defaultProvider: "claude-code",
5
+ defaultPermissionMode: "default",
6
+ maxSessions: 5,
7
+ maxEventBuffer: 500,
8
+ permissionTimeoutMs: 0,
9
+ // app controls — no SDK-side timeout
10
+ runOnceTimeoutMs: 12e4,
11
+ pollIntervalMs: 500,
12
+ keepaliveIntervalMs: 15e3,
13
+ skillPollMs: 2e3,
14
+ dbPath: "data/sna.db"
15
+ };
16
+ function fromEnv() {
17
+ const env = {};
18
+ if (process.env.SNA_PORT) env.port = parseInt(process.env.SNA_PORT, 10);
19
+ if (process.env.SNA_MODEL) env.model = process.env.SNA_MODEL;
20
+ if (process.env.SNA_PERMISSION_MODE) env.defaultPermissionMode = process.env.SNA_PERMISSION_MODE;
21
+ if (process.env.SNA_MAX_SESSIONS) env.maxSessions = parseInt(process.env.SNA_MAX_SESSIONS, 10);
22
+ if (process.env.SNA_DB_PATH) env.dbPath = process.env.SNA_DB_PATH;
23
+ if (process.env.SNA_PERMISSION_TIMEOUT_MS) env.permissionTimeoutMs = parseInt(process.env.SNA_PERMISSION_TIMEOUT_MS, 10);
24
+ return env;
25
+ }
26
+ let current = { ...defaults, ...fromEnv() };
27
+ function getConfig() {
28
+ return current;
29
+ }
30
+ function setConfig(overrides) {
31
+ current = { ...current, ...overrides };
32
+ }
33
+ function resetConfig() {
34
+ current = { ...defaults, ...fromEnv() };
35
+ }
36
+ export {
37
+ getConfig,
38
+ resetConfig,
39
+ setConfig
40
+ };
@@ -2,6 +2,7 @@ import { spawn, execSync } from "child_process";
2
2
  import { EventEmitter } from "events";
3
3
  import fs from "fs";
4
4
  import path from "path";
5
+ import { fileURLToPath } from "url";
5
6
  import { writeHistoryJsonl, buildRecalledConversation } from "./cc-history-adapter.js";
6
7
  import { logger } from "../../lib/logger.js";
7
8
  const SHELL = process.env.SHELL || "/bin/zsh";
@@ -42,6 +43,10 @@ const _ClaudeCodeProcess = class _ClaudeCodeProcess {
42
43
  this._sessionId = null;
43
44
  this._initEmitted = false;
44
45
  this.buffer = "";
46
+ /** True once we receive a real text_delta stream_event this turn */
47
+ this._receivedStreamEvents = false;
48
+ /** tool_use IDs already emitted via stream_event (to update instead of re-create in assistant block) */
49
+ this._streamedToolUseIds = /* @__PURE__ */ new Set();
45
50
  /**
46
51
  * FIFO event queue — ALL events (deltas, assistant, complete, etc.) go through
47
52
  * this queue. A fixed-interval timer drains one item at a time, guaranteeing
@@ -152,6 +157,9 @@ const _ClaudeCodeProcess = class _ClaudeCodeProcess {
152
157
  get alive() {
153
158
  return this._alive;
154
159
  }
160
+ get pid() {
161
+ return this.proc.pid ?? null;
162
+ }
155
163
  get sessionId() {
156
164
  return this._sessionId;
157
165
  }
@@ -220,7 +228,43 @@ const _ClaudeCodeProcess = class _ClaudeCodeProcess {
220
228
  }
221
229
  return null;
222
230
  }
231
+ case "stream_event": {
232
+ const inner = msg.event;
233
+ if (!inner) return null;
234
+ if (inner.type === "content_block_start" && inner.content_block?.type === "tool_use") {
235
+ const block = inner.content_block;
236
+ this._receivedStreamEvents = true;
237
+ this._streamedToolUseIds.add(block.id);
238
+ return {
239
+ type: "tool_use",
240
+ message: block.name,
241
+ data: { toolName: block.name, id: block.id, input: null, streaming: true },
242
+ timestamp: Date.now()
243
+ };
244
+ }
245
+ if (inner.type === "content_block_delta") {
246
+ const delta = inner.delta;
247
+ if (delta?.type === "text_delta" && delta.text) {
248
+ this._receivedStreamEvents = true;
249
+ return {
250
+ type: "assistant_delta",
251
+ delta: delta.text,
252
+ index: inner.index ?? 0,
253
+ timestamp: Date.now()
254
+ };
255
+ }
256
+ if (delta?.type === "thinking_delta" && delta.thinking) {
257
+ return {
258
+ type: "thinking_delta",
259
+ message: delta.thinking,
260
+ timestamp: Date.now()
261
+ };
262
+ }
263
+ }
264
+ return null;
265
+ }
223
266
  case "assistant": {
267
+ if (this._receivedStreamEvents && msg.message?.stop_reason === null) return null;
224
268
  const content = msg.message?.content;
225
269
  if (!Array.isArray(content)) return null;
226
270
  const events = [];
@@ -233,10 +277,12 @@ const _ClaudeCodeProcess = class _ClaudeCodeProcess {
233
277
  timestamp: Date.now()
234
278
  });
235
279
  } else if (block.type === "tool_use") {
280
+ const alreadyStreamed = this._streamedToolUseIds.has(block.id);
281
+ if (alreadyStreamed) this._streamedToolUseIds.delete(block.id);
236
282
  events.push({
237
283
  type: "tool_use",
238
284
  message: block.name,
239
- data: { toolName: block.name, input: block.input, id: block.id },
285
+ data: { toolName: block.name, input: block.input, id: block.id, update: alreadyStreamed },
240
286
  timestamp: Date.now()
241
287
  });
242
288
  } else if (block.type === "text") {
@@ -251,7 +297,7 @@ const _ClaudeCodeProcess = class _ClaudeCodeProcess {
251
297
  this.enqueue(e);
252
298
  }
253
299
  for (const text of textBlocks) {
254
- this.enqueueTextAsDeltas(text);
300
+ this.enqueue({ type: "assistant", message: text, timestamp: Date.now() });
255
301
  }
256
302
  }
257
303
  return null;
@@ -273,6 +319,15 @@ const _ClaudeCodeProcess = class _ClaudeCodeProcess {
273
319
  }
274
320
  case "result": {
275
321
  if (msg.subtype === "success") {
322
+ if (this._receivedStreamEvents && msg.result) {
323
+ this.enqueue({
324
+ type: "assistant",
325
+ message: msg.result,
326
+ timestamp: Date.now()
327
+ });
328
+ this._receivedStreamEvents = false;
329
+ this._streamedToolUseIds.clear();
330
+ }
276
331
  const u = msg.usage ?? {};
277
332
  const mu = msg.modelUsage ?? {};
278
333
  const modelKey = Object.keys(mu)[0] ?? "";
@@ -340,7 +395,13 @@ class ClaudeCodeProvider {
340
395
  const claudeParts = claudeCommand.split(/\s+/);
341
396
  const claudePath = claudeParts[0];
342
397
  const claudePrefix = claudeParts.slice(1);
343
- const hookScript = new URL("../../scripts/hook.js", import.meta.url).pathname;
398
+ let pkgRoot = path.dirname(fileURLToPath(import.meta.url));
399
+ while (!fs.existsSync(path.join(pkgRoot, "package.json"))) {
400
+ const parent = path.dirname(pkgRoot);
401
+ if (parent === pkgRoot) break;
402
+ pkgRoot = parent;
403
+ }
404
+ const hookScript = path.join(pkgRoot, "dist", "scripts", "hook.js");
344
405
  const sessionId = options.env?.SNA_SESSION_ID ?? "default";
345
406
  const sdkSettings = {};
346
407
  if (options.permissionMode !== "bypassPermissions") {
@@ -380,6 +441,7 @@ class ClaudeCodeProvider {
380
441
  "--input-format",
381
442
  "stream-json",
382
443
  "--verbose",
444
+ "--include-partial-messages",
383
445
  "--settings",
384
446
  JSON.stringify(sdkSettings)
385
447
  ];
@@ -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_delta" | "assistant" | "tool_use" | "tool_result" | "permission_needed" | "milestone" | "user_message" | "interrupted" | "error" | "complete";
8
+ type: "init" | "thinking" | "thinking_delta" | "text_delta" | "assistant_delta" | "assistant" | "tool_use" | "tool_result" | "permission_needed" | "milestone" | "user_message" | "interrupted" | "error" | "complete";
9
9
  message?: string;
10
10
  data?: Record<string, unknown>;
11
11
  /** Streaming text delta (for assistant_delta events only) */
@@ -30,6 +30,8 @@ interface AgentProcess {
30
30
  kill(): void;
31
31
  /** Whether the process is still running. */
32
32
  readonly alive: boolean;
33
+ /** OS process ID. */
34
+ readonly pid: number | null;
33
35
  /** Session ID assigned by the provider. */
34
36
  readonly sessionId: string | null;
35
37
  on(event: "event", handler: (e: AgentEvent) => void): void;
@@ -85,6 +85,7 @@ async function startSnaServer(options) {
85
85
  ...options.maxSessions != null ? { SNA_MAX_SESSIONS: String(options.maxSessions) } : {},
86
86
  ...options.permissionMode ? { SNA_PERMISSION_MODE: options.permissionMode } : {},
87
87
  ...options.model ? { SNA_MODEL: options.model } : {},
88
+ ...options.permissionTimeoutMs != null ? { SNA_PERMISSION_TIMEOUT_MS: String(options.permissionTimeoutMs) } : {},
88
89
  ...options.nativeBinding ? { SNA_SQLITE_NATIVE_BINDING: options.nativeBinding } : {},
89
90
  ...consumerModules ? { SNA_MODULES_PATH: consumerModules } : {},
90
91
  ...nodePath ? { NODE_PATH: nodePath } : {},
@@ -54,6 +54,11 @@ interface SnaServerOptions {
54
54
  permissionMode?: "acceptEdits" | "bypassPermissions" | "default";
55
55
  /** Claude model to use. Default: SDK default (claude-sonnet-4-6) */
56
56
  model?: string;
57
+ /**
58
+ * Permission request timeout (ms). 0 = no timeout (app controls).
59
+ * Default: 0 (app is responsible for responding or timing out)
60
+ */
61
+ permissionTimeoutMs?: number;
57
62
  /**
58
63
  * Explicit path to the better-sqlite3 native .node binding.
59
64
  *
@@ -44,6 +44,7 @@ async function startSnaServer(options) {
44
44
  ...options.maxSessions != null ? { SNA_MAX_SESSIONS: String(options.maxSessions) } : {},
45
45
  ...options.permissionMode ? { SNA_PERMISSION_MODE: options.permissionMode } : {},
46
46
  ...options.model ? { SNA_MODEL: options.model } : {},
47
+ ...options.permissionTimeoutMs != null ? { SNA_PERMISSION_TIMEOUT_MS: String(options.permissionTimeoutMs) } : {},
47
48
  ...options.nativeBinding ? { SNA_SQLITE_NATIVE_BINDING: options.nativeBinding } : {},
48
49
  ...consumerModules ? { SNA_MODULES_PATH: consumerModules } : {},
49
50
  ...nodePath ? { NODE_PATH: nodePath } : {},
package/dist/index.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ export { SnaConfig, getConfig, resetConfig, setConfig } from './config.js';
1
2
  export { ChatMessage, ChatSession, SkillEvent } from './db/schema.js';
2
3
  export { AgentEvent, AgentProcess, AgentProvider, ContentBlock, HistoryMessage, SpawnOptions } from './core/providers/types.js';
3
4
  export { Session, SessionInfo, SessionManagerOptions, SessionState } from './server/session-manager.js';
@@ -10,6 +11,7 @@ import 'better-sqlite3';
10
11
  * Server, providers, session management, database, and CLI.
11
12
  * No React dependency.
12
13
  */
14
+
13
15
  declare const DEFAULT_SNA_PORT = 3099;
14
16
  declare const DEFAULT_SNA_URL = "http://localhost:3099";
15
17
 
package/dist/index.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { getConfig, setConfig, resetConfig } from "./config.js";
1
2
  const DEFAULT_SNA_PORT = 3099;
2
3
  const DEFAULT_SNA_URL = `http://localhost:${DEFAULT_SNA_PORT}`;
3
4
  import { open, send, close, createHandle } from "./lib/dispatch.js";
@@ -7,5 +8,8 @@ export {
7
8
  createHandle as createDispatchHandle,
8
9
  close as dispatchClose,
9
10
  open as dispatchOpen,
10
- send as dispatchSend
11
+ send as dispatchSend,
12
+ getConfig,
13
+ resetConfig,
14
+ setConfig
11
15
  };
@@ -85,6 +85,7 @@ async function startSnaServer(options) {
85
85
  ...options.maxSessions != null ? { SNA_MAX_SESSIONS: String(options.maxSessions) } : {},
86
86
  ...options.permissionMode ? { SNA_PERMISSION_MODE: options.permissionMode } : {},
87
87
  ...options.model ? { SNA_MODEL: options.model } : {},
88
+ ...options.permissionTimeoutMs != null ? { SNA_PERMISSION_TIMEOUT_MS: String(options.permissionTimeoutMs) } : {},
88
89
  ...options.nativeBinding ? { SNA_SQLITE_NATIVE_BINDING: options.nativeBinding } : {},
89
90
  ...consumerModules ? { SNA_MODULES_PATH: consumerModules } : {},
90
91
  ...nodePath ? { NODE_PATH: nodePath } : {},
@@ -10,22 +10,25 @@ process.stdin.on("end", async () => {
10
10
  return;
11
11
  }
12
12
  const input = JSON.parse(raw);
13
- const toolName = input.tool_name ?? "unknown";
14
- const safeTools = ["Read", "Glob", "Grep", "Agent", "TodoRead", "TodoWrite"];
15
- if (safeTools.includes(toolName)) {
16
- allow();
17
- return;
18
- }
19
- const portFile = path.join(process.cwd(), ".sna/sna-api.port");
20
- let port;
21
- try {
22
- port = fs.readFileSync(portFile, "utf8").trim();
23
- } catch {
24
- allow();
25
- return;
13
+ let apiUrl;
14
+ if (process.env.SNA_API_URL) {
15
+ apiUrl = process.env.SNA_API_URL;
16
+ } else {
17
+ const portFile = path.join(process.cwd(), ".sna/sna-api.port");
18
+ try {
19
+ const port = fs.readFileSync(portFile, "utf8").trim();
20
+ apiUrl = `http://localhost:${port}`;
21
+ } catch {
22
+ const snaPort = process.env.SNA_PORT;
23
+ if (snaPort) {
24
+ apiUrl = `http://localhost:${snaPort}`;
25
+ } else {
26
+ allow();
27
+ return;
28
+ }
29
+ }
26
30
  }
27
31
  const sessionId = process.argv.find((a) => a.startsWith("--session="))?.slice(10) ?? process.env.SNA_SESSION_ID ?? "default";
28
- const apiUrl = `http://localhost:${port}`;
29
32
  const res = await fetch(`${apiUrl}/agent/permission-request?session=${encodeURIComponent(sessionId)}`, {
30
33
  method: "POST",
31
34
  headers: { "Content-Type": "application/json" },
@@ -8,27 +8,28 @@ import { getDb } from "../../db/schema.js";
8
8
  import { buildHistoryFromDb } from "../history-builder.js";
9
9
  import { httpJson } from "../api-types.js";
10
10
  import { saveImages } from "../image-store.js";
11
+ import { getConfig } from "../../config.js";
11
12
  function getSessionId(c) {
12
13
  return c.req.query("session") ?? "default";
13
14
  }
14
- const DEFAULT_RUN_ONCE_TIMEOUT = 12e4;
15
15
  async function runOnce(sessionManager, opts) {
16
16
  const sessionId = `run-once-${crypto.randomUUID().slice(0, 8)}`;
17
- const timeout = opts.timeout ?? DEFAULT_RUN_ONCE_TIMEOUT;
17
+ const timeout = opts.timeout ?? getConfig().runOnceTimeoutMs;
18
18
  const session = sessionManager.createSession({
19
19
  id: sessionId,
20
20
  label: "run-once",
21
21
  cwd: opts.cwd ?? process.cwd()
22
22
  });
23
- const provider = getProvider(opts.provider ?? "claude-code");
23
+ const cfg = getConfig();
24
+ const provider = getProvider(opts.provider ?? cfg.defaultProvider);
24
25
  const extraArgs = opts.extraArgs ? [...opts.extraArgs] : [];
25
26
  if (opts.systemPrompt) extraArgs.push("--system-prompt", opts.systemPrompt);
26
27
  if (opts.appendSystemPrompt) extraArgs.push("--append-system-prompt", opts.appendSystemPrompt);
27
28
  const proc = provider.spawn({
28
29
  cwd: session.cwd,
29
30
  prompt: opts.message,
30
- model: opts.model ?? "claude-sonnet-4-6",
31
- permissionMode: opts.permissionMode ?? "bypassPermissions",
31
+ model: opts.model ?? cfg.model,
32
+ permissionMode: opts.permissionMode ?? cfg.defaultPermissionMode,
32
33
  env: { SNA_SESSION_ID: sessionId },
33
34
  extraArgs
34
35
  });
@@ -69,6 +70,7 @@ function createAgentRoutes(sessionManager) {
69
70
  const body = await c.req.json().catch(() => ({}));
70
71
  try {
71
72
  const session = sessionManager.createSession({
73
+ id: body.id,
72
74
  label: body.label,
73
75
  cwd: body.cwd,
74
76
  meta: body.meta
@@ -95,6 +97,22 @@ function createAgentRoutes(sessionManager) {
95
97
  logger.log("route", `DELETE /sessions/${id} \u2192 removed`);
96
98
  return httpJson(c, "sessions.remove", { status: "removed" });
97
99
  });
100
+ app.patch("/sessions/:id", async (c) => {
101
+ const id = c.req.param("id");
102
+ const body = await c.req.json().catch(() => ({}));
103
+ try {
104
+ sessionManager.updateSession(id, {
105
+ label: body.label,
106
+ meta: body.meta,
107
+ cwd: body.cwd
108
+ });
109
+ logger.log("route", `PATCH /sessions/${id} \u2192 updated`);
110
+ return httpJson(c, "sessions.update", { status: "updated", session: id });
111
+ } catch (e) {
112
+ logger.err("err", `PATCH /sessions/${id} \u2192 ${e.message}`);
113
+ return c.json({ status: "error", message: e.message }, 404);
114
+ }
115
+ });
98
116
  app.post("/run-once", async (c) => {
99
117
  const body = await c.req.json().catch(() => ({}));
100
118
  if (!body.message) {
@@ -118,14 +136,14 @@ function createAgentRoutes(sessionManager) {
118
136
  logger.log("route", `POST /start?session=${sessionId} \u2192 already_running`);
119
137
  return httpJson(c, "agent.start", {
120
138
  status: "already_running",
121
- provider: "claude-code",
139
+ provider: getConfig().defaultProvider,
122
140
  sessionId: session.process.sessionId ?? session.id
123
141
  });
124
142
  }
125
143
  if (session.process?.alive) {
126
144
  session.process.kill();
127
145
  }
128
- const provider = getProvider(body.provider ?? "claude-code");
146
+ const provider = getProvider(body.provider ?? getConfig().defaultProvider);
129
147
  try {
130
148
  const db = getDb();
131
149
  db.prepare(`INSERT OR IGNORE INTO chat_sessions (id, label, type) VALUES (?, ?, 'main')`).run(sessionId, session.label ?? sessionId);
@@ -140,8 +158,8 @@ function createAgentRoutes(sessionManager) {
140
158
  }
141
159
  } catch {
142
160
  }
143
- const providerName = body.provider ?? "claude-code";
144
- const model = body.model ?? "claude-sonnet-4-6";
161
+ const providerName = body.provider ?? getConfig().defaultProvider;
162
+ const model = body.model ?? getConfig().model;
145
163
  const permissionMode = body.permissionMode;
146
164
  const configDir = body.configDir;
147
165
  const extraArgs = body.extraArgs;
@@ -226,7 +244,7 @@ function createAgentRoutes(sessionManager) {
226
244
  const sinceParam = c.req.query("since");
227
245
  const sinceCursor = sinceParam ? parseInt(sinceParam, 10) : session.eventCounter;
228
246
  return streamSSE(c, async (stream) => {
229
- const KEEPALIVE_MS = 15e3;
247
+ const KEEPALIVE_MS = getConfig().keepaliveIntervalMs;
230
248
  const signal = c.req.raw.signal;
231
249
  const queue = [];
232
250
  let wakeUp = null;
@@ -323,8 +341,8 @@ function createAgentRoutes(sessionManager) {
323
341
  if (history.length === 0 && !body.prompt) {
324
342
  return c.json({ status: "error", message: "No history in DB \u2014 nothing to resume." }, 400);
325
343
  }
326
- const providerName = body.provider ?? "claude-code";
327
- const model = body.model ?? session.lastStartConfig?.model ?? "claude-sonnet-4-6";
344
+ const providerName = body.provider ?? getConfig().defaultProvider;
345
+ const model = body.model ?? session.lastStartConfig?.model ?? getConfig().model;
328
346
  const permissionMode = body.permissionMode ?? session.lastStartConfig?.permissionMode;
329
347
  const configDir = body.configDir ?? session.lastStartConfig?.configDir;
330
348
  const extraArgs = body.extraArgs ?? session.lastStartConfig?.extraArgs;
@@ -23,11 +23,12 @@ function createChatRoutes() {
23
23
  app.post("/sessions", async (c) => {
24
24
  const body = await c.req.json().catch(() => ({}));
25
25
  const id = body.id ?? crypto.randomUUID().slice(0, 8);
26
+ const sessionType = body.type ?? body.chatType ?? "background";
26
27
  try {
27
28
  const db = getDb();
28
29
  db.prepare(
29
30
  `INSERT OR IGNORE INTO chat_sessions (id, label, type, meta) VALUES (?, ?, ?, ?)`
30
- ).run(id, body.label ?? id, body.type ?? "background", body.meta ? JSON.stringify(body.meta) : null);
31
+ ).run(id, body.label ?? id, sessionType, body.meta ? JSON.stringify(body.meta) : null);
31
32
  return httpJson(c, "chat.sessions.create", { status: "created", id, meta: body.meta ?? null });
32
33
  } catch (e) {
33
34
  return c.json({ status: "error", message: e.message }, 500);
@@ -3,14 +3,16 @@ import { httpJson } from "../api-types.js";
3
3
  function createEmitRoute(sessionManager) {
4
4
  return async (c) => {
5
5
  const body = await c.req.json();
6
- const { skill, type, message, data, session_id } = body;
6
+ const { skill, message, data } = body;
7
+ const type = body.type ?? body.eventType;
8
+ const session_id = c.req.query("session") ?? body.session_id ?? body.session ?? null;
7
9
  if (!skill || !type || !message) {
8
10
  return c.json({ error: "missing fields" }, 400);
9
11
  }
10
12
  const db = getDb();
11
13
  const result = db.prepare(
12
14
  `INSERT INTO skill_events (session_id, skill, type, message, data) VALUES (?, ?, ?, ?, ?)`
13
- ).run(session_id ?? null, skill, type, message, data ?? null);
15
+ ).run(session_id, skill, type, message, data ?? null);
14
16
  const id = Number(result.lastInsertRowid);
15
17
  sessionManager.broadcastSkillEvent({
16
18
  id,
@@ -1,7 +1,6 @@
1
1
  import { streamSSE } from "hono/streaming";
2
2
  import { getDb } from "../../db/schema.js";
3
- const POLL_INTERVAL_MS = 500;
4
- const KEEPALIVE_INTERVAL_MS = 15e3;
3
+ import { getConfig } from "../../config.js";
5
4
  function eventsRoute(c) {
6
5
  const sinceParam = c.req.query("since");
7
6
  let lastId = sinceParam ? parseInt(sinceParam) : -1;
@@ -26,7 +25,7 @@ function eventsRoute(c) {
26
25
  closed = true;
27
26
  clearInterval(keepaliveTimer);
28
27
  }
29
- }, KEEPALIVE_INTERVAL_MS);
28
+ }, getConfig().keepaliveIntervalMs);
30
29
  while (!closed) {
31
30
  try {
32
31
  const db = getDb();
@@ -44,7 +43,7 @@ function eventsRoute(c) {
44
43
  }
45
44
  } catch {
46
45
  }
47
- await stream.sleep(POLL_INTERVAL_MS);
46
+ await stream.sleep(getConfig().pollIntervalMs);
48
47
  }
49
48
  clearInterval(keepaliveTimer);
50
49
  });
@@ -135,7 +135,9 @@ declare class SessionManager {
135
135
  updateSessionState(sessionId: string, newState: SessionState): void;
136
136
  private setSessionState;
137
137
  /** Create a pending permission request. Returns a promise that resolves when approved/denied. */
138
- createPendingPermission(sessionId: string, request: Record<string, unknown>): Promise<boolean>;
138
+ createPendingPermission(sessionId: string, request: Record<string, unknown>, opts?: {
139
+ timeoutMs?: number;
140
+ }): Promise<boolean>;
139
141
  /** Resolve a pending permission request. Returns false if no pending request. */
140
142
  resolvePendingPermission(sessionId: string, approved: boolean): boolean;
141
143
  /** Get a pending permission for a specific session. */
@@ -1,7 +1,5 @@
1
1
  import { getDb } from "../db/schema.js";
2
- const DEFAULT_MAX_SESSIONS = 5;
3
- const MAX_EVENT_BUFFER = 500;
4
- const PERMISSION_TIMEOUT_MS = 3e5;
2
+ import { getConfig } from "../config.js";
5
3
  class SessionManager {
6
4
  constructor(options = {}) {
7
5
  this.sessions = /* @__PURE__ */ new Map();
@@ -13,7 +11,7 @@ class SessionManager {
13
11
  this.configChangedListeners = /* @__PURE__ */ new Set();
14
12
  this.stateChangedListeners = /* @__PURE__ */ new Set();
15
13
  this.metadataChangedListeners = /* @__PURE__ */ new Set();
16
- this.maxSessions = options.maxSessions ?? DEFAULT_MAX_SESSIONS;
14
+ this.maxSessions = options.maxSessions ?? getConfig().maxSessions;
17
15
  this.restoreFromDb();
18
16
  }
19
17
  /** Restore session metadata from DB (cwd, label, meta). Process state is not restored. */
@@ -152,8 +150,8 @@ class SessionManager {
152
150
  if (persisted) {
153
151
  session.eventCounter++;
154
152
  session.eventBuffer.push(e);
155
- if (session.eventBuffer.length > MAX_EVENT_BUFFER) {
156
- session.eventBuffer.splice(0, session.eventBuffer.length - MAX_EVENT_BUFFER);
153
+ if (session.eventBuffer.length > getConfig().maxEventBuffer) {
154
+ session.eventBuffer.splice(0, session.eventBuffer.length - getConfig().maxEventBuffer);
157
155
  }
158
156
  const listeners = this.eventListeners.get(sessionId);
159
157
  if (listeners) {
@@ -212,8 +210,8 @@ class SessionManager {
212
210
  if (!session) return;
213
211
  session.eventCounter++;
214
212
  session.eventBuffer.push(event);
215
- if (session.eventBuffer.length > MAX_EVENT_BUFFER) {
216
- session.eventBuffer.splice(0, session.eventBuffer.length - MAX_EVENT_BUFFER);
213
+ if (session.eventBuffer.length > getConfig().maxEventBuffer) {
214
+ session.eventBuffer.splice(0, session.eventBuffer.length - getConfig().maxEventBuffer);
217
215
  }
218
216
  const listeners = this.eventListeners.get(sessionId);
219
217
  if (listeners) {
@@ -272,19 +270,22 @@ class SessionManager {
272
270
  }
273
271
  // ── Permission management ─────────────────────────────────────
274
272
  /** Create a pending permission request. Returns a promise that resolves when approved/denied. */
275
- createPendingPermission(sessionId, request) {
273
+ createPendingPermission(sessionId, request, opts) {
276
274
  const session = this.sessions.get(sessionId);
277
275
  if (session) this.setSessionState(sessionId, session, "permission");
278
276
  return new Promise((resolve) => {
279
277
  const createdAt = Date.now();
280
278
  this.pendingPermissions.set(sessionId, { resolve, request, createdAt });
281
279
  for (const cb of this.permissionRequestListeners) cb(sessionId, request, createdAt);
282
- setTimeout(() => {
283
- if (this.pendingPermissions.has(sessionId)) {
284
- this.pendingPermissions.delete(sessionId);
285
- resolve(false);
286
- }
287
- }, PERMISSION_TIMEOUT_MS);
280
+ const timeout = opts?.timeoutMs ?? getConfig().permissionTimeoutMs;
281
+ if (timeout > 0) {
282
+ setTimeout(() => {
283
+ if (this.pendingPermissions.has(sessionId)) {
284
+ this.pendingPermissions.delete(sessionId);
285
+ resolve(false);
286
+ }
287
+ }, timeout);
288
+ }
288
289
  });
289
290
  }
290
291
  /** Resolve a pending permission request. Returns false if no pending request. */
@@ -356,7 +357,7 @@ class SessionManager {
356
357
  if (session.lastStartConfig) {
357
358
  session.lastStartConfig.model = model;
358
359
  } else {
359
- session.lastStartConfig = { provider: "claude-code", model, permissionMode: "acceptEdits" };
360
+ session.lastStartConfig = { provider: getConfig().defaultProvider, model, permissionMode: getConfig().defaultPermissionMode };
360
361
  }
361
362
  this.persistSession(session);
362
363
  this.emitConfigChanged(id, session.lastStartConfig);
@@ -370,7 +371,7 @@ class SessionManager {
370
371
  if (session.lastStartConfig) {
371
372
  session.lastStartConfig.permissionMode = mode;
372
373
  } else {
373
- session.lastStartConfig = { provider: "claude-code", model: "claude-sonnet-4-6", permissionMode: mode };
374
+ session.lastStartConfig = { provider: getConfig().defaultProvider, model: getConfig().model, permissionMode: mode };
374
375
  }
375
376
  this.persistSession(session);
376
377
  this.emitConfigChanged(id, session.lastStartConfig);
@@ -111,9 +111,38 @@ function initSchema(db) {
111
111
  `);
112
112
  }
113
113
 
114
+ // src/config.ts
115
+ var defaults = {
116
+ port: 3099,
117
+ model: "claude-sonnet-4-6",
118
+ defaultProvider: "claude-code",
119
+ defaultPermissionMode: "default",
120
+ maxSessions: 5,
121
+ maxEventBuffer: 500,
122
+ permissionTimeoutMs: 0,
123
+ // app controls — no SDK-side timeout
124
+ runOnceTimeoutMs: 12e4,
125
+ pollIntervalMs: 500,
126
+ keepaliveIntervalMs: 15e3,
127
+ skillPollMs: 2e3,
128
+ dbPath: "data/sna.db"
129
+ };
130
+ function fromEnv() {
131
+ const env = {};
132
+ if (process.env.SNA_PORT) env.port = parseInt(process.env.SNA_PORT, 10);
133
+ if (process.env.SNA_MODEL) env.model = process.env.SNA_MODEL;
134
+ if (process.env.SNA_PERMISSION_MODE) env.defaultPermissionMode = process.env.SNA_PERMISSION_MODE;
135
+ if (process.env.SNA_MAX_SESSIONS) env.maxSessions = parseInt(process.env.SNA_MAX_SESSIONS, 10);
136
+ if (process.env.SNA_DB_PATH) env.dbPath = process.env.SNA_DB_PATH;
137
+ if (process.env.SNA_PERMISSION_TIMEOUT_MS) env.permissionTimeoutMs = parseInt(process.env.SNA_PERMISSION_TIMEOUT_MS, 10);
138
+ return env;
139
+ }
140
+ var current = { ...defaults, ...fromEnv() };
141
+ function getConfig() {
142
+ return current;
143
+ }
144
+
114
145
  // src/server/routes/events.ts
115
- var POLL_INTERVAL_MS = 500;
116
- var KEEPALIVE_INTERVAL_MS = 15e3;
117
146
  function eventsRoute(c) {
118
147
  const sinceParam = c.req.query("since");
119
148
  let lastId = sinceParam ? parseInt(sinceParam) : -1;
@@ -138,7 +167,7 @@ function eventsRoute(c) {
138
167
  closed = true;
139
168
  clearInterval(keepaliveTimer);
140
169
  }
141
- }, KEEPALIVE_INTERVAL_MS);
170
+ }, getConfig().keepaliveIntervalMs);
142
171
  while (!closed) {
143
172
  try {
144
173
  const db = getDb();
@@ -156,7 +185,7 @@ function eventsRoute(c) {
156
185
  }
157
186
  } catch {
158
187
  }
159
- await stream.sleep(POLL_INTERVAL_MS);
188
+ await stream.sleep(getConfig().pollIntervalMs);
160
189
  }
161
190
  clearInterval(keepaliveTimer);
162
191
  });
@@ -177,14 +206,16 @@ function wsReply(ws, msg, data) {
177
206
  function createEmitRoute(sessionManager2) {
178
207
  return async (c) => {
179
208
  const body = await c.req.json();
180
- const { skill, type, message, data, session_id } = body;
209
+ const { skill, message, data } = body;
210
+ const type = body.type ?? body.eventType;
211
+ const session_id = c.req.query("session") ?? body.session_id ?? body.session ?? null;
181
212
  if (!skill || !type || !message) {
182
213
  return c.json({ error: "missing fields" }, 400);
183
214
  }
184
215
  const db = getDb();
185
216
  const result = db.prepare(
186
217
  `INSERT INTO skill_events (session_id, skill, type, message, data) VALUES (?, ?, ?, ?, ?)`
187
- ).run(session_id ?? null, skill, type, message, data ?? null);
218
+ ).run(session_id, skill, type, message, data ?? null);
188
219
  const id = Number(result.lastInsertRowid);
189
220
  sessionManager2.broadcastSkillEvent({
190
221
  id,
@@ -258,6 +289,7 @@ import { spawn as spawn2, execSync } from "child_process";
258
289
  import { EventEmitter } from "events";
259
290
  import fs4 from "fs";
260
291
  import path4 from "path";
292
+ import { fileURLToPath } from "url";
261
293
 
262
294
  // src/core/providers/cc-history-adapter.ts
263
295
  import fs2 from "fs";
@@ -417,6 +449,10 @@ var _ClaudeCodeProcess = class _ClaudeCodeProcess {
417
449
  this._sessionId = null;
418
450
  this._initEmitted = false;
419
451
  this.buffer = "";
452
+ /** True once we receive a real text_delta stream_event this turn */
453
+ this._receivedStreamEvents = false;
454
+ /** tool_use IDs already emitted via stream_event (to update instead of re-create in assistant block) */
455
+ this._streamedToolUseIds = /* @__PURE__ */ new Set();
420
456
  /**
421
457
  * FIFO event queue — ALL events (deltas, assistant, complete, etc.) go through
422
458
  * this queue. A fixed-interval timer drains one item at a time, guaranteeing
@@ -527,6 +563,9 @@ var _ClaudeCodeProcess = class _ClaudeCodeProcess {
527
563
  get alive() {
528
564
  return this._alive;
529
565
  }
566
+ get pid() {
567
+ return this.proc.pid ?? null;
568
+ }
530
569
  get sessionId() {
531
570
  return this._sessionId;
532
571
  }
@@ -595,7 +634,43 @@ var _ClaudeCodeProcess = class _ClaudeCodeProcess {
595
634
  }
596
635
  return null;
597
636
  }
637
+ case "stream_event": {
638
+ const inner = msg.event;
639
+ if (!inner) return null;
640
+ if (inner.type === "content_block_start" && inner.content_block?.type === "tool_use") {
641
+ const block = inner.content_block;
642
+ this._receivedStreamEvents = true;
643
+ this._streamedToolUseIds.add(block.id);
644
+ return {
645
+ type: "tool_use",
646
+ message: block.name,
647
+ data: { toolName: block.name, id: block.id, input: null, streaming: true },
648
+ timestamp: Date.now()
649
+ };
650
+ }
651
+ if (inner.type === "content_block_delta") {
652
+ const delta = inner.delta;
653
+ if (delta?.type === "text_delta" && delta.text) {
654
+ this._receivedStreamEvents = true;
655
+ return {
656
+ type: "assistant_delta",
657
+ delta: delta.text,
658
+ index: inner.index ?? 0,
659
+ timestamp: Date.now()
660
+ };
661
+ }
662
+ if (delta?.type === "thinking_delta" && delta.thinking) {
663
+ return {
664
+ type: "thinking_delta",
665
+ message: delta.thinking,
666
+ timestamp: Date.now()
667
+ };
668
+ }
669
+ }
670
+ return null;
671
+ }
598
672
  case "assistant": {
673
+ if (this._receivedStreamEvents && msg.message?.stop_reason === null) return null;
599
674
  const content = msg.message?.content;
600
675
  if (!Array.isArray(content)) return null;
601
676
  const events = [];
@@ -608,10 +683,12 @@ var _ClaudeCodeProcess = class _ClaudeCodeProcess {
608
683
  timestamp: Date.now()
609
684
  });
610
685
  } else if (block.type === "tool_use") {
686
+ const alreadyStreamed = this._streamedToolUseIds.has(block.id);
687
+ if (alreadyStreamed) this._streamedToolUseIds.delete(block.id);
611
688
  events.push({
612
689
  type: "tool_use",
613
690
  message: block.name,
614
- data: { toolName: block.name, input: block.input, id: block.id },
691
+ data: { toolName: block.name, input: block.input, id: block.id, update: alreadyStreamed },
615
692
  timestamp: Date.now()
616
693
  });
617
694
  } else if (block.type === "text") {
@@ -626,7 +703,7 @@ var _ClaudeCodeProcess = class _ClaudeCodeProcess {
626
703
  this.enqueue(e);
627
704
  }
628
705
  for (const text of textBlocks) {
629
- this.enqueueTextAsDeltas(text);
706
+ this.enqueue({ type: "assistant", message: text, timestamp: Date.now() });
630
707
  }
631
708
  }
632
709
  return null;
@@ -648,6 +725,15 @@ var _ClaudeCodeProcess = class _ClaudeCodeProcess {
648
725
  }
649
726
  case "result": {
650
727
  if (msg.subtype === "success") {
728
+ if (this._receivedStreamEvents && msg.result) {
729
+ this.enqueue({
730
+ type: "assistant",
731
+ message: msg.result,
732
+ timestamp: Date.now()
733
+ });
734
+ this._receivedStreamEvents = false;
735
+ this._streamedToolUseIds.clear();
736
+ }
651
737
  const u = msg.usage ?? {};
652
738
  const mu = msg.modelUsage ?? {};
653
739
  const modelKey = Object.keys(mu)[0] ?? "";
@@ -715,7 +801,13 @@ var ClaudeCodeProvider = class {
715
801
  const claudeParts = claudeCommand.split(/\s+/);
716
802
  const claudePath = claudeParts[0];
717
803
  const claudePrefix = claudeParts.slice(1);
718
- const hookScript = new URL("../../scripts/hook.js", import.meta.url).pathname;
804
+ let pkgRoot = path4.dirname(fileURLToPath(import.meta.url));
805
+ while (!fs4.existsSync(path4.join(pkgRoot, "package.json"))) {
806
+ const parent = path4.dirname(pkgRoot);
807
+ if (parent === pkgRoot) break;
808
+ pkgRoot = parent;
809
+ }
810
+ const hookScript = path4.join(pkgRoot, "dist", "scripts", "hook.js");
719
811
  const sessionId = options.env?.SNA_SESSION_ID ?? "default";
720
812
  const sdkSettings = {};
721
813
  if (options.permissionMode !== "bypassPermissions") {
@@ -755,6 +847,7 @@ var ClaudeCodeProvider = class {
755
847
  "--input-format",
756
848
  "stream-json",
757
849
  "--verbose",
850
+ "--include-partial-messages",
758
851
  "--settings",
759
852
  JSON.stringify(sdkSettings)
760
853
  ];
@@ -876,24 +969,24 @@ function resolveImagePath(sessionId, filename) {
876
969
  function getSessionId(c) {
877
970
  return c.req.query("session") ?? "default";
878
971
  }
879
- var DEFAULT_RUN_ONCE_TIMEOUT = 12e4;
880
972
  async function runOnce(sessionManager2, opts) {
881
973
  const sessionId = `run-once-${crypto.randomUUID().slice(0, 8)}`;
882
- const timeout = opts.timeout ?? DEFAULT_RUN_ONCE_TIMEOUT;
974
+ const timeout = opts.timeout ?? getConfig().runOnceTimeoutMs;
883
975
  const session = sessionManager2.createSession({
884
976
  id: sessionId,
885
977
  label: "run-once",
886
978
  cwd: opts.cwd ?? process.cwd()
887
979
  });
888
- const provider2 = getProvider(opts.provider ?? "claude-code");
980
+ const cfg = getConfig();
981
+ const provider2 = getProvider(opts.provider ?? cfg.defaultProvider);
889
982
  const extraArgs = opts.extraArgs ? [...opts.extraArgs] : [];
890
983
  if (opts.systemPrompt) extraArgs.push("--system-prompt", opts.systemPrompt);
891
984
  if (opts.appendSystemPrompt) extraArgs.push("--append-system-prompt", opts.appendSystemPrompt);
892
985
  const proc = provider2.spawn({
893
986
  cwd: session.cwd,
894
987
  prompt: opts.message,
895
- model: opts.model ?? "claude-sonnet-4-6",
896
- permissionMode: opts.permissionMode ?? "bypassPermissions",
988
+ model: opts.model ?? cfg.model,
989
+ permissionMode: opts.permissionMode ?? cfg.defaultPermissionMode,
897
990
  env: { SNA_SESSION_ID: sessionId },
898
991
  extraArgs
899
992
  });
@@ -934,6 +1027,7 @@ function createAgentRoutes(sessionManager2) {
934
1027
  const body = await c.req.json().catch(() => ({}));
935
1028
  try {
936
1029
  const session = sessionManager2.createSession({
1030
+ id: body.id,
937
1031
  label: body.label,
938
1032
  cwd: body.cwd,
939
1033
  meta: body.meta
@@ -960,6 +1054,22 @@ function createAgentRoutes(sessionManager2) {
960
1054
  logger.log("route", `DELETE /sessions/${id} \u2192 removed`);
961
1055
  return httpJson(c, "sessions.remove", { status: "removed" });
962
1056
  });
1057
+ app.patch("/sessions/:id", async (c) => {
1058
+ const id = c.req.param("id");
1059
+ const body = await c.req.json().catch(() => ({}));
1060
+ try {
1061
+ sessionManager2.updateSession(id, {
1062
+ label: body.label,
1063
+ meta: body.meta,
1064
+ cwd: body.cwd
1065
+ });
1066
+ logger.log("route", `PATCH /sessions/${id} \u2192 updated`);
1067
+ return httpJson(c, "sessions.update", { status: "updated", session: id });
1068
+ } catch (e) {
1069
+ logger.err("err", `PATCH /sessions/${id} \u2192 ${e.message}`);
1070
+ return c.json({ status: "error", message: e.message }, 404);
1071
+ }
1072
+ });
963
1073
  app.post("/run-once", async (c) => {
964
1074
  const body = await c.req.json().catch(() => ({}));
965
1075
  if (!body.message) {
@@ -983,14 +1093,14 @@ function createAgentRoutes(sessionManager2) {
983
1093
  logger.log("route", `POST /start?session=${sessionId} \u2192 already_running`);
984
1094
  return httpJson(c, "agent.start", {
985
1095
  status: "already_running",
986
- provider: "claude-code",
1096
+ provider: getConfig().defaultProvider,
987
1097
  sessionId: session.process.sessionId ?? session.id
988
1098
  });
989
1099
  }
990
1100
  if (session.process?.alive) {
991
1101
  session.process.kill();
992
1102
  }
993
- const provider2 = getProvider(body.provider ?? "claude-code");
1103
+ const provider2 = getProvider(body.provider ?? getConfig().defaultProvider);
994
1104
  try {
995
1105
  const db = getDb();
996
1106
  db.prepare(`INSERT OR IGNORE INTO chat_sessions (id, label, type) VALUES (?, ?, 'main')`).run(sessionId, session.label ?? sessionId);
@@ -1005,8 +1115,8 @@ function createAgentRoutes(sessionManager2) {
1005
1115
  }
1006
1116
  } catch {
1007
1117
  }
1008
- const providerName = body.provider ?? "claude-code";
1009
- const model = body.model ?? "claude-sonnet-4-6";
1118
+ const providerName = body.provider ?? getConfig().defaultProvider;
1119
+ const model = body.model ?? getConfig().model;
1010
1120
  const permissionMode2 = body.permissionMode;
1011
1121
  const configDir = body.configDir;
1012
1122
  const extraArgs = body.extraArgs;
@@ -1091,7 +1201,7 @@ function createAgentRoutes(sessionManager2) {
1091
1201
  const sinceParam = c.req.query("since");
1092
1202
  const sinceCursor = sinceParam ? parseInt(sinceParam, 10) : session.eventCounter;
1093
1203
  return streamSSE3(c, async (stream) => {
1094
- const KEEPALIVE_MS = 15e3;
1204
+ const KEEPALIVE_MS = getConfig().keepaliveIntervalMs;
1095
1205
  const signal = c.req.raw.signal;
1096
1206
  const queue = [];
1097
1207
  let wakeUp = null;
@@ -1188,8 +1298,8 @@ function createAgentRoutes(sessionManager2) {
1188
1298
  if (history.length === 0 && !body.prompt) {
1189
1299
  return c.json({ status: "error", message: "No history in DB \u2014 nothing to resume." }, 400);
1190
1300
  }
1191
- const providerName = body.provider ?? "claude-code";
1192
- const model = body.model ?? session.lastStartConfig?.model ?? "claude-sonnet-4-6";
1301
+ const providerName = body.provider ?? getConfig().defaultProvider;
1302
+ const model = body.model ?? session.lastStartConfig?.model ?? getConfig().model;
1193
1303
  const permissionMode2 = body.permissionMode ?? session.lastStartConfig?.permissionMode;
1194
1304
  const configDir = body.configDir ?? session.lastStartConfig?.configDir;
1195
1305
  const extraArgs = body.extraArgs ?? session.lastStartConfig?.extraArgs;
@@ -1320,11 +1430,12 @@ function createChatRoutes() {
1320
1430
  app.post("/sessions", async (c) => {
1321
1431
  const body = await c.req.json().catch(() => ({}));
1322
1432
  const id = body.id ?? crypto.randomUUID().slice(0, 8);
1433
+ const sessionType = body.type ?? body.chatType ?? "background";
1323
1434
  try {
1324
1435
  const db = getDb();
1325
1436
  db.prepare(
1326
1437
  `INSERT OR IGNORE INTO chat_sessions (id, label, type, meta) VALUES (?, ?, ?, ?)`
1327
- ).run(id, body.label ?? id, body.type ?? "background", body.meta ? JSON.stringify(body.meta) : null);
1438
+ ).run(id, body.label ?? id, sessionType, body.meta ? JSON.stringify(body.meta) : null);
1328
1439
  return httpJson(c, "chat.sessions.create", { status: "created", id, meta: body.meta ?? null });
1329
1440
  } catch (e) {
1330
1441
  return c.json({ status: "error", message: e.message }, 500);
@@ -1412,9 +1523,6 @@ function createChatRoutes() {
1412
1523
  }
1413
1524
 
1414
1525
  // src/server/session-manager.ts
1415
- var DEFAULT_MAX_SESSIONS = 5;
1416
- var MAX_EVENT_BUFFER = 500;
1417
- var PERMISSION_TIMEOUT_MS = 3e5;
1418
1526
  var SessionManager = class {
1419
1527
  constructor(options = {}) {
1420
1528
  this.sessions = /* @__PURE__ */ new Map();
@@ -1426,7 +1534,7 @@ var SessionManager = class {
1426
1534
  this.configChangedListeners = /* @__PURE__ */ new Set();
1427
1535
  this.stateChangedListeners = /* @__PURE__ */ new Set();
1428
1536
  this.metadataChangedListeners = /* @__PURE__ */ new Set();
1429
- this.maxSessions = options.maxSessions ?? DEFAULT_MAX_SESSIONS;
1537
+ this.maxSessions = options.maxSessions ?? getConfig().maxSessions;
1430
1538
  this.restoreFromDb();
1431
1539
  }
1432
1540
  /** Restore session metadata from DB (cwd, label, meta). Process state is not restored. */
@@ -1565,8 +1673,8 @@ var SessionManager = class {
1565
1673
  if (persisted) {
1566
1674
  session.eventCounter++;
1567
1675
  session.eventBuffer.push(e);
1568
- if (session.eventBuffer.length > MAX_EVENT_BUFFER) {
1569
- session.eventBuffer.splice(0, session.eventBuffer.length - MAX_EVENT_BUFFER);
1676
+ if (session.eventBuffer.length > getConfig().maxEventBuffer) {
1677
+ session.eventBuffer.splice(0, session.eventBuffer.length - getConfig().maxEventBuffer);
1570
1678
  }
1571
1679
  const listeners = this.eventListeners.get(sessionId);
1572
1680
  if (listeners) {
@@ -1625,8 +1733,8 @@ var SessionManager = class {
1625
1733
  if (!session) return;
1626
1734
  session.eventCounter++;
1627
1735
  session.eventBuffer.push(event);
1628
- if (session.eventBuffer.length > MAX_EVENT_BUFFER) {
1629
- session.eventBuffer.splice(0, session.eventBuffer.length - MAX_EVENT_BUFFER);
1736
+ if (session.eventBuffer.length > getConfig().maxEventBuffer) {
1737
+ session.eventBuffer.splice(0, session.eventBuffer.length - getConfig().maxEventBuffer);
1630
1738
  }
1631
1739
  const listeners = this.eventListeners.get(sessionId);
1632
1740
  if (listeners) {
@@ -1685,19 +1793,22 @@ var SessionManager = class {
1685
1793
  }
1686
1794
  // ── Permission management ─────────────────────────────────────
1687
1795
  /** Create a pending permission request. Returns a promise that resolves when approved/denied. */
1688
- createPendingPermission(sessionId, request) {
1796
+ createPendingPermission(sessionId, request, opts) {
1689
1797
  const session = this.sessions.get(sessionId);
1690
1798
  if (session) this.setSessionState(sessionId, session, "permission");
1691
1799
  return new Promise((resolve) => {
1692
1800
  const createdAt = Date.now();
1693
1801
  this.pendingPermissions.set(sessionId, { resolve, request, createdAt });
1694
1802
  for (const cb of this.permissionRequestListeners) cb(sessionId, request, createdAt);
1695
- setTimeout(() => {
1696
- if (this.pendingPermissions.has(sessionId)) {
1697
- this.pendingPermissions.delete(sessionId);
1698
- resolve(false);
1699
- }
1700
- }, PERMISSION_TIMEOUT_MS);
1803
+ const timeout = opts?.timeoutMs ?? getConfig().permissionTimeoutMs;
1804
+ if (timeout > 0) {
1805
+ setTimeout(() => {
1806
+ if (this.pendingPermissions.has(sessionId)) {
1807
+ this.pendingPermissions.delete(sessionId);
1808
+ resolve(false);
1809
+ }
1810
+ }, timeout);
1811
+ }
1701
1812
  });
1702
1813
  }
1703
1814
  /** Resolve a pending permission request. Returns false if no pending request. */
@@ -1769,7 +1880,7 @@ var SessionManager = class {
1769
1880
  if (session.lastStartConfig) {
1770
1881
  session.lastStartConfig.model = model;
1771
1882
  } else {
1772
- session.lastStartConfig = { provider: "claude-code", model, permissionMode: "acceptEdits" };
1883
+ session.lastStartConfig = { provider: getConfig().defaultProvider, model, permissionMode: getConfig().defaultPermissionMode };
1773
1884
  }
1774
1885
  this.persistSession(session);
1775
1886
  this.emitConfigChanged(id, session.lastStartConfig);
@@ -1783,7 +1894,7 @@ var SessionManager = class {
1783
1894
  if (session.lastStartConfig) {
1784
1895
  session.lastStartConfig.permissionMode = mode;
1785
1896
  } else {
1786
- session.lastStartConfig = { provider: "claude-code", model: "claude-sonnet-4-6", permissionMode: mode };
1897
+ session.lastStartConfig = { provider: getConfig().defaultProvider, model: getConfig().model, permissionMode: mode };
1787
1898
  }
1788
1899
  this.persistSession(session);
1789
1900
  this.emitConfigChanged(id, session.lastStartConfig);
@@ -2057,6 +2168,7 @@ function handleMessage(ws, msg, sm, state) {
2057
2168
  function handleSessionsCreate(ws, msg, sm) {
2058
2169
  try {
2059
2170
  const session = sm.createSession({
2171
+ id: msg.id,
2060
2172
  label: msg.label,
2061
2173
  cwd: msg.cwd,
2062
2174
  meta: msg.meta
@@ -2094,11 +2206,11 @@ function handleAgentStart(ws, msg, sm) {
2094
2206
  cwd: msg.cwd
2095
2207
  });
2096
2208
  if (session.process?.alive && !msg.force) {
2097
- wsReply(ws, msg, { status: "already_running", provider: "claude-code", sessionId: session.id });
2209
+ wsReply(ws, msg, { status: "already_running", provider: getConfig().defaultProvider, sessionId: session.id });
2098
2210
  return;
2099
2211
  }
2100
2212
  if (session.process?.alive) session.process.kill();
2101
- const provider2 = getProvider(msg.provider ?? "claude-code");
2213
+ const provider2 = getProvider(msg.provider ?? getConfig().defaultProvider);
2102
2214
  try {
2103
2215
  const db = getDb();
2104
2216
  db.prepare(`INSERT OR IGNORE INTO chat_sessions (id, label, type) VALUES (?, ?, 'main')`).run(sessionId, session.label ?? sessionId);
@@ -2111,8 +2223,9 @@ function handleAgentStart(ws, msg, sm) {
2111
2223
  }
2112
2224
  } catch {
2113
2225
  }
2114
- const providerName = msg.provider ?? "claude-code";
2115
- const model = msg.model ?? "claude-sonnet-4-6";
2226
+ const cfg = getConfig();
2227
+ const providerName = msg.provider ?? cfg.defaultProvider;
2228
+ const model = msg.model ?? cfg.model;
2116
2229
  const permissionMode2 = msg.permissionMode;
2117
2230
  const configDir = msg.configDir;
2118
2231
  const extraArgs = msg.extraArgs;
@@ -2188,8 +2301,8 @@ function handleAgentResume(ws, msg, sm) {
2188
2301
  if (history.length === 0 && !msg.prompt) {
2189
2302
  return replyError(ws, msg, "No history in DB \u2014 nothing to resume.");
2190
2303
  }
2191
- const providerName = msg.provider ?? session.lastStartConfig?.provider ?? "claude-code";
2192
- const model = msg.model ?? session.lastStartConfig?.model ?? "claude-sonnet-4-6";
2304
+ const providerName = msg.provider ?? session.lastStartConfig?.provider ?? getConfig().defaultProvider;
2305
+ const model = msg.model ?? session.lastStartConfig?.model ?? getConfig().model;
2193
2306
  const permissionMode2 = msg.permissionMode ?? session.lastStartConfig?.permissionMode;
2194
2307
  const configDir = msg.configDir ?? session.lastStartConfig?.configDir;
2195
2308
  const extraArgs = msg.extraArgs ?? session.lastStartConfig?.extraArgs;
@@ -2376,7 +2489,6 @@ function handleAgentUnsubscribe(ws, msg, state) {
2376
2489
  state.agentUnsubs.delete(sessionId);
2377
2490
  reply(ws, msg, {});
2378
2491
  }
2379
- var SKILL_POLL_MS = 2e3;
2380
2492
  function handleEventsSubscribe(ws, msg, sm, state) {
2381
2493
  state.skillEventUnsub?.();
2382
2494
  state.skillEventUnsub = null;
@@ -2416,7 +2528,7 @@ function handleEventsSubscribe(ws, msg, sm, state) {
2416
2528
  }
2417
2529
  } catch {
2418
2530
  }
2419
- }, SKILL_POLL_MS);
2531
+ }, getConfig().skillPollMs);
2420
2532
  reply(ws, msg, { lastId });
2421
2533
  }
2422
2534
  function handleEventsUnsubscribe(ws, msg, state) {
@@ -2600,10 +2712,7 @@ try {
2600
2712
  }
2601
2713
  process.exit(1);
2602
2714
  }
2603
- var port = parseInt(process.env.SNA_PORT ?? "3099", 10);
2604
- var permissionMode = process.env.SNA_PERMISSION_MODE;
2605
- var defaultModel = process.env.SNA_MODEL ?? "claude-sonnet-4-6";
2606
- var maxSessions = parseInt(process.env.SNA_MAX_SESSIONS ?? "5", 10);
2715
+ var { port, defaultPermissionMode: permissionMode, model: defaultModel, maxSessions } = getConfig();
2607
2716
  var root = new Hono4();
2608
2717
  root.use("*", cors({ origin: "*", allowMethods: ["GET", "POST", "DELETE", "OPTIONS"] }));
2609
2718
  root.onError((err2, c) => {
@@ -28,11 +28,14 @@ import '../core/providers/types.js';
28
28
  * agent.status { session? }
29
29
  * agent.subscribe { session?, since? }
30
30
  * agent.unsubscribe { session? }
31
- * agent.run-once { message, model?, systemPrompt?, permissionMode?, timeout? }
31
+ * agent.run-once { message, model?, systemPrompt?, appendSystemPrompt?, permissionMode?, cwd?, timeout?, provider?, extraArgs? }
32
32
  *
33
33
  * events.subscribe { since? }
34
34
  * events.unsubscribe {}
35
35
  * emit { skill, eventType, message, data?, session? }
36
+ * NOTE: WS uses `eventType` (not `type`) because `type` is reserved
37
+ * as the WS protocol routing field. HTTP POST /emit uses `type` instead.
38
+ * WS uses `session` (not `session_id`) consistent with all other WS ops.
36
39
  *
37
40
  * permission.respond { session?, approved }
38
41
  * permission.pending { session? }
@@ -41,6 +44,7 @@ import '../core/providers/types.js';
41
44
  *
42
45
  * chat.sessions.list {}
43
46
  * chat.sessions.create { id?, label?, chatType?, meta? }
47
+ * NOTE: WS uses `chatType` (not `type`) for the same reason as `eventType` above.
44
48
  * chat.sessions.remove { session }
45
49
  * chat.messages.list { session, since? }
46
50
  * chat.messages.create { session, role, content?, skill_name?, meta? }
package/dist/server/ws.js CHANGED
@@ -6,6 +6,7 @@ import { runOnce } from "./routes/agent.js";
6
6
  import { wsReply } from "./api-types.js";
7
7
  import { buildHistoryFromDb } from "./history-builder.js";
8
8
  import { saveImages } from "./image-store.js";
9
+ import { getConfig } from "../config.js";
9
10
  function send(ws, data) {
10
11
  if (ws.readyState === ws.OPEN) {
11
12
  ws.send(JSON.stringify(data));
@@ -161,6 +162,7 @@ function handleMessage(ws, msg, sm, state) {
161
162
  function handleSessionsCreate(ws, msg, sm) {
162
163
  try {
163
164
  const session = sm.createSession({
165
+ id: msg.id,
164
166
  label: msg.label,
165
167
  cwd: msg.cwd,
166
168
  meta: msg.meta
@@ -198,11 +200,11 @@ function handleAgentStart(ws, msg, sm) {
198
200
  cwd: msg.cwd
199
201
  });
200
202
  if (session.process?.alive && !msg.force) {
201
- wsReply(ws, msg, { status: "already_running", provider: "claude-code", sessionId: session.id });
203
+ wsReply(ws, msg, { status: "already_running", provider: getConfig().defaultProvider, sessionId: session.id });
202
204
  return;
203
205
  }
204
206
  if (session.process?.alive) session.process.kill();
205
- const provider = getProvider(msg.provider ?? "claude-code");
207
+ const provider = getProvider(msg.provider ?? getConfig().defaultProvider);
206
208
  try {
207
209
  const db = getDb();
208
210
  db.prepare(`INSERT OR IGNORE INTO chat_sessions (id, label, type) VALUES (?, ?, 'main')`).run(sessionId, session.label ?? sessionId);
@@ -215,8 +217,9 @@ function handleAgentStart(ws, msg, sm) {
215
217
  }
216
218
  } catch {
217
219
  }
218
- const providerName = msg.provider ?? "claude-code";
219
- const model = msg.model ?? "claude-sonnet-4-6";
220
+ const cfg = getConfig();
221
+ const providerName = msg.provider ?? cfg.defaultProvider;
222
+ const model = msg.model ?? cfg.model;
220
223
  const permissionMode = msg.permissionMode;
221
224
  const configDir = msg.configDir;
222
225
  const extraArgs = msg.extraArgs;
@@ -292,8 +295,8 @@ function handleAgentResume(ws, msg, sm) {
292
295
  if (history.length === 0 && !msg.prompt) {
293
296
  return replyError(ws, msg, "No history in DB \u2014 nothing to resume.");
294
297
  }
295
- const providerName = msg.provider ?? session.lastStartConfig?.provider ?? "claude-code";
296
- const model = msg.model ?? session.lastStartConfig?.model ?? "claude-sonnet-4-6";
298
+ const providerName = msg.provider ?? session.lastStartConfig?.provider ?? getConfig().defaultProvider;
299
+ const model = msg.model ?? session.lastStartConfig?.model ?? getConfig().model;
297
300
  const permissionMode = msg.permissionMode ?? session.lastStartConfig?.permissionMode;
298
301
  const configDir = msg.configDir ?? session.lastStartConfig?.configDir;
299
302
  const extraArgs = msg.extraArgs ?? session.lastStartConfig?.extraArgs;
@@ -480,7 +483,6 @@ function handleAgentUnsubscribe(ws, msg, state) {
480
483
  state.agentUnsubs.delete(sessionId);
481
484
  reply(ws, msg, {});
482
485
  }
483
- const SKILL_POLL_MS = 2e3;
484
486
  function handleEventsSubscribe(ws, msg, sm, state) {
485
487
  state.skillEventUnsub?.();
486
488
  state.skillEventUnsub = null;
@@ -520,7 +522,7 @@ function handleEventsSubscribe(ws, msg, sm, state) {
520
522
  }
521
523
  } catch {
522
524
  }
523
- }, SKILL_POLL_MS);
525
+ }, getConfig().skillPollMs);
524
526
  reply(ws, msg, { lastId });
525
527
  }
526
528
  function handleEventsUnsubscribe(ws, msg, state) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sna-sdk/core",
3
- "version": "0.8.1",
3
+ "version": "0.9.4",
4
4
  "description": "Skills-Native Application runtime — server, providers, session management, database, and CLI",
5
5
  "type": "module",
6
6
  "bin": {