@sna-sdk/core 0.3.0 → 0.4.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 +5 -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 +12 -22
- package/dist/core/providers/types.d.ts +2 -0
- 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 +7 -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 +45 -2
- package/dist/server/session-manager.d.ts +14 -3
- package/dist/server/session-manager.js +29 -9
- package/dist/server/standalone.js +249 -61
- package/dist/server/ws.js +50 -3
- package/dist/testing/mock-api.js +20 -0
- package/package.json +1 -1
|
@@ -14,7 +14,7 @@ import { streamSSE } from "hono/streaming";
|
|
|
14
14
|
import { createRequire } from "module";
|
|
15
15
|
import fs from "fs";
|
|
16
16
|
import path from "path";
|
|
17
|
-
var DB_PATH = path.join(process.cwd(), "data/sna.db");
|
|
17
|
+
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() {
|
|
@@ -247,16 +247,84 @@ import { streamSSE as streamSSE3 } from "hono/streaming";
|
|
|
247
247
|
// src/core/providers/claude-code.ts
|
|
248
248
|
import { spawn as spawn2, execSync } from "child_process";
|
|
249
249
|
import { EventEmitter } from "events";
|
|
250
|
-
import
|
|
251
|
-
import
|
|
250
|
+
import fs4 from "fs";
|
|
251
|
+
import path4 from "path";
|
|
252
252
|
|
|
253
|
-
// src/
|
|
254
|
-
import chalk from "chalk";
|
|
253
|
+
// src/core/providers/cc-history-adapter.ts
|
|
255
254
|
import fs2 from "fs";
|
|
256
255
|
import path2 from "path";
|
|
257
|
-
|
|
256
|
+
function writeHistoryJsonl(history, opts) {
|
|
257
|
+
for (let i = 1; i < history.length; i++) {
|
|
258
|
+
if (history[i].role === history[i - 1].role) {
|
|
259
|
+
throw new Error(
|
|
260
|
+
`History validation failed: consecutive ${history[i].role} at index ${i - 1} and ${i}. Messages must alternate user\u2194assistant. Merge tool results into text before injecting.`
|
|
261
|
+
);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
try {
|
|
265
|
+
const dir = path2.join(opts.cwd, ".sna", "history");
|
|
266
|
+
fs2.mkdirSync(dir, { recursive: true });
|
|
267
|
+
const sessionId = crypto.randomUUID();
|
|
268
|
+
const filePath = path2.join(dir, `${sessionId}.jsonl`);
|
|
269
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
270
|
+
const lines = [];
|
|
271
|
+
let prevUuid = null;
|
|
272
|
+
for (const msg of history) {
|
|
273
|
+
const uuid = crypto.randomUUID();
|
|
274
|
+
if (msg.role === "user") {
|
|
275
|
+
lines.push(JSON.stringify({
|
|
276
|
+
parentUuid: prevUuid,
|
|
277
|
+
isSidechain: false,
|
|
278
|
+
type: "user",
|
|
279
|
+
uuid,
|
|
280
|
+
timestamp: now,
|
|
281
|
+
cwd: opts.cwd,
|
|
282
|
+
sessionId,
|
|
283
|
+
message: { role: "user", content: msg.content }
|
|
284
|
+
}));
|
|
285
|
+
} else {
|
|
286
|
+
lines.push(JSON.stringify({
|
|
287
|
+
parentUuid: prevUuid,
|
|
288
|
+
isSidechain: false,
|
|
289
|
+
type: "assistant",
|
|
290
|
+
uuid,
|
|
291
|
+
timestamp: now,
|
|
292
|
+
cwd: opts.cwd,
|
|
293
|
+
sessionId,
|
|
294
|
+
message: {
|
|
295
|
+
role: "assistant",
|
|
296
|
+
content: [{ type: "text", text: msg.content }]
|
|
297
|
+
}
|
|
298
|
+
}));
|
|
299
|
+
}
|
|
300
|
+
prevUuid = uuid;
|
|
301
|
+
}
|
|
302
|
+
fs2.writeFileSync(filePath, lines.join("\n") + "\n");
|
|
303
|
+
return { filePath, extraArgs: ["--resume", filePath] };
|
|
304
|
+
} catch {
|
|
305
|
+
return null;
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
function buildRecalledConversation(history) {
|
|
309
|
+
const xml = history.map((msg) => `<${msg.role}>${msg.content}</${msg.role}>`).join("\n");
|
|
310
|
+
return JSON.stringify({
|
|
311
|
+
type: "assistant",
|
|
312
|
+
message: {
|
|
313
|
+
role: "assistant",
|
|
314
|
+
content: [{ type: "text", text: `<recalled-conversation>
|
|
315
|
+
${xml}
|
|
316
|
+
</recalled-conversation>` }]
|
|
317
|
+
}
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// src/lib/logger.ts
|
|
322
|
+
import chalk from "chalk";
|
|
323
|
+
import fs3 from "fs";
|
|
324
|
+
import path3 from "path";
|
|
325
|
+
var LOG_PATH = path3.join(process.cwd(), ".dev.log");
|
|
258
326
|
try {
|
|
259
|
-
|
|
327
|
+
fs3.writeFileSync(LOG_PATH, "");
|
|
260
328
|
} catch {
|
|
261
329
|
}
|
|
262
330
|
function tsPlain() {
|
|
@@ -288,7 +356,7 @@ var tagPlain = {
|
|
|
288
356
|
function appendFile(tag, args) {
|
|
289
357
|
const line = `${tsPlain()} ${tag} ${args.map((a) => typeof a === "string" ? a : JSON.stringify(a)).join(" ")}
|
|
290
358
|
`;
|
|
291
|
-
|
|
359
|
+
fs3.appendFile(LOG_PATH, line, () => {
|
|
292
360
|
});
|
|
293
361
|
}
|
|
294
362
|
function log(tag, ...args) {
|
|
@@ -305,9 +373,9 @@ var logger = { log, err };
|
|
|
305
373
|
var SHELL = process.env.SHELL || "/bin/zsh";
|
|
306
374
|
function resolveClaudePath(cwd) {
|
|
307
375
|
if (process.env.SNA_CLAUDE_COMMAND) return process.env.SNA_CLAUDE_COMMAND;
|
|
308
|
-
const cached =
|
|
309
|
-
if (
|
|
310
|
-
const p =
|
|
376
|
+
const cached = path4.join(cwd, ".sna/claude-path");
|
|
377
|
+
if (fs4.existsSync(cached)) {
|
|
378
|
+
const p = fs4.readFileSync(cached, "utf8").trim();
|
|
311
379
|
if (p) {
|
|
312
380
|
try {
|
|
313
381
|
execSync(`test -x "${p}"`, { stdio: "pipe" });
|
|
@@ -378,28 +446,9 @@ var ClaudeCodeProcess = class {
|
|
|
378
446
|
this._alive = false;
|
|
379
447
|
this.emitter.emit("error", err2);
|
|
380
448
|
});
|
|
381
|
-
if (options.history?.length) {
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
}
|
|
385
|
-
for (const msg of options.history) {
|
|
386
|
-
if (msg.role === "user") {
|
|
387
|
-
const line = JSON.stringify({
|
|
388
|
-
type: "user",
|
|
389
|
-
message: { role: "user", content: msg.content }
|
|
390
|
-
});
|
|
391
|
-
this.proc.stdin.write(line + "\n");
|
|
392
|
-
} else if (msg.role === "assistant") {
|
|
393
|
-
const line = JSON.stringify({
|
|
394
|
-
type: "assistant",
|
|
395
|
-
message: {
|
|
396
|
-
role: "assistant",
|
|
397
|
-
content: [{ type: "text", text: msg.content }]
|
|
398
|
-
}
|
|
399
|
-
});
|
|
400
|
-
this.proc.stdin.write(line + "\n");
|
|
401
|
-
}
|
|
402
|
-
}
|
|
449
|
+
if (options.history?.length && !options._historyViaResume) {
|
|
450
|
+
const line = buildRecalledConversation(options.history);
|
|
451
|
+
this.proc.stdin.write(line + "\n");
|
|
403
452
|
}
|
|
404
453
|
if (options.prompt) {
|
|
405
454
|
this.send(options.prompt);
|
|
@@ -640,6 +689,14 @@ var ClaudeCodeProvider = class {
|
|
|
640
689
|
if (options.permissionMode) {
|
|
641
690
|
args.push("--permission-mode", options.permissionMode);
|
|
642
691
|
}
|
|
692
|
+
if (options.history?.length && options.prompt) {
|
|
693
|
+
const result = writeHistoryJsonl(options.history, { cwd: options.cwd });
|
|
694
|
+
if (result) {
|
|
695
|
+
args.push(...result.extraArgs);
|
|
696
|
+
options._historyViaResume = true;
|
|
697
|
+
logger.log("agent", `history via JSONL resume \u2192 ${result.filePath}`);
|
|
698
|
+
}
|
|
699
|
+
}
|
|
643
700
|
if (extraArgsClean.length > 0) {
|
|
644
701
|
args.push(...extraArgsClean);
|
|
645
702
|
}
|
|
@@ -682,11 +739,34 @@ function getProvider(name = "claude-code") {
|
|
|
682
739
|
return provider2;
|
|
683
740
|
}
|
|
684
741
|
|
|
742
|
+
// src/server/history-builder.ts
|
|
743
|
+
function buildHistoryFromDb(sessionId) {
|
|
744
|
+
const db = getDb();
|
|
745
|
+
const rows = db.prepare(
|
|
746
|
+
`SELECT role, content FROM chat_messages
|
|
747
|
+
WHERE session_id = ? AND role IN ('user', 'assistant')
|
|
748
|
+
ORDER BY id ASC`
|
|
749
|
+
).all(sessionId);
|
|
750
|
+
if (rows.length === 0) return [];
|
|
751
|
+
const merged = [];
|
|
752
|
+
for (const row of rows) {
|
|
753
|
+
const role = row.role;
|
|
754
|
+
if (!row.content?.trim()) continue;
|
|
755
|
+
const last = merged[merged.length - 1];
|
|
756
|
+
if (last && last.role === role) {
|
|
757
|
+
last.content += "\n\n" + row.content;
|
|
758
|
+
} else {
|
|
759
|
+
merged.push({ role, content: row.content });
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
return merged;
|
|
763
|
+
}
|
|
764
|
+
|
|
685
765
|
// src/server/image-store.ts
|
|
686
|
-
import
|
|
687
|
-
import
|
|
766
|
+
import fs5 from "fs";
|
|
767
|
+
import path5 from "path";
|
|
688
768
|
import { createHash } from "crypto";
|
|
689
|
-
var IMAGE_DIR =
|
|
769
|
+
var IMAGE_DIR = path5.join(process.cwd(), "data/images");
|
|
690
770
|
var MIME_TO_EXT = {
|
|
691
771
|
"image/png": "png",
|
|
692
772
|
"image/jpeg": "jpg",
|
|
@@ -695,23 +775,23 @@ var MIME_TO_EXT = {
|
|
|
695
775
|
"image/svg+xml": "svg"
|
|
696
776
|
};
|
|
697
777
|
function saveImages(sessionId, images) {
|
|
698
|
-
const dir =
|
|
699
|
-
|
|
778
|
+
const dir = path5.join(IMAGE_DIR, sessionId);
|
|
779
|
+
fs5.mkdirSync(dir, { recursive: true });
|
|
700
780
|
return images.map((img) => {
|
|
701
781
|
const ext = MIME_TO_EXT[img.mimeType] ?? "bin";
|
|
702
782
|
const hash = createHash("sha256").update(img.base64).digest("hex").slice(0, 12);
|
|
703
783
|
const filename = `${hash}.${ext}`;
|
|
704
|
-
const filePath =
|
|
705
|
-
if (!
|
|
706
|
-
|
|
784
|
+
const filePath = path5.join(dir, filename);
|
|
785
|
+
if (!fs5.existsSync(filePath)) {
|
|
786
|
+
fs5.writeFileSync(filePath, Buffer.from(img.base64, "base64"));
|
|
707
787
|
}
|
|
708
788
|
return filename;
|
|
709
789
|
});
|
|
710
790
|
}
|
|
711
791
|
function resolveImagePath(sessionId, filename) {
|
|
712
792
|
if (filename.includes("..") || filename.includes("/")) return null;
|
|
713
|
-
const filePath =
|
|
714
|
-
return
|
|
793
|
+
const filePath = path5.join(IMAGE_DIR, sessionId, filename);
|
|
794
|
+
return fs5.existsSync(filePath) ? filePath : null;
|
|
715
795
|
}
|
|
716
796
|
|
|
717
797
|
// src/server/routes/agent.ts
|
|
@@ -902,7 +982,7 @@ function createAgentRoutes(sessionManager2) {
|
|
|
902
982
|
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);
|
|
903
983
|
} catch {
|
|
904
984
|
}
|
|
905
|
-
|
|
985
|
+
sessionManager2.updateSessionState(sessionId, "processing");
|
|
906
986
|
sessionManager2.touch(sessionId);
|
|
907
987
|
if (body.images?.length) {
|
|
908
988
|
const content = [
|
|
@@ -980,6 +1060,46 @@ function createAgentRoutes(sessionManager2) {
|
|
|
980
1060
|
return c.json({ status: "error", message: e.message }, 500);
|
|
981
1061
|
}
|
|
982
1062
|
});
|
|
1063
|
+
app.post("/resume", async (c) => {
|
|
1064
|
+
const sessionId = getSessionId(c);
|
|
1065
|
+
const body = await c.req.json().catch(() => ({}));
|
|
1066
|
+
const session = sessionManager2.getOrCreateSession(sessionId);
|
|
1067
|
+
if (session.process?.alive) {
|
|
1068
|
+
return c.json({ status: "error", message: "Session already running. Use agent.send instead." }, 400);
|
|
1069
|
+
}
|
|
1070
|
+
const history = buildHistoryFromDb(sessionId);
|
|
1071
|
+
if (history.length === 0 && !body.prompt) {
|
|
1072
|
+
return c.json({ status: "error", message: "No history in DB \u2014 nothing to resume." }, 400);
|
|
1073
|
+
}
|
|
1074
|
+
const providerName = body.provider ?? "claude-code";
|
|
1075
|
+
const model = body.model ?? session.lastStartConfig?.model ?? "claude-sonnet-4-6";
|
|
1076
|
+
const permissionMode2 = body.permissionMode ?? session.lastStartConfig?.permissionMode ?? "acceptEdits";
|
|
1077
|
+
const extraArgs = body.extraArgs ?? session.lastStartConfig?.extraArgs;
|
|
1078
|
+
const provider2 = getProvider(providerName);
|
|
1079
|
+
try {
|
|
1080
|
+
const proc = provider2.spawn({
|
|
1081
|
+
cwd: session.cwd,
|
|
1082
|
+
prompt: body.prompt,
|
|
1083
|
+
model,
|
|
1084
|
+
permissionMode: permissionMode2,
|
|
1085
|
+
env: { SNA_SESSION_ID: sessionId },
|
|
1086
|
+
history: history.length > 0 ? history : void 0,
|
|
1087
|
+
extraArgs
|
|
1088
|
+
});
|
|
1089
|
+
sessionManager2.setProcess(sessionId, proc, "resumed");
|
|
1090
|
+
sessionManager2.saveStartConfig(sessionId, { provider: providerName, model, permissionMode: permissionMode2, extraArgs });
|
|
1091
|
+
logger.log("route", `POST /resume?session=${sessionId} \u2192 resumed (${history.length} history msgs)`);
|
|
1092
|
+
return httpJson(c, "agent.resume", {
|
|
1093
|
+
status: "resumed",
|
|
1094
|
+
provider: providerName,
|
|
1095
|
+
sessionId: session.id,
|
|
1096
|
+
historyCount: history.length
|
|
1097
|
+
});
|
|
1098
|
+
} catch (e) {
|
|
1099
|
+
logger.err("err", `POST /resume?session=${sessionId} \u2192 ${e.message}`);
|
|
1100
|
+
return c.json({ status: "error", message: e.message }, 500);
|
|
1101
|
+
}
|
|
1102
|
+
});
|
|
983
1103
|
app.post("/interrupt", async (c) => {
|
|
984
1104
|
const sessionId = getSessionId(c);
|
|
985
1105
|
const interrupted = sessionManager2.interruptSession(sessionId);
|
|
@@ -1007,8 +1127,10 @@ function createAgentRoutes(sessionManager2) {
|
|
|
1007
1127
|
app.get("/status", (c) => {
|
|
1008
1128
|
const sessionId = getSessionId(c);
|
|
1009
1129
|
const session = sessionManager2.getSession(sessionId);
|
|
1130
|
+
const alive = session?.process?.alive ?? false;
|
|
1010
1131
|
return httpJson(c, "agent.status", {
|
|
1011
|
-
alive
|
|
1132
|
+
alive,
|
|
1133
|
+
agentStatus: !alive ? "disconnected" : session?.state === "processing" ? "busy" : "idle",
|
|
1012
1134
|
sessionId: session?.process?.sessionId ?? null,
|
|
1013
1135
|
ccSessionId: session?.ccSessionId ?? null,
|
|
1014
1136
|
eventCount: session?.eventCounter ?? 0,
|
|
@@ -1046,7 +1168,7 @@ function createAgentRoutes(sessionManager2) {
|
|
|
1046
1168
|
|
|
1047
1169
|
// src/server/routes/chat.ts
|
|
1048
1170
|
import { Hono as Hono2 } from "hono";
|
|
1049
|
-
import
|
|
1171
|
+
import fs6 from "fs";
|
|
1050
1172
|
function createChatRoutes() {
|
|
1051
1173
|
const app = new Hono2();
|
|
1052
1174
|
app.get("/sessions", (c) => {
|
|
@@ -1152,7 +1274,7 @@ function createChatRoutes() {
|
|
|
1152
1274
|
svg: "image/svg+xml"
|
|
1153
1275
|
};
|
|
1154
1276
|
const contentType = mimeMap[ext ?? ""] ?? "application/octet-stream";
|
|
1155
|
-
const data =
|
|
1277
|
+
const data = fs6.readFileSync(filePath);
|
|
1156
1278
|
return new Response(data, { headers: { "Content-Type": contentType, "Cache-Control": "public, max-age=31536000, immutable" } });
|
|
1157
1279
|
});
|
|
1158
1280
|
return app;
|
|
@@ -1171,6 +1293,7 @@ var SessionManager = class {
|
|
|
1171
1293
|
this.permissionRequestListeners = /* @__PURE__ */ new Set();
|
|
1172
1294
|
this.lifecycleListeners = /* @__PURE__ */ new Set();
|
|
1173
1295
|
this.configChangedListeners = /* @__PURE__ */ new Set();
|
|
1296
|
+
this.stateChangedListeners = /* @__PURE__ */ new Set();
|
|
1174
1297
|
this.maxSessions = options.maxSessions ?? DEFAULT_MAX_SESSIONS;
|
|
1175
1298
|
this.restoreFromDb();
|
|
1176
1299
|
}
|
|
@@ -1283,11 +1406,11 @@ var SessionManager = class {
|
|
|
1283
1406
|
return this.createSession({ id, ...opts });
|
|
1284
1407
|
}
|
|
1285
1408
|
/** Set the agent process for a session. Subscribes to events. */
|
|
1286
|
-
setProcess(sessionId, proc) {
|
|
1409
|
+
setProcess(sessionId, proc, lifecycleState) {
|
|
1287
1410
|
const session = this.sessions.get(sessionId);
|
|
1288
1411
|
if (!session) throw new Error(`Session "${sessionId}" not found`);
|
|
1289
1412
|
session.process = proc;
|
|
1290
|
-
|
|
1413
|
+
this.setSessionState(sessionId, session, "processing");
|
|
1291
1414
|
session.lastActivityAt = Date.now();
|
|
1292
1415
|
proc.on("event", (e) => {
|
|
1293
1416
|
if (e.type === "init" && e.data?.sessionId && !session.ccSessionId) {
|
|
@@ -1300,7 +1423,7 @@ var SessionManager = class {
|
|
|
1300
1423
|
session.eventBuffer.splice(0, session.eventBuffer.length - MAX_EVENT_BUFFER);
|
|
1301
1424
|
}
|
|
1302
1425
|
if (e.type === "complete" || e.type === "error" || e.type === "interrupted") {
|
|
1303
|
-
|
|
1426
|
+
this.setSessionState(sessionId, session, "waiting");
|
|
1304
1427
|
}
|
|
1305
1428
|
this.persistEvent(sessionId, e);
|
|
1306
1429
|
const listeners = this.eventListeners.get(sessionId);
|
|
@@ -1309,14 +1432,14 @@ var SessionManager = class {
|
|
|
1309
1432
|
}
|
|
1310
1433
|
});
|
|
1311
1434
|
proc.on("exit", (code) => {
|
|
1312
|
-
|
|
1435
|
+
this.setSessionState(sessionId, session, "idle");
|
|
1313
1436
|
this.emitLifecycle({ session: sessionId, state: code != null ? "exited" : "crashed", code });
|
|
1314
1437
|
});
|
|
1315
1438
|
proc.on("error", () => {
|
|
1316
|
-
|
|
1439
|
+
this.setSessionState(sessionId, session, "idle");
|
|
1317
1440
|
this.emitLifecycle({ session: sessionId, state: "crashed" });
|
|
1318
1441
|
});
|
|
1319
|
-
this.emitLifecycle({ session: sessionId, state: "started" });
|
|
1442
|
+
this.emitLifecycle({ session: sessionId, state: lifecycleState ?? "started" });
|
|
1320
1443
|
}
|
|
1321
1444
|
// ── Event pub/sub (for WebSocket) ─────────────────────────────
|
|
1322
1445
|
/** Subscribe to real-time events for a session. Returns unsubscribe function. */
|
|
@@ -1366,11 +1489,29 @@ var SessionManager = class {
|
|
|
1366
1489
|
emitConfigChanged(sessionId, config) {
|
|
1367
1490
|
for (const cb of this.configChangedListeners) cb({ session: sessionId, config });
|
|
1368
1491
|
}
|
|
1492
|
+
// ── Agent status change pub/sub ────────────────────────────────
|
|
1493
|
+
onStateChanged(cb) {
|
|
1494
|
+
this.stateChangedListeners.add(cb);
|
|
1495
|
+
return () => this.stateChangedListeners.delete(cb);
|
|
1496
|
+
}
|
|
1497
|
+
/** Update session state and push agentStatus change to subscribers. */
|
|
1498
|
+
updateSessionState(sessionId, newState) {
|
|
1499
|
+
const session = this.sessions.get(sessionId);
|
|
1500
|
+
if (session) this.setSessionState(sessionId, session, newState);
|
|
1501
|
+
}
|
|
1502
|
+
setSessionState(sessionId, session, newState) {
|
|
1503
|
+
const oldState = session.state;
|
|
1504
|
+
session.state = newState;
|
|
1505
|
+
const newStatus = !session.process?.alive ? "disconnected" : newState === "processing" ? "busy" : "idle";
|
|
1506
|
+
if (oldState !== newState) {
|
|
1507
|
+
for (const cb of this.stateChangedListeners) cb({ session: sessionId, agentStatus: newStatus, state: newState });
|
|
1508
|
+
}
|
|
1509
|
+
}
|
|
1369
1510
|
// ── Permission management ─────────────────────────────────────
|
|
1370
1511
|
/** Create a pending permission request. Returns a promise that resolves when approved/denied. */
|
|
1371
1512
|
createPendingPermission(sessionId, request) {
|
|
1372
1513
|
const session = this.sessions.get(sessionId);
|
|
1373
|
-
if (session)
|
|
1514
|
+
if (session) this.setSessionState(sessionId, session, "permission");
|
|
1374
1515
|
return new Promise((resolve) => {
|
|
1375
1516
|
const createdAt = Date.now();
|
|
1376
1517
|
this.pendingPermissions.set(sessionId, { resolve, request, createdAt });
|
|
@@ -1390,7 +1531,7 @@ var SessionManager = class {
|
|
|
1390
1531
|
pending.resolve(approved);
|
|
1391
1532
|
this.pendingPermissions.delete(sessionId);
|
|
1392
1533
|
const session = this.sessions.get(sessionId);
|
|
1393
|
-
if (session)
|
|
1534
|
+
if (session) this.setSessionState(sessionId, session, "processing");
|
|
1394
1535
|
return true;
|
|
1395
1536
|
}
|
|
1396
1537
|
/** Get a pending permission for a specific session. */
|
|
@@ -1442,7 +1583,7 @@ var SessionManager = class {
|
|
|
1442
1583
|
const session = this.sessions.get(id);
|
|
1443
1584
|
if (!session?.process?.alive) return false;
|
|
1444
1585
|
session.process.interrupt();
|
|
1445
|
-
|
|
1586
|
+
this.setSessionState(id, session, "waiting");
|
|
1446
1587
|
return true;
|
|
1447
1588
|
}
|
|
1448
1589
|
/** Change model. Sends control message if alive, always persists to config. */
|
|
@@ -1499,6 +1640,7 @@ var SessionManager = class {
|
|
|
1499
1640
|
label: s.label,
|
|
1500
1641
|
alive: s.process?.alive ?? false,
|
|
1501
1642
|
state: s.state,
|
|
1643
|
+
agentStatus: !s.process?.alive ? "disconnected" : s.state === "processing" ? "busy" : "idle",
|
|
1502
1644
|
cwd: s.cwd,
|
|
1503
1645
|
meta: s.meta,
|
|
1504
1646
|
config: s.lastStartConfig,
|
|
@@ -1586,13 +1728,16 @@ function attachWebSocket(server2, sessionManager2) {
|
|
|
1586
1728
|
});
|
|
1587
1729
|
wss.on("connection", (ws) => {
|
|
1588
1730
|
logger.log("ws", "client connected");
|
|
1589
|
-
const state = { agentUnsubs: /* @__PURE__ */ new Map(), skillEventUnsub: null, skillPollTimer: null, permissionUnsub: null, lifecycleUnsub: null, configChangedUnsub: null };
|
|
1731
|
+
const state = { agentUnsubs: /* @__PURE__ */ new Map(), skillEventUnsub: null, skillPollTimer: null, permissionUnsub: null, lifecycleUnsub: null, configChangedUnsub: null, stateChangedUnsub: null };
|
|
1590
1732
|
state.lifecycleUnsub = sessionManager2.onSessionLifecycle((event) => {
|
|
1591
1733
|
send(ws, { type: "session.lifecycle", ...event });
|
|
1592
1734
|
});
|
|
1593
1735
|
state.configChangedUnsub = sessionManager2.onConfigChanged((event) => {
|
|
1594
1736
|
send(ws, { type: "session.config-changed", ...event });
|
|
1595
1737
|
});
|
|
1738
|
+
state.stateChangedUnsub = sessionManager2.onStateChanged((event) => {
|
|
1739
|
+
send(ws, { type: "session.state-changed", ...event });
|
|
1740
|
+
});
|
|
1596
1741
|
ws.on("message", (raw) => {
|
|
1597
1742
|
let msg;
|
|
1598
1743
|
try {
|
|
@@ -1623,6 +1768,8 @@ function attachWebSocket(server2, sessionManager2) {
|
|
|
1623
1768
|
state.lifecycleUnsub = null;
|
|
1624
1769
|
state.configChangedUnsub?.();
|
|
1625
1770
|
state.configChangedUnsub = null;
|
|
1771
|
+
state.stateChangedUnsub?.();
|
|
1772
|
+
state.stateChangedUnsub = null;
|
|
1626
1773
|
});
|
|
1627
1774
|
});
|
|
1628
1775
|
return wss;
|
|
@@ -1641,6 +1788,8 @@ function handleMessage(ws, msg, sm, state) {
|
|
|
1641
1788
|
return handleAgentStart(ws, msg, sm);
|
|
1642
1789
|
case "agent.send":
|
|
1643
1790
|
return handleAgentSend(ws, msg, sm);
|
|
1791
|
+
case "agent.resume":
|
|
1792
|
+
return handleAgentResume(ws, msg, sm);
|
|
1644
1793
|
case "agent.restart":
|
|
1645
1794
|
return handleAgentRestart(ws, msg, sm);
|
|
1646
1795
|
case "agent.interrupt":
|
|
@@ -1782,7 +1931,7 @@ function handleAgentSend(ws, msg, sm) {
|
|
|
1782
1931
|
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);
|
|
1783
1932
|
} catch {
|
|
1784
1933
|
}
|
|
1785
|
-
|
|
1934
|
+
sm.updateSessionState(sessionId, "processing");
|
|
1786
1935
|
sm.touch(sessionId);
|
|
1787
1936
|
if (images?.length) {
|
|
1788
1937
|
const content = [
|
|
@@ -1798,6 +1947,43 @@ function handleAgentSend(ws, msg, sm) {
|
|
|
1798
1947
|
}
|
|
1799
1948
|
wsReply(ws, msg, { status: "sent" });
|
|
1800
1949
|
}
|
|
1950
|
+
function handleAgentResume(ws, msg, sm) {
|
|
1951
|
+
const sessionId = msg.session ?? "default";
|
|
1952
|
+
const session = sm.getOrCreateSession(sessionId);
|
|
1953
|
+
if (session.process?.alive) {
|
|
1954
|
+
return replyError(ws, msg, "Session already running. Use agent.send instead.");
|
|
1955
|
+
}
|
|
1956
|
+
const history = buildHistoryFromDb(sessionId);
|
|
1957
|
+
if (history.length === 0 && !msg.prompt) {
|
|
1958
|
+
return replyError(ws, msg, "No history in DB \u2014 nothing to resume.");
|
|
1959
|
+
}
|
|
1960
|
+
const providerName = msg.provider ?? session.lastStartConfig?.provider ?? "claude-code";
|
|
1961
|
+
const model = msg.model ?? session.lastStartConfig?.model ?? "claude-sonnet-4-6";
|
|
1962
|
+
const permissionMode2 = msg.permissionMode ?? session.lastStartConfig?.permissionMode ?? "acceptEdits";
|
|
1963
|
+
const extraArgs = msg.extraArgs ?? session.lastStartConfig?.extraArgs;
|
|
1964
|
+
const provider2 = getProvider(providerName);
|
|
1965
|
+
try {
|
|
1966
|
+
const proc = provider2.spawn({
|
|
1967
|
+
cwd: session.cwd,
|
|
1968
|
+
prompt: msg.prompt,
|
|
1969
|
+
model,
|
|
1970
|
+
permissionMode: permissionMode2,
|
|
1971
|
+
env: { SNA_SESSION_ID: sessionId },
|
|
1972
|
+
history: history.length > 0 ? history : void 0,
|
|
1973
|
+
extraArgs
|
|
1974
|
+
});
|
|
1975
|
+
sm.setProcess(sessionId, proc, "resumed");
|
|
1976
|
+
sm.saveStartConfig(sessionId, { provider: providerName, model, permissionMode: permissionMode2, extraArgs });
|
|
1977
|
+
wsReply(ws, msg, {
|
|
1978
|
+
status: "resumed",
|
|
1979
|
+
provider: providerName,
|
|
1980
|
+
sessionId: session.id,
|
|
1981
|
+
historyCount: history.length
|
|
1982
|
+
});
|
|
1983
|
+
} catch (e) {
|
|
1984
|
+
replyError(ws, msg, e.message);
|
|
1985
|
+
}
|
|
1986
|
+
}
|
|
1801
1987
|
function handleAgentRestart(ws, msg, sm) {
|
|
1802
1988
|
const sessionId = msg.session ?? "default";
|
|
1803
1989
|
try {
|
|
@@ -1854,8 +2040,10 @@ function handleAgentKill(ws, msg, sm) {
|
|
|
1854
2040
|
function handleAgentStatus(ws, msg, sm) {
|
|
1855
2041
|
const sessionId = msg.session ?? "default";
|
|
1856
2042
|
const session = sm.getSession(sessionId);
|
|
2043
|
+
const alive = session?.process?.alive ?? false;
|
|
1857
2044
|
wsReply(ws, msg, {
|
|
1858
|
-
alive
|
|
2045
|
+
alive,
|
|
2046
|
+
agentStatus: !alive ? "disconnected" : session?.state === "processing" ? "busy" : "idle",
|
|
1859
2047
|
sessionId: session?.process?.sessionId ?? null,
|
|
1860
2048
|
ccSessionId: session?.ccSessionId ?? null,
|
|
1861
2049
|
eventCount: session?.eventCounter ?? 0,
|
|
@@ -2135,8 +2323,8 @@ var methodColor = {
|
|
|
2135
2323
|
root.use("*", async (c, next) => {
|
|
2136
2324
|
const m = c.req.method;
|
|
2137
2325
|
const colorFn = methodColor[m] ?? chalk2.white;
|
|
2138
|
-
const
|
|
2139
|
-
logger.log("req", `${colorFn(m.padEnd(6))} ${
|
|
2326
|
+
const path6 = new URL(c.req.url).pathname;
|
|
2327
|
+
logger.log("req", `${colorFn(m.padEnd(6))} ${path6}`);
|
|
2140
2328
|
await next();
|
|
2141
2329
|
});
|
|
2142
2330
|
var sessionManager = new SessionManager({ maxSessions });
|
package/dist/server/ws.js
CHANGED
|
@@ -4,6 +4,7 @@ import { getDb } from "../db/schema.js";
|
|
|
4
4
|
import { logger } from "../lib/logger.js";
|
|
5
5
|
import { runOnce } from "./routes/agent.js";
|
|
6
6
|
import { wsReply } from "./api-types.js";
|
|
7
|
+
import { buildHistoryFromDb } from "./history-builder.js";
|
|
7
8
|
import { saveImages } from "./image-store.js";
|
|
8
9
|
function send(ws, data) {
|
|
9
10
|
if (ws.readyState === ws.OPEN) {
|
|
@@ -30,13 +31,16 @@ function attachWebSocket(server, sessionManager) {
|
|
|
30
31
|
});
|
|
31
32
|
wss.on("connection", (ws) => {
|
|
32
33
|
logger.log("ws", "client connected");
|
|
33
|
-
const state = { agentUnsubs: /* @__PURE__ */ new Map(), skillEventUnsub: null, skillPollTimer: null, permissionUnsub: null, lifecycleUnsub: null, configChangedUnsub: null };
|
|
34
|
+
const state = { agentUnsubs: /* @__PURE__ */ new Map(), skillEventUnsub: null, skillPollTimer: null, permissionUnsub: null, lifecycleUnsub: null, configChangedUnsub: null, stateChangedUnsub: null };
|
|
34
35
|
state.lifecycleUnsub = sessionManager.onSessionLifecycle((event) => {
|
|
35
36
|
send(ws, { type: "session.lifecycle", ...event });
|
|
36
37
|
});
|
|
37
38
|
state.configChangedUnsub = sessionManager.onConfigChanged((event) => {
|
|
38
39
|
send(ws, { type: "session.config-changed", ...event });
|
|
39
40
|
});
|
|
41
|
+
state.stateChangedUnsub = sessionManager.onStateChanged((event) => {
|
|
42
|
+
send(ws, { type: "session.state-changed", ...event });
|
|
43
|
+
});
|
|
40
44
|
ws.on("message", (raw) => {
|
|
41
45
|
let msg;
|
|
42
46
|
try {
|
|
@@ -67,6 +71,8 @@ function attachWebSocket(server, sessionManager) {
|
|
|
67
71
|
state.lifecycleUnsub = null;
|
|
68
72
|
state.configChangedUnsub?.();
|
|
69
73
|
state.configChangedUnsub = null;
|
|
74
|
+
state.stateChangedUnsub?.();
|
|
75
|
+
state.stateChangedUnsub = null;
|
|
70
76
|
});
|
|
71
77
|
});
|
|
72
78
|
return wss;
|
|
@@ -85,6 +91,8 @@ function handleMessage(ws, msg, sm, state) {
|
|
|
85
91
|
return handleAgentStart(ws, msg, sm);
|
|
86
92
|
case "agent.send":
|
|
87
93
|
return handleAgentSend(ws, msg, sm);
|
|
94
|
+
case "agent.resume":
|
|
95
|
+
return handleAgentResume(ws, msg, sm);
|
|
88
96
|
case "agent.restart":
|
|
89
97
|
return handleAgentRestart(ws, msg, sm);
|
|
90
98
|
case "agent.interrupt":
|
|
@@ -226,7 +234,7 @@ function handleAgentSend(ws, msg, sm) {
|
|
|
226
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);
|
|
227
235
|
} catch {
|
|
228
236
|
}
|
|
229
|
-
|
|
237
|
+
sm.updateSessionState(sessionId, "processing");
|
|
230
238
|
sm.touch(sessionId);
|
|
231
239
|
if (images?.length) {
|
|
232
240
|
const content = [
|
|
@@ -242,6 +250,43 @@ function handleAgentSend(ws, msg, sm) {
|
|
|
242
250
|
}
|
|
243
251
|
wsReply(ws, msg, { status: "sent" });
|
|
244
252
|
}
|
|
253
|
+
function handleAgentResume(ws, msg, sm) {
|
|
254
|
+
const sessionId = msg.session ?? "default";
|
|
255
|
+
const session = sm.getOrCreateSession(sessionId);
|
|
256
|
+
if (session.process?.alive) {
|
|
257
|
+
return replyError(ws, msg, "Session already running. Use agent.send instead.");
|
|
258
|
+
}
|
|
259
|
+
const history = buildHistoryFromDb(sessionId);
|
|
260
|
+
if (history.length === 0 && !msg.prompt) {
|
|
261
|
+
return replyError(ws, msg, "No history in DB \u2014 nothing to resume.");
|
|
262
|
+
}
|
|
263
|
+
const providerName = msg.provider ?? session.lastStartConfig?.provider ?? "claude-code";
|
|
264
|
+
const model = msg.model ?? session.lastStartConfig?.model ?? "claude-sonnet-4-6";
|
|
265
|
+
const permissionMode = msg.permissionMode ?? session.lastStartConfig?.permissionMode ?? "acceptEdits";
|
|
266
|
+
const extraArgs = msg.extraArgs ?? session.lastStartConfig?.extraArgs;
|
|
267
|
+
const provider = getProvider(providerName);
|
|
268
|
+
try {
|
|
269
|
+
const proc = provider.spawn({
|
|
270
|
+
cwd: session.cwd,
|
|
271
|
+
prompt: msg.prompt,
|
|
272
|
+
model,
|
|
273
|
+
permissionMode,
|
|
274
|
+
env: { SNA_SESSION_ID: sessionId },
|
|
275
|
+
history: history.length > 0 ? history : void 0,
|
|
276
|
+
extraArgs
|
|
277
|
+
});
|
|
278
|
+
sm.setProcess(sessionId, proc, "resumed");
|
|
279
|
+
sm.saveStartConfig(sessionId, { provider: providerName, model, permissionMode, extraArgs });
|
|
280
|
+
wsReply(ws, msg, {
|
|
281
|
+
status: "resumed",
|
|
282
|
+
provider: providerName,
|
|
283
|
+
sessionId: session.id,
|
|
284
|
+
historyCount: history.length
|
|
285
|
+
});
|
|
286
|
+
} catch (e) {
|
|
287
|
+
replyError(ws, msg, e.message);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
245
290
|
function handleAgentRestart(ws, msg, sm) {
|
|
246
291
|
const sessionId = msg.session ?? "default";
|
|
247
292
|
try {
|
|
@@ -298,8 +343,10 @@ function handleAgentKill(ws, msg, sm) {
|
|
|
298
343
|
function handleAgentStatus(ws, msg, sm) {
|
|
299
344
|
const sessionId = msg.session ?? "default";
|
|
300
345
|
const session = sm.getSession(sessionId);
|
|
346
|
+
const alive = session?.process?.alive ?? false;
|
|
301
347
|
wsReply(ws, msg, {
|
|
302
|
-
alive
|
|
348
|
+
alive,
|
|
349
|
+
agentStatus: !alive ? "disconnected" : session?.state === "processing" ? "busy" : "idle",
|
|
303
350
|
sessionId: session?.process?.sessionId ?? null,
|
|
304
351
|
ccSessionId: session?.ccSessionId ?? null,
|
|
305
352
|
eventCount: session?.eventCounter ?? 0,
|
package/dist/testing/mock-api.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import http from "http";
|
|
2
|
+
import fs from "fs";
|
|
3
|
+
import path from "path";
|
|
2
4
|
function ts() {
|
|
3
5
|
return (/* @__PURE__ */ new Date()).toISOString().slice(11, 23);
|
|
4
6
|
}
|
|
@@ -35,6 +37,24 @@ async function startMockAnthropicServer() {
|
|
|
35
37
|
const realText = textBlocks.find((t) => !t.startsWith("<system-reminder>"));
|
|
36
38
|
userText = realText ?? textBlocks[textBlocks.length - 1] ?? "(no text)";
|
|
37
39
|
}
|
|
40
|
+
console.log(`[${ts()}] BODY KEYS: ${Object.keys(body).join(", ")}`);
|
|
41
|
+
try {
|
|
42
|
+
const dumpPath = path.join(process.cwd(), ".sna/mock-api-last-request.json");
|
|
43
|
+
fs.writeFileSync(dumpPath, JSON.stringify(body, null, 2));
|
|
44
|
+
console.log(`[${ts()}] FULL BODY dumped to .sna/mock-api-last-request.json`);
|
|
45
|
+
} catch {
|
|
46
|
+
}
|
|
47
|
+
if (body.system) {
|
|
48
|
+
const sysText = typeof body.system === "string" ? body.system : JSON.stringify(body.system);
|
|
49
|
+
console.log(`[${ts()}] SYSTEM PROMPT (${sysText.length} chars): ${sysText.slice(0, 300)}...`);
|
|
50
|
+
if (sysText.includes("\uC720\uB2C8") || sysText.includes("\uCEE4\uD53C") || sysText.includes("\uAE30\uC5B5")) {
|
|
51
|
+
console.log(`[${ts()}] *** HISTORY FOUND IN SYSTEM PROMPT ***`);
|
|
52
|
+
for (const keyword of ["\uC720\uB2C8", "\uCEE4\uD53C", "\uAE30\uC5B5"]) {
|
|
53
|
+
const idx = sysText.indexOf(keyword);
|
|
54
|
+
if (idx >= 0) console.log(`[${ts()}] "${keyword}" at pos ${idx}: ...${sysText.slice(Math.max(0, idx - 50), idx + 80)}...`);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
38
58
|
console.log(`[${ts()}] REQ model=${body.model} stream=${body.stream} messages=${body.messages?.length} user="${userText.slice(0, 120)}"`);
|
|
39
59
|
for (let mi = 0; mi < body.messages.length; mi++) {
|
|
40
60
|
const m = body.messages[mi];
|