@sna-sdk/core 0.9.9 → 0.9.10

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.
@@ -1,9 +1,40 @@
1
1
  import { AgentProvider, SpawnOptions, AgentProcess } from './types.js';
2
2
 
3
+ /**
4
+ * Parse `command -v claude` output to extract the executable path.
5
+ * Handles: direct paths, alias with/without quotes, bare command names.
6
+ * @internal Exported for testing only.
7
+ */
8
+ declare function parseCommandVOutput(raw: string): string;
9
+ /**
10
+ * Validate a Claude CLI path by running `<path> --version`.
11
+ * Adds the binary's directory to PATH so shebang resolution works (nvm/fnm).
12
+ */
13
+ declare function validateClaudePath(claudePath: string): {
14
+ ok: boolean;
15
+ version?: string;
16
+ };
17
+ /**
18
+ * Save a validated Claude path to cache for faster startup next time.
19
+ */
20
+ declare function cacheClaudePath(claudePath: string, cacheDir?: string): void;
21
+ interface ResolveResult {
22
+ path: string;
23
+ version?: string;
24
+ source: "env" | "cache" | "static" | "shell" | "fallback";
25
+ }
26
+ /**
27
+ * Resolve Claude CLI path. Tries: env override → cache → static paths → shell detection.
28
+ * All candidates are validated with `--version` before returning.
29
+ * Consumer apps should call this and handle the `fallback` source (= not found).
30
+ */
31
+ declare function resolveClaudeCli(opts?: {
32
+ cacheDir?: string;
33
+ }): ResolveResult;
3
34
  declare class ClaudeCodeProvider implements AgentProvider {
4
35
  readonly name = "claude-code";
5
36
  isAvailable(): Promise<boolean>;
6
37
  spawn(options: SpawnOptions): AgentProcess;
7
38
  }
8
39
 
9
- export { ClaudeCodeProvider };
40
+ export { ClaudeCodeProvider, type ResolveResult, cacheClaudePath, parseCommandVOutput, resolveClaudeCli, validateClaudePath };
@@ -6,38 +6,80 @@ import { fileURLToPath } from "url";
6
6
  import { writeHistoryJsonl, buildRecalledConversation } from "./cc-history-adapter.js";
7
7
  import { logger } from "../../lib/logger.js";
8
8
  const SHELL = process.env.SHELL || "/bin/zsh";
