crewswarm 0.9.4 → 1.0.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 (64) hide show
  1. package/.env.example +8 -1
  2. package/README.md +58 -9
  3. package/apps/dashboard/README.md +49 -0
  4. package/apps/dashboard/dist/assets/{index-D-sRshvg.css → index-C5-vlIwl.css} +1 -1
  5. package/apps/dashboard/dist/assets/index-CSooN9fi.js +2 -0
  6. package/apps/dashboard/dist/assets/index-CSooN9fi.js.br +0 -0
  7. package/apps/dashboard/dist/assets/tab-spending-tab-DcXD5TQY.js +1 -0
  8. package/apps/dashboard/dist/assets/tab-spending-tab-DcXD5TQY.js.br +0 -0
  9. package/apps/dashboard/dist/assets/tab-testing-tab-Ea5K-rsb.js +1 -0
  10. package/apps/dashboard/dist/index.html +85 -7
  11. package/apps/dashboard/dist/index.html.br +0 -0
  12. package/contrib/openclaw-plugin/index.ts +20 -11
  13. package/install.sh +2 -2
  14. package/lib/autoharness/index.mjs +151 -1
  15. package/lib/chat/history.mjs +1 -1
  16. package/lib/contacts/identity-linker.mjs +24 -3
  17. package/lib/contacts/index.mjs +2 -1
  18. package/lib/crew-lead/chat-handler.mjs +56 -33
  19. package/lib/crew-lead/llm-caller.mjs +71 -14
  20. package/lib/crew-lead/prompts.mjs +4 -2
  21. package/lib/crew-lead/wave-dispatcher.mjs +53 -3
  22. package/lib/crew-lead/worktree.mjs +258 -0
  23. package/lib/crew-lead/ws-router.mjs +43 -0
  24. package/lib/engines/rt-envelope.mjs +4 -1
  25. package/lib/memory/relevance-scorer.mjs +199 -0
  26. package/lib/memory/shared-adapter.mjs +85 -19
  27. package/package.json +10 -3
  28. package/scripts/dashboard.mjs +398 -28
  29. package/scripts/health-check.mjs +70 -28
  30. package/scripts/install-docker.sh +1 -1
  31. package/scripts/restart-all-from-repo.sh +25 -21
  32. package/scripts/start.mjs +81 -26
  33. package/apps/dashboard/dist/assets/chat-core-uXb_C0GM.js.br +0 -0
  34. package/apps/dashboard/dist/assets/cli-process-CNZ_UBCt.js.br +0 -0
  35. package/apps/dashboard/dist/assets/components-BS9fQjE_.js.br +0 -0
  36. package/apps/dashboard/dist/assets/core-utils-CmOkXgzi.js.br +0 -0
  37. package/apps/dashboard/dist/assets/index-BeVllEj_.js +0 -2
  38. package/apps/dashboard/dist/assets/index-BeVllEj_.js.br +0 -0
  39. package/apps/dashboard/dist/assets/index-D-sRshvg.css.br +0 -0
  40. package/apps/dashboard/dist/assets/orchestration-Ca2DLWN-.js.br +0 -0
  41. package/apps/dashboard/dist/assets/setup-wizard-CA0Or47w.js.br +0 -0
  42. package/apps/dashboard/dist/assets/tab-agents-tab-BgpIsjkw.js.br +0 -0
  43. package/apps/dashboard/dist/assets/tab-benchmarks-tab-BHjKCPm3.js.br +0 -0
  44. package/apps/dashboard/dist/assets/tab-comms-tab-kguqTIzD.js.br +0 -0
  45. package/apps/dashboard/dist/assets/tab-contacts-tab-DiOyMYth.js.br +0 -0
  46. package/apps/dashboard/dist/assets/tab-engines-tab-BsdZVvU0.js.br +0 -0
  47. package/apps/dashboard/dist/assets/tab-memory-tab-Cu6u13EQ.js.br +0 -0
  48. package/apps/dashboard/dist/assets/tab-models-tab-dNRgsTOO.js.br +0 -0
  49. package/apps/dashboard/dist/assets/tab-pm-loop-tab-DiAPTJXu.js.br +0 -0
  50. package/apps/dashboard/dist/assets/tab-projects-tab-SFH4E--a.js.br +0 -0
  51. package/apps/dashboard/dist/assets/tab-prompts-tab-DVkUNaJd.js.br +0 -0
  52. package/apps/dashboard/dist/assets/tab-services-tab-DU_LH3uG.js.br +0 -0
  53. package/apps/dashboard/dist/assets/tab-settings-tab-CuvH_Fj_.js.br +0 -0
  54. package/apps/dashboard/dist/assets/tab-skills-tab-DR7PJ7NB.js.br +0 -0
  55. package/apps/dashboard/dist/assets/tab-spending-tab-DEccQHnt.js +0 -1
  56. package/apps/dashboard/dist/assets/tab-spending-tab-DEccQHnt.js.br +0 -0
  57. package/apps/dashboard/dist/assets/tab-swarm-chat-tab-BNrd88-r.js.br +0 -0
  58. package/apps/dashboard/dist/assets/tab-swarm-tab-B1AcjL1W.js.br +0 -0
  59. package/apps/dashboard/dist/assets/tab-testing-tab-CezZOZcJ.js +0 -1
  60. package/apps/dashboard/dist/assets/tab-testing-tab-CezZOZcJ.js.br +0 -0
  61. package/apps/dashboard/dist/assets/tab-usage-tab-BIOOnB-Y.js.br +0 -0
  62. package/apps/dashboard/dist/assets/tab-waves-tab-SaJDkb4x.js.br +0 -0
  63. package/apps/dashboard/dist/assets/tab-workflows-tab-B-soSy1k.js.br +0 -0
  64. package/apps/dashboard/dist/index.html.gz +0 -0
