@gethmy/mcp 2.14.0 → 2.15.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,6 +1793,33 @@ 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
  }
@@ -2460,7 +2487,7 @@ function initAutoSession(callback, getClient2, getClientInfo, scopeId = DEFAULT_
2460
2487
  scope.clientGetter = getClient2;
2461
2488
  scope.clientInfoGetter = getClientInfo ?? null;
2462
2489
  if (!inactivityTimer) {
2463
- inactivityTimer = setInterval(checkInactivity, CHECK_INTERVAL_MS);
2490
+ inactivityTimer = setInterval(sweepTick, CHECK_INTERVAL_MS);
2464
2491
  }
2465
2492
  }
2466
2493
  async function trackActivity(cardId, options) {
@@ -2502,7 +2529,8 @@ async function trackActivity(cardId, options) {
2502
2529
  lastActivityAt: now,
2503
2530
  isExplicit: false,
2504
2531
  agentIdentifier,
2505
- agentName
2532
+ agentName,
2533
+ status: "working"
2506
2534
  });
2507
2535
  }
2508
2536
  function markExplicit(cardId, options) {
@@ -2521,7 +2549,8 @@ function markExplicit(cardId, options) {
2521
2549
  lastActivityAt: Date.now(),
2522
2550
  isExplicit: true,
2523
2551
  agentIdentifier: options?.agentIdentifier ?? "explicit",
2524
- agentName: options?.agentName ?? "Explicit Agent"
2552
+ agentName: options?.agentName ?? "Explicit Agent",
2553
+ status: "working"
2525
2554
  });
2526
2555
  }
2527
2556
  }
@@ -2567,6 +2596,31 @@ function checkInactivity() {
2567
2596
  }
2568
2597
  }
2569
2598
  }
2599
+ function noteSessionStatus(cardId, status, scopeId = DEFAULT_SCOPE) {
2600
+ const session = scopes.get(scopeId)?.sessions.get(cardId);
2601
+ if (session)
2602
+ session.status = status;
2603
+ }
2604
+ function heartbeatActiveSessions() {
2605
+ for (const scope of scopes.values()) {
2606
+ const client3 = scope.clientGetter?.();
2607
+ if (!client3)
2608
+ continue;
2609
+ for (const session of scope.sessions.values()) {
2610
+ if ((session.status ?? "working") !== "working")
2611
+ continue;
2612
+ client3.updateAgentProgress(session.cardId, {
2613
+ agentIdentifier: session.agentIdentifier,
2614
+ agentName: session.agentName,
2615
+ status: "working"
2616
+ }).catch(() => {});
2617
+ }
2618
+ }
2619
+ }
2620
+ function sweepTick() {
2621
+ checkInactivity();
2622
+ heartbeatActiveSessions();
2623
+ }
2570
2624
  async function autoEndSession(scope, client3, cardId, status) {
2571
2625
  if (!scope.sessions.delete(cardId))
2572
2626
  return;
@@ -6203,10 +6257,14 @@ async function handleToolCall(name, args, deps) {
6203
6257
  mergedRecentActions = callerRecentActions;
6204
6258
  }
6205
6259
  const runActivity = (callerActions || []).map((a) => a.description).filter((d) => typeof d === "string" && d.length > 0);
6260
+ const reportedStatus = args.status;
6261
+ if (reportedStatus) {
6262
+ noteSessionStatus(cardId, reportedStatus, deps.getScopeId?.());
6263
+ }
6206
6264
  const result = await client3.updateAgentProgress(cardId, {
6207
6265
  agentIdentifier,
6208
6266
  agentName,
6209
- status: args.status,
6267
+ status: reportedStatus,
6210
6268
  progressPercent,
6211
6269
  currentTask: args.currentTask,
6212
6270
  blockers: args.blockers,
package/dist/index.js CHANGED
@@ -1788,6 +1788,33 @@ 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
  }
@@ -2455,7 +2482,7 @@ function initAutoSession(callback, getClient2, getClientInfo, scopeId = DEFAULT_
2455
2482
  scope.clientGetter = getClient2;
2456
2483
  scope.clientInfoGetter = getClientInfo ?? null;
2457
2484
  if (!inactivityTimer) {
2458
- inactivityTimer = setInterval(checkInactivity, CHECK_INTERVAL_MS);
2485
+ inactivityTimer = setInterval(sweepTick, CHECK_INTERVAL_MS);
2459
2486
  }
2460
2487
  }
2461
2488
  async function trackActivity(cardId, options) {
@@ -2497,7 +2524,8 @@ async function trackActivity(cardId, options) {
2497
2524
  lastActivityAt: now,
2498
2525
  isExplicit: false,
2499
2526
  agentIdentifier,
2500
- agentName
2527
+ agentName,
2528
+ status: "working"
2501
2529
  });
2502
2530
  }
2503
2531
  function markExplicit(cardId, options) {
@@ -2516,7 +2544,8 @@ function markExplicit(cardId, options) {
2516
2544
  lastActivityAt: Date.now(),
2517
2545
  isExplicit: true,
2518
2546
  agentIdentifier: options?.agentIdentifier ?? "explicit",
2519
- agentName: options?.agentName ?? "Explicit Agent"
2547
+ agentName: options?.agentName ?? "Explicit Agent",
2548
+ status: "working"
2520
2549
  });
2521
2550
  }
2522
2551
  }
@@ -2562,6 +2591,31 @@ function checkInactivity() {
2562
2591
  }
2563
2592
  }
2564
2593
  }
2594
+ function noteSessionStatus(cardId, status, scopeId = DEFAULT_SCOPE) {
2595
+ const session = scopes.get(scopeId)?.sessions.get(cardId);
2596
+ if (session)
2597
+ session.status = status;
2598
+ }
2599
+ function heartbeatActiveSessions() {
2600
+ for (const scope of scopes.values()) {
2601
+ const client3 = scope.clientGetter?.();
2602
+ if (!client3)
2603
+ continue;
2604
+ for (const session of scope.sessions.values()) {
2605
+ if ((session.status ?? "working") !== "working")
2606
+ continue;
2607
+ client3.updateAgentProgress(session.cardId, {
2608
+ agentIdentifier: session.agentIdentifier,
2609
+ agentName: session.agentName,
2610
+ status: "working"
2611
+ }).catch(() => {});
2612
+ }
2613
+ }
2614
+ }
2615
+ function sweepTick() {
2616
+ checkInactivity();
2617
+ heartbeatActiveSessions();
2618
+ }
2565
2619
  async function autoEndSession(scope, client3, cardId, status) {
2566
2620
  if (!scope.sessions.delete(cardId))
2567
2621
  return;
@@ -6198,10 +6252,14 @@ async function handleToolCall(name, args, deps) {
6198
6252
  mergedRecentActions = callerRecentActions;
6199
6253
  }
6200
6254
  const runActivity = (callerActions || []).map((a) => a.description).filter((d) => typeof d === "string" && d.length > 0);
6255
+ const reportedStatus = args.status;
6256
+ if (reportedStatus) {
6257
+ noteSessionStatus(cardId, reportedStatus, deps.getScopeId?.());
6258
+ }
6201
6259
  const result = await client3.updateAgentProgress(cardId, {
6202
6260
  agentIdentifier,
6203
6261
  agentName,
6204
- status: args.status,
6262
+ status: reportedStatus,
6205
6263
  progressPercent,
6206
6264
  currentTask: args.currentTask,
6207
6265
  blockers: args.blockers,
@@ -1240,6 +1240,33 @@ 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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gethmy/mcp",
3
- "version": "2.14.0",
3
+ "version": "2.15.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(
@@ -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,