@sna-sdk/core 0.3.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 +6 -0
- package/dist/core/providers/cc-history-adapter.d.ts +37 -0
- package/dist/core/providers/cc-history-adapter.js +70 -0
- package/dist/core/providers/claude-code.js +50 -27
- package/dist/core/providers/types.d.ts +7 -1
- package/dist/db/schema.js +1 -1
- package/dist/scripts/sna.js +20 -2
- package/dist/scripts/tu-oneshot.d.ts +2 -0
- package/dist/scripts/tu-oneshot.js +66 -0
- package/dist/server/api-types.d.ts +13 -0
- package/dist/server/history-builder.d.ts +16 -0
- package/dist/server/history-builder.js +25 -0
- package/dist/server/index.d.ts +2 -1
- package/dist/server/index.js +2 -0
- package/dist/server/routes/agent.js +105 -17
- package/dist/server/session-manager.d.ts +23 -3
- package/dist/server/session-manager.js +67 -13
- package/dist/server/standalone.js +442 -87
- package/dist/server/ws.js +107 -5
- package/dist/testing/mock-api.js +20 -0
- package/package.json +1 -1
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
} from "../../core/providers/index.js";
|
|
6
6
|
import { logger } from "../../lib/logger.js";
|
|
7
7
|
import { getDb } from "../../db/schema.js";
|
|
8
|
+
import { buildHistoryFromDb } from "../history-builder.js";
|
|
8
9
|
import { httpJson } from "../api-types.js";
|
|
9
10
|
import { saveImages } from "../image-store.js";
|
|
10
11
|
function getSessionId(c) {
|
|
@@ -194,7 +195,13 @@ function createAgentRoutes(sessionManager) {
|
|
|
194
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);
|
|
195
196
|
} catch {
|
|
196
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
|
+
});
|
|
204
|
+
sessionManager.updateSessionState(sessionId, "processing");
|
|
198
205
|
sessionManager.touch(sessionId);
|
|
199
206
|
if (body.images?.length) {
|
|
200
207
|
const content = [
|
|
@@ -216,32 +223,59 @@ function createAgentRoutes(sessionManager) {
|
|
|
216
223
|
const sessionId = getSessionId(c);
|
|
217
224
|
const session = sessionManager.getOrCreateSession(sessionId);
|
|
218
225
|
const sinceParam = c.req.query("since");
|
|
219
|
-
|
|
226
|
+
const sinceCursor = sinceParam ? parseInt(sinceParam, 10) : session.eventCounter;
|
|
220
227
|
return streamSSE(c, async (stream) => {
|
|
221
|
-
const POLL_MS = 300;
|
|
222
228
|
const KEEPALIVE_MS = 15e3;
|
|
223
|
-
|
|
224
|
-
|
|
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;
|
|
225
245
|
if (cursor < session.eventCounter) {
|
|
226
246
|
const startIdx = Math.max(
|
|
227
247
|
0,
|
|
228
248
|
session.eventBuffer.length - (session.eventCounter - cursor)
|
|
229
249
|
);
|
|
230
|
-
const
|
|
231
|
-
for (const event of newEvents) {
|
|
250
|
+
for (const event of session.eventBuffer.slice(startIdx)) {
|
|
232
251
|
cursor++;
|
|
233
|
-
await stream.writeSSE({
|
|
234
|
-
id: String(cursor),
|
|
235
|
-
data: JSON.stringify(event)
|
|
236
|
-
});
|
|
237
|
-
lastSend = Date.now();
|
|
252
|
+
await stream.writeSSE({ id: String(cursor), data: JSON.stringify(event) });
|
|
238
253
|
}
|
|
254
|
+
} else {
|
|
255
|
+
cursor = session.eventCounter;
|
|
239
256
|
}
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
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
|
+
}
|
|
243
276
|
}
|
|
244
|
-
|
|
277
|
+
} finally {
|
|
278
|
+
unsub();
|
|
245
279
|
}
|
|
246
280
|
});
|
|
247
281
|
});
|
|
@@ -272,6 +306,46 @@ function createAgentRoutes(sessionManager) {
|
|
|
272
306
|
return c.json({ status: "error", message: e.message }, 500);
|
|
273
307
|
}
|
|
274
308
|
});
|
|
309
|
+
app.post("/resume", async (c) => {
|
|
310
|
+
const sessionId = getSessionId(c);
|
|
311
|
+
const body = await c.req.json().catch(() => ({}));
|
|
312
|
+
const session = sessionManager.getOrCreateSession(sessionId);
|
|
313
|
+
if (session.process?.alive) {
|
|
314
|
+
return c.json({ status: "error", message: "Session already running. Use agent.send instead." }, 400);
|
|
315
|
+
}
|
|
316
|
+
const history = buildHistoryFromDb(sessionId);
|
|
317
|
+
if (history.length === 0 && !body.prompt) {
|
|
318
|
+
return c.json({ status: "error", message: "No history in DB \u2014 nothing to resume." }, 400);
|
|
319
|
+
}
|
|
320
|
+
const providerName = body.provider ?? "claude-code";
|
|
321
|
+
const model = body.model ?? session.lastStartConfig?.model ?? "claude-sonnet-4-6";
|
|
322
|
+
const permissionMode = body.permissionMode ?? session.lastStartConfig?.permissionMode ?? "acceptEdits";
|
|
323
|
+
const extraArgs = body.extraArgs ?? session.lastStartConfig?.extraArgs;
|
|
324
|
+
const provider = getProvider(providerName);
|
|
325
|
+
try {
|
|
326
|
+
const proc = provider.spawn({
|
|
327
|
+
cwd: session.cwd,
|
|
328
|
+
prompt: body.prompt,
|
|
329
|
+
model,
|
|
330
|
+
permissionMode,
|
|
331
|
+
env: { SNA_SESSION_ID: sessionId },
|
|
332
|
+
history: history.length > 0 ? history : void 0,
|
|
333
|
+
extraArgs
|
|
334
|
+
});
|
|
335
|
+
sessionManager.setProcess(sessionId, proc, "resumed");
|
|
336
|
+
sessionManager.saveStartConfig(sessionId, { provider: providerName, model, permissionMode, extraArgs });
|
|
337
|
+
logger.log("route", `POST /resume?session=${sessionId} \u2192 resumed (${history.length} history msgs)`);
|
|
338
|
+
return httpJson(c, "agent.resume", {
|
|
339
|
+
status: "resumed",
|
|
340
|
+
provider: providerName,
|
|
341
|
+
sessionId: session.id,
|
|
342
|
+
historyCount: history.length
|
|
343
|
+
});
|
|
344
|
+
} catch (e) {
|
|
345
|
+
logger.err("err", `POST /resume?session=${sessionId} \u2192 ${e.message}`);
|
|
346
|
+
return c.json({ status: "error", message: e.message }, 500);
|
|
347
|
+
}
|
|
348
|
+
});
|
|
275
349
|
app.post("/interrupt", async (c) => {
|
|
276
350
|
const sessionId = getSessionId(c);
|
|
277
351
|
const interrupted = sessionManager.interruptSession(sessionId);
|
|
@@ -299,11 +373,25 @@ function createAgentRoutes(sessionManager) {
|
|
|
299
373
|
app.get("/status", (c) => {
|
|
300
374
|
const sessionId = getSessionId(c);
|
|
301
375
|
const session = sessionManager.getSession(sessionId);
|
|
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
|
+
}
|
|
302
387
|
return httpJson(c, "agent.status", {
|
|
303
|
-
alive
|
|
388
|
+
alive,
|
|
389
|
+
agentStatus: !alive ? "disconnected" : session?.state === "processing" ? "busy" : "idle",
|
|
304
390
|
sessionId: session?.process?.sessionId ?? null,
|
|
305
391
|
ccSessionId: session?.ccSessionId ?? null,
|
|
306
392
|
eventCount: session?.eventCounter ?? 0,
|
|
393
|
+
messageCount,
|
|
394
|
+
lastMessage,
|
|
307
395
|
config: session?.lastStartConfig ?? null
|
|
308
396
|
});
|
|
309
397
|
});
|
|
@@ -29,23 +29,31 @@ interface Session {
|
|
|
29
29
|
createdAt: number;
|
|
30
30
|
lastActivityAt: number;
|
|
31
31
|
}
|
|
32
|
+
type AgentStatus = "idle" | "busy" | "disconnected";
|
|
32
33
|
interface SessionInfo {
|
|
33
34
|
id: string;
|
|
34
35
|
label: string;
|
|
35
36
|
alive: boolean;
|
|
36
37
|
state: SessionState;
|
|
38
|
+
agentStatus: AgentStatus;
|
|
37
39
|
cwd: string;
|
|
38
40
|
meta: Record<string, unknown> | null;
|
|
39
41
|
config: StartConfig | null;
|
|
40
42
|
ccSessionId: string | null;
|
|
41
43
|
eventCount: number;
|
|
44
|
+
messageCount: number;
|
|
45
|
+
lastMessage: {
|
|
46
|
+
role: string;
|
|
47
|
+
content: string;
|
|
48
|
+
created_at: string;
|
|
49
|
+
} | null;
|
|
42
50
|
createdAt: number;
|
|
43
51
|
lastActivityAt: number;
|
|
44
52
|
}
|
|
45
53
|
interface SessionManagerOptions {
|
|
46
54
|
maxSessions?: number;
|
|
47
55
|
}
|
|
48
|
-
type SessionLifecycleState = "started" | "killed" | "exited" | "crashed" | "restarted";
|
|
56
|
+
type SessionLifecycleState = "started" | "resumed" | "killed" | "exited" | "crashed" | "restarted";
|
|
49
57
|
interface SessionLifecycleEvent {
|
|
50
58
|
session: string;
|
|
51
59
|
state: SessionLifecycleState;
|
|
@@ -64,6 +72,7 @@ declare class SessionManager {
|
|
|
64
72
|
private permissionRequestListeners;
|
|
65
73
|
private lifecycleListeners;
|
|
66
74
|
private configChangedListeners;
|
|
75
|
+
private stateChangedListeners;
|
|
67
76
|
constructor(options?: SessionManagerOptions);
|
|
68
77
|
/** Restore session metadata from DB (cwd, label, meta). Process state is not restored. */
|
|
69
78
|
private restoreFromDb;
|
|
@@ -84,13 +93,15 @@ declare class SessionManager {
|
|
|
84
93
|
cwd?: string;
|
|
85
94
|
}): Session;
|
|
86
95
|
/** Set the agent process for a session. Subscribes to events. */
|
|
87
|
-
setProcess(sessionId: string, proc: AgentProcess): void;
|
|
96
|
+
setProcess(sessionId: string, proc: AgentProcess, lifecycleState?: SessionLifecycleState): void;
|
|
88
97
|
/** Subscribe to real-time events for a session. Returns unsubscribe function. */
|
|
89
98
|
onSessionEvent(sessionId: string, cb: (cursor: number, event: AgentEvent) => void): () => void;
|
|
90
99
|
/** Subscribe to skill events broadcast. Returns unsubscribe function. */
|
|
91
100
|
onSkillEvent(cb: (event: Record<string, unknown>) => void): () => void;
|
|
92
101
|
/** Broadcast a skill event to all subscribers (called after DB insert). */
|
|
93
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;
|
|
94
105
|
/** Subscribe to permission request notifications. Returns unsubscribe function. */
|
|
95
106
|
onPermissionRequest(cb: (sessionId: string, request: Record<string, unknown>, createdAt: number) => void): () => void;
|
|
96
107
|
/** Subscribe to session lifecycle events (started/killed/exited/crashed). Returns unsubscribe function. */
|
|
@@ -99,6 +110,14 @@ declare class SessionManager {
|
|
|
99
110
|
/** Subscribe to session config changes. Returns unsubscribe function. */
|
|
100
111
|
onConfigChanged(cb: (event: SessionConfigChangedEvent) => void): () => void;
|
|
101
112
|
private emitConfigChanged;
|
|
113
|
+
onStateChanged(cb: (event: {
|
|
114
|
+
session: string;
|
|
115
|
+
agentStatus: AgentStatus;
|
|
116
|
+
state: SessionState;
|
|
117
|
+
}) => void): () => void;
|
|
118
|
+
/** Update session state and push agentStatus change to subscribers. */
|
|
119
|
+
updateSessionState(sessionId: string, newState: SessionState): void;
|
|
120
|
+
private setSessionState;
|
|
102
121
|
/** Create a pending permission request. Returns a promise that resolves when approved/denied. */
|
|
103
122
|
createPendingPermission(sessionId: string, request: Record<string, unknown>): Promise<boolean>;
|
|
104
123
|
/** Resolve a pending permission request. Returns false if no pending request. */
|
|
@@ -136,10 +155,11 @@ declare class SessionManager {
|
|
|
136
155
|
/** Touch a session's lastActivityAt timestamp. */
|
|
137
156
|
touch(id: string): void;
|
|
138
157
|
/** Persist an agent event to chat_messages. */
|
|
158
|
+
private getMessageStats;
|
|
139
159
|
private persistEvent;
|
|
140
160
|
/** Kill all sessions. Used during shutdown. */
|
|
141
161
|
killAll(): void;
|
|
142
162
|
get size(): number;
|
|
143
163
|
}
|
|
144
164
|
|
|
145
|
-
export { type Session, type SessionConfigChangedEvent, type SessionInfo, type SessionLifecycleEvent, type SessionLifecycleState, SessionManager, type SessionManagerOptions, type SessionState, type StartConfig };
|
|
165
|
+
export { type AgentStatus, type Session, type SessionConfigChangedEvent, type SessionInfo, type SessionLifecycleEvent, type SessionLifecycleState, SessionManager, type SessionManagerOptions, type SessionState, type StartConfig };
|
|
@@ -11,6 +11,7 @@ class SessionManager {
|
|
|
11
11
|
this.permissionRequestListeners = /* @__PURE__ */ new Set();
|
|
12
12
|
this.lifecycleListeners = /* @__PURE__ */ new Set();
|
|
13
13
|
this.configChangedListeners = /* @__PURE__ */ new Set();
|
|
14
|
+
this.stateChangedListeners = /* @__PURE__ */ new Set();
|
|
14
15
|
this.maxSessions = options.maxSessions ?? DEFAULT_MAX_SESSIONS;
|
|
15
16
|
this.restoreFromDb();
|
|
16
17
|
}
|
|
@@ -123,24 +124,26 @@ class SessionManager {
|
|
|
123
124
|
return this.createSession({ id, ...opts });
|
|
124
125
|
}
|
|
125
126
|
/** Set the agent process for a session. Subscribes to events. */
|
|
126
|
-
setProcess(sessionId, proc) {
|
|
127
|
+
setProcess(sessionId, proc, lifecycleState) {
|
|
127
128
|
const session = this.sessions.get(sessionId);
|
|
128
129
|
if (!session) throw new Error(`Session "${sessionId}" not found`);
|
|
129
130
|
session.process = proc;
|
|
130
|
-
|
|
131
|
+
this.setSessionState(sessionId, session, "processing");
|
|
131
132
|
session.lastActivityAt = Date.now();
|
|
132
133
|
proc.on("event", (e) => {
|
|
133
134
|
if (e.type === "init" && e.data?.sessionId && !session.ccSessionId) {
|
|
134
135
|
session.ccSessionId = e.data.sessionId;
|
|
135
136
|
this.persistSession(session);
|
|
136
137
|
}
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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
|
+
}
|
|
141
143
|
}
|
|
144
|
+
session.eventCounter++;
|
|
142
145
|
if (e.type === "complete" || e.type === "error" || e.type === "interrupted") {
|
|
143
|
-
|
|
146
|
+
this.setSessionState(sessionId, session, "waiting");
|
|
144
147
|
}
|
|
145
148
|
this.persistEvent(sessionId, e);
|
|
146
149
|
const listeners = this.eventListeners.get(sessionId);
|
|
@@ -149,14 +152,14 @@ class SessionManager {
|
|
|
149
152
|
}
|
|
150
153
|
});
|
|
151
154
|
proc.on("exit", (code) => {
|
|
152
|
-
|
|
155
|
+
this.setSessionState(sessionId, session, "idle");
|
|
153
156
|
this.emitLifecycle({ session: sessionId, state: code != null ? "exited" : "crashed", code });
|
|
154
157
|
});
|
|
155
158
|
proc.on("error", () => {
|
|
156
|
-
|
|
159
|
+
this.setSessionState(sessionId, session, "idle");
|
|
157
160
|
this.emitLifecycle({ session: sessionId, state: "crashed" });
|
|
158
161
|
});
|
|
159
|
-
this.emitLifecycle({ session: sessionId, state: "started" });
|
|
162
|
+
this.emitLifecycle({ session: sessionId, state: lifecycleState ?? "started" });
|
|
160
163
|
}
|
|
161
164
|
// ── Event pub/sub (for WebSocket) ─────────────────────────────
|
|
162
165
|
/** Subscribe to real-time events for a session. Returns unsubscribe function. */
|
|
@@ -182,6 +185,20 @@ class SessionManager {
|
|
|
182
185
|
broadcastSkillEvent(event) {
|
|
183
186
|
for (const cb of this.skillEventListeners) cb(event);
|
|
184
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
|
+
}
|
|
185
202
|
// ── Permission pub/sub ────────────────────────────────────────
|
|
186
203
|
/** Subscribe to permission request notifications. Returns unsubscribe function. */
|
|
187
204
|
onPermissionRequest(cb) {
|
|
@@ -206,11 +223,29 @@ class SessionManager {
|
|
|
206
223
|
emitConfigChanged(sessionId, config) {
|
|
207
224
|
for (const cb of this.configChangedListeners) cb({ session: sessionId, config });
|
|
208
225
|
}
|
|
226
|
+
// ── Agent status change pub/sub ────────────────────────────────
|
|
227
|
+
onStateChanged(cb) {
|
|
228
|
+
this.stateChangedListeners.add(cb);
|
|
229
|
+
return () => this.stateChangedListeners.delete(cb);
|
|
230
|
+
}
|
|
231
|
+
/** Update session state and push agentStatus change to subscribers. */
|
|
232
|
+
updateSessionState(sessionId, newState) {
|
|
233
|
+
const session = this.sessions.get(sessionId);
|
|
234
|
+
if (session) this.setSessionState(sessionId, session, newState);
|
|
235
|
+
}
|
|
236
|
+
setSessionState(sessionId, session, newState) {
|
|
237
|
+
const oldState = session.state;
|
|
238
|
+
session.state = newState;
|
|
239
|
+
const newStatus = !session.process?.alive ? "disconnected" : newState === "processing" ? "busy" : "idle";
|
|
240
|
+
if (oldState !== newState) {
|
|
241
|
+
for (const cb of this.stateChangedListeners) cb({ session: sessionId, agentStatus: newStatus, state: newState });
|
|
242
|
+
}
|
|
243
|
+
}
|
|
209
244
|
// ── Permission management ─────────────────────────────────────
|
|
210
245
|
/** Create a pending permission request. Returns a promise that resolves when approved/denied. */
|
|
211
246
|
createPendingPermission(sessionId, request) {
|
|
212
247
|
const session = this.sessions.get(sessionId);
|
|
213
|
-
if (session)
|
|
248
|
+
if (session) this.setSessionState(sessionId, session, "permission");
|
|
214
249
|
return new Promise((resolve) => {
|
|
215
250
|
const createdAt = Date.now();
|
|
216
251
|
this.pendingPermissions.set(sessionId, { resolve, request, createdAt });
|
|
@@ -230,7 +265,7 @@ class SessionManager {
|
|
|
230
265
|
pending.resolve(approved);
|
|
231
266
|
this.pendingPermissions.delete(sessionId);
|
|
232
267
|
const session = this.sessions.get(sessionId);
|
|
233
|
-
if (session)
|
|
268
|
+
if (session) this.setSessionState(sessionId, session, "processing");
|
|
234
269
|
return true;
|
|
235
270
|
}
|
|
236
271
|
/** Get a pending permission for a specific session. */
|
|
@@ -282,7 +317,7 @@ class SessionManager {
|
|
|
282
317
|
const session = this.sessions.get(id);
|
|
283
318
|
if (!session?.process?.alive) return false;
|
|
284
319
|
session.process.interrupt();
|
|
285
|
-
|
|
320
|
+
this.setSessionState(id, session, "waiting");
|
|
286
321
|
return true;
|
|
287
322
|
}
|
|
288
323
|
/** Change model. Sends control message if alive, always persists to config. */
|
|
@@ -339,11 +374,13 @@ class SessionManager {
|
|
|
339
374
|
label: s.label,
|
|
340
375
|
alive: s.process?.alive ?? false,
|
|
341
376
|
state: s.state,
|
|
377
|
+
agentStatus: !s.process?.alive ? "disconnected" : s.state === "processing" ? "busy" : "idle",
|
|
342
378
|
cwd: s.cwd,
|
|
343
379
|
meta: s.meta,
|
|
344
380
|
config: s.lastStartConfig,
|
|
345
381
|
ccSessionId: s.ccSessionId,
|
|
346
382
|
eventCount: s.eventCounter,
|
|
383
|
+
...this.getMessageStats(s.id),
|
|
347
384
|
createdAt: s.createdAt,
|
|
348
385
|
lastActivityAt: s.lastActivityAt
|
|
349
386
|
}));
|
|
@@ -354,6 +391,23 @@ class SessionManager {
|
|
|
354
391
|
if (session) session.lastActivityAt = Date.now();
|
|
355
392
|
}
|
|
356
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
|
+
}
|
|
357
411
|
persistEvent(sessionId, e) {
|
|
358
412
|
try {
|
|
359
413
|
const db = getDb();
|