macro-agent 0.2.4 → 0.2.5

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.
Files changed (40) hide show
  1. package/dist/agent/agent-manager-v2.d.ts.map +1 -1
  2. package/dist/agent/agent-manager-v2.js +182 -43
  3. package/dist/agent/agent-manager-v2.js.map +1 -1
  4. package/dist/agent/agent-manager.d.ts +17 -0
  5. package/dist/agent/agent-manager.d.ts.map +1 -1
  6. package/dist/agent/agent-manager.js.map +1 -1
  7. package/dist/agent/types.d.ts +21 -2
  8. package/dist/agent/types.d.ts.map +1 -1
  9. package/dist/agent/types.js.map +1 -1
  10. package/dist/cognitive/macro-agent-backend.d.ts.map +1 -1
  11. package/dist/cognitive/macro-agent-backend.js +37 -8
  12. package/dist/cognitive/macro-agent-backend.js.map +1 -1
  13. package/dist/cognitive/types.d.ts +24 -0
  14. package/dist/cognitive/types.d.ts.map +1 -1
  15. package/dist/index.d.ts +1 -1
  16. package/dist/index.d.ts.map +1 -1
  17. package/dist/index.js.map +1 -1
  18. package/dist/map/mail-bridge.d.ts +7 -0
  19. package/dist/map/mail-bridge.d.ts.map +1 -1
  20. package/dist/map/mail-bridge.js +39 -9
  21. package/dist/map/mail-bridge.js.map +1 -1
  22. package/dist/teams/team-runtime-v2.d.ts.map +1 -1
  23. package/dist/teams/team-runtime-v2.js +23 -6
  24. package/dist/teams/team-runtime-v2.js.map +1 -1
  25. package/package.json +1 -1
  26. package/src/agent/__tests__/agent-manager-v2.completion-signal.test.ts +284 -0
  27. package/src/agent/agent-manager-v2.ts +216 -50
  28. package/src/agent/agent-manager.ts +17 -0
  29. package/src/agent/types.ts +23 -2
  30. package/src/cognitive/__tests__/macro-agent-backend.test.ts +158 -5
  31. package/src/cognitive/macro-agent-backend.ts +39 -7
  32. package/src/cognitive/types.ts +24 -0
  33. package/src/index.ts +2 -0
  34. package/src/map/__tests__/mail-bridge.test.ts +39 -2
  35. package/src/map/mail-bridge.ts +61 -13
  36. package/src/teams/team-runtime-v2.ts +29 -6
  37. package/dist/workspace/dataplane-adapter.d.ts +0 -260
  38. package/dist/workspace/dataplane-adapter.d.ts.map +0 -1
  39. package/dist/workspace/dataplane-adapter.js +0 -416
  40. package/dist/workspace/dataplane-adapter.js.map +0 -1
@@ -202,15 +202,36 @@ export interface AgentConfig {
202
202
  }
203
203
 
204
204
  /**
205
- * MCP server configuration
205
+ * MCP server configuration.
206
+ *
207
+ * A discriminated union over transport. The legacy stdio shape (no `type`,
208
+ * or `type: "stdio"`) is preserved byte-for-byte; new `http`/`sse` variants
209
+ * carry a `url` + optional `headers` instead of a `command`. These map
210
+ * directly onto the ACP wire's `McpServerStdio` / `McpServerHttp` /
211
+ * `McpServerSse` shapes (see acp-factory `createSession({ mcpServers })`).
206
212
  */
