@sna-sdk/core 0.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/sna.js +18 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +104 -0
- package/dist/core/providers/claude-code.d.ts +9 -0
- package/dist/core/providers/claude-code.js +257 -0
- package/dist/core/providers/codex.d.ts +18 -0
- package/dist/core/providers/codex.js +14 -0
- package/dist/core/providers/index.d.ts +14 -0
- package/dist/core/providers/index.js +22 -0
- package/dist/core/providers/types.d.ts +52 -0
- package/dist/core/providers/types.js +0 -0
- package/dist/db/schema.d.ts +13 -0
- package/dist/db/schema.js +41 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.js +6 -0
- package/dist/lib/logger.d.ts +18 -0
- package/dist/lib/logger.js +50 -0
- package/dist/lib/sna-run.d.ts +25 -0
- package/dist/lib/sna-run.js +74 -0
- package/dist/scripts/emit.d.ts +2 -0
- package/dist/scripts/emit.js +48 -0
- package/dist/scripts/hook.d.ts +2 -0
- package/dist/scripts/hook.js +34 -0
- package/dist/scripts/init-db.d.ts +2 -0
- package/dist/scripts/init-db.js +3 -0
- package/dist/scripts/sna.d.ts +2 -0
- package/dist/scripts/sna.js +650 -0
- package/dist/scripts/workflow.d.ts +112 -0
- package/dist/scripts/workflow.js +622 -0
- package/dist/server/index.d.ts +30 -0
- package/dist/server/index.js +43 -0
- package/dist/server/routes/agent.d.ts +8 -0
- package/dist/server/routes/agent.js +148 -0
- package/dist/server/routes/emit.d.ts +11 -0
- package/dist/server/routes/emit.js +15 -0
- package/dist/server/routes/events.d.ts +12 -0
- package/dist/server/routes/events.js +54 -0
- package/dist/server/routes/run.d.ts +19 -0
- package/dist/server/routes/run.js +51 -0
- package/dist/server/session-manager.d.ts +64 -0
- package/dist/server/session-manager.js +101 -0
- package/dist/server/standalone.js +820 -0
- package/package.json +91 -0
- package/skills/sna-down/SKILL.md +23 -0
- package/skills/sna-up/SKILL.md +40 -0
|
@@ -0,0 +1,820 @@
|
|
|
1
|
+
// src/server/standalone.ts
|
|
2
|
+
import { serve } from "@hono/node-server";
|
|
3
|
+
import { Hono as Hono3 } from "hono";
|
|
4
|
+
import { cors } from "hono/cors";
|
|
5
|
+
import chalk2 from "chalk";
|
|
6
|
+
|
|
7
|
+
// src/server/index.ts
|
|
8
|
+
import { Hono as Hono2 } from "hono";
|
|
9
|
+
|
|
10
|
+
// src/server/routes/events.ts
|
|
11
|
+
import { streamSSE } from "hono/streaming";
|
|
12
|
+
|
|
13
|
+
// src/db/schema.ts
|
|
14
|
+
import { createRequire } from "module";
|
|
15
|
+
import path from "path";
|
|
16
|
+
var require2 = createRequire(path.join(process.cwd(), "node_modules", "_"));
|
|
17
|
+
var BetterSqlite3 = require2("better-sqlite3");
|
|
18
|
+
var DB_PATH = path.join(process.cwd(), "data/sna.db");
|
|
19
|
+
var _db = null;
|
|
20
|
+
function getDb() {
|
|
21
|
+
if (!_db) {
|
|
22
|
+
_db = new BetterSqlite3(DB_PATH);
|
|
23
|
+
_db.pragma("journal_mode = WAL");
|
|
24
|
+
initSchema(_db);
|
|
25
|
+
}
|
|
26
|
+
return _db;
|
|
27
|
+
}
|
|
28
|
+
function migrateSkillEvents(db) {
|
|
29
|
+
const row = db.prepare(
|
|
30
|
+
"SELECT sql FROM sqlite_master WHERE type='table' AND name='skill_events'"
|
|
31
|
+
).get();
|
|
32
|
+
if (row?.sql?.includes("CHECK(type IN")) {
|
|
33
|
+
db.exec("DROP TABLE IF EXISTS skill_events");
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
function initSchema(db) {
|
|
37
|
+
migrateSkillEvents(db);
|
|
38
|
+
db.exec(`
|
|
39
|
+
CREATE TABLE IF NOT EXISTS skill_events (
|
|
40
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
41
|
+
skill TEXT NOT NULL,
|
|
42
|
+
type TEXT NOT NULL,
|
|
43
|
+
message TEXT NOT NULL,
|
|
44
|
+
data TEXT,
|
|
45
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
CREATE INDEX IF NOT EXISTS idx_skill_events_skill ON skill_events(skill);
|
|
49
|
+
CREATE INDEX IF NOT EXISTS idx_skill_events_created ON skill_events(created_at);
|
|
50
|
+
`);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// src/server/routes/events.ts
|
|
54
|
+
var POLL_INTERVAL_MS = 500;
|
|
55
|
+
var KEEPALIVE_INTERVAL_MS = 15e3;
|
|
56
|
+
function eventsRoute(c) {
|
|
57
|
+
const sinceParam = c.req.query("since");
|
|
58
|
+
let lastId = sinceParam ? parseInt(sinceParam) : -1;
|
|
59
|
+
if (lastId === -1) {
|
|
60
|
+
const db = getDb();
|
|
61
|
+
const row = db.prepare("SELECT MAX(id) as maxId FROM skill_events").get();
|
|
62
|
+
lastId = row.maxId ?? 0;
|
|
63
|
+
}
|
|
64
|
+
return streamSSE(c, async (stream) => {
|
|
65
|
+
let closed = false;
|
|
66
|
+
stream.onAbort(() => {
|
|
67
|
+
closed = true;
|
|
68
|
+
});
|
|
69
|
+
const keepaliveTimer = setInterval(async () => {
|
|
70
|
+
if (closed) {
|
|
71
|
+
clearInterval(keepaliveTimer);
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
try {
|
|
75
|
+
await stream.writeSSE({ data: "", event: "keepalive" });
|
|
76
|
+
} catch {
|
|
77
|
+
closed = true;
|
|
78
|
+
clearInterval(keepaliveTimer);
|
|
79
|
+
}
|
|
80
|
+
}, KEEPALIVE_INTERVAL_MS);
|
|
81
|
+
while (!closed) {
|
|
82
|
+
try {
|
|
83
|
+
const db = getDb();
|
|
84
|
+
const rows = db.prepare(`
|
|
85
|
+
SELECT id, skill, type, message, data, created_at
|
|
86
|
+
FROM skill_events
|
|
87
|
+
WHERE id > ?
|
|
88
|
+
ORDER BY id ASC
|
|
89
|
+
LIMIT 50
|
|
90
|
+
`).all(lastId);
|
|
91
|
+
for (const row of rows) {
|
|
92
|
+
if (closed) break;
|
|
93
|
+
await stream.writeSSE({ data: JSON.stringify(row) });
|
|
94
|
+
lastId = row.id;
|
|
95
|
+
}
|
|
96
|
+
} catch {
|
|
97
|
+
}
|
|
98
|
+
await stream.sleep(POLL_INTERVAL_MS);
|
|
99
|
+
}
|
|
100
|
+
clearInterval(keepaliveTimer);
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// src/server/routes/emit.ts
|
|
105
|
+
async function emitRoute(c) {
|
|
106
|
+
const { skill, type, message, data } = await c.req.json();
|
|
107
|
+
if (!skill || !type || !message) {
|
|
108
|
+
return c.json({ error: "missing fields" }, 400);
|
|
109
|
+
}
|
|
110
|
+
const db = getDb();
|
|
111
|
+
const result = db.prepare(
|
|
112
|
+
`INSERT INTO skill_events (skill, type, message, data) VALUES (?, ?, ?, ?)`
|
|
113
|
+
).run(skill, type, message, data ?? null);
|
|
114
|
+
return c.json({ id: result.lastInsertRowid });
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// src/server/routes/run.ts
|
|
118
|
+
import { spawn } from "child_process";
|
|
119
|
+
import { streamSSE as streamSSE2 } from "hono/streaming";
|
|
120
|
+
var ROOT = process.cwd();
|
|
121
|
+
function createRunRoute(commands) {
|
|
122
|
+
return function runRoute(c) {
|
|
123
|
+
const skill = c.req.query("skill") ?? "";
|
|
124
|
+
const cmd = commands[skill];
|
|
125
|
+
if (!cmd) {
|
|
126
|
+
return c.text(`data: unknown skill: ${skill}
|
|
127
|
+
|
|
128
|
+
data: [done]
|
|
129
|
+
|
|
130
|
+
`, 200, {
|
|
131
|
+
"Content-Type": "text/event-stream"
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
return streamSSE2(c, async (stream) => {
|
|
135
|
+
await stream.writeSSE({ data: `$ ${cmd.slice(1).join(" ")}` });
|
|
136
|
+
const child = spawn(cmd[0], cmd.slice(1), {
|
|
137
|
+
cwd: ROOT,
|
|
138
|
+
env: { ...process.env, FORCE_COLOR: "0" }
|
|
139
|
+
});
|
|
140
|
+
const write = (chunk) => {
|
|
141
|
+
for (const line of chunk.toString().split("\n")) {
|
|
142
|
+
if (line.trim()) stream.writeSSE({ data: line });
|
|
143
|
+
}
|
|
144
|
+
};
|
|
145
|
+
child.stdout.on("data", write);
|
|
146
|
+
child.stderr.on("data", (chunk) => {
|
|
147
|
+
for (const line of chunk.toString().split("\n")) {
|
|
148
|
+
if (line.trim() && !line.startsWith(">")) stream.writeSSE({ data: line });
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
await new Promise((resolve) => {
|
|
152
|
+
child.on("close", async (code) => {
|
|
153
|
+
await stream.writeSSE({ data: `[exit ${code ?? 0}]` });
|
|
154
|
+
await stream.writeSSE({ data: "[done]" });
|
|
155
|
+
resolve();
|
|
156
|
+
});
|
|
157
|
+
child.on("error", async (err2) => {
|
|
158
|
+
await stream.writeSSE({ data: `Error: ${err2.message}` });
|
|
159
|
+
await stream.writeSSE({ data: "[done]" });
|
|
160
|
+
resolve();
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// src/server/routes/agent.ts
|
|
168
|
+
import { Hono } from "hono";
|
|
169
|
+
import { streamSSE as streamSSE3 } from "hono/streaming";
|
|
170
|
+
|
|
171
|
+
// src/core/providers/claude-code.ts
|
|
172
|
+
import { spawn as spawn2, execSync } from "child_process";
|
|
173
|
+
import { EventEmitter } from "events";
|
|
174
|
+
import fs2 from "fs";
|
|
175
|
+
import path3 from "path";
|
|
176
|
+
|
|
177
|
+
// src/lib/logger.ts
|
|
178
|
+
import chalk from "chalk";
|
|
179
|
+
import fs from "fs";
|
|
180
|
+
import path2 from "path";
|
|
181
|
+
var LOG_PATH = path2.join(process.cwd(), ".dev.log");
|
|
182
|
+
try {
|
|
183
|
+
fs.writeFileSync(LOG_PATH, "");
|
|
184
|
+
} catch {
|
|
185
|
+
}
|
|
186
|
+
function tsPlain() {
|
|
187
|
+
return (/* @__PURE__ */ new Date()).toLocaleTimeString("en-US", { hour12: false, hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
|
188
|
+
}
|
|
189
|
+
function tsColored() {
|
|
190
|
+
return chalk.gray(tsPlain());
|
|
191
|
+
}
|
|
192
|
+
var tags = {
|
|
193
|
+
sna: chalk.bold.magenta(" SNA "),
|
|
194
|
+
req: chalk.bold.blue(" REQ "),
|
|
195
|
+
agent: chalk.bold.cyan(" AGT "),
|
|
196
|
+
stdin: chalk.bold.green(" IN "),
|
|
197
|
+
stdout: chalk.bold.yellow(" OUT "),
|
|
198
|
+
route: chalk.bold.blue(" API "),
|
|
199
|
+
err: chalk.bold.red(" ERR ")
|
|
200
|
+
};
|
|
201
|
+
var tagPlain = {
|
|
202
|
+
sna: " SNA ",
|
|
203
|
+
req: " REQ ",
|
|
204
|
+
agent: " AGT ",
|
|
205
|
+
stdin: " IN ",
|
|
206
|
+
stdout: " OUT ",
|
|
207
|
+
route: " API ",
|
|
208
|
+
err: " ERR "
|
|
209
|
+
};
|
|
210
|
+
function appendFile(tag, args) {
|
|
211
|
+
const line = `${tsPlain()} ${tag} ${args.map((a) => typeof a === "string" ? a : JSON.stringify(a)).join(" ")}
|
|
212
|
+
`;
|
|
213
|
+
fs.appendFile(LOG_PATH, line, () => {
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
function log(tag, ...args) {
|
|
217
|
+
console.log(`${tsColored()} ${tags[tag]}`, ...args);
|
|
218
|
+
appendFile(tagPlain[tag], args);
|
|
219
|
+
}
|
|
220
|
+
function err(tag, ...args) {
|
|
221
|
+
console.error(`${tsColored()} ${tags[tag]}`, ...args);
|
|
222
|
+
appendFile(tagPlain[tag], args);
|
|
223
|
+
}
|
|
224
|
+
var logger = { log, err };
|
|
225
|
+
|
|
226
|
+
// src/core/providers/claude-code.ts
|
|
227
|
+
var SHELL = process.env.SHELL || "/bin/zsh";
|
|
228
|
+
function resolveClaudePath(cwd) {
|
|
229
|
+
const cached = path3.join(cwd, ".sna/claude-path");
|
|
230
|
+
if (fs2.existsSync(cached)) {
|
|
231
|
+
const p = fs2.readFileSync(cached, "utf8").trim();
|
|
232
|
+
if (p) {
|
|
233
|
+
try {
|
|
234
|
+
execSync(`test -x "${p}"`, { stdio: "pipe" });
|
|
235
|
+
return p;
|
|
236
|
+
} catch {
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
for (const p of [
|
|
241
|
+
"/opt/homebrew/bin/claude",
|
|
242
|
+
"/usr/local/bin/claude",
|
|
243
|
+
`${process.env.HOME}/.local/bin/claude`
|
|
244
|
+
]) {
|
|
245
|
+
try {
|
|
246
|
+
execSync(`test -x "${p}"`, { stdio: "pipe" });
|
|
247
|
+
return p;
|
|
248
|
+
} catch {
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
try {
|
|
252
|
+
return execSync(`${SHELL} -l -c "which claude"`, { encoding: "utf8" }).trim();
|
|
253
|
+
} catch {
|
|
254
|
+
return "claude";
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
var ClaudeCodeProcess = class {
|
|
258
|
+
constructor(proc, options) {
|
|
259
|
+
this.emitter = new EventEmitter();
|
|
260
|
+
this._alive = true;
|
|
261
|
+
this._sessionId = null;
|
|
262
|
+
this.buffer = "";
|
|
263
|
+
this.proc = proc;
|
|
264
|
+
proc.stdout.on("data", (chunk) => {
|
|
265
|
+
this.buffer += chunk.toString();
|
|
266
|
+
const lines = this.buffer.split("\n");
|
|
267
|
+
this.buffer = lines.pop() ?? "";
|
|
268
|
+
for (const line of lines) {
|
|
269
|
+
if (!line.trim()) continue;
|
|
270
|
+
logger.log("stdout", line);
|
|
271
|
+
try {
|
|
272
|
+
const msg = JSON.parse(line);
|
|
273
|
+
if (msg.session_id && !this._sessionId) {
|
|
274
|
+
this._sessionId = msg.session_id;
|
|
275
|
+
}
|
|
276
|
+
const event = this.normalizeEvent(msg);
|
|
277
|
+
if (event) this.emitter.emit("event", event);
|
|
278
|
+
} catch {
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
});
|
|
282
|
+
proc.stderr.on("data", () => {
|
|
283
|
+
});
|
|
284
|
+
proc.on("exit", (code) => {
|
|
285
|
+
this._alive = false;
|
|
286
|
+
if (this.buffer.trim()) {
|
|
287
|
+
try {
|
|
288
|
+
const msg = JSON.parse(this.buffer);
|
|
289
|
+
const event = this.normalizeEvent(msg);
|
|
290
|
+
if (event) this.emitter.emit("event", event);
|
|
291
|
+
} catch {
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
this.emitter.emit("exit", code);
|
|
295
|
+
logger.log("agent", `process exited (code=${code})`);
|
|
296
|
+
});
|
|
297
|
+
proc.on("error", (err2) => {
|
|
298
|
+
this._alive = false;
|
|
299
|
+
this.emitter.emit("error", err2);
|
|
300
|
+
});
|
|
301
|
+
if (options.prompt) {
|
|
302
|
+
this.send(options.prompt);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
get alive() {
|
|
306
|
+
return this._alive;
|
|
307
|
+
}
|
|
308
|
+
get sessionId() {
|
|
309
|
+
return this._sessionId;
|
|
310
|
+
}
|
|
311
|
+
/**
|
|
312
|
+
* Send a user message to the persistent Claude process via stdin.
|
|
313
|
+
*/
|
|
314
|
+
send(input) {
|
|
315
|
+
if (!this._alive || !this.proc.stdin.writable) return;
|
|
316
|
+
const msg = JSON.stringify({
|
|
317
|
+
type: "user",
|
|
318
|
+
message: { role: "user", content: input }
|
|
319
|
+
});
|
|
320
|
+
logger.log("stdin", msg.slice(0, 200));
|
|
321
|
+
this.proc.stdin.write(msg + "\n");
|
|
322
|
+
}
|
|
323
|
+
kill() {
|
|
324
|
+
if (this._alive) {
|
|
325
|
+
this._alive = false;
|
|
326
|
+
this.proc.kill("SIGTERM");
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
on(event, handler) {
|
|
330
|
+
this.emitter.on(event, handler);
|
|
331
|
+
}
|
|
332
|
+
off(event, handler) {
|
|
333
|
+
this.emitter.off(event, handler);
|
|
334
|
+
}
|
|
335
|
+
normalizeEvent(msg) {
|
|
336
|
+
switch (msg.type) {
|
|
337
|
+
case "system": {
|
|
338
|
+
if (msg.subtype === "init") {
|
|
339
|
+
return {
|
|
340
|
+
type: "init",
|
|
341
|
+
message: `Agent ready (${msg.model ?? "unknown"})`,
|
|
342
|
+
data: { sessionId: msg.session_id, model: msg.model },
|
|
343
|
+
timestamp: Date.now()
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
return null;
|
|
347
|
+
}
|
|
348
|
+
case "assistant": {
|
|
349
|
+
const content = msg.message?.content;
|
|
350
|
+
if (!Array.isArray(content)) return null;
|
|
351
|
+
const events = [];
|
|
352
|
+
for (const block of content) {
|
|
353
|
+
if (block.type === "thinking") {
|
|
354
|
+
events.push({
|
|
355
|
+
type: "thinking",
|
|
356
|
+
message: block.thinking ?? "",
|
|
357
|
+
timestamp: Date.now()
|
|
358
|
+
});
|
|
359
|
+
} else if (block.type === "tool_use") {
|
|
360
|
+
events.push({
|
|
361
|
+
type: "tool_use",
|
|
362
|
+
message: block.name,
|
|
363
|
+
data: { toolName: block.name, input: block.input, id: block.id },
|
|
364
|
+
timestamp: Date.now()
|
|
365
|
+
});
|
|
366
|
+
} else if (block.type === "text") {
|
|
367
|
+
const text = (block.text ?? "").trim();
|
|
368
|
+
if (text) {
|
|
369
|
+
events.push({ type: "assistant", message: text, timestamp: Date.now() });
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
if (events.length > 0) {
|
|
374
|
+
for (let i = 1; i < events.length; i++) {
|
|
375
|
+
this.emitter.emit("event", events[i]);
|
|
376
|
+
}
|
|
377
|
+
return events[0];
|
|
378
|
+
}
|
|
379
|
+
return null;
|
|
380
|
+
}
|
|
381
|
+
case "user": {
|
|
382
|
+
const userContent = msg.message?.content;
|
|
383
|
+
if (!Array.isArray(userContent)) return null;
|
|
384
|
+
for (const block of userContent) {
|
|
385
|
+
if (block.type === "tool_result") {
|
|
386
|
+
return {
|
|
387
|
+
type: "tool_result",
|
|
388
|
+
message: typeof block.content === "string" ? block.content.slice(0, 300) : JSON.stringify(block.content).slice(0, 300),
|
|
389
|
+
data: { toolUseId: block.tool_use_id, isError: block.is_error },
|
|
390
|
+
timestamp: Date.now()
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
return null;
|
|
395
|
+
}
|
|
396
|
+
case "result": {
|
|
397
|
+
if (msg.subtype === "success") {
|
|
398
|
+
const mu = msg.modelUsage ?? {};
|
|
399
|
+
const modelKey = Object.keys(mu)[0] ?? "";
|
|
400
|
+
const u = mu[modelKey] ?? {};
|
|
401
|
+
return {
|
|
402
|
+
type: "complete",
|
|
403
|
+
message: msg.result ?? "Done",
|
|
404
|
+
data: {
|
|
405
|
+
durationMs: msg.duration_ms,
|
|
406
|
+
costUsd: msg.total_cost_usd,
|
|
407
|
+
inputTokens: u.inputTokens ?? 0,
|
|
408
|
+
outputTokens: u.outputTokens ?? 0,
|
|
409
|
+
cacheReadTokens: u.cacheReadInputTokens ?? 0,
|
|
410
|
+
cacheWriteTokens: u.cacheCreationInputTokens ?? 0,
|
|
411
|
+
contextWindow: u.contextWindow ?? 0,
|
|
412
|
+
maxOutputTokens: u.maxOutputTokens ?? 0,
|
|
413
|
+
model: modelKey
|
|
414
|
+
},
|
|
415
|
+
timestamp: Date.now()
|
|
416
|
+
};
|
|
417
|
+
}
|
|
418
|
+
if (msg.subtype === "error" || msg.is_error) {
|
|
419
|
+
return {
|
|
420
|
+
type: "error",
|
|
421
|
+
message: msg.result ?? msg.error ?? "Unknown error",
|
|
422
|
+
timestamp: Date.now()
|
|
423
|
+
};
|
|
424
|
+
}
|
|
425
|
+
return null;
|
|
426
|
+
}
|
|
427
|
+
case "rate_limit_event":
|
|
428
|
+
return null;
|
|
429
|
+
default:
|
|
430
|
+
logger.log("agent", `unhandled event: ${msg.type}`, JSON.stringify(msg).substring(0, 200));
|
|
431
|
+
return null;
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
};
|
|
435
|
+
var ClaudeCodeProvider = class {
|
|
436
|
+
constructor() {
|
|
437
|
+
this.name = "claude-code";
|
|
438
|
+
}
|
|
439
|
+
async isAvailable() {
|
|
440
|
+
try {
|
|
441
|
+
const p = resolveClaudePath(process.cwd());
|
|
442
|
+
execSync(`test -x "${p}"`, { stdio: "pipe" });
|
|
443
|
+
return true;
|
|
444
|
+
} catch {
|
|
445
|
+
return false;
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
spawn(options) {
|
|
449
|
+
const claudePath = resolveClaudePath(options.cwd);
|
|
450
|
+
const args = [
|
|
451
|
+
"--output-format",
|
|
452
|
+
"stream-json",
|
|
453
|
+
"--input-format",
|
|
454
|
+
"stream-json",
|
|
455
|
+
"--verbose"
|
|
456
|
+
];
|
|
457
|
+
if (options.model) {
|
|
458
|
+
args.push("--model", options.model);
|
|
459
|
+
}
|
|
460
|
+
if (options.permissionMode) {
|
|
461
|
+
args.push("--permission-mode", options.permissionMode);
|
|
462
|
+
}
|
|
463
|
+
const cleanEnv = { ...process.env, ...options.env };
|
|
464
|
+
delete cleanEnv.CLAUDECODE;
|
|
465
|
+
delete cleanEnv.CLAUDE_CODE_ENTRYPOINT;
|
|
466
|
+
delete cleanEnv.CLAUDE_CODE_SESSION_ACCESS_TOKEN;
|
|
467
|
+
const proc = spawn2(claudePath, args, {
|
|
468
|
+
cwd: options.cwd,
|
|
469
|
+
env: cleanEnv,
|
|
470
|
+
stdio: ["pipe", "pipe", "pipe"]
|
|
471
|
+
});
|
|
472
|
+
logger.log("agent", `spawned claude-code (pid=${proc.pid})`);
|
|
473
|
+
return new ClaudeCodeProcess(proc, options);
|
|
474
|
+
}
|
|
475
|
+
};
|
|
476
|
+
|
|
477
|
+
// src/core/providers/codex.ts
|
|
478
|
+
var CodexProvider = class {
|
|
479
|
+
constructor() {
|
|
480
|
+
this.name = "codex";
|
|
481
|
+
}
|
|
482
|
+
async isAvailable() {
|
|
483
|
+
return false;
|
|
484
|
+
}
|
|
485
|
+
spawn(_options) {
|
|
486
|
+
throw new Error("Codex provider not yet implemented");
|
|
487
|
+
}
|
|
488
|
+
};
|
|
489
|
+
|
|
490
|
+
// src/core/providers/index.ts
|
|
491
|
+
var providers = {
|
|
492
|
+
"claude-code": new ClaudeCodeProvider(),
|
|
493
|
+
"codex": new CodexProvider()
|
|
494
|
+
};
|
|
495
|
+
function getProvider(name = "claude-code") {
|
|
496
|
+
const provider2 = providers[name];
|
|
497
|
+
if (!provider2) throw new Error(`Unknown agent provider: ${name}`);
|
|
498
|
+
return provider2;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
// src/server/routes/agent.ts
|
|
502
|
+
function getSessionId(c) {
|
|
503
|
+
return c.req.query("session") ?? "default";
|
|
504
|
+
}
|
|
505
|
+
function createAgentRoutes(sessionManager2) {
|
|
506
|
+
const app = new Hono();
|
|
507
|
+
app.post("/sessions", async (c) => {
|
|
508
|
+
const body = await c.req.json().catch(() => ({}));
|
|
509
|
+
try {
|
|
510
|
+
const session = sessionManager2.createSession({
|
|
511
|
+
label: body.label,
|
|
512
|
+
cwd: body.cwd
|
|
513
|
+
});
|
|
514
|
+
logger.log("route", `POST /sessions \u2192 created "${session.id}"`);
|
|
515
|
+
return c.json({ status: "created", sessionId: session.id, label: session.label });
|
|
516
|
+
} catch (e) {
|
|
517
|
+
logger.err("err", `POST /sessions \u2192 ${e.message}`);
|
|
518
|
+
return c.json({ status: "error", message: e.message }, 409);
|
|
519
|
+
}
|
|
520
|
+
});
|
|
521
|
+
app.get("/sessions", (c) => {
|
|
522
|
+
return c.json({ sessions: sessionManager2.listSessions() });
|
|
523
|
+
});
|
|
524
|
+
app.delete("/sessions/:id", (c) => {
|
|
525
|
+
const id = c.req.param("id");
|
|
526
|
+
if (id === "default") {
|
|
527
|
+
return c.json({ status: "error", message: "Cannot remove default session" }, 400);
|
|
528
|
+
}
|
|
529
|
+
const removed = sessionManager2.removeSession(id);
|
|
530
|
+
if (!removed) {
|
|
531
|
+
return c.json({ status: "error", message: "Session not found" }, 404);
|
|
532
|
+
}
|
|
533
|
+
logger.log("route", `DELETE /sessions/${id} \u2192 removed`);
|
|
534
|
+
return c.json({ status: "removed" });
|
|
535
|
+
});
|
|
536
|
+
app.post("/start", async (c) => {
|
|
537
|
+
const sessionId = getSessionId(c);
|
|
538
|
+
const body = await c.req.json().catch(() => ({}));
|
|
539
|
+
const session = sessionManager2.getOrCreateSession(sessionId);
|
|
540
|
+
if (session.process?.alive && !body.force) {
|
|
541
|
+
logger.log("route", `POST /start?session=${sessionId} \u2192 already_running`);
|
|
542
|
+
return c.json({
|
|
543
|
+
status: "already_running",
|
|
544
|
+
provider: "claude-code",
|
|
545
|
+
sessionId: session.process.sessionId
|
|
546
|
+
});
|
|
547
|
+
}
|
|
548
|
+
if (session.process?.alive) {
|
|
549
|
+
session.process.kill();
|
|
550
|
+
}
|
|
551
|
+
session.eventBuffer.length = 0;
|
|
552
|
+
const provider2 = getProvider(body.provider ?? "claude-code");
|
|
553
|
+
try {
|
|
554
|
+
const proc = provider2.spawn({
|
|
555
|
+
cwd: session.cwd,
|
|
556
|
+
prompt: body.prompt,
|
|
557
|
+
model: body.model ?? "claude-sonnet-4-6",
|
|
558
|
+
permissionMode: body.permissionMode ?? "acceptEdits"
|
|
559
|
+
});
|
|
560
|
+
sessionManager2.setProcess(sessionId, proc);
|
|
561
|
+
logger.log("route", `POST /start?session=${sessionId} \u2192 started`);
|
|
562
|
+
return c.json({
|
|
563
|
+
status: "started",
|
|
564
|
+
provider: provider2.name,
|
|
565
|
+
sessionId: session.id
|
|
566
|
+
});
|
|
567
|
+
} catch (e) {
|
|
568
|
+
logger.err("err", `POST /start?session=${sessionId} failed: ${e.message}`);
|
|
569
|
+
return c.json({ status: "error", message: e.message }, 500);
|
|
570
|
+
}
|
|
571
|
+
});
|
|
572
|
+
app.post("/send", async (c) => {
|
|
573
|
+
const sessionId = getSessionId(c);
|
|
574
|
+
const session = sessionManager2.getSession(sessionId);
|
|
575
|
+
if (!session?.process?.alive) {
|
|
576
|
+
logger.err("err", `POST /send?session=${sessionId} \u2192 no active session`);
|
|
577
|
+
return c.json(
|
|
578
|
+
{ status: "error", message: `No active agent session "${sessionId}". Call POST /start first.` },
|
|
579
|
+
400
|
|
580
|
+
);
|
|
581
|
+
}
|
|
582
|
+
const body = await c.req.json().catch(() => ({}));
|
|
583
|
+
if (!body.message) {
|
|
584
|
+
logger.err("err", `POST /send?session=${sessionId} \u2192 empty message`);
|
|
585
|
+
return c.json({ status: "error", message: "message is required" }, 400);
|
|
586
|
+
}
|
|
587
|
+
sessionManager2.touch(sessionId);
|
|
588
|
+
logger.log("route", `POST /send?session=${sessionId} \u2192 "${body.message.slice(0, 80)}"`);
|
|
589
|
+
session.process.send(body.message);
|
|
590
|
+
return c.json({ status: "sent" });
|
|
591
|
+
});
|
|
592
|
+
app.get("/events", (c) => {
|
|
593
|
+
const sessionId = getSessionId(c);
|
|
594
|
+
const session = sessionManager2.getOrCreateSession(sessionId);
|
|
595
|
+
const sinceParam = c.req.query("since");
|
|
596
|
+
let cursor = sinceParam ? parseInt(sinceParam, 10) : session.eventCounter;
|
|
597
|
+
return streamSSE3(c, async (stream) => {
|
|
598
|
+
const POLL_MS = 300;
|
|
599
|
+
const KEEPALIVE_MS = 15e3;
|
|
600
|
+
let lastSend = Date.now();
|
|
601
|
+
while (true) {
|
|
602
|
+
if (cursor < session.eventCounter) {
|
|
603
|
+
const startIdx = Math.max(
|
|
604
|
+
0,
|
|
605
|
+
session.eventBuffer.length - (session.eventCounter - cursor)
|
|
606
|
+
);
|
|
607
|
+
const newEvents = session.eventBuffer.slice(startIdx);
|
|
608
|
+
for (const event of newEvents) {
|
|
609
|
+
cursor++;
|
|
610
|
+
await stream.writeSSE({
|
|
611
|
+
id: String(cursor),
|
|
612
|
+
data: JSON.stringify(event)
|
|
613
|
+
});
|
|
614
|
+
lastSend = Date.now();
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
if (Date.now() - lastSend > KEEPALIVE_MS) {
|
|
618
|
+
await stream.writeSSE({ data: "" });
|
|
619
|
+
lastSend = Date.now();
|
|
620
|
+
}
|
|
621
|
+
await new Promise((r) => setTimeout(r, POLL_MS));
|
|
622
|
+
}
|
|
623
|
+
});
|
|
624
|
+
});
|
|
625
|
+
app.post("/kill", async (c) => {
|
|
626
|
+
const sessionId = getSessionId(c);
|
|
627
|
+
const killed = sessionManager2.killSession(sessionId);
|
|
628
|
+
return c.json({ status: killed ? "killed" : "no_session" });
|
|
629
|
+
});
|
|
630
|
+
app.get("/status", (c) => {
|
|
631
|
+
const sessionId = getSessionId(c);
|
|
632
|
+
const session = sessionManager2.getSession(sessionId);
|
|
633
|
+
return c.json({
|
|
634
|
+
alive: session?.process?.alive ?? false,
|
|
635
|
+
sessionId: session?.process?.sessionId ?? null,
|
|
636
|
+
eventCount: session?.eventCounter ?? 0
|
|
637
|
+
});
|
|
638
|
+
});
|
|
639
|
+
return app;
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
// src/server/session-manager.ts
|
|
643
|
+
var DEFAULT_MAX_SESSIONS = 5;
|
|
644
|
+
var MAX_EVENT_BUFFER = 500;
|
|
645
|
+
var SessionManager = class {
|
|
646
|
+
constructor(options = {}) {
|
|
647
|
+
this.sessions = /* @__PURE__ */ new Map();
|
|
648
|
+
this.maxSessions = options.maxSessions ?? DEFAULT_MAX_SESSIONS;
|
|
649
|
+
}
|
|
650
|
+
/** Create a new session. Throws if max sessions reached. */
|
|
651
|
+
createSession(opts = {}) {
|
|
652
|
+
const id = opts.id ?? crypto.randomUUID().slice(0, 8);
|
|
653
|
+
if (this.sessions.has(id)) {
|
|
654
|
+
return this.sessions.get(id);
|
|
655
|
+
}
|
|
656
|
+
if (this.sessions.size >= this.maxSessions) {
|
|
657
|
+
throw new Error(`Max sessions (${this.maxSessions}) reached`);
|
|
658
|
+
}
|
|
659
|
+
const session = {
|
|
660
|
+
id,
|
|
661
|
+
process: null,
|
|
662
|
+
eventBuffer: [],
|
|
663
|
+
eventCounter: 0,
|
|
664
|
+
label: opts.label ?? id,
|
|
665
|
+
cwd: opts.cwd ?? process.cwd(),
|
|
666
|
+
createdAt: Date.now(),
|
|
667
|
+
lastActivityAt: Date.now()
|
|
668
|
+
};
|
|
669
|
+
this.sessions.set(id, session);
|
|
670
|
+
return session;
|
|
671
|
+
}
|
|
672
|
+
/** Get a session by ID. */
|
|
673
|
+
getSession(id) {
|
|
674
|
+
return this.sessions.get(id);
|
|
675
|
+
}
|
|
676
|
+
/** Get or create a session (used for "default" backward compat). */
|
|
677
|
+
getOrCreateSession(id, opts) {
|
|
678
|
+
const existing = this.sessions.get(id);
|
|
679
|
+
if (existing) return existing;
|
|
680
|
+
return this.createSession({ id, ...opts });
|
|
681
|
+
}
|
|
682
|
+
/** Set the agent process for a session. Subscribes to events. */
|
|
683
|
+
setProcess(sessionId, proc) {
|
|
684
|
+
const session = this.sessions.get(sessionId);
|
|
685
|
+
if (!session) throw new Error(`Session "${sessionId}" not found`);
|
|
686
|
+
session.process = proc;
|
|
687
|
+
session.lastActivityAt = Date.now();
|
|
688
|
+
proc.on("event", (e) => {
|
|
689
|
+
session.eventBuffer.push(e);
|
|
690
|
+
session.eventCounter++;
|
|
691
|
+
if (session.eventBuffer.length > MAX_EVENT_BUFFER) {
|
|
692
|
+
session.eventBuffer.splice(0, session.eventBuffer.length - MAX_EVENT_BUFFER);
|
|
693
|
+
}
|
|
694
|
+
});
|
|
695
|
+
}
|
|
696
|
+
/** Kill the agent process in a session (session stays, can be restarted). */
|
|
697
|
+
killSession(id) {
|
|
698
|
+
const session = this.sessions.get(id);
|
|
699
|
+
if (!session?.process?.alive) return false;
|
|
700
|
+
session.process.kill();
|
|
701
|
+
return true;
|
|
702
|
+
}
|
|
703
|
+
/** Remove a session entirely. Cannot remove "default". */
|
|
704
|
+
removeSession(id) {
|
|
705
|
+
if (id === "default") return false;
|
|
706
|
+
const session = this.sessions.get(id);
|
|
707
|
+
if (!session) return false;
|
|
708
|
+
if (session.process?.alive) session.process.kill();
|
|
709
|
+
this.sessions.delete(id);
|
|
710
|
+
return true;
|
|
711
|
+
}
|
|
712
|
+
/** List all sessions as serializable info objects. */
|
|
713
|
+
listSessions() {
|
|
714
|
+
return Array.from(this.sessions.values()).map((s) => ({
|
|
715
|
+
id: s.id,
|
|
716
|
+
label: s.label,
|
|
717
|
+
alive: s.process?.alive ?? false,
|
|
718
|
+
cwd: s.cwd,
|
|
719
|
+
eventCount: s.eventCounter,
|
|
720
|
+
createdAt: s.createdAt,
|
|
721
|
+
lastActivityAt: s.lastActivityAt
|
|
722
|
+
}));
|
|
723
|
+
}
|
|
724
|
+
/** Touch a session's lastActivityAt timestamp. */
|
|
725
|
+
touch(id) {
|
|
726
|
+
const session = this.sessions.get(id);
|
|
727
|
+
if (session) session.lastActivityAt = Date.now();
|
|
728
|
+
}
|
|
729
|
+
/** Kill all sessions. Used during shutdown. */
|
|
730
|
+
killAll() {
|
|
731
|
+
for (const session of this.sessions.values()) {
|
|
732
|
+
if (session.process?.alive) {
|
|
733
|
+
session.process.kill();
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
get size() {
|
|
738
|
+
return this.sessions.size;
|
|
739
|
+
}
|
|
740
|
+
};
|
|
741
|
+
|
|
742
|
+
// src/server/index.ts
|
|
743
|
+
function createSnaApp(options = {}) {
|
|
744
|
+
const sessionManager2 = options.sessionManager ?? new SessionManager();
|
|
745
|
+
const app = new Hono2();
|
|
746
|
+
app.get("/health", (c) => c.json({ ok: true, name: "sna", version: "1" }));
|
|
747
|
+
app.get("/events", eventsRoute);
|
|
748
|
+
app.post("/emit", emitRoute);
|
|
749
|
+
app.route("/agent", createAgentRoutes(sessionManager2));
|
|
750
|
+
if (options.runCommands) {
|
|
751
|
+
app.get("/run", createRunRoute(options.runCommands));
|
|
752
|
+
}
|
|
753
|
+
return app;
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
// src/server/standalone.ts
|
|
757
|
+
var port = parseInt(process.env.SNA_PORT ?? "3099", 10);
|
|
758
|
+
var permissionMode = process.env.SNA_PERMISSION_MODE ?? "acceptEdits";
|
|
759
|
+
var defaultModel = process.env.SNA_MODEL ?? "claude-sonnet-4-6";
|
|
760
|
+
var maxSessions = parseInt(process.env.SNA_MAX_SESSIONS ?? "5", 10);
|
|
761
|
+
var root = new Hono3();
|
|
762
|
+
root.use("*", cors({ origin: "*", allowMethods: ["GET", "POST", "DELETE", "OPTIONS"] }));
|
|
763
|
+
var methodColor = {
|
|
764
|
+
GET: chalk2.green,
|
|
765
|
+
POST: chalk2.yellow,
|
|
766
|
+
DELETE: chalk2.red,
|
|
767
|
+
OPTIONS: chalk2.gray
|
|
768
|
+
};
|
|
769
|
+
root.use("*", async (c, next) => {
|
|
770
|
+
const m = c.req.method;
|
|
771
|
+
const colorFn = methodColor[m] ?? chalk2.white;
|
|
772
|
+
const path4 = new URL(c.req.url).pathname;
|
|
773
|
+
logger.log("req", `${colorFn(m.padEnd(6))} ${path4}`);
|
|
774
|
+
await next();
|
|
775
|
+
});
|
|
776
|
+
var sessionManager = new SessionManager({ maxSessions });
|
|
777
|
+
sessionManager.createSession({ id: "default", cwd: process.cwd() });
|
|
778
|
+
var provider = getProvider("claude-code");
|
|
779
|
+
logger.log("sna", "spawning agent...");
|
|
780
|
+
var agentProcess = provider.spawn({ cwd: process.cwd(), permissionMode, model: defaultModel });
|
|
781
|
+
sessionManager.setProcess("default", agentProcess);
|
|
782
|
+
root.route("/", createSnaApp({ sessionManager }));
|
|
783
|
+
var server = null;
|
|
784
|
+
var shuttingDown = false;
|
|
785
|
+
function shutdown(signal) {
|
|
786
|
+
if (shuttingDown) return;
|
|
787
|
+
shuttingDown = true;
|
|
788
|
+
console.log("");
|
|
789
|
+
logger.log("sna", chalk2.dim("stopping all sessions..."));
|
|
790
|
+
sessionManager.killAll();
|
|
791
|
+
if (server) {
|
|
792
|
+
server.close(() => {
|
|
793
|
+
logger.log("sna", chalk2.green("clean shutdown") + chalk2.dim(" \u2014 see you next time"));
|
|
794
|
+
console.log("");
|
|
795
|
+
process.exit(0);
|
|
796
|
+
});
|
|
797
|
+
}
|
|
798
|
+
setTimeout(() => {
|
|
799
|
+
logger.log("sna", chalk2.green("shutdown complete"));
|
|
800
|
+
console.log("");
|
|
801
|
+
process.exit(0);
|
|
802
|
+
}, 3e3).unref();
|
|
803
|
+
}
|
|
804
|
+
process.on("SIGTERM", () => shutdown("SIGTERM"));
|
|
805
|
+
process.on("SIGINT", () => shutdown("SIGINT"));
|
|
806
|
+
process.on("uncaughtException", (err2) => {
|
|
807
|
+
if (shuttingDown) process.exit(0);
|
|
808
|
+
console.error(err2);
|
|
809
|
+
process.exit(1);
|
|
810
|
+
});
|
|
811
|
+
server = serve({ fetch: root.fetch, port }, () => {
|
|
812
|
+
console.log("");
|
|
813
|
+
logger.log("sna", chalk2.green.bold(`API server ready \u2192 http://localhost:${port}`));
|
|
814
|
+
console.log("");
|
|
815
|
+
});
|
|
816
|
+
agentProcess.on("event", (e) => {
|
|
817
|
+
if (e.type === "init") {
|
|
818
|
+
logger.log("agent", chalk2.green(`agent ready (session=${e.data?.sessionId ?? "?"})`));
|
|
819
|
+
}
|
|
820
|
+
});
|