@sna-sdk/core 0.6.1 → 0.7.1

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
@@ -84,13 +84,18 @@ const db = getDb(); // SQLite instance (data/sna.db)
84
84
  | `@sna-sdk/core/server/routes/agent` | `createAgentRoutes()`, `runOnce()` |
85
85
  | `@sna-sdk/core/db/schema` | `getDb()`, `ChatSession`, `ChatMessage`, `SkillEvent` types |
86
86
  | `@sna-sdk/core/providers` | Agent provider factory, `ClaudeCodeProvider` |
87
- | `@sna-sdk/core/lib/sna-run` | `snaRun()` helper for spawning Claude Code |
87
+ | `@sna-sdk/core/lib/sna-run` | `sna` object with `sna.run()` awaitable skill invocation |
88
88
  | `@sna-sdk/core/testing` | `startMockAnthropicServer()` for testing without real API calls |
89
+ | `@sna-sdk/core/electron` | `startSnaServer()` — launch SNA server from Electron main process (asar-aware) |
90
+ | `@sna-sdk/core/node` | `startSnaServer()` — launch SNA server from Node.js (Next.js, Express, etc.) |
89
91
 
90
92
  **Environment Variables:**
91
93
  - `SNA_DB_PATH` — Override SQLite database location (default: `process.cwd()/data/sna.db`)
92
94
  - `SNA_CLAUDE_COMMAND` — Override claude binary path
93
95
  - `SNA_PORT` — API server port (default: 3099)
96
+ - `SNA_SQLITE_NATIVE_BINDING` — Absolute path to `better_sqlite3.node` native binary; bypasses `bindings` module resolution for Electron packaged apps
97
+ - `SNA_MAX_SESSIONS` — Maximum concurrent agent sessions (default: 5)
98
+ - `SNA_PERMISSION_MODE` — Default permission mode for spawned agents (`acceptEdits` | `bypassPermissions` | `default`)
94
99
 
95
100
  ## Documentation
96
101
 
@@ -35,13 +35,20 @@ function resolveClaudePath(cwd) {
35
35
  return "claude";
36
36
  }
37
37
  }
38
- class ClaudeCodeProcess {
38
+ const _ClaudeCodeProcess = class _ClaudeCodeProcess {
39
39
  constructor(proc, options) {
40
40
  this.emitter = new EventEmitter();
41
41
  this._alive = true;
42
42
  this._sessionId = null;
43
43
  this._initEmitted = false;
44
44
  this.buffer = "";
45
+ /**
46
+ * FIFO event queue — ALL events (deltas, assistant, complete, etc.) go through
47
+ * this queue. A fixed-interval timer drains one item at a time, guaranteeing
48
+ * strict ordering: deltas → assistant → complete, never out of order.
49
+ */
50
+ this.eventQueue = [];
51
+ this.drainTimer = null;
45
52
  this.proc = proc;
46
53
  proc.stdout.on("data", (chunk) => {
47
54
  this.buffer += chunk.toString();
@@ -56,7 +63,7 @@ class ClaudeCodeProcess {
56
63
  this._sessionId = msg.session_id;
57
64
  }
58
65
  const event = this.normalizeEvent(msg);
59
- if (event) this.emitter.emit("event", event);
66
+ if (event) this.enqueue(event);
60
67
  } catch {
61
68
  }
62
69
  }
@@ -69,10 +76,11 @@ class ClaudeCodeProcess {
69
76
  try {
70
77
  const msg = JSON.parse(this.buffer);
71
78
  const event = this.normalizeEvent(msg);
72
- if (event) this.emitter.emit("event", event);
79
+ if (event) this.enqueue(event);
73
80
  } catch {
74
81
  }
75
82
  }
83
+ this.flushQueue();
76
84
  this.emitter.emit("exit", code);
77
85
  logger.log("agent", `process exited (code=${code})`);
78
86
  });
@@ -88,35 +96,58 @@ class ClaudeCodeProcess {
88
96
  this.send(options.prompt);
89
97
  }
90
98
  }
99
+ // ~67 events/sec
91
100
  /**
92
- * Split completed assistant text into chunks and emit assistant_delta events
93
- * at a fixed rate (~270 chars/sec), followed by the final assistant event.
94
- *
95
- * CHUNK_SIZE chars every CHUNK_DELAY_MS → natural TPS feel regardless of length.
101
+ * Enqueue an event for ordered emission.
102
+ * Starts the drain timer if not already running.
96
103
  */
