@gethmy/mcp 2.17.0 → 2.18.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
@@ -1499,6 +1499,21 @@ var TIMINGS = {
1499
1499
  QUERY_STALE_TIME: 1000 * 60 * 5,
1500
1500
  QUERY_GC_TIME: 1000 * 60 * 60 * 24
1501
1501
  };
1502
+ // ../harmony-shared/dist/playbookStage.js
1503
+ var STAGE_DAEMON_OWNED_TOOLS = [
1504
+ "mcp__harmony__harmony_end_agent_session",
1505
+ "mcp__harmony__harmony_start_agent_session",
1506
+ "mcp__harmony__harmony_move_card"
1507
+ ];
1508
+ // ../harmony-shared/dist/reviewTools.js
1509
+ var REVIEW_DISALLOWED_TOOLS = [
1510
+ ...STAGE_DAEMON_OWNED_TOOLS,
1511
+ "mcp__harmony__harmony_update_card",
1512
+ "mcp__harmony__harmony_create_subtask",
1513
+ "mcp__harmony__harmony_update_subtask",
1514
+ "mcp__harmony__harmony_delete_subtask",
1515
+ "mcp__harmony__harmony_toggle_subtask"
1516
+ ];
1502
1517
  // ../harmony-shared/dist/stageHandoff.js
1503
1518
  var HANDOFF_MARKER = "harmony:stage-handoff";
1504
1519
  var HANDOFF_BLOCK_RE = new RegExp("```json\\s*\\n//\\s*" + HANDOFF_MARKER + "\\s*\\n([\\s\\S]*?)\\n```", "m");
