@gethmy/agent 1.9.1 → 1.10.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.
Files changed (3) hide show
  1. package/dist/cli.js +747 -109
  2. package/dist/index.js +742 -108
  3. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -220,12 +220,170 @@ var init_board_helpers = __esm(() => {
220
220
  init_log();
221
221
  });
222
222
 
223
+ // src/plan-phase.ts
224
+ function scoreComplexity(enriched) {
225
+ const { card, labels, subtasks } = enriched;
226
+ let score = 0;
227
+ const desc = (card.description ?? "").trim();
228
+ if (desc.length > 600)
229
+ score += 3;
230
+ else if (desc.length > 200)
231
+ score += 2;
232
+ else if (desc.length > 0)
233
+ score += 1;
234
+ score += Math.min(subtasks.length, 4);
235
+ const names = labels.map((l) => l.name.toLowerCase());
236
+ if (names.some((n) => /feature|epic|refactor|architecture|migration/.test(n))) {
237
+ score += 2;
238
+ }
239
+ if (names.some((n) => /typo|chore|trivial|docs/.test(n))) {
240
+ score -= 2;
241
+ }
242
+ return Math.max(0, score);
243
+ }
244
+ function shouldPlan(enriched, config) {
245
+ if (!config.enabled)
246
+ return false;
247
+ const { card } = enriched;
248
+ const hasPlan = !!card.plan_id;
249
+ const needsRefresh = card.needs_plan_refresh === true;
250
+ if (hasPlan && !needsRefresh)
251
+ return false;
252
+ return scoreComplexity(enriched) >= config.minComplexityScore;
253
+ }
254
+ function buildPlanPrompt(enriched, worktreePath) {
255
+ const { card, column, labels, subtasks } = enriched;
256
+ const labelStr = labels.length > 0 ? labels.map((l) => l.name).join(", ") : "none";
257
+ const subtaskStr = subtasks.length > 0 ? subtasks.map((s) => `- ${s.title}`).join(`
258
+ `) : "No subtasks defined.";
259
+ const description = card.description?.trim() || "No description provided.";
260
+ return `You are a senior engineer producing an IMPLEMENTATION PLAN for a task on the Harmony board. You are in PLAN MODE: explore the codebase to ground the plan, but do NOT write, edit, or commit any code in this pass.
261
+
262
+ ## Card: #${card.short_id} - ${card.title}
263
+ **Labels**: ${labelStr}
264
+ **Column**: ${column.name}
265
+ **Priority**: ${card.priority}
266
+
267
+ ## Description
268
+ ${description}
269
+
270
+ ## Subtasks
271
+ ${subtaskStr}
272
+
273
+ ## Your job
274
+ 1. Read the parts of the codebase relevant to this task (use Read/Grep/Glob; do NOT edit).
275
+ 2. Decide the smallest correct approach. Note the exact files you expect to touch.
276
+ 3. Call out risks, unknowns, and anything that needs a human decision.
277
+ 4. Break the work into ordered, independently-verifiable tasks.
278
+
279
+ You are exploring the worktree at \`${worktreePath}\`. Read-only this pass — no Write/Edit/Bash-that-mutates, no commits.
280
+
281
+ ## Output contract
282
+ End your final message with EXACTLY ONE fenced block tagged \`plan\`, in this structure:
283
+
284
+ \`\`\`plan
285
+ # <one-line plan title>
286
+
287
+ ## Approach
288
+ <2-4 sentences: the chosen approach and why>
289
+
290
+ ## Files
291
+ - <path> — <what changes here>
292
+
293
+ ## Steps
294
+ 1. <ordered step>
295
+ 2. <ordered step>
296
+
297
+ ## Tasks
298
+ - [ ] <discrete, verifiable task>
299
+ - [ ] <discrete, verifiable task>
300
+
301
+ ## Risks
302
+ - <risk / unknown / decision needed, or "none">
303
+ \`\`\`
304
+
305
+ The \`## Tasks\` checklist is parsed into trackable tasks, so keep each line a single concrete action.`;
306
+ }
307
+ function extractPlanArtifact(assistantText, fallbackTitle) {
308
+ const text = assistantText ?? "";
309
+ const fenced = text.match(PLAN_FENCE);
310
+ const markdown = (fenced ? fenced[1] : text).trim();
311
+ const titleMatch = markdown.match(H1);
312
+ const title = (titleMatch?.[1] ?? fallbackTitle).trim() || fallbackTitle;
313
+ return {
314
+ title,
315
+ markdown,
316
+ tasks: parseTasksSection(markdown)
317
+ };
318
+ }
319
+ function parseTasksSection(markdown) {
320
+ const lines = markdown.split(`
321
+ `);
322
+ const tasks = [];
323
+ let inTasks = false;
324
+ for (const line of lines) {
325
+ const heading = line.match(/^#{1,6}\s+(.+?)\s*$/);
326
+ if (heading) {
327
+ inTasks = /^tasks\b/i.test(heading[1].trim());
328
+ continue;
329
+ }
330
+ if (!inTasks)
331
+ continue;
332
+ const item = line.match(TASK_LINE);
333
+ if (item) {
334
+ const content = item[1].trim();
335
+ if (content)
336
+ tasks.push({ content });
337
+ }
338
+ }
339
+ return tasks;
340
+ }
341
+ function buildPlanComment(artifact) {
342
+ const body = artifact.markdown.trim();
343
+ return [
344
+ "## \uD83E\uDDED Plan (agent, advisory)",
345
+ "",
346
+ "The daemon explored the worktree read-only and produced this plan before implementing. Implementation is starting now in the same run.",
347
+ "",
348
+ body
349
+ ].join(`
350
+ `);
351
+ }
352
+ function buildGatedPlanComment(artifact, pickupColumnName) {
353
+ const body = artifact.markdown.trim();
354
+ return [
355
+ "## \uD83E\uDDED Plan (agent, awaiting approval)",
356
+ "",
357
+ `The daemon explored the worktree read-only and produced this plan. Implementation is **gated on your approval** — review the plan below, then move this card to **${pickupColumnName}** to start implementation with it. Edit the linked plan first if the approach needs changes.`,
358
+ "",
359
+ body
360
+ ].join(`
361
+ `);
362
+ }
363
+ var DEFAULT_PLANNING_CONFIG, PLAN_FENCE, H1, TASK_LINE;
364
+ var init_plan_phase = __esm(() => {
365
+ DEFAULT_PLANNING_CONFIG = {
366
+ enabled: false,
367
+ mode: "advisory",
368
+ model: "sonnet",
369
+ maxTurns: 40,
370
+ postComment: true,
371
+ awaitingApprovalColumn: "To Do",
372
+ minComplexityScore: 3,
373
+ approvalTtlHours: 0
374
+ };
375
+ PLAN_FENCE = /```plan\s*\n([\s\S]*?)```/i;
376
+ H1 = /^#\s+(.+?)\s*$/m;
377
+ TASK_LINE = /^\s*(?:[-*]\s*\[[ xX]?\]|[-*]|\d+[.)])\s+(.+?)\s*$/;
378
+ });
379
+
223
380
  // src/types.ts
224
381
  function agentIdentifier(workerId) {
225
382
  return `harmony-daemon-${workerId}`;
226
383
  }
227
- var DEFAULT_AGENT_CONFIG, NEED_REVIEW_LABEL = "Need Review", NEED_REVIEW_LABEL_COLOR = "#f59e0b", AGENT_NAME = "Harmony Agent";
384
+ var DEFAULT_AGENT_CONFIG, IN_PROGRESS_COLUMN = "In Progress", NEED_REVIEW_LABEL = "Need Review", NEED_REVIEW_LABEL_COLOR = "#f59e0b", AGENT_NAME = "Harmony Agent";
228
385
  var init_types = __esm(() => {
386
+ init_plan_phase();
229
387
  DEFAULT_AGENT_CONFIG = {
230
388
  poolSize: 3,
231
389
  maxTimeout: 1800000,
@@ -292,7 +450,8 @@ var init_types = __esm(() => {
292
450
  staleHeartbeatMs: 120000,
293
451
  reconcileIntervalMs: 60000,
294
452
  worktreeGcIntervalMs: 5 * 60000
295
- }
453
+ },
454
+ planning: DEFAULT_PLANNING_CONFIG
296
455
  };
297
456
  });
298
457
 
@@ -388,6 +547,10 @@ function loadDaemonConfig() {
388
547
  timing: {
389
548
  ...DEFAULT_AGENT_CONFIG.timing,
390
549
  ...agentOverrides.timing ?? {}
550
+ },
551
+ planning: {
552
+ ...DEFAULT_AGENT_CONFIG.planning,
553
+ ...agentOverrides.planning ?? {}
391
554
  }
392
555
  };
393
556
  return {
@@ -447,6 +610,20 @@ async function validateColumnReferences(client, projectId, config) {
447
610
  }
448
611
  required.push({ value: config.review.moveToColumn, where: "review.moveToColumn" }, { value: config.review.failColumn, where: "review.failColumn" });
449
612
  }