207
- export interface MCPServerConfig {
213
+ export type MCPServerConfig = MCPServerStdioConfig | MCPServerHttpConfig;
214
+
215
+ /** Stdio transport (legacy default — preserved for M0 compatibility). */
216
+ export interface MCPServerStdioConfig {
208
217
  name: string;
218
+ /** Optional transport discriminant; absent means stdio. */
219
+ type?: "stdio";
209
220
  command: string;
210
221
  args?: string[];
211
222
  env?: Record<string, string>;
212
223
  }
213
224
 
225
+ /** HTTP (streamable-http) or SSE transport for a remote MCP server. */
226
+ export interface MCPServerHttpConfig {
227
+ name: string;
228
+ type: "http" | "sse";
229
+ /** URL to the remote MCP server. */
230
+ url: string;
231
+ /** HTTP headers to set on requests to the server. */
232
+ headers?: Record<string, string>;
233
+ }
234
+
214
235
  // ─────────────────────────────────────────────────────────────────
215
236
  // Spawned Agent Result
216
237
  // ─────────────────────────────────────────────────────────────────
@@ -6,16 +6,23 @@ import type { RoleRegistry, RoleDefinition } from "../../roles/types.js";
6
6
 
7
7
  // ── Mock Helpers ─────────────────────────────────────────────────
8
8
 
9
+ // Stand-in for the built-in "generic" role the REAL DefaultRoleRegistry falls
10
+ // back to. Used by resolveRole() below so the mock matches production
11
+ // semantics (resolveRole NEVER throws on a miss).
12
+ const GENERIC_FALLBACK = { name: "generic" } as unknown as RoleDefinition;
13
+
9
14
  function createMockRoleRegistry(): RoleRegistry {
10
15
  const roles = new Map<string, RoleDefinition>();
11
16
  return {
12
17
  registerRole: vi.fn((role: RoleDefinition) => {
13
18
  roles.set(role.name, role);
14
19
  }),
20
+ // MUST mirror the real DefaultRoleRegistry.resolveRole(): on an unknown
21
+ // role it does NOT throw — it logs and returns the GenericRole fallback.
22
+ // A throwing mock (the old behavior) masked the constructor bug where
23
+ // analyst registration was gated on resolveRole() throwing.
15
24
  resolveRole: vi.fn((name: string) => {
16
- const role = roles.get(name);
17
- if (!role) throw new Error(`Role not found: ${name}`);
18
- return role;
25
+ return roles.get(name) ?? GENERIC_FALLBACK;
19
26
  }),
20
27
  getRole: vi.fn((name: string) => roles.get(name)),
21
28
  hasCapability: vi.fn(() => true),
@@ -92,10 +99,33 @@ describe("MacroAgentBackend", () => {
92
99
  );
93
100
  });
94
101
 
102
+ it("registers analyst against a real-semantics registry (resolveRole does NOT throw on miss)", () => {
103
+ // Regression guard for the live Arm-B bug: the constructor must NOT rely
104
+ // on resolveRole() throwing to detect a missing analyst role. With a
105
+ // non-throwing registry (production behavior), analyst must still be
106
+ // registered, and resolveRole("analyst") must then return analyst —
107
+ // NOT fall back to "generic" (which would give the spawned agent
108
+ // all built-in tools + own-workspace + persistent lifecycle, exactly
109
+ // the conditions that produced 0 env-tool calls and a 600s timeout).
110
+ const registry = createMockRoleRegistry();
111
+ const am = createMockAgentManager({
112
+ getRoleRegistry: vi.fn().mockReturnValue(registry),
113
+ });
114
+
115
+ new MacroAgentBackend(am);
116
+
117
+ expect(registry.registerRole).toHaveBeenCalledWith(
118
+ expect.objectContaining({ name: "analyst" }),
119
+ );
120
+ // After construction, the role must resolve to analyst, not generic.
121
+ expect(registry.resolveRole("analyst").name).toBe("analyst");
122
+ });
123
+
95
124
  it("skips registration if analyst role already exists", () => {
96
125
  const registry = createMockRoleRegistry();
97
- // Pre-register analyst
98
- (registry.resolveRole as ReturnType<typeof vi.fn>).mockReturnValue({
126
+ // Pre-register analyst via getRole (the exact-match existence check the
127
+ // constructor now uses), mirroring the real registry's API.
128
+ (registry.getRole as ReturnType<typeof vi.fn>).mockReturnValue({
99
129
  name: "analyst",
100
130
  });
101
131
 
@@ -203,6 +233,129 @@ describe("MacroAgentBackend", () => {
203
233
  );
204
234
  });
205
235
 
236
+ it("forwards mcpServers into the raw macro spawn config (Arm B chain)", async () => {
237
+ const mcpServers = [
238
+ {
239
+ name: "tauenv",
240
+ command: "/venv/bin/python",
241
+ args: ["-m", "autonomation_tau_bench.mcp_env", "--split", "airline"],
242
+ env: { TAU_REWARD_FILE: "/tmp/reward.json" },
243
+ },
244
+ ];
245
+
246
+ await backend.spawn({
247
+ agentType: "claude-code",
248
+ task: { description: "handle the customer" },
249
+ mcpServers,
250
+ });
251
+
252
+ expect(agentManager.spawn).toHaveBeenCalledWith(
253
+ expect.objectContaining({
254
+ config: expect.objectContaining({ mcpServers }),
255
+ }),
256
+ );
257
+ });
258
+
259
+ it("forwards both env and mcpServers together into the raw spawn config", async () => {
260
+ const mcpServers = [
261
+ { name: "tauenv", command: "/venv/bin/python", args: ["-m", "x"] },
262
+ ];
263
+
264
+ await backend.spawn({
265
+ agentType: "claude-code",
266
+ task: { description: "test" },
267
+ env: { TAU_REWARD_FILE: "/tmp/r.json" },
268
+ mcpServers,
269
+ });
270
+
271
+ expect(agentManager.spawn).toHaveBeenCalledWith(
272
+ expect.objectContaining({
273
+ config: {
274
+ env: { TAU_REWARD_FILE: "/tmp/r.json" },
275
+ mcpServers,
276
+ },
277
+ }),
278
+ );
279
+ });
280
+
281
+ it("invokes beforeSpawn BEFORE driving the agent (per-attempt reset hook)", async () => {
282
+ // beforeSpawn must run before agentManager.spawn so the env subprocess /
283
+ // reward sink is reset for THIS attempt. We assert ordering by recording
284
+ // the call sequence.
285
+ const order: string[] = [];
286
+ const beforeSpawn = vi.fn(() => {
287
+ order.push("beforeSpawn");
288
+ });
289
+ const am = createMockAgentManager({
290
+ spawn: vi.fn().mockImplementation(async () => {
291
+ order.push("spawn");
292
+ return { id: "agent_test123", session_id: "session_test123" };
293
+ }),
294
+ });
295
+ const b = new MacroAgentBackend(am);
296
+
297
+ await b.spawn({
298
+ agentType: "claude-code",
299
+ task: { description: "tau episode" },
300
+ beforeSpawn,
301
+ });
302
+
303
+ expect(beforeSpawn).toHaveBeenCalledTimes(1);
304
+ expect(order).toEqual(["beforeSpawn", "spawn"]);
305
+ });
306
+
307
+ it("passes completionSignal into promptUntilDone (S1 threading)", async () => {
308
+ const completionSignal = vi.fn(() => false);
309
+
310
+ await backend.spawn({
311
+ agentType: "claude-code",
312
+ task: { description: "tau episode" },
313
+ completionSignal,
314
+ });
315
+
316
+ await vi.waitFor(() => {
317
+ expect(agentManager.promptUntilDone).toHaveBeenCalledWith(
318
+ expect.any(String),
319
+ expect.any(String),
320
+ expect.objectContaining({ isComplete: completionSignal }),
321
+ );
322
+ });
323
+ });
324
+
325
+ it("treats completedExternally as a success (session → completed) even without done()", async () => {
326
+ const am = createMockAgentManager({
327
+ promptUntilDone: vi.fn().mockResolvedValue({
328
+ doneCalled: false,
329
+ completedExternally: true,
330
+ updates: [],
331
+ }),
332
+ });
333
+ const b = new MacroAgentBackend(am);
334
+
335
+ const session = await b.spawn({
336
+ agentType: "claude-code",
337
+ task: { description: "tau episode" },
338
+ completionSignal: () => true,
339
+ });
340
+
341
+ await vi.waitFor(async () => {
342
+ const s = await b.getSession(session.id);
343
+ expect(s!.state).toBe("completed");
344
+ expect(s!.error).toBeUndefined();
345
+ });
346
+ });
347
+
348
+ it("passes config: undefined when neither env nor mcpServers set (M0 back-compat)", async () => {
349
+ await backend.spawn({
350
+ agentType: "claude-code",
351
+ task: { description: "test" },
352
+ });
353
+
354
+ expect(agentManager.spawn).toHaveBeenCalledWith(
355
+ expect.objectContaining({ config: undefined }),
356
+ );
357
+ });
358
+
206
359
  it("stores macro agent ID in session metadata", async () => {
207
360
  const session = await backend.spawn({
208
361
  agentType: "claude-code",
@@ -57,11 +57,14 @@ export class MacroAgentBackend {
57
57
  this.onSessionComplete = config?.onSessionComplete;
58
58
  this.inboxAdapter = config?.inboxAdapter;
59
59
 
60
- // Register analyst role if not already present
60
+ // Register analyst role if not already present. Use getRole() (exact match,
61
+ // returns undefined on a miss) rather than resolveRole(): the real
62
+ // DefaultRoleRegistry.resolveRole() never throws on an unknown role — it
63
+ // logs "Role 'analyst' not found, falling back to 'generic'" and returns
64
+ // GenericRole — so a resolveRole()/try-catch guard would silently skip
65
+ // registration and every spawn would fall back to GenericRole.
61
66
  const registry = this.agentManager.getRoleRegistry();
62
- try {
63
- registry.resolveRole("analyst");
64
- } catch {
67
+ if (!registry.getRole("analyst")) {
65
68
  registry.registerRole(AnalystRole);
66
69
  }
67
70
  }
@@ -189,6 +192,29 @@ export class MacroAgentBackend {
189
192
  config: CognitiveAgentSpawnConfig,
190
193
  taskId?: string,
191
194
  ): Promise<CognitiveAgentSession> {
195
+ // Per-attempt reset hook — runs BEFORE the agent is driven so each attempt
196
+ // (initial spawn AND each refinement re-spawn) starts from a clean external
197
+ // state (e.g. a deleted benchmark reward sink). Best-effort: a failing hook
198
+ // must not abort the spawn.
199
+ if (config.beforeSpawn) {
200
+ try {
201
+ await config.beforeSpawn();
202
+ } catch {
203
+ // Best effort — reset failure should not block the spawn.
204
+ }
205
+ }
206
+
207
+ // Forward env and MCP servers into the raw macro spawn's `config`. Build
208
+ // the AgentConfig only when at least one field is present so non-MCP,
209
+ // non-env spawns keep passing `config: undefined` (byte-identical to M0).
210
+ const rawConfig =
211
+ config.env || config.mcpServers
212
+ ? {
213
+ ...(config.env ? { env: config.env } : {}),
214
+ ...(config.mcpServers ? { mcpServers: config.mcpServers } : {}),
215
+ }
216
+ : undefined;
217
+
192
218
  const spawned = await this.agentManager.spawn({
193
219
  task: config.task.description,
194
220
  task_id: taskId,
@@ -197,7 +223,7 @@ export class MacroAgentBackend {
197
223
  ? this.config.coordinatorAgentId
198
224
  : null,
199
225
  cwd: config.cwd,
200
- config: config.env ? { env: config.env } : undefined,
226
+ config: rawConfig,
201
227
  customPrompt: config.systemPromptAdditions,
202
228
  });
203
229
 
@@ -319,12 +345,18 @@ export class MacroAgentBackend {
319
345
  updateSessionFromEvent(session, update);
320
346
  config.onMessage?.(session.messages[session.messages.length - 1]!);
321
347
  },
348
+ // Forward the caller's completion predicate (e.g. a benchmark reward
349
+ // sink wrote done:true). When it fires, promptUntilDone returns
350
+ // completedExternally:true instead of exhausting maxFollowUps.
351
+ isComplete: config.completionSignal,
322
352
  },
323
353
  );
324
354
 
325
355
  if (session.state === "running") {
326
- session.state = result.doneCalled ? "completed" : "failed";
327
- if (!result.doneCalled) session.error = "Agent did not call done()";
356
+ // External completion (predicate) is a success path, same as done().
357
+ const completed = result.doneCalled || result.completedExternally;
358
+ session.state = completed ? "completed" : "failed";
359
+ if (!completed) session.error = "Agent did not call done()";
328
360
  session.endTime = new Date();
329
361
  session.result = result.doneStatus;
330
362
  }
@@ -11,6 +11,7 @@
11
11
 
12
12
  import type { AgentId } from "../store/types/index.js";
13
13
  import type { TasksAdapter, InboxAdapter } from "../adapters/types.js";
14
+ import type { MCPServerConfig } from "../agent/types.js";
14
15
 
15
16
  // ─────────────────────────────────────────────────────────────────
16
17
  // Agent Session Types (matches cognitive-core/src/runtime/types.ts)
@@ -78,6 +79,29 @@ export interface CognitiveAgentSpawnConfig {
78
79
  cwd?: string;
79
80
  timeout?: number;
80
81
  onMessage?: (message: CognitiveAgentMessage) => void;
82
+ /**
83
+ * MCP servers to mount on the spawned agent (e.g. a per-task tau-env
84
+ * server). Forwarded verbatim into the macro `AgentManager.spawn`'s
85
+ * `config.mcpServers`. Optional / back-compat: non-MCP spawns are
86
+ * unaffected. Matches macro `AgentConfig.mcpServers` (the stdio/http union).
87
+ */
88
+ mcpServers?: MCPServerConfig[];
89
+ /**
90
+ * Optional completion predicate forwarded into `promptUntilDone`. When it
91
+ * returns true the agent loop completes externally (without the macro done()
92
+ * tool) — e.g. a benchmark env wrote its reward sink with done:true. Generic:
93
+ * the backend has no knowledge of what it reads. Back-compat: absent →
94
+ * unchanged.
95
+ */
96
+ completionSignal?: () => boolean | Promise<boolean>;
97
+ /**
98
+ * Optional per-attempt hook invoked BEFORE the agent is driven (and again
99
+ * before each refinement re-spawn, since refine() spreads this config). Used
100
+ * to reset per-attempt external state — e.g. delete a benchmark reward sink so
101
+ * each attempt only sees its OWN completion signal. Back-compat: absent →
102
+ * unchanged.
103
+ */
104
+ beforeSpawn?: () => void | Promise<void>;
81
105
  }
82
106
 
83
107
  // ─────────────────────────────────────────────────────────────────
package/src/index.ts CHANGED
@@ -55,6 +55,8 @@ export {
55
55
  AgentManagerError,
56
56
  type AgentManagerErrorCode,
57
57
  type MCPServerConfig as AgentMCPServerConfig,
58
+ type MCPServerStdioConfig as AgentMCPServerStdioConfig,
59
+ type MCPServerHttpConfig as AgentMCPServerHttpConfig,
58
60
  type AgentConfig as SpawnAgentConfig,
59
61
  } from './agent/types.js';
60
62
 
@@ -67,11 +67,12 @@ describe("setupMailBridge", () => {
67
67
  );
68
68
  });
69
69
 
70
- it("drops non-JSON turns silently", async () => {
70
+ it("delivers plain-text turns into the inbox as a text message", async () => {
71
71
  const logs: string[] = [];
72
72
  await setupMailBridge({
73
73
  connection: conn,
74
74
  inboxAdapter: inbox as any,
75
+ dispatcherAgentId: "dispatcher:host:1234:abc",
75
76
  log: (m) => logs.push(m),
76
77
  });
77
78
 
@@ -81,10 +82,46 @@ describe("setupMailBridge", () => {
81
82
  participant_id: "some-agent",
82
83
  content_type: "text/plain",
83
84
  content: "hello world",
85
+ thread_id: "thread-xyz",
86
+ importance: "high",
87
+ });
88
+
89
+ expect(inbox.send).toHaveBeenCalledOnce();
90
+ const [from, to, content, opts] = inbox.send.mock.calls[0];
91
+ expect(from).toBe("some-agent");
92
+ expect(to).toBe("dispatcher:host:1234:abc");
93
+ // Delivered as a text message that normalizeContent passes through,
94
+ // carrying the conversation id for reply threading.
95
+ expect(content).toMatchObject({
96
+ type: "text",
97
+ text: "hello world",
98
+ _conversationId: "conv-1",
99
+ });
100
+ // Not shaped as a work envelope — the classifier must not match it.
101
+ expect(content).not.toHaveProperty("schema");
102
+ expect(opts?.threadTag).toBe("thread-xyz");
103
+ expect(opts?.importance).toBe("high");
104
+ expect(logs.some((l) => l.includes("Forwarded text turn"))).toBe(true);
105
+ });
106
+
107
+ it("skips empty / whitespace-only text turns", async () => {
108
+ const logs: string[] = [];
109
+ await setupMailBridge({
110
+ connection: conn,
111
+ inboxAdapter: inbox as any,
112
+ log: (m) => logs.push(m),
113
+ });
114
+
115
+ await conn._fire({
116
+ conversation_id: "conv-empty",
117
+ turn_id: "turn-empty",
118
+ participant_id: "some-agent",
119
+ content_type: "text/plain",
120
+ content: " ",
84
121
  });
85
122
 
86
123
  expect(inbox.send).not.toHaveBeenCalled();
87
- expect(logs.some((l) => l.includes("Dropping"))).toBe(true);
124
+ expect(logs.some((l) => l.includes("Skipping empty"))).toBe(true);
88
125
  });
89
126
 
90
127
  describe("with dispatcherAgentId", () => {
@@ -13,6 +13,13 @@
13
13
  * 2. The classifier recognizes `x-dispatch/work` schema and routes the
14
14
  * prompt to a worker agent (existing or freshly spawned).
15
15
  *
16
+ * Turns that aren't structured `x-dispatch/work` envelopes (e.g.
17
+ * natural-language spec-discussion replies) are delivered into the inbox as
18
+ * plain-text messages instead of being dropped. The classifier won't match
19
+ * them (so no worker spawns), but the delivery still wakes the agent via
20
+ * TriggerSystemV2 (importance → wake action) so it reads the reply on its
21
+ * next turn.
22
+ *
16
23
  * Without this bridge, hub-side mail turns never reach macro-agent's
17
24
  * dispatcher. The MessagePort is wired to local inbox events only.
18
25
  */
@@ -135,14 +142,64 @@ export async function setupMailBridge(
135
142
  metadata: { source: "openhive-mail-forward" },
136
143
  });
137
144
 
145
+ // Importance derivation shared by the structured-work and plain-text
146
+ // delivery paths. Defaults to "normal" when the hub doesn't tag the turn.
147
+ const VALID_IMPORTANCE = ["low", "normal", "high", "urgent"];
148
+ const deriveImportance = (
149
+ value: string | undefined,
150
+ ): "low" | "normal" | "high" | "urgent" =>
151
+ typeof value === "string" && VALID_IMPORTANCE.includes(value)
152
+ ? (value as "low" | "normal" | "high" | "urgent")
153
+ : "normal";
154
+
138
155
  const handler = async (params: unknown): Promise<void> => {
139
156
  const turn = (params ?? {}) as MailTurnReceivedParams;
157
+ const wireImportance = deriveImportance(turn.importance);
140
158
  const raw = parseTurnContent(turn.content, turn.content_type);
159
+
160
+ // Plain-text (non-JSON) turn — e.g. a natural-language spec-discussion
161
+ // reply. Previously dropped; now delivered into the local inbox as a
162
+ // text message so the agent wakes (TriggerSystemV2 maps importance to a
163
+ // wake action) and reads it on its next turn. The dispatcher's
164
+ // MessagePort classifier won't match a text payload, so no worker is
165
+ // spawned — correct for a conversational message. The conversation id
166
+ // rides along (mirrors the structured path) so the agent's reply can be
167
+ // threaded back to the right hub conversation.
141
168
  if (!raw) {
142
- log(
143
- `[mail-bridge] Dropping non-JSON turn (conv=${turn.conversation_id ?? "?"} ` +
144
- `participant=${turn.participant_id ?? "?"})`,
145
- );
169
+ const text = typeof turn.content === "string" ? turn.content : "";
170
+ if (!text.trim()) {
171
+ log(
172
+ `[mail-bridge] Skipping empty/non-text turn (conv=${turn.conversation_id ?? "?"} ` +
173
+ `participant=${turn.participant_id ?? "?"})`,
174
+ );
175
+ return;
176
+ }
177
+ const textContent: Record<string, unknown> = {
178
+ type: "text",
179
+ text,
180
+ ...(turn.conversation_id
181
+ ? { _conversationId: turn.conversation_id }
182
+ : {}),
183
+ };
184
+ try {
185
+ await inboxAdapter.send(
186
+ turn.participant_id ?? "openhive-hub",
187
+ recipientId,
188
+ textContent as never,
189
+ {
190
+ threadTag: turn.thread_id,
191
+ importance: wireImportance,
192
+ },
193
+ );
194
+ log(
195
+ `[mail-bridge] Forwarded text turn ${turn.turn_id ?? "?"} into local inbox`,
196
+ );
197
+ } catch (err) {
198
+ log(
199
+ `[mail-bridge] Forward failed for text turn ${turn.turn_id ?? "?"}: ` +
200
+ `${(err as Error).message}`,
201
+ );
202
+ }
146
203
  return;
147
204
  }
148
205
 
@@ -172,15 +229,6 @@ export async function setupMailBridge(
172
229
  ...(turn.conversation_id ? { _conversationId: turn.conversation_id } : {}),
173
230
  };
174
231
 
175
- // Derive importance from the hub's wire params. Default to "normal"
176
- // when the hub doesn't tag the turn (backward compat).
177
- const VALID_IMPORTANCE = ["low", "normal", "high", "urgent"];
178
- const wireImportance =
179
- typeof turn.importance === "string" &&
180
- VALID_IMPORTANCE.includes(turn.importance)
181
- ? (turn.importance as "low" | "normal" | "high" | "urgent")
182
- : "normal";
183
-
184
232
  try {
185
233
  await inboxAdapter.send(
186
234
  turn.participant_id ?? "openhive-hub",
@@ -532,12 +532,35 @@ export class TeamRuntimeV2 {
532
532
  ...options.config,
533
533
  mcpServers: [
534
534
  ...(options.config?.mcpServers ?? []),
535
- ...teamMcpServers.map((s) => ({
536
- name: s.name,
537
- command: s.command,
538
- args: s.args,
539
- env: s.env,
540
- })),
535
+ ...teamMcpServers.map((s) => {
536
+ // openteams' static McpServerEntry is stdio-only, but a
537
+ // resolved loadout entry may carry a remote transport
538
+ // (type/url/headers — see openteams McpProviderSpec). Pass
539
+ // those through unchanged so http/sse loadout servers reach
540
+ // the spawn conversion; otherwise emit the stdio shape.
541
+ const remote = s as unknown as {
542
+ type?: "stdio" | "http" | "sse";
543
+ url?: string;
544
+ headers?: Record<string, string>;
545
+ };
546
+ if (
547
+ (remote.type === "http" || remote.type === "sse") &&
548
+ remote.url
549
+ ) {
550
+ return {
551
+ name: s.name,
552
+ type: remote.type,
553
+ url: remote.url,
554
+ headers: remote.headers,
555
+ };
556
+ }
557
+ return {
558
+ name: s.name,
559
+ command: s.command,
560
+ args: s.args,
561
+ env: s.env,
562
+ };
563
+ }),
541
564
  ],
542
565
  env: {
543
566
  ...options.config?.env,