autoclaw 1.3.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
@@ -121,6 +121,15 @@ Manifest lines are `{"id": "...", "task": "..."}` — `id` is optional (defaults
121
121
 
122
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
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
+
124
133
  ### Auto-Confirm (CI/CD)
125
134
  Automatically approve all tool executions (dangerous, use with caution or in sandboxes).
126
135
  ```bash
@@ -158,6 +167,7 @@ AutoClaw uses a hierarchical configuration system.
158
167
  - `model`: Default model to use.
159
168
  - `maxSteps`: Max LLM turns per task before the agent stops (default: `25`).
160
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).
161
171
  - `tavilyApiKey`: API Key for Tavily Web Search.
162
172
  - `smtpHost`, `smtpPort`, `smtpUser`, `smtpPass`, `smtpFrom`: SMTP Email settings.
163
173
  - `feishuWebhook`, `dingtalkWebhook`, `wecomWebhook`: Notification webhooks.
@@ -177,6 +187,7 @@ Create a file at `.autoclaw/setting.json`:
177
187
  - `OPENAI_API_KEY`, `OPENAI_BASE_URL`, `OPENAI_MODEL`: main LLM settings.
178
188
  - `AUTOCLOW_PROVIDER`: provider preset used when `-P` is not passed.
179
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`).
180
191
  - `AUTOCLOW_INCLUDE_USAGE`: set to `1`/`true` to request token usage from the API (opt-in).
181
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.
182
193
 
package/README.zh-CN.md CHANGED
@@ -122,6 +122,15 @@ autoclaw batch tasks.jsonl -o out.jsonl --fail-fast
122
122
 
123
123
  单个任务失败不会中断批次(需要中断用 `--fail-fast`)。全部完成时进程退出 `0`,否则 `1`,cron 和 K8s Job 由此感知批次成败。任务输出保持人类可读——结果文件才是机器可读契约,含每个任务的 `status`、`steps`、`message`、`error`、`usage`。
124
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
+
125
134
  ### 自动确认 (CI/CD)
126
135
  自动批准所有工具执行(危险操作,请谨慎使用或在沙箱环境下运行)。
127
136
  ```bash
@@ -159,6 +168,7 @@ AutoClaw 使用层级配置系统。
159
168
  - `model`: 默认使用的模型。
160
169
  - `maxSteps`: 单任务最大 LLM 轮数,超出后自动停止 (默认: `25`)。
161
170
  - `shellTimeout`: Shell 命令超时时间(毫秒)(默认: `120000`)。
171
+ - `shell`: 强制 `execute_shell_command` 使用的 shell (`bash`、`powershell`、`cmd`、`sh`;默认自动检测——Windows 上优先 Git Bash > PowerShell > cmd)。
162
172
  - `tavilyApiKey`: Tavily 网页搜索的 API 密钥。
163
173
  - `smtpHost`, `smtpPort`, `smtpUser`, `smtpPass`, `smtpFrom`: SMTP 邮件设置。
164
174
  - `feishuWebhook`, `dingtalkWebhook`, `wecomWebhook`: 通知钩子地址。
@@ -178,6 +188,7 @@ AutoClaw 使用层级配置系统。
178
188
  - `OPENAI_API_KEY`, `OPENAI_BASE_URL`, `OPENAI_MODEL`: 主模型设置。
179
189
  - `AUTOCLOW_PROVIDER`: 未传 `-P` 时使用的 provider 预设。
180
190
  - `AUTOCLOW_MAX_STEPS`, `AUTOCLOW_SHELL_TIMEOUT`: 稳定性限制(单任务最大轮数;Shell 超时毫秒数)。
191
+ - `AUTOCLOW_SHELL`: 强制 shell 命令使用的 shell (`bash`、`powershell`、`cmd`、`sh`)。
181
192
  - `AUTOCLOW_INCLUDE_USAGE`: 设为 `1`/`true` 时向 API 请求 token 用量(可选开启)。
182
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 的替代方式。
183
194
 
package/dist/agent.js CHANGED
@@ -5,18 +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;
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
- }
13
+ const TOOL_RESULT_TRIM_MARKER = 'older tool output trimmed';
20
14
  export class Agent {
21
15
  client;
22
16
  messages;
@@ -30,10 +24,11 @@ export class Agent {
30
24
  });
31
25
  this.model = model;
32
26
  this.config = config;
27
+ const shellType = resolveShellType(config);
33
28
  const systemInfo = `
