autoclaw 1.2.0 → 1.3.1

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
@@ -111,6 +111,25 @@ autoclaw "Deploy and report" -y -n --json
111
111
  ```
112
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
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.
123
+
124
+ Long batches can stop and pick up where they left off, and can use local parallelism:
125
+ ```bash
126
+ autoclaw batch big.jsonl -y --resume # skip tasks already completed in the results file
127
+ autoclaw batch big.jsonl -y -c 4 # run up to 4 tasks in parallel
128
+ ```
129
+ Unattempted tasks are simply absent from the results file, so `--fail-fast` followed by `--resume` is a natural retry loop.
130
+
131
+ AutoClaw also keeps its own prompt lean: optional tools (web search, email, group notifications, image generation) only register once their credentials are configured, and in long loops older tool results in the model context are replaced by short excerpts.
132
+
114
133
  ### Auto-Confirm (CI/CD)
115
134
  Automatically approve all tool executions (dangerous, use with caution or in sandboxes).
116
135
  ```bash
@@ -148,6 +167,7 @@ AutoClaw uses a hierarchical configuration system.
148
167
  - `model`: Default model to use.
149
168
  - `maxSteps`: Max LLM turns per task before the agent stops (default: `25`).
150
169
  - `shellTimeout`: Shell command timeout in milliseconds (default: `120000`).
170
+ - `shell`: Force a shell for `execute_shell_command` (`bash`, `powershell`, `cmd`, `sh`; default: auto-detect — Git Bash > PowerShell > cmd on Windows).
151
171
  - `tavilyApiKey`: API Key for Tavily Web Search.
152
172
  - `smtpHost`, `smtpPort`, `smtpUser`, `smtpPass`, `smtpFrom`: SMTP Email settings.
153
173
  - `feishuWebhook`, `dingtalkWebhook`, `wecomWebhook`: Notification webhooks.
@@ -167,6 +187,7 @@ Create a file at `.autoclaw/setting.json`:
167
187
  - `OPENAI_API_KEY`, `OPENAI_BASE_URL`, `OPENAI_MODEL`: main LLM settings.
168
188
  - `AUTOCLOW_PROVIDER`: provider preset used when `-P` is not passed.
169
189
  - `AUTOCLOW_MAX_STEPS`, `AUTOCLOW_SHELL_TIMEOUT`: reliability limits (max LLM turns per task; shell timeout in ms).
190
+ - `AUTOCLOW_SHELL`: force the shell for shell commands (`bash`, `powershell`, `cmd`, `sh`).
170
191
  - `AUTOCLOW_INCLUDE_USAGE`: set to `1`/`true` to request token usage from the API (opt-in).
171
192
  - `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
193
 
package/README.zh-CN.md CHANGED
@@ -112,6 +112,25 @@ autoclaw "执行部署并汇报" -y -n --json
112
112
  ```
113
113
  Token 用量仅在设置 `AUTOCLOW_INCLUDE_USAGE=1`(或 `true`)时才收集——这是可选项,因为并非所有 OpenAI 兼容端点都接受 `stream_options.include_usage`。
114
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`。
124
+
125
+ 长批次可以中途停、从断点继续,也可以本地并行:
126
+ ```bash
127
+ autoclaw batch big.jsonl -y --resume # 跳过结果文件中已完成的任务
128
+ autoclaw batch big.jsonl -y -c 4 # 最多 4 个任务并行
129
+ ```
130
+ 未执行的任务不会出现在结果文件里,所以 `--fail-fast` 之后接 `--resume` 就是天然的重试循环。
131
+
132
+ AutoClaw 同时会自动给提示词瘦身:可选工具(网页搜索、邮件、群通知、图像生成)只在凭据配置后才会注册进工具定义;长循环中较早的工具结果会被替换为短摘要。
133
+
115
134
  ### 自动确认 (CI/CD)
116
135
  自动批准所有工具执行(危险操作,请谨慎使用或在沙箱环境下运行)。
117
136
  ```bash
@@ -149,6 +168,7 @@ AutoClaw 使用层级配置系统。
149
168
  - `model`: 默认使用的模型。
150
169
  - `maxSteps`: 单任务最大 LLM 轮数,超出后自动停止 (默认: `25`)。
151
170
  - `shellTimeout`: Shell 命令超时时间(毫秒)(默认: `120000`)。
171
+ - `shell`: 强制 `execute_shell_command` 使用的 shell (`bash`、`powershell`、`cmd`、`sh`;默认自动检测——Windows 上优先 Git Bash > PowerShell > cmd)。
152
172
  - `tavilyApiKey`: Tavily 网页搜索的 API 密钥。
