@sna-sdk/core 0.8.0 → 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
  ];
@@ -401,6 +463,9 @@ class ClaudeCodeProvider {
401
463
  args.push(...extraArgsClean);
402
464
  }
403
465
  const cleanEnv = { ...process.env, ...options.env };
466
+ if (options.configDir) {
467
+ cleanEnv.CLAUDE_CONFIG_DIR = options.configDir;
468
+ }
404
469
  delete cleanEnv.CLAUDECODE;
405
470
  delete cleanEnv.CLAUDE_CODE_ENTRYPOINT;
406
471
  delete cleanEnv.CLAUDE_CODE_SESSION_ACCESS_TOKEN;
@@ -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;
@@ -61,6 +63,12 @@ interface SpawnOptions {
61
63
  model?: string;
62
64
  permissionMode?: "default" | "acceptEdits" | "bypassPermissions" | "plan";
63
65
  env?: Record<string, string>;
66
+ /**
67
+ * Override CLAUDE_CONFIG_DIR for this session.
68
+ * Isolates Claude config (permissions, theme, API keys, etc.) per session.
69
+ * If omitted, inherits the process-level CLAUDE_CONFIG_DIR or default (~/).
70
+ */
71
+ configDir?: string;
64
72
  /**
65
73
  * Conversation history to inject before the first prompt.
66
74
  * Written to stdin as NDJSON — Claude Code treats these as prior conversation turns.
@@ -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,9 +158,10 @@ 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;
164
+ const configDir = body.configDir;
146
165
  const extraArgs = body.extraArgs;
147
166
  try {
148
167
  const proc = provider.spawn({
@@ -150,12 +169,13 @@ function createAgentRoutes(sessionManager) {
150
169
  prompt: body.prompt,
151
170
  model,
152
171
  permissionMode,
172
+ configDir,
153
173
  env: { SNA_SESSION_ID: sessionId },
154
174
  history: body.history,
155
175
  extraArgs
156
176
  });
157
177
  sessionManager.setProcess(sessionId, proc);
158
- sessionManager.saveStartConfig(sessionId, { provider: providerName, model, permissionMode, extraArgs });
178
+ sessionManager.saveStartConfig(sessionId, { provider: providerName, model, permissionMode, configDir, extraArgs });
159
179
  logger.log("route", `POST /start?session=${sessionId} \u2192 started`);
160
180
  return httpJson(c, "agent.start", {
161
181
  status: "started",
@@ -224,7 +244,7 @@ function createAgentRoutes(sessionManager) {
224
244
  const sinceParam = c.req.query("since");
225
245
  const sinceCursor = sinceParam ? parseInt(sinceParam, 10) : session.eventCounter;
226
246
  return streamSSE(c, async (stream) => {
227
- const KEEPALIVE_MS = 15e3;
247
+ const KEEPALIVE_MS = getConfig().keepaliveIntervalMs;
228
248
  const signal = c.req.raw.signal;
229
249
  const queue = [];
230
250
  let wakeUp = null;
@@ -294,6 +314,7 @@ function createAgentRoutes(sessionManager) {
294
314
  cwd: sessionManager.getSession(sessionId).cwd,
295
315
  model: cfg.model,
296
316
  permissionMode: cfg.permissionMode,
317
+ configDir: cfg.configDir,
297
318
  env: { SNA_SESSION_ID: sessionId },
298
319
  extraArgs: [...cfg.extraArgs ?? [], ...resumeArgs]
299
320
  });
@@ -320,9 +341,10 @@ function createAgentRoutes(sessionManager) {
320
341
  if (history.length === 0 && !body.prompt) {
321
342
  return c.json({ status: "error", message: "No history in DB \u2014 nothing to resume." }, 400);
322
343
  }
323
- const providerName = body.provider ?? "claude-code";
324
- 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;
325
346
  const permissionMode = body.permissionMode ?? session.lastStartConfig?.permissionMode;
347
+ const configDir = body.configDir ?? session.lastStartConfig?.configDir;
326
348
  const extraArgs = body.extraArgs ?? session.lastStartConfig?.extraArgs;
327
349
  const provider = getProvider(providerName);
328
350
  try {
@@ -331,12 +353,13 @@ function createAgentRoutes(sessionManager) {
331
353
  prompt: body.prompt,
332
354
  model,
333
355
  permissionMode,
356
+ configDir,
334
357
  env: { SNA_SESSION_ID: sessionId },
335
358
  history: history.length > 0 ? history : void 0,
336
359
  extraArgs
337
360
  });
338
361
  sessionManager.setProcess(sessionId, proc, "resumed");
339
- sessionManager.saveStartConfig(sessionId, { provider: providerName, model, permissionMode, extraArgs });
362
+ sessionManager.saveStartConfig(sessionId, { provider: providerName, model, permissionMode, configDir, extraArgs });
340
363
  logger.log("route", `POST /resume?session=${sessionId} \u2192 resumed (${history.length} history msgs)`);
341
364
  return httpJson(c, "agent.resume", {
342
365
  status: "resumed",
@@ -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
  });
@@ -12,6 +12,7 @@ interface StartConfig {
12
12
  provider: string;
13
13
  model: string;
14
14
  permissionMode?: string;
15
+ configDir?: string;
15
16
  extraArgs?: string[];
16
17
  }
17
18
  interface Session {
@@ -134,7 +135,9 @@ declare class SessionManager {
134
135
  updateSessionState(sessionId: string, newState: SessionState): void;
135
136
  private setSessionState;
136
137
  /** Create a pending permission request. Returns a promise that resolves when approved/denied. */
137
- createPendingPermission(sessionId: string, request: Record<string, unknown>): Promise<boolean>;
138
+ createPendingPermission(sessionId: string, request: Record<string, unknown>, opts?: {
139
+ timeoutMs?: number;
140
+ }): Promise<boolean>;
138
141
  /** Resolve a pending permission request. Returns false if no pending request. */
139
142
  resolvePendingPermission(sessionId: string, approved: boolean): boolean;
140
143
  /** Get a pending permission for a specific session. */