@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/README.md +41 -2
- package/dist/cli.js +1188 -157
- package/dist/index.js +805 -42
- package/dist/lib/api-client.js +3 -1
- package/dist/run-hook-cli.js +742 -0
- package/package.json +4 -3
- package/src/api-client.ts +57 -1
- package/src/auto-session.ts +33 -0
- package/src/cli.ts +104 -0
- package/src/comment-session.ts +149 -0
- package/src/hook-install.ts +388 -0
- package/src/plan-task-link.ts +130 -0
- package/src/run-event-forwarder.ts +363 -0
- package/src/run-hook-cli.ts +55 -0
- package/src/run-hook-main.ts +159 -0
- package/src/run-hook.ts +203 -0
- package/src/run-redaction.ts +461 -0
- package/src/run-state.ts +679 -0
- package/src/server.ts +342 -34
- package/src/tui/setup.ts +3 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gethmy/mcp",
|
|
3
|
-
"version": "3.
|
|
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
|
package/src/auto-session.ts
CHANGED
|
@@ -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
|
+
}
|