@gethmy/mcp 2.8.6 → 2.9.1
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 +89 -47
- package/dist/index.js +85 -47
- package/dist/lib/api-client.js +6 -0
- package/package.json +1 -1
- package/src/api-client.ts +20 -0
- package/src/auto-session.ts +164 -51
- package/src/remote.ts +46 -1
- package/src/server.ts +29 -3
- package/src/tui/setup.ts +9 -1
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
|
|
2056
|
+
var DEFAULT_SCOPE = "__default__";
|
|
2057
|
+
var scopes = new Map;
|
|
2051
2058
|
var inactivityTimer = null;
|
|
2052
|
-
|
|
2053
|
-
|
|
2054
|
-
|
|
2055
|
-
|
|
2056
|
-
|
|
2057
|
-
|
|
2058
|
-
|
|
2059
|
-
|
|
2060
|
-
|
|
2061
|
-
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
2143
|
+
function untrack(cardId, scopeId = DEFAULT_SCOPE) {
|
|
2144
|
+
scopes.get(scopeId)?.sessions.delete(cardId);
|
|
2125
2145
|
}
|
|
2126
|
-
async function shutdownAllSessions() {
|
|
2127
|
-
const
|
|
2128
|
-
|
|
2129
|
-
|
|
2130
|
-
|
|
2131
|
-
|
|
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
|
-
|
|
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
|
|
2147
|
-
|
|
2148
|
-
|
|
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
|
-
|
|
2154
|
-
|
|
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
|
-
|
|
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, {
|
|
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);
|
|
@@ -7777,6 +7815,10 @@ async function runSetup(options = {}) {
|
|
|
7777
7815
|
localConfig.projectId = selectedProjectId;
|
|
7778
7816
|
saveLocalConfig(localConfig, cwd);
|
|
7779
7817
|
console.log(` ${colors.success("✓")} ${colors.dim(formatPath(getLocalConfigPath(cwd), home))} ${colors.dim("(created)")}`);
|
|
7818
|
+
if (selectedWorkspaceId)
|
|
7819
|
+
setActiveWorkspace(selectedWorkspaceId);
|
|
7820
|
+
if (selectedProjectId)
|
|
7821
|
+
setActiveProject(selectedProjectId);
|
|
7780
7822
|
}
|
|
7781
7823
|
console.log("");
|
|
7782
7824
|
p3.outro(colors.success("Setup complete!"));
|
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
|
|
2052
|
+
var DEFAULT_SCOPE = "__default__";
|
|
2053
|
+
var scopes = new Map;
|
|
2047
2054
|
var inactivityTimer = null;
|
|
2048
|
-
|
|
2049
|
-
|
|
2050
|
-
|
|
2051
|
-
|
|
2052
|
-
|
|
2053
|
-
|
|
2054
|
-
|
|
2055
|
-
|
|
2056
|
-
|
|
2057
|
-
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
2139
|
+
function untrack(cardId, scopeId = DEFAULT_SCOPE) {
|
|
2140
|
+
scopes.get(scopeId)?.sessions.delete(cardId);
|
|
2121
2141
|
}
|
|
2122
|
-
async function shutdownAllSessions() {
|
|
2123
|
-
const
|
|
2124
|
-
|
|
2125
|
-
|
|
2126
|
-
|
|
2127
|
-
|
|
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
|
-
|
|
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
|
|
2143
|
-
|
|
2144
|
-
|
|
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
|
-
|
|
2150
|
-
|
|
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
|
-
|
|
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, {
|
|
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);
|
package/dist/lib/api-client.js
CHANGED
|
@@ -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
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;
|
package/src/auto-session.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
94
|
-
|
|
95
|
-
let
|
|
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
|
-
|
|
109
|
-
|
|
110
|
-
|
|
167
|
+
const scope = getOrCreateScope(scopeId);
|
|
168
|
+
scope.endCallback = callback;
|
|
169
|
+
scope.clientGetter = getClient;
|
|
170
|
+
scope.clientInfoGetter = getClientInfo ?? null;
|
|
111
171
|
|
|
112
|
-
|
|
113
|
-
|
|
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 =
|
|
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 (
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
221
|
-
|
|
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
|
-
|
|
224
|
-
const
|
|
225
|
-
|
|
226
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
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(
|
|
249
|
-
|
|
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
|
|
259
|
-
|
|
364
|
+
for (const scope of scopes.values()) {
|
|
365
|
+
const client = scope.clientGetter?.();
|
|
366
|
+
if (!client) continue;
|
|
260
367
|
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
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
|
-
|
|
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 {
|
|
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, {
|
|
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
|
|
package/src/tui/setup.ts
CHANGED
|
@@ -1336,7 +1336,12 @@ export async function runSetup(options: SetupOptions = {}): Promise<void> {
|
|
|
1336
1336
|
}
|
|
1337
1337
|
}
|
|
1338
1338
|
|
|
1339
|
-
// Step 9: Save local
|
|
1339
|
+
// Step 9: Save context \u2014 both local (cwd-scoped) and global (user default).
|
|
1340
|
+
// Local config only resolves when the server runs with this repo as cwd;
|
|
1341
|
+
// remote/OAuth connections and other cwds fall back to the global active
|
|
1342
|
+
// context. Writing both ensures project-scoped tools (harmony_remember,
|
|
1343
|
+
// harmony_get_context) default to the selected project everywhere, not just
|
|
1344
|
+
// inside this directory.
|
|
1340
1345
|
if (selectedWorkspaceId || selectedProjectId) {
|
|
1341
1346
|
const localConfig: { workspaceId?: string; projectId?: string } = {};
|
|
1342
1347
|
if (selectedWorkspaceId) localConfig.workspaceId = selectedWorkspaceId;
|
|
@@ -1345,6 +1350,9 @@ export async function runSetup(options: SetupOptions = {}): Promise<void> {
|
|
|
1345
1350
|
console.log(
|
|
1346
1351
|
` ${colors.success("\u2713")} ${colors.dim(formatPath(getLocalConfigPath(cwd), home))} ${colors.dim("(created)")}`,
|
|
1347
1352
|
);
|
|
1353
|
+
|
|
1354
|
+
if (selectedWorkspaceId) setActiveWorkspace(selectedWorkspaceId);
|
|
1355
|
+
if (selectedProjectId) setActiveProject(selectedProjectId);
|
|
1348
1356
|
}
|
|
1349
1357
|
|
|
1350
1358
|
// Step 10: Show completion message
|