crewswarm 0.9.5 → 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 (55) hide show
  1. package/README.md +56 -7
  2. package/apps/dashboard/dist/assets/{index-D-sRshvg.css → index-C5-vlIwl.css} +1 -1
  3. package/apps/dashboard/dist/assets/index-CSooN9fi.js +2 -0
  4. package/apps/dashboard/dist/assets/index-CSooN9fi.js.br +0 -0
  5. package/apps/dashboard/dist/assets/tab-spending-tab-DcXD5TQY.js +1 -0
  6. package/apps/dashboard/dist/assets/tab-spending-tab-DcXD5TQY.js.br +0 -0
  7. package/apps/dashboard/dist/assets/tab-testing-tab-Ea5K-rsb.js +1 -0
  8. package/apps/dashboard/dist/index.html +83 -7
  9. package/apps/dashboard/dist/index.html.br +0 -0
  10. package/contrib/openclaw-plugin/index.ts +20 -11
  11. package/lib/autoharness/index.mjs +151 -1
  12. package/lib/chat/history.mjs +1 -1
  13. package/lib/contacts/identity-linker.mjs +24 -3
  14. package/lib/contacts/index.mjs +2 -1
  15. package/lib/crew-lead/chat-handler.mjs +56 -33
  16. package/lib/crew-lead/llm-caller.mjs +71 -14
  17. package/lib/crew-lead/prompts.mjs +4 -2
  18. package/lib/engines/rt-envelope.mjs +4 -1
  19. package/package.json +5 -3
  20. package/scripts/dashboard.mjs +216 -25
  21. package/scripts/health-check.mjs +70 -28
  22. package/scripts/restart-all-from-repo.sh +25 -21
  23. package/scripts/start.mjs +35 -15
  24. package/apps/dashboard/dist/assets/chat-core-uXb_C0GM.js.br +0 -0
  25. package/apps/dashboard/dist/assets/cli-process-CNZ_UBCt.js.br +0 -0
  26. package/apps/dashboard/dist/assets/components-BS9fQjE_.js.br +0 -0
  27. package/apps/dashboard/dist/assets/core-utils-CmOkXgzi.js.br +0 -0
  28. package/apps/dashboard/dist/assets/index-BeVllEj_.js +0 -2
  29. package/apps/dashboard/dist/assets/index-BeVllEj_.js.br +0 -0
  30. package/apps/dashboard/dist/assets/index-D-sRshvg.css.br +0 -0
  31. package/apps/dashboard/dist/assets/orchestration-Ca2DLWN-.js.br +0 -0
  32. package/apps/dashboard/dist/assets/setup-wizard-CA0Or47w.js.br +0 -0
  33. package/apps/dashboard/dist/assets/tab-agents-tab-BgpIsjkw.js.br +0 -0
  34. package/apps/dashboard/dist/assets/tab-benchmarks-tab-BHjKCPm3.js.br +0 -0
  35. package/apps/dashboard/dist/assets/tab-comms-tab-kguqTIzD.js.br +0 -0
  36. package/apps/dashboard/dist/assets/tab-contacts-tab-DiOyMYth.js.br +0 -0
  37. package/apps/dashboard/dist/assets/tab-engines-tab-BsdZVvU0.js.br +0 -0
  38. package/apps/dashboard/dist/assets/tab-memory-tab-Cu6u13EQ.js.br +0 -0
  39. package/apps/dashboard/dist/assets/tab-models-tab-dNRgsTOO.js.br +0 -0
  40. package/apps/dashboard/dist/assets/tab-pm-loop-tab-DiAPTJXu.js.br +0 -0
  41. package/apps/dashboard/dist/assets/tab-projects-tab-SFH4E--a.js.br +0 -0
  42. package/apps/dashboard/dist/assets/tab-prompts-tab-DVkUNaJd.js.br +0 -0
  43. package/apps/dashboard/dist/assets/tab-services-tab-DU_LH3uG.js.br +0 -0
  44. package/apps/dashboard/dist/assets/tab-settings-tab-CuvH_Fj_.js.br +0 -0
  45. package/apps/dashboard/dist/assets/tab-skills-tab-DR7PJ7NB.js.br +0 -0
  46. package/apps/dashboard/dist/assets/tab-spending-tab-DEccQHnt.js +0 -1
  47. package/apps/dashboard/dist/assets/tab-spending-tab-DEccQHnt.js.br +0 -0
  48. package/apps/dashboard/dist/assets/tab-swarm-chat-tab-BNrd88-r.js.br +0 -0
  49. package/apps/dashboard/dist/assets/tab-swarm-tab-B1AcjL1W.js.br +0 -0
  50. package/apps/dashboard/dist/assets/tab-testing-tab-CezZOZcJ.js +0 -1
  51. package/apps/dashboard/dist/assets/tab-testing-tab-CezZOZcJ.js.br +0 -0
  52. package/apps/dashboard/dist/assets/tab-usage-tab-BIOOnB-Y.js.br +0 -0
  53. package/apps/dashboard/dist/assets/tab-waves-tab-SaJDkb4x.js.br +0 -0
  54. package/apps/dashboard/dist/assets/tab-workflows-tab-B-soSy1k.js.br +0 -0
  55. package/apps/dashboard/dist/index.html.gz +0 -0
