heyio 0.31.0 → 0.33.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -16,6 +16,9 @@ import { delegateToAgent, getActiveAgentTasks, clearAgentInMemorySession } from
16
16
  import { saveConfig } from "../config.js";
17
17
  import { checkForUpdate } from "../update.js";
18
18
  import { startInstanceWatchdog } from "../instance-watchdog.js";
19
+ import { loadMcpConfig } from "../mcp/config.js";
20
+ import { McpConnectionManager } from "../mcp/client.js";
21
+ import { createMcpTools } from "../mcp/registry.js";
19
22
  import { createInstance, getInstance, listInstances, updateInstanceStatus, logInstanceDecision, getInstanceDecisions, mergeInstanceDecisions, deleteInstance, buildContextSnapshot, reconcileInstances, ensureInstanceTables, } from "../store/instances.js";
20
23
  import { createWorktree, removeWorktree } from "../store/worktrees.js";
21
24
  // ---------------------------------------------------------------------------
@@ -141,11 +144,40 @@ function getToolDeps() {
141
144
  createWorktree,
142
145
  removeWorktree,
143
146
  activeInstanceId: undefined,
147
+ reloadMcpTools: initMcpTools,
144
148
  };
145
149
  }
150
+ // MCP state — loaded at startup, refreshed on config change
151
+ let mcpToolEntries = [];
152
+ let mcpManager = null;
153
+ export async function initMcpTools() {
154
+ const config = loadMcpConfig();
155
+ if (config.servers.length === 0) {
156
+ mcpToolEntries = [];
157
+ return;
158
+ }
159
+ if (!mcpManager) {
160
+ mcpManager = new McpConnectionManager();
161
+ }
162
+ else {
163
+ await mcpManager.disconnectAll();
164
+ }
165
+ try {
166
+ mcpToolEntries = await createMcpTools(mcpManager, config);
167
+ console.error(`[mcp] Loaded ${mcpToolEntries.length} tool(s) from ${config.servers.filter(s => s.enabled !== false).length} server(s)`);
168
+ }
169
+ catch (err) {
170
+ console.error("[mcp] Error loading MCP tools:", err instanceof Error ? err.message : err);
171
+ mcpToolEntries = [];
172
+ }
173
+ }
174
+ export function getMcpManager() {
175
+ return mcpManager;
176
+ }
146
177
  function getSessionConfig() {
147
178
  const tools = createTools(getToolDeps());
148
- return { tools, skillDirectories: getSkillDirectories() };
179
+ const allTools = [...tools, ...mcpToolEntries.map(e => e.tool)];
180
+ return { tools: allTools, skillDirectories: getSkillDirectories() };
149
181
  }
150
182
  /** Hash of tool names + version — used to detect when tools change across updates. */
