openclaw-skill-claude-code 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/.eslintrc.json ADDED
@@ -0,0 +1,14 @@
1
+ {
2
+ "env": {
3
+ "es2021": true,
4
+ "node": true
5
+ },
6
+ "extends": ["eslint:recommended", "prettier"],
7
+ "parserOptions": {
8
+ "ecmaVersion": "latest",
9
+ "sourceType": "module"
10
+ },
11
+ "rules": {
12
+ "no-console": "off"
13
+ }
14
+ }
@@ -0,0 +1,54 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [ main ]
6
+ pull_request:
7
+ branches: [ main ]
8
+
9
+ jobs:
10
+ build:
11
+ runs-on: ubuntu-latest
12
+ steps:
13
+ - uses: actions/checkout@v4
14
+ with:
15
+ fetch-depth: 0
16
+
17
+ - name: Setup Node.js
18
+ uses: actions/setup-node@v4
19
+ with:
20
+ node-version: '20'
21
+ cache: 'npm'
22
+
23
+ - name: Install dependencies
24
+ run: npm ci
25
+
26
+ - name: Lint
27
+ run: npm run lint
28
+
29
+ - name: Format Check
30
+ run: npm run format:check
31
+
32
+ - name: Commitlint
33
+ if: github.event_name == 'pull_request'
34
+ uses: wagoid/commitlint-github-action@v5
35
+
36
+ release:
37
+ needs: build
38
+ if: github.event_name == 'push' && github.ref == 'refs/heads/main'
39
+ runs-on: ubuntu-latest
40
+ permissions:
41
+ contents: write
42
+ issues: write
43
+ pull-requests: write
44
+ steps:
45
+ - uses: actions/checkout@v4
46
+ - uses: actions/setup-node@v4
47
+ with:
48
+ node-version: '20'
49
+ cache: 'npm'
50
+ - run: npm ci
51
+ - run: npx semantic-release
52
+ env:
53
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
54
+ NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
@@ -0,0 +1 @@
1
+ npx --no -- commitlint --edit ${1}
@@ -0,0 +1 @@
1
+ npm test
package/.prettierrc ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "semi": true,
3
+ "singleQuote": true,
4
+ "trailingComma": "all",
5
+ "printWidth": 100
6
+ }
@@ -0,0 +1,35 @@
1
+ # Contributing to openclaw-skill-claude-code
2
+
3
+ Thank you for your interest in contributing! We want to make this skill the gold standard for robust agentic coding.
4
+
5
+ ## Development Workflow
6
+
7
+ 1. **Fork & Clone**: Fork the repo and clone it locally.
8
+ 2. **Install Dependencies**: \`npm install\`
9
+ 3. **Branch**: Create a feature branch (\`feat/my-feature\`).
10
+ 4. **Code**: Implement your changes.
11
+ 5. **Commit**: Use [Conventional Commits](https://www.conventionalcommits.org/).
12
+ * \`feat: add timeout handling\`
13
+ * \`fix: correct pid tracking\`
14
+ * \`docs: update readme\`
15
+ 6. **Push & PR**: Push your branch and open a Pull Request.
16
+
17
+ ## Commits
18
+
19
+ We enforce Conventional Commits via \`commitlint\`. This enables automated semantic versioning and changelog generation.
20
+
21
+ Types:
22
+ - \`feat\`: A new feature
23
+ - \`fix\`: A bug fix
24
+ - \`docs\`: Documentation only changes
25
+ - \`style\`: Changes that do not affect the meaning of the code (white-space, formatting, etc)
26
+ - \`refactor\`: A code change that neither fixes a bug nor adds a feature
27
+ - \`perf\`: A code change that improves performance
28
+ - \`test\`: Adding missing tests or correcting existing tests
29
+ - \`chore\`: Changes to the build process or auxiliary tools and libraries such as documentation generation
30
+
31
+ ## Release Process
32
+
33
+ Releases are automated via GitHub Actions and \`semantic-release\`.
34
+ - Merging to \`main\` triggers a release.
35
+ - Version number is determined by commit types (fix=patch, feat=minor, BREAKING CHANGE=major).
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Noncelogic
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,116 @@
1
+ # Resilient Claude Code Skill
2
+
3
+ **Persistent, detached coding jobs for OpenClaw.**
4
+
5
+ Most Claude Code wrappers run `exec('claude ...')`. If your agent turn times out, the gateway restarts, or the API hangs — your coding task dies.
6
+
7
+ This skill treats coding tasks as **persistent, detached jobs** using the `@anthropic-ai/claude-agent-sdk`.
8
+
9
+ ## Why This Exists
10
+
11
+ ### Decoupled Lifecycle
12
+ Agent turns have timeouts (e.g., 900s). Coding tasks take 20+ minutes. This skill starts a Claude agent process, **detaches it** from the agent session, and returns a `jobId`. The agent checks back later.
13
+
14
+ ### Restart Survival
15
+ If you restart OpenClaw (maintenance, crash, update), child processes usually get killed. This skill spawns processes in a separate process group with PID tracking on disk. On reboot, it **re-acquires running jobs** instead of losing them.
16
+
17
+ ### Rate Limit Intelligence
18
+ Generic wrappers treat any pause as a hang. This skill distinguishes between "Claude is thinking" and "API 429/503", bubbling precise status to the orchestrator.
19
+
20
+ ## Architecture
21
+
22
+ ```
23
+ [ OpenClaw Agent ] -> (SKILL.md instructions) -> [ exec: node scripts/run.mjs ]
24
+ |
25
+ (Spawn detached job)
26
+ |
27
+ [ Claude Agent SDK ]
28
+ |
29
+ (Write state to disk)
30
+ v
31
+ [ jobs/<jobId>/ ]
32
+ ├── meta.json (status, timestamps, pid)
33
+ ├── output.log (streaming output)
34
+ └── result.json (final result when done)
35
+ ```
36
+
37
+ 1. Agent calls `start` — gets a `jobId` and `pid`.
38
+ 2. Agent turn ends. The coding job keeps running.
39
+ 3. Agent polls `status` on next turn.
40
+ 4. Skill reads process status and log tail from disk.
41
+ 5. Returns: `running` | `completed` | `failed` | `killed`.
42
+
43
+ ## Usage
44
+
45
+ ### Start a job
46
+
47
+ ```bash
48
+ node scripts/run.mjs start \
49
+ --prompt "Read TASK.md and implement. Run tests." \
50
+ --cwd /path/to/project \
51
+ --job-id task-137
52
+ ```
53
+
54
+ ### Check status
55
+
56
+ ```bash
57
+ node scripts/run.mjs status --job-id task-137
58
+ ```
59
+
60
+ ### Get logs
61
+
62
+ ```bash
63
+ node scripts/run.mjs logs --job-id task-137 --tail 50
64
+ ```
65
+
66
+ ### Get result
67
+
68
+ ```bash
69
+ node scripts/run.mjs result --job-id task-137
70
+ ```
71
+
72
+ ### List all jobs
73
+
74
+ ```bash
75
+ node scripts/run.mjs list
76
+ ```
77
+
78
+ ### Kill a job
79
+
80
+ ```bash
81
+ node scripts/run.mjs kill --job-id task-137
82
+ ```
83
+
84
+ ## File Structure
85
+
86
+ ```
87
+ openclaw-skill-claude-code/
88
+ ├── SKILL.md # OpenClaw skill instructions
89
+ ├── README.md # Documentation
90
+ ├── package.json # Dependencies
91
+ ├── scripts/
92
+ │ ├── run.mjs # CLI entry point
93
+ │ ├── job-manager.mjs # Job lifecycle management
94
+ │ └── worker.mjs # Detached worker process
95
+ └── jobs/ # Runtime state (gitignored)
96
+ └── <jobId>/
97
+ ├── meta.json
98
+ ├── output.log
99
+ └── result.json
100
+ ```
101
+
102
+ ## Configuration
103
+
104
+ | Variable | Required | Default | Description |
105
+ |----------|----------|---------|-------------|
106
+ | `ANTHROPIC_API_KEY` | Yes | — | Claude API key |
107
+ | `CLAUDE_SKILL_JOBS_DIR` | No | `<skill-dir>/jobs` | Directory for job state |
108
+ | `CLAUDE_SKILL_MODEL` | No | SDK default | Override the model |
109
+
110
+ ## How It Works
111
+
112
+ **Job Manager** (`scripts/job-manager.mjs`): Manages the job lifecycle — `start`, `status`, `result`, `logs`, `list`, `kill`. Spawns workers as detached processes and tracks them by PID on disk.
113
+
114
+ **Worker** (`scripts/worker.mjs`): Runs in a detached process. Streams the Claude Agent SDK `query()` iterator, writing output to `output.log` and final results to `result.json`. Handles SIGTERM gracefully and detects rate limits.
115
+
116
+ **CLI** (`scripts/run.mjs`): Thin command-line wrapper over the job manager. All output is structured JSON.
package/SKILL.md ADDED
@@ -0,0 +1,109 @@
1
+ ---
2
+ name: claude-code
3
+ description: Run coding tasks as persistent, detached jobs using the Claude Agent SDK. Jobs survive agent timeouts, gateway restarts, and API hangs.
4
+ ---
5
+
6
+ # Claude Code Skill
7
+
8
+ This skill runs coding tasks as **persistent, detached jobs** using the `@anthropic-ai/claude-agent-sdk`. Unlike raw `exec claude` calls, jobs survive agent turn timeouts, gateway restarts, and API rate limits.
9
+
10
+ ## Starting a coding task
11
+
12
+ Use `exec` to start a detached job. It returns immediately with a `jobId`.
13
+
14
+ ```bash
15
+ node {{skill_dir}}/scripts/run.mjs start \
16
+ --prompt "Read TASK.md and implement. Run tests." \
17
+ --cwd /path/to/project \
18
+ --job-id task-137
19
+ ```
20
+
21
+ **Arguments:**
22
+ - `--prompt` (required): The task description for Claude.
23
+ - `--cwd` (required): The working directory for the coding task.
24
+ - `--job-id` (required): A unique identifier for this job.
25
+ - `--model` (optional): Override the model (defaults to SDK default).
26
+
27
+ **Returns JSON:**
28
+ ```json
29
+ { "jobId": "task-137", "pid": 12345, "status": "running" }
30
+ ```
31
+
32
+ ## Checking status
33
+
34
+ Poll job status to see if it's still running, completed, or failed.
35
+
36
+ ```bash
37
+ node {{skill_dir}}/scripts/run.mjs status --job-id task-137
38
+ ```
39
+
40
+ **Returns JSON:**
41
+ ```json
42
+ {
43
+ "jobId": "task-137",
44
+ "pid": 12345,
45
+ "status": "running",
46
+ "startedAt": "2025-01-01T00:00:00.000Z",
47
+ "endedAt": null,
48
+ "error": null,
49
+ "rateLimited": false
50
+ }
51
+ ```
52
+
53
+ **Status values:** `running`, `completed`, `failed`, `killed`, `not_found`
54
+
55
+ If `rateLimited` is `true`, the job hit API rate limits — this is a temporary condition, not a hang.
56
+
57
+ ## Getting logs
58
+
59
+ Read the last N lines of streaming output from the job.
60
+
61
+ ```bash
62
+ node {{skill_dir}}/scripts/run.mjs logs --job-id task-137 --tail 50
63
+ ```
64
+
65
+ ## Getting the result
66
+
67
+ Once status is `completed`, retrieve the final result.
68
+
69
+ ```bash
70
+ node {{skill_dir}}/scripts/run.mjs result --job-id task-137
71
+ ```
72
+
73
+ **Returns JSON:**
74
+ ```json
75
+ {
76
+ "jobId": "task-137",
77
+ "status": "completed",
78
+ "result": "...",
79
+ "cost_usd": 0.05,
80
+ "duration_ms": 120000,
81
+ "num_turns": 8
82
+ }
83
+ ```
84
+
85
+ ## Listing all jobs
86
+
87
+ ```bash
88
+ node {{skill_dir}}/scripts/run.mjs list
89
+ ```
90
+
91
+ ## Killing a job
92
+
93
+ ```bash
94
+ node {{skill_dir}}/scripts/run.mjs kill --job-id task-137
95
+ ```
96
+
97
+ ## Best Practices
98
+
99
+ - **Unique job IDs**: Use descriptive IDs like `issue-42` or `feat-auth-v2` to track jobs.
100
+ - **Poll status**: After starting a job, check status periodically. Jobs can take 5-30 minutes.
101
+ - **Check logs on failure**: If status is `failed`, read logs to understand what went wrong.
102
+ - **Rate limits are normal**: If `rateLimited` is true, the SDK is handling retries automatically.
103
+ - **Verification**: Include "Run tests" in your prompt to have Claude verify its own changes.
104
+
105
+ ## Environment
106
+
107
+ - `ANTHROPIC_API_KEY` — required (the skill checks for this and fails fast with a clear error if missing).
108
+ - `CLAUDE_SKILL_JOBS_DIR` — optional, defaults to `<skill_dir>/jobs`.
109
+ - `CLAUDE_SKILL_MODEL` — optional, overrides the default model.
package/TASK.md ADDED
@@ -0,0 +1,178 @@
1
+ # TASK: Build Resilient Claude Code Skill for OpenClaw (#1)
2
+
3
+ ## Context
4
+ This is an OpenClaw skill that wraps the `@anthropic-ai/claude-agent-sdk` (the official Claude Agent SDK) to run coding tasks as persistent, detached jobs. The current method (`exec claude --dangerously-skip-permissions`) is fragile — jobs die on gateway restart, timeouts, or API hangs.
5
+
6
+ ## Architecture
7
+
8
+ ```
9
+ [ OpenClaw Agent ] -> (SKILL.md instructions) -> [ exec: node scripts/run.mjs ]
10
+ |
11
+ (Spawn detached job)
12
+ |
13
+ [ Claude Agent SDK ]
14
+ |
15
+ (Write state to disk)
16
+ v
17
+ [ jobs/<jobId>/ ]
18
+ ├── meta.json (status, timestamps, pid)
19
+ ├── output.log (streaming output)
20
+ └── result.json (final result when done)
21
+ ```
22
+
23
+ ## SDK Reference
24
+
25
+ The official SDK is `@anthropic-ai/claude-agent-sdk` (v0.2.42). TypeScript usage:
26
+
27
+ ```typescript
28
+ import { query } from "@anthropic-ai/claude-agent-sdk";
29
+
30
+ for await (const message of query({
31
+ prompt: "Fix the bug in auth.py",
32
+ options: {
33
+ allowedTools: ["Read", "Edit", "Bash", "Glob", "Grep"],
34
+ permissionMode: "acceptEdits", // Auto-approve file edits
35
+ }
36
+ })) {
37
+ if (message.type === "assistant" && message.message?.content) {
38
+ for (const block of message.message.content) {
39
+ if ("text" in block) console.log(block.text);
40
+ else if ("name" in block) console.log(`Tool: ${block.name}`);
41
+ }
42
+ } else if (message.type === "result") {
43
+ console.log(`Done: ${message.subtype}`);
44
+ }
45
+ }
46
+ ```
47
+
48
+ Auth: Set `ANTHROPIC_API_KEY` env var. The SDK reads it automatically.
49
+
50
+ Available tools: `Read`, `Write`, `Edit`, `Bash`, `Glob`, `Grep`, `WebSearch`, `WebFetch`.
51
+
52
+ ## Implementation
53
+
54
+ ### 1. Package Setup
55
+ Create `package.json` with dependencies:
56
+ - `@anthropic-ai/claude-agent-sdk`
57
+ - No other runtime deps needed
58
+
59
+ ### 2. Job Manager (`scripts/job-manager.mjs`)
60
+ Core module that manages job lifecycle:
61
+
62
+ ```javascript
63
+ // start(jobId, prompt, cwd, options) → spawns detached process
64
+ // status(jobId) → reads meta.json, checks if PID alive
65
+ // result(jobId) → reads result.json
66
+ // logs(jobId, tail) → reads last N lines of output.log
67
+ // list() → lists all jobs with status
68
+ // kill(jobId) → sends SIGTERM to PID
69
+ ```
70
+
71
+ **State directory**: `jobs/<jobId>/`
72
+ - `meta.json`: `{ jobId, pid, status, prompt, cwd, startedAt, endedAt, error }`
73
+ - `output.log`: Streaming text output from the agent
74
+ - `result.json`: Final result text (written on completion)
75
+
76
+ **Detached spawn**: The job manager spawns `node scripts/worker.mjs <jobId>` as a detached child process (`{ detached: true, stdio: 'ignore' }`) and immediately unrefs it. The worker runs independently.
77
+
78
+ **PID tracking**: On startup, `status()` checks if the PID in `meta.json` is still alive (`process.kill(pid, 0)`). If the process died without updating status, mark as `failed`.
79
+
80
+ ### 3. Worker (`scripts/worker.mjs`)
81
+ Runs in a detached process. Receives `jobId` as argv:
82
+
83
+ 1. Read `jobs/<jobId>/meta.json` for prompt and cwd
84
+ 2. `process.chdir(cwd)`
85
+ 3. Stream `query()` from the Agent SDK
86
+ 4. Write each message to `output.log` (append)
87
+ 5. On completion: write `result.json`, update `meta.json` status to `completed`
88
+ 6. On error: update `meta.json` status to `failed`, write error details
89
+ 7. Handle SIGTERM gracefully
90
+
91
+ **Rate limit detection**: Watch for 429/503 patterns in error messages. Set a `rateLimited` flag in `meta.json` so the orchestrator knows it's not a hang.
92
+
93
+ ### 4. CLI Entry Points (`scripts/run.mjs`)
94
+ Simple CLI wrappers for the job manager:
95
+
96
+ ```bash
97
+ # Start a job
98
+ node scripts/run.mjs start --prompt "Fix the bug" --cwd /path/to/project --job-id my-job
99
+
100
+ # Check status
101
+ node scripts/run.mjs status --job-id my-job
102
+
103
+ # Get logs (last 50 lines)
104
+ node scripts/run.mjs logs --job-id my-job --tail 50
105
+
106
+ # Get result
107
+ node scripts/run.mjs result --job-id my-job
108
+
109
+ # List all jobs
110
+ node scripts/run.mjs list
111
+
112
+ # Kill a job
113
+ node scripts/run.mjs kill --job-id my-job
114
+ ```
115
+
116
+ Return JSON to stdout for structured output.
117
+
118
+ ### 5. SKILL.md
119
+ Update the skill instructions to use the new scripts instead of raw `exec claude`:
120
+
121
+ ```markdown
122
+ ## Starting a coding task
123
+ Use `exec` to run:
124
+ \`\`\`bash
125
+ node /path/to/skill/scripts/run.mjs start \
126
+ --prompt "Read TASK.md and implement. Run tests." \
127
+ --cwd /path/to/project \
128
+ --job-id task-137
129
+ \`\`\`
130
+
131
+ ## Checking status
132
+ \`\`\`bash
133
+ node /path/to/skill/scripts/run.mjs status --job-id task-137
134
+ \`\`\`
135
+
136
+ ## Getting results
137
+ \`\`\`bash
138
+ node /path/to/skill/scripts/run.mjs result --job-id task-137
139
+ \`\`\`
140
+ ```
141
+
142
+ ### 6. README.md
143
+ Update with architecture diagram, usage examples, and configuration.
144
+
145
+ ## Configuration
146
+
147
+ The skill reads these env vars:
148
+ - `ANTHROPIC_API_KEY` — required (Claude API key)
149
+ - `CLAUDE_SKILL_JOBS_DIR` — optional, defaults to `<skill-dir>/jobs`
150
+ - `CLAUDE_SKILL_MODEL` — optional, defaults to SDK default
151
+
152
+ ## File Structure
153
+ ```
154
+ openclaw-skill-claude-code/
155
+ ├── SKILL.md # OpenClaw skill instructions
156
+ ├── README.md # Documentation
157
+ ├── package.json # Dependencies
158
+ ├── scripts/
159
+ │ ├── run.mjs # CLI entry point
160
+ │ ├── job-manager.mjs # Job lifecycle management
161
+ │ └── worker.mjs # Detached worker process
162
+ └── jobs/ # Runtime state (gitignored)
163
+ └── <jobId>/
164
+ ├── meta.json
165
+ ├── output.log
166
+ └── result.json
167
+ ```
168
+
169
+ ## Verification
170
+ - `npm install` succeeds
171
+ - `node scripts/run.mjs start --prompt "What files are here?" --cwd . --job-id test-1` starts a job
172
+ - `node scripts/run.mjs status --job-id test-1` shows status
173
+ - `node scripts/run.mjs list` shows all jobs
174
+ - Job survives if parent process exits
175
+ - Handles missing ANTHROPIC_API_KEY gracefully (clear error message)
176
+
177
+ ## Branch
178
+ `feat/1-resilient-job-manager`
@@ -0,0 +1 @@
1
+ export default { extends: ['@commitlint/config-conventional'] };
package/package.json ADDED
@@ -0,0 +1,31 @@
1
+ {
2
+ "name": "openclaw-skill-claude-code",
3
+ "version": "1.0.0",
4
+ "description": "Resilient Claude Code skill for OpenClaw — persistent, detached coding jobs",
5
+ "type": "module",
6
+ "scripts": {
7
+ "start": "node scripts/run.mjs",
8
+ "lint": "eslint .",
9
+ "format": "prettier --write .",
10
+ "format:check": "prettier --check .",
11
+ "prepare": "husky",
12
+ "test": "echo \"No tests yet\" && exit 0"
13
+ },
14
+ "release": {
15
+ "branches": [
16
+ "main"
17
+ ]
18
+ },
19
+ "dependencies": {
20
+ "@anthropic-ai/claude-agent-sdk": "^0.2.42"
21
+ },
22
+ "devDependencies": {
23
+ "@commitlint/cli": "^20.4.1",
24
+ "@commitlint/config-conventional": "^20.4.1",
25
+ "eslint": "^10.0.0",
26
+ "eslint-config-prettier": "^10.1.8",
27
+ "husky": "^9.1.7",
28
+ "prettier": "^3.8.1",
29
+ "semantic-release": "^25.0.3"
30
+ }
31
+ }
@@ -0,0 +1,174 @@
1
+ import { spawn } from "node:child_process";
2
+ import { readFile, writeFile, mkdir, readdir } from "node:fs/promises";
3
+ import { join, dirname } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+
6
+ const __dirname = dirname(fileURLToPath(import.meta.url));
7
+ const SKILL_DIR = join(__dirname, "..");
8
+ const JOBS_DIR = process.env.CLAUDE_SKILL_JOBS_DIR || join(SKILL_DIR, "jobs");
9
+
10
+ async function ensureJobDir(jobId) {
11
+ const dir = join(JOBS_DIR, jobId);
12
+ await mkdir(dir, { recursive: true });
13
+ return dir;
14
+ }
15
+
16
+ function metaPath(jobId) {
17
+ return join(JOBS_DIR, jobId, "meta.json");
18
+ }
19
+
20
+ function outputPath(jobId) {
21
+ return join(JOBS_DIR, jobId, "output.log");
22
+ }
23
+
24
+ function resultPath(jobId) {
25
+ return join(JOBS_DIR, jobId, "result.json");
26
+ }
27
+
28
+ function isPidAlive(pid) {
29
+ try {
30
+ process.kill(pid, 0);
31
+ return true;
32
+ } catch {
33
+ return false;
34
+ }
35
+ }
36
+
37
+ async function readJson(path) {
38
+ const data = await readFile(path, "utf-8");
39
+ return JSON.parse(data);
40
+ }
41
+
42
+ async function writeJson(path, data) {
43
+ await writeFile(path, JSON.stringify(data, null, 2) + "\n");
44
+ }
45
+
46
+ export async function start(jobId, prompt, cwd, options = {}) {
47
+ const dir = await ensureJobDir(jobId);
48
+
49
+ const meta = {
50
+ jobId,
51
+ pid: null,
52
+ status: "starting",
53
+ prompt,
54
+ cwd,
55
+ model: options.model || process.env.CLAUDE_SKILL_MODEL || undefined,
56
+ allowedTools: options.allowedTools || undefined,
57
+ startedAt: new Date().toISOString(),
58
+ endedAt: null,
59
+ error: null,
60
+ rateLimited: false,
61
+ };
62
+
63
+ await writeJson(metaPath(jobId), meta);
64
+ await writeFile(outputPath(jobId), "");
65
+
66
+ const workerPath = join(__dirname, "worker.mjs");
67
+ const child = spawn("node", [workerPath, jobId], {
68
+ detached: true,
69
+ stdio: "ignore",
70
+ cwd: SKILL_DIR,
71
+ env: { ...process.env },
72
+ });
73
+
74
+ child.unref();
75
+
76
+ meta.pid = child.pid;
77
+ meta.status = "running";
78
+ await writeJson(metaPath(jobId), meta);
79
+
80
+ return { jobId, pid: child.pid, status: "running" };
81
+ }
82
+
83
+ export async function status(jobId) {
84
+ let meta;
85
+ try {
86
+ meta = await readJson(metaPath(jobId));
87
+ } catch {
88
+ return { jobId, status: "not_found" };
89
+ }
90
+
91
+ if (meta.status === "running" && meta.pid) {
92
+ if (!isPidAlive(meta.pid)) {
93
+ meta.status = "failed";
94
+ meta.error = "Process exited unexpectedly";
95
+ meta.endedAt = new Date().toISOString();
96
+ await writeJson(metaPath(jobId), meta);
97
+ }
98
+ }
99
+
100
+ return {
101
+ jobId: meta.jobId,
102
+ pid: meta.pid,
103
+ status: meta.status,
104
+ startedAt: meta.startedAt,
105
+ endedAt: meta.endedAt,
106
+ error: meta.error,
107
+ rateLimited: meta.rateLimited,
108
+ };
109
+ }
110
+
111
+ export async function result(jobId) {
112
+ try {
113
+ return await readJson(resultPath(jobId));
114
+ } catch {
115
+ const s = await status(jobId);
116
+ return { jobId, status: s.status, result: null };
117
+ }
118
+ }
119
+
120
+ export async function logs(jobId, tail = 50) {
121
+ try {
122
+ const content = await readFile(outputPath(jobId), "utf-8");
123
+ const lines = content.split("\n");
124
+ const sliced = lines.slice(-tail).join("\n");
125
+ return { jobId, lines: lines.length, tail: sliced };
126
+ } catch {
127
+ return { jobId, lines: 0, tail: "" };
128
+ }
129
+ }
130
+
131
+ export async function list() {
132
+ let entries;
133
+ try {
134
+ entries = await readdir(JOBS_DIR, { withFileTypes: true });
135
+ } catch {
136
+ return [];
137
+ }
138
+
139
+ const jobs = [];
140
+ for (const entry of entries) {
141
+ if (entry.isDirectory()) {
142
+ const s = await status(entry.name);
143
+ jobs.push(s);
144
+ }
145
+ }
146
+ return jobs;
147
+ }
148
+
149
+ export async function kill(jobId) {
150
+ let meta;
151
+ try {
152
+ meta = await readJson(metaPath(jobId));
153
+ } catch {
154
+ return { jobId, killed: false, error: "Job not found" };
155
+ }
156
+
157
+ if (!meta.pid) {
158
+ return { jobId, killed: false, error: "No PID recorded" };
159
+ }
160
+
161
+ if (!isPidAlive(meta.pid)) {
162
+ return { jobId, killed: false, error: "Process already dead" };
163
+ }
164
+
165
+ try {
166
+ process.kill(meta.pid, "SIGTERM");
167
+ meta.status = "killed";
168
+ meta.endedAt = new Date().toISOString();
169
+ await writeJson(metaPath(jobId), meta);
170
+ return { jobId, killed: true };
171
+ } catch (err) {
172
+ return { jobId, killed: false, error: err.message };
173
+ }
174
+ }
@@ -0,0 +1,90 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { start, status, result, logs, list, kill } from "./job-manager.mjs";
4
+
5
+ function parseArgs(args) {
6
+ const parsed = {};
7
+ for (let i = 0; i < args.length; i++) {
8
+ if (args[i].startsWith("--")) {
9
+ const key = args[i].slice(2);
10
+ const next = args[i + 1];
11
+ if (next && !next.startsWith("--")) {
12
+ parsed[key] = next;
13
+ i++;
14
+ } else {
15
+ parsed[key] = true;
16
+ }
17
+ }
18
+ }
19
+ return parsed;
20
+ }
21
+
22
+ function output(data) {
23
+ process.stdout.write(JSON.stringify(data, null, 2) + "\n");
24
+ }
25
+
26
+ function die(message) {
27
+ output({ error: message });
28
+ process.exit(1);
29
+ }
30
+
31
+ const [command, ...rest] = process.argv.slice(2);
32
+ const args = parseArgs(rest);
33
+
34
+ switch (command) {
35
+ case "start": {
36
+ const prompt = args.prompt;
37
+ const cwd = args.cwd || process.cwd();
38
+ const jobId = args["job-id"];
39
+
40
+ if (!prompt) die("--prompt is required");
41
+ if (!jobId) die("--job-id is required");
42
+
43
+ const options = {};
44
+ if (args.model) options.model = args.model;
45
+
46
+ const res = await start(jobId, prompt, cwd, options);
47
+ output(res);
48
+ break;
49
+ }
50
+
51
+ case "status": {
52
+ const jobId = args["job-id"];
53
+ if (!jobId) die("--job-id is required");
54
+ output(await status(jobId));
55
+ break;
56
+ }
57
+
58
+ case "result": {
59
+ const jobId = args["job-id"];
60
+ if (!jobId) die("--job-id is required");
61
+ output(await result(jobId));
62
+ break;
63
+ }
64
+
65
+ case "logs": {
66
+ const jobId = args["job-id"];
67
+ if (!jobId) die("--job-id is required");
68
+ const tail = parseInt(args.tail, 10) || 50;
69
+ output(await logs(jobId, tail));
70
+ break;
71
+ }
72
+
73
+ case "list": {
74
+ output(await list());
75
+ break;
76
+ }
77
+
78
+ case "kill": {
79
+ const jobId = args["job-id"];
80
+ if (!jobId) die("--job-id is required");
81
+ output(await kill(jobId));
82
+ break;
83
+ }
84
+
85
+ default:
86
+ die(
87
+ `Unknown command: ${command || "(none)"}. ` +
88
+ `Available: start, status, result, logs, list, kill`
89
+ );
90
+ }
@@ -0,0 +1,183 @@
1
+ import { readFile, writeFile, appendFile } from "node:fs/promises";
2
+ import { join, dirname } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+
5
+ const __dirname = dirname(fileURLToPath(import.meta.url));
6
+ const SKILL_DIR = join(__dirname, "..");
7
+ const JOBS_DIR = process.env.CLAUDE_SKILL_JOBS_DIR || join(SKILL_DIR, "jobs");
8
+
9
+ const jobId = process.argv[2];
10
+ if (!jobId) {
11
+ console.error("Usage: node worker.mjs <jobId>");
12
+ process.exit(1);
13
+ }
14
+
15
+ const metaFile = join(JOBS_DIR, jobId, "meta.json");
16
+ const outputFile = join(JOBS_DIR, jobId, "output.log");
17
+ const resultFile = join(JOBS_DIR, jobId, "result.json");
18
+
19
+ async function readMeta() {
20
+ return JSON.parse(await readFile(metaFile, "utf-8"));
21
+ }
22
+
23
+ async function writeMeta(meta) {
24
+ await writeFile(metaFile, JSON.stringify(meta, null, 2) + "\n");
25
+ }
26
+
27
+ async function log(text) {
28
+ await appendFile(outputFile, text + "\n");
29
+ }
30
+
31
+ let shuttingDown = false;
32
+
33
+ process.on("SIGTERM", async () => {
34
+ shuttingDown = true;
35
+ await log("[worker] Received SIGTERM, shutting down gracefully...");
36
+ const meta = await readMeta();
37
+ meta.status = "killed";
38
+ meta.endedAt = new Date().toISOString();
39
+ await writeMeta(meta);
40
+ process.exit(0);
41
+ });
42
+
43
+ async function run() {
44
+ const meta = await readMeta();
45
+
46
+ if (!process.env.ANTHROPIC_API_KEY) {
47
+ meta.status = "failed";
48
+ meta.error = "ANTHROPIC_API_KEY environment variable is not set";
49
+ meta.endedAt = new Date().toISOString();
50
+ await writeMeta(meta);
51
+ await log("[worker] ERROR: ANTHROPIC_API_KEY not set");
52
+ process.exit(1);
53
+ }
54
+
55
+ await log(`[worker] Starting job ${jobId}`);
56
+ await log(`[worker] Prompt: ${meta.prompt}`);
57
+ await log(`[worker] CWD: ${meta.cwd}`);
58
+
59
+ let queryFn;
60
+ try {
61
+ const sdk = await import("@anthropic-ai/claude-agent-sdk");
62
+ queryFn = sdk.query;
63
+ } catch (err) {
64
+ meta.status = "failed";
65
+ meta.error = `Failed to load SDK: ${err.message}`;
66
+ meta.endedAt = new Date().toISOString();
67
+ await writeMeta(meta);
68
+ await log(`[worker] ERROR: ${meta.error}`);
69
+ process.exit(1);
70
+ }
71
+
72
+ const options = {
73
+ cwd: meta.cwd,
74
+ permissionMode: "bypassPermissions",
75
+ allowDangerouslySkipPermissions: true,
76
+ };
77
+
78
+ if (meta.model) {
79
+ options.model = meta.model;
80
+ }
81
+
82
+ if (meta.allowedTools) {
83
+ options.allowedTools = meta.allowedTools;
84
+ }
85
+
86
+ let resultText = "";
87
+
88
+ try {
89
+ const q = queryFn({ prompt: meta.prompt, options });
90
+
91
+ for await (const message of q) {
92
+ if (shuttingDown) break;
93
+
94
+ if (message.type === "assistant" && message.message?.content) {
95
+ for (const block of message.message.content) {
96
+ if ("text" in block) {
97
+ await log(block.text);
98
+ resultText += block.text + "\n";
99
+ } else if ("name" in block) {
100
+ await log(`[tool] ${block.name}`);
101
+ }
102
+ }
103
+ } else if (message.type === "result") {
104
+ if (message.subtype === "success") {
105
+ await log(`[worker] Completed successfully`);
106
+ resultText = message.result || resultText;
107
+
108
+ const resultData = {
109
+ jobId,
110
+ status: "completed",
111
+ result: resultText,
112
+ cost_usd: message.total_cost_usd,
113
+ duration_ms: message.duration_ms,
114
+ num_turns: message.num_turns,
115
+ };
116
+ await writeFile(resultFile, JSON.stringify(resultData, null, 2) + "\n");
117
+
118
+ meta.status = "completed";
119
+ meta.endedAt = new Date().toISOString();
120
+ await writeMeta(meta);
121
+ } else {
122
+ const errorMsg = message.errors?.join("; ") || message.subtype;
123
+ await log(`[worker] Error: ${errorMsg}`);
124
+
125
+ meta.status = "failed";
126
+ meta.error = errorMsg;
127
+ meta.endedAt = new Date().toISOString();
128
+ await writeMeta(meta);
129
+
130
+ const resultData = {
131
+ jobId,
132
+ status: "failed",
133
+ error: errorMsg,
134
+ result: resultText || null,
135
+ cost_usd: message.total_cost_usd,
136
+ duration_ms: message.duration_ms,
137
+ };
138
+ await writeFile(resultFile, JSON.stringify(resultData, null, 2) + "\n");
139
+ }
140
+ } else if (message.type === "assistant" && message.error) {
141
+ const errType = message.error;
142
+ if (errType === "rate_limit") {
143
+ meta.rateLimited = true;
144
+ await writeMeta(meta);
145
+ await log("[worker] Rate limited, SDK will retry...");
146
+ }
147
+ }
148
+ }
149
+ } catch (err) {
150
+ const errMsg = err.message || String(err);
151
+ await log(`[worker] Fatal error: ${errMsg}`);
152
+
153
+ const isRateLimit = /429|rate.limit|503|overloaded/i.test(errMsg);
154
+ meta.status = "failed";
155
+ meta.error = errMsg;
156
+ meta.rateLimited = isRateLimit;
157
+ meta.endedAt = new Date().toISOString();
158
+ await writeMeta(meta);
159
+
160
+ const resultData = {
161
+ jobId,
162
+ status: "failed",
163
+ error: errMsg,
164
+ result: resultText || null,
165
+ };
166
+ await writeFile(resultFile, JSON.stringify(resultData, null, 2) + "\n");
167
+ process.exit(1);
168
+ }
169
+ }
170
+
171
+ run().catch(async (err) => {
172
+ try {
173
+ const meta = await readMeta();
174
+ meta.status = "failed";
175
+ meta.error = err.message || String(err);
176
+ meta.endedAt = new Date().toISOString();
177
+ await writeMeta(meta);
178
+ await log(`[worker] Unhandled error: ${meta.error}`);
179
+ } catch {
180
+ // Can't even write meta, just exit
181
+ }
182
+ process.exit(1);
183
+ });