@@ -385,21 +385,78 @@ function _recordCrewLeadTokens(modelId, providerKey, usage) {
385
385
  fs.writeFileSync(TOKEN_USAGE_FILE, JSON.stringify(data, null, 2));
386
386
  } catch {}
387
387
 
388
- // Calculate cost with cache discount - inline pricing table to avoid circular import
389
- const PRICING = {
390
- groq: { input: 0.05, output: 0.05, cached: 0.025 },
391
- anthropic: { input: 3.00, output: 15.00, cached: 0.30 },
392
- openai: { input: 5.00, output: 15.00, cached: 2.50 },
393
- perplexity: { input: 1.00, output: 1.00, cached: 1.00 },
394
- mistral: { input: 0.70, output: 2.00, cached: 0.70 },
395
- google: { input: 0.075, output: 0.30, cached: 0.00 }, // FREE!
396
- xai: { input: 5.00, output: 15.00, cached: 2.50 },
397
- deepseek: { input: 0.27, output: 1.10, cached: 0.135 },
398
- nvidia: { input: 1.00, output: 1.00, cached: 1.00 },
399
- cerebras: { input: 0.10, output: 0.10, cached: 0.10 },
388
+ // Calculate cost with cache discount — per-model pricing (matches dashboard usage-tab)
389
+ // Keys matched via model.includes(key); more specific keys must come first
390
+ const MODEL_PRICING = {
391
+ // xAI Grok
392
+ 'grok-4-1-fast': { input: 0.20, output: 0.50 },
393
+ 'grok-4-fast': { input: 0.20, output: 0.50 },
394
+ 'grok-4': { input: 3.00, output: 15.00 },
395
+ 'grok-3-mini': { input: 0.30, output: 0.50 },
396
+ 'grok-3': { input: 3.00, output: 15.00 },
397
+ 'grok-code-fast': { input: 0.20, output: 1.50 },
398
+ 'grok-beta': { input: 5.00, output: 15.00 },
399
+ // OpenAI
400
+ 'gpt-5.3-codex': { input: 2.50, output: 20.00 },
401
+ 'gpt-5.2-codex': { input: 1.75, output: 14.00 },
402
+ 'gpt-5.2': { input: 1.75, output: 14.00 },
403
+ 'gpt-5.1-codex-max':{ input: 2.50, output: 20.00 },
404
+ 'gpt-5.1-codex-mini':{ input: 0.25, output: 2.00 },
405
+ 'gpt-5.1-codex': { input: 1.25, output: 10.00 },
406
+ 'gpt-5.1': { input: 1.25, output: 10.00 },
407
+ 'gpt-5-codex': { input: 1.25, output: 10.00 },
408
+ 'gpt-5-nano': { input: 0.15, output: 0.60 },
409
+ 'gpt-5': { input: 1.25, output: 10.00 },
410
+ 'codex-mini': { input: 0.25, output: 2.00 },
411
+ 'gpt-4o-mini': { input: 0.15, output: 0.60 },
412
+ 'gpt-4o': { input: 2.50, output: 10.00 },
413
+ 'gpt-4': { input: 30.0, output: 60.00 },
414
+ // DeepSeek
415
+ 'deepseek-reasoner':{ input: 0.70, output: 2.50 },
416
+ 'deepseek-chat': { input: 0.27, output: 1.10 },
417
+ // Mistral
418
+ 'mistral-large': { input: 0.50, output: 1.50 },
419
+ 'mistral-small': { input: 0.10, output: 0.30 },
420
+ // Google Gemini
421
+ 'gemini-3.1-pro': { input: 2.50, output: 15.00 },
422
+ 'gemini-3.1-flash': { input: 0.075, output: 0.30 },
423
+ 'gemini-3-pro': { input: 2.50, output: 15.00 },
424
+ 'gemini-3-flash': { input: 0.075, output: 0.30 },
425
+ 'gemini-2.5-pro': { input: 1.25, output: 10.00 },
426
+ 'gemini-2.5-flash-lite': { input: 0.04, output: 0.15 },
427
+ 'gemini-2.5-flash': { input: 0.075, output: 0.30 },
428
+ 'gemini-2.0-flash': { input: 0.10, output: 0.40 },
429
+ // Anthropic
430
+ 'claude-opus-4': { input: 15.0, output: 75.00 },
431
+ 'claude-sonnet-4': { input: 3.00, output: 15.00 },
432
+ 'claude-haiku-4': { input: 0.80, output: 4.00 },
433
+ // Groq-hosted
434
+ 'llama-3.3': { input: 0.05, output: 0.05 },
435
+ 'llama-3.1': { input: 0.05, output: 0.05 },
436
+ 'gemma': { input: 0.05, output: 0.05 },
437
+ // Cerebras
438
+ 'cerebras': { input: 0.10, output: 0.10 },
439
+ // Perplexity
440
+ 'perplexity': { input: 1.00, output: 1.00 },
400
441
  };
401
-
402
- const pricing = PRICING[providerKey] || { input: 1.0, output: 1.0, cached: 1.0 };
442
+ // Provider-level fallback for models not matched above
443
+ const PROVIDER_FALLBACK = {
444
+ groq: { input: 0.05, output: 0.05 },
445
+ google: { input: 0.075, output: 0.30 },
446
+ xai: { input: 0.20, output: 0.50 },
447
+ deepseek: { input: 0.27, output: 1.10 },
448
+ anthropic: { input: 3.00, output: 15.00 },
449
+ openai: { input: 1.25, output: 10.00 },
450
+ mistral: { input: 0.50, output: 1.50 },
451
+ nvidia: { input: 1.00, output: 1.00 },
452
+ cerebras: { input: 0.10, output: 0.10 },
453
+ perplexity: { input: 1.00, output: 1.00 },
454
+ };
455
+ const modelLower = modelId.toLowerCase();
456
+ const matchedKey = Object.keys(MODEL_PRICING).find(k => modelLower.includes(k));
457
+ const pricing = matchedKey
458
+ ? { ...MODEL_PRICING[matchedKey], cached: MODEL_PRICING[matchedKey].input * 0.5 }
459
+ : { ...(PROVIDER_FALLBACK[providerKey] || { input: 1.0, output: 1.0 }), cached: (PROVIDER_FALLBACK[providerKey]?.input || 1.0) * 0.5 };
403
460
  const uncachedInput = Math.max(0, p - cached);
404
461
  const inputCost = (uncachedInput / 1_000_000) * pricing.input;
405
462
  const cachedCost = (cached / 1_000_000) * pricing.cached;
@@ -244,6 +244,7 @@ export function buildSystemPrompt(cfg) {
244
244
  "",
245
245
  "ALL MARKERS: @@READ_FILE, @@WRITE_FILE...@@END_FILE, @@MKDIR, @@RUN_CMD, @@WEB_SEARCH, @@WEB_FETCH, @@SEARCH_HISTORY, @@TELEGRAM, @@WHATSAPP, @@DISPATCH, @@PIPELINE, @@PROJECT, @@PROMPT, @@TOOLS, @@GLOBALRULE, @@SERVICE, @@BRAIN, @@MEMORY, @@SKILL, @@CREATE_AGENT, @@REMOVE_AGENT, @@DEFINE_SKILL, @@DEFINE_WORKFLOW, @@STOP, @@KILL.",
246
246
  'Self-teaching: if you make a tool mistake, emit @@PROMPT {"agent":"crew-lead","append":"learned: ..."} to remember it.',
247
+ 'CRITICAL: You CANNOT use "set" on your own prompt (crew-lead). Only "append" is allowed for yourself. "set" will be blocked to prevent accidental self-wipe.',
247
248
  "",
248
249
 
249
250
  // ═══════════════════════════════════════════════════════════════════════════
@@ -281,7 +282,7 @@ export function buildSystemPrompt(cfg) {
281
282
  "",
282
283
  "TEAM STATUS: You are the secretary. When asked about team status, answer immediately from health snapshot. Never say 'check the dashboard'.",
283
284
  "Only state status/model/runtime facts verified in this turn from snapshot or tool output.",
284
- "FULL ROSTER REQUESTS: If user asks for 'all agents', 'full roster', 'whole crew' — list EVERY agent from the health snapshot. The 2000-char brevity rule does NOT apply.",
285
+ "FULL ROSTER REQUESTS: If user asks for 'all agents', 'full roster', 'whole crew' — list EVERY agent from the health snapshot.",
285
286
  "",
286
287
 
287
288
  // ═══════════════════════════════════════════════════════════════════════════
@@ -364,6 +365,7 @@ export function buildSystemPrompt(cfg) {
364
365
  "",
365
366
  "- Never fabricate file contents, tool results, or system health output. Emit the tag; report ACTUAL results.",
366
367
  "- Never describe what a command 'would' show. Run it.",
368
+ "- If the user asks you to run a command, you MUST emit @@RUN_CMD on its own line. Do NOT skip the tool and write fake output. If you think you already know the answer, run the command ANYWAY — your job is to verify, not guess.",
367
369
  "- Never fabricate dispatch history. Only quote exact @@DISPATCH lines visible in conversation. If you don't see it, say so.",
368
370
  "- Never invent URLs, gists, or 'prior search results'. Only cite what's in conversation history.",
369
371
  "- If the user says you lied or made something up, accept it. Don't double down.",
@@ -376,7 +378,7 @@ export function buildSystemPrompt(cfg) {
376
378
  // ═══════════════════════════════════════════════════════════════════════════
377
379
  "## § 9 — STYLE",
378
380
  "",
379
- "- Under 2000 chars (except full roster requests). No filler.",
381
+ "- Be concise. No filler. Never cut yourself off mid-sentence — finish your thought.",
380
382
  "- When user throws shade, roast back. Match their energy. Sharp, sarcastic, no cap.",
381
383
  "- Every @@command you reference MUST appear as the actual @@ line in your reply. Prose descriptions execute nothing.",
382
384
  ].join("\n");
@@ -1068,11 +1068,14 @@ export async function handleRealtimeEnvelope(envelope, client, bridge) {
1068
1068
  /and nothing else\b/i.test(prompt || "");
1069
1069
 
1070
1070
  // Append original task spec for self-verification (LangChain pattern)
1071
- // Skip strict-output prompts where any extra text would violate the task.
1071
+ // Skip strict-output prompts and trivial/empty replies where the reminder adds noise.
1072
+ const replyStripped = (reply || "").replace(/\s+/g, " ").trim();
1073
+ const isTrivialReply = replyStripped.length < 50 || /^\(completed\)$/i.test(replyStripped);
1072
1074
  if (
1073
1075
  reply &&
1074
1076
  prompt &&
1075
1077
  !requestsExactReply &&
1078
+ !isTrivialReply &&
1076
1079
  !reply.includes("[ORIGINAL TASK]")
1077
1080
  ) {
1078
1081
  const taskSpecReminder = `\n\n---\n**[ORIGINAL TASK]:**\n${prompt.slice(0, 500)}${prompt.length > 500 ? "..." : ""}\n\nDoes your implementation address ALL requirements above?`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "crewswarm",
3
- "version": "0.9.5",
3
+ "version": "1.0.0",
4
4
  "description": "Local-first multi-agent orchestration platform — coordinate AI coding agents, LLMs, and tools from a single dashboard",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -133,10 +133,12 @@
133
133
  "release:check": "bash scripts/release-check.sh",
134
134
  "test:report": "node scripts/test-report-summary.mjs",
135
135
  "test:rerun": "node scripts/test-rerun.mjs",
136
- "test:stale": "node scripts/test-rerun.mjs --stale"
136
+ "test:stale": "node scripts/test-rerun.mjs --stale",
137
+ "typecheck": "tsc -p tsconfig.json"
137
138
  },
138
139
  "devDependencies": {
139
140
  "@playwright/test": "^1.58.2",
140
- "puppeteer-core": "^24.40.0"
141
+ "puppeteer-core": "^24.40.0",
142
+ "typescript": "^5.9.3"
141
143
  }
142
144
  }
@@ -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
  }
@@ -1846,8 +1901,42 @@ const server = http.createServer(async (req, res) => {
1846
1901
  const { spawn } = await import("node:child_process");
1847
1902
  const progressFile = path.join(CREWSWARM_DIR, "test-results", ".test-progress.json");
1848
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 {}
1849
1930
  // Write initial progress
1850
- 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: singleFile || "" }));
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 */ }
1851
1940
  let child;
1852
1941
  const outFd = fs.openSync(outputFile, "w");
1853
1942
  if (singleFile) {
@@ -1874,7 +1963,8 @@ const server = http.createServer(async (req, res) => {
1874
1963
  current_file = line.trim();
1875
1964
  }
1876
1965
  }
1877
- 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 }));
1878
1968
  } catch { /* file may not exist yet */ }
