autoclaw 1.1.1 → 1.2.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
@@ -30,11 +30,14 @@ Unlike "screen-seeing" agents (such as OpenClaw) that rely on visual interpretat
30
30
 
31
31
  ## Features
32
32
 
33
- - 📜 **Headless Execution**: No browsers, no GUIs. Pure terminal efficiency.
33
+ - 📜 **Headless Execution**: No GUI required pure terminal efficiency. Core operation is shell + file I/O; the optional web tools run in headless Chromium.
34
34
  - 🤖 **Non-Interactive Mode**: Intelligent flag handling (`-y`, `--no-interactive`) for zero-touch automation.
35
35
  - 📂 **Universal Control**: From simple file I/O to complex system administration.
36
- - 🧠 **Context Aware**: Detects container environments and provides accurate system time for relative date queries.
36
+ - 🛡️ **Runaway Protection**: Max-step cap per task, API retries with exponential backoff, shell command timeouts, and tool output truncation to keep the model context bounded.
37
+ - 🧠 **Context Aware**: Provides accurate OS, system and time context so relative dates ("today", "next Monday") are handled correctly.
37
38
  - 🌐 **Web Search**: Integrated with Tavily for real-time information retrieval.
39
+ - 🌍 **Web Reading & Screenshots**: Extract article content and capture page screenshots (requires `npx playwright install chromium`).
40
+ - 🎨 **Image Generation**: DALL-E compatible image generation via any OpenAI-compatible images API.
38
41
  - 🕒 **Time Accuracy**: Built-in tool to get precise system date and time for correct temporal context.
39
42
  - 📧 **Communication**: Send emails and push notifications to chat groups automatically.
40
43
 
@@ -43,7 +46,8 @@ Unlike "screen-seeing" agents (such as OpenClaw) that rely on visual interpretat
43
46
  - **Language**: TypeScript
44
47
  - **Framework**: Commander.js
45
48
  - **UI**: Inquirer (interactivity), Chalk (styling), Ora (spinners)
46
- - **AI**: OpenAI SDK (Compatible with DeepSeek, LocalLLM, etc.)
49
+ - **AI**: OpenAI SDK (any OpenAI-compatible endpoint: DeepSeek, Kimi, Qwen, GLM, Ollama, …)
50
+ - **Web tools**: Playwright (headless Chromium for `read_website` / `take_screenshot`)
47
51
 
48
52
  ## Installation
49
53
 
@@ -91,12 +95,21 @@ Simply run `autoclaw` to enter the chat loop.
91
95
  autoclaw
92
96
  > List all TypeScript files in the src folder.
93
97
  ```
98
+ Interactive commands: `exit` / `quit` to leave, and `/view` to open the full output of the last tool result in a pager — tool output longer than 20 lines is folded on screen and saved to `~/.autoclaw/output/`.
94
99
 
95
100
  ### Headless Mode (One-Shot)
96
101
  Run a single command and exit.
97
102
  ```bash
98
103
  autoclaw "Check disk usage and save the report to usage.txt" --no-interactive
99
104
  ```
105
+ The exit code reports the outcome for orchestrators: `0` completed, `1` hard failure (e.g. API error), `2` step cap reached (task unfinished).
106
+
107
+ ### Machine-Readable Output (--json)
108
+ Add `--json` to print one JSON event per line on stdout (run_start, tool_call, tool_result, usage, run_end); human output moves to stderr, including anything tools print themselves.
109
+ ```bash
110
+ autoclaw "Deploy and report" -y -n --json
111
+ ```
112
+ Token usage is collected only when `AUTOCLOW_INCLUDE_USAGE=1` (or `true`) is set — it is opt-in because not every OpenAI-compatible provider accepts `stream_options.include_usage`.
100
113
 
101
114
  ### Auto-Confirm (CI/CD)
102
115
  Automatically approve all tool executions (dangerous, use with caution or in sandboxes).
@@ -105,24 +118,36 @@ autoclaw "Refactor src/index.ts to use ES modules" -y
105
118
  ```
106
119
 
107
120
  ### CLI Options
