otterly 0.3.3 → 0.3.4

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/dist/cli.js CHANGED
@@ -17,7 +17,7 @@ const { values, positionals } = parseArgs({
17
17
  });
18
18
  const command = positionals[0] || "serve";
19
19
  if (values.version) {
20
- console.log("0.3.3");
20
+ console.log("0.3.4");
21
21
  process.exit(0);
22
22
  }
23
23
  if (values.help || command === "help") {
package/dist/engine.js CHANGED
@@ -1,5 +1,5 @@
1
- import { spawn } from "child_process";
2
- import { createInterface } from "readline";
1
+ import { execFileSync } from "child_process";
2
+ import { Worker } from "worker_threads";
3
3
  import { normalizeEvents, createEventContext } from "./events.js";
4
4
  import { classifyError, AgentError } from "./errors.js";
5
5
  import { wrapPermissionHandler } from "./permissions.js";
@@ -7,15 +7,12 @@ import { Session } from "./session.js";
7
7
  let cachedQueryFn = null;
8
8
  let resolvedMode = null;
9
9
  /**
10
- * Find the `claude` CLI binary. Returns the path or null.
10
+ * Find the `claude` CLI binary. Returns the name or null.
11
11
  */
12
- async function findClaudeCLI() {
13
- const { execFileSync } = await import("child_process");
14
- // Check common names: claude, claude-code
12
+ function findClaudeCLI() {
15
13
  for (const bin of ["claude", "claude-code"]) {
16
14
  try {
17
15
  execFileSync("which", [bin], { stdio: "pipe" });
18
- // Verify it actually runs
19
16
  execFileSync(bin, ["--version"], { stdio: "pipe", timeout: 5000 });
20
17
  return bin;
21
18
  }
@@ -26,102 +23,109 @@ async function findClaudeCLI() {
26
23
  return null;
27
24
  }
28
25
  /**
29
- * Build a QueryFn that spawns `claude -p` with stream-json output.
30
- * The CLI emits the same JSON event types the SDK does, so normalizeEvents
31
- * works unchanged.
26
+ * Build a shell command string for `claude -p`.
27
+ * Escapes the prompt for safe shell embedding.
28
+ */
29
+ function buildCLICommand(cliBin, prompt, opts) {
30
+ // Shell-escape single quotes in prompt
31
+ const safePrompt = prompt.replace(/'/g, "'\\''");
32
+ const parts = [cliBin, "-p", `'${safePrompt}'`, "--output-format", "stream-json", "--verbose"];
33
+ if (opts.cwd)
34
+ parts.push("--cwd", `'${String(opts.cwd).replace(/'/g, "'\\''")}'`);
35
+ if (opts.model)
36
+ parts.push("--model", String(opts.model));
37
+ if (opts.maxTurns)
38
+ parts.push("--max-turns", String(opts.maxTurns));
39
+ if (opts.systemPrompt) {
40
+ const safe = String(opts.systemPrompt).replace(/'/g, "'\\''");
41
+ parts.push("--system-prompt", `'${safe}'`);
42
+ }
43
+ if (opts.resume)
44
+ parts.push("--resume", String(opts.resume));
45
+ if (opts.permissionMode)
46
+ parts.push("--permission-mode", String(opts.permissionMode));
47
+ if (opts.allowedTools) {
48
+ for (const tool of opts.allowedTools)
49
+ parts.push("--allowedTools", tool);
50
+ }
51
+ if (opts.disallowedTools) {
52
+ for (const tool of opts.disallowedTools)
53
+ parts.push("--disallowedTools", tool);
54
+ }
55
+ return parts.join(" ");
56
+ }
57
+ /**
58
+ * Build a QueryFn that runs `claude -p` via execSync in a worker thread.
59
+ *
60
+ * The Bun-compiled `claude` binary doesn't pipe stdout to Node.js child_process
61
+ * async APIs (spawn/exec), but execSync through a shell works reliably.
62
+ * We run it in a worker thread to avoid blocking the event loop.
32
63
  */
33
64
  function createCLIQueryFn(cliBin) {
34
65
  return function cliQuery(args) {
35
66
  const opts = args.options || {};
36
67
  const prompt = typeof args.prompt === "string" ? args.prompt : JSON.stringify(args.prompt);
37
- const cliArgs = [
38
- "-p", prompt,
39
- "--output-format", "stream-json",
40
- "--verbose",
41
- ];
42
- if (opts.cwd)
43
- cliArgs.push("--cwd", String(opts.cwd));
44
- if (opts.model)
45
- cliArgs.push("--model", String(opts.model));
46
- if (opts.maxTurns)
47
- cliArgs.push("--max-turns", String(opts.maxTurns));
48
- if (opts.systemPrompt)
49
- cliArgs.push("--system-prompt", String(opts.systemPrompt));
50
- if (opts.resume)
51
- cliArgs.push("--resume", String(opts.resume));
52
- if (opts.permissionMode)
53
- cliArgs.push("--permission-mode", String(opts.permissionMode));
54
- if (opts.allowedTools) {
55
- for (const tool of opts.allowedTools) {
56
- cliArgs.push("--allowedTools", tool);
57
- }
58
- }
59
- if (opts.disallowedTools) {
60
- for (const tool of opts.disallowedTools) {
61
- cliArgs.push("--disallowedTools", tool);
62
- }
63
- }
64
- const abortController = opts.abortController;
68
+ const cmd = buildCLICommand(cliBin, prompt, opts);
65
69
  return {
66
- [Symbol.asyncIterator]() {
67
- let lineBuffer = [];
68
- let done = false;
69
- let error = null;
70
- let resolveNext = null;
71
- const child = spawn(cliBin, cliArgs, {
72
- stdio: ["pipe", "pipe", "pipe"],
73
- env: { ...process.env },
74
- });
75
- // Handle abort
76
- if (abortController) {
77
- abortController.signal.addEventListener("abort", () => {
78
- child.kill("SIGTERM");
70
+ async *[Symbol.asyncIterator]() {
71
+ // Run execSync in a worker thread to keep the event loop free
72
+ const stdout = await new Promise((resolve, reject) => {
73
+ const workerCode = `
74
+ const { parentPort, workerData } = require('worker_threads');
75
+ const { execSync } = require('child_process');
76
+ try {
77
+ const out = execSync(workerData.cmd, {
78
+ encoding: 'utf-8',
79
+ timeout: workerData.timeout,
80
+ maxBuffer: 50 * 1024 * 1024,
81
+ env: process.env,
82
+ });
83
+ parentPort.postMessage({ ok: true, data: out });
84
+ } catch (err) {
85
+ parentPort.postMessage({ ok: false, error: err.message });
86
+ }
87
+ `;
88
+ const timeout = opts.abortController
89
+ ? 10 * 60 * 1000 // 10 min for streaming
90
+ : 5 * 60 * 1000; // 5 min for one-shot
91
+ const worker = new Worker(workerCode, {
92
+ eval: true,
93
+ workerData: { cmd, timeout },
79
94
  });
80
- }
81
- const rl = createInterface({ input: child.stdout });
82
- rl.on("line", (line) => {
83
- if (!line.trim())
84
- return;
85
- try {
86
- const parsed = JSON.parse(line);
87
- lineBuffer.push(parsed);
88
- if (resolveNext) {
89
- resolveNext();
90
- resolveNext = null;
95
+ worker.on("message", (msg) => {
96
+ if (msg.ok) {
97
+ resolve(msg.data || "");
91
98
  }
92
- }
93
- catch {
94
- // Skip non-JSON lines (stderr leakage, etc.)
99
+ else {
100
+ reject(new Error(msg.error || "CLI execution failed"));
101
+ }
102
+ });
103
+ worker.on("error", reject);
104
+ worker.on("exit", (code) => {
105
+ if (code !== 0)
106
+ reject(new Error(`Worker exited with code ${code}`));
107
+ });
108
+ // Handle abort
109
+ const ac = opts.abortController;
110
+ if (ac) {
111
+ ac.signal.addEventListener("abort", () => {
112
+ worker.terminate();
113
+ reject(new Error("Aborted"));
114
+ });
95
115
  }
96
116
  });
97
- child.on("error", (err) => {
98
- error = err;
99
- done = true;
100
- if (resolveNext) {
101
- resolveNext();
102
- resolveNext = null;
117
+ // Parse the NDJSON output line by line and yield each event
118
+ const lines = stdout.trim().split("\n");
119
+ for (const line of lines) {
120
+ if (!line.trim())
121
+ continue;
122
+ try {
123
+ yield JSON.parse(line);
103
124
  }
104
- });
105
- child.on("close", () => {
106
- done = true;
107
- if (resolveNext) {
108
- resolveNext();
109
- resolveNext = null;
125
+ catch {
126
+ // Skip non-JSON lines
110
127
  }
111
- });
112
- return {
113
- async next() {
114
- while (lineBuffer.length === 0 && !done) {
115
- await new Promise((resolve) => { resolveNext = resolve; });
116
- }
117
- if (error)
118
- throw error;
119
- if (lineBuffer.length > 0) {
120
- return { value: lineBuffer.shift(), done: false };
121
- }
122
- return { value: undefined, done: true };
123
- },
124
- };
128
+ }
125
129
  },
126
130
  };
127
131
  };
@@ -114,7 +114,7 @@ export async function startApiServer(opts = {}) {
114
114
  }
115
115
  // GET / — server info
116
116
  if (req.method === "GET" && path === "/") {
117
- jsonResponse(res, 200, { name: "otterly", version: "0.3.3", playground: "/playground" });
117
+ jsonResponse(res, 200, { name: "otterly", version: "0.3.4", playground: "/playground" });
118
118
  return;
119
119
  }
120
120
  // ── POST routes: auth → rate limit → circuit breaker → queue ──
@@ -1335,7 +1335,7 @@ function playgroundHTML() {
1335
1335
  <div class="header-left">
1336
1336
  <span class="logo">\u{1F9A6}</span>
1337
1337
  <span class="logo-text">otterly</span>
1338
- <span class="version-badge">v0.3.1</span>
1338
+ <span class="version-badge">v0.3.4</span>
1339
1339
  </div>
1340
1340
  <div class="header-center">
1341
1341
  <nav class="nav">
@@ -5,7 +5,7 @@ import { AgentError } from "../errors.js";
5
5
  import { apiSessions } from "./session-store.js";
6
6
  import { errorToHttpStatus } from "./openai-compat.js";
7
7
  import { logError } from "./logger.js";
8
- const PKG_VERSION = "0.3.3";
8
+ const PKG_VERSION = "0.3.4";
9
9
  /**
10
10
  * GET /api/status — health check with queue and circuit breaker stats
11
11
  */
@@ -3,7 +3,7 @@ export const openApiSpec = {
3
3
  openapi: "3.0.3",
4
4
  info: {
5
5
  title: "Otterly API",
6
- version: "0.3.3",
6
+ version: "0.3.4",
7
7
  description: "Local inference server with OpenAI-compatible and native endpoints. " +
8
8
  "WebSocket available at ws://localhost:{port}/ws for interactive sessions.",
9
9
  },
@@ -21,7 +21,7 @@ export const openApiSpec = {
21
21
  type: "object",
22
22
  properties: {
23
23
  status: { type: "string", example: "ok" },
24
- version: { type: "string", example: "0.3.3" },
24
+ version: { type: "string", example: "0.3.4" },
25
25
  activeSessions: { type: "integer" },
26
26
  queue: {
27
27
  type: "object",
@@ -67,7 +67,7 @@ export const openApiSpec = {
67
67
  type: "object",
68
68
  properties: {
69
69
  name: { type: "string", example: "otterly" },
70
- version: { type: "string", example: "0.3.3" },
70
+ version: { type: "string", example: "0.3.4" },
71
71
  playground: { type: "string", example: "/playground" },
72
72
  },
73
73
  required: ["name", "version", "playground"],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "otterly",
3
- "version": "0.3.3",
3
+ "version": "0.3.4",
4
4
  "description": "Local AI inference for your apps. Use Claude Code instead of paying for API tokens.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",