@gethmy/mcp 3.2.0 → 3.4.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gethmy/mcp",
3
- "version": "3.2.0",
3
+ "version": "3.4.0",
4
4
  "description": "MCP server for Harmony, the shared surface for human–agent teams — agents claim cards, report progress, and move work on your board.",
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -24,7 +24,8 @@
24
24
  },
25
25
  "bin": {
26
26
  "harmony-mcp": "dist/cli.js",
27
- "gethmy-mcp": "dist/cli.js"
27
+ "gethmy-mcp": "dist/cli.js",
28
+ "harmony-run-hook": "dist/run-hook-cli.js"
28
29
  },
29
30
  "files": [
30
31
  "dist",
@@ -59,7 +60,7 @@
59
60
  "bun": ">=1.0.0"
60
61
  },
61
62
  "scripts": {
62
- "build": "rm -rf dist && bun build src/index.ts src/cli.ts --outdir dist --target node --external @clack/prompts --external @modelcontextprotocol/sdk --external commander --external hono --external picocolors --external zod && bun build src/api-client.ts src/config.ts src/oauth-refresh.ts --outdir dist/lib --root src --target node --external @clack/prompts --external @modelcontextprotocol/sdk --external commander --external hono --external picocolors --external zod",
63
+ "build": "rm -rf dist && bun build src/index.ts src/cli.ts src/run-hook-cli.ts --outdir dist --target node --external @clack/prompts --external @modelcontextprotocol/sdk --external commander --external hono --external picocolors --external zod && bun build src/api-client.ts src/config.ts src/oauth-refresh.ts --outdir dist/lib --root src --target node --external @clack/prompts --external @modelcontextprotocol/sdk --external commander --external hono --external picocolors --external zod",
63
64
  "build:bun": "bun build src/index.ts src/http.ts src/remote.ts src/cli.ts --outdir dist --target bun",
64
65
  "serve:remote": "bun src/remote.ts",
65
66
  "dev": "bun --watch src/index.ts",
package/src/api-client.ts CHANGED
@@ -1213,6 +1213,12 @@ export class HarmonyApiClient {
1213
1213
  data: {
1214
1214
  decision: "continue" | "stop";
1215
1215
  extraTurns: number;
1216
+ /**
1217
+ * Consumption granted with a Continue, in USD (#1058). Optional — an
1218
+ * omitted value makes the daemon fall back to its own configured grant
1219
+ * (`budget.pause.extraBudgetUsd`, else the per-spawn `sdk.maxBudgetUsd`).
1220
+ */
1221
+ extraBudgetUsd?: number;
1216
1222
  message?: string;
1217
1223
  },
1218
1224
  ): Promise<{ id: string; seq: number; createdAt: string }> {
@@ -1232,6 +1238,8 @@ export class HarmonyApiClient {
1232
1238
  decisions: Array<{
1233
1239
  decision: "continue" | "stop";
1234
1240
  extraTurns: number;
1241
+ /** Consumption granted with the Continue, in USD (#1058) — absent on a pre-#1058 row. */
1242
+ extraBudgetUsd?: number;
1235
1243
  message?: string;
1236
1244
  createdAt: string;
1237
1245
  }>;
@@ -1930,9 +1938,19 @@ export class HarmonyApiClient {
1930
1938
  return this.request("GET", `/plans/${planId}`);
1931
1939
  }
1932
1940
 
1941
+ /**
1942
+ * The plan a card belongs to, and the criteria that point back at it from elsewhere.
1943
+ *
1944
+ * `plan`/`tasks` are MEMBERSHIP (`cards.plan_id`). `foreign_criteria` is DELIVERY
1945
+ * (`plan_tasks.card_id`) for the rows where the two disagree — a card in no plan, or a
1946
+ * card in a different one (#1054). It is optional because a harmony-api older than
1947
+ * #1054 does not send it, so a reader must treat its absence as "unknown", never as
1948
+ * "there are none".
1949
+ */
1933
1950
  async getPlanByCardId(cardId: string): Promise<{
1934
1951
  plan: unknown;
1935
1952
  tasks: unknown[];
1953
+ foreign_criteria?: unknown[];
1936
1954
  } | null> {
1937
1955
  return this.request("GET", `/cards/${cardId}/plan`);
1938
1956
  }
@@ -1948,6 +1966,13 @@ export class HarmonyApiClient {
1948
1966
  return this.request("PATCH", `/plans/${planId}`, updates);
1949
1967
  }
1950
1968
 
1969
+ /**
1970
+ * Point a criterion at a card and/or set its status.
1971
+ *
1972
+ * `cardPlanAdopted` reports the second write this route makes (#1054): linking a card
1973
+ * that belongs to no plan also writes `cards.plan_id`, so the two columns cannot drift.
1974
+ * Absent from a harmony-api older than #1054 — read it as "unknown", never as "no".
1975
+ */
1951
1976
  async updatePlanTask(
1952
1977
  planId: string,
1953
1978
  taskId: string,
@@ -1955,7 +1980,7 @@ export class HarmonyApiClient {
1955
1980
  cardId?: string;
1956
1981
  status?: "pending" | "in_progress" | "completed";
1957
1982
  },
1958
- ): Promise<{ task: unknown }> {
1983
+ ): Promise<{ task: unknown; cardPlanAdopted?: boolean }> {
1959
1984
  return this.request("PATCH", `/plans/${planId}/tasks/${taskId}`, updates);
1960
1985
  }
1961
1986
 
@@ -2374,6 +2399,37 @@ export class HarmonyApiClient {
2374
2399
  ): Promise<{ playbook: unknown }> {
2375
2400
  return this.request("PATCH", `/playbooks/${playbookId}`, updates);
2376
2401
  }
2402
+
2403
+ /**
2404
+ * Delete a playbook (card #856). Irreversible: `playbook_versions` and
2405
+ * `playbook_runs` cascade with it, and every bound card is unbound.
2406
+ *
2407
+ * The unbound cards are the point of the return value — the cards that were
2408
+ * running this playbook stay on the board with their stage pointer cleared,
2409
+ * and the caller has no other way to learn which ones those were.
2410
+ * `unboundCardCount` is exact; `unboundCards` is a capped sample of them.
2411
+ *
2412
+ * The route refuses a caller who is neither the playbook's creator nor a
2413
+ * workspace owner/admin with a 403, distinct from the 404 it answers when the
2414
+ * playbook is already gone. `request` throws on both, so a resolved promise
2415
+ * means the row really was removed.
2416
+ */
2417
+ async deletePlaybook(playbookId: string): Promise<{
2418
+ success: boolean;
2419
+ playbook: { id: string; name: string; workspace_id: string };
2420
+ unboundCardCount: number;
2421
+ unboundCards: Array<{
2422
+ id: string;
2423
+ short_id: number | null;
2424
+ title: string;
2425
+ current_stage: string | null;
2426
+ }>;
2427
+ }> {
2428
+ return this.request(
2429
+ "DELETE",
2430
+ `/playbooks/${encodeURIComponent(playbookId)}`,
2431
+ );
2432
+ }
2377
2433
  }
2378
2434
 
2379
2435
  // Shared types for generateCardPrompt to avoid inline assertions
@@ -24,6 +24,7 @@
24
24
  */
25
25
 
26
26
  import type { HarmonyApiClient } from "./api-client.js";
27
+ import { beginHookTimeline, endHookTimeline } from "./run-event-forwarder.js";
27
28
 
28
29
  /**
29
30
  * Status reported for a tracked session. Drives the heartbeat decision: only
@@ -43,6 +44,17 @@ export interface TrackedSession {
43
44
  isExplicit: boolean;
44
45
  agentIdentifier: string;
45
46
  agentName: string;
47
+ /**
48
+ * `card_agent_context.id` for this session, when the start endpoint returned
49
+ * one. Recorded so the session can be PUBLISHED on disk for the `PostToolUse`
50
+ * hook to find (#874) — a hook is a separate process and cannot read this
51
+ * map, so the id has to travel through the filesystem.
52
+ *
53
+ * Optional because an older API build may return no id, and because a start
54
+ * that threw is still tracked locally. Absent means "no tool-call rows for
55
+ * this session", never an error.
56
+ */
57
+ agentSessionId?: string;
46
58
  /**
47
59
  * Last status reported for the session (default `working`). Only `working`
48
60
  * sessions are heartbeated by the sweep; see `heartbeatActiveSessions`.
@@ -290,6 +302,7 @@ export async function trackActivity(
290
302
  // rather than letting a surviving client walk the run back to life one cooldown
291
303
  // later (card #770). An explicit `/hmy` re-run is unaffected — it starts the
292
304
  // session by name and only faces the 10-minute #663 window.
305
+ let agentSessionId: string | undefined;
293
306
  try {
294
307
  const started = await client.startAgentSession(cardId, {
295
308
  agentIdentifier,
@@ -300,6 +313,7 @@ export async function trackActivity(
300
313
  // Refused: don't track it locally either, or the sweep would heartbeat a
301
314
  // session that does not exist and `checkInactivity` would later "end" it.
302
315
  if (started?.session === null) return;
316
+ agentSessionId = (started?.session as { id?: string } | undefined)?.id;
303
317
  } catch {
304
318
  // Session start failed (might already have one), still track locally
305
319
  }
@@ -311,8 +325,20 @@ export async function trackActivity(
311
325
  isExplicit: false,
312
326
  agentIdentifier,
313
327
  agentName,
328
+ agentSessionId,
314
329
  status: "working",
315
330
  });
331
+
332
+ // Publish for the `PostToolUse` hook. An auto-session is a real session with
333
+ // a real timeline, so it gets tool rows for the same reason an explicit one
334
+ // does. Best-effort by construction — `beginHookTimeline` returns null rather
335
+ // than throwing, so a failure here cannot break the tool call that triggered
336
+ // the auto-start.
337
+ beginHookTimeline({
338
+ cardId,
339
+ agentSessionId,
340
+ getClient: () => client,
341
+ });
316
342
  }
317
343
 
318
344
  /**
@@ -551,6 +577,13 @@ async function autoEndSession(
551
577
  // cardId concurrently; whichever claims it first runs the end + pipeline,
552
578
  // the loser bails so endAgentSession / runEndSessionPipeline fire exactly once.
553
579
  if (!scope.sessions.delete(cardId)) return;
580
+ // Drain and unpublish BEFORE the session row is ended — an append to an ended
581
+ // session is refused, so the tail of the tool log would be lost otherwise.
582
+ try {
583
+ await endHookTimeline(cardId);
584
+ } catch {
585
+ // Best-effort telemetry; never blocks the end.
586
+ }
554
587
  try {
555
588
  await client.endAgentSession(cardId, { status });
556
589
  } catch {
package/src/cli.ts CHANGED
@@ -211,4 +211,108 @@ program
211
211
  });
212
212
  });
213
213
 
214
+ const hook = program
215
+ .command("hook")
216
+ .description(
217
+ "Manage the PostToolUse hook that streams tool calls to a card's run timeline",
218
+ );
219
+
220
+ hook
221
+ .command("install")
222
+ .description(
223
+ "Install the hook into ~/.claude/settings.json (the user layer, never a project)",
224
+ )
225
+ .action(async () => {
226
+ // Imported lazily so `serve` — the hot path — does not parse this module.
227
+ const { installUserHook } = await import("./hook-install.js");
228
+ const result = installUserHook();
229
+ if (!result.ok) {
230
+ console.error(`Could not install the hook: ${result.error}`);
231
+ process.exit(1);
232
+ }
233
+ console.log(
234
+ result.changed
235
+ ? `Installed the Harmony PostToolUse hook in ${result.path}`
236
+ : `The Harmony PostToolUse hook is already installed in ${result.path}`,
237
+ );
238
+ console.log(` command: ${result.command}`);
239
+ console.log(
240
+ "\nTool calls from an MCP session will now appear on the card's run timeline.",
241
+ );
242
+ console.log(
243
+ "The user settings layer is deliberate: a daemon run never loads it, so it",
244
+ );
245
+ console.log("cannot double-report the stream it already sends itself.");
246
+ });
247
+
248
+ hook
249
+ .command("uninstall")
250
+ .description("Remove the hook from ~/.claude/settings.json")
251
+ .action(async () => {
252
+ const { uninstallUserHook } = await import("./hook-install.js");
253
+ const result = uninstallUserHook();
254
+ if (!result.ok) {
255
+ console.error(`Could not remove the hook: ${result.error}`);
256
+ process.exit(1);
257
+ }
258
+ console.log(
259
+ result.changed
260
+ ? `Removed the Harmony PostToolUse hook from ${result.path}`
261
+ : "The Harmony PostToolUse hook was not installed.",
262
+ );
263
+ });
264
+
265
+ hook
266
+ .command("status")
267
+ .description("Report whether the hook is installed, and any live sessions")
268
+ .action(async () => {
269
+ const { hookInstallStatus, userSettingsPath } = await import(
270
+ "./hook-install.js"
271
+ );
272
+ const { readPublishedSessions, runStateDir } = await import(
273
+ "./run-state.js"
274
+ );
275
+ const { readFileSync } = await import("node:fs");
276
+ const path = userSettingsPath();
277
+ let status = {
278
+ installed: false,
279
+ binary: null as string | null,
280
+ binaryExists: false,
281
+ };
282
+ try {
283
+ status = hookInstallStatus(JSON.parse(readFileSync(path, "utf-8")));
284
+ } catch {
285
+ // A missing or corrupt settings file reads as "not installed".
286
+ }
287
+ console.log(
288
+ `Hook: ${status.installed ? "installed" : "not installed"} (${path})`,
289
+ );
290
+ if (status.installed && !status.binaryExists) {
291
+ console.log(
292
+ ` ! its hook binary is gone: ${status.binary ?? "unparseable command"}`,
293
+ );
294
+ console.log(
295
+ " The hook is INERT until you re-run `npx @gethmy/mcp hook install`.",
296
+ );
297
+ console.log(
298
+ " An upgrade or a cleared npx/bunx cache moves the binary; the installed",
299
+ );
300
+ console.log(
301
+ " command guards its own paths, so nothing errors in the meantime.",
302
+ );
303
+ }
304
+ console.log(`State: ${runStateDir()}`);
305
+ const sessions = readPublishedSessions();
306
+ if (sessions.length === 0) {
307
+ console.log("Live sessions: none");
308
+ return;
309
+ }
310
+ console.log(`Live sessions: ${sessions.length}`);
311
+ for (const session of sessions) {
312
+ console.log(
313
+ ` card ${session.cardId} · session ${session.agentSessionId} · pid ${session.publisherPid} · ${session.cwd}`,
314
+ );
315
+ }
316
+ });
317
+
214
318
  program.parse();
@@ -0,0 +1,149 @@
1
+ /**
2
+ * Which session an agent comment names — and when it names none (card #1035).
3
+ *
4
+ * ## The question this replaces
5
+ *
6
+ * `POST /cards/:id/comments` used to answer "which session?" itself when the
7
+ * caller sent none: it selected the caller's sessions on the card by `card_id`
8
+ * + `user_id`. That predicate is true of **every concurrent run of the same
9
+ * account**, so a second run inherited the first one's attribution — measured
10
+ * on card #1029, where two findings came back carrying a neighbouring `/hmy`
11
+ * session's id. The server could not do better, because `user_id` is the only
12
+ * thing it has and the daemon's API key resolves to the same user as its
13
+ * launcher's own sessions.
14
+ *
15
+ * So the answer moved to the only place that can know it: the process making
16
+ * the call. This module is that decision, kept pure so it is a table test
17
+ * rather than a behaviour reconstructed from a live run.
18
+ *
19
+ * ## Two sources, and why absence is now an ANSWER rather than a guess
20
+ *
21
+ * A caller may hold a session two ways, and both are DECLARATIONS rather than
22
+ * observations:
23
+ *
24
+ * - `tracked` — this process called `harmony_start_agent_session` and kept the
25
+ * id (`memorySessions`). It is the interactive `/hmy` path.
26
+ * - `declared` — the daemon put the run's id in this process's environment
27
+ * (`harmonyMcpServer` in `@gethmy/harness`, and `motor-driver.ts` for a stage
28
+ * run). It is the daemon path, which needs one because
29
+ * `harmony_start_agent_session` is DENIED on a daemon run
30
+ * (`STAGE_DAEMON_OWNED_TOOLS`) — the daemon owns the lifecycle, so the agent
31
+ * has nothing of its own to track.
32
+ *
33
+ * Neither is an inference about the ACCOUNT, and that is the whole change.
34
+ * The old question — "does this user have a session on this card?" — has an
35
+ * ambiguous negative, because a process that tracks nothing may still belong to
36
+ * a user with three live runs. The new question — "was a session declared to
37
+ * THIS process for THIS card?" — has a definite negative: nothing declared
38
+ * means this caller holds no session it may claim, which is exactly the
39
+ * sessionless comment #1033 made legal. There is no third "I do not know"
40
+ * state left for the server to guess at.
41
+ *
42
+ * ## The scope check on `tracked` is load-bearing
43
+ *
44
+ * `memorySessions` is keyed by card id alone and lives at module scope, so on
45
+ * the hosted HTTP transport (`remote.ts`, one process serving every user) a
46
+ * lookup can return a DIFFERENT user's session for the same card. Claiming it
47
+ * is not merely wrong attribution — harmony-api verifies an explicit
48
+ * `agentSessionId` against `user_id` and answers 403 — so the tracked source
49
+ * carries the scope it was recorded under and is used only when that matches
50
+ * the caller's. A mismatch is treated as "not mine", never as "theirs".
51
+ *
52
+ * ## The card check on `declared` is load-bearing too
53
+ *
54
+ * A daemon run comments on its own card and, since #1033, on neighbouring ones
55
+ * — that is how a finding reaches a card the run does not hold. The run's
56
+ * session belongs to ONE card, so the declaration carries the card id and is
57
+ * used only on that card. On any other card the same run is genuinely
58
+ * sessionless, and says so.
59
+ */
60
+
61
+ /** A run's own session, as the daemon declared it to this process. */
62
+ export interface DeclaredRunSession {
63
+ /** `cards.id` — the card the run holds its session on. */
64
+ cardId: string;
65
+ /** `card_agent_context.id` — the run's own session row. */
66
+ agentSessionId: string;
67
+ }
68
+
69
+ /** The session this process tracks for a card, and the tenant it belongs to. */
70
+ export interface TrackedCommentSession {
71
+ /** `card_agent_context.id`, captured when this process started the session. */
72
+ agentSessionId?: string;
73
+ /**
74
+ * The tenant the session was recorded under (`ToolDeps.getScopeId`).
75
+ * `undefined` on stdio, where the process serves exactly one user.
76
+ */
77
+ scopeId?: string;
78
+ }
79
+
80
+ /** What `harmony_add_comment` should send for `agentSessionId`. */
81
+ export type CommentSessionChoice =
82
+ | {
83
+ kind: "session";
84
+ agentSessionId: string;
85
+ /** Which declaration named it — for the tool result, so the caller can see. */
86
+ source: "tracked" | "declared";
87
+ }
88
+ | { kind: "sessionless" };
89
+
90
+ /** The two environment keys the daemon declares a run's session through. */
91
+ export const RUN_SESSION_CARD_ENV = "HARMONY_AGENT_CARD_ID";
92
+ export const RUN_SESSION_ID_ENV = "HARMONY_AGENT_SESSION_ID";
93
+
94
+ /**
95
+ * The run this MCP process was started for, if the daemon declared one.
96
+ *
97
+ * Read per call rather than latched at startup: the value cannot change under a
98
+ * stdio server (one process per run), and re-reading keeps this a pure function
99
+ * of its input, which is what lets the tests drive it with a synthetic
100
+ * environment instead of `process.env`.
101
+ *
102
+ * BOTH keys or nothing. A card id with no session is a run that declared
103
+ * nothing usable, and a session id with no card cannot be bounded to the card
104
+ * it belongs to — claiming it on every card is the borrowing this replaces.
105
+ */
106
+ export function readDeclaredRunSession(
107
+ env: Record<string, string | undefined> = process.env,
108
+ ): DeclaredRunSession | null {
109
+ const cardId = env[RUN_SESSION_CARD_ENV]?.trim();
110
+ const agentSessionId = env[RUN_SESSION_ID_ENV]?.trim();
111
+ if (!cardId || !agentSessionId) return null;
112
+ return { cardId, agentSessionId };
113
+ }
114
+
115
+ /**
116
+ * Decide what a new agent comment on `cardId` is attributed to.
117
+ *
118
+ * `tracked` wins over `declared` when both name a session for the card. They
119
+ * cannot disagree in practice — a daemon run may not open a session of its own
120
+ * — but if one ever did, the session this process opened is the one it can
121
+ * prove it owns.
122
+ */
123
+ export function chooseCommentSession(args: {
124
+ cardId: string;
125
+ tracked?: TrackedCommentSession | undefined;
126
+ /** The caller's tenant (`ToolDeps.getScopeId`); `undefined` on stdio. */
127
+ callerScopeId?: string | undefined;
128
+ declared?: DeclaredRunSession | null;
129
+ }): CommentSessionChoice {
130
+ const tracked = args.tracked;
131
+ if (tracked?.agentSessionId && tracked.scopeId === args.callerScopeId) {
132
+ return {
133
+ kind: "session",
134
+ agentSessionId: tracked.agentSessionId,
135
+ source: "tracked",
136
+ };
137
+ }
138
+
139
+ const declared = args.declared;
140
+ if (declared && declared.cardId === args.cardId) {
141
+ return {
142
+ kind: "session",
143
+ agentSessionId: declared.agentSessionId,
144
+ source: "declared",
145
+ };
146
+ }
147
+
148
+ return { kind: "sessionless" };
149
+ }