@gethmy/agent 1.0.0 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/README.md +5 -5
  2. package/dist/board-helpers.d.ts +23 -0
  3. package/dist/board-helpers.js +131 -0
  4. package/dist/cli.d.ts +2 -0
  5. package/dist/cli.js +2 -11761
  6. package/dist/completion.d.ts +7 -0
  7. package/dist/completion.js +132 -0
  8. package/dist/config.d.ts +23 -0
  9. package/dist/config.js +91 -0
  10. package/dist/git-pr.d.ts +25 -0
  11. package/dist/git-pr.js +305 -0
  12. package/dist/index.d.ts +1 -0
  13. package/dist/index.js +165 -11730
  14. package/dist/log.d.ts +10 -0
  15. package/dist/log.js +35 -0
  16. package/dist/merge-monitor.d.ts +23 -0
  17. package/dist/merge-monitor.js +155 -0
  18. package/dist/pm.d.ts +14 -0
  19. package/dist/pm.js +63 -0
  20. package/dist/pool.d.ts +36 -0
  21. package/dist/pool.js +134 -0
  22. package/dist/progress-tracker.d.ts +39 -0
  23. package/dist/progress-tracker.js +189 -0
  24. package/dist/prompt.d.ts +5 -0
  25. package/dist/prompt.js +40 -0
  26. package/dist/queue.d.ts +37 -0
  27. package/dist/queue.js +96 -0
  28. package/dist/reconcile.d.ts +21 -0
  29. package/dist/reconcile.js +107 -0
  30. package/dist/review-completion.d.ts +31 -0
  31. package/dist/review-completion.js +247 -0
  32. package/dist/review-knowledge.d.ts +14 -0
  33. package/dist/review-knowledge.js +89 -0
  34. package/dist/review-prompt.d.ts +12 -0
  35. package/dist/review-prompt.js +100 -0
  36. package/dist/review-worker.d.ts +35 -0
  37. package/dist/review-worker.js +302 -0
  38. package/dist/review-worktree.d.ts +12 -0
  39. package/dist/review-worktree.js +83 -0
  40. package/dist/stream-parser.d.ts +22 -0
  41. package/dist/stream-parser.js +81 -0
  42. package/dist/types.d.ts +74 -0
  43. package/dist/types.js +53 -0
  44. package/dist/verification.d.ts +16 -0
  45. package/dist/verification.js +251 -0
  46. package/dist/watcher.d.ts +21 -0
  47. package/dist/watcher.js +62 -0
  48. package/dist/worker.d.ts +34 -0
  49. package/dist/worker.js +268 -0
  50. package/dist/worktree.d.ts +13 -0
  51. package/dist/worktree.js +115 -0
  52. package/package.json +6 -5
