@gethmy/mcp 2.20.0 → 2.21.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
@@ -1386,6 +1386,14 @@ import {
1386
1386
  ReadResourceRequestSchema
1387
1387
  } from "@modelcontextprotocol/sdk/types.js";
1388
1388
  import { z } from "zod";
1389
+ // ../harmony-shared/dist/agentStaleness.js
1390
+ var AGENT_HEARTBEAT_LIVENESS_MS = 5 * 60 * 1000;
1391
+ var AGENT_MILESTONE_LIVENESS_MS = 30 * 60 * 1000;
1392
+ var AGENT_SWEEP_DAEMON_MS = 30 * 60 * 1000;
1393
+ var AGENT_SWEEP_INTERACTIVE_MS = 2 * 60 * 60 * 1000;
1394
+ var AGENT_SWEEP_PAUSED_MS = 4 * 60 * 60 * 1000;
1395
+ var SWEPT_SESSION_WRITE_GRACE_MS = 60 * 60 * 1000;
1396
+ var ACTIVE_STATUSES = new Set(["working", "blocked", "waiting"]);
1389
1397
  // ../harmony-shared/dist/cardLinks.js
1390
1398
  var LINK_TYPE_INVERSES = {
1391
1399
  relates_to: "relates_to",
@@ -2556,11 +2564,14 @@ async function trackActivity(cardId, options) {
2556
2564
  await autoEndSession(scope, client3, otherCardId, "completed");
2557
2565
  }
2558
2566
  try {
2559
- await client3.startAgentSession(cardId, {
2567
+ const started = await client3.startAgentSession(cardId, {
2560
2568
  agentIdentifier,
2561
2569
  agentName,
2562
- status: "working"
2570
+ status: "working",
2571
+ implicitCreate: true
2563
2572
  });
2573
+ if (started?.session === null)
2574
+ return;
2564
2575
  } catch {}
2565
2576
  scope.sessions.set(cardId, {
2566
2577
  cardId,
@@ -3798,7 +3809,8 @@ async function flushMemoryActions(client3, cardId) {
3798
3809
  await client3.updateAgentProgress(cardId, {
3799
3810
  agentIdentifier: session.agentIdentifier,
3800
3811
  agentName: session.agentName,
3801
- recentActions: session.allActions
3812
+ recentActions: session.allActions,
3813
+ implicitCreate: true
3802
3814
  });
3803
3815
  session.dirty = false;
3804
3816
  } catch (err) {
@@ -4190,7 +4202,7 @@ var TOOLS = {
4190
4202
  }
4191
4203
  },
4192
4204
  harmony_upload: {
4193
- description: 'Upload a file in one call. `target: "card_attachment"` attaches a file to a card ' + "(max 5MB; PNG/JPEG/GIF/WebP/HEIC/HEIF/PDF/DOC(X)/XLS(X)/TXT/CSV) — requires cardId. " + '`target: "artifact"` hosts a self-contained HTML doc (text/html, max 2MB) linked to ' + "exactly one of cardId/planId/workspaceId, rendered in-app in a sandboxed iframe. Provide " + "the bytes as `filePath` (local, direct-to-storage) or `base64Data` (small-file fallback). " + "Returns the attachment/artifact + a signed URL (artifact: use harmony_share_artifact for a " + "public link). Large files on the hosted MCP server: use harmony_request_upload_url + " + "harmony_finalize_upload instead.",
4205
+ description: 'Upload a file in one call. `target: "card_attachment"` attaches a file to a card ' + "(max 5MB; PNG/JPEG/GIF/WebP/HEIC/HEIF/PDF/DOC(X)/XLS(X)/TXT/CSV) — requires cardId. " + '`target: "artifact"` hosts a self-contained HTML doc (text/html, max 2MB) linked to ' + "exactly one of cardId/planId/workspaceId, rendered in-app in a sandboxed iframe. Provide " + "the bytes as `filePath` (local, direct-to-storage) or `base64Data` (small-file fallback). " + "Returns the attachment/artifact. An artifact comes back with TWO URLs, and the difference " + "matters: `app_url` is the durable in-app permalink — this is the one to hand a person, it " + "never expires and requires them to log in as a workspace member — while `signed_url` only " + "renders the document in-app and dies within the hour, so never pass it on. To share with " + "someone OUTSIDE the workspace, mint a public link with harmony_share_artifact. Large files " + "on the hosted MCP server: use harmony_request_upload_url + harmony_finalize_upload instead.",
4194
4206
  inputSchema: {
4195
4207
  type: "object",
4196
4208
  properties: {
@@ -4276,7 +4288,7 @@ var TOOLS = {
4276
4288
  }
4277
4289
  },
4278
4290
  harmony_finalize_upload: {
4279
- description: "Step 2 of the upload handshake. After PUTting the bytes to the signed uploadUrl, call this " + "with the storagePath to validate and register. The server re-downloads the object and " + "enforces size + content-type (magic-byte sniff, never the declared type) + an optional " + 'sha256 integrity check, deleting and failing on any mismatch. `target: "card_attachment"` ' + '(cardId, storagePath, fileName) or `target: "artifact"` (storagePath + the same one of ' + "cardId/planId/workspaceId used for the upload URL). Returns the attachment/artifact + a signed URL.",
4291
+ description: "Step 2 of the upload handshake. After PUTting the bytes to the signed uploadUrl, call this " + "with the storagePath to validate and register. The server re-downloads the object and " + "enforces size + content-type (magic-byte sniff, never the declared type) + an optional " + 'sha256 integrity check, deleting and failing on any mismatch. `target: "card_attachment"` ' + '(cardId, storagePath, fileName) or `target: "artifact"` (storagePath + the same one of ' + "cardId/planId/workspaceId used for the upload URL). Returns the attachment/artifact + a signed " + "URL; an artifact also carries `app_url`, the durable in-app permalink — hand that one to a " + "person, never the short-lived `signed_url`.",
4280
4292
  inputSchema: {
4281
4293
  type: "object",
4282
4294
  properties: {
@@ -4330,7 +4342,7 @@ var TOOLS = {
4330
4342
  }
4331
4343
  },
4332
4344
  harmony_share_artifact: {
4333
- description: "Create a public, unauthenticated share link for a hosted artifact. Anyone with the link can view the rendered HTML without a Harmony account. Returns the share token and the full public URL.",
4345
+ description: "Create a public, unauthenticated share link for a hosted artifact. Anyone with the link can view the rendered HTML without a Harmony account. Returns the share token and the full public URL. Only needed for a recipient OUTSIDE the workspace — for a teammate, hand over the `app_url` the upload already returned instead of exposing the document publicly.",
4334
4346
  inputSchema: {
4335
4347
  type: "object",
4336
4348
  properties: {
@@ -4651,7 +4663,7 @@ var TOOLS = {
4651
4663
  }
4652
4664
  },
4653
4665
  harmony_update_agent_progress: {
4654
- description: "Update progress on an active agent session. Use to report progress percentage, current task, blockers, or status changes.",
4666
+ description: "Update progress on an active agent session. Use to report progress percentage, current task, blockers, or status changes. Check the reply: `session: null` with `stopped: true` means a human stopped this card's run, so reporting progress will no longer open a session here (card #770) — stop work if you were mid-run, and read `recovery` for the one legitimate way back. Otherwise a session is always returned: this call opens one if none is live.",
4655
4667
  inputSchema: {
4656
4668
  type: "object",
4657
4669
  properties: {
@@ -4736,7 +4748,7 @@ var TOOLS = {
4736
4748
  }
4737
4749
  },
4738
4750
  harmony_get_pending_messages: {
4739
- description: "Drain queued steering messages a teammate sent to your live agent session (card #473). Call at your progress checkpoints with the session id from harmony_start_agent_session and the highest seq you've already consumed; returns user messages with seq > sinceSeq, oldest first. Fold them into your next step and advance sinceSeq to the largest returned seq so each is handled exactly once.",
4751
+ description: "Drain queued steering messages a teammate sent to your live agent session, and check whether that session is still live (cards #473, #770). Call at your progress checkpoints with the session id from harmony_start_agent_session and the highest seq you've already consumed; returns user messages with seq > sinceSeq, oldest first. Fold them into your next step and advance sinceSeq to the largest returned seq so each is handled exactly once. Two liveness flags come back and mean different things: `stopped` (a human pressed Stop) is TERMINAL — stop work at once, make no further edits, commits, pushes, or progress writes, don't move the card, and report what you had done and where any uncommitted work lives. `sessionStale` only means your session id went stale (usually the 30-minute inactivity cron) — nobody stopped you, so do NOT abandon the work: open a fresh session with harmony_start_agent_session and carry on with its new id. (Distinct from harmony_move_card's `sessionEnded`, which means the move deliberately closed your session.)",
4740
4752
  inputSchema: {
4741
4753
  type: "object",
4742
4754
  properties: {
@@ -5583,7 +5595,7 @@ function registerHandlers(server, deps) {
5583
5595
  trackActivity(cardIdArg, {
5584
5596
  autoStart: isAutoStartTrigger,
5585
5597
  client: deps.getClient(),
5586
- clientInfo: cv ? { name: cv.name, version: cv.version } : undefined,
5598
+ clientInfo: cv ? { name: cv.name, version: cv.version } : deps.getClientInfo?.() ?? undefined,
5587
5599
  scopeId: deps.getScopeId?.()
5588
5600
  }).catch(() => {});
5589
5601
  }
@@ -5598,7 +5610,7 @@ function registerHandlers(server, deps) {
5598
5610
  trackActivity(resolvedCardId, {
5599
5611
  autoStart: true,
5600
5612
  client: deps.getClient(),
5601
- clientInfo: cv ? { name: cv.name, version: cv.version } : undefined,
5613
+ clientInfo: cv ? { name: cv.name, version: cv.version } : deps.getClientInfo?.() ?? undefined,
5602
5614
  scopeId: deps.getScopeId?.()
5603
5615
  }).catch(() => {});
5604
5616
  }
@@ -6454,8 +6466,12 @@ ${options}
6454
6466
  blockers: args.blockers,
6455
6467
  estimatedMinutesRemaining: optionalNonNegativeNumberArg(args.estimatedMinutesRemaining, "estimatedMinutesRemaining"),
6456
6468
  ...mergedRecentActions && { recentActions: mergedRecentActions },
6457
- ...runActivity.length > 0 && { runActivity }
6469
+ ...runActivity.length > 0 && { runActivity },
6470
+ implicitCreate: true
6458
6471
  });
6472
+ if (result.session === null) {
6473
+ untrack(cardId, deps.getScopeId?.());
6474
+ }
6459
6475
  return { success: true, midSessionLearnings: 0, ...result };
6460
6476
  }
6461
6477
  case "harmony_end_agent_session": {
@@ -6465,7 +6481,9 @@ ${options}
6465
6481
  const endProgressPercent = optionalPercentArg(args.progressPercent, "progressPercent");
6466
6482
  await flushMemoryActions(client3, cardId);
6467
6483
  cleanupMemorySession(cardId);
6468
- let result = { session: null };
6484
+ let result = {
6485
+ session: null
6486
+ };
6469
6487
  let sessionEndError = null;
6470
6488
  try {
6471
6489
  result = await client3.endAgentSession(cardId, {
@@ -6515,6 +6533,29 @@ ${options}
6515
6533
  const sessionId = z.string().min(1).parse(args.sessionId);
6516
6534
  const sinceSeq = args.sinceSeq !== undefined ? z.number().int().min(0).parse(args.sinceSeq) : 0;
6517
6535
  const result = await client3.getPendingMessages(cardId, sessionId, sinceSeq);
6536
+ const stoppedByHuman = result.session?.stoppedByHuman === true;
6537
+ const ended = result.session?.ended === true;
6538
+ if (ended || stoppedByHuman) {
6539
+ untrack(cardId, deps.getScopeId?.());
6540
+ }
6541
+ if (stoppedByHuman) {
6542
+ return {
6543
+ success: true,
6544
+ ...result,
6545
+ stopped: true,
6546
+ stopReason: "human_stopped",
6547
+ instruction: "A human stopped this run — stop work now. Make no further edits, commits, pushes, or progress writes, and do not move the card. Commit any uncommitted work in place so it isn't lost, then report to the user what you completed, what is unfinished, and the branch holding it."
6548
+ };
6549
+ }
6550
+ if (ended) {
6551
+ return {
6552
+ success: true,
6553
+ ...result,
6554
+ sessionStale: true,
6555
+ staleReason: result.session?.status,
6556
+ instruction: 'This session id is no longer live (it was closed as "' + (result.session?.status ?? "missing") + '") — but nobody stopped you. Do NOT abandon the work. Open a fresh session with harmony_start_agent_session, use its new id for later polls, and carry on.'
6557
+ };
6558
+ }
6518
6559
  return { success: true, ...result };
6519
6560
  }
6520
6561
  case "harmony_generate_prompt": {
@@ -8546,14 +8587,29 @@ ${summary}`);
8546
8587
 
8547
8588
  This project uses Harmony for task management. When working on tasks:
8548
8589
 
8590
+ ## Agent identity — always identify as yourself
8591
+
8592
+ Every \`harmony_start_agent_session\` call passes \`agentIdentifier\` + \`agentName\`. **Use your own
8593
+ identity, never a hardcoded one from this file.** AGENTS.md is a cross-runtime convention file, so
8594
+ more than one kind of agent will read it; the board shows agents as teammates, and a session
8595
+ attributed to the wrong runtime misattributes the work in front of the whole team.
8596
+
8597
+ - \`agentIdentifier\` — a stable kebab-case id for the runtime you actually are
8598
+ - \`agentName\` — its human-readable name
8599
+
8600
+ Known values: \`claude-code\` / "Claude Code", \`codex\` / "OpenAI Codex", \`cursor\` / "Cursor",
8601
+ \`claude-desktop\` / "Claude Desktop". If you are a runtime not listed here, use your own name rather
8602
+ than borrowing the closest entry.
8603
+
8549
8604
  ## Starting Work on a Card
8550
8605
 
8551
8606
  When given a card reference (e.g., #42 or a card name), follow this workflow:
8552
8607
 
8553
- 1. Use \`harmony_get_card_by_short_id\` or \`harmony_search_cards\` to find the card
8608
+ 1. Use \`harmony_get_card\` or \`harmony_search_cards\` to find the card
8554
8609
  2. Move the card to "In Progress" using \`harmony_move_card\`
8555
8610
  3. Add the "agent" label using \`harmony_add_label_to_card\`
8556
- 4. Start a session with \`harmony_start_agent_session\` (agentIdentifier: "codex", agentName: "OpenAI Codex")
8611
+ 4. Start a session with \`harmony_start_agent_session\`, passing **your own** \`agentIdentifier\` +
8612
+ \`agentName\` (see "Agent identity" above)
8557
8613
  5. Show the card details to the user
8558
8614
  6. Use \`harmony_generate_prompt\` to get guidance, then implement the solution
8559
8615
  7. Update progress periodically with \`harmony_update_agent_progress\`
@@ -8564,7 +8620,8 @@ When given a card reference (e.g., #42 or a card name), follow this workflow:
8564
8620
  Before implementing a plan or feature, check if it maps to an existing Harmony card:
8565
8621
 
8566
8622
  1. Use \`harmony_search_cards\` with keywords from the task description
8567
- 2. If a match is found, call \`harmony_start_agent_session\` (agentIdentifier: "claude-code", agentName: "Claude Code", moveToColumn: "In Progress", addLabels: ["agent"])
8623
+ 2. If a match is found, call \`harmony_start_agent_session\` with **your own** \`agentIdentifier\` +
8624
+ \`agentName\` (see "Agent identity" above), plus \`moveToColumn: "In Progress"\`, \`addLabels: ["agent"]\`
8568
8625
  3. Update progress with \`harmony_update_agent_progress\` at milestones
8569
8626
  4. When done, call \`harmony_end_agent_session\` with status: "completed", moveToColumn: "Review"
8570
8627
 
package/dist/index.js CHANGED
@@ -1381,6 +1381,14 @@ import {
1381
1381
  ReadResourceRequestSchema
1382
1382
  } from "@modelcontextprotocol/sdk/types.js";
1383
1383
  import { z } from "zod";
1384
+ // ../harmony-shared/dist/agentStaleness.js
1385
+ var AGENT_HEARTBEAT_LIVENESS_MS = 5 * 60 * 1000;
1386
+ var AGENT_MILESTONE_LIVENESS_MS = 30 * 60 * 1000;
1387
+ var AGENT_SWEEP_DAEMON_MS = 30 * 60 * 1000;
1388
+ var AGENT_SWEEP_INTERACTIVE_MS = 2 * 60 * 60 * 1000;
1389
+ var AGENT_SWEEP_PAUSED_MS = 4 * 60 * 60 * 1000;
1390
+ var SWEPT_SESSION_WRITE_GRACE_MS = 60 * 60 * 1000;
1391
+ var ACTIVE_STATUSES = new Set(["working", "blocked", "waiting"]);
1384
1392
  // ../harmony-shared/dist/cardLinks.js
1385
1393
  var LINK_TYPE_INVERSES = {
1386
1394
  relates_to: "relates_to",
@@ -2551,11 +2559,14 @@ async function trackActivity(cardId, options) {
2551
2559
  await autoEndSession(scope, client3, otherCardId, "completed");
2552
2560
  }
2553
2561
  try {
2554
- await client3.startAgentSession(cardId, {
2562
+ const started = await client3.startAgentSession(cardId, {
2555
2563
  agentIdentifier,
2556
2564
  agentName,
2557
- status: "working"
2565
+ status: "working",
2566
+ implicitCreate: true
2558
2567
  });
2568
+ if (started?.session === null)
2569
+ return;
2559
2570
  } catch {}
2560
2571
  scope.sessions.set(cardId, {
2561
2572
  cardId,
@@ -3793,7 +3804,8 @@ async function flushMemoryActions(client3, cardId) {
3793
3804
  await client3.updateAgentProgress(cardId, {
3794
3805
  agentIdentifier: session.agentIdentifier,
3795
3806
  agentName: session.agentName,
3796
- recentActions: session.allActions
3807
+ recentActions: session.allActions,
3808
+ implicitCreate: true
3797
3809
  });
3798
3810
  session.dirty = false;
3799
3811
  } catch (err) {
@@ -4185,7 +4197,7 @@ var TOOLS = {
4185
4197
  }
4186
4198
  },
4187
4199
  harmony_upload: {
4188
- description: 'Upload a file in one call. `target: "card_attachment"` attaches a file to a card ' + "(max 5MB; PNG/JPEG/GIF/WebP/HEIC/HEIF/PDF/DOC(X)/XLS(X)/TXT/CSV) — requires cardId. " + '`target: "artifact"` hosts a self-contained HTML doc (text/html, max 2MB) linked to ' + "exactly one of cardId/planId/workspaceId, rendered in-app in a sandboxed iframe. Provide " + "the bytes as `filePath` (local, direct-to-storage) or `base64Data` (small-file fallback). " + "Returns the attachment/artifact + a signed URL (artifact: use harmony_share_artifact for a " + "public link). Large files on the hosted MCP server: use harmony_request_upload_url + " + "harmony_finalize_upload instead.",
4200
+ description: 'Upload a file in one call. `target: "card_attachment"` attaches a file to a card ' + "(max 5MB; PNG/JPEG/GIF/WebP/HEIC/HEIF/PDF/DOC(X)/XLS(X)/TXT/CSV) — requires cardId. " + '`target: "artifact"` hosts a self-contained HTML doc (text/html, max 2MB) linked to ' + "exactly one of cardId/planId/workspaceId, rendered in-app in a sandboxed iframe. Provide " + "the bytes as `filePath` (local, direct-to-storage) or `base64Data` (small-file fallback). " + "Returns the attachment/artifact. An artifact comes back with TWO URLs, and the difference " + "matters: `app_url` is the durable in-app permalink — this is the one to hand a person, it " + "never expires and requires them to log in as a workspace member — while `signed_url` only " + "renders the document in-app and dies within the hour, so never pass it on. To share with " + "someone OUTSIDE the workspace, mint a public link with harmony_share_artifact. Large files " + "on the hosted MCP server: use harmony_request_upload_url + harmony_finalize_upload instead.",
4189
4201
  inputSchema: {
4190
4202
  type: "object",
4191
4203
  properties: {
@@ -4271,7 +4283,7 @@ var TOOLS = {
4271
4283
  }
4272
4284
  },
4273
4285
  harmony_finalize_upload: {
4274
- description: "Step 2 of the upload handshake. After PUTting the bytes to the signed uploadUrl, call this " + "with the storagePath to validate and register. The server re-downloads the object and " + "enforces size + content-type (magic-byte sniff, never the declared type) + an optional " + 'sha256 integrity check, deleting and failing on any mismatch. `target: "card_attachment"` ' + '(cardId, storagePath, fileName) or `target: "artifact"` (storagePath + the same one of ' + "cardId/planId/workspaceId used for the upload URL). Returns the attachment/artifact + a signed URL.",
4286
+ description: "Step 2 of the upload handshake. After PUTting the bytes to the signed uploadUrl, call this " + "with the storagePath to validate and register. The server re-downloads the object and " + "enforces size + content-type (magic-byte sniff, never the declared type) + an optional " + 'sha256 integrity check, deleting and failing on any mismatch. `target: "card_attachment"` ' + '(cardId, storagePath, fileName) or `target: "artifact"` (storagePath + the same one of ' + "cardId/planId/workspaceId used for the upload URL). Returns the attachment/artifact + a signed " + "URL; an artifact also carries `app_url`, the durable in-app permalink — hand that one to a " + "person, never the short-lived `signed_url`.",
4275
4287
  inputSchema: {
4276
4288
  type: "object",
4277
4289
  properties: {
@@ -4325,7 +4337,7 @@ var TOOLS = {
4325
4337
  }
4326
4338
  },
4327
4339
  harmony_share_artifact: {
4328
- description: "Create a public, unauthenticated share link for a hosted artifact. Anyone with the link can view the rendered HTML without a Harmony account. Returns the share token and the full public URL.",
4340
+ description: "Create a public, unauthenticated share link for a hosted artifact. Anyone with the link can view the rendered HTML without a Harmony account. Returns the share token and the full public URL. Only needed for a recipient OUTSIDE the workspace — for a teammate, hand over the `app_url` the upload already returned instead of exposing the document publicly.",
4329
4341
  inputSchema: {
4330
4342
  type: "object",
4331
4343
  properties: {
@@ -4646,7 +4658,7 @@ var TOOLS = {
4646
4658
  }
4647
4659
  },
4648
4660
  harmony_update_agent_progress: {
4649
- description: "Update progress on an active agent session. Use to report progress percentage, current task, blockers, or status changes.",
4661
+ description: "Update progress on an active agent session. Use to report progress percentage, current task, blockers, or status changes. Check the reply: `session: null` with `stopped: true` means a human stopped this card's run, so reporting progress will no longer open a session here (card #770) — stop work if you were mid-run, and read `recovery` for the one legitimate way back. Otherwise a session is always returned: this call opens one if none is live.",
4650
4662
  inputSchema: {
4651
4663
  type: "object",
4652
4664
  properties: {
@@ -4731,7 +4743,7 @@ var TOOLS = {
4731
4743
  }
4732
4744
  },
4733
4745
  harmony_get_pending_messages: {
4734
- description: "Drain queued steering messages a teammate sent to your live agent session (card #473). Call at your progress checkpoints with the session id from harmony_start_agent_session and the highest seq you've already consumed; returns user messages with seq > sinceSeq, oldest first. Fold them into your next step and advance sinceSeq to the largest returned seq so each is handled exactly once.",
4746
+ description: "Drain queued steering messages a teammate sent to your live agent session, and check whether that session is still live (cards #473, #770). Call at your progress checkpoints with the session id from harmony_start_agent_session and the highest seq you've already consumed; returns user messages with seq > sinceSeq, oldest first. Fold them into your next step and advance sinceSeq to the largest returned seq so each is handled exactly once. Two liveness flags come back and mean different things: `stopped` (a human pressed Stop) is TERMINAL — stop work at once, make no further edits, commits, pushes, or progress writes, don't move the card, and report what you had done and where any uncommitted work lives. `sessionStale` only means your session id went stale (usually the 30-minute inactivity cron) — nobody stopped you, so do NOT abandon the work: open a fresh session with harmony_start_agent_session and carry on with its new id. (Distinct from harmony_move_card's `sessionEnded`, which means the move deliberately closed your session.)",
4735
4747
  inputSchema: {
4736
4748
  type: "object",
4737
4749
  properties: {
@@ -5578,7 +5590,7 @@ function registerHandlers(server, deps) {
5578
5590
  trackActivity(cardIdArg, {
5579
5591
  autoStart: isAutoStartTrigger,
5580
5592
  client: deps.getClient(),
5581
- clientInfo: cv ? { name: cv.name, version: cv.version } : undefined,
5593
+ clientInfo: cv ? { name: cv.name, version: cv.version } : deps.getClientInfo?.() ?? undefined,
5582
5594
  scopeId: deps.getScopeId?.()
5583
5595
  }).catch(() => {});
5584
5596
  }
@@ -5593,7 +5605,7 @@ function registerHandlers(server, deps) {
5593
5605
  trackActivity(resolvedCardId, {
5594
5606
  autoStart: true,
5595
5607
  client: deps.getClient(),
5596
- clientInfo: cv ? { name: cv.name, version: cv.version } : undefined,
5608
+ clientInfo: cv ? { name: cv.name, version: cv.version } : deps.getClientInfo?.() ?? undefined,
5597
5609
  scopeId: deps.getScopeId?.()
5598
5610
  }).catch(() => {});
5599
5611
  }
@@ -6449,8 +6461,12 @@ ${options}
6449
6461
  blockers: args.blockers,
6450
6462
  estimatedMinutesRemaining: optionalNonNegativeNumberArg(args.estimatedMinutesRemaining, "estimatedMinutesRemaining"),
6451
6463
  ...mergedRecentActions && { recentActions: mergedRecentActions },
6452
- ...runActivity.length > 0 && { runActivity }
6464
+ ...runActivity.length > 0 && { runActivity },
6465
+ implicitCreate: true
6453
6466
  });
6467
+ if (result.session === null) {
6468
+ untrack(cardId, deps.getScopeId?.());
6469
+ }
6454
6470
  return { success: true, midSessionLearnings: 0, ...result };
6455
6471
  }
6456
6472
  case "harmony_end_agent_session": {
@@ -6460,7 +6476,9 @@ ${options}
6460
6476
  const endProgressPercent = optionalPercentArg(args.progressPercent, "progressPercent");
6461
6477
  await flushMemoryActions(client3, cardId);
6462
6478
  cleanupMemorySession(cardId);
6463
- let result = { session: null };
6479
+ let result = {
6480
+ session: null
6481
+ };
6464
6482
  let sessionEndError = null;
6465
6483
  try {
6466
6484
  result = await client3.endAgentSession(cardId, {
@@ -6510,6 +6528,29 @@ ${options}
6510
6528
  const sessionId = z.string().min(1).parse(args.sessionId);
6511
6529
  const sinceSeq = args.sinceSeq !== undefined ? z.number().int().min(0).parse(args.sinceSeq) : 0;
6512
6530
  const result = await client3.getPendingMessages(cardId, sessionId, sinceSeq);
6531
+ const stoppedByHuman = result.session?.stoppedByHuman === true;
6532
+ const ended = result.session?.ended === true;
6533
+ if (ended || stoppedByHuman) {
6534
+ untrack(cardId, deps.getScopeId?.());
6535
+ }
6536
+ if (stoppedByHuman) {
6537
+ return {
6538
+ success: true,
6539
+ ...result,
6540
+ stopped: true,
6541
+ stopReason: "human_stopped",
6542
+ instruction: "A human stopped this run — stop work now. Make no further edits, commits, pushes, or progress writes, and do not move the card. Commit any uncommitted work in place so it isn't lost, then report to the user what you completed, what is unfinished, and the branch holding it."
6543
+ };
6544
+ }
6545
+ if (ended) {
6546
+ return {
6547
+ success: true,
6548
+ ...result,
6549
+ sessionStale: true,
6550
+ staleReason: result.session?.status,
6551
+ instruction: 'This session id is no longer live (it was closed as "' + (result.session?.status ?? "missing") + '") — but nobody stopped you. Do NOT abandon the work. Open a fresh session with harmony_start_agent_session, use its new id for later polls, and carry on.'
6552
+ };
6553
+ }
6513
6554
  return { success: true, ...result };
6514
6555
  }
6515
6556
  case "harmony_generate_prompt": {
@@ -833,6 +833,14 @@ var init_prompt_builder = __esm(() => {
833
833
  execute: `EXECUTE MODE: Implement this task completely. Write production-ready code following best practices. Include necessary tests and documentation.`
834
834
  };
835
835
  });
836
+ // ../harmony-shared/dist/agentStaleness.js
837
+ var AGENT_HEARTBEAT_LIVENESS_MS = 5 * 60 * 1000;
838
+ var AGENT_MILESTONE_LIVENESS_MS = 30 * 60 * 1000;
839
+ var AGENT_SWEEP_DAEMON_MS = 30 * 60 * 1000;
840
+ var AGENT_SWEEP_INTERACTIVE_MS = 2 * 60 * 60 * 1000;
841
+ var AGENT_SWEEP_PAUSED_MS = 4 * 60 * 60 * 1000;
842
+ var SWEPT_SESSION_WRITE_GRACE_MS = 60 * 60 * 1000;
843
+ var ACTIVE_STATUSES = new Set(["working", "blocked", "waiting"]);
836
844
  // ../harmony-shared/dist/cardLinks.js
837
845
  var LINK_TYPE_INVERSES = {
838
846
  relates_to: "relates_to",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gethmy/mcp",
3
- "version": "2.20.0",
3
+ "version": "2.21.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 src/__tests__/auto-session.test.ts src/__tests__/setup-confirm.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 src/__tests__/remote-routing.test.ts src/__tests__/setup-confirm.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
@@ -513,6 +513,12 @@ export class HarmonyApiClient {
513
513
  userId: string;
514
514
  source: "api_key" | "oauth" | "jwt";
515
515
  workspaceId: string | null;
516
+ /**
517
+ * The OAuth grant behind the credential — stable across refresh rotation,
518
+ * null for api_key/jwt auth (#774). Optional because this client may be
519
+ * talking to a harmony-api deployed before the field existed.
520
+ */
521
+ grantId?: string | null;
516
522
  }> {
517
523
  return this.request("GET", "/auth/context");
518
524
  }
@@ -1064,27 +1070,58 @@ export class HarmonyApiClient {
1064
1070
  agentId?: string | null;
1065
1071
  /** Advertise that this session polls for queued steering messages (card #473). */
1066
1072
  steerable?: boolean;
1073
+ /**
1074
+ * The start is a side effect of a trigger tool (auto-session), not a
1075
+ * deliberate `/hmy` start. Barred on a card whose last session a human
1076
+ * cancelled, so a surviving client can't resurrect it (#770).
1077
+ */
1078
+ implicitCreate?: boolean;
1067
1079
  status?: "working" | "blocked" | "paused" | "completed";
1068
1080
  progressPercent?: number;
1069
1081
  currentTask?: string;
1070
1082
  blockers?: string[];
1071
1083
  estimatedMinutesRemaining?: number;
1072
1084
  },
1073
- ): Promise<{ session: unknown; created: boolean }> {
1085
+ ): Promise<{
1086
+ session: unknown;
1087
+ created: boolean;
1088
+ stopped?: boolean;
1089
+ stopReason?: "human_stopped";
1090
+ }> {
1074
1091
  return this.request("POST", `/cards/${cardId}/agent-context`, data);
1075
1092
  }
1076
1093
 
1077
1094
  /**
1078
- * Drain queued `user_message` steering events for a session (card #473).
1095
+ * Drain queued `user_message` steering events for a session (card #473), and
1096
+ * learn whether that session is still live (card #770).
1097
+ *
1079
1098
  * Wraps the existing `GET /cards/{id}/agent-messages` endpoint that the daemon
1080
1099
  * already polls — exposed so MCP/human sessions (e.g. /hmy) can consume steering
1081
1100
  * at their checkpoints. Returns messages with `seq > sinceSeq`, oldest first.
1101
+ *
1102
+ * `session.stoppedByHuman` is the durable half of Stop: the ephemeral
1103
+ * `agent_command` broadcast has no consumer for `agent_id == null` sessions, so
1104
+ * this poll is where such a client finds out it was stopped.
1105
+ *
1106
+ * `ended` is the weaker fact — the row is no longer live, most often because the
1107
+ * 30-minute stale cron closed it, which is NOT a stop. Only `stoppedByHuman` is
1108
+ * terminal. `session` is absent on an older server, or when the liveness read
1109
+ * itself failed; absent means "assume live".
1082
1110
  */
1083
1111
  async getPendingMessages(
1084
1112
  cardId: string,
1085
1113
  sessionId: string,
1086
1114
  sinceSeq: number,
1087
- ): Promise<{ messages: unknown[] }> {
1115
+ ): Promise<{
1116
+ messages: unknown[];
1117
+ session?: {
1118
+ id: string;
1119
+ status: string;
1120
+ endedAt: string | null;
1121
+ ended: boolean;
1122
+ stoppedByHuman: boolean;
1123
+ };
1124
+ }> {
1088
1125
  const params = new URLSearchParams();
1089
1126
  params.set("sessionId", sessionId);
1090
1127
  params.set("sinceSeq", String(sinceSeq));
@@ -1144,8 +1181,24 @@ export class HarmonyApiClient {
1144
1181
  * report on a session but must never bring one back from the dead (#696).
1145
1182
  */
1146
1183
  noCreate?: boolean;
1184
+ /**
1185
+ * This write only meant to REPORT progress — creating a session is a side
1186
+ * effect, not the intent. Weaker than `noCreate` (an agent that never called
1187
+ * `start_agent_session` still gets one), but permanently barred on a card
1188
+ * whose last session a human cancelled, where the #663 cooldown only
1189
+ * deferred the respawn by 10 minutes (#770). Reply carries `stopped: true`.
1190
+ */
1191
+ implicitCreate?: boolean;
1147
1192
  },
1148
- ): Promise<{ session: unknown; created: boolean }> {
1193
+ ): Promise<{
1194
+ session: unknown;
1195
+ created: boolean;
1196
+ /** Set when the write was refused because a human stopped this card's run. */
1197
+ stopped?: boolean;
1198
+ stopReason?: "human_stopped";
1199
+ /** How to legitimately work this card again (an explicit start). */
1200
+ recovery?: string;
1201
+ }> {
1149
1202
  return this.request("POST", `/cards/${cardId}/agent-context`, data);
1150
1203
  }
1151
1204
 
@@ -1184,7 +1237,18 @@ export class HarmonyApiClient {
1184
1237
  attemptNumber?: number;
1185
1238
  maxAttempts?: number;
1186
1239
  },
1187
- ): Promise<{ session: unknown }> {
1240
+ ): Promise<{
1241
+ session: unknown;
1242
+ /**
1243
+ * Whether this call actually wrote a terminal state (#769). `false` — with
1244
+ * `session: null` — means the response succeeded but changed nothing: there
1245
+ * was no live session (`no_active_session`), or a concurrent caller ended
1246
+ * the same one first (`concurrently_ended`). Older servers omit both fields,
1247
+ * so treat `undefined` as "unknown" and fall back to `session !== null`.
1248
+ */
1249
+ ended?: boolean;
1250
+ reason?: "no_active_session" | "concurrently_ended";
1251
+ }> {
1188
1252
  return this.request("DELETE", `/cards/${cardId}/agent-context`, data);
1189
1253
  }
1190
1254
 
@@ -130,10 +130,20 @@ const CHECK_INTERVAL_MS = 60 * 1000; // 60 seconds
130
130
  * Bounding the heartbeat by real activity restores that backstop: once the
131
131
  * agent stops calling harmony tools we stop beating, `updated_at` goes stale,
132
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.
133
+ * cron's threshold so a stalled session can actually reach it, and well above
134
+ * the milestone-gap the heartbeat exists to bridge (#608). Activity revives it —
135
+ * `trackActivity` refreshes `lastActivityAt` on every card-scoped tool call.
136
+ *
137
+ * The cron's threshold for the interactive sessions this manages is **2 hours**,
138
+ * not the 30 minutes this comment originally cited (card #771 —
139
+ * `AGENT_SWEEP_INTERACTIVE_MS` in `packages/harmony-shared/src/agentStaleness.ts`;
140
+ * 30 minutes still applies to daemon runs, which heartbeat independently). The
141
+ * old pairing is what produced the bug: this window expiring at 25 minutes plus a
142
+ * flat 30-minute sweep gave a *working* agent only ~55 minutes of total silence,
143
+ * so one long verify/review phase got its live session closed as failed. Keeping
144
+ * this window short is still correct — it is what lets a finished-but-unclosed
145
+ * session be reaped at all — but the reaping now happens on a window sized for
146
+ * real work rather than one that a single gate run can outlast.
137
147
  */
138
148
  export const HEARTBEAT_ACTIVITY_WINDOW_MS = 25 * 60 * 1000; // 25 minutes
139
149
 
@@ -274,13 +284,22 @@ export async function trackActivity(
274
284
  await autoEndSession(scope, client, otherCardId, "completed");
275
285
  }
276
286
 
277
- // Start a new auto-session
287
+ // Start a new auto-session. `implicitCreate` because this start is a side
288
+ // effect of a trigger tool, not a deliberate `harmony_start_agent_session`: on
289
+ // a card whose last session a human cancelled, the server refuses it outright
290
+ // rather than letting a surviving client walk the run back to life one cooldown
291
+ // later (card #770). An explicit `/hmy` re-run is unaffected — it starts the
292
+ // session by name and only faces the 10-minute #663 window.
278
293
  try {
279
- await client.startAgentSession(cardId, {
294
+ const started = await client.startAgentSession(cardId, {
280
295
  agentIdentifier,
281
296
  agentName,
282
297
  status: "working",
298
+ implicitCreate: true,
283
299
  });
300
+ // Refused: don't track it locally either, or the sweep would heartbeat a
301
+ // session that does not exist and `checkInactivity` would later "end" it.
302
+ if (started?.session === null) return;
284
303
  } catch {
285
304
  // Session start failed (might already have one), still track locally
286
305
  }