@sna-sdk/core 0.0.9 → 0.1.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.
@@ -3,6 +3,8 @@ import fs from "fs";
3
3
  import net from "net";
4
4
  import path from "path";
5
5
  import { cmdNew, cmdWorkflow, cmdCancel, cmdTasks } from "./workflow.js";
6
+ import { parseFlags } from "../lib/parse-flags.js";
7
+ import { loadSkillsManifest, SEND_TYPES } from "../lib/dispatch.js";
6
8
  const ROOT = process.cwd();
7
9
  const STATE_DIR = path.join(ROOT, ".sna");
8
10
  const PID_FILE = path.join(STATE_DIR, "server.pid");
@@ -12,7 +14,6 @@ const SNA_API_PID_FILE = path.join(STATE_DIR, "sna-api.pid");
12
14
  const SNA_API_PORT_FILE = path.join(STATE_DIR, "sna-api.port");
13
15
  const SNA_API_LOG_FILE = path.join(STATE_DIR, "sna-api.log");
14
16
  const PORT = process.env.PORT ?? "3000";
15
- const DB_PATH = path.join(ROOT, "data/app.db");
16
17
  const CLAUDE_PATH_FILE = path.join(STATE_DIR, "claude-path");
17
18
  const SNA_CORE_DIR = path.join(ROOT, "node_modules/@sna-sdk/core");