151
183
  function toolFingerprint(tools) {
@@ -315,7 +347,7 @@ function invalidateSession() {
315
347
  // ---------------------------------------------------------------------------
316
348
  // Message execution
317
349
  // ---------------------------------------------------------------------------
318
- async function executeOnSession(prompt, callback) {
350
+ async function executeOnSession(prompt, callback, attachments) {
319
351
  const session = await ensureOrchestratorSession();
320
352
  let accumulated = "";
321
353
  const unsubDelta = session.on("assistant.message_delta", (event) => {
@@ -324,7 +356,10 @@ async function executeOnSession(prompt, callback) {
324
356
  callback(delta, false);
325
357
  });
326
358
  try {
327
- const result = await session.sendAndWait({ prompt }, SEND_TIMEOUT_MS);
359
+ const sendPayload = { prompt };
360
+ if (attachments && attachments.length > 0)
361
+ sendPayload.attachments = attachments;
362
+ const result = await session.sendAndWait(sendPayload, SEND_TIMEOUT_MS);
328
363
  unsubDelta();
329
364
  const finalText = result?.data.content ?? accumulated;
330
365
  callback("", true);
@@ -380,7 +415,7 @@ async function processQueue() {
380
415
  let lastError;
381
416
  for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
382
417
  try {
383
- const response = await executeOnSession(taggedPrompt, msg.callback);
418
+ const response = await executeOnSession(taggedPrompt, msg.callback, msg.attachments);
384
419
  logConversation("assistant", response, sourceLabel(msg.source));
385
420
  msg.resolve();
386
421
  lastError = undefined;
@@ -431,6 +466,8 @@ export async function initOrchestrator(copilotClient) {
431
466
  }
432
467
  startInstanceWatchdog();
433
468
  clearStaleTasks();
469
+ // Load MCP server tools
470
+ await initMcpTools();
434
471
  // Validate the configured model and resolve model tiers
435
472
  try {
436
473
  const models = await copilotClient.listModels();
@@ -473,10 +510,10 @@ export async function initOrchestrator(copilotClient) {
473
510
  console.error("[io] Eager session creation failed (will retry on first message):", err instanceof Error ? err.message : err);
474
511
  }
475
512
  }
476
- export async function sendToOrchestrator(prompt, source, callback) {
513
+ export async function sendToOrchestrator(prompt, source, callback, attachments) {
477
514
  logConversation("user", prompt, sourceLabel(source));
478
515
  return new Promise((resolve, reject) => {
479
- messageQueue.push({ prompt, source, callback, resolve, reject });
516
+ messageQueue.push({ prompt, source, callback, attachments, resolve, reject });
480
517
  processQueue();
481
518
  });
482
519
  }
@@ -6,6 +6,7 @@ import { join, dirname, resolve } from "path";
6
6
  import { homedir } from "os";
7
7
  import { UNIVERSES, getOrCreateUniverse, generateUniverseRoster } from "./universes.js";
8
8
  import { createFeedEntry } from "../store/feed.js";
9
+ import { loadMcpConfig, saveMcpConfig } from "../mcp/config.js";
9
10
  import { validateCron, nextRun } from "./cron.js";
10
11
  import { createIoSchedule, deleteIoSchedule, getIoSchedule, listIoSchedules, setIoScheduleEnabled, updateIoScheduleNextRun, } from "../store/io-schedules.js";
11
12
  import { runIoScheduleNow } from "./io-scheduler.js";
@@ -1934,7 +1935,95 @@ export function createTools(deps) {
1934
1935
  return "Notification sent.";
1935
1936
  },
1936
1937
  });
1937
- return [wikiRead, wikiWrite, wikiSearch, wikiDelete, wikiList, squadCreate, squadRecall, squadStatus, squadLogDecision, squadDelegate, squadTaskStatus, squadDelete, squadAnalyze, squadAddAgent, squadAgents, squadRemoveAgent, squadResetAgent, squadSetLead, squadSetQA, squadTaskReviews, squadScheduleCreate, squadScheduleList, squadScheduleDelete, squadSchedulePause, squadScheduleResume, squadScheduleRunNow, scheduleCreate, scheduleList, scheduleDelete, schedulePause, scheduleResume, scheduleRunNow, skillList, skillInstall, skillRemove, skillSearch, configUpdate, checkUpdate, shell, fileOps, bash, readFile, viewTool, grepTool, strReplaceEditor, github, squadInstanceCreate, squadInstanceList, squadInstanceStatus, squadInstanceComplete, squadInstanceAbort, squadInstanceCleanup, squadInstanceActivate, squadInstanceDeactivate, sendToInbox, sendNotification];
1938
+ const mcpServerList = defineTool("mcp_server_list", {
1939
+ description: "List all configured MCP servers with their status (enabled/disabled, connected/disconnected).",
1940
+ skipPermission: true,
1941
+ parameters: z.object({}),
1942
+ handler: async () => {
1943
+ const config = loadMcpConfig();
1944
+ if (config.servers.length === 0)
1945
+ return "No MCP servers configured. Add one with mcp_server_add.";
1946
+ return config.servers.map(s => {
1947
+ const status = s.enabled === false ? "disabled" : "enabled";
1948
+ const transport = s.url ? `SSE: ${s.url}` : `stdio: ${s.command} ${(s.args ?? []).join(" ")}`;
1949
+ return `- **${s.name}** [${status}] — ${transport}`;
1950
+ }).join("\n");
1951
+ },
1952
+ });
1953
+ const mcpServerAdd = defineTool("mcp_server_add", {
1954
+ description: "Add a new MCP server to the configuration. Provide either command+args (stdio transport) or url (SSE transport).",
1955
+ skipPermission: true,
1956
+ parameters: z.object({
1957
+ name: z.string().describe("Unique name for the server (e.g., 'figma', 'postgres')"),
1958
+ command: z.string().optional().describe("Executable command for stdio transport (e.g., 'npx')"),
1959
+ args: z.array(z.string()).optional().describe("Command arguments (e.g., ['-y', '@anthropic/mcp-server-figma'])"),
1960
+ url: z.string().optional().describe("URL for SSE transport (e.g., 'http://localhost:3001/sse')"),
1961
+ env: z.record(z.string(), z.string()).optional().describe("Environment variables for the server process"),
1962
+ }),
1963
+ handler: async ({ name, command, args, url, env }) => {
1964
+ if (!command && !url)
1965
+ return "Error: provide either 'command' (stdio) or 'url' (SSE).";
1966
+ const config = loadMcpConfig();
1967
+ if (config.servers.some(s => s.name === name)) {
1968
+ return `Error: server "${name}" already exists. Remove it first with mcp_server_remove.`;
1969
+ }
1970
+ config.servers.push({ name, command, args, url, env, enabled: true });
1971
+ saveMcpConfig(config);
1972
+ return `MCP server "${name}" added. Use mcp_server_reload to connect.`;
1973
+ },
1974
+ });
1975
+ const mcpServerRemove = defineTool("mcp_server_remove", {
1976
+ description: "Remove an MCP server from the configuration by name.",
1977
+ skipPermission: true,
1978
+ parameters: z.object({
1979
+ name: z.string().describe("Name of the server to remove"),
1980
+ }),
1981
+ handler: async ({ name }) => {
1982
+ const config = loadMcpConfig();
1983
+ const idx = config.servers.findIndex(s => s.name === name);
1984
+ if (idx === -1)
1985
+ return `Server "${name}" not found.`;
1986
+ config.servers.splice(idx, 1);
1987
+ saveMcpConfig(config);
1988
+ return `MCP server "${name}" removed. Use mcp_server_reload to apply.`;
1989
+ },
1990
+ });
1991
+ const mcpServerToggle = defineTool("mcp_server_toggle", {
1992
+ description: "Enable or disable an MCP server without removing it from the config.",
1993
+ skipPermission: true,
1994
+ parameters: z.object({
1995
+ name: z.string().describe("Name of the server to toggle"),
1996
+ enabled: z.boolean().describe("true to enable, false to disable"),
1997
+ }),
1998
+ handler: async ({ name, enabled }) => {
1999
+ const config = loadMcpConfig();
2000
+ const server = config.servers.find(s => s.name === name);
2001
+ if (!server)
2002
+ return `Server "${name}" not found.`;
2003
+ server.enabled = enabled;
2004
+ saveMcpConfig(config);
2005
+ return `MCP server "${name}" ${enabled ? "enabled" : "disabled"}. Use mcp_server_reload to apply changes.`;
2006
+ },
2007
+ });
2008
+ const mcpServerReload = defineTool("mcp_server_reload", {
2009
+ description: "Reload MCP server connections and tools. Call after adding, removing, or toggling servers to apply changes without restarting IO.",
2010
+ skipPermission: true,
2011
+ parameters: z.object({}),
2012
+ handler: async () => {
2013
+ if (!deps.reloadMcpTools)
2014
+ return "MCP reload not available in this context.";
2015
+ try {
2016
+ await deps.reloadMcpTools();
2017
+ const config = loadMcpConfig();
2018
+ const enabled = config.servers.filter(s => s.enabled !== false);
2019
+ return `MCP tools reloaded. ${enabled.length} server(s) active.`;
2020
+ }
2021
+ catch (err) {
2022
+ return `Error reloading MCP tools: ${err instanceof Error ? err.message : String(err)}`;
2023
+ }
2024
+ },
2025
+ });
2026
+ return [wikiRead, wikiWrite, wikiSearch, wikiDelete, wikiList, squadCreate, squadRecall, squadStatus, squadLogDecision, squadDelegate, squadTaskStatus, squadDelete, squadAnalyze, squadAddAgent, squadAgents, squadRemoveAgent, squadResetAgent, squadSetLead, squadSetQA, squadTaskReviews, squadScheduleCreate, squadScheduleList, squadScheduleDelete, squadSchedulePause, squadScheduleResume, squadScheduleRunNow, scheduleCreate, scheduleList, scheduleDelete, schedulePause, scheduleResume, scheduleRunNow, skillList, skillInstall, skillRemove, skillSearch, configUpdate, checkUpdate, shell, fileOps, bash, readFile, viewTool, grepTool, strReplaceEditor, github, squadInstanceCreate, squadInstanceList, squadInstanceStatus, squadInstanceComplete, squadInstanceAbort, squadInstanceCleanup, squadInstanceActivate, squadInstanceDeactivate, sendToInbox, sendNotification, mcpServerList, mcpServerAdd, mcpServerRemove, mcpServerToggle, mcpServerReload];
1938
2027
  }
1939
2028
  function walkDirectory(dir, maxDepth = 3, depth = 0) {
1940
2029
  if (depth >= maxDepth)
package/dist/daemon.js CHANGED
@@ -110,8 +110,8 @@ export async function startDaemon() {
110
110
  await startApiServer();
111
111
  // Wire up Telegram handler
112
112
  if (config.telegramEnabled) {
113
- setTelegramHandler(async (text, chatId, messageId, callback) => {
114
- await sendToOrchestrator(text, { type: "telegram", chatId, messageId }, callback);
113
+ setTelegramHandler(async (text, chatId, messageId, callback, attachments) => {
114
+ await sendToOrchestrator(text, { type: "telegram", chatId, messageId }, callback, attachments);
115
115
  });
116
116
  createBot();
117
117
  await startBot();
@@ -3,23 +3,55 @@
3
3
  *
4
4
  * Periodically checks for active squad instances that haven't had any
5
5
  * task activity beyond a configurable timeout and auto-aborts them.
6
+ * Also detects instances stuck in 'merging' state (#267).
6
7
  */
7
8
  import { getDb } from "./store/db.js";
8
9
  import { updateInstanceStatus } from "./store/instances.js";
9
10
  import { createFeedEntry } from "./store/feed.js";
10
11
  const DEFAULT_CHECK_INTERVAL_MS = 5 * 60_000; // Check every 5 minutes
11
12
  const DEFAULT_STALE_THRESHOLD_MS = 30 * 60_000; // 30 minutes with no task activity
13
+ const DEFAULT_MERGING_THRESHOLD_MS = 5 * 60_000; // 5 minutes stuck in merging
12
14
  /**
13
- * Find active instances whose last task activity exceeds the threshold.
14
- * "Activity" is defined as the most recent started_at or completed_at
15
- * in agent_tasks for that instance, or the instance's created_at if no tasks.
15
+ * Find instances that are stale or stuck.
16
+ *
17
+ * For 'active' instances: uses task-activity-based staleness (last started_at/completed_at).
18
+ * For 'merging' instances: uses wall-clock since entering merging state (shorter threshold).
16
19
  */
17
- export function findStaleInstances(thresholdMs) {
20
+ export function findStaleInstances(thresholdMs, mergingThresholdMs = DEFAULT_MERGING_THRESHOLD_MS) {
18
21
  const db = getDb();
19
22
  const now = Date.now();
20
- const activeInstances = db.prepare("SELECT * FROM squad_instances WHERE status = 'active'").all();
23
+ const instances = db.prepare("SELECT * FROM squad_instances WHERE status IN ('active', 'merging')").all();
21
24
  const stale = [];
22
- for (const instance of activeInstances) {
25
+ for (const instance of instances) {
26
+ if (instance.status === "merging") {
27
+ // Merging instances: use the time they've been in merging state.
28
+ // We approximate this from completed_at (set when status changes to terminal)
29
+ // or fall back to created_at. Since updateInstanceStatus doesn't set completed_at
30
+ // for non-terminal states, we use a query on the DB's internal timestamp approach.
31
+ // Best proxy: last task completed_at (since merging happens after task completion).
32
+ const lastTaskCompleted = db.prepare(`
33
+ SELECT MAX(completed_at) AS last_ts
34
+ FROM agent_tasks
35
+ WHERE instance_id = ?
36
+ `).get(instance.id);
37
+ const rawTs = lastTaskCompleted?.last_ts ?? instance.created_at;
38
+ const lastTs = new Date(rawTs.includes("T") ? rawTs : rawTs + "Z").getTime();
39
+ const idleMs = now - lastTs;
40
+ if (idleMs >= mergingThresholdMs) {
41
+ stale.push({ instance, idleMs });
42
+ }
43
+ continue;
44
+ }
45
+ // Active instances: skip those whose most recent task completed successfully —
46
+ // auto-complete in agents.ts handles these (#261)
47
+ const latestTaskStatus = db.prepare(`
48
+ SELECT status FROM agent_tasks
49
+ WHERE instance_id = ?
50
+ ORDER BY COALESCE(completed_at, started_at) DESC
51
+ LIMIT 1
52
+ `).get(instance.id);
53
+ if (latestTaskStatus?.status === "done")
54
+ continue;
23
55
  // Find the most recent task activity for this instance
24
56
  const lastActivity = db.prepare(`
25
57
  SELECT MAX(COALESCE(completed_at, started_at)) AS last_ts
@@ -41,16 +73,18 @@ export function findStaleInstances(thresholdMs) {
41
73
  export function startInstanceWatchdog(opts = {}) {
42
74
  const checkInterval = opts.checkIntervalMs ?? DEFAULT_CHECK_INTERVAL_MS;
43
75
  const staleThreshold = opts.staleThresholdMs ?? DEFAULT_STALE_THRESHOLD_MS;
76
+ const mergingThreshold = opts.mergingThresholdMs ?? DEFAULT_MERGING_THRESHOLD_MS;
44
77
  const timer = setInterval(() => {
45
78
  try {
46
- const staleInstances = findStaleInstances(staleThreshold);
79
+ const staleInstances = findStaleInstances(staleThreshold, mergingThreshold);
47
80
  for (const { instance, idleMs } of staleInstances) {
48
- console.error(`[instance-watchdog] Auto-aborting stale instance "${instance.id}" idle for ${Math.round(idleMs / 60_000)}m (threshold: ${Math.round(staleThreshold / 60_000)}m)`);
81
+ const reason = instance.status === "merging" ? "stuck in merging" : "idle";
82
+ console.error(`[instance-watchdog] Auto-aborting ${reason} instance "${instance.id}" — ${Math.round(idleMs / 60_000)}m (threshold: ${Math.round(instance.status === "merging" ? mergingThreshold / 60_000 : staleThreshold / 60_000)}m)`);
49
83
  updateInstanceStatus(instance.id, "failed");
50
84
  createFeedEntry({
51
85
  type: "notification",
52
86
  title: `[${instance.master_squad_slug}] Instance auto-aborted`,
53
- body: `Instance "${instance.id}" was auto-aborted after ${Math.round(idleMs / 60_000)} minutes of inactivity. Worktree preserved at: ${instance.worktree_path}`,
87
+ body: `Instance "${instance.id}" was auto-aborted (${reason}) after ${Math.round(idleMs / 60_000)} minutes. Worktree preserved at: ${instance.worktree_path}`,
54
88
  source_type: "instance-watchdog",
55
89
  });
56
90
  if (opts.onAbort) {
@@ -24,7 +24,7 @@ beforeEach(() => {
24
24
  db.prepare("INSERT INTO squads (slug, name, project_path) VALUES (?, ?, ?)").run("test-squad", "Test", "/tmp/test");
25
25
  });
26
26
  describe("instance watchdog", () => {
27
- describe("findStaleInstances", () => {
27
+ describe("findStaleInstances — active instances", () => {
28
28
  it("detects stale active instances with no task activity", () => {
29
29
  const db = getDb();
30
30
  db.prepare(`
@@ -48,7 +48,7 @@ describe("instance watchdog", () => {
48
48
  const stale = findStaleInstances(30 * 60_000);
49
49
  assert.strictEqual(stale.length, 0);
50
50
  });
51
- it("does not flag non-active instances", () => {
51
+ it("does not flag non-active/non-merging instances", () => {
52
52
  const db = getDb();
53
53
  db.prepare(`
54
54
  INSERT INTO squad_instances (id, master_squad_slug, worktree_path, branch_name, status, created_at)
@@ -68,6 +68,80 @@ describe("instance watchdog", () => {
68
68
  assert.ok(stale[0].idleMs >= 44 * 60_000);
69
69
  assert.ok(stale[0].idleMs <= 46 * 60_000);
70
70
  });
71
+ it("skips active instances whose latest task status is done (#261)", () => {
72
+ const db = getDb();
73
+ db.prepare(`
74
+ INSERT INTO squad_instances (id, master_squad_slug, worktree_path, branch_name, status, created_at)
75
+ VALUES (?, ?, ?, ?, 'active', datetime('now', '-60 minutes'))
76
+ `).run("test-squad--task-done", "test-squad", "/tmp/wt7", "test-squad/instance/task-done");
77
+ db.prepare(`
78
+ INSERT INTO agent_tasks (task_id, agent_slug, description, status, instance_id, started_at, completed_at)
79
+ VALUES (?, ?, ?, 'done', ?, datetime('now', '-35 minutes'), datetime('now', '-34 minutes'))
80
+ `).run("task-done-1", "agent-1", "Finished work", "test-squad--task-done");
81
+ const stale = findStaleInstances(30 * 60_000);
82
+ assert.strictEqual(stale.length, 0);
83
+ });
84
+ });
85
+ describe("findStaleInstances — merging instances (#267)", () => {
86
+ it("detects merging instance older than merging threshold", () => {
87
+ const db = getDb();
88
+ db.prepare(`
89
+ INSERT INTO squad_instances (id, master_squad_slug, worktree_path, branch_name, status, created_at)
90
+ VALUES (?, ?, ?, ?, 'merging', datetime('now', '-30 minutes'))
91
+ `).run("test-squad--stuck-merge", "test-squad", "/tmp/wt-merge1", "test-squad/instance/merge1");
92
+ // Task completed 10 minutes ago — merging has been stuck since then
93
+ db.prepare(`
94
+ INSERT INTO agent_tasks (task_id, agent_slug, description, status, instance_id, started_at, completed_at)
95
+ VALUES (?, ?, ?, 'done', ?, datetime('now', '-15 minutes'), datetime('now', '-10 minutes'))
96
+ `).run("task-merge-1", "agent-1", "Work done", "test-squad--stuck-merge");
97
+ // 10 min since last task completed > 5 min merging threshold
98
+ const stale = findStaleInstances(30 * 60_000, 5 * 60_000);
99
+ assert.strictEqual(stale.length, 1);
100
+ assert.strictEqual(stale[0].instance.id, "test-squad--stuck-merge");
101
+ assert.strictEqual(stale[0].instance.status, "merging");
102
+ });
103
+ it("does not flag merging instance younger than merging threshold", () => {
104
+ const db = getDb();
105
+ db.prepare(`
106
+ INSERT INTO squad_instances (id, master_squad_slug, worktree_path, branch_name, status, created_at)
107
+ VALUES (?, ?, ?, ?, 'merging', datetime('now', '-30 minutes'))
108
+ `).run("test-squad--fresh-merge", "test-squad", "/tmp/wt-merge2", "test-squad/instance/merge2");
109
+ // Task completed 2 minutes ago — merging just started
110
+ db.prepare(`
111
+ INSERT INTO agent_tasks (task_id, agent_slug, description, status, instance_id, started_at, completed_at)
112
+ VALUES (?, ?, ?, 'done', ?, datetime('now', '-5 minutes'), datetime('now', '-2 minutes'))
113
+ `).run("task-merge-2", "agent-1", "Work done", "test-squad--fresh-merge");
114
+ // 2 min since last task completed < 5 min merging threshold
115
+ const stale = findStaleInstances(30 * 60_000, 5 * 60_000);
116
+ assert.strictEqual(stale.length, 0);
117
+ });
118
+ it("uses created_at as fallback when merging instance has no tasks", () => {
119
+ const db = getDb();
120
+ db.prepare(`
121
+ INSERT INTO squad_instances (id, master_squad_slug, worktree_path, branch_name, status, created_at)
122
+ VALUES (?, ?, ?, ?, 'merging', datetime('now', '-10 minutes'))
123
+ `).run("test-squad--no-tasks-merge", "test-squad", "/tmp/wt-merge3", "test-squad/instance/merge3");
124
+ // No tasks at all — falls back to created_at (10 min ago > 5 min threshold)
125
+ const stale = findStaleInstances(30 * 60_000, 5 * 60_000);
126
+ assert.strictEqual(stale.length, 1);
127
+ assert.strictEqual(stale[0].instance.id, "test-squad--no-tasks-merge");
128
+ });
129
+ it("does not apply done-task skip logic to merging instances", () => {
130
+ const db = getDb();
131
+ db.prepare(`
132
+ INSERT INTO squad_instances (id, master_squad_slug, worktree_path, branch_name, status, created_at)
133
+ VALUES (?, ?, ?, ?, 'merging', datetime('now', '-30 minutes'))
134
+ `).run("test-squad--merging-done-task", "test-squad", "/tmp/wt-merge4", "test-squad/instance/merge4");
135
+ // Latest task is done — but for merging instances this should NOT skip
136
+ db.prepare(`
137
+ INSERT INTO agent_tasks (task_id, agent_slug, description, status, instance_id, started_at, completed_at)
138
+ VALUES (?, ?, ?, 'done', ?, datetime('now', '-20 minutes'), datetime('now', '-10 minutes'))
139
+ `).run("task-merge-4", "agent-1", "Done", "test-squad--merging-done-task");
140
+ // 10 min since task completed > 5 min merging threshold — should be detected
141
+ const stale = findStaleInstances(30 * 60_000, 5 * 60_000);
142
+ assert.strictEqual(stale.length, 1);
143
+ assert.strictEqual(stale[0].instance.id, "test-squad--merging-done-task");
144
+ });
71
145
  });
72
146
  describe("startInstanceWatchdog", () => {
73
147
  it("calls onAbort for stale instances and stops cleanly", async () => {
@@ -82,11 +156,9 @@ describe("instance watchdog", () => {
82
156
  staleThresholdMs: 30 * 60_000,
83
157
  onAbort: (inst) => aborted.push(inst.id),
84
158
  });
85
- // Wait for at least one interval to fire
86
159
  await new Promise((r) => setTimeout(r, 150));
87
160
  stop();
88
161
  assert.ok(aborted.includes("test-squad--stale"));
89
- // Verify it was marked failed
90
162
  const row = db.prepare("SELECT status FROM squad_instances WHERE id = ?").get("test-squad--stale");
91
163
  assert.strictEqual(row.status, "failed");
92
164
  });
@@ -102,9 +174,8 @@ describe("instance watchdog", () => {
102
174
  staleThresholdMs: 30 * 60_000,
103
175
  onAbort: () => abortCount++,
104
176
  });
105
- stop(); // Stop immediately before any tick fires
177
+ stop();
106
178
  await new Promise((r) => setTimeout(r, 150));
107
- // Instance should NOT have been aborted since we stopped before first tick
108
179
  assert.strictEqual(abortCount, 0);
109
180
  });
110
181
  });
@@ -0,0 +1,109 @@
1
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
2
+ import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
3
+ import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
4
+ export class McpConnectionManager {
5
+ connections = new Map();
6
+ connecting = new Map();
7
+ async getClient(config) {
8
+ const existing = this.connections.get(config.name);
9
+ if (existing)
10
+ return existing;
11
+ // Deduplicate concurrent connection attempts
12
+ const pending = this.connecting.get(config.name);
13
+ if (pending)
14
+ return pending;
15
+ const promise = this.connect(config);
16
+ this.connecting.set(config.name, promise);
17
+ try {
18
+ const client = await promise;
19
+ this.connections.set(config.name, client);
20
+ return client;
21
+ }
22
+ finally {
23
+ this.connecting.delete(config.name);
24
+ }
25
+ }
26
+ async connect(config) {
27
+ const client = new Client({ name: "io-assistant", version: "1.0.0" }, { capabilities: {} });
28
+ let transport;
29
+ if (config.url) {
30
+ transport = new SSEClientTransport(new URL(config.url));
31
+ }
32
+ else if (config.command) {
33
+ transport = new StdioClientTransport({
34
+ command: config.command,
35
+ args: config.args,
36
+ env: config.env,
37
+ });
38
+ }
39
+ else {
40
+ throw new Error(`MCP server "${config.name}" has no command or url configured`);
41
+ }
42
+ await client.connect(transport);
43
+ return client;
44
+ }
45
+ async listTools(config) {
46
+ const execute = async () => {
47
+ const client = await this.getClient(config);
48
+ const result = await client.listTools();
49
+ return (result.tools ?? []).map((t) => ({
50
+ name: t.name,
51
+ description: t.description,
52
+ inputSchema: t.inputSchema,
53
+ }));
54
+ };
55
+ try {
56
+ return await execute();
57
+ }
58
+ catch (err) {
59
+ console.error(`[mcp] listTools failed for ${config.name}, attempting reconnect:`, err instanceof Error ? err.message : err);
60
+ this.connections.delete(config.name);
61
+ return await execute();
62
+ }
63
+ }
64
+ async callTool(config, toolName, args) {
65
+ const execute = async () => {
66
+ const client = await this.getClient(config);
67
+ const result = await client.callTool({ name: toolName, arguments: args });
68
+ // MCP returns content as an array of content blocks
69
+ if (result.content && Array.isArray(result.content)) {
70
+ return result.content
71
+ .map((block) => block.type === "text" ? block.text : JSON.stringify(block))
72
+ .join("\n");
73
+ }
74
+ return result.content ?? result;
75
+ };
76
+ try {
77
+ return await execute();
78
+ }
79
+ catch (err) {
80
+ // Connection likely dead — clear and retry once
81
+ console.error(`[mcp] Tool call failed for ${config.name}/${toolName}, attempting reconnect:`, err instanceof Error ? err.message : err);
82
+ this.connections.delete(config.name);
83
+ try {
84
+ return await execute();
85
+ }
86
+ catch (retryErr) {
87
+ throw new Error(`MCP tool ${config.name}/${toolName} failed after reconnect: ${retryErr instanceof Error ? retryErr.message : String(retryErr)}`);
88
+ }
89
+ }
90
+ }
91
+ async disconnect(name) {
92
+ const client = this.connections.get(name);
93
+ if (client) {
94
+ try {
95
+ await client.close();
96
+ }
97
+ catch { /* ignore */ }
98
+ this.connections.delete(name);
99
+ }
100
+ }
101
+ async disconnectAll() {
102
+ const names = [...this.connections.keys()];
103
+ await Promise.allSettled(names.map((n) => this.disconnect(n)));
104
+ }
105
+ isConnected(name) {
106
+ return this.connections.has(name);
107
+ }
108
+ }
109
+ //# sourceMappingURL=client.js.map
@@ -0,0 +1,99 @@
1
+ import { describe, it } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { McpConnectionManager } from "./client.js";
4
+ describe("McpConnectionManager — auto-reconnect", () => {
5
+ const testConfig = { name: "test-server", command: "echo", args: ["hello"] };
6
+ it("callTool retries once on connection failure", async () => {
7
+ const manager = new McpConnectionManager();
8
+ let attempts = 0;
9
+ // Inject a mock client that fails on first callTool, succeeds on second
10
+ const mockClient = {
11
+ callTool: async () => {
12
+ attempts++;
13
+ if (attempts === 1)
14
+ throw new Error("connection reset");
15
+ return { content: [{ type: "text", text: "success" }] };
16
+ },
17
+ listTools: async () => ({ tools: [] }),
18
+ close: async () => { },
19
+ };
20
+ // Inject into connections map
21
+ manager.connections.set("test-server", mockClient);
22
+ // Override getClient to return a fresh mock on reconnect
23
+ const originalGetClient = manager.getClient.bind(manager);
24
+ manager.getClient = async (config) => {
25
+ if (!manager.connections.has(config.name)) {
26
+ // Simulate reconnect — put a working mock
27
+ const freshMock = {
28
+ callTool: async () => ({ content: [{ type: "text", text: "reconnected" }] }),
29
+ close: async () => { },
30
+ };
31
+ manager.connections.set(config.name, freshMock);
32
+ return freshMock;
33
+ }
34
+ return manager.connections.get(config.name);
35
+ };
36
+ const result = await manager.callTool(testConfig, "test_tool", {});
37
+ assert.equal(result, "reconnected");
38
+ });
39
+ it("callTool throws after retry also fails", async () => {
40
+ const manager = new McpConnectionManager();
41
+ const failingClient = {
42
+ callTool: async () => { throw new Error("permanently broken"); },
43
+ close: async () => { },
44
+ };
45
+ manager.connections.set("test-server", failingClient);
46
+ // Override getClient to always return a broken client
47
+ manager.getClient = async () => failingClient;
48
+ await assert.rejects(() => manager.callTool(testConfig, "test_tool", {}), (err) => {
49
+ assert.ok(err.message.includes("failed after reconnect"));
50
+ return true;
51
+ });
52
+ });
53
+ it("listTools retries once on connection failure", async () => {
54
+ const manager = new McpConnectionManager();
55
+ let attempts = 0;
56
+ const mockClient = {
57
+ listTools: async () => {
58
+ attempts++;
59
+ if (attempts === 1)
60
+ throw new Error("connection lost");
61
+ return { tools: [{ name: "tool1", description: "A tool", inputSchema: {} }] };
62
+ },
63
+ close: async () => { },
64
+ };
65
+ manager.connections.set("test-server", mockClient);
66
+ manager.getClient = async (config) => {
67
+ if (!manager.connections.has(config.name)) {
68
+ const freshMock = {
69
+ listTools: async () => ({ tools: [{ name: "tool1", description: "A tool", inputSchema: {} }] }),
70
+ close: async () => { },
71
+ };
72
+ manager.connections.set(config.name, freshMock);
73
+ return freshMock;
74
+ }
75
+ return manager.connections.get(config.name);
76
+ };
77
+ const tools = await manager.listTools(testConfig);
78
+ assert.equal(tools.length, 1);
79
+ assert.equal(tools[0].name, "tool1");
80
+ });
81
+ it("clears dead connection from map on failure", async () => {
82
+ const manager = new McpConnectionManager();
83
+ const deadClient = {
84
+ callTool: async () => { throw new Error("dead"); },
85
+ close: async () => { },
86
+ };
87
+ manager.connections.set("test-server", deadClient);
88
+ assert.ok(manager.isConnected("test-server"));
89
+ // Override getClient to simulate a reconnect failure too
90
+ manager.getClient = async () => { throw new Error("cannot reconnect"); };
91
+ try {
92
+ await manager.callTool(testConfig, "test_tool", {});
93
+ }
94
+ catch { /* expected */ }
95
+ // Dead connection should have been cleared
96
+ assert.ok(!manager.isConnected("test-server"));
97
+ });
98
+ });
99
+ //# sourceMappingURL=client.test.js.map