@gethmy/agent 1.9.1 → 1.9.2

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 +739 -97
  2. package/dist/index.js +734 -96
  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;
@@ -4692,6 +4987,7 @@ class Worker {
4692
4987
  projectId;
4693
4988
  stateStore;
4694
4989
  onCardCompleted;
4990
+ onApiError;
4695
4991
  id;
4696
4992
  state = "idle";
4697
4993
  cardId = null;
@@ -4708,7 +5004,7 @@ class Worker {
4708
5004
  verificationFailed = false;
4709
5005
  sessionId = null;
4710
5006
  runId = null;
4711
- constructor(id, config, client, agentId, onDone, workspaceId, projectId, stateStore, onCardCompleted) {
5007
+ constructor(id, config, client, agentId, onDone, workspaceId, projectId, stateStore, onCardCompleted, onApiError) {
4712
5008
  this.config = config;
4713
5009
  this.client = client;
4714
5010
  this.agentId = agentId;
@@ -4717,6 +5013,7 @@ class Worker {
4717
5013
  this.projectId = projectId;
4718
5014
  this.stateStore = stateStore;
4719
5015
  this.onCardCompleted = onCardCompleted;
5016
+ this.onApiError = onApiError;
4720
5017
  this.id = id;
4721
5018
  }
4722
5019
  startHeartbeat() {
@@ -4757,7 +5054,7 @@ class Worker {
4757
5054
  return this.state === "idle";
4758
5055
  }
4759
5056
  get isActive() {
4760
- return this.state === "preparing" || this.state === "running" || this.state === "verifying" || this.state === "completing";
5057
+ return this.state === "preparing" || this.state === "planning" || this.state === "running" || this.state === "verifying" || this.state === "completing";
4761
5058
  }
4762
5059
  async run(card, column, labels, subtasks) {
4763
5060
  this.aborted = false;
@@ -4803,7 +5100,7 @@ class Worker {
4803
5100
  }
4804
5101
  this.sessionId = sid;
4805
5102
  await this.recordPhase("preparing");
4806
- const moved = await moveCardAndAddLabel(this.client, card, "In Progress", "agent");
5103
+ const moved = await moveCardAndAddLabel(this.client, card, IN_PROGRESS_COLUMN, "agent");
4807
5104
  if (!moved) {
4808
5105
  log.warn(this.tag, `Card #${card.short_id} was NOT moved to "In Progress" — check API logs`);
4809
5106
  }
@@ -4812,8 +5109,6 @@ class Worker {
4812
5109
  this.worktreePath = createWorktree(this.config.worktree.basePath, this.config.worktree.baseBranch, this.branchName);
4813
5110
  if (this.aborted)
4814
5111
  return;
4815
- this.state = "running";
4816
- await this.recordPhase("running");
4817
5112
  const enriched = {
4818
5113
  card,
4819
5114
  column,
@@ -4821,6 +5116,19 @@ class Worker {
4821
5116
  subtasks,
4822
5117
  mode: "implement"
4823
5118
  };
5119
+ if (shouldPlan(enriched, this.config.planning)) {
5120
+ this.state = "planning";
5121
+ await this.recordPhase("planning");
5122
+ const parked = await this.runPlanningPhase(enriched);
5123
+ if (this.aborted)
5124
+ return;
5125
+ if (parked) {
5126
+ log.info(this.tag, `#${card.short_id} parked for plan approval — ending run`);
5127
+ return;
5128
+ }
5129
+ }
5130
+ this.state = "running";
5131
+ await this.recordPhase("running");
4824
5132
  const prompt = await buildPrompt(enriched, this.branchName, this.worktreePath, this.client, this.workspaceId, this.projectId);
4825
5133
  await this.client.updateAgentProgress(card.id, {
4826
5134
  agentIdentifier: agentIdentifier(this.id),
@@ -4859,21 +5167,46 @@ class Worker {
4859
5167
  this.state = "error";
4860
5168
  const msg = err instanceof Error ? err.message : String(err);
4861
5169
  log.error(this.tag, `Error on #${card.short_id}: ${msg}`);
5170
+ const rawStderr = err?.stderr;
5171
+ const errClass = classifyRunError(typeof rawStderr === "string" && rawStderr ? rawStderr : msg);
5172
+ const apiError = errClass.kind !== null;
5173
+ if (apiError) {
5174
+ try {
5175
+ this.onApiError?.(errClass);
5176
+ } catch {}
5177
+ }
5178
+ if (this.worktreePath) {
5179
+ try {
5180
+ cleanupWorktree(this.worktreePath, this.branchName ?? undefined);
5181
+ } catch {
5182
+ log.warn(this.tag, "Failed to cleanup worktree before requeue");
5183
+ }
5184
+ this.worktreePath = null;
5185
+ }
5186
+ const failureReason = apiError ? errClass.kind : "other";
5187
+ const failureSummary = apiError ? describeApiError(errClass.kind) : `Run failed: ${msg.slice(0, 300)}`;
4862
5188
  try {
4863
5189
  await runTransition(this.client, card, {
5190
+ move: { columnName: this.config.pickupColumns[0] ?? "To Do" },
4864
5191
  endSession: {
4865
- status: "paused",
5192
+ status: "failed",
5193
+ failureReason,
5194
+ failureSummary,
4866
5195
  ...buildTokenPayload(this.lastSessionStats)
4867
5196
  }
4868
5197
  });
4869
5198
  } catch (tErr) {
4870
- log.error(this.tag, `endAgentSession unrecoverable on #${card.short_id}: ${tErr instanceof TransitionError ? tErr.detail : tErr}`);
5199
+ log.error(this.tag, `error transition failed on #${card.short_id}: ${tErr instanceof TransitionError ? tErr.detail : tErr}`);
4871
5200
  }
4872
5201
  if (this.runId) {
4873
5202
  try {
4874
- await this.stateStore.endRun(this.runId, "paused", msg);
5203
+ await this.stateStore.endRun(this.runId, "failed", errClass.kind ?? msg);
4875
5204
  } catch {}
4876
- await this.recordOutcome(card.id, "failure");
5205
+ if (apiError) {
5206
+ await this.stateStore.decrementAttempt(card.id);
5207
+ } else {
5208
+ await this.recordOutcome(card.id, "failure");
5209
+ }
4877
5210
  }
4878
5211
  } finally {
4879
5212
  const succeeded = this.runId && this.state !== "error" && !this.aborted && !this.verificationFailed;
@@ -5017,7 +5350,122 @@ class Worker {
5017
5350
  }
5018
5351
  }
5019
5352
  }
5020
- async spawnClaude(prompt, card, subtasks) {
5353
+ async runPlanningPhase(enriched) {
5354
+ const planning = this.config.planning;
5355
+ const { card } = enriched;
5356
+ log.info(this.tag, `Planning pass for #${card.short_id} (mode=${planning.mode}, model=${planning.model})`);
5357
+ await this.client.updateAgentProgress(card.id, {
5358
+ agentIdentifier: agentIdentifier(this.id),
5359
+ agentName: AGENT_NAME,
5360
+ status: "working",
5361
+ currentTask: "Planning approach (read-only)",
5362
+ progressPercent: 5,
5363
+ phase: "planning"
5364
+ }).catch(() => {});
5365
+ const planPrompt = buildPlanPrompt(enriched, this.worktreePath);
5366
+ let planTimedOut = false;
5367
+ const planTimeout = setTimeout(() => {
5368
+ planTimedOut = true;
5369
+ log.warn(this.tag, "Planning pass exceeded timeout — abandoning, implementing directly");
5370
+ if (this.process && !this.process.killed) {
5371
+ terminateGroup(this.process, {
5372
+ sigintTimeoutMs: 1e4,
5373
+ sigtermTimeoutMs: 5000
5374
+ }).catch(() => {});
5375
+ }
5376
+ }, Math.min(this.config.maxTimeout, PLAN_PHASE_TIMEOUT));
5377
+ try {
5378
+ await this.spawnClaude(planPrompt, card, [], {
5379
+ model: planning.model,
5380
+ maxTurns: planning.maxTurns,
5381
+ allowedTools: PLAN_ALLOWED_TOOLS,
5382
+ initialPhase: "planning"
5383
+ });
5384
+ } catch (err) {
5385
+ log.warn(this.tag, `Planning pass failed (non-fatal): ${err instanceof Error ? err.message : err}`);
5386
+ return false;
5387
+ } finally {
5388
+ clearTimeout(planTimeout);
5389
+ }
5390
+ if (this.aborted || planTimedOut)
5391
+ return false;
5392
+ const stats = this.lastSessionStats;
5393
+ if (stats?.cost) {
5394
+ const cents = Math.round(stats.cost.totalCostUsd * 100);
5395
+ if (cents > 0) {
5396
+ try {
5397
+ await this.stateStore.addCost(card.id, cents);
5398
+ } catch {}
5399
+ }
5400
+ }
5401
+ const planText = stats?.lastAssistantText ?? "";
5402
+ if (!planText.trim()) {
5403
+ log.warn(this.tag, `Planning pass for #${card.short_id} produced no text — implementing directly`);
5404
+ return false;
5405
+ }
5406
+ const artifact = extractPlanArtifact(planText, card.title);
5407
+ let planId = null;
5408
+ try {
5409
+ const result = await this.client.createPlan(this.projectId, {
5410
+ title: artifact.title,
5411
+ content: artifact.markdown,
5412
+ source: "agent",
5413
+ tasks: artifact.tasks
5414
+ });
5415
+ const plan = result?.plan;
5416
+ const createdId = plan && typeof plan === "object" && "id" in plan ? plan.id : null;
5417
+ if (createdId) {
5418
+ await this.client.updateCard(card.id, {
5419
+ planId: createdId,
5420
+ needsPlanRefresh: false
5421
+ });
5422
+ planId = createdId;
5423
+ }
5424
+ log.info(this.tag, `Stored plan ${planId ?? "(unlinked)"} for #${card.short_id} (${artifact.tasks.length} tasks)`);
5425
+ } catch (err) {
5426
+ log.warn(this.tag, `Failed to store/link plan (non-fatal): ${err instanceof Error ? err.message : err}`);
5427
+ }
5428
+ if (planning.mode === "gated" && planId) {
5429
+ try {
5430
+ if (planning.postComment) {
5431
+ await this.client.addComment(card.id, buildGatedPlanComment(artifact, planning.awaitingApprovalColumn), {
5432
+ commentType: "decision",
5433
+ agentSessionId: this.sessionId ?? undefined
5434
+ });
5435
+ }
5436
+ await runTransition(this.client, card, {
5437
+ move: { columnName: planning.awaitingApprovalColumn },
5438
+ removeLabels: ["agent"],
5439
+ endSession: {
5440
+ status: "completed",
5441
+ progressPercent: 20,
5442
+ ...buildTokenPayload(stats)
5443
+ }
5444
+ }, { store: this.stateStore, runId: this.runId ?? undefined });
5445
+ log.info(this.tag, `#${card.short_id} parked in "${planning.awaitingApprovalColumn}" for plan approval`);
5446
+ this.lastSessionStats = undefined;
5447
+ return true;
5448
+ } catch (err) {
5449
+ 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}`);
5450
+ }
5451
+ }
5452
+ if (planId && planning.postComment) {
5453
+ try {
5454
+ await this.client.addComment(card.id, buildPlanComment(artifact), {
5455
+ commentType: "decision",
5456
+ agentSessionId: this.sessionId ?? undefined
5457
+ });
5458
+ } catch (err) {
5459
+ log.warn(this.tag, `Failed to post advisory plan comment (non-fatal): ${err instanceof Error ? err.message : err}`);
5460
+ }
5461
+ }
5462
+ return false;
5463
+ }
5464
+ async spawnClaude(prompt, card, subtasks, opts = {}) {
5465
+ const model = opts.model ?? this.config.claude.model;
5466
+ const maxTurns = opts.maxTurns ?? this.config.claude.maxTurns;
5467
+ const allowedTools = opts.allowedTools ?? IMPLEMENT_ALLOWED_TOOLS;
5468
+ const initialPhase = opts.initialPhase ?? "exploring";
5021
5469
  return new Promise((resolve3, reject) => {
5022
5470
  const args = [
5023
5471
  "-p",
@@ -5025,11 +5473,11 @@ class Worker {
5025
5473
  "--output-format",
5026
5474
  "stream-json",
5027
5475
  "--model",
5028
- this.config.claude.model,
5476
+ model,
5029
5477
  "--max-turns",
5030
- String(this.config.claude.maxTurns),
5478
+ String(maxTurns),
5031
5479
  "--allowedTools",
5032
- "Bash,Read,Write,Edit,Glob,Grep,Agent,mcp__harmony__*",
5480
+ allowedTools,
5033
5481
  ...this.config.claude.additionalArgs,
5034
5482
  "--",
5035
5483
  prompt
@@ -5048,7 +5496,7 @@ class Worker {
5048
5496
  stdio: ["ignore", "pipe", "pipe"]
5049
5497
  });
5050
5498
  const parser = new StreamParser;
5051
- this.progressTracker = new ProgressTracker(this.client, card.id, this.id, subtasks);
5499
+ this.progressTracker = new ProgressTracker(this.client, card.id, this.id, subtasks, initialPhase);
5052
5500
  if (this.sessionId) {
5053
5501
  this.progressTracker.setSessionId(this.sessionId);
5054
5502
  }
@@ -5093,7 +5541,9 @@ class Worker {
5093
5541
  } else if (code === 0) {
5094
5542
  resolve3();
5095
5543
  } else {
5096
- reject(new Error(`claude exited with code ${code}${stderr ? `: ${stderr.slice(0, 500)}` : ""}`));
5544
+ const err = new Error(`claude exited with code ${code}${stderr ? `: ${stderr.slice(0, 500)}` : ""}`);
5545
+ err.stderr = stderr;
5546
+ reject(err);
5097
5547
  }
5098
5548
  });
5099
5549
  });
@@ -5125,11 +5575,13 @@ class Worker {
5125
5575
  this.sessionId = null;
5126
5576
  }
5127
5577
  }
5128
- var TAG23 = "worker", CANCEL_SIGINT_TIMEOUT2 = 30000, CANCEL_SIGTERM_TIMEOUT2 = 1e4;
5578
+ 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
5579
  var init_worker = __esm(() => {
5130
5580
  init_board_helpers();
5131
5581
  init_completion();
5582
+ init_error_classifier();
5132
5583
  init_log();
5584
+ init_plan_phase();
5133
5585
  init_process_group();
5134
5586
  init_progress_tracker();
5135
5587
  init_prompt();
@@ -5139,6 +5591,7 @@ var init_worker = __esm(() => {
5139
5591
  init_transitions();
5140
5592
  init_types();
5141
5593
  init_worktree();
5594
+ PLAN_PHASE_TIMEOUT = 10 * 60000;
5142
5595
  });
5143
5596
 
5144
5597
  // src/pool.ts
@@ -5154,6 +5607,8 @@ class Pool {
5154
5607
  budget;
5155
5608
  sleepGuard = new SleepGuard;
5156
5609
  shuttingDown = false;
5610
+ apiCooldownUntil = 0;
5611
+ authPaused = false;
5157
5612
  onCardCompleted = null;
5158
5613
  constructor(config, client, _userEmail, workspaceId, projectId, stateStore, agentId) {
5159
5614
  this.client = client;
@@ -5172,7 +5627,7 @@ class Pool {
5172
5627
  }
5173
5628
  }, workspaceId, projectId, stateStore, async (completedCard) => {
5174
5629
  await this.onCardCompleted?.(completedCard);
5175
- }));
5630
+ }, (err) => this.noteApiError(err)));
5176
5631
  }
5177
5632
  if (config.review.enabled) {
5178
5633
  for (let i = 0;i < config.review.poolSize; i++) {
@@ -5193,6 +5648,17 @@ class Pool {
5193
5648
  return;
5194
5649
  }
5195
5650
  if (mode === "implement") {
5651
+ if (this.authPaused) {
5652
+ log.debug(TAG24, `#${card.short_id} held — agent paused (auth error)`);
5653
+ await this.emitWaiting(card.id, "Agent paused — Anthropic auth error, check API credentials");
5654
+ return;
5655
+ }
5656
+ const cooldownMs = this.apiCooldownRemainingMs();
5657
+ if (cooldownMs > 0) {
5658
+ log.debug(TAG24, `#${card.short_id} held — API cooldown ${Math.round(cooldownMs / 1000)}s remaining`);
5659
+ await this.emitWaiting(card.id, `Paused — Anthropic API limit, retrying in ~${Math.round(cooldownMs / 1000)}s`);
5660
+ return;
5661
+ }
5196
5662
  const decision = this.budget.check(card.id);
5197
5663
  if (!decision.allow) {
5198
5664
  if (decision.reason === "daily_budget") {
@@ -5242,6 +5708,26 @@ class Pool {
5242
5708
  log.debug(TAG24, `waiting emit failed for ${cardId}: ${err instanceof Error ? err.message : err}`);
5243
5709
  }
5244
5710
  }
5711
+ noteApiError(err) {
5712
+ if (!err.kind)
5713
+ return;
5714
+ if (err.kind === "auth") {
5715
+ if (!this.authPaused) {
5716
+ log.error(TAG24, "Auth error from Claude CLI — pausing implement pickups until the daemon is restarted with valid credentials");
5717
+ }
5718
+ this.authPaused = true;
5719
+ return;
5720
+ }
5721
+ const cooldownMs = err.retryAfterMs ?? cooldownMsFor(err.kind);
5722
+ const until = Date.now() + cooldownMs;
5723
+ if (until > this.apiCooldownUntil) {
5724
+ this.apiCooldownUntil = until;
5725
+ log.warn(TAG24, `${describeApiError(err.kind)} — pausing implement pickups for ${Math.round(cooldownMs / 1000)}s`);
5726
+ }
5727
+ }
5728
+ apiCooldownRemainingMs() {
5729
+ return Math.max(0, this.apiCooldownUntil - Date.now());
5730
+ }
5245
5731
  async removeCard(cardId) {
5246
5732
  await this.stateStore.resetAttempts(cardId);
5247
5733
  this.lastWaitingEmit.delete(cardId);
@@ -5369,6 +5855,7 @@ class Pool {
5369
5855
  }
5370
5856
  var TAG24 = "pool";
5371
5857
  var init_pool = __esm(() => {
5858
+ init_error_classifier();
5372
5859
  init_log();
5373
5860
  init_queue();
5374
5861
  init_review_worker();
@@ -5378,6 +5865,78 @@ var init_pool = __esm(() => {
5378
5865
  init_worker();
5379
5866
  });
5380
5867
 
5868
+ // src/port-registry.ts
5869
+ var exports_port_registry = {};
5870
+ __export(exports_port_registry, {
5871
+ recordDaemonPort: () => recordDaemonPort,
5872
+ lookupDaemonPort: () => lookupDaemonPort,
5873
+ defaultRegistryPath: () => defaultRegistryPath,
5874
+ clearDaemonPort: () => clearDaemonPort
5875
+ });
5876
+ import {
5877
+ existsSync as existsSync5,
5878
+ mkdirSync as mkdirSync3,
5879
+ readFileSync as readFileSync4,
5880
+ renameSync as renameSync2,
5881
+ writeFileSync as writeFileSync2
5882
+ } from "node:fs";
5883
+ import { homedir as homedir4 } from "node:os";
5884
+ import { dirname as dirname2, join as join4 } from "node:path";
5885
+ function defaultRegistryPath() {
5886
+ return join4(homedir4(), ".harmony-mcp", "agent-ports.json");
5887
+ }
5888
+ function load(path) {
5889
+ if (!existsSync5(path))
5890
+ return {};
5891
+ try {
5892
+ const raw = readFileSync4(path, "utf-8");
5893
+ const parsed = JSON.parse(raw);
5894
+ if (parsed && typeof parsed === "object")
5895
+ return parsed;
5896
+ return {};
5897
+ } catch (err) {
5898
+ log.warn(TAG25, `failed to read ${path}: ${err instanceof Error ? err.message : err}`);
5899
+ return {};
5900
+ }
5901
+ }
5902
+ function save(path, registry) {
5903
+ const dir = dirname2(path);
5904
+ if (!existsSync5(dir))
5905
+ mkdirSync3(dir, { recursive: true });
5906
+ const tmp = `${path}.tmp`;
5907
+ writeFileSync2(tmp, JSON.stringify(registry, null, 2), "utf-8");
5908
+ renameSync2(tmp, path);
5909
+ }
5910
+ function recordDaemonPort(projectId, entry, path = defaultRegistryPath()) {
5911
+ try {
5912
+ const registry = load(path);
5913
+ registry[projectId] = { ...entry, updatedAt: Date.now() };
5914
+ save(path, registry);
5915
+ } catch (err) {
5916
+ log.warn(TAG25, `failed to record port for ${projectId}: ${err instanceof Error ? err.message : err}`);
5917
+ }
5918
+ }
5919
+ function lookupDaemonPort(projectId, path = defaultRegistryPath()) {
5920
+ const registry = load(path);
5921
+ return registry[projectId] ?? null;
5922
+ }
5923
+ function clearDaemonPort(projectId, pid, path = defaultRegistryPath()) {
5924
+ try {
5925
+ const registry = load(path);
5926
+ const existing = registry[projectId];
5927
+ if (!existing || existing.pid !== pid)
5928
+ return;
5929
+ delete registry[projectId];
5930
+ save(path, registry);
5931
+ } catch (err) {
5932
+ log.warn(TAG25, `failed to clear port for ${projectId}: ${err instanceof Error ? err.message : err}`);
5933
+ }
5934
+ }
5935
+ var TAG25 = "port-registry";
5936
+ var init_port_registry = __esm(() => {
5937
+ init_log();
5938
+ });
5939
+
5381
5940
  // src/recovery.ts
5382
5941
  function isProcessAlive(pid, currentPid) {
5383
5942
  if (pid === currentPid)
@@ -5394,7 +5953,7 @@ async function fetchCardSafely(client, cardId) {
5394
5953
  const { card } = await client.getCard(cardId);
5395
5954
  return card;
5396
5955
  } catch (err) {
5397
- log.warn(TAG25, `cannot fetch card ${cardId}: ${err instanceof Error ? err.message : err}`);
5956
+ log.warn(TAG26, `cannot fetch card ${cardId}: ${err instanceof Error ? err.message : err}`);
5398
5957
  return null;
5399
5958
  }
5400
5959
  }
@@ -5404,7 +5963,7 @@ async function recoverOrphans(store, client, config) {
5404
5963
  return [];
5405
5964
  }
5406
5965
  const outcomes = [];
5407
- log.info(TAG25, `recovering ${active.length} orphan run(s) from prior daemon`);
5966
+ log.info(TAG26, `recovering ${active.length} orphan run(s) from prior daemon`);
5408
5967
  for (const run of active) {
5409
5968
  const outcome = {
5410
5969
  runId: run.runId,
@@ -5416,11 +5975,11 @@ async function recoverOrphans(store, client, config) {
5416
5975
  };
5417
5976
  outcomes.push(outcome);
5418
5977
  if (isProcessAlive(run.daemonPid, process.pid)) {
5419
- log.warn(TAG25, `run ${run.runId} claims live daemon pid ${run.daemonPid} — skipping`);
5978
+ log.warn(TAG26, `run ${run.runId} claims live daemon pid ${run.daemonPid} — skipping`);
5420
5979
  outcome.actions.push("skipped: daemon pid still alive");
5421
5980
  continue;
5422
5981
  }
5423
- log.info(TAG25, `recovering ${run.pipeline} run ${run.runId} for card #${run.cardShortId}`);
5982
+ log.info(TAG26, `recovering ${run.pipeline} run ${run.runId} for card #${run.cardShortId}`);
5424
5983
  await recoverRun(run, store, client, config, outcome);
5425
5984
  }
5426
5985
  return outcomes;
@@ -5438,7 +5997,7 @@ async function recoverRun(run, store, client, config, outcome) {
5438
5997
  } catch (err) {
5439
5998
  const msg = err instanceof Error ? err.message : String(err);
5440
5999
  outcome.errors.push(`endAgentSession: ${msg}`);
5441
- log.warn(TAG25, `endAgentSession failed for ${run.cardId}: ${msg}`);
6000
+ log.warn(TAG26, `endAgentSession failed for ${run.cardId}: ${msg}`);
5442
6001
  }
5443
6002
  const card = await fetchCardSafely(client, run.cardId);
5444
6003
  if (card) {
@@ -5479,9 +6038,9 @@ async function recoverRun(run, store, client, config, outcome) {
5479
6038
  const msg = err instanceof Error ? err.message : String(err);
5480
6039
  outcome.errors.push(`endRun: ${msg}`);
5481
6040
  }
5482
- log.info(TAG25, `recovered run ${run.runId} (card #${run.cardShortId}): ${outcome.actions.join(", ")}${outcome.errors.length ? ` | errors: ${outcome.errors.join("; ")}` : ""}`);
6041
+ log.info(TAG26, `recovered run ${run.runId} (card #${run.cardShortId}): ${outcome.actions.join(", ")}${outcome.errors.length ? ` | errors: ${outcome.errors.join("; ")}` : ""}`);
5483
6042
  }
5484
- var TAG25 = "recovery", RECOVERED_LABEL = "agent-recovered", RECOVERED_LABEL_COLOR = "#f59e0b";
6043
+ var TAG26 = "recovery", RECOVERED_LABEL = "agent-recovered", RECOVERED_LABEL_COLOR = "#f59e0b";
5485
6044
  var init_recovery = __esm(() => {
5486
6045
  init_board_helpers();
5487
6046
  init_log();
@@ -5529,7 +6088,7 @@ class Reconciler {
5529
6088
  clearInterval(this.timer);
5530
6089
  this.timer = null;
5531
6090
  }
5532
- log.info(TAG26, "Heartbeat stopped");
6091
+ log.info(TAG27, "Heartbeat stopped");
5533
6092
  }
5534
6093
  async recoverStaleRuns() {
5535
6094
  if (!this.stateStore || !this.agentConfig)
@@ -5546,7 +6105,7 @@ class Reconciler {
5546
6105
  if (!daemonDead && !(heartbeatStale && ourZombie))
5547
6106
  continue;
5548
6107
  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`);
6108
+ log.warn(TAG27, `zombie run ${run.runId} (#${run.cardShortId}): ${reason} — recovering`);
5550
6109
  await recoverRun(run, this.stateStore, this.client, this.agentConfig, {
5551
6110
  runId: run.runId,
5552
6111
  cardId: run.cardId,
@@ -5557,6 +6116,57 @@ class Reconciler {
5557
6116
  });
5558
6117
  }
5559
6118
  }
6119
+ async recoverStrandedInProgress(cards, columns, knownCardIds) {
6120
+ const inProgressCol = columns.find((c) => c.name.toLowerCase() === IN_PROGRESS_COLUMN.toLowerCase());
6121
+ const pickupName = this.pickupColumns[0];
6122
+ const pickupCol = pickupName ? columns.find((c) => c.name.toLowerCase() === pickupName.toLowerCase()) : undefined;
6123
+ if (!inProgressCol || !pickupCol)
6124
+ return;
6125
+ const graceMs = this.agentConfig?.timing.staleHeartbeatMs ?? 120000;
6126
+ const now = Date.now();
6127
+ const activeCardIds = new Set((this.stateStore?.getActiveRuns() ?? []).map((r) => r.cardId));
6128
+ for (const card of cards) {
6129
+ if (card.assigned_agent_id !== this.agentId || card.archived_at || card.column_id !== inProgressCol.id || knownCardIds.has(card.id) || activeCardIds.has(card.id)) {
6130
+ continue;
6131
+ }
6132
+ const stalledAt = Date.parse(card.updated_at ?? "");
6133
+ if (!Number.isFinite(stalledAt) || now - stalledAt < graceMs)
6134
+ continue;
6135
+ log.warn(TAG27, `#${card.short_id} stranded in "${inProgressCol.name}" (no live run) — requeueing to "${pickupCol.name}"`);
6136
+ try {
6137
+ await this.client.moveCard(card.id, pickupCol.id);
6138
+ } catch (err) {
6139
+ log.error(TAG27, `stranded requeue failed for #${card.short_id}: ${err instanceof Error ? err.message : err}`);
6140
+ }
6141
+ }
6142
+ }
6143
+ async releaseStalledApprovals(cards, columns, knownCardIds) {
6144
+ const planning = this.agentConfig?.planning;
6145
+ if (!planning?.enabled || planning.mode !== "gated" || planning.approvalTtlHours <= 0) {
6146
+ return;
6147
+ }
6148
+ const parkCol = columns.find((c) => c.name.toLowerCase() === planning.awaitingApprovalColumn.toLowerCase());
6149
+ const pickupName = this.pickupColumns[0];
6150
+ const pickupCol = pickupName ? columns.find((c) => c.name.toLowerCase() === pickupName.toLowerCase()) : undefined;
6151
+ if (!parkCol || !pickupCol)
6152
+ return;
6153
+ const ttlMs = planning.approvalTtlHours * 3600000;
6154
+ const now = Date.now();
6155
+ for (const card of cards) {
6156
+ if (card.assigned_agent_id !== this.agentId || card.archived_at || card.column_id !== parkCol.id || !card.plan_id || knownCardIds.has(card.id)) {
6157
+ continue;
6158
+ }
6159
+ const parkedAt = Date.parse(card.updated_at ?? "");
6160
+ if (!Number.isFinite(parkedAt) || now - parkedAt < ttlMs)
6161
+ continue;
6162
+ log.warn(TAG27, `#${card.short_id} parked for approval > ${planning.approvalTtlHours}h — auto-releasing to "${pickupCol.name}"`);
6163
+ try {
6164
+ await this.client.moveCard(card.id, pickupCol.id);
6165
+ } catch (err) {
6166
+ log.error(TAG27, `auto-release failed for #${card.short_id}: ${err instanceof Error ? err.message : err}`);
6167
+ }
6168
+ }
6169
+ }
5560
6170
  async tick() {
5561
6171
  this.lastTickAt = Date.now();
5562
6172
  try {
@@ -5582,37 +6192,39 @@ class Reconciler {
5582
6192
  const subtasks = card.subtasks ?? [];
5583
6193
  const mode = reviewColumnIds.has(card.column_id) ? "review" : "implement";
5584
6194
  if (mode === "review" && this.approvedLabel && hasLabel(cardLabels, this.approvedLabel)) {
5585
- log.debug(TAG26, `Skipping #${card.short_id} — already has "${this.approvedLabel}" label`);
6195
+ log.debug(TAG27, `Skipping #${card.short_id} — already has "${this.approvedLabel}" label`);
5586
6196
  continue;
5587
6197
  }
5588
6198
  if (mode === "review" && hasLabel(cardLabels, NEED_REVIEW_LABEL)) {
5589
- log.debug(TAG26, `Skipping #${card.short_id} — has "${NEED_REVIEW_LABEL}" label (needs human)`);
6199
+ log.debug(TAG27, `Skipping #${card.short_id} — has "${NEED_REVIEW_LABEL}" label (needs human)`);
5590
6200
  continue;
5591
6201
  }
5592
6202
  if (mode === "review" && !extractBranchFromDescription(card.description)) {
5593
- log.debug(TAG26, `Skipping #${card.short_id} — no branch reference (not qualified for auto-review)`);
6203
+ log.debug(TAG27, `Skipping #${card.short_id} — no branch reference (not qualified for auto-review)`);
5594
6204
  continue;
5595
6205
  }
5596
- log.info(TAG26, `Missed assignment: #${card.short_id} "${card.title}" (${mode}) — enqueueing`);
6206
+ log.info(TAG27, `Missed assignment: #${card.short_id} "${card.title}" (${mode}) — enqueueing`);
5597
6207
  await this.pool.enqueue(card, column, cardLabels, subtasks, mode);
5598
6208
  }
5599
6209
  }
5600
6210
  if (this.stateStore && this.agentConfig) {
5601
6211
  await this.recoverStaleRuns();
5602
6212
  }
6213
+ await this.recoverStrandedInProgress(cards, columns, knownCardIds);
5603
6214
  for (const knownId of knownCardIds) {
5604
6215
  if (!allAgentCardIds.has(knownId)) {
5605
- log.info(TAG26, `Missed unassign: ${knownId} — removing`);
6216
+ log.info(TAG27, `Missed unassign: ${knownId} — removing`);
5606
6217
  await this.pool.removeCard(knownId);
5607
6218
  }
5608
6219
  }
5609
- log.debug(TAG26, `Reconciled: ${assignedCards.length} assigned, ${knownCardIds.size} known`);
6220
+ await this.releaseStalledApprovals(cards, columns, knownCardIds);
6221
+ log.debug(TAG27, `Reconciled: ${assignedCards.length} assigned, ${knownCardIds.size} known`);
5610
6222
  } catch (err) {
5611
- log.error(TAG26, `Heartbeat failed: ${err instanceof Error ? err.message : err}`);
6223
+ log.error(TAG27, `Heartbeat failed: ${err instanceof Error ? err.message : err}`);
5612
6224
  }
5613
6225
  }
5614
6226
  }
5615
- var TAG26 = "reconcile";
6227
+ var TAG27 = "reconcile";
5616
6228
  var init_reconcile = __esm(() => {
5617
6229
  init_board_helpers();
5618
6230
  init_log();
@@ -5633,6 +6245,7 @@ function prettyBanner(config, version) {
5633
6245
  const checks = [];
5634
6246
  let projectName;
5635
6247
  let gitProvider;
6248
+ let httpPort;
5636
6249
  let failed = false;
5637
6250
  let rendered = false;
5638
6251
  return {
@@ -5642,11 +6255,14 @@ function prettyBanner(config, version) {
5642
6255
  setGitProvider(provider) {
5643
6256
  gitProvider = provider;
5644
6257
  },
6258
+ setHttpPort(port) {
6259
+ httpPort = port;
6260
+ },
5645
6261
  check(message) {
5646
6262
  checks.push({ kind: "ok", message });
5647
6263
  },
5648
6264
  warn(message) {
5649
- log.warn(TAG27, message);
6265
+ log.warn(TAG28, message);
5650
6266
  checks.push({ kind: "warn", message: message.split(`
5651
6267
  `, 1)[0] });
5652
6268
  },
@@ -5662,6 +6278,7 @@ function prettyBanner(config, version) {
5662
6278
  config,
5663
6279
  projectName,
5664
6280
  gitProvider,
6281
+ httpPort,
5665
6282
  checks,
5666
6283
  readyMessage: message
5667
6284
  });
@@ -5670,22 +6287,25 @@ function prettyBanner(config, version) {
5670
6287
  };
5671
6288
  }
5672
6289
  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(", ")}`);
6290
+ log.info(TAG28, `Harmony Agent Daemon v${version} starting...`);
6291
+ log.info(TAG28, `Project: ${config.projectId} | Pool: ${config.agent.poolSize} | Model: ${config.agent.claude.model} | Pickup: ${config.agent.pickupColumns.join(", ")}`);
5675
6292
  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}`);
6293
+ log.info(TAG28, `Review: enabled | Columns: ${config.agent.review.pickupColumns.join(", ")} | → ${config.agent.review.moveToColumn} / ${config.agent.review.failColumn}`);
5677
6294
  }
5678
6295
  let failed = false;
5679
6296
  return {
5680
6297
  setProjectName(_name) {},
5681
6298
  setGitProvider(provider) {
5682
- log.info(TAG27, `Git provider: ${provider}`);
6299
+ log.info(TAG28, `Git provider: ${provider}`);
6300
+ },
6301
+ setHttpPort(port) {
6302
+ log.info(TAG28, `HTTP server on port ${port}`);
5683
6303
  },
5684
6304
  check(message) {
5685
- log.info(TAG27, message);
6305
+ log.info(TAG28, message);
5686
6306
  },
5687
6307
  warn(message) {
5688
- log.warn(TAG27, message);
6308
+ log.warn(TAG28, message);
5689
6309
  },
5690
6310
  fail() {
5691
6311
  failed = true;
@@ -5693,17 +6313,25 @@ function jsonBanner(config, version) {
5693
6313
  async ready(message) {
5694
6314
  if (failed)
5695
6315
  return;
5696
- log.info(TAG27, message);
6316
+ log.info(TAG28, message);
5697
6317
  }
5698
6318
  };
5699
6319
  }
5700
6320
  function renderPretty(input) {
5701
- const { version, config, projectName, gitProvider, checks, readyMessage } = input;
6321
+ const {
6322
+ version,
6323
+ config,
6324
+ projectName,
6325
+ gitProvider,
6326
+ httpPort,
6327
+ checks,
6328
+ readyMessage
6329
+ } = input;
5702
6330
  const lines = [];
5703
6331
  lines.push("");
5704
6332
  lines.push(titleRule(`Harmony Agent Daemon v${version}`));
5705
6333
  lines.push("");
5706
- for (const row of configRows(config, projectName, gitProvider)) {
6334
+ for (const row of configRows(config, projectName, gitProvider, httpPort)) {
5707
6335
  lines.push(` ${dim(row.label.padEnd(9))} ${row.value}`);
5708
6336
  }
5709
6337
  lines.push("");
@@ -5717,7 +6345,7 @@ function renderPretty(input) {
5717
6345
  return lines.join(`
5718
6346
  `);
5719
6347
  }
5720
- function configRows(config, projectName, gitProvider) {
6348
+ function configRows(config, projectName, gitProvider, httpPort) {
5721
6349
  const rows = [];
5722
6350
  const projectLabel = projectName ? `${projectName} (${shortenId(config.projectId)})` : shortenId(config.projectId);
5723
6351
  rows.push({ label: "Project", value: projectLabel });
@@ -5733,7 +6361,7 @@ function configRows(config, projectName, gitProvider) {
5733
6361
  if (gitProvider)
5734
6362
  tail.push(gitProvider);
5735
6363
  if (config.agent.http.enabled) {
5736
- tail.push(`HTTP http://${config.agent.http.bindAddr}:${config.agent.http.port}`);
6364
+ tail.push(`HTTP http://${config.agent.http.bindAddr}:${httpPort ?? config.agent.http.port}`);
5737
6365
  }
5738
6366
  if (tail.length > 0) {
5739
6367
  rows.push({ label: "Git", value: tail.join(" · ") });
@@ -5760,7 +6388,7 @@ function cyan(s) {
5760
6388
  function yellow(s) {
5761
6389
  return `${ANSI.yellow}${s}${ANSI.reset}`;
5762
6390
  }
5763
- var TAG27 = "daemon", RULE_WIDTH = 70, ANSI;
6391
+ var TAG28 = "daemon", RULE_WIDTH = 70, ANSI;
5764
6392
  var init_startup_banner = __esm(() => {
5765
6393
  init_log();
5766
6394
  ANSI = {
@@ -5907,18 +6535,18 @@ class Watcher {
5907
6535
  }
5908
6536
  async start() {
5909
6537
  if (!isPretty()) {
5910
- log.info(TAG28, "Connecting to Supabase realtime (broadcast)...");
6538
+ log.info(TAG29, "Connecting to Supabase realtime (broadcast)...");
5911
6539
  }
5912
6540
  this.supabase = createClient(this.credentials.supabaseUrl, this.credentials.supabaseAnonKey);
5913
6541
  const presenceChannel = this.supabase.channel(`board-presence-${this.projectId}`);
5914
6542
  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)}`);
6543
+ log.debug(TAG29, `Broadcast: card_update ${JSON.stringify(msg.payload)}`);
5916
6544
  this.onCardBroadcast({
5917
6545
  event: "card_update",
5918
6546
  payload: msg.payload ?? {}
5919
6547
  });
5920
6548
  }).on("broadcast", { event: "card_created" }, (msg) => {
5921
- log.debug(TAG28, `Broadcast: card_created ${JSON.stringify(msg.payload)}`);
6549
+ log.debug(TAG29, `Broadcast: card_created ${JSON.stringify(msg.payload)}`);
5922
6550
  this.onCardBroadcast({
5923
6551
  event: "card_created",
5924
6552
  payload: msg.payload ?? {}
@@ -5928,29 +6556,29 @@ class Watcher {
5928
6556
  const cardId = payload.card_id;
5929
6557
  const command = payload.command;
5930
6558
  if (cardId && command) {
5931
- log.info(TAG28, `Broadcast: agent_command ${command} for ${cardId}`);
6559
+ log.info(TAG29, `Broadcast: agent_command ${command} for ${cardId}`);
5932
6560
  this.onAgentCommand?.({ cardId, command });
5933
6561
  }
5934
6562
  }).subscribe((status) => {
5935
6563
  if (status === "SUBSCRIBED") {
5936
6564
  this.connected = true;
5937
6565
  if (!isPretty() || !this.suppressStartupLogs) {
5938
- log.info(TAG28, "Broadcast subscription active");
6566
+ log.info(TAG29, "Broadcast subscription active");
5939
6567
  }
5940
6568
  this.maybeResolveReady();
5941
6569
  } else if (status === "CHANNEL_ERROR") {
5942
6570
  this.connected = false;
5943
- log.error(TAG28, "Broadcast channel error — will rely on reconciliation");
6571
+ log.error(TAG29, "Broadcast channel error — will rely on reconciliation");
5944
6572
  } else if (status === "TIMED_OUT") {
5945
6573
  this.connected = false;
5946
- log.warn(TAG28, "Broadcast subscription timed out — retrying...");
6574
+ log.warn(TAG29, "Broadcast subscription timed out — retrying...");
5947
6575
  } else if (status === "CLOSED") {
5948
6576
  this.connected = false;
5949
6577
  }
5950
6578
  });
5951
6579
  this.channel = channel;
5952
6580
  presenceChannel.on("presence", { event: "sync" }, () => {
5953
- log.debug(TAG28, "Presence sync");
6581
+ log.debug(TAG29, "Presence sync");
5954
6582
  }).subscribe(async (status) => {
5955
6583
  if (status === "SUBSCRIBED") {
5956
6584
  await presenceChannel.track({
@@ -5963,7 +6591,7 @@ class Watcher {
5963
6591
  agentName: this.identity.agentName
5964
6592
  });
5965
6593
  if (!isPretty() || !this.suppressStartupLogs) {
5966
- log.info(TAG28, "Presence tracked on board-presence channel");
6594
+ log.info(TAG29, "Presence tracked on board-presence channel");
5967
6595
  }
5968
6596
  this.presenceTracked = true;
5969
6597
  this.maybeResolveReady();
@@ -5985,10 +6613,10 @@ class Watcher {
5985
6613
  this.supabase = null;
5986
6614
  }
5987
6615
  this.connected = false;
5988
- log.info(TAG28, "Broadcast subscription stopped");
6616
+ log.info(TAG29, "Broadcast subscription stopped");
5989
6617
  }
5990
6618
  }
5991
- var TAG28 = "watcher";
6619
+ var TAG29 = "watcher";
5992
6620
  var init_watcher = __esm(() => {
5993
6621
  init_log();
5994
6622
  });
@@ -6063,10 +6691,10 @@ function runWorktreeGc(basePath, store, opts = {}) {
6063
6691
  });
6064
6692
  } catch {}
6065
6693
  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(", ")}`);
6694
+ log.info(TAG30, `GC removed ${result.removed.length} orphan worktree(s): ${result.removed.map((p) => p.split("/").pop()).join(", ")}`);
6067
6695
  }
6068
6696
  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("; ")}`);
6697
+ log.warn(TAG30, `GC had ${result.errors.length} error(s): ${result.errors.map((e) => `${e.path}: ${e.error}`).join("; ")}`);
6070
6698
  }
6071
6699
  return result;
6072
6700
  }
@@ -6143,10 +6771,10 @@ function pruneFailedRemoteBranches(opts) {
6143
6771
  }
6144
6772
  }
6145
6773
  if (result.removed.length > 0) {
6146
- log.info(TAG29, `Pruned ${result.removed.length} stale remote branch(es) under ${opts.prefix}: ${result.removed.join(", ")}`);
6774
+ log.info(TAG30, `Pruned ${result.removed.length} stale remote branch(es) under ${opts.prefix}: ${result.removed.join(", ")}`);
6147
6775
  }
6148
6776
  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("; ")}`);
6777
+ log.warn(TAG30, `Remote branch GC had ${result.errors.length} error(s): ${result.errors.map((e) => `${e.ref}: ${e.error}`).join("; ")}`);
6150
6778
  }
6151
6779
  return result;
6152
6780
  }
@@ -6177,13 +6805,13 @@ class WorktreeGc {
6177
6805
  try {
6178
6806
  runWorktreeGc(this.basePath, this.store);
6179
6807
  } catch (err) {
6180
- log.warn(TAG29, `GC tick failed: ${err instanceof Error ? err.message : err}`);
6808
+ log.warn(TAG30, `GC tick failed: ${err instanceof Error ? err.message : err}`);
6181
6809
  }
6182
6810
  if (this.remoteOpts) {
6183
6811
  try {
6184
6812
  pruneFailedRemoteBranches(this.remoteOpts);
6185
6813
  } catch (err) {
6186
- log.warn(TAG29, `Remote GC tick failed: ${err instanceof Error ? err.message : err}`);
6814
+ log.warn(TAG30, `Remote GC tick failed: ${err instanceof Error ? err.message : err}`);
6187
6815
  }
6188
6816
  }
6189
6817
  }
@@ -6197,7 +6825,7 @@ function getRepoRoot2() {
6197
6825
  return null;
6198
6826
  }
6199
6827
  }
6200
- var TAG29 = "worktree-gc";
6828
+ var TAG30 = "worktree-gc";
6201
6829
  var init_worktree_gc = __esm(() => {
6202
6830
  init_log();
6203
6831
  init_worktree();
@@ -6279,7 +6907,7 @@ async function main() {
6279
6907
  } catch (err) {
6280
6908
  if (err instanceof ConfigValidationError) {
6281
6909
  banner.fail();
6282
- log.error(TAG30, err.message);
6910
+ log.error(TAG31, err.message);
6283
6911
  process.exit(1);
6284
6912
  }
6285
6913
  throw err;
@@ -6388,25 +7016,28 @@ async function main() {
6388
7016
  if (shuttingDown)
6389
7017
  return;
6390
7018
  shuttingDown = true;
6391
- log.info(TAG30, `Received ${signal}, shutting down gracefully...`);
7019
+ log.info(TAG31, `Received ${signal}, shutting down gracefully...`);
6392
7020
  reconciler.stop();
6393
7021
  mergeMonitor?.stop();
6394
7022
  worktreeGc.stop();
6395
- await httpServer?.stop();
7023
+ if (httpServer) {
7024
+ clearDaemonPort(config.projectId, process.pid);
7025
+ await httpServer.stop();
7026
+ }
6396
7027
  await watcher.stop();
6397
7028
  await pool.shutdown();
6398
- log.info(TAG30, "Daemon stopped.");
7029
+ log.info(TAG31, "Daemon stopped.");
6399
7030
  process.exit(exitCode);
6400
7031
  };
6401
7032
  process.on("SIGINT", () => shutdown("SIGINT"));
6402
7033
  process.on("SIGTERM", () => shutdown("SIGTERM"));
6403
7034
  process.on("uncaughtException", (err) => {
6404
- log.error(TAG30, `Uncaught exception: ${err.message}`);
7035
+ log.error(TAG31, `Uncaught exception: ${err.message}`);
6405
7036
  exitCode = 1;
6406
7037
  shutdown("uncaughtException");
6407
7038
  });
6408
7039
  process.on("unhandledRejection", (reason) => {
6409
- log.error(TAG30, `Unhandled rejection: ${reason instanceof Error ? reason.message : String(reason)}`);
7040
+ log.error(TAG31, `Unhandled rejection: ${reason instanceof Error ? reason.message : String(reason)}`);
6410
7041
  exitCode = 1;
6411
7042
  shutdown("unhandledRejection");
6412
7043
  });
@@ -6416,7 +7047,13 @@ async function main() {
6416
7047
  worktreeGc.start();
6417
7048
  if (httpServer) {
6418
7049
  try {
6419
- await httpServer.start();
7050
+ const boundPort = await httpServer.start();
7051
+ recordDaemonPort(config.projectId, {
7052
+ port: boundPort,
7053
+ pid: process.pid,
7054
+ bindAddr: config.agent.http.bindAddr
7055
+ });
7056
+ banner.setHttpPort(boundPort);
6420
7057
  } catch (err) {
6421
7058
  banner.warn(`HTTP server failed to bind: ${err instanceof Error ? err.message : err}`);
6422
7059
  }
@@ -6453,34 +7090,34 @@ async function handleBroadcast(event, client, pool, config, agentId) {
6453
7090
  if (assignedAgentId === undefined)
6454
7091
  return;
6455
7092
  if (assignedAgentId === agentId) {
6456
- log.info(TAG30, `Broadcast: card ${cardId} assigned to agent`);
7093
+ log.info(TAG31, `Broadcast: card ${cardId} assigned to agent`);
6457
7094
  try {
6458
7095
  await tryEnqueueCard(cardId, client, pool, config, agentId);
6459
7096
  } catch (err) {
6460
- log.error(TAG30, `Failed to process assignment: ${err instanceof Error ? err.message : err}`);
7097
+ log.error(TAG31, `Failed to process assignment: ${err instanceof Error ? err.message : err}`);
6461
7098
  }
6462
7099
  } else if (pool.isCardKnown(cardId)) {
6463
- log.info(TAG30, `Broadcast: card ${cardId} unassigned from agent`);
7100
+ log.info(TAG31, `Broadcast: card ${cardId} unassigned from agent`);
6464
7101
  await pool.removeCard(cardId);
6465
7102
  }
6466
7103
  }
6467
7104
  async function tryEnqueueCard(cardId, client, pool, config, agentId) {
6468
7105
  const { card } = await client.getCard(cardId);
6469
7106
  if (card.assigned_agent_id !== agentId) {
6470
- log.debug(TAG30, `Card ${cardId} no longer assigned to agent — skipping`);
7107
+ log.debug(TAG31, `Card ${cardId} no longer assigned to agent — skipping`);
6471
7108
  return;
6472
7109
  }
6473
7110
  const board = await client.getBoard(config.projectId, { summary: true });
6474
7111
  const columns = board.columns;
6475
7112
  const column = columns.find((c) => c.id === card.column_id);
6476
7113
  if (!column) {
6477
- log.warn(TAG30, `Column not found for card ${cardId}`);
7114
+ log.warn(TAG31, `Column not found for card ${cardId}`);
6478
7115
  return;
6479
7116
  }
6480
7117
  const isPickupColumn = config.agent.pickupColumns.some((name) => name.toLowerCase() === column.name.toLowerCase());
6481
7118
  const isReviewColumn = config.agent.review.enabled && config.agent.review.pickupColumns.some((name) => name.toLowerCase() === column.name.toLowerCase());
6482
7119
  if (!isPickupColumn && !isReviewColumn) {
6483
- log.info(TAG30, `Card #${card.short_id} is in "${column.name}", not a pickup/review column — skipping`);
7120
+ log.info(TAG31, `Card #${card.short_id} is in "${column.name}", not a pickup/review column — skipping`);
6484
7121
  return;
6485
7122
  }
6486
7123
  const mode = isReviewColumn ? "review" : "implement";
@@ -6488,16 +7125,16 @@ async function tryEnqueueCard(cardId, client, pool, config, agentId) {
6488
7125
  const cardLabels = resolveCardLabels(card, labelMap);
6489
7126
  const subtasks = card.subtasks ?? [];
6490
7127
  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`);
7128
+ log.debug(TAG31, `Card #${card.short_id} already has "${config.agent.review.approvedLabel}" — skipping review`);
6492
7129
  return;
6493
7130
  }
6494
7131
  if (mode === "review" && !extractBranchFromDescription(card.description)) {
6495
- log.info(TAG30, `Card #${card.short_id} has no branch reference — skipping auto-review`);
7132
+ log.info(TAG31, `Card #${card.short_id} has no branch reference — skipping auto-review`);
6496
7133
  return;
6497
7134
  }
6498
7135
  await pool.enqueue(card, column, cardLabels, subtasks, mode);
6499
7136
  }
6500
- var TAG30 = "daemon", PKG_VERSION;
7137
+ var TAG31 = "daemon", PKG_VERSION;
6501
7138
  var init_src = __esm(() => {
6502
7139
  init_board_helpers();
6503
7140
  init_config();
@@ -6507,6 +7144,7 @@ var init_src = __esm(() => {
6507
7144
  init_log();
6508
7145
  init_merge_monitor();
6509
7146
  init_pool();
7147
+ init_port_registry();
6510
7148
  init_reconcile();
6511
7149
  init_recovery();
6512
7150
  init_review_worktree();
@@ -6546,8 +7184,12 @@ async function runDaemon() {
6546
7184
  }
6547
7185
  async function httpCall(path, init) {
6548
7186
  const { loadDaemonConfig: loadDaemonConfig2 } = await Promise.resolve().then(() => (init_config(), exports_config));
7187
+ const { lookupDaemonPort: lookupDaemonPort2 } = await Promise.resolve().then(() => (init_port_registry(), exports_port_registry));
6549
7188
  const cfg = loadDaemonConfig2();
6550
- const url = `http://${cfg.agent.http.bindAddr}:${cfg.agent.http.port}${path}`;
7189
+ const recorded = lookupDaemonPort2(cfg.projectId);
7190
+ const bindAddr = recorded?.bindAddr ?? cfg.agent.http.bindAddr;
7191
+ const port = recorded?.port ?? cfg.agent.http.port;
7192
+ const url = `http://${bindAddr}:${port}${path}`;
6551
7193
  return fetch(url, init);
6552
7194
  }
6553
7195
  function printStatus(body) {