@sna-sdk/core 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -0
- package/dist/core/providers/cc-history-adapter.d.ts +37 -0
- package/dist/core/providers/cc-history-adapter.js +70 -0
- package/dist/core/providers/claude-code.js +12 -22
- package/dist/core/providers/types.d.ts +2 -0
- package/dist/db/schema.js +1 -1
- package/dist/scripts/sna.js +20 -2
- package/dist/scripts/tu-oneshot.d.ts +2 -0
- package/dist/scripts/tu-oneshot.js +66 -0
- package/dist/server/api-types.d.ts +7 -0
- package/dist/server/history-builder.d.ts +16 -0
- package/dist/server/history-builder.js +25 -0
- package/dist/server/index.d.ts +2 -1
- package/dist/server/index.js +2 -0
- package/dist/server/routes/agent.js +45 -2
- package/dist/server/session-manager.d.ts +14 -3
- package/dist/server/session-manager.js +29 -9
- package/dist/server/standalone.js +249 -61
- package/dist/server/ws.js +50 -3
- package/dist/testing/mock-api.js +20 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -86,6 +86,11 @@ const db = getDb(); // SQLite instance (data/sna.db)
|
|
|
86
86
|
| `@sna-sdk/core/lib/sna-run` | `snaRun()` helper for spawning Claude Code |
|
|
87
87
|
| `@sna-sdk/core/testing` | `startMockAnthropicServer()` for testing without real API calls |
|
|
88
88
|
|
|
89
|
+
**Environment Variables:**
|
|
90
|
+
- `SNA_DB_PATH` — Override SQLite database location (default: `process.cwd()/data/sna.db`)
|
|
91
|
+
- `SNA_CLAUDE_COMMAND` — Override claude binary path
|
|
92
|
+
- `SNA_PORT` — API server port (default: 3099)
|
|
93
|
+
|
|
89
94
|
## Documentation
|
|
90
95
|
|
|
91
96
|
- [Architecture](https://github.com/neuradex/sna/blob/main/docs/architecture.md)
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { HistoryMessage } from './types.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* History injection for Claude Code via JSONL resume.
|
|
5
|
+
*
|
|
6
|
+
* Writes a JSONL session file and passes --resume <filepath> to CC.
|
|
7
|
+
* CC loads it as real multi-turn conversation history.
|
|
8
|
+
*
|
|
9
|
+
* Key discovery: --resume with a .jsonl file path bypasses CC's project
|
|
10
|
+
* directory lookup and calls loadMessagesFromJsonlPath directly.
|
|
11
|
+
* This is the only reliable way to inject synthetic history.
|
|
12
|
+
*
|
|
13
|
+
* Verified: real Claude Haiku correctly recalls injected context.
|
|
14
|
+
* Fallback: recalled-conversation XML if file write fails.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Write a JSONL session file for --resume <filepath>.
|
|
19
|
+
*
|
|
20
|
+
* Minimal format (verified working):
|
|
21
|
+
* {"parentUuid":null,"isSidechain":false,"type":"user","uuid":"...","timestamp":"...","cwd":"...","sessionId":"...","message":{"role":"user","content":"..."}}
|
|
22
|
+
* {"parentUuid":"<prev>","isSidechain":false,"type":"assistant","uuid":"...","timestamp":"...","cwd":"...","sessionId":"...","message":{"role":"assistant","content":[{"type":"text","text":"..."}]}}
|
|
23
|
+
*/
|
|
24
|
+
declare function writeHistoryJsonl(history: HistoryMessage[], opts: {
|
|
25
|
+
cwd: string;
|
|
26
|
+
}): {
|
|
27
|
+
filePath: string;
|
|
28
|
+
extraArgs: string[];
|
|
29
|
+
} | null;
|
|
30
|
+
/**
|
|
31
|
+
* Pack history into a single assistant stdin message.
|
|
32
|
+
* CC treats type:"assistant" as context injection (no API call triggered).
|
|
33
|
+
* Used when file write fails.
|
|
34
|
+
*/
|
|
35
|
+
declare function buildRecalledConversation(history: HistoryMessage[]): string;
|
|
36
|
+
|
|
37
|
+
export { buildRecalledConversation, writeHistoryJsonl };
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import path from "path";
|
|
3
|
+
function writeHistoryJsonl(history, opts) {
|
|
4
|
+
for (let i = 1; i < history.length; i++) {
|
|
5
|
+
if (history[i].role === history[i - 1].role) {
|
|
6
|
+
throw new Error(
|
|
7
|
+
`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.`
|
|
8
|
+
);
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
try {
|
|
12
|
+
const dir = path.join(opts.cwd, ".sna", "history");
|
|
13
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
14
|
+
const sessionId = crypto.randomUUID();
|
|
15
|
+
const filePath = path.join(dir, `${sessionId}.jsonl`);
|
|
16
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
17
|
+
const lines = [];
|
|
18
|
+
let prevUuid = null;
|
|
19
|
+
for (const msg of history) {
|
|
20
|
+
const uuid = crypto.randomUUID();
|
|
21
|
+
if (msg.role === "user") {
|
|
22
|
+
lines.push(JSON.stringify({
|
|
23
|
+
parentUuid: prevUuid,
|
|
24
|
+
isSidechain: false,
|
|
25
|
+
type: "user",
|
|
26
|
+
uuid,
|
|
27
|
+
timestamp: now,
|
|
28
|
+
cwd: opts.cwd,
|
|
29
|
+
sessionId,
|
|
30
|
+
message: { role: "user", content: msg.content }
|
|
31
|
+
}));
|
|
32
|
+
} else {
|
|
33
|
+
lines.push(JSON.stringify({
|
|
34
|
+
parentUuid: prevUuid,
|
|
35
|
+
isSidechain: false,
|
|
36
|
+
type: "assistant",
|
|
37
|
+
uuid,
|
|
38
|
+
timestamp: now,
|
|
39
|
+
cwd: opts.cwd,
|
|
40
|
+
sessionId,
|
|
41
|
+
message: {
|
|
42
|
+
role: "assistant",
|
|
43
|
+
content: [{ type: "text", text: msg.content }]
|
|
44
|
+
}
|
|
45
|
+
}));
|
|
46
|
+
}
|
|
47
|
+
prevUuid = uuid;
|
|
48
|
+
}
|
|
49
|
+
fs.writeFileSync(filePath, lines.join("\n") + "\n");
|
|
50
|
+
return { filePath, extraArgs: ["--resume", filePath] };
|
|
51
|
+
} catch {
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
function buildRecalledConversation(history) {
|
|
56
|
+
const xml = history.map((msg) => `<${msg.role}>${msg.content}</${msg.role}>`).join("\n");
|
|
57
|
+
return JSON.stringify({
|
|
58
|
+
type: "assistant",
|
|
59
|
+
message: {
|
|
60
|
+
role: "assistant",
|
|
61
|
+
content: [{ type: "text", text: `<recalled-conversation>
|
|
62
|
+
${xml}
|
|
63
|
+
</recalled-conversation>` }]
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
export {
|
|
68
|
+
buildRecalledConversation,
|
|
69
|
+
writeHistoryJsonl
|
|
70
|
+
};
|
|
@@ -2,6 +2,7 @@ import { spawn, execSync } from "child_process";
|
|
|
2
2
|
import { EventEmitter } from "events";
|
|
3
3
|
import fs from "fs";
|
|
4
4
|
import path from "path";
|
|
5
|
+
import { writeHistoryJsonl, buildRecalledConversation } from "./cc-history-adapter.js";
|
|
5
6
|
import { logger } from "../../lib/logger.js";
|
|
6
7
|
const SHELL = process.env.SHELL || "/bin/zsh";
|
|
7
8
|
function resolveClaudePath(cwd) {
|
|
@@ -79,28 +80,9 @@ class ClaudeCodeProcess {
|
|
|
79
80
|
this._alive = false;
|
|
80
81
|
this.emitter.emit("error", err);
|
|
81
82
|
});
|
|
82
|
-
if (options.history?.length) {
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
}
|
|
86
|
-
for (const msg of options.history) {
|
|
87
|
-
if (msg.role === "user") {
|
|
88
|
-
const line = JSON.stringify({
|
|
89
|
-
type: "user",
|
|
90
|
-
message: { role: "user", content: msg.content }
|
|
91
|
-
});
|
|
92
|
-
this.proc.stdin.write(line + "\n");
|
|
93
|
-
} else if (msg.role === "assistant") {
|
|
94
|
-
const line = JSON.stringify({
|
|
95
|
-
type: "assistant",
|
|
96
|
-
message: {
|
|
97
|
-
role: "assistant",
|
|
98
|
-
content: [{ type: "text", text: msg.content }]
|
|
99
|
-
}
|
|
100
|
-
});
|
|
101
|
-
this.proc.stdin.write(line + "\n");
|
|
102
|
-
}
|
|
103
|
-
}
|
|
83
|
+
if (options.history?.length && !options._historyViaResume) {
|
|
84
|
+
const line = buildRecalledConversation(options.history);
|
|
85
|
+
this.proc.stdin.write(line + "\n");
|
|
104
86
|
}
|
|
105
87
|
if (options.prompt) {
|
|
106
88
|
this.send(options.prompt);
|
|
@@ -341,6 +323,14 @@ class ClaudeCodeProvider {
|
|
|
341
323
|
if (options.permissionMode) {
|
|
342
324
|
args.push("--permission-mode", options.permissionMode);
|
|
343
325
|
}
|
|
326
|
+
if (options.history?.length && options.prompt) {
|
|
327
|
+
const result = writeHistoryJsonl(options.history, { cwd: options.cwd });
|
|
328
|
+
if (result) {
|
|
329
|
+
args.push(...result.extraArgs);
|
|
330
|
+
options._historyViaResume = true;
|
|
331
|
+
logger.log("agent", `history via JSONL resume \u2192 ${result.filePath}`);
|
|
332
|
+
}
|
|
333
|
+
}
|
|
344
334
|
if (extraArgsClean.length > 0) {
|
|
345
335
|
args.push(...extraArgsClean);
|
|
346
336
|
}
|
|
@@ -63,6 +63,8 @@ interface SpawnOptions {
|
|
|
63
63
|
* Must alternate user→assistant. Assistant content is auto-wrapped in array format.
|
|
64
64
|
*/
|
|
65
65
|
history?: HistoryMessage[];
|
|
66
|
+
/** @internal Set by provider when history was injected via JSONL resume. */
|
|
67
|
+
_historyViaResume?: boolean;
|
|
66
68
|
/**
|
|
67
69
|
* Additional CLI flags passed directly to the agent binary.
|
|
68
70
|
* e.g. ["--system-prompt", "You are...", "--append-system-prompt", "Also...", "--mcp-config", "path"]
|
package/dist/db/schema.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { createRequire } from "node:module";
|
|
2
2
|
import fs from "fs";
|
|
3
3
|
import path from "path";
|
|
4
|
-
const DB_PATH = path.join(process.cwd(), "data/sna.db");
|
|
4
|
+
const DB_PATH = process.env.SNA_DB_PATH ?? path.join(process.cwd(), "data/sna.db");
|
|
5
5
|
const NATIVE_DIR = path.join(process.cwd(), ".sna/native");
|
|
6
6
|
let _db = null;
|
|
7
7
|
function loadBetterSqlite3() {
|
package/dist/scripts/sna.js
CHANGED
|
@@ -212,6 +212,9 @@ function cmdTu(args2) {
|
|
|
212
212
|
case "claude":
|
|
213
213
|
cmdTuClaude(args2.slice(1));
|
|
214
214
|
break;
|
|
215
|
+
case "claude:oneshot":
|
|
216
|
+
cmdTuClaudeOneshot(args2.slice(1));
|
|
217
|
+
break;
|
|
215
218
|
default:
|
|
216
219
|
console.log(`
|
|
217
220
|
sna tu \u2014 Test utilities (mock Anthropic API)
|
|
@@ -221,7 +224,8 @@ function cmdTu(args2) {
|
|
|
221
224
|
sna tu api:down Stop mock API server
|
|
222
225
|
sna tu api:log Show mock API request/response log
|
|
223
226
|
sna tu api:log -f Follow log in real-time (tail -f)
|
|
224
|
-
sna tu claude ...
|
|
227
|
+
sna tu claude ... Run claude with mock API env vars (proxy)
|
|
228
|
+
sna tu claude:oneshot ... One-shot: mock server \u2192 claude \u2192 results \u2192 cleanup
|
|
225
229
|
|
|
226
230
|
Flow:
|
|
227
231
|
1. sna tu api:up \u2192 mock server on random port
|
|
@@ -338,6 +342,19 @@ function cmdTuClaude(args2) {
|
|
|
338
342
|
process.exit(e.status ?? 1);
|
|
339
343
|
}
|
|
340
344
|
}
|
|
345
|
+
function cmdTuClaudeOneshot(args2) {
|
|
346
|
+
const scriptDir = path.dirname(new URL(import.meta.url).pathname);
|
|
347
|
+
const oneshotScript = fs.existsSync(path.join(scriptDir, "tu-oneshot.js")) ? path.join(scriptDir, "tu-oneshot.js") : path.join(scriptDir, "tu-oneshot.ts");
|
|
348
|
+
try {
|
|
349
|
+
execSync(`node --import tsx "${oneshotScript}" ${args2.map((a) => `"${a}"`).join(" ")}`, {
|
|
350
|
+
stdio: "inherit",
|
|
351
|
+
cwd: ROOT,
|
|
352
|
+
timeout: 9e4
|
|
353
|
+
});
|
|
354
|
+
} catch (e) {
|
|
355
|
+
process.exit(e.status ?? 1);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
341
358
|
function readPidFile(filePath) {
|
|
342
359
|
try {
|
|
343
360
|
return parseInt(fs.readFileSync(filePath, "utf8").trim(), 10) || null;
|
|
@@ -784,7 +801,8 @@ Testing:
|
|
|
784
801
|
sna tu api:down Stop mock API server
|
|
785
802
|
sna tu api:log Show mock API request/response log
|
|
786
803
|
sna tu api:log -f Follow log in real-time
|
|
787
|
-
sna tu claude ...
|
|
804
|
+
sna tu claude ... Run claude with mock API (isolated env)
|
|
805
|
+
sna tu claude:oneshot ... One-shot: mock server \u2192 claude \u2192 structured results \u2192 cleanup
|
|
788
806
|
|
|
789
807
|
Set SNA_CLAUDE_COMMAND to override claude binary in SDK.
|
|
790
808
|
See: docs/testing.md
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { startMockAnthropicServer } from "../testing/mock-api.js";
|
|
2
|
+
import { spawn } from "child_process";
|
|
3
|
+
import fs from "fs";
|
|
4
|
+
import path from "path";
|
|
5
|
+
async function main() {
|
|
6
|
+
const ROOT = process.cwd();
|
|
7
|
+
const STATE_DIR = path.join(ROOT, ".sna");
|
|
8
|
+
const args = process.argv.slice(2);
|
|
9
|
+
let claudePath = "claude";
|
|
10
|
+
const cachedPath = path.join(STATE_DIR, "claude-path");
|
|
11
|
+
if (fs.existsSync(cachedPath)) {
|
|
12
|
+
claudePath = fs.readFileSync(cachedPath, "utf8").trim() || claudePath;
|
|
13
|
+
}
|
|
14
|
+
const mock = await startMockAnthropicServer();
|
|
15
|
+
const mockConfigDir = path.join(STATE_DIR, "mock-claude-oneshot");
|
|
16
|
+
fs.mkdirSync(mockConfigDir, { recursive: true });
|
|
17
|
+
const env = {
|
|
18
|
+
PATH: process.env.PATH ?? "",
|
|
19
|
+
HOME: process.env.HOME ?? "",
|
|
20
|
+
SHELL: process.env.SHELL ?? "/bin/zsh",
|
|
21
|
+
TERM: process.env.TERM ?? "xterm-256color",
|
|
22
|
+
LANG: process.env.LANG ?? "en_US.UTF-8",
|
|
23
|
+
ANTHROPIC_BASE_URL: `http://localhost:${mock.port}`,
|
|
24
|
+
ANTHROPIC_API_KEY: "sk-test-mock-oneshot",
|
|
25
|
+
CLAUDE_CONFIG_DIR: mockConfigDir
|
|
26
|
+
};
|
|
27
|
+
const stdoutPath = path.join(STATE_DIR, "mock-claude-stdout.log");
|
|
28
|
+
const stderrPath = path.join(STATE_DIR, "mock-claude-stderr.log");
|
|
29
|
+
const proc = spawn(claudePath, args, {
|
|
30
|
+
env,
|
|
31
|
+
cwd: ROOT,
|
|
32
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
33
|
+
});
|
|
34
|
+
let stdout = "";
|
|
35
|
+
let stderr = "";
|
|
36
|
+
proc.stdout.on("data", (d) => {
|
|
37
|
+
stdout += d.toString();
|
|
38
|
+
});
|
|
39
|
+
proc.stderr.on("data", (d) => {
|
|
40
|
+
stderr += d.toString();
|
|
41
|
+
});
|
|
42
|
+
proc.stdout.pipe(process.stdout);
|
|
43
|
+
proc.on("exit", (code) => {
|
|
44
|
+
fs.writeFileSync(stdoutPath, stdout);
|
|
45
|
+
fs.writeFileSync(stderrPath, stderr);
|
|
46
|
+
console.log(`
|
|
47
|
+
${"\u2500".repeat(60)}`);
|
|
48
|
+
console.log(`Mock API: ${mock.requests.length} request(s)`);
|
|
49
|
+
for (const req of mock.requests) {
|
|
50
|
+
console.log(` model=${req.model} stream=${req.stream} messages=${req.messages?.length}`);
|
|
51
|
+
}
|
|
52
|
+
console.log(`
|
|
53
|
+
Log files:`);
|
|
54
|
+
console.log(` stdout: ${stdoutPath}`);
|
|
55
|
+
console.log(` stderr: ${stderrPath}`);
|
|
56
|
+
console.log(` api log: ${path.join(STATE_DIR, "mock-api-last-request.json")}`);
|
|
57
|
+
console.log(` config: ${mockConfigDir}`);
|
|
58
|
+
console.log(` exit: ${code}`);
|
|
59
|
+
mock.close();
|
|
60
|
+
process.exit(code ?? 0);
|
|
61
|
+
});
|
|
62
|
+
setTimeout(() => {
|
|
63
|
+
proc.kill();
|
|
64
|
+
}, 6e4);
|
|
65
|
+
}
|
|
66
|
+
main();
|
|
@@ -25,6 +25,12 @@ interface ApiResponses {
|
|
|
25
25
|
"agent.send": {
|
|
26
26
|
status: "sent";
|
|
27
27
|
};
|
|
28
|
+
"agent.resume": {
|
|
29
|
+
status: "resumed";
|
|
30
|
+
provider: string;
|
|
31
|
+
sessionId: string;
|
|
32
|
+
historyCount: number;
|
|
33
|
+
};
|
|
28
34
|
"agent.restart": {
|
|
29
35
|
status: "restarted";
|
|
30
36
|
provider: string;
|
|
@@ -46,6 +52,7 @@ interface ApiResponses {
|
|
|
46
52
|
};
|
|
47
53
|
"agent.status": {
|
|
48
54
|
alive: boolean;
|
|
55
|
+
agentStatus: "idle" | "busy" | "disconnected";
|
|
49
56
|
sessionId: string | null;
|
|
50
57
|
ccSessionId: string | null;
|
|
51
58
|
eventCount: number;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { HistoryMessage } from '../core/providers/types.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Build HistoryMessage[] from chat_messages DB records.
|
|
5
|
+
*
|
|
6
|
+
* Filters to user/assistant roles, ensures alternation,
|
|
7
|
+
* and merges consecutive same-role messages.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Load conversation history from DB for a session.
|
|
12
|
+
* Returns alternating user↔assistant messages ready for JSONL injection.
|
|
13
|
+
*/
|
|
14
|
+
declare function buildHistoryFromDb(sessionId: string): HistoryMessage[];
|
|
15
|
+
|
|
16
|
+
export { buildHistoryFromDb };
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { getDb } from "../db/schema.js";
|
|
2
|
+
function buildHistoryFromDb(sessionId) {
|
|
3
|
+
const db = getDb();
|
|
4
|
+
const rows = db.prepare(
|
|
5
|
+
`SELECT role, content FROM chat_messages
|
|
6
|
+
WHERE session_id = ? AND role IN ('user', 'assistant')
|
|
7
|
+
ORDER BY id ASC`
|
|
8
|
+
).all(sessionId);
|
|
9
|
+
if (rows.length === 0) return [];
|
|
10
|
+
const merged = [];
|
|
11
|
+
for (const row of rows) {
|
|
12
|
+
const role = row.role;
|
|
13
|
+
if (!row.content?.trim()) continue;
|
|
14
|
+
const last = merged[merged.length - 1];
|
|
15
|
+
if (last && last.role === role) {
|
|
16
|
+
last.content += "\n\n" + row.content;
|
|
17
|
+
} else {
|
|
18
|
+
merged.push({ role, content: row.content });
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
return merged;
|
|
22
|
+
}
|
|
23
|
+
export {
|
|
24
|
+
buildHistoryFromDb
|
|
25
|
+
};
|
package/dist/server/index.d.ts
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
import * as hono_types from 'hono/types';
|
|
2
2
|
import { Hono } from 'hono';
|
|
3
3
|
import { SessionManager } from './session-manager.js';
|
|
4
|
-
export { Session, SessionConfigChangedEvent, SessionInfo, SessionLifecycleEvent, SessionLifecycleState, SessionManagerOptions, StartConfig } from './session-manager.js';
|
|
4
|
+
export { AgentStatus, Session, SessionConfigChangedEvent, SessionInfo, SessionLifecycleEvent, SessionLifecycleState, SessionManagerOptions, StartConfig } from './session-manager.js';
|
|
5
5
|
export { eventsRoute } from './routes/events.js';
|
|
6
6
|
export { createEmitRoute, emitRoute } from './routes/emit.js';
|
|
7
7
|
export { createRunRoute } from './routes/run.js';
|
|
8
8
|
export { createAgentRoutes } from './routes/agent.js';
|
|
9
9
|
export { createChatRoutes } from './routes/chat.js';
|
|
10
10
|
export { attachWebSocket } from './ws.js';
|
|
11
|
+
export { buildHistoryFromDb } from './history-builder.js';
|
|
11
12
|
import '../core/providers/types.js';
|
|
12
13
|
import 'hono/utils/http-status';
|
|
13
14
|
import 'ws';
|
package/dist/server/index.js
CHANGED
|
@@ -27,6 +27,7 @@ import { createAgentRoutes as createAgentRoutes2 } from "./routes/agent.js";
|
|
|
27
27
|
import { createChatRoutes as createChatRoutes2 } from "./routes/chat.js";
|
|
28
28
|
import { SessionManager as SessionManager2 } from "./session-manager.js";
|
|
29
29
|
import { attachWebSocket } from "./ws.js";
|
|
30
|
+
import { buildHistoryFromDb } from "./history-builder.js";
|
|
30
31
|
function snaPortRoute(c) {
|
|
31
32
|
const portFile = _path.join(process.cwd(), ".sna/sna-api.port");
|
|
32
33
|
try {
|
|
@@ -39,6 +40,7 @@ function snaPortRoute(c) {
|
|
|
39
40
|
export {
|
|
40
41
|
SessionManager2 as SessionManager,
|
|
41
42
|
attachWebSocket,
|
|
43
|
+
buildHistoryFromDb,
|
|
42
44
|
createAgentRoutes2 as createAgentRoutes,
|
|
43
45
|
createChatRoutes2 as createChatRoutes,
|
|
44
46
|
createEmitRoute2 as createEmitRoute,
|
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
} from "../../core/providers/index.js";
|
|
6
6
|
import { logger } from "../../lib/logger.js";
|
|
7
7
|
import { getDb } from "../../db/schema.js";
|
|
8
|
+
import { buildHistoryFromDb } from "../history-builder.js";
|
|
8
9
|
import { httpJson } from "../api-types.js";
|
|
9
10
|
import { saveImages } from "../image-store.js";
|
|
10
11
|
function getSessionId(c) {
|
|
@@ -194,7 +195,7 @@ function createAgentRoutes(sessionManager) {
|
|
|
194
195
|
db.prepare(`INSERT INTO chat_messages (session_id, role, content, meta) VALUES (?, 'user', ?, ?)`).run(sessionId, textContent, Object.keys(meta).length > 0 ? JSON.stringify(meta) : null);
|
|
195
196
|
} catch {
|
|
196
197
|
}
|
|
197
|
-
|
|
198
|
+
sessionManager.updateSessionState(sessionId, "processing");
|
|
198
199
|
sessionManager.touch(sessionId);
|
|
199
200
|
if (body.images?.length) {
|
|
200
201
|
const content = [
|
|
@@ -272,6 +273,46 @@ function createAgentRoutes(sessionManager) {
|
|
|
272
273
|
return c.json({ status: "error", message: e.message }, 500);
|
|
273
274
|
}
|
|
274
275
|
});
|
|
276
|
+
app.post("/resume", async (c) => {
|
|
277
|
+
const sessionId = getSessionId(c);
|
|
278
|
+
const body = await c.req.json().catch(() => ({}));
|
|
279
|
+
const session = sessionManager.getOrCreateSession(sessionId);
|
|
280
|
+
if (session.process?.alive) {
|
|
281
|
+
return c.json({ status: "error", message: "Session already running. Use agent.send instead." }, 400);
|
|
282
|
+
}
|
|
283
|
+
const history = buildHistoryFromDb(sessionId);
|
|
284
|
+
if (history.length === 0 && !body.prompt) {
|
|
285
|
+
return c.json({ status: "error", message: "No history in DB \u2014 nothing to resume." }, 400);
|
|
286
|
+
}
|
|
287
|
+
const providerName = body.provider ?? "claude-code";
|
|
288
|
+
const model = body.model ?? session.lastStartConfig?.model ?? "claude-sonnet-4-6";
|
|
289
|
+
const permissionMode = body.permissionMode ?? session.lastStartConfig?.permissionMode ?? "acceptEdits";
|
|
290
|
+
const extraArgs = body.extraArgs ?? session.lastStartConfig?.extraArgs;
|
|
291
|
+
const provider = getProvider(providerName);
|
|
292
|
+
try {
|
|
293
|
+
const proc = provider.spawn({
|
|
294
|
+
cwd: session.cwd,
|
|
295
|
+
prompt: body.prompt,
|
|
296
|
+
model,
|
|
297
|
+
permissionMode,
|
|
298
|
+
env: { SNA_SESSION_ID: sessionId },
|
|
299
|
+
history: history.length > 0 ? history : void 0,
|
|
300
|
+
extraArgs
|
|
301
|
+
});
|
|
302
|
+
sessionManager.setProcess(sessionId, proc, "resumed");
|
|
303
|
+
sessionManager.saveStartConfig(sessionId, { provider: providerName, model, permissionMode, extraArgs });
|
|
304
|
+
logger.log("route", `POST /resume?session=${sessionId} \u2192 resumed (${history.length} history msgs)`);
|
|
305
|
+
return httpJson(c, "agent.resume", {
|
|
306
|
+
status: "resumed",
|
|
307
|
+
provider: providerName,
|
|
308
|
+
sessionId: session.id,
|
|
309
|
+
historyCount: history.length
|
|
310
|
+
});
|
|
311
|
+
} catch (e) {
|
|
312
|
+
logger.err("err", `POST /resume?session=${sessionId} \u2192 ${e.message}`);
|
|
313
|
+
return c.json({ status: "error", message: e.message }, 500);
|
|
314
|
+
}
|
|
315
|
+
});
|
|
275
316
|
app.post("/interrupt", async (c) => {
|
|
276
317
|
const sessionId = getSessionId(c);
|
|
277
318
|
const interrupted = sessionManager.interruptSession(sessionId);
|
|
@@ -299,8 +340,10 @@ function createAgentRoutes(sessionManager) {
|
|
|
299
340
|
app.get("/status", (c) => {
|
|
300
341
|
const sessionId = getSessionId(c);
|
|
301
342
|
const session = sessionManager.getSession(sessionId);
|
|
343
|
+
const alive = session?.process?.alive ?? false;
|
|
302
344
|
return httpJson(c, "agent.status", {
|
|
303
|
-
alive
|
|
345
|
+
alive,
|
|
346
|
+
agentStatus: !alive ? "disconnected" : session?.state === "processing" ? "busy" : "idle",
|
|
304
347
|
sessionId: session?.process?.sessionId ?? null,
|
|
305
348
|
ccSessionId: session?.ccSessionId ?? null,
|
|
306
349
|
eventCount: session?.eventCounter ?? 0,
|
|
@@ -29,11 +29,13 @@ interface Session {
|
|
|
29
29
|
createdAt: number;
|
|
30
30
|
lastActivityAt: number;
|
|
31
31
|
}
|
|
32
|
+
type AgentStatus = "idle" | "busy" | "disconnected";
|
|
32
33
|
interface SessionInfo {
|
|
33
34
|
id: string;
|
|
34
35
|
label: string;
|
|
35
36
|
alive: boolean;
|
|
36
37
|
state: SessionState;
|
|
38
|
+
agentStatus: AgentStatus;
|
|
37
39
|
cwd: string;
|
|
38
40
|
meta: Record<string, unknown> | null;
|
|
39
41
|
config: StartConfig | null;
|
|
@@ -45,7 +47,7 @@ interface SessionInfo {
|
|
|
45
47
|
interface SessionManagerOptions {
|
|
46
48
|
maxSessions?: number;
|
|
47
49
|
}
|
|
48
|
-
type SessionLifecycleState = "started" | "killed" | "exited" | "crashed" | "restarted";
|
|
50
|
+
type SessionLifecycleState = "started" | "resumed" | "killed" | "exited" | "crashed" | "restarted";
|
|
49
51
|
interface SessionLifecycleEvent {
|
|
50
52
|
session: string;
|
|
51
53
|
state: SessionLifecycleState;
|
|
@@ -64,6 +66,7 @@ declare class SessionManager {
|
|
|
64
66
|
private permissionRequestListeners;
|
|
65
67
|
private lifecycleListeners;
|
|
66
68
|
private configChangedListeners;
|
|
69
|
+
private stateChangedListeners;
|
|
67
70
|
constructor(options?: SessionManagerOptions);
|
|
68
71
|
/** Restore session metadata from DB (cwd, label, meta). Process state is not restored. */
|
|
69
72
|
private restoreFromDb;
|
|
@@ -84,7 +87,7 @@ declare class SessionManager {
|
|
|
84
87
|
cwd?: string;
|
|
85
88
|
}): Session;
|
|
86
89
|
/** Set the agent process for a session. Subscribes to events. */
|
|
87
|
-
setProcess(sessionId: string, proc: AgentProcess): void;
|
|
90
|
+
setProcess(sessionId: string, proc: AgentProcess, lifecycleState?: SessionLifecycleState): void;
|
|
88
91
|
/** Subscribe to real-time events for a session. Returns unsubscribe function. */
|
|
89
92
|
onSessionEvent(sessionId: string, cb: (cursor: number, event: AgentEvent) => void): () => void;
|
|
90
93
|
/** Subscribe to skill events broadcast. Returns unsubscribe function. */
|
|
@@ -99,6 +102,14 @@ declare class SessionManager {
|
|
|
99
102
|
/** Subscribe to session config changes. Returns unsubscribe function. */
|
|
100
103
|
onConfigChanged(cb: (event: SessionConfigChangedEvent) => void): () => void;
|
|
101
104
|
private emitConfigChanged;
|
|
105
|
+
onStateChanged(cb: (event: {
|
|
106
|
+
session: string;
|
|
107
|
+
agentStatus: AgentStatus;
|
|
108
|
+
state: SessionState;
|
|
109
|
+
}) => void): () => void;
|
|
110
|
+
/** Update session state and push agentStatus change to subscribers. */
|
|
111
|
+
updateSessionState(sessionId: string, newState: SessionState): void;
|
|
112
|
+
private setSessionState;
|
|
102
113
|
/** Create a pending permission request. Returns a promise that resolves when approved/denied. */
|
|
103
114
|
createPendingPermission(sessionId: string, request: Record<string, unknown>): Promise<boolean>;
|
|
104
115
|
/** Resolve a pending permission request. Returns false if no pending request. */
|
|
@@ -142,4 +153,4 @@ declare class SessionManager {
|
|
|
142
153
|
get size(): number;
|
|
143
154
|
}
|
|
144
155
|
|
|
145
|
-
export { type Session, type SessionConfigChangedEvent, type SessionInfo, type SessionLifecycleEvent, type SessionLifecycleState, SessionManager, type SessionManagerOptions, type SessionState, type StartConfig };
|
|
156
|
+
export { type AgentStatus, type Session, type SessionConfigChangedEvent, type SessionInfo, type SessionLifecycleEvent, type SessionLifecycleState, SessionManager, type SessionManagerOptions, type SessionState, type StartConfig };
|
|
@@ -11,6 +11,7 @@ class SessionManager {
|
|
|
11
11
|
this.permissionRequestListeners = /* @__PURE__ */ new Set();
|
|
12
12
|
this.lifecycleListeners = /* @__PURE__ */ new Set();
|
|
13
13
|
this.configChangedListeners = /* @__PURE__ */ new Set();
|
|
14
|
+
this.stateChangedListeners = /* @__PURE__ */ new Set();
|
|
14
15
|
this.maxSessions = options.maxSessions ?? DEFAULT_MAX_SESSIONS;
|
|
15
16
|
this.restoreFromDb();
|
|
16
17
|
}
|
|
@@ -123,11 +124,11 @@ class SessionManager {
|
|
|
123
124
|
return this.createSession({ id, ...opts });
|
|
124
125
|
}
|
|
125
126
|
/** Set the agent process for a session. Subscribes to events. */
|
|
126
|
-
setProcess(sessionId, proc) {
|
|
127
|
+
setProcess(sessionId, proc, lifecycleState) {
|
|
127
128
|
const session = this.sessions.get(sessionId);
|
|
128
129
|
if (!session) throw new Error(`Session "${sessionId}" not found`);
|
|
129
130
|
session.process = proc;
|
|
130
|
-
|
|
131
|
+
this.setSessionState(sessionId, session, "processing");
|
|
131
132
|
session.lastActivityAt = Date.now();
|
|
132
133
|
proc.on("event", (e) => {
|
|
133
134
|
if (e.type === "init" && e.data?.sessionId && !session.ccSessionId) {
|
|
@@ -140,7 +141,7 @@ class SessionManager {
|
|
|
140
141
|
session.eventBuffer.splice(0, session.eventBuffer.length - MAX_EVENT_BUFFER);
|
|
141
142
|
}
|
|
142
143
|
if (e.type === "complete" || e.type === "error" || e.type === "interrupted") {
|
|
143
|
-
|
|
144
|
+
this.setSessionState(sessionId, session, "waiting");
|
|
144
145
|
}
|
|
145
146
|
this.persistEvent(sessionId, e);
|
|
146
147
|
const listeners = this.eventListeners.get(sessionId);
|
|
@@ -149,14 +150,14 @@ class SessionManager {
|
|
|
149
150
|
}
|
|
150
151
|
});
|
|
151
152
|
proc.on("exit", (code) => {
|
|
152
|
-
|
|
153
|
+
this.setSessionState(sessionId, session, "idle");
|
|
153
154
|
this.emitLifecycle({ session: sessionId, state: code != null ? "exited" : "crashed", code });
|
|
154
155
|
});
|
|
155
156
|
proc.on("error", () => {
|
|
156
|
-
|
|
157
|
+
this.setSessionState(sessionId, session, "idle");
|
|
157
158
|
this.emitLifecycle({ session: sessionId, state: "crashed" });
|
|
158
159
|
});
|
|
159
|
-
this.emitLifecycle({ session: sessionId, state: "started" });
|
|
160
|
+
this.emitLifecycle({ session: sessionId, state: lifecycleState ?? "started" });
|
|
160
161
|
}
|
|
161
162
|
// ── Event pub/sub (for WebSocket) ─────────────────────────────
|
|
162
163
|
/** Subscribe to real-time events for a session. Returns unsubscribe function. */
|
|
@@ -206,11 +207,29 @@ class SessionManager {
|
|
|
206
207
|
emitConfigChanged(sessionId, config) {
|
|
207
208
|
for (const cb of this.configChangedListeners) cb({ session: sessionId, config });
|
|
208
209
|
}
|
|
210
|
+
// ── Agent status change pub/sub ────────────────────────────────
|
|
211
|
+
onStateChanged(cb) {
|
|
212
|
+
this.stateChangedListeners.add(cb);
|
|
213
|
+
return () => this.stateChangedListeners.delete(cb);
|
|
214
|
+
}
|
|
215
|
+
/** Update session state and push agentStatus change to subscribers. */
|
|
216
|
+
updateSessionState(sessionId, newState) {
|
|
217
|
+
const session = this.sessions.get(sessionId);
|
|
218
|
+
if (session) this.setSessionState(sessionId, session, newState);
|
|
219
|
+
}
|
|
220
|
+
setSessionState(sessionId, session, newState) {
|
|
221
|
+
const oldState = session.state;
|
|
222
|
+
session.state = newState;
|
|
223
|
+
const newStatus = !session.process?.alive ? "disconnected" : newState === "processing" ? "busy" : "idle";
|
|
224
|
+
if (oldState !== newState) {
|
|
225
|
+
for (const cb of this.stateChangedListeners) cb({ session: sessionId, agentStatus: newStatus, state: newState });
|
|
226
|
+
}
|
|
227
|
+
}
|
|
209
228
|
// ── Permission management ─────────────────────────────────────
|
|
210
229
|
/** Create a pending permission request. Returns a promise that resolves when approved/denied. */
|
|
211
230
|
createPendingPermission(sessionId, request) {
|
|
212
231
|
const session = this.sessions.get(sessionId);
|
|
213
|
-
if (session)
|
|
232
|
+
if (session) this.setSessionState(sessionId, session, "permission");
|
|
214
233
|
return new Promise((resolve) => {
|
|
215
234
|
const createdAt = Date.now();
|
|
216
235
|
this.pendingPermissions.set(sessionId, { resolve, request, createdAt });
|
|
@@ -230,7 +249,7 @@ class SessionManager {
|
|
|
230
249
|
pending.resolve(approved);
|
|
231
250
|
this.pendingPermissions.delete(sessionId);
|
|
232
251
|
const session = this.sessions.get(sessionId);
|
|
233
|
-
if (session)
|
|
252
|
+
if (session) this.setSessionState(sessionId, session, "processing");
|
|
234
253
|
return true;
|
|
235
254
|
}
|
|
236
255
|
/** Get a pending permission for a specific session. */
|
|
@@ -282,7 +301,7 @@ class SessionManager {
|
|
|
282
301
|
const session = this.sessions.get(id);
|
|
283
302
|
if (!session?.process?.alive) return false;
|
|
284
303
|
session.process.interrupt();
|
|
285
|
-
|
|
304
|
+
this.setSessionState(id, session, "waiting");
|
|
286
305
|
return true;
|
|
287
306
|
}
|
|
288
307
|
/** Change model. Sends control message if alive, always persists to config. */
|
|
@@ -339,6 +358,7 @@ class SessionManager {
|
|
|
339
358
|
label: s.label,
|
|
340
359
|
alive: s.process?.alive ?? false,
|
|
341
360
|
state: s.state,
|
|
361
|
+
agentStatus: !s.process?.alive ? "disconnected" : s.state === "processing" ? "busy" : "idle",
|
|
342
362
|
cwd: s.cwd,
|
|
343
363
|
meta: s.meta,
|
|
344
364
|
config: s.lastStartConfig,
|