153
173
  - `smtpHost`, `smtpPort`, `smtpUser`, `smtpPass`, `smtpFrom`: SMTP 邮件设置。
154
174
  - `feishuWebhook`, `dingtalkWebhook`, `wecomWebhook`: 通知钩子地址。
@@ -168,6 +188,7 @@ AutoClaw 使用层级配置系统。
168
188
  - `OPENAI_API_KEY`, `OPENAI_BASE_URL`, `OPENAI_MODEL`: 主模型设置。
169
189
  - `AUTOCLOW_PROVIDER`: 未传 `-P` 时使用的 provider 预设。
170
190
  - `AUTOCLOW_MAX_STEPS`, `AUTOCLOW_SHELL_TIMEOUT`: 稳定性限制(单任务最大轮数;Shell 超时毫秒数)。
191
+ - `AUTOCLOW_SHELL`: 强制 shell 命令使用的 shell (`bash`、`powershell`、`cmd`、`sh`)。
171
192
  - `AUTOCLOW_INCLUDE_USAGE`: 设为 `1`/`true` 时向 API 请求 token 用量(可选开启)。
172
193
  - `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
194
 
package/dist/agent.js CHANGED
@@ -5,10 +5,12 @@ import * as fs from 'fs';
5
5
  import * as os from 'os';
6
6
  import * as path from 'path';
7
7
  import * as util from 'util';
8
- import { getToolDefinitions, executeToolHandler } from './tools/index.js';
8
+ import { getToolDefinitions, executeToolHandler, listUnavailableTools } from './tools/index.js';
9
9
  import { withRetry } from './retry.js';
10
10
  import { truncateOutput } from './truncate.js';
11
+ import { buildShellInfo, resolveShellType } from './shell.js';
11
12
  const DEFAULT_MAX_STEPS = 25;
13
+ const TOOL_RESULT_TRIM_MARKER = 'older tool output trimmed';
12
14
  export class Agent {
13
15
  client;
14
16
  messages;
@@ -22,9 +24,11 @@ export class Agent {
22
24
  });
23
25
  this.model = model;
24
26
  this.config = config;
27
+ const shellType = resolveShellType(config);
25
28
  const systemInfo = `
26
29
  System Information:
27
30
  - OS: ${os.type()} ${os.release()} (${os.platform()})
31
+ - Shell: ${buildShellInfo(shellType)}
28
32
  - Architecture: ${os.arch()}
29
33
  - Node.js Version: ${process.version}
30
34
  - Current Working Directory: ${process.cwd()}
@@ -32,6 +36,27 @@ System Information:
32
36
  - Home Directory: ${os.homedir()}
33
37
  - Current Date: ${new Date().toLocaleString()}
34
38
  `;
39
+ const shellRule = shellType === 'cmd'
40
+ ? `\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.`
41
+ : shellType === 'powershell'
42
+ ? `\n8. The shell is Windows PowerShell: use PowerShell syntax — \$( ) works, but && does not in Windows PowerShell 5; use ; or separate tool calls instead.`
43
+ : '';
44
+ // Every turn resends every tool definition, so unconfigured capabilities
45
+ // are dropped from both the tool array and this capability list.
46
+ const unavailable = listUnavailableTools(config);
47
+ const has = (tool) => !unavailable.includes(tool);
48
+ const capabilities = [
49
+ '- Shell: execute_shell_command — run scripts, install packages, manage processes, interact with the OS',
50
+ '- Files: read_file / write_file — inspect logs, generate configs, produce reports',
51
+ has('web_search') ? '- Web: web_search — real-time information lookup' : null,
52
+ has('read_website') ? '- Web: read_website — extract article content from a URL' : null,
53
+ has('take_screenshot') ? '- Web: take_screenshot — capture page visuals' : null,
54
+ has('send_email') ? '- Communication: send_email — SMTP email delivery' : null,
55
+ has('send_notification') ? '- Communication: send_notification — push to Feishu/DingTalk/WeCom' : null,
56
+ has('generate_image') ? '- Creation: generate_image — AI image generation (DALL-E compatible)' : null,
57
+ has('optimize_prompt') ? '- Creation: optimize_prompt — refine raw prompts for creative/complex tasks (recommended before creative work)' : null,
58
+ '- Utility: get_current_datetime — accurate system time for temporal reasoning'
59
+ ].filter((line) => line !== null).join('\n');
35
60
  this.messages = [
36
61
  {
37
62
  role: "system",
@@ -42,12 +67,7 @@ You may be running on a developer workstation, a headless server, inside a Docke
42
67
  ${systemInfo}
43
68
 
44
69
  WHAT YOU CAN DO:
45
- - Shell: execute_shell_command — run scripts, install packages, manage processes, interact with the OS
46
- - Files: read_file / write_file — inspect logs, generate configs, produce reports
47
- - Web: web_search — real-time information lookup; read_website — extract article content; take_screenshot — capture page visuals
48
- - Communication: send_email — SMTP email delivery; send_notification — push to Feishu/DingTalk/WeCom
49
- - Creation: generate_image — AI image generation (DALL-E compatible); optimize_prompt — refine raw prompts for creative/complex tasks
50
- - Utility: get_current_datetime — accurate system time for temporal reasoning
70
+ ${capabilities}
51
71
 
52
72
  RULES OF ENGAGEMENT:
53
73
  1. One shot, not one chat. Produce working results, not conversation. Be terse.
@@ -56,7 +76,7 @@ RULES OF ENGAGEMENT:
56
76
  4. Container-friendly: stick to standard Unix tools available in Alpine/Debian slim images. No GUI apps, no browser-based debug tools.
57
77
  5. For creative or complex tasks (image prompts, long-form writing, intricate scripts): call optimize_prompt first. It significantly raises output quality.
58
78
  6. If a command fails, diagnose and try one alternative. Don't retry the same thing, don't give up on first error.
59
- 7. Read before write. When modifying a file, read it first. When installing a package, check if it's already there.
79
+ 7. Read before write. When modifying a file, read it first. When installing a package, check if it's already there.${shellRule}
60
80
  `
