@sna-sdk/core 0.4.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -0
- package/dist/core/providers/claude-code.js +38 -5
- package/dist/core/providers/types.d.ts +5 -1
- package/dist/server/api-types.d.ts +6 -0
- package/dist/server/routes/agent.js +60 -15
- package/dist/server/session-manager.d.ts +9 -0
- package/dist/server/session-manager.js +38 -4
- package/dist/server/standalone.js +193 -26
- package/dist/server/ws.js +57 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -9,6 +9,7 @@ Server runtime for [Skills-Native Applications](https://github.com/neuradex/sna)
|
|
|
9
9
|
- **SQLite database** — schema and `getDb()` for `skill_events`, `chat_sessions`, `chat_messages`
|
|
10
10
|
- **Hono server factory** — `createSnaApp()` with events, emit, agent, chat, and run routes
|
|
11
11
|
- **WebSocket API** — `attachWebSocket()` wrapping all HTTP routes over a single WS connection
|
|
12
|
+
- **History management** — `agent.resume` auto-loads DB history, `agent.subscribe({ since: 0 })` unified history+realtime channel
|
|
12
13
|
- **One-shot execution** — `POST /agent/run-once` for single-request LLM calls
|
|
13
14
|
- **CLI** — `sna up/down/status`, `sna dispatch`, `sna gen client`, `sna tu` (mock API testing)
|
|
14
15
|
- **Agent providers** — Claude Code and Codex process management
|
|
@@ -88,6 +88,36 @@ class ClaudeCodeProcess {
|
|
|
88
88
|
this.send(options.prompt);
|
|
89
89
|
}
|
|
90
90
|
}
|
|
91
|
+
/**
|
|
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.
|
|
96
|
+
*/
|
|
97
|
+
emitTextAsDeltas(text) {
|
|
98
|
+
const CHUNK_SIZE = 4;
|
|
99
|
+
const CHUNK_DELAY_MS = 15;
|
|
100
|
+
let t = 0;
|
|
101
|
+
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,
|
|
117
|
+
timestamp: Date.now()
|
|
118
|
+
});
|
|
119
|
+
}, t);
|
|
120
|
+
}
|
|
91
121
|
get alive() {
|
|
92
122
|
return this._alive;
|
|
93
123
|
}
|
|
@@ -163,6 +193,7 @@ class ClaudeCodeProcess {
|
|
|
163
193
|
const content = msg.message?.content;
|
|
164
194
|
if (!Array.isArray(content)) return null;
|
|
165
195
|
const events = [];
|
|
196
|
+
const textBlocks = [];
|
|
166
197
|
for (const block of content) {
|
|
167
198
|
if (block.type === "thinking") {
|
|
168
199
|
events.push({
|
|
@@ -180,15 +211,17 @@ class ClaudeCodeProcess {
|
|
|
180
211
|
} else if (block.type === "text") {
|
|
181
212
|
const text = (block.text ?? "").trim();
|
|
182
213
|
if (text) {
|
|
183
|
-
|
|
214
|
+
textBlocks.push(text);
|
|
184
215
|
}
|
|
185
216
|
}
|
|
186
217
|
}
|
|
187
|
-
if (events.length > 0) {
|
|
188
|
-
for (
|
|
189
|
-
this.emitter.emit("event",
|
|
218
|
+
if (events.length > 0 || textBlocks.length > 0) {
|
|
219
|
+
for (const e of events) {
|
|
220
|
+
this.emitter.emit("event", e);
|
|
221
|
+
}
|
|
222
|
+
for (const text of textBlocks) {
|
|
223
|
+
this.emitTextAsDeltas(text);
|
|
190
224
|
}
|
|
191
|
-
return events[0];
|
|
192
225
|
}
|
|
193
226
|
return null;
|
|
194
227
|
}
|
|
@@ -5,9 +5,13 @@
|
|
|
5
5
|
* Codex JSONL, etc.) into these common types.
|
|
6
6
|
*/
|
|
7
7
|
interface AgentEvent {
|
|
8
|
-
type: "init" | "thinking" | "text_delta" | "assistant" | "tool_use" | "tool_result" | "permission_needed" | "milestone" | "interrupted" | "error" | "complete";
|
|
8
|
+
type: "init" | "thinking" | "text_delta" | "assistant_delta" | "assistant" | "tool_use" | "tool_result" | "permission_needed" | "milestone" | "user_message" | "interrupted" | "error" | "complete";
|
|
9
9
|
message?: string;
|
|
10
10
|
data?: Record<string, unknown>;
|
|
11
|
+
/** Streaming text delta (for assistant_delta events only) */
|
|
12
|
+
delta?: string;
|
|
13
|
+
/** Content block index (for assistant_delta events only) */
|
|
14
|
+
index?: number;
|
|
11
15
|
timestamp: number;
|
|
12
16
|
}
|
|
13
17
|
/**
|
|
@@ -56,6 +56,12 @@ interface ApiResponses {
|
|
|
56
56
|
sessionId: string | null;
|
|
57
57
|
ccSessionId: string | null;
|
|
58
58
|
eventCount: number;
|
|
59
|
+
messageCount: number;
|
|
60
|
+
lastMessage: {
|
|
61
|
+
role: string;
|
|
62
|
+
content: string;
|
|
63
|
+
created_at: string;
|
|
64
|
+
} | null;
|
|
59
65
|
config: {
|
|
60
66
|
provider: string;
|
|
61
67
|
model: string;
|
|
@@ -195,6 +195,12 @@ function createAgentRoutes(sessionManager) {
|
|
|
195
195
|
db.prepare(`INSERT INTO chat_messages (session_id, role, content, meta) VALUES (?, 'user', ?, ?)`).run(sessionId, textContent, Object.keys(meta).length > 0 ? JSON.stringify(meta) : null);
|
|
196
196
|
} catch {
|
|
197
197
|
}
|
|
198
|
+
sessionManager.pushEvent(sessionId, {
|
|
199
|
+
type: "user_message",
|
|
200
|
+
message: textContent,
|
|
201
|
+
data: Object.keys(meta).length > 0 ? meta : void 0,
|
|
202
|
+
timestamp: Date.now()
|
|
203
|
+
});
|
|
198
204
|
sessionManager.updateSessionState(sessionId, "processing");
|
|
199
205
|
sessionManager.touch(sessionId);
|
|
200
206
|
if (body.images?.length) {
|
|
@@ -217,32 +223,59 @@ function createAgentRoutes(sessionManager) {
|
|
|
217
223
|
const sessionId = getSessionId(c);
|
|
218
224
|
const session = sessionManager.getOrCreateSession(sessionId);
|
|
219
225
|
const sinceParam = c.req.query("since");
|
|
220
|
-
|
|
226
|
+
const sinceCursor = sinceParam ? parseInt(sinceParam, 10) : session.eventCounter;
|
|
221
227
|
return streamSSE(c, async (stream) => {
|
|
222
|
-
const POLL_MS = 300;
|
|
223
228
|
const KEEPALIVE_MS = 15e3;
|
|
224
|
-
|
|
225
|
-
|
|
229
|
+
const signal = c.req.raw.signal;
|
|
230
|
+
const queue = [];
|
|
231
|
+
let wakeUp = null;
|
|
232
|
+
const unsub = sessionManager.onSessionEvent(sessionId, (eventCursor, event) => {
|
|
233
|
+
queue.push({ cursor: eventCursor, event });
|
|
234
|
+
const fn = wakeUp;
|
|
235
|
+
wakeUp = null;
|
|
236
|
+
fn?.();
|
|
237
|
+
});
|
|
238
|
+
signal.addEventListener("abort", () => {
|
|
239
|
+
const fn = wakeUp;
|
|
240
|
+
wakeUp = null;
|
|
241
|
+
fn?.();
|
|
242
|
+
});
|
|
243
|
+
try {
|
|
244
|
+
let cursor = sinceCursor;
|
|
226
245
|
if (cursor < session.eventCounter) {
|
|
227
246
|
const startIdx = Math.max(
|
|
228
247
|
0,
|
|
229
248
|
session.eventBuffer.length - (session.eventCounter - cursor)
|
|
230
249
|
);
|
|
231
|
-
const
|
|
232
|
-
for (const event of newEvents) {
|
|
250
|
+
for (const event of session.eventBuffer.slice(startIdx)) {
|
|
233
251
|
cursor++;
|
|
234
|
-
await stream.writeSSE({
|
|
235
|
-
id: String(cursor),
|
|
236
|
-
data: JSON.stringify(event)
|
|
237
|
-
});
|
|
238
|
-
lastSend = Date.now();
|
|
252
|
+
await stream.writeSSE({ id: String(cursor), data: JSON.stringify(event) });
|
|
239
253
|
}
|
|
254
|
+
} else {
|
|
255
|
+
cursor = session.eventCounter;
|
|
240
256
|
}
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
257
|
+
while (queue.length > 0 && queue[0].cursor <= cursor) queue.shift();
|
|
258
|
+
while (!signal.aborted) {
|
|
259
|
+
if (queue.length === 0) {
|
|
260
|
+
await Promise.race([
|
|
261
|
+
new Promise((r) => {
|
|
262
|
+
wakeUp = r;
|
|
263
|
+
}),
|
|
264
|
+
new Promise((r) => setTimeout(r, KEEPALIVE_MS))
|
|
265
|
+
]);
|
|
266
|
+
}
|
|
267
|
+
if (signal.aborted) break;
|
|
268
|
+
if (queue.length > 0) {
|
|
269
|
+
while (queue.length > 0) {
|
|
270
|
+
const item = queue.shift();
|
|
271
|
+
await stream.writeSSE({ id: String(item.cursor), data: JSON.stringify(item.event) });
|
|
272
|
+
}
|
|
273
|
+
} else {
|
|
274
|
+
await stream.writeSSE({ data: "" });
|
|
275
|
+
}
|
|
244
276
|
}
|
|
245
|
-
|
|
277
|
+
} finally {
|
|
278
|
+
unsub();
|
|
246
279
|
}
|
|
247
280
|
});
|
|
248
281
|
});
|
|
@@ -341,12 +374,24 @@ function createAgentRoutes(sessionManager) {
|
|
|
341
374
|
const sessionId = getSessionId(c);
|
|
342
375
|
const session = sessionManager.getSession(sessionId);
|
|
343
376
|
const alive = session?.process?.alive ?? false;
|
|
377
|
+
let messageCount = 0;
|
|
378
|
+
let lastMessage = null;
|
|
379
|
+
try {
|
|
380
|
+
const db = getDb();
|
|
381
|
+
const count = db.prepare("SELECT COUNT(*) as c FROM chat_messages WHERE session_id = ?").get(sessionId);
|
|
382
|
+
messageCount = count?.c ?? 0;
|
|
383
|
+
const last = db.prepare("SELECT role, content, created_at FROM chat_messages WHERE session_id = ? ORDER BY id DESC LIMIT 1").get(sessionId);
|
|
384
|
+
if (last) lastMessage = { role: last.role, content: last.content, created_at: last.created_at };
|
|
385
|
+
} catch {
|
|
386
|
+
}
|
|
344
387
|
return httpJson(c, "agent.status", {
|
|
345
388
|
alive,
|
|
346
389
|
agentStatus: !alive ? "disconnected" : session?.state === "processing" ? "busy" : "idle",
|
|
347
390
|
sessionId: session?.process?.sessionId ?? null,
|
|
348
391
|
ccSessionId: session?.ccSessionId ?? null,
|
|
349
392
|
eventCount: session?.eventCounter ?? 0,
|
|
393
|
+
messageCount,
|
|
394
|
+
lastMessage,
|
|
350
395
|
config: session?.lastStartConfig ?? null
|
|
351
396
|
});
|
|
352
397
|
});
|
|
@@ -41,6 +41,12 @@ interface SessionInfo {
|
|
|
41
41
|
config: StartConfig | null;
|
|
42
42
|
ccSessionId: string | null;
|
|
43
43
|
eventCount: number;
|
|
44
|
+
messageCount: number;
|
|
45
|
+
lastMessage: {
|
|
46
|
+
role: string;
|
|
47
|
+
content: string;
|
|
48
|
+
created_at: string;
|
|
49
|
+
} | null;
|
|
44
50
|
createdAt: number;
|
|
45
51
|
lastActivityAt: number;
|
|
46
52
|
}
|
|
@@ -94,6 +100,8 @@ declare class SessionManager {
|
|
|
94
100
|
onSkillEvent(cb: (event: Record<string, unknown>) => void): () => void;
|
|
95
101
|
/** Broadcast a skill event to all subscribers (called after DB insert). */
|
|
96
102
|
broadcastSkillEvent(event: Record<string, unknown>): void;
|
|
103
|
+
/** Push a synthetic event into a session's event stream (for user message broadcast). */
|
|
104
|
+
pushEvent(sessionId: string, event: AgentEvent): void;
|
|
97
105
|
/** Subscribe to permission request notifications. Returns unsubscribe function. */
|
|
98
106
|
onPermissionRequest(cb: (sessionId: string, request: Record<string, unknown>, createdAt: number) => void): () => void;
|
|
99
107
|
/** Subscribe to session lifecycle events (started/killed/exited/crashed). Returns unsubscribe function. */
|
|
@@ -147,6 +155,7 @@ declare class SessionManager {
|
|
|
147
155
|
/** Touch a session's lastActivityAt timestamp. */
|
|
148
156
|
touch(id: string): void;
|
|
149
157
|
/** Persist an agent event to chat_messages. */
|
|
158
|
+
private getMessageStats;
|
|
150
159
|
private persistEvent;
|
|
151
160
|
/** Kill all sessions. Used during shutdown. */
|
|
152
161
|
killAll(): void;
|
|
@@ -135,11 +135,13 @@ class SessionManager {
|
|
|
135
135
|
session.ccSessionId = e.data.sessionId;
|
|
136
136
|
this.persistSession(session);
|
|
137
137
|
}
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
138
|
+
if (e.type !== "assistant_delta") {
|
|
139
|
+
session.eventBuffer.push(e);
|
|
140
|
+
if (session.eventBuffer.length > MAX_EVENT_BUFFER) {
|
|
141
|
+
session.eventBuffer.splice(0, session.eventBuffer.length - MAX_EVENT_BUFFER);
|
|
142
|
+
}
|
|
142
143
|
}
|
|
144
|
+
session.eventCounter++;
|
|
143
145
|
if (e.type === "complete" || e.type === "error" || e.type === "interrupted") {
|
|
144
146
|
this.setSessionState(sessionId, session, "waiting");
|
|
145
147
|
}
|
|
@@ -183,6 +185,20 @@ class SessionManager {
|
|
|
183
185
|
broadcastSkillEvent(event) {
|
|
184
186
|
for (const cb of this.skillEventListeners) cb(event);
|
|
185
187
|
}
|
|
188
|
+
/** Push a synthetic event into a session's event stream (for user message broadcast). */
|
|
189
|
+
pushEvent(sessionId, event) {
|
|
190
|
+
const session = this.sessions.get(sessionId);
|
|
191
|
+
if (!session) return;
|
|
192
|
+
session.eventBuffer.push(event);
|
|
193
|
+
session.eventCounter++;
|
|
194
|
+
if (session.eventBuffer.length > MAX_EVENT_BUFFER) {
|
|
195
|
+
session.eventBuffer.splice(0, session.eventBuffer.length - MAX_EVENT_BUFFER);
|
|
196
|
+
}
|
|
197
|
+
const listeners = this.eventListeners.get(sessionId);
|
|
198
|
+
if (listeners) {
|
|
199
|
+
for (const cb of listeners) cb(session.eventCounter, event);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
186
202
|
// ── Permission pub/sub ────────────────────────────────────────
|
|
187
203
|
/** Subscribe to permission request notifications. Returns unsubscribe function. */
|
|
188
204
|
onPermissionRequest(cb) {
|
|
@@ -364,6 +380,7 @@ class SessionManager {
|
|
|
364
380
|
config: s.lastStartConfig,
|
|
365
381
|
ccSessionId: s.ccSessionId,
|
|
366
382
|
eventCount: s.eventCounter,
|
|
383
|
+
...this.getMessageStats(s.id),
|
|
367
384
|
createdAt: s.createdAt,
|
|
368
385
|
lastActivityAt: s.lastActivityAt
|
|
369
386
|
}));
|
|
@@ -374,6 +391,23 @@ class SessionManager {
|
|
|
374
391
|
if (session) session.lastActivityAt = Date.now();
|
|
375
392
|
}
|
|
376
393
|
/** Persist an agent event to chat_messages. */
|
|
394
|
+
getMessageStats(sessionId) {
|
|
395
|
+
try {
|
|
396
|
+
const db = getDb();
|
|
397
|
+
const count = db.prepare(
|
|
398
|
+
`SELECT COUNT(*) as c FROM chat_messages WHERE session_id = ?`
|
|
399
|
+
).get(sessionId);
|
|
400
|
+
const last = db.prepare(
|
|
401
|
+
`SELECT role, content, created_at FROM chat_messages WHERE session_id = ? ORDER BY id DESC LIMIT 1`
|
|
402
|
+
).get(sessionId);
|
|
403
|
+
return {
|
|
404
|
+
messageCount: count.c,
|
|
405
|
+
lastMessage: last ? { role: last.role, content: last.content, created_at: last.created_at } : null
|
|
406
|
+
};
|
|
407
|
+
} catch {
|
|
408
|
+
return { messageCount: 0, lastMessage: null };
|
|
409
|
+
}
|
|
410
|
+
}
|
|
377
411
|
persistEvent(sessionId, e) {
|
|
378
412
|
try {
|
|
379
413
|
const db = getDb();
|
|
@@ -454,6 +454,36 @@ var ClaudeCodeProcess = class {
|
|
|
454
454
|
this.send(options.prompt);
|
|
455
455
|
}
|
|
456
456
|
}
|
|
457
|
+
/**
|
|
458
|
+
* Split completed assistant text into chunks and emit assistant_delta events
|
|
459
|
+
* at a fixed rate (~270 chars/sec), followed by the final assistant event.
|
|
460
|
+
*
|
|
461
|
+
* CHUNK_SIZE chars every CHUNK_DELAY_MS → natural TPS feel regardless of length.
|
|
462
|
+
*/
|
|
463
|
+
emitTextAsDeltas(text) {
|
|
464
|
+
const CHUNK_SIZE = 4;
|
|
465
|
+
const CHUNK_DELAY_MS = 15;
|
|
466
|
+
let t = 0;
|
|
467
|
+
for (let i = 0; i < text.length; i += CHUNK_SIZE) {
|
|
468
|
+
const chunk = text.slice(i, i + CHUNK_SIZE);
|
|
469
|
+
setTimeout(() => {
|
|
470
|
+
this.emitter.emit("event", {
|
|
471
|
+
type: "assistant_delta",
|
|
472
|
+
delta: chunk,
|
|
473
|
+
index: 0,
|
|
474
|
+
timestamp: Date.now()
|
|
475
|
+
});
|
|
476
|
+
}, t);
|
|
477
|
+
t += CHUNK_DELAY_MS;
|
|
478
|
+
}
|
|
479
|
+
setTimeout(() => {
|
|
480
|
+
this.emitter.emit("event", {
|
|
481
|
+
type: "assistant",
|
|
482
|
+
message: text,
|
|
483
|
+
timestamp: Date.now()
|
|
484
|
+
});
|
|
485
|
+
}, t);
|
|
486
|
+
}
|
|
457
487
|
get alive() {
|
|
458
488
|
return this._alive;
|
|
459
489
|
}
|
|
@@ -529,6 +559,7 @@ var ClaudeCodeProcess = class {
|
|
|
529
559
|
const content = msg.message?.content;
|
|
530
560
|
if (!Array.isArray(content)) return null;
|
|
531
561
|
const events = [];
|
|
562
|
+
const textBlocks = [];
|
|
532
563
|
for (const block of content) {
|
|
533
564
|
if (block.type === "thinking") {
|
|
534
565
|
events.push({
|
|
@@ -546,15 +577,17 @@ var ClaudeCodeProcess = class {
|
|
|
546
577
|
} else if (block.type === "text") {
|
|
547
578
|
const text = (block.text ?? "").trim();
|
|
548
579
|
if (text) {
|
|
549
|
-
|
|
580
|
+
textBlocks.push(text);
|
|
550
581
|
}
|
|
551
582
|
}
|
|
552
583
|
}
|
|
553
|
-
if (events.length > 0) {
|
|
554
|
-
for (
|
|
555
|
-
this.emitter.emit("event",
|
|
584
|
+
if (events.length > 0 || textBlocks.length > 0) {
|
|
585
|
+
for (const e of events) {
|
|
586
|
+
this.emitter.emit("event", e);
|
|
587
|
+
}
|
|
588
|
+
for (const text of textBlocks) {
|
|
589
|
+
this.emitTextAsDeltas(text);
|
|
556
590
|
}
|
|
557
|
-
return events[0];
|
|
558
591
|
}
|
|
559
592
|
return null;
|
|
560
593
|
}
|
|
@@ -982,6 +1015,12 @@ function createAgentRoutes(sessionManager2) {
|
|
|
982
1015
|
db.prepare(`INSERT INTO chat_messages (session_id, role, content, meta) VALUES (?, 'user', ?, ?)`).run(sessionId, textContent, Object.keys(meta).length > 0 ? JSON.stringify(meta) : null);
|
|
983
1016
|
} catch {
|
|
984
1017
|
}
|
|
1018
|
+
sessionManager2.pushEvent(sessionId, {
|
|
1019
|
+
type: "user_message",
|
|
1020
|
+
message: textContent,
|
|
1021
|
+
data: Object.keys(meta).length > 0 ? meta : void 0,
|
|
1022
|
+
timestamp: Date.now()
|
|
1023
|
+
});
|
|
985
1024
|
sessionManager2.updateSessionState(sessionId, "processing");
|
|
986
1025
|
sessionManager2.touch(sessionId);
|
|
987
1026
|
if (body.images?.length) {
|
|
@@ -1004,32 +1043,59 @@ function createAgentRoutes(sessionManager2) {
|
|
|
1004
1043
|
const sessionId = getSessionId(c);
|
|
1005
1044
|
const session = sessionManager2.getOrCreateSession(sessionId);
|
|
1006
1045
|
const sinceParam = c.req.query("since");
|
|
1007
|
-
|
|
1046
|
+
const sinceCursor = sinceParam ? parseInt(sinceParam, 10) : session.eventCounter;
|
|
1008
1047
|
return streamSSE3(c, async (stream) => {
|
|
1009
|
-
const POLL_MS = 300;
|
|
1010
1048
|
const KEEPALIVE_MS = 15e3;
|
|
1011
|
-
|
|
1012
|
-
|
|
1049
|
+
const signal = c.req.raw.signal;
|
|
1050
|
+
const queue = [];
|
|
1051
|
+
let wakeUp = null;
|
|
1052
|
+
const unsub = sessionManager2.onSessionEvent(sessionId, (eventCursor, event) => {
|
|
1053
|
+
queue.push({ cursor: eventCursor, event });
|
|
1054
|
+
const fn = wakeUp;
|
|
1055
|
+
wakeUp = null;
|
|
1056
|
+
fn?.();
|
|
1057
|
+
});
|
|
1058
|
+
signal.addEventListener("abort", () => {
|
|
1059
|
+
const fn = wakeUp;
|
|
1060
|
+
wakeUp = null;
|
|
1061
|
+
fn?.();
|
|
1062
|
+
});
|
|
1063
|
+
try {
|
|
1064
|
+
let cursor = sinceCursor;
|
|
1013
1065
|
if (cursor < session.eventCounter) {
|
|
1014
1066
|
const startIdx = Math.max(
|
|
1015
1067
|
0,
|
|
1016
1068
|
session.eventBuffer.length - (session.eventCounter - cursor)
|
|
1017
1069
|
);
|
|
1018
|
-
const
|
|
1019
|
-
for (const event of newEvents) {
|
|
1070
|
+
for (const event of session.eventBuffer.slice(startIdx)) {
|
|
1020
1071
|
cursor++;
|
|
1021
|
-
await stream.writeSSE({
|
|
1022
|
-
id: String(cursor),
|
|
1023
|
-
data: JSON.stringify(event)
|
|
1024
|
-
});
|
|
1025
|
-
lastSend = Date.now();
|
|
1072
|
+
await stream.writeSSE({ id: String(cursor), data: JSON.stringify(event) });
|
|
1026
1073
|
}
|
|
1074
|
+
} else {
|
|
1075
|
+
cursor = session.eventCounter;
|
|
1027
1076
|
}
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1077
|
+
while (queue.length > 0 && queue[0].cursor <= cursor) queue.shift();
|
|
1078
|
+
while (!signal.aborted) {
|
|
1079
|
+
if (queue.length === 0) {
|
|
1080
|
+
await Promise.race([
|
|
1081
|
+
new Promise((r) => {
|
|
1082
|
+
wakeUp = r;
|
|
1083
|
+
}),
|
|
1084
|
+
new Promise((r) => setTimeout(r, KEEPALIVE_MS))
|
|
1085
|
+
]);
|
|
1086
|
+
}
|
|
1087
|
+
if (signal.aborted) break;
|
|
1088
|
+
if (queue.length > 0) {
|
|
1089
|
+
while (queue.length > 0) {
|
|
1090
|
+
const item = queue.shift();
|
|
1091
|
+
await stream.writeSSE({ id: String(item.cursor), data: JSON.stringify(item.event) });
|
|
1092
|
+
}
|
|
1093
|
+
} else {
|
|
1094
|
+
await stream.writeSSE({ data: "" });
|
|
1095
|
+
}
|
|
1031
1096
|
}
|
|
1032
|
-
|
|
1097
|
+
} finally {
|
|
1098
|
+
unsub();
|
|
1033
1099
|
}
|
|
1034
1100
|
});
|
|
1035
1101
|
});
|
|
@@ -1128,12 +1194,24 @@ function createAgentRoutes(sessionManager2) {
|
|
|
1128
1194
|
const sessionId = getSessionId(c);
|
|
1129
1195
|
const session = sessionManager2.getSession(sessionId);
|
|
1130
1196
|
const alive = session?.process?.alive ?? false;
|
|
1197
|
+
let messageCount = 0;
|
|
1198
|
+
let lastMessage = null;
|
|
1199
|
+
try {
|
|
1200
|
+
const db = getDb();
|
|
1201
|
+
const count = db.prepare("SELECT COUNT(*) as c FROM chat_messages WHERE session_id = ?").get(sessionId);
|
|
1202
|
+
messageCount = count?.c ?? 0;
|
|
1203
|
+
const last = db.prepare("SELECT role, content, created_at FROM chat_messages WHERE session_id = ? ORDER BY id DESC LIMIT 1").get(sessionId);
|
|
1204
|
+
if (last) lastMessage = { role: last.role, content: last.content, created_at: last.created_at };
|
|
1205
|
+
} catch {
|
|
1206
|
+
}
|
|
1131
1207
|
return httpJson(c, "agent.status", {
|
|
1132
1208
|
alive,
|
|
1133
1209
|
agentStatus: !alive ? "disconnected" : session?.state === "processing" ? "busy" : "idle",
|
|
1134
1210
|
sessionId: session?.process?.sessionId ?? null,
|
|
1135
1211
|
ccSessionId: session?.ccSessionId ?? null,
|
|
1136
1212
|
eventCount: session?.eventCounter ?? 0,
|
|
1213
|
+
messageCount,
|
|
1214
|
+
lastMessage,
|
|
1137
1215
|
config: session?.lastStartConfig ?? null
|
|
1138
1216
|
});
|
|
1139
1217
|
});
|
|
@@ -1417,11 +1495,13 @@ var SessionManager = class {
|
|
|
1417
1495
|
session.ccSessionId = e.data.sessionId;
|
|
1418
1496
|
this.persistSession(session);
|
|
1419
1497
|
}
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1498
|
+
if (e.type !== "assistant_delta") {
|
|
1499
|
+
session.eventBuffer.push(e);
|
|
1500
|
+
if (session.eventBuffer.length > MAX_EVENT_BUFFER) {
|
|
1501
|
+
session.eventBuffer.splice(0, session.eventBuffer.length - MAX_EVENT_BUFFER);
|
|
1502
|
+
}
|
|
1424
1503
|
}
|
|
1504
|
+
session.eventCounter++;
|
|
1425
1505
|
if (e.type === "complete" || e.type === "error" || e.type === "interrupted") {
|
|
1426
1506
|
this.setSessionState(sessionId, session, "waiting");
|
|
1427
1507
|
}
|
|
@@ -1465,6 +1545,20 @@ var SessionManager = class {
|
|
|
1465
1545
|
broadcastSkillEvent(event) {
|
|
1466
1546
|
for (const cb of this.skillEventListeners) cb(event);
|
|
1467
1547
|
}
|
|
1548
|
+
/** Push a synthetic event into a session's event stream (for user message broadcast). */
|
|
1549
|
+
pushEvent(sessionId, event) {
|
|
1550
|
+
const session = this.sessions.get(sessionId);
|
|
1551
|
+
if (!session) return;
|
|
1552
|
+
session.eventBuffer.push(event);
|
|
1553
|
+
session.eventCounter++;
|
|
1554
|
+
if (session.eventBuffer.length > MAX_EVENT_BUFFER) {
|
|
1555
|
+
session.eventBuffer.splice(0, session.eventBuffer.length - MAX_EVENT_BUFFER);
|
|
1556
|
+
}
|
|
1557
|
+
const listeners = this.eventListeners.get(sessionId);
|
|
1558
|
+
if (listeners) {
|
|
1559
|
+
for (const cb of listeners) cb(session.eventCounter, event);
|
|
1560
|
+
}
|
|
1561
|
+
}
|
|
1468
1562
|
// ── Permission pub/sub ────────────────────────────────────────
|
|
1469
1563
|
/** Subscribe to permission request notifications. Returns unsubscribe function. */
|
|
1470
1564
|
onPermissionRequest(cb) {
|
|
@@ -1646,6 +1740,7 @@ var SessionManager = class {
|
|
|
1646
1740
|
config: s.lastStartConfig,
|
|
1647
1741
|
ccSessionId: s.ccSessionId,
|
|
1648
1742
|
eventCount: s.eventCounter,
|
|
1743
|
+
...this.getMessageStats(s.id),
|
|
1649
1744
|
createdAt: s.createdAt,
|
|
1650
1745
|
lastActivityAt: s.lastActivityAt
|
|
1651
1746
|
}));
|
|
@@ -1656,6 +1751,23 @@ var SessionManager = class {
|
|
|
1656
1751
|
if (session) session.lastActivityAt = Date.now();
|
|
1657
1752
|
}
|
|
1658
1753
|
/** Persist an agent event to chat_messages. */
|
|
1754
|
+
getMessageStats(sessionId) {
|
|
1755
|
+
try {
|
|
1756
|
+
const db = getDb();
|
|
1757
|
+
const count = db.prepare(
|
|
1758
|
+
`SELECT COUNT(*) as c FROM chat_messages WHERE session_id = ?`
|
|
1759
|
+
).get(sessionId);
|
|
1760
|
+
const last = db.prepare(
|
|
1761
|
+
`SELECT role, content, created_at FROM chat_messages WHERE session_id = ? ORDER BY id DESC LIMIT 1`
|
|
1762
|
+
).get(sessionId);
|
|
1763
|
+
return {
|
|
1764
|
+
messageCount: count.c,
|
|
1765
|
+
lastMessage: last ? { role: last.role, content: last.content, created_at: last.created_at } : null
|
|
1766
|
+
};
|
|
1767
|
+
} catch {
|
|
1768
|
+
return { messageCount: 0, lastMessage: null };
|
|
1769
|
+
}
|
|
1770
|
+
}
|
|
1659
1771
|
persistEvent(sessionId, e) {
|
|
1660
1772
|
try {
|
|
1661
1773
|
const db = getDb();
|
|
@@ -1931,6 +2043,12 @@ function handleAgentSend(ws, msg, sm) {
|
|
|
1931
2043
|
db.prepare(`INSERT INTO chat_messages (session_id, role, content, meta) VALUES (?, 'user', ?, ?)`).run(sessionId, textContent, Object.keys(meta).length > 0 ? JSON.stringify(meta) : null);
|
|
1932
2044
|
} catch {
|
|
1933
2045
|
}
|
|
2046
|
+
sm.pushEvent(sessionId, {
|
|
2047
|
+
type: "user_message",
|
|
2048
|
+
message: textContent,
|
|
2049
|
+
data: Object.keys(meta).length > 0 ? meta : void 0,
|
|
2050
|
+
timestamp: Date.now()
|
|
2051
|
+
});
|
|
1934
2052
|
sm.updateSessionState(sessionId, "processing");
|
|
1935
2053
|
sm.touch(sessionId);
|
|
1936
2054
|
if (images?.length) {
|
|
@@ -2041,12 +2159,24 @@ function handleAgentStatus(ws, msg, sm) {
|
|
|
2041
2159
|
const sessionId = msg.session ?? "default";
|
|
2042
2160
|
const session = sm.getSession(sessionId);
|
|
2043
2161
|
const alive = session?.process?.alive ?? false;
|
|
2162
|
+
let messageCount = 0;
|
|
2163
|
+
let lastMessage = null;
|
|
2164
|
+
try {
|
|
2165
|
+
const db = getDb();
|
|
2166
|
+
const count = db.prepare("SELECT COUNT(*) as c FROM chat_messages WHERE session_id = ?").get(sessionId);
|
|
2167
|
+
messageCount = count?.c ?? 0;
|
|
2168
|
+
const last = db.prepare("SELECT role, content, created_at FROM chat_messages WHERE session_id = ? ORDER BY id DESC LIMIT 1").get(sessionId);
|
|
2169
|
+
if (last) lastMessage = { role: last.role, content: last.content, created_at: last.created_at };
|
|
2170
|
+
} catch {
|
|
2171
|
+
}
|
|
2044
2172
|
wsReply(ws, msg, {
|
|
2045
2173
|
alive,
|
|
2046
2174
|
agentStatus: !alive ? "disconnected" : session?.state === "processing" ? "busy" : "idle",
|
|
2047
2175
|
sessionId: session?.process?.sessionId ?? null,
|
|
2048
2176
|
ccSessionId: session?.ccSessionId ?? null,
|
|
2049
2177
|
eventCount: session?.eventCounter ?? 0,
|
|
2178
|
+
messageCount,
|
|
2179
|
+
lastMessage,
|
|
2050
2180
|
config: session?.lastStartConfig ?? null
|
|
2051
2181
|
});
|
|
2052
2182
|
}
|
|
@@ -2063,7 +2193,38 @@ function handleAgentSubscribe(ws, msg, sm, state) {
|
|
|
2063
2193
|
const sessionId = msg.session ?? "default";
|
|
2064
2194
|
const session = sm.getOrCreateSession(sessionId);
|
|
2065
2195
|
state.agentUnsubs.get(sessionId)?.();
|
|
2066
|
-
|
|
2196
|
+
const includeHistory = msg.since === 0 || msg.includeHistory === true;
|
|
2197
|
+
let cursor = 0;
|
|
2198
|
+
if (includeHistory) {
|
|
2199
|
+
try {
|
|
2200
|
+
const db = getDb();
|
|
2201
|
+
const rows = db.prepare(
|
|
2202
|
+
`SELECT role, content, meta, created_at FROM chat_messages
|
|
2203
|
+
WHERE session_id = ? ORDER BY id ASC`
|
|
2204
|
+
).all(sessionId);
|
|
2205
|
+
for (const row of rows) {
|
|
2206
|
+
cursor++;
|
|
2207
|
+
const eventType = row.role === "user" ? "user_message" : row.role === "assistant" ? "assistant" : row.role === "thinking" ? "thinking" : row.role === "tool" ? "tool_use" : row.role === "tool_result" ? "tool_result" : row.role === "error" ? "error" : null;
|
|
2208
|
+
if (!eventType) continue;
|
|
2209
|
+
const meta = row.meta ? JSON.parse(row.meta) : void 0;
|
|
2210
|
+
send(ws, {
|
|
2211
|
+
type: "agent.event",
|
|
2212
|
+
session: sessionId,
|
|
2213
|
+
cursor,
|
|
2214
|
+
isHistory: true,
|
|
2215
|
+
event: {
|
|
2216
|
+
type: eventType,
|
|
2217
|
+
message: row.content,
|
|
2218
|
+
data: meta,
|
|
2219
|
+
timestamp: new Date(row.created_at).getTime()
|
|
2220
|
+
}
|
|
2221
|
+
});
|
|
2222
|
+
}
|
|
2223
|
+
} catch {
|
|
2224
|
+
}
|
|
2225
|
+
}
|
|
2226
|
+
const bufferStart = typeof msg.since === "number" && msg.since > 0 ? msg.since : session.eventCounter;
|
|
2227
|
+
if (!includeHistory) cursor = bufferStart;
|
|
2067
2228
|
if (cursor < session.eventCounter) {
|
|
2068
2229
|
const startIdx = Math.max(0, session.eventBuffer.length - (session.eventCounter - cursor));
|
|
2069
2230
|
const events = session.eventBuffer.slice(startIdx);
|
|
@@ -2071,6 +2232,8 @@ function handleAgentSubscribe(ws, msg, sm, state) {
|
|
|
2071
2232
|
cursor++;
|
|
2072
2233
|
send(ws, { type: "agent.event", session: sessionId, cursor, event });
|
|
2073
2234
|
}
|
|
2235
|
+
} else {
|
|
2236
|
+
cursor = session.eventCounter;
|
|
2074
2237
|
}
|
|
2075
2238
|
const unsub = sm.onSessionEvent(sessionId, (eventCursor, event) => {
|
|
2076
2239
|
send(ws, { type: "agent.event", session: sessionId, cursor: eventCursor, event });
|
|
@@ -2183,10 +2346,14 @@ function handlePermissionPending(ws, msg, sm) {
|
|
|
2183
2346
|
}
|
|
2184
2347
|
function handlePermissionSubscribe(ws, msg, sm, state) {
|
|
2185
2348
|
state.permissionUnsub?.();
|
|
2349
|
+
const pending = sm.getAllPendingPermissions();
|
|
2350
|
+
for (const p of pending) {
|
|
2351
|
+
send(ws, { type: "permission.request", session: p.sessionId, request: p.request, createdAt: p.createdAt, isHistory: true });
|
|
2352
|
+
}
|
|
2186
2353
|
state.permissionUnsub = sm.onPermissionRequest((sessionId, request, createdAt) => {
|
|
2187
2354
|
send(ws, { type: "permission.request", session: sessionId, request, createdAt });
|
|
2188
2355
|
});
|
|
2189
|
-
reply(ws, msg, {});
|
|
2356
|
+
reply(ws, msg, { pendingCount: pending.length });
|
|
2190
2357
|
}
|
|
2191
2358
|
function handlePermissionUnsubscribe(ws, msg, state) {
|
|
2192
2359
|
state.permissionUnsub?.();
|
package/dist/server/ws.js
CHANGED
|
@@ -234,6 +234,12 @@ function handleAgentSend(ws, msg, sm) {
|
|
|
234
234
|
db.prepare(`INSERT INTO chat_messages (session_id, role, content, meta) VALUES (?, 'user', ?, ?)`).run(sessionId, textContent, Object.keys(meta).length > 0 ? JSON.stringify(meta) : null);
|
|
235
235
|
} catch {
|
|
236
236
|
}
|
|
237
|
+
sm.pushEvent(sessionId, {
|
|
238
|
+
type: "user_message",
|
|
239
|
+
message: textContent,
|
|
240
|
+
data: Object.keys(meta).length > 0 ? meta : void 0,
|
|
241
|
+
timestamp: Date.now()
|
|
242
|
+
});
|
|
237
243
|
sm.updateSessionState(sessionId, "processing");
|
|
238
244
|
sm.touch(sessionId);
|
|
239
245
|
if (images?.length) {
|
|
@@ -344,12 +350,24 @@ function handleAgentStatus(ws, msg, sm) {
|
|
|
344
350
|
const sessionId = msg.session ?? "default";
|
|
345
351
|
const session = sm.getSession(sessionId);
|
|
346
352
|
const alive = session?.process?.alive ?? false;
|
|
353
|
+
let messageCount = 0;
|
|
354
|
+
let lastMessage = null;
|
|
355
|
+
try {
|
|
356
|
+
const db = getDb();
|
|
357
|
+
const count = db.prepare("SELECT COUNT(*) as c FROM chat_messages WHERE session_id = ?").get(sessionId);
|
|
358
|
+
messageCount = count?.c ?? 0;
|
|
359
|
+
const last = db.prepare("SELECT role, content, created_at FROM chat_messages WHERE session_id = ? ORDER BY id DESC LIMIT 1").get(sessionId);
|
|
360
|
+
if (last) lastMessage = { role: last.role, content: last.content, created_at: last.created_at };
|
|
361
|
+
} catch {
|
|
362
|
+
}
|
|
347
363
|
wsReply(ws, msg, {
|
|
348
364
|
alive,
|
|
349
365
|
agentStatus: !alive ? "disconnected" : session?.state === "processing" ? "busy" : "idle",
|
|
350
366
|
sessionId: session?.process?.sessionId ?? null,
|
|
351
367
|
ccSessionId: session?.ccSessionId ?? null,
|
|
352
368
|
eventCount: session?.eventCounter ?? 0,
|
|
369
|
+
messageCount,
|
|
370
|
+
lastMessage,
|
|
353
371
|
config: session?.lastStartConfig ?? null
|
|
354
372
|
});
|
|
355
373
|
}
|
|
@@ -366,7 +384,38 @@ function handleAgentSubscribe(ws, msg, sm, state) {
|
|
|
366
384
|
const sessionId = msg.session ?? "default";
|
|
367
385
|
const session = sm.getOrCreateSession(sessionId);
|
|
368
386
|
state.agentUnsubs.get(sessionId)?.();
|
|
369
|
-
|
|
387
|
+
const includeHistory = msg.since === 0 || msg.includeHistory === true;
|
|
388
|
+
let cursor = 0;
|
|
389
|
+
if (includeHistory) {
|
|
390
|
+
try {
|
|
391
|
+
const db = getDb();
|
|
392
|
+
const rows = db.prepare(
|
|
393
|
+
`SELECT role, content, meta, created_at FROM chat_messages
|
|
394
|
+
WHERE session_id = ? ORDER BY id ASC`
|
|
395
|
+
).all(sessionId);
|
|
396
|
+
for (const row of rows) {
|
|
397
|
+
cursor++;
|
|
398
|
+
const eventType = row.role === "user" ? "user_message" : row.role === "assistant" ? "assistant" : row.role === "thinking" ? "thinking" : row.role === "tool" ? "tool_use" : row.role === "tool_result" ? "tool_result" : row.role === "error" ? "error" : null;
|
|
399
|
+
if (!eventType) continue;
|
|
400
|
+
const meta = row.meta ? JSON.parse(row.meta) : void 0;
|
|
401
|
+
send(ws, {
|
|
402
|
+
type: "agent.event",
|
|
403
|
+
session: sessionId,
|
|
404
|
+
cursor,
|
|
405
|
+
isHistory: true,
|
|
406
|
+
event: {
|
|
407
|
+
type: eventType,
|
|
408
|
+
message: row.content,
|
|
409
|
+
data: meta,
|
|
410
|
+
timestamp: new Date(row.created_at).getTime()
|
|
411
|
+
}
|
|
412
|
+
});
|
|
413
|
+
}
|
|
414
|
+
} catch {
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
const bufferStart = typeof msg.since === "number" && msg.since > 0 ? msg.since : session.eventCounter;
|
|
418
|
+
if (!includeHistory) cursor = bufferStart;
|
|
370
419
|
if (cursor < session.eventCounter) {
|
|
371
420
|
const startIdx = Math.max(0, session.eventBuffer.length - (session.eventCounter - cursor));
|
|
372
421
|
const events = session.eventBuffer.slice(startIdx);
|
|
@@ -374,6 +423,8 @@ function handleAgentSubscribe(ws, msg, sm, state) {
|
|
|
374
423
|
cursor++;
|
|
375
424
|
send(ws, { type: "agent.event", session: sessionId, cursor, event });
|
|
376
425
|
}
|
|
426
|
+
} else {
|
|
427
|
+
cursor = session.eventCounter;
|
|
377
428
|
}
|
|
378
429
|
const unsub = sm.onSessionEvent(sessionId, (eventCursor, event) => {
|
|
379
430
|
send(ws, { type: "agent.event", session: sessionId, cursor: eventCursor, event });
|
|
@@ -486,10 +537,14 @@ function handlePermissionPending(ws, msg, sm) {
|
|
|
486
537
|
}
|
|
487
538
|
function handlePermissionSubscribe(ws, msg, sm, state) {
|
|
488
539
|
state.permissionUnsub?.();
|
|
540
|
+
const pending = sm.getAllPendingPermissions();
|
|
541
|
+
for (const p of pending) {
|
|
542
|
+
send(ws, { type: "permission.request", session: p.sessionId, request: p.request, createdAt: p.createdAt, isHistory: true });
|
|
543
|
+
}
|
|
489
544
|
state.permissionUnsub = sm.onPermissionRequest((sessionId, request, createdAt) => {
|
|
490
545
|
send(ws, { type: "permission.request", session: sessionId, request, createdAt });
|
|
491
546
|
});
|
|
492
|
-
reply(ws, msg, {});
|
|
547
|
+
reply(ws, msg, { pendingCount: pending.length });
|
|
493
548
|
}
|
|
494
549
|
function handlePermissionUnsubscribe(ws, msg, state) {
|
|
495
550
|
state.permissionUnsub?.();
|