@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 +6 -1
- package/dist/core/providers/claude-code.js +62 -29
- package/dist/db/schema.js +8 -0
- package/dist/electron/index.cjs +8 -28
- package/dist/electron/index.d.ts +4 -4
- package/dist/electron/index.js +8 -28
- package/dist/node/index.cjs +8 -28
- package/dist/scripts/sna.js +9 -7
- package/dist/server/api-types.d.ts +4 -0
- package/dist/server/routes/agent.js +0 -1
- package/dist/server/session-manager.d.ts +10 -1
- package/dist/server/session-manager.js +40 -23
- package/dist/server/standalone.js +154 -65
- package/dist/server/ws.d.ts +1 -0
- package/dist/server/ws.js +44 -12
- package/package.json +4 -2
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` | `
|
|
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
|
|
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.
|
|
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.
|
|
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
|
-
*
|
|
93
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
-
}
|
|
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.
|
|
251
|
+
this.enqueue(e);
|
|
221
252
|
}
|
|
222
253
|
for (const text of textBlocks) {
|
|
223
|
-
this.
|
|
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"));
|
package/dist/electron/index.cjs
CHANGED
|
@@ -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 ?? {}
|
package/dist/electron/index.d.ts
CHANGED
|
@@ -29,10 +29,10 @@ import { ChildProcess } from 'child_process';
|
|
|
29
29
|
*
|
|
30
30
|
* asarUnpack: ["node_modules/@sna-sdk/core/**"]
|
|
31
31
|
*
|
|
32
|
-
* The
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
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 {
|
package/dist/electron/index.js
CHANGED
|
@@ -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 ?? {}
|
package/dist/node/index.cjs
CHANGED
|
@@ -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 ?? {}
|
package/dist/scripts/sna.js
CHANGED
|
@@ -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
|
-
|
|
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 {
|
|
@@ -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
|
-
|
|
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"
|
|
135
|
-
|
|
136
|
-
|
|
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 === "
|
|
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
|
|
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.
|
|
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.
|
|
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
|
-
*
|
|
460
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
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
|
-
}
|
|
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.
|
|
626
|
+
this.enqueue(e);
|
|
588
627
|
}
|
|
589
628
|
for (const text of textBlocks) {
|
|
590
|
-
this.
|
|
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
|
-
|
|
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"
|
|
1496
|
-
|
|
1497
|
-
|
|
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 === "
|
|
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
|
-
|
|
2228
|
-
|
|
2229
|
-
|
|
2230
|
-
|
|
2231
|
-
|
|
2232
|
-
|
|
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.
|
|
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 });
|
package/dist/server/ws.d.ts
CHANGED
|
@@ -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
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
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.
|
|
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",
|