@@ -1761,6 +1776,9 @@ class HarmonyApiClient {
1761
1776
  async getWorkspaceMembers(workspaceId) {
1762
1777
  return this.request("GET", `/workspaces/${workspaceId}/members`);
1763
1778
  }
1779
+ async getAuthContext() {
1780
+ return this.request("GET", "/auth/context");
1781
+ }
1764
1782
  async listWorkspaceAgents(workspaceId) {
1765
1783
  return this.request("GET", `/workspaces/${workspaceId}/agents`);
1766
1784
  }
@@ -1851,6 +1869,10 @@ class HarmonyApiClient {
1851
1869
  async getCardByShortId(projectId, shortId) {
1852
1870
  return this.request("GET", `/projects/${projectId}/cards/${shortId}`);
1853
1871
  }
1872
+ async resolveCardByShortId(shortId, preferredProjectId) {
1873
+ const qs = preferredProjectId ? `?preferred_project_id=${encodeURIComponent(preferredProjectId)}` : "";
1874
+ return this.request("GET", `/cards/resolve/${shortId}${qs}`);
1875
+ }
1854
1876
  async bulkGetCards(projectId, shortIds) {
1855
1877
  return this.request("POST", `/projects/${projectId}/cards/bulk-get`, {
1856
1878
  shortIds
@@ -2481,6 +2503,7 @@ var AUTO_START_TRIGGERS = new Set([
2481
2503
  ]);
2482
2504
  var INACTIVITY_TIMEOUT_MS = 10 * 60 * 1000;
2483
2505
  var CHECK_INTERVAL_MS = 60 * 1000;
2506
+ var HEARTBEAT_ACTIVITY_WINDOW_MS = 25 * 60 * 1000;
2484
2507
  var DEFAULT_SCOPE = "__default__";
2485
2508
  var scopes = new Map;
2486
2509
  var inactivityTimer = null;
@@ -2618,17 +2641,25 @@ function noteSessionStatus(cardId, status, scopeId = DEFAULT_SCOPE) {
2618
2641
  session.status = status;
2619
2642
  }
2620
2643
  function heartbeatActiveSessions() {
2644
+ const now = Date.now();
2621
2645
  for (const scope of scopes.values()) {
2622
2646
  const client3 = scope.clientGetter?.();
2623
2647
  if (!client3)
2624
2648
  continue;
2625
- for (const session of scope.sessions.values()) {
2649
+ for (const [cardId, session] of [...scope.sessions.entries()]) {
2626
2650
  if ((session.status ?? "working") !== "working")
2627
2651
  continue;
2652
+ if (now - session.lastActivityAt > HEARTBEAT_ACTIVITY_WINDOW_MS)
2653
+ continue;
2628
2654
  client3.updateAgentProgress(session.cardId, {
2629
2655
  agentIdentifier: session.agentIdentifier,
2630
2656
  agentName: session.agentName,
2631
- status: "working"
2657
+ status: "working",
2658
+ noCreate: true
2659
+ }).then((res) => {
2660
+ if (res?.session === null && scope.sessions.get(cardId) === session) {
2661
+ scope.sessions.delete(cardId);
2662
+ }
2632
2663
  }).catch(() => {});
2633
2664
  }
2634
2665
  }
@@ -5736,9 +5767,51 @@ async function handleToolCall(name, args, deps) {
5736
5767
  }
5737
5768
  if (hasShortId) {
5738
5769
  const shortId = z.number().int().positive().parse(args.shortId);
5739
- const projectId = args.projectId || getProjectId();
5740
- const result2 = await client3.getCardByShortId(projectId, shortId);
5741
- return { success: true, ...result2 };
5770
+ const explicitProjectId = args.projectId;
5771
+ if (explicitProjectId) {
5772
+ const result2 = await client3.getCardByShortId(explicitProjectId, shortId);
5773
+ return { success: true, ...result2 };
5774
+ }
5775
+ const activeProjectId = deps.getActiveProjectId();
5776
+ const resolved = await client3.resolveCardByShortId(shortId, activeProjectId);
5777
+ if (resolved.kind === "found") {
5778
+ const cardTitle = resolved.card?.title ?? `#${shortId}`;
5779
+ const where = resolved.project.workspaceName ? `project "${resolved.project.name ?? resolved.project.id}" (workspace "${resolved.project.workspaceName}")` : `project "${resolved.project.name ?? resolved.project.id}"`;
5780
+ const established = activeProjectId == null;
5781
+ if (established) {
5782
+ deps.setActiveProject(resolved.project.id);
5783
+ }
5784
+ return {
5785
+ success: true,
5786
+ card: resolved.card,
5787
+ resolvedProject: resolved.project,
5788
+ activeProjectId: resolved.project.id,
5789
+ note: established ? `Resolved #${shortId} → "${cardTitle}" in ${where}. No active project was set — set to this for follow-up references.` : `Resolved #${shortId} → "${cardTitle}" in ${where} (your active project).`
5790
+ };
5791
+ }
5792
+ if (resolved.kind === "not_in_preferred") {
5793
+ const list = resolved.candidates.map((c) => ` • "${c.title}" — project "${c.projectName ?? c.projectId}"${c.workspaceName ? ` / workspace "${c.workspaceName}"` : ""} (projectId: ${c.projectId})`).join(`
5794
+ `);
5795
+ throw new Error(`#${shortId} is not in your active project (projectId: ${resolved.preferredProjectId}). ` + `It exists in ${resolved.candidates.length} other project(s) you can access:
5796
+ ${list}
5797
+
5798
+ ` + `Switch with harmony_set_project_context, or pass an explicit projectId to fetch it directly.`);
5799
+ }
5800
+ if (resolved.kind === "ambiguous") {
5801
+ const list = resolved.candidates.map((c) => ` • "${c.title}" — project "${c.projectName ?? c.projectId}"${c.workspaceName ? ` / workspace "${c.workspaceName}"` : ""} (projectId: ${c.projectId})`).join(`
5802
+ `);
5803
+ return {
5804
+ success: true,
5805
+ needsDisambiguation: true,
5806
+ shortId,
5807
+ candidates: resolved.candidates,
5808
+ message: `#${shortId} exists in ${resolved.candidates.length} projects you can access:
5809
+ ${list}
5810
+
5811
+ ` + `Ask which one is meant, then re-fetch with an explicit projectId ` + `(or call harmony_set_project_context first).`
5812
+ };
5813
+ }
5814
+ throw new Error(resolved.searchedProjectCount === 0 ? `#${shortId} can't be resolved: no project is accessible to this connection. ` + `Check the workspace this connection is authorized for with harmony_list_workspaces.` : `Card #${shortId} was not found in any of the ${resolved.searchedProjectCount} ` + `project(s) across ${resolved.searchedWorkspaceCount} workspace(s) this connection can access. ` + `Use harmony_list_projects to see them, or pass an explicit projectId.`);
5742
5815
  }
5743
5816
  const cardId = z.string().uuid().parse(args.cardId);
5744
5817
  const result = await client3.getCard(cardId);
@@ -6148,6 +6221,23 @@ async function handleToolCall(name, args, deps) {
6148
6221
  }
6149
6222
  case "harmony_set_workspace_context": {
6150
6223
  const workspaceId = z.string().uuid().parse(args.workspaceId);
6224
+ const { workspaces } = await client3.listWorkspaces();
6225
+ const available = workspaces ?? [];
6226
+ const match = available.find((w) => w?.id === workspaceId);
6227
+ if (!match) {
6228
+ const options = available.filter((w) => w?.id).map((w) => ` - ${w.name ?? "(unnamed)"} (${w.id})`).join(`
6229
+ `);
6230
+ throw new Error(`Workspace ${workspaceId} is not available to this connection.
6231
+
6232
+ ` + `This MCP connection is authorized for a specific workspace, chosen ` + `during OAuth consent — it cannot read or write any other workspace, ` + `even ones you're a member of.
6233
+
6234
+ ` + (options ? `Available here:
6235
+ ${options}
6236
+
6237
+ ` : `No workspaces are available to this connection.
6238
+
6239
+ `) + `To work in a different workspace, run /mcp to reconnect and select ` + `it on the consent screen.`);
6240
+ }
6151
6241
  deps.setActiveWorkspace(workspaceId);
6152
6242
  return { success: true, activeWorkspaceId: workspaceId };
6153
6243
  }
package/dist/index.js CHANGED
@@ -1494,6 +1494,21 @@ var TIMINGS = {
1494
1494
  QUERY_STALE_TIME: 1000 * 60 * 5,
1495
1495
  QUERY_GC_TIME: 1000 * 60 * 60 * 24
1496
1496
  };
1497
+ // ../harmony-shared/dist/playbookStage.js
1498
+ var STAGE_DAEMON_OWNED_TOOLS = [
1499
+ "mcp__harmony__harmony_end_agent_session",
1500
+ "mcp__harmony__harmony_start_agent_session",
1501
+ "mcp__harmony__harmony_move_card"
1502
+ ];
1503
+ // ../harmony-shared/dist/reviewTools.js
1504
+ var REVIEW_DISALLOWED_TOOLS = [
1505
+ ...STAGE_DAEMON_OWNED_TOOLS,
1506
+ "mcp__harmony__harmony_update_card",
1507
+ "mcp__harmony__harmony_create_subtask",
1508
+ "mcp__harmony__harmony_update_subtask",
1509
+ "mcp__harmony__harmony_delete_subtask",
1510
+ "mcp__harmony__harmony_toggle_subtask"
1511
+ ];
1497
1512
  // ../harmony-shared/dist/stageHandoff.js
1498
1513
  var HANDOFF_MARKER = "harmony:stage-handoff";
1499
1514
  var HANDOFF_BLOCK_RE = new RegExp("```json\\s*\\n//\\s*" + HANDOFF_MARKER + "\\s*\\n([\\s\\S]*?)\\n```", "m");
@@ -1756,6 +1771,9 @@ class HarmonyApiClient {
1756
1771
  async getWorkspaceMembers(workspaceId) {
1757
1772
  return this.request("GET", `/workspaces/${workspaceId}/members`);
1758
1773
  }
1774
+ async getAuthContext() {
1775
+ return this.request("GET", "/auth/context");
1776
+ }
1759
1777
  async listWorkspaceAgents(workspaceId) {
1760
1778
  return this.request("GET", `/workspaces/${workspaceId}/agents`);
1761
1779
  }
@@ -1846,6 +1864,10 @@ class HarmonyApiClient {
1846
1864
  async getCardByShortId(projectId, shortId) {
1847
1865
  return this.request("GET", `/projects/${projectId}/cards/${shortId}`);
1848
1866
  }
1867
+ async resolveCardByShortId(shortId, preferredProjectId) {
1868
+ const qs = preferredProjectId ? `?preferred_project_id=${encodeURIComponent(preferredProjectId)}` : "";
1869
+ return this.request("GET", `/cards/resolve/${shortId}${qs}`);
1870
+ }
1849
1871
  async bulkGetCards(projectId, shortIds) {
1850
1872
  return this.request("POST", `/projects/${projectId}/cards/bulk-get`, {
1851
1873
  shortIds
@@ -2476,6 +2498,7 @@ var AUTO_START_TRIGGERS = new Set([
2476
2498
  ]);
2477
2499
  var INACTIVITY_TIMEOUT_MS = 10 * 60 * 1000;
2478
2500
  var CHECK_INTERVAL_MS = 60 * 1000;
2501
+ var HEARTBEAT_ACTIVITY_WINDOW_MS = 25 * 60 * 1000;
2479
2502
  var DEFAULT_SCOPE = "__default__";
2480
2503
  var scopes = new Map;
2481
2504
  var inactivityTimer = null;
@@ -2613,17 +2636,25 @@ function noteSessionStatus(cardId, status, scopeId = DEFAULT_SCOPE) {
2613
2636
  session.status = status;
2614
2637
  }
2615
2638
  function heartbeatActiveSessions() {
2639
+ const now = Date.now();
2616
2640
  for (const scope of scopes.values()) {
2617
2641
  const client3 = scope.clientGetter?.();
2618
2642
  if (!client3)
2619
2643
  continue;
2620
- for (const session of scope.sessions.values()) {
2644
+ for (const [cardId, session] of [...scope.sessions.entries()]) {
2621
2645
  if ((session.status ?? "working") !== "working")
2622
2646
  continue;
2647
+ if (now - session.lastActivityAt > HEARTBEAT_ACTIVITY_WINDOW_MS)
2648
+ continue;
2623
2649
  client3.updateAgentProgress(session.cardId, {
2624
2650
  agentIdentifier: session.agentIdentifier,
2625
2651
  agentName: session.agentName,
2626
- status: "working"
2652
+ status: "working",
2653
+ noCreate: true
2654
+ }).then((res) => {
2655
+ if (res?.session === null && scope.sessions.get(cardId) === session) {
2656
+ scope.sessions.delete(cardId);
2657
+ }
2627
2658
  }).catch(() => {});
2628
2659
  }
2629
2660
  }
@@ -5731,9 +5762,51 @@ async function handleToolCall(name, args, deps) {
5731
5762
  }
5732
5763
  if (hasShortId) {
5733
5764
  const shortId = z.number().int().positive().parse(args.shortId);
5734
- const projectId = args.projectId || getProjectId();
5735
- const result2 = await client3.getCardByShortId(projectId, shortId);
5736
- return { success: true, ...result2 };
5765
+ const explicitProjectId = args.projectId;
5766
+ if (explicitProjectId) {
5767
+ const result2 = await client3.getCardByShortId(explicitProjectId, shortId);
5768
+ return { success: true, ...result2 };
5769
+ }
5770
+ const activeProjectId = deps.getActiveProjectId();
5771
+ const resolved = await client3.resolveCardByShortId(shortId, activeProjectId);
5772
+ if (resolved.kind === "found") {
5773
+ const cardTitle = resolved.card?.title ?? `#${shortId}`;
5774
+ const where = resolved.project.workspaceName ? `project "${resolved.project.name ?? resolved.project.id}" (workspace "${resolved.project.workspaceName}")` : `project "${resolved.project.name ?? resolved.project.id}"`;
5775
+ const established = activeProjectId == null;
5776
+ if (established) {
5777
+ deps.setActiveProject(resolved.project.id);
5778
+ }
5779
+ return {
5780
+ success: true,
5781
+ card: resolved.card,
5782
+ resolvedProject: resolved.project,
5783
+ activeProjectId: resolved.project.id,
5784
+ note: established ? `Resolved #${shortId} → "${cardTitle}" in ${where}. No active project was set — set to this for follow-up references.` : `Resolved #${shortId} → "${cardTitle}" in ${where} (your active project).`
5785
+ };
5786
+ }
5787
+ if (resolved.kind === "not_in_preferred") {
5788
+ const list = resolved.candidates.map((c) => ` • "${c.title}" — project "${c.projectName ?? c.projectId}"${c.workspaceName ? ` / workspace "${c.workspaceName}"` : ""} (projectId: ${c.projectId})`).join(`
5789
+ `);
5790
+ throw new Error(`#${shortId} is not in your active project (projectId: ${resolved.preferredProjectId}). ` + `It exists in ${resolved.candidates.length} other project(s) you can access:
5791
+ ${list}
5792
+
5793
+ ` + `Switch with harmony_set_project_context, or pass an explicit projectId to fetch it directly.`);
5794
+ }
5795
+ if (resolved.kind === "ambiguous") {
5796
+ const list = resolved.candidates.map((c) => ` • "${c.title}" — project "${c.projectName ?? c.projectId}"${c.workspaceName ? ` / workspace "${c.workspaceName}"` : ""} (projectId: ${c.projectId})`).join(`
5797
+ `);
5798
+ return {
5799
+ success: true,
5800
+ needsDisambiguation: true,
5801
+ shortId,
5802
+ candidates: resolved.candidates,
5803
+ message: `#${shortId} exists in ${resolved.candidates.length} projects you can access:
5804
+ ${list}
5805
+
5806
+ ` + `Ask which one is meant, then re-fetch with an explicit projectId ` + `(or call harmony_set_project_context first).`
5807
+ };
5808
+ }
5809
+ throw new Error(resolved.searchedProjectCount === 0 ? `#${shortId} can't be resolved: no project is accessible to this connection. ` + `Check the workspace this connection is authorized for with harmony_list_workspaces.` : `Card #${shortId} was not found in any of the ${resolved.searchedProjectCount} ` + `project(s) across ${resolved.searchedWorkspaceCount} workspace(s) this connection can access. ` + `Use harmony_list_projects to see them, or pass an explicit projectId.`);
5737
5810
  }
5738
5811
  const cardId = z.string().uuid().parse(args.cardId);
5739
5812
  const result = await client3.getCard(cardId);
@@ -6143,6 +6216,23 @@ async function handleToolCall(name, args, deps) {
6143
6216
  }
6144
6217
  case "harmony_set_workspace_context": {
6145
6218
  const workspaceId = z.string().uuid().parse(args.workspaceId);
6219
+ const { workspaces } = await client3.listWorkspaces();
6220
+ const available = workspaces ?? [];
6221
+ const match = available.find((w) => w?.id === workspaceId);
6222
+ if (!match) {
6223
+ const options = available.filter((w) => w?.id).map((w) => ` - ${w.name ?? "(unnamed)"} (${w.id})`).join(`
6224
+ `);
6225
+ throw new Error(`Workspace ${workspaceId} is not available to this connection.
6226
+
6227
+ ` + `This MCP connection is authorized for a specific workspace, chosen ` + `during OAuth consent — it cannot read or write any other workspace, ` + `even ones you're a member of.
6228
+
6229
+ ` + (options ? `Available here:
6230
+ ${options}
6231
+
6232
+ ` : `No workspaces are available to this connection.
6233
+
6234
+ `) + `To work in a different workspace, run /mcp to reconnect and select ` + `it on the consent screen.`);
6235
+ }
6146
6236
  deps.setActiveWorkspace(workspaceId);
6147
6237
  return { success: true, activeWorkspaceId: workspaceId };
6148
6238
  }
@@ -946,6 +946,21 @@ var TIMINGS = {
946
946
  QUERY_STALE_TIME: 1000 * 60 * 5,
947
947
  QUERY_GC_TIME: 1000 * 60 * 60 * 24
948
948
  };
949
+ // ../harmony-shared/dist/playbookStage.js
950
+ var STAGE_DAEMON_OWNED_TOOLS = [
951
+ "mcp__harmony__harmony_end_agent_session",
952
+ "mcp__harmony__harmony_start_agent_session",
953
+ "mcp__harmony__harmony_move_card"
954
+ ];
955
+ // ../harmony-shared/dist/reviewTools.js
956
+ var REVIEW_DISALLOWED_TOOLS = [
957
+ ...STAGE_DAEMON_OWNED_TOOLS,
958
+ "mcp__harmony__harmony_update_card",
959
+ "mcp__harmony__harmony_create_subtask",
960
+ "mcp__harmony__harmony_update_subtask",
961
+ "mcp__harmony__harmony_delete_subtask",
962
+ "mcp__harmony__harmony_toggle_subtask"
963
+ ];
949
964
  // ../harmony-shared/dist/stageHandoff.js
950
965
  var HANDOFF_MARKER = "harmony:stage-handoff";
951
966
  var HANDOFF_BLOCK_RE = new RegExp("```json\\s*\\n//\\s*" + HANDOFF_MARKER + "\\s*\\n([\\s\\S]*?)\\n```", "m");
@@ -1208,6 +1223,9 @@ class HarmonyApiClient {
1208
1223
  async getWorkspaceMembers(workspaceId) {
1209
1224
  return this.request("GET", `/workspaces/${workspaceId}/members`);
1210
1225
  }
1226
+ async getAuthContext() {
1227
+ return this.request("GET", "/auth/context");
1228
+ }
1211
1229
  async listWorkspaceAgents(workspaceId) {
1212
1230
  return this.request("GET", `/workspaces/${workspaceId}/agents`);
1213
1231
  }
@@ -1298,6 +1316,10 @@ class HarmonyApiClient {
1298
1316
  async getCardByShortId(projectId, shortId) {
1299
1317
  return this.request("GET", `/projects/${projectId}/cards/${shortId}`);
1300
1318
  }
1319
+ async resolveCardByShortId(shortId, preferredProjectId) {
1320
+ const qs = preferredProjectId ? `?preferred_project_id=${encodeURIComponent(preferredProjectId)}` : "";
1321
+ return this.request("GET", `/cards/resolve/${shortId}${qs}`);
1322
+ }
1301
1323
  async bulkGetCards(projectId, shortIds) {
1302
1324
  return this.request("POST", `/projects/${projectId}/cards/bulk-get`, {
1303
1325
  shortIds
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gethmy/mcp",
3
- "version": "2.17.0",
3
+ "version": "2.18.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"
package/src/api-client.ts CHANGED
@@ -164,6 +164,46 @@ export interface CardExternalLinkRow {
164
164
  created_at: string;
165
165
  }
166
166
 
167
+ /** One candidate card returned by the cross-project short-id resolver (#709). */
168
+ export interface ResolveCardCandidate {
169
+ cardId: string;
170
+ title: string;
171
+ projectId: string;
172
+ projectName: string | null;
173
+ workspaceId: string;
174
+ workspaceName: string | null;
175
+ }
176
+
177
+ /** Discriminated result of GET /cards/resolve/:shortId (#709). `found` carries
178
+ * the hydrated card + its project (for sticky-context + confirm-back);
179
+ * `ambiguous` carries the candidates so the caller asks which one; `not_found`
180
+ * carries the searched scope for a legible failure; `not_in_preferred` (#428)
181
+ * means a deliberately-set active project (the hard scope) doesn't hold the id,
182
+ * and carries the projects that DO so the caller can switch or pass an explicit
183
+ * projectId — the tool never silently hops to another project. */
184
+ export type ResolveCardApiResult =
185
+ | {
186
+ kind: "found";
187
+ card: unknown;
188
+ project: {
189
+ id: string;
190
+ name: string | null;
191
+ workspaceId: string;
192
+ workspaceName: string | null;
193
+ };
194
+ }
195
+ | { kind: "ambiguous"; candidates: ResolveCardCandidate[] }
196
+ | {
197
+ kind: "not_found";
198
+ searchedWorkspaceCount: number;
199
+ searchedProjectCount: number;
200
+ }
201
+ | {
202
+ kind: "not_in_preferred";
203
+ preferredProjectId: string;
204
+ candidates: ResolveCardCandidate[];
205
+ };
206
+
167
207
  /** Result of the classify-card classifier (card #415). Any field may be null
168
208
  * if the LLM didn't return a usable value. `model_override` is never touched. */
169
209
  export interface CardClassificationResult {
@@ -461,6 +501,22 @@ export class HarmonyApiClient {
461
501
  return this.request("GET", `/workspaces/${workspaceId}/members`);
462
502
  }
463
503
 
504
+ /**
505
+ * The auth user THIS client's credential resolves to server-side — the id
506
+ * harmony-api stamps onto sessions it creates for this caller. The agent
507
+ * daemon uses it to assert its configured `userEmail` and its API key are the
508
+ * same user; a divergence would make it fail closed on its own comment
509
+ * artifacts (card #693). `GET /v1/auth/context` is long-deployed (the MCP
510
+ * transport authenticates every request through it).
511
+ */
512
+ async getAuthContext(): Promise<{
513
+ userId: string;
514
+ source: "api_key" | "oauth" | "jwt";
515
+ workspaceId: string | null;
516
+ }> {
517
+ return this.request("GET", "/auth/context");
518
+ }
519
+
464
520
  async listWorkspaceAgents(
465
521
  workspaceId: string,
466
522
  ): Promise<{ agents: WorkspaceAgent[] }> {
@@ -678,6 +734,21 @@ export class HarmonyApiClient {
678
734
  return this.request("GET", `/projects/${projectId}/cards/${shortId}`);
679
735
  }
680
736
 
737
+ // #709: resolve a `#shortId` across every project the caller can reach when no
738
+ // explicit project is in play (the remote/OAuth MCP seeds a workspace but no
739
+ // project). `preferredProjectId` biases to the session's active/sticky project
740
+ // so a deliberately-set context wins outright instead of reading as ambiguous.
741
+ // Always resolves (HTTP 200) with a discriminated body — the caller decides.
742
+ async resolveCardByShortId(
743
+ shortId: number,
744
+ preferredProjectId?: string | null,
745
+ ): Promise<ResolveCardApiResult> {
746
+ const qs = preferredProjectId
747
+ ? `?preferred_project_id=${encodeURIComponent(preferredProjectId)}`
748
+ : "";
749
+ return this.request("GET", `/cards/resolve/${shortId}${qs}`);
750
+ }
751
+
681
752
  async bulkGetCards(
682
753
  projectId: string,
683
754
  shortIds: number[],
@@ -1067,6 +1138,12 @@ export class HarmonyApiClient {
1067
1138
  recoveryBranch?: string;
1068
1139
  attemptNumber?: number;
1069
1140
  maxAttempts?: number;
1141
+ /**
1142
+ * Update-only: never create a session if none is live, answer
1143
+ * `{session: null}` instead. Set by the auto-session heartbeat, which must
1144
+ * report on a session but must never bring one back from the dead (#696).
1145
+ */
1146
+ noCreate?: boolean;
1070
1147
  },
1071
1148
  ): Promise<{ session: unknown; created: boolean }> {
1072
1149
  return this.request("POST", `/cards/${cardId}/agent-context`, data);
@@ -1075,7 +1152,13 @@ export class HarmonyApiClient {
1075
1152
  async endAgentSession(
1076
1153
  cardId: string,
1077
1154
  data?: {
1078
- status?: "completed" | "paused" | "failed";
1155
+ /**
1156
+ * `cancelled` marks a HUMAN stop specifically — it is what arms the
1157
+ * server's human-stop cooldown (card #663). Never use it for a graceful
1158
+ * shutdown or an internal abort, which must stay `paused`/`failed` so the
1159
+ * card can be re-picked immediately (card #696).
1160
+ */
1161
+ status?: "completed" | "paused" | "failed" | "cancelled";
1079
1162
  progressPercent?: number;
1080
1163
  costCents?: number;
1081
1164
  inputTokens?: number;
@@ -116,6 +116,27 @@ export const AUTO_START_TRIGGERS = new Set([
116
116
  export const INACTIVITY_TIMEOUT_MS = 10 * 60 * 1000; // 10 minutes
117
117
  const CHECK_INTERVAL_MS = 60 * 1000; // 60 seconds
118
118
 
119
+ /**
120
+ * How long after the agent's last harmony tool call we keep heartbeating a
121
+ * session (card #696).
122
+ *
123
+ * WHY: the heartbeat asserts "this agent is still working". An *explicit*
124
+ * session (`/hmy`) is exempt from `checkInactivity`, so an unbounded heartbeat
125
+ * asserted that forever — bumping `updated_at` every 60s and starving the
126
+ * 30-minute `cleanup_stale_agent_sessions` cron, which is the only backstop for
127
+ * a session whose agent finished (card moved to Review) but never called
128
+ * `harmony_end_agent_session`. The session then showed "working" forever.
129
+ *
130
+ * Bounding the heartbeat by real activity restores that backstop: once the
131
+ * agent stops calling harmony tools we stop beating, `updated_at` goes stale,
132
+ * and the cron closes the row as `failed`/`stale`. Sits deliberately BELOW the
133
+ * cron's 30-minute threshold so a stalled session can actually reach it, and
134
+ * well above the milestone-gap the heartbeat exists to bridge (#608). Activity
135
+ * revives it — `trackActivity` refreshes `lastActivityAt` on every card-scoped
136
+ * tool call.
137
+ */
138
+ export const HEARTBEAT_ACTIVITY_WINDOW_MS = 25 * 60 * 1000; // 25 minutes
139
+
119
140
  /**
120
141
  * Scope used when no `scopeId` is supplied. stdio is single-user, so it shares
121
142
  * one scope; the unit tests also exercise this default scope exclusively.
@@ -429,22 +450,54 @@ export function noteSessionStatus(
429
450
  * `updated_at`. Covers both auto-started and explicit sessions (both live in
430
451
  * `scope.sessions`).
431
452
  *
453
+ * Two bounds keep a heartbeat from outliving the work it reports on (card #696):
454
+ *
455
+ * 1. `noCreate` — a heartbeat may only ever UPDATE a live session, never create
456
+ * one. The endpoint shares a single upsert with `start_agent_session`, so
457
+ * without this a heartbeat for a session ended out-of-band (the UI "Stop",
458
+ * which cannot reach this process's map) re-created the row and re-added the
459
+ * `agent` label. The #663 human-stop cooldown only deferred that by 10
460
+ * minutes, so a stopped card reliably respawned exactly one cooldown later.
461
+ * `session: null` back means the session is gone → untrack, stop beating.
462
+ * 2. `HEARTBEAT_ACTIVITY_WINDOW_MS` — only beat while the agent is still calling
463
+ * harmony tools, so an abandoned session goes stale and the cron can close it.
464
+ *
432
465
  * No double-heartbeat for the daemon: `packages/harmony-agent` has its own
433
466
  * progress-tracker heartbeat and never routes through mcp-server auto-sessions.
434
467
  */
435
468
  export function heartbeatActiveSessions(): void {
469
+ const now = Date.now();
436
470
  for (const scope of scopes.values()) {
437
471
  const client = scope.clientGetter?.();
438
472
  if (!client) continue;
439
- for (const session of scope.sessions.values()) {
473
+ // Snapshot: the untrack-on-ended path below mutates the map.
474
+ for (const [cardId, session] of [...scope.sessions.entries()]) {
440
475
  // Default-undefined status is treated as `working` (sessions created
441
476
  // before a status was reported).
442
477
  if ((session.status ?? "working") !== "working") continue;
478
+ // Silent agent → stop asserting it's working; hand the row to the cron.
479
+ if (now - session.lastActivityAt > HEARTBEAT_ACTIVITY_WINDOW_MS) continue;
443
480
  client
444
481
  .updateAgentProgress(session.cardId, {
445
482
  agentIdentifier: session.agentIdentifier,
446
483
  agentName: session.agentName,
447
484
  status: "working",
485
+ noCreate: true,
486
+ })
487
+ .then((res) => {
488
+ // Ended out-of-band (UI "Stop", another client, the stale cron). Drop
489
+ // it so we never beat — nor respawn — this session again. Only an
490
+ // explicit null counts: an unknown/absent field must not untrack a
491
+ // live session.
492
+ //
493
+ // Identity-checked: this reply describes the session we beat, which
494
+ // may no longer be the one tracked for this card. An end + restart
495
+ // landing inside the request's round-trip replaces the map entry, and
496
+ // deleting by cardId alone would drop that NEW live session — leaving
497
+ // it unbeaten until the stale cron closed it early.
498
+ if (res?.session === null && scope.sessions.get(cardId) === session) {
499
+ scope.sessions.delete(cardId);
500
+ }
448
501
  })
449
502
  .catch(() => {
450
503
  // Best-effort: a transient failure just defers the bump to the next
package/src/server.ts CHANGED
@@ -2872,9 +2872,113 @@ async function handleToolCall(
2872
2872
  }
2873
2873
  if (hasShortId) {
2874
2874
  const shortId = z.number().int().positive().parse(args.shortId);
2875
- const projectId = (args.projectId as string) || getProjectId();
2876
- const result = await client.getCardByShortId(projectId, shortId);
2877
- return { success: true, ...result };
2875
+ const explicitProjectId = args.projectId as string | undefined;
2876
+
2877
+ // Explicit projectId is the deterministic override: fetch from exactly
2878
+ // that project and error if it's not there — never look elsewhere.
2879
+ if (explicitProjectId) {
2880
+ const result = await client.getCardByShortId(
2881
+ explicitProjectId,
2882
+ shortId,
2883
+ );
2884
+ return { success: true, ...result };
2885
+ }
2886
+
2887
+ // No explicit project: resolve the `#shortId` (#709). The active/sticky
2888
+ // project is a HARD SCOPE (#428) — when one is set, the resolver only
2889
+ // matches within it (a miss comes back as `not_in_preferred`, handled
2890
+ // below) and this read never repoints it. Cross-project auto-resolution
2891
+ // + sticky happens ONLY when no active project is set — the remote/OAuth
2892
+ // MCP case this feature exists for, where the session seeds a workspace
2893
+ // but no project.
2894
+ const activeProjectId = deps.getActiveProjectId();
2895
+ const resolved = await client.resolveCardByShortId(
2896
+ shortId,
2897
+ activeProjectId,
2898
+ );
2899
+
2900
+ if (resolved.kind === "found") {
2901
+ const cardTitle =
2902
+ (resolved.card as { title?: string } | null)?.title ??
2903
+ `#${shortId}`;
2904
+ const where = resolved.project.workspaceName
2905
+ ? `project "${resolved.project.name ?? resolved.project.id}" (workspace "${resolved.project.workspaceName}")`
2906
+ : `project "${resolved.project.name ?? resolved.project.id}"`;
2907
+ // Sticky ONLY when this ESTABLISHES a context (none was set). A
2908
+ // deliberately-set active project is a hard scope: `found` there means
2909
+ // the card was already in it, so there is nothing to change — and we
2910
+ // never let a read silently repoint a context the user chose (which,
2911
+ // on the local stdio MCP, persists to ~/.harmony-mcp/config.json
2912
+ // across sessions). Confirm the target back either way so a
2913
+ // wrong-context resolve is visible immediately.
2914
+ const established = activeProjectId == null;
2915
+ if (established) {
2916
+ deps.setActiveProject(resolved.project.id);
2917
+ }
2918
+ return {
2919
+ success: true,
2920
+ card: resolved.card,
2921
+ resolvedProject: resolved.project,
2922
+ activeProjectId: resolved.project.id,
2923
+ note: established
2924
+ ? `Resolved #${shortId} → "${cardTitle}" in ${where}. No active project was set — set to this for follow-up references.`
2925
+ : `Resolved #${shortId} → "${cardTitle}" in ${where} (your active project).`,
2926
+ };
2927
+ }
2928
+
2929
+ if (resolved.kind === "not_in_preferred") {
2930
+ // Hard scope (#428): the active project is a constraint the user set,
2931
+ // so a `#shortId` that isn't in it is an error — NOT a silent hop to
2932
+ // whatever other project happens to carry that number, and NOT a
2933
+ // change to the active project. Name where it *does* live so the
2934
+ // caller can switch context or fetch it explicitly.
2935
+ const list = resolved.candidates
2936
+ .map(
2937
+ (c) =>
2938
+ ` • "${c.title}" — project "${c.projectName ?? c.projectId}"${
2939
+ c.workspaceName ? ` / workspace "${c.workspaceName}"` : ""
2940
+ } (projectId: ${c.projectId})`,
2941
+ )
2942
+ .join("\n");
2943
+ throw new Error(
2944
+ `#${shortId} is not in your active project (projectId: ${resolved.preferredProjectId}). ` +
2945
+ `It exists in ${resolved.candidates.length} other project(s) you can access:\n${list}\n\n` +
2946
+ `Switch with harmony_set_project_context, or pass an explicit projectId to fetch it directly.`,
2947
+ );
2948
+ }
2949
+
2950
+ if (resolved.kind === "ambiguous") {
2951
+ // Never guess: hand the candidates back so the caller can disambiguate.
2952
+ const list = resolved.candidates
2953
+ .map(
2954
+ (c) =>
2955
+ ` • "${c.title}" — project "${c.projectName ?? c.projectId}"${
2956
+ c.workspaceName ? ` / workspace "${c.workspaceName}"` : ""
2957
+ } (projectId: ${c.projectId})`,
2958
+ )
2959
+ .join("\n");
2960
+ return {
2961
+ success: true,
2962
+ needsDisambiguation: true,
2963
+ shortId,
2964
+ candidates: resolved.candidates,
2965
+ message:
2966
+ `#${shortId} exists in ${resolved.candidates.length} projects you can access:\n${list}\n\n` +
2967
+ `Ask which one is meant, then re-fetch with an explicit projectId ` +
2968
+ `(or call harmony_set_project_context first).`,
2969
+ };
2970
+ }
2971
+
2972
+ // not_found — name the searched scope instead of the bare "No project
2973
+ // specified", and point at the tools that list the caller's options.
2974
+ throw new Error(
2975
+ resolved.searchedProjectCount === 0
2976
+ ? `#${shortId} can't be resolved: no project is accessible to this connection. ` +
2977
+ `Check the workspace this connection is authorized for with harmony_list_workspaces.`
2978
+ : `Card #${shortId} was not found in any of the ${resolved.searchedProjectCount} ` +
2979
+ `project(s) across ${resolved.searchedWorkspaceCount} workspace(s) this connection can access. ` +
2980
+ `Use harmony_list_projects to see them, or pass an explicit projectId.`,
2981
+ );
2878
2982
  }
2879
2983
  const cardId = z.string().uuid().parse(args.cardId);
2880
2984
  const result = await client.getCard(cardId);
@@ -3489,6 +3593,42 @@ async function handleToolCall(
3489
3593
 
3490
3594
  case "harmony_set_workspace_context": {
3491
3595
  const workspaceId = z.string().uuid().parse(args.workspaceId);
3596
+
3597
+ // Fail fast on a workspace this connection can't actually reach (#695).
3598
+ // An OAuth token is bound at consent time to the single workspace the user
3599
+ // picked ("It will be able to read and modify cards, plans, and memory in
3600
+ // the workspace you select below"), and harmony-api enforces that on every
3601
+ // call. Setting the context is a pure local assignment, so without this
3602
+ // check it always "succeeded" and the caller only discovered the binding
3603
+ // when some later, unrelated tool call 403'd mid-task — the exact confusion
3604
+ // this card reports. `listWorkspaces` is server-side scoped to the bound
3605
+ // workspace, so presence in it is the authoritative reachability answer for
3606
+ // OAuth and legacy-key connections alike.
3607
+ const { workspaces } = await client.listWorkspaces();
3608
+ const available = (workspaces ?? []) as Array<{
3609
+ id?: string;
3610
+ name?: string;
3611
+ }>;
3612
+ const match = available.find((w) => w?.id === workspaceId);
3613
+
3614
+ if (!match) {
3615
+ const options = available
3616
+ .filter((w) => w?.id)
3617
+ .map((w) => ` - ${w.name ?? "(unnamed)"} (${w.id})`)
3618
+ .join("\n");
3619
+ throw new Error(
3620
+ `Workspace ${workspaceId} is not available to this connection.\n\n` +
3621
+ `This MCP connection is authorized for a specific workspace, chosen ` +
3622
+ `during OAuth consent — it cannot read or write any other workspace, ` +
3623
+ `even ones you're a member of.\n\n` +
3624
+ (options
3625
+ ? `Available here:\n${options}\n\n`
3626
+ : `No workspaces are available to this connection.\n\n`) +
3627
+ `To work in a different workspace, run /mcp to reconnect and select ` +
3628
+ `it on the consent screen.`,
3629
+ );
3630
+ }
3631
+
3492
3632
  deps.setActiveWorkspace(workspaceId);
3493
3633
  return { success: true, activeWorkspaceId: workspaceId };
3494
3634
  }