@sna-sdk/core 0.9.10 → 0.9.11
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/dist/electron/index.cjs +2375 -26
- package/dist/electron/index.d.ts +31 -1
- package/dist/electron/index.js +80 -0
- package/dist/node/index.cjs +339 -18
- package/dist/node/index.d.ts +2 -0
- package/package.json +1 -1
package/dist/electron/index.cjs
CHANGED
|
@@ -34,6 +34,7 @@ __export(electron_exports, {
|
|
|
34
34
|
parseCommandVOutput: () => parseCommandVOutput,
|
|
35
35
|
resolveClaudeCli: () => resolveClaudeCli,
|
|
36
36
|
startSnaServer: () => startSnaServer,
|
|
37
|
+
startSnaServerInProcess: () => startSnaServerInProcess,
|
|
37
38
|
validateClaudePath: () => validateClaudePath
|
|
38
39
|
});
|
|
39
40
|
module.exports = __toCommonJS(electron_exports);
|
|
@@ -43,17 +44,72 @@ var getImportMetaUrl = () => typeof document === "undefined" ? new URL(`file:${_
|
|
|
43
44
|
var importMetaUrl = /* @__PURE__ */ getImportMetaUrl();
|
|
44
45
|
|
|
45
46
|
// src/electron/index.ts
|
|
46
|
-
var
|
|
47
|
-
var
|
|
48
|
-
var
|
|
47
|
+
var import_child_process3 = require("child_process");
|
|
48
|
+
var import_url2 = require("url");
|
|
49
|
+
var import_fs7 = __toESM(require("fs"), 1);
|
|
49
50
|
|
|
50
51
|
// src/core/providers/claude-code.ts
|
|
51
52
|
var import_child_process = require("child_process");
|
|
52
53
|
var import_events = require("events");
|
|
53
|
-
var
|
|
54
|
-
var
|
|
54
|
+
var import_fs3 = __toESM(require("fs"), 1);
|
|
55
|
+
var import_path3 = __toESM(require("path"), 1);
|
|
56
|
+
var import_url = require("url");
|
|
55
57
|
|
|
56
58
|
// src/core/providers/cc-history-adapter.ts
|
|
59
|
+
var import_fs = __toESM(require("fs"), 1);
|
|
60
|
+
var import_path = __toESM(require("path"), 1);
|
|
61
|
+
function writeHistoryJsonl(history, opts) {
|
|
62
|
+
for (let i = 1; i < history.length; i++) {
|
|
63
|
+
if (history[i].role === history[i - 1].role) {
|
|
64
|
+
throw new Error(
|
|
65
|
+
`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.`
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
try {
|
|
70
|
+
const dir = import_path.default.join(opts.cwd, ".sna", "history");
|
|
71
|
+
import_fs.default.mkdirSync(dir, { recursive: true });
|
|
72
|
+
const sessionId = crypto.randomUUID();
|
|
73
|
+
const filePath = import_path.default.join(dir, `${sessionId}.jsonl`);
|
|
74
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
75
|
+
const lines = [];
|
|
76
|
+
let prevUuid = null;
|
|
77
|
+
for (const msg of history) {
|
|
78
|
+
const uuid = crypto.randomUUID();
|
|
79
|
+
if (msg.role === "user") {
|
|
80
|
+
lines.push(JSON.stringify({
|
|
81
|
+
parentUuid: prevUuid,
|
|
82
|
+
isSidechain: false,
|
|
83
|
+
type: "user",
|
|
84
|
+
uuid,
|
|
85
|
+
timestamp: now,
|
|
86
|
+
cwd: opts.cwd,
|
|
87
|
+
sessionId,
|
|
88
|
+
message: { role: "user", content: msg.content }
|
|
89
|
+
}));
|
|
90
|
+
} else {
|
|
91
|
+
lines.push(JSON.stringify({
|
|
92
|
+
parentUuid: prevUuid,
|
|
93
|
+
isSidechain: false,
|
|
94
|
+
type: "assistant",
|
|
95
|
+
uuid,
|
|
96
|
+
timestamp: now,
|
|
97
|
+
cwd: opts.cwd,
|
|
98
|
+
sessionId,
|
|
99
|
+
message: {
|
|
100
|
+
role: "assistant",
|
|
101
|
+
content: [{ type: "text", text: msg.content }]
|
|
102
|
+
}
|
|
103
|
+
}));
|
|
104
|
+
}
|
|
105
|
+
prevUuid = uuid;
|
|
106
|
+
}
|
|
107
|
+
import_fs.default.writeFileSync(filePath, lines.join("\n") + "\n");
|
|
108
|
+
return { filePath, extraArgs: ["--resume", filePath] };
|
|
109
|
+
} catch {
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
57
113
|
function buildRecalledConversation(history) {
|
|
58
114
|
const xml = history.map((msg) => `<${msg.role}>${msg.content}</${msg.role}>`).join("\n");
|
|
59
115
|
return JSON.stringify({
|
|
@@ -68,11 +124,11 @@ ${xml}
|
|
|
68
124
|
}
|
|
69
125
|
|
|
70
126
|
// src/lib/logger.ts
|
|
71
|
-
var
|
|
72
|
-
var
|
|
73
|
-
var LOG_PATH =
|
|
127
|
+
var import_fs2 = __toESM(require("fs"), 1);
|
|
128
|
+
var import_path2 = __toESM(require("path"), 1);
|
|
129
|
+
var LOG_PATH = import_path2.default.join(process.cwd(), ".dev.log");
|
|
74
130
|
try {
|
|
75
|
-
|
|
131
|
+
import_fs2.default.writeFileSync(LOG_PATH, "");
|
|
76
132
|
} catch {
|
|
77
133
|
}
|
|
78
134
|
function ts() {
|
|
@@ -91,7 +147,7 @@ var tags = {
|
|
|
91
147
|
function appendFile(tag, args) {
|
|
92
148
|
const line = `${ts()} ${tag} ${args.map((a) => typeof a === "string" ? a : JSON.stringify(a)).join(" ")}
|
|
93
149
|
`;
|
|
94
|
-
|
|
150
|
+
import_fs2.default.appendFile(LOG_PATH, line, () => {
|
|
95
151
|
});
|
|
96
152
|
}
|
|
97
153
|
function log(tag, ...args) {
|
|
@@ -117,7 +173,7 @@ function parseCommandVOutput(raw) {
|
|
|
117
173
|
}
|
|
118
174
|
function validateClaudePath(claudePath) {
|
|
119
175
|
try {
|
|
120
|
-
const claudeDir =
|
|
176
|
+
const claudeDir = import_path3.default.dirname(claudePath);
|
|
121
177
|
const env = { ...process.env, PATH: `${claudeDir}:${process.env.PATH ?? ""}` };
|
|
122
178
|
const out = (0, import_child_process.execSync)(`"${claudePath}" --version`, { encoding: "utf8", stdio: "pipe", timeout: 1e4, env }).trim();
|
|
123
179
|
return { ok: true, version: out.split("\n")[0].slice(0, 30) };
|
|
@@ -126,10 +182,10 @@ function validateClaudePath(claudePath) {
|
|
|
126
182
|
}
|
|
127
183
|
}
|
|
128
184
|
function cacheClaudePath(claudePath, cacheDir) {
|
|
129
|
-
const dir = cacheDir ??
|
|
185
|
+
const dir = cacheDir ?? import_path3.default.join(process.cwd(), ".sna");
|
|
130
186
|
try {
|
|
131
|
-
if (!
|
|
132
|
-
|
|
187
|
+
if (!import_fs3.default.existsSync(dir)) import_fs3.default.mkdirSync(dir, { recursive: true });
|
|
188
|
+
import_fs3.default.writeFileSync(import_path3.default.join(dir, "claude-path"), claudePath);
|
|
133
189
|
} catch {
|
|
134
190
|
}
|
|
135
191
|
}
|
|
@@ -139,9 +195,9 @@ function resolveClaudeCli(opts) {
|
|
|
139
195
|
const v = validateClaudePath(process.env.SNA_CLAUDE_COMMAND);
|
|
140
196
|
return { path: process.env.SNA_CLAUDE_COMMAND, version: v.version, source: "env" };
|
|
141
197
|
}
|
|
142
|
-
const cacheFile = cacheDir ?
|
|
198
|
+
const cacheFile = cacheDir ? import_path3.default.join(cacheDir, "claude-path") : import_path3.default.join(process.cwd(), ".sna/claude-path");
|
|
143
199
|
try {
|
|
144
|
-
const cached =
|
|
200
|
+
const cached = import_fs3.default.readFileSync(cacheFile, "utf8").trim();
|
|
145
201
|
if (cached) {
|
|
146
202
|
const v = validateClaudePath(cached);
|
|
147
203
|
if (v.ok) return { path: cached, version: v.version, source: "cache" };
|
|
@@ -176,6 +232,11 @@ function resolveClaudeCli(opts) {
|
|
|
176
232
|
}
|
|
177
233
|
return { path: "claude", source: "fallback" };
|
|
178
234
|
}
|
|
235
|
+
function resolveClaudePath(cwd) {
|
|
236
|
+
const result = resolveClaudeCli({ cacheDir: import_path3.default.join(cwd, ".sna") });
|
|
237
|
+
logger.log("agent", `claude path: ${result.source}=${result.path}${result.version ? ` (${result.version})` : ""}`);
|
|
238
|
+
return result.path;
|
|
239
|
+
}
|
|
179
240
|
var _ClaudeCodeProcess = class _ClaudeCodeProcess {
|
|
180
241
|
constructor(proc, options) {
|
|
181
242
|
this.emitter = new import_events.EventEmitter();
|
|
@@ -517,16 +578,2232 @@ var _ClaudeCodeProcess = class _ClaudeCodeProcess {
|
|
|
517
578
|
};
|
|
518
579
|
_ClaudeCodeProcess.DRAIN_INTERVAL_MS = 15;
|
|
519
580
|
var ClaudeCodeProcess = _ClaudeCodeProcess;
|
|
581
|
+
var ClaudeCodeProvider = class {
|
|
582
|
+
constructor() {
|
|
583
|
+
this.name = "claude-code";
|
|
584
|
+
}
|
|
585
|
+
async isAvailable() {
|
|
586
|
+
try {
|
|
587
|
+
const p = resolveClaudePath(process.cwd());
|
|
588
|
+
(0, import_child_process.execSync)(`test -x "${p}"`, { stdio: "pipe" });
|
|
589
|
+
return true;
|
|
590
|
+
} catch {
|
|
591
|
+
return false;
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
spawn(options) {
|
|
595
|
+
const claudeCommand = resolveClaudePath(options.cwd);
|
|
596
|
+
const claudeParts = claudeCommand.split(/\s+/);
|
|
597
|
+
const claudePath = claudeParts[0];
|
|
598
|
+
const claudePrefix = claudeParts.slice(1);
|
|
599
|
+
let pkgRoot = import_path3.default.dirname((0, import_url.fileURLToPath)(importMetaUrl));
|
|
600
|
+
while (!import_fs3.default.existsSync(import_path3.default.join(pkgRoot, "package.json"))) {
|
|
601
|
+
const parent = import_path3.default.dirname(pkgRoot);
|
|
602
|
+
if (parent === pkgRoot) break;
|
|
603
|
+
pkgRoot = parent;
|
|
604
|
+
}
|
|
605
|
+
const hookScript = import_path3.default.join(pkgRoot, "dist", "scripts", "hook.js");
|
|
606
|
+
const sessionId = options.env?.SNA_SESSION_ID ?? "default";
|
|
607
|
+
const sdkSettings = {};
|
|
608
|
+
if (options.permissionMode !== "bypassPermissions") {
|
|
609
|
+
sdkSettings.hooks = {
|
|
610
|
+
PreToolUse: [{
|
|
611
|
+
matcher: ".*",
|
|
612
|
+
hooks: [{ type: "command", command: `node "${hookScript}" --session=${sessionId}` }]
|
|
613
|
+
}]
|
|
614
|
+
};
|
|
615
|
+
}
|
|
616
|
+
let extraArgsClean = options.extraArgs ? [...options.extraArgs] : [];
|
|
617
|
+
const settingsIdx = extraArgsClean.indexOf("--settings");
|
|
618
|
+
if (settingsIdx !== -1 && settingsIdx + 1 < extraArgsClean.length) {
|
|
619
|
+
try {
|
|
620
|
+
const appSettings = JSON.parse(extraArgsClean[settingsIdx + 1]);
|
|
621
|
+
if (appSettings.hooks) {
|
|
622
|
+
for (const [event, hooks] of Object.entries(appSettings.hooks)) {
|
|
623
|
+
if (sdkSettings.hooks && sdkSettings.hooks[event]) {
|
|
624
|
+
sdkSettings.hooks[event] = [
|
|
625
|
+
...sdkSettings.hooks[event],
|
|
626
|
+
...hooks
|
|
627
|
+
];
|
|
628
|
+
} else {
|
|
629
|
+
sdkSettings.hooks[event] = hooks;
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
delete appSettings.hooks;
|
|
633
|
+
}
|
|
634
|
+
Object.assign(sdkSettings, appSettings);
|
|
635
|
+
} catch {
|
|
636
|
+
}
|
|
637
|
+
extraArgsClean.splice(settingsIdx, 2);
|
|
638
|
+
}
|
|
639
|
+
const args = [
|
|
640
|
+
"--output-format",
|
|
641
|
+
"stream-json",
|
|
642
|
+
"--input-format",
|
|
643
|
+
"stream-json",
|
|
644
|
+
"--verbose",
|
|
645
|
+
"--include-partial-messages",
|
|
646
|
+
"--settings",
|
|
647
|
+
JSON.stringify(sdkSettings)
|
|
648
|
+
];
|
|
649
|
+
if (options.model) {
|
|
650
|
+
args.push("--model", options.model);
|
|
651
|
+
}
|
|
652
|
+
if (options.permissionMode) {
|
|
653
|
+
args.push("--permission-mode", options.permissionMode);
|
|
654
|
+
}
|
|
655
|
+
if (options.history?.length && options.prompt) {
|
|
656
|
+
const result = writeHistoryJsonl(options.history, { cwd: options.cwd });
|
|
657
|
+
if (result) {
|
|
658
|
+
args.push(...result.extraArgs);
|
|
659
|
+
options._historyViaResume = true;
|
|
660
|
+
logger.log("agent", `history via JSONL resume \u2192 ${result.filePath}`);
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
if (extraArgsClean.length > 0) {
|
|
664
|
+
args.push(...extraArgsClean);
|
|
665
|
+
}
|
|
666
|
+
const cleanEnv = { ...process.env, ...options.env };
|
|
667
|
+
if (options.configDir) {
|
|
668
|
+
cleanEnv.CLAUDE_CONFIG_DIR = options.configDir;
|
|
669
|
+
}
|
|
670
|
+
delete cleanEnv.CLAUDECODE;
|
|
671
|
+
delete cleanEnv.CLAUDE_CODE_ENTRYPOINT;
|
|
672
|
+
delete cleanEnv.CLAUDE_CODE_SESSION_ACCESS_TOKEN;
|
|
673
|
+
delete cleanEnv.CLAUDE_CODE_OAUTH_TOKEN;
|
|
674
|
+
const claudeDir = import_path3.default.dirname(claudePath);
|
|
675
|
+
if (claudeDir && claudeDir !== ".") {
|
|
676
|
+
cleanEnv.PATH = `${claudeDir}:${cleanEnv.PATH ?? ""}`;
|
|
677
|
+
}
|
|
678
|
+
const proc = (0, import_child_process.spawn)(claudePath, [...claudePrefix, ...args], {
|
|
679
|
+
cwd: options.cwd,
|
|
680
|
+
env: cleanEnv,
|
|
681
|
+
stdio: ["pipe", "pipe", "pipe"]
|
|
682
|
+
});
|
|
683
|
+
logger.log("agent", `spawned claude-code (pid=${proc.pid}) \u2192 ${claudeCommand} ${args.join(" ")}`);
|
|
684
|
+
return new ClaudeCodeProcess(proc, options);
|
|
685
|
+
}
|
|
686
|
+
};
|
|
687
|
+
|
|
688
|
+
// src/electron/index.ts
|
|
689
|
+
var import_path6 = __toESM(require("path"), 1);
|
|
690
|
+
var import_hono4 = require("hono");
|
|
691
|
+
var import_cors = require("hono/cors");
|
|
692
|
+
var import_node_server = require("@hono/node-server");
|
|
693
|
+
|
|
694
|
+
// src/server/index.ts
|
|
695
|
+
var import_hono3 = require("hono");
|
|
696
|
+
|
|
697
|
+
// src/server/routes/events.ts
|
|
698
|
+
var import_streaming = require("hono/streaming");
|
|
699
|
+
|
|
700
|
+
// src/db/schema.ts
|
|
701
|
+
var import_node_module = require("module");
|
|
702
|
+
var import_fs4 = __toESM(require("fs"), 1);
|
|
703
|
+
var import_path4 = __toESM(require("path"), 1);
|
|
704
|
+
var DB_PATH = process.env.SNA_DB_PATH ?? import_path4.default.join(process.cwd(), "data/sna.db");
|
|
705
|
+
var NATIVE_DIR = import_path4.default.join(process.cwd(), ".sna/native");
|
|
706
|
+
var _db = null;
|
|
707
|
+
function loadBetterSqlite3() {
|
|
708
|
+
const modulesPath = process.env.SNA_MODULES_PATH;
|
|
709
|
+
if (modulesPath) {
|
|
710
|
+
const entry = import_path4.default.join(modulesPath, "better-sqlite3");
|
|
711
|
+
if (import_fs4.default.existsSync(entry)) {
|
|
712
|
+
const req2 = (0, import_node_module.createRequire)(import_path4.default.join(modulesPath, "noop.js"));
|
|
713
|
+
return req2("better-sqlite3");
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
const nativeEntry = import_path4.default.join(NATIVE_DIR, "node_modules", "better-sqlite3");
|
|
717
|
+
if (import_fs4.default.existsSync(nativeEntry)) {
|
|
718
|
+
const req2 = (0, import_node_module.createRequire)(import_path4.default.join(NATIVE_DIR, "noop.js"));
|
|
719
|
+
return req2("better-sqlite3");
|
|
720
|
+
}
|
|
721
|
+
const req = (0, import_node_module.createRequire)(importMetaUrl);
|
|
722
|
+
return req("better-sqlite3");
|
|
723
|
+
}
|
|
724
|
+
function getDb() {
|
|
725
|
+
if (!_db) {
|
|
726
|
+
const BetterSqlite3 = loadBetterSqlite3();
|
|
727
|
+
const dir = import_path4.default.dirname(DB_PATH);
|
|
728
|
+
if (!import_fs4.default.existsSync(dir)) import_fs4.default.mkdirSync(dir, { recursive: true });
|
|
729
|
+
const nativeBinding = process.env.SNA_SQLITE_NATIVE_BINDING || void 0;
|
|
730
|
+
_db = nativeBinding ? new BetterSqlite3(DB_PATH, { nativeBinding }) : new BetterSqlite3(DB_PATH);
|
|
731
|
+
_db.pragma("journal_mode = WAL");
|
|
732
|
+
initSchema(_db);
|
|
733
|
+
}
|
|
734
|
+
return _db;
|
|
735
|
+
}
|
|
736
|
+
function migrateSkillEvents(db) {
|
|
737
|
+
const row = db.prepare(
|
|
738
|
+
"SELECT sql FROM sqlite_master WHERE type='table' AND name='skill_events'"
|
|
739
|
+
).get();
|
|
740
|
+
if (row?.sql?.includes("CHECK(type IN")) {
|
|
741
|
+
db.exec("DROP TABLE IF EXISTS skill_events");
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
function migrateChatSessionsMeta(db) {
|
|
745
|
+
const cols = db.prepare("PRAGMA table_info(chat_sessions)").all();
|
|
746
|
+
if (cols.length > 0 && !cols.some((c) => c.name === "meta")) {
|
|
747
|
+
db.exec("ALTER TABLE chat_sessions ADD COLUMN meta TEXT");
|
|
748
|
+
}
|
|
749
|
+
if (cols.length > 0 && !cols.some((c) => c.name === "cwd")) {
|
|
750
|
+
db.exec("ALTER TABLE chat_sessions ADD COLUMN cwd TEXT");
|
|
751
|
+
}
|
|
752
|
+
if (cols.length > 0 && !cols.some((c) => c.name === "last_start_config")) {
|
|
753
|
+
db.exec("ALTER TABLE chat_sessions ADD COLUMN last_start_config TEXT");
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
function initSchema(db) {
|
|
757
|
+
migrateSkillEvents(db);
|
|
758
|
+
migrateChatSessionsMeta(db);
|
|
759
|
+
db.exec(`
|
|
760
|
+
CREATE TABLE IF NOT EXISTS chat_sessions (
|
|
761
|
+
id TEXT PRIMARY KEY,
|
|
762
|
+
label TEXT NOT NULL DEFAULT '',
|
|
763
|
+
type TEXT NOT NULL DEFAULT 'main',
|
|
764
|
+
meta TEXT,
|
|
765
|
+
cwd TEXT,
|
|
766
|
+
last_start_config TEXT,
|
|
767
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
768
|
+
);
|
|
769
|
+
|
|
770
|
+
-- Ensure default session always exists
|
|
771
|
+
INSERT OR IGNORE INTO chat_sessions (id, label, type) VALUES ('default', 'Chat', 'main');
|
|
772
|
+
|
|
773
|
+
CREATE TABLE IF NOT EXISTS chat_messages (
|
|
774
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
775
|
+
session_id TEXT NOT NULL REFERENCES chat_sessions(id) ON DELETE CASCADE,
|
|
776
|
+
role TEXT NOT NULL,
|
|
777
|
+
content TEXT NOT NULL DEFAULT '',
|
|
778
|
+
skill_name TEXT,
|
|
779
|
+
meta TEXT,
|
|
780
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
781
|
+
);
|
|
782
|
+
|
|
783
|
+
CREATE INDEX IF NOT EXISTS idx_chat_messages_session ON chat_messages(session_id);
|
|
784
|
+
|
|
785
|
+
CREATE TABLE IF NOT EXISTS skill_events (
|
|
786
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
787
|
+
session_id TEXT REFERENCES chat_sessions(id) ON DELETE SET NULL,
|
|
788
|
+
skill TEXT NOT NULL,
|
|
789
|
+
type TEXT NOT NULL,
|
|
790
|
+
message TEXT NOT NULL,
|
|
791
|
+
data TEXT,
|
|
792
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
793
|
+
);
|
|
794
|
+
|
|
795
|
+
CREATE INDEX IF NOT EXISTS idx_skill_events_skill ON skill_events(skill);
|
|
796
|
+
CREATE INDEX IF NOT EXISTS idx_skill_events_created ON skill_events(created_at);
|
|
797
|
+
CREATE INDEX IF NOT EXISTS idx_skill_events_session ON skill_events(session_id);
|
|
798
|
+
`);
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
// src/config.ts
|
|
802
|
+
var defaults = {
|
|
803
|
+
port: 3099,
|
|
804
|
+
model: "claude-sonnet-4-6",
|
|
805
|
+
defaultProvider: "claude-code",
|
|
806
|
+
defaultPermissionMode: "default",
|
|
807
|
+
maxSessions: 5,
|
|
808
|
+
maxEventBuffer: 500,
|
|
809
|
+
permissionTimeoutMs: 0,
|
|
810
|
+
// app controls — no SDK-side timeout
|
|
811
|
+
runOnceTimeoutMs: 12e4,
|
|
812
|
+
pollIntervalMs: 500,
|
|
813
|
+
keepaliveIntervalMs: 15e3,
|
|
814
|
+
skillPollMs: 2e3,
|
|
815
|
+
dbPath: "data/sna.db"
|
|
816
|
+
};
|
|
817
|
+
function fromEnv() {
|
|
818
|
+
const env = {};
|
|
819
|
+
if (process.env.SNA_PORT) env.port = parseInt(process.env.SNA_PORT, 10);
|
|
820
|
+
if (process.env.SNA_MODEL) env.model = process.env.SNA_MODEL;
|
|
821
|
+
if (process.env.SNA_PERMISSION_MODE) env.defaultPermissionMode = process.env.SNA_PERMISSION_MODE;
|
|
822
|
+
if (process.env.SNA_MAX_SESSIONS) env.maxSessions = parseInt(process.env.SNA_MAX_SESSIONS, 10);
|
|
823
|
+
if (process.env.SNA_DB_PATH) env.dbPath = process.env.SNA_DB_PATH;
|
|
824
|
+
if (process.env.SNA_PERMISSION_TIMEOUT_MS) env.permissionTimeoutMs = parseInt(process.env.SNA_PERMISSION_TIMEOUT_MS, 10);
|
|
825
|
+
return env;
|
|
826
|
+
}
|
|
827
|
+
var current = { ...defaults, ...fromEnv() };
|
|
828
|
+
function getConfig() {
|
|
829
|
+
return current;
|
|
830
|
+
}
|
|
831
|
+
function setConfig(overrides) {
|
|
832
|
+
current = { ...current, ...overrides };
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
// src/server/routes/events.ts
|
|
836
|
+
function eventsRoute(c) {
|
|
837
|
+
const sinceParam = c.req.query("since");
|
|
838
|
+
let lastId = sinceParam ? parseInt(sinceParam) : -1;
|
|
839
|
+
if (lastId <= 0) {
|
|
840
|
+
const db = getDb();
|
|
841
|
+
const row = db.prepare("SELECT MAX(id) as maxId FROM skill_events").get();
|
|
842
|
+
lastId = row.maxId ?? 0;
|
|
843
|
+
}
|
|
844
|
+
return (0, import_streaming.streamSSE)(c, async (stream) => {
|
|
845
|
+
let closed = false;
|
|
846
|
+
stream.onAbort(() => {
|
|
847
|
+
closed = true;
|
|
848
|
+
});
|
|
849
|
+
const keepaliveTimer = setInterval(async () => {
|
|
850
|
+
if (closed) {
|
|
851
|
+
clearInterval(keepaliveTimer);
|
|
852
|
+
return;
|
|
853
|
+
}
|
|
854
|
+
try {
|
|
855
|
+
await stream.writeSSE({ data: "", event: "keepalive" });
|
|
856
|
+
} catch {
|
|
857
|
+
closed = true;
|
|
858
|
+
clearInterval(keepaliveTimer);
|
|
859
|
+
}
|
|
860
|
+
}, getConfig().keepaliveIntervalMs);
|
|
861
|
+
while (!closed) {
|
|
862
|
+
try {
|
|
863
|
+
const db = getDb();
|
|
864
|
+
const rows = db.prepare(`
|
|
865
|
+
SELECT id, skill, type, message, data, created_at
|
|
866
|
+
FROM skill_events
|
|
867
|
+
WHERE id > ?
|
|
868
|
+
ORDER BY id ASC
|
|
869
|
+
LIMIT 50
|
|
870
|
+
`).all(lastId);
|
|
871
|
+
for (const row of rows) {
|
|
872
|
+
if (closed) break;
|
|
873
|
+
await stream.writeSSE({ data: JSON.stringify(row) });
|
|
874
|
+
lastId = row.id;
|
|
875
|
+
}
|
|
876
|
+
} catch {
|
|
877
|
+
}
|
|
878
|
+
await stream.sleep(getConfig().pollIntervalMs);
|
|
879
|
+
}
|
|
880
|
+
clearInterval(keepaliveTimer);
|
|
881
|
+
});
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
// src/server/api-types.ts
|
|
885
|
+
function httpJson(c, _op, data, status) {
|
|
886
|
+
return c.json(data, status);
|
|
887
|
+
}
|
|
888
|
+
function wsReply(ws, msg, data) {
|
|
889
|
+
if (ws.readyState !== ws.OPEN) return;
|
|
890
|
+
const out = { ...data, type: msg.type };
|
|
891
|
+
if (msg.rid != null) out.rid = msg.rid;
|
|
892
|
+
ws.send(JSON.stringify(out));
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
// src/server/routes/emit.ts
|
|
896
|
+
function createEmitRoute(sessionManager) {
|
|
897
|
+
return async (c) => {
|
|
898
|
+
const body = await c.req.json();
|
|
899
|
+
const { skill, message, data } = body;
|
|
900
|
+
const type = body.type ?? body.eventType;
|
|
901
|
+
const session_id = c.req.query("session") ?? body.session_id ?? body.session ?? null;
|
|
902
|
+
if (!skill || !type || !message) {
|
|
903
|
+
return c.json({ error: "missing fields" }, 400);
|
|
904
|
+
}
|
|
905
|
+
const db = getDb();
|
|
906
|
+
const result = db.prepare(
|
|
907
|
+
`INSERT INTO skill_events (session_id, skill, type, message, data) VALUES (?, ?, ?, ?, ?)`
|
|
908
|
+
).run(session_id, skill, type, message, data ?? null);
|
|
909
|
+
const id = Number(result.lastInsertRowid);
|
|
910
|
+
sessionManager.broadcastSkillEvent({
|
|
911
|
+
id,
|
|
912
|
+
session_id: session_id ?? null,
|
|
913
|
+
skill,
|
|
914
|
+
type,
|
|
915
|
+
message,
|
|
916
|
+
data: data ?? null,
|
|
917
|
+
created_at: (/* @__PURE__ */ new Date()).toISOString()
|
|
918
|
+
});
|
|
919
|
+
return httpJson(c, "emit", { id });
|
|
920
|
+
};
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
// src/server/routes/run.ts
|
|
924
|
+
var import_child_process2 = require("child_process");
|
|
925
|
+
var import_streaming2 = require("hono/streaming");
|
|
926
|
+
var ROOT = process.cwd();
|
|
927
|
+
function createRunRoute(commands) {
|
|
928
|
+
return function runRoute(c) {
|
|
929
|
+
const skill = c.req.query("skill") ?? "";
|
|
930
|
+
const cmd = commands[skill];
|
|
931
|
+
if (!cmd) {
|
|
932
|
+
return c.text(`data: unknown skill: ${skill}
|
|
933
|
+
|
|
934
|
+
data: [done]
|
|
935
|
+
|
|
936
|
+
`, 200, {
|
|
937
|
+
"Content-Type": "text/event-stream"
|
|
938
|
+
});
|
|
939
|
+
}
|
|
940
|
+
return (0, import_streaming2.streamSSE)(c, async (stream) => {
|
|
941
|
+
await stream.writeSSE({ data: `$ ${cmd.slice(1).join(" ")}` });
|
|
942
|
+
const child = (0, import_child_process2.spawn)(cmd[0], cmd.slice(1), {
|
|
943
|
+
cwd: ROOT,
|
|
944
|
+
env: { ...process.env, FORCE_COLOR: "0" }
|
|
945
|
+
});
|
|
946
|
+
const write = (chunk) => {
|
|
947
|
+
for (const line of chunk.toString().split("\n")) {
|
|
948
|
+
if (line.trim()) stream.writeSSE({ data: line });
|
|
949
|
+
}
|
|
950
|
+
};
|
|
951
|
+
child.stdout.on("data", write);
|
|
952
|
+
child.stderr.on("data", (chunk) => {
|
|
953
|
+
for (const line of chunk.toString().split("\n")) {
|
|
954
|
+
if (line.trim() && !line.startsWith(">")) stream.writeSSE({ data: line });
|
|
955
|
+
}
|
|
956
|
+
});
|
|
957
|
+
await new Promise((resolve) => {
|
|
958
|
+
child.on("close", async (code) => {
|
|
959
|
+
await stream.writeSSE({ data: `[exit ${code ?? 0}]` });
|
|
960
|
+
await stream.writeSSE({ data: "[done]" });
|
|
961
|
+
resolve();
|
|
962
|
+
});
|
|
963
|
+
child.on("error", async (err2) => {
|
|
964
|
+
await stream.writeSSE({ data: `Error: ${err2.message}` });
|
|
965
|
+
await stream.writeSSE({ data: "[done]" });
|
|
966
|
+
resolve();
|
|
967
|
+
});
|
|
968
|
+
});
|
|
969
|
+
});
|
|
970
|
+
};
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
// src/server/routes/agent.ts
|
|
974
|
+
var import_hono = require("hono");
|
|
975
|
+
var import_streaming3 = require("hono/streaming");
|
|
976
|
+
|
|
977
|
+
// src/core/providers/codex.ts
|
|
978
|
+
var CodexProvider = class {
|
|
979
|
+
constructor() {
|
|
980
|
+
this.name = "codex";
|
|
981
|
+
}
|
|
982
|
+
async isAvailable() {
|
|
983
|
+
return false;
|
|
984
|
+
}
|
|
985
|
+
spawn(_options) {
|
|
986
|
+
throw new Error("Codex provider not yet implemented");
|
|
987
|
+
}
|
|
988
|
+
};
|
|
989
|
+
|
|
990
|
+
// src/core/providers/index.ts
|
|
991
|
+
var providers = {
|
|
992
|
+
"claude-code": new ClaudeCodeProvider(),
|
|
993
|
+
"codex": new CodexProvider()
|
|
994
|
+
};
|
|
995
|
+
function getProvider(name = "claude-code") {
|
|
996
|
+
const provider = providers[name];
|
|
997
|
+
if (!provider) throw new Error(`Unknown agent provider: ${name}`);
|
|
998
|
+
return provider;
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
// src/server/history-builder.ts
|
|
1002
|
+
function buildHistoryFromDb(sessionId) {
|
|
1003
|
+
const db = getDb();
|
|
1004
|
+
const rows = db.prepare(
|
|
1005
|
+
`SELECT role, content FROM chat_messages
|
|
1006
|
+
WHERE session_id = ? AND role IN ('user', 'assistant')
|
|
1007
|
+
ORDER BY id ASC`
|
|
1008
|
+
).all(sessionId);
|
|
1009
|
+
if (rows.length === 0) return [];
|
|
1010
|
+
const merged = [];
|
|
1011
|
+
for (const row of rows) {
|
|
1012
|
+
const role = row.role;
|
|
1013
|
+
if (!row.content?.trim()) continue;
|
|
1014
|
+
const last = merged[merged.length - 1];
|
|
1015
|
+
if (last && last.role === role) {
|
|
1016
|
+
last.content += "\n\n" + row.content;
|
|
1017
|
+
} else {
|
|
1018
|
+
merged.push({ role, content: row.content });
|
|
1019
|
+
}
|
|
1020
|
+
}
|
|
1021
|
+
return merged;
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
// src/server/image-store.ts
|
|
1025
|
+
var import_fs5 = __toESM(require("fs"), 1);
|
|
1026
|
+
var import_path5 = __toESM(require("path"), 1);
|
|
1027
|
+
var import_crypto = require("crypto");
|
|
1028
|
+
var IMAGE_DIR = import_path5.default.join(process.cwd(), "data/images");
|
|
1029
|
+
var MIME_TO_EXT = {
|
|
1030
|
+
"image/png": "png",
|
|
1031
|
+
"image/jpeg": "jpg",
|
|
1032
|
+
"image/gif": "gif",
|
|
1033
|
+
"image/webp": "webp",
|
|
1034
|
+
"image/svg+xml": "svg"
|
|
1035
|
+
};
|
|
1036
|
+
function saveImages(sessionId, images) {
|
|
1037
|
+
const dir = import_path5.default.join(IMAGE_DIR, sessionId);
|
|
1038
|
+
import_fs5.default.mkdirSync(dir, { recursive: true });
|
|
1039
|
+
return images.map((img) => {
|
|
1040
|
+
const ext = MIME_TO_EXT[img.mimeType] ?? "bin";
|
|
1041
|
+
const hash = (0, import_crypto.createHash)("sha256").update(img.base64).digest("hex").slice(0, 12);
|
|
1042
|
+
const filename = `${hash}.${ext}`;
|
|
1043
|
+
const filePath = import_path5.default.join(dir, filename);
|
|
1044
|
+
if (!import_fs5.default.existsSync(filePath)) {
|
|
1045
|
+
import_fs5.default.writeFileSync(filePath, Buffer.from(img.base64, "base64"));
|
|
1046
|
+
}
|
|
1047
|
+
return filename;
|
|
1048
|
+
});
|
|
1049
|
+
}
|
|
1050
|
+
function resolveImagePath(sessionId, filename) {
|
|
1051
|
+
if (filename.includes("..") || filename.includes("/")) return null;
|
|
1052
|
+
const filePath = import_path5.default.join(IMAGE_DIR, sessionId, filename);
|
|
1053
|
+
return import_fs5.default.existsSync(filePath) ? filePath : null;
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
// src/server/routes/agent.ts
|
|
1057
|
+
function getSessionId(c) {
|
|
1058
|
+
return c.req.query("session") ?? "default";
|
|
1059
|
+
}
|
|
1060
|
+
async function runOnce(sessionManager, opts) {
|
|
1061
|
+
const sessionId = `run-once-${crypto.randomUUID().slice(0, 8)}`;
|
|
1062
|
+
const timeout = opts.timeout ?? getConfig().runOnceTimeoutMs;
|
|
1063
|
+
const session = sessionManager.createSession({
|
|
1064
|
+
id: sessionId,
|
|
1065
|
+
label: "run-once",
|
|
1066
|
+
cwd: opts.cwd ?? process.cwd()
|
|
1067
|
+
});
|
|
1068
|
+
const cfg = getConfig();
|
|
1069
|
+
const provider = getProvider(opts.provider ?? cfg.defaultProvider);
|
|
1070
|
+
const extraArgs = opts.extraArgs ? [...opts.extraArgs] : [];
|
|
1071
|
+
if (opts.systemPrompt) extraArgs.push("--system-prompt", opts.systemPrompt);
|
|
1072
|
+
if (opts.appendSystemPrompt) extraArgs.push("--append-system-prompt", opts.appendSystemPrompt);
|
|
1073
|
+
const proc = provider.spawn({
|
|
1074
|
+
cwd: session.cwd,
|
|
1075
|
+
prompt: opts.message,
|
|
1076
|
+
model: opts.model ?? cfg.model,
|
|
1077
|
+
permissionMode: opts.permissionMode ?? cfg.defaultPermissionMode,
|
|
1078
|
+
env: { SNA_SESSION_ID: sessionId },
|
|
1079
|
+
extraArgs
|
|
1080
|
+
});
|
|
1081
|
+
sessionManager.setProcess(sessionId, proc);
|
|
1082
|
+
try {
|
|
1083
|
+
const result = await new Promise((resolve, reject) => {
|
|
1084
|
+
const texts = [];
|
|
1085
|
+
let usage = null;
|
|
1086
|
+
const timer = setTimeout(() => {
|
|
1087
|
+
reject(new Error(`run-once timed out after ${timeout}ms`));
|
|
1088
|
+
}, timeout);
|
|
1089
|
+
const unsub = sessionManager.onSessionEvent(sessionId, (_cursor, e) => {
|
|
1090
|
+
if (e.type === "assistant" && e.message) {
|
|
1091
|
+
texts.push(e.message);
|
|
1092
|
+
}
|
|
1093
|
+
if (e.type === "complete") {
|
|
1094
|
+
clearTimeout(timer);
|
|
1095
|
+
unsub();
|
|
1096
|
+
usage = e.data ?? null;
|
|
1097
|
+
resolve({ result: texts.join("\n"), usage });
|
|
1098
|
+
}
|
|
1099
|
+
if (e.type === "error") {
|
|
1100
|
+
clearTimeout(timer);
|
|
1101
|
+
unsub();
|
|
1102
|
+
reject(new Error(e.message ?? "Agent error"));
|
|
1103
|
+
}
|
|
1104
|
+
});
|
|
1105
|
+
});
|
|
1106
|
+
return result;
|
|
1107
|
+
} finally {
|
|
1108
|
+
sessionManager.killSession(sessionId);
|
|
1109
|
+
sessionManager.removeSession(sessionId);
|
|
1110
|
+
}
|
|
1111
|
+
}
|
|
1112
|
+
function createAgentRoutes(sessionManager) {
|
|
1113
|
+
const app = new import_hono.Hono();
|
|
1114
|
+
app.post("/sessions", async (c) => {
|
|
1115
|
+
const body = await c.req.json().catch(() => ({}));
|
|
1116
|
+
try {
|
|
1117
|
+
const session = sessionManager.createSession({
|
|
1118
|
+
id: body.id,
|
|
1119
|
+
label: body.label,
|
|
1120
|
+
cwd: body.cwd,
|
|
1121
|
+
meta: body.meta
|
|
1122
|
+
});
|
|
1123
|
+
logger.log("route", `POST /sessions \u2192 created "${session.id}"`);
|
|
1124
|
+
return httpJson(c, "sessions.create", { status: "created", sessionId: session.id, label: session.label, meta: session.meta });
|
|
1125
|
+
} catch (e) {
|
|
1126
|
+
logger.err("err", `POST /sessions \u2192 ${e.message}`);
|
|
1127
|
+
return c.json({ status: "error", message: e.message }, 409);
|
|
1128
|
+
}
|
|
1129
|
+
});
|
|
1130
|
+
app.get("/sessions", (c) => {
|
|
1131
|
+
return httpJson(c, "sessions.list", { sessions: sessionManager.listSessions() });
|
|
1132
|
+
});
|
|
1133
|
+
app.delete("/sessions/:id", (c) => {
|
|
1134
|
+
const id = c.req.param("id");
|
|
1135
|
+
if (id === "default") {
|
|
1136
|
+
return c.json({ status: "error", message: "Cannot remove default session" }, 400);
|
|
1137
|
+
}
|
|
1138
|
+
const removed = sessionManager.removeSession(id);
|
|
1139
|
+
if (!removed) {
|
|
1140
|
+
return c.json({ status: "error", message: "Session not found" }, 404);
|
|
1141
|
+
}
|
|
1142
|
+
logger.log("route", `DELETE /sessions/${id} \u2192 removed`);
|
|
1143
|
+
return httpJson(c, "sessions.remove", { status: "removed" });
|
|
1144
|
+
});
|
|
1145
|
+
app.patch("/sessions/:id", async (c) => {
|
|
1146
|
+
const id = c.req.param("id");
|
|
1147
|
+
const body = await c.req.json().catch(() => ({}));
|
|
1148
|
+
try {
|
|
1149
|
+
sessionManager.updateSession(id, {
|
|
1150
|
+
label: body.label,
|
|
1151
|
+
meta: body.meta,
|
|
1152
|
+
cwd: body.cwd
|
|
1153
|
+
});
|
|
1154
|
+
logger.log("route", `PATCH /sessions/${id} \u2192 updated`);
|
|
1155
|
+
return httpJson(c, "sessions.update", { status: "updated", session: id });
|
|
1156
|
+
} catch (e) {
|
|
1157
|
+
logger.err("err", `PATCH /sessions/${id} \u2192 ${e.message}`);
|
|
1158
|
+
return c.json({ status: "error", message: e.message }, 404);
|
|
1159
|
+
}
|
|
1160
|
+
});
|
|
1161
|
+
app.post("/run-once", async (c) => {
|
|
1162
|
+
const body = await c.req.json().catch(() => ({}));
|
|
1163
|
+
if (!body.message) {
|
|
1164
|
+
return c.json({ status: "error", message: "message is required" }, 400);
|
|
1165
|
+
}
|
|
1166
|
+
try {
|
|
1167
|
+
const result = await runOnce(sessionManager, body);
|
|
1168
|
+
return httpJson(c, "agent.run-once", result);
|
|
1169
|
+
} catch (e) {
|
|
1170
|
+
logger.err("err", `POST /run-once \u2192 ${e.message}`);
|
|
1171
|
+
return c.json({ status: "error", message: e.message }, 500);
|
|
1172
|
+
}
|
|
1173
|
+
});
|
|
1174
|
+
app.post("/start", async (c) => {
|
|
1175
|
+
const sessionId = getSessionId(c);
|
|
1176
|
+
const body = await c.req.json().catch(() => ({}));
|
|
1177
|
+
const session = sessionManager.getOrCreateSession(sessionId, {
|
|
1178
|
+
cwd: body.cwd
|
|
1179
|
+
});
|
|
1180
|
+
if (session.process?.alive && !body.force) {
|
|
1181
|
+
logger.log("route", `POST /start?session=${sessionId} \u2192 already_running`);
|
|
1182
|
+
return httpJson(c, "agent.start", {
|
|
1183
|
+
status: "already_running",
|
|
1184
|
+
provider: getConfig().defaultProvider,
|
|
1185
|
+
sessionId: session.process.sessionId ?? session.id
|
|
1186
|
+
});
|
|
1187
|
+
}
|
|
1188
|
+
if (session.process?.alive) {
|
|
1189
|
+
session.process.kill();
|
|
1190
|
+
}
|
|
1191
|
+
const provider = getProvider(body.provider ?? getConfig().defaultProvider);
|
|
1192
|
+
try {
|
|
1193
|
+
const db = getDb();
|
|
1194
|
+
db.prepare(`INSERT OR IGNORE INTO chat_sessions (id, label, type) VALUES (?, ?, 'main')`).run(sessionId, session.label ?? sessionId);
|
|
1195
|
+
if (body.prompt) {
|
|
1196
|
+
db.prepare(`INSERT INTO chat_messages (session_id, role, content, meta) VALUES (?, 'user', ?, ?)`).run(sessionId, body.prompt, body.meta ? JSON.stringify(body.meta) : null);
|
|
1197
|
+
}
|
|
1198
|
+
const skillMatch = body.prompt?.match(/^Execute the skill:\s*(\S+)/);
|
|
1199
|
+
if (skillMatch) {
|
|
1200
|
+
db.prepare(
|
|
1201
|
+
`INSERT INTO skill_events (session_id, skill, type, message) VALUES (?, ?, 'invoked', ?)`
|
|
1202
|
+
).run(sessionId, skillMatch[1], `Skill ${skillMatch[1]} invoked`);
|
|
1203
|
+
}
|
|
1204
|
+
} catch {
|
|
1205
|
+
}
|
|
1206
|
+
const providerName = body.provider ?? getConfig().defaultProvider;
|
|
1207
|
+
const model = body.model ?? getConfig().model;
|
|
1208
|
+
const permissionMode = body.permissionMode;
|
|
1209
|
+
const configDir = body.configDir;
|
|
1210
|
+
const extraArgs = body.extraArgs;
|
|
1211
|
+
try {
|
|
1212
|
+
const proc = provider.spawn({
|
|
1213
|
+
cwd: session.cwd,
|
|
1214
|
+
prompt: body.prompt,
|
|
1215
|
+
model,
|
|
1216
|
+
permissionMode,
|
|
1217
|
+
configDir,
|
|
1218
|
+
env: { SNA_SESSION_ID: sessionId },
|
|
1219
|
+
history: body.history,
|
|
1220
|
+
extraArgs
|
|
1221
|
+
});
|
|
1222
|
+
sessionManager.setProcess(sessionId, proc);
|
|
1223
|
+
sessionManager.saveStartConfig(sessionId, { provider: providerName, model, permissionMode, configDir, extraArgs });
|
|
1224
|
+
logger.log("route", `POST /start?session=${sessionId} \u2192 started`);
|
|
1225
|
+
return httpJson(c, "agent.start", {
|
|
1226
|
+
status: "started",
|
|
1227
|
+
provider: provider.name,
|
|
1228
|
+
sessionId: session.id
|
|
1229
|
+
});
|
|
1230
|
+
} catch (e) {
|
|
1231
|
+
logger.err("err", `POST /start?session=${sessionId} failed: ${e.message}`);
|
|
1232
|
+
return c.json({ status: "error", message: e.message }, 500);
|
|
1233
|
+
}
|
|
1234
|
+
});
|
|
1235
|
+
app.post("/send", async (c) => {
|
|
1236
|
+
const sessionId = getSessionId(c);
|
|
1237
|
+
const session = sessionManager.getSession(sessionId);
|
|
1238
|
+
if (!session?.process?.alive) {
|
|
1239
|
+
logger.err("err", `POST /send?session=${sessionId} \u2192 no active session`);
|
|
1240
|
+
return c.json(
|
|
1241
|
+
{ status: "error", message: `No active agent session "${sessionId}". Call POST /start first.` },
|
|
1242
|
+
400
|
|
1243
|
+
);
|
|
1244
|
+
}
|
|
1245
|
+
const body = await c.req.json().catch(() => ({}));
|
|
1246
|
+
if (!body.message && !body.images?.length) {
|
|
1247
|
+
logger.err("err", `POST /send?session=${sessionId} \u2192 empty message`);
|
|
1248
|
+
return c.json({ status: "error", message: "message or images required" }, 400);
|
|
1249
|
+
}
|
|
1250
|
+
const textContent = body.message ?? "(image)";
|
|
1251
|
+
let meta = body.meta ? { ...body.meta } : {};
|
|
1252
|
+
if (body.images?.length) {
|
|
1253
|
+
const filenames = saveImages(sessionId, body.images);
|
|
1254
|
+
meta.images = filenames;
|
|
1255
|
+
}
|
|
1256
|
+
try {
|
|
1257
|
+
const db = getDb();
|
|
1258
|
+
db.prepare(`INSERT OR IGNORE INTO chat_sessions (id, label, type) VALUES (?, ?, 'main')`).run(sessionId, session.label ?? sessionId);
|
|
1259
|
+
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);
|
|
1260
|
+
} catch {
|
|
1261
|
+
}
|
|
1262
|
+
sessionManager.pushEvent(sessionId, {
|
|
1263
|
+
type: "user_message",
|
|
1264
|
+
message: textContent,
|
|
1265
|
+
data: Object.keys(meta).length > 0 ? meta : void 0,
|
|
1266
|
+
timestamp: Date.now()
|
|
1267
|
+
});
|
|
1268
|
+
sessionManager.updateSessionState(sessionId, "processing");
|
|
1269
|
+
sessionManager.touch(sessionId);
|
|
1270
|
+
if (body.images?.length) {
|
|
1271
|
+
const content = [
|
|
1272
|
+
...body.images.map((img) => ({
|
|
1273
|
+
type: "image",
|
|
1274
|
+
source: { type: "base64", media_type: img.mimeType, data: img.base64 }
|
|
1275
|
+
})),
|
|
1276
|
+
...body.message ? [{ type: "text", text: body.message }] : []
|
|
1277
|
+
];
|
|
1278
|
+
logger.log("route", `POST /send?session=${sessionId} \u2192 ${body.images.length} image(s) + "${(body.message ?? "").slice(0, 40)}"`);
|
|
1279
|
+
session.process.send(content);
|
|
1280
|
+
} else {
|
|
1281
|
+
logger.log("route", `POST /send?session=${sessionId} \u2192 "${body.message.slice(0, 80)}"`);
|
|
1282
|
+
session.process.send(body.message);
|
|
1283
|
+
}
|
|
1284
|
+
return httpJson(c, "agent.send", { status: "sent" });
|
|
1285
|
+
});
|
|
1286
|
+
app.get("/events", (c) => {
|
|
1287
|
+
const sessionId = getSessionId(c);
|
|
1288
|
+
const session = sessionManager.getOrCreateSession(sessionId);
|
|
1289
|
+
const sinceParam = c.req.query("since");
|
|
1290
|
+
const sinceCursor = sinceParam ? parseInt(sinceParam, 10) : session.eventCounter;
|
|
1291
|
+
return (0, import_streaming3.streamSSE)(c, async (stream) => {
|
|
1292
|
+
const KEEPALIVE_MS = getConfig().keepaliveIntervalMs;
|
|
1293
|
+
const signal = c.req.raw.signal;
|
|
1294
|
+
const queue = [];
|
|
1295
|
+
let wakeUp = null;
|
|
1296
|
+
const unsub = sessionManager.onSessionEvent(sessionId, (eventCursor, event) => {
|
|
1297
|
+
queue.push({ cursor: eventCursor, event });
|
|
1298
|
+
const fn = wakeUp;
|
|
1299
|
+
wakeUp = null;
|
|
1300
|
+
fn?.();
|
|
1301
|
+
});
|
|
1302
|
+
signal.addEventListener("abort", () => {
|
|
1303
|
+
const fn = wakeUp;
|
|
1304
|
+
wakeUp = null;
|
|
1305
|
+
fn?.();
|
|
1306
|
+
});
|
|
1307
|
+
try {
|
|
1308
|
+
let cursor = sinceCursor;
|
|
1309
|
+
if (cursor < session.eventCounter) {
|
|
1310
|
+
const startIdx = Math.max(
|
|
1311
|
+
0,
|
|
1312
|
+
session.eventBuffer.length - (session.eventCounter - cursor)
|
|
1313
|
+
);
|
|
1314
|
+
for (const event of session.eventBuffer.slice(startIdx)) {
|
|
1315
|
+
cursor++;
|
|
1316
|
+
await stream.writeSSE({ id: String(cursor), data: JSON.stringify(event) });
|
|
1317
|
+
}
|
|
1318
|
+
} else {
|
|
1319
|
+
cursor = session.eventCounter;
|
|
1320
|
+
}
|
|
1321
|
+
while (queue.length > 0 && queue[0].cursor !== -1 && queue[0].cursor <= cursor) queue.shift();
|
|
1322
|
+
while (!signal.aborted) {
|
|
1323
|
+
if (queue.length === 0) {
|
|
1324
|
+
await Promise.race([
|
|
1325
|
+
new Promise((r) => {
|
|
1326
|
+
wakeUp = r;
|
|
1327
|
+
}),
|
|
1328
|
+
new Promise((r) => setTimeout(r, KEEPALIVE_MS))
|
|
1329
|
+
]);
|
|
1330
|
+
}
|
|
1331
|
+
if (signal.aborted) break;
|
|
1332
|
+
if (queue.length > 0) {
|
|
1333
|
+
while (queue.length > 0) {
|
|
1334
|
+
const item = queue.shift();
|
|
1335
|
+
if (item.cursor === -1) {
|
|
1336
|
+
await stream.writeSSE({ data: JSON.stringify(item.event) });
|
|
1337
|
+
} else {
|
|
1338
|
+
await stream.writeSSE({ id: String(item.cursor), data: JSON.stringify(item.event) });
|
|
1339
|
+
}
|
|
1340
|
+
}
|
|
1341
|
+
} else {
|
|
1342
|
+
await stream.writeSSE({ data: "" });
|
|
1343
|
+
}
|
|
1344
|
+
}
|
|
1345
|
+
} finally {
|
|
1346
|
+
unsub();
|
|
1347
|
+
}
|
|
1348
|
+
});
|
|
1349
|
+
});
|
|
1350
|
+
app.post("/restart", async (c) => {
|
|
1351
|
+
const sessionId = getSessionId(c);
|
|
1352
|
+
const body = await c.req.json().catch(() => ({}));
|
|
1353
|
+
try {
|
|
1354
|
+
const ccSessionId = sessionManager.getSession(sessionId)?.ccSessionId;
|
|
1355
|
+
const { config } = sessionManager.restartSession(sessionId, body, (cfg) => {
|
|
1356
|
+
const prov = getProvider(cfg.provider);
|
|
1357
|
+
const resumeArgs = ccSessionId ? ["--resume", ccSessionId] : ["--resume"];
|
|
1358
|
+
return prov.spawn({
|
|
1359
|
+
cwd: sessionManager.getSession(sessionId).cwd,
|
|
1360
|
+
model: cfg.model,
|
|
1361
|
+
permissionMode: cfg.permissionMode,
|
|
1362
|
+
configDir: cfg.configDir,
|
|
1363
|
+
env: { SNA_SESSION_ID: sessionId },
|
|
1364
|
+
extraArgs: [...cfg.extraArgs ?? [], ...resumeArgs]
|
|
1365
|
+
});
|
|
1366
|
+
});
|
|
1367
|
+
logger.log("route", `POST /restart?session=${sessionId} \u2192 restarted`);
|
|
1368
|
+
return httpJson(c, "agent.restart", {
|
|
1369
|
+
status: "restarted",
|
|
1370
|
+
provider: config.provider,
|
|
1371
|
+
sessionId
|
|
1372
|
+
});
|
|
1373
|
+
} catch (e) {
|
|
1374
|
+
logger.err("err", `POST /restart?session=${sessionId} \u2192 ${e.message}`);
|
|
1375
|
+
return c.json({ status: "error", message: e.message }, 500);
|
|
1376
|
+
}
|
|
1377
|
+
});
|
|
1378
|
+
app.post("/resume", async (c) => {
|
|
1379
|
+
const sessionId = getSessionId(c);
|
|
1380
|
+
const body = await c.req.json().catch(() => ({}));
|
|
1381
|
+
const session = sessionManager.getOrCreateSession(sessionId);
|
|
1382
|
+
if (session.process?.alive) {
|
|
1383
|
+
return c.json({ status: "error", message: "Session already running. Use agent.send instead." }, 400);
|
|
1384
|
+
}
|
|
1385
|
+
const history = buildHistoryFromDb(sessionId);
|
|
1386
|
+
if (history.length === 0 && !body.prompt) {
|
|
1387
|
+
return c.json({ status: "error", message: "No history in DB \u2014 nothing to resume." }, 400);
|
|
1388
|
+
}
|
|
1389
|
+
const providerName = body.provider ?? getConfig().defaultProvider;
|
|
1390
|
+
const model = body.model ?? session.lastStartConfig?.model ?? getConfig().model;
|
|
1391
|
+
const permissionMode = body.permissionMode ?? session.lastStartConfig?.permissionMode;
|
|
1392
|
+
const configDir = body.configDir ?? session.lastStartConfig?.configDir;
|
|
1393
|
+
const extraArgs = body.extraArgs ?? session.lastStartConfig?.extraArgs;
|
|
1394
|
+
const provider = getProvider(providerName);
|
|
1395
|
+
try {
|
|
1396
|
+
const proc = provider.spawn({
|
|
1397
|
+
cwd: session.cwd,
|
|
1398
|
+
prompt: body.prompt,
|
|
1399
|
+
model,
|
|
1400
|
+
permissionMode,
|
|
1401
|
+
configDir,
|
|
1402
|
+
env: { SNA_SESSION_ID: sessionId },
|
|
1403
|
+
history: history.length > 0 ? history : void 0,
|
|
1404
|
+
extraArgs
|
|
1405
|
+
});
|
|
1406
|
+
sessionManager.setProcess(sessionId, proc, "resumed");
|
|
1407
|
+
sessionManager.saveStartConfig(sessionId, { provider: providerName, model, permissionMode, configDir, extraArgs });
|
|
1408
|
+
logger.log("route", `POST /resume?session=${sessionId} \u2192 resumed (${history.length} history msgs)`);
|
|
1409
|
+
return httpJson(c, "agent.resume", {
|
|
1410
|
+
status: "resumed",
|
|
1411
|
+
provider: providerName,
|
|
1412
|
+
sessionId: session.id,
|
|
1413
|
+
historyCount: history.length
|
|
1414
|
+
});
|
|
1415
|
+
} catch (e) {
|
|
1416
|
+
logger.err("err", `POST /resume?session=${sessionId} \u2192 ${e.message}`);
|
|
1417
|
+
return c.json({ status: "error", message: e.message }, 500);
|
|
1418
|
+
}
|
|
1419
|
+
});
|
|
1420
|
+
app.post("/interrupt", async (c) => {
|
|
1421
|
+
const sessionId = getSessionId(c);
|
|
1422
|
+
const interrupted = sessionManager.interruptSession(sessionId);
|
|
1423
|
+
return httpJson(c, "agent.interrupt", { status: interrupted ? "interrupted" : "no_session" });
|
|
1424
|
+
});
|
|
1425
|
+
app.post("/set-model", async (c) => {
|
|
1426
|
+
const sessionId = getSessionId(c);
|
|
1427
|
+
const body = await c.req.json().catch(() => ({}));
|
|
1428
|
+
if (!body.model) return c.json({ status: "error", message: "model is required" }, 400);
|
|
1429
|
+
const updated = sessionManager.setSessionModel(sessionId, body.model);
|
|
1430
|
+
return httpJson(c, "agent.set-model", { status: updated ? "updated" : "no_session", model: body.model });
|
|
1431
|
+
});
|
|
1432
|
+
app.post("/set-permission-mode", async (c) => {
|
|
1433
|
+
const sessionId = getSessionId(c);
|
|
1434
|
+
const body = await c.req.json().catch(() => ({}));
|
|
1435
|
+
if (!body.permissionMode) return c.json({ status: "error", message: "permissionMode is required" }, 400);
|
|
1436
|
+
const updated = sessionManager.setSessionPermissionMode(sessionId, body.permissionMode);
|
|
1437
|
+
return httpJson(c, "agent.set-permission-mode", { status: updated ? "updated" : "no_session", permissionMode: body.permissionMode });
|
|
1438
|
+
});
|
|
1439
|
+
app.post("/kill", async (c) => {
|
|
1440
|
+
const sessionId = getSessionId(c);
|
|
1441
|
+
const killed = sessionManager.killSession(sessionId);
|
|
1442
|
+
return httpJson(c, "agent.kill", { status: killed ? "killed" : "no_session" });
|
|
1443
|
+
});
|
|
1444
|
+
app.get("/status", (c) => {
|
|
1445
|
+
const sessionId = getSessionId(c);
|
|
1446
|
+
const session = sessionManager.getSession(sessionId);
|
|
1447
|
+
const alive = session?.process?.alive ?? false;
|
|
1448
|
+
let messageCount = 0;
|
|
1449
|
+
let lastMessage = null;
|
|
1450
|
+
try {
|
|
1451
|
+
const db = getDb();
|
|
1452
|
+
const count = db.prepare("SELECT COUNT(*) as c FROM chat_messages WHERE session_id = ?").get(sessionId);
|
|
1453
|
+
messageCount = count?.c ?? 0;
|
|
1454
|
+
const last = db.prepare("SELECT role, content, created_at FROM chat_messages WHERE session_id = ? ORDER BY id DESC LIMIT 1").get(sessionId);
|
|
1455
|
+
if (last) lastMessage = { role: last.role, content: last.content, created_at: last.created_at };
|
|
1456
|
+
} catch {
|
|
1457
|
+
}
|
|
1458
|
+
return httpJson(c, "agent.status", {
|
|
1459
|
+
alive,
|
|
1460
|
+
agentStatus: !alive ? "disconnected" : session?.state === "processing" ? "busy" : "idle",
|
|
1461
|
+
sessionId: session?.process?.sessionId ?? null,
|
|
1462
|
+
ccSessionId: session?.ccSessionId ?? null,
|
|
1463
|
+
eventCount: session?.eventCounter ?? 0,
|
|
1464
|
+
messageCount,
|
|
1465
|
+
lastMessage,
|
|
1466
|
+
config: session?.lastStartConfig ?? null
|
|
1467
|
+
});
|
|
1468
|
+
});
|
|
1469
|
+
app.post("/permission-request", async (c) => {
|
|
1470
|
+
const sessionId = getSessionId(c);
|
|
1471
|
+
const body = await c.req.json().catch(() => ({}));
|
|
1472
|
+
logger.log("route", `POST /permission-request?session=${sessionId} \u2192 ${body.tool_name}`);
|
|
1473
|
+
const result = await sessionManager.createPendingPermission(sessionId, body);
|
|
1474
|
+
return c.json({ approved: result });
|
|
1475
|
+
});
|
|
1476
|
+
app.post("/permission-respond", async (c) => {
|
|
1477
|
+
const sessionId = getSessionId(c);
|
|
1478
|
+
const body = await c.req.json().catch(() => ({}));
|
|
1479
|
+
const approved = body.approved ?? false;
|
|
1480
|
+
const resolved = sessionManager.resolvePendingPermission(sessionId, approved);
|
|
1481
|
+
if (!resolved) {
|
|
1482
|
+
return c.json({ status: "error", message: "No pending permission request" }, 404);
|
|
1483
|
+
}
|
|
1484
|
+
logger.log("route", `POST /permission-respond?session=${sessionId} \u2192 ${approved ? "approved" : "denied"}`);
|
|
1485
|
+
return httpJson(c, "permission.respond", { status: approved ? "approved" : "denied" });
|
|
1486
|
+
});
|
|
1487
|
+
app.get("/permission-pending", (c) => {
|
|
1488
|
+
const sessionId = c.req.query("session");
|
|
1489
|
+
if (sessionId) {
|
|
1490
|
+
const pending = sessionManager.getPendingPermission(sessionId);
|
|
1491
|
+
return httpJson(c, "permission.pending", { pending: pending ? [{ sessionId, ...pending }] : [] });
|
|
1492
|
+
}
|
|
1493
|
+
return httpJson(c, "permission.pending", { pending: sessionManager.getAllPendingPermissions() });
|
|
1494
|
+
});
|
|
1495
|
+
return app;
|
|
1496
|
+
}
|
|
1497
|
+
|
|
1498
|
+
// src/server/routes/chat.ts
|
|
1499
|
+
var import_hono2 = require("hono");
|
|
1500
|
+
var import_fs6 = __toESM(require("fs"), 1);
|
|
1501
|
+
function createChatRoutes() {
|
|
1502
|
+
const app = new import_hono2.Hono();
|
|
1503
|
+
app.get("/sessions", (c) => {
|
|
1504
|
+
try {
|
|
1505
|
+
const db = getDb();
|
|
1506
|
+
const rows = db.prepare(
|
|
1507
|
+
`SELECT id, label, type, meta, cwd, created_at FROM chat_sessions ORDER BY created_at DESC`
|
|
1508
|
+
).all();
|
|
1509
|
+
const sessions = rows.map((r) => ({
|
|
1510
|
+
...r,
|
|
1511
|
+
meta: r.meta ? JSON.parse(r.meta) : null
|
|
1512
|
+
}));
|
|
1513
|
+
return httpJson(c, "chat.sessions.list", { sessions });
|
|
1514
|
+
} catch (e) {
|
|
1515
|
+
return c.json({ status: "error", message: e.message, stack: e.stack }, 500);
|
|
1516
|
+
}
|
|
1517
|
+
});
|
|
1518
|
+
app.post("/sessions", async (c) => {
|
|
1519
|
+
const body = await c.req.json().catch(() => ({}));
|
|
1520
|
+
const id = body.id ?? crypto.randomUUID().slice(0, 8);
|
|
1521
|
+
const sessionType = body.type ?? body.chatType ?? "background";
|
|
1522
|
+
try {
|
|
1523
|
+
const db = getDb();
|
|
1524
|
+
db.prepare(
|
|
1525
|
+
`INSERT OR IGNORE INTO chat_sessions (id, label, type, meta) VALUES (?, ?, ?, ?)`
|
|
1526
|
+
).run(id, body.label ?? id, sessionType, body.meta ? JSON.stringify(body.meta) : null);
|
|
1527
|
+
return httpJson(c, "chat.sessions.create", { status: "created", id, meta: body.meta ?? null });
|
|
1528
|
+
} catch (e) {
|
|
1529
|
+
return c.json({ status: "error", message: e.message }, 500);
|
|
1530
|
+
}
|
|
1531
|
+
});
|
|
1532
|
+
app.delete("/sessions/:id", (c) => {
|
|
1533
|
+
const id = c.req.param("id");
|
|
1534
|
+
if (id === "default") {
|
|
1535
|
+
return c.json({ status: "error", message: "Cannot delete default session" }, 400);
|
|
1536
|
+
}
|
|
1537
|
+
try {
|
|
1538
|
+
const db = getDb();
|
|
1539
|
+
db.prepare(`DELETE FROM chat_sessions WHERE id = ?`).run(id);
|
|
1540
|
+
return httpJson(c, "chat.sessions.remove", { status: "deleted" });
|
|
1541
|
+
} catch (e) {
|
|
1542
|
+
return c.json({ status: "error", message: e.message }, 500);
|
|
1543
|
+
}
|
|
1544
|
+
});
|
|
1545
|
+
app.get("/sessions/:id/messages", (c) => {
|
|
1546
|
+
const id = c.req.param("id");
|
|
1547
|
+
const sinceParam = c.req.query("since");
|
|
1548
|
+
try {
|
|
1549
|
+
const db = getDb();
|
|
1550
|
+
const query = sinceParam ? db.prepare(`SELECT * FROM chat_messages WHERE session_id = ? AND id > ? ORDER BY id ASC`) : db.prepare(`SELECT * FROM chat_messages WHERE session_id = ? ORDER BY id ASC`);
|
|
1551
|
+
const messages = sinceParam ? query.all(id, parseInt(sinceParam, 10)) : query.all(id);
|
|
1552
|
+
return httpJson(c, "chat.messages.list", { messages });
|
|
1553
|
+
} catch (e) {
|
|
1554
|
+
return c.json({ status: "error", message: e.message, stack: e.stack }, 500);
|
|
1555
|
+
}
|
|
1556
|
+
});
|
|
1557
|
+
app.post("/sessions/:id/messages", async (c) => {
|
|
1558
|
+
const sessionId = c.req.param("id");
|
|
1559
|
+
const body = await c.req.json().catch(() => ({}));
|
|
1560
|
+
if (!body.role) {
|
|
1561
|
+
return c.json({ status: "error", message: "role is required" }, 400);
|
|
1562
|
+
}
|
|
1563
|
+
try {
|
|
1564
|
+
const db = getDb();
|
|
1565
|
+
db.prepare(`INSERT OR IGNORE INTO chat_sessions (id, label, type) VALUES (?, ?, 'main')`).run(sessionId, sessionId);
|
|
1566
|
+
const result = db.prepare(
|
|
1567
|
+
`INSERT INTO chat_messages (session_id, role, content, skill_name, meta) VALUES (?, ?, ?, ?, ?)`
|
|
1568
|
+
).run(
|
|
1569
|
+
sessionId,
|
|
1570
|
+
body.role,
|
|
1571
|
+
body.content ?? "",
|
|
1572
|
+
body.skill_name ?? null,
|
|
1573
|
+
body.meta ? JSON.stringify(body.meta) : null
|
|
1574
|
+
);
|
|
1575
|
+
return httpJson(c, "chat.messages.create", { status: "created", id: Number(result.lastInsertRowid) });
|
|
1576
|
+
} catch (e) {
|
|
1577
|
+
return c.json({ status: "error", message: e.message }, 500);
|
|
1578
|
+
}
|
|
1579
|
+
});
|
|
1580
|
+
app.delete("/sessions/:id/messages", (c) => {
|
|
1581
|
+
const id = c.req.param("id");
|
|
1582
|
+
try {
|
|
1583
|
+
const db = getDb();
|
|
1584
|
+
db.prepare(`DELETE FROM chat_messages WHERE session_id = ?`).run(id);
|
|
1585
|
+
return httpJson(c, "chat.messages.clear", { status: "cleared" });
|
|
1586
|
+
} catch (e) {
|
|
1587
|
+
return c.json({ status: "error", message: e.message }, 500);
|
|
1588
|
+
}
|
|
1589
|
+
});
|
|
1590
|
+
app.get("/images/:sessionId/:filename", (c) => {
|
|
1591
|
+
const sessionId = c.req.param("sessionId");
|
|
1592
|
+
const filename = c.req.param("filename");
|
|
1593
|
+
const filePath = resolveImagePath(sessionId, filename);
|
|
1594
|
+
if (!filePath) {
|
|
1595
|
+
return c.json({ status: "error", message: "Image not found" }, 404);
|
|
1596
|
+
}
|
|
1597
|
+
const ext = filename.split(".").pop()?.toLowerCase();
|
|
1598
|
+
const mimeMap = {
|
|
1599
|
+
png: "image/png",
|
|
1600
|
+
jpg: "image/jpeg",
|
|
1601
|
+
jpeg: "image/jpeg",
|
|
1602
|
+
gif: "image/gif",
|
|
1603
|
+
webp: "image/webp",
|
|
1604
|
+
svg: "image/svg+xml"
|
|
1605
|
+
};
|
|
1606
|
+
const contentType = mimeMap[ext ?? ""] ?? "application/octet-stream";
|
|
1607
|
+
const data = import_fs6.default.readFileSync(filePath);
|
|
1608
|
+
return new Response(data, { headers: { "Content-Type": contentType, "Cache-Control": "public, max-age=31536000, immutable" } });
|
|
1609
|
+
});
|
|
1610
|
+
return app;
|
|
1611
|
+
}
|
|
1612
|
+
|
|
1613
|
+
// src/server/session-manager.ts
|
|
1614
|
+
var SessionManager = class {
|
|
1615
|
+
constructor(options = {}) {
|
|
1616
|
+
this.sessions = /* @__PURE__ */ new Map();
|
|
1617
|
+
this.eventListeners = /* @__PURE__ */ new Map();
|
|
1618
|
+
this.pendingPermissions = /* @__PURE__ */ new Map();
|
|
1619
|
+
this.skillEventListeners = /* @__PURE__ */ new Set();
|
|
1620
|
+
this.permissionRequestListeners = /* @__PURE__ */ new Set();
|
|
1621
|
+
this.lifecycleListeners = /* @__PURE__ */ new Set();
|
|
1622
|
+
this.configChangedListeners = /* @__PURE__ */ new Set();
|
|
1623
|
+
this.stateChangedListeners = /* @__PURE__ */ new Set();
|
|
1624
|
+
this.metadataChangedListeners = /* @__PURE__ */ new Set();
|
|
1625
|
+
this.maxSessions = options.maxSessions ?? getConfig().maxSessions;
|
|
1626
|
+
this.restoreFromDb();
|
|
1627
|
+
}
|
|
1628
|
+
/** Restore session metadata from DB (cwd, label, meta). Process state is not restored. */
|
|
1629
|
+
restoreFromDb() {
|
|
1630
|
+
try {
|
|
1631
|
+
const db = getDb();
|
|
1632
|
+
const rows = db.prepare(
|
|
1633
|
+
`SELECT id, label, meta, cwd, last_start_config, created_at FROM chat_sessions`
|
|
1634
|
+
).all();
|
|
1635
|
+
for (const row of rows) {
|
|
1636
|
+
if (this.sessions.has(row.id)) continue;
|
|
1637
|
+
this.sessions.set(row.id, {
|
|
1638
|
+
id: row.id,
|
|
1639
|
+
process: null,
|
|
1640
|
+
eventBuffer: [],
|
|
1641
|
+
eventCounter: 0,
|
|
1642
|
+
label: row.label,
|
|
1643
|
+
cwd: row.cwd ?? process.cwd(),
|
|
1644
|
+
meta: row.meta ? JSON.parse(row.meta) : null,
|
|
1645
|
+
state: "idle",
|
|
1646
|
+
lastStartConfig: row.last_start_config ? JSON.parse(row.last_start_config) : null,
|
|
1647
|
+
ccSessionId: null,
|
|
1648
|
+
createdAt: new Date(row.created_at).getTime() || Date.now(),
|
|
1649
|
+
lastActivityAt: Date.now()
|
|
1650
|
+
});
|
|
1651
|
+
}
|
|
1652
|
+
} catch {
|
|
1653
|
+
}
|
|
1654
|
+
}
|
|
1655
|
+
/** Persist session metadata to DB. */
|
|
1656
|
+
persistSession(session) {
|
|
1657
|
+
try {
|
|
1658
|
+
const db = getDb();
|
|
1659
|
+
db.prepare(
|
|
1660
|
+
`INSERT INTO chat_sessions (id, label, type, meta, cwd, last_start_config)
|
|
1661
|
+
VALUES (?, ?, 'main', ?, ?, ?)
|
|
1662
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
1663
|
+
label = excluded.label,
|
|
1664
|
+
meta = excluded.meta,
|
|
1665
|
+
cwd = excluded.cwd,
|
|
1666
|
+
last_start_config = excluded.last_start_config`
|
|
1667
|
+
).run(
|
|
1668
|
+
session.id,
|
|
1669
|
+
session.label,
|
|
1670
|
+
session.meta ? JSON.stringify(session.meta) : null,
|
|
1671
|
+
session.cwd,
|
|
1672
|
+
session.lastStartConfig ? JSON.stringify(session.lastStartConfig) : null
|
|
1673
|
+
);
|
|
1674
|
+
} catch {
|
|
1675
|
+
}
|
|
1676
|
+
}
|
|
1677
|
+
/** Create a new session. Throws if session already exists or max sessions reached. */
|
|
1678
|
+
createSession(opts = {}) {
|
|
1679
|
+
const id = opts.id ?? crypto.randomUUID().slice(0, 8);
|
|
1680
|
+
if (this.sessions.has(id)) {
|
|
1681
|
+
throw new Error(`Session "${id}" already exists`);
|
|
1682
|
+
}
|
|
1683
|
+
const aliveCount = Array.from(this.sessions.values()).filter((s) => s.process?.alive).length;
|
|
1684
|
+
if (aliveCount >= this.maxSessions) {
|
|
1685
|
+
throw new Error(`Max active sessions (${this.maxSessions}) reached \u2014 ${aliveCount} alive`);
|
|
1686
|
+
}
|
|
1687
|
+
const session = {
|
|
1688
|
+
id,
|
|
1689
|
+
process: null,
|
|
1690
|
+
eventBuffer: [],
|
|
1691
|
+
eventCounter: 0,
|
|
1692
|
+
label: opts.label ?? id,
|
|
1693
|
+
cwd: opts.cwd ?? process.cwd(),
|
|
1694
|
+
meta: opts.meta ?? null,
|
|
1695
|
+
state: "idle",
|
|
1696
|
+
lastStartConfig: null,
|
|
1697
|
+
ccSessionId: null,
|
|
1698
|
+
createdAt: Date.now(),
|
|
1699
|
+
lastActivityAt: Date.now()
|
|
1700
|
+
};
|
|
1701
|
+
this.sessions.set(id, session);
|
|
1702
|
+
this.persistSession(session);
|
|
1703
|
+
return session;
|
|
1704
|
+
}
|
|
1705
|
+
/** Update an existing session's metadata. Throws if session not found. */
|
|
1706
|
+
updateSession(id, opts) {
|
|
1707
|
+
const session = this.sessions.get(id);
|
|
1708
|
+
if (!session) throw new Error(`Session "${id}" not found`);
|
|
1709
|
+
if (opts.label !== void 0) session.label = opts.label;
|
|
1710
|
+
if (opts.meta !== void 0) session.meta = opts.meta;
|
|
1711
|
+
if (opts.cwd !== void 0) session.cwd = opts.cwd;
|
|
1712
|
+
this.persistSession(session);
|
|
1713
|
+
this.emitMetadataChanged(id);
|
|
1714
|
+
return session;
|
|
1715
|
+
}
|
|
1716
|
+
/** Get a session by ID. */
|
|
1717
|
+
getSession(id) {
|
|
1718
|
+
return this.sessions.get(id);
|
|
1719
|
+
}
|
|
1720
|
+
/** Get or create a session (used for "default" backward compat). */
|
|
1721
|
+
getOrCreateSession(id, opts) {
|
|
1722
|
+
const existing = this.sessions.get(id);
|
|
1723
|
+
if (existing) {
|
|
1724
|
+
if (opts?.cwd && opts.cwd !== existing.cwd) {
|
|
1725
|
+
existing.cwd = opts.cwd;
|
|
1726
|
+
this.persistSession(existing);
|
|
1727
|
+
}
|
|
1728
|
+
return existing;
|
|
1729
|
+
}
|
|
1730
|
+
return this.createSession({ id, ...opts });
|
|
1731
|
+
}
|
|
1732
|
+
/** Set the agent process for a session. Subscribes to events. */
|
|
1733
|
+
setProcess(sessionId, proc, lifecycleState) {
|
|
1734
|
+
const session = this.sessions.get(sessionId);
|
|
1735
|
+
if (!session) throw new Error(`Session "${sessionId}" not found`);
|
|
1736
|
+
session.process = proc;
|
|
1737
|
+
session.lastActivityAt = Date.now();
|
|
1738
|
+
session.eventBuffer.length = 0;
|
|
1739
|
+
try {
|
|
1740
|
+
const db = getDb();
|
|
1741
|
+
const row = db.prepare(
|
|
1742
|
+
`SELECT COUNT(*) as c FROM chat_messages WHERE session_id = ?`
|
|
1743
|
+
).get(sessionId);
|
|
1744
|
+
session.eventCounter = row.c;
|
|
1745
|
+
} catch {
|
|
1746
|
+
}
|
|
1747
|
+
proc.on("event", (e) => {
|
|
1748
|
+
if (e.type === "init") {
|
|
1749
|
+
if (e.data?.sessionId && !session.ccSessionId) {
|
|
1750
|
+
session.ccSessionId = e.data.sessionId;
|
|
1751
|
+
this.persistSession(session);
|
|
1752
|
+
}
|
|
1753
|
+
this.setSessionState(sessionId, session, "waiting");
|
|
1754
|
+
}
|
|
1755
|
+
if (e.type === "thinking" || e.type === "tool_use" || e.type === "assistant_delta") {
|
|
1756
|
+
this.setSessionState(sessionId, session, "processing");
|
|
1757
|
+
} else if (e.type === "complete" || e.type === "error" || e.type === "interrupted") {
|
|
1758
|
+
this.setSessionState(sessionId, session, "waiting");
|
|
1759
|
+
}
|
|
1760
|
+
const persisted = this.persistEvent(sessionId, e);
|
|
1761
|
+
if (persisted) {
|
|
1762
|
+
session.eventCounter++;
|
|
1763
|
+
session.eventBuffer.push(e);
|
|
1764
|
+
if (session.eventBuffer.length > getConfig().maxEventBuffer) {
|
|
1765
|
+
session.eventBuffer.splice(0, session.eventBuffer.length - getConfig().maxEventBuffer);
|
|
1766
|
+
}
|
|
1767
|
+
const listeners = this.eventListeners.get(sessionId);
|
|
1768
|
+
if (listeners) {
|
|
1769
|
+
for (const cb of listeners) cb(session.eventCounter, e);
|
|
1770
|
+
}
|
|
1771
|
+
} else if (e.type === "assistant_delta") {
|
|
1772
|
+
const listeners = this.eventListeners.get(sessionId);
|
|
1773
|
+
if (listeners) {
|
|
1774
|
+
for (const cb of listeners) cb(-1, e);
|
|
1775
|
+
}
|
|
1776
|
+
}
|
|
1777
|
+
});
|
|
1778
|
+
proc.on("exit", (code) => {
|
|
1779
|
+
this.setSessionState(sessionId, session, "idle");
|
|
1780
|
+
this.emitLifecycle({ session: sessionId, state: code != null ? "exited" : "crashed", code });
|
|
1781
|
+
});
|
|
1782
|
+
proc.on("error", () => {
|
|
1783
|
+
this.setSessionState(sessionId, session, "idle");
|
|
1784
|
+
this.emitLifecycle({ session: sessionId, state: "crashed" });
|
|
1785
|
+
});
|
|
1786
|
+
this.emitLifecycle({ session: sessionId, state: lifecycleState ?? "started" });
|
|
1787
|
+
}
|
|
1788
|
+
// ── Event pub/sub (for WebSocket) ─────────────────────────────
|
|
1789
|
+
/** Subscribe to real-time events for a session. Returns unsubscribe function. */
|
|
1790
|
+
onSessionEvent(sessionId, cb) {
|
|
1791
|
+
let set = this.eventListeners.get(sessionId);
|
|
1792
|
+
if (!set) {
|
|
1793
|
+
set = /* @__PURE__ */ new Set();
|
|
1794
|
+
this.eventListeners.set(sessionId, set);
|
|
1795
|
+
}
|
|
1796
|
+
set.add(cb);
|
|
1797
|
+
return () => {
|
|
1798
|
+
set.delete(cb);
|
|
1799
|
+
if (set.size === 0) this.eventListeners.delete(sessionId);
|
|
1800
|
+
};
|
|
1801
|
+
}
|
|
1802
|
+
// ── Skill event pub/sub ────────────────────────────────────────
|
|
1803
|
+
/** Subscribe to skill events broadcast. Returns unsubscribe function. */
|
|
1804
|
+
onSkillEvent(cb) {
|
|
1805
|
+
this.skillEventListeners.add(cb);
|
|
1806
|
+
return () => this.skillEventListeners.delete(cb);
|
|
1807
|
+
}
|
|
1808
|
+
/** Broadcast a skill event to all subscribers (called after DB insert). */
|
|
1809
|
+
broadcastSkillEvent(event) {
|
|
1810
|
+
for (const cb of this.skillEventListeners) cb(event);
|
|
1811
|
+
}
|
|
1812
|
+
/** Push a synthetic event into a session's event stream (for user message broadcast). */
|
|
1813
|
+
/**
|
|
1814
|
+
* Push an externally-persisted event into the session.
|
|
1815
|
+
* The caller is responsible for DB persistence — this method only updates
|
|
1816
|
+
* the in-memory counter/buffer and notifies listeners.
|
|
1817
|
+
* eventCounter increments to stay in sync with the DB row count.
|
|
1818
|
+
*/
|
|
1819
|
+
pushEvent(sessionId, event) {
|
|
1820
|
+
const session = this.sessions.get(sessionId);
|
|
1821
|
+
if (!session) return;
|
|
1822
|
+
session.eventCounter++;
|
|
1823
|
+
session.eventBuffer.push(event);
|
|
1824
|
+
if (session.eventBuffer.length > getConfig().maxEventBuffer) {
|
|
1825
|
+
session.eventBuffer.splice(0, session.eventBuffer.length - getConfig().maxEventBuffer);
|
|
1826
|
+
}
|
|
1827
|
+
const listeners = this.eventListeners.get(sessionId);
|
|
1828
|
+
if (listeners) {
|
|
1829
|
+
for (const cb of listeners) cb(session.eventCounter, event);
|
|
1830
|
+
}
|
|
1831
|
+
}
|
|
1832
|
+
// ── Permission pub/sub ────────────────────────────────────────
|
|
1833
|
+
/** Subscribe to permission request notifications. Returns unsubscribe function. */
|
|
1834
|
+
onPermissionRequest(cb) {
|
|
1835
|
+
this.permissionRequestListeners.add(cb);
|
|
1836
|
+
return () => this.permissionRequestListeners.delete(cb);
|
|
1837
|
+
}
|
|
1838
|
+
// ── Session lifecycle pub/sub ──────────────────────────────────
|
|
1839
|
+
/** Subscribe to session lifecycle events (started/killed/exited/crashed). Returns unsubscribe function. */
|
|
1840
|
+
onSessionLifecycle(cb) {
|
|
1841
|
+
this.lifecycleListeners.add(cb);
|
|
1842
|
+
return () => this.lifecycleListeners.delete(cb);
|
|
1843
|
+
}
|
|
1844
|
+
emitLifecycle(event) {
|
|
1845
|
+
for (const cb of this.lifecycleListeners) cb(event);
|
|
1846
|
+
}
|
|
1847
|
+
// ── Config changed pub/sub ────────────────────────────────────
|
|
1848
|
+
/** Subscribe to session config changes. Returns unsubscribe function. */
|
|
1849
|
+
onConfigChanged(cb) {
|
|
1850
|
+
this.configChangedListeners.add(cb);
|
|
1851
|
+
return () => this.configChangedListeners.delete(cb);
|
|
1852
|
+
}
|
|
1853
|
+
emitConfigChanged(sessionId, config) {
|
|
1854
|
+
for (const cb of this.configChangedListeners) cb({ session: sessionId, config });
|
|
1855
|
+
}
|
|
1856
|
+
// ── Session metadata change pub/sub ─────────────────────────────
|
|
1857
|
+
onMetadataChanged(cb) {
|
|
1858
|
+
this.metadataChangedListeners.add(cb);
|
|
1859
|
+
return () => this.metadataChangedListeners.delete(cb);
|
|
1860
|
+
}
|
|
1861
|
+
emitMetadataChanged(sessionId) {
|
|
1862
|
+
for (const cb of this.metadataChangedListeners) cb(sessionId);
|
|
1863
|
+
}
|
|
1864
|
+
// ── Agent status change pub/sub ────────────────────────────────
|
|
1865
|
+
onStateChanged(cb) {
|
|
1866
|
+
this.stateChangedListeners.add(cb);
|
|
1867
|
+
return () => this.stateChangedListeners.delete(cb);
|
|
1868
|
+
}
|
|
1869
|
+
/** Update session state and push agentStatus change to subscribers. */
|
|
1870
|
+
updateSessionState(sessionId, newState) {
|
|
1871
|
+
const session = this.sessions.get(sessionId);
|
|
1872
|
+
if (session) this.setSessionState(sessionId, session, newState);
|
|
1873
|
+
}
|
|
1874
|
+
setSessionState(sessionId, session, newState) {
|
|
1875
|
+
const oldState = session.state;
|
|
1876
|
+
session.state = newState;
|
|
1877
|
+
const newStatus = !session.process?.alive ? "disconnected" : newState === "processing" ? "busy" : "idle";
|
|
1878
|
+
if (oldState !== newState) {
|
|
1879
|
+
for (const cb of this.stateChangedListeners) cb({ session: sessionId, agentStatus: newStatus, state: newState });
|
|
1880
|
+
}
|
|
1881
|
+
}
|
|
1882
|
+
// ── Permission management ─────────────────────────────────────
|
|
1883
|
+
/** Create a pending permission request. Returns a promise that resolves when approved/denied. */
|
|
1884
|
+
createPendingPermission(sessionId, request, opts) {
|
|
1885
|
+
const session = this.sessions.get(sessionId);
|
|
1886
|
+
if (session) this.setSessionState(sessionId, session, "permission");
|
|
1887
|
+
return new Promise((resolve) => {
|
|
1888
|
+
const createdAt = Date.now();
|
|
1889
|
+
this.pendingPermissions.set(sessionId, { resolve, request, createdAt });
|
|
1890
|
+
for (const cb of this.permissionRequestListeners) cb(sessionId, request, createdAt);
|
|
1891
|
+
const timeout = opts?.timeoutMs ?? getConfig().permissionTimeoutMs;
|
|
1892
|
+
if (timeout > 0) {
|
|
1893
|
+
setTimeout(() => {
|
|
1894
|
+
if (this.pendingPermissions.has(sessionId)) {
|
|
1895
|
+
this.pendingPermissions.delete(sessionId);
|
|
1896
|
+
resolve(false);
|
|
1897
|
+
}
|
|
1898
|
+
}, timeout);
|
|
1899
|
+
}
|
|
1900
|
+
});
|
|
1901
|
+
}
|
|
1902
|
+
/** Resolve a pending permission request. Returns false if no pending request. */
|
|
1903
|
+
resolvePendingPermission(sessionId, approved) {
|
|
1904
|
+
const pending = this.pendingPermissions.get(sessionId);
|
|
1905
|
+
if (!pending) return false;
|
|
1906
|
+
pending.resolve(approved);
|
|
1907
|
+
this.pendingPermissions.delete(sessionId);
|
|
1908
|
+
const session = this.sessions.get(sessionId);
|
|
1909
|
+
if (session) this.setSessionState(sessionId, session, "processing");
|
|
1910
|
+
return true;
|
|
1911
|
+
}
|
|
1912
|
+
/** Get a pending permission for a specific session. */
|
|
1913
|
+
getPendingPermission(sessionId) {
|
|
1914
|
+
const p = this.pendingPermissions.get(sessionId);
|
|
1915
|
+
return p ? { request: p.request, createdAt: p.createdAt } : null;
|
|
1916
|
+
}
|
|
1917
|
+
/** Get all pending permissions across sessions. */
|
|
1918
|
+
getAllPendingPermissions() {
|
|
1919
|
+
return Array.from(this.pendingPermissions.entries()).map(([id, p]) => ({
|
|
1920
|
+
sessionId: id,
|
|
1921
|
+
request: p.request,
|
|
1922
|
+
createdAt: p.createdAt
|
|
1923
|
+
}));
|
|
1924
|
+
}
|
|
1925
|
+
// ── Session lifecycle ─────────────────────────────────────────
|
|
1926
|
+
/** Kill the agent process in a session (session stays, can be restarted). */
|
|
1927
|
+
/** Save the start config for a session (called by start handlers). */
|
|
1928
|
+
saveStartConfig(id, config) {
|
|
1929
|
+
const session = this.sessions.get(id);
|
|
1930
|
+
if (!session) return;
|
|
1931
|
+
session.lastStartConfig = config;
|
|
1932
|
+
this.persistSession(session);
|
|
1933
|
+
}
|
|
1934
|
+
/** Restart session: kill → re-spawn with merged config + --resume. */
|
|
1935
|
+
restartSession(id, overrides, spawnFn) {
|
|
1936
|
+
const session = this.sessions.get(id);
|
|
1937
|
+
if (!session) throw new Error(`Session "${id}" not found`);
|
|
1938
|
+
const base = session.lastStartConfig;
|
|
1939
|
+
if (!base) throw new Error(`Session "${id}" has no previous start config`);
|
|
1940
|
+
const config = {
|
|
1941
|
+
provider: overrides.provider ?? base.provider,
|
|
1942
|
+
model: overrides.model ?? base.model,
|
|
1943
|
+
permissionMode: overrides.permissionMode ?? base.permissionMode,
|
|
1944
|
+
extraArgs: overrides.extraArgs ?? base.extraArgs
|
|
1945
|
+
};
|
|
1946
|
+
if (session.process?.alive) session.process.kill();
|
|
1947
|
+
const proc = spawnFn(config);
|
|
1948
|
+
this.setProcess(id, proc);
|
|
1949
|
+
session.lastStartConfig = config;
|
|
1950
|
+
this.persistSession(session);
|
|
1951
|
+
this.emitLifecycle({ session: id, state: "restarted" });
|
|
1952
|
+
this.emitConfigChanged(id, config);
|
|
1953
|
+
return { config };
|
|
1954
|
+
}
|
|
1955
|
+
/** Interrupt the current turn. Process stays alive, returns to waiting. */
|
|
1956
|
+
interruptSession(id) {
|
|
1957
|
+
const session = this.sessions.get(id);
|
|
1958
|
+
if (!session?.process?.alive) return false;
|
|
1959
|
+
session.process.interrupt();
|
|
1960
|
+
this.setSessionState(id, session, "waiting");
|
|
1961
|
+
return true;
|
|
1962
|
+
}
|
|
1963
|
+
/** Change model. Sends control message if alive, always persists to config. */
|
|
1964
|
+
setSessionModel(id, model) {
|
|
1965
|
+
const session = this.sessions.get(id);
|
|
1966
|
+
if (!session) return false;
|
|
1967
|
+
if (session.process?.alive) session.process.setModel(model);
|
|
1968
|
+
if (session.lastStartConfig) {
|
|
1969
|
+
session.lastStartConfig.model = model;
|
|
1970
|
+
} else {
|
|
1971
|
+
session.lastStartConfig = { provider: getConfig().defaultProvider, model, permissionMode: getConfig().defaultPermissionMode };
|
|
1972
|
+
}
|
|
1973
|
+
this.persistSession(session);
|
|
1974
|
+
this.emitConfigChanged(id, session.lastStartConfig);
|
|
1975
|
+
return true;
|
|
1976
|
+
}
|
|
1977
|
+
/** Change permission mode. Sends control message if alive, always persists to config. */
|
|
1978
|
+
setSessionPermissionMode(id, mode) {
|
|
1979
|
+
const session = this.sessions.get(id);
|
|
1980
|
+
if (!session) return false;
|
|
1981
|
+
if (session.process?.alive) session.process.setPermissionMode(mode);
|
|
1982
|
+
if (session.lastStartConfig) {
|
|
1983
|
+
session.lastStartConfig.permissionMode = mode;
|
|
1984
|
+
} else {
|
|
1985
|
+
session.lastStartConfig = { provider: getConfig().defaultProvider, model: getConfig().model, permissionMode: mode };
|
|
1986
|
+
}
|
|
1987
|
+
this.persistSession(session);
|
|
1988
|
+
this.emitConfigChanged(id, session.lastStartConfig);
|
|
1989
|
+
return true;
|
|
1990
|
+
}
|
|
1991
|
+
/** Kill the agent process in a session (session stays, can be restarted). */
|
|
1992
|
+
killSession(id) {
|
|
1993
|
+
const session = this.sessions.get(id);
|
|
1994
|
+
if (!session?.process?.alive) return false;
|
|
1995
|
+
session.process.kill();
|
|
1996
|
+
this.emitLifecycle({ session: id, state: "killed" });
|
|
1997
|
+
return true;
|
|
1998
|
+
}
|
|
1999
|
+
/** Remove a session entirely. Cannot remove "default". */
|
|
2000
|
+
removeSession(id) {
|
|
2001
|
+
if (id === "default") return false;
|
|
2002
|
+
const session = this.sessions.get(id);
|
|
2003
|
+
if (!session) return false;
|
|
2004
|
+
if (session.process?.alive) session.process.kill();
|
|
2005
|
+
this.eventListeners.delete(id);
|
|
2006
|
+
this.pendingPermissions.delete(id);
|
|
2007
|
+
this.sessions.delete(id);
|
|
2008
|
+
return true;
|
|
2009
|
+
}
|
|
2010
|
+
/** List all sessions as serializable info objects. */
|
|
2011
|
+
listSessions() {
|
|
2012
|
+
return Array.from(this.sessions.values()).map((s) => ({
|
|
2013
|
+
id: s.id,
|
|
2014
|
+
label: s.label,
|
|
2015
|
+
alive: s.process?.alive ?? false,
|
|
2016
|
+
state: s.state,
|
|
2017
|
+
agentStatus: !s.process?.alive ? "disconnected" : s.state === "processing" ? "busy" : "idle",
|
|
2018
|
+
cwd: s.cwd,
|
|
2019
|
+
meta: s.meta,
|
|
2020
|
+
config: s.lastStartConfig,
|
|
2021
|
+
ccSessionId: s.ccSessionId,
|
|
2022
|
+
eventCount: s.eventCounter,
|
|
2023
|
+
...this.getMessageStats(s.id),
|
|
2024
|
+
createdAt: s.createdAt,
|
|
2025
|
+
lastActivityAt: s.lastActivityAt
|
|
2026
|
+
}));
|
|
2027
|
+
}
|
|
2028
|
+
/** Touch a session's lastActivityAt timestamp. */
|
|
2029
|
+
touch(id) {
|
|
2030
|
+
const session = this.sessions.get(id);
|
|
2031
|
+
if (session) session.lastActivityAt = Date.now();
|
|
2032
|
+
}
|
|
2033
|
+
/** Persist an agent event to chat_messages. */
|
|
2034
|
+
getMessageStats(sessionId) {
|
|
2035
|
+
try {
|
|
2036
|
+
const db = getDb();
|
|
2037
|
+
const count = db.prepare(
|
|
2038
|
+
`SELECT COUNT(*) as c FROM chat_messages WHERE session_id = ?`
|
|
2039
|
+
).get(sessionId);
|
|
2040
|
+
const last = db.prepare(
|
|
2041
|
+
`SELECT role, content, created_at FROM chat_messages WHERE session_id = ? ORDER BY id DESC LIMIT 1`
|
|
2042
|
+
).get(sessionId);
|
|
2043
|
+
return {
|
|
2044
|
+
messageCount: count.c,
|
|
2045
|
+
lastMessage: last ? { role: last.role, content: last.content, created_at: last.created_at } : null
|
|
2046
|
+
};
|
|
2047
|
+
} catch {
|
|
2048
|
+
return { messageCount: 0, lastMessage: null };
|
|
2049
|
+
}
|
|
2050
|
+
}
|
|
2051
|
+
/** Persist an agent event to chat_messages. Returns true if a row was inserted. */
|
|
2052
|
+
persistEvent(sessionId, e) {
|
|
2053
|
+
try {
|
|
2054
|
+
const db = getDb();
|
|
2055
|
+
switch (e.type) {
|
|
2056
|
+
case "assistant":
|
|
2057
|
+
if (e.message) {
|
|
2058
|
+
db.prepare(`INSERT INTO chat_messages (session_id, role, content) VALUES (?, 'assistant', ?)`).run(sessionId, e.message);
|
|
2059
|
+
return true;
|
|
2060
|
+
}
|
|
2061
|
+
return false;
|
|
2062
|
+
case "thinking":
|
|
2063
|
+
if (e.message) {
|
|
2064
|
+
db.prepare(`INSERT INTO chat_messages (session_id, role, content) VALUES (?, 'thinking', ?)`).run(sessionId, e.message);
|
|
2065
|
+
return true;
|
|
2066
|
+
}
|
|
2067
|
+
return false;
|
|
2068
|
+
case "tool_use": {
|
|
2069
|
+
const toolName = e.data?.toolName ?? e.message ?? "tool";
|
|
2070
|
+
db.prepare(`INSERT INTO chat_messages (session_id, role, content, meta) VALUES (?, 'tool', ?, ?)`).run(sessionId, toolName, JSON.stringify(e.data ?? {}));
|
|
2071
|
+
return true;
|
|
2072
|
+
}
|
|
2073
|
+
case "tool_result":
|
|
2074
|
+
db.prepare(`INSERT INTO chat_messages (session_id, role, content, meta) VALUES (?, 'tool_result', ?, ?)`).run(sessionId, e.message ?? "", JSON.stringify(e.data ?? {}));
|
|
2075
|
+
return true;
|
|
2076
|
+
case "complete":
|
|
2077
|
+
db.prepare(`INSERT INTO chat_messages (session_id, role, content, meta) VALUES (?, 'status', '', ?)`).run(sessionId, JSON.stringify({ status: "complete", ...e.data }));
|
|
2078
|
+
return true;
|
|
2079
|
+
case "error":
|
|
2080
|
+
db.prepare(`INSERT INTO chat_messages (session_id, role, content, meta) VALUES (?, 'error', ?, ?)`).run(sessionId, e.message ?? "Error", JSON.stringify({ status: "error" }));
|
|
2081
|
+
return true;
|
|
2082
|
+
default:
|
|
2083
|
+
return false;
|
|
2084
|
+
}
|
|
2085
|
+
} catch {
|
|
2086
|
+
return false;
|
|
2087
|
+
}
|
|
2088
|
+
}
|
|
2089
|
+
/** Kill all sessions. Used during shutdown. */
|
|
2090
|
+
killAll() {
|
|
2091
|
+
const pids = [];
|
|
2092
|
+
for (const session of this.sessions.values()) {
|
|
2093
|
+
if (session.process?.alive) {
|
|
2094
|
+
const pid = session.process.pid;
|
|
2095
|
+
session.process.kill();
|
|
2096
|
+
if (pid) pids.push(pid);
|
|
2097
|
+
}
|
|
2098
|
+
}
|
|
2099
|
+
if (pids.length > 0) {
|
|
2100
|
+
setTimeout(() => {
|
|
2101
|
+
for (const pid of pids) {
|
|
2102
|
+
try {
|
|
2103
|
+
process.kill(pid, "SIGKILL");
|
|
2104
|
+
} catch {
|
|
2105
|
+
}
|
|
2106
|
+
}
|
|
2107
|
+
}, 1e3);
|
|
2108
|
+
}
|
|
2109
|
+
}
|
|
2110
|
+
get size() {
|
|
2111
|
+
return this.sessions.size;
|
|
2112
|
+
}
|
|
2113
|
+
};
|
|
2114
|
+
|
|
2115
|
+
// src/server/ws.ts
|
|
2116
|
+
var import_ws = require("ws");
|
|
2117
|
+
function send(ws, data) {
|
|
2118
|
+
if (ws.readyState === ws.OPEN) {
|
|
2119
|
+
ws.send(JSON.stringify(data));
|
|
2120
|
+
}
|
|
2121
|
+
}
|
|
2122
|
+
function reply(ws, msg, data) {
|
|
2123
|
+
send(ws, { ...data, type: msg.type, ...msg.rid != null ? { rid: msg.rid } : {} });
|
|
2124
|
+
}
|
|
2125
|
+
function replyError(ws, msg, message) {
|
|
2126
|
+
send(ws, { type: "error", ...msg.rid != null ? { rid: msg.rid } : {}, message });
|
|
2127
|
+
}
|
|
2128
|
+
function attachWebSocket(server, sessionManager) {
|
|
2129
|
+
const wss = new import_ws.WebSocketServer({ noServer: true });
|
|
2130
|
+
server.on("upgrade", (req, socket, head) => {
|
|
2131
|
+
const url = new URL(req.url ?? "/", `http://${req.headers.host}`);
|
|
2132
|
+
if (url.pathname === "/ws") {
|
|
2133
|
+
wss.handleUpgrade(req, socket, head, (ws) => {
|
|
2134
|
+
wss.emit("connection", ws, req);
|
|
2135
|
+
});
|
|
2136
|
+
} else {
|
|
2137
|
+
socket.destroy();
|
|
2138
|
+
}
|
|
2139
|
+
});
|
|
2140
|
+
wss.on("connection", (ws) => {
|
|
2141
|
+
logger.log("ws", "client connected");
|
|
2142
|
+
const state = { agentUnsubs: /* @__PURE__ */ new Map(), skillEventUnsub: null, skillPollTimer: null, permissionUnsub: null, lifecycleUnsub: null, configChangedUnsub: null, stateChangedUnsub: null, metadataChangedUnsub: null };
|
|
2143
|
+
const pushSnapshot = () => send(ws, { type: "sessions.snapshot", sessions: sessionManager.listSessions() });
|
|
2144
|
+
pushSnapshot();
|
|
2145
|
+
state.lifecycleUnsub = sessionManager.onSessionLifecycle((event) => {
|
|
2146
|
+
send(ws, { type: "session.lifecycle", ...event });
|
|
2147
|
+
pushSnapshot();
|
|
2148
|
+
});
|
|
2149
|
+
state.configChangedUnsub = sessionManager.onConfigChanged((event) => {
|
|
2150
|
+
send(ws, { type: "session.config-changed", ...event });
|
|
2151
|
+
});
|
|
2152
|
+
state.stateChangedUnsub = sessionManager.onStateChanged((event) => {
|
|
2153
|
+
send(ws, { type: "session.state-changed", ...event });
|
|
2154
|
+
pushSnapshot();
|
|
2155
|
+
});
|
|
2156
|
+
state.metadataChangedUnsub = sessionManager.onMetadataChanged(() => {
|
|
2157
|
+
pushSnapshot();
|
|
2158
|
+
});
|
|
2159
|
+
ws.on("message", (raw) => {
|
|
2160
|
+
let msg;
|
|
2161
|
+
try {
|
|
2162
|
+
msg = JSON.parse(raw.toString());
|
|
2163
|
+
} catch {
|
|
2164
|
+
send(ws, { type: "error", message: "invalid JSON" });
|
|
2165
|
+
return;
|
|
2166
|
+
}
|
|
2167
|
+
if (!msg.type) {
|
|
2168
|
+
send(ws, { type: "error", message: "type is required" });
|
|
2169
|
+
return;
|
|
2170
|
+
}
|
|
2171
|
+
handleMessage(ws, msg, sessionManager, state);
|
|
2172
|
+
});
|
|
2173
|
+
ws.on("close", () => {
|
|
2174
|
+
logger.log("ws", "client disconnected");
|
|
2175
|
+
for (const unsub of state.agentUnsubs.values()) unsub();
|
|
2176
|
+
state.agentUnsubs.clear();
|
|
2177
|
+
state.skillEventUnsub?.();
|
|
2178
|
+
state.skillEventUnsub = null;
|
|
2179
|
+
if (state.skillPollTimer) {
|
|
2180
|
+
clearInterval(state.skillPollTimer);
|
|
2181
|
+
state.skillPollTimer = null;
|
|
2182
|
+
}
|
|
2183
|
+
state.permissionUnsub?.();
|
|
2184
|
+
state.permissionUnsub = null;
|
|
2185
|
+
state.lifecycleUnsub?.();
|
|
2186
|
+
state.lifecycleUnsub = null;
|
|
2187
|
+
state.configChangedUnsub?.();
|
|
2188
|
+
state.configChangedUnsub = null;
|
|
2189
|
+
state.stateChangedUnsub?.();
|
|
2190
|
+
state.stateChangedUnsub = null;
|
|
2191
|
+
state.metadataChangedUnsub?.();
|
|
2192
|
+
state.metadataChangedUnsub = null;
|
|
2193
|
+
});
|
|
2194
|
+
});
|
|
2195
|
+
return wss;
|
|
2196
|
+
}
|
|
2197
|
+
function handleMessage(ws, msg, sm, state) {
|
|
2198
|
+
switch (msg.type) {
|
|
2199
|
+
// ── Session CRUD ──────────────────────────────────
|
|
2200
|
+
case "sessions.create":
|
|
2201
|
+
return handleSessionsCreate(ws, msg, sm);
|
|
2202
|
+
case "sessions.list":
|
|
2203
|
+
return wsReply(ws, msg, { sessions: sm.listSessions() });
|
|
2204
|
+
case "sessions.update":
|
|
2205
|
+
return handleSessionsUpdate(ws, msg, sm);
|
|
2206
|
+
case "sessions.remove":
|
|
2207
|
+
return handleSessionsRemove(ws, msg, sm);
|
|
2208
|
+
// ── Agent lifecycle ───────────────────────────────
|
|
2209
|
+
case "agent.start":
|
|
2210
|
+
return handleAgentStart(ws, msg, sm);
|
|
2211
|
+
case "agent.send":
|
|
2212
|
+
return handleAgentSend(ws, msg, sm);
|
|
2213
|
+
case "agent.resume":
|
|
2214
|
+
return handleAgentResume(ws, msg, sm);
|
|
2215
|
+
case "agent.restart":
|
|
2216
|
+
return handleAgentRestart(ws, msg, sm);
|
|
2217
|
+
case "agent.interrupt":
|
|
2218
|
+
return handleAgentInterrupt(ws, msg, sm);
|
|
2219
|
+
case "agent.set-model":
|
|
2220
|
+
return handleAgentSetModel(ws, msg, sm);
|
|
2221
|
+
case "agent.set-permission-mode":
|
|
2222
|
+
return handleAgentSetPermissionMode(ws, msg, sm);
|
|
2223
|
+
case "agent.kill":
|
|
2224
|
+
return handleAgentKill(ws, msg, sm);
|
|
2225
|
+
case "agent.status":
|
|
2226
|
+
return handleAgentStatus(ws, msg, sm);
|
|
2227
|
+
case "agent.run-once":
|
|
2228
|
+
handleAgentRunOnce(ws, msg, sm);
|
|
2229
|
+
return;
|
|
2230
|
+
// ── Agent event subscription ──────────────────────
|
|
2231
|
+
case "agent.subscribe":
|
|
2232
|
+
return handleAgentSubscribe(ws, msg, sm, state);
|
|
2233
|
+
case "agent.unsubscribe":
|
|
2234
|
+
return handleAgentUnsubscribe(ws, msg, state);
|
|
2235
|
+
// ── Skill events ──────────────────────────────────
|
|
2236
|
+
case "events.subscribe":
|
|
2237
|
+
return handleEventsSubscribe(ws, msg, sm, state);
|
|
2238
|
+
case "events.unsubscribe":
|
|
2239
|
+
return handleEventsUnsubscribe(ws, msg, state);
|
|
2240
|
+
case "emit":
|
|
2241
|
+
return handleEmit(ws, msg, sm);
|
|
2242
|
+
// ── Permission ────────────────────────────────────
|
|
2243
|
+
case "permission.respond":
|
|
2244
|
+
return handlePermissionRespond(ws, msg, sm);
|
|
2245
|
+
case "permission.pending":
|
|
2246
|
+
return handlePermissionPending(ws, msg, sm);
|
|
2247
|
+
case "permission.subscribe":
|
|
2248
|
+
return handlePermissionSubscribe(ws, msg, sm, state);
|
|
2249
|
+
case "permission.unsubscribe":
|
|
2250
|
+
return handlePermissionUnsubscribe(ws, msg, state);
|
|
2251
|
+
// ── Chat sessions ─────────────────────────────────
|
|
2252
|
+
case "chat.sessions.list":
|
|
2253
|
+
return handleChatSessionsList(ws, msg);
|
|
2254
|
+
case "chat.sessions.create":
|
|
2255
|
+
return handleChatSessionsCreate(ws, msg);
|
|
2256
|
+
case "chat.sessions.remove":
|
|
2257
|
+
return handleChatSessionsRemove(ws, msg);
|
|
2258
|
+
// ── Chat messages ─────────────────────────────────
|
|
2259
|
+
case "chat.messages.list":
|
|
2260
|
+
return handleChatMessagesList(ws, msg);
|
|
2261
|
+
case "chat.messages.create":
|
|
2262
|
+
return handleChatMessagesCreate(ws, msg);
|
|
2263
|
+
case "chat.messages.clear":
|
|
2264
|
+
return handleChatMessagesClear(ws, msg);
|
|
2265
|
+
default:
|
|
2266
|
+
replyError(ws, msg, `Unknown message type: ${msg.type}`);
|
|
2267
|
+
}
|
|
2268
|
+
}
|
|
2269
|
+
function handleSessionsCreate(ws, msg, sm) {
|
|
2270
|
+
try {
|
|
2271
|
+
const session = sm.createSession({
|
|
2272
|
+
id: msg.id,
|
|
2273
|
+
label: msg.label,
|
|
2274
|
+
cwd: msg.cwd,
|
|
2275
|
+
meta: msg.meta
|
|
2276
|
+
});
|
|
2277
|
+
wsReply(ws, msg, { status: "created", sessionId: session.id, label: session.label, meta: session.meta });
|
|
2278
|
+
} catch (e) {
|
|
2279
|
+
replyError(ws, msg, e.message);
|
|
2280
|
+
}
|
|
2281
|
+
}
|
|
2282
|
+
function handleSessionsUpdate(ws, msg, sm) {
|
|
2283
|
+
const id = msg.session;
|
|
2284
|
+
if (!id) return replyError(ws, msg, "session is required");
|
|
2285
|
+
try {
|
|
2286
|
+
sm.updateSession(id, {
|
|
2287
|
+
label: msg.label,
|
|
2288
|
+
meta: msg.meta,
|
|
2289
|
+
cwd: msg.cwd
|
|
2290
|
+
});
|
|
2291
|
+
wsReply(ws, msg, { status: "updated", session: id });
|
|
2292
|
+
} catch (e) {
|
|
2293
|
+
replyError(ws, msg, e.message);
|
|
2294
|
+
}
|
|
2295
|
+
}
|
|
2296
|
+
function handleSessionsRemove(ws, msg, sm) {
|
|
2297
|
+
const id = msg.session;
|
|
2298
|
+
if (!id) return replyError(ws, msg, "session is required");
|
|
2299
|
+
if (id === "default") return replyError(ws, msg, "Cannot remove default session");
|
|
2300
|
+
const removed = sm.removeSession(id);
|
|
2301
|
+
if (!removed) return replyError(ws, msg, "Session not found");
|
|
2302
|
+
wsReply(ws, msg, { status: "removed" });
|
|
2303
|
+
}
|
|
2304
|
+
function handleAgentStart(ws, msg, sm) {
|
|
2305
|
+
const sessionId = msg.session ?? "default";
|
|
2306
|
+
const session = sm.getOrCreateSession(sessionId, {
|
|
2307
|
+
cwd: msg.cwd
|
|
2308
|
+
});
|
|
2309
|
+
if (session.process?.alive && !msg.force) {
|
|
2310
|
+
wsReply(ws, msg, { status: "already_running", provider: getConfig().defaultProvider, sessionId: session.id });
|
|
2311
|
+
return;
|
|
2312
|
+
}
|
|
2313
|
+
if (session.process?.alive) session.process.kill();
|
|
2314
|
+
const provider = getProvider(msg.provider ?? getConfig().defaultProvider);
|
|
2315
|
+
try {
|
|
2316
|
+
const db = getDb();
|
|
2317
|
+
db.prepare(`INSERT OR IGNORE INTO chat_sessions (id, label, type) VALUES (?, ?, 'main')`).run(sessionId, session.label ?? sessionId);
|
|
2318
|
+
if (msg.prompt) {
|
|
2319
|
+
db.prepare(`INSERT INTO chat_messages (session_id, role, content, meta) VALUES (?, 'user', ?, ?)`).run(sessionId, msg.prompt, msg.meta ? JSON.stringify(msg.meta) : null);
|
|
2320
|
+
}
|
|
2321
|
+
const skillMatch = msg.prompt?.match(/^Execute the skill:\s*(\S+)/);
|
|
2322
|
+
if (skillMatch) {
|
|
2323
|
+
db.prepare(`INSERT INTO skill_events (session_id, skill, type, message) VALUES (?, ?, 'invoked', ?)`).run(sessionId, skillMatch[1], `Skill ${skillMatch[1]} invoked`);
|
|
2324
|
+
}
|
|
2325
|
+
} catch {
|
|
2326
|
+
}
|
|
2327
|
+
const cfg = getConfig();
|
|
2328
|
+
const providerName = msg.provider ?? cfg.defaultProvider;
|
|
2329
|
+
const model = msg.model ?? cfg.model;
|
|
2330
|
+
const permissionMode = msg.permissionMode;
|
|
2331
|
+
const configDir = msg.configDir;
|
|
2332
|
+
const extraArgs = msg.extraArgs;
|
|
2333
|
+
try {
|
|
2334
|
+
const proc = provider.spawn({
|
|
2335
|
+
cwd: session.cwd,
|
|
2336
|
+
prompt: msg.prompt,
|
|
2337
|
+
model,
|
|
2338
|
+
permissionMode,
|
|
2339
|
+
configDir,
|
|
2340
|
+
env: { SNA_SESSION_ID: sessionId },
|
|
2341
|
+
history: msg.history,
|
|
2342
|
+
extraArgs
|
|
2343
|
+
});
|
|
2344
|
+
sm.setProcess(sessionId, proc);
|
|
2345
|
+
sm.saveStartConfig(sessionId, { provider: providerName, model, permissionMode, configDir, extraArgs });
|
|
2346
|
+
wsReply(ws, msg, { status: "started", provider: provider.name, sessionId: session.id });
|
|
2347
|
+
} catch (e) {
|
|
2348
|
+
replyError(ws, msg, e.message);
|
|
2349
|
+
}
|
|
2350
|
+
}
|
|
2351
|
+
function handleAgentSend(ws, msg, sm) {
|
|
2352
|
+
const sessionId = msg.session ?? "default";
|
|
2353
|
+
const session = sm.getSession(sessionId);
|
|
2354
|
+
if (!session?.process?.alive) {
|
|
2355
|
+
return replyError(ws, msg, `No active agent session "${sessionId}". Start first.`);
|
|
2356
|
+
}
|
|
2357
|
+
const images = msg.images;
|
|
2358
|
+
if (!msg.message && !images?.length) {
|
|
2359
|
+
return replyError(ws, msg, "message or images required");
|
|
2360
|
+
}
|
|
2361
|
+
const textContent = msg.message ?? "(image)";
|
|
2362
|
+
let meta = msg.meta ? { ...msg.meta } : {};
|
|
2363
|
+
if (images?.length) {
|
|
2364
|
+
const filenames = saveImages(sessionId, images);
|
|
2365
|
+
meta.images = filenames;
|
|
2366
|
+
}
|
|
2367
|
+
try {
|
|
2368
|
+
const db = getDb();
|
|
2369
|
+
db.prepare(`INSERT OR IGNORE INTO chat_sessions (id, label, type) VALUES (?, ?, 'main')`).run(sessionId, session.label ?? sessionId);
|
|
2370
|
+
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);
|
|
2371
|
+
} catch {
|
|
2372
|
+
}
|
|
2373
|
+
sm.pushEvent(sessionId, {
|
|
2374
|
+
type: "user_message",
|
|
2375
|
+
message: textContent,
|
|
2376
|
+
data: Object.keys(meta).length > 0 ? meta : void 0,
|
|
2377
|
+
timestamp: Date.now()
|
|
2378
|
+
});
|
|
2379
|
+
sm.updateSessionState(sessionId, "processing");
|
|
2380
|
+
sm.touch(sessionId);
|
|
2381
|
+
if (images?.length) {
|
|
2382
|
+
const content = [
|
|
2383
|
+
...images.map((img) => ({
|
|
2384
|
+
type: "image",
|
|
2385
|
+
source: { type: "base64", media_type: img.mimeType, data: img.base64 }
|
|
2386
|
+
})),
|
|
2387
|
+
...msg.message ? [{ type: "text", text: msg.message }] : []
|
|
2388
|
+
];
|
|
2389
|
+
session.process.send(content);
|
|
2390
|
+
} else {
|
|
2391
|
+
session.process.send(msg.message);
|
|
2392
|
+
}
|
|
2393
|
+
wsReply(ws, msg, { status: "sent" });
|
|
2394
|
+
}
|
|
2395
|
+
function handleAgentResume(ws, msg, sm) {
|
|
2396
|
+
const sessionId = msg.session ?? "default";
|
|
2397
|
+
const session = sm.getOrCreateSession(sessionId);
|
|
2398
|
+
if (session.process?.alive) {
|
|
2399
|
+
return replyError(ws, msg, "Session already running. Use agent.send instead.");
|
|
2400
|
+
}
|
|
2401
|
+
const history = buildHistoryFromDb(sessionId);
|
|
2402
|
+
if (history.length === 0 && !msg.prompt) {
|
|
2403
|
+
return replyError(ws, msg, "No history in DB \u2014 nothing to resume.");
|
|
2404
|
+
}
|
|
2405
|
+
const providerName = msg.provider ?? session.lastStartConfig?.provider ?? getConfig().defaultProvider;
|
|
2406
|
+
const model = msg.model ?? session.lastStartConfig?.model ?? getConfig().model;
|
|
2407
|
+
const permissionMode = msg.permissionMode ?? session.lastStartConfig?.permissionMode;
|
|
2408
|
+
const configDir = msg.configDir ?? session.lastStartConfig?.configDir;
|
|
2409
|
+
const extraArgs = msg.extraArgs ?? session.lastStartConfig?.extraArgs;
|
|
2410
|
+
const provider = getProvider(providerName);
|
|
2411
|
+
try {
|
|
2412
|
+
const proc = provider.spawn({
|
|
2413
|
+
cwd: session.cwd,
|
|
2414
|
+
prompt: msg.prompt,
|
|
2415
|
+
model,
|
|
2416
|
+
permissionMode,
|
|
2417
|
+
configDir,
|
|
2418
|
+
env: { SNA_SESSION_ID: sessionId },
|
|
2419
|
+
history: history.length > 0 ? history : void 0,
|
|
2420
|
+
extraArgs
|
|
2421
|
+
});
|
|
2422
|
+
sm.setProcess(sessionId, proc, "resumed");
|
|
2423
|
+
sm.saveStartConfig(sessionId, { provider: providerName, model, permissionMode, configDir, extraArgs });
|
|
2424
|
+
wsReply(ws, msg, {
|
|
2425
|
+
status: "resumed",
|
|
2426
|
+
provider: providerName,
|
|
2427
|
+
sessionId: session.id,
|
|
2428
|
+
historyCount: history.length
|
|
2429
|
+
});
|
|
2430
|
+
} catch (e) {
|
|
2431
|
+
replyError(ws, msg, e.message);
|
|
2432
|
+
}
|
|
2433
|
+
}
|
|
2434
|
+
function handleAgentRestart(ws, msg, sm) {
|
|
2435
|
+
const sessionId = msg.session ?? "default";
|
|
2436
|
+
try {
|
|
2437
|
+
const ccSessionId = sm.getSession(sessionId)?.ccSessionId;
|
|
2438
|
+
const { config } = sm.restartSession(
|
|
2439
|
+
sessionId,
|
|
2440
|
+
{
|
|
2441
|
+
provider: msg.provider,
|
|
2442
|
+
model: msg.model,
|
|
2443
|
+
permissionMode: msg.permissionMode,
|
|
2444
|
+
configDir: msg.configDir,
|
|
2445
|
+
extraArgs: msg.extraArgs
|
|
2446
|
+
},
|
|
2447
|
+
(cfg) => {
|
|
2448
|
+
const prov = getProvider(cfg.provider);
|
|
2449
|
+
const resumeArgs = ccSessionId ? ["--resume", ccSessionId] : ["--resume"];
|
|
2450
|
+
return prov.spawn({
|
|
2451
|
+
cwd: sm.getSession(sessionId).cwd,
|
|
2452
|
+
model: cfg.model,
|
|
2453
|
+
permissionMode: cfg.permissionMode,
|
|
2454
|
+
configDir: cfg.configDir,
|
|
2455
|
+
env: { SNA_SESSION_ID: sessionId },
|
|
2456
|
+
extraArgs: [...cfg.extraArgs ?? [], ...resumeArgs]
|
|
2457
|
+
});
|
|
2458
|
+
}
|
|
2459
|
+
);
|
|
2460
|
+
wsReply(ws, msg, { status: "restarted", provider: config.provider, sessionId });
|
|
2461
|
+
} catch (e) {
|
|
2462
|
+
replyError(ws, msg, e.message);
|
|
2463
|
+
}
|
|
2464
|
+
}
|
|
2465
|
+
function handleAgentInterrupt(ws, msg, sm) {
|
|
2466
|
+
const sessionId = msg.session ?? "default";
|
|
2467
|
+
const interrupted = sm.interruptSession(sessionId);
|
|
2468
|
+
wsReply(ws, msg, { status: interrupted ? "interrupted" : "no_session" });
|
|
2469
|
+
}
|
|
2470
|
+
function handleAgentSetModel(ws, msg, sm) {
|
|
2471
|
+
const sessionId = msg.session ?? "default";
|
|
2472
|
+
const model = msg.model;
|
|
2473
|
+
if (!model) return replyError(ws, msg, "model is required");
|
|
2474
|
+
const updated = sm.setSessionModel(sessionId, model);
|
|
2475
|
+
wsReply(ws, msg, { status: updated ? "updated" : "no_session", model });
|
|
2476
|
+
}
|
|
2477
|
+
function handleAgentSetPermissionMode(ws, msg, sm) {
|
|
2478
|
+
const sessionId = msg.session ?? "default";
|
|
2479
|
+
const permissionMode = msg.permissionMode;
|
|
2480
|
+
if (!permissionMode) return replyError(ws, msg, "permissionMode is required");
|
|
2481
|
+
const updated = sm.setSessionPermissionMode(sessionId, permissionMode);
|
|
2482
|
+
wsReply(ws, msg, { status: updated ? "updated" : "no_session", permissionMode });
|
|
2483
|
+
}
|
|
2484
|
+
function handleAgentKill(ws, msg, sm) {
|
|
2485
|
+
const sessionId = msg.session ?? "default";
|
|
2486
|
+
const killed = sm.killSession(sessionId);
|
|
2487
|
+
wsReply(ws, msg, { status: killed ? "killed" : "no_session" });
|
|
2488
|
+
}
|
|
2489
|
+
function handleAgentStatus(ws, msg, sm) {
|
|
2490
|
+
const sessionId = msg.session ?? "default";
|
|
2491
|
+
const session = sm.getSession(sessionId);
|
|
2492
|
+
const alive = session?.process?.alive ?? false;
|
|
2493
|
+
let messageCount = 0;
|
|
2494
|
+
let lastMessage = null;
|
|
2495
|
+
try {
|
|
2496
|
+
const db = getDb();
|
|
2497
|
+
const count = db.prepare("SELECT COUNT(*) as c FROM chat_messages WHERE session_id = ?").get(sessionId);
|
|
2498
|
+
messageCount = count?.c ?? 0;
|
|
2499
|
+
const last = db.prepare("SELECT role, content, created_at FROM chat_messages WHERE session_id = ? ORDER BY id DESC LIMIT 1").get(sessionId);
|
|
2500
|
+
if (last) lastMessage = { role: last.role, content: last.content, created_at: last.created_at };
|
|
2501
|
+
} catch {
|
|
2502
|
+
}
|
|
2503
|
+
wsReply(ws, msg, {
|
|
2504
|
+
alive,
|
|
2505
|
+
agentStatus: !alive ? "disconnected" : session?.state === "processing" ? "busy" : "idle",
|
|
2506
|
+
sessionId: session?.process?.sessionId ?? null,
|
|
2507
|
+
ccSessionId: session?.ccSessionId ?? null,
|
|
2508
|
+
eventCount: session?.eventCounter ?? 0,
|
|
2509
|
+
messageCount,
|
|
2510
|
+
lastMessage,
|
|
2511
|
+
config: session?.lastStartConfig ?? null
|
|
2512
|
+
});
|
|
2513
|
+
}
|
|
2514
|
+
async function handleAgentRunOnce(ws, msg, sm) {
|
|
2515
|
+
if (!msg.message) return replyError(ws, msg, "message is required");
|
|
2516
|
+
try {
|
|
2517
|
+
const { result, usage } = await runOnce(sm, msg);
|
|
2518
|
+
wsReply(ws, msg, { result, usage });
|
|
2519
|
+
} catch (e) {
|
|
2520
|
+
replyError(ws, msg, e.message);
|
|
2521
|
+
}
|
|
2522
|
+
}
|
|
2523
|
+
function handleAgentSubscribe(ws, msg, sm, state) {
|
|
2524
|
+
const sessionId = msg.session ?? "default";
|
|
2525
|
+
const session = sm.getOrCreateSession(sessionId);
|
|
2526
|
+
state.agentUnsubs.get(sessionId)?.();
|
|
2527
|
+
const includeHistory = msg.since === 0 || msg.includeHistory === true;
|
|
2528
|
+
let cursor = 0;
|
|
2529
|
+
if (includeHistory) {
|
|
2530
|
+
try {
|
|
2531
|
+
const db = getDb();
|
|
2532
|
+
const rows = db.prepare(
|
|
2533
|
+
`SELECT role, content, meta, created_at FROM chat_messages
|
|
2534
|
+
WHERE session_id = ? ORDER BY id ASC`
|
|
2535
|
+
).all(sessionId);
|
|
2536
|
+
for (const row of rows) {
|
|
2537
|
+
cursor++;
|
|
2538
|
+
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;
|
|
2539
|
+
if (!eventType) continue;
|
|
2540
|
+
const meta = row.meta ? JSON.parse(row.meta) : void 0;
|
|
2541
|
+
send(ws, {
|
|
2542
|
+
type: "agent.event",
|
|
2543
|
+
session: sessionId,
|
|
2544
|
+
cursor,
|
|
2545
|
+
isHistory: true,
|
|
2546
|
+
event: {
|
|
2547
|
+
type: eventType,
|
|
2548
|
+
message: row.content,
|
|
2549
|
+
data: meta,
|
|
2550
|
+
timestamp: new Date(row.created_at).getTime()
|
|
2551
|
+
}
|
|
2552
|
+
});
|
|
2553
|
+
}
|
|
2554
|
+
} catch {
|
|
2555
|
+
}
|
|
2556
|
+
if (cursor < session.eventCounter) {
|
|
2557
|
+
const unpersisted = session.eventCounter - cursor;
|
|
2558
|
+
const bufferSlice = session.eventBuffer.slice(-unpersisted);
|
|
2559
|
+
for (const event of bufferSlice) {
|
|
2560
|
+
cursor++;
|
|
2561
|
+
send(ws, { type: "agent.event", session: sessionId, cursor, event });
|
|
2562
|
+
}
|
|
2563
|
+
}
|
|
2564
|
+
} else {
|
|
2565
|
+
cursor = typeof msg.since === "number" && msg.since > 0 ? msg.since : session.eventCounter;
|
|
2566
|
+
if (cursor < session.eventCounter) {
|
|
2567
|
+
const startIdx = Math.max(0, session.eventBuffer.length - (session.eventCounter - cursor));
|
|
2568
|
+
const events = session.eventBuffer.slice(startIdx);
|
|
2569
|
+
for (const event of events) {
|
|
2570
|
+
cursor++;
|
|
2571
|
+
send(ws, { type: "agent.event", session: sessionId, cursor, event });
|
|
2572
|
+
}
|
|
2573
|
+
} else {
|
|
2574
|
+
cursor = session.eventCounter;
|
|
2575
|
+
}
|
|
2576
|
+
}
|
|
2577
|
+
const unsub = sm.onSessionEvent(sessionId, (eventCursor, event) => {
|
|
2578
|
+
if (eventCursor === -1) {
|
|
2579
|
+
send(ws, { type: "agent.event", session: sessionId, event });
|
|
2580
|
+
} else {
|
|
2581
|
+
send(ws, { type: "agent.event", session: sessionId, cursor: eventCursor, event });
|
|
2582
|
+
}
|
|
2583
|
+
});
|
|
2584
|
+
state.agentUnsubs.set(sessionId, unsub);
|
|
2585
|
+
reply(ws, msg, { cursor });
|
|
2586
|
+
}
|
|
2587
|
+
function handleAgentUnsubscribe(ws, msg, state) {
|
|
2588
|
+
const sessionId = msg.session ?? "default";
|
|
2589
|
+
state.agentUnsubs.get(sessionId)?.();
|
|
2590
|
+
state.agentUnsubs.delete(sessionId);
|
|
2591
|
+
reply(ws, msg, {});
|
|
2592
|
+
}
|
|
2593
|
+
function handleEventsSubscribe(ws, msg, sm, state) {
|
|
2594
|
+
state.skillEventUnsub?.();
|
|
2595
|
+
state.skillEventUnsub = null;
|
|
2596
|
+
if (state.skillPollTimer) {
|
|
2597
|
+
clearInterval(state.skillPollTimer);
|
|
2598
|
+
state.skillPollTimer = null;
|
|
2599
|
+
}
|
|
2600
|
+
let lastId = typeof msg.since === "number" ? msg.since : -1;
|
|
2601
|
+
if (lastId <= 0) {
|
|
2602
|
+
try {
|
|
2603
|
+
const db = getDb();
|
|
2604
|
+
const row = db.prepare("SELECT MAX(id) as maxId FROM skill_events").get();
|
|
2605
|
+
lastId = row.maxId ?? 0;
|
|
2606
|
+
} catch {
|
|
2607
|
+
lastId = 0;
|
|
2608
|
+
}
|
|
2609
|
+
}
|
|
2610
|
+
state.skillEventUnsub = sm.onSkillEvent((event) => {
|
|
2611
|
+
const eventId = event.id;
|
|
2612
|
+
if (eventId > lastId) {
|
|
2613
|
+
lastId = eventId;
|
|
2614
|
+
send(ws, { type: "skill.event", data: event });
|
|
2615
|
+
}
|
|
2616
|
+
});
|
|
2617
|
+
state.skillPollTimer = setInterval(() => {
|
|
2618
|
+
try {
|
|
2619
|
+
const db = getDb();
|
|
2620
|
+
const rows = db.prepare(
|
|
2621
|
+
`SELECT id, session_id, skill, type, message, data, created_at
|
|
2622
|
+
FROM skill_events WHERE id > ? ORDER BY id ASC LIMIT 50`
|
|
2623
|
+
).all(lastId);
|
|
2624
|
+
for (const row of rows) {
|
|
2625
|
+
if (row.id > lastId) {
|
|
2626
|
+
lastId = row.id;
|
|
2627
|
+
send(ws, { type: "skill.event", data: row });
|
|
2628
|
+
}
|
|
2629
|
+
}
|
|
2630
|
+
} catch {
|
|
2631
|
+
}
|
|
2632
|
+
}, getConfig().skillPollMs);
|
|
2633
|
+
reply(ws, msg, { lastId });
|
|
2634
|
+
}
|
|
2635
|
+
function handleEventsUnsubscribe(ws, msg, state) {
|
|
2636
|
+
state.skillEventUnsub?.();
|
|
2637
|
+
state.skillEventUnsub = null;
|
|
2638
|
+
if (state.skillPollTimer) {
|
|
2639
|
+
clearInterval(state.skillPollTimer);
|
|
2640
|
+
state.skillPollTimer = null;
|
|
2641
|
+
}
|
|
2642
|
+
reply(ws, msg, {});
|
|
2643
|
+
}
|
|
2644
|
+
function handleEmit(ws, msg, sm) {
|
|
2645
|
+
const skill = msg.skill;
|
|
2646
|
+
const eventType = msg.eventType;
|
|
2647
|
+
const emitMessage = msg.message;
|
|
2648
|
+
const data = msg.data;
|
|
2649
|
+
const sessionId = msg.session;
|
|
2650
|
+
if (!skill || !eventType || !emitMessage) {
|
|
2651
|
+
return replyError(ws, msg, "skill, eventType, message are required");
|
|
2652
|
+
}
|
|
2653
|
+
try {
|
|
2654
|
+
const db = getDb();
|
|
2655
|
+
const result = db.prepare(
|
|
2656
|
+
`INSERT INTO skill_events (session_id, skill, type, message, data) VALUES (?, ?, ?, ?, ?)`
|
|
2657
|
+
).run(sessionId ?? null, skill, eventType, emitMessage, data ?? null);
|
|
2658
|
+
const id = Number(result.lastInsertRowid);
|
|
2659
|
+
sm.broadcastSkillEvent({
|
|
2660
|
+
id,
|
|
2661
|
+
session_id: sessionId ?? null,
|
|
2662
|
+
skill,
|
|
2663
|
+
type: eventType,
|
|
2664
|
+
message: emitMessage,
|
|
2665
|
+
data: data ?? null,
|
|
2666
|
+
created_at: (/* @__PURE__ */ new Date()).toISOString()
|
|
2667
|
+
});
|
|
2668
|
+
wsReply(ws, msg, { id });
|
|
2669
|
+
} catch (e) {
|
|
2670
|
+
replyError(ws, msg, e.message);
|
|
2671
|
+
}
|
|
2672
|
+
}
|
|
2673
|
+
function handlePermissionRespond(ws, msg, sm) {
|
|
2674
|
+
const sessionId = msg.session ?? "default";
|
|
2675
|
+
const approved = msg.approved === true;
|
|
2676
|
+
const resolved = sm.resolvePendingPermission(sessionId, approved);
|
|
2677
|
+
if (!resolved) return replyError(ws, msg, "No pending permission request");
|
|
2678
|
+
wsReply(ws, msg, { status: approved ? "approved" : "denied" });
|
|
2679
|
+
}
|
|
2680
|
+
function handlePermissionPending(ws, msg, sm) {
|
|
2681
|
+
const sessionId = msg.session;
|
|
2682
|
+
if (sessionId) {
|
|
2683
|
+
const pending = sm.getPendingPermission(sessionId);
|
|
2684
|
+
wsReply(ws, msg, { pending: pending ? [{ sessionId, ...pending }] : [] });
|
|
2685
|
+
} else {
|
|
2686
|
+
wsReply(ws, msg, { pending: sm.getAllPendingPermissions() });
|
|
2687
|
+
}
|
|
2688
|
+
}
|
|
2689
|
+
function handlePermissionSubscribe(ws, msg, sm, state) {
|
|
2690
|
+
state.permissionUnsub?.();
|
|
2691
|
+
const pending = sm.getAllPendingPermissions();
|
|
2692
|
+
for (const p of pending) {
|
|
2693
|
+
send(ws, { type: "permission.request", session: p.sessionId, request: p.request, createdAt: p.createdAt, isHistory: true });
|
|
2694
|
+
}
|
|
2695
|
+
state.permissionUnsub = sm.onPermissionRequest((sessionId, request, createdAt) => {
|
|
2696
|
+
send(ws, { type: "permission.request", session: sessionId, request, createdAt });
|
|
2697
|
+
});
|
|
2698
|
+
reply(ws, msg, { pendingCount: pending.length });
|
|
2699
|
+
}
|
|
2700
|
+
function handlePermissionUnsubscribe(ws, msg, state) {
|
|
2701
|
+
state.permissionUnsub?.();
|
|
2702
|
+
state.permissionUnsub = null;
|
|
2703
|
+
reply(ws, msg, {});
|
|
2704
|
+
}
|
|
2705
|
+
function handleChatSessionsList(ws, msg) {
|
|
2706
|
+
try {
|
|
2707
|
+
const db = getDb();
|
|
2708
|
+
const rows = db.prepare(
|
|
2709
|
+
`SELECT id, label, type, meta, cwd, created_at FROM chat_sessions ORDER BY created_at DESC`
|
|
2710
|
+
).all();
|
|
2711
|
+
const sessions = rows.map((r) => ({ ...r, meta: r.meta ? JSON.parse(r.meta) : null }));
|
|
2712
|
+
wsReply(ws, msg, { sessions });
|
|
2713
|
+
} catch (e) {
|
|
2714
|
+
replyError(ws, msg, e.message);
|
|
2715
|
+
}
|
|
2716
|
+
}
|
|
2717
|
+
function handleChatSessionsCreate(ws, msg) {
|
|
2718
|
+
const id = msg.id ?? crypto.randomUUID().slice(0, 8);
|
|
2719
|
+
try {
|
|
2720
|
+
const db = getDb();
|
|
2721
|
+
db.prepare(`INSERT OR IGNORE INTO chat_sessions (id, label, type, meta) VALUES (?, ?, ?, ?)`).run(id, msg.label ?? id, msg.chatType ?? "background", msg.meta ? JSON.stringify(msg.meta) : null);
|
|
2722
|
+
wsReply(ws, msg, { status: "created", id, meta: msg.meta ?? null });
|
|
2723
|
+
} catch (e) {
|
|
2724
|
+
replyError(ws, msg, e.message);
|
|
2725
|
+
}
|
|
2726
|
+
}
|
|
2727
|
+
function handleChatSessionsRemove(ws, msg) {
|
|
2728
|
+
const id = msg.session;
|
|
2729
|
+
if (!id) return replyError(ws, msg, "session is required");
|
|
2730
|
+
if (id === "default") return replyError(ws, msg, "Cannot delete default session");
|
|
2731
|
+
try {
|
|
2732
|
+
const db = getDb();
|
|
2733
|
+
db.prepare(`DELETE FROM chat_sessions WHERE id = ?`).run(id);
|
|
2734
|
+
wsReply(ws, msg, { status: "deleted" });
|
|
2735
|
+
} catch (e) {
|
|
2736
|
+
replyError(ws, msg, e.message);
|
|
2737
|
+
}
|
|
2738
|
+
}
|
|
2739
|
+
function handleChatMessagesList(ws, msg) {
|
|
2740
|
+
const id = msg.session;
|
|
2741
|
+
if (!id) return replyError(ws, msg, "session is required");
|
|
2742
|
+
try {
|
|
2743
|
+
const db = getDb();
|
|
2744
|
+
const query = msg.since != null ? db.prepare(`SELECT * FROM chat_messages WHERE session_id = ? AND id > ? ORDER BY id ASC`) : db.prepare(`SELECT * FROM chat_messages WHERE session_id = ? ORDER BY id ASC`);
|
|
2745
|
+
const messages = msg.since != null ? query.all(id, msg.since) : query.all(id);
|
|
2746
|
+
wsReply(ws, msg, { messages });
|
|
2747
|
+
} catch (e) {
|
|
2748
|
+
replyError(ws, msg, e.message);
|
|
2749
|
+
}
|
|
2750
|
+
}
|
|
2751
|
+
function handleChatMessagesCreate(ws, msg) {
|
|
2752
|
+
const sessionId = msg.session;
|
|
2753
|
+
if (!sessionId) return replyError(ws, msg, "session is required");
|
|
2754
|
+
if (!msg.role) return replyError(ws, msg, "role is required");
|
|
2755
|
+
try {
|
|
2756
|
+
const db = getDb();
|
|
2757
|
+
db.prepare(`INSERT OR IGNORE INTO chat_sessions (id, label, type) VALUES (?, ?, 'main')`).run(sessionId, sessionId);
|
|
2758
|
+
const result = db.prepare(
|
|
2759
|
+
`INSERT INTO chat_messages (session_id, role, content, skill_name, meta) VALUES (?, ?, ?, ?, ?)`
|
|
2760
|
+
).run(
|
|
2761
|
+
sessionId,
|
|
2762
|
+
msg.role,
|
|
2763
|
+
msg.content ?? "",
|
|
2764
|
+
msg.skill_name ?? null,
|
|
2765
|
+
msg.meta ? JSON.stringify(msg.meta) : null
|
|
2766
|
+
);
|
|
2767
|
+
wsReply(ws, msg, { status: "created", id: Number(result.lastInsertRowid) });
|
|
2768
|
+
} catch (e) {
|
|
2769
|
+
replyError(ws, msg, e.message);
|
|
2770
|
+
}
|
|
2771
|
+
}
|
|
2772
|
+
function handleChatMessagesClear(ws, msg) {
|
|
2773
|
+
const id = msg.session;
|
|
2774
|
+
if (!id) return replyError(ws, msg, "session is required");
|
|
2775
|
+
try {
|
|
2776
|
+
const db = getDb();
|
|
2777
|
+
db.prepare(`DELETE FROM chat_messages WHERE session_id = ?`).run(id);
|
|
2778
|
+
wsReply(ws, msg, { status: "cleared" });
|
|
2779
|
+
} catch (e) {
|
|
2780
|
+
replyError(ws, msg, e.message);
|
|
2781
|
+
}
|
|
2782
|
+
}
|
|
2783
|
+
|
|
2784
|
+
// src/server/index.ts
|
|
2785
|
+
function createSnaApp(options = {}) {
|
|
2786
|
+
const sessionManager = options.sessionManager ?? new SessionManager();
|
|
2787
|
+
const app = new import_hono3.Hono();
|
|
2788
|
+
app.get("/health", (c) => c.json({ ok: true, name: "sna", version: "1" }));
|
|
2789
|
+
app.get("/events", eventsRoute);
|
|
2790
|
+
app.post("/emit", createEmitRoute(sessionManager));
|
|
2791
|
+
app.route("/agent", createAgentRoutes(sessionManager));
|
|
2792
|
+
app.route("/chat", createChatRoutes());
|
|
2793
|
+
if (options.runCommands) {
|
|
2794
|
+
app.get("/run", createRunRoute(options.runCommands));
|
|
2795
|
+
}
|
|
2796
|
+
return app;
|
|
2797
|
+
}
|
|
520
2798
|
|
|
521
2799
|
// src/electron/index.ts
|
|
522
|
-
var import_path3 = __toESM(require("path"), 1);
|
|
523
2800
|
function resolveStandaloneScript() {
|
|
524
|
-
const selfPath = (0,
|
|
525
|
-
let script =
|
|
2801
|
+
const selfPath = (0, import_url2.fileURLToPath)(importMetaUrl);
|
|
2802
|
+
let script = import_path6.default.resolve(import_path6.default.dirname(selfPath), "../server/standalone.js");
|
|
526
2803
|
if (script.includes(".asar") && !script.includes(".asar.unpacked")) {
|
|
527
2804
|
script = script.replace(/(\.asar)([/\\])/, ".asar.unpacked$2");
|
|
528
2805
|
}
|
|
529
|
-
if (!
|
|
2806
|
+
if (!import_fs7.default.existsSync(script)) {
|
|
530
2807
|
throw new Error(
|
|
531
2808
|
`SNA standalone script not found: ${script}
|
|
532
2809
|
Ensure "@sna-sdk/core" is listed in asarUnpack in your electron-builder config.`
|
|
@@ -537,14 +2814,14 @@ Ensure "@sna-sdk/core" is listed in asarUnpack in your electron-builder config.`
|
|
|
537
2814
|
function buildNodePath() {
|
|
538
2815
|
const resourcesPath = process.resourcesPath;
|
|
539
2816
|
if (!resourcesPath) return void 0;
|
|
540
|
-
const unpacked =
|
|
541
|
-
if (!
|
|
2817
|
+
const unpacked = import_path6.default.join(resourcesPath, "app.asar.unpacked", "node_modules");
|
|
2818
|
+
if (!import_fs7.default.existsSync(unpacked)) return void 0;
|
|
542
2819
|
const existing = process.env.NODE_PATH;
|
|
543
|
-
return existing ? `${unpacked}${
|
|
2820
|
+
return existing ? `${unpacked}${import_path6.default.delimiter}${existing}` : unpacked;
|
|
544
2821
|
}
|
|
545
2822
|
async function startSnaServer(options) {
|
|
546
2823
|
const port = options.port ?? 3099;
|
|
547
|
-
const cwd = options.cwd ??
|
|
2824
|
+
const cwd = options.cwd ?? import_path6.default.dirname(options.dbPath);
|
|
548
2825
|
const readyTimeout = options.readyTimeout ?? 15e3;
|
|
549
2826
|
const { onLog } = options;
|
|
550
2827
|
const standaloneScript = resolveStandaloneScript();
|
|
@@ -552,7 +2829,7 @@ async function startSnaServer(options) {
|
|
|
552
2829
|
let consumerModules;
|
|
553
2830
|
try {
|
|
554
2831
|
const bsPkg = require.resolve("better-sqlite3/package.json", { paths: [process.cwd()] });
|
|
555
|
-
consumerModules =
|
|
2832
|
+
consumerModules = import_path6.default.resolve(bsPkg, "../..");
|
|
556
2833
|
} catch {
|
|
557
2834
|
}
|
|
558
2835
|
const env = {
|
|
@@ -569,7 +2846,7 @@ async function startSnaServer(options) {
|
|
|
569
2846
|
// Consumer overrides last so they can always win
|
|
570
2847
|
...options.env ?? {}
|
|
571
2848
|
};
|
|
572
|
-
const proc = (0,
|
|
2849
|
+
const proc = (0, import_child_process3.fork)(standaloneScript, [], {
|
|
573
2850
|
cwd,
|
|
574
2851
|
env,
|
|
575
2852
|
stdio: "pipe"
|
|
@@ -624,11 +2901,83 @@ async function startSnaServer(options) {
|
|
|
624
2901
|
}
|
|
625
2902
|
};
|
|
626
2903
|
}
|
|
2904
|
+
async function startSnaServerInProcess(options) {
|
|
2905
|
+
const port = options.port ?? 3099;
|
|
2906
|
+
const cwd = options.cwd ?? import_path6.default.dirname(options.dbPath);
|
|
2907
|
+
setConfig({
|
|
2908
|
+
port,
|
|
2909
|
+
dbPath: options.dbPath,
|
|
2910
|
+
...options.maxSessions != null ? { maxSessions: options.maxSessions } : {},
|
|
2911
|
+
...options.permissionMode ? { defaultPermissionMode: options.permissionMode } : {},
|
|
2912
|
+
...options.model ? { model: options.model } : {},
|
|
2913
|
+
...options.permissionTimeoutMs != null ? { permissionTimeoutMs: options.permissionTimeoutMs } : {}
|
|
2914
|
+
});
|
|
2915
|
+
process.env.SNA_PORT = String(port);
|
|
2916
|
+
process.env.SNA_DB_PATH = options.dbPath;
|
|
2917
|
+
if (options.maxSessions != null) process.env.SNA_MAX_SESSIONS = String(options.maxSessions);
|
|
2918
|
+
if (options.permissionMode) process.env.SNA_PERMISSION_MODE = options.permissionMode;
|
|
2919
|
+
if (options.model) process.env.SNA_MODEL = options.model;
|
|
2920
|
+
if (options.permissionTimeoutMs != null) process.env.SNA_PERMISSION_TIMEOUT_MS = String(options.permissionTimeoutMs);
|
|
2921
|
+
if (options.nativeBinding) process.env.SNA_SQLITE_NATIVE_BINDING = options.nativeBinding;
|
|
2922
|
+
if (!process.env.SNA_MODULES_PATH) {
|
|
2923
|
+
try {
|
|
2924
|
+
const bsPkg = require.resolve("better-sqlite3/package.json", { paths: [process.cwd()] });
|
|
2925
|
+
process.env.SNA_MODULES_PATH = import_path6.default.resolve(bsPkg, "../..");
|
|
2926
|
+
} catch {
|
|
2927
|
+
}
|
|
2928
|
+
}
|
|
2929
|
+
const originalCwd = process.cwd();
|
|
2930
|
+
try {
|
|
2931
|
+
process.chdir(cwd);
|
|
2932
|
+
} catch {
|
|
2933
|
+
}
|
|
2934
|
+
try {
|
|
2935
|
+
getDb();
|
|
2936
|
+
} catch (err2) {
|
|
2937
|
+
process.chdir(originalCwd);
|
|
2938
|
+
throw new Error(`SNA in-process: database init failed: ${err2.message}`);
|
|
2939
|
+
}
|
|
2940
|
+
const config = getConfig();
|
|
2941
|
+
const root = new import_hono4.Hono();
|
|
2942
|
+
root.use("*", (0, import_cors.cors)({ origin: "*", allowMethods: ["GET", "POST", "PATCH", "DELETE", "OPTIONS"] }));
|
|
2943
|
+
root.onError((err2, c) => {
|
|
2944
|
+
const pathname = new URL(c.req.url).pathname;
|
|
2945
|
+
if (options.onLog) options.onLog(`ERR ${c.req.method} ${pathname} \u2192 ${err2.message}`);
|
|
2946
|
+
return c.json({ status: "error", message: err2.message, stack: err2.stack }, 500);
|
|
2947
|
+
});
|
|
2948
|
+
root.use("*", async (c, next) => {
|
|
2949
|
+
const m = c.req.method;
|
|
2950
|
+
const pathname = new URL(c.req.url).pathname;
|
|
2951
|
+
if (options.onLog) options.onLog(`${m.padEnd(6)} ${pathname}`);
|
|
2952
|
+
await next();
|
|
2953
|
+
});
|
|
2954
|
+
const sessionManager = new SessionManager({ maxSessions: config.maxSessions });
|
|
2955
|
+
root.route("/", createSnaApp({ sessionManager }));
|
|
2956
|
+
const httpServer = (0, import_node_server.serve)({ fetch: root.fetch, port }, () => {
|
|
2957
|
+
if (options.onLog) options.onLog(`API server ready \u2192 http://localhost:${port}`);
|
|
2958
|
+
if (options.onLog) options.onLog(`WebSocket endpoint \u2192 ws://localhost:${port}/ws`);
|
|
2959
|
+
});
|
|
2960
|
+
attachWebSocket(httpServer, sessionManager);
|
|
2961
|
+
return {
|
|
2962
|
+
process: null,
|
|
2963
|
+
port,
|
|
2964
|
+
sessionManager,
|
|
2965
|
+
httpServer,
|
|
2966
|
+
async stop() {
|
|
2967
|
+
sessionManager.killAll();
|
|
2968
|
+
await new Promise((resolve) => {
|
|
2969
|
+
httpServer.close(() => resolve());
|
|
2970
|
+
setTimeout(() => resolve(), 3e3).unref();
|
|
2971
|
+
});
|
|
2972
|
+
}
|
|
2973
|
+
};
|
|
2974
|
+
}
|
|
627
2975
|
// Annotate the CommonJS export names for ESM import in node:
|
|
628
2976
|
0 && (module.exports = {
|
|
629
2977
|
cacheClaudePath,
|
|
630
2978
|
parseCommandVOutput,
|
|
631
2979
|
resolveClaudeCli,
|
|
632
2980
|
startSnaServer,
|
|
2981
|
+
startSnaServerInProcess,
|
|
633
2982
|
validateClaudePath
|
|
634
2983
|
});
|