34
29
  System Information:
35
30
  - OS: ${os.type()} ${os.release()} (${os.platform()})
36
- - Shell: ${buildShellInfo()}
31
+ - Shell: ${buildShellInfo(shellType)}
37
32
  - Architecture: ${os.arch()}
38
33
  - Node.js Version: ${process.version}
39
34
  - Current Working Directory: ${process.cwd()}
@@ -41,9 +36,27 @@ System Information:
41
36
  - Home Directory: ${os.homedir()}
42
37
  - Current Date: ${new Date().toLocaleString()}
43
38
  `;
44
- const windowsShellRule = os.platform() === 'win32'
39
+ const shellRule = shellType === 'cmd'
45
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.`
46
- : '';
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');
47
60
  this.messages = [
48
61
  {
49
62
  role: "system",
@@ -54,12 +67,7 @@ You may be running on a developer workstation, a headless server, inside a Docke
54
67
  ${systemInfo}
55
68
 
56
69
  WHAT YOU CAN DO:
57
- - Shell: execute_shell_command — run scripts, install packages, manage processes, interact with the OS
58
- - Files: read_file / write_file — inspect logs, generate configs, produce reports
59
- - Web: web_search — real-time information lookup; read_website — extract article content; take_screenshot — capture page visuals
60
- - Communication: send_email — SMTP email delivery; send_notification — push to Feishu/DingTalk/WeCom
61
- - Creation: generate_image — AI image generation (DALL-E compatible); optimize_prompt — refine raw prompts for creative/complex tasks
62
- - Utility: get_current_datetime — accurate system time for temporal reasoning
70
+ ${capabilities}
63
71
 
64
72
  RULES OF ENGAGEMENT:
65
73
  1. One shot, not one chat. Produce working results, not conversation. Be terse.
@@ -68,7 +76,7 @@ RULES OF ENGAGEMENT:
68
76
  4. Container-friendly: stick to standard Unix tools available in Alpine/Debian slim images. No GUI apps, no browser-based debug tools.
69
77
  5. For creative or complex tasks (image prompts, long-form writing, intricate scripts): call optimize_prompt first. It significantly raises output quality.
70
78
  6. If a command fails, diagnose and try one alternative. Don't retry the same thing, don't give up on first error.
71
- 7. Read before write. When modifying a file, read it first. When installing a package, check if it's already there.${windowsShellRule}
79
+ 7. Read before write. When modifying a file, read it first. When installing a package, check if it's already there.${shellRule}
72
80
  `
73
81
  }
74
82
  ];
@@ -112,6 +120,7 @@ RULES OF ENGAGEMENT:
112
120
  }
113
121
  break;
114
122
  }
123
+ this.trimOldToolResults();
115
124
  step++;
116
125
  const spinner = this.jsonMode
117
126
  ? { stop() { }, fail() { }, text: '' }
@@ -352,6 +361,25 @@ RULES OF ENGAGEMENT:
352
361
  this.emitEvent({ event: 'run_end', ...result });
353
362
  return result;
354
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
+ }
355
383
  async saveOutput(functionName, toolResult) {
356
384
  const outputDir = path.join(os.homedir(), '.autoclaw', 'output');
357
385
  if (!fs.existsSync(outputDir)) {
package/dist/batch.js CHANGED
@@ -38,11 +38,21 @@ export async function runBatch(entries, execute, options = {}) {
38
38
  const results = [];
39
39
  let completed = 0;
40
40
  let failed = 0;
41
- for (const entry of entries) {
41
+ let skipped = 0;
42
+ let stopped = false;
43
+ let done = 0;
44
+ const runEntry = async (i) => {
45
+ const entry = entries[i];
42
46
  let result;
43
47
  if (entry.error) {
44
48
  result = { id: entry.id, status: 'error', error: `Manifest line ${entry.lineNo}: ${entry.error}`, durationMs: 0 };
45
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
+ }
46
56
  else {
47
57
  try {
48
58
  result = await execute(entry);
@@ -56,9 +66,19 @@ export async function runBatch(entries, execute, options = {}) {
56
66
  else
57
67
  failed++;
58
68
  results.push(result);
59
- options.onResult?.(entry, result, results.length, total);
69
+ done++;
70
+ options.onResult?.(entry, result, done, total);
60
71
  if (options.failFast && result.status !== 'completed')
61
- break;
62
- }
63
- return { results, completed, failed };
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 };
64
84
  }
package/dist/index.js CHANGED
@@ -44,7 +44,7 @@ dotenv.config({ path: GLOBAL_ENV_FILE });
44
44
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
45
45
  // In dist/index.js, package.json is usually up one level in the root
46
46
  const pkgPath = path.join(__dirname, '..', 'package.json');
47
- let version = '1.3.0';
47
+ let version = '1.3.1';
48
48
  try {
49
49
  const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
50
50
  version = pkg.version;
@@ -81,6 +81,8 @@ program
81
81
  .description('Run tasks from a JSONL manifest, each in a fresh agent ({"id":"...","task":"..."})')
82
82
  .option('-o, --output <file>', 'Per-task results as JSONL (default: <manifest base>.results.jsonl)')
83
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)')
84
86
  .action(async (manifest, cmdOptions) => {
85
87
  const options = program.opts();
86
88
  await runBatchCommand(manifest, options, cmdOptions);
@@ -465,10 +467,37 @@ async function runBatchCommand(manifestPath, globalOptions, cmdOptions) {
465
467
  process.exit(1);
466
468
  }
467
469
  const outputPath = cmdOptions.output || manifestPath.replace(/\.[^.]+$/, '') + '.results.jsonl';
468
- console.log(chalk.bold.cyan(`AutoClaw Batch 🦞 ${entries.length} task(s)`));
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]' : ''}`));
469
498
  console.log(chalk.dim(`Results: ${outputPath}\n`));
