heyio 0.40.0 → 0.41.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.
Files changed (29) hide show
  1. package/dist/api/server.js +96 -2
  2. package/dist/api/wiki.test.js +283 -0
  3. package/dist/copilot/skills.js +72 -0
  4. package/dist/copilot/skills.test.js +55 -0
  5. package/dist/store/db.js +28 -0
  6. package/dist/store/squads.js +33 -1
  7. package/dist/store/squads.test.js +52 -0
  8. package/package.json +1 -1
  9. package/web-dist/assets/{AgentActivityView-D-xGvKsm.js → AgentActivityView-Dxppa-7j.js} +1 -1
  10. package/web-dist/assets/{ChatView-DV4zdwDI.js → ChatView-CFkHd2XI.js} +1 -1
  11. package/web-dist/assets/FeedView-B6ehFAhf.js +1 -0
  12. package/web-dist/assets/{InboxView-C_6LL8bG.js → InboxView-Btmn836W.js} +1 -1
  13. package/web-dist/assets/{LoginView-DNcKxnq-.js → LoginView-CiBwBd04.js} +1 -1
  14. package/web-dist/assets/{McpView-CbiIag57.js → McpView-BhOHMFKD.js} +1 -1
  15. package/web-dist/assets/{SchedulesView-uxtLtxEL.js → SchedulesView-COk9mHap.js} +1 -1
  16. package/web-dist/assets/{SettingsTabs.vue_vue_type_script_setup_true_lang-BE0YBCo4.js → SettingsTabs.vue_vue_type_script_setup_true_lang-CVs3VGNq.js} +1 -1
  17. package/web-dist/assets/SkillsView-32U-60Dv.js +1 -0
  18. package/web-dist/assets/SquadsView-ByOGRKXx.js +1 -0
  19. package/web-dist/assets/StatusIndicator.vue_vue_type_script_setup_true_lang-B7XkblIY.js +1 -0
  20. package/web-dist/assets/WikiView-CqiPSoBp.js +1 -0
  21. package/web-dist/assets/{index-D1C7prBJ.js → index-CQJxzKQb.js} +24 -24
  22. package/web-dist/assets/index-DC4EVTvc.css +10 -0
  23. package/web-dist/index.html +2 -2
  24. package/web-dist/assets/FeedView-BBbnBU_A.js +0 -1
  25. package/web-dist/assets/SkillsView-CobJkgd1.js +0 -1
  26. package/web-dist/assets/SquadsView-DpI4RInq.js +0 -1
  27. package/web-dist/assets/StatusIndicator.vue_vue_type_script_setup_true_lang-BwmRY2qH.js +0 -1
  28. package/web-dist/assets/WikiView-DeY1mFGq.js +0 -1
  29. package/web-dist/assets/index-DMGoXFX1.css +0 -10
@@ -19,7 +19,7 @@ import { listSchedules, getSchedule, deleteSchedule, setScheduleEnabled } from "
19
19
  import { listIoSchedules, getIoSchedule, deleteIoSchedule, setIoScheduleEnabled } from "../store/io-schedules.js";
20
20
  import { getScheduleRuns } from "../store/schedule-runs.js";
21
21
  import { createFeedEntry, listFeedEntries, listFeedSquads, countUnreadFeedEntries, markFeedEntryRead, markAllFeedEntriesRead, deleteFeedEntry, markFeedEntriesRead, deleteFeedEntries } from "../store/feed.js";
22
- import { listPages, readPage } from "../wiki/fs.js";
22
+ import { listPages, readPage, writePage, deletePage, assertPagePath } from "../wiki/fs.js";
23
23
  import { runScheduleNow } from "../copilot/scheduler.js";
24
24
  import { runIoScheduleNow } from "../copilot/io-scheduler.js";
25
25
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
@@ -958,7 +958,7 @@ export async function startApiServer() {
958
958
  });
