autoclaw 1.1.1 → 1.3.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,31 @@ 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`.
113
+
114
+ ### Batch Mode (Swarm Worker)
115
+ Feed a JSONL manifest of tasks; each task runs in a fresh, isolated agent (one task's context never leaks into another) and per-task results are written as JSONL:
116
+ ```bash
117
+ autoclaw batch tasks.jsonl -y # results -> tasks.results.jsonl
118
+ autoclaw batch tasks.jsonl -o out.jsonl --fail-fast
119
+ ```
120
+ Manifest lines are `{"id": "...", "task": "..."}` — `id` is optional (defaults to `task-N`); blank lines and `#` comments are skipped. Optional per-task overrides: `maxSteps`, `model`, `provider`.
121
+
122
+ One failing task does not stop the batch (use `--fail-fast` for that). The process exits `0` when every task completed, `1` otherwise, so cron and K8s Jobs can detect bad batches. Task output stays human-readable on stdout — the results file is the machine-readable contract, with `status`, `steps`, `message`, `error` and `usage` per task.
100
123
 
101
124
  ### Auto-Confirm (CI/CD)
102
125
  Automatically approve all tool executions (dangerous, use with caution or in sandboxes).
@@ -105,24 +128,36 @@ autoclaw "Refactor src/index.ts to use ES modules" -y
105
128
  ```
106
129
 
107
130
  ### CLI Options
108
- - `-m, --model <model>`: Specify the LLM model (default: `gpt-4o`).
131
+ - `-m, --model <model>`: Specify the LLM model (default: `gpt-5.6`).
132
+ - `-P, --provider <name>`: Use a provider preset (see [Providers](#providers)).
109
133
  - `-n, --no-interactive`: Exit after processing the initial query (Headless mode).
110
134
  - `-y, --yes`: Auto-confirm all tool executions (e.g., shell commands).
135
+ - `--json`: Emit NDJSON events on stdout (for orchestrators; use with `-n`).
136
+
137
+ ### Providers
138
+ AutoClaw works with any OpenAI-compatible endpoint. Built-in presets fill in the base URL and a default model for you:
139
+ ```bash
140
+ autoclaw -P deepseek "Check disk usage and save a report" -y -n
141
+ ```
142
+ 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
143
 
112
144
  ## Configuration
113
145
 
114
146
  AutoClaw uses a hierarchical configuration system.
115
147
 
116
148
  **Priority Order (Highest to Lowest):**
117
- 1. **CLI Arguments**: (e.g., `-m gpt-4o`)
149
+ 1. **CLI Arguments**: (e.g., `-m gpt-5.6`)
118
150
  2. **Environment Variables**: (`OPENAI_API_KEY`, `.env` file)
119
151
  3. **Project Config**: (`./.autoclaw/setting.json` in current directory)
120
152
  4. **Global Config**: (`~/.autoclaw/setting.json`)
121
153
 
122
154
  ### Supported Configuration Keys (JSON)
155
+ - `provider`: Provider preset name (e.g. `deepseek`).
123
156
  - `apiKey`: Your OpenAI API Key.
124
157
  - `baseUrl`: Custom Base URL (e.g., for DeepSeek or LocalLLM).
125
158
  - `model`: Default model to use.
159
+ - `maxSteps`: Max LLM turns per task before the agent stops (default: `25`).
160
+ - `shellTimeout`: Shell command timeout in milliseconds (default: `120000`).
126
161
  - `tavilyApiKey`: API Key for Tavily Web Search.
127
162
  - `smtpHost`, `smtpPort`, `smtpUser`, `smtpPass`, `smtpFrom`: SMTP Email settings.
128
163
  - `feishuWebhook`, `dingtalkWebhook`, `wecomWebhook`: Notification webhooks.
@@ -131,13 +166,20 @@ AutoClaw uses a hierarchical configuration system.
131
166
  Create a file at `.autoclaw/setting.json`:
132
167
  ```json
133
168
  {
134
- "model": "gpt-3.5-turbo",
169
+ "model": "gpt-5.6",
135
170
  "baseUrl": "https://api.deepseek.com/v1"
136
171
  }
137
172
  ```
138
173
 
139
174
  > **⚠️ 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
175
 
176
+ ### Environment Variables
177
+ - `OPENAI_API_KEY`, `OPENAI_BASE_URL`, `OPENAI_MODEL`: main LLM settings.
178
+ - `AUTOCLOW_PROVIDER`: provider preset used when `-P` is not passed.
179
+ - `AUTOCLOW_MAX_STEPS`, `AUTOCLOW_SHELL_TIMEOUT`: reliability limits (max LLM turns per task; shell timeout in ms).
180
+ - `AUTOCLOW_INCLUDE_USAGE`: set to `1`/`true` to request token usage from the API (opt-in).
181
+ - `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.
182
+
141
183
  ## Integrations
142
184
 
143
185
  ### Web Search (Tavily)
@@ -158,6 +200,14 @@ Built-in utility to provide the agent with the current system time, ensuring acc
158
200
 
159
201
  ## Docker Support
160
202
 
203
+ ### Build & Run
204
+ 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:
205
+ ```bash
206
+ docker build -t autoclaw .
207
+ docker run --rm -v "$PWD":/workspace -w /workspace -e OPENAI_API_KEY=sk-... autoclaw "Check disk usage and save a report" -y -n
208
+ ```
209
+ 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.
210
+
161
211
  ### Chinese Font Issues in Screenshots
162
212
  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
