@gethmy/mcp 2.8.6 → 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
  }
@@ -2047,43 +2053,56 @@ var AUTO_START_TRIGGERS = new Set([
2047
2053
  ]);
2048
2054
  var INACTIVITY_TIMEOUT_MS = 10 * 60 * 1000;
2049
2055
  var CHECK_INTERVAL_MS = 60 * 1000;
2050
- var activeSessions = new Map;
2056
+ var DEFAULT_SCOPE = "__default__";
2057
+ var scopes = new Map;
2051
2058
  var inactivityTimer = null;
2052
- var endCallback = null;
2053
- var clientGetter = null;
2054
- var clientInfoGetter = null;
2055
- function initAutoSession(callback, getClient2, getClientInfo) {
2056
- endCallback = callback;
2057
- clientGetter = getClient2;
2058
- clientInfoGetter = getClientInfo ?? null;
2059
- if (inactivityTimer)
2060
- clearInterval(inactivityTimer);
2061
- 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
+ }
2062
2080
  }
2063
2081
  async function trackActivity(cardId, options) {
2082
+ const scope = getOrCreateScope(options?.scopeId ?? DEFAULT_SCOPE);
2064
2083
  const now = Date.now();
2065
- const existing = activeSessions.get(cardId);
2084
+ const existing = scope.sessions.get(cardId);
2066
2085
  if (existing) {
2067
2086
  existing.lastActivityAt = now;
2068
2087
  return;
2069
2088
  }
2070
2089
  if (!options?.autoStart)
2071
2090
  return;
2072
- const client3 = options?.client ?? clientGetter?.();
2091
+ const client3 = options?.client ?? scope.clientGetter?.();
2073
2092
  if (!client3)
2074
2093
  return;
2075
- const info = clientInfoGetter?.() ?? null;
2094
+ const info = options?.clientInfo ?? scope.clientInfoGetter?.() ?? null;
2076
2095
  if (!info?.name)
2077
2096
  return;
2078
2097
  const { agentIdentifier, agentName } = resolveAgentIdentity(info);
2079
2098
  const toEnd = [];
2080
- for (const [otherCardId, session] of activeSessions) {
2099
+ for (const [otherCardId, session] of scope.sessions) {
2081
2100
  if (otherCardId !== cardId && !session.isExplicit) {
2082
2101
  toEnd.push(otherCardId);
2083
2102
  }
2084
2103
  }
2085
2104
  for (const otherCardId of toEnd) {
2086
- await autoEndSession(client3, otherCardId, "completed");
2105
+ await autoEndSession(scope, client3, otherCardId, "completed");
2087
2106
  }
2088
2107
  try {
2089
2108
  await client3.startAgentSession(cardId, {
@@ -2092,7 +2111,7 @@ async function trackActivity(cardId, options) {
2092
2111
  status: "working"
2093
2112
  });
2094
2113
  } catch {}
2095
- activeSessions.set(cardId, {
2114
+ scope.sessions.set(cardId, {
2096
2115
  cardId,
2097
2116
  startedAt: now,
2098
2117
  lastActivityAt: now,
@@ -2102,7 +2121,8 @@ async function trackActivity(cardId, options) {
2102
2121
  });
2103
2122
  }
2104
2123
  function markExplicit(cardId, options) {
2105
- const existing = activeSessions.get(cardId);
2124
+ const scope = getOrCreateScope(options?.scopeId ?? DEFAULT_SCOPE);
2125
+ const existing = scope.sessions.get(cardId);
2106
2126
  if (existing) {
2107
2127
  existing.isExplicit = true;
2108
2128
  if (options?.agentIdentifier)
@@ -2110,7 +2130,7 @@ function markExplicit(cardId, options) {
2110
2130
  if (options?.agentName)
2111
2131
  existing.agentName = options.agentName;
2112
2132
  } else {
2113
- activeSessions.set(cardId, {
2133
+ scope.sessions.set(cardId, {
2114
2134
  cardId,
2115
2135
  startedAt: Date.now(),
2116
2136
  lastActivityAt: Date.now(),
@@ -2120,15 +2140,23 @@ function markExplicit(cardId, options) {
2120
2140
  });
2121
2141
  }
2122
2142
  }
2123
- function untrack(cardId) {
2124
- activeSessions.delete(cardId);
2143
+ function untrack(cardId, scopeId = DEFAULT_SCOPE) {
2144
+ scopes.get(scopeId)?.sessions.delete(cardId);
2125
2145
  }
2126
- async function shutdownAllSessions() {
2127
- const client3 = clientGetter?.();
2128
- if (!client3)
2129
- return;
2130
- const cardIds = [...activeSessions.keys()];
2131
- 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
+ }
2132
2160
  await Promise.allSettled(promises);
2133
2161
  }
2134
2162
  function destroyAutoSession() {
@@ -2136,32 +2164,32 @@ function destroyAutoSession() {
2136
2164
  clearInterval(inactivityTimer);
2137
2165
  inactivityTimer = null;
2138
2166
  }
2139
- activeSessions.clear();
2140
- endCallback = null;
2141
- clientGetter = null;
2142
- clientInfoGetter = null;
2167
+ scopes.clear();
2143
2168
  }
2144
2169
  function checkInactivity() {
2145
2170
  const now = Date.now();
2146
- const client3 = clientGetter?.();
2147
- if (!client3)
2148
- return;
2149
- const entries = [...activeSessions.entries()];
2150
- for (const [cardId, session] of entries) {
2151
- if (session.isExplicit)
2171
+ for (const scope of scopes.values()) {
2172
+ const client3 = scope.clientGetter?.();
2173
+ if (!client3)
2152
2174
  continue;
2153
- if (now - session.lastActivityAt > INACTIVITY_TIMEOUT_MS) {
2154
- 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
+ }
2155
2182
  }
2156
2183
  }
2157
2184
  }
2158
- async function autoEndSession(client3, cardId, status) {
2159
- activeSessions.delete(cardId);
2185
+ async function autoEndSession(scope, client3, cardId, status) {
2186
+ if (!scope.sessions.delete(cardId))
2187
+ return;
2160
2188
  try {
2161
2189
  await client3.endAgentSession(cardId, { status });
2162
2190
  } catch {}
2163
2191
  try {
2164
- await endCallback?.(client3, cardId, status);
2192
+ await scope.endCallback?.(client3, cardId, status);
2165
2193
  } catch {}
2166
2194
  }
2167
2195
 
@@ -4565,9 +4593,12 @@ function registerHandlers(server, deps) {
4565
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;
4566
4594
  if (cardIdArg && UUID_RE.test(cardIdArg) && deps.isConfigured()) {
4567
4595
  const isAutoStartTrigger = AUTO_START_TRIGGERS.has(name);
4596
+ const cv = server.getClientVersion?.();
4568
4597
  trackActivity(cardIdArg, {
4569
4598
  autoStart: isAutoStartTrigger,
4570
- client: deps.getClient()
4599
+ client: deps.getClient(),
4600
+ clientInfo: cv ? { name: cv.name, version: cv.version } : undefined,
4601
+ scopeId: deps.getScopeId?.()
4571
4602
  }).catch(() => {});
4572
4603
  }
4573
4604
  try {
@@ -4577,9 +4608,12 @@ function registerHandlers(server, deps) {
4577
4608
  const parsed = typeof result === "object" && result !== null ? result : {};
4578
4609
  const resolvedCardId = parsed.cardId;
4579
4610
  if (resolvedCardId && UUID_RE.test(resolvedCardId)) {
4611
+ const cv = server.getClientVersion?.();
4580
4612
  trackActivity(resolvedCardId, {
4581
4613
  autoStart: true,
4582
- client: deps.getClient()
4614
+ client: deps.getClient(),
4615
+ clientInfo: cv ? { name: cv.name, version: cv.version } : undefined,
4616
+ scopeId: deps.getScopeId?.()
4583
4617
  }).catch(() => {});
4584
4618
  }
4585
4619
  } catch {}
@@ -4736,7 +4770,7 @@ async function handleToolCall(name, args, deps) {
4736
4770
  const { session } = await client3.getAgentSession(cardId);
4737
4771
  if (session) {
4738
4772
  await client3.endAgentSession(cardId, { status: "completed" });
4739
- untrack(cardId);
4773
+ untrack(cardId, deps.getScopeId?.());
4740
4774
  sessionEnded = true;
4741
4775
  }
4742
4776
  }
@@ -5094,7 +5128,11 @@ async function handleToolCall(name, args, deps) {
5094
5128
  currentTask: args.currentTask,
5095
5129
  estimatedMinutesRemaining: args.estimatedMinutesRemaining
5096
5130
  });
5097
- markExplicit(cardId, { agentIdentifier, agentName });
5131
+ markExplicit(cardId, {
5132
+ agentIdentifier,
5133
+ agentName,
5134
+ scopeId: deps.getScopeId?.()
5135
+ });
5098
5136
  const agentSessionId = result.session?.id;
5099
5137
  initMemorySession(cardId, agentIdentifier, agentName, agentSessionId);
5100
5138
  return {
@@ -5156,7 +5194,7 @@ async function handleToolCall(name, args, deps) {
5156
5194
  } catch (err) {
5157
5195
  sessionEndError = err instanceof Error ? err.message : "Failed to end session";
5158
5196
  }
5159
- untrack(cardId);
5197
+ untrack(cardId, deps.getScopeId?.());
5160
5198
  let movedTo = null;
5161
5199
  try {
5162
5200
  const { card } = await client3.getCard(cardId);
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
  }
@@ -2043,43 +2049,56 @@ var AUTO_START_TRIGGERS = new Set([
2043
2049
  ]);
2044
2050
  var INACTIVITY_TIMEOUT_MS = 10 * 60 * 1000;
2045
2051
  var CHECK_INTERVAL_MS = 60 * 1000;
2046
- var activeSessions = new Map;
2052
+ var DEFAULT_SCOPE = "__default__";
2053
+ var scopes = new Map;
2047
2054
  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);
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
+ }
2058
2076
  }
2059
2077
  async function trackActivity(cardId, options) {
2078
+ const scope = getOrCreateScope(options?.scopeId ?? DEFAULT_SCOPE);
2060
2079
  const now = Date.now();
2061
- const existing = activeSessions.get(cardId);
2080
+ const existing = scope.sessions.get(cardId);
2062
2081
  if (existing) {
2063
2082
  existing.lastActivityAt = now;
2064
2083
  return;
2065
2084
  }
2066
2085
  if (!options?.autoStart)
2067
2086
  return;
2068
- const client3 = options?.client ?? clientGetter?.();
2087
+ const client3 = options?.client ?? scope.clientGetter?.();
2069
2088
  if (!client3)
2070
2089
  return;
2071
- const info = clientInfoGetter?.() ?? null;
2090
+ const info = options?.clientInfo ?? scope.clientInfoGetter?.() ?? null;
2072
2091
  if (!info?.name)
2073
2092
  return;
2074
2093
  const { agentIdentifier, agentName } = resolveAgentIdentity(info);
2075
2094
  const toEnd = [];
2076
- for (const [otherCardId, session] of activeSessions) {
2095
+ for (const [otherCardId, session] of scope.sessions) {
2077
2096
  if (otherCardId !== cardId && !session.isExplicit) {
2078
2097
  toEnd.push(otherCardId);
2079
2098
  }
2080
2099
  }
2081
2100
  for (const otherCardId of toEnd) {
2082
- await autoEndSession(client3, otherCardId, "completed");
2101
+ await autoEndSession(scope, client3, otherCardId, "completed");
2083
2102
  }
2084
2103
  try {
2085
2104
  await client3.startAgentSession(cardId, {
@@ -2088,7 +2107,7 @@ async function trackActivity(cardId, options) {
2088
2107
  status: "working"
2089
2108
  });
2090
2109
  } catch {}
2091
- activeSessions.set(cardId, {
2110
+ scope.sessions.set(cardId, {
2092
2111
  cardId,
2093
2112
  startedAt: now,
2094
2113
  lastActivityAt: now,
@@ -2098,7 +2117,8 @@ async function trackActivity(cardId, options) {
2098
2117
  });
2099
2118
  }
2100
2119
  function markExplicit(cardId, options) {
2101
- const existing = activeSessions.get(cardId);
2120
+ const scope = getOrCreateScope(options?.scopeId ?? DEFAULT_SCOPE);
2121
+ const existing = scope.sessions.get(cardId);
2102
2122
  if (existing) {
2103
2123
  existing.isExplicit = true;
2104
2124
  if (options?.agentIdentifier)
@@ -2106,7 +2126,7 @@ function markExplicit(cardId, options) {
2106
2126
  if (options?.agentName)
2107
2127
  existing.agentName = options.agentName;
2108
2128
  } else {
2109
- activeSessions.set(cardId, {
2129
+ scope.sessions.set(cardId, {
2110
2130
  cardId,
2111
2131
  startedAt: Date.now(),
2112
2132
  lastActivityAt: Date.now(),
@@ -2116,15 +2136,23 @@ function markExplicit(cardId, options) {
2116
2136
  });
2117
2137
  }
2118
2138
  }
2119
- function untrack(cardId) {
2120
- activeSessions.delete(cardId);
2139
+ function untrack(cardId, scopeId = DEFAULT_SCOPE) {
2140
+ scopes.get(scopeId)?.sessions.delete(cardId);
2121
2141
  }
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"));
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
+ }
2128
2156
  await Promise.allSettled(promises);
2129
2157
  }
2130
2158
  function destroyAutoSession() {
@@ -2132,32 +2160,32 @@ function destroyAutoSession() {
2132
2160
  clearInterval(inactivityTimer);
2133
2161
  inactivityTimer = null;
2134
2162
  }
2135
- activeSessions.clear();
2136
- endCallback = null;
2137
- clientGetter = null;
2138
- clientInfoGetter = null;
2163
+ scopes.clear();
2139
2164
  }
2140
2165
  function checkInactivity() {
2141
2166
  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)
2167
+ for (const scope of scopes.values()) {
2168
+ const client3 = scope.clientGetter?.();
2169
+ if (!client3)
2148
2170
  continue;
2149
- if (now - session.lastActivityAt > INACTIVITY_TIMEOUT_MS) {
2150
- 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
+ }
2151
2178
  }
2152
2179
  }
2153
2180
  }
2154
- async function autoEndSession(client3, cardId, status) {
2155
- activeSessions.delete(cardId);
2181
+ async function autoEndSession(scope, client3, cardId, status) {
2182
+ if (!scope.sessions.delete(cardId))
2183
+ return;
2156
2184
  try {
2157
2185
  await client3.endAgentSession(cardId, { status });
2158
2186
  } catch {}
2159
2187
  try {
2160
- await endCallback?.(client3, cardId, status);
2188
+ await scope.endCallback?.(client3, cardId, status);
2161
2189
  } catch {}
2162
2190
  }
2163
2191
 
@@ -4561,9 +4589,12 @@ function registerHandlers(server, deps) {
4561
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;
4562
4590
  if (cardIdArg && UUID_RE.test(cardIdArg) && deps.isConfigured()) {
4563
4591
  const isAutoStartTrigger = AUTO_START_TRIGGERS.has(name);
4592
+ const cv = server.getClientVersion?.();
4564
4593
  trackActivity(cardIdArg, {
4565
4594
  autoStart: isAutoStartTrigger,
4566
- client: deps.getClient()
4595
+ client: deps.getClient(),
4596
+ clientInfo: cv ? { name: cv.name, version: cv.version } : undefined,
4597
+ scopeId: deps.getScopeId?.()
4567
4598
  }).catch(() => {});
4568
4599
  }
4569
4600
  try {
@@ -4573,9 +4604,12 @@ function registerHandlers(server, deps) {
4573
4604
  const parsed = typeof result === "object" && result !== null ? result : {};
4574
4605
  const resolvedCardId = parsed.cardId;
4575
4606
  if (resolvedCardId && UUID_RE.test(resolvedCardId)) {
4607
+ const cv = server.getClientVersion?.();
4576
4608
  trackActivity(resolvedCardId, {
4577
4609
  autoStart: true,
4578
- client: deps.getClient()
4610
+ client: deps.getClient(),
4611
+ clientInfo: cv ? { name: cv.name, version: cv.version } : undefined,
4612
+ scopeId: deps.getScopeId?.()
4579
4613
  }).catch(() => {});
4580
4614
  }
4581
4615
  } catch {}
@@ -4732,7 +4766,7 @@ async function handleToolCall(name, args, deps) {
4732
4766
  const { session } = await client3.getAgentSession(cardId);
4733
4767
  if (session) {
4734
4768
  await client3.endAgentSession(cardId, { status: "completed" });
4735
- untrack(cardId);
4769
+ untrack(cardId, deps.getScopeId?.());
4736
4770
  sessionEnded = true;
4737
4771
  }
4738
4772
  }
@@ -5090,7 +5124,11 @@ async function handleToolCall(name, args, deps) {
5090
5124
  currentTask: args.currentTask,
5091
5125
  estimatedMinutesRemaining: args.estimatedMinutesRemaining
5092
5126
  });
5093
- markExplicit(cardId, { agentIdentifier, agentName });
5127
+ markExplicit(cardId, {
5128
+ agentIdentifier,
5129
+ agentName,
5130
+ scopeId: deps.getScopeId?.()
5131
+ });
5094
5132
  const agentSessionId = result.session?.id;
5095
5133
  initMemorySession(cardId, agentIdentifier, agentName, agentSessionId);
5096
5134
  return {
@@ -5152,7 +5190,7 @@ async function handleToolCall(name, args, deps) {
5152
5190
  } catch (err) {
5153
5191
  sessionEndError = err instanceof Error ? err.message : "Failed to end session";
5154
5192
  }
5155
- untrack(cardId);
5193
+ untrack(cardId, deps.getScopeId?.());
5156
5194
  let movedTo = null;
5157
5195
  try {
5158
5196
  const { card } = await client3.getCard(cardId);
@@ -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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gethmy/mcp",
3
- "version": "2.8.6",
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;
@@ -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
  }
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