@workermill/agent 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +73 -0
- package/dist/api.d.ts +13 -0
- package/dist/api.js +29 -0
- package/dist/cli.d.ts +8 -0
- package/dist/cli.js +59 -0
- package/dist/commands/logs.d.ts +9 -0
- package/dist/commands/logs.js +52 -0
- package/dist/commands/pull.d.ts +4 -0
- package/dist/commands/pull.js +35 -0
- package/dist/commands/setup.d.ts +11 -0
- package/dist/commands/setup.js +396 -0
- package/dist/commands/start.d.ts +11 -0
- package/dist/commands/start.js +117 -0
- package/dist/commands/status.d.ts +6 -0
- package/dist/commands/status.js +86 -0
- package/dist/commands/stop.d.ts +6 -0
- package/dist/commands/stop.js +61 -0
- package/dist/config.d.ts +77 -0
- package/dist/config.js +284 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.js +104 -0
- package/dist/planner.d.ts +19 -0
- package/dist/planner.js +268 -0
- package/dist/poller.d.ts +15 -0
- package/dist/poller.js +188 -0
- package/dist/spawner.d.ts +42 -0
- package/dist/spawner.js +290 -0
- package/package.json +34 -0
package/dist/spawner.js
ADDED
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Remote Agent Spawner
|
|
3
|
+
*
|
|
4
|
+
* Spawns Docker worker containers that talk directly to the cloud WorkerMill API.
|
|
5
|
+
* Extracted from api/src/services/local-epic-spawner.ts with key differences:
|
|
6
|
+
* - API_BASE_URL points to cloud (https://workermill.com)
|
|
7
|
+
* - ORG_API_KEY is the real org API key
|
|
8
|
+
* - Container logs stream to cloud dashboard via SSE
|
|
9
|
+
*/
|
|
10
|
+
import chalk from "chalk";
|
|
11
|
+
import { spawn, execSync } from "child_process";
|
|
12
|
+
import * as path from "path";
|
|
13
|
+
import * as fs from "fs";
|
|
14
|
+
import * as os from "os";
|
|
15
|
+
/** Timestamp prefix */
|
|
16
|
+
function ts() {
|
|
17
|
+
return chalk.dim(new Date().toLocaleTimeString());
|
|
18
|
+
}
|
|
19
|
+
// Track active containers
|
|
20
|
+
const activeContainers = new Map();
|
|
21
|
+
/**
|
|
22
|
+
* Detect if running in WSL.
|
|
23
|
+
*/
|
|
24
|
+
function detectWSL() {
|
|
25
|
+
if (process.env.WSL_DISTRO_NAME || process.env.WSL_INTEROP)
|
|
26
|
+
return true;
|
|
27
|
+
try {
|
|
28
|
+
const procVersion = fs.readFileSync("/proc/version", "utf-8");
|
|
29
|
+
return procVersion.toLowerCase().includes("microsoft");
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
const isWSL = detectWSL();
|
|
36
|
+
const isDockerDesktop = isWSL || process.platform === "darwin" || process.platform === "win32";
|
|
37
|
+
/**
|
|
38
|
+
* Convert WSL paths to Windows paths for Docker volume mounts.
|
|
39
|
+
*/
|
|
40
|
+
function toDockerPath(unixPath) {
|
|
41
|
+
if (!isWSL)
|
|
42
|
+
return unixPath;
|
|
43
|
+
const match = unixPath.match(/^\/mnt\/([a-zA-Z])\/(.*)$/);
|
|
44
|
+
if (match)
|
|
45
|
+
return `${match[1].toUpperCase()}:/${match[2]}`;
|
|
46
|
+
return unixPath;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Find Claude config directory (handles WSL).
|
|
50
|
+
*/
|
|
51
|
+
function findClaudeConfigDir() {
|
|
52
|
+
const standardDir = path.join(os.homedir(), ".claude");
|
|
53
|
+
if (fs.existsSync(standardDir))
|
|
54
|
+
return standardDir;
|
|
55
|
+
if (isWSL) {
|
|
56
|
+
const windowsUsersDir = "/mnt/c/Users";
|
|
57
|
+
if (fs.existsSync(windowsUsersDir)) {
|
|
58
|
+
try {
|
|
59
|
+
for (const user of fs.readdirSync(windowsUsersDir)) {
|
|
60
|
+
if (["Public", "Default", "Default User", "All Users"].includes(user))
|
|
61
|
+
continue;
|
|
62
|
+
const claudeDir = path.join(windowsUsersDir, user, ".claude");
|
|
63
|
+
if (fs.existsSync(claudeDir))
|
|
64
|
+
return claudeDir;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
catch { /* ignore */ }
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Get SCM token based on provider.
|
|
74
|
+
*/
|
|
75
|
+
function getScmToken(scmProvider, config) {
|
|
76
|
+
switch (scmProvider) {
|
|
77
|
+
case "bitbucket": return config.bitbucketToken;
|
|
78
|
+
case "gitlab": return config.gitlabToken;
|
|
79
|
+
default: return config.githubToken;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
/** Check if a task has the self-review label (works across Jira, GitHub, GitLab, Linear) */
|
|
83
|
+
function hasSelfReviewLabel(task) {
|
|
84
|
+
const fields = task.jiraFields;
|
|
85
|
+
if (!fields)
|
|
86
|
+
return false;
|
|
87
|
+
// Jira: labels is a string array at top level
|
|
88
|
+
const jiraLabels = fields.labels;
|
|
89
|
+
if (Array.isArray(jiraLabels) && jiraLabels.some((l) => typeof l === "string" && l.toLowerCase() === "self-review"))
|
|
90
|
+
return true;
|
|
91
|
+
// GitHub/GitLab: labels are in nested issue object with {name: string} shape
|
|
92
|
+
const issue = fields.issue;
|
|
93
|
+
const issueLabels = issue?.labels;
|
|
94
|
+
if (Array.isArray(issueLabels) && issueLabels.some((l) => {
|
|
95
|
+
if (typeof l === "string")
|
|
96
|
+
return l.toLowerCase() === "self-review";
|
|
97
|
+
if (l && typeof l === "object" && "name" in l)
|
|
98
|
+
return (l.name || "").toLowerCase() === "self-review";
|
|
99
|
+
return false;
|
|
100
|
+
}))
|
|
101
|
+
return true;
|
|
102
|
+
return false;
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Spawn a Docker worker container for a task.
|
|
106
|
+
*/
|
|
107
|
+
export async function spawnWorker(task, config, orgConfig) {
|
|
108
|
+
const taskLabel = chalk.cyan(task.id.slice(0, 8));
|
|
109
|
+
if (activeContainers.has(task.id)) {
|
|
110
|
+
console.log(`${ts()} ${taskLabel} ${chalk.dim("Already running, skipping")}`);
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
const containerName = `workermill-${task.id.slice(0, 8)}`;
|
|
114
|
+
// Build Docker run arguments
|
|
115
|
+
const dockerArgs = ["run", "--rm", "--pull", "always", "--name", containerName];
|
|
116
|
+
// Resource limits — 6GB memory with swap for overflow.
|
|
117
|
+
// NODE_OPTIONS caps V8 heap at 2GB; the extra room is for git, npm, Claude CLI subprocesses.
|
|
118
|
+
const totalRamGB = Math.round(os.totalmem() / (1024 * 1024 * 1024));
|
|
119
|
+
if (totalRamGB <= 16) {
|
|
120
|
+
dockerArgs.push("--memory", "6g", "--memory-swap", "10g", "--cpus", "2");
|
|
121
|
+
}
|
|
122
|
+
else if (totalRamGB <= 32) {
|
|
123
|
+
dockerArgs.push("--memory", "6g", "--memory-swap", "12g", "--cpus", "4");
|
|
124
|
+
}
|
|
125
|
+
else {
|
|
126
|
+
dockerArgs.push("--memory", "6g", "--memory-swap", "12g", "--cpus", "4");
|
|
127
|
+
}
|
|
128
|
+
// Network mode
|
|
129
|
+
if (isDockerDesktop) {
|
|
130
|
+
dockerArgs.push("--add-host=host.docker.internal:host-gateway");
|
|
131
|
+
}
|
|
132
|
+
else {
|
|
133
|
+
dockerArgs.push("--network", "host");
|
|
134
|
+
}
|
|
135
|
+
// Mount Claude credentials
|
|
136
|
+
const claudeConfigDir = findClaudeConfigDir();
|
|
137
|
+
if (!claudeConfigDir) {
|
|
138
|
+
console.error(`${ts()} ${taskLabel} ${chalk.red("✗")} Claude credentials not found. Run 'claude' and complete the sign-in flow.`);
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
// Copy credentials to a temp dir with relaxed permissions for container access
|
|
142
|
+
// (avoids weakening permissions on the user's actual credentials file)
|
|
143
|
+
const credFile = path.join(claudeConfigDir, ".credentials.json");
|
|
144
|
+
const dockerClaudeDir = toDockerPath(claudeConfigDir);
|
|
145
|
+
dockerArgs.push("-v", `${dockerClaudeDir}:/home/worker/.claude`);
|
|
146
|
+
// Build environment variables — KEY DIFFERENCE: API_BASE_URL points to cloud
|
|
147
|
+
const scmProvider = (task.scmProvider || "github");
|
|
148
|
+
const scmToken = getScmToken(scmProvider, config);
|
|
149
|
+
const envVars = {
|
|
150
|
+
// Cap V8 heap to 3GB — forces aggressive GC instead of bloating to fill container.
|
|
151
|
+
// Each Claude CLI subprocess inherits this, preventing unbounded heap growth.
|
|
152
|
+
// Container has 6GB total; this leaves room for git, npm, and OS overhead.
|
|
153
|
+
NODE_OPTIONS: "--max-old-space-size=3072",
|
|
154
|
+
EPIC_MODE: "true",
|
|
155
|
+
EXECUTION_MODE: "local",
|
|
156
|
+
TASK_ID: task.id,
|
|
157
|
+
PARENT_TASK_ID: task.id,
|
|
158
|
+
JIRA_ISSUE_KEY: task.jiraIssueKey || "",
|
|
159
|
+
TASK_SUMMARY: task.summary || "",
|
|
160
|
+
TASK_DESCRIPTION: task.description || "",
|
|
161
|
+
// Cloud API — this is what makes remote agent mode work
|
|
162
|
+
API_BASE_URL: config.apiUrl,
|
|
163
|
+
ORG_API_KEY: config.apiKey,
|
|
164
|
+
// SCM configuration
|
|
165
|
+
SCM_PROVIDER: scmProvider,
|
|
166
|
+
SCM_TOKEN: scmToken,
|
|
167
|
+
GITHUB_TOKEN: config.githubToken,
|
|
168
|
+
GH_TOKEN: config.githubToken,
|
|
169
|
+
BITBUCKET_TOKEN: config.bitbucketToken,
|
|
170
|
+
BITBUCKET_USERNAME: "x-token-auth",
|
|
171
|
+
GITLAB_TOKEN: config.gitlabToken,
|
|
172
|
+
// Target repository
|
|
173
|
+
TARGET_REPO: task.githubRepo || "",
|
|
174
|
+
GITHUB_REPO: task.githubRepo || "",
|
|
175
|
+
// Worker model
|
|
176
|
+
WORKER_MODEL: task.workerModel || String(orgConfig.defaultWorkerModel || "sonnet"),
|
|
177
|
+
// Anthropic API key (if available)
|
|
178
|
+
ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY || "",
|
|
179
|
+
// Resilience settings from org config
|
|
180
|
+
BLOCKER_MAX_AUTO_RETRIES: String(orgConfig.blockerMaxAutoRetries ?? 3),
|
|
181
|
+
BLOCKER_AUTO_RETRY_ENABLED: orgConfig.blockerAutoRetryEnabled !== false ? "true" : "false",
|
|
182
|
+
PUSH_AFTER_COMMIT: orgConfig.pushAfterCommit !== false ? "true" : "false",
|
|
183
|
+
GRACEFUL_SHUTDOWN_ENABLED: orgConfig.gracefulShutdownEnabled !== false ? "true" : "false",
|
|
184
|
+
REVIEW_ENABLED: task.skipManagerReview === false ? "true" : "false",
|
|
185
|
+
SELF_REVIEW_ENABLED: hasSelfReviewLabel(task) || (orgConfig.selfReviewEnabled !== false) ? "true" : "false",
|
|
186
|
+
};
|
|
187
|
+
// Build -e args, filtering empty values
|
|
188
|
+
for (const [k, v] of Object.entries(envVars)) {
|
|
189
|
+
if (v !== "") {
|
|
190
|
+
dockerArgs.push("-e", `${k}=${v}`);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
// Worker image (configurable: Docker Hub for CLI users, local for bin/remote-agent)
|
|
194
|
+
dockerArgs.push(config.workerImage || "public.ecr.aws/a7k5r0v0/workermill-worker:latest");
|
|
195
|
+
const reviewEnabled = task.skipManagerReview === false;
|
|
196
|
+
console.log(`${ts()} ${taskLabel} ${chalk.dim("Starting container")} ${chalk.yellow(containerName)}`);
|
|
197
|
+
console.log(`${ts()} ${taskLabel} ${chalk.dim(` skipManagerReview=${task.skipManagerReview} → REVIEW_ENABLED=${reviewEnabled}`)}`);
|
|
198
|
+
console.log(`${ts()} ${taskLabel} ${chalk.dim(` model=${task.workerModel} repo=${task.githubRepo}`)}`);
|
|
199
|
+
console.log(`${ts()} ${taskLabel} ${chalk.dim(` totalRamGB=${totalRamGB} docker args:`)} ${dockerArgs.slice(0, 10).join(" ")}`);
|
|
200
|
+
// Spawn Docker container
|
|
201
|
+
const proc = spawn("docker", dockerArgs, {
|
|
202
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
203
|
+
detached: false,
|
|
204
|
+
});
|
|
205
|
+
if (!proc.pid) {
|
|
206
|
+
console.error(`${ts()} ${taskLabel} ${chalk.red("✗")} Failed to spawn container`);
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
const container = {
|
|
210
|
+
taskId: task.id,
|
|
211
|
+
containerName,
|
|
212
|
+
process: proc,
|
|
213
|
+
startedAt: new Date(),
|
|
214
|
+
status: "running",
|
|
215
|
+
};
|
|
216
|
+
activeContainers.set(task.id, container);
|
|
217
|
+
// Stream stdout/stderr to console (logs go to cloud via container's own HTTP calls)
|
|
218
|
+
proc.stdout?.on("data", (data) => {
|
|
219
|
+
const lines = data.toString().split("\n").filter((l) => l.trim());
|
|
220
|
+
for (const line of lines) {
|
|
221
|
+
console.log(`${ts()} ${taskLabel} ${chalk.dim(line)}`);
|
|
222
|
+
}
|
|
223
|
+
});
|
|
224
|
+
proc.stderr?.on("data", (data) => {
|
|
225
|
+
const lines = data.toString().split("\n").filter((l) => l.trim());
|
|
226
|
+
for (const line of lines) {
|
|
227
|
+
console.log(`${ts()} ${taskLabel} ${chalk.red(line)}`);
|
|
228
|
+
}
|
|
229
|
+
});
|
|
230
|
+
// Handle exit
|
|
231
|
+
proc.on("exit", (code) => {
|
|
232
|
+
container.status = code === 0 ? "completed" : "failed";
|
|
233
|
+
const duration = Math.round((Date.now() - container.startedAt.getTime()) / 1000);
|
|
234
|
+
const icon = code === 0 ? chalk.green("✓") : chalk.red("✗");
|
|
235
|
+
const status = code === 0 ? chalk.green("completed") : chalk.red(`failed (exit ${code})`);
|
|
236
|
+
console.log(`${ts()} ${taskLabel} ${icon} Container ${status} ${chalk.dim(`(${duration}s)`)}`);
|
|
237
|
+
// Clean up after delay
|
|
238
|
+
setTimeout(() => activeContainers.delete(task.id), 60_000);
|
|
239
|
+
});
|
|
240
|
+
proc.on("error", (err) => {
|
|
241
|
+
container.status = "failed";
|
|
242
|
+
console.error(`${ts()} ${taskLabel} ${chalk.red("✗")} Container error: ${err.message}`);
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
/**
|
|
246
|
+
* Get count of actively running containers.
|
|
247
|
+
*/
|
|
248
|
+
export function getActiveCount() {
|
|
249
|
+
return Array.from(activeContainers.values()).filter((c) => c.status === "running").length;
|
|
250
|
+
}
|
|
251
|
+
/**
|
|
252
|
+
* Get IDs of all active tasks.
|
|
253
|
+
*/
|
|
254
|
+
export function getActiveTaskIds() {
|
|
255
|
+
return Array.from(activeContainers.values())
|
|
256
|
+
.filter((c) => c.status === "running")
|
|
257
|
+
.map((c) => c.taskId);
|
|
258
|
+
}
|
|
259
|
+
/**
|
|
260
|
+
* Stop a specific task's container by task ID.
|
|
261
|
+
*/
|
|
262
|
+
export function stopTask(taskId) {
|
|
263
|
+
const container = activeContainers.get(taskId);
|
|
264
|
+
if (!container || container.status !== "running")
|
|
265
|
+
return;
|
|
266
|
+
const taskLabel = chalk.cyan(taskId.slice(0, 8));
|
|
267
|
+
console.log(`${ts()} ${taskLabel} ${chalk.red("■")} Stopping container (cancelled by dashboard)`);
|
|
268
|
+
try {
|
|
269
|
+
execSync(`docker stop ${container.containerName}`, { stdio: "ignore", timeout: 15_000 });
|
|
270
|
+
container.status = "completed";
|
|
271
|
+
}
|
|
272
|
+
catch { /* may have already exited */ }
|
|
273
|
+
activeContainers.delete(taskId);
|
|
274
|
+
}
|
|
275
|
+
/**
|
|
276
|
+
* Stop all running containers.
|
|
277
|
+
*/
|
|
278
|
+
export async function stopAll() {
|
|
279
|
+
console.log(`${ts()} ${chalk.dim(`Stopping ${activeContainers.size} containers...`)}`);
|
|
280
|
+
for (const [, container] of activeContainers) {
|
|
281
|
+
if (container.status === "running") {
|
|
282
|
+
try {
|
|
283
|
+
execSync(`docker stop ${container.containerName}`, { stdio: "ignore", timeout: 15_000 });
|
|
284
|
+
container.status = "completed";
|
|
285
|
+
}
|
|
286
|
+
catch { /* may have already exited */ }
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
activeContainers.clear();
|
|
290
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@workermill/agent",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "WorkerMill Remote Agent - Run AI workers locally with your Claude Max subscription",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.js",
|
|
7
|
+
"bin": {
|
|
8
|
+
"workermill-agent": "./dist/cli.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"dist",
|
|
12
|
+
"README.md"
|
|
13
|
+
],
|
|
14
|
+
"scripts": {
|
|
15
|
+
"build": "tsc",
|
|
16
|
+
"dev": "tsc --watch",
|
|
17
|
+
"typecheck": "tsc --noEmit"
|
|
18
|
+
},
|
|
19
|
+
"engines": {
|
|
20
|
+
"node": ">=20.0.0"
|
|
21
|
+
},
|
|
22
|
+
"dependencies": {
|
|
23
|
+
"axios": "^1.7.0",
|
|
24
|
+
"chalk": "^5.3.0",
|
|
25
|
+
"commander": "^12.0.0",
|
|
26
|
+
"inquirer": "^9.2.0",
|
|
27
|
+
"ora": "^8.0.0"
|
|
28
|
+
},
|
|
29
|
+
"devDependencies": {
|
|
30
|
+
"@types/inquirer": "^9.0.9",
|
|
31
|
+
"@types/node": "^22.0.0",
|
|
32
|
+
"typescript": "^5.5.0"
|
|
33
|
+
}
|
|
34
|
+
}
|