34 Commits

Author SHA1 Message Date
stefano 0bea1c590d fix: review feedback — signal handling, cancel race, stderr consistency
Address issues found by code review:

1. Bug: timeout/signal-killed child reported as 'completed' with exit
   code 0 because close handler ignored the signal parameter. Now
   treats any signal termination as timed_out.

2. Bug: cancelled job gets overwritten by watcher on child exit. The
   watcher now re-reads the job file before writing and skips if the
   status has been changed to 'cancelled'.

3. Inconsistency: watcher path skipped stderr noise filtering. Added
   filterStderrNoise to the watcher (duplicated from execute.ts to
   keep the watcher self-contained).

4. getJobResult now guards against missing result field instead of
   using non-null assertion.
2026-05-20 14:17:28 -05:00
stefano 33c898ff9a fix: use companion watcher process for async job completion
The async startJob previously relied on Node.js event listeners in the
CLI process to capture child output and finalize the job file. But the
CLI process exits immediately after returning the job ID, killing the
event loop before the close handler fires — leaving jobs stuck at
'running' forever.

Fix: startJob now spawns a companion watcher process (job-watcher.ts)
that is itself detached and outlives the CLI. The watcher:
- Spawns the actual client (codex/claude/opencode)
- Captures stdout/stderr
- Writes the final job record to disk on child exit
- Has its own 10-minute timeout safety net

The CLI returns the job ID immediately. The watcher independently
finalizes the job. The CLI no longer needs to stay alive.

