@gethmy/mcp 2.14.0 → 2.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -1793,12 +1793,43 @@ class HarmonyApiClient {
1793
1793
  const query = params.toString() ? `?${params.toString()}` : "";
1794
1794
  return this.request("GET", `/board/${projectId}${query}`);
1795
1795
  }
1796
+ async getFullBoard(projectId, options) {
1797
+ const { pageSize = 200, ...boardOpts } = options ?? {};
1798
+ const cards = [];
1799
+ let offset = 0;
1800
+ let last = null;
1801
+ for (;; ) {
1802
+ const page = await this.getBoard(projectId, {
1803
+ ...boardOpts,
1804
+ limit: pageSize,
1805
+ offset
1806
+ });
1807
+ last = page;
1808
+ const pageCards = page.cards ?? [];
1809
+ cards.push(...pageCards);
1810
+ const hasMore = page.pagination?.hasMore ?? false;
1811
+ if (!hasMore || pageCards.length === 0)
1812
+ break;
1813
+ offset += pageSize;
1814
+ }
1815
+ return {
1816
+ project: last?.project,
1817
+ columns: last?.columns ?? [],
1818
+ cards,
1819
+ labels: last?.labels ?? [],
1820
+ totalCards: last?.pagination?.totalCards ?? cards.length
1821
+ };
1822
+ }
1796
1823
  async createCard(projectId, data) {
1797
1824
  return this.request("POST", "/cards", { projectId, ...data });
1798
1825
  }
1799
1826
  async updateCard(cardId, updates) {
1800
1827
  return this.request("PATCH", `/cards/${cardId}`, updates);
1801
1828
  }
1829
+ async claimCard(cardId, agentId) {
1830
+ const res = await this.request("PATCH", `/cards/${cardId}`, { assignedAgentId: agentId, ifAssignedAgentNull: true });
1831
+ return { claimed: res.claimed !== false };
1832
+ }
1802
1833
  async moveCard(cardId, columnId, position) {
1803
1834
  return this.request("POST", `/cards/${cardId}/move`, {
1804
1835
  columnId,
@@ -2460,7 +2491,7 @@ function initAutoSession(callback, getClient2, getClientInfo, scopeId = DEFAULT_
2460
2491
  scope.clientGetter = getClient2;
2461
2492
  scope.clientInfoGetter = getClientInfo ?? null;
2462
2493
  if (!inactivityTimer) {
2463
- inactivityTimer = setInterval(checkInactivity, CHECK_INTERVAL_MS);
2494
+ inactivityTimer = setInterval(sweepTick, CHECK_INTERVAL_MS);
2464
2495
  }
2465
2496
  }
2466
2497
  async function trackActivity(cardId, options) {
@@ -2502,7 +2533,8 @@ async function trackActivity(cardId, options) {
2502
2533
  lastActivityAt: now,
2503
2534
  isExplicit: false,
2504
2535
  agentIdentifier,
2505
- agentName
2536
+ agentName,
2537
+ status: "working"
2506
2538
  });
2507
2539
  }
2508
2540
  function markExplicit(cardId, options) {
@@ -2521,7 +2553,8 @@ function markExplicit(cardId, options) {
2521
2553
  lastActivityAt: Date.now(),
2522
2554
  isExplicit: true,
2523
2555
  agentIdentifier: options?.agentIdentifier ?? "explicit",
2524
- agentName: options?.agentName ?? "Explicit Agent"
2556
+ agentName: options?.agentName ?? "Explicit Agent",
2557
+ status: "working"
2525
2558
  });
2526
2559
  }
2527
2560
  }
@@ -2567,6 +2600,31 @@ function checkInactivity() {
2567
2600
  }
2568
2601
  }
2569
2602
  }
