@gethmy/mcp 2.8.5 → 2.9.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
@@ -1456,6 +1456,12 @@ class HarmonyApiClient {
1456
1456
  async getWorkspaceMembers(workspaceId) {
1457
1457
  return this.request("GET", `/workspaces/${workspaceId}/members`);
1458
1458
  }
1459
+ async listWorkspaceAgents(workspaceId) {
1460
+ return this.request("GET", `/workspaces/${workspaceId}/agents`);
1461
+ }
1462
+ async registerWorkspaceAgent(workspaceId, data) {
1463
+ return this.request("POST", `/workspaces/${workspaceId}/agents`, data);
1464
+ }
1459
1465
  async listProjects(workspaceId) {
1460
1466
  return this.request("GET", `/workspaces/${workspaceId}/projects`);
1461
1467
  }
@@ -1720,6 +1726,10 @@ class HarmonyApiClient {
1720
1726
  params.set("type", options.type);
1721
1727
  if (options?.limit !== undefined)
1722
1728
  params.set("limit", String(options.limit));
1729
+ for (const tag of options?.tags ?? [])
1730
+ params.append("tags", tag);
1731
+ if (options?.include_superseded)
1732
+ params.set("include_superseded", "true");
1723
1733
  return this.request("GET", `/memory/search?${params.toString()}`);
1724
1734
  }
1725
1735
  async getVaultIndex(options) {
@@ -2043,43 +2053,56 @@ var AUTO_START_TRIGGERS = new Set([
2043
2053
  ]);
2044
2054
  var INACTIVITY_TIMEOUT_MS = 10 * 60 * 1000;
2045
2055
  var CHECK_INTERVAL_MS = 60 * 1000;
2046
- var activeSessions = new Map;
2056
+ var DEFAULT_SCOPE = "__default__";
2057
+ var scopes = new Map;
2047
2058
  var inactivityTimer = null;
2048
- var endCallback = null;
2049
- var clientGetter = null;
2050
- var clientInfoGetter = null;
2051
- function initAutoSession(callback, getClient2, getClientInfo) {
2052
- endCallback = callback;
2053
- clientGetter = getClient2;
2054
- clientInfoGetter = getClientInfo ?? null;
2055
- if (inactivityTimer)
2056
- clearInterval(inactivityTimer);
2057
- inactivityTimer = setInterval(checkInactivity, CHECK_INTERVAL_MS);
2059
+ function getOrCreateScope(scopeId) {
2060
+ let scope = scopes.get(scopeId);
2061
+ if (!scope) {
2062
+ scope = {
2063
+ sessions: new Map,
2064
+ endCallback: null,
2065
+ clientGetter: null,
2066
+ clientInfoGetter: null
2067
+ };
2068
+ scopes.set(scopeId, scope);
2069
+ }
2070
+ return scope;
2071
+ }
2072
+ function initAutoSession(callback, getClient2, getClientInfo, scopeId = DEFAULT_SCOPE) {
2073
+ const scope = getOrCreateScope(scopeId);
2074
+ scope.endCallback = callback;
2075
+ scope.clientGetter = getClient2;
2076
+ scope.clientInfoGetter = getClientInfo ?? null;
2077
+ if (!inactivityTimer) {
2078
+ inactivityTimer = setInterval(checkInactivity, CHECK_INTERVAL_MS);
2079
+ }
2058
2080
  }
2059
2081
  async function trackActivity(cardId, options) {
2082
+ const scope = getOrCreateScope(options?.scopeId ?? DEFAULT_SCOPE);
2060
2083
  const now = Date.now();
2061
- const existing = activeSessions.get(cardId);
2084
+ const existing = scope.sessions.get(cardId);
2062
2085
  if (existing) {
2063
2086
  existing.lastActivityAt = now;
2064
2087
  return;
2065
2088
  }
2066
2089
  if (!options?.autoStart)
2067
2090
  return;
2068
- const client3 = options?.client ?? clientGetter?.();
2091
+ const client3 = options?.client ?? scope.clientGetter?.();
2069
2092
  if (!client3)
2070
2093
  return;
2071
- const info = clientInfoGetter?.() ?? null;
2094
+ const info = options?.clientInfo ?? scope.clientInfoGetter?.() ?? null;
2072
2095
  if (!info?.name)
2073
2096
  return;
2074
2097
  const { agentIdentifier, agentName } = resolveAgentIdentity(info);
2075
2098
  const toEnd = [];
2076
- for (const [otherCardId, session] of activeSessions) {
2099
+ for (const [otherCardId, session] of scope.sessions) {
2077
2100
  if (otherCardId !== cardId && !session.isExplicit) {
2078
2101
  toEnd.push(otherCardId);
2079
2102
  }
2080
2103
  }
2081
2104
  for (const otherCardId of toEnd) {
2082
- await autoEndSession(client3, otherCardId, "completed");
2105
+ await autoEndSession(scope, client3, otherCardId, "completed");
2083
2106
  }
2084
2107
  try {
2085
2108
  await client3.startAgentSession(cardId, {
@@ -2088,7 +2111,7 @@ async function trackActivity(cardId, options) {
2088
2111
  status: "working"
2089
2112
  });
2090
2113
  } catch {}
2091
- activeSessions.set(cardId, {
2114
+ scope.sessions.set(cardId, {
2092
2115
  cardId,
2093
2116
  startedAt: now,
2094
2117
  lastActivityAt: now,
@@ -2098,7 +2121,8 @@ async function trackActivity(cardId, options) {
2098
2121
  });
2099
2122
  }
2100
2123
  function markExplicit(cardId, options) {
2101
- const existing = activeSessions.get(cardId);
2124
+ const scope = getOrCreateScope(options?.scopeId ?? DEFAULT_SCOPE);
2125
+ const existing = scope.sessions.get(cardId);
2102
2126
  if (existing) {
2103
2127
  existing.isExplicit = true;
2104
2128
  if (options?.agentIdentifier)
@@ -2106,7 +2130,7 @@ function markExplicit(cardId, options) {
2106
2130
  if (options?.agentName)
2107
2131
  existing.agentName = options.agentName;
2108
2132
  } else {
2109
- activeSessions.set(cardId, {
2133
+ scope.sessions.set(cardId, {
2110
2134
  cardId,
2111
2135
  startedAt: Date.now(),
2112
2136
  lastActivityAt: Date.now(),
@@ -2116,15 +2140,23 @@ function markExplicit(cardId, options) {
2116
2140
  });
2117
2141
  }
2118
2142
  }
2119
- function untrack(cardId) {
2120
- activeSessions.delete(cardId);
2143
+ function untrack(cardId, scopeId = DEFAULT_SCOPE) {
2144
+ scopes.get(scopeId)?.sessions.delete(cardId);
2121
2145
  }
2122
- async function shutdownAllSessions() {
2123
- const client3 = clientGetter?.();
2124
- if (!client3)
2125
- return;
2126
- const cardIds = [...activeSessions.keys()];
2127
- const promises = cardIds.map((cardId) => autoEndSession(client3, cardId, "paused"));
2146
+ async function shutdownAllSessions(scopeId) {
2147
+ const targets = scopeId !== undefined ? scopes.has(scopeId) ? [scopeId] : [] : [...scopes.keys()];
2148
+ const promises = [];
2149
+ for (const sid of targets) {
2150
+ const scope = scopes.get(sid);
2151
+ if (!scope)
2152
+ continue;
2153
+ const client3 = scope.clientGetter?.();
2154
+ if (!client3)
2155
+ continue;
2156
+ for (const cardId of [...scope.sessions.keys()]) {
2157
+ promises.push(autoEndSession(scope, client3, cardId, "paused"));
2158
+ }
2159
+ }
2128
2160
  await Promise.allSettled(promises);
2129
2161
  }
2130
2162
  function destroyAutoSession() {
@@ -2132,32 +2164,32 @@ function destroyAutoSession() {
2132
2164
  clearInterval(inactivityTimer);
2133
2165
  inactivityTimer = null;
2134
2166
  }
2135
- activeSessions.clear();
2136
- endCallback = null;
2137
- clientGetter = null;
2138
- clientInfoGetter = null;
2167
+ scopes.clear();
2139
2168
  }
2140
2169
  function checkInactivity() {
2141
2170
  const now = Date.now();
2142
- const client3 = clientGetter?.();
2143
- if (!client3)
2144
- return;
2145
- const entries = [...activeSessions.entries()];
2146
- for (const [cardId, session] of entries) {
2147
- if (session.isExplicit)
2171
+ for (const scope of scopes.values()) {
2172
+ const client3 = scope.clientGetter?.();
2173
+ if (!client3)
2148
2174
  continue;
2149
- if (now - session.lastActivityAt > INACTIVITY_TIMEOUT_MS) {
2150
- autoEndSession(client3, cardId, "completed").catch(() => {});
2175
+ const entries = [...scope.sessions.entries()];
2176
+ for (const [cardId, session] of entries) {
2177
+ if (session.isExplicit)
2178
+ continue;
2179
+ if (now - session.lastActivityAt > INACTIVITY_TIMEOUT_MS) {
2180
+ autoEndSession(scope, client3, cardId, "completed").catch(() => {});
2181
+ }
2151
2182
  }
2152
2183
  }
2153
2184
  }
2154
- async function autoEndSession(client3, cardId, status) {
2155
- activeSessions.delete(cardId);
2185
+ async function autoEndSession(scope, client3, cardId, status) {
2186
+ if (!scope.sessions.delete(cardId))
2187
+ return;
2156
2188
  try {
2157
2189
  await client3.endAgentSession(cardId, { status });
2158
2190
  } catch {}
2159
2191
  try {
2160
- await endCallback?.(client3, cardId, status);
2192
+ await scope.endCallback?.(client3, cardId, status);
2161
2193
  } catch {}
2162
2194
  }
2163
2195
 
@@ -4561,9 +4593,12 @@ function registerHandlers(server, deps) {
4561
4593
  const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
4562
4594
  if (cardIdArg && UUID_RE.test(cardIdArg) && deps.isConfigured()) {
4563
4595
  const isAutoStartTrigger = AUTO_START_TRIGGERS.has(name);
4596
+ const cv = server.getClientVersion?.();
4564
4597
  trackActivity(cardIdArg, {
4565
4598
  autoStart: isAutoStartTrigger,
4566
- client: deps.getClient()
4599
+ client: deps.getClient(),
4600
+ clientInfo: cv ? { name: cv.name, version: cv.version } : undefined,
4601
+ scopeId: deps.getScopeId?.()
4567
4602
  }).catch(() => {});
4568
4603
  }
4569
4604
  try {
@@ -4573,9 +4608,12 @@ function registerHandlers(server, deps) {
4573
4608
  const parsed = typeof result === "object" && result !== null ? result : {};
4574
4609
  const resolvedCardId = parsed.cardId;
4575
4610
  if (resolvedCardId && UUID_RE.test(resolvedCardId)) {
4611
+ const cv = server.getClientVersion?.();
4576
4612
  trackActivity(resolvedCardId, {
4577
4613
  autoStart: true,
4578
- client: deps.getClient()
4614
+ client: deps.getClient(),
4615
+ clientInfo: cv ? { name: cv.name, version: cv.version } : undefined,
4616
+ scopeId: deps.getScopeId?.()
4579
4617
  }).catch(() => {});
4580
4618
  }
4581
4619
  } catch {}
@@ -4732,7 +4770,7 @@ async function handleToolCall(name, args, deps) {
4732
4770
  const { session } = await client3.getAgentSession(cardId);
4733
4771
  if (session) {
4734
4772
  await client3.endAgentSession(cardId, { status: "completed" });
4735
- untrack(cardId);
4773
+ untrack(cardId, deps.getScopeId?.());
4736
4774
  sessionEnded = true;
4737
4775
  }
4738
4776
  }
@@ -5090,7 +5128,11 @@ async function handleToolCall(name, args, deps) {
5090
5128
  currentTask: args.currentTask,
5091
5129
  estimatedMinutesRemaining: args.estimatedMinutesRemaining
5092
5130
  });
5093
- markExplicit(cardId, { agentIdentifier, agentName });
5131
+ markExplicit(cardId, {
5132
+ agentIdentifier,
5133
+ agentName,
5134
+ scopeId: deps.getScopeId?.()
5135
+ });
5094
5136
  const agentSessionId = result.session?.id;
5095
5137
  initMemorySession(cardId, agentIdentifier, agentName, agentSessionId);
5096
5138
  return {
@@ -5152,7 +5194,7 @@ async function handleToolCall(name, args, deps) {
5152
5194
  } catch (err) {
5153
5195
  sessionEndError = err instanceof Error ? err.message : "Failed to end session";
5154
5196
  }
5155
- untrack(cardId);
5197
+ untrack(cardId, deps.getScopeId?.());
5156
5198
  let movedTo = null;
5157
5199
  try {
5158
5200
  const { card } = await client3.getCard(cardId);
@@ -5329,23 +5371,21 @@ async function handleToolCall(name, args, deps) {
5329
5371
  let entities;
5330
5372
  let relevanceMap;
5331
5373
  if (queryText) {
5374
+ const requestedTags = args.tags;
5332
5375
  const searchResult = await client3.searchMemoryEntities(workspaceId, queryText, {
5333
5376
  project_id: projectId,
5334
5377
  type: args.type,
5335
- limit: fetchLimit
5378
+ limit: fetchLimit,
5379
+ tags: requestedTags && requestedTags.length > 0 ? requestedTags : undefined,
5380
+ include_superseded: includeSuperseded
5336
5381
  });
5337
5382
  entities = searchResult.entities ?? [];
5338
- const requestedTags = args.tags;
5339
5383
  const minConfidence = args.minConfidence;
5340
5384
  if (userScopeFilter) {
5341
5385
  entities = entities.filter((e) => e?.scope === userScopeFilter);
5342
5386
  } else if (excludeSessionFromLongTerm) {
5343
5387
  entities = entities.filter((e) => !isSessionScope(e?.scope));
5344
5388
  }
5345
- if (requestedTags && requestedTags.length > 0) {
5346
- const wanted = new Set(requestedTags);
5347
- entities = entities.filter((e) => (e?.tags ?? []).some((t) => wanted.has(t)));
5348
- }
5349
5389
  entities = filterByMinConfidence(entities, minConfidence);
5350
5390
  if (!includeSuperseded) {
5351
5391
  entities = entities.filter((e) => !e?.superseded_at);
package/dist/index.js CHANGED
@@ -1452,6 +1452,12 @@ class HarmonyApiClient {
1452
1452
  async getWorkspaceMembers(workspaceId) {
1453
1453
  return this.request("GET", `/workspaces/${workspaceId}/members`);
1454
1454
  }
1455
+ async listWorkspaceAgents(workspaceId) {
1456
+ return this.request("GET", `/workspaces/${workspaceId}/agents`);
1457
+ }
1458
+ async registerWorkspaceAgent(workspaceId, data) {
1459
+ return this.request("POST", `/workspaces/${workspaceId}/agents`, data);
1460
+ }
1455
1461
  async listProjects(workspaceId) {
1456
1462
  return this.request("GET", `/workspaces/${workspaceId}/projects`);
1457
1463
  }
@@ -1716,6 +1722,10 @@ class HarmonyApiClient {
1716
1722
  params.set("type", options.type);
1717
1723
  if (options?.limit !== undefined)
1718
1724
  params.set("limit", String(options.limit));
1725
+ for (const tag of options?.tags ?? [])
1726
+ params.append("tags", tag);
1727
+ if (options?.include_superseded)
1728
+ params.set("include_superseded", "true");
1719
1729
  return this.request("GET", `/memory/search?${params.toString()}`);
1720
1730
  }
1721
1731
  async getVaultIndex(options) {
@@ -2039,43 +2049,56 @@ var AUTO_START_TRIGGERS = new Set([
2039
2049
  ]);
2040
2050
  var INACTIVITY_TIMEOUT_MS = 10 * 60 * 1000;
2041
2051
  var CHECK_INTERVAL_MS = 60 * 1000;
2042
- var activeSessions = new Map;
2052
+ var DEFAULT_SCOPE = "__default__";
2053
+ var scopes = new Map;
2043
2054
  var inactivityTimer = null;
2044
- var endCallback = null;
2045
- var clientGetter = null;
2046
- var clientInfoGetter = null;
2047
- function initAutoSession(callback, getClient2, getClientInfo) {
2048
- endCallback = callback;
2049
- clientGetter = getClient2;
2050
- clientInfoGetter = getClientInfo ?? null;
2051
- if (inactivityTimer)
2052
- clearInterval(inactivityTimer);
2053
- inactivityTimer = setInterval(checkInactivity, CHECK_INTERVAL_MS);
2055
+ function getOrCreateScope(scopeId) {
2056
+ let scope = scopes.get(scopeId);
2057
+ if (!scope) {
2058
+ scope = {
2059
+ sessions: new Map,
2060
+ endCallback: null,
2061
+ clientGetter: null,
2062
+ clientInfoGetter: null
2063
+ };
2064
+ scopes.set(scopeId, scope);
2065
+ }
2066
+ return scope;
2067
+ }
2068
+ function initAutoSession(callback, getClient2, getClientInfo, scopeId = DEFAULT_SCOPE) {
2069
+ const scope = getOrCreateScope(scopeId);
2070
+ scope.endCallback = callback;
2071
+ scope.clientGetter = getClient2;
2072
+ scope.clientInfoGetter = getClientInfo ?? null;
2073
+ if (!inactivityTimer) {
2074
+ inactivityTimer = setInterval(checkInactivity, CHECK_INTERVAL_MS);
2075
+ }
2054
2076
  }
2055
2077
  async function trackActivity(cardId, options) {
2078
+ const scope = getOrCreateScope(options?.scopeId ?? DEFAULT_SCOPE);
2056
2079
  const now = Date.now();
2057
- const existing = activeSessions.get(cardId);
2080
+ const existing = scope.sessions.get(cardId);
2058
2081
  if (existing) {
2059
2082
  existing.lastActivityAt = now;
2060
2083
  return;
2061
2084
  }
2062
2085
  if (!options?.autoStart)
2063
2086
  return;
2064
- const client3 = options?.client ?? clientGetter?.();
2087
+ const client3 = options?.client ?? scope.clientGetter?.();
2065
2088
  if (!client3)
2066
2089
  return;
2067
- const info = clientInfoGetter?.() ?? null;
2090
+ const info = options?.clientInfo ?? scope.clientInfoGetter?.() ?? null;
2068
2091
  if (!info?.name)
2069
2092
  return;
2070
2093
  const { agentIdentifier, agentName } = resolveAgentIdentity(info);
2071
2094
  const toEnd = [];
2072
- for (const [otherCardId, session] of activeSessions) {
2095
+ for (const [otherCardId, session] of scope.sessions) {
2073
2096
  if (otherCardId !== cardId && !session.isExplicit) {
2074
2097
  toEnd.push(otherCardId);
2075
2098
  }
2076
2099
  }
2077
2100
  for (const otherCardId of toEnd) {
2078
- await autoEndSession(client3, otherCardId, "completed");
2101
+ await autoEndSession(scope, client3, otherCardId, "completed");
2079
2102
  }
2080
2103
  try {
2081
2104
  await client3.startAgentSession(cardId, {
@@ -2084,7 +2107,7 @@ async function trackActivity(cardId, options) {
2084
2107
  status: "working"
2085
2108
  });
2086
2109
  } catch {}
2087
- activeSessions.set(cardId, {
2110
+ scope.sessions.set(cardId, {
2088
2111
  cardId,
2089
2112
  startedAt: now,
2090
2113
  lastActivityAt: now,
@@ -2094,7 +2117,8 @@ async function trackActivity(cardId, options) {
2094
2117
  });
2095
2118
  }
2096
2119
  function markExplicit(cardId, options) {
2097
- const existing = activeSessions.get(cardId);
2120
+ const scope = getOrCreateScope(options?.scopeId ?? DEFAULT_SCOPE);
2121
+ const existing = scope.sessions.get(cardId);
2098
2122
  if (existing) {
2099
2123
  existing.isExplicit = true;
2100
2124
  if (options?.agentIdentifier)
@@ -2102,7 +2126,7 @@ function markExplicit(cardId, options) {
2102
2126
  if (options?.agentName)
2103
2127
  existing.agentName = options.agentName;
2104
2128
  } else {
2105
- activeSessions.set(cardId, {
2129
+ scope.sessions.set(cardId, {
2106
2130
  cardId,
2107
2131
  startedAt: Date.now(),
2108
2132
  lastActivityAt: Date.now(),
@@ -2112,15 +2136,23 @@ function markExplicit(cardId, options) {
2112
2136
  });
2113
2137
  }
2114
2138
  }
2115
- function untrack(cardId) {
2116
- activeSessions.delete(cardId);
2139
+ function untrack(cardId, scopeId = DEFAULT_SCOPE) {
2140
+ scopes.get(scopeId)?.sessions.delete(cardId);
2117
2141
  }
2118
- async function shutdownAllSessions() {
2119
- const client3 = clientGetter?.();
2120
- if (!client3)
2121
- return;
2122
- const cardIds = [...activeSessions.keys()];
2123
- const promises = cardIds.map((cardId) => autoEndSession(client3, cardId, "paused"));
2142
+ async function shutdownAllSessions(scopeId) {
2143
+ const targets = scopeId !== undefined ? scopes.has(scopeId) ? [scopeId] : [] : [...scopes.keys()];
2144
+ const promises = [];
2145
+ for (const sid of targets) {
2146
+ const scope = scopes.get(sid);
2147
+ if (!scope)
2148
+ continue;
2149
+ const client3 = scope.clientGetter?.();
2150
+ if (!client3)
2151
+ continue;
2152
+ for (const cardId of [...scope.sessions.keys()]) {
2153
+ promises.push(autoEndSession(scope, client3, cardId, "paused"));
2154
+ }
2155
+ }
2124
2156
  await Promise.allSettled(promises);
2125
2157
  }
2126
2158
  function destroyAutoSession() {
@@ -2128,32 +2160,32 @@ function destroyAutoSession() {
2128
2160
  clearInterval(inactivityTimer);
2129
2161
  inactivityTimer = null;
2130
2162
  }
2131
- activeSessions.clear();
2132
- endCallback = null;
2133
- clientGetter = null;
2134
- clientInfoGetter = null;
2163
+ scopes.clear();
2135
2164
  }
2136
2165
  function checkInactivity() {
2137
2166
  const now = Date.now();
2138
- const client3 = clientGetter?.();
2139
- if (!client3)
2140
- return;
2141
- const entries = [...activeSessions.entries()];
2142
- for (const [cardId, session] of entries) {
2143
- if (session.isExplicit)
2167
+ for (const scope of scopes.values()) {
2168
+ const client3 = scope.clientGetter?.();
2169
+ if (!client3)
2144
2170
  continue;
2145
- if (now - session.lastActivityAt > INACTIVITY_TIMEOUT_MS) {
2146
- autoEndSession(client3, cardId, "completed").catch(() => {});
2171
+ const entries = [...scope.sessions.entries()];
2172
+ for (const [cardId, session] of entries) {
2173
+ if (session.isExplicit)
2174
+ continue;
2175
+ if (now - session.lastActivityAt > INACTIVITY_TIMEOUT_MS) {
2176
+ autoEndSession(scope, client3, cardId, "completed").catch(() => {});
2177
+ }
2147
2178
  }
2148
2179
  }
2149
2180
  }
2150
- async function autoEndSession(client3, cardId, status) {
2151
- activeSessions.delete(cardId);
2181
+ async function autoEndSession(scope, client3, cardId, status) {
2182
+ if (!scope.sessions.delete(cardId))
2183
+ return;
2152
2184
  try {
2153
2185
  await client3.endAgentSession(cardId, { status });
2154
2186
  } catch {}
2155
2187
  try {
2156
- await endCallback?.(client3, cardId, status);
2188
+ await scope.endCallback?.(client3, cardId, status);
2157
2189
  } catch {}
2158
2190
  }
2159
2191
 
@@ -4557,9 +4589,12 @@ function registerHandlers(server, deps) {
4557
4589
  const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
4558
4590
  if (cardIdArg && UUID_RE.test(cardIdArg) && deps.isConfigured()) {
4559
4591
  const isAutoStartTrigger = AUTO_START_TRIGGERS.has(name);
4592
+ const cv = server.getClientVersion?.();
4560
4593
  trackActivity(cardIdArg, {
4561
4594
  autoStart: isAutoStartTrigger,
4562
- client: deps.getClient()
4595
+ client: deps.getClient(),
4596
+ clientInfo: cv ? { name: cv.name, version: cv.version } : undefined,
4597
+ scopeId: deps.getScopeId?.()
4563
4598
  }).catch(() => {});
4564
4599
  }
4565
4600
  try {
@@ -4569,9 +4604,12 @@ function registerHandlers(server, deps) {
4569
4604
  const parsed = typeof result === "object" && result !== null ? result : {};
4570
4605
  const resolvedCardId = parsed.cardId;
4571
4606
  if (resolvedCardId && UUID_RE.test(resolvedCardId)) {
4607
+ const cv = server.getClientVersion?.();
4572
4608
  trackActivity(resolvedCardId, {
4573
4609
  autoStart: true,
4574
- client: deps.getClient()
4610
+ client: deps.getClient(),
4611
+ clientInfo: cv ? { name: cv.name, version: cv.version } : undefined,
4612
+ scopeId: deps.getScopeId?.()
4575
4613
  }).catch(() => {});
4576
4614
  }
4577
4615
  } catch {}
@@ -4728,7 +4766,7 @@ async function handleToolCall(name, args, deps) {
4728
4766
  const { session } = await client3.getAgentSession(cardId);
4729
4767
  if (session) {
4730
4768
  await client3.endAgentSession(cardId, { status: "completed" });
4731
- untrack(cardId);
4769
+ untrack(cardId, deps.getScopeId?.());
4732
4770
  sessionEnded = true;
4733
4771
  }
4734
4772
  }
@@ -5086,7 +5124,11 @@ async function handleToolCall(name, args, deps) {
5086
5124
  currentTask: args.currentTask,
5087
5125
  estimatedMinutesRemaining: args.estimatedMinutesRemaining
5088
5126
  });
5089
- markExplicit(cardId, { agentIdentifier, agentName });
5127
+ markExplicit(cardId, {
5128
+ agentIdentifier,
5129
+ agentName,
5130
+ scopeId: deps.getScopeId?.()
5131
+ });
5090
5132
  const agentSessionId = result.session?.id;
5091
5133
  initMemorySession(cardId, agentIdentifier, agentName, agentSessionId);
5092
5134
  return {
@@ -5148,7 +5190,7 @@ async function handleToolCall(name, args, deps) {
5148
5190
  } catch (err) {
5149
5191
  sessionEndError = err instanceof Error ? err.message : "Failed to end session";
5150
5192
  }
5151
- untrack(cardId);
5193
+ untrack(cardId, deps.getScopeId?.());
5152
5194
  let movedTo = null;
5153
5195
  try {
5154
5196
  const { card } = await client3.getCard(cardId);
@@ -5325,23 +5367,21 @@ async function handleToolCall(name, args, deps) {
5325
5367
  let entities;
5326
5368
  let relevanceMap;
5327
5369
  if (queryText) {
5370
+ const requestedTags = args.tags;
5328
5371
  const searchResult = await client3.searchMemoryEntities(workspaceId, queryText, {
5329
5372
  project_id: projectId,
5330
5373
  type: args.type,
5331
- limit: fetchLimit
5374
+ limit: fetchLimit,
5375
+ tags: requestedTags && requestedTags.length > 0 ? requestedTags : undefined,
5376
+ include_superseded: includeSuperseded
5332
5377
  });
5333
5378
  entities = searchResult.entities ?? [];
5334
- const requestedTags = args.tags;
5335
5379
  const minConfidence = args.minConfidence;
5336
5380
  if (userScopeFilter) {
5337
5381
  entities = entities.filter((e) => e?.scope === userScopeFilter);
5338
5382
  } else if (excludeSessionFromLongTerm) {
5339
5383
  entities = entities.filter((e) => !isSessionScope(e?.scope));
5340
5384
  }
5341
- if (requestedTags && requestedTags.length > 0) {
5342
- const wanted = new Set(requestedTags);
5343
- entities = entities.filter((e) => (e?.tags ?? []).some((t) => wanted.has(t)));
5344
- }
5345
5385
  entities = filterByMinConfidence(entities, minConfidence);
5346
5386
  if (!includeSuperseded) {
5347
5387
  entities = entities.filter((e) => !e?.superseded_at);
@@ -1059,6 +1059,12 @@ class HarmonyApiClient {
1059
1059
  async getWorkspaceMembers(workspaceId) {
1060
1060
  return this.request("GET", `/workspaces/${workspaceId}/members`);
1061
1061
  }
1062
+ async listWorkspaceAgents(workspaceId) {
1063
+ return this.request("GET", `/workspaces/${workspaceId}/agents`);
1064
+ }
1065
+ async registerWorkspaceAgent(workspaceId, data) {
1066
+ return this.request("POST", `/workspaces/${workspaceId}/agents`, data);
1067
+ }
1062
1068
  async listProjects(workspaceId) {
1063
1069
  return this.request("GET", `/workspaces/${workspaceId}/projects`);
1064
1070
  }
@@ -1323,6 +1329,10 @@ class HarmonyApiClient {
1323
1329
  params.set("type", options.type);
1324
1330
  if (options?.limit !== undefined)
1325
1331
  params.set("limit", String(options.limit));
1332
+ for (const tag of options?.tags ?? [])
1333
+ params.append("tags", tag);
1334
+ if (options?.include_superseded)
1335
+ params.set("include_superseded", "true");
1326
1336
  return this.request("GET", `/memory/search?${params.toString()}`);
1327
1337
  }
1328
1338
  async getVaultIndex(options) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gethmy/mcp",
3
- "version": "2.8.5",
3
+ "version": "2.9.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"
package/src/api-client.ts CHANGED
@@ -2,6 +2,7 @@ import {
2
2
  type Comment,
3
3
  getDisplayLinkType,
4
4
  serializeCommentThread,
5
+ type WorkspaceAgent,
5
6
  } from "@harmony/shared";
6
7
  import { getApiKey, getApiUrl } from "./config.js";
7
8
 
@@ -396,6 +397,20 @@ export class HarmonyApiClient {
396
397
  return this.request("GET", `/workspaces/${workspaceId}/members`);
397
398
  }
398
399
 
400
+ async listWorkspaceAgents(
401
+ workspaceId: string,
402
+ ): Promise<{ agents: WorkspaceAgent[] }> {
403
+ return this.request("GET", `/workspaces/${workspaceId}/agents`);
404
+ }
405
+
406
+ /** Register/upsert this daemon's virtual agent. Idempotent by (workspace, identifier). */
407
+ async registerWorkspaceAgent(
408
+ workspaceId: string,
409
+ data: { identifier: string; name: string; color?: string },
410
+ ): Promise<{ agent: WorkspaceAgent }> {
411
+ return this.request("POST", `/workspaces/${workspaceId}/agents`, data);
412
+ }
413
+
399
414
  // ============ PROJECT OPERATIONS ============
400
415
 
401
416
  async listProjects(workspaceId: string): Promise<{ projects: unknown[] }> {
@@ -676,6 +691,7 @@ export class HarmonyApiClient {
676
691
  data: {
677
692
  agentIdentifier: string;
678
693
  agentName: string;
694
+ agentId?: string | null;
679
695
  status?: "working" | "blocked" | "paused" | "completed";
680
696
  progressPercent?: number;
681
697
  currentTask?: string;
@@ -716,6 +732,8 @@ export class HarmonyApiClient {
716
732
  | "review"
717
733
  | "daemon_restart"
718
734
  | "budget"
735
+ | "timeout"
736
+ | "stale"
719
737
  | "other";
720
738
  failureSummary?: string;
721
739
  recoveryBranch?: string;
@@ -742,6 +760,8 @@ export class HarmonyApiClient {
742
760
  | "review"
743
761
  | "daemon_restart"
744
762
  | "budget"
763
+ | "timeout"
764
+ | "stale"
745
765
  | "other";
746
766
  failureSummary?: string;
747
767
  recoveryBranch?: string;
@@ -1003,6 +1023,8 @@ export class HarmonyApiClient {
1003
1023
  project_id?: string;
1004
1024
  type?: string;
1005
1025
  limit?: number;
1026
+ tags?: string[];
1027
+ include_superseded?: boolean;
1006
1028
  },
1007
1029
  ): Promise<{ entities: unknown[]; count: number }> {
1008
1030
  const params = new URLSearchParams();
@@ -1012,6 +1034,10 @@ export class HarmonyApiClient {
1012
1034
  if (options?.type) params.set("type", options.type);
1013
1035
  if (options?.limit !== undefined)
1014
1036
  params.set("limit", String(options.limit));
1037
+ // Repeated `tags` params — the search endpoint reads them via getAll and
1038
+ // matches against the canonical `tags_normalized` column (#299).
1039
+ for (const tag of options?.tags ?? []) params.append("tags", tag);
1040
+ if (options?.include_superseded) params.set("include_superseded", "true");
1015
1041
  return this.request("GET", `/memory/search?${params.toString()}`);
1016
1042
  }
1017
1043
 
@@ -8,6 +8,19 @@
8
8
  * Agent identity is resolved from the MCP client's `initialize` handshake
9
9
  * (clientInfo.name), so "Claude Code", "Cursor", "Codex", etc. are
10
10
  * detected automatically — no hardcoded fallback needed.
11
+ *
12
+ * ## Multi-tenant scoping
13
+ *
14
+ * Bookkeeping is partitioned into per-scope state (`ScopeState`), keyed by a
15
+ * caller-supplied `scopeId`. The hosted/remote transport is multi-tenant — two
16
+ * different users acting on the *same card* must NOT collide on one shared
17
+ * entry. Remote scopes by `userId`; stdio (single user) uses `DEFAULT_SCOPE`.
18
+ *
19
+ * Each scope owns its own session map, end-of-session callback, API client
20
+ * getter, and client-identity getter, so per-connection `initAutoSession` calls
21
+ * no longer fight over a single global getter (the enabler for wiring the
22
+ * inactivity sweep + end pipeline on remote). A single process-wide timer
23
+ * sweeps every scope, ending idle sessions with that scope's own client.
11
24
  */
12
25
 
13
26
  import type { HarmonyApiClient } from "./api-client.js";
@@ -88,29 +101,80 @@ export const AUTO_START_TRIGGERS = new Set([
88
101
  export const INACTIVITY_TIMEOUT_MS = 10 * 60 * 1000; // 10 minutes
89
102
  const CHECK_INTERVAL_MS = 60 * 1000; // 60 seconds
90
103
 
91
- const activeSessions = new Map<string, TrackedSession>();
104
+ /**
105
+ * Scope used when no `scopeId` is supplied. stdio is single-user, so it shares
106
+ * one scope; the unit tests also exercise this default scope exclusively.
107
+ */
108
+ const DEFAULT_SCOPE = "__default__";
109
+
110
+ /**
111
+ * Per-tenant auto-session state. One per `scopeId` (e.g. one per remote user,
112
+ * or the single shared `DEFAULT_SCOPE` on stdio). Keying `sessions` by cardId
113
+ * *within* a scope means two users on the same card no longer collide.
114
+ */
115
+ interface ScopeState {
116
+ /** cardId → tracked session */
117
+ sessions: Map<string, TrackedSession>;
118
+ endCallback: EndSessionCallback | null;
119
+ clientGetter: (() => HarmonyApiClient) | null;
120
+ clientInfoGetter: (() => ClientInfo | null) | null;
121
+ }
122
+
123
+ /** scopeId → per-tenant state */
124
+ const scopes = new Map<string, ScopeState>();
125
+ /**
126
+ * Single process-wide inactivity sweep. Started once; on each tick it walks
127
+ * every scope and ends that scope's idle sessions with the scope's own client.
128
+ * Keeping it global (rather than one timer per scope) is what lets the remote
129
+ * transport register many per-connection scopes without spawning a timer each.
130
+ */
92
131
  let inactivityTimer: ReturnType<typeof setInterval> | null = null;
93
- let endCallback: EndSessionCallback | null = null;
94
- let clientGetter: (() => HarmonyApiClient) | null = null;
95
- let clientInfoGetter: (() => ClientInfo | null) | null = null;
132
+
133
+ function getOrCreateScope(scopeId: string): ScopeState {
134
+ let scope = scopes.get(scopeId);
135
+ if (!scope) {
136
+ scope = {
137
+ sessions: new Map(),
138
+ endCallback: null,
139
+ clientGetter: null,
140
+ clientInfoGetter: null,
141
+ };
142
+ scopes.set(scopeId, scope);
143
+ }
144
+ return scope;
145
+ }
96
146
 
97
147
  /**
98
- * Initialize auto-session tracking.
148
+ * Initialize auto-session tracking for a scope.
149
+ *
150
+ * Safe to call once per scope (stdio: once for `DEFAULT_SCOPE`; remote: once per
151
+ * connection, keyed by the connection's userId). Re-calling a scope just updates
152
+ * its callbacks/getters — the latest connection's client wins, which is fine
153
+ * because all of a user's connections share equivalent API access. The shared
154
+ * inactivity timer is started on first init and left running.
155
+ *
99
156
  * @param callback Called when an auto-session ends (runs the learning pipeline)
100
- * @param getClient Function to get the current API client
157
+ * @param getClient Function to get the current API client for this scope
101
158
  * @param getClientInfo Function to get MCP client identity from the initialize handshake
159
+ * @param scopeId Tenant scope (default: the shared single-user scope)
102
160
  */
103
161
  export function initAutoSession(
104
162
  callback: EndSessionCallback,
105
163
  getClient: () => HarmonyApiClient,
106
164
  getClientInfo?: () => ClientInfo | null,
165
+ scopeId: string = DEFAULT_SCOPE,
107
166
  ): void {
108
- endCallback = callback;
109
- clientGetter = getClient;
110
- clientInfoGetter = getClientInfo ?? null;
167
+ const scope = getOrCreateScope(scopeId);
168
+ scope.endCallback = callback;
169
+ scope.clientGetter = getClient;
170
+ scope.clientInfoGetter = getClientInfo ?? null;
111
171
 
112
- if (inactivityTimer) clearInterval(inactivityTimer);
113
- inactivityTimer = setInterval(checkInactivity, CHECK_INTERVAL_MS);
172
+ // Start the shared sweep once. Don't clear/restart on every (re-)init — on
173
+ // the remote transport, connections arrive faster than the 60s interval and a
174
+ // restart-each-time would keep deferring the sweep indefinitely (starvation).
175
+ if (!inactivityTimer) {
176
+ inactivityTimer = setInterval(checkInactivity, CHECK_INTERVAL_MS);
177
+ }
114
178
  }
115
179
 
116
180
  /**
@@ -121,10 +185,23 @@ export async function trackActivity(
121
185
  options?: {
122
186
  autoStart?: boolean;
123
187
  client?: HarmonyApiClient;
188
+ /**
189
+ * Per-request MCP client identity. Takes precedence over the scope's
190
+ * clientInfoGetter. The remote/HTTP transport resolves identity from the
191
+ * in-scope Server.getClientVersion() and passes it here, so auto-sessions
192
+ * attribute correctly on every transport (card #297).
193
+ */
194
+ clientInfo?: ClientInfo;
195
+ /**
196
+ * Tenant scope. Remote passes the connection's userId so two users on the
197
+ * same card don't collide; stdio/tests omit it and share `DEFAULT_SCOPE`.
198
+ */
199
+ scopeId?: string;
124
200
  },
125
201
  ): Promise<void> {
202
+ const scope = getOrCreateScope(options?.scopeId ?? DEFAULT_SCOPE);
126
203
  const now = Date.now();
127
- const existing = activeSessions.get(cardId);
204
+ const existing = scope.sessions.get(cardId);
128
205
 
129
206
  if (existing) {
130
207
  // Update last activity timestamp
@@ -135,7 +212,7 @@ export async function trackActivity(
135
212
  // Only auto-start if the tool is a trigger
136
213
  if (!options?.autoStart) return;
137
214
 
138
- const client = options?.client ?? clientGetter?.();
215
+ const client = options?.client ?? scope.clientGetter?.();
139
216
  if (!client) return;
140
217
 
141
218
  // Resolve agent identity from the MCP `initialize` handshake. Never auto-start
@@ -143,19 +220,22 @@ export async function trackActivity(
143
220
  // phantom "Unknown Agent" session (card #295). Identified clients only — this
144
221
  // bail happens BEFORE ending other sessions so an unidentified call can't tear
145
222
  // down a legitimate tracked session.
146
- const info = clientInfoGetter?.() ?? null;
223
+ const info = options?.clientInfo ?? scope.clientInfoGetter?.() ?? null;
147
224
  if (!info?.name) return;
148
225
  const { agentIdentifier, agentName } = resolveAgentIdentity(info);
149
226
 
150
- // Collect auto-sessions on other cards to end (avoid mutating map during iteration)
227
+ // Collect this scope's auto-sessions on other cards to end (a card switch
228
+ // within one tenant ends the prior auto-session). Only this scope's sessions
229
+ // are swept — another user's work on a different card is untouched. Snapshot
230
+ // first to avoid mutating the map during iteration.
151
231
  const toEnd: string[] = [];
152
- for (const [otherCardId, session] of activeSessions) {
232
+ for (const [otherCardId, session] of scope.sessions) {
153
233
  if (otherCardId !== cardId && !session.isExplicit) {
154
234
  toEnd.push(otherCardId);
155
235
  }
156
236
  }
157
237
  for (const otherCardId of toEnd) {
158
- await autoEndSession(client, otherCardId, "completed");
238
+ await autoEndSession(scope, client, otherCardId, "completed");
159
239
  }
160
240
 
161
241
  // Start a new auto-session
@@ -169,7 +249,7 @@ export async function trackActivity(
169
249
  // Session start failed (might already have one), still track locally
170
250
  }
171
251
 
172
- activeSessions.set(cardId, {
252
+ scope.sessions.set(cardId, {
173
253
  cardId,
174
254
  startedAt: now,
175
255
  lastActivityAt: now,
@@ -185,9 +265,10 @@ export async function trackActivity(
185
265
  */
186
266
  export function markExplicit(
187
267
  cardId: string,
188
- options?: { agentIdentifier?: string; agentName?: string },
268
+ options?: { agentIdentifier?: string; agentName?: string; scopeId?: string },
189
269
  ): void {
190
- const existing = activeSessions.get(cardId);
270
+ const scope = getOrCreateScope(options?.scopeId ?? DEFAULT_SCOPE);
271
+ const existing = scope.sessions.get(cardId);
191
272
  if (existing) {
192
273
  existing.isExplicit = true;
193
274
  if (options?.agentIdentifier)
@@ -195,7 +276,7 @@ export function markExplicit(
195
276
  if (options?.agentName) existing.agentName = options.agentName;
196
277
  } else {
197
278
  // Track the explicit session even if we didn't auto-start it
198
- activeSessions.set(cardId, {
279
+ scope.sessions.set(cardId, {
199
280
  cardId,
200
281
  startedAt: Date.now(),
201
282
  lastActivityAt: Date.now(),
@@ -209,61 +290,88 @@ export function markExplicit(
209
290
  /**
210
291
  * Remove a session from tracking (called when session is explicitly ended).
211
292
  */
212
- export function untrack(cardId: string): void {
213
- activeSessions.delete(cardId);
293
+ export function untrack(cardId: string, scopeId: string = DEFAULT_SCOPE): void {
294
+ scopes.get(scopeId)?.sessions.delete(cardId);
214
295
  }
215
296
 
216
297
  /**
217
- * End all active auto-sessions (called on process shutdown).
298
+ * End active auto-sessions (called on process shutdown, or on a single tenant's
299
+ * disconnect). With a `scopeId`, only that scope's sessions are ended; without
300
+ * one, every scope is drained.
218
301
  */
219
- export async function shutdownAllSessions(): Promise<void> {
220
- const client = clientGetter?.();
221
- if (!client) return;
302
+ export async function shutdownAllSessions(scopeId?: string): Promise<void> {
303
+ const targets =
304
+ scopeId !== undefined
305
+ ? scopes.has(scopeId)
306
+ ? [scopeId]
307
+ : []
308
+ : [...scopes.keys()];
222
309
 
223
- // Snapshot keys to avoid mutating map during iteration
224
- const cardIds = [...activeSessions.keys()];
225
- const promises = cardIds.map((cardId) =>
226
- autoEndSession(client, cardId, "paused"),
227
- );
310
+ const promises: Promise<void>[] = [];
311
+ for (const sid of targets) {
312
+ const scope = scopes.get(sid);
313
+ if (!scope) continue;
314
+ const client = scope.clientGetter?.();
315
+ if (!client) continue;
316
+ // Snapshot keys to avoid mutating map during iteration
317
+ for (const cardId of [...scope.sessions.keys()]) {
318
+ promises.push(autoEndSession(scope, client, cardId, "paused"));
319
+ }
320
+ }
228
321
  await Promise.allSettled(promises);
229
322
  }
230
323
 
231
324
  /**
232
- * Clean up the interval timer (for tests).
325
+ * Drop a tenant scope entirely (remote: called when a user's last connection
326
+ * closes). Bounds the `scopes` map on a long-lived multi-tenant server so it
327
+ * doesn't retain one stale per-connection client/deps closure per distinct user
328
+ * forever. The shared sweep timer then only walks live scopes. End the scope's
329
+ * sessions FIRST (via shutdownAllSessions(scopeId)) so nothing dangles. Never
330
+ * drops DEFAULT_SCOPE — stdio's single scope lives for the whole process.
331
+ */
332
+ export function dropScope(scopeId: string): void {
333
+ if (scopeId !== DEFAULT_SCOPE) scopes.delete(scopeId);
334
+ }
335
+
336
+ /**
337
+ * Clean up the interval timer and all scope state (for tests / full shutdown).
233
338
  */
234
339
  export function destroyAutoSession(): void {
235
340
  if (inactivityTimer) {
236
341
  clearInterval(inactivityTimer);
237
342
  inactivityTimer = null;
238
343
  }
239
- activeSessions.clear();
240
- endCallback = null;
241
- clientGetter = null;
242
- clientInfoGetter = null;
344
+ scopes.clear();
243
345
  }
244
346
 
245
347
  /**
246
- * Get a snapshot of active sessions (for testing/debugging).
348
+ * Get a snapshot of a scope's active sessions (for testing/debugging).
349
+ * Defaults to the shared single-user scope.
247
350
  */
248
- export function getActiveSessions(): Map<string, TrackedSession> {
249
- return activeSessions;
351
+ export function getActiveSessions(
352
+ scopeId: string = DEFAULT_SCOPE,
353
+ ): Map<string, TrackedSession> {
354
+ return getOrCreateScope(scopeId).sessions;
250
355
  }
251
356
 
252
357
  /**
253
358
  * Run inactivity check immediately (exported for testing).
254
- * In production, called by the setInterval timer every 60s.
359
+ * In production, called by the setInterval timer every 60s. Sweeps every scope
360
+ * so the remote transport's per-user sessions auto-end just like stdio's.
255
361
  */
256
362
  export function checkInactivity(): void {
257
363
  const now = Date.now();
258
- const client = clientGetter?.();
259
- if (!client) return;
364
+ for (const scope of scopes.values()) {
365
+ const client = scope.clientGetter?.();
366
+ if (!client) continue;
260
367
 
261
- // Snapshot keys to avoid mutating map during iteration
262
- const entries = [...activeSessions.entries()];
263
- for (const [cardId, session] of entries) {
264
- if (session.isExplicit) continue;
265
- if (now - session.lastActivityAt > INACTIVITY_TIMEOUT_MS) {
266
- autoEndSession(client, cardId, "completed").catch(() => {});
368
+ // Snapshot entries to avoid mutating map during iteration
369
+ const entries = [...scope.sessions.entries()];
370
+ for (const [cardId, session] of entries) {
371
+ if (session.isExplicit) continue;
372
+ if (now - session.lastActivityAt > INACTIVITY_TIMEOUT_MS) {
373
+ autoEndSession(scope, client, cardId, "completed").catch(() => {});
374
+ }
267
375
  }
268
376
  }
269
377
  }
@@ -271,18 +379,23 @@ export function checkInactivity(): void {
271
379
  // --- Internal ---
272
380
 
273
381
  async function autoEndSession(
382
+ scope: ScopeState,
274
383
  client: HarmonyApiClient,
275
384
  cardId: string,
276
385
  status: "completed" | "paused",
277
386
  ): Promise<void> {
278
- activeSessions.delete(cardId);
387
+ // Delete-and-claim: Map.delete returns false if the entry is already gone.
388
+ // The card-switch sweep and the inactivity timer can both target the same
389
+ // cardId concurrently; whichever claims it first runs the end + pipeline,
390
+ // the loser bails so endAgentSession / runEndSessionPipeline fire exactly once.
391
+ if (!scope.sessions.delete(cardId)) return;
279
392
  try {
280
393
  await client.endAgentSession(cardId, { status });
281
394
  } catch {
282
395
  // Best-effort end
283
396
  }
284
397
  try {
285
- await endCallback?.(client, cardId, status);
398
+ await scope.endCallback?.(client, cardId, status);
286
399
  } catch {
287
400
  // Best-effort pipeline
288
401
  }
@@ -264,13 +264,11 @@ export async function findSupersedeCandidates(
264
264
  if (options?.scope && (e as { scope?: string }).scope !== undefined) {
265
265
  if ((e as { scope?: string }).scope !== options.scope) return false;
266
266
  }
267
- // Skip already-superseded rows when the field is present. NOTE: the
268
- // hybrid-search RPC does not return `superseded_at`, so this only fires
269
- // on the FTS-fallback path; on the embedding path an already-retired row
270
- // can still surface as a *candidate*. That is non-destructive the
271
- // `similar` list is advisory and the caller decides explicitly whether
272
- // to supersede. A complete fix needs the RPC to return/filter the column
273
- // (migration + deploy); tracked in docs/memory.md.
267
+ // Skip already-superseded rows. The hybrid-search RPC now both returns
268
+ // `superseded_at` and excludes tombstoned rows by default (#298), so
269
+ // retired rows no longer surface as candidates on the embedding path.
270
+ // Kept as belt-and-suspenders for the FTS fallback and any caller that
271
+ // opts into include_superseded.
274
272
  if ((e as { superseded_at?: string | null }).superseded_at) {
275
273
  return false;
276
274
  }
package/src/remote.ts CHANGED
@@ -21,7 +21,16 @@ import { serve } from "bun";
21
21
  import { Hono } from "hono";
22
22
  import { cors } from "hono/cors";
23
23
  import { HarmonyApiClient } from "./api-client.js";
24
- import { registerHandlers, type ToolDeps } from "./server.js";
24
+ import {
25
+ dropScope,
26
+ initAutoSession,
27
+ shutdownAllSessions,
28
+ } from "./auto-session.js";
29
+ import {
30
+ registerHandlers,
31
+ runEndSessionPipeline,
32
+ type ToolDeps,
33
+ } from "./server.js";
25
34
 
26
35
  // ---------------------------------------------------------------------------
27
36
  // Config from env
@@ -258,16 +267,52 @@ function createSession(apiKey: string, keyInfo: TokenInfo): McpSession {
258
267
  getUserEmail: () => null,
259
268
  saveConfig: () => {}, // No-op in remote mode
260
269
  resetClient: () => {}, // No-op in remote mode
270
+ // Partition auto-session bookkeeping by user. The hosted server is
271
+ // multi-tenant: keying by userId stops two users on the same card from
272
+ // colliding on one shared auto-session entry (card #301, Gap 2).
273
+ getScopeId: () => keyInfo.userId,
261
274
  };
262
275
 
263
276
  registerHandlers(server, deps);
264
277
 
278
+ // Wire the inactivity sweep + end-of-session pipeline for this user's scope.
279
+ // The remote transport historically skipped initAutoSession entirely, so
280
+ // hosted auto-sessions never auto-ended on inactivity and the end pipeline
281
+ // never fired — they dangled "working" indefinitely (card #301, Gap 1).
282
+ // Now each connection registers its userId scope; the shared process timer
283
+ // sweeps it like stdio. Identity is resolved per-request via options.clientInfo
284
+ // in the pre-hook (card #297), so no clientInfoGetter is supplied here. Re-init
285
+ // by a second connection of the same user just refreshes the scope's client —
286
+ // both are equivalent, and per-scope state means it can't clobber other users.
287
+ initAutoSession(
288
+ async (endClient, cardId, status) => {
289
+ await runEndSessionPipeline(endClient, deps, cardId, status);
290
+ },
291
+ () => client,
292
+ undefined,
293
+ keyInfo.userId,
294
+ );
295
+
265
296
  // Single cleanup path: fires on explicit DELETE, our evictSession,
266
297
  // and the stale-session GC. Keeping onsessioninitialized + this onclose
267
298
  // (instead of also wiring onsessionclosed) avoids double-logging on DELETE.
268
299
  transport.onclose = () => {
269
300
  if (transport.sessionId) {
270
301
  sessions.delete(transport.sessionId);
302
+ // Reap this user's auto-session scope once their LAST live connection is
303
+ // gone — otherwise `scopes` grows unbounded (one stale client/deps closure
304
+ // per distinct user) on a long-lived multi-tenant process. A user with
305
+ // another open connection keeps the scope (sessions.delete already ran, so
306
+ // the closing session isn't counted). Pause any in-flight auto-sessions so
307
+ // nothing dangles "working"; a reconnect + next tool call starts fresh.
308
+ const stillLive = [...sessions.values()].some(
309
+ (s) => s.userId === session.userId,
310
+ );
311
+ if (!stillLive) {
312
+ shutdownAllSessions(session.userId)
313
+ .catch(() => {})
314
+ .finally(() => dropScope(session.userId));
315
+ }
271
316
  console.log(`[mcp] session=${transport.sessionId} closed`);
272
317
  }
273
318
  };
package/src/server.ts CHANGED
@@ -81,6 +81,12 @@ export interface ToolDeps {
81
81
  getUserEmail: () => string | null;
82
82
  saveConfig: (config: { apiKey: string }) => void;
83
83
  resetClient: () => void;
84
+ /**
85
+ * Tenant scope for auto-session bookkeeping. The remote/HTTP transport returns
86
+ * the connection's userId so two users on the same card don't collide on one
87
+ * shared auto-session entry. Omitted on stdio (single user → default scope).
88
+ */
89
+ getScopeId?: () => string;
84
90
  }
85
91
 
86
92
  // --- Memory Session Tracking ---
@@ -1852,9 +1858,18 @@ export function registerHandlers(server: Server, deps: ToolDeps): void {
1852
1858
  /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
1853
1859
  if (cardIdArg && UUID_RE.test(cardIdArg) && deps.isConfigured()) {
1854
1860
  const isAutoStartTrigger = AUTO_START_TRIGGERS.has(name);
1861
+ // Resolve MCP client identity per request from the in-scope Server. The
1862
+ // remote transport never calls initAutoSession (global getter is null),
1863
+ // so passing it here is what makes auto-sessions attribute correctly on
1864
+ // the hosted/OAuth path, not just stdio (card #297).
1865
+ // Optional-chained: the pre-hook is best-effort and must never throw into
1866
+ // tool dispatch if a transport/wrapper doesn't expose getClientVersion.
1867
+ const cv = server.getClientVersion?.();
1855
1868
  trackActivity(cardIdArg, {
1856
1869
  autoStart: isAutoStartTrigger,
1857
1870
  client: deps.getClient(),
1871
+ clientInfo: cv ? { name: cv.name, version: cv.version } : undefined,
1872
+ scopeId: deps.getScopeId?.(),
1858
1873
  }).catch(() => {}); // fire-and-forget
1859
1874
  }
1860
1875
 
@@ -1874,9 +1889,16 @@ export function registerHandlers(server: Server, deps: ToolDeps): void {
1874
1889
  | string
1875
1890
  | undefined;
1876
1891
  if (resolvedCardId && UUID_RE.test(resolvedCardId)) {
1892
+ // Optional-chained: the pre-hook is best-effort and must never throw into
1893
+ // tool dispatch if a transport/wrapper doesn't expose getClientVersion.
1894
+ const cv = server.getClientVersion?.();
1877
1895
  trackActivity(resolvedCardId, {
1878
1896
  autoStart: true,
1879
1897
  client: deps.getClient(),
1898
+ clientInfo: cv
1899
+ ? { name: cv.name, version: cv.version }
1900
+ : undefined,
1901
+ scopeId: deps.getScopeId?.(),
1880
1902
  }).catch(() => {});
1881
1903
  }
1882
1904
  } catch {
@@ -2113,7 +2135,7 @@ async function handleToolCall(
2113
2135
  const { session } = await client.getAgentSession(cardId);
2114
2136
  if (session) {
2115
2137
  await client.endAgentSession(cardId, { status: "completed" });
2116
- untrack(cardId);
2138
+ untrack(cardId, deps.getScopeId?.());
2117
2139
  sessionEnded = true;
2118
2140
  }
2119
2141
  }
@@ -2618,7 +2640,11 @@ async function handleToolCall(
2618
2640
  });
2619
2641
 
2620
2642
  // Mark as explicit so auto-session won't interfere
2621
- markExplicit(cardId, { agentIdentifier, agentName });
2643
+ markExplicit(cardId, {
2644
+ agentIdentifier,
2645
+ agentName,
2646
+ scopeId: deps.getScopeId?.(),
2647
+ });
2622
2648
 
2623
2649
  // Initialize memory session tracking for action visibility. Capture the
2624
2650
  // backend session id so working-memory writes (`scope: 'session'`) bind
@@ -2721,7 +2747,7 @@ async function handleToolCall(
2721
2747
  }
2722
2748
 
2723
2749
  // Remove from auto-session tracking regardless
2724
- untrack(cardId);
2750
+ untrack(cardId, deps.getScopeId?.());
2725
2751
 
2726
2752
  let movedTo: string | null = null;
2727
2753
 
@@ -3080,6 +3106,12 @@ async function handleToolCall(
3080
3106
  let relevanceMap: Map<string, number>;
3081
3107
 
3082
3108
  if (queryText) {
3109
+ const requestedTags = args.tags as string[] | undefined;
3110
+ // Tag + superseded filtering is now authoritative in the hybrid_search
3111
+ // RPC (#298/#299): tags match the canonical `tags_normalized` column at
3112
+ // the DB level (no client-side fetch-then-filter completeness gap), and
3113
+ // tombstoned rows are excluded unless include_superseded is set. Tags
3114
+ // are normalized server-side; we pass them through verbatim.
3083
3115
  const searchResult = await client.searchMemoryEntities(
3084
3116
  workspaceId,
3085
3117
  queryText,
@@ -3087,14 +3119,18 @@ async function handleToolCall(
3087
3119
  project_id: projectId,
3088
3120
  type: args.type as string | undefined,
3089
3121
  limit: fetchLimit,
3122
+ tags:
3123
+ requestedTags && requestedTags.length > 0
3124
+ ? requestedTags
3125
+ : undefined,
3126
+ include_superseded: includeSuperseded,
3090
3127
  },
3091
3128
  );
3092
3129
  entities = (searchResult.entities ?? []) as any[];
3093
3130
 
3094
- // Post-filter the rest of the params client-side. Hybrid search RPC
3095
- // exposes only project_id + type; tags / scope / minConfidence /
3096
- // include_superseded are applied here on the rank-ordered set.
3097
- const requestedTags = args.tags as string[] | undefined;
3131
+ // Post-filter the params the RPC does not handle. Scope + minConfidence
3132
+ // are applied here on the rank-ordered set. (Tags + include_superseded
3133
+ // are handled server-side above.)
3098
3134
  const minConfidence = args.minConfidence as number | undefined;
3099
3135
  if (userScopeFilter) {
3100
3136
  entities = entities.filter((e) => e?.scope === userScopeFilter);
@@ -3103,13 +3139,9 @@ async function handleToolCall(
3103
3139
  // out of the long-term mix so the same row never shows twice.
3104
3140
  entities = entities.filter((e) => !isSessionScope(e?.scope));
3105
3141
  }
3106
- if (requestedTags && requestedTags.length > 0) {
3107
- const wanted = new Set(requestedTags);
3108
- entities = entities.filter((e) =>
3109
- (e?.tags ?? []).some((t: string) => wanted.has(t)),
3110
- );
3111
- }
3112
3142
  entities = filterByMinConfidence(entities, minConfidence);
3143
+ // Belt-and-suspenders: the RPC already excludes tombstoned rows; this
3144
+ // also drops any if a stale/FTS path slipped one through.
3113
3145
  if (!includeSuperseded) {
3114
3146
  entities = entities.filter((e) => !e?.superseded_at);
3115
3147
  }