@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/src/server.ts CHANGED
@@ -19,6 +19,7 @@ import {
19
19
  } from "./api-client.js";
20
20
  import {
21
21
  AUTO_START_TRIGGERS,
22
+ type ClientInfo,
22
23
  destroyAutoSession,
23
24
  initAutoSession,
24
25
  markExplicit,
@@ -317,6 +318,18 @@ export interface ToolDeps {
317
318
  * shared auto-session entry. Omitted on stdio (single user → default scope).
318
319
  */
319
320
  getScopeId?: () => string;
321
+ /**
322
+ * MCP client identity for the caller, when the transport remembered one.
323
+ *
324
+ * A stateless `tools/call` carries no handshake, so `getClientVersion()` is
325
+ * undefined there and this is the only thing auto-session can attribute the
326
+ * work to (#297). Resolved per request, which makes it per *OAuth grant* on
327
+ * the hosted path (#774) — the scope-level `clientInfoGetter` cannot be,
328
+ * because a user's second client re-runs `initAutoSession` for the same scope
329
+ * and overwrites it (last-initializer-wins). Omitted on stdio, which reads
330
+ * identity straight off the in-scope `Server`.
331
+ */
332
+ getClientInfo?: () => ClientInfo | null;
320
333
  }
321
334
 
322
335
  // --- Memory Session Tracking ---
@@ -532,8 +545,15 @@ async function flushMemoryActions(
532
545
  agentIdentifier: session.agentIdentifier,
533
546
  agentName: session.agentName,
534
547
  recentActions: session.allActions,
548
+ // A bookkeeping flush of memory actions is the last thing that should mint
549
+ // a session on a card a human stopped (#770).
550
+ implicitCreate: true,
535
551
  });
536
552
 
553
+ // Cleared even when the write was refused (`{session: null, stopped: true}`
554
+ // resolves rather than throwing). Deliberate: the bar never lapses, so these
555
+ // buffered display-only actions can never be written — retaining them would
556
+ // just re-attempt the same refusal on every later flush.
537
557
  session.dirty = false;
