min-agent 0.1.4 → 0.1.5

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/serve.js CHANGED
@@ -5,13 +5,18 @@
5
5
  import { createServer } from "http";
6
6
  import { readFileSync, existsSync } from "fs";
7
7
  import path from "path";
8
- import { initMcp, shutdownMcp } from "./mcp.js";
9
- import { discoverSkills } from "./skills.js";
8
+ import { initMcp, shutdownMcp, getMcpStatus } from "./mcp.js";
9
+ import { discoverSkills, getSkills } from "./skills.js";
10
10
  import { loadInstructions } from "./instructions.js";
11
11
  import { loadConfig, fetchModels, isConfigured } from "./config.js";
12
12
  import { setAutoApprove } from "./confirm.js";
13
13
  import { runOnce, buildUserContent } from "./agent.js";
14
- import { loadSession, saveSession } from "./sessions.js";
14
+ import { loadSession, saveSession, listSessions, deleteSession } from "./sessions.js";
15
+ import { loadMemories, addMemory, deleteMemory, searchMemories } from "./memory.js";
16
+ import { scanProject } from "./code-mode.js";
17
+ import { compactMessages } from "./compaction.js";
18
+ import { resolveModel } from "./provider.js";
19
+ import { getContextWindow } from "./context-window.js";
15
20
  const MAX_BODY_BYTES = 2 * 1024 * 1024;
16
21
  const MAX_TOOL_RESULT_SSE_CHARS = 48_000;
17
22
  function packageVersion() {
@@ -328,6 +333,349 @@ export async function runServe(opts = {}) {
328
333
  });
329
334
  return;
330
335
  }
