@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 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
- if (!options.prompt) {
84
- throw new Error("history requires a prompt \u2014 the last stdin message must be a user message");
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() {
@@ -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 ... Run claude with mock API env vars (proxy)
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 ... Run claude with mock API (isolated env, no account pollution)
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,2 @@
1
+
2
+ export { }
@@ -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
+ };
@@ -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';
@@ -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
- session.state = "processing";
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: session?.process?.alive ?? false,
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
- session.state = "processing";
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
- session.state = "waiting";
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
- session.state = "idle";
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
- session.state = "idle";
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) session.state = "permission";
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) session.state = "processing";
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
- session.state = "waiting";
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,