613
+ if (config.planning.enabled && config.planning.mode === "gated") {
614
+ required.push({
615
+ value: config.planning.awaitingApprovalColumn,
616
+ where: "planning.awaitingApprovalColumn"
617
+ });
618
+ const parkCol = config.planning.awaitingApprovalColumn?.toLowerCase();
619
+ const allPickups = [
620
+ ...config.pickupColumns,
621
+ ...config.review.enabled ? config.review.pickupColumns : []
622
+ ];
623
+ if (parkCol && allPickups.some((c) => c.toLowerCase() === parkCol)) {
624
+ issues.push(`planning.awaitingApprovalColumn: "${config.planning.awaitingApprovalColumn}" is also a pickup column (implement or review) — a gated card parked there is picked up immediately, bypassing approval. Use a column the daemon does not pick up from.`);
625
+ }
626
+ }
450
627
  for (const { value, where } of required) {
451
628
  if (!value)
452
629
  continue;
@@ -841,24 +1018,63 @@ import {
841
1018
  class HttpServer {
842
1019
  opts;
843
1020
  server = null;
1021
+ boundPort = null;
844
1022
  constructor(opts) {
845
1023
  this.opts = opts;
846
1024
  }
1025
+ get port() {
1026
+ return this.boundPort;
1027
+ }
847
1028
  async start() {
848
- return new Promise((resolve, reject) => {
849
- this.server = createServer((req, res) => {
850
- this.route(req, res).catch((err) => {
851
- log.error(TAG3, `unhandled: ${err instanceof Error ? err.message : err}`);
852
- if (!res.headersSent) {
853
- res.writeHead(500, { "content-type": "application/json" });
854
- res.end(JSON.stringify({ error: "internal_error" }));
855
- }
856
- });
1029
+ this.server = createServer((req, res) => {
1030
+ this.route(req, res).catch((err) => {
1031
+ log.error(TAG3, `unhandled: ${err instanceof Error ? err.message : err}`);
1032
+ if (!res.headersSent) {
1033
+ res.writeHead(500, { "content-type": "application/json" });
1034
+ res.end(JSON.stringify({ error: "internal_error" }));
1035
+ }
857
1036
  });
858
- this.server.once("error", reject);
859
- this.server.listen(this.opts.port, this.opts.bindAddr, () => {
1037
+ });
1038
+ const attempts = Math.max(1, this.opts.maxPortAttempts ?? 10);
1039
+ const startPort = this.opts.port;
1040
+ for (let i = 0;i < attempts; i++) {
1041
+ const port = startPort + i;
1042
+ try {
1043
+ await this.listenOnce(port);
1044
+ this.boundPort = port;
1045
+ if (port !== startPort) {
1046
+ log.info(TAG3, `port ${startPort} busy — bound to ${port} instead`);
1047
+ }
1048
+ return port;
1049
+ } catch (err) {
1050
+ const lastAttempt = i === attempts - 1;
1051
+ if (isAddrInUse(err) && !lastAttempt) {
1052
+ log.debug(TAG3, `port ${port} in use, trying ${port + 1}`);
1053
+ continue;
1054
+ }
1055
+ throw err;
1056
+ }
1057
+ }
1058
+ throw new Error("HTTP server failed to bind");
1059
+ }
1060
+ listenOnce(port) {
1061
+ return new Promise((resolve, reject) => {
1062
+ const server = this.server;
1063
+ if (!server) {
1064
+ reject(new Error("server not created"));
1065
+ return;
1066
+ }
1067
+ const onError = (err) => {
1068
+ server.removeListener("listening", onListening);
1069
+ reject(err);
1070
+ };
1071
+ const onListening = () => {
1072
+ server.removeListener("error", onError);
860
1073
  resolve();
861
- });
1074
+ };
1075
+ server.once("error", onError);
1076
+ server.once("listening", onListening);
1077
+ server.listen(port, this.opts.bindAddr);
862
1078
  });
863
1079
  }
864
1080
  async stop() {
@@ -868,6 +1084,7 @@ class HttpServer {
868
1084
  this.server?.close(() => resolve());
869
1085
  });
870
1086
  this.server = null;
1087
+ this.boundPort = null;
871
1088
  }
872
1089
  async route(req, res) {
873
1090
  const url = new URL(req.url ?? "/", "http://localhost");
@@ -914,6 +1131,9 @@ class HttpServer {
914
1131
  }
915
1132
  }
916
1133
  }
1134
+ function isAddrInUse(err) {
1135
+ return typeof err === "object" && err !== null && err.code === "EADDRINUSE";
1136
+ }
917
1137
  function parseCommand(path) {
918
1138
  const match = path.match(/^\/(pause|resume|stop)\/([^/]+)$/);
919
1139
  if (!match)
@@ -1374,6 +1594,62 @@ function formatCents(cents) {
1374
1594
  return `$${(cents / 100).toFixed(2)}`;
1375
1595
  }
1376
1596
 
1597
+ // src/error-classifier.ts
1598
+ function parseRetryAfterMs(message) {
1599
+ const match = message.match(/retry[- ]?after["':\s]+(\d+)/i);
1600
+ if (!match)
1601
+ return;
1602
+ const seconds = Number(match[1]);
1603
+ if (!Number.isFinite(seconds) || seconds <= 0)
1604
+ return;
1605
+ return seconds * 1000;
1606
+ }
1607
+ function classifyRunError(message) {
1608
+ if (!message)
1609
+ return { kind: null };
1610
+ const retryAfterMs = parseRetryAfterMs(message);
1611
+ if (AUTH.test(message))
1612
+ return { kind: "auth", retryAfterMs };
1613
+ if (OUT_OF_CREDITS.test(message))
1614
+ return { kind: "out_of_credits", retryAfterMs };
1615
+ if (USAGE_LIMIT.test(message))
1616
+ return { kind: "usage_limit", retryAfterMs };
1617
+ if (RATE_LIMIT.test(message))
1618
+ return { kind: "rate_limit", retryAfterMs };
1619
+ return { kind: null };
1620
+ }
1621
+ function describeApiError(kind) {
1622
+ switch (kind) {
1623
+ case "auth":
1624
+ return "Anthropic auth error — agent paused, check API credentials";
1625
+ case "out_of_credits":
1626
+ return "Anthropic credit balance too low — retrying after top-up";
1627
+ case "usage_limit":
1628
+ return "Anthropic usage limit reached — retrying after reset";
1629
+ case "rate_limit":
1630
+ return "Anthropic rate limit hit — retrying shortly";
1631
+ }
1632
+ }
1633
+ function cooldownMsFor(kind) {
1634
+ switch (kind) {
1635
+ case "rate_limit":
1636
+ return 60000;
1637
+ case "usage_limit":
1638
+ return 15 * 60000;
1639
+ case "out_of_credits":
1640
+ return 30 * 60000;
1641
+ case "auth":
1642
+ return 30 * 60000;
1643
+ }
1644
+ }
1645
+ var AUTH, OUT_OF_CREDITS, USAGE_LIMIT, RATE_LIMIT;
1646
+ var init_error_classifier = __esm(() => {
1647
+ AUTH = /\b401\b|invalid x-api-key|authentication_error|unauthorized|oauth token (?:has )?expired|please run .*login|invalid bearer token/i;
1648
+ OUT_OF_CREDITS = /\b402\b|credit balance is too low|insufficient (?:funds|credit|balance)|billing|payment required|purchase more credits/i;
1649
+ USAGE_LIMIT = /usage limit|daily limit|monthly limit|quota (?:exceeded|reached)|reached your .{0,20}limit|usage_limit_reached|limit will reset/i;
1650
+ RATE_LIMIT = /\b429\b|\b529\b|rate[ _-]?limit|too many requests|overloaded_error|"type"\s*:\s*"overloaded"/i;
1651
+ });
1652
+
1377
1653
  // src/queue.ts
1378
1654
  class PriorityQueue {
1379
1655
  config;
@@ -2386,13 +2662,15 @@ class ProgressTracker {
2386
2662
  sessionId = null;
2387
2663
  lastAssistantText = "";
2388
2664
  assistantTextBlocks = [];
2389
- constructor(client, cardId, workerId, subtasks) {
2665
+ constructor(client, cardId, workerId, subtasks, initialPhase = "exploring") {
2390
2666
  this.client = client;
2391
2667
  this.cardId = cardId;
2392
2668
  this.workerId = workerId;
2393
2669
  this.subtaskTotal = subtasks.length;
2394
2670
  this.subtaskCompleted = subtasks.filter((s) => s.completed).length;
2395
2671
  this.subtaskMode = subtasks.length > 0;
2672
+ this.phase = initialPhase;
2673
+ this.progress = PHASES[initialPhase].min;
2396
2674
  }
2397
2675
  setSessionId(id) {
2398
2676
  this.sessionId = id;
@@ -2720,6 +2998,7 @@ var init_progress_tracker = __esm(() => {
2720
2998
  GIT_COMMIT_RE = /\bgit\s+commit\b/;
2721
2999
  BUILD_CMD_RE = /\b(test|build|lint|check|tsc|vitest|jest|(?:bun|npm|pnpm|yarn) run (?:build|lint))\b/;
2722
3000
  PHASES = {
3001
+ planning: { min: 5, max: 20, label: "Planning" },
2723
3002
  exploring: { min: 10, max: 25, label: "Exploring" },
2724
3003
  implementing: { min: 25, max: 55, label: "Implementing" },
2725
3004
  testing: { min: 55, max: 65, label: "Testing" },
@@ -2727,6 +3006,7 @@ var init_progress_tracker = __esm(() => {
2727
3006
  finishing: { min: 70, max: 75, label: "Finalizing" }
2728
3007
  };
2729
3008
  PHASE_ORDER = {
3009
+ planning: -1,
2730
3010
  exploring: 0,
2731
3011
  implementing: 1,
2732
3012
  testing: 2,
@@ -3032,6 +3312,14 @@ ${finding.description}${locationLine}`
3032
3312
  `);
3033
3313
  await postReviewComment(client, card, "summary", body);
3034
3314
  }
3315
+ if (config.planning.enabled && card.plan_id) {
3316
+ try {
3317
+ await client.updateCard(card.id, { needsPlanRefresh: true });
3318
+ log.info(TAG15, `#${card.short_id} flagged needs_plan_refresh after rejected review`);
3319
+ } catch (err) {
3320
+ log.warn(TAG15, `Failed to flag needs_plan_refresh for #${card.short_id}: ${err instanceof Error ? err.message : err}`);
3321
+ }
3322
+ }
3035
3323
  await moveCardToColumn(client, card, config.review.failColumn);
3036
3324
  const failureSummary = `Review rejected (cycle ${currentCycle}/${maxCycles}): ${criticalFindings.length} critical, ${majorFindings.length} major, ${minorFindings.length} minor`;
3037
3325
  const recoveryBranch = branchName ?? undefined;
@@ -3444,6 +3732,13 @@ class StateStore {
3444
3732
  await this.persist();
3445
3733
  return rec.attempts;
3446
3734
  }
3735
+ async decrementAttempt(cardId) {
3736
+ const rec = this.getCard(cardId);
3737
+ if (!rec || rec.attempts === 0)
3738
+ return;
3739
+ rec.attempts = Math.max(0, rec.attempts - 1);
3740
+ await this.persist();
3741
+ }
3447
3742
  async recordOutcome(cardId, outcome) {
3448
3743
  const rec = this.ensureCard(cardId);
3449
3744
  rec.lastOutcome = outcome;
@@ -3753,7 +4048,6 @@ var init_transitions = __esm(() => {
3753
4048
 
3754
4049
  // src/review-worker.ts
3755
4050
  import { execFileSync as execFileSync8 } from "node:child_process";
3756
- import { createHash } from "node:crypto";
3757
4051
 
3758
4052
  class ReviewWorker {
3759
4053
  config;
@@ -3945,19 +4239,11 @@ class ReviewWorker {
3945
4239
  const systemPrompt = buildReviewSystemPrompt();
3946
4240
  const userPrompt = buildReviewUserPrompt(enriched, this.branchName, cwd, previewUrl, diff, this.config.worktree.baseBranch);
3947
4241
  try {
3948
- const sessionResp = await this.client.getAgentSession(card.id);
3949
- const reviewSession2 = sessionResp.session;
3950
- const reviewSessionId = reviewSession2?.id ?? null;
3951
- const contentHash = createHash("sha256").update(systemPrompt).digest("hex");
3952
4242
  await this.client.recordPromptHistory({
3953
4243
  cardId: card.id,
3954
4244
  generatedPrompt: systemPrompt,
3955
4245
  variant: "execute",
3956
- contextIncluded: { source: "review-knowledge", mode: "review" },
3957
- sessionId: reviewSessionId,
3958
- contentHash,
3959
- templateVersion: 1,
3960
- confidence: 0.5
4246
+ contextIncluded: { source: "review-knowledge", mode: "review" }
3961
4247
  });
3962
4248
  } catch (err) {
3963
4249
  log.warn(this.tag, `prompt_history persistence skipped: ${err instanceof Error ? err.message : String(err)}`);
@@ -4409,8 +4695,13 @@ async function promoteUnblockedSuccessors(completedCard, deps) {
4409
4695
  const successorId = link.target_card.id;
4410
4696
  try {
4411
4697
  const { card } = await deps.client.getCard(successorId);
4412
- if (card.assigned_agent_id !== deps.agentId) {
4413
- log.debug(TAG21, `successor #${card.short_id} not assigned to agent — skipping promotion`);
4698
+ if (card.assigned_agent_id === deps.agentId) {} else if (card.assigned_agent_id === null && !card.assignee_id) {
4699
+ log.info(TAG21, `successor #${card.short_id} unassigned auto-assigning to continue chain`);
4700
+ await deps.client.updateCard(successorId, {
4701
+ assignedAgentId: deps.agentId
4702
+ });
4703
+ } else {
4704
+ log.debug(TAG21, `successor #${card.short_id} assigned to different entity — skipping`);
4414
4705
  continue;
4415
4706
  }
4416
4707
  await deps.enqueue(successorId);
@@ -4692,6 +4983,7 @@ class Worker {
4692
4983
  projectId;
4693
4984
  stateStore;
4694
4985
  onCardCompleted;
4986
+ onApiError;
4695
4987
  id;
4696
4988
  state = "idle";
4697
4989
  cardId = null;
@@ -4708,7 +5000,7 @@ class Worker {
4708
5000
  verificationFailed = false;
4709
5001
  sessionId = null;
4710
5002
  runId = null;
4711
- constructor(id, config, client, agentId, onDone, workspaceId, projectId, stateStore, onCardCompleted) {
5003
+ constructor(id, config, client, agentId, onDone, workspaceId, projectId, stateStore, onCardCompleted, onApiError) {
4712
5004
  this.config = config;
4713
5005
  this.client = client;
4714
5006
  this.agentId = agentId;
@@ -4717,6 +5009,7 @@ class Worker {
4717
5009
  this.projectId = projectId;
4718
5010
  this.stateStore = stateStore;
4719
5011
  this.onCardCompleted = onCardCompleted;
5012
+ this.onApiError = onApiError;
4720
5013
  this.id = id;
4721
5014
  }
4722
5015
  startHeartbeat() {
@@ -4757,7 +5050,7 @@ class Worker {
4757
5050
  return this.state === "idle";
4758
5051
  }
4759
5052
  get isActive() {
4760
- return this.state === "preparing" || this.state === "running" || this.state === "verifying" || this.state === "completing";
5053
+ return this.state === "preparing" || this.state === "planning" || this.state === "running" || this.state === "verifying" || this.state === "completing";
4761
5054
  }
4762
5055
  async run(card, column, labels, subtasks) {
4763
5056
  this.aborted = false;
@@ -4803,7 +5096,7 @@ class Worker {
4803
5096
  }
4804
5097
  this.sessionId = sid;
4805
5098
  await this.recordPhase("preparing");
4806
- const moved = await moveCardAndAddLabel(this.client, card, "In Progress", "agent");
5099
+ const moved = await moveCardAndAddLabel(this.client, card, IN_PROGRESS_COLUMN, "agent");
4807
5100
  if (!moved) {
4808
5101
  log.warn(this.tag, `Card #${card.short_id} was NOT moved to "In Progress" — check API logs`);
4809
5102
  }
@@ -4812,8 +5105,6 @@ class Worker {
4812
5105
  this.worktreePath = createWorktree(this.config.worktree.basePath, this.config.worktree.baseBranch, this.branchName);
4813
5106
  if (this.aborted)
4814
5107
  return;
4815
- this.state = "running";
4816
- await this.recordPhase("running");
4817
5108
  const enriched = {
4818
5109
  card,
4819
5110
  column,
@@ -4821,6 +5112,19 @@ class Worker {
4821
5112
  subtasks,
4822
5113
  mode: "implement"
4823
5114
  };
5115
+ if (shouldPlan(enriched, this.config.planning)) {
5116
+ this.state = "planning";
5117
+ await this.recordPhase("planning");
5118
+ const parked = await this.runPlanningPhase(enriched);
5119
+ if (this.aborted)
5120
+ return;
5121
+ if (parked) {
5122
+ log.info(this.tag, `#${card.short_id} parked for plan approval — ending run`);
5123
+ return;
5124
+ }
5125
+ }
5126
+ this.state = "running";
5127
+ await this.recordPhase("running");
4824
5128
  const prompt = await buildPrompt(enriched, this.branchName, this.worktreePath, this.client, this.workspaceId, this.projectId);
4825
5129
  await this.client.updateAgentProgress(card.id, {
4826
5130
  agentIdentifier: agentIdentifier(this.id),
@@ -4859,21 +5163,46 @@ class Worker {
4859
5163
  this.state = "error";
4860
5164
  const msg = err instanceof Error ? err.message : String(err);
4861
5165
  log.error(this.tag, `Error on #${card.short_id}: ${msg}`);
5166
+ const rawStderr = err?.stderr;
5167
+ const errClass = classifyRunError(typeof rawStderr === "string" && rawStderr ? rawStderr : msg);
5168
+ const apiError = errClass.kind !== null;
5169
+ if (apiError) {
5170
+ try {
5171
+ this.onApiError?.(errClass);
5172
+ } catch {}
5173
+ }
5174
+ if (this.worktreePath) {
5175
+ try {
5176
+ cleanupWorktree(this.worktreePath, this.branchName ?? undefined);
5177
+ } catch {
5178
+ log.warn(this.tag, "Failed to cleanup worktree before requeue");
5179
+ }
5180
+ this.worktreePath = null;
5181
+ }
5182
+ const failureReason = apiError ? errClass.kind : "other";
5183
+ const failureSummary = apiError ? describeApiError(errClass.kind) : `Run failed: ${msg.slice(0, 300)}`;
4862
5184
  try {
4863
5185
  await runTransition(this.client, card, {
5186
+ move: { columnName: this.config.pickupColumns[0] ?? "To Do" },
4864
5187
  endSession: {
4865
- status: "paused",
5188
+ status: "failed",
5189
+ failureReason,
5190
+ failureSummary,
4866
5191
  ...buildTokenPayload(this.lastSessionStats)
4867
5192
  }
4868
5193
  });
4869
5194
  } catch (tErr) {
4870
- log.error(this.tag, `endAgentSession unrecoverable on #${card.short_id}: ${tErr instanceof TransitionError ? tErr.detail : tErr}`);
5195
+ log.error(this.tag, `error transition failed on #${card.short_id}: ${tErr instanceof TransitionError ? tErr.detail : tErr}`);
4871
5196
  }
4872
5197
  if (this.runId) {
4873
5198
  try {
4874
- await this.stateStore.endRun(this.runId, "paused", msg);
5199
+ await this.stateStore.endRun(this.runId, "failed", errClass.kind ?? msg);
4875
5200
  } catch {}
4876
- await this.recordOutcome(card.id, "failure");
5201
+ if (apiError) {
5202
+ await this.stateStore.decrementAttempt(card.id);
5203
+ } else {
5204
+ await this.recordOutcome(card.id, "failure");
5205
+ }
4877
5206
  }
4878
5207
  } finally {
4879
5208
  const succeeded = this.runId && this.state !== "error" && !this.aborted && !this.verificationFailed;
@@ -5017,7 +5346,122 @@ class Worker {
5017
5346
  }
5018
5347
  }
5019
5348
  }
5020
- async spawnClaude(prompt, card, subtasks) {
5349
+ async runPlanningPhase(enriched) {
5350
+ const planning = this.config.planning;
5351
+ const { card } = enriched;
5352
+ log.info(this.tag, `Planning pass for #${card.short_id} (mode=${planning.mode}, model=${planning.model})`);
5353
+ await this.client.updateAgentProgress(card.id, {
5354
+ agentIdentifier: agentIdentifier(this.id),
5355
+ agentName: AGENT_NAME,
5356
+ status: "working",
5357
+ currentTask: "Planning approach (read-only)",
5358
+ progressPercent: 5,
5359
+ phase: "planning"
5360
+ }).catch(() => {});
5361
+ const planPrompt = buildPlanPrompt(enriched, this.worktreePath);
5362
+ let planTimedOut = false;
5363
+ const planTimeout = setTimeout(() => {
5364
+ planTimedOut = true;
5365
+ log.warn(this.tag, "Planning pass exceeded timeout — abandoning, implementing directly");
5366
+ if (this.process && !this.process.killed) {
5367
+ terminateGroup(this.process, {
5368
+ sigintTimeoutMs: 1e4,
5369
+ sigtermTimeoutMs: 5000
5370
+ }).catch(() => {});
5371
+ }
5372
+ }, Math.min(this.config.maxTimeout, PLAN_PHASE_TIMEOUT));
5373
+ try {
5374
+ await this.spawnClaude(planPrompt, card, [], {
5375
+ model: planning.model,
5376
+ maxTurns: planning.maxTurns,
5377
+ allowedTools: PLAN_ALLOWED_TOOLS,
5378
+ initialPhase: "planning"
5379
+ });
5380
+ } catch (err) {
5381
+ log.warn(this.tag, `Planning pass failed (non-fatal): ${err instanceof Error ? err.message : err}`);
5382
+ return false;
5383
+ } finally {
5384
+ clearTimeout(planTimeout);
5385
+ }
5386
+ if (this.aborted || planTimedOut)
5387
+ return false;
5388
+ const stats = this.lastSessionStats;
5389
+ if (stats?.cost) {
5390
+ const cents = Math.round(stats.cost.totalCostUsd * 100);
5391
+ if (cents > 0) {
5392
+ try {
5393
+ await this.stateStore.addCost(card.id, cents);
5394
+ } catch {}
5395
+ }
5396
+ }
5397
+ const planText = stats?.lastAssistantText ?? "";
5398
+ if (!planText.trim()) {
5399
+ log.warn(this.tag, `Planning pass for #${card.short_id} produced no text — implementing directly`);
5400
+ return false;
5401
+ }
5402
+ const artifact = extractPlanArtifact(planText, card.title);
5403
+ let planId = null;
5404
+ try {
5405
+ const result = await this.client.createPlan(this.projectId, {
5406
+ title: artifact.title,
5407
+ content: artifact.markdown,
5408
+ source: "agent",
5409
+ tasks: artifact.tasks
5410
+ });
5411
+ const plan = result?.plan;
5412
+ const createdId = plan && typeof plan === "object" && "id" in plan ? plan.id : null;
5413
+ if (createdId) {
5414
+ await this.client.updateCard(card.id, {
5415
+ planId: createdId,
5416
+ needsPlanRefresh: false
5417
+ });
5418
+ planId = createdId;
5419
+ }
5420
+ log.info(this.tag, `Stored plan ${planId ?? "(unlinked)"} for #${card.short_id} (${artifact.tasks.length} tasks)`);
5421
+ } catch (err) {
5422
+ log.warn(this.tag, `Failed to store/link plan (non-fatal): ${err instanceof Error ? err.message : err}`);
5423
+ }
5424
+ if (planning.mode === "gated" && planId) {
5425
+ try {
5426
+ if (planning.postComment) {
5427
+ await this.client.addComment(card.id, buildGatedPlanComment(artifact, planning.awaitingApprovalColumn), {
5428
+ commentType: "decision",
5429
+ agentSessionId: this.sessionId ?? undefined
5430
+ });
5431
+ }
5432
+ await runTransition(this.client, card, {
5433
+ move: { columnName: planning.awaitingApprovalColumn },
5434
+ removeLabels: ["agent"],
5435
+ endSession: {
5436
+ status: "completed",
5437
+ progressPercent: 20,
5438
+ ...buildTokenPayload(stats)
5439
+ }
5440
+ }, { store: this.stateStore, runId: this.runId ?? undefined });
5441
+ log.info(this.tag, `#${card.short_id} parked in "${planning.awaitingApprovalColumn}" for plan approval`);
5442
+ this.lastSessionStats = undefined;
5443
+ return true;
5444
+ } catch (err) {
5445
+ log.warn(this.tag, `Gated park failed for #${card.short_id} (non-fatal, implementing directly): ${err instanceof TransitionError ? err.detail : err instanceof Error ? err.message : err}`);
5446
+ }
5447
+ }
5448
+ if (planId && planning.postComment) {
5449
+ try {
5450
+ await this.client.addComment(card.id, buildPlanComment(artifact), {
5451
+ commentType: "decision",
5452
+ agentSessionId: this.sessionId ?? undefined
5453
+ });
5454
+ } catch (err) {
5455
+ log.warn(this.tag, `Failed to post advisory plan comment (non-fatal): ${err instanceof Error ? err.message : err}`);
5456
+ }
5457
+ }
5458
+ return false;
5459
+ }
5460
+ async spawnClaude(prompt, card, subtasks, opts = {}) {
5461
+ const model = opts.model ?? this.config.claude.model;
5462
+ const maxTurns = opts.maxTurns ?? this.config.claude.maxTurns;
5463
+ const allowedTools = opts.allowedTools ?? IMPLEMENT_ALLOWED_TOOLS;
5464
+ const initialPhase = opts.initialPhase ?? "exploring";
5021
5465
  return new Promise((resolve3, reject) => {
5022
5466
  const args = [
5023
5467
  "-p",
@@ -5025,11 +5469,11 @@ class Worker {
5025
5469
  "--output-format",
5026
5470
  "stream-json",
5027
5471
  "--model",
5028
- this.config.claude.model,
5472
+ model,
5029
5473
  "--max-turns",
5030
- String(this.config.claude.maxTurns),
5474
+ String(maxTurns),
5031
5475
  "--allowedTools",
5032
- "Bash,Read,Write,Edit,Glob,Grep,Agent,mcp__harmony__*",
5476
+ allowedTools,
5033
5477
  ...this.config.claude.additionalArgs,
5034
5478
  "--",
5035
5479
  prompt
@@ -5048,7 +5492,7 @@ class Worker {
5048
5492
  stdio: ["ignore", "pipe", "pipe"]
5049
5493
  });
5050
5494
  const parser = new StreamParser;
5051
- this.progressTracker = new ProgressTracker(this.client, card.id, this.id, subtasks);
5495
+ this.progressTracker = new ProgressTracker(this.client, card.id, this.id, subtasks, initialPhase);
5052
5496
  if (this.sessionId) {
5053
5497
  this.progressTracker.setSessionId(this.sessionId);
5054
5498
  }
@@ -5093,7 +5537,9 @@ class Worker {
5093
5537
  } else if (code === 0) {
5094
5538
  resolve3();
5095
5539
  } else {
5096
- reject(new Error(`claude exited with code ${code}${stderr ? `: ${stderr.slice(0, 500)}` : ""}`));
5540
+ const err = new Error(`claude exited with code ${code}${stderr ? `: ${stderr.slice(0, 500)}` : ""}`);
5541
+ err.stderr = stderr;
5542
+ reject(err);
5097
5543
  }
5098
5544
  });
5099
5545
  });
@@ -5125,11 +5571,13 @@ class Worker {
5125
5571
  this.sessionId = null;
5126
5572
  }
5127
5573
  }
5128
- var TAG23 = "worker", CANCEL_SIGINT_TIMEOUT2 = 30000, CANCEL_SIGTERM_TIMEOUT2 = 1e4;
5574
+ var TAG23 = "worker", CANCEL_SIGINT_TIMEOUT2 = 30000, CANCEL_SIGTERM_TIMEOUT2 = 1e4, PLAN_ALLOWED_TOOLS = "Read,Grep,Glob,mcp__harmony__*", IMPLEMENT_ALLOWED_TOOLS = "Bash,Read,Write,Edit,Glob,Grep,Agent,mcp__harmony__*", PLAN_PHASE_TIMEOUT;
5129
5575
  var init_worker = __esm(() => {
5130
5576
  init_board_helpers();
5131
5577
  init_completion();
5578
+ init_error_classifier();
5132
5579
  init_log();
5580
+ init_plan_phase();
5133
5581
  init_process_group();
5134
5582
  init_progress_tracker();
5135
5583
  init_prompt();
@@ -5139,6 +5587,7 @@ var init_worker = __esm(() => {
5139
5587
  init_transitions();
5140
5588
  init_types();
5141
5589
  init_worktree();
5590
+ PLAN_PHASE_TIMEOUT = 10 * 60000;
5142
5591
  });
5143
5592
 
5144
5593
  // src/pool.ts
@@ -5154,6 +5603,8 @@ class Pool {
5154
5603
  budget;
5155
5604
  sleepGuard = new SleepGuard;
5156
5605
  shuttingDown = false;
5606
+ apiCooldownUntil = 0;
5607
+ authPaused = false;
5157
5608
  onCardCompleted = null;
5158
5609
  constructor(config, client, _userEmail, workspaceId, projectId, stateStore, agentId) {
5159
5610
  this.client = client;
@@ -5172,7 +5623,7 @@ class Pool {
5172
5623
  }
5173
5624
  }, workspaceId, projectId, stateStore, async (completedCard) => {
5174
5625
  await this.onCardCompleted?.(completedCard);
5175
- }));
5626
+ }, (err) => this.noteApiError(err)));
5176
5627
  }
5177
5628
  if (config.review.enabled) {
5178
5629
  for (let i = 0;i < config.review.poolSize; i++) {
@@ -5193,6 +5644,17 @@ class Pool {
5193
5644
  return;
5194
5645
  }
5195
5646
  if (mode === "implement") {
5647
+ if (this.authPaused) {
5648
+ log.debug(TAG24, `#${card.short_id} held — agent paused (auth error)`);
5649
+ await this.emitWaiting(card.id, "Agent paused — Anthropic auth error, check API credentials");
5650
+ return;
5651
+ }
5652
+ const cooldownMs = this.apiCooldownRemainingMs();
5653
+ if (cooldownMs > 0) {
5654
+ log.debug(TAG24, `#${card.short_id} held — API cooldown ${Math.round(cooldownMs / 1000)}s remaining`);
5655
+ await this.emitWaiting(card.id, `Paused — Anthropic API limit, retrying in ~${Math.round(cooldownMs / 1000)}s`);
5656
+ return;
5657
+ }
5196
5658
  const decision = this.budget.check(card.id);
5197
5659
  if (!decision.allow) {
5198
5660
  if (decision.reason === "daily_budget") {
@@ -5242,6 +5704,26 @@ class Pool {
5242
5704
  log.debug(TAG24, `waiting emit failed for ${cardId}: ${err instanceof Error ? err.message : err}`);
5243
5705
  }
5244
5706
  }
5707
+ noteApiError(err) {
5708
+ if (!err.kind)
5709
+ return;
5710
+ if (err.kind === "auth") {
5711
+ if (!this.authPaused) {
5712
+ log.error(TAG24, "Auth error from Claude CLI — pausing implement pickups until the daemon is restarted with valid credentials");
5713
+ }
5714
+ this.authPaused = true;
5715
+ return;
5716
+ }
5717
+ const cooldownMs = err.retryAfterMs ?? cooldownMsFor(err.kind);
5718
+ const until = Date.now() + cooldownMs;
5719
+ if (until > this.apiCooldownUntil) {
5720
+ this.apiCooldownUntil = until;
5721
+ log.warn(TAG24, `${describeApiError(err.kind)} — pausing implement pickups for ${Math.round(cooldownMs / 1000)}s`);
5722
+ }
5723
+ }
5724
+ apiCooldownRemainingMs() {
5725
+ return Math.max(0, this.apiCooldownUntil - Date.now());
5726
+ }
5245
5727
  async removeCard(cardId) {
5246
5728
  await this.stateStore.resetAttempts(cardId);
5247
5729
  this.lastWaitingEmit.delete(cardId);
@@ -5369,6 +5851,7 @@ class Pool {
5369
5851
  }
5370
5852
  var TAG24 = "pool";
5371
5853
  var init_pool = __esm(() => {
5854
+ init_error_classifier();
5372
5855
  init_log();
5373
5856
  init_queue();
5374
5857
  init_review_worker();
@@ -5378,6 +5861,78 @@ var init_pool = __esm(() => {
5378
5861
  init_worker();
5379
5862
  });
5380
5863
 
5864
+ // src/port-registry.ts
5865
+ var exports_port_registry = {};
5866
+ __export(exports_port_registry, {
5867
+ recordDaemonPort: () => recordDaemonPort,
5868
+ lookupDaemonPort: () => lookupDaemonPort,
5869
+ defaultRegistryPath: () => defaultRegistryPath,
5870
+ clearDaemonPort: () => clearDaemonPort
5871
+ });
5872
+ import {
5873
+ existsSync as existsSync5,
5874
+ mkdirSync as mkdirSync3,
5875
+ readFileSync as readFileSync4,
5876
+ renameSync as renameSync2,
5877
+ writeFileSync as writeFileSync2
5878
+ } from "node:fs";
5879
+ import { homedir as homedir4 } from "node:os";
5880
+ import { dirname as dirname2, join as join4 } from "node:path";
5881
+ function defaultRegistryPath() {
5882
+ return join4(homedir4(), ".harmony-mcp", "agent-ports.json");
5883
+ }
5884
+ function load(path) {
5885
+ if (!existsSync5(path))
5886
+ return {};
5887
+ try {
5888
+ const raw = readFileSync4(path, "utf-8");
5889
+ const parsed = JSON.parse(raw);
5890
+ if (parsed && typeof parsed === "object")
5891
+ return parsed;
5892
+ return {};
5893
+ } catch (err) {
5894
+ log.warn(TAG25, `failed to read ${path}: ${err instanceof Error ? err.message : err}`);
5895
+ return {};
5896
+ }
5897
+ }
5898
+ function save(path, registry) {
5899
+ const dir = dirname2(path);
5900
+ if (!existsSync5(dir))
5901
+ mkdirSync3(dir, { recursive: true });
5902
+ const tmp = `${path}.tmp`;
5903
+ writeFileSync2(tmp, JSON.stringify(registry, null, 2), "utf-8");
5904
+ renameSync2(tmp, path);
5905
+ }
5906
+ function recordDaemonPort(projectId, entry, path = defaultRegistryPath()) {
5907
+ try {
5908
+ const registry = load(path);
5909
+ registry[projectId] = { ...entry, updatedAt: Date.now() };
5910
+ save(path, registry);
5911
+ } catch (err) {
5912
+ log.warn(TAG25, `failed to record port for ${projectId}: ${err instanceof Error ? err.message : err}`);
5913
+ }
5914
+ }
5915
+ function lookupDaemonPort(projectId, path = defaultRegistryPath()) {
5916
+ const registry = load(path);
5917
+ return registry[projectId] ?? null;
5918
+ }
5919
+ function clearDaemonPort(projectId, pid, path = defaultRegistryPath()) {
5920
+ try {
5921
+ const registry = load(path);
5922
+ const existing = registry[projectId];
5923
+ if (!existing || existing.pid !== pid)
5924
+ return;
5925
+ delete registry[projectId];
5926
+ save(path, registry);
5927
+ } catch (err) {
5928
+ log.warn(TAG25, `failed to clear port for ${projectId}: ${err instanceof Error ? err.message : err}`);
5929
+ }
5930
+ }
5931
+ var TAG25 = "port-registry";
5932
+ var init_port_registry = __esm(() => {
5933
+ init_log();
5934
+ });
5935
+
5381
5936
  // src/recovery.ts
5382
5937
  function isProcessAlive(pid, currentPid) {
5383
5938
  if (pid === currentPid)
@@ -5394,7 +5949,7 @@ async function fetchCardSafely(client, cardId) {
5394
5949
  const { card } = await client.getCard(cardId);
5395
5950
  return card;
5396
5951
  } catch (err) {
5397
- log.warn(TAG25, `cannot fetch card ${cardId}: ${err instanceof Error ? err.message : err}`);
5952
+ log.warn(TAG26, `cannot fetch card ${cardId}: ${err instanceof Error ? err.message : err}`);
5398
5953
  return null;
5399
5954
  }
5400
5955
  }
@@ -5404,7 +5959,7 @@ async function recoverOrphans(store, client, config) {
5404
5959
  return [];
5405
5960
  }
5406
5961
  const outcomes = [];
5407
- log.info(TAG25, `recovering ${active.length} orphan run(s) from prior daemon`);
5962
+ log.info(TAG26, `recovering ${active.length} orphan run(s) from prior daemon`);
5408
5963
  for (const run of active) {
5409
5964
  const outcome = {
5410
5965
  runId: run.runId,
@@ -5416,11 +5971,11 @@ async function recoverOrphans(store, client, config) {
5416
5971
  };
5417
5972
  outcomes.push(outcome);
5418
5973
  if (isProcessAlive(run.daemonPid, process.pid)) {
5419
- log.warn(TAG25, `run ${run.runId} claims live daemon pid ${run.daemonPid} — skipping`);
5974
+ log.warn(TAG26, `run ${run.runId} claims live daemon pid ${run.daemonPid} — skipping`);
5420
5975
  outcome.actions.push("skipped: daemon pid still alive");
5421
5976
  continue;
5422
5977
  }
5423
- log.info(TAG25, `recovering ${run.pipeline} run ${run.runId} for card #${run.cardShortId}`);
5978
+ log.info(TAG26, `recovering ${run.pipeline} run ${run.runId} for card #${run.cardShortId}`);
5424
5979
  await recoverRun(run, store, client, config, outcome);
5425
5980
  }
5426
5981
  return outcomes;
@@ -5438,7 +5993,7 @@ async function recoverRun(run, store, client, config, outcome) {
5438
5993
  } catch (err) {
5439
5994
  const msg = err instanceof Error ? err.message : String(err);
5440
5995
  outcome.errors.push(`endAgentSession: ${msg}`);
5441
- log.warn(TAG25, `endAgentSession failed for ${run.cardId}: ${msg}`);
5996
+ log.warn(TAG26, `endAgentSession failed for ${run.cardId}: ${msg}`);
5442
5997
  }
5443
5998
  const card = await fetchCardSafely(client, run.cardId);
5444
5999
  if (card) {
@@ -5479,9 +6034,9 @@ async function recoverRun(run, store, client, config, outcome) {
5479
6034
  const msg = err instanceof Error ? err.message : String(err);
5480
6035
  outcome.errors.push(`endRun: ${msg}`);
5481
6036
  }
5482
- log.info(TAG25, `recovered run ${run.runId} (card #${run.cardShortId}): ${outcome.actions.join(", ")}${outcome.errors.length ? ` | errors: ${outcome.errors.join("; ")}` : ""}`);
6037
+ log.info(TAG26, `recovered run ${run.runId} (card #${run.cardShortId}): ${outcome.actions.join(", ")}${outcome.errors.length ? ` | errors: ${outcome.errors.join("; ")}` : ""}`);
5483
6038
  }
5484
- var TAG25 = "recovery", RECOVERED_LABEL = "agent-recovered", RECOVERED_LABEL_COLOR = "#f59e0b";
6039
+ var TAG26 = "recovery", RECOVERED_LABEL = "agent-recovered", RECOVERED_LABEL_COLOR = "#f59e0b";
5485
6040
  var init_recovery = __esm(() => {
5486
6041
  init_board_helpers();
5487
6042
  init_log();
@@ -5529,7 +6084,7 @@ class Reconciler {
5529
6084
  clearInterval(this.timer);
5530
6085
  this.timer = null;
5531
6086
  }
5532
- log.info(TAG26, "Heartbeat stopped");
6087
+ log.info(TAG27, "Heartbeat stopped");
5533
6088
  }
5534
6089
  async recoverStaleRuns() {
5535
6090
  if (!this.stateStore || !this.agentConfig)
@@ -5546,7 +6101,7 @@ class Reconciler {
5546
6101
  if (!daemonDead && !(heartbeatStale && ourZombie))
5547
6102
  continue;
5548
6103
  const reason = daemonDead ? `foreign daemon ${run.daemonPid} is dead` : `our worker lost card ${run.cardId} with ${Math.round((now - run.lastHeartbeatAt) / 1000)}s stale heartbeat`;
5549
- log.warn(TAG26, `zombie run ${run.runId} (#${run.cardShortId}): ${reason} — recovering`);
6104
+ log.warn(TAG27, `zombie run ${run.runId} (#${run.cardShortId}): ${reason} — recovering`);
5550
6105
  await recoverRun(run, this.stateStore, this.client, this.agentConfig, {
5551
6106
  runId: run.runId,
5552
6107
  cardId: run.cardId,
@@ -5557,6 +6112,57 @@ class Reconciler {
5557
6112
  });
5558
6113
  }
5559
6114
  }
6115
+ async recoverStrandedInProgress(cards, columns, knownCardIds) {
6116
+ const inProgressCol = columns.find((c) => c.name.toLowerCase() === IN_PROGRESS_COLUMN.toLowerCase());
6117
+ const pickupName = this.pickupColumns[0];
6118
+ const pickupCol = pickupName ? columns.find((c) => c.name.toLowerCase() === pickupName.toLowerCase()) : undefined;
6119
+ if (!inProgressCol || !pickupCol)
6120
+ return;
6121
+ const graceMs = this.agentConfig?.timing.staleHeartbeatMs ?? 120000;
6122
+ const now = Date.now();
6123
+ const activeCardIds = new Set((this.stateStore?.getActiveRuns() ?? []).map((r) => r.cardId));
6124
+ for (const card of cards) {
6125
+ if (card.assigned_agent_id !== this.agentId || card.archived_at || card.column_id !== inProgressCol.id || knownCardIds.has(card.id) || activeCardIds.has(card.id)) {
6126
+ continue;
6127
+ }
6128
+ const stalledAt = Date.parse(card.updated_at ?? "");
6129
+ if (!Number.isFinite(stalledAt) || now - stalledAt < graceMs)
6130
+ continue;
6131
+ log.warn(TAG27, `#${card.short_id} stranded in "${inProgressCol.name}" (no live run) — requeueing to "${pickupCol.name}"`);
6132
+ try {
6133
+ await this.client.moveCard(card.id, pickupCol.id);
6134
+ } catch (err) {
6135
+ log.error(TAG27, `stranded requeue failed for #${card.short_id}: ${err instanceof Error ? err.message : err}`);
6136
+ }
6137
+ }
6138
+ }
6139
+ async releaseStalledApprovals(cards, columns, knownCardIds) {
6140
+ const planning = this.agentConfig?.planning;
6141
+ if (!planning?.enabled || planning.mode !== "gated" || planning.approvalTtlHours <= 0) {
6142
+ return;
6143
+ }
6144
+ const parkCol = columns.find((c) => c.name.toLowerCase() === planning.awaitingApprovalColumn.toLowerCase());
6145
+ const pickupName = this.pickupColumns[0];
6146
+ const pickupCol = pickupName ? columns.find((c) => c.name.toLowerCase() === pickupName.toLowerCase()) : undefined;
6147
+ if (!parkCol || !pickupCol)
6148
+ return;
6149
+ const ttlMs = planning.approvalTtlHours * 3600000;
6150
+ const now = Date.now();
6151
+ for (const card of cards) {
6152
+ if (card.assigned_agent_id !== this.agentId || card.archived_at || card.column_id !== parkCol.id || !card.plan_id || knownCardIds.has(card.id)) {
6153
+ continue;
6154
+ }
6155
+ const parkedAt = Date.parse(card.updated_at ?? "");
6156
+ if (!Number.isFinite(parkedAt) || now - parkedAt < ttlMs)
6157
+ continue;
6158
+ log.warn(TAG27, `#${card.short_id} parked for approval > ${planning.approvalTtlHours}h — auto-releasing to "${pickupCol.name}"`);
6159
+ try {
6160
+ await this.client.moveCard(card.id, pickupCol.id);
6161
+ } catch (err) {
6162
+ log.error(TAG27, `auto-release failed for #${card.short_id}: ${err instanceof Error ? err.message : err}`);
6163
+ }
6164
+ }
6165
+ }
5560
6166
  async tick() {
5561
6167
  this.lastTickAt = Date.now();
5562
6168
  try {
@@ -5582,37 +6188,39 @@ class Reconciler {
5582
6188
  const subtasks = card.subtasks ?? [];
5583
6189
  const mode = reviewColumnIds.has(card.column_id) ? "review" : "implement";
5584
6190
  if (mode === "review" && this.approvedLabel && hasLabel(cardLabels, this.approvedLabel)) {
5585
- log.debug(TAG26, `Skipping #${card.short_id} — already has "${this.approvedLabel}" label`);
6191
+ log.debug(TAG27, `Skipping #${card.short_id} — already has "${this.approvedLabel}" label`);
5586
6192
  continue;
5587
6193
  }
5588
6194
  if (mode === "review" && hasLabel(cardLabels, NEED_REVIEW_LABEL)) {
5589
- log.debug(TAG26, `Skipping #${card.short_id} — has "${NEED_REVIEW_LABEL}" label (needs human)`);
6195
+ log.debug(TAG27, `Skipping #${card.short_id} — has "${NEED_REVIEW_LABEL}" label (needs human)`);
5590
6196
  continue;
5591
6197
  }
5592
6198
  if (mode === "review" && !extractBranchFromDescription(card.description)) {
5593
- log.debug(TAG26, `Skipping #${card.short_id} — no branch reference (not qualified for auto-review)`);
6199
+ log.debug(TAG27, `Skipping #${card.short_id} — no branch reference (not qualified for auto-review)`);
5594
6200
  continue;
5595
6201
  }
5596
- log.info(TAG26, `Missed assignment: #${card.short_id} "${card.title}" (${mode}) — enqueueing`);
6202
+ log.info(TAG27, `Missed assignment: #${card.short_id} "${card.title}" (${mode}) — enqueueing`);
5597
6203
  await this.pool.enqueue(card, column, cardLabels, subtasks, mode);
5598
6204
  }
5599
6205
  }
5600
6206
  if (this.stateStore && this.agentConfig) {
5601
6207
  await this.recoverStaleRuns();
5602
6208
  }
6209
+ await this.recoverStrandedInProgress(cards, columns, knownCardIds);
5603
6210
  for (const knownId of knownCardIds) {
5604
6211
  if (!allAgentCardIds.has(knownId)) {
5605
- log.info(TAG26, `Missed unassign: ${knownId} — removing`);
6212
+ log.info(TAG27, `Missed unassign: ${knownId} — removing`);
5606
6213
  await this.pool.removeCard(knownId);
5607
6214
  }
5608
6215
  }
5609
- log.debug(TAG26, `Reconciled: ${assignedCards.length} assigned, ${knownCardIds.size} known`);
6216
+ await this.releaseStalledApprovals(cards, columns, knownCardIds);
6217
+ log.debug(TAG27, `Reconciled: ${assignedCards.length} assigned, ${knownCardIds.size} known`);
5610
6218
  } catch (err) {
5611
- log.error(TAG26, `Heartbeat failed: ${err instanceof Error ? err.message : err}`);
6219
+ log.error(TAG27, `Heartbeat failed: ${err instanceof Error ? err.message : err}`);
5612
6220
  }
5613
6221
  }
5614
6222
  }
5615
- var TAG26 = "reconcile";
6223
+ var TAG27 = "reconcile";
5616
6224
  var init_reconcile = __esm(() => {
5617
6225
  init_board_helpers();
5618
6226
  init_log();
@@ -5633,6 +6241,7 @@ function prettyBanner(config, version) {
5633
6241
  const checks = [];
5634
6242
  let projectName;
5635
6243
  let gitProvider;
6244
+ let httpPort;
5636
6245
  let failed = false;
5637
6246
  let rendered = false;
5638
6247
  return {
@@ -5642,11 +6251,14 @@ function prettyBanner(config, version) {
5642
6251
  setGitProvider(provider) {
5643
6252
  gitProvider = provider;
5644
6253
  },
6254
+ setHttpPort(port) {
6255
+ httpPort = port;
6256
+ },
5645
6257
  check(message) {
5646
6258
  checks.push({ kind: "ok", message });
5647
6259
  },
5648
6260
  warn(message) {
5649
- log.warn(TAG27, message);
6261
+ log.warn(TAG28, message);
5650
6262
  checks.push({ kind: "warn", message: message.split(`
5651
6263
  `, 1)[0] });
5652
6264
  },
@@ -5662,6 +6274,7 @@ function prettyBanner(config, version) {
5662
6274
  config,
5663
6275
  projectName,
5664
6276
  gitProvider,
6277
+ httpPort,
5665
6278
  checks,
5666
6279
  readyMessage: message
5667
6280
  });
@@ -5670,22 +6283,25 @@ function prettyBanner(config, version) {
5670
6283
  };
5671
6284
  }
5672
6285
  function jsonBanner(config, version) {
5673
- log.info(TAG27, `Harmony Agent Daemon v${version} starting...`);
5674
- log.info(TAG27, `Project: ${config.projectId} | Pool: ${config.agent.poolSize} | Model: ${config.agent.claude.model} | Pickup: ${config.agent.pickupColumns.join(", ")}`);
6286
+ log.info(TAG28, `Harmony Agent Daemon v${version} starting...`);
6287
+ log.info(TAG28, `Project: ${config.projectId} | Pool: ${config.agent.poolSize} | Model: ${config.agent.claude.model} | Pickup: ${config.agent.pickupColumns.join(", ")}`);
5675
6288
  if (config.agent.review.enabled) {
5676
- log.info(TAG27, `Review: enabled | Columns: ${config.agent.review.pickupColumns.join(", ")} | → ${config.agent.review.moveToColumn} / ${config.agent.review.failColumn}`);
6289
+ log.info(TAG28, `Review: enabled | Columns: ${config.agent.review.pickupColumns.join(", ")} | → ${config.agent.review.moveToColumn} / ${config.agent.review.failColumn}`);
5677
6290
  }
5678
6291
  let failed = false;
5679
6292
  return {
5680
6293
  setProjectName(_name) {},
5681
6294
  setGitProvider(provider) {
5682
- log.info(TAG27, `Git provider: ${provider}`);
6295
+ log.info(TAG28, `Git provider: ${provider}`);
6296
+ },
6297
+ setHttpPort(port) {
6298
+ log.info(TAG28, `HTTP server on port ${port}`);
5683
6299
  },
5684
6300
  check(message) {
5685
- log.info(TAG27, message);
6301
+ log.info(TAG28, message);
5686
6302
  },
5687
6303
  warn(message) {
5688
- log.warn(TAG27, message);
6304
+ log.warn(TAG28, message);
5689
6305
  },
5690
6306
  fail() {
5691
6307
  failed = true;
@@ -5693,17 +6309,25 @@ function jsonBanner(config, version) {
5693
6309
  async ready(message) {
5694
6310
  if (failed)
5695
6311
  return;
5696
- log.info(TAG27, message);
6312
+ log.info(TAG28, message);
5697
6313
  }
5698
6314
  };
5699
6315
  }
5700
6316
  function renderPretty(input) {
5701
- const { version, config, projectName, gitProvider, checks, readyMessage } = input;
6317
+ const {
6318
+ version,
6319
+ config,
6320
+ projectName,
6321
+ gitProvider,
6322
+ httpPort,
6323
+ checks,
6324
+ readyMessage
6325
+ } = input;
5702
6326
  const lines = [];
5703
6327
  lines.push("");
5704
6328
  lines.push(titleRule(`Harmony Agent Daemon v${version}`));
5705
6329
  lines.push("");
5706
- for (const row of configRows(config, projectName, gitProvider)) {
6330
+ for (const row of configRows(config, projectName, gitProvider, httpPort)) {
5707
6331
  lines.push(` ${dim(row.label.padEnd(9))} ${row.value}`);
5708
6332
  }
5709
6333
  lines.push("");
@@ -5717,7 +6341,7 @@ function renderPretty(input) {
5717
6341
  return lines.join(`
5718
6342
  `);
5719
6343
  }
5720
- function configRows(config, projectName, gitProvider) {
6344
+ function configRows(config, projectName, gitProvider, httpPort) {
5721
6345
  const rows = [];
5722
6346
  const projectLabel = projectName ? `${projectName} (${shortenId(config.projectId)})` : shortenId(config.projectId);
5723
6347
  rows.push({ label: "Project", value: projectLabel });
@@ -5733,7 +6357,7 @@ function configRows(config, projectName, gitProvider) {
5733
6357
  if (gitProvider)
5734
6358
  tail.push(gitProvider);
5735
6359
  if (config.agent.http.enabled) {
5736
- tail.push(`HTTP http://${config.agent.http.bindAddr}:${config.agent.http.port}`);
6360
+ tail.push(`HTTP http://${config.agent.http.bindAddr}:${httpPort ?? config.agent.http.port}`);
5737
6361
  }
5738
6362
  if (tail.length > 0) {
5739
6363
  rows.push({ label: "Git", value: tail.join(" · ") });
@@ -5760,7 +6384,7 @@ function cyan(s) {
5760
6384
  function yellow(s) {
5761
6385
  return `${ANSI.yellow}${s}${ANSI.reset}`;
5762
6386
  }
5763
- var TAG27 = "daemon", RULE_WIDTH = 70, ANSI;
6387
+ var TAG28 = "daemon", RULE_WIDTH = 70, ANSI;
5764
6388
  var init_startup_banner = __esm(() => {
5765
6389
  init_log();
5766
6390
  ANSI = {
@@ -5907,18 +6531,18 @@ class Watcher {
5907
6531
  }
5908
6532
  async start() {
5909
6533
  if (!isPretty()) {
5910
- log.info(TAG28, "Connecting to Supabase realtime (broadcast)...");
6534
+ log.info(TAG29, "Connecting to Supabase realtime (broadcast)...");
5911
6535
  }
5912
6536
  this.supabase = createClient(this.credentials.supabaseUrl, this.credentials.supabaseAnonKey);
5913
6537
  const presenceChannel = this.supabase.channel(`board-presence-${this.projectId}`);
5914
6538
  const channel = this.supabase.channel(`board-${this.projectId}`).on("broadcast", { event: "card_update" }, (msg) => {
5915
- log.debug(TAG28, `Broadcast: card_update ${JSON.stringify(msg.payload)}`);
6539
+ log.debug(TAG29, `Broadcast: card_update ${JSON.stringify(msg.payload)}`);
5916
6540
  this.onCardBroadcast({
5917
6541
  event: "card_update",
5918
6542
  payload: msg.payload ?? {}
5919
6543
  });
5920
6544
  }).on("broadcast", { event: "card_created" }, (msg) => {
5921
- log.debug(TAG28, `Broadcast: card_created ${JSON.stringify(msg.payload)}`);
6545
+ log.debug(TAG29, `Broadcast: card_created ${JSON.stringify(msg.payload)}`);
5922
6546
  this.onCardBroadcast({
5923
6547
  event: "card_created",
5924
6548
  payload: msg.payload ?? {}
@@ -5928,29 +6552,29 @@ class Watcher {
5928
6552
  const cardId = payload.card_id;
5929
6553
  const command = payload.command;
5930
6554
  if (cardId && command) {
5931
- log.info(TAG28, `Broadcast: agent_command ${command} for ${cardId}`);
6555
+ log.info(TAG29, `Broadcast: agent_command ${command} for ${cardId}`);
5932
6556
  this.onAgentCommand?.({ cardId, command });
5933
6557
  }
5934
6558
  }).subscribe((status) => {
5935
6559
  if (status === "SUBSCRIBED") {
5936
6560
  this.connected = true;
5937
6561
  if (!isPretty() || !this.suppressStartupLogs) {
5938
- log.info(TAG28, "Broadcast subscription active");
6562
+ log.info(TAG29, "Broadcast subscription active");
5939
6563
  }
5940
6564
  this.maybeResolveReady();
5941
6565
  } else if (status === "CHANNEL_ERROR") {
5942
6566
  this.connected = false;
5943
- log.error(TAG28, "Broadcast channel error — will rely on reconciliation");
6567
+ log.error(TAG29, "Broadcast channel error — will rely on reconciliation");
5944
6568
  } else if (status === "TIMED_OUT") {
5945
6569
  this.connected = false;
5946
- log.warn(TAG28, "Broadcast subscription timed out — retrying...");
6570
+ log.warn(TAG29, "Broadcast subscription timed out — retrying...");
5947
6571
  } else if (status === "CLOSED") {
5948
6572
  this.connected = false;
5949
6573
  }
5950
6574
  });
5951
6575
  this.channel = channel;
5952
6576
  presenceChannel.on("presence", { event: "sync" }, () => {
5953
- log.debug(TAG28, "Presence sync");
6577
+ log.debug(TAG29, "Presence sync");
5954
6578
  }).subscribe(async (status) => {
5955
6579
  if (status === "SUBSCRIBED") {
5956
6580
  await presenceChannel.track({
@@ -5963,7 +6587,7 @@ class Watcher {
5963
6587
  agentName: this.identity.agentName
5964
6588
  });
5965
6589
  if (!isPretty() || !this.suppressStartupLogs) {
5966
- log.info(TAG28, "Presence tracked on board-presence channel");
6590
+ log.info(TAG29, "Presence tracked on board-presence channel");
5967
6591
  }
5968
6592
  this.presenceTracked = true;
5969
6593
  this.maybeResolveReady();
@@ -5985,10 +6609,10 @@ class Watcher {
5985
6609
  this.supabase = null;
5986
6610
  }
5987
6611
  this.connected = false;
5988
- log.info(TAG28, "Broadcast subscription stopped");
6612
+ log.info(TAG29, "Broadcast subscription stopped");
5989
6613
  }
5990
6614
  }
5991
- var TAG28 = "watcher";
6615
+ var TAG29 = "watcher";
5992
6616
  var init_watcher = __esm(() => {
5993
6617
  init_log();
5994
6618
  });
@@ -6063,10 +6687,10 @@ function runWorktreeGc(basePath, store, opts = {}) {
6063
6687
  });
6064
6688
  } catch {}
6065
6689
  if (result.removed.length > 0) {
6066
- log.info(TAG29, `GC removed ${result.removed.length} orphan worktree(s): ${result.removed.map((p) => p.split("/").pop()).join(", ")}`);
6690
+ log.info(TAG30, `GC removed ${result.removed.length} orphan worktree(s): ${result.removed.map((p) => p.split("/").pop()).join(", ")}`);
6067
6691
  }
6068
6692
  if (result.errors.length > 0) {
6069
- log.warn(TAG29, `GC had ${result.errors.length} error(s): ${result.errors.map((e) => `${e.path}: ${e.error}`).join("; ")}`);
6693
+ log.warn(TAG30, `GC had ${result.errors.length} error(s): ${result.errors.map((e) => `${e.path}: ${e.error}`).join("; ")}`);
6070
6694
  }
6071
6695
  return result;
6072
6696
  }
@@ -6143,10 +6767,10 @@ function pruneFailedRemoteBranches(opts) {
6143
6767
  }
6144
6768
  }
6145
6769
  if (result.removed.length > 0) {
6146
- log.info(TAG29, `Pruned ${result.removed.length} stale remote branch(es) under ${opts.prefix}: ${result.removed.join(", ")}`);
6770
+ log.info(TAG30, `Pruned ${result.removed.length} stale remote branch(es) under ${opts.prefix}: ${result.removed.join(", ")}`);
6147
6771
  }
6148
6772
  if (result.errors.length > 0) {
6149
- log.warn(TAG29, `Remote branch GC had ${result.errors.length} error(s): ${result.errors.map((e) => `${e.ref}: ${e.error}`).join("; ")}`);
6773
+ log.warn(TAG30, `Remote branch GC had ${result.errors.length} error(s): ${result.errors.map((e) => `${e.ref}: ${e.error}`).join("; ")}`);
6150
6774
  }
6151
6775
  return result;
6152
6776
  }
@@ -6177,13 +6801,13 @@ class WorktreeGc {
6177
6801
  try {
6178
6802
  runWorktreeGc(this.basePath, this.store);
6179
6803
  } catch (err) {
6180
- log.warn(TAG29, `GC tick failed: ${err instanceof Error ? err.message : err}`);
6804
+ log.warn(TAG30, `GC tick failed: ${err instanceof Error ? err.message : err}`);
6181
6805
  }
6182
6806
  if (this.remoteOpts) {
6183
6807
  try {
6184
6808
  pruneFailedRemoteBranches(this.remoteOpts);
6185
6809
  } catch (err) {
6186
- log.warn(TAG29, `Remote GC tick failed: ${err instanceof Error ? err.message : err}`);
6810
+ log.warn(TAG30, `Remote GC tick failed: ${err instanceof Error ? err.message : err}`);
6187
6811
  }
6188
6812
  }
6189
6813
  }
@@ -6197,7 +6821,7 @@ function getRepoRoot2() {
6197
6821
  return null;
6198
6822
  }
6199
6823
  }
6200
- var TAG29 = "worktree-gc";
6824
+ var TAG30 = "worktree-gc";
6201
6825
  var init_worktree_gc = __esm(() => {
6202
6826
  init_log();
6203
6827
  init_worktree();
@@ -6279,7 +6903,7 @@ async function main() {
6279
6903
  } catch (err) {
6280
6904
  if (err instanceof ConfigValidationError) {
6281
6905
  banner.fail();
6282
- log.error(TAG30, err.message);
6906
+ log.error(TAG31, err.message);
6283
6907
  process.exit(1);
6284
6908
  }
6285
6909
  throw err;
@@ -6388,25 +7012,28 @@ async function main() {
6388
7012
  if (shuttingDown)
6389
7013
  return;
6390
7014
  shuttingDown = true;
6391
- log.info(TAG30, `Received ${signal}, shutting down gracefully...`);
7015
+ log.info(TAG31, `Received ${signal}, shutting down gracefully...`);
6392
7016
  reconciler.stop();
6393
7017
  mergeMonitor?.stop();
6394
7018
  worktreeGc.stop();
6395
- await httpServer?.stop();
7019
+ if (httpServer) {
7020
+ clearDaemonPort(config.projectId, process.pid);
7021
+ await httpServer.stop();
7022
+ }
6396
7023
  await watcher.stop();
6397
7024
  await pool.shutdown();
6398
- log.info(TAG30, "Daemon stopped.");
7025
+ log.info(TAG31, "Daemon stopped.");
6399
7026
  process.exit(exitCode);
6400
7027
  };
6401
7028
  process.on("SIGINT", () => shutdown("SIGINT"));
6402
7029
  process.on("SIGTERM", () => shutdown("SIGTERM"));
6403
7030
  process.on("uncaughtException", (err) => {
6404
- log.error(TAG30, `Uncaught exception: ${err.message}`);
7031
+ log.error(TAG31, `Uncaught exception: ${err.message}`);
6405
7032
  exitCode = 1;
6406
7033
  shutdown("uncaughtException");
6407
7034
  });
6408
7035
  process.on("unhandledRejection", (reason) => {
6409
- log.error(TAG30, `Unhandled rejection: ${reason instanceof Error ? reason.message : String(reason)}`);
7036
+ log.error(TAG31, `Unhandled rejection: ${reason instanceof Error ? reason.message : String(reason)}`);
6410
7037
  exitCode = 1;
6411
7038
  shutdown("unhandledRejection");
6412
7039
  });
@@ -6416,7 +7043,13 @@ async function main() {
6416
7043
  worktreeGc.start();
6417
7044
  if (httpServer) {
6418
7045
  try {
6419
- await httpServer.start();
7046
+ const boundPort = await httpServer.start();
7047
+ recordDaemonPort(config.projectId, {
7048
+ port: boundPort,
7049
+ pid: process.pid,
7050
+ bindAddr: config.agent.http.bindAddr
7051
+ });
7052
+ banner.setHttpPort(boundPort);
6420
7053
  } catch (err) {
6421
7054
  banner.warn(`HTTP server failed to bind: ${err instanceof Error ? err.message : err}`);
6422
7055
  }
@@ -6453,34 +7086,34 @@ async function handleBroadcast(event, client, pool, config, agentId) {
6453
7086
  if (assignedAgentId === undefined)
6454
7087
  return;
6455
7088
  if (assignedAgentId === agentId) {
6456
- log.info(TAG30, `Broadcast: card ${cardId} assigned to agent`);
7089
+ log.info(TAG31, `Broadcast: card ${cardId} assigned to agent`);
6457
7090
  try {
6458
7091
  await tryEnqueueCard(cardId, client, pool, config, agentId);
6459
7092
  } catch (err) {
6460
- log.error(TAG30, `Failed to process assignment: ${err instanceof Error ? err.message : err}`);
7093
+ log.error(TAG31, `Failed to process assignment: ${err instanceof Error ? err.message : err}`);
6461
7094
  }
6462
7095
  } else if (pool.isCardKnown(cardId)) {
6463
- log.info(TAG30, `Broadcast: card ${cardId} unassigned from agent`);
7096
+ log.info(TAG31, `Broadcast: card ${cardId} unassigned from agent`);
6464
7097
  await pool.removeCard(cardId);
6465
7098
  }
6466
7099
  }
6467
7100
  async function tryEnqueueCard(cardId, client, pool, config, agentId) {
6468
7101
  const { card } = await client.getCard(cardId);
6469
7102
  if (card.assigned_agent_id !== agentId) {
6470
- log.debug(TAG30, `Card ${cardId} no longer assigned to agent — skipping`);
7103
+ log.debug(TAG31, `Card ${cardId} no longer assigned to agent — skipping`);
6471
7104
  return;
6472
7105
  }
6473
7106
  const board = await client.getBoard(config.projectId, { summary: true });
6474
7107
  const columns = board.columns;
6475
7108
  const column = columns.find((c) => c.id === card.column_id);
6476
7109
  if (!column) {
6477
- log.warn(TAG30, `Column not found for card ${cardId}`);
7110
+ log.warn(TAG31, `Column not found for card ${cardId}`);
6478
7111
  return;
6479
7112
  }
6480
7113
  const isPickupColumn = config.agent.pickupColumns.some((name) => name.toLowerCase() === column.name.toLowerCase());
6481
7114
  const isReviewColumn = config.agent.review.enabled && config.agent.review.pickupColumns.some((name) => name.toLowerCase() === column.name.toLowerCase());
6482
7115
  if (!isPickupColumn && !isReviewColumn) {
6483
- log.info(TAG30, `Card #${card.short_id} is in "${column.name}", not a pickup/review column — skipping`);
7116
+ log.info(TAG31, `Card #${card.short_id} is in "${column.name}", not a pickup/review column — skipping`);
6484
7117
  return;
6485
7118
  }
6486
7119
  const mode = isReviewColumn ? "review" : "implement";
@@ -6488,16 +7121,16 @@ async function tryEnqueueCard(cardId, client, pool, config, agentId) {
6488
7121
  const cardLabels = resolveCardLabels(card, labelMap);
6489
7122
  const subtasks = card.subtasks ?? [];
6490
7123
  if (mode === "review" && config.agent.review.approvedLabel && hasLabel(cardLabels, config.agent.review.approvedLabel)) {
6491
- log.debug(TAG30, `Card #${card.short_id} already has "${config.agent.review.approvedLabel}" — skipping review`);
7124
+ log.debug(TAG31, `Card #${card.short_id} already has "${config.agent.review.approvedLabel}" — skipping review`);
6492
7125
  return;
6493
7126
  }
6494
7127
  if (mode === "review" && !extractBranchFromDescription(card.description)) {
6495
- log.info(TAG30, `Card #${card.short_id} has no branch reference — skipping auto-review`);
7128
+ log.info(TAG31, `Card #${card.short_id} has no branch reference — skipping auto-review`);
6496
7129
  return;
6497
7130
  }
6498
7131
  await pool.enqueue(card, column, cardLabels, subtasks, mode);
6499
7132
  }
6500
- var TAG30 = "daemon", PKG_VERSION;
7133
+ var TAG31 = "daemon", PKG_VERSION;
6501
7134
  var init_src = __esm(() => {
6502
7135
  init_board_helpers();
6503
7136
  init_config();
@@ -6507,6 +7140,7 @@ var init_src = __esm(() => {
6507
7140
  init_log();
6508
7141
  init_merge_monitor();
6509
7142
  init_pool();
7143
+ init_port_registry();
6510
7144
  init_reconcile();
6511
7145
  init_recovery();
6512
7146
  init_review_worktree();
@@ -6546,8 +7180,12 @@ async function runDaemon() {
6546
7180
  }
6547
7181
  async function httpCall(path, init) {
6548
7182
  const { loadDaemonConfig: loadDaemonConfig2 } = await Promise.resolve().then(() => (init_config(), exports_config));
7183
+ const { lookupDaemonPort: lookupDaemonPort2 } = await Promise.resolve().then(() => (init_port_registry(), exports_port_registry));
6549
7184
  const cfg = loadDaemonConfig2();
6550
- const url = `http://${cfg.agent.http.bindAddr}:${cfg.agent.http.port}${path}`;
7185
+ const recorded = lookupDaemonPort2(cfg.projectId);
7186
+ const bindAddr = recorded?.bindAddr ?? cfg.agent.http.bindAddr;
7187
+ const port = recorded?.port ?? cfg.agent.http.port;
7188
+ const url = `http://${bindAddr}:${port}${path}`;
6551
7189
  return fetch(url, init);
6552
7190
  }
6553
7191
  function printStatus(body) {