336
+ // ─── Sessions ──────────────────────────────────────────────────────
337
+ if (req.method === "GET" && pathname === "/v1/sessions") {
338
+ const sessions = listSessions();
339
+ sendJson(res, 200, { sessions });
340
+ return;
341
+ }
342
+ if (req.method === "DELETE" && pathname.startsWith("/v1/sessions/")) {
343
+ const id = pathname.slice("/v1/sessions/".length);
344
+ if (!id) {
345
+ sendJson(res, 400, { error: "missing_session_id" });
346
+ return;
347
+ }
348
+ const ok = deleteSession(id);
349
+ if (!ok) {
350
+ sendJson(res, 404, { error: "session_not_found", session_id: id });
351
+ return;
352
+ }
353
+ sendJson(res, 200, { ok: true, deleted: id });
354
+ return;
355
+ }
356
+ // ─── Memory ────────────────────────────────────────────────────────
357
+ if (req.method === "GET" && pathname === "/v1/memory") {
358
+ const memories = loadMemories();
359
+ sendJson(res, 200, { memories });
360
+ return;
361
+ }
362
+ if (req.method === "POST" && pathname === "/v1/memory") {
363
+ const raw = await readBody(req);
364
+ const body = JSON.parse(raw);
365
+ if (!body.content) {
366
+ sendJson(res, 400, { error: "missing_content" });
367
+ return;
368
+ }
369
+ const memory = addMemory(body.content, body.tags ?? []);
370
+ sendJson(res, 201, { ok: true, memory });
371
+ return;
372
+ }
373
+ if (req.method === "GET" && pathname === "/v1/memory/search") {
374
+ const query = url.searchParams.get("q") ?? "";
375
+ if (!query) {
376
+ sendJson(res, 400, { error: "missing_query_param_q" });
377
+ return;
378
+ }
379
+ const results = searchMemories(query);
380
+ sendJson(res, 200, { results });
381
+ return;
382
+ }
383
+ if (req.method === "DELETE" && pathname.startsWith("/v1/memory/")) {
384
+ const idx = parseInt(pathname.slice("/v1/memory/".length));
385
+ if (isNaN(idx)) {
386
+ sendJson(res, 400, { error: "invalid_index" });
387
+ return;
388
+ }
389
+ const ok = deleteMemory(idx - 1);
390
+ if (!ok) {
391
+ sendJson(res, 404, { error: "memory_not_found", index: idx });
392
+ return;
393
+ }
394
+ sendJson(res, 200, { ok: true, deleted: idx });
395
+ return;
396
+ }
397
+ // ─── MCP ───────────────────────────────────────────────────────────
398
+ if (req.method === "GET" && pathname === "/v1/mcp") {
399
+ const status = getMcpStatus();
400
+ sendJson(res, 200, { servers: status });
401
+ return;
402
+ }
403
+ // ─── Skills ────────────────────────────────────────────────────────
404
+ if (req.method === "GET" && pathname === "/v1/skills") {
405
+ const skills = getSkills();
406
+ sendJson(res, 200, {
407
+ skills: skills.map((s) => ({ name: s.name, description: s.description, location: s.location })),
408
+ });
409
+ return;
410
+ }
411
+ // ─── Rules ─────────────────────────────────────────────────────────
412
+ if (req.method === "GET" && pathname === "/v1/rules") {
413
+ sendJson(res, 200, {
414
+ count: instructions.length,
415
+ rules: instructions.map((inst) => {
416
+ const firstLine = inst.split("\n")[0] ?? "";
417
+ return { source: firstLine.replace("Instructions from: ", ""), chars: inst.length };
418
+ }),
419
+ });
420
+ return;
421
+ }
422
+ // ─── Project (code mode scan) ──────────────────────────────────────
423
+ if (req.method === "GET" && pathname === "/v1/project") {
424
+ const project = scanProject();
425
+ sendJson(res, 200, { project });
426
+ return;
427
+ }
428
+ // ─── Code mode chat ────────────────────────────────────────────────
429
+ if (req.method === "POST" && pathname === "/v1/code") {
430
+ if (req.headers["content-type"]?.split(";")[0]?.trim() !== "application/json") {
431
+ sendJson(res, 415, { error: "unsupported_media_type" });
432
+ return;
433
+ }
434
+ let raw;
435
+ try {
436
+ raw = await readBody(req);
437
+ }
438
+ catch {
439
+ sendJson(res, 413, { error: "payload_too_large" });
440
+ return;
441
+ }
442
+ let body;
443
+ try {
444
+ body = JSON.parse(raw);
445
+ }
446
+ catch {
447
+ sendJson(res, 400, { error: "invalid_json" });
448
+ return;
449
+ }
450
+ const modelId = typeof body.model === "string" ? body.model : undefined;
451
+ const stream = body.stream === true;
452
+ const sessionId = typeof body.session_id === "string" ? body.session_id : undefined;
453
+ // Build code-mode system prompt
454
+ const { buildCodeSystemPrompt } = await import("./code-mode.js");
455
+ const project = scanProject();
456
+ const codeSystemPrompt = buildCodeSystemPrompt(project, instructions);
457
+ let messages;
458
+ if (sessionId) {
459
+ const session = loadSession(sessionId);
460
+ if (!session) {
461
+ sendJson(res, 404, { error: "session_not_found", session_id: sessionId });
462
+ return;
463
+ }
464
+ if (!body.message) {
465
+ sendJson(res, 400, { error: "session_requires_message" });
466
+ return;
467
+ }
468
+ messages = [...session.messages];
469
+ const content = body.images?.length ? await buildUserContent(body.message, body.images) : body.message;
470
+ messages.push({ role: "user", content });
471
+ }
472
+ else {
473
+ const norm = normalizeMessages(body);
474
+ if (!norm.ok) {
475
+ sendJson(res, 400, { error: "invalid_body", detail: norm.error });
476
+ return;
477
+ }
478
+ messages = norm.messages;
479
+ }
480
+ const abort = new AbortController();
481
+ req.on("close", () => abort.abort());
482
+ // Use runOnce with code system prompt override
483
+ const { runOnceWithSystem } = await import("./agent.js");
484
+ if (stream) {
485
+ res.writeHead(200, { "Content-Type": "text/event-stream; charset=utf-8", "Cache-Control": "no-cache", Connection: "keep-alive", ...c });
486
+ res.flushHeaders?.();
487
+ const callbacks = {
488
+ onAssistantDisplayDelta(delta) { sseWrite(res, { type: "assistant", text: delta }); },
489
+ onThinkingDelta(delta) { sseWrite(res, { type: "thinking", text: delta }); },
490
+ onToolCall(name, input) { sseWrite(res, { type: "tool_call", name, input }); },
491
+ onToolResult(name, output) { sseWrite(res, { type: "tool_result", name, output: truncateForJson(output, MAX_TOOL_RESULT_SSE_CHARS) }); },
492
+ onCompaction(line) { sseWrite(res, { type: "compaction", line }); },
493
+ onStreamError(message) { sseWrite(res, { type: "error", message }); },
494
+ onRunFinish(info) {
495
+ let saved;
496
+ if (sessionId && messages.length > 0) {
497
+ try {
498
+ saved = saveSession(messages, sessionId);
499
+ }
500
+ catch { }
501
+ }
502
+ sseWrite(res, { type: "done", step_count: info.stepCount, usage: info.usage, has_error: info.hasError, session_id: saved, messages, mode: "code", project });
503
+ if (!res.writableEnded)
504
+ res.end();
505
+ },
506
+ };
507
+ try {
508
+ await runOnceWithSystem(messages, codeSystemPrompt, modelId, abort.signal, callbacks);
509
+ }
510
+ catch (err) {
511
+ if (!res.writableEnded) {
512
+ sseWrite(res, { type: "fatal", message: err?.message ?? String(err) });
513
+ res.end();
514
+ }
515
+ }
516
+ }
517
+ else {
518
+ const toolCalls = [];
519
+ const toolResults = [];
520
+ const finishBox = { info: null };
521
+ await runOnceWithSystem(messages, codeSystemPrompt, modelId, abort.signal, {
522
+ onToolCall(name, input) { toolCalls.push({ name, input }); },
523
+ onToolResult(name, output) { toolResults.push({ name, output: truncateForJson(output, MAX_TOOL_RESULT_SSE_CHARS) }); },
524
+ onRunFinish(info) { finishBox.info = info; },
525
+ });
526
+ const lastAssistant = [...messages].reverse().find((m) => m.role === "assistant");
527
+ let saved;
528
+ if (sessionId && messages.length > 0) {
529
+ try {
530
+ saved = saveSession(messages, sessionId);
531
+ }
532
+ catch { }
533
+ }
534
+ const fi = finishBox.info;
535
+ sendJson(res, 200, { mode: "code", project, messages, assistant: lastAssistant ?? null, tool_calls: toolCalls, tool_results: toolResults, session_id: saved, step_count: fi?.stepCount ?? 0, usage: fi?.usage ?? null, has_error: fi?.hasError ?? false });
536
+ }
537
+ return;
538
+ }
539
+ // ─── MCP management ────────────────────────────────────────────────
540
+ if (req.method === "POST" && pathname === "/v1/paste") {
541
+ if (req.headers["content-type"]?.split(";")[0]?.trim() !== "application/json") {
542
+ sendJson(res, 415, { error: "unsupported_media_type" });
543
+ return;
544
+ }
545
+ const raw = await readBody(req);
546
+ const body = JSON.parse(raw);
547
+ if (!body.image_base64) {
548
+ sendJson(res, 400, { error: "missing_image_base64" });
549
+ return;
550
+ }
551
+ const imageBuffer = Buffer.from(body.image_base64, "base64");
552
+ const mimeType = body.mime_type ?? "image/png";
553
+ const text = body.message ?? "What's in this image?";
554
+ const modelId = body.model;
555
+ const sessionId = body.session_id;
556
+ const stream = body.stream === true;
557
+ let messages = [];
558
+ if (sessionId) {
559
+ const session = loadSession(sessionId);
560
+ if (session)
561
+ messages = [...session.messages];
562
+ }
563
+ const content = [
564
+ { type: "text", text },
565
+ { type: "image", image: imageBuffer, mimeType },
566
+ ];
567
+ messages.push({ role: "user", content });
568
+ const abort = new AbortController();
569
+ req.on("close", () => abort.abort());
570
+ if (stream) {
571
+ res.writeHead(200, { "Content-Type": "text/event-stream; charset=utf-8", "Cache-Control": "no-cache", Connection: "keep-alive", ...c });
572
+ res.flushHeaders?.();
573
+ const callbacks = {
574
+ onAssistantDisplayDelta(delta) { sseWrite(res, { type: "assistant", text: delta }); },
575
+ onThinkingDelta(delta) { sseWrite(res, { type: "thinking", text: delta }); },
576
+ onToolCall(name, input) { sseWrite(res, { type: "tool_call", name, input }); },
577
+ onToolResult(name, output) { sseWrite(res, { type: "tool_result", name, output: truncateForJson(output, MAX_TOOL_RESULT_SSE_CHARS) }); },
578
+ onStreamError(message) { sseWrite(res, { type: "error", message }); },
579
+ onRunFinish(info) {
580
+ let saved;
581
+ if (sessionId) {
582
+ try {
583
+ saved = saveSession(messages, sessionId);
584
+ }
585
+ catch { }
586
+ }
587
+ sseWrite(res, { type: "done", step_count: info.stepCount, usage: info.usage, session_id: saved });
588
+ if (!res.writableEnded)
589
+ res.end();
590
+ },
591
+ };
592
+ await runOnce(messages, instructions, modelId, abort.signal, callbacks);
593
+ }
594
+ else {
595
+ await runOnce(messages, instructions, modelId, abort.signal);
596
+ const lastAssistant = [...messages].reverse().find((m) => m.role === "assistant");
597
+ let saved;
598
+ if (sessionId) {
599
+ try {
600
+ saved = saveSession(messages, sessionId);
601
+ }
602
+ catch { }
603
+ }
604
+ sendJson(res, 200, { messages, assistant: lastAssistant ?? null, session_id: saved });
605
+ }
606
+ return;
607
+ }
608
+ // ─── MCP management (continued) ───────────────────────────────────
609
+ if (req.method === "POST" && pathname === "/v1/mcp") {
610
+ const raw = await readBody(req);
611
+ const body = JSON.parse(raw);
612
+ if (!body.name) {
613
+ sendJson(res, 400, { error: "missing_name" });
614
+ return;
615
+ }
616
+ const { loadMcpConfig, saveMcpConfig } = await import("./mcp.js");
617
+ const config = loadMcpConfig();
618
+ if (body.url) {
619
+ config.mcpServers[body.name] = { url: body.url, token: body.token, enabled: body.enabled !== false };
620
+ }
621
+ else if (body.command && body.command.length > 0) {
622
+ config.mcpServers[body.name] = { command: body.command, enabled: body.enabled !== false };
623
+ }
624
+ else {
625
+ sendJson(res, 400, { error: "provide command[] or url" });
626
+ return;
627
+ }
628
+ saveMcpConfig(config);
629
+ sendJson(res, 201, { ok: true, name: body.name });
630
+ return;
631
+ }
632
+ if (req.method === "DELETE" && pathname.startsWith("/v1/mcp/")) {
633
+ const name = decodeURIComponent(pathname.slice("/v1/mcp/".length));
634
+ if (!name) {
635
+ sendJson(res, 400, { error: "missing_name" });
636
+ return;
637
+ }
638
+ const { loadMcpConfig, saveMcpConfig } = await import("./mcp.js");
639
+ const config = loadMcpConfig();
640
+ if (!config.mcpServers[name]) {
641
+ sendJson(res, 404, { error: "mcp_not_found", name });
642
+ return;
643
+ }
644
+ delete config.mcpServers[name];
645
+ saveMcpConfig(config);
646
+ sendJson(res, 200, { ok: true, deleted: name });
647
+ return;
648
+ }
649
+ // ─── Context / Compact ─────────────────────────────────────────────
650
+ if (req.method === "GET" && pathname === "/v1/context") {
651
+ const config = loadConfig();
652
+ const ctxWindow = await getContextWindow(config.provider?.defaultModel);
653
+ sendJson(res, 200, {
654
+ context_window: ctxWindow,
655
+ model: config.provider?.defaultModel ?? null,
656
+ });
657
+ return;
658
+ }
659
+ if (req.method === "POST" && pathname === "/v1/chat/compact") {
660
+ const raw = await readBody(req);
661
+ const body = JSON.parse(raw);
662
+ if (!body.session_id) {
663
+ sendJson(res, 400, { error: "missing_session_id" });
664
+ return;
665
+ }
666
+ const session = loadSession(body.session_id);
667
+ if (!session) {
668
+ sendJson(res, 404, { error: "session_not_found" });
669
+ return;
670
+ }
671
+ const model = resolveModel();
672
+ const result = await compactMessages(session.messages, model, { autoContinue: false });
673
+ if (result.compacted) {
674
+ saveSession(result.messages, body.session_id);
675
+ }
676
+ sendJson(res, 200, { ok: true, compacted: result.compacted, message_count: result.messages.length });
677
+ return;
678
+ }
331
679
  sendJson(res, 404, { error: "not_found", path: pathname });