61
81
  }
62
82
  ];
@@ -100,6 +120,7 @@ RULES OF ENGAGEMENT:
100
120
  }
101
121
  break;
102
122
  }
123
+ this.trimOldToolResults();
103
124
  step++;
104
125
  const spinner = this.jsonMode
105
126
  ? { stop() { }, fail() { }, text: '' }
@@ -340,6 +361,25 @@ RULES OF ENGAGEMENT:
340
361
  this.emitEvent({ event: 'run_end', ...result });
341
362
  return result;
342
363
  }
364
+ // Every turn resends the full history, so early large tool results
365
+ // dominate context growth. Keep the most recent results intact and bound
366
+ // older ones to a short excerpt (full output stays on disk via /view when
367
+ // it was large enough to be saved).
368
+ trimOldToolResults() {
369
+ const toolIndexes = [];
370
+ this.messages.forEach((m, i) => {
371
+ if (m.role === 'tool')
372
+ toolIndexes.push(i);
373
+ });
374
+ const cutoff = toolIndexes.length - 3;
375
+ for (let k = 0; k < cutoff; k++) {
376
+ const msg = this.messages[toolIndexes[k]];
377
+ if (typeof msg.content === 'string' && msg.content.length > 512 && !msg.content.includes(TOOL_RESULT_TRIM_MARKER)) {
378
+ const original = msg.content.length;
379
+ msg.content = `${msg.content.slice(0, 256)}\n[${TOOL_RESULT_TRIM_MARKER}: ${original} bytes total; re-run the tool if you need the full output again]`;
380
+ }
381
+ }
382
+ }
343
383
  async saveOutput(functionName, toolResult) {
344
384
  const outputDir = path.join(os.homedir(), '.autoclaw', 'output');
345
385
  if (!fs.existsSync(outputDir)) {
package/dist/batch.js ADDED
@@ -0,0 +1,84 @@
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
+ let skipped = 0;
42
+ let stopped = false;
43
+ let done = 0;
44
+ const runEntry = async (i) => {
45
+ const entry = entries[i];
46
+ let result;
47
+ if (entry.error) {
48
+ result = { id: entry.id, status: 'error', error: `Manifest line ${entry.lineNo}: ${entry.error}`, durationMs: 0 };
49
+ }
50
+ else if (options.resume?.completedIds.has(entry.id)) {
51
+ result = options.resume.previousById.get(entry.id) ?? {
52
+ id: entry.id, status: 'error', error: 'missing previous result', durationMs: 0
53
+ };
54
+ skipped++;
55
+ }
56
+ else {
57
+ try {
58
+ result = await execute(entry);
59
+ }
60
+ catch (err) {
61
+ result = { id: entry.id, status: 'error', error: err?.message ?? String(err), durationMs: 0 };
62
+ }
63
+ }
64
+ if (result.status === 'completed')
65
+ completed++;
66
+ else
67
+ failed++;
68
+ results.push(result);
69
+ done++;
70
+ options.onResult?.(entry, result, done, total);
71
+ if (options.failFast && result.status !== 'completed')
72
+ stopped = true;
73
+ };
74
+ const concurrency = Math.max(1, Math.min(options.concurrency ?? 1, total || 1));
75
+ let next = 0;
76
+ const worker = async () => {
77
+ while (next < total && !stopped) {
78
+ const i = next++;
79
+ await runEntry(i);
80
+ }
81
+ };
82
+ await Promise.all(Array.from({ length: concurrency }, () => worker()));
83
+ return { results, completed, failed, skipped };
84
+ }
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 { parseManifest, runBatch } from './batch.js';
7
8
  import { PROVIDER_PRESETS, providerNames, resolveProvider } from './providers.js';
8
9
  import * as fs from 'fs';
9
10
  import * as path from 'path';
@@ -43,7 +44,7 @@ dotenv.config({ path: GLOBAL_ENV_FILE });
43
44
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
44
45
  // In dist/index.js, package.json is usually up one level in the root
45
46
  const pkgPath = path.join(__dirname, '..', 'package.json');
46
- let version = '1.2.0';
47
+ let version = '1.3.1';
47
48
  try {
48
49
  const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
49
50
  version = pkg.version;
@@ -75,6 +76,17 @@ program
75
76
  const options = program.opts();
76
77
  await runChat(queryParts, options);
77
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
+ .option('--resume', 'Skip tasks already completed in the results file')
85
+ .option('-c, --concurrency <n>', 'Max tasks to run in parallel (default: 1)')
86
+ .action(async (manifest, cmdOptions) => {
87
+ const options = program.opts();
88
+ await runBatchCommand(manifest, options, cmdOptions);
89
+ });
78
90
  program.parse(process.argv);
79
91
  async function runSetup(options = {}) {
80
92
  const isProject = options.project;
@@ -354,11 +366,9 @@ async function runSetup(options = {}) {
354
366
  console.error(chalk.red(`Failed to write config: ${error.message}`));
355
367
  }
356
368
  }
357
- async function runChat(queryParts, options) {
358
- if (options.interactive) {
359
- console.log(chalk.bold.cyan("Welcome to AutoClaw CLI 🦞"));
360
- }
361
- const initialQuery = queryParts.join(' ');
369
+ // Shared by `chat` and `batch`: resolve credentials, endpoints and runtime
370
+ // flags from CLI args > env > project config > global config > provider preset.
371
+ async function resolveRuntime(options) {
362
372
  // 1. Load Global JSON
363
373
  const globalConfig = loadJsonConfig(GLOBAL_CONFIG_FILE);
364
374
  // 2. Load Local JSON (Project Level)
@@ -439,6 +449,104 @@ async function runChat(queryParts, options) {
439
449
  console.error(chalk.red("API Key is still missing. Exiting."));
440
450
  process.exit(1);
441
451
  }
452
+ return { apiKey, baseURL, model, fullConfig };
453
+ }
454
+ async function runBatchCommand(manifestPath, globalOptions, cmdOptions) {
455
+ const { apiKey, baseURL, model, fullConfig } = await resolveRuntime(globalOptions);
456
+ let raw;
457
+ try {
458
+ raw = fs.readFileSync(manifestPath, 'utf-8');
459
+ }
460
+ catch (err) {
461
+ console.error(chalk.red(`Cannot read manifest ${manifestPath}: ${err.message}`));
462
+ process.exit(1);
463
+ }
464
+ const entries = parseManifest(raw);
465
+ if (entries.length === 0) {
466
+ console.error(chalk.red(`Manifest ${manifestPath} contains no tasks (blank lines and '#' comments are skipped).`));
467
+ process.exit(1);
468
+ }
469
+ const outputPath = cmdOptions.output || manifestPath.replace(/\.[^.]+$/, '') + '.results.jsonl';
470
+ let resumeState;
471
+ if (cmdOptions.resume) {
472
+ const previousById = new Map();
473
+ const completedIds = new Set();
474
+ if (fs.existsSync(outputPath)) {
475
+ for (const line of fs.readFileSync(outputPath, 'utf-8').split('\n')) {
476
+ if (!line.trim())
477
+ continue;
478
+ try {
479
+ const r = JSON.parse(line);
480
+ if (r?.id) {
481
+ previousById.set(r.id, r);
482
+ if (r.status === 'completed')
483
+ completedIds.add(r.id);
484
+ }
485
+ }
486
+ catch {
487
+ // ignore malformed lines in the previous results file
488
+ }
489
+ }
490
+ }
491
+ else {
492
+ console.log(chalk.yellow(`--resume: no previous results at ${outputPath}; starting fresh.`));
493
+ }
494
+ resumeState = { completedIds, previousById };
495
+ }
496
+ const concurrency = Math.max(1, parseInt(cmdOptions.concurrency ?? '1', 10) || 1);
497
+ console.log(chalk.bold.cyan(`AutoClaw Batch 🦞 ${entries.length} task(s)${concurrency > 1 ? ` (concurrency ${concurrency})` : ''}${resumeState ? ' [resume]' : ''}`));
498
+ console.log(chalk.dim(`Results: ${outputPath}\n`));
499
+ const startedAt = Date.now();
500
+ const { results, completed, failed, skipped } = await runBatch(entries, async (entry) => {
501
+ // A fresh Agent per task keeps contexts isolated; per-task overrides
502
+ // beat the globally resolved defaults.
503
+ const taskPreset = resolveProvider(entry.provider);
504
+ const taskModel = entry.model || taskPreset?.defaultModel || model;
505
+ const taskBaseURL = taskPreset?.baseUrl || baseURL;
506
+ const taskConfig = {
507
+ ...fullConfig,
508
+ // The results file is the machine-readable contract in batch mode.
509
+ jsonMode: false,
510
+ maxSteps: entry.maxSteps ?? fullConfig.maxSteps
511
+ };
512
+ const agent = new Agent(apiKey, taskBaseURL, taskModel, taskConfig);
513
+ const start = Date.now();
514
+ const runResult = await agent.chat(entry.task);
515
+ return {
516
+ id: entry.id,
517
+ status: runResult.status,
518
+ steps: runResult.steps,
519
+ message: runResult.message ?? null,
520
+ ...(runResult.error ? { error: runResult.error } : {}),
521
+ ...(runResult.usage ? { usage: runResult.usage } : {}),
522
+ durationMs: Date.now() - start
523
+ };
524
+ }, {
525
+ failFast: !!cmdOptions.failFast,
526
+ concurrency,
527
+ resume: resumeState,
528
+ onResult: (entry, result, done, total) => {
529
+ const color = result.status === 'completed' ? chalk.green : chalk.red;
530
+ console.log(color(`[${done}/${total}] ${entry.id} -> ${result.status} (${Math.round(result.durationMs / 1000)}s)`));
531
+ }
532
+ });
533
+ try {
534
+ fs.writeFileSync(outputPath, results.map(r => JSON.stringify(r)).join('\n') + '\n', 'utf-8');
535
+ }
536
+ catch (err) {
537
+ console.error(chalk.red(`Failed to write results to ${outputPath}: ${err.message}`));
538
+ process.exit(1);
539
+ }
540
+ const skipNote = skipped > 0 ? `, ${skipped} skipped (resume)` : '';
541
+ console.log(chalk.cyan(`\nBatch done: ${completed}/${results.length + skipped} completed, ${failed} failed${skipNote} in ${Math.round((Date.now() - startedAt) / 1000)}s -> ${outputPath}`));
542
+ process.exit(failed > 0 ? 1 : 0);
543
+ }
544
+ async function runChat(queryParts, options) {
545
+ if (options.interactive) {
546
+ console.log(chalk.bold.cyan("Welcome to AutoClaw CLI 🦞"));
547
+ }
548
+ const initialQuery = queryParts.join(' ');
549
+ const { apiKey, baseURL, model, fullConfig } = await resolveRuntime(options);
442
550
  const agent = new Agent(apiKey, baseURL, model, fullConfig);
443
551
  if (options.interactive) {
444
552
  console.log(chalk.green(`Agent initialized with model: ${model}`));
package/dist/shell.js ADDED
@@ -0,0 +1,171 @@
1
+ import { spawn, execSync } from 'child_process';
2
+ import * as fs from 'fs';
3
+ const GIT_BASH_CANDIDATES = [
4
+ 'C:\\Program Files\\Git\\bin\\bash.exe',
5
+ 'C:\\Program Files\\Git\\usr\\bin\\bash.exe',
6
+ 'C:\\Program Files (x86)\\Git\\bin\\bash.exe'
7
+ ];
8
+ let cachedType = null;
9
+ let cachedBashPath;
10
+ function findBashPath() {
11
+ if (process.platform === 'win32') {
12
+ for (const candidate of GIT_BASH_CANDIDATES) {
13
+ if (fs.existsSync(candidate))
14
+ return candidate;
15
+ }
16
+ try {
17
+ // 'where' may report WSL's System32\bash.exe, which has a different
18
+ // filesystem view — exclude it.
19
+ const out = execSync('where bash.exe', { stdio: ['ignore', 'pipe', 'ignore'] }).toString();
20
+ const hit = out.split(/\r?\n/).map(l => l.trim()).find(l => l && !/System32/i.test(l));
21
+ return hit ?? null;
22
+ }
23
+ catch {
24
+ return null;
25
+ }
26
+ }
27
+ return fs.existsSync('/bin/bash') ? '/bin/bash' : null;
28
+ }
29
+ export function getBashPath() {
30
+ if (cachedBashPath === undefined)
31
+ cachedBashPath = findBashPath();
32
+ return cachedBashPath;
33
+ }
34
+ export function resolveShellType(config) {
35
+ const requested = String(config?.shell || process.env.AUTOCLOW_SHELL || 'auto').toLowerCase();
36
+ if (requested === 'bash' || requested === 'powershell' || requested === 'cmd' || requested === 'sh') {
37
+ if (requested !== 'bash' || getBashPath())
38
+ return requested;
39
+ }
40
+ if (!cachedType) {
41
+ cachedType = process.platform === 'win32' ? (getBashPath() ? 'bash' : 'powershell') : 'sh';
42
+ }
43
+ return cachedType;
44
+ }
45
+ // Windows tools emit GBK on CN-locale systems while Unix tools emit UTF-8;
46
+ // strict UTF-8 decode with GBK fallback covers both.
47
+ export function smartDecode(buf) {
48
+ try {
49
+ return new TextDecoder('utf-8', { fatal: true }).decode(buf);
50
+ }
51
+ catch {
52
+ try {
53
+ return new TextDecoder('gbk').decode(buf);
54
+ }
55
+ catch {
56
+ return buf.toString('utf8');
57
+ }
58
+ }
59
+ }
60
+ export function execShellCommand(command, opts) {
61
+ const type = resolveShellType();
62
+ let file;
63
+ let args;
64
+ if (type === 'bash') {
65
+ // -l sources the profile so Git Bash's /usr/bin lands on PATH and Unix
66
+ // tools (ls, grep, ...) actually resolve.
67
+ file = getBashPath();
68
+ args = ['-l', '-c', command];
69
+ }
70
+ else if (type === 'powershell') {
71
+ file = 'powershell.exe';
72
+ args = ['-NoProfile', '-Command', command];
73
+ }
74
+ else if (type === 'cmd') {
75
+ file = process.env.ComSpec || 'cmd.exe';
76
+ args = ['/d', '/s', '/c', command];
77
+ }
78
+ else {
79
+ file = '/bin/sh';
80
+ args = ['-c', command];
81
+ }
82
+ return new Promise(resolve => {
83
+ const stdout = [];
84
+ const stderr = [];
85
+ let total = 0;
86
+ let timedOut = false;
87
+ let truncated = false;
88
+ let settled = false;
89
+ const posix = process.platform !== 'win32';
90
+ const child = spawn(file, args, { windowsHide: true, detached: posix });
91
+ const finish = () => {
92
+ if (settled)
93
+ return;
94
+ settled = true;
95
+ clearTimeout(timer);
96
+ resolve({
97
+ stdout: smartDecode(Buffer.concat(stdout)),
98
+ stderr: smartDecode(Buffer.concat(stderr)),
99
+ timedOut,
100
+ truncated
101
+ });
102
+ };
103
+ const killTree = () => {
104
+ if (!child.pid)
105
+ return;
106
+ if (posix) {
107
+ try {
108
+ process.kill(-child.pid, 'SIGKILL');
109
+ }
110
+ catch {
111
+ child.kill('SIGKILL');
112
+ }
113
+ }
114
+ else {
115
+ // Terminating the shell alone would orphan grandchildren, whose
116
+ // inherited stdio pipes keep 'close' pending — kill the whole tree.
117
+ spawn('taskkill', ['/pid', String(child.pid), '/T', '/F'], { stdio: 'ignore', windowsHide: true });
118
+ }
119
+ };
120
+ const timer = setTimeout(() => {
121
+ timedOut = true;
122
+ killTree();
123
+ }, Math.max(1, opts.timeoutMs));
124
+ const collect = (buf, chunk) => {
125
+ if (total >= opts.maxBuffer)
126
+ return;
127
+ total += chunk.length;
128
+ if (total > opts.maxBuffer) {
129
+ buf.push(chunk.subarray(0, chunk.length - (total - opts.maxBuffer)));
130
+ truncated = true;
131
+ killTree();
132
+ }
133
+ else {
134
+ buf.push(chunk);
135
+ }
136
+ };
137
+ child.stdout.on('data', c => collect(stdout, c));
138
+ child.stderr.on('data', c => collect(stderr, c));
139
+ child.on('error', err => {
140
+ stderr.push(Buffer.from(`\n[shell] failed to start ${type}: ${err.message}`));
141
+ finish();
142
+ });
143
+ child.on('exit', () => {
144
+ // The direct child is gone. Killed shells leave grandchildren holding
145
+ // stdio pipes, so don't wait for 'close' after a kill — return what we
146
+ // collected. On natural exit give the streams a short grace period
147
+ // before 'close' confirms the full flush.
148
+ if (timedOut || truncated) {
149
+ finish();
150
+ }
151
+ else {
152
+ const grace = setTimeout(finish, 1000);
153
+ if (typeof grace.unref === 'function')
154
+ grace.unref();
155
+ }
156
+ });
157
+ child.on('close', finish);
158
+ });
159
+ }
160
+ export function buildShellInfo(type) {
161
+ switch (type) {
162
+ case 'bash':
163
+ return 'Bash (POSIX). Standard Unix tools, pipes and $() substitution work.';
164
+ case 'powershell':
165
+ return 'Windows PowerShell. Use PowerShell syntax: $() works, but && does not on Windows PowerShell 5 — use ; or separate calls. Prefer cmdlets like Get-ChildItem.';
166
+ case 'cmd':
167
+ 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 "..."';
168
+ default:
169
+ return 'POSIX shell (sh). Standard Unix tools apply.';
170
+ }
171
+ }
@@ -1,9 +1,20 @@
1
1
  import { chromium } from 'playwright';
2
2
  import { Readability } from '@mozilla/readability';
3
3
  import { JSDOM } from 'jsdom';
4
+ import { createRequire } from 'module';
5
+ const require = createRequire(import.meta.url);
4
6
  export const BrowserTool = {
5
7
  name: "Web Browser",
6
8
  configKeys: [],
9
+ isAvailable: () => {
10
+ try {
11
+ require.resolve('playwright');
12
+ return true;
13
+ }
14
+ catch {
15
+ return false;
16
+ }
17
+ },
7
18
  definition: {
8
19
  type: "function",
9
20
  function: {
@@ -1,10 +1,8 @@
1
- import { exec } from 'child_process';
2
1
  import * as fs from 'fs/promises';
3
2
  import * as path from 'path';
4
3
  import inquirer from 'inquirer';
5
4
  import chalk from 'chalk';
6
- import util from 'util';
7
- const execAsync = util.promisify(exec);
5
+ import { execShellCommand } from '../shell.js';
8
6
  const DEFAULT_SHELL_TIMEOUT_MS = 120000;
9
7
  const SHELL_MAX_BUFFER = 10 * 1024 * 1024;
10
8
  export const ShellTool = {
@@ -50,16 +48,15 @@ export const ShellTool = {
50
48
  console.log(chalk.gray("(Auto-confirming command execution due to --yes flag)"));
51
49
  }
52
50
  try {
53
- const { stdout, stderr } = await execAsync(args.command, {
54
- timeout: timeoutMs,
55
- maxBuffer: SHELL_MAX_BUFFER
56
- });
57
- return stdout + (stderr ? `\nStderr: ${stderr}` : '');
51
+ const r = await execShellCommand(args.command, { timeoutMs, maxBuffer: SHELL_MAX_BUFFER });
52
+ if (r.timedOut) {
53
+ return `Command timed out after ${timeoutMs}ms and was terminated.\nStdout: ${r.stdout}\nStderr: ${r.stderr}`;
54
+ }
55
+ return (r.stdout +
56
+ (r.truncated ? '\n[AutoClaw] Output truncated at the buffer limit.' : '') +
57
+ (r.stderr ? `\nStderr: ${r.stderr}` : ''));
58
58
  }
59
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
- }
63
60
  return `Command failed: ${error.message}\nStdout: ${error.stdout}\nStderr: ${error.stderr}`;
64
61
  }
65
62
  }
@@ -2,6 +2,7 @@ import nodemailer from 'nodemailer';
2
2
  export const EmailTool = {
3
3
  name: "Email Service",
4
4
  configKeys: ["smtpHost", "smtpPort", "smtpUser", "smtpPass", "smtpFrom"],
5
+ isAvailable: (config) => !!(config?.smtpHost && config?.smtpUser && config?.smtpPass),
5
6
  definition: {
6
7
  type: "function",
7
8
  function: {
@@ -251,6 +251,7 @@ const handler = async (args, config) => {
251
251
  };
252
252
  export const ImageTool = {
253
253
  name: "Image Generation",
254
+ isAvailable: (config) => !!(config?.imageApiKey || config?.apiKey || process.env.OPENAI_API_KEY),
254
255
  definition: toolDefinition,
255
256
  handler: handler
256
257
  };
@@ -20,8 +20,15 @@ export const toolRegistry = [
20
20
  ScreenshotTool,
21
21
  ImageTool
22
22
  ];
23
- export function getToolDefinitions() {
24
- return toolRegistry.map(t => t.definition);
23
+ export function getToolDefinitions(config) {
24
+ return toolRegistry
25
+ .filter(t => !t.isAvailable || t.isAvailable(config ?? {}))
26
+ .map(t => t.definition);
27
+ }
28
+ export function listUnavailableTools(config) {
29
+ return toolRegistry
30
+ .filter(t => t.isAvailable && !t.isAvailable(config ?? {}))
31
+ .map(t => t.definition.function.name);
25
32
  }
26
33
  export async function executeToolHandler(name, args, fullConfig) {
27
34
  const tool = toolRegistry.find(t => t.definition.function.name === name);
@@ -5,6 +5,8 @@ export const NotifyTool = {
5
5
  "dingtalkWebhook", "dingtalkKeyword",
6
6
  "wecomWebhook", "wecomKeyword"
7
7
  ],
8
+ isAvailable: (config) => !!(config?.feishuWebhook || config?.dingtalkWebhook || config?.wecomWebhook ||
9
+ process.env.FEISHU_WEBHOOK || process.env.DINGTALK_WEBHOOK || process.env.WECOM_WEBHOOK),
8
10
  definition: {
9
11
  type: "function",
10
12
  function: {
@@ -1,6 +1,7 @@
1
1
  import OpenAI from 'openai';
2
2
  export const PromptOptimizerTool = {
3
3
  name: "Prompt Optimizer",
4
+ isAvailable: (config) => !!(config?.apiKey || process.env.OPENAI_API_KEY),
4
5
  definition: {
5
6
  type: "function",
6
7
  function: {
@@ -1,7 +1,9 @@
1
1
  import { chromium } from 'playwright';
2
+ import { createRequire } from 'module';
2
3
  import * as fs from 'fs';
3
4
  import * as os from 'os';
4
5
  import * as child_process from 'child_process';
6
+ const require = createRequire(import.meta.url);
5
7
  // Helper to check for common CJK and Emoji font paths on Linux
6
8
  const checkLinuxFonts = () => {
7
9
  if (os.platform() !== 'linux')
@@ -77,6 +79,15 @@ const installFonts = (missing) => {
77
79
  export const ScreenshotTool = {
78
80
  name: "Screenshot Tool",
79
81
  configKeys: [],
82
+ isAvailable: () => {
83
+ try {
84
+ require.resolve('playwright');
85
+ return true;
86
+ }
87
+ catch {
88
+ return false;
89
+ }
90
+ },
80
91
  definition: {
81
92
  type: "function",
82
93
  function: {
@@ -1,6 +1,7 @@
1
1
  export const SearchTool = {
2
2
  name: "Web Search (Tavily)",
3
3
  configKeys: ["tavilyApiKey"],
4
+ isAvailable: (config) => !!(config?.tavilyApiKey || process.env.TAVILY_API_KEY),
4
5
  definition: {
5
6
  type: "function",
6
7
  function: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "autoclaw",
3
- "version": "1.2.0",
3
+ "version": "1.3.1",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "bin": {
@@ -12,6 +12,7 @@
12
12
  "dev": "node --loader ts-node/esm src/index.ts",
13
13
  "test": "vitest run",
14
14
  "test:watch": "vitest",
15
+ "coverage": "vitest run --coverage",
15
16
  "prepublishOnly": "npm run build"
16
17
  },
17
18
  "files": [
@@ -65,6 +66,7 @@
65
66
  "@types/jsdom": "^27.0.0",
66
67
  "@types/node": "^25.2.1",
67
68
  "@types/nodemailer": "^7.0.9",
69
+ "@vitest/coverage-v8": "^4.1.11",
68
70
  "ts-node": "^10.9.2",
69
71
  "typescript": "^5.9.3",
70
72
  "vitest": "^4.1.4"