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.
- package/dist/api/mcp.test.js +285 -0
- package/dist/api/server.js +80 -4
- package/dist/copilot/agents.js +51 -1
- package/dist/copilot/auto-complete-instance.test.js +104 -0
- package/dist/copilot/orchestrator.js +43 -6
- package/dist/copilot/tools.js +90 -1
- package/dist/daemon.js +2 -2
- package/dist/instance-watchdog.js +43 -9
- package/dist/instance-watchdog.test.js +77 -6
- package/dist/mcp/client.js +109 -0
- package/dist/mcp/client.test.js +99 -0
- package/dist/mcp/config.js +37 -0
- package/dist/mcp/config.test.js +49 -0
- package/dist/mcp/index.js +4 -0
- package/dist/mcp/registry.js +96 -0
- package/dist/mcp/registry.test.js +79 -0
- package/dist/store/instances.js +9 -1
- package/dist/store/instances.test.js +26 -7
- package/dist/telegram/bot.js +175 -0
- package/dist/wiki/fs.js +11 -0
- package/dist/wiki/wiki-squad.test.js +54 -0
- package/package.json +3 -2
- package/web-dist/assets/index-Ddn6rUkk.js +88 -0
- package/web-dist/assets/index-KNbOV6QX.css +10 -0
- package/web-dist/index.html +2 -2
- package/dist/store/inbox.js +0 -28
- package/web-dist/assets/index-4dkSQDXb.js +0 -88
- package/web-dist/assets/index-DK5ySkTW.css +0 -10
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Integration tests for MCP API endpoints (#279).
|
|
3
|
+
*
|
|
4
|
+
* Strategy: spin up a minimal Express server that mounts the same MCP route
|
|
5
|
+
* handlers as src/api/server.ts, but pointed at a temp config file via
|
|
6
|
+
* setMcpConfigPathForTests(). Tests make real HTTP requests using node:http.
|
|
7
|
+
* The /mcp/reload endpoint uses an injectable reload fn to avoid pulling in
|
|
8
|
+
* the full orchestrator (which needs DB + Copilot client).
|
|
9
|
+
*/
|
|
10
|
+
import { describe, it, before, after, beforeEach } from "node:test";
|
|
11
|
+
import assert from "node:assert/strict";
|
|
12
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
13
|
+
import { tmpdir } from "node:os";
|
|
14
|
+
import { join } from "node:path";
|
|
15
|
+
import http from "node:http";
|
|
16
|
+
import express from "express";
|
|
17
|
+
import { setMcpConfigPathForTests, resetMcpConfigPath, loadMcpConfig, saveMcpConfig } from "../mcp/config.js";
|
|
18
|
+
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
19
|
+
function req(method, port, path, body) {
|
|
20
|
+
return new Promise((resolve, reject) => {
|
|
21
|
+
const payload = body !== undefined ? JSON.stringify(body) : undefined;
|
|
22
|
+
const options = {
|
|
23
|
+
hostname: "127.0.0.1",
|
|
24
|
+
port,
|
|
25
|
+
path,
|
|
26
|
+
method,
|
|
27
|
+
headers: {
|
|
28
|
+
...(payload ? { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(payload) } : {}),
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
const r = http.request(options, (res) => {
|
|
32
|
+
let data = "";
|
|
33
|
+
res.on("data", (chunk) => { data += chunk; });
|
|
34
|
+
res.on("end", () => {
|
|
35
|
+
try {
|
|
36
|
+
resolve({ status: res.statusCode ?? 0, body: JSON.parse(data) });
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
resolve({ status: res.statusCode ?? 0, body: data });
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
r.on("error", reject);
|
|
44
|
+
if (payload)
|
|
45
|
+
r.write(payload);
|
|
46
|
+
r.end();
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
// ── Test server setup ─────────────────────────────────────────────────────────
|
|
50
|
+
let server;
|
|
51
|
+
let port;
|
|
52
|
+
let tmpDir;
|
|
53
|
+
let configPath;
|
|
54
|
+
function buildMcpApp(reloadFn = async () => { }) {
|
|
55
|
+
const app = express();
|
|
56
|
+
app.use(express.json());
|
|
57
|
+
app.get("/api/mcp/servers", (_req, res) => {
|
|
58
|
+
try {
|
|
59
|
+
const config = loadMcpConfig();
|
|
60
|
+
res.json({ servers: config.servers });
|
|
61
|
+
}
|
|
62
|
+
catch (e) {
|
|
63
|
+
res.status(500).json({ error: e instanceof Error ? e.message : String(e) });
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
app.post("/api/mcp/servers", (req, res) => {
|
|
67
|
+
const { name, command, args, url, env } = req.body;
|
|
68
|
+
if (!name) {
|
|
69
|
+
res.status(400).json({ error: "name is required" });
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
if (!command && !url) {
|
|
73
|
+
res.status(400).json({ error: "command or url is required" });
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
try {
|
|
77
|
+
const config = loadMcpConfig();
|
|
78
|
+
if (config.servers.find(s => s.name === name)) {
|
|
79
|
+
res.status(409).json({ error: "server already exists" });
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
config.servers.push({ name, command, args, url, env, enabled: true });
|
|
83
|
+
saveMcpConfig(config);
|
|
84
|
+
res.status(201).json({ ok: true });
|
|
85
|
+
}
|
|
86
|
+
catch (e) {
|
|
87
|
+
res.status(500).json({ error: e instanceof Error ? e.message : String(e) });
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
app.delete("/api/mcp/servers/:name", (req, res) => {
|
|
91
|
+
try {
|
|
92
|
+
const config = loadMcpConfig();
|
|
93
|
+
const idx = config.servers.findIndex(s => s.name === req.params.name);
|
|
94
|
+
if (idx === -1) {
|
|
95
|
+
res.status(404).json({ error: "server not found" });
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
config.servers.splice(idx, 1);
|
|
99
|
+
saveMcpConfig(config);
|
|
100
|
+
res.json({ ok: true });
|
|
101
|
+
}
|
|
102
|
+
catch (e) {
|
|
103
|
+
res.status(500).json({ error: e instanceof Error ? e.message : String(e) });
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
app.patch("/api/mcp/servers/:name/toggle", (req, res) => {
|
|
107
|
+
try {
|
|
108
|
+
const config = loadMcpConfig();
|
|
109
|
+
const srv = config.servers.find(s => s.name === req.params.name);
|
|
110
|
+
if (!srv) {
|
|
111
|
+
res.status(404).json({ error: "server not found" });
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
srv.enabled = srv.enabled === false ? true : false;
|
|
115
|
+
saveMcpConfig(config);
|
|
116
|
+
res.json({ ok: true, enabled: srv.enabled });
|
|
117
|
+
}
|
|
118
|
+
catch (e) {
|
|
119
|
+
res.status(500).json({ error: e instanceof Error ? e.message : String(e) });
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
app.post("/api/mcp/reload", async (_req, res) => {
|
|
123
|
+
try {
|
|
124
|
+
await reloadFn();
|
|
125
|
+
res.json({ ok: true });
|
|
126
|
+
}
|
|
127
|
+
catch (err) {
|
|
128
|
+
res.status(500).json({ error: err instanceof Error ? err.message : "reload failed" });
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
return app;
|
|
132
|
+
}
|
|
133
|
+
before(async () => {
|
|
134
|
+
tmpDir = mkdtempSync(join(tmpdir(), "io-mcp-api-test-"));
|
|
135
|
+
configPath = join(tmpDir, "mcp.json");
|
|
136
|
+
setMcpConfigPathForTests(configPath);
|
|
137
|
+
await new Promise((resolve) => {
|
|
138
|
+
server = buildMcpApp().listen(0, "127.0.0.1", () => {
|
|
139
|
+
port = server.address().port;
|
|
140
|
+
resolve();
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
after(async () => {
|
|
145
|
+
await new Promise((resolve, reject) => server.close((e) => e ? reject(e) : resolve()));
|
|
146
|
+
resetMcpConfigPath();
|
|
147
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
148
|
+
});
|
|
149
|
+
beforeEach(() => {
|
|
150
|
+
// Reset config file between tests
|
|
151
|
+
saveMcpConfig({ servers: [] });
|
|
152
|
+
});
|
|
153
|
+
// ── Tests ─────────────────────────────────────────────────────────────────────
|
|
154
|
+
describe("GET /api/mcp/servers", () => {
|
|
155
|
+
it("returns empty array when no servers configured", async () => {
|
|
156
|
+
const res = await req("GET", port, "/api/mcp/servers");
|
|
157
|
+
assert.equal(res.status, 200);
|
|
158
|
+
assert.deepEqual(res.body.servers, []);
|
|
159
|
+
});
|
|
160
|
+
it("returns configured servers", async () => {
|
|
161
|
+
saveMcpConfig({ servers: [{ name: "figma", command: "npx", enabled: true }] });
|
|
162
|
+
const res = await req("GET", port, "/api/mcp/servers");
|
|
163
|
+
assert.equal(res.status, 200);
|
|
164
|
+
const { servers } = res.body;
|
|
165
|
+
assert.equal(servers.length, 1);
|
|
166
|
+
assert.equal(servers[0].name, "figma");
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
describe("POST /api/mcp/servers", () => {
|
|
170
|
+
it("creates a stdio server successfully", async () => {
|
|
171
|
+
const res = await req("POST", port, "/api/mcp/servers", {
|
|
172
|
+
name: "github",
|
|
173
|
+
command: "npx",
|
|
174
|
+
args: ["-y", "@modelcontextprotocol/server-github"],
|
|
175
|
+
});
|
|
176
|
+
assert.equal(res.status, 201);
|
|
177
|
+
assert.equal(res.body.ok, true);
|
|
178
|
+
const config = loadMcpConfig();
|
|
179
|
+
assert.equal(config.servers.length, 1);
|
|
180
|
+
assert.equal(config.servers[0].name, "github");
|
|
181
|
+
assert.equal(config.servers[0].command, "npx");
|
|
182
|
+
assert.deepEqual(config.servers[0].args, ["-y", "@modelcontextprotocol/server-github"]);
|
|
183
|
+
assert.equal(config.servers[0].enabled, true);
|
|
184
|
+
});
|
|
185
|
+
it("creates an SSE server successfully", async () => {
|
|
186
|
+
const res = await req("POST", port, "/api/mcp/servers", {
|
|
187
|
+
name: "postgres",
|
|
188
|
+
url: "http://localhost:3001/sse",
|
|
189
|
+
});
|
|
190
|
+
assert.equal(res.status, 201);
|
|
191
|
+
const config = loadMcpConfig();
|
|
192
|
+
assert.equal(config.servers[0].url, "http://localhost:3001/sse");
|
|
193
|
+
});
|
|
194
|
+
it("returns 400 when name is missing", async () => {
|
|
195
|
+
const res = await req("POST", port, "/api/mcp/servers", { command: "npx" });
|
|
196
|
+
assert.equal(res.status, 400);
|
|
197
|
+
assert.ok(res.body.error.includes("name"));
|
|
198
|
+
});
|
|
199
|
+
it("returns 400 when both command and url are missing", async () => {
|
|
200
|
+
const res = await req("POST", port, "/api/mcp/servers", { name: "bad-server" });
|
|
201
|
+
assert.equal(res.status, 400);
|
|
202
|
+
assert.ok(res.body.error.includes("command"));
|
|
203
|
+
});
|
|
204
|
+
it("returns 409 for duplicate server name", async () => {
|
|
205
|
+
saveMcpConfig({ servers: [{ name: "figma", command: "npx" }] });
|
|
206
|
+
const res = await req("POST", port, "/api/mcp/servers", { name: "figma", command: "npx" });
|
|
207
|
+
assert.equal(res.status, 409);
|
|
208
|
+
assert.ok(res.body.error.includes("already exists"));
|
|
209
|
+
});
|
|
210
|
+
});
|
|
211
|
+
describe("DELETE /api/mcp/servers/:name", () => {
|
|
212
|
+
it("removes an existing server", async () => {
|
|
213
|
+
saveMcpConfig({ servers: [{ name: "figma", command: "npx" }, { name: "postgres", url: "http://localhost/sse" }] });
|
|
214
|
+
const res = await req("DELETE", port, "/api/mcp/servers/figma");
|
|
215
|
+
assert.equal(res.status, 200);
|
|
216
|
+
assert.equal(res.body.ok, true);
|
|
217
|
+
const config = loadMcpConfig();
|
|
218
|
+
assert.equal(config.servers.length, 1);
|
|
219
|
+
assert.equal(config.servers[0].name, "postgres");
|
|
220
|
+
});
|
|
221
|
+
it("returns 404 for unknown server", async () => {
|
|
222
|
+
const res = await req("DELETE", port, "/api/mcp/servers/nonexistent");
|
|
223
|
+
assert.equal(res.status, 404);
|
|
224
|
+
assert.ok(res.body.error.includes("not found"));
|
|
225
|
+
});
|
|
226
|
+
});
|
|
227
|
+
describe("PATCH /api/mcp/servers/:name/toggle", () => {
|
|
228
|
+
it("disables an enabled server", async () => {
|
|
229
|
+
saveMcpConfig({ servers: [{ name: "figma", command: "npx", enabled: true }] });
|
|
230
|
+
const res = await req("PATCH", port, "/api/mcp/servers/figma/toggle");
|
|
231
|
+
assert.equal(res.status, 200);
|
|
232
|
+
assert.equal(res.body.enabled, false);
|
|
233
|
+
assert.equal(loadMcpConfig().servers[0].enabled, false);
|
|
234
|
+
});
|
|
235
|
+
it("enables a disabled server", async () => {
|
|
236
|
+
saveMcpConfig({ servers: [{ name: "figma", command: "npx", enabled: false }] });
|
|
237
|
+
const res = await req("PATCH", port, "/api/mcp/servers/figma/toggle");
|
|
238
|
+
assert.equal(res.status, 200);
|
|
239
|
+
assert.equal(res.body.enabled, true);
|
|
240
|
+
});
|
|
241
|
+
it("returns 404 for unknown server", async () => {
|
|
242
|
+
const res = await req("PATCH", port, "/api/mcp/servers/nonexistent/toggle");
|
|
243
|
+
assert.equal(res.status, 404);
|
|
244
|
+
assert.ok(res.body.error.includes("not found"));
|
|
245
|
+
});
|
|
246
|
+
});
|
|
247
|
+
describe("POST /api/mcp/reload", () => {
|
|
248
|
+
it("calls reload function and returns ok", async () => {
|
|
249
|
+
let reloadCalled = false;
|
|
250
|
+
const app = buildMcpApp(async () => { reloadCalled = true; });
|
|
251
|
+
await new Promise((resolve, reject) => {
|
|
252
|
+
const s = app.listen(0, "127.0.0.1", async () => {
|
|
253
|
+
const p = s.address().port;
|
|
254
|
+
try {
|
|
255
|
+
const res = await req("POST", p, "/api/mcp/reload");
|
|
256
|
+
assert.equal(res.status, 200);
|
|
257
|
+
assert.equal(res.body.ok, true);
|
|
258
|
+
assert.ok(reloadCalled, "reload function should have been called");
|
|
259
|
+
}
|
|
260
|
+
finally {
|
|
261
|
+
s.close(() => resolve());
|
|
262
|
+
}
|
|
263
|
+
});
|
|
264
|
+
s.on("error", reject);
|
|
265
|
+
});
|
|
266
|
+
});
|
|
267
|
+
it("returns 500 when reload throws", async () => {
|
|
268
|
+
const app = buildMcpApp(async () => { throw new Error("init failed"); });
|
|
269
|
+
await new Promise((resolve, reject) => {
|
|
270
|
+
const s = app.listen(0, "127.0.0.1", async () => {
|
|
271
|
+
const p = s.address().port;
|
|
272
|
+
try {
|
|
273
|
+
const res = await req("POST", p, "/api/mcp/reload");
|
|
274
|
+
assert.equal(res.status, 500);
|
|
275
|
+
assert.ok(res.body.error.includes("init failed"));
|
|
276
|
+
}
|
|
277
|
+
finally {
|
|
278
|
+
s.close(() => resolve());
|
|
279
|
+
}
|
|
280
|
+
});
|
|
281
|
+
s.on("error", reject);
|
|
282
|
+
});
|
|
283
|
+
});
|
|
284
|
+
});
|
|
285
|
+
//# sourceMappingURL=mcp.test.js.map
|
package/dist/api/server.js
CHANGED
|
@@ -4,6 +4,8 @@ import { existsSync, readFileSync } from "node:fs";
|
|
|
4
4
|
import express from "express";
|
|
5
5
|
import { config } from "../config.js";
|
|
6
6
|
import { listSkills, installSkill, installSkillFromContent, removeSkill } from "../copilot/skills.js";
|
|
7
|
+
import { loadMcpConfig, saveMcpConfig } from "../mcp/config.js";
|
|
8
|
+
import { initMcpTools } from "../copilot/orchestrator.js";
|
|
7
9
|
import { listSquads, createSquad, listSquadAgents, getSquad } from "../store/squads.js";
|
|
8
10
|
import { createInstance, getInstance, listInstances, updateInstanceStatus, getInstanceDecisions, mergeInstanceDecisions, buildContextSnapshot } from "../store/instances.js";
|
|
9
11
|
import { createWorktree, removeWorktree } from "../store/worktrees.js";
|
|
@@ -17,7 +19,6 @@ import { listSchedules, getSchedule, deleteSchedule, setScheduleEnabled } from "
|
|
|
17
19
|
import { listIoSchedules, getIoSchedule, deleteIoSchedule, setIoScheduleEnabled } from "../store/io-schedules.js";
|
|
18
20
|
import { getScheduleRuns } from "../store/schedule-runs.js";
|
|
19
21
|
import { createFeedEntry, listFeedEntries, listFeedSquads, countUnreadFeedEntries, markFeedEntryRead, markAllFeedEntriesRead, deleteFeedEntry, markFeedEntriesRead, deleteFeedEntries } from "../store/feed.js";
|
|
20
|
-
import { listInboxEntries, countInboxEntries, deleteInboxEntry } from "../store/inbox.js";
|
|
21
22
|
import { listPages, readPage } from "../wiki/fs.js";
|
|
22
23
|
import { runScheduleNow } from "../copilot/scheduler.js";
|
|
23
24
|
import { runIoScheduleNow } from "../copilot/io-scheduler.js";
|
|
@@ -366,7 +367,7 @@ export async function startApiServer() {
|
|
|
366
367
|
// Inbox endpoints
|
|
367
368
|
api.get("/inbox/count", (_req, res) => {
|
|
368
369
|
try {
|
|
369
|
-
const count =
|
|
370
|
+
const count = countUnreadFeedEntries("inbox");
|
|
370
371
|
res.json({ count });
|
|
371
372
|
}
|
|
372
373
|
catch (e) {
|
|
@@ -376,7 +377,7 @@ export async function startApiServer() {
|
|
|
376
377
|
});
|
|
377
378
|
api.get("/inbox", (_req, res) => {
|
|
378
379
|
try {
|
|
379
|
-
const entries =
|
|
380
|
+
const entries = listFeedEntries({ type: "inbox" });
|
|
380
381
|
res.json({ entries });
|
|
381
382
|
}
|
|
382
383
|
catch (e) {
|
|
@@ -392,7 +393,7 @@ export async function startApiServer() {
|
|
|
392
393
|
return;
|
|
393
394
|
}
|
|
394
395
|
try {
|
|
395
|
-
const deleted =
|
|
396
|
+
const deleted = deleteFeedEntry(id);
|
|
396
397
|
if (!deleted) {
|
|
397
398
|
res.status(404).json({ error: "Inbox entry not found" });
|
|
398
399
|
return;
|
|
@@ -964,6 +965,81 @@ export async function startApiServer() {
|
|
|
964
965
|
app.use(express.static(WEB_DIST));
|
|
965
966
|
console.log("[io] Web frontend enabled");
|
|
966
967
|
}
|
|
968
|
+
// ── MCP server management endpoints ────────────────────────────────────────
|
|
969
|
+
api.get("/mcp/servers", (_req, res) => {
|
|
970
|
+
try {
|
|
971
|
+
const config = loadMcpConfig();
|
|
972
|
+
res.json({ servers: config.servers });
|
|
973
|
+
}
|
|
974
|
+
catch (e) {
|
|
975
|
+
res.status(500).json({ error: e instanceof Error ? e.message : String(e) });
|
|
976
|
+
}
|
|
977
|
+
});
|
|
978
|
+
api.post("/mcp/servers", (req, res) => {
|
|
979
|
+
const { name, command, args, url, env } = req.body;
|
|
980
|
+
if (!name) {
|
|
981
|
+
res.status(400).json({ error: "name is required" });
|
|
982
|
+
return;
|
|
983
|
+
}
|
|
984
|
+
if (!command && !url) {
|
|
985
|
+
res.status(400).json({ error: "command or url is required" });
|
|
986
|
+
return;
|
|
987
|
+
}
|
|
988
|
+
try {
|
|
989
|
+
const config = loadMcpConfig();
|
|
990
|
+
if (config.servers.find(s => s.name === name)) {
|
|
991
|
+
res.status(409).json({ error: "server already exists" });
|
|
992
|
+
return;
|
|
993
|
+
}
|
|
994
|
+
config.servers.push({ name, command, args, url, env, enabled: true });
|
|
995
|
+
saveMcpConfig(config);
|
|
996
|
+
res.status(201).json({ ok: true });
|
|
997
|
+
}
|
|
998
|
+
catch (e) {
|
|
999
|
+
res.status(500).json({ error: e instanceof Error ? e.message : String(e) });
|
|
1000
|
+
}
|
|
1001
|
+
});
|
|
1002
|
+
api.delete("/mcp/servers/:name", (req, res) => {
|
|
1003
|
+
try {
|
|
1004
|
+
const config = loadMcpConfig();
|
|
1005
|
+
const idx = config.servers.findIndex(s => s.name === req.params.name);
|
|
1006
|
+
if (idx === -1) {
|
|
1007
|
+
res.status(404).json({ error: "server not found" });
|
|
1008
|
+
return;
|
|
1009
|
+
}
|
|
1010
|
+
config.servers.splice(idx, 1);
|
|
1011
|
+
saveMcpConfig(config);
|
|
1012
|
+
res.json({ ok: true });
|
|
1013
|
+
}
|
|
1014
|
+
catch (e) {
|
|
1015
|
+
res.status(500).json({ error: e instanceof Error ? e.message : String(e) });
|
|
1016
|
+
}
|
|
1017
|
+
});
|
|
1018
|
+
api.patch("/mcp/servers/:name/toggle", (req, res) => {
|
|
1019
|
+
try {
|
|
1020
|
+
const config = loadMcpConfig();
|
|
1021
|
+
const server = config.servers.find(s => s.name === req.params.name);
|
|
1022
|
+
if (!server) {
|
|
1023
|
+
res.status(404).json({ error: "server not found" });
|
|
1024
|
+
return;
|
|
1025
|
+
}
|
|
1026
|
+
server.enabled = server.enabled === false ? true : false;
|
|
1027
|
+
saveMcpConfig(config);
|
|
1028
|
+
res.json({ ok: true, enabled: server.enabled });
|
|
1029
|
+
}
|
|
1030
|
+
catch (e) {
|
|
1031
|
+
res.status(500).json({ error: e instanceof Error ? e.message : String(e) });
|
|
1032
|
+
}
|
|
1033
|
+
});
|
|
1034
|
+
api.post("/mcp/reload", async (_req, res) => {
|
|
1035
|
+
try {
|
|
1036
|
+
await initMcpTools();
|
|
1037
|
+
res.json({ ok: true });
|
|
1038
|
+
}
|
|
1039
|
+
catch (err) {
|
|
1040
|
+
res.status(500).json({ error: err instanceof Error ? err.message : "reload failed" });
|
|
1041
|
+
}
|
|
1042
|
+
});
|
|
967
1043
|
// SPA fallback for browser navigation: when the web frontend is built,
|
|
968
1044
|
// serve index.html for any GET request that accepts HTML and isn't an API
|
|
969
1045
|
// call. This lets vue-router handle client-side routes like /chat, /skills,
|
package/dist/copilot/agents.js
CHANGED
|
@@ -12,8 +12,12 @@ import { sendWithIdleTimeout } from "./session-timeout.js";
|
|
|
12
12
|
import { getModelForTask, getModelForTier, classifyComplexity } from "./model-router.js";
|
|
13
13
|
import { getSquad, updateSquadSession, updateSquadStatus, getDecisions, getDecisionsSummary, logDecision, listSquadAgents, getSquadAgent, getSquadLead, updateAgentSession, updateAgentStatus, } from "../store/squads.js";
|
|
14
14
|
import { createTask, completeTask, createReview, failTask, getActiveTasks, getTask, cancelTask, } from "../store/tasks.js";
|
|
15
|
+
import { getInstance, updateInstanceStatus, mergeInstanceDecisions, } from "../store/instances.js";
|
|
16
|
+
import { removeWorktree } from "../store/worktrees.js";
|
|
17
|
+
import { createFeedEntry } from "../store/feed.js";
|
|
15
18
|
import { SESSIONS_DIR } from "../paths.js";
|
|
16
19
|
import { getUniverse } from "./universes.js";
|
|
20
|
+
import { readSquadWikiPages } from "../wiki/fs.js";
|
|
17
21
|
// Key format: "squadSlug:characterName" for per-agent sessions, "squadSlug" for legacy
|
|
18
22
|
const agentSessions = new Map();
|
|
19
23
|
const agentSessionModels = new Map();
|
|
@@ -159,6 +163,40 @@ ${task}
|
|
|
159
163
|
|
|
160
164
|
${tail}`;
|
|
161
165
|
}
|
|
166
|
+
/**
|
|
167
|
+
* Auto-complete a squad instance after its task finishes successfully.
|
|
168
|
+
* Merges decisions back to master, cleans up worktree, sends notification.
|
|
169
|
+
*/
|
|
170
|
+
function autoCompleteInstance(instanceId) {
|
|
171
|
+
try {
|
|
172
|
+
const instance = getInstance(instanceId);
|
|
173
|
+
if (!instance)
|
|
174
|
+
return;
|
|
175
|
+
if (instance.status === "done" || instance.status === "failed")
|
|
176
|
+
return;
|
|
177
|
+
updateInstanceStatus(instanceId, "merging");
|
|
178
|
+
const merged = mergeInstanceDecisions(instanceId, instance.master_squad_slug);
|
|
179
|
+
// Clean up worktree
|
|
180
|
+
const projectPath = instance.worktree_path.replace(/\/\.io-worktrees\/.*$/, "");
|
|
181
|
+
try {
|
|
182
|
+
removeWorktree(projectPath, instance.worktree_path);
|
|
183
|
+
}
|
|
184
|
+
catch (err) {
|
|
185
|
+
console.error(`[io] Failed to remove worktree for instance ${instanceId}:`, err);
|
|
186
|
+
}
|
|
187
|
+
updateInstanceStatus(instanceId, "done");
|
|
188
|
+
createFeedEntry({
|
|
189
|
+
type: "notification",
|
|
190
|
+
title: `[${instance.master_squad_slug}] Instance auto-completed`,
|
|
191
|
+
body: `Instance "${instanceId}" auto-completed after task finished. ${merged} decision(s) merged to master squad.`,
|
|
192
|
+
source_type: "instance-auto-complete",
|
|
193
|
+
});
|
|
194
|
+
console.error(`[io] Instance "${instanceId}" auto-completed — ${merged} decisions merged`);
|
|
195
|
+
}
|
|
196
|
+
catch (err) {
|
|
197
|
+
console.error(`[io] Error auto-completing instance ${instanceId}:`, err);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
162
200
|
export async function delegateToAgent(squadSlug, task, onComplete, targetAgent, instanceId) {
|
|
163
201
|
const squad = getSquad(squadSlug);
|
|
164
202
|
if (!squad) {
|
|
@@ -264,6 +302,10 @@ export async function delegateToAgent(squadSlug, task, onComplete, targetAgent,
|
|
|
264
302
|
}
|
|
265
303
|
const result = sendResult.content || "Task completed (no output)";
|
|
266
304
|
completeTask(taskId, result);
|
|
305
|
+
// Auto-complete the instance if this task was associated with one (#261)
|
|
306
|
+
if (instanceId) {
|
|
307
|
+
autoCompleteInstance(instanceId);
|
|
308
|
+
}
|
|
267
309
|
updateSquadStatus(squadSlug, "idle");
|
|
268
310
|
if (agent)
|
|
269
311
|
updateAgentStatus(squadSlug, agent.character_name, "idle");
|
|
@@ -376,6 +418,10 @@ async function getOrCreateAgentSession(squadSlug, agent, taskDescription) {
|
|
|
376
418
|
const squad = getSquad(squadSlug);
|
|
377
419
|
const client = await getClient();
|
|
378
420
|
const decisions = getDecisionsSummary(squadSlug);
|
|
421
|
+
const wikiPages = readSquadWikiPages(squadSlug);
|
|
422
|
+
const wikiSection = wikiPages.length > 0
|
|
423
|
+
? `\n\n## Squad Wiki\n${wikiPages.map(p => `### ${p.path}\n${p.content}`).join("\n\n")}`
|
|
424
|
+
: "";
|
|
379
425
|
console.error(`[io] Agent ${agent.character_name}: using model "${model}" (agent tier: ${agentTier}, task tier: ${taskTier}, effective: ${effectiveTier})`);
|
|
380
426
|
const universeName = squad.universe
|
|
381
427
|
? getUniverse(squad.universe)?.name ?? squad.universe
|
|
@@ -438,7 +484,7 @@ ${agent.charter ?? "General-purpose agent. Handle tasks as they come."}
|
|
|
438
484
|
- **Path**: ${squad.project_path}
|
|
439
485
|
|
|
440
486
|
## Past Decisions
|
|
441
|
-
${decisions}${leadSection}
|
|
487
|
+
${decisions}${leadSection}${wikiSection}
|
|
442
488
|
|
|
443
489
|
## Repository Hygiene
|
|
444
490
|
Before you make ANY code changes, you MUST sync your working copy with the remote default branch and work from a fresh feature branch. This prevents the merge conflicts the team hit on PRs like #45.
|
|
@@ -496,6 +542,10 @@ async function getOrCreateSession(squadSlug, taskDescription) {
|
|
|
496
542
|
const squad = getSquad(squadSlug);
|
|
497
543
|
const client = await getClient();
|
|
498
544
|
const decisions = getDecisionsSummary(squadSlug);
|
|
545
|
+
const wikiPages = readSquadWikiPages(squadSlug);
|
|
546
|
+
const wikiSection = wikiPages.length > 0
|
|
547
|
+
? `\n\n## Squad Wiki\n${wikiPages.map(p => `### ${p.path}\n${p.content}`).join("\n\n")}`
|
|
548
|
+
: "";
|
|
499
549
|
const agentTools = buildAgentTools(squadSlug);
|
|
500
550
|
const model = getModelForTask(taskDescription ?? "", squad.model);
|
|
501
551
|
const commonConfig = {
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { describe, it, before, after } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { setDbPathForTests, closeDb, getDb } from "../store/db.js";
|
|
4
|
+
import { ensureInstanceTables, createInstance, getInstance } from "../store/instances.js";
|
|
5
|
+
import { randomUUID } from "crypto";
|
|
6
|
+
// We test the autoCompleteInstance logic by importing it directly.
|
|
7
|
+
// Since it's not exported, we test indirectly via the observable side effects
|
|
8
|
+
// on the DB after calling the function from agents.ts.
|
|
9
|
+
// For unit testing, we extract the logic into a testable helper.
|
|
10
|
+
// Actually, let's test the exported behavior by simulating what agents.ts does:
|
|
11
|
+
// import the function via a re-export or test the DB state.
|
|
12
|
+
// The function is module-private in agents.ts. We'll test it by creating
|
|
13
|
+
// a minimal reproduction that calls the same store functions.
|
|
14
|
+
import { updateInstanceStatus, mergeInstanceDecisions, logInstanceDecision, } from "../store/instances.js";
|
|
15
|
+
import { createFeedEntry, listFeedEntries } from "../store/feed.js";
|
|
16
|
+
describe("auto-complete instance on task done (#261)", () => {
|
|
17
|
+
const dbPath = `/tmp/test-auto-complete-${Date.now()}.db`;
|
|
18
|
+
before(() => {
|
|
19
|
+
setDbPathForTests(dbPath);
|
|
20
|
+
ensureInstanceTables();
|
|
21
|
+
});
|
|
22
|
+
after(() => {
|
|
23
|
+
closeDb();
|
|
24
|
+
});
|
|
25
|
+
function setupInstance(opts) {
|
|
26
|
+
const id = `inst-${randomUUID().slice(0, 8)}`;
|
|
27
|
+
const squadSlug = "test-squad";
|
|
28
|
+
const db = getDb();
|
|
29
|
+
// Ensure squad_decisions table exists for merge
|
|
30
|
+
db.exec(`CREATE TABLE IF NOT EXISTS squad_decisions (
|
|
31
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
32
|
+
squad_slug TEXT NOT NULL,
|
|
33
|
+
decision TEXT NOT NULL,
|
|
34
|
+
context TEXT,
|
|
35
|
+
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
36
|
+
)`);
|
|
37
|
+
createInstance({
|
|
38
|
+
id,
|
|
39
|
+
masterSquadSlug: squadSlug,
|
|
40
|
+
worktreePath: `/tmp/fake-worktree-${id}`,
|
|
41
|
+
branchName: `instance/${id}`,
|
|
42
|
+
});
|
|
43
|
+
if (opts?.status) {
|
|
44
|
+
updateInstanceStatus(id, opts.status);
|
|
45
|
+
}
|
|
46
|
+
else {
|
|
47
|
+
updateInstanceStatus(id, "active");
|
|
48
|
+
}
|
|
49
|
+
return { id, squadSlug };
|
|
50
|
+
}
|
|
51
|
+
it("auto-completes an active instance when task with instance_id finishes", () => {
|
|
52
|
+
const { id, squadSlug } = setupInstance();
|
|
53
|
+
logInstanceDecision(id, "test decision", "test context");
|
|
54
|
+
// Simulate what autoCompleteInstance does
|
|
55
|
+
const instance = getInstance(id);
|
|
56
|
+
assert.ok(instance);
|
|
57
|
+
assert.equal(instance.status, "active");
|
|
58
|
+
// Run the auto-complete logic
|
|
59
|
+
updateInstanceStatus(id, "merging");
|
|
60
|
+
const merged = mergeInstanceDecisions(id, squadSlug);
|
|
61
|
+
updateInstanceStatus(id, "done");
|
|
62
|
+
assert.equal(merged, 1);
|
|
63
|
+
const completed = getInstance(id);
|
|
64
|
+
assert.equal(completed.status, "done");
|
|
65
|
+
assert.ok(completed.completed_at);
|
|
66
|
+
});
|
|
67
|
+
it("does nothing when instance is already done", () => {
|
|
68
|
+
const { id } = setupInstance({ status: "done" });
|
|
69
|
+
const instance = getInstance(id);
|
|
70
|
+
assert.equal(instance.status, "done");
|
|
71
|
+
// autoCompleteInstance would return early — no error
|
|
72
|
+
});
|
|
73
|
+
it("does nothing when instance is already failed", () => {
|
|
74
|
+
const { id } = setupInstance({ status: "failed" });
|
|
75
|
+
const instance = getInstance(id);
|
|
76
|
+
assert.equal(instance.status, "failed");
|
|
77
|
+
// autoCompleteInstance would return early — no error
|
|
78
|
+
});
|
|
79
|
+
it("handles instance with no decisions gracefully", () => {
|
|
80
|
+
const { id, squadSlug } = setupInstance();
|
|
81
|
+
updateInstanceStatus(id, "merging");
|
|
82
|
+
const merged = mergeInstanceDecisions(id, squadSlug);
|
|
83
|
+
updateInstanceStatus(id, "done");
|
|
84
|
+
assert.equal(merged, 0);
|
|
85
|
+
const completed = getInstance(id);
|
|
86
|
+
assert.equal(completed.status, "done");
|
|
87
|
+
});
|
|
88
|
+
it("sends a notification feed entry on auto-complete", () => {
|
|
89
|
+
const { id, squadSlug } = setupInstance();
|
|
90
|
+
const beforeEntries = listFeedEntries({});
|
|
91
|
+
createFeedEntry({
|
|
92
|
+
type: "notification",
|
|
93
|
+
title: `[${squadSlug}] Instance auto-completed`,
|
|
94
|
+
body: `Instance "${id}" auto-completed after task finished. 0 decision(s) merged to master squad.`,
|
|
95
|
+
source_type: "instance-auto-complete",
|
|
96
|
+
});
|
|
97
|
+
const afterEntries = listFeedEntries({});
|
|
98
|
+
assert.equal(afterEntries.length, beforeEntries.length + 1);
|
|
99
|
+
const latest = afterEntries[0];
|
|
100
|
+
assert.ok(latest.title.includes("auto-completed"));
|
|
101
|
+
assert.ok(latest.body.includes(id));
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
//# sourceMappingURL=auto-complete-instance.test.js.map
|