332
680
  }
333
681
  catch (err) {
package/dist/sessions.js CHANGED
@@ -1,6 +1,7 @@
1
- import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync } from "fs";
1
+ import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync, unlinkSync } from "fs";
2
2
  import path from "path";
3
3
  import { getConfigDir } from "./config.js";
4
+ import { generateTitle } from "./title-gen.js";
4
5
  function getSessionsDir() {
5
6
  return path.join(getConfigDir(), "sessions");
6
7
  }
@@ -17,7 +18,7 @@ function deriveTitle(messages) {
17
18
  const content = typeof first.content === "string" ? first.content : "";
18
19
  return content.slice(0, 60) || "Untitled";
19
20
  }
20
- export function saveSession(messages, existingId) {
21
+ export function saveSession(messages, existingId, title) {
21
22
  const dir = getSessionsDir();
22
23
  mkdirSync(dir, { recursive: true });
23
24
  const id = existingId ?? generateId();
@@ -25,7 +26,7 @@ export function saveSession(messages, existingId) {
25
26
  const data = {
26
27
  meta: {
27
28
  id,
28
- title: deriveTitle(messages),
29
+ title: title ?? deriveTitle(messages),
29
30
  created: existingId ? loadSession(id)?.meta.created ?? now : now,
30
31
  updated: now,
31
32
  messageCount: messages.length,
@@ -35,6 +36,15 @@ export function saveSession(messages, existingId) {
35
36
  writeFileSync(sessionPath(id), JSON.stringify(data, null, 2), "utf-8");
36
37
  return id;
37
38
  }
39
+ /** Save session with auto-generated title from LLM */
40
+ export async function saveSessionWithTitle(messages, model, existingId) {
41
+ let title = null;
42
+ try {
43
+ title = await generateTitle(model, messages);
44
+ }
45
+ catch { }
46
+ return saveSession(messages, existingId, title ?? undefined);
47
+ }
38
48
  export function loadSession(id) {
39
49
  const file = sessionPath(id);
40
50
  if (!existsSync(file))
@@ -68,7 +78,6 @@ export function deleteSession(id) {
68
78
  const file = sessionPath(id);
69
79
  if (!existsSync(file))
70
80
  return false;
71
- const { unlinkSync } = require("fs");
72
81
  unlinkSync(file);
73
82
  return true;
74
83
  }
@@ -0,0 +1,29 @@
1
+ import { tool, jsonSchema } from "ai";
2
+ /**
3
+ * Structured output support.
4
+ *
5
+ * When the user requests JSON output matching a schema, a StructuredOutput tool
6
+ * is injected. The model MUST call this tool to return its final answer in the
7
+ * requested format.
8
+ *
9
+ * Based on opencode's structured output implementation in prompt.ts.
10
+ */
11
+ const STRUCTURED_OUTPUT_DESCRIPTION = `Use this tool to return your final response in the requested structured format.
12
+
13
+ IMPORTANT:
14
+ - You MUST call this tool exactly once at the end of your response
15
+ - The input must be valid JSON matching the required schema
16
+ - Complete all necessary research and tool calls BEFORE calling this tool
17
+ - This tool provides your final answer - no further actions are taken after calling it`;
18
+ export const STRUCTURED_OUTPUT_SYSTEM = `IMPORTANT: The user has requested structured output. You MUST use the StructuredOutput tool to provide your final response. Do NOT respond with plain text - you MUST call the StructuredOutput tool with your answer formatted according to the schema.`;
19
+ export function createStructuredOutputTool(request, onSuccess) {
20
+ const { $schema: _, ...toolSchema } = request.schema;
21
+ return tool({
22
+ description: STRUCTURED_OUTPUT_DESCRIPTION,
23
+ inputSchema: jsonSchema(toolSchema),
24
+ execute: async (args) => {
25
+ onSuccess(args);
26
+ return JSON.stringify(args, null, 2);
27
+ },
28
+ });
29
+ }
@@ -0,0 +1,48 @@
1
+ import { generateText } from "ai";
2
+ /**
3
+ * Auto-generate a short title for a conversation session.
4
+ *
5
+ * Based on opencode's title agent — uses the LLM to produce a concise
6
+ * title from the first user message and assistant response.
7
+ */
8
+ const TITLE_PROMPT = `Generate a very short title (max 50 chars) for this conversation.
9
+ Output ONLY the title text, nothing else. No quotes, no prefix.
10
+ The title should capture the main topic or intent.`;
11
+ export async function generateTitle(model, messages) {
12
+ // Need at least one user message
13
+ const userMsg = messages.find((m) => m.role === "user");
14
+ if (!userMsg)
15
+ return null;
16
+ const userContent = typeof userMsg.content === "string"
17
+ ? userMsg.content
18
+ : Array.isArray(userMsg.content)
19
+ ? userMsg.content.filter((p) => "text" in p).map((p) => p.text).join(" ")
20
+ : "";
21
+ if (!userContent.trim())
22
+ return null;
23
+ // Include first assistant response if available for better context
24
+ const assistantMsg = messages.find((m) => m.role === "assistant");
25
+ const assistantContent = assistantMsg
26
+ ? typeof assistantMsg.content === "string"
27
+ ? assistantMsg.content.slice(0, 200)
28
+ : ""
29
+ : "";
30
+ const context = assistantContent
31
+ ? `User: ${userContent.slice(0, 300)}\nAssistant: ${assistantContent}`
32
+ : `User: ${userContent.slice(0, 300)}`;
33
+ try {
34
+ const result = await generateText({
35
+ model,
36
+ messages: [
37
+ { role: "system", content: TITLE_PROMPT },
38
+ { role: "user", content: context },
39
+ ],
40
+ temperature: 0.5,
41
+ });
42
+ const title = result.text.trim().replace(/^["']|["']$/g, "").slice(0, 60);
43
+ return title || null;
44
+ }
45
+ catch {
46
+ return null;
47
+ }
48
+ }
@@ -1,72 +1,13 @@
1
- import { spawn } from "child_process";
2
1
  import { tool, jsonSchema } from "ai";
2
+ import { spawn } from "child_process";
3
3
  import { confirm, isDangerousCommand, isAutoApprove } from "../confirm.js";
4
- import { truncateToolOutput } from "../tool-output.js";
5
- /** Hard cap for in-memory collection before killing the process (avoid OOM on huge stdout). */
6
- const COLLECT_HARD_CAP_BYTES = 16 * 1024 * 1024;
7
- function runCommand(command, cwd, timeoutMs) {
8
- return new Promise((resolve, reject) => {
9
- const child = spawn(command, {
10
- shell: true,
11
- cwd,
12
- env: process.env,
13
- stdio: ["ignore", "pipe", "pipe"],
14
- });
15
- const outChunks = [];
16
- const errChunks = [];
17
- let total = 0;
18
- let killedCap = false;
19
- let killedTimeout = false;
20
- const timer = setTimeout(() => {
21
- killedTimeout = true;
22
- child.kill("SIGTERM");
23
- setTimeout(() => child.kill("SIGKILL"), 2000).unref();
24
- }, timeoutMs);
25
- const push = (buf, arr) => {
26
- total += buf.length;
27
- if (total > COLLECT_HARD_CAP_BYTES && !killedCap) {
28
- killedCap = true;
29
- child.kill("SIGKILL");
30
- return;
31
- }
32
- arr.push(buf);
33
- };
34
- child.stdout?.on("data", (b) => push(b, outChunks));
35
- child.stderr?.on("data", (b) => push(b, errChunks));
36
- child.on("error", (err) => {
37
- clearTimeout(timer);
38
- reject(err);
39
- });
40
- child.on("close", (code) => {
41
- clearTimeout(timer);
42
- const stdout = Buffer.concat(outChunks).toString("utf-8");
43
- const stderr = Buffer.concat(errChunks).toString("utf-8");
44
- let combined = stdout.replace(/\s+$/, "");
45
- if (stderr)
46
- combined += (combined ? "\n" : "") + stderr.replace(/\s+$/, "");
47
- if (killedCap) {
48
- combined +=
49
- `\n\n[bash] Output collection stopped: exceeded ${COLLECT_HARD_CAP_BYTES} bytes in-memory cap (process was killed). Prefer redirecting to a file (e.g. > out.txt) then read with startLine/endLine.`;
50
- }
51
- else if (killedTimeout) {
52
- combined += `\n\n[bash] Command exceeded timeout ${timeoutMs} ms (process terminated).`;
53
- }
54
- resolve({
55
- code,
56
- output: combined || "(no output)",
57
- killedByTimeout: killedTimeout,
58
- killedByCap: killedCap,
59
- });
60
- });
61
- });
62
- }
63
4
  export const bashTool = tool({
64
- description: "Run a shell command. Use this for system operations, running builds, tests, git commands, etc. The command runs in the current working directory. Very long stdout/stderr is truncated: full output may be saved under ~/.min-agent/tool-output/ with a preview returned.",
5
+ description: "Run a shell command. Use this for system operations, running builds, tests, git commands, etc. The command runs in the current working directory. You SHOULD set a timeout based on how long you expect the command to take. If no timeout is set, the command runs until it finishes or the user manually interrupts (Ctrl+C).",
65
6
  inputSchema: jsonSchema({
66
7
  type: "object",
67
8
  properties: {
68
9
  command: { type: "string", description: "The shell command to execute" },
69
- timeout: { type: "number", description: "Timeout in milliseconds (default: 30000)" },
10
+ timeout: { type: "number", description: "Timeout in milliseconds. Set based on expected duration (e.g. 5000 for quick commands, 60000 for builds). Omit only for commands with unpredictable duration." },
70
11
  },
71
12
  required: ["command"],
72
13
  }),
@@ -76,18 +17,84 @@ export const bashTool = tool({
76
17
  if (!approved)
77
18
  return "Command rejected by user.";
78
19
  }
79
- const timeoutMs = timeout ?? 30000;
80
- try {
81
- const { code, output, killedByTimeout, killedByCap } = await runCommand(command, process.cwd(), timeoutMs);
82
- let body = output;
83
- if (code !== 0 && code !== null && !killedByTimeout && !killedByCap) {
84
- body = `Exit code ${code}\n${body}`;
20
+ return new Promise((resolve) => {
21
+ const chunks = [];
22
+ let killed = false;
23
+ let timer;
24
+ const proc = spawn(command, [], {
25
+ shell: true,
26
+ cwd: process.cwd(),
27
+ stdio: ["ignore", "pipe", "pipe"],
28
+ detached: process.platform !== "win32",
29
+ });
30
+ proc.stdout?.on("data", (chunk) => chunks.push(chunk));
31
+ proc.stderr?.on("data", (chunk) => chunks.push(chunk));
32
+ // Timeout kill (only if timeout is specified)
33
+ if (timeout && timeout > 0) {
34
+ timer = setTimeout(() => {
35
+ killed = true;
36
+ killProcess(proc.pid);
37
+ resolve(getOutput(chunks) +
38
+ `\n\n[Command timed out after ${timeout}ms and was killed. Retry with a larger timeout if needed.]`);
39
+ }, timeout);
85
40
  }
86
- const { content } = truncateToolOutput(body, { direction: "tail" });
87
- return content.trim() || "(no output)";
88
- }
89
- catch (err) {
90
- return `Error: ${err.message ?? String(err)}`;
91
- }
41
+ // Allow user to interrupt with Ctrl+C (SIGINT)
42
+ const sigintHandler = () => {
43
+ killed = true;
44
+ if (timer)
45
+ clearTimeout(timer);
46
+ killProcess(proc.pid);
47
+ resolve(getOutput(chunks) + "\n\n[Command interrupted by user.]");
48
+ };
49
+ process.on("SIGINT", sigintHandler);
50
+ proc.on("close", (code) => {
51
+ process.removeListener("SIGINT", sigintHandler);
52
+ if (timer)
53
+ clearTimeout(timer);
54
+ if (killed)
55
+ return;
56
+ const output = getOutput(chunks);
57
+ if (code === 0) {
58
+ resolve(output || "(no output)");
59
+ }
60
+ else {
61
+ resolve(`Exit code ${code}\n${output || "(no output)"}`);
62
+ }
63
+ });
64
+ proc.on("error", (err) => {
65
+ process.removeListener("SIGINT", sigintHandler);
66
+ if (timer)
67
+ clearTimeout(timer);
68
+ if (killed)
69
+ return;
70
+ resolve(`Error: ${err.message}`);
71
+ });
72
+ });
92
73
  },
93
74
  });
75
+ function getOutput(chunks) {
76
+ const output = Buffer.concat(chunks).toString("utf-8").trim();
77
+ if (output.length > 100_000) {
78
+ return output.slice(0, 50_000) + `\n\n...(truncated, ${output.length} bytes total)...\n\n` + output.slice(-10_000);
79
+ }
80
+ return output;
81
+ }
82
+ function killProcess(pid) {
83
+ if (!pid)
84
+ return;
85
+ try {
86
+ if (process.platform !== "win32") {
87
+ process.kill(-pid, "SIGTERM");
88
+ setTimeout(() => {
89
+ try {
90
+ process.kill(-pid, "SIGKILL");
91
+ }
92
+ catch { }
93
+ }, 3000);
94
+ }
95
+ else {
96
+ process.kill(pid, "SIGTERM");
97
+ }
98
+ }
99
+ catch { }
100
+ }