Also updates tests to mock the watcher spawn via injectable
spawnWatcher option.
2026-05-20 14:08:44 -05:00
stefano 017eb1b410 fix: pipe and close stdin for codex to prevent hang on stdin read
When codex exec receives a prompt as a positional argument, it still
tries to read additional input from stdin (prints 'Reading additional
input from stdin...'). With stdio stdin set to 'ignore' or default,
codex blocks indefinitely waiting for stdin that never comes.

Fix: use stdio ['pipe', 'pipe', 'pipe'] and immediately close stdin
via child.stdin.end() in both execute.ts (sync) and jobs.ts (async).
This signals EOF to codex so it proceeds with the positional prompt.
2026-05-20 13:47:32 -05:00
stefano afac143cb3 fix: filter codex ReasoningSummary stderr noise on exit code 0
Codex writes informational ERROR messages about ReasoningSummaryDelta
to stderr even on successful execution (exit code 0). The OpenClaw
agent misinterprets this non-empty stderr as a failure.

- Add filterStderrNoise() to strip known codex noise patterns from
  stderr when exit code is 0
- Preserve raw stderr in DebugInfo.rawStderr when --debug is active
- Add 5 new tests covering noise filtering, preservation on failure,
  debug raw output, and non-codex client passthrough
2026-05-20 13:37:21 -05:00
stefano edb6611b74 merge M4 into implement/2026-05-19-ai-cli-dispatch-fixes 2026-05-19 22:49:05 -05:00
stefano 7b886a7b33 feat(M4): Documentation & Final Integration 2026-05-19 22:49:05 -05:00
stefano 48bef5cc7c merge S-402 into M4 2026-05-19 22:45:38 -05:00
stefano e6f2908624 merge S-401 into M4 2026-05-19 22:45:38 -05:00
stefano 601f7cce89 feat(S-402): Update docs/ai-cli-dispatch.md and docs/architecture.md 2026-05-19 22:45:38 -05:00
stefano 6655e2e1e8 feat(S-401): Update SKILL.md for async-first usage 2026-05-19 22:42:16 -05:00
stefano bd88df7dd2 merge M3 into implement/2026-05-19-ai-cli-dispatch-fixes 2026-05-19 22:22:54 -05:00
stefano 591829369c feat(M3): Async CLI Integration 2026-05-19 22:22:54 -05:00
stefano a2c2b8bf6d merge S-303 into M3 2026-05-19 22:04:19 -05:00
stefano 51f978db4c feat(S-303): Update --help output and add CLI integration smoke tests 2026-05-19 22:04:19 -05:00
stefano d061244121 merge S-302 into M3 2026-05-19 22:00:11 -05:00
stefano 4fe99b8c57 feat(S-302): Test-drive and implement job lifecycle subcommands 2026-05-19 22:00:11 -05:00
stefano 816374cef8 merge S-301 into M3 2026-05-19 21:42:58 -05:00
stefano 62840b908e feat(S-301): Test-drive and implement async default for run and dispatch 2026-05-19 21:42:58 -05:00
stefano e11c36b7d8 merge M2 into implement/2026-05-19-ai-cli-dispatch-fixes 2026-05-19 20:29:35 -05:00
stefano e7b01612c8 feat(M2): Background Job Manager 2026-05-19 20:29:35 -05:00
stefano 9c7d9cbaee merge S-202 into M2 2026-05-19 20:17:15 -05:00
stefano 3b9ed0cc38 feat(S-202): Test-drive and implement src/jobs.ts (write) 2026-05-19 20:17:15 -05:00
stefano aa860a6afd merge S-201 into M2 2026-05-19 19:58:48 -05:00
stefano abf7726071 feat(S-201): Define job types and storage interfaces 2026-05-19 19:58:48 -05:00
stefano 21c13562a7 merge M1 into implement/2026-05-19-ai-cli-dispatch-fixes 2026-05-19 19:54:27 -05:00
stefano bcddb42608 feat(M1): Codex Reliability Fix 2026-05-19 19:54:27 -05:00
stefano 5b78889b09 merge S-104 into M1 2026-05-19 19:51:10 -05:00
stefano 1983dd82e7 feat(S-104): Add stderr-length and exit-code correlation diagnostics 2026-05-19 19:51:10 -05:00
stefano 106c7d6425 merge S-103 into M1 2026-05-19 19:48:40 -05:00
stefano 94389df6f1 feat(S-103): Test-drive and implement --debug diagnostic mode 2026-05-19 19:48:40 -05:00
stefano 32964bf994 merge S-102 into M1 2026-05-19 19:39:46 -05:00
stefano dc3fe8d6eb feat(S-102): Test-drive and implement --timeout flag, config layering, and default in 2026-05-19 19:39:46 -05:00
stefano 5375c83c77 merge S-101 into M1 2026-05-19 19:20:53 -05:00
stefano 476dd317b3 feat(S-101): Extend types.ts with ExecResult metadata, timeout config shape, and debu 2026-05-19 19:20:53 -05:00
16 changed files with 3921 additions and 167 deletions
+224 -22
View File
@@ -9,8 +9,10 @@ Dispatch AI CLI coding tasks to available clients (Codex, Claude Code, OpenCode)
- dispatch a prompt to a specific client by name
- auto-resolve the best client from prompt keywords
- forward arguments natively to each client
- run tasks asynchronously as background jobs with lifecycle management
- run tasks synchronously when blocking until completion is desired
The tool is a lightweight sync-only dispatcher. It does not implement streaming, chat sessions, or ACP orchestration. For ACP-based harnesses, see `docs/openclaw-acp-orchestration.md`.
The tool supports both async (default) and sync execution modes. Async jobs run as detached background processes and are tracked on disk. For ACP-based harnesses, see `docs/openclaw-acp-orchestration.md`.
## Setup
@@ -27,8 +29,14 @@ The dispatcher itself requires only Node.js 20+ and `npm`. The actual AI CLI cli
```bash
ai-cli-dispatch list [--json|--text]
ai-cli-dispatch run --client <client> --prompt <prompt> [--json|--text]
ai-cli-dispatch dispatch <prompt> [--client <client>] [--json|--text]
ai-cli-dispatch run --client <client> --prompt <prompt> [--sync] [--timeout <ms>] [--debug] [--json|--text]
ai-cli-dispatch dispatch <prompt> [--client <client>] [--sync] [--timeout <ms>] [--debug] [--json|--text]
ai-cli-dispatch start --client <client> --prompt <prompt> [--timeout <ms>] [--debug] [--json|--text]
ai-cli-dispatch status <job-id> [--json|--text]
ai-cli-dispatch results <job-id> [--json|--text]
ai-cli-dispatch cancel <job-id> [--json|--text]
ai-cli-dispatch list-jobs [--status running|completed|failed] [--json|--text]
ai-cli-dispatch cleanup-jobs [--max-age <number>[h|m|s|d]] [--json|--text]
ai-cli-dispatch --help
```
@@ -71,25 +79,30 @@ ai-cli-dispatch list --text
### `run`
Execute a prompt directly through a named client.
Execute a prompt directly through a named client. By default, this starts a background job and returns immediately.
```bash
# Async (default) — returns a job ID
ai-cli-dispatch run --client codex --prompt "refactor this function"
ai-cli-dispatch run --client claude --prompt "add tests for auth middleware"
ai-cli-dispatch run --client opencode --prompt "migrate to ESM"
# Sync — blocks until the client finishes
ai-cli-dispatch run --client claude --prompt "add tests for auth middleware" --sync
# With custom timeout and debug diagnostics
ai-cli-dispatch run --client opencode --prompt "migrate to ESM" --timeout 600000 --debug
```
The prompt is forwarded with each clients native argument shape:
| Client | Arguments passed |
|---|---|
| `codex` | `exec "<prompt>"` |
| `claude` | `-p "<prompt>"` |
| `opencode` | `"<prompt>"` |
| `codex` | `exec --yolo "<prompt>"` |
| `claude` | `-p "<prompt>" --dangerously-skip-permissions` |
| `opencode` | `run --dangerously-skip-permissions "<prompt>"` |
### `dispatch`
Auto-resolve the client from prompt keywords, then execute.
Auto-resolve the client from prompt keywords, then execute. Defaults to async; use `--sync` to block.
```bash
ai-cli-dispatch dispatch "use claude to write tests"
@@ -112,6 +125,106 @@ Override auto-resolution explicitly:
ai-cli-dispatch dispatch "fix the bug" --client claude
```
### `start`
Explicitly start a background job (same as `run` without `--sync`). Useful when you want the async behavior unambiguously.
```bash
ai-cli-dispatch start --client codex --prompt "refactor this function"
```
### `status`
Check the status of a background job.
```bash
ai-cli-dispatch status <job-id>
```
JSON output:
```json
{
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"client": "codex",
"prompt": "refactor this function",
"status": "running",
"startedAt": "2026-05-19T12:34:56.789Z",
"pid": 12345
}
```
Statuses: `running`, `completed`, `failed`, `timed_out`, `cancelled`.
### `results`
Retrieve the execution result of a completed job.
```bash
ai-cli-dispatch results <job-id>
```
JSON output:
```json
{
"stdout": "...",
"stderr": "...",
"exitCode": 0,
"client": "codex",
"durationMs": 45231
}
```
Requires status `completed`. For `failed` or `timed_out` jobs, use `status` to see the captured error.
### `cancel`
Cancel a running job.
```bash
ai-cli-dispatch cancel <job-id>
```
### `list-jobs`
List all tracked jobs, newest first.
```bash
ai-cli-dispatch list-jobs --json
ai-cli-dispatch list-jobs --status running --json
```
### `cleanup-jobs`
Remove job files older than a threshold. Default unit is hours.
```bash
ai-cli-dispatch cleanup-jobs --max-age 24h
ai-cli-dispatch cleanup-jobs --max-age 30m
```
## Async vs Sync Mode
By default, `run` and `dispatch` are **async**: they start a detached background process, persist a job record to disk, and return a job ID immediately. This is ideal for:
- Fire-and-forget tasks that may run for minutes
- Long-running codegen or migration tasks
- Scenarios where the caller should not block
Use `--sync` when you need:
- The complete output before the next step
- Synchronous composition in shell pipelines or scripts
- Immediate error propagation to the calling process
| Aspect | Async (default) | Sync (`--sync`) |
|---|---|---|
| Return value | Job ID + status | Full stdout/stderr + exit code |
| Process model | Detached child, parent exits immediately | Attached child, parent waits |
| Persistence | Job file written to disk | No job file |
| Timeout | Enforced via `child.kill()` after `--timeout` | Enforced via `child.kill()` after `--timeout` |
## Client Discovery
Discovery searches `PATH` in this order for each client name:
@@ -138,7 +251,8 @@ Example:
"codex": "/usr/local/bin/codex",
"claude": "/opt/homebrew/bin/claude"
},
"defaultClient": "claude"
"defaultClient": "claude",
"timeout": 300000
}
```
@@ -162,17 +276,54 @@ Supported env vars:
Default output is JSON. Use `--text` to stream raw `stdout`/`stderr` directly.
JSON success shape (`run` and `dispatch`):
### Sync JSON success shape (`run --sync`, `dispatch --sync`)
```json
{
"stdout": "...",
"stderr": "...",
"exitCode": 0
"exitCode": 0,
"client": "codex",
"durationMs": 45231
}
```
JSON error shape:
### Async JSON success shape (`run`, `dispatch`, `start`)
```json
{
"jobId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"client": "codex",
"status": "running"
}
```
### Job status shape (`status`)
```json
{
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"client": "codex",
"prompt": "refactor this function",
"status": "running",
"startedAt": "2026-05-19T12:34:56.789Z",
"pid": 12345
}
```
### Job result shape (`results`)
```json
{
"stdout": "...",
"stderr": "...",
"exitCode": 0,
"client": "codex",
"durationMs": 45231
}
```
### JSON error shape
```json
{
@@ -185,7 +336,7 @@ Exit codes:
| Code | Meaning |
|---|---|
| `0` | Success |
| `1` | Missing/unknown command, missing argument, unknown client, resolution failure, or execution error |
| `1` | Missing/unknown command, missing argument, unknown client, resolution failure, execution error, or job lifecycle error |
## Error Handling Guidance
@@ -205,11 +356,11 @@ Meaning: the prompt string was empty or whitespace-only.
Action: supply a non-empty `--prompt` or positional prompt argument.
### `Execution timed out after 300000ms`
### `Execution timed out after 600000ms`
Meaning: the client subprocess did not finish within the default 5-minute timeout.
Meaning: the client subprocess did not finish within the timeout.
Action: the client may be waiting for interactive input or the task is too large. Break the prompt into smaller pieces, or run the client directly to diagnose.
Action: the client may be waiting for interactive input or the task is too large. Break the prompt into smaller pieces, increase `--timeout`, or run the client directly to diagnose. Async jobs that time out are recorded with status `timed_out`.
### `Could not resolve client from prompt`
@@ -217,6 +368,49 @@ Meaning: `dispatch` found no matching keyword and no `defaultClient` is configur
Action: include a client name in the prompt (e.g., `"use claude to ..."`) or set `defaultClient` in config.
### `Job "<job-id>" not found`
Meaning: the requested job ID does not exist in the job store.
Action: verify the job ID. Job files are stored under `~/.openclaw/ai-cli-dispatch/jobs/`. If the directory was cleaned or the host restarted, old jobs may have been removed.
### `Job "<job-id>" result is not available (status: <status>)`
Meaning: `results` was called on a job that has not finished (`running`) or finished unsuccessfully (`failed`, `timed_out`, `cancelled`).
Action: poll `status` until the job reaches `completed`, or inspect `status` output for the error field.
## Job Lifecycle Workflows
### Fire-and-forget
```bash
JOB=$(ai-cli-dispatch run --client codex --prompt "refactor auth" --json | jq -r '.jobId')
# caller continues immediately
```
### Poll until completion
```bash
JOB=$(ai-cli-dispatch start --client claude --prompt "write tests" --json | jq -r '.jobId')
while [ "$(ai-cli-dispatch status "$JOB" --json | jq -r '.status')" = "running" ]; do
sleep 5
done
ai-cli-dispatch results "$JOB" --json
```
### Sync one-shot
```bash
ai-cli-dispatch run --client opencode --prompt "fix lint" --sync --text
```
### Batch cleanup
```bash
ai-cli-dispatch cleanup-jobs --max-age 24h
```
## Common Flows
### Check what is installed
@@ -225,12 +419,18 @@ Action: include a client name in the prompt (e.g., `"use claude to ..."`) or set
ai-cli-dispatch list --json
```
### Run a quick task through a specific client
### Run a quick task through a specific client (async)
```bash
ai-cli-dispatch run --client codex --prompt "fix lint errors in src/app.ts"
```
### Run a quick task synchronously
```bash
ai-cli-dispatch run --client codex --prompt "fix lint errors in src/app.ts" --sync
```
### Let the tool pick the client from the prompt
```bash
@@ -245,14 +445,16 @@ ai-cli-dispatch dispatch "review this PR" --client claude
## Coexistence with ACP
`ai-cli-dispatch` is a direct subprocess dispatcher. It runs the client binary synchronously and returns its output. It is not an ACP agent and does not participate in ACP orchestration.
`ai-cli-dispatch` is a direct subprocess dispatcher. It is not an ACP agent and does not participate in ACP orchestration.
- Use `ai-cli-dispatch` when you need a quick, local, one-shot CLI execution.
- Use `ai-cli-dispatch` when you need a quick, local, one-shot CLI execution or a background job.
- Use ACP (`docs/openclaw-acp-orchestration.md`) when you need session-bound coding harnesses with thread context, multi-turn review, or orchestrator-managed verification gates.
## Implementation Notes
- The dispatcher is TypeScript/Node.js with a single external dependency (`minimist`).
- Client arguments are hardcoded per tool to match each clients stable CLI contract.
- The default timeout is 5 minutes (`300_000` ms).
- The default timeout is 10 minutes (`600_000` ms); override with `--timeout` or config.
- On Windows, discovery uses `where` instead of `which` and `.exe` extensions are assumed.
- Async jobs run as detached processes with `stdio: ["ignore", "pipe", "pipe"]` so the dispatcher can exit without waiting.
- Job files are written atomically to `~/.openclaw/ai-cli-dispatch/jobs/<jobId>.json`.
+119 -35
View File
@@ -6,30 +6,36 @@ This document describes the internal design of `ai-cli-dispatch`, the module bre
```text
src/
├── cli.ts — Entry point: argument parsing, command routing, I/O formatting
├── types.ts — Shared types and error classes
├── constants.ts — Client name registry and platform helpers
├── config.ts Layered configuration resolution (flags → env → file → PATH)
├── detect.ts — Client discovery: binary lookup and version extraction
├── dispatch.ts — Prompt-to-client resolution (explicit flag → keywords → default)
── execute.ts — Subprocess spawning, stdout/stderr capture, timeout handling
├── cli.ts — Entry point: argument parsing, command routing, I/O formatting
├── cli-helpers.ts — Shared formatting, sync/async run handlers, error reporters
├── types.ts — Shared types and error classes
├── constants.ts — Client name registry and platform helpers
├── config.ts — Layered configuration resolution (flags → env → file → PATH)
├── detect.ts — Client discovery: binary lookup and version extraction
── dispatch.ts Prompt-to-client resolution (explicit flag → keywords → default)
├── execute.ts — Synchronous subprocess spawning, stdout/stderr capture, timeout handling
└── jobs.ts — Async job lifecycle: detached spawn, disk-backed state, polling API
```
### Responsibilities
| Module | Responsibility |
|---|---|
| `cli.ts` | Parses `argv` with `minimist`, routes to `list` / `run` / `dispatch`, prints JSON or text output, and controls the process exit code. |
| `types.ts` | Defines `ClientName`, `ClientInfo`, `ExecResult`, `ToolConfig`, and the error hierarchy (`ClientNotFoundError`, `ExecError`). |
| `cli.ts` | Parses `argv` with `minimist`, routes to all commands, prints JSON or text output, and controls the process exit code. |
| `cli-helpers.ts` | Shared helpers for `reportError`, `reportCliError`, `handleSyncRun`, and `handleAsyncRun` to keep `cli.ts` focused on routing. |
| `types.ts` | Defines `ClientName`, `ClientInfo`, `ExecResult`, `ToolConfig`, `Job`, `JobRecord`, `JobStatus`, and the error hierarchy (`ClientNotFoundError`, `ExecError`, `JobNotFoundError`, `JobResultUnavailableError`). |
| `constants.ts` | Holds the canonical `CLIENT_NAMES` array and `isWindows()` helper used by discovery and config. |
| `config.ts` | Resolves per-client binary paths and the optional `defaultClient` from four layered sources. |
| `detect.ts` | Locates each client binary on `PATH`, falls back to a manual directory scan, and invokes `--version` to extract a semver string. |
| `dispatch.ts` | Chooses the target client from a prompt string using ordered keyword matching, with overrides for explicit `--client` and `defaultClient`. |
| `execute.ts` | Spawns the chosen client with its native argument shape, buffers `stdout`/`stderr`, enforces a timeout, and returns an `ExecResult` or throws a typed error. |
| `jobs.ts` | Manages background jobs: writes job records to disk, spawns detached child processes, tracks running children in memory, and provides `status`, `results`, `cancel`, `list`, and `cleanup` operations. |
## Data Flow
A typical `dispatch` invocation flows through four stages:
### Synchronous dispatch (`run --sync`, `dispatch --sync`)
A sync invocation flows through four stages:
```
┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐
@@ -41,6 +47,30 @@ A typical `dispatch` invocation flows through four stages:
--version fallback default timeout / exitCode
```
### Asynchronous dispatch (`run`, `dispatch`, `start`)
An async invocation adds the `jobs.ts` stage. The caller receives a job ID immediately; the child process continues in the background.
```
┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ detect │ ──► │ config │ ──► │ dispatch │ ──► │ execute │ ──► │ jobs │
└─────────────┘ └─────────────┘ └─────────────┘ └─────────────┘ └─────────────┘
│ │ │ │ │
which/where flags/env/file keyword scan spawn child write job file
PATH walk defaultClient --client override capture output detached + unref
--version fallback default timeout / exitCode update on close
```
Later, lifecycle commands read from or modify the job store:
```
status <jobId> ──► readJobFile ──► return Job (sans stdout/stderr)
results <jobId> ──► readJobFile ──► return ExecResult (completed only)
cancel <jobId> ──► readJobFile ──► kill child or PID ──► write cancelled status
list-jobs ──► readdir jobDir ──► read each file ──► sort + filter
cleanup-jobs ──► readdir jobDir ──► stat mtime ──► unlink old files
```
### 1. Detect
`detectClients()` iterates over `CLIENT_NAMES` and attempts to locate each binary:
@@ -55,9 +85,9 @@ Result: an array of `ClientInfo` objects with `name`, `found`, `path`, and `vers
`resolveConfig()` builds a `ResolvedConfig` by layering sources (highest to lowest precedence):
1. **CLI flags**`--codex-path`, `--claude-path`, `--opencode-path`, `--default-client`
1. **CLI flags**`--codex-path`, `--claude-path`, `--opencode-path`, `--default-client`, `--timeout`
2. **Environment variables**`AI_CLI_CODEX_PATH`, `AI_CLI_CLAUDE_PATH`, `AI_CLI_OPENCODE_PATH`, `AI_CLI_DEFAULT_CLIENT`
3. **Config file**`~/.openclaw/ai-cli-dispatch.json` (`paths` and `defaultClient` keys)
3. **Config file**`~/.openclaw/ai-cli-dispatch.json` (`paths`, `defaultClient`, `timeout` keys)
4. **PATH discovery**`which`/`where` fallback via `defaultWhichSync()`
Only values for the three known `ClientName` entries are accepted; unknown `defaultClient` values are ignored.
@@ -78,30 +108,88 @@ This ordering intentionally prioritizes `"open code"` before `"opencode"` so the
### 4. Execute
`executePrompt(client, prompt, options)` runs the selected client:
`executePrompt(client, prompt, options)` runs the selected client synchronously:
1. Reject empty or whitespace-only prompts with `ExecError`.
2. Validate that an explicit `clientPath` exists on disk (if provided).
3. Map the client to its native argument array via `CLIENT_ARGS`:
- `codex``["exec", prompt]`
- `claude``["-p", prompt]`
- `opencode``[prompt]`
- `codex``["exec", "--yolo", prompt]`
- `claude``["-p", prompt, "--dangerously-skip-permissions"]`
- `opencode``["run", "--dangerously-skip-permissions", prompt]`
4. `spawn()` the process with `shell: false`.
5. Buffer `stdout` and `stderr` via `"data"` listeners.
6. Start a `setTimeout`; if it fires, `child.kill()` is sent.
7. On `close`, resolve with `{ stdout, stderr, exitCode }`.
7. On `close`, resolve with `{ stdout, stderr, exitCode, client, durationMs }`.
8. On `error`, reject with `ClientNotFoundError` for `ENOENT` or `ExecError` for anything else.
9. On timeout, reject with `ExecError` containing the buffered output so far.
10. If `debug` is enabled, emit a `DebugInfo` object via `onDebug`.
The default timeout is **5 minutes** (`300_000` ms).
The default timeout is **10 minutes** (`600_000` ms).
### 5. Jobs
`startJob(client, prompt, options)` launches a background job:
1. Generate a UUID for the job ID.
2. Build the client argument array via `CLIENT_ARGS`.
3. `spawn()` the process with `detached: true` and `stdio: ["ignore", "pipe", "pipe"]`.
4. Write an initial `JobRecord` to `~/.openclaw/ai-cli-dispatch/jobs/<jobId>.json` with status `running`.
5. Update the record with the child `pid` once available.
6. Register the child in an in-memory `runningChildren` Map for cancellation and timeout tracking.
7. Buffer `stdout`/`stderr` via `"data"` listeners.
8. On `close`, finalize the record: write status (`completed`, `failed`, `timed_out`, or `cancelled`), capture stdout/stderr, and record `durationMs`.
9. Call `child.unref()` so the dispatcher process can exit without waiting for the child.
`getJob(jobId)` reads the job file and returns a `Job` (omitting the full stdout/stderr buffers).
`getJobResult(jobId)` returns the `ExecResult` for a completed job.
`cancelJob(jobId)` looks up the running child in memory, sends `SIGTERM`, and writes a `cancelled` status. If the child is no longer in memory, it attempts `process.kill(pid, "SIGTERM")` as a fallback.
`listJobs({ filter })` reads all `.json` files in the job directory, parses them, sorts by `startedAt` descending, and optionally filters by status.
`cleanupJobs({ maxAgeMs })` deletes job files whose `mtime` exceeds the threshold. Default max age is 24 hours.
## Design Decisions
### Async-First Architecture
The default execution mode is **async** (background job). Synchronous execution requires an explicit `--sync` flag.
**Rationale:**
- **Primary use case alignment:** Most AI CLI tasks (refactoring, test generation, migration) run for multiple minutes. Blocking the caller for that long is often undesirable in automation and orchestration contexts.
- **Resilience:** A detached background job survives an unexpected dispatcher exit. The caller can reconnect later via `status` and `results`.
- **Batching:** Multiple jobs can be started in parallel without blocking the dispatcher process.
- **Backward compatibility path:** `--sync` preserves the original one-shot behavior for callers that need it, without changing the default.
### Disk-Backed Job Store
Job state is persisted as JSON files on disk rather than kept solely in memory.
**Rationale:**
- **Durability across restarts:** If the dispatcher process crashes or the host reboots, job files remain. A caller can still query `status` or `results` after recovery.
- **No memory leaks:** Long-running or forgotten jobs do not accumulate in heap. Cleanup is explicit via `cleanup-jobs`.
- **External observability:** Operators can inspect `~/.openclaw/ai-cli-dispatch/jobs/` directly without calling the CLI.
- **Simplicity:** A file-per-job model avoids the need for an embedded database or external service. It maps cleanly to the Node.js `fs` API and is trivial to mock in tests.
**Trade-off:** High-frequency job creation could strain the filesystem, but the expected volume is low (tens to hundreds of jobs, not thousands per second).
### Detached-Process Approach
Async jobs use `detached: true` with `child.unref()`.
**Rationale:**
- **Parent independence:** The dispatcher can start a job and exit immediately. This is essential for CLI usage where the user or orchestrator should not hold a shell open for the duration of the task.
- **Signal isolation:** A detached process group means the child does not receive `SIGINT` or `SIGHUP` sent to the dispatcher terminal session.
- **PID tracking:** Even though the child is detached, the `pid` is captured and written to the job file. This enables `cancelJob` to send signals even if the dispatcher has restarted and lost its in-memory `runningChildren` map.
**Trade-off:** The child is truly independent. If the host reboots, the child is lost (same as any other process). The job file will eventually reflect `timed_out` or remain `running` until `cancel` or `cleanup` is run.
### Coexistence with ACP
`ai-cli-dispatch` is intentionally **not** an ACP agent. It is a thin, local subprocess wrapper with no session state, no thread binding, and no orchestrator protocol.
- Use `ai-cli-dispatch` when you need a quick, one-shot CLI execution on the gateway host.
- Use `ai-cli-dispatch` when you need a quick, one-shot CLI execution or a background job on the gateway host.
- Use ACP (`docs/openclaw-acp-orchestration.md`) when you need session-bound coding harnesses, multi-turn review, or orchestrator-managed verification gates.
This separation keeps the dispatcher small and avoids duplicating ACPs scheduling, context persistence, and review-loop responsibilities.
@@ -118,17 +206,6 @@ Client resolution uses deterministic substring matching instead of natural-langu
The trade-off is that prompts like `"compare codex and claude"` resolve to `codex` because `"codex"` is checked first. Users can always override with `--client`.
### Sync-Only Initial Release
The current implementation is entirely synchronous from the callers perspective: `executePrompt` returns a promise that resolves only when the child process exits or the timeout fires.
**Rationale:**
- The primary use case is one-shot tasks (refactor, add tests, migrate) where the agent needs the complete output before proceeding.
- Streaming would require a different output contract (callbacks, generators, or an event emitter) and complicates the JSON error model.
- ACP already covers interactive, streaming, and session-based use cases.
Streaming is an intentional future extension point (see below).
### Error Taxonomy
All runtime failures are represented as typed errors so callers and tests can branch precisely:
@@ -136,7 +213,9 @@ All runtime failures are represented as typed errors so callers and tests can br
| Error | When thrown | Data carried |
|---|---|---|
| `ClientNotFoundError` | Binary not on `PATH`, explicit `clientPath` missing, or `ENOENT` from `spawn` | `message` with client name |
| `ExecError` | Empty prompt, unknown client, timeout, non-`ENOENT` spawn error, or child exit | `message` + full `ExecResult` (`stdout`, `stderr`, `exitCode`) |
| `ExecError` | Empty prompt, unknown client, timeout, non-`ENOENT` spawn error, or child exit | `message` + full `ExecResult` (`stdout`, `stderr`, `exitCode`, `client`, `durationMs`) |
| `JobNotFoundError` | Job ID not found in the job store | `message` with job ID |
| `JobResultUnavailableError` | `results` called on a non-completed job | `message` with job ID and current status |
`ExecError` carries the `ExecResult` so that timeout handlers still return partial output. This avoids losing buffered stdout/stderr when a long-running task is killed.
@@ -151,7 +230,7 @@ Every non-trivial module accepts an `options` bag with injectable dependencies (
### Minimal Dependency Surface
The runtime dependency graph contains exactly one external package: `minimist` (argument parsing). Everything else uses Node.js built-ins (`child_process`, `fs`, `os`, `path`).
The runtime dependency graph contains exactly one external package: `minimist` (argument parsing). Everything else uses Node.js built-ins (`child_process`, `fs`, `os`, `path`, `crypto`).
**Rationale:**
- Reduces supply-chain risk and install time.
@@ -169,7 +248,8 @@ To support a fourth (or fifth) AI CLI client, change four files in `src/` and th
3. **`src/execute.ts`** — Add an entry to `CLIENT_ARGS` with the clients native argument shape.
4. **`src/config.ts`** — No change required; the existing loop over `CLIENT_NAMES` automatically picks up the new env/flag/file keys.
5. **`src/dispatch.ts`** — Add a keyword check for the new client in `resolveClient`. Decide its precedence relative to existing keywords.
6. **Tests**Add colocated test cases in `tests/dispatch.test.ts`, `tests/execute.test.ts`, and `tests/detect.test.ts`.
6. **`src/jobs.ts`** — No change required; `CLIENT_ARGS` is already shared.
7. **Tests** — Add colocated test cases in `tests/dispatch.test.ts`, `tests/execute.test.ts`, `tests/detect.test.ts`, and `tests/jobs.test.ts`.
No changes are needed in `cli.ts` because it iterates over `CLIENT_NAMES` for validation.
@@ -194,6 +274,8 @@ When `onData` is provided, `executePrompt` would:
This preserves backward compatibility: existing callers that omit `onData` receive the exact same buffered `ExecResult` they get today.
For async jobs, `jobs.ts` could store a partial `stdout`/`stderr` in the job file on each chunk (or at a throttled interval) so `status` callers can see progress without waiting for completion.
### Platform Backends
The current Windows support is limited to discovery (`where` instead of `which`, `.exe` extension assumptions). If future clients require platform-specific spawn options (e.g., PowerShell quoting rules), the extension point is `CLIENT_ARGS` or a new `CLIENT_SPAWN_OPTIONS` record keyed by `ClientName`.
@@ -204,10 +286,12 @@ The test suite in `tests/` mirrors the `src/` structure:
| Test file | Coverage |
|---|---|
| `cli.test.ts` | Argument parsing, command routing, JSON/text output modes, exit codes, error formatting |
| `cli.test.ts` | Argument parsing, command routing, JSON/text output modes, exit codes, error formatting, sync vs async branches, all job lifecycle commands |
| `cli-helpers.test.ts` | `reportError`, `reportCliError`, `handleSyncRun`, `handleAsyncRun` with JSON and text modes |
| `config.test.ts` | Layered precedence of flags, env, file, and `which` fallback; malformed JSON tolerance |
| `detect.test.ts` | `which` success/failure, PATH directory fallback, version parsing, missing binary handling |
| `dispatch.test.ts` | Keyword matching, case insensitivity, `--client` precedence, `defaultClient` fallback, invalid flag handling |
| `execute.test.ts` | Successful execution, stderr capture, non-zero exit codes, `ENOENT``ClientNotFoundError`, timeout, empty prompt rejection, special-character preservation |
| `execute.test.ts` | Successful execution, stderr capture, non-zero exit codes, `ENOENT``ClientNotFoundError`, timeout, empty prompt rejection, special-character preservation, debug info emission |
| `jobs.test.ts` | Job start, status query, result retrieval, cancellation, listing, cleanup, timeout handling, unknown client fallback, detached process behavior, in-memory vs on-disk consistency |
All tests use injected mocks; no test spawns real client binaries or reads the real filesystem.
+155 -4
View File
@@ -26,14 +26,98 @@ npm install
## Commands
### `list`
Discover and report all supported clients.
```bash
scripts/ai-cli-dispatch list --json
scripts/ai-cli-dispatch exec --client codex --prompt "refactor this function"
scripts/ai-cli-dispatch exec --client claude --prompt "add tests for auth middleware"
scripts/ai-cli-dispatch exec --client opencode --prompt "migrate to ESM"
scripts/ai-cli-dispatch list --text
```
Use `--json` for machine-readable command output.
### `start` — start a background job
Starts a detached background job and returns immediately with a job ID.
```bash
scripts/ai-cli-dispatch start --client codex --prompt "refactor this function"
scripts/ai-cli-dispatch start --client claude --prompt "add tests for auth middleware"
```
### `run` — run a task (async by default)
Runs a prompt through a named client. By default this is **async**: it starts a background job and returns the job ID immediately. Use `--sync` to block until the client finishes and return the full result.
```bash
# Async (default) — returns job ID immediately
scripts/ai-cli-dispatch run --client codex --prompt "refactor this function"
scripts/ai-cli-dispatch run --client claude --prompt "add tests for auth middleware"
scripts/ai-cli-dispatch run --client opencode --prompt "migrate to ESM"
# Sync — blocks until completion and returns stdout/stderr/exitCode
scripts/ai-cli-dispatch run --client codex --prompt "fix lint errors" --sync
```
### `dispatch` — auto-resolve client and run (async by default)
Auto-resolves the client from prompt keywords, then executes. By default this is **async**. Use `--sync` to block until completion.
```bash
# Async (default)
scripts/ai-cli-dispatch dispatch "use codex to write tests"
scripts/ai-cli-dispatch dispatch "claude: add unit tests for utils.ts"
scripts/ai-cli-dispatch dispatch "opencode migrate to ESM"
# Sync
scripts/ai-cli-dispatch dispatch "review this PR" --client claude --sync
```
### Job lifecycle commands
After starting an async job, manage it with these subcommands:
```bash
# Check job status
scripts/ai-cli-dispatch status <job-id>
# Get results (only when status is completed)
scripts/ai-cli-dispatch results <job-id>
# Cancel a running job
scripts/ai-cli-dispatch cancel <job-id>
# List all jobs, newest first
scripts/ai-cli-dispatch list-jobs --json
scripts/ai-cli-dispatch list-jobs --status running --json
# Clean up old job files
scripts/ai-cli-dispatch cleanup-jobs --max-age 24h
```
## Async vs Sync Mode
The dispatcher is **async-first**: `run` and `dispatch` start a detached background job unless you pass `--sync`.
| Mode | Behavior | When to use |
|---|---|---|
| **Async** (default) | Starts a detached process, returns a `jobId` immediately, and stores output on disk. | Fire-and-forget tasks, long-running jobs, parallel dispatches, or when you need to poll/check results later. |
| **Sync** (`--sync`) | Blocks until the client subprocess exits, then returns `stdout`, `stderr`, and `exitCode` directly. | Short, interactive tasks where you need the result in the same turn. |
Use `--timeout <ms>` to control how long a job may run before it is terminated (default: 10 minutes / 600,000 ms for both async and sync). Use `--debug` to emit diagnostic metadata to stderr.
## Flags
| Flag | Description |
|---|---|
| `--sync` | Run synchronously and block until the client returns. Default is async (starts a background job). |
| `--timeout <ms>` | Timeout in milliseconds. Overrides the default and any config value. |
| `--debug` | Emit diagnostic JSON to stderr (command, args, PID, duration, exit signal). |
| `--json` | Output JSON (default). |
| `--text` | Output plain text instead of JSON. |
| `--client <name>` | Explicitly set the client (`codex`, `claude`, `opencode`). |
| `--prompt <text>` | The prompt to send to the client. |
| `--status <status>` | Filter `list-jobs` by status (`running`, `completed`, `failed`). |
| `--max-age <number>[h\|m\|s\|d]` | Maximum age for `cleanup-jobs` (default unit: hours). |
## Client Discovery
@@ -45,6 +129,73 @@ The skill searches for the following clients in order:
Run `list` to see which clients are installed and their resolved versions.
## Job Lifecycle & Storage
Async jobs run as detached child processes. Each job writes a record to disk at:
```text
~/.openclaw/ai-cli-dispatch/jobs/<jobId>.json
```
A job moves through the following statuses:
| Status | Meaning |
|---|---|
| `running` | The client subprocess is active. |
| `completed` | The subprocess exited with code 0. |
| `failed` | The subprocess exited with a non-zero code. |
| `timed_out` | The job exceeded `--timeout` and was terminated. |
| `cancelled` | The job was cancelled via `cancel <job-id>`. |
Example async workflow:
```bash
# 1. Start a job
scripts/ai-cli-dispatch run --client codex --prompt "refactor auth module"
# → { "jobId": "a1b2c3d4...", "client": "codex", "status": "running" }
# 2. Poll status
scripts/ai-cli-dispatch status a1b2c3d4...
# → { "id": "a1b2c3d4...", "status": "running", ... }
# 3. Get results when done
scripts/ai-cli-dispatch results a1b2c3d4...
# → { "stdout": "...", "stderr": "...", "exitCode": 0, "client": "codex", "durationMs": 42000 }
```
## Background Jobs (Programmatic API)
For long-running or fire-and-forget tasks, use the programmatic job API:
```typescript
import { startJob, getJob, getJobResult, cancelJob, listJobs, cleanupJobs } from "./src/jobs.js";
// Start a detached job
const job = await startJob("codex", "refactor auth module", { timeoutMs: 300_000 });
console.log(job.id); // e.g. "a1b2c3d4..."
console.log(job.status); // "running"
// Poll for completion
const latest = getJob(job.id);
console.log(latest.status); // "running" | "completed" | "failed" | "timed_out" | "cancelled"
// Get result (throws if not completed)
const result = getJobResult(job.id);
console.log(result.stdout, result.exitCode);
// Cancel a running job
cancelJob(job.id);
// List all jobs (newest first)
const jobs = listJobs(); // Job[]
const running = listJobs({ filter: "running" });
// Clean up job files older than 24 hours (default)
cleanupJobs({ maxAgeMs: 24 * 60 * 60 * 1000 });
```
Job files include stdout, stderr, exit code, timing, and error state.
## Output Rules
- Normal JSON output redacts local file paths and credential metadata.
+85
View File
@@ -0,0 +1,85 @@
import type { ClientName, ExecResult, DebugInfo, Job } from "./types.js";
export interface RunContext {
jsonMode: boolean;
stdoutWrite: (chunk: string) => void;
stderrWrite: (chunk: string) => void;
}
export function reportError(err: unknown, jsonMode: boolean): number {
const message = err instanceof Error ? err.message : String(err);
if (jsonMode) {
console.error(JSON.stringify({ error: message }, null, 2));
} else {
console.error(message);
}
return 1;
}
export function reportCliError(message: string, jsonMode: boolean): number {
if (jsonMode) {
console.error(JSON.stringify({ error: message }, null, 2));
} else {
console.error(`Error: ${message}`);
}
return 1;
}
export async function handleSyncRun(
executePrompt: (
client: ClientName,
prompt: string,
options?: { timeoutMs?: number; debug?: boolean; onDebug?: (info: DebugInfo) => void }
) => Promise<ExecResult>,
client: ClientName,
prompt: string,
timeoutMs: number | undefined,
debug: boolean,
ctx: RunContext
): Promise<number> {
try {
const result = await executePrompt(client, prompt, {
timeoutMs,
debug,
onDebug: debug ? (info) => ctx.stderrWrite(JSON.stringify(info) + "\n") : undefined,
});
if (ctx.jsonMode) {
console.log(JSON.stringify(result, null, 2));
} else {
if (result.stdout) ctx.stdoutWrite(result.stdout);
if (result.stderr) ctx.stderrWrite(result.stderr);
}
return 0;
} catch (err) {
return reportError(err, ctx.jsonMode);
}
}
export async function handleAsyncRun(
startJob: (
client: ClientName,
prompt: string,
options?: { timeoutMs?: number; debug?: boolean; onDebug?: (info: DebugInfo) => void }
) => Promise<Job>,
client: ClientName,
prompt: string,
timeoutMs: number | undefined,
debug: boolean,
ctx: RunContext
): Promise<number> {
try {
const job = await startJob(client, prompt, {
timeoutMs,
debug,
onDebug: debug ? (info) => ctx.stderrWrite(JSON.stringify(info) + "\n") : undefined,
});
if (ctx.jsonMode) {
console.log(JSON.stringify({ jobId: job.id, client: job.client, status: job.status }, null, 2));
} else {
console.log(`Job ${job.id} started (${job.client}): ${job.status}`);
}
return 0;
} catch (err) {
return reportError(err, ctx.jsonMode);
}
}
+238 -64
View File
@@ -1,6 +1,14 @@
import minimist from "minimist";
import { detectClients as realDetectClients } from "./detect.js";
import { executePrompt as realExecutePrompt } from "./execute.js";
import {
startJob as realStartJob,
getJob as realGetJob,
getJobResult as realGetJobResult,
cancelJob as realCancelJob,
listJobs as realListJobs,
cleanupJobs as realCleanupJobs,
} from "./jobs.js";
import { resolveClient as realResolveClient } from "./dispatch.js";
import { resolveConfig as realResolveConfig } from "./config.js";
import { CLIENT_NAMES } from "./constants.js";
@@ -8,22 +16,60 @@ import {
type ClientName,
type ClientInfo,
type ExecResult,
type DebugInfo,
type Job,
type JobStatus,
ClientNotFoundError,
} from "./types.js";
import {
reportError,
reportCliError,
handleSyncRun,
handleAsyncRun,
} from "./cli-helpers.js";
export interface CliDeps {
detectClients?: () => ClientInfo[];
executePrompt?: (
client: ClientName,
prompt: string
prompt: string,
options?: { timeoutMs?: number; debug?: boolean; onDebug?: (info: DebugInfo) => void }
) => Promise<ExecResult>;
startJob?: (
client: ClientName,
prompt: string,
options?: { timeoutMs?: number; debug?: boolean; onDebug?: (info: DebugInfo) => void }
) => Promise<Job>;
resolveClient?: (
prompt: string,
config?: { client?: ClientName; defaultClient?: ClientName }
) => ClientName | null;
resolveConfig?: () => { paths: Partial<Record<ClientName, string>>; defaultClient?: ClientName };
resolveConfig?: () => { paths: Partial<Record<ClientName, string>>; defaultClient?: ClientName; timeout?: number };
stdoutWrite?: (chunk: string) => void;
stderrWrite?: (chunk: string) => void;
getJob?: (jobId: string) => Job;
getJobResult?: (jobId: string) => ExecResult;
cancelJob?: (jobId: string) => void;
listJobs?: (options?: { filter?: JobStatus }) => Job[];
cleanupJobs?: (options?: { maxAgeMs?: number }) => void;
}
function parseMaxAge(value: string): number | null {
const match = value.match(/^(\d+(?:\.\d+)?)\s*([hmsd]?)$/i);
if (!match) return null;
const num = parseFloat(match[1]);
if (!Number.isFinite(num) || num < 0) return null;
const unit = match[2].toLowerCase();
const multipliers: Record<string, number> = {
s: 1000,
m: 60 * 1000,
h: 60 * 60 * 1000,
d: 24 * 60 * 60 * 1000,
"": 60 * 60 * 1000,
};
const multiplier = multipliers[unit];
if (multiplier === undefined) return null;
return num * multiplier;
}
function printHelp(): void {
@@ -31,10 +77,25 @@ function printHelp(): void {
Usage:
ai-cli-dispatch list [--json|--text]
ai-cli-dispatch run --client <client> --prompt <prompt> [--json|--text]
ai-cli-dispatch dispatch <prompt> [--client <client>] [--json|--text]
ai-cli-dispatch run --client <client> --prompt <prompt> [--sync] [--timeout <ms>] [--debug] [--json|--text]
ai-cli-dispatch dispatch <prompt> [--client <client>] [--sync] [--timeout <ms>] [--debug] [--json|--text]
ai-cli-dispatch start --client <client> --prompt <prompt> [--timeout <ms>] [--debug] [--json|--text]
ai-cli-dispatch status <job-id> [--json|--text]
ai-cli-dispatch results <job-id> [--json|--text]
ai-cli-dispatch cancel <job-id> [--json|--text]
ai-cli-dispatch list-jobs [--status running|completed|failed] [--json|--text]
ai-cli-dispatch cleanup-jobs [--max-age <number>[h|m|s|d]] [--json|--text]
ai-cli-dispatch --help
Flags:
--sync Run synchronously and block until the client returns (default is async)
--timeout Timeout in milliseconds (or override via config)
--debug Emit diagnostic JSON to stderr
--max-age Maximum age for cleanup (default unit: hours, e.g. 24h or 30m)
--status Filter jobs by status (running, completed, failed)
--json Output JSON (default)
--text Output plain text
Clients: codex, claude, opencode`);
}
@@ -44,22 +105,29 @@ export async function main(
): Promise<number> {
const detectClients = deps.detectClients ?? realDetectClients;
const executePrompt = deps.executePrompt ?? realExecutePrompt;
const startJob = deps.startJob ?? realStartJob;
const resolveClient = deps.resolveClient ?? realResolveClient;
const resolveConfig = deps.resolveConfig ?? realResolveConfig;
const stdoutWrite = deps.stdoutWrite ?? ((c: string) => process.stdout.write(c));
const stderrWrite = deps.stderrWrite ?? ((c: string) => process.stderr.write(c));
const getJob = deps.getJob ?? realGetJob;
const getJobResult = deps.getJobResult ?? realGetJobResult;
const cancelJob = deps.cancelJob ?? realCancelJob;
const listJobs = deps.listJobs ?? realListJobs;
const cleanupJobs = deps.cleanupJobs ?? realCleanupJobs;
const rawArgs = argv.slice(2);
const parseArgs =
rawArgs[0]?.includes("cli.ts") ? rawArgs.slice(1) : rawArgs;
const args = minimist(parseArgs, {
string: ["client", "prompt"],
boolean: ["json", "text", "help", "debug"],
string: ["client", "prompt", "timeout", "max-age"],
boolean: ["json", "text", "help", "debug", "sync"],
alias: { h: "help" },
});
const jsonMode = !args.text;
const debug = !!args.debug;
if (args.help) {
printHelp();
@@ -93,45 +161,27 @@ export async function main(
const prompt = args.prompt as string | undefined;
if (!client || !CLIENT_NAMES.includes(client)) {
const message = !client
? "--client is required"
: `Unknown client: ${client}`;
if (jsonMode) {
console.error(JSON.stringify({ error: message }, null, 2));
} else {
console.error(`Error: ${message}`);
}
return 1;
return reportCliError(
!client ? "--client is required" : `Unknown client: ${client}`,
jsonMode
);
}
if (!prompt) {
const message = "--prompt is required";
if (jsonMode) {
console.error(JSON.stringify({ error: message }, null, 2));
} else {
console.error(`Error: ${message}`);
}
return 1;
return reportCliError("--prompt is required", jsonMode);
}
try {
const result = await executePrompt(client, prompt);
if (jsonMode) {
console.log(JSON.stringify(result, null, 2));
} else {
if (result.stdout) stdoutWrite(result.stdout);
if (result.stderr) stderrWrite(result.stderr);
}
return 0;
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
if (jsonMode) {
console.error(JSON.stringify({ error: message }, null, 2));
} else {
console.error(message);
}
return 1;
const config = resolveConfig();
const parsedTimeout =
typeof args.timeout === "string" ? Number(args.timeout) : undefined;
const timeoutMs =
Number.isFinite(parsedTimeout) ? parsedTimeout : config.timeout;
const ctx = { jsonMode, stdoutWrite, stderrWrite };
if (args.sync) {
return await handleSyncRun(executePrompt, client, prompt, timeoutMs, debug, ctx);
}
return await handleAsyncRun(startJob, client, prompt, timeoutMs, debug, ctx);
}
if (command === "dispatch") {
@@ -141,13 +191,7 @@ export async function main(
}
if (!prompt) {
const message = "prompt is required";
if (jsonMode) {
console.error(JSON.stringify({ error: message }, null, 2));
} else {
console.error(`Error: ${message}`);
}
return 1;
return reportCliError("prompt is required", jsonMode);
}
const config = resolveConfig();
@@ -158,17 +202,86 @@ export async function main(
});
if (!client) {
const message = "Could not resolve client from prompt";
return reportCliError("Could not resolve client from prompt", jsonMode);
}
const parsedTimeout =
typeof args.timeout === "string" ? Number(args.timeout) : undefined;
const timeoutMs =
Number.isFinite(parsedTimeout) ? parsedTimeout : config.timeout;
const ctx = { jsonMode, stdoutWrite, stderrWrite };
if (args.sync) {
return await handleSyncRun(executePrompt, client, prompt, timeoutMs, debug, ctx);
}
return await handleAsyncRun(startJob, client, prompt, timeoutMs, debug, ctx);
}
if (command === "start") {
const client = args.client as ClientName | undefined;
const prompt = args.prompt as string | undefined;
if (!client || !CLIENT_NAMES.includes(client)) {
return reportCliError(
!client ? "--client is required" : `Unknown client: ${client}`,
jsonMode
);
}
if (!prompt) {
return reportCliError("--prompt is required", jsonMode);
}
const config = resolveConfig();
const parsedTimeout =
typeof args.timeout === "string" ? Number(args.timeout) : undefined;
const timeoutMs =
Number.isFinite(parsedTimeout) ? parsedTimeout : config.timeout;
try {
const job = await startJob(client, prompt, {
timeoutMs,
debug,
onDebug: debug ? (info) => stderrWrite(JSON.stringify(info) + "\n") : undefined,
});
if (jsonMode) {
console.error(JSON.stringify({ error: message }, null, 2));
console.log(JSON.stringify({ jobId: job.id, client: job.client, status: job.status }, null, 2));
} else {
console.error(`Error: ${message}`);
console.log(`Job ${job.id} started (${job.client}): ${job.status}`);
}
return 1;
return 0;
} catch (err) {
return reportError(err, jsonMode);
}
}
if (command === "status") {
const jobId = args._[1];
if (!jobId) {
return reportCliError("job-id is required", jsonMode);
}
try {
const result = await executePrompt(client, prompt);
const job = getJob(jobId);
if (jsonMode) {
console.log(JSON.stringify(job, null, 2));
} else {
console.log(`Job ${jobId}: ${job.status}`);
}
return 0;
} catch (err) {
return reportError(err, jsonMode);
}
}
if (command === "results") {
const jobId = args._[1];
if (!jobId) {
return reportCliError("job-id is required", jsonMode);
}
try {
const result = getJobResult(jobId);
if (jsonMode) {
console.log(JSON.stringify(result, null, 2));
} else {
@@ -177,23 +290,84 @@ export async function main(
}
return 0;
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
if (jsonMode) {
console.error(JSON.stringify({ error: message }, null, 2));
} else {
console.error(message);
}
return 1;
return reportError(err, jsonMode);
}
}
const message = `Unknown command: ${command}`;
if (jsonMode) {
console.error(JSON.stringify({ error: message }, null, 2));
} else {
console.error(message);
if (command === "cancel") {
const jobId = args._[1];
if (!jobId) {
return reportCliError("job-id is required", jsonMode);
}
try {
const job = getJob(jobId);
if (job.status !== "running") {
return reportCliError(
`Job is not running (status: ${job.status})`,
jsonMode
);
}
cancelJob(jobId);
if (jsonMode) {
console.log(JSON.stringify({ jobId, cancelled: true }, null, 2));
} else {
console.log(`Job ${jobId} cancelled`);
}
return 0;
} catch (err) {
return reportError(err, jsonMode);
}
}
return 1;
if (command === "list-jobs") {
try {
const filter = args.status as JobStatus | undefined;
const jobs = listJobs({ filter });
if (jsonMode) {
console.log(JSON.stringify(jobs, null, 2));
} else {
for (const job of jobs) {
console.log(`${job.id} (${job.client}): ${job.status}`);
}
}
return 0;
} catch (err) {
return reportError(err, jsonMode);
}
}
if (command === "cleanup-jobs") {
const maxAgeRaw = args["max-age"] as string | undefined;
let maxAgeMs: number | undefined;
if (maxAgeRaw !== undefined) {
const parsed = parseMaxAge(maxAgeRaw);
if (parsed === null) {
return reportCliError(
"Invalid --max-age format. Use: <number>[h|m|s|d], e.g. 24h",
jsonMode
);
}
maxAgeMs = parsed;
}
try {
const jobsBefore = listJobs();
cleanupJobs({ maxAgeMs });
const jobsAfter = listJobs();
const count = jobsBefore.length - jobsAfter.length;
if (jsonMode) {
console.log(JSON.stringify({ count }, null, 2));
} else {
console.log(`Cleaned ${count} jobs`);
}
return 0;
} catch (err) {
return reportError(err, jsonMode);
}
}
return reportError(`Unknown command: ${command}`, jsonMode);
}
const isMain =
+18
View File
@@ -10,6 +10,7 @@ import { CLIENT_NAMES, isWindows } from "./constants.js";
export interface ResolvedConfig {
paths: Partial<Record<ClientName, string>>;
defaultClient?: ClientName;
timeout?: number;
}
export interface ResolveConfigOptions {
@@ -89,5 +90,22 @@ export function resolveConfig(
) {
result.defaultClient = defaultClient as ClientName;
}
const flagTimeout =
typeof flags.timeout === "string" ? Number(flags.timeout) : undefined;
const envTimeout =
typeof env.AI_CLI_TIMEOUT === "string"
? Number(env.AI_CLI_TIMEOUT)
: undefined;
const fileTimeout =
typeof fileConfig.timeout === "number" ? fileConfig.timeout : undefined;
const resolvedTimeout =
(Number.isFinite(flagTimeout) ? flagTimeout : undefined) ??
(Number.isFinite(envTimeout) ? envTimeout : undefined) ??
(Number.isFinite(fileTimeout) ? fileTimeout : undefined) ??
600_000;
result.timeout = resolvedTimeout;
return result;
}
+1 -2
View File
@@ -1,3 +1,4 @@
import { CLIENT_NAMES } from "./constants.js";
import type { ClientName } from "./types.js";
export interface DispatchConfig {
@@ -5,8 +6,6 @@ export interface DispatchConfig {
client?: ClientName;
}
const CLIENT_NAMES: ClientName[] = ["codex", "claude", "opencode"];
export function resolveClient(
prompt: string,
config?: DispatchConfig
+68 -15
View File
@@ -1,21 +1,34 @@
import type { ChildProcess } from "node:child_process";
import { spawn as defaultSpawn } from "node:child_process";
import type { PathLike } from "node:fs";
import { existsSync as defaultExistsSync } from "node:fs";
import type { ClientName, ExecResult } from "./types.js";
import type { ClientName, ExecResult, DebugInfo, ExecuteOptions } from "./types.js";
import { ClientNotFoundError, ExecError } from "./types.js";
const CLIENT_ARGS: Record<ClientName, (prompt: string) => string[]> = {
export const CLIENT_ARGS: Record<ClientName, (prompt: string) => string[]> = {
codex: (p) => ["exec", "--yolo", p],
claude: (p) => ["-p", p, "--dangerously-skip-permissions"],
opencode: (p) => ["run", "--dangerously-skip-permissions", p],
};
export interface ExecuteOptions {
clientPath?: string;
timeoutMs?: number;
spawn?: (command: string, args: string[], options?: { shell?: boolean }) => ChildProcess;
existsSync?: (path: PathLike) => boolean;
/**
* Known stderr noise patterns per client.
* When exit code is 0, lines matching these patterns are stripped from the
* returned stderr to prevent agents from misinterpreting informational
* diagnostics as errors. The raw (unfiltered) stderr is preserved in
* DebugInfo.rawStderr when --debug is active.
*/
const STDERR_NOISE_PATTERNS: Partial<Record<ClientName, RegExp[]>> = {
codex: [
/^\d{4}-\d{2}-\d{2}T[\d:.]+Z\s+ERROR\s+codex_core::util:\s+ReasoningSummary\w*\s+/,
],
};
function filterStderrNoise(client: ClientName, stderr: string, exitCode: number): string {
if (exitCode !== 0) return stderr;
const patterns = STDERR_NOISE_PATTERNS[client];
if (!patterns) return stderr;
const lines = stderr.split("\n");
const filtered = lines.filter((line) => !patterns.some((p) => p.test(line)));
return filtered.join("\n").replace(/\n+$/, "");
}
export async function executePrompt(
@@ -28,12 +41,14 @@ export async function executePrompt(
stdout: "",
stderr: "",
exitCode: -1,
client,
durationMs: 0,
});
}
const spawnImpl = options.spawn ?? defaultSpawn;
const existsSyncImpl = options.existsSync ?? defaultExistsSync;
const timeoutMs = options.timeoutMs ?? 300_000;
const timeoutMs = options.timeoutMs ?? 600_000;
const command = options.clientPath ?? client;
if (options.clientPath && !existsSyncImpl(options.clientPath)) {
@@ -46,6 +61,8 @@ export async function executePrompt(
stdout: "",
stderr: "",
exitCode: -1,
client,
durationMs: 0,
});
}
const args = argBuilder(prompt);
@@ -55,11 +72,17 @@ export async function executePrompt(
let timedOut = false;
let stdout = "";
let stderr = "";
let exitSignal: NodeJS.Signals | null = null;
const startMs = Date.now();
const child = spawnImpl(command, args, {
shell: false,
stdio: ["pipe", "pipe", "pipe"],
});
// Close stdin immediately so clients like codex don't hang waiting for input
child.stdin?.end();
child.stdout?.on("data", (chunk: Buffer | string) => {
stdout += chunk.toString();
});
@@ -80,8 +103,35 @@ export async function executePrompt(
if (settled) return;
settled = true;
clearTimeout(timeout);
if (err) reject(err);
else resolve(result!);
const durationMs = Date.now() - startMs;
const rawStderr = stderr;
const cleanedStderr = filterStderrNoise(client, rawStderr, result?.exitCode ?? -1);
if (options.debug || options.onDebug) {
const effectiveExitCode = result?.exitCode ?? (err instanceof ExecError ? err.result.exitCode : null);
const debugInfo: DebugInfo = {
command,
args,
pid: child.pid ?? undefined,
exitCode: effectiveExitCode,
exitSignal,
durationMs,
stderrLength: rawStderr.length,
stdoutLength: stdout.length,
noisySuccess: effectiveExitCode === 0 && rawStderr.length > 0,
rawStderr: rawStderr !== cleanedStderr ? rawStderr : undefined,
};
options.onDebug?.(debugInfo);
}
if (err) {
if (err instanceof ExecError) {
err.result.stderr = cleanedStderr;
err.result.client = client;
err.result.durationMs = durationMs;
}
reject(err);
} else {
resolve({ ...result!, client, durationMs, stderr: cleanedStderr });
}
}
child.on("error", (err: NodeJS.ErrnoException) => {
@@ -89,22 +139,25 @@ export async function executePrompt(
settle(new ClientNotFoundError(client));
} else {
settle(
new ExecError(err.message, { stdout, stderr, exitCode: -1 })
new ExecError(err.message, { stdout, stderr, exitCode: -1, client, durationMs: 0 })
);
}
});
child.on("close", (code: number | null) => {
child.on("close", (code: number | null, signal: NodeJS.Signals | null) => {
exitSignal = signal;
if (timedOut) {
settle(
new ExecError(`Execution timed out after ${timeoutMs}ms`, {
stdout,
stderr,
exitCode: -1,
client,
durationMs: 0,
})
);
} else {
settle(undefined, { stdout, stderr, exitCode: code ?? -1 });
settle(undefined, { stdout, stderr, exitCode: code ?? -1, client, durationMs: 0 });
}
});
});
+128
View File
@@ -0,0 +1,128 @@
/**
* Job watcher — a tiny self-contained process that monitors a detached
* child and writes the final job record to disk.
*
* Invoked as: node --import tsx src/job-watcher.ts <jobFile> <command> <arg1> <arg2> ...
*
* The watcher is itself spawned as detached+unref'd by the CLI, so the CLI
* can return the job ID immediately while this process stays alive to capture
* the child's output and finalize the job file.
*/
import { spawn } from "node:child_process";
import { readFileSync, writeFileSync } from "node:fs";
import type { JobRecord, ExecResult, JobStatus, ClientName } from "./types.js";
// Must import CLIENT_ARGS to know the client command mapping
// And the noise filter for consistent stderr handling
/**
* Known stderr noise patterns per client (duplicated from execute.ts to keep
* the watcher self-contained with no runtime dependency on execute.ts).
*/
const STDERR_NOISE_PATTERNS: Partial<Record<ClientName, RegExp[]>> = {
codex: [
/^\d{4}-\d{2}-\d{2}T[\d:.]+Z\s+ERROR\s+codex_core::util:\s+ReasoningSummary\w*\s+/,
],
};
function filterStderrNoise(client: ClientName, stderr: string, exitCode: number): string {
if (exitCode !== 0) return stderr;
const patterns = STDERR_NOISE_PATTERNS[client];
if (!patterns) return stderr;
const lines = stderr.split("\n");
const filtered = lines.filter((line) => !patterns.some((p) => p.test(line)));
return filtered.join("\n").replace(/\n+$/, "");
}
const jobFile = process.argv[2];
const command = process.argv[3];
const childArgs = process.argv.slice(4);
if (!jobFile || !command) {
process.exit(1);
}
let record: JobRecord;
try {
record = JSON.parse(readFileSync(jobFile, "utf-8")) as JobRecord;
} catch {
process.exit(1);
}
const timeoutMs = 600_000; // 10 min default
let stdout = "";
let stderr = "";
let settled = false;
let timedOut = false;
const startMs = Date.now();
const child = spawn(command, childArgs, {
shell: false,
stdio: ["pipe", "pipe", "pipe"],
});
// Close stdin so clients like codex don't hang
child.stdin?.end();
// Update pid in job file
record.pid = child.pid ?? undefined;
writeFileSync(jobFile, JSON.stringify(record, null, 2));
child.stdout?.on("data", (chunk: Buffer | string) => {
stdout += chunk.toString();
});
child.stderr?.on("data", (chunk: Buffer | string) => {
stderr += chunk.toString();
});
const timeout = setTimeout(() => {
timedOut = true;
try { child.kill("SIGTERM"); } catch { /* ignore */ }
}, timeoutMs);
function finalize(status: JobStatus, result?: ExecResult, error?: string) {
if (settled) return;
settled = true;
clearTimeout(timeout);
const completedAt = new Date().toISOString();
const durationMs = Date.now() - startMs;
const finalRecord: JobRecord = {
...record,
status,
stdout,
stderr: result ? filterStderrNoise(record.client, stderr, result.exitCode) : stderr,
result: result ? { ...result, durationMs } : undefined,
error,
completedAt,
};
try {
// Check if the job was cancelled while we were running
const current = JSON.parse(readFileSync(jobFile, "utf-8")) as JobRecord;
if (current.status === "cancelled") {
return; // Don't overwrite a cancelled job
}
writeFileSync(jobFile, JSON.stringify(finalRecord, null, 2));
} catch { /* best effort */ }
process.exit(0);
}
child.on("error", (err: NodeJS.ErrnoException) => {
finalize("failed", undefined, err.message);
});
child.on("close", (code: number | null, signal: NodeJS.Signals | null) => {
if (timedOut || signal) {
finalize("timed_out", {
stdout, stderr, exitCode: -1, client: record.client, durationMs: 0,
});
} else if (code !== null && code !== 0) {
finalize("failed", {
stdout, stderr, exitCode: code, client: record.client, durationMs: 0,
});
} else {
finalize("completed", {
stdout, stderr, exitCode: code ?? 0, client: record.client, durationMs: 0,
});
}
});
+290
View File
@@ -0,0 +1,290 @@
import { spawn as defaultSpawn } from "node:child_process";
import { existsSync as defaultExistsSync, mkdirSync as defaultMkdirSync, readFileSync as defaultReadFileSync, readdirSync as defaultReaddirSync, statSync as defaultStatSync, unlinkSync as defaultUnlinkSync, writeFileSync as defaultWriteFileSync } from "node:fs";
import { randomUUID } from "node:crypto";
import { CLIENT_ARGS } from "./execute.js";
import type { ClientName, Job, JobRecord, JobStartOptions, ExecResult, JobStatus } from "./types.js";
import { JobNotFoundError, JobResultUnavailableError } from "./types.js";
export interface JobOperationsOptions {
jobDir?: string;
fs?: {
mkdirSync: typeof defaultMkdirSync;
writeFileSync: typeof defaultWriteFileSync;
readFileSync: typeof defaultReadFileSync;
readdirSync: typeof defaultReaddirSync;
existsSync: typeof defaultExistsSync;
statSync: typeof defaultStatSync;
unlinkSync: typeof defaultUnlinkSync;
};
}
export interface StartJobOptions extends JobStartOptions, JobOperationsOptions {
/** Override the watcher spawn (for testing). When provided, startJob calls
* this instead of spawning `node --import tsx job-watcher.ts ...` */
spawnWatcher?: (jobFilePath: string, command: string, args: string[]) => { pid?: number; unref?: () => void };
}
const DEFAULT_JOB_DIR = `${process.env.HOME || process.env.USERPROFILE}/.openclaw/ai-cli-dispatch/jobs`;
function getJobDir(options?: { jobDir?: string }): string {
return options?.jobDir ?? DEFAULT_JOB_DIR;
}
function writeJobFile(
jobDir: string,
record: JobRecord,
fs: NonNullable<JobOperationsOptions["fs"]>
): void {
fs.mkdirSync(jobDir, { recursive: true });
fs.writeFileSync(`${jobDir}/${record.id}.json`, JSON.stringify(record, null, 2));
}
function readJobFile(
jobId: string,
jobDir: string,
fs: NonNullable<JobOperationsOptions["fs"]>
): JobRecord {
const path = `${jobDir}/${jobId}.json`;
try {
return JSON.parse(fs.readFileSync(path, "utf-8")) as JobRecord;
} catch (err: any) {
if (err.code === "ENOENT") {
throw new JobNotFoundError(jobId);
}
throw err;
}
}
export async function startJob(
client: ClientName,
prompt: string,
options: StartJobOptions = {}
): Promise<Job> {
const jobId = randomUUID();
const jobDir = getJobDir(options);
const fs = options.fs ?? {
mkdirSync: defaultMkdirSync,
writeFileSync: defaultWriteFileSync,
readFileSync: defaultReadFileSync,
readdirSync: defaultReaddirSync,
existsSync: defaultExistsSync,
statSync: defaultStatSync,
unlinkSync: defaultUnlinkSync,
};
const spawnImpl = options.spawn ?? defaultSpawn;
const argBuilder = (CLIENT_ARGS as Record<string, (prompt: string) => string[]>)[client];
if (!argBuilder) {
const startedAt = new Date().toISOString();
const errRecord: JobRecord = {
id: jobId,
client,
prompt,
status: "failed",
startedAt,
completedAt: startedAt,
stdout: "",
stderr: "",
error: `Unknown client: ${client}`,
};
writeJobFile(jobDir, errRecord, fs);
return { id: jobId, client, prompt, status: "failed", startedAt, error: errRecord.error };
}
const clientArgs = argBuilder(prompt);
const command = options.clientPath ?? client;
const startedAt = new Date().toISOString();
const record: JobRecord = {
id: jobId,
client,
prompt,
status: "running",
startedAt,
pid: undefined,
stdout: "",
stderr: "",
};
writeJobFile(jobDir, record, fs);
// Spawn a companion watcher process that outlives this CLI invocation.
// The watcher monitors the actual client (codex/claude/opencode), captures
// stdout/stderr, and writes the final job record to disk on exit.
// This allows the CLI to return the job ID immediately while the watcher
// stays alive to finalize the job.
let watcher: { pid?: number; unref?: () => void };
if (options.spawnWatcher) {
// Test path: use the injected watcher mock
watcher = options.spawnWatcher(`${jobDir}/${jobId}.json`, command, clientArgs);
} else {
const watcherArgs = [
"--import", "tsx",
new URL("./job-watcher.ts", import.meta.url).pathname,
`${jobDir}/${jobId}.json`,
command,
...clientArgs,
];
watcher = spawnImpl("node", watcherArgs, {
detached: true,
shell: false,
stdio: "ignore",
});
watcher.unref?.();
}
// Give the watcher a tick to spawn and record the real child PID
await new Promise((r) => setTimeout(r, 100));
// Re-read the job file to pick up the watcher's PID update
let updatedRecord: JobRecord;
try {
updatedRecord = JSON.parse(fs.readFileSync(`${jobDir}/${jobId}.json`, "utf-8")) as JobRecord;
} catch {
updatedRecord = record;
}
return {
id: jobId,
client,
prompt,
status: "running",
startedAt,
pid: updatedRecord.pid ?? watcher.pid ?? undefined,
};
}
export function getJob(jobId: string, options: JobOperationsOptions = {}): Job {
const jobDir = getJobDir(options);
const fs = options.fs ?? {
mkdirSync: defaultMkdirSync,
writeFileSync: defaultWriteFileSync,
readFileSync: defaultReadFileSync,
readdirSync: defaultReaddirSync,
existsSync: defaultExistsSync,
statSync: defaultStatSync,
unlinkSync: defaultUnlinkSync,
};
const record = readJobFile(jobId, jobDir, fs);
const { stdout: _stdout, stderr: _stderr, ...job } = record;
return job;
}
export function getJobResult(jobId: string, options: JobOperationsOptions = {}): ExecResult {
const job = getJob(jobId, options);
if (job.status !== "completed") {
throw new JobResultUnavailableError(jobId, job.status);
}
if (!job.result) {
throw new JobResultUnavailableError(jobId, "completed");
}
return job.result;
}
export function cancelJob(jobId: string, options: JobOperationsOptions = {}): void {
const jobDir = getJobDir(options);
const fs = options.fs ?? {
mkdirSync: defaultMkdirSync,
writeFileSync: defaultWriteFileSync,
readFileSync: defaultReadFileSync,
readdirSync: defaultReaddirSync,
existsSync: defaultExistsSync,
statSync: defaultStatSync,
unlinkSync: defaultUnlinkSync,
};
const record = readJobFile(jobId, jobDir, fs);
if (record.status !== "running") {
return;
}
// Kill the client child process (PID recorded by the watcher)
if (record.pid) {
try {
process.kill(record.pid, "SIGTERM");
} catch {
// ignore — process may have already exited
}
}
// Update the job file to cancelled
const cancelledRecord: JobRecord = {
...record,
status: "cancelled",
completedAt: new Date().toISOString(),
};
writeJobFile(jobDir, cancelledRecord, fs);
}
export function listJobs(options: JobOperationsOptions & { filter?: JobStatus } = {}): Job[] {
const jobDir = getJobDir(options);
const fs = options.fs ?? {
mkdirSync: defaultMkdirSync,
writeFileSync: defaultWriteFileSync,
readFileSync: defaultReadFileSync,
readdirSync: defaultReaddirSync,
existsSync: defaultExistsSync,
statSync: defaultStatSync,
unlinkSync: defaultUnlinkSync,
};
if (!fs.existsSync(jobDir)) {
return [];
}
const entries = fs.readdirSync(jobDir).filter((f) => f.endsWith(".json"));
const jobs: Job[] = [];
for (const entry of entries) {
const jobId = entry.replace(/\.json$/, "");
try {
const record = readJobFile(jobId, jobDir, fs);
const { stdout: _stdout, stderr: _stderr, ...job } = record;
jobs.push(job);
} catch {
// ignore corrupt/missing files
}
}
jobs.sort((a, b) => new Date(b.startedAt).getTime() - new Date(a.startedAt).getTime());
if (options.filter) {
return jobs.filter((j) => j.status === options.filter);
}
return jobs;
}
export function cleanupJobs(options: JobOperationsOptions & { maxAgeMs?: number } = {}): void {
const jobDir = getJobDir(options);
const fs = options.fs ?? {
mkdirSync: defaultMkdirSync,
writeFileSync: defaultWriteFileSync,
readFileSync: defaultReadFileSync,
readdirSync: defaultReaddirSync,
existsSync: defaultExistsSync,
statSync: defaultStatSync,
unlinkSync: defaultUnlinkSync,
};
const maxAgeMs = options.maxAgeMs ?? 24 * 60 * 60 * 1000;
if (!fs.existsSync(jobDir)) {
return;
}
const now = Date.now();
const entries = fs.readdirSync(jobDir).filter((f) => f.endsWith(".json"));
for (const entry of entries) {
const path = `${jobDir}/${entry}`;
try {
const stat = fs.statSync(path);
if (now - stat.mtimeMs > maxAgeMs) {
fs.unlinkSync(path);
}
} catch {
// ignore
}
}
}
+67 -3
View File
@@ -1,3 +1,6 @@
import type { ChildProcess } from "node:child_process";
import type { PathLike } from "node:fs";
export type ClientName = "codex" | "claude" | "opencode";
export interface ClientInfo {
@@ -11,11 +14,58 @@ export interface ExecResult {
stdout: string;
stderr: string;
exitCode: number;
client: ClientName;
durationMs: number;
}
export interface ToolConfig {
clients: ClientName[];
defaultClient?: ClientName;
export interface DebugInfo {
command: string;
args: string[];
pid?: number;
exitCode: number | null;
exitSignal: NodeJS.Signals | null;
durationMs: number;
stderrLength: number;
stdoutLength: number;
noisySuccess: boolean;
/** Unfiltered stderr before noise removal (only present when --debug). */
rawStderr?: string;
}
export interface ExecuteOptions {
clientPath?: string;
timeoutMs?: number;
debug?: boolean;
onDebug?: (info: DebugInfo) => void;
spawn?: (command: string, args: string[], options?: { shell?: boolean }) => ChildProcess;
existsSync?: (path: PathLike) => boolean;
}
export type JobStatus = "running" | "completed" | "failed" | "timed_out" | "cancelled";
export interface Job {
id: string;
client: ClientName;
prompt: string;
status: JobStatus;
result?: ExecResult;
error?: string;
startedAt: string;
completedAt?: string;
pid?: number;
}
/**
* On-disk storage contract for job files under
* ~/.openclaw/ai-cli-dispatch/jobs/<jobId>.json
*/
export interface JobRecord extends Job {
stdout: string;
stderr: string;
}
export interface JobStartOptions extends ExecuteOptions {
jobDir?: string;
}
export class ClientNotFoundError extends Error {
@@ -34,3 +84,17 @@ export class ExecError extends Error {
this.result = result;
}
}
export class JobNotFoundError extends Error {
constructor(jobId: string) {
super(`Job "${jobId}" not found.`);
this.name = "JobNotFoundError";
}
}
export class JobResultUnavailableError extends Error {
constructor(jobId: string, status: JobStatus) {
super(`Job "${jobId}" result is not available (status: ${status}).`);
this.name = "JobResultUnavailableError";
}
}
@@ -0,0 +1,278 @@
import { describe, it } from "node:test";
import assert from "node:assert";
import {
reportError,
reportCliError,
handleSyncRun,
handleAsyncRun,
} from "../src/cli-helpers.js";
import type { ExecResult, Job } from "../src/types.js";
function captureConsole() {
const logs: string[] = [];
const errors: string[] = [];
const origLog = console.log;
const origError = console.error;
console.log = (...args: unknown[]) => logs.push(args.map(String).join(" "));
console.error = (...args: unknown[]) => errors.push(args.map(String).join(" "));
return {
logs,
errors,
restore() {
console.log = origLog;
console.error = origError;
},
};
}
describe("reportError", () => {
it("prints JSON error for Error instance when jsonMode=true", () => {
const out = captureConsole();
try {
const code = reportError(new Error("boom"), true);
assert.strictEqual(code, 1);
assert.strictEqual(out.errors.length, 1);
const parsed = JSON.parse(out.errors[0]);
assert.strictEqual(parsed.error, "boom");
} finally {
out.restore();
}
});
it("prints plain text error for Error instance when jsonMode=false", () => {
const out = captureConsole();
try {
const code = reportError(new Error("boom"), false);
assert.strictEqual(code, 1);
assert.strictEqual(out.errors.length, 1);
assert.strictEqual(out.errors[0], "boom");
} finally {
out.restore();
}
});
it("prints JSON error for non-Error value when jsonMode=true", () => {
const out = captureConsole();
try {
const code = reportError("plain string", true);
assert.strictEqual(code, 1);
const parsed = JSON.parse(out.errors[0]);
assert.strictEqual(parsed.error, "plain string");
} finally {
out.restore();
}
});
});
describe("reportCliError", () => {
it("prints JSON error with jsonMode=true", () => {
const out = captureConsole();
try {
const code = reportCliError("missing arg", true);
assert.strictEqual(code, 1);
const parsed = JSON.parse(out.errors[0]);
assert.strictEqual(parsed.error, "missing arg");
} finally {
out.restore();
}
});
it("prints prefixed text error with jsonMode=false", () => {
const out = captureConsole();
try {
const code = reportCliError("missing arg", false);
assert.strictEqual(code, 1);
assert.strictEqual(out.errors[0], "Error: missing arg");
} finally {
out.restore();
}
});
});
describe("handleSyncRun", () => {
it("returns 0 and prints JSON result in jsonMode", async () => {
const out = captureConsole();
try {
const result: ExecResult = {
stdout: "out",
stderr: "err",
exitCode: 0,
client: "codex",
durationMs: 10,
};
const code = await handleSyncRun(
async () => result,
"codex",
"hello",
5000,
false,
{ jsonMode: true, stdoutWrite: () => {}, stderrWrite: () => {} }
);
assert.strictEqual(code, 0);
assert.strictEqual(out.logs.length, 1);
const parsed = JSON.parse(out.logs[0]);
assert.strictEqual(parsed.stdout, "out");
} finally {
out.restore();
}
});
it("returns 0 and writes stdout/stderr in text mode", async () => {
const out = captureConsole();
const stdout: string[] = [];
const stderr: string[] = [];
try {
const result: ExecResult = {
stdout: "out",
stderr: "err",
exitCode: 0,
client: "codex",
durationMs: 10,
};
const code = await handleSyncRun(
async () => result,
"codex",
"hello",
5000,
false,
{
jsonMode: false,
stdoutWrite: (c) => stdout.push(c),
stderrWrite: (c) => stderr.push(c),
}
);
assert.strictEqual(code, 0);
assert.strictEqual(stdout.join(""), "out");
assert.strictEqual(stderr.join(""), "err");
assert.strictEqual(out.logs.length, 0);
} finally {
out.restore();
}
});
it("passes timeoutMs and debug to executePrompt", async () => {
const out = captureConsole();
try {
let received: any;
const code = await handleSyncRun(
async (_c, _p, opts) => {
received = opts;
return {
stdout: "",
stderr: "",
exitCode: 0,
client: "codex",
durationMs: 1,
};
},
"codex",
"hello",
12345,
true,
{ jsonMode: true, stdoutWrite: () => {}, stderrWrite: () => {} }
);
assert.strictEqual(code, 0);
assert.strictEqual(received?.timeoutMs, 12345);
assert.strictEqual(received?.debug, true);
} finally {
out.restore();
}
});
it("returns 1 and prints error JSON when executePrompt throws", async () => {
const out = captureConsole();
try {
const code = await handleSyncRun(
async () => {
throw new Error("fail");
},
"codex",
"hello",
undefined,
false,
{ jsonMode: true, stdoutWrite: () => {}, stderrWrite: () => {} }
);
assert.strictEqual(code, 1);
const parsed = JSON.parse(out.errors[0]);
assert.strictEqual(parsed.error, "fail");
} finally {
out.restore();
}
});
});
describe("handleAsyncRun", () => {
it("returns 0 and prints JSON job in jsonMode", async () => {
const out = captureConsole();
try {
const job: Job = {
id: "j1",
client: "codex",
prompt: "hi",
status: "running",
startedAt: "2024-01-01T00:00:00Z",
};
const code = await handleAsyncRun(
async () => job,
"codex",
"hi",
undefined,
false,
{ jsonMode: true, stdoutWrite: () => {}, stderrWrite: () => {} }
);
assert.strictEqual(code, 0);
const parsed = JSON.parse(out.logs[0]);
assert.strictEqual(parsed.jobId, "j1");
assert.strictEqual(parsed.status, "running");
} finally {
out.restore();
}
});
it("returns 0 and prints text job info", async () => {
const out = captureConsole();
try {
const job: Job = {
id: "j1",
client: "codex",
prompt: "hi",
status: "running",
startedAt: "2024-01-01T00:00:00Z",
};
const code = await handleAsyncRun(
async () => job,
"codex",
"hi",
undefined,
false,
{ jsonMode: false, stdoutWrite: () => {}, stderrWrite: () => {} }
);
assert.strictEqual(code, 0);
assert.ok(out.logs[0].includes("j1"));
assert.ok(out.logs[0].includes("running"));
} finally {
out.restore();
}
});
it("returns 1 and prints error JSON when startJob throws", async () => {
const out = captureConsole();
try {
const code = await handleAsyncRun(
async () => {
throw new Error("fail");
},
"codex",
"hi",
undefined,
false,
{ jsonMode: true, stdoutWrite: () => {}, stderrWrite: () => {} }
);
assert.strictEqual(code, 1);
const parsed = JSON.parse(out.errors[0]);
assert.strictEqual(parsed.error, "fail");
} finally {
out.restore();
}
});
});
File diff suppressed because it is too large Load Diff
@@ -135,4 +135,71 @@ describe("resolveConfig", () => {
});
assert.strictEqual(config.defaultClient, undefined);
});
it("returns default timeout of 600000 when no sources are present", () => {
const config = resolveConfig({
existsSync: () => false,
whichSync: () => undefined,
});
assert.strictEqual(config.timeout, 600_000);
});
it("loads timeout from file config", () => {
const config = resolveConfig({
existsSync: () => true,
readFileSync: () => JSON.stringify({ timeout: 120_000 }),
whichSync: () => undefined,
});
assert.strictEqual(config.timeout, 120_000);
});
it("overrides file timeout with env var", () => {
const config = resolveConfig({
env: { AI_CLI_TIMEOUT: "240000" },
existsSync: () => true,
readFileSync: () => JSON.stringify({ timeout: 120_000 }),
whichSync: () => undefined,
});
assert.strictEqual(config.timeout, 240_000);
});
it("overrides env timeout with CLI flag", () => {
const config = resolveConfig({
flags: { timeout: "480000" },
env: { AI_CLI_TIMEOUT: "240000" },
existsSync: () => true,
readFileSync: () => JSON.stringify({ timeout: 120_000 }),
whichSync: () => undefined,
});
assert.strictEqual(config.timeout, 480_000);
});
it("respects full priority ordering for timeout: flag > env > file > default", () => {
const config = resolveConfig({
flags: { timeout: "480000" },
env: { AI_CLI_TIMEOUT: "240000" },
existsSync: () => true,
readFileSync: () => JSON.stringify({ timeout: 120_000 }),
whichSync: () => undefined,
});
assert.strictEqual(config.timeout, 480_000);
});
it("ignores invalid timeout from env var and falls back to default", () => {
const config = resolveConfig({
env: { AI_CLI_TIMEOUT: "not-a-number" },
existsSync: () => false,
whichSync: () => undefined,
});
assert.strictEqual(config.timeout, 600_000);
});
it("ignores invalid timeout from file and falls back to default", () => {
const config = resolveConfig({
existsSync: () => true,
readFileSync: () => JSON.stringify({ timeout: "not-a-number" }),
whichSync: () => undefined,
});
assert.strictEqual(config.timeout, 600_000);
});
});
+238
View File
@@ -16,6 +16,7 @@ interface MockScenario {
function createMockChildProcess(scenario: MockScenario): any {
const child = new EventEmitter() as any;
child.pid = 12345;
child.stdout = Readable.from(
scenario.stdout !== undefined ? [scenario.stdout] : []
);
@@ -203,4 +204,241 @@ describe("executePrompt", () => {
err instanceof ExecError && err.message.includes("Unknown client")
);
});
it("includes client and durationMs in result", async () => {
const scenarios = new Map<string, MockScenario>([
["codex exec --yolo hello", { stdout: "ok", exitCode: 0 }],
]);
const result = await executePrompt("codex", "hello", {
spawn: mockSpawn(scenarios),
existsSync: () => true,
});
assert.strictEqual(result.client, "codex");
assert.strictEqual(typeof result.durationMs, "number");
assert.ok(result.durationMs >= 0);
});
it("rejects with ExecError containing custom timeout value", async () => {
const scenarios = new Map<string, MockScenario>([
["codex exec --yolo slow", { hang: true }],
]);
await assert.rejects(
executePrompt("codex", "slow", {
spawn: mockSpawn(scenarios),
existsSync: () => true,
timeoutMs: 50,
}),
(err: unknown) =>
err instanceof ExecError &&
err.message === "Execution timed out after 50ms" &&
err.result.exitCode === -1 &&
err.result.client === "codex" &&
typeof err.result.durationMs === "number"
);
});
it("uses default timeout of 600000 when timeoutMs is not provided", async () => {
const delays: number[] = [];
const origSetTimeout = global.setTimeout;
(global as any).setTimeout = function(callback: any, delay: number) {
delays.push(delay);
return origSetTimeout(callback, delay);
};
const scenarios = new Map<string, MockScenario>([
["codex exec --yolo hello", { stdout: "ok", exitCode: 0 }],
]);
try {
await executePrompt("codex", "hello", {
spawn: mockSpawn(scenarios),
existsSync: () => true,
});
assert.strictEqual(delays[0], 600_000);
} finally {
global.setTimeout = origSetTimeout;
}
});
it("emits debug info via onDebug when debug is true for successful execution", async () => {
const scenarios = new Map<string, MockScenario>([
["codex exec --yolo hello", { stdout: "ok", stderr: "warn", exitCode: 0 }],
]);
const debugInfos: any[] = [];
const result = await executePrompt("codex", "hello", {
spawn: mockSpawn(scenarios),
existsSync: () => true,
debug: true,
onDebug: (info) => debugInfos.push(info),
});
assert.strictEqual(result.exitCode, 0);
assert.strictEqual(debugInfos.length, 1);
const info = debugInfos[0];
assert.strictEqual(info.command, "codex");
assert.deepStrictEqual(info.args, ["exec", "--yolo", "hello"]);
assert.strictEqual(info.pid, 12345);
assert.strictEqual(info.exitCode, 0);
assert.strictEqual(info.exitSignal, null);
assert.strictEqual(info.stderrLength, 4);
assert.strictEqual(info.stdoutLength, 2);
assert.strictEqual(typeof info.durationMs, "number");
assert.ok(info.durationMs >= 0);
});
it("emits debug info via onDebug when debug is true for failed execution", async () => {
const scenarios = new Map<string, MockScenario>([
["codex exec --yolo fail", { stdout: "", stderr: "error", exitCode: 1 }],
]);
const debugInfos: any[] = [];
const result = await executePrompt("codex", "fail", {
spawn: mockSpawn(scenarios),
existsSync: () => true,
debug: true,
onDebug: (info) => debugInfos.push(info),
});
assert.strictEqual(result.exitCode, 1);
assert.strictEqual(debugInfos.length, 1);
assert.strictEqual(debugInfos[0].exitCode, 1);
assert.strictEqual(debugInfos[0].stderrLength, 5);
assert.strictEqual(debugInfos[0].stdoutLength, 0);
});
it("emits debug info via onDebug for spawn errors", async () => {
const scenarios = new Map<string, MockScenario>();
const debugInfos: any[] = [];
await assert.rejects(
executePrompt("codex", "hello", {
spawn: mockSpawn(scenarios),
existsSync: () => true,
debug: true,
onDebug: (info) => debugInfos.push(info),
}),
(err: unknown) => err instanceof ClientNotFoundError
);
assert.strictEqual(debugInfos.length, 1);
assert.strictEqual(debugInfos[0].command, "codex");
assert.deepStrictEqual(debugInfos[0].args, ["exec", "--yolo", "hello"]);
assert.strictEqual(debugInfos[0].exitCode, null);
assert.strictEqual(debugInfos[0].exitSignal, null);
});
it("reports noisySuccess=true when stderr is non-empty and exitCode is 0", async () => {
const scenarios = new Map<string, MockScenario>([
["codex exec --yolo hello", { stdout: "ok", stderr: "warn", exitCode: 0 }],
]);
const debugInfos: any[] = [];
await executePrompt("codex", "hello", {
spawn: mockSpawn(scenarios),
existsSync: () => true,
debug: true,
onDebug: (info) => debugInfos.push(info),
});
assert.strictEqual(debugInfos[0].noisySuccess, true);
});
it("reports noisySuccess=false when stderr is empty and exitCode is 0", async () => {
const scenarios = new Map<string, MockScenario>([
["codex exec --yolo hello", { stdout: "ok", stderr: "", exitCode: 0 }],
]);
const debugInfos: any[] = [];
await executePrompt("codex", "hello", {
spawn: mockSpawn(scenarios),
existsSync: () => true,
debug: true,
onDebug: (info) => debugInfos.push(info),
});
assert.strictEqual(debugInfos[0].noisySuccess, false);
});
it("reports noisySuccess=false when exitCode is non-zero even if stderr is non-empty", async () => {
const scenarios = new Map<string, MockScenario>([
["codex exec --yolo fail", { stdout: "", stderr: "error", exitCode: 1 }],
]);
const debugInfos: any[] = [];
await executePrompt("codex", "fail", {
spawn: mockSpawn(scenarios),
existsSync: () => true,
debug: true,
onDebug: (info) => debugInfos.push(info),
});
assert.strictEqual(debugInfos[0].noisySuccess, false);
});
it("filters codex ReasoningSummary noise from stderr on exit code 0", async () => {
const noisyStderr = [
'2026-05-20T18:33:01.969310Z ERROR codex_core::util: ReasoningSummaryPartAdded without active item',
'2026-05-20T18:33:03.281713Z ERROR codex_core::util: ReasoningSummaryDelta without active item',
'2026-05-20T18:33:03.348247Z ERROR codex_core::util: ReasoningSummaryDelta without active item',
].join('\n');
const scenarios = new Map<string, MockScenario>([
["codex exec --yolo hello", { stdout: "Hello world!", stderr: noisyStderr, exitCode: 0 }],
]);
const result = await executePrompt("codex", "hello", {
spawn: mockSpawn(scenarios),
existsSync: () => true,
});
assert.strictEqual(result.exitCode, 0);
assert.strictEqual(result.stderr, "");
assert.strictEqual(result.stdout, "Hello world!");
});
it("preserves real error stderr from codex on non-zero exit code", async () => {
const noisyStderr = [
'2026-05-20T18:33:01.969310Z ERROR codex_core::util: ReasoningSummaryDelta without active item',
'Error: something actually went wrong',
].join('\n');
const scenarios = new Map<string, MockScenario>([
["codex exec --yolo fail", { stdout: "", stderr: noisyStderr, exitCode: 1 }],
]);
const result = await executePrompt("codex", "fail", {
spawn: mockSpawn(scenarios),
existsSync: () => true,
});
assert.strictEqual(result.exitCode, 1);
assert.ok(result.stderr.includes("ReasoningSummaryDelta"));
assert.ok(result.stderr.includes("something actually went wrong"));
});
it("provides rawStderr in debug info when noise is filtered", async () => {
const noisyStderr = '2026-05-20T18:33:01.969310Z ERROR codex_core::util: ReasoningSummaryDelta without active item\n';
const scenarios = new Map<string, MockScenario>([
["codex exec --yolo hello", { stdout: "ok", stderr: noisyStderr, exitCode: 0 }],
]);
const debugInfos: any[] = [];
const result = await executePrompt("codex", "hello", {
spawn: mockSpawn(scenarios),
existsSync: () => true,
debug: true,
onDebug: (info) => debugInfos.push(info),
});
assert.strictEqual(result.exitCode, 0);
assert.strictEqual(result.stderr, "");
assert.strictEqual(debugInfos[0].rawStderr, noisyStderr);
});
it("does not set rawStderr when no noise filtering occurred", async () => {
const scenarios = new Map<string, MockScenario>([
["codex exec --yolo hello", { stdout: "ok", stderr: "", exitCode: 0 }],
]);
const debugInfos: any[] = [];
await executePrompt("codex", "hello", {
spawn: mockSpawn(scenarios),
existsSync: () => true,
debug: true,
onDebug: (info) => debugInfos.push(info),
});
assert.strictEqual(debugInfos[0].rawStderr, undefined);
});
it("does not filter stderr for non-codex clients", async () => {
const noisyStderr = '2026-05-20T18:33:01.969310Z ERROR codex_core::util: ReasoningSummaryDelta without active item\n';
const scenarios = new Map<string, MockScenario>([
["claude -p hello --dangerously-skip-permissions", { stdout: "ok", stderr: noisyStderr, exitCode: 0 }],
]);
const result = await executePrompt("claude", "hello", {
spawn: mockSpawn(scenarios),
existsSync: () => true,
});
assert.strictEqual(result.exitCode, 0);
assert.strictEqual(result.stderr, noisyStderr);
});
});
+738
View File
@@ -0,0 +1,738 @@
import { describe, it } from "node:test";
import assert from "node:assert";
import { EventEmitter } from "node:events";
import { Readable } from "node:stream";
import {
startJob,
getJob,
getJobResult,
cancelJob,
listJobs,
cleanupJobs,
} from "../src/jobs.js";
import { JobNotFoundError, JobResultUnavailableError } from "../src/types.js";
import type { ClientName, JobRecord, JobStatus } from "../src/types.js";
interface MockScenario {
stdout?: string;
stderr?: string;
exitCode?: number;
error?: NodeJS.ErrnoException;
hang?: boolean;
}
function createMockChildProcess(scenario: MockScenario): any {
const child = new EventEmitter() as any;
child.pid = 12345;
child.stdout = Readable.from(
scenario.stdout !== undefined ? [scenario.stdout] : []
);
child.stderr = Readable.from(
scenario.stderr !== undefined ? [scenario.stderr] : []
);
child.killed = false;
child.kill = (signal: string = "SIGTERM") => {
child.killed = true;
process.nextTick(() => {
child.emit("exit", null, signal);
child.emit("close", null, signal);
});
return true;
};
child.unref = () => {};
let stdoutEnded = scenario.stdout === undefined;
let stderrEnded = scenario.stderr === undefined;
child.stdout.on("end", () => {
stdoutEnded = true;
maybeClose();
});
child.stderr.on("end", () => {
stderrEnded = true;
maybeClose();
});
function maybeClose() {
if (stdoutEnded && stderrEnded && !scenario.hang && !scenario.error) {
child.emit("exit", scenario.exitCode ?? 0, null);
child.emit("close", scenario.exitCode ?? 0, null);
}
}
process.nextTick(() => {
if (scenario.error) {
child.emit("error", scenario.error);
}
});
return child;
}
function createMockFs() {
const files = new Map<string, string>();
const dirs = new Set<string>();
const fs = {
mkdirSync: (p: string, _opts?: any) => {
dirs.add(p);
},
writeFileSync: (p: string, data: string) => {
files.set(p, data);
},
readFileSync: (p: string, _opts?: any): string => {
if (!files.has(p)) {
const err = Object.assign(new Error(`ENOENT: ${p}`), { code: "ENOENT" });
throw err;
}
return files.get(p)!;
},
readdirSync: (p: string): string[] => {
const result: string[] = [];
for (const f of files.keys()) {
const dir = f.substring(0, f.lastIndexOf("/"));
if (dir === p) {
result.push(f.substring(f.lastIndexOf("/") + 1));
}
}
return result;
},
existsSync: (p: string): boolean => {
return files.has(p) || dirs.has(p);
},
statSync: (p: string): { mtimeMs: number; isFile: () => boolean } => {
if (!files.has(p)) {
const err = Object.assign(new Error(`ENOENT: ${p}`), { code: "ENOENT" });
throw err;
}
const data = files.get(p)!;
// Use startedAt from JSON for deterministic age tests
try {
const record = JSON.parse(data) as JobRecord;
return { mtimeMs: new Date(record.startedAt).getTime(), isFile: () => true };
} catch {
return { mtimeMs: Date.now(), isFile: () => true };
}
},
unlinkSync: (p: string) => {
files.delete(p);
},
__files: files,
__dirs: dirs,
};
return fs;
}
function mockSpawn(scenarios: Map<string, MockScenario>): any {
return (cmd: string, args: string[], _opts: any): any => {
const key = [cmd, ...args].join(" ");
const scenario = scenarios.get(key);
if (!scenario) {
return createMockChildProcess({
error: Object.assign(new Error("spawn ENOENT"), { code: "ENOENT" }),
});
}
return createMockChildProcess(scenario);
};
}
/**
* Creates a mock spawnWatcher that simulates the job-watcher.ts behavior:
* spawns the mock child, captures output, and writes the final job record.
*/
function createMockWatcher(
scenarios: Map<string, MockScenario>,
fs: ReturnType<typeof createMockFs>,
opts?: { watcherTimeoutMs?: number }
): (jobFilePath: string, command: string, args: string[]) => { pid: number; unref: () => void } {
return (jobFilePath: string, command: string, clientArgs: string[]) => {
const key = [command, ...clientArgs].join(" ");
const scenario = scenarios.get(key) ?? { error: Object.assign(new Error("spawn ENOENT"), { code: "ENOENT" }) };
const child = createMockChildProcess(scenario);
const watcherPid = 99999;
let stdout = "";
let stderr = "";
child.stdout?.on("data", (chunk: Buffer | string) => { stdout += chunk.toString(); });
child.stderr?.on("data", (chunk: Buffer | string) => { stderr += chunk.toString(); });
child.on("close", (code: number | null) => {
// Read current record, update with results
let record: JobRecord;
try {
record = JSON.parse(fs.readFileSync(jobFilePath)) as JobRecord;
} catch {
return;
}
const durationMs = Date.now() - new Date(record.startedAt).getTime();
const status = code === 0 || code === null ? "completed" : "failed";
record.status = status;
record.stdout = stdout;
record.stderr = stderr;
record.completedAt = new Date().toISOString();
record.result = { stdout, stderr, exitCode: code ?? 0, client: record.client, durationMs };
if (scenario.error) {
record.status = "failed";
record.error = scenario.error.message;
}
fs.writeFileSync(jobFilePath, JSON.stringify(record));
});
child.on("error", (err: NodeJS.ErrnoException) => {
let record: JobRecord;
try {
record = JSON.parse(fs.readFileSync(jobFilePath)) as JobRecord;
} catch {
return;
}
record.status = "failed";
record.error = err.message;
record.completedAt = new Date().toISOString();
fs.writeFileSync(jobFilePath, JSON.stringify(record));
});
// For hang scenarios, simulate a timeout by writing timed_out after a delay
if (scenario.hang) {
const watcherTimeout = opts?.watcherTimeoutMs ?? 30;
setTimeout(() => {
try {
const existing = JSON.parse(fs.readFileSync(jobFilePath)) as JobRecord;
// Don't overwrite if already cancelled/completed
if (existing.status !== "running") return;
existing.status = "timed_out";
existing.completedAt = new Date().toISOString();
existing.result = { stdout, stderr, exitCode: -1, client: existing.client, durationMs: watcherTimeout };
fs.writeFileSync(jobFilePath, JSON.stringify(existing));
} catch { /* ignore */ }
}, watcherTimeout);
}
// Simulate watcher updating the PID in the job file
try {
const record = JSON.parse(fs.readFileSync(jobFilePath)) as JobRecord;
record.pid = child.pid;
fs.writeFileSync(jobFilePath, JSON.stringify(record));
} catch { /* ignore */ }
return { pid: watcherPid, unref: () => {} };
};
}
function createJobTestHelper(scenarios: Map<string, MockScenario>, jobDir: string) {
const fs = createMockFs();
const spawnWatcher = createMockWatcher(scenarios, fs);
const spawn = mockSpawn(scenarios);
return { fs, spawn, spawnWatcher, jobDir };
}
function readJobRecord(fs: ReturnType<typeof createMockFs>, path: string): JobRecord {
return JSON.parse(fs.readFileSync(path)) as JobRecord;
}
function delay(ms: number): Promise<void> {
return new Promise((r) => setTimeout(r, ms));
}
describe("startJob", () => {
it("spawns a detached child process and returns a running Job", async () => {
const scenarios = new Map<string, MockScenario>([
["codex exec --yolo hello world", { stdout: "ok\n", exitCode: 0 }],
]);
const fs = createMockFs();
const jobDir = "/tmp/jobs";
const job = await startJob("codex", "hello world", {
jobDir,
spawn: mockSpawn(scenarios),
spawnWatcher: createMockWatcher(scenarios, fs),
fs,
});
assert.strictEqual(job.client, "codex");
assert.strictEqual(job.prompt, "hello world");
assert.strictEqual(job.status, "running");
assert.strictEqual(typeof job.id, "string");
assert.ok(job.id.length > 0);
assert.strictEqual(typeof job.pid, "number");
assert.ok(typeof job.startedAt === "string");
// Wait for child to finish
await delay(50);
const record = readJobRecord(fs, `${jobDir}/${job.id}.json`);
assert.strictEqual(record.status, "completed");
assert.strictEqual(record.stdout, "ok\n");
assert.strictEqual(record.result?.exitCode, 0);
assert.ok(typeof record.completedAt === "string");
});
it("generates unique ids for concurrent jobs", async () => {
const scenarios = new Map<string, MockScenario>([
["codex exec --yolo a", { stdout: "a", exitCode: 0 }],
["codex exec --yolo b", { stdout: "b", exitCode: 0 }],
]);
const fs = createMockFs();
const jobDir = "/tmp/jobs";
const job1 = await startJob("codex", "a", {
jobDir,
spawn: mockSpawn(scenarios),
spawnWatcher: createMockWatcher(scenarios, fs),
fs,
});
const job2 = await startJob("codex", "b", {
jobDir,
spawn: mockSpawn(scenarios),
spawnWatcher: createMockWatcher(scenarios, fs),
fs,
});
assert.notStrictEqual(job1.id, job2.id);
});
it("sets status to timed_out when timeout is exceeded", async () => {
const scenarios = new Map<string, MockScenario>([
["codex exec --yolo slow", { hang: true }],
]);
const fs = createMockFs();
const jobDir = "/tmp/jobs";
const job = await startJob("codex", "slow", {
jobDir,
spawn: mockSpawn(scenarios),
spawnWatcher: createMockWatcher(scenarios, fs),
fs,
timeoutMs: 20,
});
// Wait for timeout + processing
await delay(100);
const record = readJobRecord(fs, `${jobDir}/${job.id}.json`);
assert.strictEqual(record.status, "timed_out");
});
it("sets status to failed on non-zero exit code", async () => {
const scenarios = new Map<string, MockScenario>([
["codex exec --yolo fail", { stdout: "", stderr: "err", exitCode: 1 }],
]);
const fs = createMockFs();
const jobDir = "/tmp/jobs";
const job = await startJob("codex", "fail", {
jobDir,
spawn: mockSpawn(scenarios),
spawnWatcher: createMockWatcher(scenarios, fs),
fs,
});
await delay(50);
const record = readJobRecord(fs, `${jobDir}/${job.id}.json`);
assert.strictEqual(record.status, "failed");
assert.strictEqual(record.result?.exitCode, 1);
assert.strictEqual(record.stderr, "err");
});
it("sets status to failed on spawn ENOENT", async () => {
const scenarios = new Map<string, MockScenario>();
const fs = createMockFs();
const jobDir = "/tmp/jobs";
const job = await startJob("codex", "hello", {
jobDir,
spawn: mockSpawn(scenarios),
spawnWatcher: createMockWatcher(scenarios, fs),
fs,
});
await delay(50);
const record = readJobRecord(fs, `${jobDir}/${job.id}.json`);
assert.strictEqual(record.status, "failed");
assert.ok(record.error);
});
});
describe("getJob", () => {
it("returns the current Job state from disk", async () => {
const scenarios = new Map<string, MockScenario>([
["codex exec --yolo hello", { hang: true }],
]);
const fs = createMockFs();
const jobDir = "/tmp/jobs";
const job = await startJob("codex", "hello", {
jobDir,
spawn: mockSpawn(scenarios),
spawnWatcher: createMockWatcher(scenarios, fs, { watcherTimeoutMs: 5000 }),
fs,
});
const fetched = getJob(job.id, { jobDir, fs });
assert.strictEqual(fetched.id, job.id);
assert.strictEqual(fetched.status, "running");
await delay(50);
// Job should still be running since watcher timeout is 5s
const after = getJob(job.id, { jobDir, fs });
assert.strictEqual(after.status, "running");
});
it("throws JobNotFoundError for nonexistent job", () => {
const fs = createMockFs();
assert.throws(
() => getJob("missing", { jobDir: "/tmp/jobs", fs }),
(err: unknown) => err instanceof JobNotFoundError
);
});
});
describe("getJobResult", () => {
it("returns ExecResult when job is completed", async () => {
const scenarios = new Map<string, MockScenario>([
["codex exec --yolo hello", { stdout: "ok", exitCode: 0 }],
]);
const fs = createMockFs();
const jobDir = "/tmp/jobs";
const job = await startJob("codex", "hello", {
jobDir,
spawn: mockSpawn(scenarios),
spawnWatcher: createMockWatcher(scenarios, fs),
fs,
});
await delay(50);
const result = getJobResult(job.id, { jobDir, fs });
assert.strictEqual(result.stdout, "ok");
assert.strictEqual(result.exitCode, 0);
});
it("throws JobResultUnavailableError when job is still running", async () => {
const scenarios = new Map<string, MockScenario>([
["codex exec --yolo hello", { hang: true }],
]);
const fs = createMockFs();
const jobDir = "/tmp/jobs";
const job = await startJob("codex", "hello", {
jobDir,
spawn: mockSpawn(scenarios),
spawnWatcher: createMockWatcher(scenarios, fs),
fs,
timeoutMs: 50,
});
assert.throws(
() => getJobResult(job.id, { jobDir, fs }),
(err: unknown) => err instanceof JobResultUnavailableError
);
});
it("throws JobResultUnavailableError when job failed", async () => {
const scenarios = new Map<string, MockScenario>([
["codex exec --yolo fail", { stdout: "", stderr: "err", exitCode: 1 }],
]);
const fs = createMockFs();
const jobDir = "/tmp/jobs";
const job = await startJob("codex", "fail", {
jobDir,
spawn: mockSpawn(scenarios),
spawnWatcher: createMockWatcher(scenarios, fs),
fs,
});
await delay(50);
assert.throws(
() => getJobResult(job.id, { jobDir, fs }),
(err: unknown) => err instanceof JobResultUnavailableError
);
});
it("throws JobNotFoundError for nonexistent job", () => {
const fs = createMockFs();
assert.throws(
() => getJobResult("missing", { jobDir: "/tmp/jobs", fs }),
(err: unknown) => err instanceof JobNotFoundError
);
});
});
describe("cancelJob", () => {
it("sends SIGTERM and updates status to cancelled", async () => {
const scenarios = new Map<string, MockScenario>([
["codex exec --yolo hello", { hang: true }],
]);
const fs = createMockFs();
const jobDir = "/tmp/jobs";
const job = await startJob("codex", "hello", {
jobDir,
spawn: mockSpawn(scenarios),
spawnWatcher: createMockWatcher(scenarios, fs, { watcherTimeoutMs: 5000 }),
fs,
});
cancelJob(job.id, { jobDir, fs });
await delay(50);
const record = readJobRecord(fs, `${jobDir}/${job.id}.json`);
assert.strictEqual(record.status, "cancelled");
});
it("throws JobNotFoundError for nonexistent job", () => {
const fs = createMockFs();
assert.throws(
() => cancelJob("missing", { jobDir: "/tmp/jobs", fs }),
(err: unknown) => err instanceof JobNotFoundError
);
});
it("is a no-op when job is not running", () => {
const fs = createMockFs();
const jobDir = "/tmp/jobs";
const now = new Date().toISOString();
const completedRecord: JobRecord = {
id: "job-completed",
client: "codex",
prompt: "done",
status: "completed",
startedAt: now,
completedAt: now,
stdout: "ok",
stderr: "",
result: {
stdout: "ok",
stderr: "",
exitCode: 0,
client: "codex",
durationMs: 100,
},
};
fs.mkdirSync(jobDir, { recursive: true });
fs.writeFileSync(`${jobDir}/job-completed.json`, JSON.stringify(completedRecord));
cancelJob("job-completed", { jobDir, fs });
const record = readJobRecord(fs, `${jobDir}/job-completed.json`);
assert.strictEqual(record.status, "completed");
});
});
describe("listJobs", () => {
it("returns all jobs sorted by startedAt desc", async () => {
const scenarios = new Map<string, MockScenario>([
["codex exec --yolo a", { stdout: "a", exitCode: 0 }],
["codex exec --yolo b", { stdout: "b", exitCode: 0 }],
]);
const fs = createMockFs();
const jobDir = "/tmp/jobs";
const job1 = await startJob("codex", "a", {
jobDir,
spawn: mockSpawn(scenarios),
spawnWatcher: createMockWatcher(scenarios, fs),
fs,
});
await delay(20);
const job2 = await startJob("codex", "b", {
jobDir,
spawn: mockSpawn(scenarios),
spawnWatcher: createMockWatcher(scenarios, fs),
fs,
});
await delay(50);
const jobs = listJobs({ jobDir, fs });
assert.strictEqual(jobs.length, 2);
assert.strictEqual(jobs[0].id, job2.id);
assert.strictEqual(jobs[1].id, job1.id);
});
it("returns empty array when jobDir does not exist", () => {
const fs = createMockFs();
const jobs = listJobs({ jobDir: "/tmp/jobs", fs });
assert.deepStrictEqual(jobs, []);
});
it("ignores corrupt or unreadable job files", () => {
const fs = createMockFs();
const jobDir = "/tmp/jobs";
const now = new Date().toISOString();
const validRecord: JobRecord = {
id: "job-valid",
client: "codex",
prompt: "ok",
status: "completed",
startedAt: now,
completedAt: now,
stdout: "ok",
stderr: "",
result: {
stdout: "ok",
stderr: "",
exitCode: 0,
client: "codex",
durationMs: 100,
},
};
fs.mkdirSync(jobDir, { recursive: true });
fs.writeFileSync(`${jobDir}/job-valid.json`, JSON.stringify(validRecord));
fs.writeFileSync(`${jobDir}/job-corrupt.json`, "not-json");
const jobs = listJobs({ jobDir, fs });
assert.strictEqual(jobs.length, 1);
assert.strictEqual(jobs[0].id, "job-valid");
});
it("filters jobs by status when filter is provided", () => {
const fs = createMockFs();
const jobDir = "/tmp/jobs";
const now = new Date().toISOString();
const runningRecord: JobRecord = {
id: "job-running",
client: "codex",
prompt: "run",
status: "running",
startedAt: now,
stdout: "",
stderr: "",
};
const completedRecord: JobRecord = {
id: "job-completed",
client: "claude",
prompt: "done",
status: "completed",
startedAt: now,
completedAt: now,
stdout: "ok",
stderr: "",
result: {
stdout: "ok",
stderr: "",
exitCode: 0,
client: "claude",
durationMs: 100,
},
};
const failedRecord: JobRecord = {
id: "job-failed",
client: "opencode",
prompt: "fail",
status: "failed",
startedAt: now,
completedAt: now,
stdout: "",
stderr: "err",
result: {
stdout: "",
stderr: "err",
exitCode: 1,
client: "opencode",
durationMs: 100,
},
};
fs.mkdirSync(jobDir, { recursive: true });
fs.writeFileSync(`${jobDir}/job-running.json`, JSON.stringify(runningRecord));
fs.writeFileSync(`${jobDir}/job-completed.json`, JSON.stringify(completedRecord));
fs.writeFileSync(`${jobDir}/job-failed.json`, JSON.stringify(failedRecord));
const all = listJobs({ jobDir, fs });
assert.strictEqual(all.length, 3);
const running = listJobs({ jobDir, fs, filter: "running" });
assert.strictEqual(running.length, 1);
assert.strictEqual(running[0].id, "job-running");
const completed = listJobs({ jobDir, fs, filter: "completed" });
assert.strictEqual(completed.length, 1);
assert.strictEqual(completed[0].id, "job-completed");
const failed = listJobs({ jobDir, fs, filter: "failed" });
assert.strictEqual(failed.length, 1);
assert.strictEqual(failed[0].id, "job-failed");
});
});
describe("cleanupJobs", () => {
it("deletes job files older than maxAgeMs", async () => {
const scenarios = new Map<string, MockScenario>([
["codex exec --yolo old", { stdout: "old", exitCode: 0 }],
["codex exec --yolo new", { stdout: "new", exitCode: 0 }],
]);
const fs = createMockFs();
const jobDir = "/tmp/jobs";
// Create an old job by manipulating startedAt after creation
const oldJob = await startJob("codex", "old", {
jobDir,
spawn: mockSpawn(scenarios),
spawnWatcher: createMockWatcher(scenarios, fs),
fs,
});
await delay(50);
const newJob = await startJob("codex", "new", {
jobDir,
spawn: mockSpawn(scenarios),
spawnWatcher: createMockWatcher(scenarios, fs),
fs,
});
await delay(50);
// Make old job appear 25h old by patching its record
const oldPath = `${jobDir}/${oldJob.id}.json`;
const oldRecord = readJobRecord(fs, oldPath);
oldRecord.startedAt = new Date(Date.now() - 25 * 60 * 60 * 1000).toISOString();
fs.writeFileSync(oldPath, JSON.stringify(oldRecord));
cleanupJobs({ jobDir, fs, maxAgeMs: 24 * 60 * 60 * 1000 });
assert.strictEqual(fs.existsSync(oldPath), false);
assert.strictEqual(fs.existsSync(`${jobDir}/${newJob.id}.json`), true);
});
it("uses default maxAge of 24 hours", async () => {
const scenarios = new Map<string, MockScenario>([
["codex exec --yolo old", { stdout: "old", exitCode: 0 }],
]);
const fs = createMockFs();
const jobDir = "/tmp/jobs";
const job = await startJob("codex", "old", {
jobDir,
spawn: mockSpawn(scenarios),
spawnWatcher: createMockWatcher(scenarios, fs),
fs,
});
await delay(50);
const path = `${jobDir}/${job.id}.json`;
const record = readJobRecord(fs, path);
record.startedAt = new Date(Date.now() - 25 * 60 * 60 * 1000).toISOString();
fs.writeFileSync(path, JSON.stringify(record));
cleanupJobs({ jobDir, fs });
assert.strictEqual(fs.existsSync(path), false);
});
it("is a no-op when jobDir does not exist", () => {
const fs = createMockFs();
// Should not throw
cleanupJobs({ jobDir: "/tmp/jobs", fs });
assert.strictEqual(fs.existsSync("/tmp/jobs"), false);
});
});