@@ -132,9 +132,21 @@ const OAUTH_TOKEN_TTL_MS = 25 * 60 * 1000; // 25 minutes (tokens typically expir
132
132
  // Capture execFileSync at module load for sync token refresh
133
133
  const { execFileSync: _oauthExecFileSync } = await import("node:child_process");
134
134
 
135
+ function getKeychainAccountCandidates() {
136
+ const candidates = [
137
+ process.env.CLAUDE_CODE_ACCOUNT,
138
+ process.env.USER,
139
+ process.env.LOGNAME,
140
+ ];
141
+ try {
142
+ candidates.unshift(os.userInfo().username);
143
+ } catch { /* os user lookup can fail in restricted environments */ }
144
+ return [...new Set(candidates.filter(Boolean))];
145
+ }
146
+
135
147
  function refreshAnthropicOAuthToken() {
136
148
  try {
137
- for (const acct of [os.userInfo().username, "jeffhobbs", "unknown"]) {
149
+ for (const acct of getKeychainAccountCandidates()) {
138
150
  try {
139
151
  const raw = _oauthExecFileSync("security", [
140
152
  "find-generic-password", "-s", "Claude Code-credentials", "-a", acct, "-w"
@@ -152,7 +164,7 @@ function refreshAnthropicOAuthToken() {
152
164
 
153
165
  // Initial load at startup
154
166
  try {
155
- for (const acct of [os.userInfo().username, "jeffhobbs", "unknown"]) {
167
+ for (const acct of getKeychainAccountCandidates()) {
156
168
  try {
157
169
  const raw = _oauthExecFileSync("security", [
158
170
  "find-generic-password", "-s", "Claude Code-credentials", "-a", acct, "-w"
@@ -1581,16 +1593,34 @@ const server = http.createServer(async (req, res) => {
1581
1593
  // by inspecting run.json test_command
1582
1594
  async function detectSuite(runDir) {
1583
1595
  try {
1584
- const r = JSON.parse(await fs.promises.readFile(path.join(runDir, "run.json"), "utf8"));
1585
- const cmd = r.test_command || "";
1596
+ // Try run.json first
1597
+ let cmd = "";
1598
+ try {
1599
+ const r = JSON.parse(await fs.promises.readFile(path.join(runDir, "run.json"), "utf8"));
1600
+ cmd = r.test_command || "";
1601
+ } catch {
1602
+ // No run.json — try to infer from directory contents
1603
+ try {
1604
+ const ents = await fs.promises.readdir(runDir);
1605
+ const testDirs = ents.filter(e => !e.endsWith(".json") && !e.startsWith("."));
1606
+ // Check first test dir name for suite hint
1607
+ const first = testDirs[0] || "";
1608
+ if (first.includes("test-e2e-")) return "e2e";
1609
+ if (first.includes("test-integration-")) return "integration";
1610
+ if (first.includes("test-unit-")) return "unit";
1611
+ if (first.includes("spec-")) return "playwright";
1612
+ } catch {}
1613
+ return "unknown";
1614
+ }
1615
+ if (cmd.includes("test:playwright") || cmd.includes("playwright test")) return "playwright";
1586
1616
  if (cmd.includes("test/e2e/") || cmd.includes("test:e2e")) return "e2e";
1587
1617
  if (cmd.includes("test/integration/")) return "integration";
1588
1618
  if (cmd.includes("test/unit/")) {
1589
- // If it has ONLY unit tests, it's a unit run; if mixed, it's "all"
1590
1619
  if (cmd.includes("test/integration/") || cmd.includes("test/e2e/")) return "all";
1591
1620
  return "unit";
1592
1621
  }
1593
- // Check file count heuristic: >100 files = probably "all"
1622
+ if (cmd.includes("test:all")) return "all";
1623
+ if (cmd.includes("crew-cli") || cmd.includes("--prefix crew-cli")) return "crew-cli";
1594
1624
  const fileCount = (cmd.match(/\.test\.mjs/g) || []).length;
1595
1625
  if (fileCount > 100) return "all";
1596
1626
  if (fileCount > 15) return "unit";
@@ -1699,7 +1729,7 @@ const server = http.createServer(async (req, res) => {
1699
1729
  let count = 0;
1700
1730
  try {
1701
1731
  for (const ent of await fs.promises.readdir(dir, { withFileTypes: true })) {
1702
- if (ent.isDirectory()) { count += countTestCallsRecursive(path.join(dir, ent.name)); continue; }
1732
+ if (ent.isDirectory()) { count += await countTestCallsRecursive(path.join(dir, ent.name)); continue; }
1703
1733
  if (!ent.name.match(/\.test\./)) continue;
1704
1734
  const src = await fs.promises.readFile(path.join(dir, ent.name), "utf8");
1705
1735
  const matches = src.match(/^\s*(test|it)\s*\(/gm);
@@ -1708,13 +1738,22 @@ const server = http.createServer(async (req, res) => {
1708
1738
  } catch {}
1709
1739
  return count;
1710
1740
  }
1741
+ const [unitTests, intTests, e2eTests, pwTests, cliTests1, cliTests2, rootTests] = await Promise.all([
1742
+ countTestCalls(path.join(testFileDir, "unit"), /\.test\.mjs$/),
1743
+ countTestCalls(path.join(testFileDir, "integration"), /\.test\.mjs$/),
1744
+ countTestCalls(path.join(testFileDir, "e2e"), /\.test\.mjs$/),
1745
+ countTestCalls(testsE2eDir, /\.spec\.js$/),
1746
+ countTestCallsRecursive(crewCliTestDir),
1747
+ countTestCallsRecursive(crewCliTestDir2),
1748
+ countTestCalls(testFileDir, /\.test\./),
1749
+ ]);
1711
1750
  const testCounts = {
1712
- unit: countTestCalls(path.join(testFileDir, "unit"), /\.test\.mjs$/),
1713
- integration: countTestCalls(path.join(testFileDir, "integration"), /\.test\.mjs$/),
1714
- e2e: countTestCalls(path.join(testFileDir, "e2e"), /\.test\.mjs$/),
1715
- playwright: countTestCalls(testsE2eDir, /\.spec\.js$/),
1716
- "crew-cli": countTestCallsRecursive(crewCliTestDir) + countTestCallsRecursive(crewCliTestDir2),
1717
- root: countTestCalls(testFileDir, /\.test\./),
1751
+ unit: unitTests,
1752
+ integration: intTests,
1753
+ e2e: e2eTests,
1754
+ playwright: pwTests,
1755
+ "crew-cli": cliTests1 + cliTests2,
1756
+ root: rootTests,
1718
1757
  };
1719
1758
  res.writeHead(200, { "content-type": "application/json" });
1720
1759
  res.end(JSON.stringify({
@@ -1745,22 +1784,38 @@ const server = http.createServer(async (req, res) => {
1745
1784
  const s = JSON.parse(await fs.promises.readFile(summaryFile, "utf8"));
1746
1785
  entry = { runId: d, timestamp: s.timestamp, status: s.status || (s.failed > 0 ? "failed" : "passed"), passed: s.passed || 0, failed: s.failed || 0, skipped: s.skipped || 0, total: s.total || 0, duration_ms: s.duration_ms || 0 };
1747
1786
  } else {
1748
- const runMeta = JSON.parse(await fs.promises.readFile(path.join(runDir, "run.json"), "utf8"));
1787
+ let timestamp = null;
1788
+ try { const runMeta = JSON.parse(await fs.promises.readFile(path.join(runDir, "run.json"), "utf8")); timestamp = runMeta.timestamp; } catch {}
1789
+ if (!timestamp) { try { const stat = await fs.promises.stat(runDir); timestamp = stat.mtime.toISOString(); } catch {} }
1749
1790
  const ents = await fs.promises.readdir(runDir);
1750
1791
  const testDirs = ents.filter(e => !e.endsWith(".json") && !e.startsWith("."));
1751
1792
  let failed = 0;
1752
1793
  for (const td of testDirs) { if (await exists(path.join(runDir, td, "failure.json"))) failed++; }
1753
- entry = { runId: d, timestamp: runMeta.timestamp, status: failed > 0 ? "failed" : "passed", passed: testDirs.length - failed, failed, skipped: 0, total: testDirs.length, duration_ms: 0 };
1794
+ entry = { runId: d, timestamp, status: failed > 0 ? "failed" : "passed", passed: testDirs.length - failed, failed, skipped: 0, total: testDirs.length, duration_ms: 0 };
1754
1795
  }
1755
1796
  // Detect suite from test_command
1756
1797
  try {
1757
1798
  const r = JSON.parse(await fs.promises.readFile(path.join(runDir, "run.json"), "utf8"));
1758
1799
  const cmd = r.test_command || "";
1759
- if (cmd.includes("test/e2e/")) entry.suite = "e2e";
1800
+ if (cmd.includes("test:playwright") || cmd.includes("playwright test")) entry.suite = "playwright";
1801
+ else if (cmd.includes("test/e2e/") || cmd.includes("test:e2e")) entry.suite = "e2e";
1760
1802
  else if (cmd.includes("test/integration/")) entry.suite = "integration";
1803
+ else if (cmd.includes("test:all")) entry.suite = "all";
1804
+ else if (cmd.includes("crew-cli") || cmd.includes("--prefix crew-cli")) entry.suite = "crew-cli";
1761
1805
  else if (cmd.includes("test/unit/") && !cmd.includes("test/integration/")) entry.suite = "unit";
1762
1806
  else { const fc = (cmd.match(/\.test\.mjs/g) || []).length; entry.suite = fc > 100 ? "all" : fc > 15 ? "unit" : "unknown"; }
1763
- } catch { entry.suite = "unknown"; }
1807
+ } catch {
1808
+ // No run.json — try to infer from directory contents
1809
+ try {
1810
+ const ents = await fs.promises.readdir(runDir);
1811
+ const first = ents.find(e => !e.endsWith(".json") && !e.startsWith(".")) || "";
1812
+ if (first.includes("test-e2e-")) entry.suite = "e2e";
1813
+ else if (first.includes("test-integration-")) entry.suite = "integration";
1814
+ else if (first.includes("test-unit-")) entry.suite = "unit";
1815
+ else if (first.includes("spec-")) entry.suite = "playwright";
1816
+ else entry.suite = "unknown";
1817
+ } catch { entry.suite = "unknown"; }
1818
+ }
1764
1819
  history.push(entry);
1765
1820
  } catch { /* skip */ }
1766
1821
  }
@@ -1831,8 +1886,13 @@ const server = http.createServer(async (req, res) => {
1831
1886
  req.on("data", (c) => (body += c));
1832
1887
  req.on("end", async () => {
1833
1888
  let suite = "test:unit";
1834
- try { const parsed = JSON.parse(body); suite = parsed.suite || suite; } catch { /* default */ }
1835
- const allowed = ["test:unit", "test:integration", "test:e2e", "test:all", "test", "test:e2e:vibe"];
1889
+ let singleFile = null;
1890
+ try {
1891
+ const parsed = JSON.parse(body);
1892
+ suite = parsed.suite || suite;
1893
+ singleFile = parsed.file || null;
1894
+ } catch { /* default */ }
1895
+ const allowed = ["test:unit", "test:integration", "test:e2e", "test:all", "test", "test:e2e:vibe", "test:playwright"];
1836
1896
  if (!allowed.includes(suite)) {
1837
1897
  res.writeHead(400, { "content-type": "application/json" });
1838
1898
  res.end(JSON.stringify({ error: "Invalid suite: " + suite }));
@@ -1841,10 +1901,51 @@ const server = http.createServer(async (req, res) => {
1841
1901
  const { spawn } = await import("node:child_process");
1842
1902
  const progressFile = path.join(CREWSWARM_DIR, "test-results", ".test-progress.json");
1843
1903
  const outputFile = path.join(CREWSWARM_DIR, "test-results", ".test-output.log");
1904
+ // Count total files for this suite so progress can show X/Y
1905
+ let files_total = 0;
1906
+ try {
1907
+ const testFileDir = path.join(CREWSWARM_DIR, "test");
1908
+ const testsE2eDir = path.join(CREWSWARM_DIR, "tests", "e2e");
1909
+ const crewCliTestDir = path.join(CREWSWARM_DIR, "crew-cli", "tests");
1910
+ const crewCliTestDir2 = path.join(CREWSWARM_DIR, "crew-cli", "test");
1911
+ const suiteKey = suite.replace("test:", "");
1912
+ if (suiteKey === "unit") files_total = (await fs.promises.readdir(path.join(testFileDir, "unit"))).filter(f => f.endsWith(".test.mjs")).length;
1913
+ else if (suiteKey === "integration") files_total = (await fs.promises.readdir(path.join(testFileDir, "integration"))).filter(f => f.endsWith(".test.mjs")).length;
1914
+ else if (suiteKey === "e2e") files_total = (await fs.promises.readdir(path.join(testFileDir, "e2e"))).filter(f => f.endsWith(".test.mjs")).length;
1915
+ else if (suiteKey === "playwright") files_total = (await fs.promises.readdir(testsE2eDir)).filter(f => f.endsWith(".spec.js")).length;
1916
+ else if (suite === "test") { // crew-cli
1917
+ const count = async (d) => { try { return (await fs.promises.readdir(d)).filter(f => f.match(/\.test\./)).length; } catch { return 0; } };
1918
+ files_total = await count(path.join(crewCliTestDir, "unit")) + await count(crewCliTestDir) + await count(crewCliTestDir2);
1919
+ } else if (suiteKey === "all") {
1920
+ // Sum all suites
1921
+ const count = async (d, p) => { try { return (await fs.promises.readdir(d)).filter(f => f.match(p)).length; } catch { return 0; } };
1922
+ const countR = async (d) => { try { return (await fs.promises.readdir(d)).filter(f => f.match(/\.test\./)).length; } catch { return 0; } };
1923
+ files_total = await count(path.join(testFileDir, "unit"), /\.test\.mjs$/)
1924
+ + await count(path.join(testFileDir, "integration"), /\.test\.mjs$/)
1925
+ + await count(path.join(testFileDir, "e2e"), /\.test\.mjs$/)
1926
+ + await count(testsE2eDir, /\.spec\.js$/)
1927
+ + await countR(path.join(crewCliTestDir, "unit")) + await countR(crewCliTestDir) + await countR(crewCliTestDir2);
1928
+ }
1929
+ } catch {}
1844
1930
  // Write initial progress
1845
- await fs.promises.writeFile(progressFile, JSON.stringify({ suite, running: true, pid: 0, started: Date.now(), passed: 0, failed: 0, skipped: 0, files_done: 0, current_file: "" }));
1931
+ await fs.promises.writeFile(progressFile, JSON.stringify({ suite, running: true, pid: 0, started: Date.now(), passed: 0, failed: 0, skipped: 0, files_done: 0, files_total, current_file: singleFile || "" }));
1932
+ // Clean up any stale progress from a previous interrupted run
1933
+ const staleProgress = path.join(CREWSWARM_DIR, "test-results", ".test-progress.json");
1934
+ try {
1935
+ const prev = JSON.parse(fs.readFileSync(staleProgress, "utf8"));
1936
+ if (prev.running && prev.pid) {
1937
+ try { process.kill(prev.pid, 0); process.kill(prev.pid, "SIGTERM"); } catch { /* already dead */ }
1938
+ }
1939
+ } catch { /* no stale progress */ }
1940
+ let child;
1846
1941
  const outFd = fs.openSync(outputFile, "w");
1847
- const child = spawn("npm", ["run", suite], { cwd: CREWSWARM_DIR, stdio: ["ignore", outFd, outFd], detached: true });
1942
+ if (singleFile) {
1943
+ // Single-file run: use node --test directly on the file
1944
+ const absFile = path.isAbsolute(singleFile) ? singleFile : path.join(CREWSWARM_DIR, singleFile);
1945
+ child = spawn("node", ["--test", "--test-reporter=./scripts/test-reporter.mjs", absFile], { cwd: CREWSWARM_DIR, stdio: ["ignore", outFd, outFd], detached: true });
1946
+ } else {
1947
+ child = spawn("npm", ["run", suite], { cwd: CREWSWARM_DIR, stdio: ["ignore", outFd, outFd], detached: true });
1948
+ }
1848
1949
  child.unref();
1849
1950
  fs.closeSync(outFd);
1850
1951
  // Update progress by tailing the output file
@@ -1862,7 +1963,8 @@ const server = http.createServer(async (req, res) => {
1862
1963
  current_file = line.trim();
1863
1964
  }
1864
1965
  }
1865
- fs.writeFileSync(progressFile, JSON.stringify({ suite, running: true, pid: child.pid, started: JSON.parse(fs.readFileSync(progressFile, "utf8")).started, passed, failed, skipped, files_done, current_file }));
1966
+ const prev = JSON.parse(fs.readFileSync(progressFile, "utf8"));
1967
+ fs.writeFileSync(progressFile, JSON.stringify({ suite, running: true, pid: child.pid, started: prev.started, passed, failed, skipped, files_done, files_total: prev.files_total || 0, current_file }));
1866
1968
  } catch { /* file may not exist yet */ }
1867
1969
  }, 2000);
1868
1970
  child.on("exit", (code) => {
@@ -1885,6 +1987,30 @@ const server = http.createServer(async (req, res) => {
1885
1987
  });
1886
1988
  return;
1887
1989
  }
1990
+ if (url.pathname === "/api/tests/stop" && req.method === "POST") {
1991
+ const progressFile = path.join(CREWSWARM_DIR, "test-results", ".test-progress.json");
1992
+ try {
1993
+ const data = JSON.parse(await fs.promises.readFile(progressFile, "utf8"));
1994
+ if (data.running && data.pid) {
1995
+ try { process.kill(data.pid, "SIGTERM"); } catch { /* already dead */ }
1996
+ // Also kill child processes (node --test spawns sub-processes)
1997
+ try { process.kill(-data.pid, "SIGTERM"); } catch { /* no process group */ }
1998
+ data.running = false;
1999
+ data.stopped = true;
2000
+ data.finished = Date.now();
2001
+ await fs.promises.writeFile(progressFile, JSON.stringify(data));
2002
+ res.writeHead(200, { "content-type": "application/json" });
2003
+ res.end(JSON.stringify({ stopped: true, pid: data.pid }));
2004
+ } else {
2005
+ res.writeHead(200, { "content-type": "application/json" });
2006
+ res.end(JSON.stringify({ stopped: false, reason: "no running test" }));
2007
+ }
2008
+ } catch {
2009
+ res.writeHead(200, { "content-type": "application/json" });
2010
+ res.end(JSON.stringify({ stopped: false, reason: "no progress file" }));
2011
+ }
2012
+ return;
2013
+ }
1888
2014
  if (url.pathname === "/api/tests/progress" && req.method === "GET") {
1889
2015
  const progressFile = path.join(CREWSWARM_DIR, "test-results", ".test-progress.json");
1890
2016
  try {
@@ -1898,6 +2024,173 @@ const server = http.createServer(async (req, res) => {
1898
2024
  return;
1899
2025
  }
1900
2026
 
2027
+ // ── GET /api/tests/stale — files changed since last test run ────────────
2028
+ if (url.pathname === "/api/tests/stale" && req.method === "GET") {
2029
+ const resultsDir = path.join(CREWSWARM_DIR, "test-results");
2030
+ const logPath = path.join(resultsDir, "test-log.jsonl");
2031
+ try {
2032
+ // Read last-run fingerprints from test-log.jsonl
2033
+ const fingerprintByFile = new Map();
2034
+ try {
2035
+ const lines = (await fs.promises.readFile(logPath, "utf8")).split("\n").filter(Boolean);
2036
+ for (const line of lines) {
2037
+ try {
2038
+ const entry = JSON.parse(line);
2039
+ if (entry.file && entry.file_fingerprint?.mtime) {
2040
+ // Keep the most recent entry per file
2041
+ fingerprintByFile.set(entry.file, entry.file_fingerprint);
2042
+ }
2043
+ } catch {}
2044
+ }
2045
+ } catch {}
2046
+ const stale = [];
2047
+ for (const [filePath, fp] of fingerprintByFile) {
2048
+ try {
2049
+ const stat = await fs.promises.stat(filePath);
2050
+ const currentMtime = stat.mtime.toISOString();
2051
+ if (currentMtime > fp.mtime) {
2052
+ stale.push({
2053
+ file: filePath.replace(CREWSWARM_DIR + "/", ""),
2054
+ lastRun: fp.mtime,
2055
+ lastModified: currentMtime,
2056
+ });
2057
+ }
2058
+ } catch {}
2059
+ }
2060
+ res.writeHead(200, { "content-type": "application/json" });
2061
+ res.end(JSON.stringify({ stale }));
2062
+ } catch (e) {
2063
+ res.writeHead(200, { "content-type": "application/json" });
2064
+ res.end(JSON.stringify({ stale: [], error: e.message }));
2065
+ }
2066
+ return;
2067
+ }
2068
+
2069
+ // ── GET /api/tests/stream — SSE stream of running test output ───────────
2070
+ if (url.pathname === "/api/tests/stream" && req.method === "GET") {
2071
+ const outputFile = path.join(CREWSWARM_DIR, "test-results", ".test-output.log");
2072
+ res.writeHead(200, {
2073
+ "content-type": "text/event-stream",
2074
+ "cache-control": "no-cache",
2075
+ "connection": "keep-alive",
2076
+ "access-control-allow-origin": "*",
2077
+ });
2078
+ let lastSize = 0;
2079
+ // Send existing content first
2080
+ try {
2081
+ const content = await fs.promises.readFile(outputFile, "utf8");
2082
+ if (content) {
2083
+ res.write("data: " + JSON.stringify({ text: content, reset: true }) + "\n\n");
2084
+ lastSize = Buffer.byteLength(content, "utf8");
2085
+ }
2086
+ } catch {}
2087
+ const streamInterval = setInterval(async () => {
2088
+ try {
2089
+ const stat = await fs.promises.stat(outputFile);
2090
+ if (stat.size > lastSize) {
2091
+ const fd = await fs.promises.open(outputFile, "r");
2092
+ const newBytes = stat.size - lastSize;
2093
+ const buf = Buffer.alloc(newBytes);
2094
+ await fd.read(buf, 0, newBytes, lastSize);
2095
+ await fd.close();
2096
+ const text = buf.toString("utf8");
2097
+ lastSize = stat.size;
2098
+ res.write("data: " + JSON.stringify({ text }) + "\n\n");
2099
+ }
2100
+ // Check if done
2101
+ const progressFile = path.join(CREWSWARM_DIR, "test-results", ".test-progress.json");
2102
+ try {
2103
+ const prog = JSON.parse(await fs.promises.readFile(progressFile, "utf8"));
2104
+ if (!prog.running && prog.finished) {
2105
+ res.write("data: " + JSON.stringify({ done: true }) + "\n\n");
2106
+ clearInterval(streamInterval);
2107
+ res.end();
2108
+ }
2109
+ } catch {}
2110
+ } catch {}
2111
+ }, 500);
2112
+ req.on("close", () => { clearInterval(streamInterval); });
2113
+ return;
2114
+ }
2115
+
2116
+ // ── GET /api/tests/screenshot — serve a Playwright failure screenshot ────
2117
+ if (url.pathname === "/api/tests/screenshot" && req.method === "GET") {
2118
+ const relPath = url.searchParams.get("path");
2119
+ if (!relPath) { res.writeHead(400); res.end("Missing path"); return; }
2120
+ // Security: only allow paths within test-results/
2121
+ const safePath = path.normalize(relPath).replace(/^(\.\.(\/|\\|$))+/, "");
2122
+ const absPath = path.join(CREWSWARM_DIR, "test-results", safePath);
2123
+ if (!absPath.startsWith(path.join(CREWSWARM_DIR, "test-results"))) {
2124
+ res.writeHead(403); res.end("Forbidden"); return;
2125
+ }
2126
+ try {
2127
+ const stat = await fs.promises.stat(absPath);
2128
+ const ext = path.extname(absPath).toLowerCase();
2129
+ const mimeMap = { ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".webp": "image/webp" };
2130
+ const mime = mimeMap[ext] || "application/octet-stream";
2131
+ res.writeHead(200, { "content-type": mime, "content-length": stat.size, "cache-control": "max-age=300" });
2132
+ fs.createReadStream(absPath).pipe(res);
2133
+ } catch {
2134
+ res.writeHead(404); res.end("Screenshot not found");
2135
+ }
2136
+ return;
2137
+ }
2138
+
2139
+ // ── GET /api/tests/coverage-map — source files vs test coverage ──────────
2140
+ if (url.pathname === "/api/tests/coverage-map" && req.method === "GET") {
2141
+ try {
2142
+ const libDir = path.join(CREWSWARM_DIR, "lib");
2143
+ const crewCliSrcDir = path.join(CREWSWARM_DIR, "crew-cli", "src");
2144
+ const unitTestDir = path.join(CREWSWARM_DIR, "test", "unit");
2145
+ const crewCliTestDir = path.join(CREWSWARM_DIR, "crew-cli", "tests", "unit");
2146
+
2147
+ // Collect test file basenames (strip extension)
2148
+ const testBases = new Set();
2149
+ async function collectTestBases(dir) {
2150
+ try {
2151
+ for (const ent of await fs.promises.readdir(dir, { withFileTypes: true })) {
2152
+ if (ent.isDirectory()) { await collectTestBases(path.join(dir, ent.name)); continue; }
2153
+ if (ent.name.match(/\.test\.(mjs|js|ts)$/)) {
2154
+ // e.g. crew-judge.test.mjs -> crew-judge
2155
+ testBases.add(ent.name.replace(/\.test\.(mjs|js|ts)$/, ""));
2156
+ }
2157
+ }
2158
+ } catch {}
2159
+ }
2160
+ await collectTestBases(unitTestDir);
2161
+ await collectTestBases(crewCliTestDir);
2162
+
2163
+ // Collect source files and check coverage
2164
+ const covered = [];
2165
+ const uncovered = [];
2166
+ async function scanSourceDir(dir, prefix) {
2167
+ try {
2168
+ for (const ent of await fs.promises.readdir(dir, { withFileTypes: true })) {
2169
+ const fullPath = path.join(dir, ent.name);
2170
+ const relPath = prefix + "/" + ent.name;
2171
+ if (ent.isDirectory()) { await scanSourceDir(fullPath, relPath); continue; }
2172
+ if (!ent.name.match(/\.(mjs|js|ts)$/) || ent.name.includes(".d.ts") || ent.name.includes(".test.")) continue;
2173
+ const base = ent.name.replace(/\.(mjs|js|ts)$/, "");
2174
+ // Check if any test file matches by base name (fuzzy: contains or equals)
2175
+ const hasCoverage = [...testBases].some(tb => tb === base || tb.includes(base) || base.includes(tb));
2176
+ const entry = { file: relPath, base };
2177
+ if (hasCoverage) covered.push(entry);
2178
+ else uncovered.push(entry);
2179
+ }
2180
+ } catch {}
2181
+ }
2182
+ await scanSourceDir(libDir, "lib");
2183
+ await scanSourceDir(crewCliSrcDir, "crew-cli/src");
2184
+
2185
+ res.writeHead(200, { "content-type": "application/json" });
2186
+ res.end(JSON.stringify({ covered, uncovered, totalCovered: covered.length, totalUncovered: uncovered.length }));
2187
+ } catch (e) {
2188
+ res.writeHead(200, { "content-type": "application/json" });
2189
+ res.end(JSON.stringify({ covered: [], uncovered: [], error: e.message }));
2190
+ }
2191
+ return;
2192
+ }
2193
+
1901
2194
  // ── First-run detection ──────────────────────────────────────────────────
1902
2195
  if (url.pathname === "/api/first-run-status" && req.method === "GET") {
1903
2196
  const cfg = readSwarmConfigSafe();
@@ -4517,8 +4810,7 @@ const server = http.createServer(async (req, res) => {
4517
4810
  if (!providers["anthropic-oauth"]) {
4518
4811
  try {
4519
4812
  const { execFileSync } = await import("node:child_process");
4520
- const { userInfo } = await import("node:os");
4521
- for (const acct of [userInfo().username, "jeffhobbs", "unknown"]) {
4813
+ for (const acct of getKeychainAccountCandidates()) {
4522
4814
  try {
4523
4815
  const raw = execFileSync("security", [
4524
4816
  "find-generic-password", "-s", "Claude Code-credentials", "-a", acct, "-w"
@@ -4553,8 +4845,7 @@ const server = http.createServer(async (req, res) => {
4553
4845
  if (!token) {
4554
4846
  if (providerId === "anthropic-oauth") {
4555
4847
  const { execFileSync } = await import("node:child_process");
4556
- const { userInfo } = await import("node:os");
4557
- for (const acct of [userInfo().username, "jeffhobbs", "unknown"]) {
4848
+ for (const acct of getKeychainAccountCandidates()) {
4558
4849
  try {
4559
4850
  const raw = execFileSync("security", [
4560
4851
  "find-generic-password", "-s", "Claude Code-credentials", "-a", acct, "-w"
@@ -4683,8 +4974,7 @@ const server = http.createServer(async (req, res) => {
4683
4974
  let token = getOAuthTokenCached(providerId);
4684
4975
  if (!token && providerId === "anthropic-oauth") {
4685
4976
  const { execFileSync } = await import("node:child_process");
4686
- const { userInfo } = await import("node:os");
4687
- for (const acct of [userInfo().username, "jeffhobbs", "unknown"]) {
4977
+ for (const acct of getKeychainAccountCandidates()) {
4688
4978
  try {
4689
4979
  const raw = execFileSync("security", [
4690
4980
  "find-generic-password", "-s", "Claude Code-credentials", "-a", acct, "-w"
@@ -7643,6 +7933,86 @@ ORDER BY day DESC, cost DESC;`;
7643
7933
  }
7644
7934
  return;
7645
7935
  }
7936
+ // ── crew-cli cost stats ──────────────────────────────────────────────────
7937
+ if (url.pathname === "/api/crew-cli-stats" && req.method === "GET") {
7938
+ const days = Number(url.searchParams.get("days") || "14");
7939
+ const cutoff = new Date(Date.now() - days * 86400000).toISOString().slice(0, 10);
7940
+ try {
7941
+ // Scan known project directories for .crew/cost.json files
7942
+ const searchDirs = new Set();
7943
+ // 1. opencodeProject from config
7944
+ try {
7945
+ const cfg = JSON.parse(fs.readFileSync(path.join(os.homedir(), ".crewswarm", "crewswarm.json"), "utf8"));
7946
+ if (cfg.opencodeProject) searchDirs.add(cfg.opencodeProject.replace(/\/+$/, ""));
7947
+ } catch {}
7948
+ // 2. Registered projects
7949
+ try {
7950
+ const projFile = path.join(os.homedir(), ".crewswarm", "projects.json");
7951
+ const projs = JSON.parse(fs.readFileSync(projFile, "utf8"));
7952
+ for (const p of (projs.projects || projs || [])) {
7953
+ if (p.outputDir) searchDirs.add(p.outputDir.replace(/\/+$/, ""));
7954
+ }
7955
+ } catch {}
7956
+ // 3. Home .crew dir
7957
+ searchDirs.add(os.homedir());
7958
+
7959
+ const allEntries = [];
7960
+ for (const dir of searchDirs) {
7961
+ const costFile = path.join(dir, ".crew", "cost.json");
7962
+ try {
7963
+ const raw = JSON.parse(fs.readFileSync(costFile, "utf8"));
7964
+ for (const entry of (raw.entries || [])) {
7965
+ if (!entry.timestamp) continue;
7966
+ const day = entry.timestamp.slice(0, 10);
7967
+ if (day >= cutoff) {
7968
+ allEntries.push({ ...entry, day, project: dir });
7969
+ }
7970
+ }
7971
+ } catch {}
7972
+ }
7973
+
7974
+ // Roll up by day
7975
+ const byDay = {};
7976
+ let totalCost = 0;
7977
+ let totalCalls = 0;
7978
+ let totalPromptTokens = 0;
7979
+ let totalCompletionTokens = 0;
7980
+ for (const e of allEntries) {
7981
+ if (!byDay[e.day]) byDay[e.day] = { cost: 0, calls: 0, prompt_tokens: 0, completion_tokens: 0, byModel: {} };
7982
+ const d = byDay[e.day];
7983
+ const usd = Number(e.usd || 0);
7984
+ d.cost += usd;
7985
+ d.calls += 1;
7986
+ d.prompt_tokens += Number(e.promptTokens || 0);
7987
+ d.completion_tokens += Number(e.completionTokens || 0);
7988
+ const model = e.model || "unknown";
7989
+ if (!d.byModel[model]) d.byModel[model] = { cost: 0, calls: 0, prompt_tokens: 0, completion_tokens: 0 };
7990
+ d.byModel[model].cost += usd;
7991
+ d.byModel[model].calls += 1;
7992
+ d.byModel[model].prompt_tokens += Number(e.promptTokens || 0);
7993
+ d.byModel[model].completion_tokens += Number(e.completionTokens || 0);
7994
+ totalCost += usd;
7995
+ totalCalls += 1;
7996
+ totalPromptTokens += Number(e.promptTokens || 0);
7997
+ totalCompletionTokens += Number(e.completionTokens || 0);
7998
+ }
7999
+
8000
+ res.writeHead(200, { "content-type": "application/json" });
8001
+ res.end(JSON.stringify({
8002
+ ok: true,
8003
+ totalCost,
8004
+ totalCalls,
8005
+ totalPromptTokens,
8006
+ totalCompletionTokens,
8007
+ projects: [...searchDirs],
8008
+ byDay,
8009
+ }));
8010
+ } catch (e) {
8011
+ res.writeHead(200, { "content-type": "application/json" });
8012
+ res.end(JSON.stringify({ ok: false, error: e.message, byDay: {} }));
8013
+ }
8014
+ return;
8015
+ }
7646
8016
  // ── OpenCode models API ──────────────────────────────────────────────────
7647
8017
  if (url.pathname === "/api/opencode-models" && req.method === "GET") {
7648
8018
  let models = [];