213
 
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,31 @@ 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`。
114
+
115
+ ### 批处理模式 (蜂群工人)
116
+ 把 JSONL 任务清单交给 AutoClaw;每个任务在全新的隔离 Agent 中执行(任务之间上下文互不污染),结果逐行写入 JSONL 文件:
117
+ ```bash
118
+ autoclaw batch tasks.jsonl -y # 结果 -> tasks.results.jsonl
119
+ autoclaw batch tasks.jsonl -o out.jsonl --fail-fast
120
+ ```
121
+ 清单每行为 `{"id": "...", "task": "..."}`——`id` 可省略(默认 `task-N`);空行和 `#` 注释行会跳过。可选的每任务覆盖项:`maxSteps`、`model`、`provider`。
122
+
123
+ 单个任务失败不会中断批次(需要中断用 `--fail-fast`)。全部完成时进程退出 `0`,否则 `1`,cron 和 K8s Job 由此感知批次成败。任务输出保持人类可读——结果文件才是机器可读契约,含每个任务的 `status`、`steps`、`message`、`error`、`usage`。
101
124
 
102
125
  ### 自动确认 (CI/CD)
103
126
  自动批准所有工具执行(危险操作,请谨慎使用或在沙箱环境下运行)。
@@ -106,24 +129,36 @@ autoclaw "将 src/index.ts 重构为使用 ES 模块" -y
106
129
  ```
107
130
 
108
131
  ### CLI 选项
109
- - `-m, --model <model>`: 指定 LLM 模型 (默认: `gpt-4o`)。
132
+ - `-m, --model <model>`: 指定 LLM 模型 (默认: `gpt-5.6`)。
133
+ - `-P, --provider <name>`: 使用 provider 预设 (见 [Provider 预设](#provider-预设))。
110
134
  - `-n, --no-interactive`: 处理完初始查询后退出 (无头模式)。
111
135
  - `-y, --yes`: 自动确认所有工具执行 (例如 Shell 命令)。
136
+ - `--json`: 在 stdout 输出 NDJSON 事件流 (供编排器使用,配合 `-n`)。
137
+
138
+ ### Provider 预设
139
+ AutoClaw 可对接任意 OpenAI 兼容端点。内置预设可自动填好 Base URL 和默认模型:
140
+ ```bash
141
+ autoclaw -P deepseek "检查磁盘使用情况并保存报告" -y -n
142
+ ```
143
+ 可用预设:`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
144
 
113
145
  ## 配置
114
146
 
115
147
  AutoClaw 使用层级配置系统。
116
148
 
117
149
  **优先级排序 (从高到低):**
118
- 1. **CLI 参数**: (例如 `-m gpt-4o`)
150
+ 1. **CLI 参数**: (例如 `-m gpt-5.6`)
119
151
  2. **环境变量**: (`OPENAI_API_KEY`, `.env` 文件)
120
152
  3. **项目配置**: (当前目录下的 `./.autoclaw/setting.json`)
121
153
  4. **全局配置**: (`~/.autoclaw/setting.json`)
122
154
 
123
155
  ### 支持的配置键 (JSON)
156
+ - `provider`: Provider 预设名 (如 `deepseek`)。
124
157
  - `apiKey`: 您的 OpenAI API 密钥。
125
158
  - `baseUrl`: 自定义 API 基础地址 (例如 DeepSeek 或本地 LLM)。
126
159
  - `model`: 默认使用的模型。
160
+ - `maxSteps`: 单任务最大 LLM 轮数,超出后自动停止 (默认: `25`)。
161
+ - `shellTimeout`: Shell 命令超时时间(毫秒)(默认: `120000`)。
127
162
  - `tavilyApiKey`: Tavily 网页搜索的 API 密钥。
128
163
  - `smtpHost`, `smtpPort`, `smtpUser`, `smtpPass`, `smtpFrom`: SMTP 邮件设置。
129
164
  - `feishuWebhook`, `dingtalkWebhook`, `wecomWebhook`: 通知钩子地址。
@@ -132,13 +167,20 @@ AutoClaw 使用层级配置系统。
132
167
  在 `.autoclaw/setting.json` 创建文件:
133
168
  ```json
134
169
  {
135
- "model": "gpt-3.5-turbo",
170
+ "model": "gpt-5.6",
136
171
  "baseUrl": "https://api.deepseek.com/v1"
137
172
  }
138
173
  ```
139
174
 
140
175
  > **⚠️ 安全警告**: 如果您在 `.autoclaw/setting.json` 中存储了 `apiKey` 或机密信息,请务必将 `.autoclaw/` 添加到您的 `.gitignore` 文件中,以防泄露!
141
176
 
177
+ ### 环境变量
178
+ - `OPENAI_API_KEY`, `OPENAI_BASE_URL`, `OPENAI_MODEL`: 主模型设置。
179
+ - `AUTOCLOW_PROVIDER`: 未传 `-P` 时使用的 provider 预设。
180
+ - `AUTOCLOW_MAX_STEPS`, `AUTOCLOW_SHELL_TIMEOUT`: 稳定性限制(单任务最大轮数;Shell 超时毫秒数)。
181
+ - `AUTOCLOW_INCLUDE_USAGE`: 设为 `1`/`true` 时向 API 请求 token 用量(可选开启)。
182
+ - `TAVILY_API_KEY`, `SMTP_HOST`/`SMTP_PORT`/`SMTP_USER`/`SMTP_PASS`, `FEISHU_WEBHOOK`/`FEISHU_KEYWORD`, `DINGTALK_WEBHOOK`/`DINGTALK_KEYWORD`, `WECOM_WEBHOOK`/`WECOM_KEYWORD`: 工具凭据,可作为 setup 的替代方式。
183
+
142
184
  ## 集成功能
143
185
 
144
186
  ### 网页搜索 (Tavily)