538
558
  } catch (err) {
539
559
  // Fire-and-forget: log but don't propagate
@@ -1005,9 +1025,12 @@ export const TOOLS = {
1005
1025
  '`target: "artifact"` hosts a self-contained HTML doc (text/html, max 2MB) linked to ' +
1006
1026
  "exactly one of cardId/planId/workspaceId, rendered in-app in a sandboxed iframe. Provide " +
1007
1027
  "the bytes as `filePath` (local, direct-to-storage) or `base64Data` (small-file fallback). " +
1008
- "Returns the attachment/artifact + a signed URL (artifact: use harmony_share_artifact for a " +
1009
- "public link). Large files on the hosted MCP server: use harmony_request_upload_url + " +
1010
- "harmony_finalize_upload instead.",
1028
+ "Returns the attachment/artifact. An artifact comes back with TWO URLs, and the difference " +
1029
+ "matters: `app_url` is the durable in-app permalink — this is the one to hand a person, it " +
1030
+ "never expires and requires them to log in as a workspace member — while `signed_url` only " +
1031
+ "renders the document in-app and dies within the hour, so never pass it on. To share with " +
1032
+ "someone OUTSIDE the workspace, mint a public link with harmony_share_artifact. Large files " +
1033
+ "on the hosted MCP server: use harmony_request_upload_url + harmony_finalize_upload instead.",
1011
1034
  inputSchema: {
1012
1035
  type: "object",
1013
1036
  properties: {
@@ -1119,7 +1142,9 @@ export const TOOLS = {
1119
1142
  "enforces size + content-type (magic-byte sniff, never the declared type) + an optional " +
1120
1143
  'sha256 integrity check, deleting and failing on any mismatch. `target: "card_attachment"` ' +
1121
1144
  '(cardId, storagePath, fileName) or `target: "artifact"` (storagePath + the same one of ' +
1122
- "cardId/planId/workspaceId used for the upload URL). Returns the attachment/artifact + a signed URL.",
1145
+ "cardId/planId/workspaceId used for the upload URL). Returns the attachment/artifact + a signed " +
1146
+ "URL; an artifact also carries `app_url`, the durable in-app permalink — hand that one to a " +
1147
+ "person, never the short-lived `signed_url`.",
1123
1148
  inputSchema: {
1124
1149
  type: "object",
1125
1150
  properties: {
@@ -1182,7 +1207,7 @@ export const TOOLS = {
1182
1207
  },
1183
1208
  harmony_share_artifact: {
1184
1209
  description:
1185
- "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.",
1210
+ "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.",
1186
1211
  inputSchema: {
1187
1212
  type: "object",
1188
1213
  properties: {
@@ -1531,7 +1556,7 @@ export const TOOLS = {
1531
1556
  },
1532
1557
  harmony_update_agent_progress: {
1533
1558
  description:
1534
- "Update progress on an active agent session. Use to report progress percentage, current task, blockers, or status changes.",
1559
+ "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.",
1535
1560
  inputSchema: {
1536
1561
  type: "object",
1537
1562
  properties: {
@@ -1622,7 +1647,7 @@ export const TOOLS = {
1622
1647
  },
1623
1648
  harmony_get_pending_messages: {
1624
1649
  description:
1625
- "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.",
1650
+ "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.)",
1626
1651
  inputSchema: {
1627
1652
  type: "object",
1628
1653
  properties: {
@@ -2609,11 +2634,15 @@ export function registerHandlers(server: Server, deps: ToolDeps): void {
2609
2634
  // the hosted/OAuth path, not just stdio (card #297).
2610
2635
  // Optional-chained: the pre-hook is best-effort and must never throw into
2611
2636
  // tool dispatch if a transport/wrapper doesn't expose getClientVersion.
2637
+ // `getClientInfo` covers the stateless case, where the handshake this
2638
+ // reads is on a different request entirely (#774).
2612
2639
  const cv = server.getClientVersion?.();
2613
2640
  trackActivity(cardIdArg, {
2614
2641
  autoStart: isAutoStartTrigger,
2615
2642
  client: deps.getClient(),
2616
- clientInfo: cv ? { name: cv.name, version: cv.version } : undefined,
2643
+ clientInfo: cv
2644
+ ? { name: cv.name, version: cv.version }
2645
+ : (deps.getClientInfo?.() ?? undefined),
2617
2646
  scopeId: deps.getScopeId?.(),
2618
2647
  }).catch(() => {}); // fire-and-forget
2619
2648
  }
@@ -2642,7 +2671,7 @@ export function registerHandlers(server: Server, deps: ToolDeps): void {
2642
2671
  client: deps.getClient(),
2643
2672
  clientInfo: cv
2644
2673
  ? { name: cv.name, version: cv.version }
2645
- : undefined,
2674
+ : (deps.getClientInfo?.() ?? undefined),
2646
2675
  scopeId: deps.getScopeId?.(),
2647
2676
  }).catch(() => {});
2648
2677
  }
@@ -3987,8 +4016,21 @@ async function handleToolCall(
3987
4016
  ),
3988
4017
  ...(mergedRecentActions && { recentActions: mergedRecentActions }),
3989
4018
  ...(runActivity.length > 0 && { runActivity }),
4019
+ // Reporting progress must never resurrect a run a human stopped (#770).
4020
+ // This is THE call the surviving client makes at every checkpoint, so it
4021
+ // is both the resurrection vector and the earliest place to hand that
4022
+ // client the news that it was stopped.
4023
+ implicitCreate: true,
3990
4024
  });
3991
4025
 
4026
+ // This handler never sends `noCreate`, so the create branch either creates
4027
+ // a row or refuses — meaning a null session here can only be the human-stop
4028
+ // refusal. Drop it from auto-session tracking so the 60s heartbeat stops
4029
+ // beating a card we're barred from writing to.
4030
+ if (result.session === null) {
4031
+ untrack(cardId, deps.getScopeId?.());
4032
+ }
4033
+
3992
4034
  // Phase 0 (memory architecture v2): mid-session learning extraction removed.
3993
4035
  return { success: true, midSessionLearnings: 0, ...result };
3994
4036
  }
@@ -4007,8 +4049,13 @@ async function handleToolCall(
4007
4049
  await flushMemoryActions(client, cardId);
4008
4050
  cleanupMemorySession(cardId);
4009
4051
 
4010
- // End the session — tolerate failure (e.g., session already ended or not found)
4011
- let result: { session: unknown } = { session: null };
4052
+ // End the session — tolerate failure (e.g., session already ended or not found).
4053
+ // Typed off the client so the `ended`/`reason` discriminator (#769) reaches the
4054
+ // tool payload by contract, not by accident of the spread below. Left absent
4055
+ // here on purpose: a throw means we don't know what the server did.
4056
+ let result: Awaited<ReturnType<typeof client.endAgentSession>> = {
4057
+ session: null,
4058
+ };
4012
4059
  let sessionEndError: string | null = null;
4013
4060
  try {
4014
4061
  result = await client.endAgentSession(cardId, {
@@ -4095,6 +4142,54 @@ async function handleToolCall(
4095
4142
  sessionId,
4096
4143
  sinceSeq,
4097
4144
  );
4145
+
4146
+ // Hoist the verdict out of the nested `session` object and say what it
4147
+ // obliges (#770). The poll is the one call a stopped MCP/human client is
4148
+ // guaranteed to make — Stop's Realtime broadcast has no consumer for
4149
+ // `agent_id == null` sessions — so the news has to be impossible to skim
4150
+ // past here.
4151
+ //
4152
+ // Two outcomes, deliberately NOT merged. Only a human Stop is terminal; a
4153
+ // row the 30-minute stale cron closed (or one that vanished) just means the
4154
+ // session id is stale. Telling an agent to abandon its work in that case
4155
+ // would destroy a healthy run — a `blocked` session waiting on a user's
4156
+ // answer is not heartbeated, so it reaches the cron routinely.
4157
+ const stoppedByHuman = result.session?.stoppedByHuman === true;
4158
+ const ended = result.session?.ended === true;
4159
+ // Either verdict means stop beating this card: `ended` because the row is
4160
+ // gone, `stoppedByHuman` because we're about to tell the client to stand
4161
+ // down. Gating on `ended` alone would leave the 60s heartbeat polling a card
4162
+ // this very call declared terminal.
4163
+ if (ended || stoppedByHuman) {
4164
+ untrack(cardId, deps.getScopeId?.());
4165
+ }
4166
+ if (stoppedByHuman) {
4167
+ return {
4168
+ success: true,
4169
+ ...result,
4170
+ stopped: true,
4171
+ stopReason: "human_stopped" as const,
4172
+ instruction:
4173
+ "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.",
4174
+ };
4175
+ }
4176
+ if (ended) {
4177
+ // Deliberately NOT `sessionEnded` — `harmony_move_card` already returns a
4178
+ // field by that name meaning "I ended your session for you", which is
4179
+ // close to the opposite of this. Two tools answering the same word with
4180
+ // near-inverse meanings is how a client ends up re-opening a session it
4181
+ // just intentionally closed.
4182
+ return {
4183
+ success: true,
4184
+ ...result,
4185
+ sessionStale: true,
4186
+ staleReason: result.session?.status,
4187
+ instruction:
4188
+ 'This session id is no longer live (it was closed as "' +
4189
+ (result.session?.status ?? "missing") +
4190
+ '") — 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.',
4191
+ };
4192
+ }
4098
4193
  return { success: true, ...result };
4099
4194
  }
4100
4195
 
package/src/tui/setup.ts CHANGED
@@ -515,14 +515,29 @@ async function getAgentFiles(
515
515
 
516
516
  This project uses Harmony for task management. When working on tasks:
517
517
 
518
+ ## Agent identity — always identify as yourself
519
+
520
+ Every \`harmony_start_agent_session\` call passes \`agentIdentifier\` + \`agentName\`. **Use your own
521
+ identity, never a hardcoded one from this file.** AGENTS.md is a cross-runtime convention file, so
522
+ more than one kind of agent will read it; the board shows agents as teammates, and a session
523
+ attributed to the wrong runtime misattributes the work in front of the whole team.
524
+
525
+ - \`agentIdentifier\` — a stable kebab-case id for the runtime you actually are
526
+ - \`agentName\` — its human-readable name
527
+
528
+ Known values: \`claude-code\` / "Claude Code", \`codex\` / "OpenAI Codex", \`cursor\` / "Cursor",
529
+ \`claude-desktop\` / "Claude Desktop". If you are a runtime not listed here, use your own name rather
530
+ than borrowing the closest entry.
531
+
518
532
  ## Starting Work on a Card
519
533
 
520
534
  When given a card reference (e.g., #42 or a card name), follow this workflow:
521
535
 
522
- 1. Use \`harmony_get_card_by_short_id\` or \`harmony_search_cards\` to find the card
536
+ 1. Use \`harmony_get_card\` or \`harmony_search_cards\` to find the card
523
537
  2. Move the card to "In Progress" using \`harmony_move_card\`
524
538
  3. Add the "agent" label using \`harmony_add_label_to_card\`
525
- 4. Start a session with \`harmony_start_agent_session\` (agentIdentifier: "codex", agentName: "OpenAI Codex")
539
+ 4. Start a session with \`harmony_start_agent_session\`, passing **your own** \`agentIdentifier\` +
540
+ \`agentName\` (see "Agent identity" above)
526
541
  5. Show the card details to the user
527
542
  6. Use \`harmony_generate_prompt\` to get guidance, then implement the solution
528
543
  7. Update progress periodically with \`harmony_update_agent_progress\`
@@ -533,7 +548,8 @@ When given a card reference (e.g., #42 or a card name), follow this workflow:
533
548
  Before implementing a plan or feature, check if it maps to an existing Harmony card:
534
549
 
535
550
  1. Use \`harmony_search_cards\` with keywords from the task description
536
- 2. If a match is found, call \`harmony_start_agent_session\` (agentIdentifier: "claude-code", agentName: "Claude Code", moveToColumn: "In Progress", addLabels: ["agent"])
551
+ 2. If a match is found, call \`harmony_start_agent_session\` with **your own** \`agentIdentifier\` +
552
+ \`agentName\` (see "Agent identity" above), plus \`moveToColumn: "In Progress"\`, \`addLabels: ["agent"]\`
537
553
  3. Update progress with \`harmony_update_agent_progress\` at milestones
538
554
  4. When done, call \`harmony_end_agent_session\` with status: "completed", moveToColumn: "Review"
539
555