2603
+ function noteSessionStatus(cardId, status, scopeId = DEFAULT_SCOPE) {
2604
+ const session = scopes.get(scopeId)?.sessions.get(cardId);
2605
+ if (session)
2606
+ session.status = status;
2607
+ }
2608
+ function heartbeatActiveSessions() {
2609
+ for (const scope of scopes.values()) {
2610
+ const client3 = scope.clientGetter?.();
2611
+ if (!client3)
2612
+ continue;
2613
+ for (const session of scope.sessions.values()) {
2614
+ if ((session.status ?? "working") !== "working")
2615
+ continue;
2616
+ client3.updateAgentProgress(session.cardId, {
2617
+ agentIdentifier: session.agentIdentifier,
2618
+ agentName: session.agentName,
2619
+ status: "working"
2620
+ }).catch(() => {});
2621
+ }
2622
+ }
2623
+ }
2624
+ function sweepTick() {
2625
+ checkInactivity();
2626
+ heartbeatActiveSessions();
2627
+ }
2570
2628
  async function autoEndSession(scope, client3, cardId, status) {
2571
2629
  if (!scope.sessions.delete(cardId))
2572
2630
  return;
@@ -6203,10 +6261,14 @@ async function handleToolCall(name, args, deps) {
6203
6261
  mergedRecentActions = callerRecentActions;
6204
6262
  }
6205
6263
  const runActivity = (callerActions || []).map((a) => a.description).filter((d) => typeof d === "string" && d.length > 0);
6264
+ const reportedStatus = args.status;
6265
+ if (reportedStatus) {
6266
+ noteSessionStatus(cardId, reportedStatus, deps.getScopeId?.());
6267
+ }
6206
6268
  const result = await client3.updateAgentProgress(cardId, {
6207
6269
  agentIdentifier,
6208
6270
  agentName,
6209
- status: args.status,
6271
+ status: reportedStatus,
6210
6272
  progressPercent,
6211
6273
  currentTask: args.currentTask,
6212
6274
  blockers: args.blockers,
@@ -6277,11 +6339,11 @@ async function handleToolCall(name, args, deps) {
6277
6339
  }
6278
6340
  case "harmony_generate_prompt": {
6279
6341
  let cardId;
6342
+ const projectId = args.projectId || deps.getActiveProjectId() || undefined;
6280
6343
  if (args.cardId) {
6281
6344
  cardId = z.string().uuid().parse(args.cardId);
6282
6345
  } else if (args.shortId !== undefined) {
6283
6346
  const shortId = z.number().int().positive().parse(args.shortId);
6284
- const projectId = args.projectId || deps.getActiveProjectId();
6285
6347
  if (!projectId) {
6286
6348
  throw new Error("Project ID required when using shortId. Use harmony_set_project_context or provide projectId.");
6287
6349
  }
@@ -6290,23 +6352,40 @@ async function handleToolCall(name, args, deps) {
6290
6352
  } else {
6291
6353
  throw new Error("Either cardId or shortId must be provided");
6292
6354
  }
6293
- let cardTitle = "";
6294
- let cardDescription = "";
6355
+ const variant = args.variant || undefined;
6356
+ const customConstraints = typeof args.customConstraints === "string" ? args.customConstraints : undefined;
6357
+ const contextOptions = {
6358
+ includeSubtasks: args.includeSubtasks !== false,
6359
+ includeLinks: args.includeLinks !== false,
6360
+ includeDescription: args.includeDescription !== false
6361
+ };
6295
6362
  try {
6296
- const { card } = await client3.getCard(cardId);
6297
- const typedCard = card;
6298
- cardTitle = typedCard.title || "";
6299
- cardDescription = typedCard.description || "";
6300
- } catch {}
6301
- const taskBlock = [cardTitle, cardDescription].filter(Boolean).join(`
6363
+ const result = await client3.generateCardPrompt({
6364
+ cardId,
6365
+ workspaceId: deps.getActiveWorkspaceId() || "",
6366
+ projectId,
6367
+ variant,
6368
+ customConstraints,
6369
+ contextOptions
6370
+ });
6371
+ return { success: true, ...result };
6372
+ } catch (err) {
6373
+ const msg = err instanceof Error ? err.message : String(err);
6374
+ console.debug(`[harmony_generate_prompt] falling back: ${msg}`);
6375
+ let cardTitle = "";
6376
+ let cardDescription = "";
6377
+ try {
6378
+ const { card } = await client3.getCard(cardId);
6379
+ const typedCard = card;
6380
+ cardTitle = typedCard.title || "";
6381
+ cardDescription = typedCard.description || "";
6382
+ } catch {}
6383
+ const taskBlock = [cardTitle, cardDescription].filter(Boolean).join(`
6302
6384
 
6303
6385
  `);
6304
- const prompt = `Here is the task: ${taskBlock || "(no description available)"}`;
6305
- return {
6306
- success: true,
6307
- prompt,
6308
- cardId
6309
- };
6386
+ const prompt = `Here is the task: ${taskBlock || "(no description available)"}`;
6387
+ return { success: true, prompt, cardId };
6388
+ }
6310
6389
  }
6311
6390
  case "harmony_remember": {
6312
6391
  const title = z.string().min(1).max(300).parse(args.title);
package/dist/index.js CHANGED
@@ -1788,12 +1788,43 @@ class HarmonyApiClient {
1788
1788
  const query = params.toString() ? `?${params.toString()}` : "";
1789
1789
  return this.request("GET", `/board/${projectId}${query}`);
1790
1790
  }
1791
+ async getFullBoard(projectId, options) {
1792
+ const { pageSize = 200, ...boardOpts } = options ?? {};
1793
+ const cards = [];
1794
+ let offset = 0;
1795
+ let last = null;
1796
+ for (;; ) {
1797
+ const page = await this.getBoard(projectId, {
1798
+ ...boardOpts,
1799
+ limit: pageSize,
1800
+ offset
1801
+ });
1802
+ last = page;
1803
+ const pageCards = page.cards ?? [];
1804
+ cards.push(...pageCards);
1805
+ const hasMore = page.pagination?.hasMore ?? false;
1806
+ if (!hasMore || pageCards.length === 0)
1807
+ break;
1808
+ offset += pageSize;
1809
+ }
1810
+ return {
1811
+ project: last?.project,
1812
+ columns: last?.columns ?? [],
1813
+ cards,
1814
+ labels: last?.labels ?? [],
1815
+ totalCards: last?.pagination?.totalCards ?? cards.length
1816
+ };
1817
+ }
1791
1818
  async createCard(projectId, data) {
1792
1819
  return this.request("POST", "/cards", { projectId, ...data });
1793
1820
  }
1794
1821
  async updateCard(cardId, updates) {
1795
1822
  return this.request("PATCH", `/cards/${cardId}`, updates);
1796
1823
  }
1824
+ async claimCard(cardId, agentId) {
1825
+ const res = await this.request("PATCH", `/cards/${cardId}`, { assignedAgentId: agentId, ifAssignedAgentNull: true });
1826
+ return { claimed: res.claimed !== false };
1827
+ }
1797
1828
  async moveCard(cardId, columnId, position) {
1798
1829
  return this.request("POST", `/cards/${cardId}/move`, {
1799
1830
  columnId,
@@ -2455,7 +2486,7 @@ function initAutoSession(callback, getClient2, getClientInfo, scopeId = DEFAULT_
2455
2486
  scope.clientGetter = getClient2;
2456
2487
  scope.clientInfoGetter = getClientInfo ?? null;
2457
2488
  if (!inactivityTimer) {
2458
- inactivityTimer = setInterval(checkInactivity, CHECK_INTERVAL_MS);
2489
+ inactivityTimer = setInterval(sweepTick, CHECK_INTERVAL_MS);
2459
2490
  }
2460
2491
  }
2461
2492
  async function trackActivity(cardId, options) {
@@ -2497,7 +2528,8 @@ async function trackActivity(cardId, options) {
2497
2528
  lastActivityAt: now,
2498
2529
  isExplicit: false,
2499
2530
  agentIdentifier,
2500
- agentName
2531
+ agentName,
2532
+ status: "working"
2501
2533
  });
2502
2534
  }
2503
2535
  function markExplicit(cardId, options) {
@@ -2516,7 +2548,8 @@ function markExplicit(cardId, options) {
2516
2548
  lastActivityAt: Date.now(),
2517
2549
  isExplicit: true,
2518
2550
  agentIdentifier: options?.agentIdentifier ?? "explicit",
2519
- agentName: options?.agentName ?? "Explicit Agent"
2551
+ agentName: options?.agentName ?? "Explicit Agent",
2552
+ status: "working"
2520
2553
  });
2521
2554
  }
2522
2555
  }
@@ -2562,6 +2595,31 @@ function checkInactivity() {
2562
2595
  }
2563
2596
  }
2564
2597
  }
2598
+ function noteSessionStatus(cardId, status, scopeId = DEFAULT_SCOPE) {
2599
+ const session = scopes.get(scopeId)?.sessions.get(cardId);
2600
+ if (session)
2601
+ session.status = status;
2602
+ }
2603
+ function heartbeatActiveSessions() {
2604
+ for (const scope of scopes.values()) {
2605
+ const client3 = scope.clientGetter?.();
2606
+ if (!client3)
2607
+ continue;
2608
+ for (const session of scope.sessions.values()) {
2609
+ if ((session.status ?? "working") !== "working")
2610
+ continue;
2611
+ client3.updateAgentProgress(session.cardId, {
2612
+ agentIdentifier: session.agentIdentifier,
2613
+ agentName: session.agentName,
2614
+ status: "working"
2615
+ }).catch(() => {});
2616
+ }
2617
+ }
2618
+ }
2619
+ function sweepTick() {
2620
+ checkInactivity();
2621
+ heartbeatActiveSessions();
2622
+ }
2565
2623
  async function autoEndSession(scope, client3, cardId, status) {
2566
2624
  if (!scope.sessions.delete(cardId))
2567
2625
  return;
@@ -6198,10 +6256,14 @@ async function handleToolCall(name, args, deps) {
6198
6256
  mergedRecentActions = callerRecentActions;
6199
6257
  }
6200
6258
  const runActivity = (callerActions || []).map((a) => a.description).filter((d) => typeof d === "string" && d.length > 0);
6259
+ const reportedStatus = args.status;
6260
+ if (reportedStatus) {
6261
+ noteSessionStatus(cardId, reportedStatus, deps.getScopeId?.());
6262
+ }
6201
6263
  const result = await client3.updateAgentProgress(cardId, {
6202
6264
  agentIdentifier,
6203
6265
  agentName,
6204
- status: args.status,
6266
+ status: reportedStatus,
6205
6267
  progressPercent,
6206
6268
  currentTask: args.currentTask,
6207
6269
  blockers: args.blockers,
@@ -6272,11 +6334,11 @@ async function handleToolCall(name, args, deps) {
6272
6334
  }
6273
6335
  case "harmony_generate_prompt": {
6274
6336
  let cardId;
6337
+ const projectId = args.projectId || deps.getActiveProjectId() || undefined;
6275
6338
  if (args.cardId) {
6276
6339
  cardId = z.string().uuid().parse(args.cardId);
6277
6340
  } else if (args.shortId !== undefined) {
6278
6341
  const shortId = z.number().int().positive().parse(args.shortId);
6279
- const projectId = args.projectId || deps.getActiveProjectId();
6280
6342
  if (!projectId) {
6281
6343
  throw new Error("Project ID required when using shortId. Use harmony_set_project_context or provide projectId.");
6282
6344
  }
@@ -6285,23 +6347,40 @@ async function handleToolCall(name, args, deps) {
6285
6347
  } else {
6286
6348
  throw new Error("Either cardId or shortId must be provided");
6287
6349
  }
6288
- let cardTitle = "";
6289
- let cardDescription = "";
6350
+ const variant = args.variant || undefined;
6351
+ const customConstraints = typeof args.customConstraints === "string" ? args.customConstraints : undefined;
6352
+ const contextOptions = {
6353
+ includeSubtasks: args.includeSubtasks !== false,
6354
+ includeLinks: args.includeLinks !== false,
6355
+ includeDescription: args.includeDescription !== false
6356
+ };
6290
6357
  try {
6291
- const { card } = await client3.getCard(cardId);
6292
- const typedCard = card;
6293
- cardTitle = typedCard.title || "";
6294
- cardDescription = typedCard.description || "";
6295
- } catch {}
6296
- const taskBlock = [cardTitle, cardDescription].filter(Boolean).join(`
6358
+ const result = await client3.generateCardPrompt({
6359
+ cardId,
6360
+ workspaceId: deps.getActiveWorkspaceId() || "",
6361
+ projectId,
6362
+ variant,
6363
+ customConstraints,
6364
+ contextOptions
6365
+ });
6366
+ return { success: true, ...result };
6367
+ } catch (err) {
6368
+ const msg = err instanceof Error ? err.message : String(err);
6369
+ console.debug(`[harmony_generate_prompt] falling back: ${msg}`);
6370
+ let cardTitle = "";
6371
+ let cardDescription = "";
6372
+ try {
6373
+ const { card } = await client3.getCard(cardId);
6374
+ const typedCard = card;
6375
+ cardTitle = typedCard.title || "";
6376
+ cardDescription = typedCard.description || "";
6377
+ } catch {}
6378
+ const taskBlock = [cardTitle, cardDescription].filter(Boolean).join(`
6297
6379
 
6298
6380
  `);
6299
- const prompt = `Here is the task: ${taskBlock || "(no description available)"}`;
6300
- return {
6301
- success: true,
6302
- prompt,
6303
- cardId
6304
- };
6381
+ const prompt = `Here is the task: ${taskBlock || "(no description available)"}`;
6382
+ return { success: true, prompt, cardId };
6383
+ }
6305
6384
  }
6306
6385
  case "harmony_remember": {
6307
6386
  const title = z.string().min(1).max(300).parse(args.title);
@@ -1240,12 +1240,43 @@ class HarmonyApiClient {
1240
1240
  const query = params.toString() ? `?${params.toString()}` : "";
1241
1241
  return this.request("GET", `/board/${projectId}${query}`);
1242
1242
  }
1243
+ async getFullBoard(projectId, options) {
1244
+ const { pageSize = 200, ...boardOpts } = options ?? {};
1245
+ const cards = [];
1246
+ let offset = 0;
1247
+ let last = null;
1248
+ for (;; ) {
1249
+ const page = await this.getBoard(projectId, {
1250
+ ...boardOpts,
1251
+ limit: pageSize,
1252
+ offset
1253
+ });
1254
+ last = page;
1255
+ const pageCards = page.cards ?? [];
1256
+ cards.push(...pageCards);
1257
+ const hasMore = page.pagination?.hasMore ?? false;
1258
+ if (!hasMore || pageCards.length === 0)
1259
+ break;
1260
+ offset += pageSize;
1261
+ }
1262
+ return {
1263
+ project: last?.project,
1264
+ columns: last?.columns ?? [],
1265
+ cards,
1266
+ labels: last?.labels ?? [],
1267
+ totalCards: last?.pagination?.totalCards ?? cards.length
1268
+ };
1269
+ }
1243
1270
  async createCard(projectId, data) {
1244
1271
  return this.request("POST", "/cards", { projectId, ...data });
1245
1272
  }
1246
1273
  async updateCard(cardId, updates) {
1247
1274
  return this.request("PATCH", `/cards/${cardId}`, updates);
1248
1275
  }
1276
+ async claimCard(cardId, agentId) {
1277
+ const res = await this.request("PATCH", `/cards/${cardId}`, { assignedAgentId: agentId, ifAssignedAgentNull: true });
1278
+ return { claimed: res.claimed !== false };
1279
+ }
1249
1280
  async moveCard(cardId, columnId, position) {
1250
1281
  return this.request("POST", `/cards/${cardId}/move`, {
1251
1282
  columnId,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gethmy/mcp",
3
- "version": "2.14.0",
3
+ "version": "2.16.0",
4
4
  "description": "MCP server for Harmony Kanban board - enables AI coding agents to manage your boards",
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -64,7 +64,7 @@
64
64
  "serve:remote": "bun src/remote.ts",
65
65
  "dev": "bun --watch src/index.ts",
66
66
  "test": "bun run test:unit && bun run test:integration",
67
- "test:unit": "bun test src/__tests__/active-learning.test.ts src/__tests__/context-assembly.test.ts src/__tests__/prompt-builder.test.ts src/__tests__/memory-audit.test.ts src/__tests__/skills.test.ts src/__tests__/hmy-config.test.ts src/__tests__/tool-dispatch.test.ts src/__tests__/mcp-integration.test.ts",
67
+ "test:unit": "bun test src/__tests__/active-learning.test.ts src/__tests__/context-assembly.test.ts src/__tests__/prompt-builder.test.ts src/__tests__/memory-audit.test.ts src/__tests__/skills.test.ts src/__tests__/hmy-config.test.ts src/__tests__/tool-dispatch.test.ts src/__tests__/mcp-integration.test.ts src/__tests__/auto-session.test.ts",
68
68
  "test:integration": "bun test src/__tests__/integration-memory-system.test.ts src/__tests__/integration-memory-crud.test.ts",
69
69
  "typecheck": "tsc --noEmit",
70
70
  "prepublishOnly": "bun run typecheck && bun run build"
package/src/api-client.ts CHANGED
@@ -527,6 +527,70 @@ export class HarmonyApiClient {
527
527
  return this.request("GET", `/board/${projectId}${query}`);
528
528
  }
529
529
 
530
+ /**
531
+ * Fetch the FULL board — every non-archived card — by paginating past the
532
+ * server's default 50-card window (`getBoard` caps at `limit=50` to keep MCP
533
+ * responses under the token budget).
534
+ *
535
+ * Daemon board scans must see the whole board: with the 50-card cap the
536
+ * entire `Review` column fell outside the first-50-by-position window once a
537
+ * project crossed 50 cards, silently starving the review pipeline (#628 — a
538
+ * 272-card project had 0 reviews for ~2 days). This has no LLM-context/token
539
+ * concern daemon-side, so it walks `offset` until `pagination.hasMore` is
540
+ * false and concatenates every page's cards.
541
+ *
542
+ * Summary mode carries no `cards` array, so callers that only need per-column
543
+ * counts should keep using `getBoard({ summary: true })`. This is the
544
+ * card-list path.
545
+ */
546
+ async getFullBoard(
547
+ projectId: string,
548
+ options?: {
549
+ columnId?: string;
550
+ includeArchived?: boolean;
551
+ labelName?: string;
552
+ /** Page size for the offset walk. Defaults to 200. */
553
+ pageSize?: number;
554
+ },
555
+ ): Promise<{
556
+ project: unknown;
557
+ columns: unknown[];
558
+ cards: unknown[];
559
+ labels: unknown[];
560
+ totalCards: number;
561
+ }> {
562
+ const { pageSize = 200, ...boardOpts } = options ?? {};
563
+ const cards: unknown[] = [];
564
+ let offset = 0;
565
+ let last: Awaited<ReturnType<HarmonyApiClient["getBoard"]>> | null = null;
566
+
567
+ // Walk pages until the server reports no more. Guard on `hasMore` (not
568
+ // `cards.length`) so a label-filtered final page shorter than pageSize
569
+ // terminates cleanly; the empty-page break is a belt-and-braces backstop
570
+ // against a server that omits `pagination`.
571
+ for (;;) {
572
+ const page = await this.getBoard(projectId, {
573
+ ...boardOpts,
574
+ limit: pageSize,
575
+ offset,
576
+ });
577
+ last = page;
578
+ const pageCards = page.cards ?? [];
579
+ cards.push(...pageCards);
580
+ const hasMore = page.pagination?.hasMore ?? false;
581
+ if (!hasMore || pageCards.length === 0) break;
582
+ offset += pageSize;
583
+ }
584
+
585
+ return {
586
+ project: last?.project,
587
+ columns: last?.columns ?? [],
588
+ cards,
589
+ labels: last?.labels ?? [],
590
+ totalCards: last?.pagination?.totalCards ?? cards.length,
591
+ };
592
+ }
593
+
530
594
  // ============ CARD OPERATIONS ============
531
595
 
532
596
  async createCard(
@@ -561,6 +625,25 @@ export class HarmonyApiClient {
561
625
  return this.request("PATCH", `/cards/${cardId}`, updates);
562
626
  }
563
627
 
628
+ /**
629
+ * Compare-and-set claim of a card for a virtual agent: sets assigned_agent_id
630
+ * only if it is currently NULL (server-side guard). Returns `{ claimed: false }`
631
+ * when another daemon already owns it. Used by the agent-agnostic review pickup.
632
+ */
633
+ async claimCard(
634
+ cardId: string,
635
+ agentId: string,
636
+ ): Promise<{ claimed: boolean }> {
637
+ const res = await this.request<{ card?: unknown; claimed?: boolean }>(
638
+ "PATCH",
639
+ `/cards/${cardId}`,
640
+ { assignedAgentId: agentId, ifAssignedAgentNull: true },
641
+ );
642
+ // Success body is `{ card }` (no `claimed` key) → won. Lost-race body is
643
+ // `{ claimed: false }`.
644
+ return { claimed: res.claimed !== false };
645
+ }
646
+
564
647
  async moveCard(
565
648
  cardId: string,
566
649
  columnId: string,
@@ -25,6 +25,16 @@
25
25
 
26
26
  import type { HarmonyApiClient } from "./api-client.js";
27
27
 
28
+ /**
29
+ * Status reported for a tracked session. Drives the heartbeat decision: only
30
+ * `working` sessions are heartbeated by the sweep. `paused`/`blocked`/`waiting`
31
+ * are intentional non-heartbeat states — the agent has deliberately stopped
32
+ * making progress (parked for a human, awaiting input, …), so bumping their
33
+ * `updated_at` would both keep a paused row looking active and mask a genuinely
34
+ * stalled run.
35
+ */
36
+ export type SessionStatus = "working" | "blocked" | "waiting" | "paused";
37
+
28
38
  export interface TrackedSession {
29
39
  cardId: string;
30
40
  startedAt: number;
@@ -33,6 +43,11 @@ export interface TrackedSession {
33
43
  isExplicit: boolean;
34
44
  agentIdentifier: string;
35
45
  agentName: string;
46
+ /**
47
+ * Last status reported for the session (default `working`). Only `working`
48
+ * sessions are heartbeated by the sweep; see `heartbeatActiveSessions`.
49
+ */
50
+ status?: SessionStatus;
36
51
  }
37
52
 
38
53
  export type EndSessionCallback = (
@@ -173,7 +188,7 @@ export function initAutoSession(
173
188
  // the remote transport, connections arrive faster than the 60s interval and a
174
189
  // restart-each-time would keep deferring the sweep indefinitely (starvation).
175
190
  if (!inactivityTimer) {
176
- inactivityTimer = setInterval(checkInactivity, CHECK_INTERVAL_MS);
191
+ inactivityTimer = setInterval(sweepTick, CHECK_INTERVAL_MS);
177
192
  }
178
193
  }
179
194
 
@@ -256,6 +271,7 @@ export async function trackActivity(
256
271
  isExplicit: false,
257
272
  agentIdentifier,
258
273
  agentName,
274
+ status: "working",
259
275
  });
260
276
  }
261
277
 
@@ -283,6 +299,7 @@ export function markExplicit(
283
299
  isExplicit: true,
284
300
  agentIdentifier: options?.agentIdentifier ?? "explicit",
285
301
  agentName: options?.agentName ?? "Explicit Agent",
302
+ status: "working",
286
303
  });
287
304
  }
288
305
  }
@@ -376,6 +393,79 @@ export function checkInactivity(): void {
376
393
  }
377
394
  }
378
395
 
396
+ /**
397
+ * Record the latest status reported for a tracked session so the sweep knows
398
+ * whether to keep heartbeating it. Wired into `harmony_update_agent_progress`
399
+ * (server.ts): when an agent reports `paused`/`blocked`/`waiting` the heartbeat
400
+ * stops for that session (those are intentional non-heartbeat states); a later
401
+ * `working` report resumes it. No-op when the card isn't tracked in the scope.
402
+ */
403
+ export function noteSessionStatus(
404
+ cardId: string,
405
+ status: SessionStatus,
406
+ scopeId: string = DEFAULT_SCOPE,
407
+ ): void {
408
+ const session = scopes.get(scopeId)?.sessions.get(cardId);
409
+ if (session) session.status = status;
410
+ }
411
+
412
+ /**
413
+ * Heartbeat every tracked `working` session by writing a status-only
414
+ * `updateAgentProgress`. The backend's `card_agent_context` update trigger
415
+ * bumps `updated_at` — the field both the kanban card ribbon
416
+ * (`isAgentContextLive`) and the header "Connected Agents" panel key off to
417
+ * decide a session is still live.
418
+ *
419
+ * WHY (card #608): an interactive MCP client (Claude Code, Cursor, /hmy) opens a
420
+ * `card_agent_context` row but, unlike the agent daemon's progress-tracker (60s
421
+ * heartbeat), only writes progress at milestones — often many minutes apart. On
422
+ * a long run `updated_at` goes stale and the card drops its live indicator
423
+ * mid-flight. This sweep gives interactive sessions the same ~60s heartbeat.
424
+ *
425
+ * Only `working` sessions are heartbeated; `paused`/`blocked`/`waiting` are
426
+ * intentional non-heartbeat states (see `SessionStatus`). The payload is
427
+ * status-only (no currentTask/progressPercent) so the heartbeat never clobbers
428
+ * the real progress the agent last reported — the trigger still bumps
429
+ * `updated_at`. Covers both auto-started and explicit sessions (both live in
430
+ * `scope.sessions`).
431
+ *
432
+ * No double-heartbeat for the daemon: `packages/harmony-agent` has its own
433
+ * progress-tracker heartbeat and never routes through mcp-server auto-sessions.
434
+ */
435
+ export function heartbeatActiveSessions(): void {
436
+ for (const scope of scopes.values()) {
437
+ const client = scope.clientGetter?.();
438
+ if (!client) continue;
439
+ for (const session of scope.sessions.values()) {
440
+ // Default-undefined status is treated as `working` (sessions created
441
+ // before a status was reported).
442
+ if ((session.status ?? "working") !== "working") continue;
443
+ client
444
+ .updateAgentProgress(session.cardId, {
445
+ agentIdentifier: session.agentIdentifier,
446
+ agentName: session.agentName,
447
+ status: "working",
448
+ })
449
+ .catch(() => {
450
+ // Best-effort: a transient failure just defers the bump to the next
451
+ // sweep. updateAgentProgress is fire-and-forget here.
452
+ });
453
+ }
454
+ }
455
+ }
456
+
457
+ /**
458
+ * One tick of the shared 60s sweep: prune idle auto-sessions
459
+ * (`checkInactivity`) first, then heartbeat the surviving `working` sessions
460
+ * (`heartbeatActiveSessions`). Order matters — prune before heartbeat so we
461
+ * never bump a session we're about to end. Exported for tests; in production
462
+ * the single process-wide timer calls it every CHECK_INTERVAL_MS.
463
+ */
464
+ export function sweepTick(): void {
465
+ checkInactivity();
466
+ heartbeatActiveSessions();
467
+ }
468
+
379
469
  // --- Internal ---
380
470
 
381
471
  async function autoEndSession(
package/src/server.ts CHANGED
@@ -22,6 +22,7 @@ import {
22
22
  destroyAutoSession,
23
23
  initAutoSession,
24
24
  markExplicit,
25
+ noteSessionStatus,
25
26
  shutdownAllSessions,
26
27
  trackActivity,
27
28
  untrack,
@@ -3684,15 +3685,24 @@ async function handleToolCall(
3684
3685
  .map((a) => a.description)
3685
3686
  .filter((d): d is string => typeof d === "string" && d.length > 0);
3686
3687
 
3688
+ const reportedStatus = args.status as
3689
+ | "working"
3690
+ | "blocked"
3691
+ | "waiting"
3692
+ | "paused"
3693
+ | undefined;
3694
+
3695
+ // Keep the auto-session heartbeat in step with the agent's own status
3696
+ // reports: `paused`/`blocked`/`waiting` stop the 60s heartbeat (intentional
3697
+ // non-heartbeat states), a later `working` resumes it (card #608).
3698
+ if (reportedStatus) {
3699
+ noteSessionStatus(cardId, reportedStatus, deps.getScopeId?.());
3700
+ }
3701
+
3687
3702
  const result = await client.updateAgentProgress(cardId, {
3688
3703
  agentIdentifier,
3689
3704
  agentName,
3690
- status: args.status as
3691
- | "working"
3692
- | "blocked"
3693
- | "waiting"
3694
- | "paused"
3695
- | undefined,
3705
+ status: reportedStatus,
3696
3706
  progressPercent,
3697
3707
  currentTask: args.currentTask as string | undefined,
3698
3708
  blockers: args.blockers as string[] | undefined,
@@ -3814,17 +3824,16 @@ async function handleToolCall(
3814
3824
  }
3815
3825
 
3816
3826
  // Prompt generation
3817
- // TODO Phase 1: rebuild full context assembly per docs/superpowers/plans/2026-05-07-memory-architecture-v2.md §10
3818
3827
  case "harmony_generate_prompt": {
3819
3828
  // Resolve card ID — either directly or via short ID
3820
3829
  let cardId: string;
3830
+ const projectId =
3831
+ (args.projectId as string) || deps.getActiveProjectId() || undefined;
3821
3832
 
3822
3833
  if (args.cardId) {
3823
3834
  cardId = z.string().uuid().parse(args.cardId);
3824
3835
  } else if (args.shortId !== undefined) {
3825
3836
  const shortId = z.number().int().positive().parse(args.shortId);
3826
- const projectId =
3827
- (args.projectId as string) || deps.getActiveProjectId();
3828
3837
  if (!projectId) {
3829
3838
  throw new Error(
3830
3839
  "Project ID required when using shortId. Use harmony_set_project_context or provide projectId.",
@@ -3836,29 +3845,58 @@ async function handleToolCall(
3836
3845
  throw new Error("Either cardId or shortId must be provided");
3837
3846
  }
3838
3847
 
3839
- // Phase 0: minimal placeholder prompt with no context assembly.
3840
- // Fetch just the card title/description so the agent has the basics.
3841
- let cardTitle = "";
3842
- let cardDescription = "";
3848
+ const variant =
3849
+ (args.variant as "analysis" | "draft" | "execute") || undefined;
3850
+ const customConstraints =
3851
+ typeof args.customConstraints === "string"
3852
+ ? args.customConstraints
3853
+ : undefined;
3854
+
3855
+ // Context toggles default ON — a card's reference fields (linked cards,
3856
+ // external URLs, attachments) are hydrated inside generateCardPrompt so
3857
+ // the prompt carries the links the agent needs to fetch related content.
3858
+ const contextOptions = {
3859
+ includeSubtasks: args.includeSubtasks !== false,
3860
+ includeLinks: args.includeLinks !== false,
3861
+ includeDescription: args.includeDescription !== false,
3862
+ };
3863
+
3843
3864
  try {
3844
- const { card } = await client.getCard(cardId);
3845
- const typedCard = card as { title?: string; description?: string };
3846
- cardTitle = typedCard.title || "";
3847
- cardDescription = typedCard.description || "";
3848
- } catch {
3849
- // Card fetch failed; return an empty task description.
3850
- }
3865
+ const result = await client.generateCardPrompt({
3866
+ cardId,
3867
+ workspaceId: deps.getActiveWorkspaceId() || "",
3868
+ projectId,
3869
+ variant,
3870
+ customConstraints,
3871
+ contextOptions,
3872
+ });
3873
+ return { success: true, ...result };
3874
+ } catch (err) {
3875
+ // Graceful fallback: if full context assembly fails (e.g. an offline
3876
+ // sub-fetch), still return a usable title/description prompt so the
3877
+ // Harmony command never dead-ends. The reference-link hydration is
3878
+ // best-effort inside generateCardPrompt and won't reach here.
3879
+ const msg = err instanceof Error ? err.message : String(err);
3880
+ console.debug(`[harmony_generate_prompt] falling back: ${msg}`);
3881
+
3882
+ let cardTitle = "";
3883
+ let cardDescription = "";
3884
+ try {
3885
+ const { card } = await client.getCard(cardId);
3886
+ const typedCard = card as { title?: string; description?: string };
3887
+ cardTitle = typedCard.title || "";
3888
+ cardDescription = typedCard.description || "";
3889
+ } catch {
3890
+ // Card fetch failed; return an empty task description.
3891
+ }
3851
3892
 
3852
- const taskBlock = [cardTitle, cardDescription]
3853
- .filter(Boolean)
3854
- .join("\n\n");
3855
- const prompt = `Here is the task: ${taskBlock || "(no description available)"}`;
3893
+ const taskBlock = [cardTitle, cardDescription]
3894
+ .filter(Boolean)
3895
+ .join("\n\n");
3896
+ const prompt = `Here is the task: ${taskBlock || "(no description available)"}`;
3856
3897
 
3857
- return {
3858
- success: true,
3859
- prompt,
3860
- cardId,
3861
- };
3898
+ return { success: true, prompt, cardId };
3899
+ }
3862
3900
  }
3863
3901
 
3864
3902
  // Memory / Knowledge Graph operations