@@ -159,6 +201,14 @@ AutoClaw 使用层级配置系统。
159
201
 
160
202
  ## Docker 支持
161
203
 
204
+ ### 构建与运行
205
+ 仓库自带多阶段构建的 `Dockerfile`(node:22-alpine,跳过浏览器下载保持镜像苗条)。容器内直接运行无头一次性任务,配合目录挂载操作当前目录:
206
+ ```bash
207
+ docker build -t autoclaw .
208
+ docker run --rm -v "$PWD":/workspace -w /workspace -e OPENAI_API_KEY=sk-... autoclaw "检查磁盘使用情况并保存报告" -y -n
209
+ ```
210
+ 注意:默认镜像中未内置浏览器,基于浏览器的工具(`read_website` / `take_screenshot`)不可用——它们会返回友好的安装提示,而不是报错崩溃。
211
+
162
212
  ### 截图中的中文显示问题
163
213
  在 Docker 容器(尤其是 Alpine 或 Debian Slim)中运行时,网页截图中的中文可能会显示为方块("豆腐块")。表情符号(如 🔥)也可能显示为方块。
164
214
 
package/dist/agent.js CHANGED
@@ -4,14 +4,26 @@ 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;
12
+ // The shell behind child_process.exec differs by platform; telling the model
13
+ // up front avoids trial-and-error turns (cmd.exe rejects ; and $()).
14
+ export function buildShellInfo(platform = os.platform()) {
15
+ if (platform === 'win32') {
16
+ return 'cmd.exe (Windows). Chain commands with && only, there is no $() command substitution, mkdir has no -p flag, and native tool output may be GBK-garbled. For system queries prefer: powershell -Command "..."';
17
+ }
18
+ return 'POSIX shell (sh). Standard Unix tools apply.';
19
+ }
8
20
  export class Agent {
9
21
  client;
10
22
  messages;
11
23
  model;
12
24
  config;
13
25
  lastOutputFile = null;
14
- constructor(apiKey, baseURL, model = 'gpt-4-turbo-preview', config = {}) {
26
+ constructor(apiKey, baseURL, model = 'gpt-5.6', config = {}) {
15
27
  this.client = new OpenAI({
16
28
  apiKey: apiKey,
17
29
  baseURL: baseURL
@@ -21,6 +33,7 @@ export class Agent {
21
33
  const systemInfo = `
22
34
  System Information:
23
35
  - OS: ${os.type()} ${os.release()} (${os.platform()})
36
+ - Shell: ${buildShellInfo()}
24
37
  - Architecture: ${os.arch()}
25
38
  - Node.js Version: ${process.version}
26
39
  - Current Working Directory: ${process.cwd()}
@@ -28,6 +41,9 @@ System Information:
28
41
  - Home Directory: ${os.homedir()}
29
42
  - Current Date: ${new Date().toLocaleString()}
30
43
  `;
44
+ const windowsShellRule = os.platform() === 'win32'
45
+ ? `\n8. Mind the shell noted above: on Windows it is cmd.exe, not bash — && only, no \$(...) substitution, no mkdir -p, GBK output possible; prefer powershell -Command "..." for system queries.`
46
+ : '';
31
47
  this.messages = [
32
48
  {
33
49
  role: "system",
@@ -48,59 +64,133 @@ WHAT YOU CAN DO:
48
64
  RULES OF ENGAGEMENT:
49
65
  1. One shot, not one chat. Produce working results, not conversation. Be terse.
50
66
  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.
67
+ 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
68
  4. Container-friendly: stick to standard Unix tools available in Alpine/Debian slim images. No GUI apps, no browser-based debug tools.
53
69
  5. For creative or complex tasks (image prompts, long-form writing, intricate scripts): call optimize_prompt first. It significantly raises output quality.
54
70
  6. If a command fails, diagnose and try one alternative. Don't retry the same thing, don't give up on first error.
55
- 7. Read before write. When modifying a file, read it first. When installing a package, check if it's already there.
71
+ 7. Read before write. When modifying a file, read it first. When installing a package, check if it's already there.${windowsShellRule}
56
72
  `
57
73
  }
58
74
  ];
59
75
  }
76
+ get jsonMode() {
77
+ return !!this.config?.jsonMode;
78
+ }
79
+ emitEvent(event) {
80
+ if (this.jsonMode)
81
+ console.log(JSON.stringify(event));
82
+ }
83
+ // Tool handlers print progress via console; in JSON mode stdout is an NDJSON
84
+ // contract, so route those prints to stderr for the duration of the call.
85
+ async runToolQuietly(run) {
86
+ const original = [console.log, console.info, console.warn, console.error];
87
+ const toStderr = (...args) => process.stderr.write(util.format(...args) + '\n');
88
+ [console.log, console.info, console.warn, console.error] = [toStderr, toStderr, toStderr, toStderr];
89
+ try {
90
+ return await run();
91
+ }
92
+ finally {
93
+ [console.log, console.info, console.warn, console.error] = original;
94
+ }
95
+ }
60
96
  async chat(userInput) {
61
97
  this.messages.push({ role: "user", content: userInput });
98
+ const maxSteps = Number(this.config?.maxSteps || process.env.AUTOCLOW_MAX_STEPS || DEFAULT_MAX_STEPS);
62
99
  let active = true;
100
+ let step = 0;
101
+ let status = 'completed';
102
+ let errorMessage;
103
+ let lastContent = null;
104
+ const totalUsage = { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 };
105
+ let sawUsage = false;
106
+ this.emitEvent({ event: 'run_start', model: this.model, task: userInput });
63
107
  while (active) {
64
- const spinner = ora('Thinking...').start();
108
+ if (step >= maxSteps) {
109
+ status = 'max_steps';
110
+ if (!this.jsonMode) {
111
+ console.log(chalk.yellow(`\n[MaxSteps] Reached the ${maxSteps}-turn limit; stopping to avoid a runaway loop.`));
112
+ }
113
+ break;
114
+ }
115
+ step++;
116
+ const spinner = this.jsonMode
117
+ ? { stop() { }, fail() { }, text: '' }
118
+ : ora('Thinking...').start();
119
+ let stream;
65
120
  try {
66
- const stream = await this.client.chat.completions.create({
121
+ // Retries cover request setup and the header phase; a failure after
122
+ // the stream started yielding chunks is not retried, because partial
123
+ // output may already have been printed.
124
+ stream = await withRetry(async () => this.client.chat.completions.create({
67
125
  model: this.model,
68
126
  messages: this.messages,
69
127
  tools: getToolDefinitions(),
70
128
  tool_choice: "auto",
71
- stream: true
129
+ stream: true,
130
+ // Not every OpenAI-compatible provider accepts stream_options;
131
+ // usage tracking is therefore opt-in only.
132
+ ...(this.config?.includeUsage ? { stream_options: { include_usage: true } } : {})
133
+ }), {
134
+ onRetry: (err, nextAttempt, delayMs) => {
135
+ spinner.text = `API error (${err.message}); retrying in ${Math.round(delayMs / 1000)}s (attempt ${nextAttempt})...`;
136
+ }
72
137
  });
73
- let content = '';
74
- let reasoningContent = '';
75
- let toolCalls = [];
76
- let contentStarted = false;
77
- let reasoningStarted = false;
78
- const toolNamesSeen = new Set();
138
+ }
139
+ catch (error) {
140
+ spinner.fail('Error during processing');
141
+ if (!this.jsonMode)
142
+ console.error(chalk.red(error.message));
143
+ status = 'error';
144
+ errorMessage = error.message;
145
+ active = false;
146
+ break;
147
+ }
148
+ let content = '';
149
+ let reasoningContent = '';
150
+ let toolCalls = [];
151
+ let contentStarted = false;
152
+ let reasoningStarted = false;
153
+ const toolNamesSeen = new Set();
154
+ try {
79
155
  for await (const chunk of stream) {
156
+ if (chunk.usage) {
157
+ sawUsage = true;
158
+ totalUsage.prompt_tokens += chunk.usage.prompt_tokens ?? 0;
159
+ totalUsage.completion_tokens += chunk.usage.completion_tokens ?? 0;
160
+ totalUsage.total_tokens += chunk.usage.total_tokens ?? 0;
161
+ }
80
162
  const delta = chunk.choices[0]?.delta;
81
163
  // Handle reasoning/thinking content (e.g., DeepSeek)
82
164
  if (delta?.reasoning_content) {
83
165
  if (!reasoningStarted) {
84
166
  spinner.stop();
85
- process.stdout.write(chalk.dim('\n[Thinking] '));
167
+ if (!this.jsonMode) {
168
+ process.stdout.write(chalk.dim('\n[Thinking] '));
169
+ }
86
170
  reasoningStarted = true;
87
171
  }
88
- process.stdout.write(chalk.dim(delta.reasoning_content));
172
+ if (!this.jsonMode) {
173
+ process.stdout.write(chalk.dim(delta.reasoning_content));
174
+ }
89
175
  reasoningContent += delta.reasoning_content;
90
176
  }
91
177
  // Handle regular content
92
178
  if (delta?.content) {
93
179
  if (!contentStarted) {
94
180
  spinner.stop();
95
- if (reasoningStarted)
96
- process.stdout.write('\n');
97
- process.stdout.write(chalk.blue("AutoClaw: "));
181
+ if (!this.jsonMode) {
182
+ if (reasoningStarted)
183
+ process.stdout.write('\n');
184
+ process.stdout.write(chalk.blue("AutoClaw: "));
185
+ }
98
186
  contentStarted = true;
99
187
  }
100
- process.stdout.write(delta.content);
188
+ if (!this.jsonMode) {
189
+ process.stdout.write(delta.content);
190
+ }
101
191
  content += delta.content;
102
192
  }
103
- // Handle tool calls - show name as soon as available
193
+ // Handle tool calls
104
194
  if (delta?.tool_calls) {
105
195
  for (const tc of delta.tool_calls) {
106
196
  const idx = tc.index;
@@ -113,45 +203,76 @@ RULES OF ENGAGEMENT:
113
203
  toolCalls[idx].function.name += tc.function.name;
114
204
  if (tc.function?.arguments)
115
205
  toolCalls[idx].function.arguments += tc.function.arguments;
116
- // Show tool name as soon as it's complete
117
206
  if (tc.function?.name && !toolNamesSeen.has(idx)) {
118
207
  toolNamesSeen.add(idx);
119
208
  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`));
209
+ if (!this.jsonMode) {
210
+ if (contentStarted)
211
+ process.stdout.write('\n');
212
+ if (reasoningStarted && !contentStarted)
213
+ process.stdout.write('\n');
214
+ process.stdout.write(chalk.cyan(`[Calling] ${tc.function.name}\n`));
215
+ }
125
216
  }
126
217
  }
127
218
  }
128
219
  }
129
- if (reasoningStarted) {
220
+ }
221
+ catch (error) {
222
+ spinner.fail('Error during processing');
223
+ if (!this.jsonMode)
224
+ console.error(chalk.red(error.message));
225
+ status = 'error';
226
+ errorMessage = error.message;
227
+ active = false;
228
+ break;
229
+ }
230
+ if (!this.jsonMode) {
231
+ if (reasoningStarted)
130
232
  console.log(); // newline after reasoning
131
- }
132
- if (contentStarted) {
233
+ if (contentStarted)
133
234
  console.log(); // newline after streamed content
134
- }
135
- if (!reasoningStarted && !contentStarted) {
235
+ if (!reasoningStarted && !contentStarted)
136
236
  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);
237
+ }
238
+ // Build the full message for history
239
+ const message = { role: "assistant" };
240
+ if (content)
241
+ message.content = content;
242
+ if (reasoningContent)
243
+ message.reasoning_content = reasoningContent;
244
+ if (toolCalls.length > 0) {
245
+ message.tool_calls = toolCalls;
246
+ message.content = message.content || null;
247
+ }
248
+ this.messages.push(message);
249
+ if (content)
250
+ lastContent = content;
251
+ if (toolCalls.length > 0) {
252
+ for (const toolCall of toolCalls) {
253
+ if (toolCall.type !== 'function')
254
+ continue;
255
+ const functionName = toolCall.function.name;
256
+ let functionArgs;
257
+ try {
258
+ functionArgs = JSON.parse(toolCall.function.arguments || '{}');
259
+ }
260
+ catch (parseError) {
261
+ // Feed the failure back so the model can correct itself next turn
262
+ if (!this.jsonMode) {
263
+ console.log(chalk.red(`\n[Tool] ${functionName} — malformed arguments (not valid JSON)`));
264
+ }
265
+ this.messages.push({
266
+ role: "tool",
267
+ tool_call_id: toolCall.id,
268
+ content: `Error: arguments for ${functionName} were not valid JSON (${parseError.message}). Re-issue the tool call with well-formed JSON arguments.`
269
+ });
270
+ continue;
271
+ }
272
+ if (this.jsonMode) {
273
+ this.emitEvent({ event: 'tool_call', step, tool: functionName, args: functionArgs });
274
+ }
275
+ else {
155
276
  // Display tool call info
156
277
  console.log(chalk.cyan(`\n[Tool] ${functionName}`));
157
278
  const argsStr = JSON.stringify(functionArgs, null, 2);
@@ -163,56 +284,82 @@ RULES OF ENGAGEMENT:
163
284
  else {
164
285
  console.log(chalk.dim(argsStr));
165
286
  }
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
287
+ }
288
+ let toolResult;
289
+ try {
290
+ const run = () => executeToolHandler(functionName, functionArgs, this.config);
291
+ toolResult = this.jsonMode ? await this.runToolQuietly(run) : await run();
292
+ }
293
+ catch (err) {
294
+ toolResult = `Error: ${err.message}`;
295
+ }
296
+ // Bound what goes back into the model context; the full output is
297
+ // kept on disk for /view.
298
+ const MAX_PREVIEW_LINES = 20;
299
+ const truncation = truncateOutput(toolResult);
300
+ const boundedResult = truncation.content;
301
+ const resultLines = boundedResult.split('\n');
302
+ let outputFile = null;
303
+ if (resultLines.length > MAX_PREVIEW_LINES || truncation.truncated) {
304
+ outputFile = await this.saveOutput(functionName, toolResult);
305
+ this.lastOutputFile = outputFile;
306
+ if (!this.jsonMode) {
307
+ console.log(chalk.green(`[Result]`));
182
308
  console.log(resultLines.slice(0, MAX_PREVIEW_LINES).join('\n'));
183
309
  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 });
310
+ if (remaining > 0) {
311
+ console.log(chalk.dim(`\n ... ${remaining} more lines (${resultLines.length} lines total)`));
189
312
  }
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
313
  console.log(chalk.dim(` Type '/view' to see full output`));
195
314
  }
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
315
+ }
316
+ else if (!this.jsonMode) {
317
+ console.log(chalk.green(`[Result]`));
318
+ console.log(boundedResult);
319
+ this.lastOutputFile = null;
320
+ }
321
+ if (this.jsonMode) {
322
+ this.emitEvent({
323
+ event: 'tool_result',
324
+ step,
325
+ tool: functionName,
326
+ truncated: truncation.truncated,
327
+ bytes: truncation.totalBytes,
328
+ ...(outputFile ? { output_file: outputFile } : {})
204
329
  });
205
330
  }
206
- }
207
- else {
208
- active = false;
331
+ this.messages.push({
332
+ role: "tool",
333
+ tool_call_id: toolCall.id,
334
+ content: boundedResult
335
+ });
209
336
  }