108
- - `-m, --model <model>`: Specify the LLM model (default: `gpt-4o`).
121
+ - `-m, --model <model>`: Specify the LLM model (default: `gpt-5.6`).
122
+ - `-P, --provider <name>`: Use a provider preset (see [Providers](#providers)).
109
123
  - `-n, --no-interactive`: Exit after processing the initial query (Headless mode).
110
124
  - `-y, --yes`: Auto-confirm all tool executions (e.g., shell commands).
125
+ - `--json`: Emit NDJSON events on stdout (for orchestrators; use with `-n`).
126
+
127
+ ### Providers
128
+ AutoClaw works with any OpenAI-compatible endpoint. Built-in presets fill in the base URL and a default model for you:
129
+ ```bash
130
+ autoclaw -P deepseek "Check disk usage and save a report" -y -n
131
+ ```
132
+ Available presets: `openai`, `deepseek`, `moonshot` (Kimi), `dashscope` (Qwen), `zhipu` (GLM), `openrouter`, `ollama` (local). You can still override the model with `-m` or config. When `OPENAI_API_KEY` is not set, the API key is read from the provider's own env var (e.g. `DEEPSEEK_API_KEY`, `MOONSHOT_API_KEY`, `DASHSCOPE_API_KEY`, `ZHIPU_API_KEY`, `OPENROUTER_API_KEY`).
111
133
 
112
134
  ## Configuration
113
135
 
114
136
  AutoClaw uses a hierarchical configuration system.
115
137
 
116
138
  **Priority Order (Highest to Lowest):**
117
- 1. **CLI Arguments**: (e.g., `-m gpt-4o`)
139
+ 1. **CLI Arguments**: (e.g., `-m gpt-5.6`)
118
140
  2. **Environment Variables**: (`OPENAI_API_KEY`, `.env` file)
119
141
  3. **Project Config**: (`./.autoclaw/setting.json` in current directory)
120
142
  4. **Global Config**: (`~/.autoclaw/setting.json`)
121
143
 
122
144
  ### Supported Configuration Keys (JSON)
145
+ - `provider`: Provider preset name (e.g. `deepseek`).
123
146
  - `apiKey`: Your OpenAI API Key.
124
147
  - `baseUrl`: Custom Base URL (e.g., for DeepSeek or LocalLLM).
125
148
  - `model`: Default model to use.
149
+ - `maxSteps`: Max LLM turns per task before the agent stops (default: `25`).
150
+ - `shellTimeout`: Shell command timeout in milliseconds (default: `120000`).
126
151
  - `tavilyApiKey`: API Key for Tavily Web Search.
127
152
  - `smtpHost`, `smtpPort`, `smtpUser`, `smtpPass`, `smtpFrom`: SMTP Email settings.
128
153
  - `feishuWebhook`, `dingtalkWebhook`, `wecomWebhook`: Notification webhooks.
@@ -131,13 +156,20 @@ AutoClaw uses a hierarchical configuration system.
131
156
  Create a file at `.autoclaw/setting.json`:
132
157
  ```json
133
158
  {
134
- "model": "gpt-3.5-turbo",
159
+ "model": "gpt-5.6",
135
160
  "baseUrl": "https://api.deepseek.com/v1"
136
161
  }
137
162
  ```
138
163
 
139
164
  > **⚠️ Security Warning**: If you store your `apiKey` or secrets in `.autoclaw/setting.json`, make sure to add `.autoclaw/` to your `.gitignore` file to prevent leaking secrets!
140
165
 
166
+ ### Environment Variables
167
+ - `OPENAI_API_KEY`, `OPENAI_BASE_URL`, `OPENAI_MODEL`: main LLM settings.
168
+ - `AUTOCLOW_PROVIDER`: provider preset used when `-P` is not passed.
169
+ - `AUTOCLOW_MAX_STEPS`, `AUTOCLOW_SHELL_TIMEOUT`: reliability limits (max LLM turns per task; shell timeout in ms).
170
+ - `AUTOCLOW_INCLUDE_USAGE`: set to `1`/`true` to request token usage from the API (opt-in).
171
+ - `TAVILY_API_KEY`, `SMTP_HOST`/`SMTP_PORT`/`SMTP_USER`/`SMTP_PASS`, `FEISHU_WEBHOOK`/`FEISHU_KEYWORD`, `DINGTALK_WEBHOOK`/`DINGTALK_KEYWORD`, `WECOM_WEBHOOK`/`WECOM_KEYWORD`: tool credentials as an alternative to setup.
172
+
141
173
  ## Integrations
142
174
 
143
175
  ### Web Search (Tavily)
@@ -158,6 +190,14 @@ Built-in utility to provide the agent with the current system time, ensuring acc
158
190
 
159
191
  ## Docker Support
160
192
 
193
+ ### Build & Run
194
+ The repository ships a multi-stage `Dockerfile` (node:22-alpine, browser downloads skipped to keep the image slim). The container runs headless one-shot tasks against the mounted directory:
195
+ ```bash
196
+ docker build -t autoclaw .
197
+ docker run --rm -v "$PWD":/workspace -w /workspace -e OPENAI_API_KEY=sk-... autoclaw "Check disk usage and save a report" -y -n
198
+ ```
199
+ Note: browser-based tools (`read_website` / `take_screenshot`) are not functional in the default image since browsers are not bundled — they return a friendly install hint instead.
200
+
161
201
  ### Chinese Font Issues in Screenshots
162
202
  When running AutoClaw inside a Docker container (especially Alpine or Debian Slim), screenshots of Chinese websites may display text as square boxes ("tofu") due to missing fonts. Emojis (e.g., 🔥) may also appear as squares.
163
203
 
package/README.zh-CN.md CHANGED
@@ -31,11 +31,14 @@ AutoClaw 是一款针对 **“无界面系统” (Headless Systems)** 的高稳
31
31
 
32
32
  ## 特性
33
33
 
34
- - 📜 **无头执行 (Headless Execution)**: 真正的无头模式,无需浏览器或图形化界面。
34
+ - 📜 **无头执行 (Headless Execution)**: 无需图形界面,纯终端运行。核心流程仅依赖 Shell 与文件操作;可选的网页工具在无头 Chromium 中运行。
35
35
  - 🤖 **非交互模式**: 支持自动化标志(`-y`, `--no-interactive`),完美适配零干预的自动化流程。
36
- - 📂 **全方位控制 (Universal Control)**: 从基础的文件 I/O 到复杂的系统管理与代码重构。
37
- - 🧠 **上下文感知 (Context Aware)**: 自动识别操作系统与容器环境,并提供精确的系统时间以处理相对时间查询。
36
+ - 📂 **全方位控制 (Universal Control)**: 从基础的文件 I/O 到复杂的系统管理。
37
+ - 🛡️ **防失控保护**: 单任务步数上限、API 指数退避重试、Shell 命令超时、工具输出截断,保证模型上下文不被撑爆。
38
+ - 🧠 **上下文感知 (Context Aware)**: 提供精确的操作系统与时间上下文,正确处理"今天"、"下周一"等相对时间。
38
39
  - 🌐 **网页搜索**: 集成 Tavily,支持实时信息检索。
40
+ - 🌍 **网页阅读与截图**: 提取文章正文、截取页面图片(需先执行 `npx playwright install chromium`)。
41
+ - 🎨 **图像生成**: 通过任意 OpenAI 兼容的图像接口生成图片(兼容 DALL-E)。
39
42
  - 🕒 **时间精准**: 内置工具获取精确系统日期和时间,确保正确的时间上下文。
40
43
  - 📧 **通讯能力**: 自动发送电子邮件并将通知推送至聊天群组。
41
44
 
@@ -44,7 +47,8 @@ AutoClaw 是一款针对 **“无界面系统” (Headless Systems)** 的高稳
44
47
  - **语言**: TypeScript
45
48
  - **框架**: Commander.js
46
49
  - **UI**: Inquirer (交互), Chalk (样式), Ora (加载动画)
47
- - **AI**: OpenAI SDK (兼容 DeepSeek, LocalLLM 等)
50
+ - **AI**: OpenAI SDK(任意 OpenAI 兼容端点:DeepSeek、Kimi、Qwen、GLM、Ollama 等)
51
+ - **网页工具**: Playwright(无头 Chromium,用于 `read_website` / `take_screenshot`)
48
52
 
49
53
  ## 安装
50
54
 
@@ -92,12 +96,21 @@ npm install -g autoclaw
92
96
  autoclaw
93
97
  > 列出 src 文件夹中所有的 TypeScript 文件。
94
98
  ```
99
+ 交互命令:`exit` / `quit` 退出;`/view` 用分页器查看上一次工具的完整输出——超过 20 行的工具输出会在屏幕上折叠,并保存到 `~/.autoclaw/output/`。
95
100
 
96
101
  ### 无头模式 (一次性任务)
97
102
  执行单个指令后立即退出。
98
103
  ```bash
99
104
  autoclaw "检查磁盘使用情况并将报告保存到 usage.txt" --no-interactive
100
105
  ```
106
+ 退出码向编排器报告结果:`0` 完成,`1` 硬性失败(如 API 错误),`2` 触发步数上限(任务未完成)。
107
+
108
+ ### 机器可读输出 (--json)
109
+ 加 `--json` 后,stdout 每行输出一个 JSON 事件(run_start、tool_call、tool_result、usage、run_end);人类可读输出(包括工具自己打印的内容)全部转到 stderr。
110
+ ```bash
111
+ autoclaw "执行部署并汇报" -y -n --json
112
+ ```
113
+ Token 用量仅在设置 `AUTOCLOW_INCLUDE_USAGE=1`(或 `true`)时才收集——这是可选项,因为并非所有 OpenAI 兼容端点都接受 `stream_options.include_usage`。
101
114
 
102
115
  ### 自动确认 (CI/CD)
103
116
  自动批准所有工具执行(危险操作,请谨慎使用或在沙箱环境下运行)。
@@ -106,24 +119,36 @@ autoclaw "将 src/index.ts 重构为使用 ES 模块" -y
106
119
  ```
107
120
 
108
121
  ### CLI 选项
109
- - `-m, --model <model>`: 指定 LLM 模型 (默认: `gpt-4o`)。
122
+ - `-m, --model <model>`: 指定 LLM 模型 (默认: `gpt-5.6`)。
123
+ - `-P, --provider <name>`: 使用 provider 预设 (见 [Provider 预设](#provider-预设))。
110
124
  - `-n, --no-interactive`: 处理完初始查询后退出 (无头模式)。
111
125
  - `-y, --yes`: 自动确认所有工具执行 (例如 Shell 命令)。
126
+ - `--json`: 在 stdout 输出 NDJSON 事件流 (供编排器使用,配合 `-n`)。
127
+
128
+ ### Provider 预设
129
+ AutoClaw 可对接任意 OpenAI 兼容端点。内置预设可自动填好 Base URL 和默认模型:
130
+ ```bash
131
+ autoclaw -P deepseek "检查磁盘使用情况并保存报告" -y -n
132
+ ```
133
+ 可用预设:`openai`、`deepseek`、`moonshot` (Kimi)、`dashscope` (Qwen)、`zhipu` (GLM)、`openrouter`、`ollama` (本地)。模型仍可用 `-m` 或配置覆盖。未设置 `OPENAI_API_KEY` 时,会自动读取各家自己的环境变量 (如 `DEEPSEEK_API_KEY`、`MOONSHOT_API_KEY`、`DASHSCOPE_API_KEY`、`ZHIPU_API_KEY`、`OPENROUTER_API_KEY`)。
112
134
 
113
135
  ## 配置
114
136
 
115
137
  AutoClaw 使用层级配置系统。
116
138
 
117
139
  **优先级排序 (从高到低):**
118
- 1. **CLI 参数**: (例如 `-m gpt-4o`)
140
+ 1. **CLI 参数**: (例如 `-m gpt-5.6`)
119
141
  2. **环境变量**: (`OPENAI_API_KEY`, `.env` 文件)
120
142
  3. **项目配置**: (当前目录下的 `./.autoclaw/setting.json`)
121
143
  4. **全局配置**: (`~/.autoclaw/setting.json`)
122
144
 
123
145
  ### 支持的配置键 (JSON)
146
+ - `provider`: Provider 预设名 (如 `deepseek`)。
124
147
  - `apiKey`: 您的 OpenAI API 密钥。
125
148
  - `baseUrl`: 自定义 API 基础地址 (例如 DeepSeek 或本地 LLM)。
126
149
  - `model`: 默认使用的模型。
150
+ - `maxSteps`: 单任务最大 LLM 轮数,超出后自动停止 (默认: `25`)。
151
+ - `shellTimeout`: Shell 命令超时时间(毫秒)(默认: `120000`)。
127
152
  - `tavilyApiKey`: Tavily 网页搜索的 API 密钥。
128
153
  - `smtpHost`, `smtpPort`, `smtpUser`, `smtpPass`, `smtpFrom`: SMTP 邮件设置。
129
154
  - `feishuWebhook`, `dingtalkWebhook`, `wecomWebhook`: 通知钩子地址。
@@ -132,13 +157,20 @@ AutoClaw 使用层级配置系统。
132
157
  在 `.autoclaw/setting.json` 创建文件:
133
158
  ```json
134
159
  {
135
- "model": "gpt-3.5-turbo",
160
+ "model": "gpt-5.6",
136
161
  "baseUrl": "https://api.deepseek.com/v1"
137
162
  }
138
163
  ```
139
164
 
140
165
  > **⚠️ 安全警告**: 如果您在 `.autoclaw/setting.json` 中存储了 `apiKey` 或机密信息,请务必将 `.autoclaw/` 添加到您的 `.gitignore` 文件中,以防泄露!
141
166
 
167
+ ### 环境变量
168
+ - `OPENAI_API_KEY`, `OPENAI_BASE_URL`, `OPENAI_MODEL`: 主模型设置。
169
+ - `AUTOCLOW_PROVIDER`: 未传 `-P` 时使用的 provider 预设。
170
+ - `AUTOCLOW_MAX_STEPS`, `AUTOCLOW_SHELL_TIMEOUT`: 稳定性限制(单任务最大轮数;Shell 超时毫秒数)。
171
+ - `AUTOCLOW_INCLUDE_USAGE`: 设为 `1`/`true` 时向 API 请求 token 用量(可选开启)。
172
+ - `TAVILY_API_KEY`, `SMTP_HOST`/`SMTP_PORT`/`SMTP_USER`/`SMTP_PASS`, `FEISHU_WEBHOOK`/`FEISHU_KEYWORD`, `DINGTALK_WEBHOOK`/`DINGTALK_KEYWORD`, `WECOM_WEBHOOK`/`WECOM_KEYWORD`: 工具凭据,可作为 setup 的替代方式。
173
+
142
174
  ## 集成功能
143
175
 
144
176
  ### 网页搜索 (Tavily)
@@ -159,6 +191,14 @@ AutoClaw 使用层级配置系统。
159
191
 
160
192
  ## Docker 支持
161
193
 
194
+ ### 构建与运行
195
+ 仓库自带多阶段构建的 `Dockerfile`(node:22-alpine,跳过浏览器下载保持镜像苗条)。容器内直接运行无头一次性任务,配合目录挂载操作当前目录:
196
+ ```bash
197
+ docker build -t autoclaw .
198
+ docker run --rm -v "$PWD":/workspace -w /workspace -e OPENAI_API_KEY=sk-... autoclaw "检查磁盘使用情况并保存报告" -y -n
199
+ ```
200
+ 注意:默认镜像中未内置浏览器,基于浏览器的工具(`read_website` / `take_screenshot`)不可用——它们会返回友好的安装提示,而不是报错崩溃。
201
+
162
202
  ### 截图中的中文显示问题
163
203
  在 Docker 容器(尤其是 Alpine 或 Debian Slim)中运行时,网页截图中的中文可能会显示为方块("豆腐块")。表情符号(如 🔥)也可能显示为方块。
164
204
 
package/dist/agent.js CHANGED
@@ -4,14 +4,18 @@ import ora from 'ora';
4
4
  import * as fs from 'fs';
5
5
  import * as os from 'os';
6
6
  import * as path from 'path';
7
+ import * as util from 'util';
7
8
  import { getToolDefinitions, executeToolHandler } from './tools/index.js';
9
+ import { withRetry } from './retry.js';
10
+ import { truncateOutput } from './truncate.js';
11
+ const DEFAULT_MAX_STEPS = 25;
8
12
  export class Agent {
9
13
  client;
10
14
  messages;
11
15
  model;
12
16
  config;
13
17
  lastOutputFile = null;
14
- constructor(apiKey, baseURL, model = 'gpt-4-turbo-preview', config = {}) {
18
+ constructor(apiKey, baseURL, model = 'gpt-5.6', config = {}) {
15
19
  this.client = new OpenAI({
16
20
  apiKey: apiKey,
17
21
  baseURL: baseURL
@@ -48,7 +52,7 @@ WHAT YOU CAN DO:
48
52
  RULES OF ENGAGEMENT:
49
53
  1. One shot, not one chat. Produce working results, not conversation. Be terse.
50
54
  2. Use the right tool for the job. Shell for system ops. Files for content. Web tools for external info.
51
- 3. Always pass non-interactive flags: --yes for npx, -y for apt/apk, -f for rm, etc. Assume no human is watching.
55
+ 3. Always pass non-interactive flags: --yes for npx, -y for apt/apk, -f for rm, etc. Assume no human is watching. Set GIT_TERMINAL_PROMPT=0 for git commands that may need credentials so they fail fast instead of hanging.
52
56
  4. Container-friendly: stick to standard Unix tools available in Alpine/Debian slim images. No GUI apps, no browser-based debug tools.
53
57
  5. For creative or complex tasks (image prompts, long-form writing, intricate scripts): call optimize_prompt first. It significantly raises output quality.
54
58
  6. If a command fails, diagnose and try one alternative. Don't retry the same thing, don't give up on first error.
@@ -57,50 +61,124 @@ RULES OF ENGAGEMENT:
57
61
  }
58
62
  ];
59
63
  }
64
+ get jsonMode() {
65
+ return !!this.config?.jsonMode;
66
+ }
67
+ emitEvent(event) {
68
+ if (this.jsonMode)
69
+ console.log(JSON.stringify(event));
70
+ }
71
+ // Tool handlers print progress via console; in JSON mode stdout is an NDJSON
72
+ // contract, so route those prints to stderr for the duration of the call.
73
+ async runToolQuietly(run) {
74
+ const original = [console.log, console.info, console.warn, console.error];
75
+ const toStderr = (...args) => process.stderr.write(util.format(...args) + '\n');
76
+ [console.log, console.info, console.warn, console.error] = [toStderr, toStderr, toStderr, toStderr];
77
+ try {
78
+ return await run();
79
+ }
80
+ finally {
81
+ [console.log, console.info, console.warn, console.error] = original;
82
+ }
83
+ }
60
84
  async chat(userInput) {
61
85
  this.messages.push({ role: "user", content: userInput });
86
+ const maxSteps = Number(this.config?.maxSteps || process.env.AUTOCLOW_MAX_STEPS || DEFAULT_MAX_STEPS);
62
87
  let active = true;
88
+ let step = 0;
89
+ let status = 'completed';
90
+ let errorMessage;
91
+ let lastContent = null;
92
+ const totalUsage = { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 };
93
+ let sawUsage = false;
94
+ this.emitEvent({ event: 'run_start', model: this.model, task: userInput });
63
95
  while (active) {
64
- const spinner = ora('Thinking...').start();
96
+ if (step >= maxSteps) {
97
+ status = 'max_steps';
98
+ if (!this.jsonMode) {
99
+ console.log(chalk.yellow(`\n[MaxSteps] Reached the ${maxSteps}-turn limit; stopping to avoid a runaway loop.`));
100
+ }
101
+ break;
102
+ }
103
+ step++;
104
+ const spinner = this.jsonMode
105
+ ? { stop() { }, fail() { }, text: '' }
106
+ : ora('Thinking...').start();
107
+ let stream;
65
108
  try {
66
- const stream = await this.client.chat.completions.create({
109
+ // Retries cover request setup and the header phase; a failure after
110
+ // the stream started yielding chunks is not retried, because partial
111
+ // output may already have been printed.
112
+ stream = await withRetry(async () => this.client.chat.completions.create({
67
113
  model: this.model,
68
114
  messages: this.messages,
69
115
  tools: getToolDefinitions(),
70
116
  tool_choice: "auto",
71
- stream: true
117
+ stream: true,
118
+ // Not every OpenAI-compatible provider accepts stream_options;
119
+ // usage tracking is therefore opt-in only.
120
+ ...(this.config?.includeUsage ? { stream_options: { include_usage: true } } : {})
121
+ }), {
122
+ onRetry: (err, nextAttempt, delayMs) => {
123
+ spinner.text = `API error (${err.message}); retrying in ${Math.round(delayMs / 1000)}s (attempt ${nextAttempt})...`;
124
+ }
72
125
  });
73
- let content = '';
74
- let reasoningContent = '';
75
- let toolCalls = [];
76
- let contentStarted = false;
77
- let reasoningStarted = false;
78
- const toolNamesSeen = new Set();
126
+ }
127
+ catch (error) {
128
+ spinner.fail('Error during processing');
129
+ if (!this.jsonMode)
130
+ console.error(chalk.red(error.message));
131
+ status = 'error';
132
+ errorMessage = error.message;
133
+ active = false;
134
+ break;
135
+ }
136
+ let content = '';
137
+ let reasoningContent = '';
138
+ let toolCalls = [];
139
+ let contentStarted = false;
140
+ let reasoningStarted = false;
141
+ const toolNamesSeen = new Set();
142
+ try {
79
143
  for await (const chunk of stream) {
144
+ if (chunk.usage) {
145
+ sawUsage = true;
146
+ totalUsage.prompt_tokens += chunk.usage.prompt_tokens ?? 0;
147
+ totalUsage.completion_tokens += chunk.usage.completion_tokens ?? 0;
148
+ totalUsage.total_tokens += chunk.usage.total_tokens ?? 0;
149
+ }
80
150
  const delta = chunk.choices[0]?.delta;
81
151
  // Handle reasoning/thinking content (e.g., DeepSeek)
82
152
  if (delta?.reasoning_content) {
83
153
  if (!reasoningStarted) {
84
154
  spinner.stop();
85
- process.stdout.write(chalk.dim('\n[Thinking] '));
155
+ if (!this.jsonMode) {
156
+ process.stdout.write(chalk.dim('\n[Thinking] '));
157
+ }
86
158
  reasoningStarted = true;
87
159
  }
88
- process.stdout.write(chalk.dim(delta.reasoning_content));
160
+ if (!this.jsonMode) {
161
+ process.stdout.write(chalk.dim(delta.reasoning_content));
162
+ }
89
163
  reasoningContent += delta.reasoning_content;
90
164
  }
91
165
  // Handle regular content
92
166
  if (delta?.content) {
93
167
  if (!contentStarted) {
94
168
  spinner.stop();
95
- if (reasoningStarted)
96
- process.stdout.write('\n');
97
- process.stdout.write(chalk.blue("AutoClaw: "));
169
+ if (!this.jsonMode) {
170
+ if (reasoningStarted)
171
+ process.stdout.write('\n');
172
+ process.stdout.write(chalk.blue("AutoClaw: "));
173
+ }
98
174
  contentStarted = true;
99
175
  }
100
- process.stdout.write(delta.content);
176
+ if (!this.jsonMode) {
177
+ process.stdout.write(delta.content);
178
+ }
101
179
  content += delta.content;
102
180
  }
103
- // Handle tool calls - show name as soon as available
181
+ // Handle tool calls
104
182
  if (delta?.tool_calls) {
105
183
  for (const tc of delta.tool_calls) {
106
184
  const idx = tc.index;
@@ -113,45 +191,76 @@ RULES OF ENGAGEMENT:
113
191
  toolCalls[idx].function.name += tc.function.name;
114
192
  if (tc.function?.arguments)
115
193
  toolCalls[idx].function.arguments += tc.function.arguments;
116
- // Show tool name as soon as it's complete
117
194
  if (tc.function?.name && !toolNamesSeen.has(idx)) {
118
195
  toolNamesSeen.add(idx);
119
196
  spinner.stop();
120
- if (contentStarted)
121
- process.stdout.write('\n');
122
- if (reasoningStarted && !contentStarted)
123
- process.stdout.write('\n');
124
- process.stdout.write(chalk.cyan(`[Calling] ${tc.function.name}\n`));
197
+ if (!this.jsonMode) {
198
+ if (contentStarted)
199
+ process.stdout.write('\n');
200
+ if (reasoningStarted && !contentStarted)
201
+ process.stdout.write('\n');
202
+ process.stdout.write(chalk.cyan(`[Calling] ${tc.function.name}\n`));
203
+ }
125
204
  }
126
205
  }
127
206
  }
128
207
  }
129
- if (reasoningStarted) {
208
+ }
209
+ catch (error) {
210
+ spinner.fail('Error during processing');
211
+ if (!this.jsonMode)
212
+ console.error(chalk.red(error.message));
213
+ status = 'error';
214
+ errorMessage = error.message;
215
+ active = false;
216
+ break;
217
+ }
218
+ if (!this.jsonMode) {
219
+ if (reasoningStarted)
130
220
  console.log(); // newline after reasoning
131
- }
132
- if (contentStarted) {
221
+ if (contentStarted)
133
222
  console.log(); // newline after streamed content
134
- }
135
- if (!reasoningStarted && !contentStarted) {
223
+ if (!reasoningStarted && !contentStarted)
136
224
  spinner.stop();
137
- }
138
- // Build the full message for history
139
- const message = { role: "assistant" };
140
- if (content)
141
- message.content = content;
142
- if (reasoningContent)
143
- message.reasoning_content = reasoningContent;
144
- if (toolCalls.length > 0) {
145
- message.tool_calls = toolCalls;
146
- message.content = message.content || null;
147
- }
148
- this.messages.push(message);
149
- if (toolCalls.length > 0) {
150
- for (const toolCall of toolCalls) {
151
- if (toolCall.type !== 'function')
152
- continue;
153
- const functionName = toolCall.function.name;
154
- const functionArgs = JSON.parse(toolCall.function.arguments);
225
+ }
226
+ // Build the full message for history
227
+ const message = { role: "assistant" };
228
+ if (content)
229
+ message.content = content;
230
+ if (reasoningContent)
231
+ message.reasoning_content = reasoningContent;
232
+ if (toolCalls.length > 0) {
233
+ message.tool_calls = toolCalls;
234
+ message.content = message.content || null;
235
+ }
236
+ this.messages.push(message);
237
+ if (content)
238
+ lastContent = content;
239
+ if (toolCalls.length > 0) {
240
+ for (const toolCall of toolCalls) {
241
+ if (toolCall.type !== 'function')
242
+ continue;
243
+ const functionName = toolCall.function.name;
244
+ let functionArgs;
245
+ try {
246
+ functionArgs = JSON.parse(toolCall.function.arguments || '{}');
247
+ }
248
+ catch (parseError) {
249
+ // Feed the failure back so the model can correct itself next turn
250
+ if (!this.jsonMode) {
251
+ console.log(chalk.red(`\n[Tool] ${functionName} — malformed arguments (not valid JSON)`));
252
+ }
253
+ this.messages.push({
254
+ role: "tool",
255
+ tool_call_id: toolCall.id,
256
+ content: `Error: arguments for ${functionName} were not valid JSON (${parseError.message}). Re-issue the tool call with well-formed JSON arguments.`
257
+ });
258
+ continue;
259
+ }
260
+ if (this.jsonMode) {
261
+ this.emitEvent({ event: 'tool_call', step, tool: functionName, args: functionArgs });
262
+ }
263
+ else {
155
264
  // Display tool call info
156
265
  console.log(chalk.cyan(`\n[Tool] ${functionName}`));
157
266
  const argsStr = JSON.stringify(functionArgs, null, 2);
@@ -163,56 +272,82 @@ RULES OF ENGAGEMENT:
163
272
  else {
164
273
  console.log(chalk.dim(argsStr));
165
274
  }
166
- const execSpinner = ora('Executing...').start();
167
- let toolResult;
168
- try {
169
- toolResult = await executeToolHandler(functionName, functionArgs, this.config);
170
- execSpinner.stop();
171
- }
172
- catch (err) {
173
- execSpinner.fail('Tool execution failed');
174
- toolResult = `Error: ${err.message}`;
175
- }
176
- // Display result with folding for long output
177
- const MAX_PREVIEW_LINES = 20;
178
- const resultLines = toolResult.split('\n');
179
- console.log(chalk.green(`[Result]`));
180
- if (resultLines.length > MAX_PREVIEW_LINES) {
181
- // Show preview
275
+ }
276
+ let toolResult;
277
+ try {
278
+ const run = () => executeToolHandler(functionName, functionArgs, this.config);
279
+ toolResult = this.jsonMode ? await this.runToolQuietly(run) : await run();
280
+ }
281
+ catch (err) {
282
+ toolResult = `Error: ${err.message}`;
283
+ }
284
+ // Bound what goes back into the model context; the full output is
285
+ // kept on disk for /view.
286
+ const MAX_PREVIEW_LINES = 20;
287
+ const truncation = truncateOutput(toolResult);
288
+ const boundedResult = truncation.content;
289
+ const resultLines = boundedResult.split('\n');
290
+ let outputFile = null;
291
+ if (resultLines.length > MAX_PREVIEW_LINES || truncation.truncated) {
292
+ outputFile = await this.saveOutput(functionName, toolResult);
293
+ this.lastOutputFile = outputFile;
294
+ if (!this.jsonMode) {
295
+ console.log(chalk.green(`[Result]`));
182
296
  console.log(resultLines.slice(0, MAX_PREVIEW_LINES).join('\n'));
183
297
  const remaining = resultLines.length - MAX_PREVIEW_LINES;
184
- console.log(chalk.dim(`\n ... ${remaining} more lines (${resultLines.length} lines total)`));
185
- // Save full output to file
186
- const outputDir = path.join(os.homedir(), '.autoclaw', 'output');
187
- if (!fs.existsSync(outputDir)) {
188
- fs.mkdirSync(outputDir, { recursive: true });
298
+ if (remaining > 0) {
299
+ console.log(chalk.dim(`\n ... ${remaining} more lines (${resultLines.length} lines total)`));
189
300
  }
190
- const ts = new Date().toISOString().replace(/[:.]/g, '-');
191
- const outputFile = path.join(outputDir, `${functionName}_${ts}.txt`);
192
- fs.writeFileSync(outputFile, toolResult, 'utf-8');
193
- this.lastOutputFile = outputFile;
194
301
  console.log(chalk.dim(` Type '/view' to see full output`));
195
302
  }
196
- else {
197
- console.log(toolResult);
198
- this.lastOutputFile = null;
199
- }
200
- this.messages.push({
201
- role: "tool",
202
- tool_call_id: toolCall.id,
203
- content: toolResult
303
+ }
304
+ else if (!this.jsonMode) {
305
+ console.log(chalk.green(`[Result]`));
306
+ console.log(boundedResult);
307
+ this.lastOutputFile = null;
308
+ }
309
+ if (this.jsonMode) {
310
+ this.emitEvent({
311
+ event: 'tool_result',
312
+ step,
313
+ tool: functionName,
314
+ truncated: truncation.truncated,
315
+ bytes: truncation.totalBytes,
316
+ ...(outputFile ? { output_file: outputFile } : {})
204
317
  });
205
318
  }
206
- }
207
- else {
208
- active = false;
319
+ this.messages.push({
320
+ role: "tool",
321
+ tool_call_id: toolCall.id,
322
+ content: boundedResult
323
+ });
209
324
  }
210
325
  }
211
- catch (error) {
212
- spinner.fail('Error during processing');
213
- console.error(chalk.red(error.message));
326
+ else {
214
327
  active = false;
215
328
  }
329
+ if (sawUsage) {
330
+ this.emitEvent({ event: 'usage', step, ...totalUsage });
331
+ }
332
+ }
333
+ const result = {
334
+ status,
335
+ steps: step,
336
+ message: lastContent,
337
+ ...(errorMessage ? { error: errorMessage } : {}),
338
+ ...(sawUsage ? { usage: totalUsage } : {})
339
+ };
340
+ this.emitEvent({ event: 'run_end', ...result });
341
+ return result;
342
+ }
343
+ async saveOutput(functionName, toolResult) {
344
+ const outputDir = path.join(os.homedir(), '.autoclaw', 'output');
345
+ if (!fs.existsSync(outputDir)) {
346
+ fs.mkdirSync(outputDir, { recursive: true });
216
347
  }
348
+ const ts = new Date().toISOString().replace(/[:.]/g, '-');
349
+ const outputFile = path.join(outputDir, `${functionName}_${ts}.txt`);
350
+ fs.writeFileSync(outputFile, toolResult, 'utf-8');
351
+ return outputFile;
217
352
  }
218
353
  }
package/dist/index.js CHANGED
@@ -4,6 +4,7 @@ import inquirer from 'inquirer';
4
4
  import chalk from 'chalk';
5
5
  import dotenv from 'dotenv';
6
6
  import { Agent } from './agent.js';
7
+ import { PROVIDER_PRESETS, providerNames, resolveProvider } from './providers.js';
7
8
  import * as fs from 'fs';
8
9
  import * as path from 'path';
9
10
  import * as os from 'os';
@@ -42,7 +43,7 @@ dotenv.config({ path: GLOBAL_ENV_FILE });
42
43
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
43
44
  // In dist/index.js, package.json is usually up one level in the root
44
45
  const pkgPath = path.join(__dirname, '..', 'package.json');
45
- let version = '1.0.2';
46
+ let version = '1.2.0';
46
47
  try {
47
48
  const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
48
49
  version = pkg.version;
@@ -56,8 +57,10 @@ program
56
57
  .description('A lightweight AI agent CLI tool')
57
58
  .version(version)
58
59
  .option('-m, --model <model>', 'Model to use')
60
+ .option('-P, --provider <name>', 'Use a provider preset (openai, deepseek, moonshot, dashscope, zhipu, openrouter, ollama)')
59
61
  .option('-n, --no-interactive', 'Exit after processing the initial query (Headless mode)')
60
- .option('-y, --yes', 'Auto-confirm all tool executions (e.g., shell commands)');
62
+ .option('-y, --yes', 'Auto-confirm all tool executions (e.g., shell commands)')
63
+ .option('--json', 'Emit NDJSON events on stdout (for orchestrators; use with -n)');
61
64
  program
62
65
  .command('setup')
63
66
  .description('Run the interactive setup wizard to configure API keys')
@@ -92,6 +95,20 @@ async function runSetup(options = {}) {
92
95
  return '******';
93
96
  return `${secret.slice(0, 3)}...${secret.slice(-4)}`;
94
97
  }
98
+ const providerAnswer = await inquirer.prompt([
99
+ {
100
+ type: 'list',
101
+ name: 'provider',
102
+ message: 'Select your LLM provider:',
103
+ choices: [
104
+ ...providerNames().map((name) => ({ name: `${PROVIDER_PRESETS[name].label} (${name})`, value: name })),
105
+ { name: 'Custom OpenAI-compatible endpoint', value: 'custom' }
106
+ ],
107
+ default: currentConfig.provider || 'openai'
108
+ }
109
+ ]);
110
+ const provider = providerAnswer.provider;
111
+ const preset = resolveProvider(provider === 'custom' ? undefined : provider);
95
112
  const answers = await inquirer.prompt([
96
113
  {
97
114
  type: 'password',
@@ -112,13 +129,13 @@ async function runSetup(options = {}) {
112
129
  type: 'input',
113
130
  name: 'baseUrl',
114
131
  message: 'Enter API Base URL:',
115
- default: currentConfig.baseUrl || 'https://api.openai.com/v1'
132
+ default: currentConfig.baseUrl || preset?.baseUrl || 'https://api.openai.com/v1'
116
133
  },
117
134
  {
118
135
  type: 'input',
119
136
  name: 'model',
120
137
  message: 'Enter default Model:',
121
- default: currentConfig.model || 'gpt-4o'
138
+ default: currentConfig.model || preset?.defaultModel || 'gpt-5.6'
122
139
  },
123
140
  {
124
141
  type: 'confirm',
@@ -319,6 +336,7 @@ async function runSetup(options = {}) {
319
336
  apiKey: finalApiKey,
320
337
  baseUrl: answers.baseUrl,
321
338
  model: answers.model,
339
+ provider: provider === 'custom' ? undefined : provider,
322
340
  ...imageConfig,
323
341
  ...emailConfig,
324
342
  ...searchConfig,
@@ -351,18 +369,31 @@ async function runChat(queryParts, options) {
351
369
  // 3. Merge Configs for Tool Usage
352
370
  // Priority: Local > Global
353
371
  const fullConfig = { ...globalConfig, ...localConfig };
354
- // 4. Resolve Env Vars (CLI > Env > Config)
355
- let apiKey = process.env.OPENAI_API_KEY || fullConfig.apiKey;
356
- let baseURL = process.env.OPENAI_BASE_URL || fullConfig.baseUrl;
357
- let model = options.model || process.env.OPENAI_MODEL || fullConfig.model || 'gpt-4o';
372
+ // 4. Resolve Provider Preset (CLI > Env > Config)
373
+ const providerName = options.provider || process.env.AUTOCLOW_PROVIDER || fullConfig.provider;
374
+ const preset = resolveProvider(providerName);
375
+ if (providerName && !preset) {
376
+ console.log(chalk.yellow(`Unknown provider '${providerName}'. Known providers: ${providerNames().join(', ')}`));
377
+ }
378
+ // 5. Resolve Env Vars (CLI > Env > Config > Provider preset)
379
+ let apiKey = process.env.OPENAI_API_KEY || fullConfig.apiKey || (preset?.apiKeyEnv ? process.env[preset.apiKeyEnv] : undefined);
380
+ let baseURL = process.env.OPENAI_BASE_URL || fullConfig.baseUrl || preset?.baseUrl;
381
+ let model = options.model || process.env.OPENAI_MODEL || fullConfig.model || preset?.defaultModel || 'gpt-5.6';
358
382
  // Inject Runtime Flags
359
383
  fullConfig.autoConfirm = options.yes;
384
+ fullConfig.jsonMode = !!options.json;
385
+ // Usage tracking is opt-in: not every OpenAI-compatible provider accepts
386
+ // stream_options.include_usage, so never force it on.
387
+ fullConfig.includeUsage =
388
+ !!fullConfig.includeUsage ||
389
+ process.env.AUTOCLOW_INCLUDE_USAGE === '1' ||
390
+ process.env.AUTOCLOW_INCLUDE_USAGE === 'true';
360
391
  // Inject Env vars
361
392
  if (process.env.SMTP_HOST)
362
393
  fullConfig.smtpHost = process.env.SMTP_HOST;
363
394
  if (process.env.SMTP_PORT)
364
395
  fullConfig.smtpPort = process.env.SMTP_PORT;
365
- if (process.env.SMTP_User)
396
+ if (process.env.SMTP_USER)
366
397
  fullConfig.smtpUser = process.env.SMTP_USER;
367
398
  if (process.env.SMTP_PASS)
368
399
  fullConfig.smtpPass = process.env.SMTP_PASS;
@@ -393,9 +424,10 @@ async function runChat(queryParts, options) {
393
424
  if (doSetup) {
394
425
  await runSetup();
395
426
  const newConfig = loadJsonConfig(GLOBAL_CONFIG_FILE);
396
- apiKey = newConfig.apiKey;
397
- baseURL = newConfig.baseUrl;
398
- model = options.model || newConfig.model || 'gpt-4o';
427
+ const setupPreset = resolveProvider(newConfig.provider);
428
+ apiKey = newConfig.apiKey || (setupPreset?.apiKeyEnv ? process.env[setupPreset.apiKeyEnv] : undefined);
429
+ baseURL = newConfig.baseUrl || setupPreset?.baseUrl;
430
+ model = options.model || newConfig.model || setupPreset?.defaultModel || 'gpt-5.6';
399
431
  Object.assign(fullConfig, newConfig);
400
432
  }
401
433
  else {
@@ -417,10 +449,11 @@ async function runChat(queryParts, options) {
417
449
  if (options.interactive) {
418
450
  console.log(chalk.blue("\nProcessing initial request: ") + chalk.bold(initialQuery));
419
451
  }
420
- await agent.chat(initialQuery);
421
- // Headless mode exit
452
+ const result = await agent.chat(initialQuery);
453
+ // Headless mode exit — the exit code is the orchestrator-facing outcome:
454
+ // 0 completed, 1 hard failure, 2 step cap reached (task unfinished).
422
455
  if (!options.interactive) {
423
- process.exit(0);
456
+ process.exit(result.status === 'completed' ? 0 : result.status === 'max_steps' ? 2 : 1);
424
457
  }
425
458
  }
426
459
  // Main chat loop
@@ -0,0 +1,51 @@
1
+ export const PROVIDER_PRESETS = {
2
+ openai: {
3
+ label: 'OpenAI',
4
+ baseUrl: 'https://api.openai.com/v1',
5
+ defaultModel: 'gpt-5.6',
6
+ apiKeyEnv: 'OPENAI_API_KEY'
7
+ },
8
+ deepseek: {
9
+ label: 'DeepSeek',
10
+ baseUrl: 'https://api.deepseek.com/v1',
11
+ defaultModel: 'deepseek-v4-pro',
12
+ apiKeyEnv: 'DEEPSEEK_API_KEY'
13
+ },
14
+ moonshot: {
15
+ label: 'Moonshot (Kimi)',
16
+ baseUrl: 'https://api.moonshot.cn/v1',
17
+ defaultModel: 'kimi-k3',
18
+ apiKeyEnv: 'MOONSHOT_API_KEY'
19
+ },
20
+ dashscope: {
21
+ label: 'Alibaba DashScope (Qwen)',
22
+ baseUrl: 'https://dashscope.aliyuncs.com/compatible-mode/v1',
23
+ defaultModel: 'qwen3.8-max',
24
+ apiKeyEnv: 'DASHSCOPE_API_KEY'
25
+ },
26
+ zhipu: {
27
+ label: 'Zhipu (GLM)',
28
+ baseUrl: 'https://open.bigmodel.cn/api/paas/v4',
29
+ defaultModel: 'glm-5',
30
+ apiKeyEnv: 'ZHIPU_API_KEY'
31
+ },
32
+ openrouter: {
33
+ label: 'OpenRouter',
34
+ baseUrl: 'https://openrouter.ai/api/v1',
35
+ defaultModel: 'openai/gpt-5.6',
36
+ apiKeyEnv: 'OPENROUTER_API_KEY'
37
+ },
38
+ ollama: {
39
+ label: 'Ollama (local)',
40
+ baseUrl: 'http://localhost:11434/v1',
41
+ defaultModel: 'qwen3'
42
+ }
43
+ };
44
+ export function resolveProvider(name) {
45
+ if (!name)
46
+ return undefined;
47
+ return PROVIDER_PRESETS[name.toLowerCase()];
48
+ }
49
+ export function providerNames() {
50
+ return Object.keys(PROVIDER_PRESETS);
51
+ }
package/dist/retry.js ADDED
@@ -0,0 +1,42 @@
1
+ export const DEFAULT_MAX_ATTEMPTS = 3;
2
+ export const DEFAULT_BASE_DELAY_MS = 1000;
3
+ export function sleep(ms) {
4
+ return new Promise(resolve => setTimeout(resolve, ms));
5
+ }
6
+ export function isRetryableError(err) {
7
+ if (!err)
8
+ return false;
9
+ const status = err.status ?? err.statusCode;
10
+ if (typeof status === 'number') {
11
+ return status === 429 || status === 408 || status >= 500;
12
+ }
13
+ const match = /status code (\d{3})/.exec(String(err.message ?? ''));
14
+ if (match) {
15
+ const code = Number(match[1]);
16
+ return code === 429 || code === 408 || code >= 500;
17
+ }
18
+ const transientCodes = ['ECONNRESET', 'ETIMEDOUT', 'ECONNREFUSED', 'EAI_AGAIN', 'ECONNABORTED', 'EPIPE', 'ENOTFOUND'];
19
+ if (typeof err.code === 'string' && transientCodes.includes(err.code))
20
+ return true;
21
+ return /fetch failed|network|socket hang up|connection error|terminated/i.test(String(err.message ?? ''));
22
+ }
23
+ export async function withRetry(fn, options = {}) {
24
+ const attempts = options.attempts ?? DEFAULT_MAX_ATTEMPTS;
25
+ const baseDelayMs = options.baseDelayMs ?? DEFAULT_BASE_DELAY_MS;
26
+ const sleepFn = options.sleepFn ?? sleep;
27
+ let lastErr;
28
+ for (let attempt = 1; attempt <= attempts; attempt++) {
29
+ try {
30
+ return await fn();
31
+ }
32
+ catch (err) {
33
+ lastErr = err;
34
+ if (attempt >= attempts || !isRetryableError(err))
35
+ throw err;
36
+ const delayMs = baseDelayMs * Math.pow(2, attempt - 1);
37
+ options.onRetry?.(err, attempt + 1, delayMs);
38
+ await sleepFn(delayMs);
39
+ }
40
+ }
41
+ throw lastErr;
42
+ }
@@ -5,6 +5,8 @@ import inquirer from 'inquirer';
5
5
  import chalk from 'chalk';
6
6
  import util from 'util';
7
7
  const execAsync = util.promisify(exec);
8
+ const DEFAULT_SHELL_TIMEOUT_MS = 120000;
9
+ const SHELL_MAX_BUFFER = 10 * 1024 * 1024;
8
10
  export const ShellTool = {
9
11
  name: "Shell Execution",
10
12
  definition: {
@@ -25,8 +27,14 @@ export const ShellTool = {
25
27
  handler: async (args, config) => {
26
28
  console.log(chalk.yellow(`\nAI wants to execute: `) + chalk.bold(args.command));
27
29
  console.log(chalk.dim(`Reason: ${args.rationale}`));
30
+ const timeoutMs = Number(config?.shellTimeout || process.env.AUTOCLOW_SHELL_TIMEOUT || DEFAULT_SHELL_TIMEOUT_MS);
28
31
  // Check for auto-confirm flag
29
32
  if (!config?.autoConfirm) {
33
+ if (!process.stdin.isTTY) {
34
+ // No human can answer the confirmation prompt in this environment;
35
+ // denying beats hanging on a dead prompt or auto-running unasked.
36
+ return "Denied: this shell command requires user confirmation, but no interactive terminal is attached. Re-run with --yes (or -y) to allow unattended execution.";
37
+ }
30
38
  const { confirm } = await inquirer.prompt([
31
39
  {
32
40
  type: 'confirm',
@@ -42,10 +50,16 @@ export const ShellTool = {
42
50
  console.log(chalk.gray("(Auto-confirming command execution due to --yes flag)"));
43
51
  }
44
52
  try {
45
- const { stdout, stderr } = await execAsync(args.command);
53
+ const { stdout, stderr } = await execAsync(args.command, {
54
+ timeout: timeoutMs,
55
+ maxBuffer: SHELL_MAX_BUFFER
56
+ });
46
57
  return stdout + (stderr ? `\nStderr: ${stderr}` : '');
47
58
  }
48
59
  catch (error) {
60
+ if (error.killed === true || error.signal) {
61
+ return `Command timed out after ${timeoutMs}ms and was terminated.\nStdout: ${error.stdout ?? ''}\nStderr: ${error.stderr ?? ''}`;
62
+ }
49
63
  return `Command failed: ${error.message}\nStdout: ${error.stdout}\nStderr: ${error.stderr}`;
50
64
  }
51
65
  }
@@ -33,7 +33,7 @@ export const PromptOptimizerTool = {
33
33
  const contextMsg = args.context ? `Context: ${args.context}` : "Context: General AI Assistant interaction.";
34
34
  try {
35
35
  const completion = await client.chat.completions.create({
36
- model: config.model || 'gpt-4o',
36
+ model: config.model || 'gpt-5.6',
37
37
  messages: [
38
38
  {
39
39
  role: "system",
@@ -0,0 +1,32 @@
1
+ // Tool outputs are fed back into the model context, so they must be bounded.
2
+ // Two independent limits apply — whichever is hit first wins:
3
+ // - line limit (default 2000 lines)
4
+ // - byte limit (default 50KB, counted as UTF-8 bytes)
5
+ export const DEFAULT_MAX_LINES = 2000;
6
+ export const DEFAULT_MAX_BYTES = 50 * 1024;
7
+ export function truncateOutput(content, options = {}) {
8
+ const maxLines = options.maxLines ?? DEFAULT_MAX_LINES;
9
+ const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES;
10
+ const totalBytes = Buffer.byteLength(content, 'utf8');
11
+ const lines = content.length === 0 ? [] : content.split('\n');
12
+ const totalLines = lines.length;
13
+ if (totalLines <= maxLines && totalBytes <= maxBytes) {
14
+ return { content, truncated: false, totalLines, totalBytes, emittedLines: totalLines, emittedBytes: totalBytes };
15
+ }
16
+ let emittedLines = 0;
17
+ let emittedBytes = 0;
18
+ const kept = [];
19
+ for (const line of lines) {
20
+ if (kept.length + 1 > maxLines)
21
+ break;
22
+ const lineBytes = Buffer.byteLength(line, 'utf8') + (kept.length < lines.length - 1 ? 1 : 0);
23
+ if (emittedBytes + lineBytes > maxBytes)
24
+ break;
25
+ kept.push(line);
26
+ emittedLines++;
27
+ emittedBytes += lineBytes;
28
+ }
29
+ const notice = `[Truncated: showing ${emittedLines} of ${totalLines} lines, ${emittedBytes} of ${totalBytes} bytes]`;
30
+ const out = kept.length > 0 ? kept.join('\n') + '\n' + notice : notice;
31
+ return { content: out, truncated: true, totalLines, totalBytes, emittedLines, emittedBytes };
32
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "autoclaw",
3
- "version": "1.1.1",
3
+ "version": "1.2.0",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "bin": {
@@ -10,7 +10,8 @@
10
10
  "build": "tsc",
11
11
  "start": "node dist/index.js",
12
12
  "dev": "node --loader ts-node/esm src/index.ts",
13
- "test": "echo \"Error: no test specified\" && exit 1",
13
+ "test": "vitest run",
14
+ "test:watch": "vitest",
14
15
  "prepublishOnly": "npm run build"
15
16
  },
16
17
  "files": [
@@ -65,6 +66,7 @@
65
66
  "@types/node": "^25.2.1",
66
67
  "@types/nodemailer": "^7.0.9",
67
68
  "ts-node": "^10.9.2",
68
- "typescript": "^5.9.3"
69
+ "typescript": "^5.9.3",
70
+ "vitest": "^4.1.4"
69
71
  }
70
72
  }