97
- emitTextAsDeltas(text) {
104
+ enqueue(event) {
105
+ this.eventQueue.push(event);
106
+ if (!this.drainTimer) {
107
+ this.drainTimer = setInterval(() => this.drainOne(), _ClaudeCodeProcess.DRAIN_INTERVAL_MS);
108
+ }
109
+ }
110
+ /** Emit one event from the front of the queue. Stop timer when empty. */
111
+ drainOne() {
112
+ const event = this.eventQueue.shift();
113
+ if (event) {
114
+ this.emitter.emit("event", event);
115
+ }
116
+ if (this.eventQueue.length === 0 && this.drainTimer) {
117
+ clearInterval(this.drainTimer);
118
+ this.drainTimer = null;
119
+ }
120
+ }
121
+ /** Flush all remaining queued events immediately (used on process exit). */
122
+ flushQueue() {
123
+ if (this.drainTimer) {
124
+ clearInterval(this.drainTimer);
125
+ this.drainTimer = null;
126
+ }
127
+ while (this.eventQueue.length > 0) {
128
+ this.emitter.emit("event", this.eventQueue.shift());
129
+ }
130
+ }
131
+ /**
132
+ * Split completed assistant text into delta chunks and enqueue them,
133
+ * followed by the final assistant event. All go through the FIFO queue
134
+ * so subsequent events (complete, etc.) are guaranteed to come after.
135
+ */
136
+ enqueueTextAsDeltas(text) {
98
137
  const CHUNK_SIZE = 4;
99
- const CHUNK_DELAY_MS = 15;
100
- let t = 0;
101
138
  for (let i = 0; i < text.length; i += CHUNK_SIZE) {
102
- const chunk = text.slice(i, i + CHUNK_SIZE);
103
- setTimeout(() => {
104
- this.emitter.emit("event", {
105
- type: "assistant_delta",
106
- delta: chunk,
107
- index: 0,
108
- timestamp: Date.now()
109
- });
110
- }, t);
111
- t += CHUNK_DELAY_MS;
112
- }
113
- setTimeout(() => {
114
- this.emitter.emit("event", {
115
- type: "assistant",
116
- message: text,
139
+ this.enqueue({
140
+ type: "assistant_delta",
141
+ delta: text.slice(i, i + CHUNK_SIZE),
142
+ index: 0,
117
143
  timestamp: Date.now()
118
144
  });
119
- }, t);
145
+ }
146
+ this.enqueue({
147
+ type: "assistant",
148
+ message: text,
149
+ timestamp: Date.now()
150
+ });
120
151
  }
121
152
  get alive() {
122
153
  return this._alive;
@@ -217,10 +248,10 @@ class ClaudeCodeProcess {
217
248
  }
218
249
  if (events.length > 0 || textBlocks.length > 0) {
219
250
  for (const e of events) {
220
- this.emitter.emit("event", e);
251
+ this.enqueue(e);
221
252
  }
222
253
  for (const text of textBlocks) {
223
- this.emitTextAsDeltas(text);
254
+ this.enqueueTextAsDeltas(text);
224
255
  }
225
256
  }
226
257
  return null;
@@ -288,7 +319,9 @@ class ClaudeCodeProcess {
288
319
  return null;
289
320
  }
290
321
  }
291
- }
322
+ };
323
+ _ClaudeCodeProcess.DRAIN_INTERVAL_MS = 15;
324
+ let ClaudeCodeProcess = _ClaudeCodeProcess;
292
325
  class ClaudeCodeProvider {
293
326
  constructor() {
294
327
  this.name = "claude-code";
package/dist/db/schema.js CHANGED
@@ -5,6 +5,14 @@ const DB_PATH = process.env.SNA_DB_PATH ?? path.join(process.cwd(), "data/sna.db
5
5
  const NATIVE_DIR = path.join(process.cwd(), ".sna/native");
6
6
  let _db = null;
7
7
  function loadBetterSqlite3() {
8
+ const modulesPath = process.env.SNA_MODULES_PATH;
9
+ if (modulesPath) {
10
+ const entry = path.join(modulesPath, "better-sqlite3");
11
+ if (fs.existsSync(entry)) {
12
+ const req2 = createRequire(path.join(modulesPath, "noop.js"));
13
+ return req2("better-sqlite3");
14
+ }
15
+ }
8
16
  const nativeEntry = path.join(NATIVE_DIR, "node_modules", "better-sqlite3");
9
17
  if (fs.existsSync(nativeEntry)) {
10
18
  const req2 = createRequire(path.join(NATIVE_DIR, "noop.js"));
@@ -57,32 +57,6 @@ Ensure "@sna-sdk/core" is listed in asarUnpack in your electron-builder config.`
57
57
  }
58
58
  return script;
59
59
  }
60
- function resolveNativeBinding(override) {
61
- if (override) {
62
- if (!import_fs.default.existsSync(override)) {
63
- console.warn(`[sna] SNA nativeBinding override not found: ${override}`);
64
- return void 0;
65
- }
66
- return override;
67
- }
68
- const BINDING_REL = import_path.default.join("better-sqlite3", "build", "Release", "better_sqlite3.node");
69
- const resourcesPath = process.resourcesPath;
70
- if (resourcesPath) {
71
- const unpackedBase = import_path.default.join(resourcesPath, "app.asar.unpacked", "node_modules");
72
- const candidates = [
73
- import_path.default.join(unpackedBase, BINDING_REL),
74
- // nested under @sna-sdk/core if hoisting differs
75
- import_path.default.join(unpackedBase, "@sna-sdk", "core", "node_modules", BINDING_REL)
76
- ];
77
- for (const c of candidates) {
78
- if (import_fs.default.existsSync(c)) return c;
79
- }
80
- }
81
- const selfPath = (0, import_url.fileURLToPath)(importMetaUrl);
82
- const local = import_path.default.resolve(import_path.default.dirname(selfPath), "../../node_modules", BINDING_REL);
83
- if (import_fs.default.existsSync(local)) return local;
84
- return void 0;
85
- }
86
60
  function buildNodePath() {
87
61
  const resourcesPath = process.resourcesPath;
88
62
  if (!resourcesPath) return void 0;
@@ -97,8 +71,13 @@ async function startSnaServer(options) {
97
71
  const readyTimeout = options.readyTimeout ?? 15e3;
98
72
  const { onLog } = options;
99
73
  const standaloneScript = resolveStandaloneScript();
100
- const nativeBinding = resolveNativeBinding(options.nativeBinding);
101
74
  const nodePath = buildNodePath();
75
+ let consumerModules;
76
+ try {
77
+ const bsPkg = require.resolve("better-sqlite3/package.json", { paths: [process.cwd()] });
78
+ consumerModules = import_path.default.resolve(bsPkg, "../..");
79
+ } catch {
80
+ }
102
81
  const env = {
103
82
  ...process.env,
104
83
  SNA_PORT: String(port),
@@ -106,7 +85,8 @@ async function startSnaServer(options) {
106
85
  ...options.maxSessions != null ? { SNA_MAX_SESSIONS: String(options.maxSessions) } : {},
107
86
  ...options.permissionMode ? { SNA_PERMISSION_MODE: options.permissionMode } : {},
108
87
  ...options.model ? { SNA_MODEL: options.model } : {},
109
- ...nativeBinding ? { SNA_SQLITE_NATIVE_BINDING: nativeBinding } : {},
88
+ ...options.nativeBinding ? { SNA_SQLITE_NATIVE_BINDING: options.nativeBinding } : {},
89
+ ...consumerModules ? { SNA_MODULES_PATH: consumerModules } : {},
110
90
  ...nodePath ? { NODE_PATH: nodePath } : {},
111
91
  // Consumer overrides last so they can always win
112
92
  ...options.env ?? {}
@@ -29,10 +29,10 @@ import { ChildProcess } from 'child_process';
29
29
  *
30
30
  * asarUnpack: ["node_modules/@sna-sdk/core/**"]
31
31
  *
32
- * The better-sqlite3 native binding used by the forked process must be
33
- * compiled for the system Node.js (not Electron). If your app uses
34
- * electron-rebuild, set options.nativeBinding to a Node.js-compiled
35
- * .node file, or let SNA manage its own native install via `sna api:up`.
32
+ * The forked server process runs on Electron's Node.js. The launcher
33
+ * automatically detects the consumer app's electron-rebuilt native modules
34
+ * and passes their path to the server process, so better-sqlite3 just works
35
+ * without any manual configuration.
36
36
  */
37
37
 
38
38
  interface SnaServerOptions {
@@ -16,32 +16,6 @@ Ensure "@sna-sdk/core" is listed in asarUnpack in your electron-builder config.`
16
16
  }
17
17
  return script;
18
18
  }
19
- function resolveNativeBinding(override) {
20
- if (override) {
21
- if (!fs.existsSync(override)) {
22
- console.warn(`[sna] SNA nativeBinding override not found: ${override}`);
23
- return void 0;
24
- }
25
- return override;
26
- }
27
- const BINDING_REL = path.join("better-sqlite3", "build", "Release", "better_sqlite3.node");
28
- const resourcesPath = process.resourcesPath;
29
- if (resourcesPath) {
30
- const unpackedBase = path.join(resourcesPath, "app.asar.unpacked", "node_modules");
31
- const candidates = [
32
- path.join(unpackedBase, BINDING_REL),
33
- // nested under @sna-sdk/core if hoisting differs
34
- path.join(unpackedBase, "@sna-sdk", "core", "node_modules", BINDING_REL)
35
- ];
36
- for (const c of candidates) {
37
- if (fs.existsSync(c)) return c;
38
- }
39
- }
40
- const selfPath = fileURLToPath(import.meta.url);
41
- const local = path.resolve(path.dirname(selfPath), "../../node_modules", BINDING_REL);
42
- if (fs.existsSync(local)) return local;
43
- return void 0;
44
- }
45
19
  function buildNodePath() {
46
20
  const resourcesPath = process.resourcesPath;
47
21
  if (!resourcesPath) return void 0;
@@ -56,8 +30,13 @@ async function startSnaServer(options) {
56
30
  const readyTimeout = options.readyTimeout ?? 15e3;
57
31
  const { onLog } = options;
58
32
  const standaloneScript = resolveStandaloneScript();
59
- const nativeBinding = resolveNativeBinding(options.nativeBinding);
60
33
  const nodePath = buildNodePath();
34
+ let consumerModules;
35
+ try {
36
+ const bsPkg = require.resolve("better-sqlite3/package.json", { paths: [process.cwd()] });
37
+ consumerModules = path.resolve(bsPkg, "../..");
38
+ } catch {
39
+ }
61
40
  const env = {
62
41
  ...process.env,
63
42
  SNA_PORT: String(port),
@@ -65,7 +44,8 @@ async function startSnaServer(options) {
65
44
  ...options.maxSessions != null ? { SNA_MAX_SESSIONS: String(options.maxSessions) } : {},
66
45
  ...options.permissionMode ? { SNA_PERMISSION_MODE: options.permissionMode } : {},
67
46
  ...options.model ? { SNA_MODEL: options.model } : {},
68
- ...nativeBinding ? { SNA_SQLITE_NATIVE_BINDING: nativeBinding } : {},
47
+ ...options.nativeBinding ? { SNA_SQLITE_NATIVE_BINDING: options.nativeBinding } : {},
48
+ ...consumerModules ? { SNA_MODULES_PATH: consumerModules } : {},
69
49
  ...nodePath ? { NODE_PATH: nodePath } : {},
70
50
  // Consumer overrides last so they can always win
71
51
  ...options.env ?? {}
@@ -57,32 +57,6 @@ Ensure "@sna-sdk/core" is listed in asarUnpack in your electron-builder config.`
57
57
  }
58
58
  return script;
59
59
  }
60
- function resolveNativeBinding(override) {
61
- if (override) {
62
- if (!import_fs.default.existsSync(override)) {
63
- console.warn(`[sna] SNA nativeBinding override not found: ${override}`);
64
- return void 0;
65
- }
66
- return override;
67
- }
68
- const BINDING_REL = import_path.default.join("better-sqlite3", "build", "Release", "better_sqlite3.node");
69
- const resourcesPath = process.resourcesPath;
70
- if (resourcesPath) {
71
- const unpackedBase = import_path.default.join(resourcesPath, "app.asar.unpacked", "node_modules");
72
- const candidates = [
73
- import_path.default.join(unpackedBase, BINDING_REL),
74
- // nested under @sna-sdk/core if hoisting differs
75
- import_path.default.join(unpackedBase, "@sna-sdk", "core", "node_modules", BINDING_REL)
76
- ];
77
- for (const c of candidates) {
78
- if (import_fs.default.existsSync(c)) return c;
79
- }
80
- }
81
- const selfPath = (0, import_url.fileURLToPath)(importMetaUrl);
82
- const local = import_path.default.resolve(import_path.default.dirname(selfPath), "../../node_modules", BINDING_REL);
83
- if (import_fs.default.existsSync(local)) return local;
84
- return void 0;
85
- }
86
60
  function buildNodePath() {
87
61
  const resourcesPath = process.resourcesPath;
88
62
  if (!resourcesPath) return void 0;
@@ -97,8 +71,13 @@ async function startSnaServer(options) {
97
71
  const readyTimeout = options.readyTimeout ?? 15e3;
98
72
  const { onLog } = options;
99
73
  const standaloneScript = resolveStandaloneScript();
100
- const nativeBinding = resolveNativeBinding(options.nativeBinding);
101
74
  const nodePath = buildNodePath();
75
+ let consumerModules;
76
+ try {
77
+ const bsPkg = require.resolve("better-sqlite3/package.json", { paths: [process.cwd()] });
78
+ consumerModules = import_path.default.resolve(bsPkg, "../..");
79
+ } catch {
80
+ }
102
81
  const env = {
103
82
  ...process.env,
104
83
  SNA_PORT: String(port),
@@ -106,7 +85,8 @@ async function startSnaServer(options) {
106
85
  ...options.maxSessions != null ? { SNA_MAX_SESSIONS: String(options.maxSessions) } : {},
107
86
  ...options.permissionMode ? { SNA_PERMISSION_MODE: options.permissionMode } : {},
108
87
  ...options.model ? { SNA_MODEL: options.model } : {},
109
- ...nativeBinding ? { SNA_SQLITE_NATIVE_BINDING: nativeBinding } : {},
88
+ ...options.nativeBinding ? { SNA_SQLITE_NATIVE_BINDING: options.nativeBinding } : {},
89
+ ...consumerModules ? { SNA_MODULES_PATH: consumerModules } : {},
110
90
  ...nodePath ? { NODE_PATH: nodePath } : {},
111
91
  // Consumer overrides last so they can always win
112
92
  ...options.env ?? {}
@@ -379,6 +379,14 @@ function isPortInUse(port) {
379
379
  }
380
380
  function resolveAndCacheClaudePath() {
381
381
  const SHELL = process.env.SHELL || "/bin/zsh";
382
+ try {
383
+ const resolved = execSync(`${SHELL} -l -c "which claude"`, { encoding: "utf8" }).trim();
384
+ if (resolved) {
385
+ fs.writeFileSync(CLAUDE_PATH_FILE, resolved);
386
+ return resolved;
387
+ }
388
+ } catch {
389
+ }
382
390
  const candidates = [
383
391
  "/opt/homebrew/bin/claude",
384
392
  "/usr/local/bin/claude",
@@ -392,13 +400,7 @@ function resolveAndCacheClaudePath() {
392
400
  } catch {
393
401
  }
394
402
  }
395
- try {
396
- const resolved = execSync(`${SHELL} -l -c "which claude"`, { encoding: "utf8" }).trim();
397
- fs.writeFileSync(CLAUDE_PATH_FILE, resolved);
398
- return resolved;
399
- } catch {
400
- return "claude";
401
- }
403
+ return "claude";
402
404
  }
403
405
  function openBrowser(url) {
404
406
  try {
@@ -14,6 +14,10 @@ interface ApiResponses {
14
14
  "sessions.list": {
15
15
  sessions: SessionInfo[];
16
16
  };
17
+ "sessions.update": {
18
+ status: "updated";
19
+ session: string;
20
+ };
17
21
  "sessions.remove": {
18
22
  status: "removed";
19
23
  };
@@ -125,7 +125,6 @@ function createAgentRoutes(sessionManager) {
125
125
  if (session.process?.alive) {
126
126
  session.process.kill();
127
127
  }
128
- session.eventBuffer.length = 0;
129
128
  const provider = getProvider(body.provider ?? "claude-code");
130
129
  try {
131
130
  const db = getDb();
@@ -73,18 +73,25 @@ declare class SessionManager {
73
73
  private lifecycleListeners;
74
74
  private configChangedListeners;
75
75
  private stateChangedListeners;
76
+ private metadataChangedListeners;
76
77
  constructor(options?: SessionManagerOptions);
77
78
  /** Restore session metadata from DB (cwd, label, meta). Process state is not restored. */
78
79
  private restoreFromDb;
79
80
  /** Persist session metadata to DB. */
80
81
  private persistSession;
81
- /** Create a new session. Throws if max sessions reached. */
82
+ /** Create a new session. Throws if session already exists or max sessions reached. */
82
83
  createSession(opts?: {
83
84
  id?: string;
84
85
  label?: string;
85
86
  cwd?: string;
86
87
  meta?: Record<string, unknown> | null;
87
88
  }): Session;
89
+ /** Update an existing session's metadata. Throws if session not found. */
90
+ updateSession(id: string, opts: {
91
+ label?: string;
92
+ meta?: Record<string, unknown> | null;
93
+ cwd?: string;
94
+ }): Session;
88
95
  /** Get a session by ID. */
89
96
  getSession(id: string): Session | undefined;
90
97
  /** Get or create a session (used for "default" backward compat). */
@@ -110,6 +117,8 @@ declare class SessionManager {
110
117
  /** Subscribe to session config changes. Returns unsubscribe function. */
111
118
  onConfigChanged(cb: (event: SessionConfigChangedEvent) => void): () => void;
112
119
  private emitConfigChanged;
120
+ onMetadataChanged(cb: (sessionId: string) => void): () => void;
121
+ private emitMetadataChanged;
113
122
  onStateChanged(cb: (event: {
114
123
  session: string;
115
124
  agentStatus: AgentStatus;
@@ -12,6 +12,7 @@ class SessionManager {
12
12
  this.lifecycleListeners = /* @__PURE__ */ new Set();
13
13
  this.configChangedListeners = /* @__PURE__ */ new Set();
14
14
  this.stateChangedListeners = /* @__PURE__ */ new Set();
15
+ this.metadataChangedListeners = /* @__PURE__ */ new Set();
15
16
  this.maxSessions = options.maxSessions ?? DEFAULT_MAX_SESSIONS;
16
17
  this.restoreFromDb();
17
18
  }
@@ -64,26 +65,11 @@ class SessionManager {
64
65
  } catch {
65
66
  }
66
67
  }
67
- /** Create a new session. Throws if max sessions reached. */
68
+ /** Create a new session. Throws if session already exists or max sessions reached. */
68
69
  createSession(opts = {}) {
69
70
  const id = opts.id ?? crypto.randomUUID().slice(0, 8);
70
71
  if (this.sessions.has(id)) {
71
- const existing = this.sessions.get(id);
72
- let changed = false;
73
- if (opts.cwd && opts.cwd !== existing.cwd) {
74
- existing.cwd = opts.cwd;
75
- changed = true;
76
- }
77
- if (opts.label && opts.label !== existing.label) {
78
- existing.label = opts.label;
79
- changed = true;
80
- }
81
- if (opts.meta !== void 0 && opts.meta !== existing.meta) {
82
- existing.meta = opts.meta ?? null;
83
- changed = true;
84
- }
85
- if (changed) this.persistSession(existing);
86
- return existing;
72
+ throw new Error(`Session "${id}" already exists`);
87
73
  }
88
74
  const aliveCount = Array.from(this.sessions.values()).filter((s) => s.process?.alive).length;
89
75
  if (aliveCount >= this.maxSessions) {
@@ -107,6 +93,17 @@ class SessionManager {
107
93
  this.persistSession(session);
108
94
  return session;
109
95
  }
96
+ /** Update an existing session's metadata. Throws if session not found. */
97
+ updateSession(id, opts) {
98
+ const session = this.sessions.get(id);
99
+ if (!session) throw new Error(`Session "${id}" not found`);
100
+ if (opts.label !== void 0) session.label = opts.label;
101
+ if (opts.meta !== void 0) session.meta = opts.meta;
102
+ if (opts.cwd !== void 0) session.cwd = opts.cwd;
103
+ this.persistSession(session);
104
+ this.emitMetadataChanged(id);
105
+ return session;
106
+ }
110
107
  /** Get a session by ID. */
111
108
  getSession(id) {
112
109
  return this.sessions.get(id);
@@ -128,12 +125,23 @@ class SessionManager {
128
125
  const session = this.sessions.get(sessionId);
129
126
  if (!session) throw new Error(`Session "${sessionId}" not found`);
130
127
  session.process = proc;
131
- this.setSessionState(sessionId, session, "processing");
132
128
  session.lastActivityAt = Date.now();
129
+ session.eventBuffer.length = 0;
130
+ try {
131
+ const db = getDb();
132
+ const row = db.prepare(
133
+ `SELECT COUNT(*) as c FROM chat_messages WHERE session_id = ?`
134
+ ).get(sessionId);
135
+ session.eventCounter = row.c;
136
+ } catch {
137
+ }
133
138
  proc.on("event", (e) => {
134
- if (e.type === "init" && e.data?.sessionId && !session.ccSessionId) {
135
- session.ccSessionId = e.data.sessionId;
136
- this.persistSession(session);
139
+ if (e.type === "init") {
140
+ if (e.data?.sessionId && !session.ccSessionId) {
141
+ session.ccSessionId = e.data.sessionId;
142
+ this.persistSession(session);
143
+ }
144
+ this.setSessionState(sessionId, session, "waiting");
137
145
  }
138
146
  if (e.type !== "assistant_delta") {
139
147
  session.eventBuffer.push(e);
@@ -142,7 +150,9 @@ class SessionManager {
142
150
  }
143
151
  }
144
152
  session.eventCounter++;
145
- if (e.type === "complete" || e.type === "error" || e.type === "interrupted") {
153
+ if (e.type === "thinking" || e.type === "tool_use" || e.type === "assistant_delta") {
154
+ this.setSessionState(sessionId, session, "processing");
155
+ } else if (e.type === "complete" || e.type === "error" || e.type === "interrupted") {
146
156
  this.setSessionState(sessionId, session, "waiting");
147
157
  }
148
158
  this.persistEvent(sessionId, e);
@@ -223,6 +233,14 @@ class SessionManager {
223
233
  emitConfigChanged(sessionId, config) {
224
234
  for (const cb of this.configChangedListeners) cb({ session: sessionId, config });
225
235
  }
236
+ // ── Session metadata change pub/sub ─────────────────────────────
237
+ onMetadataChanged(cb) {
238
+ this.metadataChangedListeners.add(cb);
239
+ return () => this.metadataChangedListeners.delete(cb);
240
+ }
241
+ emitMetadataChanged(sessionId) {
242
+ for (const cb of this.metadataChangedListeners) cb(sessionId);
243
+ }
226
244
  // ── Agent status change pub/sub ────────────────────────────────
227
245
  onStateChanged(cb) {
228
246
  this.stateChangedListeners.add(cb);
@@ -303,7 +321,6 @@ class SessionManager {
303
321
  extraArgs: overrides.extraArgs ?? base.extraArgs
304
322
  };
305
323
  if (session.process?.alive) session.process.kill();
306
- session.eventBuffer.length = 0;
307
324
  const proc = spawnFn(config);
308
325
  this.setProcess(id, proc);
309
326
  session.lastStartConfig = config;
@@ -18,6 +18,14 @@ var DB_PATH = process.env.SNA_DB_PATH ?? path.join(process.cwd(), "data/sna.db")
18
18
  var NATIVE_DIR = path.join(process.cwd(), ".sna/native");
19
19
  var _db = null;
20
20
  function loadBetterSqlite3() {
21
+ const modulesPath = process.env.SNA_MODULES_PATH;
22
+ if (modulesPath) {
23
+ const entry = path.join(modulesPath, "better-sqlite3");
24
+ if (fs.existsSync(entry)) {
25
+ const req2 = createRequire(path.join(modulesPath, "noop.js"));
26
+ return req2("better-sqlite3");
27
+ }
28
+ }
21
29
  const nativeEntry = path.join(NATIVE_DIR, "node_modules", "better-sqlite3");
22
30
  if (fs.existsSync(nativeEntry)) {
23
31
  const req2 = createRequire(path.join(NATIVE_DIR, "noop.js"));
@@ -402,13 +410,20 @@ function resolveClaudePath(cwd) {
402
410
  return "claude";
403
411
  }
404
412
  }
405
- var ClaudeCodeProcess = class {
413
+ var _ClaudeCodeProcess = class _ClaudeCodeProcess {
406
414
  constructor(proc, options) {
407
415
  this.emitter = new EventEmitter();
408
416
  this._alive = true;
409
417
  this._sessionId = null;
410
418
  this._initEmitted = false;
411
419
  this.buffer = "";
420
+ /**
421
+ * FIFO event queue — ALL events (deltas, assistant, complete, etc.) go through
422
+ * this queue. A fixed-interval timer drains one item at a time, guaranteeing
423
+ * strict ordering: deltas → assistant → complete, never out of order.
424
+ */
425
+ this.eventQueue = [];
426
+ this.drainTimer = null;
412
427
  this.proc = proc;
413
428
  proc.stdout.on("data", (chunk) => {
414
429
  this.buffer += chunk.toString();
@@ -423,7 +438,7 @@ var ClaudeCodeProcess = class {
423
438
  this._sessionId = msg.session_id;
424
439
  }
425
440
  const event = this.normalizeEvent(msg);
426
- if (event) this.emitter.emit("event", event);
441
+ if (event) this.enqueue(event);
427
442
  } catch {
428
443
  }
429
444
  }
@@ -436,10 +451,11 @@ var ClaudeCodeProcess = class {
436
451
  try {
437
452
  const msg = JSON.parse(this.buffer);
438
453
  const event = this.normalizeEvent(msg);
439
- if (event) this.emitter.emit("event", event);
454
+ if (event) this.enqueue(event);
440
455
  } catch {
441
456
  }
442
457
  }
458
+ this.flushQueue();
443
459
  this.emitter.emit("exit", code);
444
460
  logger.log("agent", `process exited (code=${code})`);
445
461
  });
@@ -455,35 +471,58 @@ var ClaudeCodeProcess = class {
455
471
  this.send(options.prompt);
456
472
  }
457
473
  }
474
+ // ~67 events/sec
458
475
  /**
459
- * Split completed assistant text into chunks and emit assistant_delta events
460
- * at a fixed rate (~270 chars/sec), followed by the final assistant event.
461
- *
462
- * CHUNK_SIZE chars every CHUNK_DELAY_MS → natural TPS feel regardless of length.
476
+ * Enqueue an event for ordered emission.
477
+ * Starts the drain timer if not already running.
463
478
  */
464
- emitTextAsDeltas(text) {
479
+ enqueue(event) {
480
+ this.eventQueue.push(event);
481
+ if (!this.drainTimer) {
482
+ this.drainTimer = setInterval(() => this.drainOne(), _ClaudeCodeProcess.DRAIN_INTERVAL_MS);
483
+ }
484
+ }
485
+ /** Emit one event from the front of the queue. Stop timer when empty. */
486
+ drainOne() {
487
+ const event = this.eventQueue.shift();
488
+ if (event) {
489
+ this.emitter.emit("event", event);
490
+ }
491
+ if (this.eventQueue.length === 0 && this.drainTimer) {
492
+ clearInterval(this.drainTimer);
493
+ this.drainTimer = null;
494
+ }
495
+ }
496
+ /** Flush all remaining queued events immediately (used on process exit). */
497
+ flushQueue() {
498
+ if (this.drainTimer) {
499
+ clearInterval(this.drainTimer);
500
+ this.drainTimer = null;
501
+ }
502
+ while (this.eventQueue.length > 0) {
503
+ this.emitter.emit("event", this.eventQueue.shift());
504
+ }
505
+ }
506
+ /**
507
+ * Split completed assistant text into delta chunks and enqueue them,
508
+ * followed by the final assistant event. All go through the FIFO queue
509
+ * so subsequent events (complete, etc.) are guaranteed to come after.
510
+ */
511
+ enqueueTextAsDeltas(text) {
465
512
  const CHUNK_SIZE = 4;
466
- const CHUNK_DELAY_MS = 15;
467
- let t = 0;
468
513
  for (let i = 0; i < text.length; i += CHUNK_SIZE) {
469
- const chunk = text.slice(i, i + CHUNK_SIZE);
470
- setTimeout(() => {
471
- this.emitter.emit("event", {
472
- type: "assistant_delta",
473
- delta: chunk,
474
- index: 0,
475
- timestamp: Date.now()
476
- });
477
- }, t);
478
- t += CHUNK_DELAY_MS;
479
- }
480
- setTimeout(() => {
481
- this.emitter.emit("event", {
482
- type: "assistant",
483
- message: text,
514
+ this.enqueue({
515
+ type: "assistant_delta",
516
+ delta: text.slice(i, i + CHUNK_SIZE),
517
+ index: 0,
484
518
  timestamp: Date.now()
485
519
  });
486
- }, t);
520
+ }
521
+ this.enqueue({
522
+ type: "assistant",
523
+ message: text,
524
+ timestamp: Date.now()
525
+ });
487
526
  }
488
527
  get alive() {
489
528
  return this._alive;
@@ -584,10 +623,10 @@ var ClaudeCodeProcess = class {
584
623
  }
585
624
  if (events.length > 0 || textBlocks.length > 0) {
586
625
  for (const e of events) {
587
- this.emitter.emit("event", e);
626
+ this.enqueue(e);
588
627
  }
589
628
  for (const text of textBlocks) {
590
- this.emitTextAsDeltas(text);
629
+ this.enqueueTextAsDeltas(text);
591
630
  }
592
631
  }
593
632
  return null;
@@ -656,6 +695,8 @@ var ClaudeCodeProcess = class {
656
695
  }
657
696
  }
658
697
  };
698
+ _ClaudeCodeProcess.DRAIN_INTERVAL_MS = 15;
699
+ var ClaudeCodeProcess = _ClaudeCodeProcess;
659
700
  var ClaudeCodeProvider = class {
660
701
  constructor() {
661
702
  this.name = "claude-code";
@@ -946,7 +987,6 @@ function createAgentRoutes(sessionManager2) {
946
987
  if (session.process?.alive) {
947
988
  session.process.kill();
948
989
  }
949
- session.eventBuffer.length = 0;
950
990
  const provider2 = getProvider(body.provider ?? "claude-code");
951
991
  try {
952
992
  const db = getDb();
@@ -1373,6 +1413,7 @@ var SessionManager = class {
1373
1413
  this.lifecycleListeners = /* @__PURE__ */ new Set();
1374
1414
  this.configChangedListeners = /* @__PURE__ */ new Set();
1375
1415
  this.stateChangedListeners = /* @__PURE__ */ new Set();
1416
+ this.metadataChangedListeners = /* @__PURE__ */ new Set();
1376
1417
  this.maxSessions = options.maxSessions ?? DEFAULT_MAX_SESSIONS;
1377
1418
  this.restoreFromDb();
1378
1419
  }
@@ -1425,26 +1466,11 @@ var SessionManager = class {
1425
1466
  } catch {
1426
1467
  }
1427
1468
  }
1428
- /** Create a new session. Throws if max sessions reached. */
1469
+ /** Create a new session. Throws if session already exists or max sessions reached. */
1429
1470
  createSession(opts = {}) {
1430
1471
  const id = opts.id ?? crypto.randomUUID().slice(0, 8);
1431
1472
  if (this.sessions.has(id)) {
1432
- const existing = this.sessions.get(id);
1433
- let changed = false;
1434
- if (opts.cwd && opts.cwd !== existing.cwd) {
1435
- existing.cwd = opts.cwd;
1436
- changed = true;
1437
- }
1438
- if (opts.label && opts.label !== existing.label) {
1439
- existing.label = opts.label;
1440
- changed = true;
1441
- }
1442
- if (opts.meta !== void 0 && opts.meta !== existing.meta) {
1443
- existing.meta = opts.meta ?? null;
1444
- changed = true;
1445
- }
1446
- if (changed) this.persistSession(existing);
1447
- return existing;
1473
+ throw new Error(`Session "${id}" already exists`);
1448
1474
  }
1449
1475
  const aliveCount = Array.from(this.sessions.values()).filter((s) => s.process?.alive).length;
1450
1476
  if (aliveCount >= this.maxSessions) {
@@ -1468,6 +1494,17 @@ var SessionManager = class {
1468
1494
  this.persistSession(session);
1469
1495
  return session;
1470
1496
  }
1497
+ /** Update an existing session's metadata. Throws if session not found. */
1498
+ updateSession(id, opts) {
1499
+ const session = this.sessions.get(id);
1500
+ if (!session) throw new Error(`Session "${id}" not found`);
1501
+ if (opts.label !== void 0) session.label = opts.label;
1502
+ if (opts.meta !== void 0) session.meta = opts.meta;
1503
+ if (opts.cwd !== void 0) session.cwd = opts.cwd;
1504
+ this.persistSession(session);
1505
+ this.emitMetadataChanged(id);
1506
+ return session;
1507
+ }
1471
1508
  /** Get a session by ID. */
1472
1509
  getSession(id) {
1473
1510
  return this.sessions.get(id);
@@ -1489,12 +1526,23 @@ var SessionManager = class {
1489
1526
  const session = this.sessions.get(sessionId);
1490
1527
  if (!session) throw new Error(`Session "${sessionId}" not found`);
1491
1528
  session.process = proc;
1492
- this.setSessionState(sessionId, session, "processing");
1493
1529
  session.lastActivityAt = Date.now();
1530
+ session.eventBuffer.length = 0;
1531
+ try {
1532
+ const db = getDb();
1533
+ const row = db.prepare(
1534
+ `SELECT COUNT(*) as c FROM chat_messages WHERE session_id = ?`
1535
+ ).get(sessionId);
1536
+ session.eventCounter = row.c;
1537
+ } catch {
1538
+ }
1494
1539
  proc.on("event", (e) => {
1495
- if (e.type === "init" && e.data?.sessionId && !session.ccSessionId) {
1496
- session.ccSessionId = e.data.sessionId;
1497
- this.persistSession(session);
1540
+ if (e.type === "init") {
1541
+ if (e.data?.sessionId && !session.ccSessionId) {
1542
+ session.ccSessionId = e.data.sessionId;
1543
+ this.persistSession(session);
1544
+ }
1545
+ this.setSessionState(sessionId, session, "waiting");
1498
1546
  }
1499
1547
  if (e.type !== "assistant_delta") {
1500
1548
  session.eventBuffer.push(e);
@@ -1503,7 +1551,9 @@ var SessionManager = class {
1503
1551
  }
1504
1552
  }
1505
1553
  session.eventCounter++;
1506
- if (e.type === "complete" || e.type === "error" || e.type === "interrupted") {
1554
+ if (e.type === "thinking" || e.type === "tool_use" || e.type === "assistant_delta") {
1555
+ this.setSessionState(sessionId, session, "processing");
1556
+ } else if (e.type === "complete" || e.type === "error" || e.type === "interrupted") {
1507
1557
  this.setSessionState(sessionId, session, "waiting");
1508
1558
  }
1509
1559
  this.persistEvent(sessionId, e);
@@ -1584,6 +1634,14 @@ var SessionManager = class {
1584
1634
  emitConfigChanged(sessionId, config) {
1585
1635
  for (const cb of this.configChangedListeners) cb({ session: sessionId, config });
1586
1636
  }
1637
+ // ── Session metadata change pub/sub ─────────────────────────────
1638
+ onMetadataChanged(cb) {
1639
+ this.metadataChangedListeners.add(cb);
1640
+ return () => this.metadataChangedListeners.delete(cb);
1641
+ }
1642
+ emitMetadataChanged(sessionId) {
1643
+ for (const cb of this.metadataChangedListeners) cb(sessionId);
1644
+ }
1587
1645
  // ── Agent status change pub/sub ────────────────────────────────
1588
1646
  onStateChanged(cb) {
1589
1647
  this.stateChangedListeners.add(cb);
@@ -1664,7 +1722,6 @@ var SessionManager = class {
1664
1722
  extraArgs: overrides.extraArgs ?? base.extraArgs
1665
1723
  };
1666
1724
  if (session.process?.alive) session.process.kill();
1667
- session.eventBuffer.length = 0;
1668
1725
  const proc = spawnFn(config);
1669
1726
  this.setProcess(id, proc);
1670
1727
  session.lastStartConfig = config;
@@ -1841,15 +1898,22 @@ function attachWebSocket(server2, sessionManager2) {
1841
1898
  });
1842
1899
  wss.on("connection", (ws) => {
1843
1900
  logger.log("ws", "client connected");
1844
- const state = { agentUnsubs: /* @__PURE__ */ new Map(), skillEventUnsub: null, skillPollTimer: null, permissionUnsub: null, lifecycleUnsub: null, configChangedUnsub: null, stateChangedUnsub: null };
1901
+ const state = { agentUnsubs: /* @__PURE__ */ new Map(), skillEventUnsub: null, skillPollTimer: null, permissionUnsub: null, lifecycleUnsub: null, configChangedUnsub: null, stateChangedUnsub: null, metadataChangedUnsub: null };
1902
+ const pushSnapshot = () => send(ws, { type: "sessions.snapshot", sessions: sessionManager2.listSessions() });
1903
+ pushSnapshot();
1845
1904
  state.lifecycleUnsub = sessionManager2.onSessionLifecycle((event) => {
1846
1905
  send(ws, { type: "session.lifecycle", ...event });
1906
+ pushSnapshot();
1847
1907
  });
1848
1908
  state.configChangedUnsub = sessionManager2.onConfigChanged((event) => {
1849
1909
  send(ws, { type: "session.config-changed", ...event });
1850
1910
  });
1851
1911
  state.stateChangedUnsub = sessionManager2.onStateChanged((event) => {
1852
1912
  send(ws, { type: "session.state-changed", ...event });
1913
+ pushSnapshot();
1914
+ });
1915
+ state.metadataChangedUnsub = sessionManager2.onMetadataChanged(() => {
1916
+ pushSnapshot();
1853
1917
  });
1854
1918
  ws.on("message", (raw) => {
1855
1919
  let msg;
@@ -1883,6 +1947,8 @@ function attachWebSocket(server2, sessionManager2) {
1883
1947
  state.configChangedUnsub = null;
1884
1948
  state.stateChangedUnsub?.();
1885
1949
  state.stateChangedUnsub = null;
1950
+ state.metadataChangedUnsub?.();
1951
+ state.metadataChangedUnsub = null;
1886
1952
  });
1887
1953
  });
1888
1954
  return wss;
@@ -1894,6 +1960,8 @@ function handleMessage(ws, msg, sm, state) {
1894
1960
  return handleSessionsCreate(ws, msg, sm);
1895
1961
  case "sessions.list":
1896
1962
  return wsReply(ws, msg, { sessions: sm.listSessions() });
1963
+ case "sessions.update":
1964
+ return handleSessionsUpdate(ws, msg, sm);
1897
1965
  case "sessions.remove":
1898
1966
  return handleSessionsRemove(ws, msg, sm);
1899
1967
  // ── Agent lifecycle ───────────────────────────────
@@ -1969,6 +2037,20 @@ function handleSessionsCreate(ws, msg, sm) {
1969
2037
  replyError(ws, msg, e.message);
1970
2038
  }
1971
2039
  }
2040
+ function handleSessionsUpdate(ws, msg, sm) {
2041
+ const id = msg.session;
2042
+ if (!id) return replyError(ws, msg, "session is required");
2043
+ try {
2044
+ sm.updateSession(id, {
2045
+ label: msg.label,
2046
+ meta: msg.meta,
2047
+ cwd: msg.cwd
2048
+ });
2049
+ wsReply(ws, msg, { status: "updated", session: id });
2050
+ } catch (e) {
2051
+ replyError(ws, msg, e.message);
2052
+ }
2053
+ }
1972
2054
  function handleSessionsRemove(ws, msg, sm) {
1973
2055
  const id = msg.session;
1974
2056
  if (!id) return replyError(ws, msg, "session is required");
@@ -1987,7 +2069,6 @@ function handleAgentStart(ws, msg, sm) {
1987
2069
  return;
1988
2070
  }
1989
2071
  if (session.process?.alive) session.process.kill();
1990
- session.eventBuffer.length = 0;
1991
2072
  const provider2 = getProvider(msg.provider ?? "claude-code");
1992
2073
  try {
1993
2074
  const db = getDb();
@@ -2223,18 +2304,26 @@ function handleAgentSubscribe(ws, msg, sm, state) {
2223
2304
  }
2224
2305
  } catch {
2225
2306
  }
2226
- }
2227
- const bufferStart = typeof msg.since === "number" && msg.since > 0 ? msg.since : session.eventCounter;
2228
- if (!includeHistory) cursor = bufferStart;
2229
- if (cursor < session.eventCounter) {
2230
- const startIdx = Math.max(0, session.eventBuffer.length - (session.eventCounter - cursor));
2231
- const events = session.eventBuffer.slice(startIdx);
2232
- for (const event of events) {
2233
- cursor++;
2234
- send(ws, { type: "agent.event", session: sessionId, cursor, event });
2307
+ if (cursor < session.eventCounter) {
2308
+ const unpersisted = session.eventCounter - cursor;
2309
+ const bufferSlice = session.eventBuffer.slice(-unpersisted);
2310
+ for (const event of bufferSlice) {
2311
+ cursor++;
2312
+ send(ws, { type: "agent.event", session: sessionId, cursor, event });
2313
+ }
2235
2314
  }
2236
2315
  } else {
2237
- cursor = session.eventCounter;
2316
+ cursor = typeof msg.since === "number" && msg.since > 0 ? msg.since : session.eventCounter;
2317
+ if (cursor < session.eventCounter) {
2318
+ const startIdx = Math.max(0, session.eventBuffer.length - (session.eventCounter - cursor));
2319
+ const events = session.eventBuffer.slice(startIdx);
2320
+ for (const event of events) {
2321
+ cursor++;
2322
+ send(ws, { type: "agent.event", session: sessionId, cursor, event });
2323
+ }
2324
+ } else {
2325
+ cursor = session.eventCounter;
2326
+ }
2238
2327
  }
2239
2328
  const unsub = sm.onSessionEvent(sessionId, (eventCursor, event) => {
2240
2329
  send(ws, { type: "agent.event", session: sessionId, cursor: eventCursor, event });
@@ -2496,7 +2585,7 @@ root.use("*", async (c, next) => {
2496
2585
  await next();
2497
2586
  });
2498
2587
  var sessionManager = new SessionManager({ maxSessions });
2499
- sessionManager.createSession({ id: "default", cwd: process.cwd() });
2588
+ sessionManager.getOrCreateSession("default", { cwd: process.cwd() });
2500
2589
  var provider = getProvider("claude-code");
2501
2590
  logger.log("sna", "spawning agent...");
2502
2591
  var agentProcess = provider.spawn({ cwd: process.cwd(), permissionMode, model: defaultModel });
@@ -13,6 +13,7 @@ import '../core/providers/types.js';
13
13
  * Server → Client: { type: "sessions.list", rid: "1", sessions: [...] }
14
14
  * Server → Client: { type: "error", rid: "1", message: "..." }
15
15
  * Server → Client: { type: "agent.event", session: "abc", cursor: 42, event: {...} } (push)
16
+ * Server → Client: { type: "sessions.snapshot", sessions: [...] } (auto-push on connect + state change)
16
17
  * Server → Client: { type: "session.lifecycle", session: "abc", state: "killed" } (auto-push)
17
18
  * Server → Client: { type: "skill.event", data: {...} } (push)
18
19
  *
package/dist/server/ws.js CHANGED
@@ -31,15 +31,22 @@ function attachWebSocket(server, sessionManager) {
31
31
  });
32
32
  wss.on("connection", (ws) => {
33
33
  logger.log("ws", "client connected");
34
- const state = { agentUnsubs: /* @__PURE__ */ new Map(), skillEventUnsub: null, skillPollTimer: null, permissionUnsub: null, lifecycleUnsub: null, configChangedUnsub: null, stateChangedUnsub: null };
34
+ const state = { agentUnsubs: /* @__PURE__ */ new Map(), skillEventUnsub: null, skillPollTimer: null, permissionUnsub: null, lifecycleUnsub: null, configChangedUnsub: null, stateChangedUnsub: null, metadataChangedUnsub: null };
35
+ const pushSnapshot = () => send(ws, { type: "sessions.snapshot", sessions: sessionManager.listSessions() });
36
+ pushSnapshot();
35
37
  state.lifecycleUnsub = sessionManager.onSessionLifecycle((event) => {
36
38
  send(ws, { type: "session.lifecycle", ...event });
39
+ pushSnapshot();
37
40
  });
38
41
  state.configChangedUnsub = sessionManager.onConfigChanged((event) => {
39
42
  send(ws, { type: "session.config-changed", ...event });
40
43
  });
41
44
  state.stateChangedUnsub = sessionManager.onStateChanged((event) => {
42
45
  send(ws, { type: "session.state-changed", ...event });
46
+ pushSnapshot();
47
+ });
48
+ state.metadataChangedUnsub = sessionManager.onMetadataChanged(() => {
49
+ pushSnapshot();
43
50
  });
44
51
  ws.on("message", (raw) => {
45
52
  let msg;
@@ -73,6 +80,8 @@ function attachWebSocket(server, sessionManager) {
73
80
  state.configChangedUnsub = null;
74
81
  state.stateChangedUnsub?.();
75
82
  state.stateChangedUnsub = null;
83
+ state.metadataChangedUnsub?.();
84
+ state.metadataChangedUnsub = null;
76
85
  });
77
86
  });
78
87
  return wss;
@@ -84,6 +93,8 @@ function handleMessage(ws, msg, sm, state) {
84
93
  return handleSessionsCreate(ws, msg, sm);
85
94
  case "sessions.list":
86
95
  return wsReply(ws, msg, { sessions: sm.listSessions() });
96
+ case "sessions.update":
97
+ return handleSessionsUpdate(ws, msg, sm);
87
98
  case "sessions.remove":
88
99
  return handleSessionsRemove(ws, msg, sm);
89
100
  // ── Agent lifecycle ───────────────────────────────
@@ -159,6 +170,20 @@ function handleSessionsCreate(ws, msg, sm) {
159
170
  replyError(ws, msg, e.message);
160
171
  }
161
172
  }
173
+ function handleSessionsUpdate(ws, msg, sm) {
174
+ const id = msg.session;
175
+ if (!id) return replyError(ws, msg, "session is required");
176
+ try {
177
+ sm.updateSession(id, {
178
+ label: msg.label,
179
+ meta: msg.meta,
180
+ cwd: msg.cwd
181
+ });
182
+ wsReply(ws, msg, { status: "updated", session: id });
183
+ } catch (e) {
184
+ replyError(ws, msg, e.message);
185
+ }
186
+ }
162
187
  function handleSessionsRemove(ws, msg, sm) {
163
188
  const id = msg.session;
164
189
  if (!id) return replyError(ws, msg, "session is required");
@@ -177,7 +202,6 @@ function handleAgentStart(ws, msg, sm) {
177
202
  return;
178
203
  }
179
204
  if (session.process?.alive) session.process.kill();
180
- session.eventBuffer.length = 0;
181
205
  const provider = getProvider(msg.provider ?? "claude-code");
182
206
  try {
183
207
  const db = getDb();
@@ -413,18 +437,26 @@ function handleAgentSubscribe(ws, msg, sm, state) {
413
437
  }
414
438
  } catch {
415
439
  }
416
- }
417
- const bufferStart = typeof msg.since === "number" && msg.since > 0 ? msg.since : session.eventCounter;
418
- if (!includeHistory) cursor = bufferStart;
419
- if (cursor < session.eventCounter) {
420
- const startIdx = Math.max(0, session.eventBuffer.length - (session.eventCounter - cursor));
421
- const events = session.eventBuffer.slice(startIdx);
422
- for (const event of events) {
423
- cursor++;
424
- send(ws, { type: "agent.event", session: sessionId, cursor, event });
440
+ if (cursor < session.eventCounter) {
441
+ const unpersisted = session.eventCounter - cursor;
442
+ const bufferSlice = session.eventBuffer.slice(-unpersisted);
443
+ for (const event of bufferSlice) {
444
+ cursor++;
445
+ send(ws, { type: "agent.event", session: sessionId, cursor, event });
446
+ }
425
447
  }
426
448
  } else {
427
- cursor = session.eventCounter;
449
+ cursor = typeof msg.since === "number" && msg.since > 0 ? msg.since : session.eventCounter;
450
+ if (cursor < session.eventCounter) {
451
+ const startIdx = Math.max(0, session.eventBuffer.length - (session.eventCounter - cursor));
452
+ const events = session.eventBuffer.slice(startIdx);
453
+ for (const event of events) {
454
+ cursor++;
455
+ send(ws, { type: "agent.event", session: sessionId, cursor, event });
456
+ }
457
+ } else {
458
+ cursor = session.eventCounter;
459
+ }
428
460
  }
429
461
  const unsub = sm.onSessionEvent(sessionId, (eventCursor, event) => {
430
462
  send(ws, { type: "agent.event", session: sessionId, cursor: eventCursor, event });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sna-sdk/core",
3
- "version": "0.6.1",
3
+ "version": "0.7.1",
4
4
  "description": "Skills-Native Application runtime — server, providers, session management, database, and CLI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -102,12 +102,14 @@
102
102
  ],
103
103
  "dependencies": {
104
104
  "@hono/node-server": "^1.19.11",
105
- "better-sqlite3": "^12.6.2",
106
105
  "chalk": "^5.0.0",
107
106
  "hono": "^4.12.7",
108
107
  "js-yaml": "^4.1.0",
109
108
  "ws": "^8.20.0"
110
109
  },
110
+ "peerDependencies": {
111
+ "better-sqlite3": ">=11.0.0"
112
+ },
111
113
  "devDependencies": {
112
114
  "@types/better-sqlite3": "^7.6.13",
113
115
  "@types/js-yaml": "^4.0.9",