470
499
  const startedAt = Date.now();
471
- const { results, completed, failed } = await runBatch(entries, async (entry) => {
500
+ const { results, completed, failed, skipped } = await runBatch(entries, async (entry) => {
472
501
  // A fresh Agent per task keeps contexts isolated; per-task overrides
473
502
  // beat the globally resolved defaults.
474
503
  const taskPreset = resolveProvider(entry.provider);
@@ -494,6 +523,8 @@ async function runBatchCommand(manifestPath, globalOptions, cmdOptions) {
494
523
  };
495
524
  }, {
496
525
  failFast: !!cmdOptions.failFast,
526
+ concurrency,
527
+ resume: resumeState,
497
528
  onResult: (entry, result, done, total) => {
498
529
  const color = result.status === 'completed' ? chalk.green : chalk.red;
499
530
  console.log(color(`[${done}/${total}] ${entry.id} -> ${result.status} (${Math.round(result.durationMs / 1000)}s)`));
@@ -506,7 +537,8 @@ async function runBatchCommand(manifestPath, globalOptions, cmdOptions) {
506
537
  console.error(chalk.red(`Failed to write results to ${outputPath}: ${err.message}`));
507
538
  process.exit(1);
508
539
  }
509
- console.log(chalk.cyan(`\nBatch done: ${completed}/${results.length} completed, ${failed} failed in ${Math.round((Date.now() - startedAt) / 1000)}s -> ${outputPath}`));
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}`));
510
542
  process.exit(failed > 0 ? 1 : 0);
511
543
  }
512
544
  async function runChat(queryParts, options) {
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.3.0",
3
+ "version": "1.3.1",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "bin": {