959
959
  api.get("/wiki/*path", (req, res) => {
960
960
  try {
961
- const pagePath = Array.isArray(req.params.path) ? req.params.path[0] : req.params.path;
961
+ const pagePath = Array.isArray(req.params.path) ? req.params.path.join("/") : req.params.path;
962
962
  if (!pagePath) {
963
963
  res.status(400).json({ error: "Missing page path" });
964
964
  return;
@@ -975,6 +975,100 @@ export async function startApiServer() {
975
975
  res.status(500).json({ error: "Failed to read wiki page" });
976
976
  }
977
977
  });
978
+ // Create a new wiki page
979
+ api.post("/wiki", (req, res) => {
980
+ try {
981
+ const { path: pagePath, content } = req.body;
982
+ if (!pagePath || typeof pagePath !== "string") {
983
+ res.status(400).json({ error: "Missing page path" });
984
+ return;
985
+ }
986
+ if (content === undefined || typeof content !== "string") {
987
+ res.status(400).json({ error: "Missing page content" });
988
+ return;
989
+ }
990
+ try {
991
+ assertPagePath(pagePath);
992
+ }
993
+ catch (e) {
994
+ res.status(400).json({ error: e.message });
995
+ return;
996
+ }
997
+ if (readPage(pagePath) !== undefined) {
998
+ res.status(409).json({ error: "Page already exists" });
999
+ return;
1000
+ }
1001
+ writePage(pagePath, content);
1002
+ res.status(201).json({ path: pagePath, content });
1003
+ }
1004
+ catch (e) {
1005
+ console.error("Error creating wiki page:", e);
1006
+ res.status(500).json({ error: "Failed to create wiki page" });
1007
+ }
1008
+ });
1009
+ // Update an existing wiki page
1010
+ api.put("/wiki/*path", (req, res) => {
1011
+ try {
1012
+ const pagePath = Array.isArray(req.params.path) ? req.params.path.join("/") : req.params.path;
1013
+ if (!pagePath) {
1014
+ res.status(400).json({ error: "Missing page path" });
1015
+ return;
1016
+ }
1017
+ const { content } = req.body;
1018
+ if (content === undefined || typeof content !== "string") {
1019
+ res.status(400).json({ error: "Missing page content" });
1020
+ return;
1021
+ }
1022
+ try {
1023
+ assertPagePath(pagePath);
1024
+ }
1025
+ catch (e) {
1026
+ res.status(400).json({ error: e.message });
1027
+ return;
1028
+ }
1029
+ if (readPage(pagePath) === undefined) {
1030
+ res.status(404).json({ error: "Page not found" });
1031
+ return;
1032
+ }
1033
+ writePage(pagePath, content);
1034
+ res.json({ path: pagePath, content });
1035
+ }
1036
+ catch (e) {
1037
+ console.error("Error updating wiki page:", e);
1038
+ res.status(500).json({ error: "Failed to update wiki page" });
1039
+ }
1040
+ });
1041
+ // Delete a wiki page
1042
+ api.delete("/wiki/*path", (req, res) => {
1043
+ try {
1044
+ const pagePath = Array.isArray(req.params.path) ? req.params.path.join("/") : req.params.path;
1045
+ if (!pagePath) {
1046
+ res.status(400).json({ error: "Missing page path" });
1047
+ return;
1048
+ }
1049
+ try {
1050
+ assertPagePath(pagePath);
1051
+ }
1052
+ catch (e) {
1053
+ res.status(400).json({ error: e.message });
1054
+ return;
1055
+ }
1056
+ const deleted = deletePage(pagePath);
1057
+ if (!deleted) {
1058
+ res.status(404).json({ error: "Page not found" });
1059
+ return;
1060
+ }
1061
+ res.status(204).send();
1062
+ }
1063
+ catch (e) {
1064
+ console.error("Error deleting wiki page:", e);
1065
+ res.status(500).json({ error: "Failed to delete wiki page" });
1066
+ }
1067
+ });
1068
+ // Get available wiki categories
1069
+ api.get("/wiki-categories", (_req, res) => {
1070
+ res.json({ categories: ["preferences", "projects", "people", "general", "squads"] });
1071
+ });
978
1072
  // Mount API at /api (for frontend)
979
1073
  app.use("/api", api);
980
1074
  // Serve Vue frontend if built assets exist (before backward-compat API mount)
@@ -0,0 +1,283 @@
1
+ /**
2
+ * Integration tests for Wiki API endpoints (#312).
3
+ * Tests POST, PUT, DELETE endpoints for wiki pages.
4
+ */
5
+ import { describe, it, before, after, afterEach } from "node:test";
6
+ import assert from "node:assert/strict";
7
+ import http from "node:http";
8
+ import express from "express";
9
+ import { listPages, readPage, writePage, deletePage, assertPagePath, ensureWikiStructure, } from "../wiki/fs.js";
10
+ // ── Helpers ───────────────────────────────────────────────────────────────────
11
+ function req(method, port, path, body) {
12
+ return new Promise((resolve, reject) => {
13
+ const payload = body !== undefined ? JSON.stringify(body) : undefined;
14
+ const options = {
15
+ hostname: "127.0.0.1",
16
+ port,
17
+ path,
18
+ method,
19
+ headers: {
20
+ ...(payload ? { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(payload) } : {}),
21
+ },
22
+ };
23
+ const r = http.request(options, (res) => {
24
+ let data = "";
25
+ res.on("data", (chunk) => { data += chunk; });
26
+ res.on("end", () => {
27
+ try {
28
+ resolve({ status: res.statusCode ?? 0, body: data ? JSON.parse(data) : {} });
29
+ }
30
+ catch {
31
+ resolve({ status: res.statusCode ?? 0, body: data });
32
+ }
33
+ });
34
+ });
35
+ r.on("error", reject);
36
+ if (payload)
37
+ r.write(payload);
38
+ r.end();
39
+ });
40
+ }
41
+ // ── Server Setup ──────────────────────────────────────────────────────────────
42
+ function createTestServer() {
43
+ const app = express();
44
+ app.use(express.json());
45
+ // Wiki list
46
+ app.get("/api/wiki", (_req, res) => {
47
+ try {
48
+ const pages = listPages();
49
+ const result = pages.map((pagePath) => {
50
+ const pageContent = readPage(pagePath);
51
+ const title = pageContent?.match(/^#\s+(.+)/m)?.[1]?.trim() ?? pagePath;
52
+ return { path: pagePath, title };
53
+ });
54
+ res.json({ pages: result });
55
+ }
56
+ catch (e) {
57
+ res.status(500).json({ error: "Failed to list wiki pages" });
58
+ }
59
+ });
60
+ // Wiki read
61
+ app.get("/api/wiki/*path", (req, res) => {
62
+ const pagePath = Array.isArray(req.params.path) ? req.params.path.join("/") : req.params.path;
63
+ if (!pagePath) {
64
+ res.status(400).json({ error: "Missing page path" });
65
+ return;
66
+ }
67
+ const pageContent = readPage(pagePath);
68
+ if (pageContent === undefined) {
69
+ res.status(404).json({ error: "Page not found" });
70
+ return;
71
+ }
72
+ res.json({ path: pagePath, content: pageContent });
73
+ });
74
+ // Wiki create
75
+ app.post("/api/wiki", (req, res) => {
76
+ const { path: pagePath, content } = req.body;
77
+ if (!pagePath || typeof pagePath !== "string") {
78
+ res.status(400).json({ error: "Missing page path" });
79
+ return;
80
+ }
81
+ if (content === undefined || typeof content !== "string") {
82
+ res.status(400).json({ error: "Missing page content" });
83
+ return;
84
+ }
85
+ try {
86
+ assertPagePath(pagePath);
87
+ }
88
+ catch (e) {
89
+ res.status(400).json({ error: e.message });
90
+ return;
91
+ }
92
+ if (readPage(pagePath) !== undefined) {
93
+ res.status(409).json({ error: "Page already exists" });
94
+ return;
95
+ }
96
+ writePage(pagePath, content);
97
+ res.status(201).json({ path: pagePath, content });
98
+ });
99
+ // Wiki update
100
+ app.put("/api/wiki/*path", (req, res) => {
101
+ const pagePath = Array.isArray(req.params.path) ? req.params.path.join("/") : req.params.path;
102
+ if (!pagePath) {
103
+ res.status(400).json({ error: "Missing page path" });
104
+ return;
105
+ }
106
+ const { content } = req.body;
107
+ if (content === undefined || typeof content !== "string") {
108
+ res.status(400).json({ error: "Missing page content" });
109
+ return;
110
+ }
111
+ try {
112
+ assertPagePath(pagePath);
113
+ }
114
+ catch (e) {
115
+ res.status(400).json({ error: e.message });
116
+ return;
117
+ }
118
+ if (readPage(pagePath) === undefined) {
119
+ res.status(404).json({ error: "Page not found" });
120
+ return;
121
+ }
122
+ writePage(pagePath, content);
123
+ res.json({ path: pagePath, content });
124
+ });
125
+ // Wiki delete
126
+ app.delete("/api/wiki/*path", (req, res) => {
127
+ const pagePath = Array.isArray(req.params.path) ? req.params.path.join("/") : req.params.path;
128
+ if (!pagePath) {
129
+ res.status(400).json({ error: "Missing page path" });
130
+ return;
131
+ }
132
+ try {
133
+ assertPagePath(pagePath);
134
+ }
135
+ catch (e) {
136
+ res.status(400).json({ error: e.message });
137
+ return;
138
+ }
139
+ const deleted = deletePage(pagePath);
140
+ if (!deleted) {
141
+ res.status(404).json({ error: "Page not found" });
142
+ return;
143
+ }
144
+ res.status(204).send();
145
+ });
146
+ // Wiki categories
147
+ app.get("/api/wiki-categories", (_req, res) => {
148
+ res.json({ categories: ["preferences", "projects", "people", "general", "squads"] });
149
+ });
150
+ return app;
151
+ }
152
+ // ── Tests ─────────────────────────────────────────────────────────────────────
153
+ describe("Wiki API Endpoints", () => {
154
+ let server;
155
+ let port;
156
+ const testPagePath = `pages/general/test-wiki-api-${Date.now()}.md`;
157
+ before(() => {
158
+ ensureWikiStructure();
159
+ const app = createTestServer();
160
+ server = app.listen(0);
161
+ port = server.address().port;
162
+ });
163
+ after(() => {
164
+ server.close();
165
+ // Clean up test page if exists
166
+ try {
167
+ deletePage(testPagePath);
168
+ }
169
+ catch { /* ignore */ }
170
+ });
171
+ afterEach(() => {
172
+ // Clean up test page after each test
173
+ try {
174
+ deletePage(testPagePath);
175
+ }
176
+ catch { /* ignore */ }
177
+ });
178
+ describe("GET /api/wiki", () => {
179
+ it("returns list of wiki pages", async () => {
180
+ const { status, body } = await req("GET", port, "/api/wiki");
181
+ assert.equal(status, 200);
182
+ assert.ok(Array.isArray(body.pages));
183
+ });
184
+ });
185
+ describe("GET /api/wiki-categories", () => {
186
+ it("returns available categories", async () => {
187
+ const { status, body } = await req("GET", port, "/api/wiki-categories");
188
+ assert.equal(status, 200);
189
+ const categories = body.categories;
190
+ assert.ok(categories.includes("general"));
191
+ assert.ok(categories.includes("projects"));
192
+ assert.ok(categories.includes("preferences"));
193
+ });
194
+ });
195
+ describe("POST /api/wiki", () => {
196
+ it("creates a new wiki page", async () => {
197
+ const { status, body } = await req("POST", port, "/api/wiki", {
198
+ path: testPagePath,
199
+ content: "# Test Page\n\nThis is a test.",
200
+ });
201
+ assert.equal(status, 201);
202
+ assert.equal(body.path, testPagePath);
203
+ // Verify it was created
204
+ const page = readPage(testPagePath);
205
+ assert.ok(page?.includes("# Test Page"));
206
+ });
207
+ it("returns 409 if page already exists", async () => {
208
+ writePage(testPagePath, "# Existing");
209
+ const { status, body } = await req("POST", port, "/api/wiki", {
210
+ path: testPagePath,
211
+ content: "# New Content",
212
+ });
213
+ assert.equal(status, 409);
214
+ assert.equal(body.error, "Page already exists");
215
+ });
216
+ it("returns 400 for missing path", async () => {
217
+ const { status } = await req("POST", port, "/api/wiki", {
218
+ content: "# Test",
219
+ });
220
+ assert.equal(status, 400);
221
+ });
222
+ it("returns 400 for missing content", async () => {
223
+ const { status } = await req("POST", port, "/api/wiki", {
224
+ path: testPagePath,
225
+ });
226
+ assert.equal(status, 400);
227
+ });
228
+ it("returns 400 for invalid path (not under pages/)", async () => {
229
+ const { status, body } = await req("POST", port, "/api/wiki", {
230
+ path: "invalid/path.md",
231
+ content: "# Test",
232
+ });
233
+ assert.equal(status, 400);
234
+ assert.ok(body.error.includes("pages/"));
235
+ });
236
+ });
237
+ describe("PUT /api/wiki/*path", () => {
238
+ it("updates an existing wiki page", async () => {
239
+ writePage(testPagePath, "# Original Content");
240
+ const { status, body } = await req("PUT", port, `/api/wiki/${testPagePath}`, {
241
+ content: "# Updated Content\n\nNew text here.",
242
+ });
243
+ assert.equal(status, 200);
244
+ assert.equal(body.path, testPagePath);
245
+ // Verify it was updated
246
+ const page = readPage(testPagePath);
247
+ assert.ok(page?.includes("Updated Content"));
248
+ });
249
+ it("returns 404 if page does not exist", async () => {
250
+ const { status, body } = await req("PUT", port, `/api/wiki/${testPagePath}`, {
251
+ content: "# New Content",
252
+ });
253
+ assert.equal(status, 404);
254
+ assert.equal(body.error, "Page not found");
255
+ });
256
+ it("returns 400 for missing content", async () => {
257
+ writePage(testPagePath, "# Original");
258
+ const { status } = await req("PUT", port, `/api/wiki/${testPagePath}`, {});
259
+ assert.equal(status, 400);
260
+ });
261
+ });
262
+ describe("DELETE /api/wiki/*path", () => {
263
+ it("deletes an existing wiki page", async () => {
264
+ writePage(testPagePath, "# To Delete");
265
+ const { status } = await req("DELETE", port, `/api/wiki/${testPagePath}`);
266
+ assert.equal(status, 204);
267
+ // Verify it was deleted
268
+ const page = readPage(testPagePath);
269
+ assert.equal(page, undefined);
270
+ });
271
+ it("returns 404 if page does not exist", async () => {
272
+ const { status, body } = await req("DELETE", port, `/api/wiki/${testPagePath}`);
273
+ assert.equal(status, 404);
274
+ assert.equal(body.error, "Page not found");
275
+ });
276
+ it("returns 400 for invalid path", async () => {
277
+ const { status, body } = await req("DELETE", port, "/api/wiki/invalid-path.md");
278
+ assert.equal(status, 400);
279
+ assert.ok(body.error.includes("pages/"));
280
+ });
281
+ });
282
+ });
283
+ //# sourceMappingURL=wiki.test.js.map
@@ -310,4 +310,76 @@ export async function searchSkillsRegistry(query) {
310
310
  return [];
311
311
  }
312
312
  }
313
+ /**
314
+ * Update a skill's metadata (name and/or description).
315
+ * Rewrites the SKILL.md file with the updated frontmatter while preserving content.
316
+ * Throws if the skill doesn't exist.
317
+ */
318
+ export function updateSkill(slug, updates) {
319
+ const skillDir = join(SKILLS_DIR, slug);
320
+ if (!existsSync(skillDir)) {
321
+ throw new Error(`Skill not found: ${slug}`);
322
+ }
323
+ const skillMdPath = join(skillDir, "SKILL.md");
324
+ if (!existsSync(skillMdPath)) {
325
+ throw new Error(`SKILL.md not found for skill: ${slug}`);
326
+ }
327
+ const currentContent = readFileSync(skillMdPath, "utf-8");
328
+ const { name: currentName, description: currentDescription } = parseSkillMd(currentContent);
329
+ // Use provided updates or fall back to current values
330
+ const newName = updates.name ?? currentName;
331
+ const newDescription = updates.description ?? currentDescription;
332
+ // Rebuild SKILL.md with new metadata
333
+ // Keep everything after the description intact (preserve any extra content)
334
+ const lines = currentContent.split(/\r?\n/);
335
+ const newLines = [];
336
+ // Add the new heading
337
+ newLines.push(`# ${newName}`);
338
+ newLines.push("");
339
+ // Add the new description
340
+ if (newDescription) {
341
+ newLines.push(newDescription);
342
+ newLines.push("");
343
+ }
344
+ // Find where the original description ends and append the rest
345
+ let foundHeading = false;
346
+ let skippedDescription = false;
347
+ let blankLinesSeen = 0;
348
+ for (const line of lines) {
349
+ if (!foundHeading) {
350
+ if (line.match(/^#\s+(.+)/)) {
351
+ foundHeading = true;
352
+ }
353
+ continue;
354
+ }
355
+ if (!skippedDescription) {
356
+ if (line.trim() === "") {
357
+ blankLinesSeen++;
358
+ if (blankLinesSeen >= 2) {
359
+ skippedDescription = true;
360
+ }
361
+ }
362
+ else {
363
+ blankLinesSeen = 0;
364
+ }
365
+ continue;
366
+ }
367
+ // We're past the description now
368
+ newLines.push(line);
369
+ }
370
+ const newContent = newLines.join("\n");
371
+ writeFileSync(skillMdPath, newContent, "utf-8");
372
+ return {
373
+ name: newName,
374
+ slug,
375
+ description: newDescription,
376
+ path: skillDir,
377
+ };
378
+ }
379
+ /**
380
+ * Delete a skill (alias for removeSkill for consistency with other store modules).
381
+ */
382
+ export function deleteSkill(slug) {
383
+ return removeSkill(slug);
384
+ }
313
385
  //# sourceMappingURL=skills.js.map
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Tests for src/copilot/skills.ts — skill installation, listing, and management.
3
+ */
4
+ import { describe, it } from "node:test";
5
+ import assert from "node:assert/strict";
6
+ import { parseSkillUrl, removeSkill, updateSkill, deleteSkill, } from "./skills.js";
7
+ // ── Tests ─────────────────────────────────────────────────────────────────────
8
+ describe("parseSkillUrl", () => {
9
+ it("parses GitHub blob URLs for SKILL.md", () => {
10
+ const url = "https://github.com/owner/repo/blob/main/SKILL.md";
11
+ const result = parseSkillUrl(url);
12
+ assert.equal(result.type, "file");
13
+ assert.ok("rawUrl" in result);
14
+ assert.ok(result.rawUrl.includes("raw.githubusercontent.com"));
15
+ });
16
+ it("parses GitHub raw content URLs", () => {
17
+ const url = "https://raw.githubusercontent.com/owner/repo/main/SKILL.md";
18
+ const result = parseSkillUrl(url);
19
+ assert.equal(result.type, "file");
20
+ assert.ok("rawUrl" in result);
21
+ });
22
+ it("parses GitHub repo URLs as repo type", () => {
23
+ const url = "https://github.com/owner/repo";
24
+ const result = parseSkillUrl(url);
25
+ assert.equal(result.type, "repo");
26
+ assert.equal(result.url, url);
27
+ });
28
+ it("rejects non-https SKILL.md URLs", () => {
29
+ const url = "http://example.com/SKILL.md";
30
+ assert.throws(() => parseSkillUrl(url));
31
+ });
32
+ it("derives slug from repo name with path prefix", () => {
33
+ const url = "https://github.com/owner/repo/blob/main/skills/ai-chat/SKILL.md";
34
+ const result = parseSkillUrl(url);
35
+ assert.equal(result.type, "file");
36
+ assert.ok(result.slug.includes("repo"));
37
+ });
38
+ });
39
+ describe("skill store functions", () => {
40
+ it("exports updateSkill function", () => {
41
+ assert.ok(typeof updateSkill === "function");
42
+ });
43
+ it("exports deleteSkill function", () => {
44
+ assert.ok(typeof deleteSkill === "function");
45
+ });
46
+ it("exports removeSkill function", () => {
47
+ assert.ok(typeof removeSkill === "function");
48
+ });
49
+ it("deleteSkill is callable (alias)", () => {
50
+ // Just verify these are functions and importable
51
+ assert.ok(deleteSkill !== undefined);
52
+ assert.ok(removeSkill !== undefined);
53
+ });
54
+ });
55
+ //# sourceMappingURL=skills.test.js.map
package/dist/store/db.js CHANGED
@@ -172,6 +172,7 @@ GROUP BY agent_slug`,
172
172
  )`,
173
173
  `CREATE INDEX IF NOT EXISTS idx_unified_feed_type ON unified_feed(type, created_at)`,
174
174
  `CREATE INDEX IF NOT EXISTS idx_unified_feed_unread ON unified_feed(read_at, created_at)`,
175
+ `ALTER TABLE squads ADD COLUMN color TEXT`,
175
176
  `CREATE TABLE IF NOT EXISTS squad_instances (
176
177
  id TEXT PRIMARY KEY,
177
178
  master_squad_slug TEXT NOT NULL,
@@ -213,6 +214,7 @@ GROUP BY agent_slug`,
213
214
  `DROP TABLE unified_feed_old`,
214
215
  `CREATE INDEX IF NOT EXISTS idx_unified_feed_type ON unified_feed(type, created_at)`,
215
216
  `CREATE INDEX IF NOT EXISTS idx_unified_feed_unread ON unified_feed(read_at, created_at)`,
217
+ `ALTER TABLE squads ADD COLUMN color TEXT`,
216
218
  ];
217
219
  for (const migration of migrations) {
218
220
  try {
@@ -242,6 +244,32 @@ GROUP BY agent_slug`,
242
244
  catch {
243
245
  // Migration failed (e.g. old tables don't exist yet on a fresh install) — safe to ignore
244
246
  }
247
+ // One-time migration: assign colors to existing squads that have none
248
+ try {
249
+ const colorMigrated = db.prepare("SELECT value FROM io_state WHERE key = 'squad_colors_migrated'").get();
250
+ if (!colorMigrated) {
251
+ const palette = [
252
+ "#ff6b35", "#ffd000", "#5fff87", "#c4a7ff", "#00d9ff",
253
+ "#ff9800", "#9c27b0", "#2196f3", "#e91e63", "#00bcd4",
254
+ "#8bc34a", "#ff5722",
255
+ ];
256
+ const uncolored = db.prepare("SELECT slug FROM squads WHERE color IS NULL ORDER BY id ASC").all();
257
+ const usedColors = new Set(db.prepare("SELECT color FROM squads WHERE color IS NOT NULL").all().map(r => r.color));
258
+ const update = db.prepare("UPDATE squads SET color = ? WHERE slug = ?");
259
+ let idx = 0;
260
+ for (const { slug } of uncolored) {
261
+ const available = palette.filter(c => !usedColors.has(c));
262
+ const color = available.length > 0 ? available[0] : palette[idx % palette.length];
263
+ update.run(color, slug);
264
+ usedColors.add(color);
265
+ idx++;
266
+ }
267
+ db.prepare("INSERT OR REPLACE INTO io_state (key, value) VALUES ('squad_colors_migrated', '1')").run();
268
+ }
269
+ }
270
+ catch {
271
+ // Safe to ignore on fresh install
272
+ }
245
273
  return db;
246
274
  }
247
275
  export function closeDb() {
@@ -1,11 +1,43 @@
1
1
  import { getDb } from "./db.js";
2
2
  import { nextCharacter, randomUniverse, getOrCreateUniverse } from "../copilot/universes.js";
3
+ // ---------------------------------------------------------------------------
4
+ // Squad color palette — universe-inspired distinct colors
5
+ // ---------------------------------------------------------------------------
6
+ export const SQUAD_COLOR_PALETTE = [
7
+ "#ff6b35", // A-Team orange
8
+ "#ffd000", // Thundercats gold
9
+ "#5fff87", // GI Joe green
10
+ "#c4a7ff", // Ghostbusters purple
11
+ "#00d9ff", // Transformers cyan
12
+ "#ff9800", // extra amber
13
+ "#9c27b0", // extra violet
14
+ "#2196f3", // extra blue
15
+ "#e91e63", // extra pink
16
+ "#00bcd4", // extra teal
17
+ "#8bc34a", // extra lime
18
+ "#ff5722", // extra deep-orange
19
+ ];
20
+ /**
21
+ * Pick a color for a new squad. Prefers an unused palette color; when all
22
+ * are taken, cycles through the palette by position (modular assignment).
23
+ * This guarantees we never throw and always return a valid hex color.
24
+ */
25
+ export function pickSquadColor(existingColors) {
26
+ const used = new Set(existingColors.filter(Boolean));
27
+ const unused = SQUAD_COLOR_PALETTE.filter((c) => !used.has(c));
28
+ if (unused.length > 0)
29
+ return unused[0];
30
+ // All palette colors taken — cycle by squad count
31
+ return SQUAD_COLOR_PALETTE[used.size % SQUAD_COLOR_PALETTE.length];
32
+ }
3
33
  export function createSquad(slug, name, projectPath, universeId) {
4
34
  const db = getDb();
5
35
  const universe = universeId
6
36
  ? getOrCreateUniverse(universeId).id
7
37
  : randomUniverse().id;
8
- db.prepare("INSERT INTO squads (slug, name, project_path, universe) VALUES (?, ?, ?, ?)").run(slug, name, projectPath, universe);
38
+ const existingSquads = listSquads();
39
+ const color = pickSquadColor(existingSquads.map((s) => s.color));
40
+ db.prepare("INSERT INTO squads (slug, name, project_path, universe, color) VALUES (?, ?, ?, ?, ?)").run(slug, name, projectPath, universe, color);
9
41
  return getSquad(slug);
10
42
  }
11
43
  export function getSquad(slug) {