210
337
  }
211
- catch (error) {
212
- spinner.fail('Error during processing');
213
- console.error(chalk.red(error.message));
338
+ else {
214
339
  active = false;
215
340
  }
341
+ if (sawUsage) {
342
+ this.emitEvent({ event: 'usage', step, ...totalUsage });
343
+ }
344
+ }
345
+ const result = {
346
+ status,
347
+ steps: step,
348
+ message: lastContent,
349
+ ...(errorMessage ? { error: errorMessage } : {}),
350
+ ...(sawUsage ? { usage: totalUsage } : {})
351
+ };
352
+ this.emitEvent({ event: 'run_end', ...result });
353
+ return result;
354
+ }
355
+ async saveOutput(functionName, toolResult) {
356
+ const outputDir = path.join(os.homedir(), '.autoclaw', 'output');
357
+ if (!fs.existsSync(outputDir)) {
358
+ fs.mkdirSync(outputDir, { recursive: true });
216
359
  }
360
+ const ts = new Date().toISOString().replace(/[:.]/g, '-');
361
+ const outputFile = path.join(outputDir, `${functionName}_${ts}.txt`);
362
+ fs.writeFileSync(outputFile, toolResult, 'utf-8');
363
+ return outputFile;
217
364
  }
218
365
  }
package/dist/batch.js ADDED
@@ -0,0 +1,64 @@
1
+ // Blank lines and lines starting with '#' are skipped. Malformed lines are
2
+ // kept as errored entries (in manifest order) rather than dropped, so the
3
+ // result file accounts for every task the user submitted.
4
+ export function parseManifest(raw) {
5
+ return raw
6
+ .split('\n')
7
+ .map((line, idx) => {
8
+ const lineNo = idx + 1;
9
+ const trimmed = line.trim();
10
+ if (!trimmed || trimmed.startsWith('#'))
11
+ return null;
12
+ let parsed = null;
13
+ try {
14
+ parsed = JSON.parse(trimmed);
15
+ }
16
+ catch {
17
+ // fall through to the error entry below
18
+ }
19
+ const id = parsed && typeof parsed === 'object' && typeof parsed.id === 'string' && parsed.id.trim()
20
+ ? parsed.id.trim()
21
+ : `task-${lineNo}`;
22
+ if (!parsed || typeof parsed !== 'object' || typeof parsed.task !== 'string' || !parsed.task.trim()) {
23
+ return { lineNo, id, task: '', error: 'not valid JSON or missing "task" field' };
24
+ }
25
+ const entry = { lineNo, id, task: parsed.task.trim() };
26
+ if (typeof parsed.maxSteps === 'number' && parsed.maxSteps > 0)
27
+ entry.maxSteps = parsed.maxSteps;
28
+ if (typeof parsed.model === 'string' && parsed.model.trim())
29
+ entry.model = parsed.model.trim();
30
+ if (typeof parsed.provider === 'string' && parsed.provider.trim())
31
+ entry.provider = parsed.provider.trim();
32
+ return entry;
33
+ })
34
+ .filter((entry) => entry !== null);
35
+ }
36
+ export async function runBatch(entries, execute, options = {}) {
37
+ const total = entries.length;
38
+ const results = [];
39
+ let completed = 0;
40
+ let failed = 0;
41
+ for (const entry of entries) {
42
+ let result;
43
+ if (entry.error) {
44
+ result = { id: entry.id, status: 'error', error: `Manifest line ${entry.lineNo}: ${entry.error}`, durationMs: 0 };
45
+ }
46
+ else {
47
+ try {
48
+ result = await execute(entry);
49
+ }
50
+ catch (err) {
51
+ result = { id: entry.id, status: 'error', error: err?.message ?? String(err), durationMs: 0 };
52
+ }
53
+ }
54
+ if (result.status === 'completed')
55
+ completed++;
56
+ else
57
+ failed++;
58
+ results.push(result);
59
+ options.onResult?.(entry, result, results.length, total);
60
+ if (options.failFast && result.status !== 'completed')
61
+ break;
62
+ }
63
+ return { results, completed, failed };
64
+ }
package/dist/index.js CHANGED
@@ -4,6 +4,8 @@ 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 { parseManifest, runBatch } from './batch.js';
8
+ import { PROVIDER_PRESETS, providerNames, resolveProvider } from './providers.js';
7
9
  import * as fs from 'fs';