@@ -0,0 +1,251 @@
1
+ import { execFileSync, spawn } from "node:child_process";
2
+ import { log } from "./log.js";
3
+ import { spawnRunArgs } from "./pm.js";
4
+ const TAG = "verification";
5
+ // ============ PUBLIC API ============
6
+ export async function runVerification(worktreePath, config, workerId) {
7
+ const result = {
8
+ passed: true,
9
+ buildErrors: [],
10
+ lintWarnings: [],
11
+ reviewFindings: [],
12
+ };
13
+ if (config.verification.build) {
14
+ log.info(TAG, `[worker:${workerId}] Running build...`);
15
+ result.buildErrors = runBuild(worktreePath, config.verification.timeout);
16
+ if (result.buildErrors.length > 0) {
17
+ log.warn(TAG, `[worker:${workerId}] Build failed with ${result.buildErrors.length} error(s)`);
18
+ result.passed = false;
19
+ }
20
+ else {
21
+ log.info(TAG, `[worker:${workerId}] Build passed`);
22
+ }
23
+ }
24
+ if (config.verification.lint) {
25
+ log.info(TAG, `[worker:${workerId}] Running lint...`);
26
+ result.lintWarnings = runLint(worktreePath, config.verification.timeout);
27
+ if (result.lintWarnings.length > 0) {
28
+ log.warn(TAG, `[worker:${workerId}] Lint found ${result.lintWarnings.length} issue(s)`);
29
+ // Lint warnings alone don't block — only build errors block
30
+ }
31
+ else {
32
+ log.info(TAG, `[worker:${workerId}] Lint passed`);
33
+ }
34
+ }
35
+ if (config.verification.deepReview) {
36
+ log.info(TAG, `[worker:${workerId}] Running deep review...`);
37
+ result.reviewFindings = await runDeepReview(worktreePath, config, workerId);
38
+ if (result.reviewFindings.length > 0) {
39
+ log.warn(TAG, `[worker:${workerId}] Deep review found ${result.reviewFindings.length} finding(s)`);
40
+ }
41
+ else {
42
+ log.info(TAG, `[worker:${workerId}] Deep review passed`);
43
+ }
44
+ }
45
+ return result;
46
+ }
47
+ export function runBuild(worktreePath, timeout) {
48
+ try {
49
+ const [cmd, args] = spawnRunArgs("build");
50
+ execFileSync(cmd, args, {
51
+ cwd: worktreePath,
52
+ timeout,
53
+ stdio: "pipe",
54
+ });
55
+ return [];
56
+ }
57
+ catch (err) {
58
+ return parseErrorOutput(err);
59
+ }
60
+ }
61
+ export function runLint(worktreePath, timeout) {
62
+ try {
63
+ const [cmd, args] = spawnRunArgs("lint");
64
+ execFileSync(cmd, args, {
65
+ cwd: worktreePath,
66
+ timeout,
67
+ stdio: "pipe",
68
+ });
69
+ return [];
70
+ }
71
+ catch (err) {
72
+ return parseErrorOutput(err);
73
+ }
74
+ }
75
+ export async function runDeepReview(worktreePath, config, workerId) {
76
+ const port = config.verification.devServerBasePort + workerId;
77
+ let devServer = null;
78
+ try {
79
+ // Start dev server in background
80
+ const [cmd, args] = spawnRunArgs("dev", "--port", String(port));
81
+ devServer = spawn(cmd, args, {
82
+ cwd: worktreePath,
83
+ stdio: ["ignore", "pipe", "pipe"],
84
+ });
85
+ // Wait for dev server to be ready
86
+ await waitForDevServer(devServer, 30_000);
87
+ // Get diff for review context
88
+ let diff = "";
89
+ try {
90
+ diff = execFileSync("git", ["diff", `origin/${config.worktree.baseBranch}..HEAD`], { cwd: worktreePath, encoding: "utf-8", timeout: 30_000 });
91
+ }
92
+ catch {
93
+ diff = "(unable to retrieve diff)";
94
+ }
95
+ // Spawn Claude for review
96
+ const reviewPrompt = [
97
+ "You are reviewing code changes for quality and correctness.",
98
+ `A dev server is running at http://localhost:${port}.`,
99
+ "Review the following diff and report any issues found.",
100
+ "Output ONLY a numbered list of findings, one per line.",
101
+ "If no issues, output: No issues found.",
102
+ "",
103
+ "```diff",
104
+ diff.slice(0, 50_000),
105
+ "```",
106
+ ].join("\n");
107
+ const output = execFileSync("claude", ["--print", "--model", "sonnet", "--max-turns", "10", "--", reviewPrompt], {
108
+ cwd: worktreePath,
109
+ encoding: "utf-8",
110
+ timeout: config.verification.timeout,
111
+ stdio: "pipe",
112
+ });
113
+ return parseReviewFindings(output);
114
+ }
115
+ catch (err) {
116
+ log.error(TAG, `Deep review failed: ${err instanceof Error ? err.message : err}`);
117
+ return [];
118
+ }
119
+ finally {
120
+ if (devServer && !devServer.killed) {
121
+ devServer.kill("SIGTERM");
122
+ }
123
+ }
124
+ }
125
+ export function attemptAutoFix(worktreePath, config, errors) {
126
+ const errorSummary = errors.slice(0, 20).join("\n");
127
+ const fixPrompt = [
128
+ "The following build/lint errors were found after implementing a feature.",
129
+ "Fix the source files to resolve these errors.",
130
+ "Do NOT commit build artifacts or modify files in dist/.",
131
+ "Fix source files only.",
132
+ "",
133
+ "Errors:",
134
+ "```",
135
+ errorSummary,
136
+ "```",
137
+ ].join("\n");
138
+ const args = [
139
+ "--print",
140
+ "--model",
141
+ config.claude.model,
142
+ "--max-turns",
143
+ "50",
144
+ "--allowedTools",
145
+ "Bash,Read,Write,Edit,Glob,Grep",
146
+ "--",
147
+ fixPrompt,
148
+ ];
149
+ log.info(TAG, "Spawning Claude for auto-fix...");
150
+ execFileSync("claude", args, {
151
+ cwd: worktreePath,
152
+ timeout: config.verification.timeout,
153
+ stdio: "pipe",
154
+ });
155
+ }
156
+ export async function reportFindings(client, cardId, result) {
157
+ const items = [];
158
+ for (const err of result.buildErrors) {
159
+ items.push(`Build: ${err}`);
160
+ }
161
+ for (const err of result.lintWarnings) {
162
+ items.push(`Lint: ${err}`);
163
+ }
164
+ for (const finding of result.reviewFindings) {
165
+ items.push(`Review: ${finding}`);
166
+ }
167
+ const maxSubtasks = 10;
168
+ const overflow = items.length - maxSubtasks;
169
+ const toCreate = items.slice(0, maxSubtasks);
170
+ await Promise.all(toCreate.map(async (item) => {
171
+ const title = item.length > 120 ? `${item.slice(0, 117)}...` : item;
172
+ try {
173
+ await client.createSubtask(cardId, title);
174
+ }
175
+ catch (err) {
176
+ log.error(TAG, `Failed to create subtask: ${err instanceof Error ? err.message : err}`);
177
+ }
178
+ }));
179
+ if (overflow > 0) {
180
+ try {
181
+ await client.createSubtask(cardId, `...and ${overflow} more issues`);
182
+ }
183
+ catch {
184
+ // best-effort
185
+ }
186
+ }
187
+ log.info(TAG, `Reported ${Math.min(items.length, maxSubtasks)} finding(s) as subtasks on card ${cardId}`);
188
+ }
189
+ // ============ HELPERS ============
190
+ function parseErrorOutput(err) {
191
+ const stderr = err?.stderr?.toString() ?? "";
192
+ const stdout = err?.stdout?.toString() ?? "";
193
+ const combined = `${stderr}\n${stdout}`;
194
+ const lines = combined
195
+ .split("\n")
196
+ .map((l) => l.trim())
197
+ .filter((l) => l.length > 0 &&
198
+ (l.includes("error") ||
199
+ l.includes("Error") ||
200
+ l.includes("✖") ||
201
+ l.includes("×")))
202
+ .map((l) => (l.length > 200 ? `${l.slice(0, 197)}...` : l));
203
+ // If we couldn't parse specific error lines, return the whole output truncated
204
+ if (lines.length === 0 && combined.trim().length > 0) {
205
+ return [combined.trim().slice(0, 200)];
206
+ }
207
+ return lines;
208
+ }
209
+ function parseReviewFindings(output) {
210
+ if (output.toLowerCase().includes("no issues found")) {
211
+ return [];
212
+ }
213
+ return output
214
+ .split("\n")
215
+ .map((l) => l.trim())
216
+ .filter((l) => /^\d+[.)]/.test(l))
217
+ .map((l) => l.replace(/^\d+[.)]\s*/, ""))
218
+ .filter((l) => l.length > 0);
219
+ }
220
+ export function waitForDevServer(proc, timeout) {
221
+ return new Promise((resolve, reject) => {
222
+ const cleanup = () => {
223
+ proc.stdout?.off("data", onData);
224
+ proc.stderr?.off("data", onData);
225
+ proc.off("error", onError);
226
+ };
227
+ const timer = setTimeout(() => {
228
+ log.warn(TAG, "Dev server readiness not detected, proceeding anyway");
229
+ cleanup();
230
+ resolve();
231
+ }, timeout);
232
+ const onData = (data) => {
233
+ const text = data.toString();
234
+ if (text.includes("ready") ||
235
+ text.includes("localhost") ||
236
+ text.includes("Local:")) {
237
+ clearTimeout(timer);
238
+ cleanup();
239
+ resolve();
240
+ }
241
+ };
242
+ const onError = (err) => {
243
+ clearTimeout(timer);
244
+ cleanup();
245
+ reject(err);
246
+ };
247
+ proc.stdout?.on("data", onData);
248
+ proc.stderr?.on("data", onData);
249
+ proc.on("error", onError);
250
+ });
251
+ }
@@ -0,0 +1,21 @@
1
+ import type { RealtimeCredentials } from "./types.js";
2
+ export interface CardBroadcastEvent {
3
+ event: "card_update" | "card_created";
4
+ payload: Record<string, unknown>;
5
+ }
6
+ export type CardBroadcastHandler = (event: CardBroadcastEvent) => void;
7
+ /**
8
+ * Subscribes to Supabase broadcast events on the board channel.
9
+ * The harmony-api broadcasts card_update and card_created events
10
+ * after every mutation — this works with the anon key (no RLS needed).
11
+ */
12
+ export declare class Watcher {
13
+ private credentials;
14
+ private projectId;
15
+ private onCardBroadcast;
16
+ private channel;
17
+ private supabase;
18
+ constructor(credentials: RealtimeCredentials, projectId: string, onCardBroadcast: CardBroadcastHandler);
19
+ start(): Promise<void>;
20
+ stop(): Promise<void>;
21
+ }
@@ -0,0 +1,62 @@
1
+ import { createClient } from "@supabase/supabase-js";
2
+ import { log } from "./log.js";
3
+ const TAG = "watcher";
4
+ /**
5
+ * Subscribes to Supabase broadcast events on the board channel.
6
+ * The harmony-api broadcasts card_update and card_created events
7
+ * after every mutation — this works with the anon key (no RLS needed).
8
+ */
9
+ export class Watcher {
10
+ credentials;
11
+ projectId;
12
+ onCardBroadcast;
13
+ channel = null;
14
+ supabase = null;
15
+ constructor(credentials, projectId, onCardBroadcast) {
16
+ this.credentials = credentials;
17
+ this.projectId = projectId;
18
+ this.onCardBroadcast = onCardBroadcast;
19
+ }
20
+ async start() {
21
+ log.info(TAG, "Connecting to Supabase realtime (broadcast)...");
22
+ this.supabase = createClient(this.credentials.supabaseUrl, this.credentials.supabaseAnonKey);
23
+ this.channel = this.supabase
24
+ .channel(`board-${this.projectId}`)
25
+ .on("broadcast", { event: "card_update" }, (msg) => {
26
+ log.debug(TAG, `Broadcast: card_update ${JSON.stringify(msg.payload)}`);
27
+ this.onCardBroadcast({
28
+ event: "card_update",
29
+ payload: msg.payload ?? {},
30
+ });
31
+ })
32
+ .on("broadcast", { event: "card_created" }, (msg) => {
33
+ log.debug(TAG, `Broadcast: card_created ${JSON.stringify(msg.payload)}`);
34
+ this.onCardBroadcast({
35
+ event: "card_created",
36
+ payload: msg.payload ?? {},
37
+ });
38
+ })
39
+ .subscribe((status) => {
40
+ if (status === "SUBSCRIBED") {
41
+ log.info(TAG, "Broadcast subscription active");
42
+ }
43
+ else if (status === "CHANNEL_ERROR") {
44
+ log.error(TAG, "Broadcast channel error — will rely on reconciliation");
45
+ }
46
+ else if (status === "TIMED_OUT") {
47
+ log.warn(TAG, "Broadcast subscription timed out — retrying...");
48
+ }
49
+ });
50
+ }
51
+ async stop() {
52
+ if (this.channel) {
53
+ await this.supabase?.removeChannel(this.channel);
54
+ this.channel = null;
55
+ }
56
+ if (this.supabase) {
57
+ await this.supabase.realtime.disconnect();
58
+ this.supabase = null;
59
+ }
60
+ log.info(TAG, "Broadcast subscription stopped");
61
+ }
62
+ }
@@ -0,0 +1,34 @@
1
+ import type { HarmonyApiClient } from "@gethmy/mcp/src/api-client.js";
2
+ import type { Card, Column, Label, Subtask } from "@harmony/shared";
3
+ import { type AgentConfig, type WorkerState } from "./types.js";
4
+ export declare class Worker {
5
+ private config;
6
+ private client;
7
+ private onDone;
8
+ id: number;
9
+ state: WorkerState;
10
+ cardId: string | null;
11
+ branchName: string | null;
12
+ worktreePath: string | null;
13
+ startedAt: number | null;
14
+ private process;
15
+ private timeoutTimer;
16
+ private progressTracker;
17
+ private aborted;
18
+ constructor(id: number, config: AgentConfig, client: HarmonyApiClient, _userEmail: string, onDone: (worker: Worker) => void);
19
+ get tag(): string;
20
+ get isIdle(): boolean;
21
+ get isActive(): boolean;
22
+ /**
23
+ * Start working on a card. Runs the full lifecycle:
24
+ * PREPARING → RUNNING → COMPLETING → IDLE
25
+ */
26
+ run(card: Card, column: Column, labels: Label[], subtasks: Subtask[]): Promise<void>;
27
+ /**
28
+ * Cancel the current work. Sends escalating signals to the Claude process.
29
+ */
30
+ cancel(): Promise<void>;
31
+ private spawnClaude;
32
+ private waitForExit;
33
+ private cleanup;
34
+ }
package/dist/worker.js ADDED
@@ -0,0 +1,268 @@
1
+ import { spawn } from "node:child_process";
2
+ import { moveCardAndAddLabel } from "./board-helpers.js";
3
+ import { runCompletion } from "./completion.js";
4
+ import { log } from "./log.js";
5
+ import { ProgressTracker } from "./progress-tracker.js";
6
+ import { buildPrompt } from "./prompt.js";
7
+ import { StreamParser } from "./stream-parser.js";
8
+ import { AGENT_NAME, agentIdentifier, } from "./types.js";
9
+ import { cleanupWorktree, createWorktree, makeBranchName } from "./worktree.js";
10
+ const TAG = "worker";
11
+ const CANCEL_SIGINT_TIMEOUT = 30_000;
12
+ const CANCEL_SIGTERM_TIMEOUT = 10_000;
13
+ export class Worker {
14
+ config;
15
+ client;
16
+ onDone;
17
+ id;
18
+ state = "idle";
19
+ cardId = null;
20
+ branchName = null;
21
+ worktreePath = null;
22
+ startedAt = null;
23
+ process = null;
24
+ timeoutTimer = null;
25
+ progressTracker = null;
26
+ aborted = false;
27
+ constructor(id, config, client, _userEmail, onDone) {
28
+ this.config = config;
29
+ this.client = client;
30
+ this.onDone = onDone;
31
+ this.id = id;
32
+ }
33
+ get tag() {
34
+ return `${TAG}:${this.id}`;
35
+ }
36
+ get isIdle() {
37
+ return this.state === "idle";
38
+ }
39
+ get isActive() {
40
+ return (this.state === "preparing" ||
41
+ this.state === "running" ||
42
+ this.state === "verifying" ||
43
+ this.state === "completing");
44
+ }
45
+ /**
46
+ * Start working on a card. Runs the full lifecycle:
47
+ * PREPARING → RUNNING → COMPLETING → IDLE
48
+ */
49
+ async run(card, column, labels, subtasks) {
50
+ this.aborted = false;
51
+ this.cardId = card.id;
52
+ this.startedAt = Date.now();
53
+ try {
54
+ // --- PREPARING ---
55
+ this.state = "preparing";
56
+ this.branchName = makeBranchName(card.short_id, card.title);
57
+ log.info(this.tag, `Preparing #${card.short_id} "${card.title}"`);
58
+ // Start agent session and make it visible on the board
59
+ await this.client.startAgentSession(card.id, {
60
+ agentIdentifier: agentIdentifier(this.id),
61
+ agentName: AGENT_NAME,
62
+ status: "working",
63
+ currentTask: "Setting up worktree",
64
+ progressPercent: 5,
65
+ });
66
+ // Move card to "In Progress" and add "agent" label so the board shows the progress ring
67
+ const moved = await moveCardAndAddLabel(this.client, card, "In Progress", "agent");
68
+ if (!moved) {
69
+ log.warn(this.tag, `Card #${card.short_id} was NOT moved to "In Progress" — check API logs`);
70
+ }
71
+ if (this.aborted)
72
+ return;
73
+ // Create worktree
74
+ this.worktreePath = createWorktree(this.config.worktree.basePath, this.config.worktree.baseBranch, this.branchName);
75
+ if (this.aborted)
76
+ return;
77
+ // --- RUNNING ---
78
+ this.state = "running";
79
+ const enriched = {
80
+ card,
81
+ column,
82
+ labels,
83
+ subtasks,
84
+ mode: "implement",
85
+ };
86
+ const prompt = buildPrompt(enriched, this.branchName, this.worktreePath);
87
+ await this.client.updateAgentProgress(card.id, {
88
+ agentIdentifier: agentIdentifier(this.id),
89
+ agentName: AGENT_NAME,
90
+ status: "working",
91
+ currentTask: "Running Claude CLI",
92
+ progressPercent: 10,
93
+ });
94
+ // Start timeout watchdog
95
+ this.timeoutTimer = setTimeout(() => {
96
+ log.warn(this.tag, `Timeout reached (${this.config.maxTimeout}ms), cancelling`);
97
+ this.cancel();
98
+ }, this.config.maxTimeout);
99
+ // Spawn Claude CLI
100
+ await this.spawnClaude(prompt, card, subtasks);
101
+ if (this.aborted)
102
+ return;
103
+ // --- VERIFYING + COMPLETING ---
104
+ this.state = "verifying";
105
+ log.info(this.tag, `Claude finished for #${card.short_id}, running verification & completion`);
106
+ await this.client.updateAgentProgress(card.id, {
107
+ agentIdentifier: agentIdentifier(this.id),
108
+ agentName: AGENT_NAME,
109
+ status: "working",
110
+ currentTask: "Verifying implementation",
111
+ progressPercent: 75,
112
+ });
113
+ this.state = "completing";
114
+ await runCompletion(this.client, card, this.branchName, this.worktreePath, this.config, this.id);
115
+ }
116
+ catch (err) {
117
+ this.state = "error";
118
+ const msg = err instanceof Error ? err.message : String(err);
119
+ log.error(this.tag, `Error on #${card.short_id}: ${msg}`);
120
+ // End session as paused on error
121
+ try {
122
+ await this.client.endAgentSession(card.id, {
123
+ status: "paused",
124
+ });
125
+ }
126
+ catch {
127
+ // best-effort
128
+ }
129
+ }
130
+ finally {
131
+ this.cleanup();
132
+ this.state = "idle";
133
+ this.onDone(this);
134
+ }
135
+ }
136
+ /**
137
+ * Cancel the current work. Sends escalating signals to the Claude process.
138
+ */
139
+ async cancel() {
140
+ if (!this.isActive)
141
+ return;
142
+ this.aborted = true;
143
+ this.state = "cancelling";
144
+ log.info(this.tag, `Cancelling work on ${this.cardId}`);
145
+ if (this.process && !this.process.killed) {
146
+ // Step 1: SIGINT (let Claude save state gracefully)
147
+ this.process.kill("SIGINT");
148
+ log.debug(this.tag, "Sent SIGINT");
149
+ const sigintDead = await this.waitForExit(CANCEL_SIGINT_TIMEOUT);
150
+ if (sigintDead)
151
+ return;
152
+ // Step 2: SIGTERM
153
+ this.process.kill("SIGTERM");
154
+ log.debug(this.tag, "Sent SIGTERM");
155
+ const sigtermDead = await this.waitForExit(CANCEL_SIGTERM_TIMEOUT);
156
+ if (sigtermDead)
157
+ return;
158
+ // Step 3: SIGKILL
159
+ this.process.kill("SIGKILL");
160
+ log.warn(this.tag, "Sent SIGKILL");
161
+ }
162
+ // End agent session as paused
163
+ if (this.cardId) {
164
+ try {
165
+ await this.client.endAgentSession(this.cardId, { status: "paused" });
166
+ }
167
+ catch {
168
+ // best-effort
169
+ }
170
+ }
171
+ }
172
+ async spawnClaude(prompt, card, subtasks) {
173
+ return new Promise((resolve, reject) => {
174
+ const args = [
175
+ "-p",
176
+ "--verbose", // required for stream-json to emit all event types
177
+ "--output-format",
178
+ "stream-json",
179
+ "--model",
180
+ this.config.claude.model,
181
+ "--max-turns",
182
+ String(this.config.claude.maxTurns),
183
+ "--allowedTools",
184
+ "Bash,Read,Write,Edit,Glob,Grep,Agent,mcp__harmony__*",
185
+ ...this.config.claude.additionalArgs,
186
+ "--",
187
+ prompt,
188
+ ];
189
+ log.info(this.tag, `Spawning: claude ${args.slice(0, 4).join(" ")} ...`);
190
+ this.process = spawn("claude", args, {
191
+ cwd: this.worktreePath,
192
+ stdio: ["ignore", "pipe", "pipe"],
193
+ });
194
+ // Stream parser for structured NDJSON events
195
+ const parser = new StreamParser();
196
+ // Progress tracker for phase-based updates
197
+ this.progressTracker = new ProgressTracker(this.client, card.id, this.id, subtasks);
198
+ this.progressTracker.attach(parser);
199
+ // Pipe stdout through parser
200
+ if (this.process.stdout) {
201
+ parser.pipe(this.process.stdout);
202
+ }
203
+ parser.on("error", (msg) => {
204
+ log.debug(this.tag, `Stream parse error (non-fatal): ${msg}`);
205
+ });
206
+ let stderr = "";
207
+ this.process.stderr?.on("data", (data) => {
208
+ stderr += data.toString();
209
+ });
210
+ this.process.on("error", (err) => {
211
+ reject(new Error(`Failed to spawn claude: ${err.message}`));
212
+ });
213
+ this.process.on("close", (code) => {
214
+ this.process = null;
215
+ this.progressTracker?.stop();
216
+ this.progressTracker = null;
217
+ if (this.aborted) {
218
+ resolve(); // Cancellation is not an error
219
+ }
220
+ else if (code === 0) {
221
+ resolve();
222
+ }
223
+ else {
224
+ reject(new Error(`claude exited with code ${code}${stderr ? `: ${stderr.slice(0, 500)}` : ""}`));
225
+ }
226
+ });
227
+ });
228
+ }
229
+ waitForExit(timeout) {
230
+ return new Promise((resolve) => {
231
+ if (!this.process || this.process.killed) {
232
+ resolve(true);
233
+ return;
234
+ }
235
+ const timer = setTimeout(() => {
236
+ resolve(false);
237
+ }, timeout);
238
+ this.process.once("close", () => {
239
+ clearTimeout(timer);
240
+ resolve(true);
241
+ });
242
+ });
243
+ }
244
+ cleanup() {
245
+ if (this.progressTracker) {
246
+ this.progressTracker.stop();
247
+ this.progressTracker = null;
248
+ }
249
+ if (this.timeoutTimer) {
250
+ clearTimeout(this.timeoutTimer);
251
+ this.timeoutTimer = null;
252
+ }
253
+ // Clean up worktree + branch on error
254
+ if (this.worktreePath && this.state === "error") {
255
+ try {
256
+ cleanupWorktree(this.worktreePath, this.branchName ?? undefined);
257
+ }
258
+ catch {
259
+ log.warn(this.tag, "Failed to cleanup worktree");
260
+ }
261
+ }
262
+ this.process = null;
263
+ this.cardId = null;
264
+ this.branchName = null;
265
+ this.worktreePath = null;
266
+ this.startedAt = null;
267
+ }
268
+ }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Create a git worktree for the agent to work in.
3
+ * Returns the absolute path to the new worktree.
4
+ */
5
+ export declare function createWorktree(basePath: string, baseBranch: string, branchName: string): string;
6
+ /**
7
+ * Remove a git worktree and its branch.
8
+ */
9
+ export declare function cleanupWorktree(worktreePath: string, branchName?: string): void;
10
+ /**
11
+ * Generate a branch name from a card's short ID and title.
12
+ */
13
+ export declare function makeBranchName(shortId: number, title: string): string;