9
- function resolveClaudePath(cwd) {
10
- if (process.env.SNA_CLAUDE_COMMAND) return process.env.SNA_CLAUDE_COMMAND;
11
- const cached = path.join(cwd, ".sna/claude-path");
12
- if (fs.existsSync(cached)) {
13
- const p = fs.readFileSync(cached, "utf8").trim();
14
- if (p) {
15
- try {
16
- execSync(`test -x "${p}"`, { stdio: "pipe" });
17
- return p;
18
- } catch {
19
- }
9
+ function parseCommandVOutput(raw) {
10
+ const trimmed = raw.trim();
11
+ if (!trimmed) return "claude";
12
+ const aliasMatch = trimmed.match(/=\s*['"]?([^'"]+?)['"]?\s*$/);
13
+ if (aliasMatch) return aliasMatch[1];
14
+ const pathMatch = trimmed.match(/^(\/\S+)/m);
15
+ if (pathMatch) return pathMatch[1];
16
+ return trimmed;
17
+ }
18
+ function validateClaudePath(claudePath) {
19
+ try {
20
+ const claudeDir = path.dirname(claudePath);
21
+ const env = { ...process.env, PATH: `${claudeDir}:${process.env.PATH ?? ""}` };
22
+ const out = execSync(`"${claudePath}" --version`, { encoding: "utf8", stdio: "pipe", timeout: 1e4, env }).trim();
23
+ return { ok: true, version: out.split("\n")[0].slice(0, 30) };
24
+ } catch {
25
+ return { ok: false };
26
+ }
27
+ }
28
+ function cacheClaudePath(claudePath, cacheDir) {
29
+ const dir = cacheDir ?? path.join(process.cwd(), ".sna");
30
+ try {
31
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
32
+ fs.writeFileSync(path.join(dir, "claude-path"), claudePath);
33
+ } catch {
34
+ }
35
+ }
36
+ function resolveClaudeCli(opts) {
37
+ const cacheDir = opts?.cacheDir;
38
+ if (process.env.SNA_CLAUDE_COMMAND) {
39
+ const v = validateClaudePath(process.env.SNA_CLAUDE_COMMAND);
40
+ return { path: process.env.SNA_CLAUDE_COMMAND, version: v.version, source: "env" };
41
+ }
42
+ const cacheFile = cacheDir ? path.join(cacheDir, "claude-path") : path.join(process.cwd(), ".sna/claude-path");
43
+ try {
44
+ const cached = fs.readFileSync(cacheFile, "utf8").trim();
45
+ if (cached) {
46
+ const v = validateClaudePath(cached);
47
+ if (v.ok) return { path: cached, version: v.version, source: "cache" };
20
48
  }
49
+ } catch {
21
50
  }
22
- for (const p of [
51
+ const staticPaths = [
23
52
  "/opt/homebrew/bin/claude",
24
53
  "/usr/local/bin/claude",
25
54
  `${process.env.HOME}/.local/bin/claude`,
26
- `${process.env.HOME}/.claude/bin/claude`
27
- ]) {
28
- try {
29
- execSync(`test -x "${p}"`, { stdio: "pipe" });
30
- return p;
31
- } catch {
55
+ `${process.env.HOME}/.claude/bin/claude`,
56
+ `${process.env.HOME}/.volta/bin/claude`
57
+ ];
58
+ for (const p of staticPaths) {
59
+ const v = validateClaudePath(p);
60
+ if (v.ok) {
61
+ cacheClaudePath(p, cacheDir);
62
+ return { path: p, version: v.version, source: "static" };
32
63
  }
33
64
  }
34
65
  try {
35
66
  const raw = execSync(`${SHELL} -i -l -c "command -v claude" 2>/dev/null`, { encoding: "utf8", timeout: 5e3 }).trim();
36
- const match = raw.match(/=(.+)/) ?? raw.match(/^(\/\S+)/m);
37
- return match ? match[1] : raw;
67
+ const resolved = parseCommandVOutput(raw);
68
+ if (resolved && resolved !== "claude") {
69
+ const v = validateClaudePath(resolved);
70
+ if (v.ok) {
71
+ cacheClaudePath(resolved, cacheDir);
72
+ return { path: resolved, version: v.version, source: "shell" };
73
+ }
74
+ }
38
75
  } catch {
39
- return "claude";
40
76
  }
77
+ return { path: "claude", source: "fallback" };
78
+ }
79
+ function resolveClaudePath(cwd) {
80
+ const result = resolveClaudeCli({ cacheDir: path.join(cwd, ".sna") });
81
+ logger.log("agent", `claude path: ${result.source}=${result.path}${result.version ? ` (${result.version})` : ""}`);
82
+ return result.path;
41
83
  }
42
84
  const _ClaudeCodeProcess = class _ClaudeCodeProcess {
43
85
  constructor(proc, options) {
@@ -473,6 +515,10 @@ class ClaudeCodeProvider {
473
515
  delete cleanEnv.CLAUDE_CODE_ENTRYPOINT;
474
516
  delete cleanEnv.CLAUDE_CODE_SESSION_ACCESS_TOKEN;
475
517
  delete cleanEnv.CLAUDE_CODE_OAUTH_TOKEN;
518
+ const claudeDir = path.dirname(claudePath);
519
+ if (claudeDir && claudeDir !== ".") {
520
+ cleanEnv.PATH = `${claudeDir}:${cleanEnv.PATH ?? ""}`;
521
+ }
476
522
  const proc = spawn(claudePath, [...claudePrefix, ...args], {
477
523
  cwd: options.cwd,
478
524
  env: cleanEnv,
@@ -483,5 +529,9 @@ class ClaudeCodeProvider {
483
529
  }
484
530
  }
485
531
  export {
486
- ClaudeCodeProvider
532
+ ClaudeCodeProvider,
533
+ cacheClaudePath,
534
+ parseCommandVOutput,
535
+ resolveClaudeCli,
536
+ validateClaudePath
487
537
  };
@@ -30,7 +30,11 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
30
30
  // src/electron/index.ts
31
31
  var electron_exports = {};
32
32
  __export(electron_exports, {
33
- startSnaServer: () => startSnaServer
33
+ cacheClaudePath: () => cacheClaudePath,
34
+ parseCommandVOutput: () => parseCommandVOutput,
35
+ resolveClaudeCli: () => resolveClaudeCli,
36
+ startSnaServer: () => startSnaServer,
37
+ validateClaudePath: () => validateClaudePath
34
38
  });
35
39
  module.exports = __toCommonJS(electron_exports);
36
40
 
@@ -39,17 +43,490 @@ var getImportMetaUrl = () => typeof document === "undefined" ? new URL(`file:${_
39
43
  var importMetaUrl = /* @__PURE__ */ getImportMetaUrl();
40
44
 
41
45
  // src/electron/index.ts
42
- var import_child_process = require("child_process");
46
+ var import_child_process2 = require("child_process");
43
47
  var import_url = require("url");
48
+ var import_fs3 = __toESM(require("fs"), 1);
49
+
50
+ // src/core/providers/claude-code.ts
51
+ var import_child_process = require("child_process");
52
+ var import_events = require("events");
53
+ var import_fs2 = __toESM(require("fs"), 1);
54
+ var import_path2 = __toESM(require("path"), 1);
55
+
56
+ // src/core/providers/cc-history-adapter.ts
57
+ function buildRecalledConversation(history) {
58
+ const xml = history.map((msg) => `<${msg.role}>${msg.content}</${msg.role}>`).join("\n");
59
+ return JSON.stringify({
60
+ type: "assistant",
61
+ message: {
62
+ role: "assistant",
63
+ content: [{ type: "text", text: `<recalled-conversation>
64
+ ${xml}
65
+ </recalled-conversation>` }]
66
+ }
67
+ });
68
+ }
69
+
70
+ // src/lib/logger.ts
44
71
  var import_fs = __toESM(require("fs"), 1);
45
72
  var import_path = __toESM(require("path"), 1);
73
+ var LOG_PATH = import_path.default.join(process.cwd(), ".dev.log");
74
+ try {
75
+ import_fs.default.writeFileSync(LOG_PATH, "");
76
+ } catch {
77
+ }
78
+ function ts() {
79
+ return (/* @__PURE__ */ new Date()).toLocaleTimeString("en-US", { hour12: false, hour: "2-digit", minute: "2-digit", second: "2-digit" });
80
+ }
81
+ var tags = {
82
+ sna: " SNA ",
83
+ req: " REQ ",
84
+ agent: " AGT ",
85
+ stdin: " IN ",
86
+ stdout: " OUT ",
87
+ route: " API ",
88
+ ws: " WS ",
89
+ err: " ERR "
90
+ };
91
+ function appendFile(tag, args) {
92
+ const line = `${ts()} ${tag} ${args.map((a) => typeof a === "string" ? a : JSON.stringify(a)).join(" ")}
93
+ `;
94
+ import_fs.default.appendFile(LOG_PATH, line, () => {
95
+ });
96
+ }
97
+ function log(tag, ...args) {
98
+ console.log(`${ts()} ${tags[tag] ?? tag}`, ...args);
99
+ appendFile(tags[tag] ?? tag, args);
100
+ }
101
+ function err(tag, ...args) {
102
+ console.error(`${ts()} ${tags[tag] ?? tag}`, ...args);
103
+ appendFile(tags[tag] ?? tag, args);
104
+ }
105
+ var logger = { log, err };
106
+
107
+ // src/core/providers/claude-code.ts
108
+ var SHELL = process.env.SHELL || "/bin/zsh";
109
+ function parseCommandVOutput(raw) {
110
+ const trimmed = raw.trim();
111
+ if (!trimmed) return "claude";
112
+ const aliasMatch = trimmed.match(/=\s*['"]?([^'"]+?)['"]?\s*$/);
113
+ if (aliasMatch) return aliasMatch[1];
114
+ const pathMatch = trimmed.match(/^(\/\S+)/m);
115
+ if (pathMatch) return pathMatch[1];
116
+ return trimmed;
117
+ }
118
+ function validateClaudePath(claudePath) {
119
+ try {
120
+ const claudeDir = import_path2.default.dirname(claudePath);
121
+ const env = { ...process.env, PATH: `${claudeDir}:${process.env.PATH ?? ""}` };
122
+ const out = (0, import_child_process.execSync)(`"${claudePath}" --version`, { encoding: "utf8", stdio: "pipe", timeout: 1e4, env }).trim();
123
+ return { ok: true, version: out.split("\n")[0].slice(0, 30) };
124
+ } catch {
125
+ return { ok: false };
126
+ }
127
+ }
128
+ function cacheClaudePath(claudePath, cacheDir) {
129
+ const dir = cacheDir ?? import_path2.default.join(process.cwd(), ".sna");
130
+ try {
131
+ if (!import_fs2.default.existsSync(dir)) import_fs2.default.mkdirSync(dir, { recursive: true });
132
+ import_fs2.default.writeFileSync(import_path2.default.join(dir, "claude-path"), claudePath);
133
+ } catch {
134
+ }
135
+ }
136
+ function resolveClaudeCli(opts) {
137
+ const cacheDir = opts?.cacheDir;
138
+ if (process.env.SNA_CLAUDE_COMMAND) {
139
+ const v = validateClaudePath(process.env.SNA_CLAUDE_COMMAND);
140
+ return { path: process.env.SNA_CLAUDE_COMMAND, version: v.version, source: "env" };
141
+ }
142
+ const cacheFile = cacheDir ? import_path2.default.join(cacheDir, "claude-path") : import_path2.default.join(process.cwd(), ".sna/claude-path");
143
+ try {
144
+ const cached = import_fs2.default.readFileSync(cacheFile, "utf8").trim();
145
+ if (cached) {
146
+ const v = validateClaudePath(cached);
147
+ if (v.ok) return { path: cached, version: v.version, source: "cache" };
148
+ }
149
+ } catch {
150
+ }
151
+ const staticPaths = [
152
+ "/opt/homebrew/bin/claude",
153
+ "/usr/local/bin/claude",
154
+ `${process.env.HOME}/.local/bin/claude`,
155
+ `${process.env.HOME}/.claude/bin/claude`,
156
+ `${process.env.HOME}/.volta/bin/claude`
157
+ ];
158
+ for (const p of staticPaths) {
159
+ const v = validateClaudePath(p);
160
+ if (v.ok) {
161
+ cacheClaudePath(p, cacheDir);
162
+ return { path: p, version: v.version, source: "static" };
163
+ }
164
+ }
165
+ try {
166
+ const raw = (0, import_child_process.execSync)(`${SHELL} -i -l -c "command -v claude" 2>/dev/null`, { encoding: "utf8", timeout: 5e3 }).trim();
167
+ const resolved = parseCommandVOutput(raw);
168
+ if (resolved && resolved !== "claude") {
169
+ const v = validateClaudePath(resolved);
170
+ if (v.ok) {
171
+ cacheClaudePath(resolved, cacheDir);
172
+ return { path: resolved, version: v.version, source: "shell" };
173
+ }
174
+ }
175
+ } catch {
176
+ }
177
+ return { path: "claude", source: "fallback" };
178
+ }
179
+ var _ClaudeCodeProcess = class _ClaudeCodeProcess {
180
+ constructor(proc, options) {
181
+ this.emitter = new import_events.EventEmitter();
182
+ this._alive = true;
183
+ this._sessionId = null;
184
+ this._initEmitted = false;
185
+ this.buffer = "";
186
+ /** True once we receive a real text_delta stream_event this turn */
187
+ this._receivedStreamEvents = false;
188
+ /** tool_use IDs already emitted via stream_event (to update instead of re-create in assistant block) */
189
+ this._streamedToolUseIds = /* @__PURE__ */ new Set();
190
+ /**
191
+ * FIFO event queue — ALL events (deltas, assistant, complete, etc.) go through
192
+ * this queue. A fixed-interval timer drains one item at a time, guaranteeing
193
+ * strict ordering: deltas → assistant → complete, never out of order.
194
+ */
195
+ this.eventQueue = [];
196
+ this.drainTimer = null;
197
+ this.proc = proc;
198
+ proc.stdout.on("data", (chunk) => {
199
+ this.buffer += chunk.toString();
200
+ const lines = this.buffer.split("\n");
201
+ this.buffer = lines.pop() ?? "";
202
+ for (const line of lines) {
203
+ if (!line.trim()) continue;
204
+ logger.log("stdout", line);
205
+ try {
206
+ const msg = JSON.parse(line);
207
+ if (msg.session_id && !this._sessionId) {
208
+ this._sessionId = msg.session_id;
209
+ }
210
+ const event = this.normalizeEvent(msg);
211
+ if (event) this.enqueue(event);
212
+ } catch {
213
+ }
214
+ }
215
+ });
216
+ proc.stderr.on("data", () => {
217
+ });
218
+ proc.on("exit", (code) => {
219
+ this._alive = false;
220
+ if (this.buffer.trim()) {
221
+ try {
222
+ const msg = JSON.parse(this.buffer);
223
+ const event = this.normalizeEvent(msg);
224
+ if (event) this.enqueue(event);
225
+ } catch {
226
+ }
227
+ }
228
+ this.flushQueue();
229
+ this.emitter.emit("exit", code);
230
+ logger.log("agent", `process exited (code=${code})`);
231
+ });
232
+ proc.on("error", (err2) => {
233
+ this._alive = false;
234
+ this.emitter.emit("error", err2);
235
+ });
236
+ if (options.history?.length && !options._historyViaResume) {
237
+ const line = buildRecalledConversation(options.history);
238
+ this.proc.stdin.write(line + "\n");
239
+ }
240
+ if (options.prompt) {
241
+ this.send(options.prompt);
242
+ }
243
+ }
244
+ // ~67 events/sec
245
+ /**
246
+ * Enqueue an event for ordered emission.
247
+ * Starts the drain timer if not already running.
248
+ */
249
+ enqueue(event) {
250
+ this.eventQueue.push(event);
251
+ if (!this.drainTimer) {
252
+ this.drainTimer = setInterval(() => this.drainOne(), _ClaudeCodeProcess.DRAIN_INTERVAL_MS);
253
+ }
254
+ }
255
+ /** Emit one event from the front of the queue. Stop timer when empty. */
256
+ drainOne() {
257
+ const event = this.eventQueue.shift();
258
+ if (event) {
259
+ this.emitter.emit("event", event);
260
+ }
261
+ if (this.eventQueue.length === 0 && this.drainTimer) {
262
+ clearInterval(this.drainTimer);
263
+ this.drainTimer = null;
264
+ }
265
+ }
266
+ /** Flush all remaining queued events immediately (used on process exit). */
267
+ flushQueue() {
268
+ if (this.drainTimer) {
269
+ clearInterval(this.drainTimer);
270
+ this.drainTimer = null;
271
+ }
272
+ while (this.eventQueue.length > 0) {
273
+ this.emitter.emit("event", this.eventQueue.shift());
274
+ }
275
+ }
276
+ /**
277
+ * Split completed assistant text into delta chunks and enqueue them,
278
+ * followed by the final assistant event. All go through the FIFO queue
279
+ * so subsequent events (complete, etc.) are guaranteed to come after.
280
+ */
281
+ enqueueTextAsDeltas(text) {
282
+ const CHUNK_SIZE = 4;
283
+ for (let i = 0; i < text.length; i += CHUNK_SIZE) {
284
+ this.enqueue({
285
+ type: "assistant_delta",
286
+ delta: text.slice(i, i + CHUNK_SIZE),
287
+ index: 0,
288
+ timestamp: Date.now()
289
+ });
290
+ }
291
+ this.enqueue({
292
+ type: "assistant",
293
+ message: text,
294
+ timestamp: Date.now()
295
+ });
296
+ }
297
+ get alive() {
298
+ return this._alive;
299
+ }
300
+ get pid() {
301
+ return this.proc.pid ?? null;
302
+ }
303
+ get sessionId() {
304
+ return this._sessionId;
305
+ }
306
+ /**
307
+ * Send a user message to the persistent Claude process via stdin.
308
+ * Accepts plain string or content block array (text + images).
309
+ */
310
+ send(input) {
311
+ if (!this._alive || !this.proc.stdin.writable) return;
312
+ const content = typeof input === "string" ? input : input;
313
+ const msg = JSON.stringify({
314
+ type: "user",
315
+ message: { role: "user", content }
316
+ });
317
+ logger.log("stdin", msg.slice(0, 200));
318
+ this.proc.stdin.write(msg + "\n");
319
+ }
320
+ interrupt() {
321
+ if (!this._alive || !this.proc.stdin.writable) return;
322
+ const msg = JSON.stringify({
323
+ type: "control_request",
324
+ request: { subtype: "interrupt" }
325
+ });
326
+ this.proc.stdin.write(msg + "\n");
327
+ }
328
+ setModel(model) {
329
+ if (!this._alive || !this.proc.stdin.writable) return;
330
+ const msg = JSON.stringify({
331
+ type: "control_request",
332
+ request: { subtype: "set_model", model }
333
+ });
334
+ this.proc.stdin.write(msg + "\n");
335
+ }
336
+ setPermissionMode(mode) {
337
+ if (!this._alive || !this.proc.stdin.writable) return;
338
+ const msg = JSON.stringify({
339
+ type: "control_request",
340
+ request: { subtype: "set_permission_mode", permission_mode: mode }
341
+ });
342
+ this.proc.stdin.write(msg + "\n");
343
+ }
344
+ kill() {
345
+ if (this._alive) {
346
+ this._alive = false;
347
+ this.proc.kill("SIGTERM");
348
+ }
349
+ }
350
+ on(event, handler) {
351
+ this.emitter.on(event, handler);
352
+ }
353
+ off(event, handler) {
354
+ this.emitter.off(event, handler);
355
+ }
356
+ normalizeEvent(msg) {
357
+ switch (msg.type) {
358
+ case "system": {
359
+ if (msg.subtype === "init") {
360
+ if (this._initEmitted) return null;
361
+ this._initEmitted = true;
362
+ return {
363
+ type: "init",
364
+ message: `Agent ready (${msg.model ?? "unknown"})`,
365
+ data: { sessionId: msg.session_id, model: msg.model },
366
+ timestamp: Date.now()
367
+ };
368
+ }
369
+ return null;
370
+ }
371
+ case "stream_event": {
372
+ const inner = msg.event;
373
+ if (!inner) return null;
374
+ if (inner.type === "content_block_start" && inner.content_block?.type === "tool_use") {
375
+ const block = inner.content_block;
376
+ this._receivedStreamEvents = true;
377
+ this._streamedToolUseIds.add(block.id);
378
+ return {
379
+ type: "tool_use",
380
+ message: block.name,
381
+ data: { toolName: block.name, id: block.id, input: null, streaming: true },
382
+ timestamp: Date.now()
383
+ };
384
+ }
385
+ if (inner.type === "content_block_delta") {
386
+ const delta = inner.delta;
387
+ if (delta?.type === "text_delta" && delta.text) {
388
+ this._receivedStreamEvents = true;
389
+ return {
390
+ type: "assistant_delta",
391
+ delta: delta.text,
392
+ index: inner.index ?? 0,
393
+ timestamp: Date.now()
394
+ };
395
+ }
396
+ if (delta?.type === "thinking_delta" && delta.thinking) {
397
+ return {
398
+ type: "thinking_delta",
399
+ message: delta.thinking,
400
+ timestamp: Date.now()
401
+ };
402
+ }
403
+ }
404
+ return null;
405
+ }
406
+ case "assistant": {
407
+ if (this._receivedStreamEvents && msg.message?.stop_reason === null) return null;
408
+ const content = msg.message?.content;
409
+ if (!Array.isArray(content)) return null;
410
+ const events = [];
411
+ const textBlocks = [];
412
+ for (const block of content) {
413
+ if (block.type === "thinking") {
414
+ events.push({
415
+ type: "thinking",
416
+ message: block.thinking ?? "",
417
+ timestamp: Date.now()
418
+ });
419
+ } else if (block.type === "tool_use") {
420
+ const alreadyStreamed = this._streamedToolUseIds.has(block.id);
421
+ if (alreadyStreamed) this._streamedToolUseIds.delete(block.id);
422
+ events.push({
423
+ type: "tool_use",
424
+ message: block.name,
425
+ data: { toolName: block.name, input: block.input, id: block.id, update: alreadyStreamed },
426
+ timestamp: Date.now()
427
+ });
428
+ } else if (block.type === "text") {
429
+ const text = (block.text ?? "").trim();
430
+ if (text) {
431
+ textBlocks.push(text);
432
+ }
433
+ }
434
+ }
435
+ if (events.length > 0 || textBlocks.length > 0) {
436
+ for (const e of events) {
437
+ this.enqueue(e);
438
+ }
439
+ for (const text of textBlocks) {
440
+ this.enqueue({ type: "assistant", message: text, timestamp: Date.now() });
441
+ }
442
+ }
443
+ return null;
444
+ }
445
+ case "user": {
446
+ const userContent = msg.message?.content;
447
+ if (!Array.isArray(userContent)) return null;
448
+ for (const block of userContent) {
449
+ if (block.type === "tool_result") {
450
+ return {
451
+ type: "tool_result",
452
+ message: typeof block.content === "string" ? block.content.slice(0, 300) : JSON.stringify(block.content).slice(0, 300),
453
+ data: { toolUseId: block.tool_use_id, isError: block.is_error },
454
+ timestamp: Date.now()
455
+ };
456
+ }
457
+ }
458
+ return null;
459
+ }
460
+ case "result": {
461
+ if (msg.subtype === "success") {
462
+ if (this._receivedStreamEvents && msg.result) {
463
+ this.enqueue({
464
+ type: "assistant",
465
+ message: msg.result,
466
+ timestamp: Date.now()
467
+ });
468
+ this._receivedStreamEvents = false;
469
+ this._streamedToolUseIds.clear();
470
+ }
471
+ const u = msg.usage ?? {};
472
+ const mu = msg.modelUsage ?? {};
473
+ const modelKey = Object.keys(mu)[0] ?? "";
474
+ const modelInfo = mu[modelKey] ?? {};
475
+ return {
476
+ type: "complete",
477
+ message: msg.result ?? "Done",
478
+ data: {
479
+ durationMs: msg.duration_ms,
480
+ costUsd: msg.total_cost_usd,
481
+ // Per-turn: actual context window usage this turn
482
+ inputTokens: u.input_tokens ?? 0,
483
+ outputTokens: u.output_tokens ?? 0,
484
+ cacheReadTokens: u.cache_read_input_tokens ?? 0,
485
+ cacheWriteTokens: u.cache_creation_input_tokens ?? 0,
486
+ // Static model info
487
+ contextWindow: modelInfo.contextWindow ?? 0,
488
+ model: modelKey
489
+ },
490
+ timestamp: Date.now()
491
+ };
492
+ }
493
+ if (msg.subtype === "error_during_execution" && msg.is_error === false) {
494
+ return {
495
+ type: "interrupted",
496
+ message: "Turn interrupted by user",
497
+ data: { durationMs: msg.duration_ms, costUsd: msg.total_cost_usd },
498
+ timestamp: Date.now()
499
+ };
500
+ }
501
+ if (msg.subtype?.startsWith("error") || msg.is_error) {
502
+ return {
503
+ type: "error",
504
+ message: msg.result ?? msg.error ?? "Unknown error",
505
+ timestamp: Date.now()
506
+ };
507
+ }
508
+ return null;
509
+ }
510
+ case "rate_limit_event":
511
+ return null;
512
+ default:
513
+ logger.log("agent", `unhandled event: ${msg.type}`, JSON.stringify(msg).substring(0, 200));
514
+ return null;
515
+ }
516
+ }
517
+ };
518
+ _ClaudeCodeProcess.DRAIN_INTERVAL_MS = 15;
519
+ var ClaudeCodeProcess = _ClaudeCodeProcess;
520
+
521
+ // src/electron/index.ts
522
+ var import_path3 = __toESM(require("path"), 1);
46
523
  function resolveStandaloneScript() {
47
524
  const selfPath = (0, import_url.fileURLToPath)(importMetaUrl);
48
- let script = import_path.default.resolve(import_path.default.dirname(selfPath), "../server/standalone.js");
525
+ let script = import_path3.default.resolve(import_path3.default.dirname(selfPath), "../server/standalone.js");
49
526
  if (script.includes(".asar") && !script.includes(".asar.unpacked")) {
50
527
  script = script.replace(/(\.asar)([/\\])/, ".asar.unpacked$2");
51
528
  }
52
- if (!import_fs.default.existsSync(script)) {
529
+ if (!import_fs3.default.existsSync(script)) {
53
530
  throw new Error(
54
531
  `SNA standalone script not found: ${script}
55
532
  Ensure "@sna-sdk/core" is listed in asarUnpack in your electron-builder config.`
@@ -60,14 +537,14 @@ Ensure "@sna-sdk/core" is listed in asarUnpack in your electron-builder config.`
60
537
  function buildNodePath() {
61
538
  const resourcesPath = process.resourcesPath;
62
539
  if (!resourcesPath) return void 0;
63
- const unpacked = import_path.default.join(resourcesPath, "app.asar.unpacked", "node_modules");
64
- if (!import_fs.default.existsSync(unpacked)) return void 0;
540
+ const unpacked = import_path3.default.join(resourcesPath, "app.asar.unpacked", "node_modules");
541
+ if (!import_fs3.default.existsSync(unpacked)) return void 0;
65
542
  const existing = process.env.NODE_PATH;
66
- return existing ? `${unpacked}${import_path.default.delimiter}${existing}` : unpacked;
543
+ return existing ? `${unpacked}${import_path3.default.delimiter}${existing}` : unpacked;
67
544
  }
68
545
  async function startSnaServer(options) {
69
546
  const port = options.port ?? 3099;
70
- const cwd = options.cwd ?? import_path.default.dirname(options.dbPath);
547
+ const cwd = options.cwd ?? import_path3.default.dirname(options.dbPath);
71
548
  const readyTimeout = options.readyTimeout ?? 15e3;
72
549
  const { onLog } = options;
73
550
  const standaloneScript = resolveStandaloneScript();
@@ -75,7 +552,7 @@ async function startSnaServer(options) {
75
552
  let consumerModules;
76
553
  try {
77
554
  const bsPkg = require.resolve("better-sqlite3/package.json", { paths: [process.cwd()] });
78
- consumerModules = import_path.default.resolve(bsPkg, "../..");
555
+ consumerModules = import_path3.default.resolve(bsPkg, "../..");
79
556
  } catch {
80
557
  }
81
558
  const env = {
@@ -92,7 +569,7 @@ async function startSnaServer(options) {
92
569
  // Consumer overrides last so they can always win
93
570
  ...options.env ?? {}
94
571
  };
95
- const proc = (0, import_child_process.fork)(standaloneScript, [], {
572
+ const proc = (0, import_child_process2.fork)(standaloneScript, [], {
96
573
  cwd,
97
574
  env,
98
575
  stdio: "pipe"
@@ -132,10 +609,10 @@ async function startSnaServer(options) {
132
609
  reject(new Error(`SNA server process exited (code=${code ?? "null"}) before becoming ready`));
133
610
  }
134
611
  });
135
- proc.on("error", (err) => {
612
+ proc.on("error", (err2) => {
136
613
  if (!isReady) {
137
614
  clearTimeout(timer);
138
- reject(err);
615
+ reject(err2);
139
616
  }
140
617
  });
141
618
  });
@@ -149,5 +626,9 @@ async function startSnaServer(options) {
149
626
  }
150
627
  // Annotate the CommonJS export names for ESM import in node:
151
628
  0 && (module.exports = {
152
- startSnaServer
629
+ cacheClaudePath,
630
+ parseCommandVOutput,
631
+ resolveClaudeCli,
632
+ startSnaServer,
633
+ validateClaudePath
153
634
  });
@@ -1,4 +1,6 @@
1
1
  import { ChildProcess } from 'child_process';
2
+ export { ResolveResult, cacheClaudePath, parseCommandVOutput, resolveClaudeCli, validateClaudePath } from '../core/providers/claude-code.js';
3
+ import '../core/providers/types.js';
2
4
 
3
5
  /**
4
6
  * @sna-sdk/core/electron — Electron launcher API
@@ -1,6 +1,7 @@
1
1
  import { fork } from "child_process";
2
2
  import { fileURLToPath } from "url";
3
3
  import fs from "fs";
4
+ import { resolveClaudeCli, validateClaudePath, cacheClaudePath, parseCommandVOutput } from "../core/providers/claude-code.js";
4
5
  import path from "path";
5
6
  function resolveStandaloneScript() {
6
7
  const selfPath = fileURLToPath(import.meta.url);
@@ -107,5 +108,9 @@ async function startSnaServer(options) {
107
108
  };
108
109
  }
109
110
  export {
110
- startSnaServer
111
+ cacheClaudePath,
112
+ parseCommandVOutput,
113
+ resolveClaudeCli,
114
+ startSnaServer,
115
+ validateClaudePath
111
116
  };
@@ -41,15 +41,415 @@ var importMetaUrl = /* @__PURE__ */ getImportMetaUrl();
41
41
  // src/electron/index.ts
42
42
  var import_child_process = require("child_process");
43
43
  var import_url = require("url");
44
+ var import_fs2 = __toESM(require("fs"), 1);
45
+
46
+ // src/core/providers/claude-code.ts
47
+ var import_events = require("events");
48
+
49
+ // src/core/providers/cc-history-adapter.ts
50
+ function buildRecalledConversation(history) {
51
+ const xml = history.map((msg) => `<${msg.role}>${msg.content}</${msg.role}>`).join("\n");
52
+ return JSON.stringify({
53
+ type: "assistant",
54
+ message: {
55
+ role: "assistant",
56
+ content: [{ type: "text", text: `<recalled-conversation>
57
+ ${xml}
58
+ </recalled-conversation>` }]
59
+ }
60
+ });
61
+ }
62
+
63
+ // src/lib/logger.ts
44
64
  var import_fs = __toESM(require("fs"), 1);
45
65
  var import_path = __toESM(require("path"), 1);
66
+ var LOG_PATH = import_path.default.join(process.cwd(), ".dev.log");
67
+ try {
68
+ import_fs.default.writeFileSync(LOG_PATH, "");
69
+ } catch {
70
+ }
71
+ function ts() {
72
+ return (/* @__PURE__ */ new Date()).toLocaleTimeString("en-US", { hour12: false, hour: "2-digit", minute: "2-digit", second: "2-digit" });
73
+ }
74
+ var tags = {
75
+ sna: " SNA ",
76
+ req: " REQ ",
77
+ agent: " AGT ",
78
+ stdin: " IN ",
79
+ stdout: " OUT ",
80
+ route: " API ",
81
+ ws: " WS ",
82
+ err: " ERR "
83
+ };
84
+ function appendFile(tag, args) {
85
+ const line = `${ts()} ${tag} ${args.map((a) => typeof a === "string" ? a : JSON.stringify(a)).join(" ")}
86
+ `;
87
+ import_fs.default.appendFile(LOG_PATH, line, () => {
88
+ });
89
+ }
90
+ function log(tag, ...args) {
91
+ console.log(`${ts()} ${tags[tag] ?? tag}`, ...args);
92
+ appendFile(tags[tag] ?? tag, args);
93
+ }
94
+ function err(tag, ...args) {
95
+ console.error(`${ts()} ${tags[tag] ?? tag}`, ...args);
96
+ appendFile(tags[tag] ?? tag, args);
97
+ }
98
+ var logger = { log, err };
99
+
100
+ // src/core/providers/claude-code.ts
101
+ var SHELL = process.env.SHELL || "/bin/zsh";
102
+ var _ClaudeCodeProcess = class _ClaudeCodeProcess {
103
+ constructor(proc, options) {
104
+ this.emitter = new import_events.EventEmitter();
105
+ this._alive = true;
106
+ this._sessionId = null;
107
+ this._initEmitted = false;
108
+ this.buffer = "";
109
+ /** True once we receive a real text_delta stream_event this turn */
110
+ this._receivedStreamEvents = false;
111
+ /** tool_use IDs already emitted via stream_event (to update instead of re-create in assistant block) */
112
+ this._streamedToolUseIds = /* @__PURE__ */ new Set();
113
+ /**
114
+ * FIFO event queue — ALL events (deltas, assistant, complete, etc.) go through
115
+ * this queue. A fixed-interval timer drains one item at a time, guaranteeing
116
+ * strict ordering: deltas → assistant → complete, never out of order.
117
+ */
118
+ this.eventQueue = [];
119
+ this.drainTimer = null;
120
+ this.proc = proc;
121
+ proc.stdout.on("data", (chunk) => {
122
+ this.buffer += chunk.toString();
123
+ const lines = this.buffer.split("\n");
124
+ this.buffer = lines.pop() ?? "";
125
+ for (const line of lines) {
126
+ if (!line.trim()) continue;
127
+ logger.log("stdout", line);
128
+ try {
129
+ const msg = JSON.parse(line);
130
+ if (msg.session_id && !this._sessionId) {
131
+ this._sessionId = msg.session_id;
132
+ }
133
+ const event = this.normalizeEvent(msg);
134
+ if (event) this.enqueue(event);
135
+ } catch {
136
+ }
137
+ }
138
+ });
139
+ proc.stderr.on("data", () => {
140
+ });
141
+ proc.on("exit", (code) => {
142
+ this._alive = false;
143
+ if (this.buffer.trim()) {
144
+ try {
145
+ const msg = JSON.parse(this.buffer);
146
+ const event = this.normalizeEvent(msg);
147
+ if (event) this.enqueue(event);
148
+ } catch {
149
+ }
150
+ }
151
+ this.flushQueue();
152
+ this.emitter.emit("exit", code);
153
+ logger.log("agent", `process exited (code=${code})`);
154
+ });
155
+ proc.on("error", (err2) => {
156
+ this._alive = false;
157
+ this.emitter.emit("error", err2);
158
+ });
159
+ if (options.history?.length && !options._historyViaResume) {
160
+ const line = buildRecalledConversation(options.history);
161
+ this.proc.stdin.write(line + "\n");
162
+ }
163
+ if (options.prompt) {
164
+ this.send(options.prompt);
165
+ }
166
+ }
167
+ // ~67 events/sec
168
+ /**
169
+ * Enqueue an event for ordered emission.
170
+ * Starts the drain timer if not already running.
171
+ */
172
+ enqueue(event) {
173
+ this.eventQueue.push(event);
174
+ if (!this.drainTimer) {
175
+ this.drainTimer = setInterval(() => this.drainOne(), _ClaudeCodeProcess.DRAIN_INTERVAL_MS);
176
+ }
177
+ }
178
+ /** Emit one event from the front of the queue. Stop timer when empty. */
179
+ drainOne() {
180
+ const event = this.eventQueue.shift();
181
+ if (event) {
182
+ this.emitter.emit("event", event);
183
+ }
184
+ if (this.eventQueue.length === 0 && this.drainTimer) {
185
+ clearInterval(this.drainTimer);
186
+ this.drainTimer = null;
187
+ }
188
+ }
189
+ /** Flush all remaining queued events immediately (used on process exit). */
190
+ flushQueue() {
191
+ if (this.drainTimer) {
192
+ clearInterval(this.drainTimer);
193
+ this.drainTimer = null;
194
+ }
195
+ while (this.eventQueue.length > 0) {
196
+ this.emitter.emit("event", this.eventQueue.shift());
197
+ }
198
+ }
199
+ /**
200
+ * Split completed assistant text into delta chunks and enqueue them,
201
+ * followed by the final assistant event. All go through the FIFO queue
202
+ * so subsequent events (complete, etc.) are guaranteed to come after.
203
+ */
204
+ enqueueTextAsDeltas(text) {
205
+ const CHUNK_SIZE = 4;
206
+ for (let i = 0; i < text.length; i += CHUNK_SIZE) {
207
+ this.enqueue({
208
+ type: "assistant_delta",
209
+ delta: text.slice(i, i + CHUNK_SIZE),
210
+ index: 0,
211
+ timestamp: Date.now()
212
+ });
213
+ }
214
+ this.enqueue({
215
+ type: "assistant",
216
+ message: text,
217
+ timestamp: Date.now()
218
+ });
219
+ }
220
+ get alive() {
221
+ return this._alive;
222
+ }
223
+ get pid() {
224
+ return this.proc.pid ?? null;
225
+ }
226
+ get sessionId() {
227
+ return this._sessionId;
228
+ }
229
+ /**
230
+ * Send a user message to the persistent Claude process via stdin.
231
+ * Accepts plain string or content block array (text + images).
232
+ */
233
+ send(input) {
234
+ if (!this._alive || !this.proc.stdin.writable) return;
235
+ const content = typeof input === "string" ? input : input;
236
+ const msg = JSON.stringify({
237
+ type: "user",
238
+ message: { role: "user", content }
239
+ });
240
+ logger.log("stdin", msg.slice(0, 200));
241
+ this.proc.stdin.write(msg + "\n");
242
+ }
243
+ interrupt() {
244
+ if (!this._alive || !this.proc.stdin.writable) return;
245
+ const msg = JSON.stringify({
246
+ type: "control_request",
247
+ request: { subtype: "interrupt" }
248
+ });
249
+ this.proc.stdin.write(msg + "\n");
250
+ }
251
+ setModel(model) {
252
+ if (!this._alive || !this.proc.stdin.writable) return;
253
+ const msg = JSON.stringify({
254
+ type: "control_request",
255
+ request: { subtype: "set_model", model }
256
+ });
257
+ this.proc.stdin.write(msg + "\n");
258
+ }
259
+ setPermissionMode(mode) {
260
+ if (!this._alive || !this.proc.stdin.writable) return;
261
+ const msg = JSON.stringify({
262
+ type: "control_request",
263
+ request: { subtype: "set_permission_mode", permission_mode: mode }
264
+ });
265
+ this.proc.stdin.write(msg + "\n");
266
+ }
267
+ kill() {
268
+ if (this._alive) {
269
+ this._alive = false;
270
+ this.proc.kill("SIGTERM");
271
+ }
272
+ }
273
+ on(event, handler) {
274
+ this.emitter.on(event, handler);
275
+ }
276
+ off(event, handler) {
277
+ this.emitter.off(event, handler);
278
+ }
279
+ normalizeEvent(msg) {
280
+ switch (msg.type) {
281
+ case "system": {
282
+ if (msg.subtype === "init") {
283
+ if (this._initEmitted) return null;
284
+ this._initEmitted = true;
285
+ return {
286
+ type: "init",
287
+ message: `Agent ready (${msg.model ?? "unknown"})`,
288
+ data: { sessionId: msg.session_id, model: msg.model },
289
+ timestamp: Date.now()
290
+ };
291
+ }
292
+ return null;
293
+ }
294
+ case "stream_event": {
295
+ const inner = msg.event;
296
+ if (!inner) return null;
297
+ if (inner.type === "content_block_start" && inner.content_block?.type === "tool_use") {
298
+ const block = inner.content_block;
299
+ this._receivedStreamEvents = true;
300
+ this._streamedToolUseIds.add(block.id);
301
+ return {
302
+ type: "tool_use",
303
+ message: block.name,
304
+ data: { toolName: block.name, id: block.id, input: null, streaming: true },
305
+ timestamp: Date.now()
306
+ };
307
+ }
308
+ if (inner.type === "content_block_delta") {
309
+ const delta = inner.delta;
310
+ if (delta?.type === "text_delta" && delta.text) {
311
+ this._receivedStreamEvents = true;
312
+ return {
313
+ type: "assistant_delta",
314
+ delta: delta.text,
315
+ index: inner.index ?? 0,
316
+ timestamp: Date.now()
317
+ };
318
+ }
319
+ if (delta?.type === "thinking_delta" && delta.thinking) {
320
+ return {
321
+ type: "thinking_delta",
322
+ message: delta.thinking,
323
+ timestamp: Date.now()
324
+ };
325
+ }
326
+ }
327
+ return null;
328
+ }
329
+ case "assistant": {
330
+ if (this._receivedStreamEvents && msg.message?.stop_reason === null) return null;
331
+ const content = msg.message?.content;
332
+ if (!Array.isArray(content)) return null;
333
+ const events = [];
334
+ const textBlocks = [];
335
+ for (const block of content) {
336
+ if (block.type === "thinking") {
337
+ events.push({
338
+ type: "thinking",
339
+ message: block.thinking ?? "",
340
+ timestamp: Date.now()
341
+ });
342
+ } else if (block.type === "tool_use") {
343
+ const alreadyStreamed = this._streamedToolUseIds.has(block.id);
344
+ if (alreadyStreamed) this._streamedToolUseIds.delete(block.id);
345
+ events.push({
346
+ type: "tool_use",
347
+ message: block.name,
348
+ data: { toolName: block.name, input: block.input, id: block.id, update: alreadyStreamed },
349
+ timestamp: Date.now()
350
+ });
351
+ } else if (block.type === "text") {
352
+ const text = (block.text ?? "").trim();
353
+ if (text) {
354
+ textBlocks.push(text);
355
+ }
356
+ }
357
+ }
358
+ if (events.length > 0 || textBlocks.length > 0) {
359
+ for (const e of events) {
360
+ this.enqueue(e);
361
+ }
362
+ for (const text of textBlocks) {
363
+ this.enqueue({ type: "assistant", message: text, timestamp: Date.now() });
364
+ }
365
+ }
366
+ return null;
367
+ }
368
+ case "user": {
369
+ const userContent = msg.message?.content;
370
+ if (!Array.isArray(userContent)) return null;
371
+ for (const block of userContent) {
372
+ if (block.type === "tool_result") {
373
+ return {
374
+ type: "tool_result",
375
+ message: typeof block.content === "string" ? block.content.slice(0, 300) : JSON.stringify(block.content).slice(0, 300),
376
+ data: { toolUseId: block.tool_use_id, isError: block.is_error },
377
+ timestamp: Date.now()
378
+ };
379
+ }
380
+ }
381
+ return null;
382
+ }
383
+ case "result": {
384
+ if (msg.subtype === "success") {
385
+ if (this._receivedStreamEvents && msg.result) {
386
+ this.enqueue({
387
+ type: "assistant",
388
+ message: msg.result,
389
+ timestamp: Date.now()
390
+ });
391
+ this._receivedStreamEvents = false;
392
+ this._streamedToolUseIds.clear();
393
+ }
394
+ const u = msg.usage ?? {};
395
+ const mu = msg.modelUsage ?? {};
396
+ const modelKey = Object.keys(mu)[0] ?? "";
397
+ const modelInfo = mu[modelKey] ?? {};
398
+ return {
399
+ type: "complete",
400
+ message: msg.result ?? "Done",
401
+ data: {
402
+ durationMs: msg.duration_ms,
403
+ costUsd: msg.total_cost_usd,
404
+ // Per-turn: actual context window usage this turn
405
+ inputTokens: u.input_tokens ?? 0,
406
+ outputTokens: u.output_tokens ?? 0,
407
+ cacheReadTokens: u.cache_read_input_tokens ?? 0,
408
+ cacheWriteTokens: u.cache_creation_input_tokens ?? 0,
409
+ // Static model info
410
+ contextWindow: modelInfo.contextWindow ?? 0,
411
+ model: modelKey
412
+ },
413
+ timestamp: Date.now()
414
+ };
415
+ }
416
+ if (msg.subtype === "error_during_execution" && msg.is_error === false) {
417
+ return {
418
+ type: "interrupted",
419
+ message: "Turn interrupted by user",
420
+ data: { durationMs: msg.duration_ms, costUsd: msg.total_cost_usd },
421
+ timestamp: Date.now()
422
+ };
423
+ }
424
+ if (msg.subtype?.startsWith("error") || msg.is_error) {
425
+ return {
426
+ type: "error",
427
+ message: msg.result ?? msg.error ?? "Unknown error",
428
+ timestamp: Date.now()
429
+ };
430
+ }
431
+ return null;
432
+ }
433
+ case "rate_limit_event":
434
+ return null;
435
+ default:
436
+ logger.log("agent", `unhandled event: ${msg.type}`, JSON.stringify(msg).substring(0, 200));
437
+ return null;
438
+ }
439
+ }
440
+ };
441
+ _ClaudeCodeProcess.DRAIN_INTERVAL_MS = 15;
442
+ var ClaudeCodeProcess = _ClaudeCodeProcess;
443
+
444
+ // src/electron/index.ts
445
+ var import_path2 = __toESM(require("path"), 1);
46
446
  function resolveStandaloneScript() {
47
447
  const selfPath = (0, import_url.fileURLToPath)(importMetaUrl);
48
- let script = import_path.default.resolve(import_path.default.dirname(selfPath), "../server/standalone.js");
448
+ let script = import_path2.default.resolve(import_path2.default.dirname(selfPath), "../server/standalone.js");
49
449
  if (script.includes(".asar") && !script.includes(".asar.unpacked")) {
50
450
  script = script.replace(/(\.asar)([/\\])/, ".asar.unpacked$2");
51
451
  }
52
- if (!import_fs.default.existsSync(script)) {
452
+ if (!import_fs2.default.existsSync(script)) {
53
453
  throw new Error(
54
454
  `SNA standalone script not found: ${script}
55
455
  Ensure "@sna-sdk/core" is listed in asarUnpack in your electron-builder config.`
@@ -60,14 +460,14 @@ Ensure "@sna-sdk/core" is listed in asarUnpack in your electron-builder config.`
60
460
  function buildNodePath() {
61
461
  const resourcesPath = process.resourcesPath;
62
462
  if (!resourcesPath) return void 0;
63
- const unpacked = import_path.default.join(resourcesPath, "app.asar.unpacked", "node_modules");
64
- if (!import_fs.default.existsSync(unpacked)) return void 0;
463
+ const unpacked = import_path2.default.join(resourcesPath, "app.asar.unpacked", "node_modules");
464
+ if (!import_fs2.default.existsSync(unpacked)) return void 0;
65
465
  const existing = process.env.NODE_PATH;
66
- return existing ? `${unpacked}${import_path.default.delimiter}${existing}` : unpacked;
466
+ return existing ? `${unpacked}${import_path2.default.delimiter}${existing}` : unpacked;
67
467
  }
68
468
  async function startSnaServer(options) {
69
469
  const port = options.port ?? 3099;
70
- const cwd = options.cwd ?? import_path.default.dirname(options.dbPath);
470
+ const cwd = options.cwd ?? import_path2.default.dirname(options.dbPath);
71
471
  const readyTimeout = options.readyTimeout ?? 15e3;
72
472
  const { onLog } = options;
73
473
  const standaloneScript = resolveStandaloneScript();
@@ -75,7 +475,7 @@ async function startSnaServer(options) {
75
475
  let consumerModules;
76
476
  try {
77
477
  const bsPkg = require.resolve("better-sqlite3/package.json", { paths: [process.cwd()] });
78
- consumerModules = import_path.default.resolve(bsPkg, "../..");
478
+ consumerModules = import_path2.default.resolve(bsPkg, "../..");
79
479
  } catch {
80
480
  }
81
481
  const env = {
@@ -132,10 +532,10 @@ async function startSnaServer(options) {
132
532
  reject(new Error(`SNA server process exited (code=${code ?? "null"}) before becoming ready`));
133
533
  }
134
534
  });
135
- proc.on("error", (err) => {
535
+ proc.on("error", (err2) => {
136
536
  if (!isReady) {
137
537
  clearTimeout(timer);
138
- reject(err);
538
+ reject(err2);
139
539
  }
140
540
  });
141
541
  });
@@ -1,2 +1,4 @@
1
1
  export { SnaServerHandle, SnaServerOptions, startSnaServer } from '../electron/index.js';
2
2
  import 'child_process';
3
+ import '../core/providers/claude-code.js';
4
+ import '../core/providers/types.js';
@@ -397,38 +397,80 @@ var logger = { log, err };
397
397
 
398
398
  // src/core/providers/claude-code.ts
399
399
  var SHELL = process.env.SHELL || "/bin/zsh";
400
- function resolveClaudePath(cwd) {
401
- if (process.env.SNA_CLAUDE_COMMAND) return process.env.SNA_CLAUDE_COMMAND;
402
- const cached = path4.join(cwd, ".sna/claude-path");
403
- if (fs4.existsSync(cached)) {
404
- const p = fs4.readFileSync(cached, "utf8").trim();
405
- if (p) {
406
- try {
407
- execSync(`test -x "${p}"`, { stdio: "pipe" });
408
- return p;
409
- } catch {
410
- }
400
+ function parseCommandVOutput(raw) {
401
+ const trimmed = raw.trim();
402
+ if (!trimmed) return "claude";
403
+ const aliasMatch = trimmed.match(/=\s*['"]?([^'"]+?)['"]?\s*$/);
404
+ if (aliasMatch) return aliasMatch[1];
405
+ const pathMatch = trimmed.match(/^(\/\S+)/m);
406
+ if (pathMatch) return pathMatch[1];
407
+ return trimmed;
408
+ }
409
+ function validateClaudePath(claudePath) {
410
+ try {
411
+ const claudeDir = path4.dirname(claudePath);
412
+ const env = { ...process.env, PATH: `${claudeDir}:${process.env.PATH ?? ""}` };
413
+ const out = execSync(`"${claudePath}" --version`, { encoding: "utf8", stdio: "pipe", timeout: 1e4, env }).trim();
414
+ return { ok: true, version: out.split("\n")[0].slice(0, 30) };
415
+ } catch {
416
+ return { ok: false };
417
+ }
418
+ }
419
+ function cacheClaudePath(claudePath, cacheDir) {
420
+ const dir = cacheDir ?? path4.join(process.cwd(), ".sna");
421
+ try {
422
+ if (!fs4.existsSync(dir)) fs4.mkdirSync(dir, { recursive: true });
423
+ fs4.writeFileSync(path4.join(dir, "claude-path"), claudePath);
424
+ } catch {
425
+ }
426
+ }
427
+ function resolveClaudeCli(opts) {
428
+ const cacheDir = opts?.cacheDir;
429
+ if (process.env.SNA_CLAUDE_COMMAND) {
430
+ const v = validateClaudePath(process.env.SNA_CLAUDE_COMMAND);
431
+ return { path: process.env.SNA_CLAUDE_COMMAND, version: v.version, source: "env" };
432
+ }
433
+ const cacheFile = cacheDir ? path4.join(cacheDir, "claude-path") : path4.join(process.cwd(), ".sna/claude-path");
434
+ try {
435
+ const cached = fs4.readFileSync(cacheFile, "utf8").trim();
436
+ if (cached) {
437
+ const v = validateClaudePath(cached);
438
+ if (v.ok) return { path: cached, version: v.version, source: "cache" };
411
439
  }
440
+ } catch {
412
441
  }
413
- for (const p of [
442
+ const staticPaths = [
414
443
  "/opt/homebrew/bin/claude",
415
444
  "/usr/local/bin/claude",
416
445
  `${process.env.HOME}/.local/bin/claude`,
417
- `${process.env.HOME}/.claude/bin/claude`
418
- ]) {
419
- try {
420
- execSync(`test -x "${p}"`, { stdio: "pipe" });
421
- return p;
422
- } catch {
446
+ `${process.env.HOME}/.claude/bin/claude`,
447
+ `${process.env.HOME}/.volta/bin/claude`
448
+ ];
449
+ for (const p of staticPaths) {
450
+ const v = validateClaudePath(p);
451
+ if (v.ok) {
452
+ cacheClaudePath(p, cacheDir);
453
+ return { path: p, version: v.version, source: "static" };
423
454
  }
424
455
  }
425
456
  try {
426
457
  const raw = execSync(`${SHELL} -i -l -c "command -v claude" 2>/dev/null`, { encoding: "utf8", timeout: 5e3 }).trim();
427
- const match = raw.match(/=(.+)/) ?? raw.match(/^(\/\S+)/m);
428
- return match ? match[1] : raw;
458
+ const resolved = parseCommandVOutput(raw);
459
+ if (resolved && resolved !== "claude") {
460
+ const v = validateClaudePath(resolved);
461
+ if (v.ok) {
462
+ cacheClaudePath(resolved, cacheDir);
463
+ return { path: resolved, version: v.version, source: "shell" };
464
+ }
465
+ }
429
466
  } catch {
430
- return "claude";
431
467
  }
468
+ return { path: "claude", source: "fallback" };
469
+ }
470
+ function resolveClaudePath(cwd) {
471
+ const result = resolveClaudeCli({ cacheDir: path4.join(cwd, ".sna") });
472
+ logger.log("agent", `claude path: ${result.source}=${result.path}${result.version ? ` (${result.version})` : ""}`);
473
+ return result.path;
432
474
  }
433
475
  var _ClaudeCodeProcess = class _ClaudeCodeProcess {
434
476
  constructor(proc, options) {
@@ -864,6 +906,10 @@ var ClaudeCodeProvider = class {
864
906
  delete cleanEnv.CLAUDE_CODE_ENTRYPOINT;
865
907
  delete cleanEnv.CLAUDE_CODE_SESSION_ACCESS_TOKEN;
866
908
  delete cleanEnv.CLAUDE_CODE_OAUTH_TOKEN;
909
+ const claudeDir = path4.dirname(claudePath);
910
+ if (claudeDir && claudeDir !== ".") {
911
+ cleanEnv.PATH = `${claudeDir}:${cleanEnv.PATH ?? ""}`;
912
+ }
867
913
  const proc = spawn2(claudePath, [...claudePrefix, ...args], {
868
914
  cwd: options.cwd,
869
915
  env: cleanEnv,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sna-sdk/core",
3
- "version": "0.9.9",
3
+ "version": "0.9.10",
4
4
  "description": "Skills-Native Application runtime — server, providers, session management, database, and CLI",
5
5
  "type": "module",
6
6
  "bin": {