8
10
  import * as path from 'path';
9
11
  import * as os from 'os';
@@ -42,7 +44,7 @@ dotenv.config({ path: GLOBAL_ENV_FILE });
42
44
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
43
45
  // In dist/index.js, package.json is usually up one level in the root
44
46
  const pkgPath = path.join(__dirname, '..', 'package.json');
45
- let version = '1.0.2';
47
+ let version = '1.3.0';
46
48
  try {
47
49
  const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
48
50
  version = pkg.version;
@@ -56,8 +58,10 @@ program
56
58
  .description('A lightweight AI agent CLI tool')
57
59
  .version(version)
58
60
  .option('-m, --model <model>', 'Model to use')
61
+ .option('-P, --provider <name>', 'Use a provider preset (openai, deepseek, moonshot, dashscope, zhipu, openrouter, ollama)')
59
62
  .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)');
63
+ .option('-y, --yes', 'Auto-confirm all tool executions (e.g., shell commands)')
64
+ .option('--json', 'Emit NDJSON events on stdout (for orchestrators; use with -n)');
61
65
  program
62
66
  .command('setup')
63
67
  .description('Run the interactive setup wizard to configure API keys')
@@ -72,6 +76,15 @@ program
72
76
  const options = program.opts();
73
77
  await runChat(queryParts, options);
