@gethmy/mcp 2.20.1 → 2.22.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
@@ -1536,7 +1556,7 @@ export const TOOLS = {
1536
1556
  },
1537
1557
  harmony_update_agent_progress: {
1538
1558
  description:
1539
- "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.",
1540
1560
  inputSchema: {
1541
1561
  type: "object",
1542
1562
  properties: {
@@ -1627,7 +1647,7 @@ export const TOOLS = {
1627
1647
  },
1628
1648
  harmony_get_pending_messages: {
1629
1649
  description:
1630
- "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.)",
1631
1651
  inputSchema: {
1632
1652
  type: "object",
1633
1653
  properties: {
@@ -2614,11 +2634,15 @@ export function registerHandlers(server: Server, deps: ToolDeps): void {
2614
2634
  // the hosted/OAuth path, not just stdio (card #297).
2615
2635
  // Optional-chained: the pre-hook is best-effort and must never throw into
2616
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).
2617
2639
  const cv = server.getClientVersion?.();
2618
2640
  trackActivity(cardIdArg, {
2619
2641
  autoStart: isAutoStartTrigger,
2620
2642
  client: deps.getClient(),
2621
- clientInfo: cv ? { name: cv.name, version: cv.version } : undefined,
2643
+ clientInfo: cv
2644
+ ? { name: cv.name, version: cv.version }
2645
+ : (deps.getClientInfo?.() ?? undefined),
2622
2646
  scopeId: deps.getScopeId?.(),
2623
2647
  }).catch(() => {}); // fire-and-forget
2624
2648
  }
@@ -2647,7 +2671,7 @@ export function registerHandlers(server: Server, deps: ToolDeps): void {
2647
2671
  client: deps.getClient(),
2648
2672
  clientInfo: cv
2649
2673
  ? { name: cv.name, version: cv.version }
2650
- : undefined,
2674
+ : (deps.getClientInfo?.() ?? undefined),
2651
2675
  scopeId: deps.getScopeId?.(),
2652
2676
  }).catch(() => {});
2653
2677
  }
@@ -3992,8 +4016,21 @@ async function handleToolCall(
3992
4016
  ),
3993
4017
  ...(mergedRecentActions && { recentActions: mergedRecentActions }),
3994
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,
3995
4024
  });
3996
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
+
3997
4034
  // Phase 0 (memory architecture v2): mid-session learning extraction removed.
3998
4035
  return { success: true, midSessionLearnings: 0, ...result };
3999
4036
  }
@@ -4012,8 +4049,13 @@ async function handleToolCall(
4012
4049
  await flushMemoryActions(client, cardId);
4013
4050
  cleanupMemorySession(cardId);
4014
4051
 
4015
- // End the session — tolerate failure (e.g., session already ended or not found)
4016
- 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
+ };
4017
4059
  let sessionEndError: string | null = null;
4018
4060
  try {
4019
4061
  result = await client.endAgentSession(cardId, {
@@ -4100,6 +4142,54 @@ async function handleToolCall(
4100
4142
  sessionId,
4101
4143
  sinceSeq,
4102
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
+ }
4103
4193
  return { success: true, ...result };
4104
4194
  }
4105
4195