@sna-sdk/core 0.0.10 → 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.
package/README.md CHANGED
@@ -5,9 +5,10 @@ Server runtime for [Skills-Native Applications](https://github.com/neuradex/sna)
5
5
  ## What's included
6
6
 
7
7
  - **Skill event pipeline** — emit, SSE streaming, and hook scripts
8
+ - **Dispatch** — unified event dispatcher with validation, session lifecycle, and cleanup (`sna dispatch` CLI + programmatic API)
8
9
  - **SQLite database** — schema and `getDb()` for `skill_events`
9
10
  - **Hono server factory** — `createSnaApp()` with events, emit, agent, and run routes
10
- - **Lifecycle CLI** — `sna api:up`, `sna api:down`
11
+ - **Lifecycle CLI** — `sna api:up`, `sna api:down`, `sna dispatch`, `sna validate`
11
12
  - **Agent providers** — Claude Code and Codex process management
12
13
 
13
14
  ## Install
@@ -18,7 +19,29 @@ npm install @sna-sdk/core
18
19
 
19
20
  ## Usage
20
21
 
21
- ### Emit skill events
22
+ ### Dispatch skill events (recommended)
23
+
24
+ ```bash
25
+ # CLI
26
+ ID=$(sna dispatch open --skill my-skill)
27
+ sna dispatch $ID start --message "Starting..."
28
+ sna dispatch $ID milestone --message "Step done"
29
+ sna dispatch $ID close --message "Done."
30
+ ```
31
+
32
+ ```typescript
33
+ // Programmatic
34
+ import { createDispatchHandle } from "@sna-sdk/core";
35
+
36
+ const h = createDispatchHandle({ skill: "my-skill" });
37
+ h.start("Starting...");
38
+ h.milestone("Step done");
39
+ await h.close();
40
+ ```
41
+
42
+ ### Emit skill events (legacy, deprecated)
43
+
44
+ > Use `sna dispatch` instead. `emit.js` remains for backward compatibility.
22
45
 
23
46
  ```bash
24
47
  node node_modules/@sna-sdk/core/dist/scripts/emit.js \
@@ -48,7 +71,7 @@ const db = getDb(); // SQLite instance (data/sna.db)
48
71
 
49
72
  | Import path | Contents |
50
73
  |-------------|----------|
51
- | `@sna-sdk/core` | `DEFAULT_SNA_PORT`, `DEFAULT_SNA_URL`, types |
74
+ | `@sna-sdk/core` | `DEFAULT_SNA_PORT`, `DEFAULT_SNA_URL`, `dispatchOpen`, `dispatchSend`, `dispatchClose`, `createDispatchHandle`, `SEND_TYPES`, `loadSkillsManifest`, types |
52
75
  | `@sna-sdk/core/server` | `createSnaApp()`, route handlers, `SessionManager` |
53
76
  | `@sna-sdk/core/db/schema` | `getDb()`, `SkillEvent` type |
54
77
  | `@sna-sdk/core/providers` | Agent provider factory, `ClaudeCodeProvider` |
@@ -228,12 +228,47 @@ class ClaudeCodeProvider {
228
228
  }
229
229
  spawn(options) {
230
230
  const claudePath = resolveClaudePath(options.cwd);
231
+ const hookScript = path.join(options.cwd, "node_modules/@sna-sdk/core/dist/scripts/hook.js");
232
+ const sdkSettings = {};
233
+ if (options.permissionMode !== "bypassPermissions") {
234
+ sdkSettings.hooks = {
235
+ PreToolUse: [{
236
+ matcher: ".*",
237
+ hooks: [{ type: "command", command: `node "${hookScript}"` }]
238
+ }]
239
+ };
240
+ }
241
+ let extraArgsClean = options.extraArgs ? [...options.extraArgs] : [];
242
+ const settingsIdx = extraArgsClean.indexOf("--settings");
243
+ if (settingsIdx !== -1 && settingsIdx + 1 < extraArgsClean.length) {
244
+ try {
245
+ const appSettings = JSON.parse(extraArgsClean[settingsIdx + 1]);
246
+ if (appSettings.hooks) {
247
+ for (const [event, hooks] of Object.entries(appSettings.hooks)) {
248
+ if (sdkSettings.hooks && sdkSettings.hooks[event]) {
249
+ sdkSettings.hooks[event] = [
250
+ ...sdkSettings.hooks[event],
251
+ ...hooks
252
+ ];
253
+ } else {
254
+ sdkSettings.hooks[event] = hooks;
255
+ }
256
+ }
257
+ delete appSettings.hooks;
258
+ }
259
+ Object.assign(sdkSettings, appSettings);
260
+ } catch {
261
+ }
262
+ extraArgsClean.splice(settingsIdx, 2);
263
+ }
231
264
  const args = [
232
265
  "--output-format",
233
266
  "stream-json",
234
267
  "--input-format",
235
268
  "stream-json",
236
- "--verbose"
269
+ "--verbose",
270
+ "--settings",
271
+ JSON.stringify(sdkSettings)
237
272
  ];
238
273
  if (options.model) {
239
274
  args.push("--model", options.model);
@@ -241,6 +276,9 @@ class ClaudeCodeProvider {
241
276
  if (options.permissionMode) {
242
277
  args.push("--permission-mode", options.permissionMode);
243
278
  }
279
+ if (extraArgsClean.length > 0) {
280
+ args.push(...extraArgsClean);
281
+ }
244
282
  const cleanEnv = { ...process.env, ...options.env };
245
283
  delete cleanEnv.CLAUDECODE;
246
284
  delete cleanEnv.CLAUDE_CODE_ENTRYPOINT;
@@ -250,7 +288,7 @@ class ClaudeCodeProvider {
250
288
  env: cleanEnv,
251
289
  stdio: ["pipe", "pipe", "pipe"]
252
290
  });
253
- logger.log("agent", `spawned claude-code (pid=${proc.pid})`);
291
+ logger.log("agent", `spawned claude-code (pid=${proc.pid}) \u2192 ${claudePath} ${args.join(" ")}`);
254
292
  return new ClaudeCodeProcess(proc, options);
255
293
  }
256
294
  }
@@ -36,6 +36,11 @@ interface SpawnOptions {
36
36
  model?: string;
37
37
  permissionMode?: "default" | "acceptEdits" | "bypassPermissions" | "plan";
38
38
  env?: Record<string, string>;
39
+ /**
40
+ * Additional CLI flags passed directly to the agent binary.
41
+ * e.g. ["--system-prompt", "You are...", "--append-system-prompt", "Also...", "--mcp-config", "path"]
42
+ */
43
+ extraArgs?: string[];
39
44
  }
40
45
  /**
41
46
  * Agent provider interface. Each backend (Claude Code, Codex, etc.)
package/dist/db/schema.js CHANGED
@@ -1,11 +1,14 @@
1
1
  import { createRequire } from "node:module";
2
+ import fs from "fs";
2
3
  import path from "path";
3
- const require2 = createRequire(path.join(process.cwd(), "node_modules", "_"));
4
- const BetterSqlite3 = require2("better-sqlite3");
5
4
  const DB_PATH = path.join(process.cwd(), "data/sna.db");
6
5
  let _db = null;
7
6
  function getDb() {
8
7
  if (!_db) {
8
+ const req = createRequire(import.meta.url);
9
+ const BetterSqlite3 = req("better-sqlite3");
10
+ const dir = path.dirname(DB_PATH);
11
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
9
12
  _db = new BetterSqlite3(DB_PATH);
10
13
  _db.pragma("journal_mode = WAL");
11
14
  initSchema(_db);
package/dist/index.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  export { ChatMessage, ChatSession, SkillEvent } from './db/schema.js';
2
2
  export { AgentEvent, AgentProcess, AgentProvider, SpawnOptions } from './core/providers/types.js';
3
- export { Session, SessionInfo, SessionManagerOptions } from './server/session-manager.js';
3
+ export { Session, SessionInfo, SessionManagerOptions, SessionState } from './server/session-manager.js';
4
+ export { DispatchCloseOptions, DispatchEventType, DispatchOpenOptions, DispatchOpenResult, DispatchSendOptions, createHandle as createDispatchHandle, close as dispatchClose, open as dispatchOpen, send as dispatchSend } from './lib/dispatch.js';
4
5
  import 'better-sqlite3';
5
6
 
6
7
  /**
package/dist/index.js CHANGED
@@ -1,6 +1,11 @@
1
1
  const DEFAULT_SNA_PORT = 3099;
2
2
  const DEFAULT_SNA_URL = `http://localhost:${DEFAULT_SNA_PORT}`;
3
+ import { open, send, close, createHandle } from "./lib/dispatch.js";
3
4
  export {
4
5
  DEFAULT_SNA_PORT,
5
- DEFAULT_SNA_URL
6
+ DEFAULT_SNA_URL,
7
+ createHandle as createDispatchHandle,
8
+ close as dispatchClose,
9
+ open as dispatchOpen,
10
+ send as dispatchSend
6
11
  };
@@ -0,0 +1,77 @@
1
+ /**
2
+ * dispatch.ts — Unified event dispatcher for SNA.
3
+ *
4
+ * Single entry point for all skill lifecycle events.
5
+ * Used by both CLI (`sna dispatch`) and SDK (programmatic).
6
+ *
7
+ * Lifecycle:
8
+ * dispatch.open({ skill }) → id (validate + create session, no event written)
9
+ * dispatch.send(id, { type, message }) (write event to DB)
10
+ * dispatch.close(id) (complete + kill session)
11
+ * dispatch.close(id, { error }) (error + kill session)
12
+ *
13
+ * Responsibilities:
14
+ * - Validate skill name against .sna/skills.json (fallback: SKILL.md existence)
15
+ * - Write events to SQLite (skill_events table)
16
+ * - On close: notify SNA API server to kill background session
17
+ */
18
+ interface DispatchOpenOptions {
19
+ skill: string;
20
+ sessionId?: string;
21
+ cwd?: string;
22
+ }
23
+ interface DispatchOpenResult {
24
+ id: string;
25
+ skill: string;
26
+ sessionId: string | null;
27
+ }
28
+ type DispatchEventType = "called" | "start" | "progress" | "milestone" | "permission_needed";
29
+ interface DispatchSendOptions {
30
+ type: DispatchEventType;
31
+ message: string;
32
+ data?: string;
33
+ }
34
+ interface DispatchCloseOptions {
35
+ error?: string;
36
+ message?: string;
37
+ }
38
+ interface DispatchSession {
39
+ id: string;
40
+ skill: string;
41
+ sessionId: string | null;
42
+ cwd: string;
43
+ closed: boolean;
44
+ }
45
+ declare const SEND_TYPES: readonly string[];
46
+ declare function loadSkillsManifest(cwd: string): Record<string, unknown> | null;
47
+ /**
48
+ * Open a dispatch session. Validates skill name, creates session.
49
+ * Does NOT write any event — caller decides what to send first.
50
+ */
51
+ declare function open(opts: DispatchOpenOptions): DispatchOpenResult;
52
+ /**
53
+ * Send an event within an open dispatch session.
54
+ */
55
+ declare function send(id: string, opts: DispatchSendOptions): void;
56
+ /**
57
+ * Close a dispatch session. Emits terminal events and triggers cleanup.
58
+ */
59
+ declare function close(id: string, opts?: DispatchCloseOptions): Promise<void>;
60
+ /**
61
+ * Get an active dispatch session (for internal inspection).
62
+ */
63
+ declare function getSession(id: string): DispatchSession | undefined;
64
+ /**
65
+ * Convenience: create a dispatch handle with chainable methods.
66
+ */
67
+ declare function createHandle(opts: DispatchOpenOptions): {
68
+ id: string;
69
+ skill: string;
70
+ called: (message: string) => void;
71
+ start: (message: string) => void;
72
+ progress: (message: string) => void;
73
+ milestone: (message: string) => void;
74
+ close: (closeOpts?: DispatchCloseOptions) => Promise<void>;
75
+ };
76
+
77
+ export { type DispatchCloseOptions, type DispatchEventType, type DispatchOpenOptions, type DispatchOpenResult, type DispatchSendOptions, SEND_TYPES, close, createHandle, getSession, loadSkillsManifest, open, send };
@@ -0,0 +1,159 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+ import crypto from "crypto";
4
+ import { getDb } from "../db/schema.js";
5
+ const activeSessions = /* @__PURE__ */ new Map();
6
+ const SEND_TYPES = [
7
+ "called",
8
+ "start",
9
+ "progress",
10
+ "milestone",
11
+ "permission_needed"
12
+ ];
13
+ function loadSkillsManifest(cwd) {
14
+ const manifestPath = path.join(cwd, ".sna/skills.json");
15
+ if (!fs.existsSync(manifestPath)) return null;
16
+ try {
17
+ return JSON.parse(fs.readFileSync(manifestPath, "utf8"));
18
+ } catch {
19
+ return null;
20
+ }
21
+ }
22
+ function skillMdExists(cwd, skill) {
23
+ return fs.existsSync(path.join(cwd, ".claude/skills", skill, "SKILL.md"));
24
+ }
25
+ function generateId() {
26
+ return crypto.randomBytes(4).toString("hex");
27
+ }
28
+ function writeEvent(sessionId, skill, type, message, data) {
29
+ const db = getDb();
30
+ db.prepare(`
31
+ INSERT INTO skill_events (session_id, skill, type, message, data)
32
+ VALUES (?, ?, ?, ?, ?)
33
+ `).run(sessionId, skill, type, message, data ?? null);
34
+ }
35
+ async function notifySessionClose(cwd, sessionId) {
36
+ if (!sessionId) return;
37
+ try {
38
+ const port = fs.readFileSync(path.join(cwd, ".sna/sna-api.port"), "utf8").trim();
39
+ if (!port) return;
40
+ await fetch(`http://localhost:${port}/agent/kill?session=${encodeURIComponent(sessionId)}`, {
41
+ method: "POST",
42
+ signal: AbortSignal.timeout(3e3)
43
+ });
44
+ } catch {
45
+ }
46
+ }
47
+ const PREFIX = {
48
+ called: "\u2192",
49
+ start: "\u25B6",
50
+ progress: "\xB7",
51
+ milestone: "\u25C6",
52
+ permission_needed: "\u26A0",
53
+ complete: "\u2713",
54
+ error: "\u2717",
55
+ success: "\u2713",
56
+ failed: "\u2717"
57
+ };
58
+ function log(skill, type, message) {
59
+ const p = PREFIX[type] ?? "\xB7";
60
+ console.log(`${p} [${skill}] ${message}`);
61
+ }
62
+ function open(opts) {
63
+ const cwd = opts.cwd ?? process.cwd();
64
+ const manifest = loadSkillsManifest(cwd);
65
+ if (manifest) {
66
+ if (!(opts.skill in manifest)) {
67
+ if (skillMdExists(cwd, opts.skill)) {
68
+ console.warn(
69
+ `\u26A0 Skill "${opts.skill}" has SKILL.md but is not in .sna/skills.json \u2014 run 'sna gen client'`
70
+ );
71
+ } else {
72
+ const available = Object.keys(manifest).join(", ");
73
+ throw new Error(
74
+ `Unknown skill: "${opts.skill}". Available: ${available}.`
75
+ );
76
+ }
77
+ }
78
+ } else {
79
+ if (!skillMdExists(cwd, opts.skill)) {
80
+ throw new Error(
81
+ `Unknown skill: "${opts.skill}". No .sna/skills.json and no SKILL.md found.`
82
+ );
83
+ }
84
+ }
85
+ const id = generateId();
86
+ const sessionId = opts.sessionId ?? process.env.SNA_SESSION_ID ?? null;
87
+ const session = {
88
+ id,
89
+ skill: opts.skill,
90
+ sessionId,
91
+ cwd,
92
+ closed: false
93
+ };
94
+ activeSessions.set(id, session);
95
+ return { id, skill: opts.skill, sessionId };
96
+ }
97
+ function send(id, opts) {
98
+ const session = activeSessions.get(id);
99
+ if (!session) {
100
+ throw new Error(`Dispatch session "${id}" not found. Call dispatch.open() first.`);
101
+ }
102
+ if (session.closed) {
103
+ throw new Error(`Dispatch session "${id}" is already closed.`);
104
+ }
105
+ if (!SEND_TYPES.includes(opts.type)) {
106
+ throw new Error(
107
+ `Invalid event type: "${opts.type}". Must be one of: ${SEND_TYPES.join(", ")}`
108
+ );
109
+ }
110
+ writeEvent(session.sessionId, session.skill, opts.type, opts.message, opts.data);
111
+ log(session.skill, opts.type, opts.message);
112
+ }
113
+ async function close(id, opts) {
114
+ const session = activeSessions.get(id);
115
+ if (!session) {
116
+ throw new Error(`Dispatch session "${id}" not found.`);
117
+ }
118
+ if (session.closed) {
119
+ throw new Error(`Dispatch session "${id}" is already closed.`);
120
+ }
121
+ session.closed = true;
122
+ if (opts?.error) {
123
+ const message = opts.error;
124
+ writeEvent(session.sessionId, session.skill, "error", message);
125
+ writeEvent(session.sessionId, session.skill, "failed", message);
126
+ log(session.skill, "error", message);
127
+ } else {
128
+ const message = opts?.message ?? "Done";
129
+ writeEvent(session.sessionId, session.skill, "complete", message);
130
+ writeEvent(session.sessionId, session.skill, "success", message);
131
+ log(session.skill, "complete", message);
132
+ }
133
+ await notifySessionClose(session.cwd, session.sessionId);
134
+ activeSessions.delete(id);
135
+ }
136
+ function getSession(id) {
137
+ return activeSessions.get(id);
138
+ }
139
+ function createHandle(opts) {
140
+ const result = open(opts);
141
+ return {
142
+ id: result.id,
143
+ skill: result.skill,
144
+ called: (message) => send(result.id, { type: "called", message }),
145
+ start: (message) => send(result.id, { type: "start", message }),
146
+ progress: (message) => send(result.id, { type: "progress", message }),
147
+ milestone: (message) => send(result.id, { type: "milestone", message }),
148
+ close: (closeOpts) => close(result.id, closeOpts)
149
+ };
150
+ }
151
+ export {
152
+ SEND_TYPES,
153
+ close,
154
+ createHandle,
155
+ getSession,
156
+ loadSkillsManifest,
157
+ open,
158
+ send
159
+ };
@@ -0,0 +1,12 @@
1
+ /**
2
+ * parse-flags.ts — Shared CLI flag parser for SNA scripts.
3
+ *
4
+ * Parses --key value pairs from argv-style arrays.
5
+ */
6
+ /**
7
+ * Parse --key value pairs from an argument array.
8
+ * Handles --flag (without value) by setting it to "true".
9
+ */
10
+ declare function parseFlags(args: string[]): Record<string, string>;
11
+
12
+ export { parseFlags };
@@ -0,0 +1,20 @@
1
+ function parseFlags(args) {
2
+ const result = {};
3
+ for (let i = 0; i < args.length; i++) {
4
+ const arg = args[i];
5
+ if (arg?.startsWith("--")) {
6
+ const key = arg.slice(2);
7
+ const next = args[i + 1];
8
+ if (next && !next.startsWith("--")) {
9
+ result[key] = next;
10
+ i++;
11
+ } else {
12
+ result[key] = "true";
13
+ }
14
+ }
15
+ }
16
+ return result;
17
+ }
18
+ export {
19
+ parseFlags
20
+ };
@@ -1,51 +1,29 @@
1
- import { getDb } from "../db/schema.js";
2
- function parseArgs(args2) {
3
- const result = {};
4
- for (let i = 0; i < args2.length; i += 2) {
5
- const key = args2[i]?.replace(/^--/, "");
6
- if (key) result[key] = args2[i + 1] ?? "";
7
- }
8
- return result;
9
- }
1
+ import { open, send, close, SEND_TYPES } from "../lib/dispatch.js";
2
+ import { parseFlags } from "../lib/parse-flags.js";
10
3
  const [, , ...args] = process.argv;
11
- const flags = parseArgs(args);
12
- const VALID_TYPES = [
13
- "called",
14
- "success",
15
- "failed",
16
- "permission_needed",
17
- "start",
18
- "progress",
19
- "milestone",
20
- "complete",
21
- "error"
22
- ];
4
+ const flags = parseFlags(args);
5
+ const CLOSE_SUCCESS_TYPES = ["complete", "success"];
6
+ const CLOSE_ERROR_TYPES = ["error", "failed"];
23
7
  if (!flags.skill || !flags.type || !flags.message) {
24
- console.error("Usage: tsx node_modules/@sna-sdk/core/src/scripts/emit.ts --skill <name> --type <type> --message <text> [--data <json>]");
25
- process.exit(1);
26
- }
27
- if (!VALID_TYPES.includes(flags.type)) {
28
- console.error(`Invalid type: ${flags.type}. Must be one of: ${VALID_TYPES.join(", ")}`);
8
+ console.error("DEPRECATED: Use 'sna dispatch' instead.");
9
+ console.error("Usage: node emit.js --skill <name> --type <type> --message <text>");
29
10
  process.exit(1);
30
11
  }
31
- const sessionId = process.env.SNA_SESSION_ID;
32
- if (sessionId) {
33
- const db = getDb();
34
- db.prepare(`
35
- INSERT INTO skill_events (session_id, skill, type, message, data)
36
- VALUES (?, ?, ?, ?, ?)
37
- `).run(sessionId, flags.skill, flags.type, flags.message, flags.data ?? null);
38
- }
39
- const prefix = {
40
- called: "\u2192",
41
- success: "\u2713",
42
- failed: "\u2717",
43
- permission_needed: "\u26A0",
44
- start: "\u25B6",
45
- progress: "\xB7",
46
- milestone: "\u25C6",
47
- complete: "\u2713",
48
- error: "\u2717"
49
- };
50
- const p = prefix[flags.type] ?? "\xB7";
51
- console.log(`${p} [${flags.skill}] ${flags.message}`);
12
+ (async () => {
13
+ try {
14
+ const d = open({ skill: flags.skill });
15
+ if (SEND_TYPES.includes(flags.type)) {
16
+ send(d.id, { type: flags.type, message: flags.message, data: flags.data });
17
+ } else if (CLOSE_SUCCESS_TYPES.includes(flags.type)) {
18
+ await close(d.id, { message: flags.message });
19
+ } else if (CLOSE_ERROR_TYPES.includes(flags.type)) {
20
+ await close(d.id, { error: flags.message });
21
+ } else {
22
+ console.error(`Unknown type: ${flags.type}`);
23
+ process.exit(1);
24
+ }
25
+ } catch (err) {
26
+ console.error(`\u2717 ${err.message}`);
27
+ process.exit(1);
28
+ }
29
+ })();
@@ -1,15 +1,8 @@
1
1
  import fs from "fs";
2
2
  import path from "path";
3
3
  import { scanSkills } from "../lib/skill-parser.js";
4
+ import { parseFlags } from "../lib/parse-flags.js";
4
5
  const ROOT = process.cwd();
5
- function parseFlags(args) {
6
- const flags2 = {};
7
- for (let i = 0; i < args.length; i += 2) {
8
- const key = args[i]?.replace(/^--/, "");
9
- if (key) flags2[key] = args[i + 1] ?? "";
10
- }
11
- return flags2;
12
- }
13
6
  function tsType(argDef) {
14
7
  switch (argDef.type) {
15
8
  case "number":
@@ -106,7 +99,16 @@ const code = generateClient(schemas);
106
99
  const outDir = path.dirname(outPath);
107
100
  if (!fs.existsSync(outDir)) fs.mkdirSync(outDir, { recursive: true });
108
101
  fs.writeFileSync(outPath, code);
102
+ const snaDir = path.join(ROOT, ".sna");
103
+ if (!fs.existsSync(snaDir)) fs.mkdirSync(snaDir, { recursive: true });
104
+ const skillsManifest = {};
105
+ for (const s of schemas) {
106
+ skillsManifest[s.name] = { description: s.description, args: s.args };
107
+ }
108
+ const manifestPath = path.join(snaDir, "skills.json");
109
+ fs.writeFileSync(manifestPath, JSON.stringify(skillsManifest, null, 2) + "\n");
109
110
  console.log(`\u2713 Generated ${outPath}`);
111
+ console.log(`\u2713 Generated ${manifestPath}`);
110
112
  console.log(` ${schemas.length} skills:`);
111
113
  for (const s of schemas) {
112
114
  const argCount = Object.keys(s.args).length;
@@ -1,34 +1,61 @@
1
- import { getDb } from "../db/schema.js";
1
+ import fs from "fs";
2
+ import path from "path";
2
3
  const chunks = [];
3
4
  process.stdin.on("data", (chunk) => chunks.push(chunk));
4
- process.stdin.on("end", () => {
5
+ process.stdin.on("end", async () => {
5
6
  try {
6
7
  const raw = Buffer.concat(chunks).toString().trim();
7
- if (!raw) process.exit(0);
8
+ if (!raw) {
9
+ allow();
10
+ return;
11
+ }
8
12
  const input = JSON.parse(raw);
9
13
  const toolName = input.tool_name ?? "unknown";
10
- const toolInput = input.tool_input ?? {};
11
- const db = getDb();
12
- const latestCalled = db.prepare(`
13
- SELECT skill FROM skill_events
14
- WHERE type = 'called'
15
- AND id > COALESCE(
16
- (SELECT MAX(id) FROM skill_events WHERE type IN ('success', 'failed')),
17
- 0
18
- )
19
- ORDER BY id DESC LIMIT 1
20
- `).get();
21
- const skillName = latestCalled?.skill ?? "system";
22
- const summary = toolName === "Bash" ? String(toolInput.command ?? "").slice(0, 120) : toolName === "Write" ? String(toolInput.file_path ?? "") : toolName === "Edit" || toolName === "MultiEdit" ? String(toolInput.file_path ?? "") : JSON.stringify(toolInput).slice(0, 120);
23
- db.prepare(
24
- `INSERT INTO skill_events (skill, type, message, data) VALUES (?, ?, ?, ?)`
25
- ).run(
26
- skillName,
27
- "permission_needed",
28
- `${toolName}: ${summary}`,
29
- JSON.stringify({ tool_name: toolName, tool_input: toolInput })
30
- );
14
+ const safeTools = ["Read", "Glob", "Grep", "Agent", "TodoRead", "TodoWrite"];
15
+ if (safeTools.includes(toolName)) {
16
+ allow();
17
+ return;
18
+ }
19
+ const portFile = path.join(process.cwd(), ".sna/sna-api.port");
20
+ let port;
21
+ try {
22
+ port = fs.readFileSync(portFile, "utf8").trim();
23
+ } catch {
24
+ allow();
25
+ return;
26
+ }
27
+ const sessionId = process.env.SNA_SESSION_ID ?? "default";
28
+ const apiUrl = `http://localhost:${port}`;
29
+ const res = await fetch(`${apiUrl}/agent/permission-request?session=${encodeURIComponent(sessionId)}`, {
30
+ method: "POST",
31
+ headers: { "Content-Type": "application/json" },
32
+ body: JSON.stringify({
33
+ tool_name: input.tool_name,
34
+ tool_input: input.tool_input
35
+ }),
36
+ signal: AbortSignal.timeout(3e5)
37
+ // 5 min timeout
38
+ });
39
+ const data = await res.json();
40
+ if (data.approved) {
41
+ allow();
42
+ } else {
43
+ deny("User denied this tool execution");
44
+ }
31
45
  } catch {
46
+ allow();
32
47
  }
33
- process.exit(0);
34
48
  });
49
+ function allow() {
50
+ console.log(JSON.stringify({
51
+ hookSpecificOutput: {
52
+ hookEventName: "PreToolUse",
53
+ permissionDecision: "allow"
54
+ }
55
+ }));
56
+ process.exit(0);
57
+ }
58
+ function deny(reason) {
59
+ process.stderr.write(reason);
60
+ process.exit(2);
61
+ }