u-foo 3.0.21 → 3.0.22

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.
@@ -176,6 +176,13 @@ function resolveConfiguredLaunchMode(configuredMode = "", options = {}) {
176
176
  if (mode === "internal" || mode === "tmux" || mode === "terminal" || mode === "host") {
177
177
  return mode;
178
178
  }
179
+ if (
180
+ normalizeOptionalString(options.tmuxTarget)
181
+ || normalizeOptionalString(options.tmuxPane)
182
+ || normalizeOptionalString(options.tmuxSession)
183
+ ) {
184
+ return "tmux";
185
+ }
179
186
  // Auto mode: defer to the unified detector. Daemon ops differs from the
180
187
  // launcher in two ways: host context can also arrive via `options`, and
181
188
  // when running under a native terminal app we want to override a stale
@@ -704,7 +711,6 @@ async function spawnInternalAgent(
704
711
 
705
712
  // Daemon 预先在 bus 中注册
706
713
  bus.loadBusData();
707
- process.env.UFOO_PARENT_PID = String(originalPid);
708
714
  const replaceAgentId = typeof options.replaceAgentId === "string" ? options.replaceAgentId.trim() : "";
709
715
  if (replaceAgentId && bus.busData.agents && bus.busData.agents[replaceAgentId]) {
710
716
  delete bus.busData.agents[replaceAgentId];
@@ -721,6 +727,7 @@ async function spawnInternalAgent(
721
727
  launchMode,
722
728
  parentPid: originalPid,
723
729
  providerSessionId,
730
+ scopedNickname: String(extraEnv.UFOO_SCOPED_NICKNAME || "").trim(),
724
731
  });
725
732
  const finalNickname = joinResult.nickname || requestedNickname || "";
726
733
  bus.saveBusData();
@@ -803,7 +810,14 @@ async function spawnInternalAgent(
803
810
  return { children, subscriberIds };
804
811
  }
805
812
 
806
- function spawnTmuxWindow(projectRoot, agent, nickname = "", extraArgs = [], extraEnv = "") {
813
+ function spawnTmuxWindow(
814
+ projectRoot,
815
+ agent,
816
+ nickname = "",
817
+ extraArgs = [],
818
+ extraEnv = "",
819
+ targetSession = ""
820
+ ) {
807
821
  return new Promise((resolve, reject) => {
808
822
  const normalizedAgent = normalizeLaunchAgent(agent);
809
823
  const binary = toTmuxBinary(normalizedAgent);
@@ -826,11 +840,12 @@ function spawnTmuxWindow(projectRoot, agent, nickname = "", extraArgs = [], extr
826
840
 
827
841
  // Use detached mode (-d) to avoid stealing focus
828
842
  // Use -a flag to insert after current window, avoiding index conflicts
829
- // Use target session from env or current session
830
- const targetSession = process.env.UFOO_TMUX_SESSION || "";
843
+ // Use the request-scoped target session when available. The daemon may
844
+ // host multiple projects and must not mutate process.env to select one.
845
+ const normalizedTargetSession = String(targetSession || "").trim();
831
846
  const tmuxArgs = ["new-window", "-a", "-d", "-n", windowName];
832
- if (targetSession) {
833
- tmuxArgs.push("-t", targetSession);
847
+ if (normalizedTargetSession) {
848
+ tmuxArgs.push("-t", normalizedTargetSession);
834
849
  }
835
850
  tmuxArgs.push(runCmd);
836
851
 
@@ -846,10 +861,20 @@ function spawnTmuxWindow(projectRoot, agent, nickname = "", extraArgs = [], extr
846
861
  });
847
862
  }
848
863
 
849
- function resolveTmuxPaneTarget() {
850
- const explicit = String(process.env.UFOO_TMUX_TARGET || "").trim();
864
+ function resolveTmuxPaneTarget(options = {}) {
865
+ const explicit = String(
866
+ options.tmuxTarget
867
+ || options.tmux_target
868
+ || process.env.UFOO_TMUX_TARGET
869
+ || ""
870
+ ).trim();
851
871
  if (explicit) return explicit;
852
- const preferredPane = String(process.env.UFOO_TMUX_PANE || "").trim();
872
+ const preferredPane = String(
873
+ options.tmuxPane
874
+ || options.tmux_pane
875
+ || process.env.UFOO_TMUX_PANE
876
+ || ""
877
+ ).trim();
853
878
  if (preferredPane) return preferredPane;
854
879
  const currentPane = String(process.env.TMUX_PANE || "").trim();
855
880
  if (currentPane) return currentPane;
@@ -982,15 +1007,21 @@ async function launchAgent(projectRoot, agent, count = 1, nickname = "", process
982
1007
  if (!tmuxAvailable) {
983
1008
  throw new Error("tmux is not available or no tmux session is running");
984
1009
  }
985
- // If UFOO_TMUX_SESSION not set, use first available session
986
- if (!process.env.UFOO_TMUX_SESSION && stdout) {
1010
+ let tmuxSession = String(
1011
+ options.tmuxSession
1012
+ || options.tmux_session
1013
+ || process.env.UFOO_TMUX_SESSION
1014
+ || ""
1015
+ ).trim();
1016
+ // If no request-scoped or inherited session was supplied, use the first
1017
+ // available session locally without changing the daemon environment.
1018
+ if (!tmuxSession && stdout) {
987
1019
  const sessions = stdout.trim().split("\n");
988
1020
  if (sessions.length > 0) {
989
- const firstSession = sessions[0].split(":")[0];
990
- process.env.UFOO_TMUX_SESSION = firstSession;
1021
+ tmuxSession = String(sessions[0].split(":")[0] || "").trim();
991
1022
  }
992
1023
  }
993
- const paneTarget = resolveTmuxPaneTarget();
1024
+ const paneTarget = resolveTmuxPaneTarget(options);
994
1025
  const useSeparateWindow = launchScope === "window";
995
1026
  const tmuxLayoutContext = options.tmuxLayoutContext && typeof options.tmuxLayoutContext === "object"
996
1027
  ? options.tmuxLayoutContext
@@ -1004,7 +1035,7 @@ async function launchAgent(projectRoot, agent, count = 1, nickname = "", process
1004
1035
  const nick = count > 1 ? `${nickname || defaultNick}-${i + 1}` : (nickname || "");
1005
1036
  if (useSeparateWindow) {
1006
1037
  // eslint-disable-next-line no-await-in-loop
1007
- await spawnTmuxWindow(projectRoot, normalizedAgent, nick, extraArgs, extraEnvPrefix);
1038
+ await spawnTmuxWindow(projectRoot, normalizedAgent, nick, extraArgs, extraEnvPrefix, tmuxSession);
1008
1039
  } else if (useGroupRightColumnLayout && paneTarget) {
1009
1040
  const basePane = String(tmuxLayoutContext.basePane || paneTarget).trim() || paneTarget;
1010
1041
  tmuxLayoutContext.basePane = basePane;
@@ -1021,7 +1052,7 @@ async function launchAgent(projectRoot, agent, count = 1, nickname = "", process
1021
1052
  } catch {
1022
1053
  // Fallback to new window when current pane target cannot be resolved.
1023
1054
  // eslint-disable-next-line no-await-in-loop
1024
- await spawnTmuxWindow(projectRoot, normalizedAgent, nick, extraArgs, extraEnvPrefix);
1055
+ await spawnTmuxWindow(projectRoot, normalizedAgent, nick, extraArgs, extraEnvPrefix, tmuxSession);
1025
1056
  continue;
1026
1057
  }
1027
1058
  if (!rightColumnPane && splitResult && splitResult.paneId) {
@@ -1037,7 +1068,7 @@ async function launchAgent(projectRoot, agent, count = 1, nickname = "", process
1037
1068
  } catch {
1038
1069
  // Fallback to new window when current pane target cannot be resolved.
1039
1070
  // eslint-disable-next-line no-await-in-loop
1040
- await spawnTmuxWindow(projectRoot, normalizedAgent, nick, extraArgs, extraEnvPrefix);
1071
+ await spawnTmuxWindow(projectRoot, normalizedAgent, nick, extraArgs, extraEnvPrefix, tmuxSession);
1041
1072
  }
1042
1073
  }
1043
1074
  }
@@ -0,0 +1,127 @@
1
+ "use strict";
2
+
3
+ class DaemonProcessLifecycle {
4
+ constructor(processRef = process) {
5
+ this.processRef = processRef;
6
+ this.registrations = new Map();
7
+ this.installed = false;
8
+ this.shuttingDown = false;
9
+ this.boundHandlers = null;
10
+ }
11
+
12
+ register(id, hooks = {}) {
13
+ const key = String(id || "").trim();
14
+ if (!key) throw new Error("daemon process lifecycle registration id is required");
15
+ if (this.registrations.has(key)) {
16
+ throw new Error(`daemon process lifecycle already registered: ${key}`);
17
+ }
18
+ this.registrations.set(key, hooks);
19
+ this.install();
20
+ return () => {
21
+ this.registrations.delete(key);
22
+ };
23
+ }
24
+
25
+ install() {
26
+ if (this.installed) return;
27
+ this.installed = true;
28
+ const beforeExit = (code) => {
29
+ this.notify("onBeforeExit", code);
30
+ };
31
+ const exit = (code) => {
32
+ this.shutdown(`exit code=${code}`, { sync: true, notify: "onExit", value: code });
33
+ };
34
+ const sigterm = () => {
35
+ this.shutdown("SIGTERM", { sync: true, notify: "onSignal", value: "SIGTERM" });
36
+ this.processRef.exit(0);
37
+ };
38
+ const sigint = () => {
39
+ this.shutdown("SIGINT", { sync: true, notify: "onSignal", value: "SIGINT" });
40
+ this.processRef.exit(0);
41
+ };
42
+ const uncaughtException = (err) => {
43
+ this.shutdown("uncaughtException", {
44
+ sync: true,
45
+ notify: "onFatal",
46
+ value: { kind: "uncaughtException", error: err },
47
+ });
48
+ this.processRef.exit(1);
49
+ };
50
+ const unhandledRejection = (reason) => {
51
+ this.shutdown("unhandledRejection", {
52
+ sync: true,
53
+ notify: "onFatal",
54
+ value: { kind: "unhandledRejection", error: reason },
55
+ });
56
+ this.processRef.exit(1);
57
+ };
58
+ this.boundHandlers = {
59
+ beforeExit,
60
+ exit,
61
+ SIGTERM: sigterm,
62
+ SIGINT: sigint,
63
+ uncaughtException,
64
+ unhandledRejection,
65
+ };
66
+ for (const [event, handler] of Object.entries(this.boundHandlers)) {
67
+ this.processRef.on(event, handler);
68
+ }
69
+ }
70
+
71
+ notify(method, value) {
72
+ for (const hooks of Array.from(this.registrations.values())) {
73
+ const handler = hooks && hooks[method];
74
+ if (typeof handler !== "function") continue;
75
+ try {
76
+ handler(value);
77
+ } catch {
78
+ // A lifecycle observer cannot prevent other runtimes from cleaning up.
79
+ }
80
+ }
81
+ }
82
+
83
+ shutdown(reason, options = {}) {
84
+ if (this.shuttingDown) return;
85
+ this.shuttingDown = true;
86
+ const registrations = Array.from(this.registrations.values());
87
+ for (const hooks of registrations) {
88
+ try {
89
+ const notify = options.notify && hooks && hooks[options.notify];
90
+ if (typeof notify === "function") notify(options.value);
91
+ } catch {
92
+ // Fatal diagnostics for one runtime must not block cleanup of another.
93
+ }
94
+ try {
95
+ if (hooks && typeof hooks.cleanup === "function") {
96
+ hooks.cleanup(reason, { sync: options.sync === true });
97
+ }
98
+ } catch {
99
+ // Continue draining every registered runtime.
100
+ }
101
+ }
102
+ }
103
+
104
+ disposeForTests() {
105
+ if (this.boundHandlers && typeof this.processRef.removeListener === "function") {
106
+ for (const [event, handler] of Object.entries(this.boundHandlers)) {
107
+ this.processRef.removeListener(event, handler);
108
+ }
109
+ }
110
+ this.boundHandlers = null;
111
+ this.registrations.clear();
112
+ this.installed = false;
113
+ this.shuttingDown = false;
114
+ }
115
+ }
116
+
117
+ const defaultDaemonProcessLifecycle = new DaemonProcessLifecycle(process);
118
+
119
+ function registerDaemonRuntimeLifecycle(id, hooks = {}) {
120
+ return defaultDaemonProcessLifecycle.register(id, hooks);
121
+ }
122
+
123
+ module.exports = {
124
+ DaemonProcessLifecycle,
125
+ defaultDaemonProcessLifecycle,
126
+ registerDaemonRuntimeLifecycle,
127
+ };
@@ -0,0 +1,46 @@
1
+ "use strict";
2
+
3
+ const path = require("path");
4
+
5
+ const { loadConfig } = require("../../config");
6
+ const { getUfooPaths } = require("../../coordination/state/paths");
7
+ const {
8
+ buildProjectId,
9
+ canonicalProjectRoot,
10
+ } = require("../projects");
11
+
12
+ function cloneJson(value) {
13
+ return JSON.parse(JSON.stringify(value == null ? {} : value));
14
+ }
15
+
16
+ function deepFreeze(value) {
17
+ if (!value || typeof value !== "object" || Object.isFrozen(value)) return value;
18
+ for (const child of Object.values(value)) deepFreeze(child);
19
+ return Object.freeze(value);
20
+ }
21
+
22
+ function createProjectContext(options = {}) {
23
+ const projectRoot = canonicalProjectRoot(options.projectRoot);
24
+ const configSnapshot = cloneJson(options.config || loadConfig(projectRoot));
25
+ const runtimeGeneration = Number.isInteger(options.runtimeGeneration)
26
+ && options.runtimeGeneration > 0
27
+ ? options.runtimeGeneration
28
+ : 1;
29
+ const context = {
30
+ projectId: options.projectId || buildProjectId(projectRoot),
31
+ projectRoot,
32
+ projectName: options.projectName || path.basename(projectRoot),
33
+ paths: getUfooPaths(projectRoot),
34
+ config: configSnapshot,
35
+ provider: String(options.provider || configSnapshot.agentProvider || ""),
36
+ model: String(options.model || configSnapshot.agentModel || ""),
37
+ daemonTopology: String(options.daemonTopology || configSnapshot.daemonTopology || "global"),
38
+ runtimeGeneration,
39
+ };
40
+ return deepFreeze(context);
41
+ }
42
+
43
+ module.exports = {
44
+ createProjectContext,
45
+ deepFreeze,
46
+ };
@@ -0,0 +1,221 @@
1
+ "use strict";
2
+
3
+ const { randomUUID } = require("crypto");
4
+
5
+ const RUNTIME_STATES = Object.freeze({
6
+ REGISTERED: "registered",
7
+ DORMANT: "dormant",
8
+ ACTIVATING: "activating",
9
+ ACTIVE: "active",
10
+ DRAINING: "draining",
11
+ FAILED: "failed",
12
+ DISPOSED: "disposed",
13
+ });
14
+
15
+ class ProjectRuntime {
16
+ constructor(context, options = {}) {
17
+ if (!context || !context.projectId || !context.projectRoot) {
18
+ throw new Error("ProjectRuntime requires an immutable ProjectContext");
19
+ }
20
+ this.context = context;
21
+ this.state = RUNTIME_STATES.REGISTERED;
22
+ this.operations = new Map();
23
+ this.resources = new Map();
24
+ this.activeRequests = new Map();
25
+ this.lastError = null;
26
+ this.lastActiveAt = null;
27
+ this.onActivate = typeof options.onActivate === "function" ? options.onActivate : null;
28
+ this.onSuspend = typeof options.onSuspend === "function" ? options.onSuspend : null;
29
+ this.onRecover = typeof options.onRecover === "function" ? options.onRecover : null;
30
+ this.canSuspendHook = typeof options.canSuspend === "function" ? options.canSuspend : null;
31
+ this.onDispose = typeof options.onDispose === "function" ? options.onDispose : null;
32
+ }
33
+
34
+ registerOperation(name, handler) {
35
+ const operation = String(name || "").trim();
36
+ if (!operation || typeof handler !== "function") {
37
+ throw new Error("registerOperation requires a name and handler");
38
+ }
39
+ this.operations.set(operation, handler);
40
+ return this;
41
+ }
42
+
43
+ own(name, resource) {
44
+ const key = String(name || "").trim();
45
+ if (!key) throw new Error("runtime resource name is required");
46
+ if (this.resources.has(key)) {
47
+ throw new Error(`runtime resource already exists: ${key}`);
48
+ }
49
+ this.resources.set(key, resource);
50
+ return resource;
51
+ }
52
+
53
+ resource(name) {
54
+ return this.resources.get(name);
55
+ }
56
+
57
+ async activate() {
58
+ if (this.state === RUNTIME_STATES.ACTIVE) return this;
59
+ if (this.state === RUNTIME_STATES.DISPOSED) {
60
+ throw new Error("disposed runtime cannot be activated");
61
+ }
62
+ this.state = RUNTIME_STATES.ACTIVATING;
63
+ try {
64
+ if (this.onActivate) await this.onActivate(this);
65
+ this.state = RUNTIME_STATES.ACTIVE;
66
+ this.lastActiveAt = new Date().toISOString();
67
+ this.lastError = null;
68
+ return this;
69
+ } catch (err) {
70
+ this.fail(err);
71
+ throw err;
72
+ }
73
+ }
74
+
75
+ async call(operation, args = {}, requestContext = {}) {
76
+ if (this.state !== RUNTIME_STATES.ACTIVE) {
77
+ const err = new Error(
78
+ `project runtime ${this.context.projectId} is not active (${this.state})`
79
+ );
80
+ err.code = "PROJECT_RUNTIME_NOT_ACTIVE";
81
+ throw err;
82
+ }
83
+ const handler = this.operations.get(String(operation || ""));
84
+ if (!handler) {
85
+ const err = new Error(`unknown project runtime operation: ${operation || ""}`);
86
+ err.code = "PROJECT_RUNTIME_UNKNOWN_OPERATION";
87
+ throw err;
88
+ }
89
+ const requestId = String(requestContext.requestId || randomUUID());
90
+ if (this.activeRequests.has(requestId)) {
91
+ const err = new Error(`duplicate project runtime request: ${requestId}`);
92
+ err.code = "PROJECT_RUNTIME_DUPLICATE_REQUEST";
93
+ throw err;
94
+ }
95
+ const controller = new AbortController();
96
+ const externalSignal = requestContext.signal;
97
+ const abortFromExternal = () => controller.abort(externalSignal.reason);
98
+ if (externalSignal) {
99
+ if (externalSignal.aborted) abortFromExternal();
100
+ else externalSignal.addEventListener("abort", abortFromExternal, { once: true });
101
+ }
102
+ this.activeRequests.set(requestId, controller);
103
+ this.lastActiveAt = new Date().toISOString();
104
+ try {
105
+ return await handler(args, {
106
+ context: this.context,
107
+ requestContext: {
108
+ ...requestContext,
109
+ requestId,
110
+ },
111
+ runtime: this,
112
+ signal: controller.signal,
113
+ });
114
+ } catch (err) {
115
+ if (err && err.runtimeFatal === true) this.fail(err);
116
+ throw err;
117
+ } finally {
118
+ this.activeRequests.delete(requestId);
119
+ if (externalSignal) externalSignal.removeEventListener("abort", abortFromExternal);
120
+ }
121
+ }
122
+
123
+ cancel(requestId, reason = "project runtime request cancelled") {
124
+ const controller = this.activeRequests.get(String(requestId || ""));
125
+ if (!controller) return false;
126
+ controller.abort(new Error(reason));
127
+ return true;
128
+ }
129
+
130
+ fail(err) {
131
+ this.lastError = {
132
+ code: err && err.code ? String(err.code) : "PROJECT_RUNTIME_FAILED",
133
+ message: err && err.message ? err.message : String(err || "runtime failed"),
134
+ at: new Date().toISOString(),
135
+ };
136
+ this.state = RUNTIME_STATES.FAILED;
137
+ }
138
+
139
+ canSuspend() {
140
+ if (this.state !== RUNTIME_STATES.ACTIVE || this.activeRequests.size > 0) return false;
141
+ if (this.canSuspendHook) return this.canSuspendHook(this) === true;
142
+ return true;
143
+ }
144
+
145
+ async suspend() {
146
+ if (!this.canSuspend()) {
147
+ const err = new Error(`project runtime ${this.context.projectId} cannot suspend`);
148
+ err.code = "PROJECT_RUNTIME_BUSY";
149
+ throw err;
150
+ }
151
+ this.state = RUNTIME_STATES.DRAINING;
152
+ try {
153
+ if (this.onSuspend) await this.onSuspend(this);
154
+ this.state = RUNTIME_STATES.DORMANT;
155
+ } catch (err) {
156
+ this.fail(err);
157
+ throw err;
158
+ }
159
+ }
160
+
161
+ async recover() {
162
+ if (this.state !== RUNTIME_STATES.FAILED) return this;
163
+ this.state = RUNTIME_STATES.ACTIVATING;
164
+ try {
165
+ if (this.onRecover) await this.onRecover(this);
166
+ this.state = RUNTIME_STATES.ACTIVE;
167
+ this.lastActiveAt = new Date().toISOString();
168
+ this.lastError = null;
169
+ return this;
170
+ } catch (err) {
171
+ this.fail(err);
172
+ throw err;
173
+ }
174
+ }
175
+
176
+ dispose() {
177
+ if (this.state === RUNTIME_STATES.DISPOSED) return;
178
+ this.state = RUNTIME_STATES.DRAINING;
179
+ for (const controller of this.activeRequests.values()) {
180
+ controller.abort(new Error("project runtime disposed"));
181
+ }
182
+ this.activeRequests.clear();
183
+ if (this.onDispose) {
184
+ try {
185
+ this.onDispose(this);
186
+ } catch (err) {
187
+ this.lastError = {
188
+ code: err && err.code ? String(err.code) : "PROJECT_RUNTIME_DISPOSE_FAILED",
189
+ message: err && err.message ? err.message : String(err || "runtime dispose failed"),
190
+ at: new Date().toISOString(),
191
+ };
192
+ }
193
+ }
194
+ this.state = RUNTIME_STATES.DISPOSED;
195
+ }
196
+
197
+ status() {
198
+ return {
199
+ project_id: this.context.projectId,
200
+ project_root: this.context.projectRoot,
201
+ project_name: this.context.projectName,
202
+ topology: this.context.daemonTopology,
203
+ generation: this.context.runtimeGeneration,
204
+ state: this.state,
205
+ active_request_count: this.activeRequests.size,
206
+ resource_count: this.resources.size,
207
+ last_active_at: this.lastActiveAt,
208
+ last_error: this.lastError,
209
+ };
210
+ }
211
+ }
212
+
213
+ function createProjectRuntime(context, options = {}) {
214
+ return new ProjectRuntime(context, options);
215
+ }
216
+
217
+ module.exports = {
218
+ ProjectRuntime,
219
+ RUNTIME_STATES,
220
+ createProjectRuntime,
221
+ };
@@ -11,6 +11,7 @@ const {
11
11
  assertToolAllowedForCallerTier,
12
12
  } = require("../../tools/registry");
13
13
  const { CALLER_TIERS } = require("../../tools/types");
14
+ const { ProjectRuntimeManager } = require("./projectRuntimeManager");
14
15
 
15
16
  const MCP_EXPOSED_SHARED_TOOLS = Object.freeze([
16
17
  "read_project_registry",
@@ -140,6 +141,71 @@ class LocalProjectRuntimeGateway {
140
141
  }
141
142
  }
142
143
 
144
+ class ManagedProjectRuntimeGateway {
145
+ constructor(options = {}) {
146
+ this.options = options;
147
+ this.activeRequestProjects = new Map();
148
+ if (options.manager) {
149
+ this.manager = options.manager;
150
+ } else {
151
+ const managerOptions = options.managerOptions || {};
152
+ const callerConfigureRuntime = managerOptions.configureRuntime;
153
+ this.manager = new ProjectRuntimeManager({
154
+ ...managerOptions,
155
+ configureRuntime: (runtime, context) => {
156
+ for (const operation of [
157
+ ...CONTROL_PLANE_OPERATIONS,
158
+ ...MCP_EXPOSED_SHARED_TOOLS.filter((name) => name !== "read_project_registry"),
159
+ ]) {
160
+ runtime.registerOperation(operation, (args, callContext) =>
161
+ executeProjectRuntimeOperation(
162
+ context.projectRoot,
163
+ operation,
164
+ args,
165
+ {
166
+ ...callContext.requestContext,
167
+ signal: callContext.signal,
168
+ },
169
+ this.options
170
+ ));
171
+ }
172
+ if (typeof callerConfigureRuntime === "function") {
173
+ callerConfigureRuntime(runtime, context);
174
+ }
175
+ },
176
+ });
177
+ }
178
+ }
179
+
180
+ async call(projectRoot, operation, args = {}, context = {}) {
181
+ const requestId = String(context.requestId || context.toolCallId || crypto.randomUUID());
182
+ this.activeRequestProjects.set(requestId, projectRoot);
183
+ try {
184
+ return await this.manager.call(projectRoot, operation, args, {
185
+ ...context,
186
+ requestId,
187
+ });
188
+ } finally {
189
+ this.activeRequestProjects.delete(requestId);
190
+ }
191
+ }
192
+
193
+ cancel(requestId) {
194
+ const id = String(requestId || "");
195
+ const projectRoot = this.activeRequestProjects.get(id);
196
+ return projectRoot ? this.manager.cancel(projectRoot, id) : false;
197
+ }
198
+
199
+ status() {
200
+ return this.manager.status();
201
+ }
202
+
203
+ dispose() {
204
+ this.manager.dispose();
205
+ this.activeRequestProjects.clear();
206
+ }
207
+ }
208
+
143
209
  function projectRuntimeError(code, message) {
144
210
  const err = new Error(String(message || "project runtime request failed"));
145
211
  err.code = String(code || "project_runtime_error");
@@ -344,14 +410,20 @@ function createSocketProjectRuntimeGateway(options = {}) {
344
410
  return new SocketProjectRuntimeGateway(options);
345
411
  }
346
412
 
413
+ function createManagedProjectRuntimeGateway(options = {}) {
414
+ return new ManagedProjectRuntimeGateway(options);
415
+ }
416
+
347
417
  module.exports = {
348
418
  MCP_EXPOSED_SHARED_TOOLS,
349
419
  CONTROL_PLANE_OPERATIONS,
350
420
  stripRoutingArgs,
351
421
  executeProjectRuntimeOperation,
352
422
  LocalProjectRuntimeGateway,
423
+ ManagedProjectRuntimeGateway,
353
424
  SocketProjectRuntimeGateway,
354
425
  createLocalProjectRuntimeGateway,
426
+ createManagedProjectRuntimeGateway,
355
427
  createSocketProjectRuntimeGateway,
356
428
  connectProjectRuntimeSocket,
357
429
  resolveCallTimeoutMs,