1879
1969
  }, 2000);
1880
1970
  child.on("exit", (code) => {
@@ -1897,6 +1987,30 @@ const server = http.createServer(async (req, res) => {
1897
1987
  });
1898
1988
  return;
1899
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
+ }
1900
2014
  if (url.pathname === "/api/tests/progress" && req.method === "GET") {
1901
2015
  const progressFile = path.join(CREWSWARM_DIR, "test-results", ".test-progress.json");
1902
2016
  try {
@@ -4696,8 +4810,7 @@ const server = http.createServer(async (req, res) => {
4696
4810
  if (!providers["anthropic-oauth"]) {
4697
4811
  try {
4698
4812
  const { execFileSync } = await import("node:child_process");
4699
- const { userInfo } = await import("node:os");
4700
- for (const acct of [userInfo().username, "jeffhobbs", "unknown"]) {
4813
+ for (const acct of getKeychainAccountCandidates()) {
4701
4814
  try {
4702
4815
  const raw = execFileSync("security", [
4703
4816
  "find-generic-password", "-s", "Claude Code-credentials", "-a", acct, "-w"
@@ -4732,8 +4845,7 @@ const server = http.createServer(async (req, res) => {
4732
4845
  if (!token) {
4733
4846
  if (providerId === "anthropic-oauth") {
4734
4847
  const { execFileSync } = await import("node:child_process");
4735
- const { userInfo } = await import("node:os");
4736
- for (const acct of [userInfo().username, "jeffhobbs", "unknown"]) {
4848
+ for (const acct of getKeychainAccountCandidates()) {
4737
4849
  try {
4738
4850
  const raw = execFileSync("security", [
4739
4851
  "find-generic-password", "-s", "Claude Code-credentials", "-a", acct, "-w"
@@ -4862,8 +4974,7 @@ const server = http.createServer(async (req, res) => {
4862
4974
  let token = getOAuthTokenCached(providerId);
4863
4975
  if (!token && providerId === "anthropic-oauth") {
4864
4976
  const { execFileSync } = await import("node:child_process");
4865
- const { userInfo } = await import("node:os");
4866
- for (const acct of [userInfo().username, "jeffhobbs", "unknown"]) {
4977
+ for (const acct of getKeychainAccountCandidates()) {
4867
4978
  try {
4868
4979
  const raw = execFileSync("security", [
4869
4980
  "find-generic-password", "-s", "Claude Code-credentials", "-a", acct, "-w"
@@ -7822,6 +7933,86 @@ ORDER BY day DESC, cost DESC;`;
7822
7933
  }
7823
7934
  return;
7824
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
+ }
7825
8016
  // ── OpenCode models API ──────────────────────────────────────────────────
7826
8017
  if (url.pathname === "/api/opencode-models" && req.method === "GET") {
7827
8018
  let models = [];