18
19
  function ensureStateDir() {
@@ -218,13 +219,6 @@ function cmdUp() {
218
219
  step("Dependencies ready");
219
220
  }
220
221
  cmdInit();
221
- if (!fs.existsSync(DB_PATH)) {
222
- process.stdout.write(" \u2026 Setting up database");
223
- execSync("pnpm db:init", { cwd: ROOT, stdio: "pipe" });
224
- console.log("\r \u2713 Database initialized ");
225
- } else {
226
- step("Database ready");
227
- }
228
222
  if (isPortInUse(PORT)) {
229
223
  process.stdout.write(` \u2026 Port ${PORT} busy \u2014 freeing`);
230
224
  try {
@@ -357,12 +351,13 @@ function cmdStatus() {
357
351
  console.log(` SNA API \u2717 stopped`);
358
352
  if (snaApiPid) clearSnaApiState();
359
353
  }
360
- if (fs.existsSync(DB_PATH)) {
361
- const stat = fs.statSync(DB_PATH);
354
+ const snaDbPath = path.join(ROOT, "data/sna.db");
355
+ if (fs.existsSync(snaDbPath)) {
356
+ const stat = fs.statSync(snaDbPath);
362
357
  const kb = (stat.size / 1024).toFixed(1);
363
- console.log(` Database \u2713 ${kb} KB (${DB_PATH})`);
358
+ console.log(` SDK DB \u2713 ${kb} KB (${snaDbPath})`);
364
359
  } else {
365
- console.log(` Database \u2717 not initialized`);
360
+ console.log(` SDK DB \u2014 not yet created (auto-initializes on first use)`);
366
361
  }
367
362
  console.log("\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500");
368
363
  }
@@ -377,7 +372,7 @@ function cmdInit(force2 = false) {
377
372
  const hookCommand = `node "$CLAUDE_PROJECT_DIR"/node_modules/@sna-sdk/core/dist/scripts/hook.js`;
378
373
  const permissionHook = {
379
374
  matcher: ".*",
380
- hooks: [{ type: "command", async: true, command: hookCommand }]
375
+ hooks: [{ type: "command", command: hookCommand }]
381
376
  };
382
377
  let settings = {};
383
378
  if (fs.existsSync(settingsPath)) {
@@ -387,15 +382,15 @@ function cmdInit(force2 = false) {
387
382
  }
388
383
  }
389
384
  const hooks = settings.hooks ?? {};
390
- const existing = hooks.PermissionRequest ?? [];
385
+ const existing = hooks.PreToolUse ?? [];
391
386
  const alreadySet = existing.some(
392
387
  (entry) => entry.hooks?.some((h) => h.command === hookCommand)
393
388
  );
394
389
  if (!alreadySet) {
395
- hooks.PermissionRequest = [...existing, permissionHook];
390
+ hooks.PreToolUse = [...existing, permissionHook];
396
391
  settings.hooks = hooks;
397
392
  fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n");
398
- step(".claude/settings.json \u2014 PermissionRequest hook registered");
393
+ step(".claude/settings.json \u2014 PreToolUse hook registered");
399
394
  } else {
400
395
  step(".claude/settings.json \u2014 hook already set, skipped");
401
396
  }
@@ -429,6 +424,125 @@ function cmdInit(force2 = false) {
429
424
  }
430
425
  console.log("\n\u2713 SNA init complete");
431
426
  }
427
+ function cmdValidate() {
428
+ console.log("\u25B6 SNA \u2014 validate\n");
429
+ let ok = true;
430
+ const manifest = loadSkillsManifest(ROOT);
431
+ if (!manifest) {
432
+ console.log(" \u2717 .sna/skills.json not found or malformed \u2014 run 'sna gen client' first");
433
+ ok = false;
434
+ } else {
435
+ const skillNames = Object.keys(manifest);
436
+ console.log(` \u2713 .sna/skills.json \u2014 ${skillNames.length} skills registered`);
437
+ for (const name of skillNames) {
438
+ const skillMd = path.join(ROOT, ".claude/skills", name, "SKILL.md");
439
+ if (!fs.existsSync(skillMd)) {
440
+ console.log(` \u2717 skill "${name}" registered but .claude/skills/${name}/SKILL.md missing`);
441
+ ok = false;
442
+ }
443
+ }
444
+ const skillsDir = path.join(ROOT, ".claude/skills");
445
+ if (fs.existsSync(skillsDir)) {
446
+ for (const entry of fs.readdirSync(skillsDir)) {
447
+ const skillMd = path.join(skillsDir, entry, "SKILL.md");
448
+ if (fs.existsSync(skillMd) && !(entry in manifest)) {
449
+ console.log(` \u25B3 skill "${entry}" has SKILL.md but not in skills.json \u2014 run 'sna gen client'`);
450
+ }
451
+ }
452
+ }
453
+ }
454
+ const settingsPath = path.join(ROOT, ".claude/settings.json");
455
+ if (!fs.existsSync(settingsPath)) {
456
+ console.log(" \u2717 .claude/settings.json not found \u2014 run 'sna init'");
457
+ ok = false;
458
+ } else {
459
+ try {
460
+ const settings = JSON.parse(fs.readFileSync(settingsPath, "utf8"));
461
+ const hooks = settings.hooks?.PreToolUse;
462
+ const hookCommand = `node "$CLAUDE_PROJECT_DIR"/node_modules/@sna-sdk/core/dist/scripts/hook.js`;
463
+ const hasHook = Array.isArray(hooks) && hooks.some(
464
+ (entry) => entry.hooks?.some((h) => h.command === hookCommand)
465
+ );
466
+ if (hasHook) {
467
+ console.log(" \u2713 .claude/settings.json \u2014 PreToolUse hook OK");
468
+ } else {
469
+ console.log(" \u2717 .claude/settings.json \u2014 PreToolUse hook missing \u2014 run 'sna init'");
470
+ ok = false;
471
+ }
472
+ } catch {
473
+ console.log(" \u2717 .claude/settings.json is malformed");
474
+ ok = false;
475
+ }
476
+ }
477
+ const nmPath = path.join(ROOT, "node_modules");
478
+ if (!fs.existsSync(nmPath)) {
479
+ console.log(" \u2717 node_modules not found \u2014 run 'pnpm install'");
480
+ ok = false;
481
+ } else {
482
+ console.log(" \u2713 node_modules \u2014 installed");
483
+ }
484
+ console.log(ok ? "\n\u2713 Validation passed" : "\n\u2717 Validation failed \u2014 fix issues above");
485
+ if (!ok) process.exit(1);
486
+ }
487
+ async function cmdDispatch(args2) {
488
+ const { open, send, close } = await import("../lib/dispatch.js");
489
+ const sub = args2[0];
490
+ if (!sub || sub === "--help" || sub === "-h") {
491
+ console.log(`sna dispatch \u2014 unified event dispatcher
492
+
493
+ Usage:
494
+ sna dispatch open --skill <name> Open a dispatch session \u2192 prints ID
495
+ sna dispatch <id> called --message "..." Emit called event
496
+ sna dispatch <id> start --message "..." Emit start event
497
+ sna dispatch <id> milestone --message "..." Emit milestone event
498
+ sna dispatch <id> progress --message "..." Emit progress event
499
+ sna dispatch <id> close [--message "..."] Close as success (complete + kill)
500
+ sna dispatch <id> close --error "..." Close as error (error + kill)`);
501
+ return;
502
+ }
503
+ if (sub === "open") {
504
+ const flags2 = parseFlags(args2.slice(1));
505
+ if (!flags2.skill) {
506
+ console.error("Usage: sna dispatch open --skill <name>");
507
+ process.exit(1);
508
+ }
509
+ try {
510
+ const result = open({ skill: flags2.skill, cwd: ROOT });
511
+ console.log(result.id);
512
+ } catch (err) {
513
+ console.error(`\u2717 ${err.message}`);
514
+ process.exit(1);
515
+ }
516
+ return;
517
+ }
518
+ const id = sub;
519
+ const action = args2[1];
520
+ if (!action) {
521
+ console.error("Usage: sna dispatch <id> <called|start|milestone|progress|close>");
522
+ process.exit(1);
523
+ }
524
+ const flags = parseFlags(args2.slice(2));
525
+ try {
526
+ if (action === "close") {
527
+ await close(id, {
528
+ error: flags.error,
529
+ message: flags.message
530
+ });
531
+ } else if (SEND_TYPES.includes(action)) {
532
+ if (!flags.message) {
533
+ console.error(`Usage: sna dispatch <id> ${action} --message "..."`);
534
+ process.exit(1);
535
+ }
536
+ send(id, { type: action, message: flags.message, data: flags.data });
537
+ } else {
538
+ console.error(`Unknown dispatch action: "${action}". Use ${SEND_TYPES.join(", ")}, or close.`);
539
+ process.exit(1);
540
+ }
541
+ } catch (err) {
542
+ console.error(`\u2717 ${err.message}`);
543
+ process.exit(1);
544
+ }
545
+ }
432
546
  function printHelp() {
433
547
  console.log(`sna \u2014 Skills-Native Application CLI
434
548
 
@@ -441,6 +555,8 @@ Lifecycle:
441
555
  sna status Show running services
442
556
  sna restart Stop + start
443
557
  sna init [--force] Initialize .claude/settings.json and skills
558
+ sna validate Check project setup (skills.json, hooks, deps)
559
+ sna dispatch Unified event dispatcher (open/send/close)
444
560
 
445
561
  Workflow:
446
562
  sna new <skill> [--param val ...] Create a task from a workflow.yml
@@ -634,12 +750,22 @@ Run "sna help submit" for data submission patterns.`);
634
750
  case "status":
635
751
  cmdStatus();
636
752
  break;
753
+ case "validate":
754
+ cmdValidate();
755
+ break;
756
+ case "dispatch":
757
+ cmdDispatch(args);
758
+ break;
637
759
  case "api:up":
638
760
  cmdApiUp();
639
761
  break;
640
762
  case "api:down":
641
763
  cmdApiDown();
642
764
  break;
765
+ case "api:restart":
766
+ cmdApiDown();
767
+ cmdApiUp();
768
+ break;
643
769
  case "restart":
644
770
  cmdDown();
645
771
  console.log();
@@ -2,7 +2,7 @@ import { execSync } from "child_process";
2
2
  import fs from "fs";
3
3
  import path from "path";
4
4
  import yaml from "js-yaml";
5
- import { getDb } from "../db/schema.js";
5
+ import { createHandle } from "../lib/dispatch.js";
6
6
  const ROOT = process.cwd();
7
7
  const TASKS_DIR = path.join(ROOT, ".sna", "tasks");
8
8
  function ensureTasksDir() {
@@ -53,22 +53,6 @@ function interpolate(template, context) {
53
53
  return String(val);
54
54
  });
55
55
  }
56
- function emitEvent(skill, type, message) {
57
- const db = getDb();
58
- db.prepare(`
59
- INSERT INTO skill_events (skill, type, message)
60
- VALUES (?, ?, ?)
61
- `).run(skill, type, message);
62
- const prefix = {
63
- start: "\u25B6",
64
- progress: "\xB7",
65
- milestone: "\u25C6",
66
- complete: "\u2713",
67
- error: "\u2717"
68
- };
69
- const p = prefix[type] ?? "\xB7";
70
- console.log(`${p} [${skill}] ${message}`);
71
- }
72
56
  function kebabToSnake(s) {
73
57
  return s.replace(/-/g, "_");
74
58
  }
@@ -353,7 +337,7 @@ function displayStep(step, stepIndex, totalSteps, context, taskId) {
353
337
  printRequiredSubmission(afterFields, taskId);
354
338
  }
355
339
  }
356
- function autoAdvance(task, workflow) {
340
+ function autoAdvance(task, workflow, d) {
357
341
  while (task.current_step < workflow.steps.length) {
358
342
  const step = workflow.steps[task.current_step];
359
343
  if (!step.exec) break;
@@ -365,8 +349,7 @@ function autoAdvance(task, workflow) {
365
349
  Object.assign(task.context, extracted);
366
350
  task.steps[step.id] = { status: "completed" };
367
351
  if (step.event) {
368
- const msg = interpolate(step.event, task.context);
369
- emitEvent(workflow.skill, "milestone", msg);
352
+ d.milestone(interpolate(step.event, task.context));
370
353
  }
371
354
  console.log(" done");
372
355
  } catch (err) {
@@ -375,7 +358,7 @@ function autoAdvance(task, workflow) {
375
358
  task.steps[step.id] = { status: "error" };
376
359
  task.status = "error";
377
360
  saveTask(task);
378
- emitEvent(workflow.skill, "error", interpolate(workflow.error, { ...task.context, error: msg }));
361
+ d.close({ error: interpolate(workflow.error, { ...task.context, error: msg }) });
379
362
  process.exit(1);
380
363
  }
381
364
  task.current_step++;
@@ -404,12 +387,14 @@ function cmdNew(args) {
404
387
  };
405
388
  saveTask(task);
406
389
  console.log(`\u25B6 Task ${taskId} created (${workflow.skill})`);
407
- emitEvent(workflow.skill, "start", `Task ${taskId} started`);
390
+ const d = createHandle({ skill: workflow.skill });
391
+ d.called(`Task ${taskId} created`);
392
+ d.start(`Task ${taskId} started`);
408
393
  const firstStep = workflow.steps[0];
409
394
  if (firstStep) {
410
395
  task.steps[firstStep.id] = { status: "in_progress" };
411
396
  }
412
- const advanced = autoAdvance(task, workflow);
397
+ const advanced = autoAdvance(task, workflow, d);
413
398
  if (advanced.current_step < workflow.steps.length) {
414
399
  const currentStep = workflow.steps[advanced.current_step];
415
400
  advanced.steps[currentStep.id] = { status: "in_progress" };
@@ -419,7 +404,7 @@ function cmdNew(args) {
419
404
  advanced.status = "completed";
420
405
  saveTask(advanced);
421
406
  const msg = interpolate(workflow.complete, advanced.context);
422
- emitEvent(workflow.skill, "complete", msg);
407
+ d.close({ message: msg });
423
408
  console.log(`
424
409
  ${msg}`);
425
410
  }
@@ -428,6 +413,7 @@ function cmdWorkflow(taskId, args) {
428
413
  const subcommand = args[0];
429
414
  const task = loadTask(taskId);
430
415
  const workflow = loadWorkflow(task.skill);
416
+ const d = createHandle({ skill: workflow.skill });
431
417
  if (subcommand === "start") {
432
418
  if (task.status === "completed") {
433
419
  console.error(`Task ${taskId} is already completed.`);
@@ -444,7 +430,8 @@ function cmdWorkflow(taskId, args) {
444
430
  saveTask(task);
445
431
  console.log(`\u21BB Retrying from step ${task.current_step + 1}/${workflow.steps.length}: ${currentStep.name}`);
446
432
  }
447
- const advanced = autoAdvance(task, workflow);
433
+ d.start(`Task ${taskId} resumed`);
434
+ const advanced = autoAdvance(task, workflow, d);
448
435
  if (advanced.current_step < workflow.steps.length) {
449
436
  const currentStep = workflow.steps[advanced.current_step];
450
437
  advanced.steps[currentStep.id] = { status: "in_progress" };
@@ -454,7 +441,7 @@ function cmdWorkflow(taskId, args) {
454
441
  advanced.status = "completed";
455
442
  saveTask(advanced);
456
443
  const msg = interpolate(workflow.complete, advanced.context);
457
- emitEvent(workflow.skill, "complete", msg);
444
+ d.close({ message: msg });
458
445
  console.log(`
459
446
  ${msg}`);
460
447
  }
@@ -485,7 +472,7 @@ ${msg}`);
485
472
  task.steps[currentStep.id] = { status: "error" };
486
473
  task.status = "error";
487
474
  saveTask(task);
488
- emitEvent(workflow.skill, "error", interpolate(workflow.error, { ...task.context, error: msg }));
475
+ d.close({ error: interpolate(workflow.error, { ...task.context, error: msg }) });
489
476
  process.exit(1);
490
477
  }
491
478
  } else {
@@ -493,11 +480,10 @@ ${msg}`);
493
480
  }
494
481
  task.steps[currentStep.id] = { status: "completed" };
495
482
  if (currentStep.event) {
496
- const msg = interpolate(currentStep.event, task.context);
497
- emitEvent(workflow.skill, "milestone", msg);
483
+ d.milestone(interpolate(currentStep.event, task.context));
498
484
  }
499
485
  task.current_step++;
500
- const advanced2 = autoAdvance(task, workflow);
486
+ const advanced2 = autoAdvance(task, workflow, d);
501
487
  if (advanced2.current_step < workflow.steps.length) {
502
488
  const nextStep = workflow.steps[advanced2.current_step];
503
489
  advanced2.steps[nextStep.id] = { status: "in_progress" };
@@ -507,7 +493,7 @@ ${msg}`);
507
493
  advanced2.status = "completed";
508
494
  saveTask(advanced2);
509
495
  const msg = interpolate(workflow.complete, advanced2.context);
510
- emitEvent(workflow.skill, "complete", msg);
496
+ d.close({ message: msg });
511
497
  console.log(`
512
498
  ${msg}`);
513
499
  }
@@ -518,11 +504,10 @@ ${msg}`);
518
504
  Object.assign(task.context, validated);
519
505
  task.steps[currentStep.id] = { status: "completed" };
520
506
  if (currentStep.event) {
521
- const msg = interpolate(currentStep.event, task.context);
522
- emitEvent(workflow.skill, "milestone", msg);
507
+ d.milestone(interpolate(currentStep.event, task.context));
523
508
  }
524
509
  task.current_step++;
525
- const advanced = autoAdvance(task, workflow);
510
+ const advanced = autoAdvance(task, workflow, d);
526
511
  if (advanced.current_step < workflow.steps.length) {
527
512
  const nextStep = workflow.steps[advanced.current_step];
528
513
  advanced.steps[nextStep.id] = { status: "in_progress" };
@@ -532,7 +517,7 @@ ${msg}`);
532
517
  advanced.status = "completed";
533
518
  saveTask(advanced);
534
519
  const msg = interpolate(workflow.complete, advanced.context);
535
- emitEvent(workflow.skill, "complete", msg);
520
+ d.close({ message: msg });
536
521
  console.log(`
537
522
  ${msg}`);
538
523
  }
@@ -558,7 +543,8 @@ function cmdCancel(taskId) {
558
543
  }
559
544
  task.status = "cancelled";
560
545
  saveTask(task);
561
- emitEvent(workflow.skill, "error", `Task ${taskId} cancelled`);
546
+ const d = createHandle({ skill: workflow.skill });
547
+ d.close({ error: `Task ${taskId} cancelled` });
562
548
  console.log(`\u2717 Task ${taskId} cancelled`);
563
549
  }
564
550
  function cmdTasks() {
@@ -56,15 +56,19 @@ function createAgentRoutes(sessionManager) {
56
56
  }
57
57
  session.eventBuffer.length = 0;
58
58
  const provider = getProvider(body.provider ?? "claude-code");
59
- const skillMatch = body.prompt?.match(/^Execute the skill:\s*(\S+)/);
60
- if (skillMatch) {
61
- try {
62
- const db = getDb();
59
+ try {
60
+ const db = getDb();
61
+ db.prepare(`INSERT OR IGNORE INTO chat_sessions (id, label, type) VALUES (?, ?, 'main')`).run(sessionId, session.label ?? sessionId);
62
+ if (body.prompt) {
63
+ db.prepare(`INSERT INTO chat_messages (session_id, role, content, meta) VALUES (?, 'user', ?, ?)`).run(sessionId, body.prompt, body.meta ? JSON.stringify(body.meta) : null);
64
+ }
65
+ const skillMatch = body.prompt?.match(/^Execute the skill:\s*(\S+)/);
66
+ if (skillMatch) {
63
67
  db.prepare(
64
68
  `INSERT INTO skill_events (session_id, skill, type, message) VALUES (?, ?, 'invoked', ?)`
65
69
  ).run(sessionId, skillMatch[1], `Skill ${skillMatch[1]} invoked`);
66
- } catch {
67
70
  }
71
+ } catch {
68
72
  }
69
73
  try {
70
74
  const proc = provider.spawn({
@@ -72,7 +76,8 @@ function createAgentRoutes(sessionManager) {
72
76
  prompt: body.prompt,
73
77
  model: body.model ?? "claude-sonnet-4-6",
74
78
  permissionMode: body.permissionMode ?? "acceptEdits",
75
- env: { SNA_SESSION_ID: sessionId }
79
+ env: { SNA_SESSION_ID: sessionId },
80
+ extraArgs: body.extraArgs
76
81
  });
77
82
  sessionManager.setProcess(sessionId, proc);
78
83
  logger.log("route", `POST /start?session=${sessionId} \u2192 started`);
@@ -101,6 +106,13 @@ function createAgentRoutes(sessionManager) {
101
106
  logger.err("err", `POST /send?session=${sessionId} \u2192 empty message`);
102
107
  return c.json({ status: "error", message: "message is required" }, 400);
103
108
  }
109
+ try {
110
+ const db = getDb();
111
+ db.prepare(`INSERT OR IGNORE INTO chat_sessions (id, label, type) VALUES (?, ?, 'main')`).run(sessionId, session.label ?? sessionId);
112
+ db.prepare(`INSERT INTO chat_messages (session_id, role, content, meta) VALUES (?, 'user', ?, ?)`).run(sessionId, body.message, body.meta ? JSON.stringify(body.meta) : null);
113
+ } catch {
114
+ }
115
+ session.state = "processing";
104
116
  sessionManager.touch(sessionId);
105
117
  logger.log("route", `POST /send?session=${sessionId} \u2192 "${body.message.slice(0, 80)}"`);
106
118
  session.process.send(body.message);
@@ -153,6 +165,63 @@ function createAgentRoutes(sessionManager) {
153
165
  eventCount: session?.eventCounter ?? 0
154
166
  });
155
167
  });
168
+ const pendingPermissions = /* @__PURE__ */ new Map();
169
+ app.post("/permission-request", async (c) => {
170
+ const sessionId = getSessionId(c);
171
+ const body = await c.req.json().catch(() => ({}));
172
+ logger.log("route", `POST /permission-request?session=${sessionId} \u2192 ${body.tool_name}`);
173
+ const session = sessionManager.getSession(sessionId);
174
+ if (session) session.state = "permission";
175
+ const result = await new Promise((resolve) => {
176
+ pendingPermissions.set(sessionId, {
177
+ resolve,
178
+ request: body,
179
+ createdAt: Date.now()
180
+ });
181
+ setTimeout(() => {
182
+ if (pendingPermissions.has(sessionId)) {
183
+ pendingPermissions.delete(sessionId);
184
+ resolve(false);
185
+ }
186
+ }, 3e5);
187
+ });
188
+ return c.json({ approved: result });
189
+ });
190
+ app.post("/permission-respond", async (c) => {
191
+ const sessionId = getSessionId(c);
192
+ const body = await c.req.json().catch(() => ({}));
193
+ const approved = body.approved ?? false;
194
+ const pending = pendingPermissions.get(sessionId);
195
+ if (!pending) {
196
+ return c.json({ status: "error", message: "No pending permission request" }, 404);
197
+ }
198
+ pending.resolve(approved);
199
+ pendingPermissions.delete(sessionId);
200
+ const session = sessionManager.getSession(sessionId);
201
+ if (session) session.state = "processing";
202
+ logger.log("route", `POST /permission-respond?session=${sessionId} \u2192 ${approved ? "approved" : "denied"}`);
203
+ return c.json({ status: approved ? "approved" : "denied" });
204
+ });
205
+ app.get("/permission-pending", (c) => {
206
+ const sessionId = c.req.query("session");
207
+ if (sessionId) {
208
+ const pending = pendingPermissions.get(sessionId);
209
+ if (!pending) return c.json({ pending: null });
210
+ return c.json({
211
+ pending: {
212
+ sessionId,
213
+ request: pending.request,
214
+ createdAt: pending.createdAt
215
+ }
216
+ });
217
+ }
218
+ const all = Array.from(pendingPermissions.entries()).map(([id, p]) => ({
219
+ sessionId: id,
220
+ request: p.request,
221
+ createdAt: p.createdAt
222
+ }));
223
+ return c.json({ pending: all });
224
+ });
156
225
  return app;
157
226
  }
158
227
  export {
@@ -3,37 +3,53 @@ import { getDb } from "../../db/schema.js";
3
3
  function createChatRoutes() {
4
4
  const app = new Hono();
5
5
  app.get("/sessions", (c) => {
6
- const db = getDb();
7
- const sessions = db.prepare(
8
- `SELECT id, label, type, created_at FROM chat_sessions ORDER BY created_at DESC`
9
- ).all();
10
- return c.json({ sessions });
6
+ try {
7
+ const db = getDb();
8
+ const sessions = db.prepare(
9
+ `SELECT id, label, type, created_at FROM chat_sessions ORDER BY created_at DESC`
10
+ ).all();
11
+ return c.json({ sessions });
12
+ } catch (e) {
13
+ return c.json({ status: "error", message: e.message, stack: e.stack }, 500);
14
+ }
11
15
  });
12
16
  app.post("/sessions", async (c) => {
13
17
  const body = await c.req.json().catch(() => ({}));
14
18
  const id = body.id ?? crypto.randomUUID().slice(0, 8);
15
- const db = getDb();
16
- db.prepare(
17
- `INSERT OR IGNORE INTO chat_sessions (id, label, type) VALUES (?, ?, ?)`
18
- ).run(id, body.label ?? id, body.type ?? "background");
19
- return c.json({ status: "created", id });
19
+ try {
20
+ const db = getDb();
21
+ db.prepare(
22
+ `INSERT OR IGNORE INTO chat_sessions (id, label, type) VALUES (?, ?, ?)`
23
+ ).run(id, body.label ?? id, body.type ?? "background");
24
+ return c.json({ status: "created", id });
25
+ } catch (e) {
26
+ return c.json({ status: "error", message: e.message }, 500);
27
+ }
20
28
  });
21
29
  app.delete("/sessions/:id", (c) => {
22
30
  const id = c.req.param("id");
23
31
  if (id === "default") {
24
32
  return c.json({ status: "error", message: "Cannot delete default session" }, 400);
25
33
  }
26
- const db = getDb();
27
- db.prepare(`DELETE FROM chat_sessions WHERE id = ?`).run(id);
28
- return c.json({ status: "deleted" });
34
+ try {
35
+ const db = getDb();
36
+ db.prepare(`DELETE FROM chat_sessions WHERE id = ?`).run(id);
37
+ return c.json({ status: "deleted" });
38
+ } catch (e) {
39
+ return c.json({ status: "error", message: e.message }, 500);
40
+ }
29
41
  });
30
42
  app.get("/sessions/:id/messages", (c) => {
31
43
  const id = c.req.param("id");
32
44
  const sinceParam = c.req.query("since");
33
- const db = getDb();
34
- const query = sinceParam ? db.prepare(`SELECT * FROM chat_messages WHERE session_id = ? AND id > ? ORDER BY id ASC`) : db.prepare(`SELECT * FROM chat_messages WHERE session_id = ? ORDER BY id ASC`);
35
- const messages = sinceParam ? query.all(id, parseInt(sinceParam, 10)) : query.all(id);
36
- return c.json({ messages });
45
+ try {
46
+ const db = getDb();
47
+ const query = sinceParam ? db.prepare(`SELECT * FROM chat_messages WHERE session_id = ? AND id > ? ORDER BY id ASC`) : db.prepare(`SELECT * FROM chat_messages WHERE session_id = ? ORDER BY id ASC`);
48
+ const messages = sinceParam ? query.all(id, parseInt(sinceParam, 10)) : query.all(id);
49
+ return c.json({ messages });
50
+ } catch (e) {
51
+ return c.json({ status: "error", message: e.message, stack: e.stack }, 500);
52
+ }
37
53
  });
38
54
  app.post("/sessions/:id/messages", async (c) => {
39
55
  const sessionId = c.req.param("id");
@@ -41,24 +57,32 @@ function createChatRoutes() {
41
57
  if (!body.role) {
42
58
  return c.json({ status: "error", message: "role is required" }, 400);
43
59
  }
44
- const db = getDb();
45
- db.prepare(`INSERT OR IGNORE INTO chat_sessions (id, label, type) VALUES (?, ?, 'main')`).run(sessionId, sessionId);
46
- const result = db.prepare(
47
- `INSERT INTO chat_messages (session_id, role, content, skill_name, meta) VALUES (?, ?, ?, ?, ?)`
48
- ).run(
49
- sessionId,
50
- body.role,
51
- body.content ?? "",
52
- body.skill_name ?? null,
53
- body.meta ? JSON.stringify(body.meta) : null
54
- );
55
- return c.json({ status: "created", id: result.lastInsertRowid });
60
+ try {
61
+ const db = getDb();
62
+ db.prepare(`INSERT OR IGNORE INTO chat_sessions (id, label, type) VALUES (?, ?, 'main')`).run(sessionId, sessionId);
63
+ const result = db.prepare(
64
+ `INSERT INTO chat_messages (session_id, role, content, skill_name, meta) VALUES (?, ?, ?, ?, ?)`
65
+ ).run(
66
+ sessionId,
67
+ body.role,
68
+ body.content ?? "",
69
+ body.skill_name ?? null,
70
+ body.meta ? JSON.stringify(body.meta) : null
71
+ );
72
+ return c.json({ status: "created", id: result.lastInsertRowid });
73
+ } catch (e) {
74
+ return c.json({ status: "error", message: e.message }, 500);
75
+ }
56
76
  });
57
77
  app.delete("/sessions/:id/messages", (c) => {
58
78
  const id = c.req.param("id");
59
- const db = getDb();
60
- db.prepare(`DELETE FROM chat_messages WHERE session_id = ?`).run(id);
61
- return c.json({ status: "cleared" });
79
+ try {
80
+ const db = getDb();
81
+ db.prepare(`DELETE FROM chat_messages WHERE session_id = ?`).run(id);
82
+ return c.json({ status: "cleared" });
83
+ } catch (e) {
84
+ return c.json({ status: "error", message: e.message }, 500);
85
+ }
62
86
  });
63
87
  return app;
64
88
  }
@@ -7,6 +7,7 @@ import { AgentProcess, AgentEvent } from '../core/providers/types.js';
7
7
  * The default "default" session provides backward compatibility.
8
8
  */
9
9
 
10
+ type SessionState = "idle" | "processing" | "waiting" | "permission";
10
11
  interface Session {
11
12
  id: string;
12
13
  process: AgentProcess | null;
@@ -14,6 +15,7 @@ interface Session {
14
15
  eventCounter: number;
15
16
  label: string;
16
17
  cwd: string;
18
+ state: SessionState;
17
19
  createdAt: number;
18
20
  lastActivityAt: number;
19
21
  }
@@ -21,6 +23,7 @@ interface SessionInfo {
21
23
  id: string;
22
24
  label: string;
23
25
  alive: boolean;
26
+ state: SessionState;
24
27
  cwd: string;
25
28
  eventCount: number;
26
29
  createdAt: number;
@@ -56,9 +59,11 @@ declare class SessionManager {
56
59
  listSessions(): SessionInfo[];
57
60
  /** Touch a session's lastActivityAt timestamp. */
58
61
  touch(id: string): void;
62
+ /** Persist an agent event to chat_messages. */
63
+ private persistEvent;
59
64
  /** Kill all sessions. Used during shutdown. */
60
65
  killAll(): void;
61
66
  get size(): number;
62
67
  }
63
68
 
64
- export { type Session, type SessionInfo, SessionManager, type SessionManagerOptions };
69
+ export { type Session, type SessionInfo, SessionManager, type SessionManagerOptions, type SessionState };