@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.
@@ -0,0 +1,268 @@
1
+ /**
2
+ * Remote Agent Planner
3
+ *
4
+ * Fetches the planning prompt from the cloud API, runs it through
5
+ * Claude CLI locally (using the customer's Claude Max subscription),
6
+ * and posts the raw output back for server-side validation.
7
+ *
8
+ * Logs are streamed to the cloud dashboard in real-time so the user
9
+ * sees the same planning progress as cloud mode.
10
+ */
11
+ import chalk from "chalk";
12
+ import { spawn } from "child_process";
13
+ import { findClaudePath } from "./config.js";
14
+ import { api } from "./api.js";
15
+ /** Timestamp prefix */
16
+ function ts() {
17
+ return chalk.dim(new Date().toLocaleTimeString());
18
+ }
19
+ /**
20
+ * Post a log message to the cloud dashboard for real-time visibility.
21
+ */
22
+ async function postLog(taskId, message, type = "system", severity = "info") {
23
+ try {
24
+ await api.post("/api/control-center/logs", {
25
+ taskId,
26
+ type,
27
+ message,
28
+ severity,
29
+ });
30
+ }
31
+ catch {
32
+ // Fire and forget — don't block planning on log failures
33
+ }
34
+ }
35
+ /**
36
+ * Post planning progress to the cloud API for SSE relay to the dashboard.
37
+ * This drives the animated progress bar (PlanningTerminalBar) in the frontend.
38
+ */
39
+ async function postProgress(taskId, phase, elapsedSeconds, detail, charsGenerated, toolCallCount) {
40
+ try {
41
+ await api.post("/api/agent/planning-progress", {
42
+ taskId,
43
+ phase,
44
+ elapsedSeconds,
45
+ detail,
46
+ charsGenerated,
47
+ toolCallCount,
48
+ });
49
+ }
50
+ catch {
51
+ // Fire and forget
52
+ }
53
+ }
54
+ function phaseLabel(phase, elapsed) {
55
+ switch (phase) {
56
+ case "initializing": return "Starting planning agent...";
57
+ case "reading_repo": return "Reading repository structure...";
58
+ case "analyzing": return "Analyzing requirements...";
59
+ case "generating_plan": return `Generating execution plan... (${elapsed}s)`;
60
+ case "validating": return "Validating plan...";
61
+ case "complete": return "Planning complete";
62
+ }
63
+ }
64
+ /**
65
+ * Run Claude CLI with stream-json output, posting real-time phase milestones
66
+ * to the cloud dashboard — identical terminal experience to cloud planning.
67
+ */
68
+ function runClaudeCli(claudePath, model, prompt, env, taskId, startTime) {
69
+ const taskLabel = chalk.cyan(taskId.slice(0, 8));
70
+ return new Promise((resolve, reject) => {
71
+ const proc = spawn(claudePath, [
72
+ "--print",
73
+ "--verbose",
74
+ "--output-format", "stream-json",
75
+ "--model", model,
76
+ "--permission-mode", "bypassPermissions",
77
+ ], {
78
+ env,
79
+ stdio: ["pipe", "pipe", "pipe"],
80
+ });
81
+ proc.stdin.write(prompt);
82
+ proc.stdin.end();
83
+ let fullText = "";
84
+ let resultText = "";
85
+ let stderrOutput = "";
86
+ let charsReceived = 0;
87
+ let toolCallCount = 0;
88
+ // Phase detection state
89
+ let currentPhase = "initializing";
90
+ let firstTextSeen = false;
91
+ const milestoneSent = { started: true, reading: false, analyzing: false, generating: false };
92
+ // Post milestone when phase transitions (to dashboard terminal)
93
+ function transitionPhase(newPhase) {
94
+ if (newPhase === currentPhase)
95
+ return;
96
+ currentPhase = newPhase;
97
+ const elapsed = Math.round((Date.now() - startTime) / 1000);
98
+ const msg = phaseLabel(newPhase, elapsed);
99
+ postLog(taskId, msg);
100
+ console.log(`${ts()} ${taskLabel} ${chalk.dim(msg)}`);
101
+ }
102
+ // SSE progress updates every 2s — drives PlanningTerminalBar in dashboard
103
+ // (same cadence as local dev's progressInterval in planning-agent-local.ts)
104
+ const sseProgressInterval = setInterval(() => {
105
+ const elapsed = Math.round((Date.now() - startTime) / 1000);
106
+ postProgress(taskId, currentPhase, elapsed, phaseLabel(currentPhase, elapsed), charsReceived, toolCallCount);
107
+ }, 2_000);
108
+ // Phase transition logs + periodic DB logs (every 30s during generation)
109
+ let lastProgressLogAt = 0;
110
+ const progressInterval = setInterval(() => {
111
+ const elapsed = Math.round((Date.now() - startTime) / 1000);
112
+ // Time-based phase fallback (in case stream events are sparse)
113
+ if (currentPhase === "initializing" && elapsed >= 5) {
114
+ transitionPhase("reading_repo");
115
+ }
116
+ else if (currentPhase === "reading_repo" && elapsed >= 15 && !firstTextSeen) {
117
+ transitionPhase("analyzing");
118
+ }
119
+ // Periodic progress during generation
120
+ if (currentPhase === "generating_plan" && elapsed - lastProgressLogAt >= 30) {
121
+ lastProgressLogAt = elapsed;
122
+ const msg = `Generating execution plan... (${elapsed}s, ${charsReceived} chars, ${toolCallCount} tool calls)`;
123
+ postLog(taskId, msg);
124
+ console.log(`${ts()} ${taskLabel} ${chalk.dim(msg)}`);
125
+ }
126
+ }, 5_000);
127
+ // Parse streaming JSON lines from Claude CLI
128
+ let lineBuffer = "";
129
+ proc.stdout.on("data", (data) => {
130
+ lineBuffer += data.toString();
131
+ const lines = lineBuffer.split("\n");
132
+ lineBuffer = lines.pop() || "";
133
+ for (const line of lines) {
134
+ const trimmed = line.trim();
135
+ if (!trimmed)
136
+ continue;
137
+ try {
138
+ const event = JSON.parse(trimmed);
139
+ if (event.type === "content_block_delta" && event.delta?.text) {
140
+ fullText += event.delta.text;
141
+ charsReceived += event.delta.text.length;
142
+ // Phase: first text after tool calls → analyzing
143
+ if (!firstTextSeen) {
144
+ firstTextSeen = true;
145
+ if (toolCallCount > 0 && !milestoneSent.analyzing) {
146
+ transitionPhase("analyzing");
147
+ milestoneSent.analyzing = true;
148
+ }
149
+ }
150
+ // Phase: substantial text → generating_plan
151
+ if (charsReceived > 500 && !milestoneSent.generating) {
152
+ transitionPhase("generating_plan");
153
+ milestoneSent.generating = true;
154
+ lastProgressLogAt = Math.round((Date.now() - startTime) / 1000);
155
+ }
156
+ }
157
+ else if (event.type === "content_block_start" && event.content_block?.type === "tool_use") {
158
+ toolCallCount++;
159
+ if (!milestoneSent.reading) {
160
+ transitionPhase("reading_repo");
161
+ milestoneSent.reading = true;
162
+ }
163
+ }
164
+ else if (event.type === "assistant" && event.message?.content) {
165
+ const text = typeof event.message.content === "string" ? event.message.content : "";
166
+ if (text) {
167
+ fullText += text;
168
+ charsReceived += text.length;
169
+ }
170
+ }
171
+ else if (event.type === "result" && event.result) {
172
+ resultText = typeof event.result === "string" ? event.result : "";
173
+ }
174
+ }
175
+ catch {
176
+ // Not valid JSON — raw text, accumulate
177
+ fullText += trimmed + "\n";
178
+ charsReceived += trimmed.length;
179
+ }
180
+ }
181
+ });
182
+ proc.stderr.on("data", (chunk) => {
183
+ stderrOutput += chunk.toString();
184
+ });
185
+ const timeout = setTimeout(() => {
186
+ clearInterval(progressInterval);
187
+ clearInterval(sseProgressInterval);
188
+ proc.kill("SIGTERM");
189
+ reject(new Error("Claude CLI timed out after 10 minutes"));
190
+ }, 600_000);
191
+ proc.on("exit", (code) => {
192
+ clearTimeout(timeout);
193
+ clearInterval(progressInterval);
194
+ clearInterval(sseProgressInterval);
195
+ // Emit final "validating" phase to dashboard
196
+ const elapsedAtClose = Math.round((Date.now() - startTime) / 1000);
197
+ postProgress(taskId, "validating", elapsedAtClose, "Validating plan...", charsReceived, toolCallCount);
198
+ if (code !== 0) {
199
+ reject(new Error(`Claude CLI failed (exit ${code}): ${stderrOutput.substring(0, 300)}`));
200
+ }
201
+ else {
202
+ // Prefer the result event's text (authoritative), fall back to accumulated deltas
203
+ resolve(resultText || fullText);
204
+ }
205
+ });
206
+ proc.on("error", (err) => {
207
+ clearTimeout(timeout);
208
+ clearInterval(progressInterval);
209
+ clearInterval(sseProgressInterval);
210
+ reject(err);
211
+ });
212
+ });
213
+ }
214
+ /**
215
+ * Run planning for a task: fetch prompt, execute Claude CLI, post result.
216
+ */
217
+ export async function planTask(task, config) {
218
+ const taskLabel = chalk.cyan(task.id.slice(0, 8));
219
+ console.log(`${ts()} ${taskLabel} Fetching planning prompt...`);
220
+ await postLog(task.id, "Fetching planning prompt from cloud API...");
221
+ // 1. Fetch the assembled planning prompt from the cloud API
222
+ const promptResponse = await api.get("/api/agent/planning-prompt", {
223
+ params: { taskId: task.id },
224
+ });
225
+ const { prompt, model } = promptResponse.data;
226
+ const cliModel = model || "sonnet";
227
+ console.log(`${ts()} ${taskLabel} Running Claude CLI ${chalk.dim(`(model: ${chalk.yellow(cliModel)})`)}`);
228
+ await postLog(task.id, `Starting planning agent (model: ${cliModel})...`);
229
+ // 2. Run Claude CLI asynchronously with progress logging
230
+ const claudePath = process.env.CLAUDE_CLI_PATH || findClaudePath() || "claude";
231
+ const cleanEnv = { ...process.env };
232
+ delete cleanEnv.CLAUDE_CODE_OAUTH_TOKEN;
233
+ const startTime = Date.now();
234
+ let rawOutput;
235
+ try {
236
+ rawOutput = await runClaudeCli(claudePath, cliModel, prompt, cleanEnv, task.id, startTime);
237
+ }
238
+ catch (error) {
239
+ const elapsed = Math.round((Date.now() - startTime) / 1000);
240
+ const errMsg = error instanceof Error ? error.message : String(error);
241
+ console.error(`${ts()} ${taskLabel} ${chalk.red("✗")} Failed after ${elapsed}s: ${errMsg.substring(0, 100)}`);
242
+ await postLog(task.id, `Planning agent failed after ${elapsed}s: ${errMsg.substring(0, 200)}`, "error", "error");
243
+ return false;
244
+ }
245
+ const elapsed = Math.round((Date.now() - startTime) / 1000);
246
+ console.log(`${ts()} ${taskLabel} ${chalk.green("✓")} Claude CLI done ${chalk.dim(`(${elapsed}s, ${rawOutput.length} chars)`)}`);
247
+ await postLog(task.id, `Planning complete (${elapsed}s, ${rawOutput.length} chars). Validating plan...`);
248
+ // 3. Post raw output back to cloud API for validation
249
+ try {
250
+ const result = await api.post("/api/agent/plan-result", {
251
+ taskId: task.id,
252
+ rawOutput,
253
+ agentId: config.agentId,
254
+ });
255
+ const storyCount = result.data.storyCount;
256
+ console.log(`${ts()} ${taskLabel} ${chalk.green("✓")} Plan validated: ${chalk.bold(storyCount)} stories → ${chalk.green("queued")}`);
257
+ await postLog(task.id, `Plan validated: ${storyCount} stories. Task queued for execution.`);
258
+ await postProgress(task.id, "complete", elapsed, "Planning complete", 0, 0);
259
+ return true;
260
+ }
261
+ catch (error) {
262
+ const err = error;
263
+ const detail = err.response?.data?.detail || String(error);
264
+ console.error(`${ts()} ${taskLabel} ${chalk.red("✗")} Validation failed: ${detail.substring(0, 100)}`);
265
+ await postLog(task.id, `Plan validation failed: ${detail.substring(0, 200)}`, "error", "error");
266
+ return false;
267
+ }
268
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Remote Agent Poller
3
+ *
4
+ * Main poll loop: every N seconds, query the cloud API for tasks,
5
+ * dispatch to planner or spawner based on task status.
6
+ */
7
+ import type { AgentConfig } from "./config.js";
8
+ /**
9
+ * Start the poll loop.
10
+ */
11
+ export declare function startPolling(config: AgentConfig): void;
12
+ /**
13
+ * Start the heartbeat loop.
14
+ */
15
+ export declare function startHeartbeat(config: AgentConfig): void;
package/dist/poller.js ADDED
@@ -0,0 +1,188 @@
1
+ /**
2
+ * Remote Agent Poller
3
+ *
4
+ * Main poll loop: every N seconds, query the cloud API for tasks,
5
+ * dispatch to planner or spawner based on task status.
6
+ */
7
+ import chalk from "chalk";
8
+ import { api } from "./api.js";
9
+ import { planTask } from "./planner.js";
10
+ import { spawnWorker, getActiveCount, getActiveTaskIds, stopTask } from "./spawner.js";
11
+ // Track tasks currently being planned (to avoid double-dispatching)
12
+ const planningInProgress = new Set();
13
+ // Cached org config
14
+ let orgConfig = null;
15
+ /** Timestamp prefix for log lines */
16
+ function ts() {
17
+ return chalk.dim(new Date().toLocaleTimeString());
18
+ }
19
+ /**
20
+ * Fetch org config from the cloud API (cached).
21
+ */
22
+ async function getOrgConfig() {
23
+ if (orgConfig)
24
+ return orgConfig;
25
+ try {
26
+ const response = await api.get("/api/agent/config");
27
+ orgConfig = response.data;
28
+ return orgConfig;
29
+ }
30
+ catch (error) {
31
+ console.error(`${ts()} ${chalk.red("✗")} Failed to fetch org config`);
32
+ return {};
33
+ }
34
+ }
35
+ /**
36
+ * Run a single poll iteration.
37
+ */
38
+ async function pollOnce(config) {
39
+ try {
40
+ const response = await api.get("/api/agent/poll", {
41
+ params: { agentId: config.agentId },
42
+ });
43
+ const tasks = response.data.tasks;
44
+ if (tasks.length === 0)
45
+ return;
46
+ for (const task of tasks) {
47
+ if (task.status === "planning" && !planningInProgress.has(task.id)) {
48
+ // Claim and plan
49
+ await handlePlanningTask(task, config);
50
+ }
51
+ else if (task.status === "queued") {
52
+ // Claim and spawn
53
+ await handleQueuedTask(task, config);
54
+ }
55
+ }
56
+ }
57
+ catch (error) {
58
+ const err = error;
59
+ if (err.response?.status === 401) {
60
+ console.error(`${ts()} ${chalk.red("✗")} Authentication failed. Check your API key.`);
61
+ }
62
+ else {
63
+ console.error(`${ts()} ${chalk.red("✗")} Poll error: ${err.message || String(error)}`);
64
+ }
65
+ }
66
+ }
67
+ /**
68
+ * Handle a task in "planning" status.
69
+ */
70
+ async function handlePlanningTask(task, config) {
71
+ // Claim the task
72
+ try {
73
+ const claimResponse = await api.post("/api/agent/claim", {
74
+ taskId: task.id,
75
+ agentId: config.agentId,
76
+ });
77
+ if (!claimResponse.data.claimed) {
78
+ return; // Another agent or cloud orchestrator claimed it
79
+ }
80
+ }
81
+ catch {
82
+ return;
83
+ }
84
+ const taskLabel = chalk.cyan(task.id.slice(0, 8));
85
+ console.log();
86
+ console.log(`${ts()} ${chalk.magenta("◆ PLANNING")} ${taskLabel} ${task.summary.substring(0, 60)}`);
87
+ planningInProgress.add(task.id);
88
+ // Run planning asynchronously (don't block the poll loop)
89
+ planTask(task, config)
90
+ .then((success) => {
91
+ if (success) {
92
+ console.log(`${ts()} ${chalk.green("✓")} Planning complete for ${taskLabel}`);
93
+ }
94
+ else {
95
+ console.log(`${ts()} ${chalk.red("✗")} Planning failed for ${taskLabel}`);
96
+ }
97
+ })
98
+ .catch((err) => console.error(`${ts()} ${chalk.red("✗")} Planning error for ${taskLabel}:`, err.message || err))
99
+ .finally(() => planningInProgress.delete(task.id));
100
+ }
101
+ /**
102
+ * Handle a task in "queued" status.
103
+ */
104
+ async function handleQueuedTask(task, config) {
105
+ // Check concurrency limit
106
+ const activeCount = getActiveCount();
107
+ if (activeCount >= config.maxWorkers) {
108
+ return; // At capacity
109
+ }
110
+ // Claim the task
111
+ let claimData;
112
+ try {
113
+ const claimResponse = await api.post("/api/agent/claim", {
114
+ taskId: task.id,
115
+ agentId: config.agentId,
116
+ });
117
+ claimData = claimResponse.data;
118
+ if (!claimData.claimed) {
119
+ return;
120
+ }
121
+ }
122
+ catch {
123
+ return;
124
+ }
125
+ // Report started
126
+ try {
127
+ await api.post("/api/agent/started", {
128
+ taskId: task.id,
129
+ agentId: config.agentId,
130
+ });
131
+ }
132
+ catch (err) {
133
+ const taskLabel = chalk.cyan(task.id.slice(0, 8));
134
+ console.error(`${ts()} ${chalk.red("✗")} Failed to report started for ${taskLabel}`);
135
+ }
136
+ const taskLabel = chalk.cyan(task.id.slice(0, 8));
137
+ console.log();
138
+ console.log(`${ts()} ${chalk.blue("▶ EXECUTING")} ${taskLabel} ${task.summary.substring(0, 60)}`);
139
+ const oc = await getOrgConfig();
140
+ const spawnableTask = claimData.task || {
141
+ id: task.id,
142
+ summary: task.summary,
143
+ description: task.description,
144
+ jiraIssueKey: task.jiraIssueKey,
145
+ workerModel: task.workerModel,
146
+ githubRepo: task.githubRepo,
147
+ scmProvider: task.scmProvider,
148
+ skipManagerReview: task.skipManagerReview,
149
+ executionPlanV2: task.executionPlanV2,
150
+ jiraFields: task.jiraFields || {},
151
+ };
152
+ // Spawn asynchronously (don't block the poll loop)
153
+ spawnWorker(spawnableTask, config, oc).catch((err) => console.error(`${ts()} ${chalk.red("✗")} Spawn failed for ${taskLabel}:`, err.message || err));
154
+ }
155
+ /**
156
+ * Start the poll loop.
157
+ */
158
+ export function startPolling(config) {
159
+ console.log(` ${chalk.dim("Polling every")} ${config.pollIntervalMs / 1000}s ${chalk.dim("· waiting for tasks...")}`);
160
+ // Initial poll
161
+ pollOnce(config);
162
+ // Recurring poll
163
+ setInterval(() => pollOnce(config), config.pollIntervalMs);
164
+ }
165
+ /**
166
+ * Start the heartbeat loop.
167
+ */
168
+ export function startHeartbeat(config) {
169
+ setInterval(async () => {
170
+ const activeTaskIds = getActiveTaskIds();
171
+ try {
172
+ const response = await api.post("/api/agent/heartbeat", {
173
+ agentId: config.agentId,
174
+ activeTasks: activeTaskIds,
175
+ });
176
+ // Stop containers for tasks cancelled via the cloud dashboard
177
+ const cancelledTasks = response.data?.cancelledTasks;
178
+ if (cancelledTasks && cancelledTasks.length > 0) {
179
+ for (const taskId of cancelledTasks) {
180
+ stopTask(taskId);
181
+ }
182
+ }
183
+ }
184
+ catch {
185
+ // Heartbeat failures are non-critical
186
+ }
187
+ }, config.heartbeatIntervalMs);
188
+ }
@@ -0,0 +1,42 @@
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 type { AgentConfig } from "./config.js";
11
+ export interface SpawnableTask {
12
+ id: string;
13
+ summary: string;
14
+ description: string | null;
15
+ jiraIssueKey: string | null;
16
+ workerModel: string;
17
+ githubRepo: string;
18
+ scmProvider: string;
19
+ skipManagerReview?: boolean;
20
+ executionPlanV2: unknown;
21
+ jiraFields: Record<string, unknown>;
22
+ }
23
+ /**
24
+ * Spawn a Docker worker container for a task.
25
+ */
26
+ export declare function spawnWorker(task: SpawnableTask, config: AgentConfig, orgConfig: Record<string, unknown>): Promise<void>;
27
+ /**
28
+ * Get count of actively running containers.
29
+ */
30
+ export declare function getActiveCount(): number;
31
+ /**
32
+ * Get IDs of all active tasks.
33
+ */
34
+ export declare function getActiveTaskIds(): string[];
35
+ /**
36
+ * Stop a specific task's container by task ID.
37
+ */
38
+ export declare function stopTask(taskId: string): void;
39
+ /**
40
+ * Stop all running containers.
41
+ */
42
+ export declare function stopAll(): Promise<void>;