74
78
  });
79
+ program
80
+ .command('batch <manifest>')
81
+ .description('Run tasks from a JSONL manifest, each in a fresh agent ({"id":"...","task":"..."})')
82
+ .option('-o, --output <file>', 'Per-task results as JSONL (default: <manifest base>.results.jsonl)')
83
+ .option('--fail-fast', 'Stop on the first failed task')
84
+ .action(async (manifest, cmdOptions) => {
85
+ const options = program.opts();
86
+ await runBatchCommand(manifest, options, cmdOptions);
87
+ });
75
88
  program.parse(process.argv);
76
89
  async function runSetup(options = {}) {
77
90
  const isProject = options.project;
@@ -92,6 +105,20 @@ async function runSetup(options = {}) {
92
105
  return '******';
93
106
  return `${secret.slice(0, 3)}...${secret.slice(-4)}`;
94
107
  }
108
+ const providerAnswer = await inquirer.prompt([
109
+ {
110
+ type: 'list',
111
+ name: 'provider',
112
+ message: 'Select your LLM provider:',
113
+ choices: [
114
+ ...providerNames().map((name) => ({ name: `${PROVIDER_PRESETS[name].label} (${name})`, value: name })),
115
+ { name: 'Custom OpenAI-compatible endpoint', value: 'custom' }
116
+ ],
117
+ default: currentConfig.provider || 'openai'
118
+ }
119
+ ]);
120
+ const provider = providerAnswer.provider;
121
+ const preset = resolveProvider(provider === 'custom' ? undefined : provider);
95
122
  const answers = await inquirer.prompt([
96
123
  {
97
124
  type: 'password',
@@ -112,13 +139,13 @@ async function runSetup(options = {}) {
112
139
  type: 'input',
113
140
  name: 'baseUrl',
114
141
  message: 'Enter API Base URL:',
115
- default: currentConfig.baseUrl || 'https://api.openai.com/v1'
142
+ default: currentConfig.baseUrl || preset?.baseUrl || 'https://api.openai.com/v1'
116
143
  },
117
144
  {
118
145
  type: 'input',
119
146
  name: 'model',
120
147
  message: 'Enter default Model:',
121
- default: currentConfig.model || 'gpt-4o'
148
+ default: currentConfig.model || preset?.defaultModel || 'gpt-5.6'
122
149
  },
123
150
  {
124
151
  type: 'confirm',
@@ -319,6 +346,7 @@ async function runSetup(options = {}) {
319
346
  apiKey: finalApiKey,
320
347
  baseUrl: answers.baseUrl,
321
348
  model: answers.model,
349
+ provider: provider === 'custom' ? undefined : provider,
322
350
  ...imageConfig,
323
351
  ...emailConfig,
324
352
  ...searchConfig,
@@ -336,11 +364,9 @@ async function runSetup(options = {}) {
336
364
  console.error(chalk.red(`Failed to write config: ${error.message}`));
337
365
  }
338
366
  }
339
- async function runChat(queryParts, options) {
340
- if (options.interactive) {
341
- console.log(chalk.bold.cyan("Welcome to AutoClaw CLI 🦞"));
342
- }
343
- const initialQuery = queryParts.join(' ');
367
+ // Shared by `chat` and `batch`: resolve credentials, endpoints and runtime
368
+ // flags from CLI args > env > project config > global config > provider preset.
369
+ async function resolveRuntime(options) {
344
370
  // 1. Load Global JSON
345
371
  const globalConfig = loadJsonConfig(GLOBAL_CONFIG_FILE);
346
372
  // 2. Load Local JSON (Project Level)
@@ -351,18 +377,31 @@ async function runChat(queryParts, options) {
351
377
  // 3. Merge Configs for Tool Usage
352
378
  // Priority: Local > Global
353
379
  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';
380
+ // 4. Resolve Provider Preset (CLI > Env > Config)
381
+ const providerName = options.provider || process.env.AUTOCLOW_PROVIDER || fullConfig.provider;
382
+ const preset = resolveProvider(providerName);
383
+ if (providerName && !preset) {
384
+ console.log(chalk.yellow(`Unknown provider '${providerName}'. Known providers: ${providerNames().join(', ')}`));
385
+ }
386
+ // 5. Resolve Env Vars (CLI > Env > Config > Provider preset)
387
+ let apiKey = process.env.OPENAI_API_KEY || fullConfig.apiKey || (preset?.apiKeyEnv ? process.env[preset.apiKeyEnv] : undefined);
388
+ let baseURL = process.env.OPENAI_BASE_URL || fullConfig.baseUrl || preset?.baseUrl;
389
+ let model = options.model || process.env.OPENAI_MODEL || fullConfig.model || preset?.defaultModel || 'gpt-5.6';
358
390
  // Inject Runtime Flags
359
391
  fullConfig.autoConfirm = options.yes;
392
+ fullConfig.jsonMode = !!options.json;
393
+ // Usage tracking is opt-in: not every OpenAI-compatible provider accepts
394
+ // stream_options.include_usage, so never force it on.
395
+ fullConfig.includeUsage =
396
+ !!fullConfig.includeUsage ||
397
+ process.env.AUTOCLOW_INCLUDE_USAGE === '1' ||
398
+ process.env.AUTOCLOW_INCLUDE_USAGE === 'true';
360
399
  // Inject Env vars
361
400
  if (process.env.SMTP_HOST)
362
401
  fullConfig.smtpHost = process.env.SMTP_HOST;
363
402
  if (process.env.SMTP_PORT)
364
403
  fullConfig.smtpPort = process.env.SMTP_PORT;
365
- if (process.env.SMTP_User)
404
+ if (process.env.SMTP_USER)
366
405
  fullConfig.smtpUser = process.env.SMTP_USER;
367
406
  if (process.env.SMTP_PASS)
368
407
  fullConfig.smtpPass = process.env.SMTP_PASS;
@@ -393,9 +432,10 @@ async function runChat(queryParts, options) {
393
432
  if (doSetup) {
394
433
  await runSetup();
395
434
  const newConfig = loadJsonConfig(GLOBAL_CONFIG_FILE);
396
- apiKey = newConfig.apiKey;
397
- baseURL = newConfig.baseUrl;
398
- model = options.model || newConfig.model || 'gpt-4o';
435
+ const setupPreset = resolveProvider(newConfig.provider);
436
+ apiKey = newConfig.apiKey || (setupPreset?.apiKeyEnv ? process.env[setupPreset.apiKeyEnv] : undefined);
437
+ baseURL = newConfig.baseUrl || setupPreset?.baseUrl;
438
+ model = options.model || newConfig.model || setupPreset?.defaultModel || 'gpt-5.6';
399
439
  Object.assign(fullConfig, newConfig);
400
440
  }
401
441
  else {
@@ -407,6 +447,74 @@ async function runChat(queryParts, options) {
407
447
  console.error(chalk.red("API Key is still missing. Exiting."));
408
448
  process.exit(1);
409
449
  }
450
+ return { apiKey, baseURL, model, fullConfig };
451
+ }
452
+ async function runBatchCommand(manifestPath, globalOptions, cmdOptions) {
453
+ const { apiKey, baseURL, model, fullConfig } = await resolveRuntime(globalOptions);
454
+ let raw;
455
+ try {
456
+ raw = fs.readFileSync(manifestPath, 'utf-8');
457
+ }
458
+ catch (err) {
459
+ console.error(chalk.red(`Cannot read manifest ${manifestPath}: ${err.message}`));
460
+ process.exit(1);
461
+ }
462
+ const entries = parseManifest(raw);
463
+ if (entries.length === 0) {
464
+ console.error(chalk.red(`Manifest ${manifestPath} contains no tasks (blank lines and '#' comments are skipped).`));
465
+ process.exit(1);
466
+ }
467
+ const outputPath = cmdOptions.output || manifestPath.replace(/\.[^.]+$/, '') + '.results.jsonl';
468
+ console.log(chalk.bold.cyan(`AutoClaw Batch 🦞 ${entries.length} task(s)`));
469
+ console.log(chalk.dim(`Results: ${outputPath}\n`));
470
+ const startedAt = Date.now();
471
+ const { results, completed, failed } = await runBatch(entries, async (entry) => {
472
+ // A fresh Agent per task keeps contexts isolated; per-task overrides
473
+ // beat the globally resolved defaults.
474
+ const taskPreset = resolveProvider(entry.provider);
475
+ const taskModel = entry.model || taskPreset?.defaultModel || model;
476
+ const taskBaseURL = taskPreset?.baseUrl || baseURL;
477
+ const taskConfig = {
478
+ ...fullConfig,
479
+ // The results file is the machine-readable contract in batch mode.
480
+ jsonMode: false,
481
+ maxSteps: entry.maxSteps ?? fullConfig.maxSteps
482
+ };
483
+ const agent = new Agent(apiKey, taskBaseURL, taskModel, taskConfig);
484
+ const start = Date.now();
485
+ const runResult = await agent.chat(entry.task);
486
+ return {
487
+ id: entry.id,
488
+ status: runResult.status,
489
+ steps: runResult.steps,
490
+ message: runResult.message ?? null,
491
+ ...(runResult.error ? { error: runResult.error } : {}),
492
+ ...(runResult.usage ? { usage: runResult.usage } : {}),
493
+ durationMs: Date.now() - start
494
+ };
495
+ }, {
496
+ failFast: !!cmdOptions.failFast,
497
+ onResult: (entry, result, done, total) => {
498
+ const color = result.status === 'completed' ? chalk.green : chalk.red;
499
+ console.log(color(`[${done}/${total}] ${entry.id} -> ${result.status} (${Math.round(result.durationMs / 1000)}s)`));
500
+ }
501
+ });
502
+ try {
503
+ fs.writeFileSync(outputPath, results.map(r => JSON.stringify(r)).join('\n') + '\n', 'utf-8');
504
+ }
505
+ catch (err) {
506
+ console.error(chalk.red(`Failed to write results to ${outputPath}: ${err.message}`));
507
+ process.exit(1);
508
+ }
509
+ console.log(chalk.cyan(`\nBatch done: ${completed}/${results.length} completed, ${failed} failed in ${Math.round((Date.now() - startedAt) / 1000)}s -> ${outputPath}`));
510
+ process.exit(failed > 0 ? 1 : 0);
511
+ }
512
+ async function runChat(queryParts, options) {
513
+ if (options.interactive) {
514
+ console.log(chalk.bold.cyan("Welcome to AutoClaw CLI 🦞"));
515
+ }
516
+ const initialQuery = queryParts.join(' ');
517
+ const { apiKey, baseURL, model, fullConfig } = await resolveRuntime(options);
410
518
  const agent = new Agent(apiKey, baseURL, model, fullConfig);
411
519
  if (options.interactive) {
412
520
  console.log(chalk.green(`Agent initialized with model: ${model}`));
@@ -417,10 +525,11 @@ async function runChat(queryParts, options) {
417
525
  if (options.interactive) {
418
526
  console.log(chalk.blue("\nProcessing initial request: ") + chalk.bold(initialQuery));
419
527
  }
420
- await agent.chat(initialQuery);
421
- // Headless mode exit
528
+ const result = await agent.chat(initialQuery);
529
+ // Headless mode exit — the exit code is the orchestrator-facing outcome:
530
+ // 0 completed, 1 hard failure, 2 step cap reached (task unfinished).
422
531
  if (!options.interactive) {
423
- process.exit(0);
532
+ process.exit(result.status === 'completed' ? 0 : result.status === 'max_steps' ? 2 : 1);
424
533
  }
425
534
  }
426
535
  // 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.3.0",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "bin": {
@@ -10,7 +10,9 @@
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",
15
+ "coverage": "vitest run --coverage",
14
16
  "prepublishOnly": "npm run build"
15
17
  },
16
18
  "files": [
@@ -64,7 +66,9 @@
64
66
  "@types/jsdom": "^27.0.0",
65
67
  "@types/node": "^25.2.1",
66
68
  "@types/nodemailer": "^7.0.9",
69
+ "@vitest/coverage-v8": "^4.1.11",
67
70
  "ts-node": "^10.9.2",
68
- "typescript": "^5.9.3"
71
+ "typescript": "^5.9.3",
72
+ "vitest": "^4.1.4"
69
73
  }
70
74
  }