memstack-skill-loader 4.0.3__tar.gz → 4.0.5__tar.gz

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 (28) hide show
  1. {memstack_skill_loader-4.0.3/src/memstack_skill_loader.egg-info → memstack_skill_loader-4.0.5}/PKG-INFO +1 -1
  2. {memstack_skill_loader-4.0.3 → memstack_skill_loader-4.0.5}/pyproject.toml +1 -1
  3. {memstack_skill_loader-4.0.3 → memstack_skill_loader-4.0.5}/src/memstack_skill_loader/__init__.py +1 -1
  4. {memstack_skill_loader-4.0.3 → memstack_skill_loader-4.0.5}/src/memstack_skill_loader/agent_runner.py +272 -56
  5. {memstack_skill_loader-4.0.3 → memstack_skill_loader-4.0.5}/src/memstack_skill_loader/dashboard.html +246 -4
  6. {memstack_skill_loader-4.0.3 → memstack_skill_loader-4.0.5}/src/memstack_skill_loader/dashboard.py +69 -1
  7. {memstack_skill_loader-4.0.3 → memstack_skill_loader-4.0.5/src/memstack_skill_loader.egg-info}/PKG-INFO +1 -1
  8. {memstack_skill_loader-4.0.3 → memstack_skill_loader-4.0.5}/MANIFEST.in +0 -0
  9. {memstack_skill_loader-4.0.3 → memstack_skill_loader-4.0.5}/README.md +0 -0
  10. {memstack_skill_loader-4.0.3 → memstack_skill_loader-4.0.5}/setup.cfg +0 -0
  11. {memstack_skill_loader-4.0.3 → memstack_skill_loader-4.0.5}/src/memstack_skill_loader/__main__.py +0 -0
  12. {memstack_skill_loader-4.0.3 → memstack_skill_loader-4.0.5}/src/memstack_skill_loader/categories.py +0 -0
  13. {memstack_skill_loader-4.0.3 → memstack_skill_loader-4.0.5}/src/memstack_skill_loader/compression.py +0 -0
  14. {memstack_skill_loader-4.0.3 → memstack_skill_loader-4.0.5}/src/memstack_skill_loader/config.py +0 -0
  15. {memstack_skill_loader-4.0.3 → memstack_skill_loader-4.0.5}/src/memstack_skill_loader/indexer.py +0 -0
  16. {memstack_skill_loader-4.0.3 → memstack_skill_loader-4.0.5}/src/memstack_skill_loader/license.py +0 -0
  17. {memstack_skill_loader-4.0.3 → memstack_skill_loader-4.0.5}/src/memstack_skill_loader/memory_db.py +0 -0
  18. {memstack_skill_loader-4.0.3 → memstack_skill_loader-4.0.5}/src/memstack_skill_loader/search.py +0 -0
  19. {memstack_skill_loader-4.0.3 → memstack_skill_loader-4.0.5}/src/memstack_skill_loader/server.py +0 -0
  20. {memstack_skill_loader-4.0.3 → memstack_skill_loader-4.0.5}/src/memstack_skill_loader/skill_config.py +0 -0
  21. {memstack_skill_loader-4.0.3 → memstack_skill_loader-4.0.5}/src/memstack_skill_loader/stats.py +0 -0
  22. {memstack_skill_loader-4.0.3 → memstack_skill_loader-4.0.5}/src/memstack_skill_loader/tfidf_search.py +0 -0
  23. {memstack_skill_loader-4.0.3 → memstack_skill_loader-4.0.5}/src/memstack_skill_loader/version_check.py +0 -0
  24. {memstack_skill_loader-4.0.3 → memstack_skill_loader-4.0.5}/src/memstack_skill_loader.egg-info/SOURCES.txt +0 -0
  25. {memstack_skill_loader-4.0.3 → memstack_skill_loader-4.0.5}/src/memstack_skill_loader.egg-info/dependency_links.txt +0 -0
  26. {memstack_skill_loader-4.0.3 → memstack_skill_loader-4.0.5}/src/memstack_skill_loader.egg-info/entry_points.txt +0 -0
  27. {memstack_skill_loader-4.0.3 → memstack_skill_loader-4.0.5}/src/memstack_skill_loader.egg-info/requires.txt +0 -0
  28. {memstack_skill_loader-4.0.3 → memstack_skill_loader-4.0.5}/src/memstack_skill_loader.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: memstack-skill-loader
3
- Version: 4.0.3
3
+ Version: 4.0.5
4
4
  Summary: MCP server that vector-indexes MemStack Pro skills for on-demand loading
5
5
  Requires-Python: >=3.10
6
6
  Requires-Dist: mcp>=1.0.0
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "memstack-skill-loader"
7
- version = "4.0.3"
7
+ version = "4.0.5"
8
8
  description = "MCP server that vector-indexes MemStack Pro skills for on-demand loading"
9
9
  requires-python = ">=3.10"
10
10
  dependencies = [
@@ -1,3 +1,3 @@
1
1
  """MemStack Skill Loader — MCP server for semantic skill search."""
2
2
 
3
- __version__ = "4.0.3"
3
+ __version__ = "4.0.5"
@@ -12,6 +12,8 @@ import uuid
12
12
  from pathlib import Path
13
13
  from typing import Optional
14
14
 
15
+ import httpx
16
+
15
17
  from .stats import log_agent_invocation
16
18
 
17
19
 
@@ -26,6 +28,8 @@ AGENT_TIMEOUT = 3600 # seconds per --print invocation (default 60 minutes)
26
28
  MAX_ITERATIONS = 2
27
29
 
28
30
  ANTHROPIC_BASE_URL = os.environ.get("ANTHROPIC_BASE_URL", "")
31
+ API_DEFAULT_MODEL = "claude-sonnet-4-20250514"
32
+ API_MAX_TOKENS = 16000
29
33
 
30
34
  SYSTEM_PROMPTS = {
31
35
  "manager": (
@@ -84,6 +88,49 @@ SYSTEM_PROMPTS = {
84
88
  ),
85
89
  }
86
90
 
91
+ BUILDER_TOOLS_CONFIG_FILE = Path.home() / ".memstack" / "builder-tools-config.json"
92
+
93
+
94
+ # ---------------------------------------------------------------------------
95
+ # MCP server discovery & Builder tools config
96
+ # ---------------------------------------------------------------------------
97
+
98
+ def discover_mcp_servers(workdir: str) -> list[str]:
99
+ """Read .mcp.json from workdir and return the list of MCP server name keys."""
100
+ try:
101
+ mcp_file = Path(workdir) / ".mcp.json"
102
+ if not mcp_file.is_file():
103
+ return []
104
+ data = json.loads(mcp_file.read_text(encoding="utf-8"))
105
+ servers = data.get("mcpServers", {})
106
+ return sorted(servers.keys()) if isinstance(servers, dict) else []
107
+ except Exception:
108
+ return []
109
+
110
+
111
+ def load_builder_tools_config() -> dict:
112
+ """Load per-project blocked MCP server lists from ~/.memstack/builder-tools-config.json."""
113
+ try:
114
+ if BUILDER_TOOLS_CONFIG_FILE.is_file():
115
+ return json.loads(BUILDER_TOOLS_CONFIG_FILE.read_text(encoding="utf-8"))
116
+ except Exception:
117
+ pass
118
+ return {}
119
+
120
+
121
+ def save_builder_tools_config(config: dict) -> None:
122
+ """Save per-project blocked MCP server config."""
123
+ BUILDER_TOOLS_CONFIG_FILE.parent.mkdir(parents=True, exist_ok=True)
124
+ BUILDER_TOOLS_CONFIG_FILE.write_text(json.dumps(config, indent=2), encoding="utf-8")
125
+
126
+
127
+ def get_blocked_servers_for_project(workdir: str) -> list[str]:
128
+ """Return blocked MCP servers for a project, falling back to global defaults."""
129
+ cfg = load_builder_tools_config()
130
+ if workdir in cfg:
131
+ return cfg[workdir]
132
+ return cfg.get("_global_defaults", [])
133
+
87
134
 
88
135
  # ---------------------------------------------------------------------------
89
136
  # Project context gathering
@@ -188,6 +235,7 @@ def _extract_commit_from_reviewer(reviewer_output: str) -> str:
188
235
  def _build_env() -> dict:
189
236
  """Build environment for subprocess, ensuring Anthropic vars are passed."""
190
237
  env = os.environ.copy()
238
+ env.pop("MEMSTACK_ENABLE_TTS", None)
191
239
  if ANTHROPIC_BASE_URL:
192
240
  env["ANTHROPIC_BASE_URL"] = ANTHROPIC_BASE_URL
193
241
  return env
@@ -254,22 +302,145 @@ def _parse_stream_json(raw: str) -> tuple[str, int, int, float, int]:
254
302
  return text, input_tokens, output_tokens, cost_usd, context_tokens
255
303
 
256
304
 
305
+ def _extract_text_from_stream_line(line: str) -> Optional[str]:
306
+ """Extract user-visible text from a single stream-json line.
307
+
308
+ Returns the text if the line contains assistant text or a result, else None.
309
+ """
310
+ try:
311
+ obj = json.loads(line.strip())
312
+ except (json.JSONDecodeError, ValueError):
313
+ return None
314
+ msg_type = obj.get("type")
315
+ if msg_type == "assistant":
316
+ parts = []
317
+ for block in obj.get("message", {}).get("content", []):
318
+ if block.get("type") == "text":
319
+ t = block.get("text", "")
320
+ if t:
321
+ parts.append(t)
322
+ return "\n".join(parts) if parts else None
323
+ if msg_type == "result":
324
+ t = obj.get("result", "")
325
+ return t if t else None
326
+ return None
327
+
328
+
329
+ # ---------------------------------------------------------------------------
330
+ # Direct API invocation (Manager / Reviewer — no file tools needed)
331
+ # ---------------------------------------------------------------------------
332
+
333
+ def _invoke_api_agent(name: str, prompt: str, system_prompt: str,
334
+ log_path: Optional[Path] = None, timeout: int = 600,
335
+ model: str = "", session_id: Optional[str] = None,
336
+ ) -> tuple[str, int, int]:
337
+ """Call the Anthropic Messages API directly via httpx.
338
+
339
+ Returns (text, input_tokens, output_tokens).
340
+ """
341
+ api_key = os.environ.get("ANTHROPIC_API_KEY", "")
342
+ base_url = ANTHROPIC_BASE_URL or "https://api.anthropic.com"
343
+
344
+ if not api_key and not ANTHROPIC_BASE_URL:
345
+ raise RuntimeError(
346
+ f"{name}: ANTHROPIC_API_KEY not set and no ANTHROPIC_BASE_URL proxy configured"
347
+ )
348
+
349
+ url = f"{base_url.rstrip('/')}/v1/messages"
350
+ headers = {
351
+ "content-type": "application/json",
352
+ "anthropic-version": "2023-06-01",
353
+ }
354
+ if api_key:
355
+ headers["x-api-key"] = api_key
356
+
357
+ body = {
358
+ "model": model or API_DEFAULT_MODEL,
359
+ "max_tokens": API_MAX_TOKENS,
360
+ "system": system_prompt,
361
+ "messages": [{"role": "user", "content": prompt}],
362
+ }
363
+
364
+ if log_path:
365
+ log_path.parent.mkdir(parents=True, exist_ok=True)
366
+ ts = time.strftime("%H:%M:%S")
367
+ with open(log_path, "a", encoding="utf-8") as f:
368
+ f.write(f"[{ts}] === API call: {name} ===\n")
369
+ f.write(f"[{ts}] Model: {body['model']}\n")
370
+ f.write(f"[{ts}] Prompt length: {len(prompt)} chars\n")
371
+
372
+ try:
373
+ resp = httpx.post(url, json=body, headers=headers, timeout=timeout)
374
+ resp.raise_for_status()
375
+ except httpx.TimeoutException:
376
+ if log_path:
377
+ ts = time.strftime("%H:%M:%S")
378
+ with open(log_path, "a", encoding="utf-8") as f:
379
+ f.write(f"[{ts}] API timeout after {timeout}s\n")
380
+ raise subprocess.TimeoutExpired(f"api:{name}", timeout)
381
+ except httpx.HTTPStatusError as exc:
382
+ if log_path:
383
+ ts = time.strftime("%H:%M:%S")
384
+ with open(log_path, "a", encoding="utf-8") as f:
385
+ f.write(f"[{ts}] API error {exc.response.status_code}: {exc.response.text[:500]}\n")
386
+ raise RuntimeError(f"{name} API error {exc.response.status_code}: {exc.response.text[:200]}") from exc
387
+ except httpx.HTTPError as exc:
388
+ if log_path:
389
+ ts = time.strftime("%H:%M:%S")
390
+ with open(log_path, "a", encoding="utf-8") as f:
391
+ f.write(f"[{ts}] HTTP error: {exc}\n")
392
+ raise RuntimeError(f"{name} HTTP error: {exc}") from exc
393
+
394
+ data = resp.json()
395
+ text_parts = []
396
+ for block in data.get("content", []):
397
+ if block.get("type") == "text":
398
+ text_parts.append(block["text"])
399
+ output = "\n".join(text_parts).strip()
400
+
401
+ usage = data.get("usage", {})
402
+ input_tokens = usage.get("input_tokens", 0)
403
+ output_tokens = usage.get("output_tokens", 0)
404
+
405
+ if log_path:
406
+ ts = time.strftime("%H:%M:%S")
407
+ with open(log_path, "a", encoding="utf-8") as f:
408
+ f.write(f"[{ts}] Response: {len(output)} chars, "
409
+ f"in={input_tokens} out={output_tokens}\n")
410
+
411
+ try:
412
+ log_agent_invocation(
413
+ name, len(prompt), len(output), session_id, "",
414
+ input_tokens=input_tokens, output_tokens=output_tokens, cost_usd=0.0,
415
+ )
416
+ except Exception:
417
+ pass
418
+
419
+ return output, input_tokens, output_tokens
420
+
421
+
422
+ # ---------------------------------------------------------------------------
423
+ # CC subprocess invocation (Builder — needs file tools)
424
+ # ---------------------------------------------------------------------------
425
+
257
426
  def _invoke_agent(name: str, prompt: str, working_dir: str, log_path: Optional[Path] = None,
258
- skip_permissions: bool = False, bare: bool = False,
427
+ skip_permissions: bool = False,
259
428
  session_id: Optional[str] = None, timeout: int = AGENT_TIMEOUT,
260
- model: str = "") -> tuple[str, int, int, int]:
429
+ model: str = "", disallowed_tools: Optional[list[str]] = None,
430
+ session: Optional["Session"] = None) -> tuple[str, int, int, int]:
261
431
  """Run a single claude --print invocation and return the output."""
262
432
  claude_bin = shutil.which("claude")
263
433
  if not claude_bin:
264
434
  raise FileNotFoundError("'claude' CLI not found on PATH")
265
435
 
266
436
  cmd = [claude_bin, "--print", "--verbose", "--output-format", "stream-json"]
267
- if bare and os.environ.get("ANTHROPIC_API_KEY"):
268
- cmd.append("--bare")
269
437
  if skip_permissions:
270
438
  cmd.append("--dangerously-skip-permissions")
271
439
  if model:
272
440
  cmd.extend(["--model", model])
441
+ if disallowed_tools:
442
+ patterns = ",".join(f"mcp__{srv}__*" for srv in disallowed_tools)
443
+ cmd.extend(["--disallowedTools", patterns])
273
444
 
274
445
  if log_path:
275
446
  log_path.parent.mkdir(parents=True, exist_ok=True)
@@ -280,65 +451,96 @@ def _invoke_agent(name: str, prompt: str, working_dir: str, log_path: Optional[P
280
451
  f.write(f"[{ts}] === Invoking {name} ===\n")
281
452
  f.write(f"[{ts}] Prompt length: {len(prompt)} chars\n")
282
453
 
454
+ prompt_dir = log_path.parent if log_path else Path.home() / ".memstack" / "agent-runner"
455
+ prompt_dir.mkdir(parents=True, exist_ok=True)
456
+ prompt_file = prompt_dir / f"{name}_prompt.txt"
457
+ prompt_file.write_text(prompt, encoding="utf-8")
458
+
459
+ stdin_fh = open(prompt_file, "r", encoding="utf-8") # noqa: SIM115
283
460
  global _current_process
284
461
  proc = subprocess.Popen(
285
462
  cmd,
286
- stdin=subprocess.PIPE,
463
+ stdin=stdin_fh,
287
464
  stdout=subprocess.PIPE,
288
465
  stderr=subprocess.PIPE,
289
466
  cwd=working_dir,
290
- text=True,
291
- encoding="utf-8",
292
- errors="replace",
293
467
  env=_build_env(),
294
- creationflags=subprocess.CREATE_NO_WINDOW if sys.platform == "win32" else 0,
468
+ creationflags=0,
295
469
  )
470
+ stdin_fh.close()
296
471
  with _lock:
297
472
  _current_process = proc
298
473
 
474
+ if log_path:
475
+ with open(log_path, "a", encoding="utf-8") as f:
476
+ f.write(f"[{ts}] PID: {proc.pid}\n")
477
+
478
+ killed_by_watchdog = threading.Event()
479
+
480
+ def _watchdog_kill() -> None:
481
+ killed_by_watchdog.set()
482
+ proc.kill()
483
+
299
484
  stdout_chunks: list[str] = []
300
485
  stderr_chunks: list[str] = []
486
+ partial_text: list[str] = []
487
+
488
+ def _read_stdout() -> None:
489
+ for raw_line in proc.stdout:
490
+ line = raw_line.decode("utf-8", errors="replace")
491
+ stdout_chunks.append(line)
492
+ extracted = _extract_text_from_stream_line(line)
493
+ if extracted and session is not None:
494
+ partial_text.append(extracted)
495
+ try:
496
+ session.agents[name]["last_output"] = "".join(partial_text)[-500:]
497
+ session._save_state()
498
+ except Exception:
499
+ pass
301
500
 
302
- def _reader(stream: object, dest: list[str]) -> None:
303
- try:
304
- for line in stream: # type: ignore[union-attr]
305
- dest.append(line)
306
- except Exception:
307
- pass
501
+ def _read_stderr() -> None:
502
+ for raw_line in proc.stderr:
503
+ stderr_chunks.append(raw_line.decode("utf-8", errors="replace"))
504
+
505
+ t_out = threading.Thread(target=_read_stdout, daemon=True)
506
+ t_err = threading.Thread(target=_read_stderr, daemon=True)
507
+ t_out.start()
508
+ t_err.start()
308
509
 
309
- t_out = threading.Thread(target=_reader, args=(proc.stdout, stdout_chunks), daemon=True)
310
- t_err = threading.Thread(target=_reader, args=(proc.stderr, stderr_chunks), daemon=True)
510
+ watchdog = threading.Timer(timeout, _watchdog_kill)
511
+ watchdog.daemon = True
512
+ watchdog.start()
311
513
 
312
514
  try:
313
- if proc.stdin:
314
- try:
315
- proc.stdin.write(prompt)
316
- proc.stdin.close()
317
- except OSError:
318
- pass
319
- t_out.start()
320
- t_err.start()
321
-
322
- deadline = time.monotonic() + timeout
323
- while True:
324
- t_out.join(timeout=30)
325
- rc = proc.poll()
326
- if rc is not None:
327
- break
328
- if time.monotonic() > deadline:
329
- proc.kill()
330
- t_out.join(5)
331
- t_err.join(5)
332
- raise subprocess.TimeoutExpired(cmd, timeout)
333
- t_out.join(5)
334
- t_err.join(5)
335
- stdout_data = "".join(stdout_chunks)
336
- stderr_data = "".join(stderr_chunks)
515
+ proc.wait()
516
+ except Exception as e:
517
+ watchdog.cancel()
518
+ with _lock:
519
+ if _current_process is proc:
520
+ _current_process = None
521
+ if log_path:
522
+ with open(log_path, "a", encoding="utf-8") as f:
523
+ f.write(f"[{ts}] wait() error: {e}\n")
524
+ raise RuntimeError(f"{name} wait() failed: {e}") from e
337
525
  finally:
526
+ watchdog.cancel()
338
527
  with _lock:
339
528
  if _current_process is proc:
340
529
  _current_process = None
341
530
 
531
+ t_out.join(timeout=10)
532
+ t_err.join(timeout=10)
533
+
534
+ if killed_by_watchdog.is_set():
535
+ raise subprocess.TimeoutExpired(cmd, timeout)
536
+
537
+ stdout_data = "".join(stdout_chunks)
538
+ stderr_data = "".join(stderr_chunks)
539
+
540
+ if log_path:
541
+ with open(log_path, "a", encoding="utf-8") as f:
542
+ f.write(f"[{ts}] Process exited: stdout={len(stdout_data)} stderr={len(stderr_data)}\n")
543
+
342
544
  raw_stdout = stdout_data or ""
343
545
  stderr = stderr_data or ""
344
546
  output, input_tokens, output_tokens, cost_usd, context_tokens = _parse_stream_json(raw_stdout)
@@ -383,7 +585,8 @@ class Session:
383
585
  """Tracks the full state of an agent run session."""
384
586
 
385
587
  def __init__(self, task: str, working_dir: str, context: Optional[str] = None, auto_commit: bool = False, timeout_minutes: int = 60,
386
- manager_model: str = "", builder_model: str = "", reviewer_model: str = "", user_name: str = ""):
588
+ manager_model: str = "", builder_model: str = "", reviewer_model: str = "", user_name: str = "",
589
+ blocked_mcp_servers: Optional[list[str]] = None):
387
590
  self.session_id = uuid.uuid4().hex[:12]
388
591
  self.task = task
389
592
  self.working_dir = working_dir
@@ -391,6 +594,7 @@ class Session:
391
594
  self.user_name = user_name
392
595
  self.context = context
393
596
  self.auto_commit = auto_commit
597
+ self.blocked_mcp_servers = blocked_mcp_servers or []
394
598
  self.auto_committed = False
395
599
  self.timeout = max(5, min(120, timeout_minutes)) * 60
396
600
  self.status = "running"
@@ -505,6 +709,7 @@ def _orchestrate(session: Session) -> None:
505
709
  # Step 1: Manager analyzes the task
506
710
  session.agents["manager"]["status"] = "busy"
507
711
  session.agents["manager"]["started_at"] = time.strftime("%Y-%m-%dT%H:%M:%S")
712
+ session.agents["manager"]["last_output"] = ""
508
713
  session._save_state()
509
714
 
510
715
  context = _gather_project_context(session.working_dir)
@@ -515,17 +720,18 @@ def _orchestrate(session: Session) -> None:
515
720
  if session.user_name:
516
721
  manager_prompt = f"The user's name is {session.user_name}.\n\n" + manager_prompt
517
722
  try:
518
- manager_output, m_in, m_out, m_ctx = _invoke_agent(
519
- "manager", manager_prompt, session.working_dir,
723
+ manager_output, m_in, m_out = _invoke_api_agent(
724
+ "manager", manager_prompt,
725
+ system_prompt=SYSTEM_PROMPTS["manager"],
520
726
  log_path=session_log_dir / "manager.log",
521
- skip_permissions=True, session_id=session.session_id,
522
- timeout=min(180, session.timeout),
727
+ timeout=min(600, session.timeout),
523
728
  model=session.models.get("manager", ""),
729
+ session_id=session.session_id,
524
730
  )
525
731
  except subprocess.TimeoutExpired:
526
732
  session.agents["manager"]["status"] = "timeout"
527
733
  session.status = "error"
528
- session.result = "Manager timed out after 3 minutes. Try a simpler task description or break the task into smaller pieces."
734
+ session.result = "Manager timed out after 10 minutes. Try a simpler task description or break the task into smaller pieces."
529
735
  session._save_state()
530
736
  return
531
737
  except RuntimeError:
@@ -536,7 +742,7 @@ def _orchestrate(session: Session) -> None:
536
742
  return
537
743
  session.agents["manager"]["input_tokens"] += m_in
538
744
  session.agents["manager"]["output_tokens"] += m_out
539
- session.agents["manager"]["context_tokens"] = m_ctx
745
+ session.agents["manager"]["context_tokens"] = m_in
540
746
  session.agents["manager"]["last_output"] = (manager_output or "")[:500]
541
747
 
542
748
  session.agents["manager"]["status"] = "done"
@@ -563,6 +769,7 @@ def _orchestrate(session: Session) -> None:
563
769
  # Builder
564
770
  session.agents["builder"]["status"] = "busy"
565
771
  session.agents["builder"]["started_at"] = time.strftime("%Y-%m-%dT%H:%M:%S")
772
+ session.agents["builder"]["last_output"] = ""
566
773
  session._save_state()
567
774
 
568
775
  builder_prompt = (
@@ -578,9 +785,11 @@ def _orchestrate(session: Session) -> None:
578
785
  builder_output, b_in, b_out, b_ctx = _invoke_agent(
579
786
  "builder", builder_prompt, session.working_dir,
580
787
  log_path=session_log_dir / "builder.log",
581
- skip_permissions=True, bare=True, session_id=session.session_id,
788
+ skip_permissions=True, session_id=session.session_id,
582
789
  timeout=session.timeout,
583
790
  model=session.models.get("builder", ""),
791
+ disallowed_tools=session.blocked_mcp_servers,
792
+ session=session,
584
793
  )
585
794
  except subprocess.TimeoutExpired:
586
795
  session.agents["builder"]["status"] = "timeout"
@@ -630,6 +839,7 @@ def _orchestrate(session: Session) -> None:
630
839
  # Reviewer
631
840
  session.agents["reviewer"]["status"] = "busy"
632
841
  session.agents["reviewer"]["started_at"] = time.strftime("%Y-%m-%dT%H:%M:%S")
842
+ session.agents["reviewer"]["last_output"] = ""
633
843
  session._save_state()
634
844
 
635
845
  reviewer_prompt = (
@@ -638,12 +848,13 @@ def _orchestrate(session: Session) -> None:
638
848
  + f"\n\nBuilder output (iteration {iteration}):\n{builder_output}"
639
849
  )
640
850
  try:
641
- reviewer_output, r_in, r_out, r_ctx = _invoke_agent(
642
- "reviewer", reviewer_prompt, session.working_dir,
851
+ reviewer_output, r_in, r_out = _invoke_api_agent(
852
+ "reviewer", reviewer_prompt,
853
+ system_prompt=SYSTEM_PROMPTS["reviewer"],
643
854
  log_path=session_log_dir / "reviewer.log",
644
- bare=True, session_id=session.session_id,
645
855
  timeout=session.timeout,
646
856
  model=session.models.get("reviewer", ""),
857
+ session_id=session.session_id,
647
858
  )
648
859
  except subprocess.TimeoutExpired:
649
860
  session.agents["reviewer"]["status"] = "timeout"
@@ -659,7 +870,7 @@ def _orchestrate(session: Session) -> None:
659
870
  return
660
871
  session.agents["reviewer"]["input_tokens"] += r_in
661
872
  session.agents["reviewer"]["output_tokens"] += r_out
662
- session.agents["reviewer"]["context_tokens"] = r_ctx
873
+ session.agents["reviewer"]["context_tokens"] = r_in
663
874
  session.agents["reviewer"]["last_output"] = (reviewer_output or "")[:500]
664
875
 
665
876
  session.agents["reviewer"]["status"] = "done"
@@ -772,7 +983,8 @@ _BLOCKED_NIX = {"/etc", "/var", "/proc", "/sys", "/boot"}
772
983
 
773
984
 
774
985
  def start_run(task: str, working_dir: Optional[str] = None, context: Optional[str] = None, auto_commit: bool = True, timeout_minutes: int = 60,
775
- manager_model: str = "", builder_model: str = "", reviewer_model: str = "", user_name: str = "") -> dict:
986
+ manager_model: str = "", builder_model: str = "", reviewer_model: str = "", user_name: str = "",
987
+ blocked_mcp_servers: Optional[list[str]] = None) -> dict:
776
988
  """Start a new agent run. Returns session info."""
777
989
  global _current_session, _orchestration_thread
778
990
 
@@ -791,18 +1003,22 @@ def start_run(task: str, working_dir: Optional[str] = None, context: Optional[st
791
1003
  if any(str_wd == bp or str_wd.startswith(bp + "/") for bp in _BLOCKED_NIX):
792
1004
  return {"error": f"Working directory not allowed: {wd}"}
793
1005
 
1006
+ if blocked_mcp_servers is None:
1007
+ blocked_mcp_servers = get_blocked_servers_for_project(str(wd))
1008
+
794
1009
  with _lock:
795
1010
  if _current_session and _current_session.status == "running":
796
1011
  return {"error": "A session is already running", "session_id": _current_session.session_id}
797
1012
 
798
1013
  session = Session(task=task, working_dir=working_dir, context=context, auto_commit=auto_commit, timeout_minutes=timeout_minutes,
799
- manager_model=manager_model, builder_model=builder_model, reviewer_model=reviewer_model, user_name=user_name)
1014
+ manager_model=manager_model, builder_model=builder_model, reviewer_model=reviewer_model, user_name=user_name,
1015
+ blocked_mcp_servers=blocked_mcp_servers)
800
1016
  _current_session = session
801
1017
 
802
1018
  session._save_state()
803
1019
 
804
1020
  _orchestration_thread = threading.Thread(
805
- target=_orchestrate, args=(session,), daemon=True, name="orchestrator"
1021
+ target=_orchestrate, args=(session,), daemon=False, name="orchestrator"
806
1022
  )
807
1023
  _orchestration_thread.start()
808
1024
 
@@ -835,6 +835,76 @@
835
835
  font-size: 0.82rem;
836
836
  }
837
837
 
838
+ /* ── Builder MCP Tools section ── */
839
+ .mcp-tools-details {
840
+ margin-bottom: 0.8rem;
841
+ }
842
+ .mcp-tools-summary {
843
+ cursor: pointer;
844
+ font-size: 0.82rem;
845
+ color: #8b949e;
846
+ font-weight: 600;
847
+ list-style: none;
848
+ display: flex;
849
+ align-items: center;
850
+ gap: 0.4rem;
851
+ }
852
+ .mcp-tools-summary::-webkit-details-marker { display: none; }
853
+ .mcp-tools-summary::before {
854
+ content: '▶';
855
+ font-size: 0.6rem;
856
+ transition: transform 0.2s;
857
+ }
858
+ .mcp-tools-details[open] > .mcp-tools-summary::before {
859
+ transform: rotate(90deg);
860
+ }
861
+ .mcp-tools-summary:hover { color: #c9d1d9; }
862
+ .mcp-tools-list {
863
+ margin-top: 0.5rem;
864
+ display: flex;
865
+ flex-direction: column;
866
+ gap: 0.3rem;
867
+ }
868
+ .mcp-tools-row {
869
+ display: flex;
870
+ align-items: center;
871
+ gap: 0.5rem;
872
+ font-size: 0.82rem;
873
+ color: #c9d1d9;
874
+ }
875
+ .mcp-tools-row input[type="checkbox"] {
876
+ accent-color: #238636;
877
+ width: 14px;
878
+ height: 14px;
879
+ cursor: pointer;
880
+ flex-shrink: 0;
881
+ }
882
+ .mcp-tools-row label { cursor: pointer; font-family: monospace; }
883
+ .mcp-tools-hint {
884
+ font-size: 0.72rem;
885
+ color: #484f58;
886
+ margin-top: 0.4rem;
887
+ }
888
+ .mcp-tools-save-btn {
889
+ margin-top: 0.5rem;
890
+ background: #21262d;
891
+ color: #8b949e;
892
+ border: 1px solid #30363d;
893
+ padding: 0.3rem 0.8rem;
894
+ border-radius: 6px;
895
+ font-size: 0.75rem;
896
+ cursor: pointer;
897
+ transition: background 0.2s, color 0.2s;
898
+ }
899
+ .mcp-tools-save-btn:hover { background: #30363d; color: #c9d1d9; }
900
+ .mcp-tools-saved {
901
+ font-size: 0.72rem;
902
+ color: #39d353;
903
+ margin-left: 0.5rem;
904
+ opacity: 0;
905
+ transition: opacity 0.3s;
906
+ }
907
+
838
908
  /* ── Dancing status phrases ── */
839
909
  .agent-status-phrase {
840
910
  transition: opacity 0.3s ease;
@@ -1431,7 +1501,7 @@
1431
1501
  <label style="display:block;font-size:0.82rem;color:#8b949e;margin-bottom:0.4rem;font-weight:600;">Task Description</label>
1432
1502
  <div style="position:relative;margin-bottom:1rem;">
1433
1503
  <textarea id="agent-task-input" rows="4" placeholder="Describe what you want the agents to build..." style="width:100%;background:#0d1117;border:1px solid #30363d;border-radius:6px;color:#e6edf3;padding:0.7rem;font-size:0.88rem;resize:vertical;font-family:inherit;margin:0;"></textarea>
1434
- <button onclick="document.getElementById('agent-task-input').value='';document.getElementById('agent-workdir-input').value='';document.getElementById('agent-context-input').value='';document.getElementById('agent-task-input').focus();" style="position:absolute;top:4px;right:4px;background:#21262d;border:1px solid #30363d;color:#8b949e;padding:8px;border-radius:4px;cursor:pointer;font-size:14px;line-height:1;z-index:1;" onmouseenter="this.style.color='#e6edf3'" onmouseleave="this.style.color='#8b949e'" title="Clear task and working directory">&times;</button>
1504
+ <button onclick="document.getElementById('agent-task-input').value='';document.getElementById('agent-workdir-input').value='';document.getElementById('agent-context-input').value='';fetchMcpServers('');document.getElementById('agent-task-input').focus();" style="position:absolute;top:4px;right:4px;background:#21262d;border:1px solid #30363d;color:#8b949e;padding:8px;border-radius:4px;cursor:pointer;font-size:14px;line-height:1;z-index:1;" onmouseenter="this.style.color='#e6edf3'" onmouseleave="this.style.color='#8b949e'" title="Clear task and working directory">&times;</button>
1435
1505
  </div>
1436
1506
  <label style="display:block;font-size:0.82rem;color:#8b949e;margin-bottom:0.4rem;font-weight:600;">Working Directory</label>
1437
1507
  <div style="display:flex;gap:0.5rem;margin-bottom:0.3rem;">
@@ -1474,6 +1544,20 @@
1474
1544
  </div>
1475
1545
  </div>
1476
1546
  </details>
1547
+ <details class="mcp-tools-details" id="mcp-tools-details">
1548
+ <summary class="mcp-tools-summary" id="mcp-tools-summary">Builder MCP Tools</summary>
1549
+ <div id="mcp-tools-container">
1550
+ <div id="mcp-tools-loading" style="font-size:0.78rem;color:#484f58;padding:0.3rem 0;">Enter a working directory to discover MCP servers</div>
1551
+ <div id="mcp-tools-list" class="mcp-tools-list" style="display:none;"></div>
1552
+ <div id="mcp-tools-empty" style="display:none;font-size:0.78rem;color:#484f58;padding:0.3rem 0;">No MCP servers found in this project</div>
1553
+ <p class="mcp-tools-hint" id="mcp-tools-hint" style="display:none;">Unchecked servers will be excluded from the Builder's context to reduce token usage</p>
1554
+ <p id="mcp-tools-global-note" style="display:none;font-size:0.72rem;color:#d29922;margin:0.3rem 0 0;">Using global defaults</p>
1555
+ <div id="mcp-tools-actions" style="display:none;margin-top:0.4rem;">
1556
+ <button class="mcp-tools-save-btn" onclick="saveMcpToolsConfig()">Save as Default</button>
1557
+ <span class="mcp-tools-saved" id="mcp-tools-saved">Saved</span>
1558
+ </div>
1559
+ </div>
1560
+ </details>
1477
1561
  <label for="agent-autocommit-checkbox" style="display:flex;align-items:center;gap:0.5rem;font-size:0.82rem;color:#8b949e;margin-bottom:1rem;cursor:pointer;position:relative;z-index:1;">
1478
1562
  <input id="agent-autocommit-checkbox" type="checkbox" style="accent-color:#238636;width:15px;height:15px;cursor:pointer;flex-shrink:0;">
1479
1563
  Auto-commit on approval
@@ -1644,6 +1728,36 @@
1644
1728
  </div>
1645
1729
  </div>
1646
1730
 
1731
+ <div class="panel">
1732
+ <h3>Agent Runner Setup</h3>
1733
+ <p style="color:#8b949e;font-size:0.82rem;margin:0 0 0.8rem;">
1734
+ The Agent Runner requires an Anthropic API key for the Manager and Reviewer agents.
1735
+ The Builder uses Claude Code directly. Headroom is optional but recommended for token compression.
1736
+ </p>
1737
+ <div style="display:grid;grid-template-columns:auto 1fr;gap:0.5rem 1rem;font-size:0.82rem;align-items:start;">
1738
+ <span style="color:#8b949e;">API Key</span>
1739
+ <span id="settings-api-key-status" style="color:#c9d1d9;">—</span>
1740
+ <span style="color:#8b949e;">Headroom Proxy</span>
1741
+ <span id="settings-proxy-status" style="color:#c9d1d9;">—</span>
1742
+ </div>
1743
+ </div>
1744
+
1745
+ <div class="panel">
1746
+ <details class="mcp-tools-details">
1747
+ <summary class="mcp-tools-summary">Default MCP Tools</summary>
1748
+ <div style="padding:0.3rem 0;">
1749
+ <p style="color:#8b949e;font-size:0.78rem;margin:0 0 0.6rem;">Set which MCP servers are blocked by default across all projects. Per-project settings override these defaults.</p>
1750
+ <label style="font-size:0.78rem;color:#8b949e;display:block;margin-bottom:0.3rem;">Blocked servers (comma-separated)</label>
1751
+ <input type="text" id="settings-global-mcp-blocked" class="settings-text-input" placeholder="e.g. agent-bridge, connectstack-uptimerobot">
1752
+ <div style="display:flex;gap:0.5rem;margin-top:0.5rem;align-items:center;">
1753
+ <button class="mcp-tools-save-btn" onclick="saveGlobalMcpDefaults(false)">Save Defaults</button>
1754
+ <button class="mcp-tools-save-btn" style="background:#30363d;" onclick="applyGlobalMcpToAll()">Apply to All Projects</button>
1755
+ <span id="settings-global-mcp-saved" class="mcp-tools-saved">Saved</span>
1756
+ </div>
1757
+ </div>
1758
+ </details>
1759
+ </div>
1760
+
1647
1761
  <div class="panel">
1648
1762
  <h3>Dashboard Info</h3>
1649
1763
  <div style="display:grid;grid-template-columns:auto 1fr;gap:0.4rem 1.2rem;font-size:0.82rem;">
@@ -2805,6 +2919,8 @@ async function startAgentTask() {
2805
2919
  body.builder_model = document.getElementById('agent-model-builder').value;
2806
2920
  body.reviewer_model = document.getElementById('agent-model-reviewer').value;
2807
2921
  body.user_name = userProfile.user_name || '';
2922
+ const blockedMcp = getBlockedMcpServers();
2923
+ if (blockedMcp.length) body.blocked_mcp_servers = blockedMcp;
2808
2924
  const res = await fetch('/api/agent-run', {method:'POST', headers: AUTH_HEADERS, body: JSON.stringify(body)});
2809
2925
  const data = await res.json();
2810
2926
  if (data.error) { alert(data.error); return; }
@@ -2843,6 +2959,12 @@ async function loadAgentMonitor() {
2843
2959
  lbl.textContent = userProfile.agent_names[role];
2844
2960
  }
2845
2961
  }
2962
+ updateMcpToolsSummaryLabel();
2963
+ const wdInput = document.getElementById('agent-workdir-input');
2964
+ if (wdInput && !wdInput._mcpBound) {
2965
+ wdInput._mcpBound = true;
2966
+ wdInput.addEventListener('blur', () => fetchMcpServers(wdInput.value.trim()));
2967
+ }
2846
2968
  fetchAgentStatus();
2847
2969
  loadRecentProjects();
2848
2970
  loadLastWorkdir();
@@ -2858,10 +2980,110 @@ async function loadLastWorkdir() {
2858
2980
  if (input.value.trim()) return;
2859
2981
  const res = await fetch('/api/last-workdir', {headers: {'X-Auth-Token': AUTH_TOKEN}});
2860
2982
  const data = await res.json();
2861
- if (data.path) input.value = data.path;
2983
+ if (data.path) { input.value = data.path; fetchMcpServers(data.path); }
2862
2984
  } catch(e) { /* ignore */ }
2863
2985
  }
2864
2986
 
2987
+ /* ─── Builder MCP Tools ─── */
2988
+ let _mcpServers = [];
2989
+ let _mcpBlocked = new Set();
2990
+
2991
+ function updateMcpToolsSummaryLabel() {
2992
+ const el = document.getElementById('mcp-tools-summary');
2993
+ if (!el) return;
2994
+ const name = (userProfile.agent_names && userProfile.agent_names.builder) || 'Builder';
2995
+ el.textContent = name + ' MCP Tools';
2996
+ }
2997
+
2998
+ async function fetchMcpServers(workdir) {
2999
+ const listEl = document.getElementById('mcp-tools-list');
3000
+ const emptyEl = document.getElementById('mcp-tools-empty');
3001
+ const loadingEl = document.getElementById('mcp-tools-loading');
3002
+ const hintEl = document.getElementById('mcp-tools-hint');
3003
+ const actionsEl = document.getElementById('mcp-tools-actions');
3004
+ if (!workdir) {
3005
+ listEl.style.display = 'none'; emptyEl.style.display = 'none'; hintEl.style.display = 'none'; actionsEl.style.display = 'none';
3006
+ const gn = document.getElementById('mcp-tools-global-note'); if (gn) gn.style.display = 'none';
3007
+ loadingEl.style.display = ''; loadingEl.textContent = 'Enter a working directory to discover MCP servers';
3008
+ _mcpServers = []; _mcpBlocked = new Set();
3009
+ return;
3010
+ }
3011
+ const globalNoteEl = document.getElementById('mcp-tools-global-note');
3012
+ loadingEl.style.display = ''; loadingEl.textContent = 'Discovering MCP servers...';
3013
+ listEl.style.display = 'none'; emptyEl.style.display = 'none'; hintEl.style.display = 'none'; actionsEl.style.display = 'none';
3014
+ if (globalNoteEl) globalNoteEl.style.display = 'none';
3015
+ try {
3016
+ const res = await fetch('/api/mcp-servers?workdir=' + encodeURIComponent(workdir), {headers: AUTH_GET});
3017
+ const data = await res.json();
3018
+ _mcpServers = data.servers || [];
3019
+ _mcpBlocked = new Set(data.blocked || []);
3020
+ loadingEl.style.display = 'none';
3021
+ if (_mcpServers.length === 0) {
3022
+ emptyEl.style.display = ''; return;
3023
+ }
3024
+ listEl.innerHTML = _mcpServers.map(srv => {
3025
+ const checked = !_mcpBlocked.has(srv) ? 'checked' : '';
3026
+ const id = 'mcp-srv-' + srv.replace(/[^a-zA-Z0-9_-]/g, '_');
3027
+ return `<div class="mcp-tools-row"><input type="checkbox" id="${id}" value="${escapeHtml(srv)}" ${checked}><label for="${id}">${escapeHtml(srv)}</label></div>`;
3028
+ }).join('');
3029
+ listEl.style.display = ''; hintEl.style.display = ''; actionsEl.style.display = '';
3030
+ if (globalNoteEl && !data.has_project_config && (data.global_blocked || []).length > 0) {
3031
+ globalNoteEl.style.display = '';
3032
+ }
3033
+ } catch(e) {
3034
+ loadingEl.style.display = 'none'; emptyEl.style.display = ''; emptyEl.textContent = 'Failed to discover MCP servers';
3035
+ }
3036
+ }
3037
+
3038
+ function getBlockedMcpServers() {
3039
+ const blocked = [];
3040
+ for (const srv of _mcpServers) {
3041
+ const id = 'mcp-srv-' + srv.replace(/[^a-zA-Z0-9_-]/g, '_');
3042
+ const cb = document.getElementById(id);
3043
+ if (cb && !cb.checked) blocked.push(srv);
3044
+ }
3045
+ return blocked;
3046
+ }
3047
+
3048
+ async function saveMcpToolsConfig() {
3049
+ const workdir = document.getElementById('agent-workdir-input').value.trim();
3050
+ if (!workdir) return;
3051
+ const blocked = getBlockedMcpServers();
3052
+ try {
3053
+ await fetch('/api/builder-tools-config', {method: 'POST', headers: AUTH_HEADERS, body: JSON.stringify({workdir, blocked})});
3054
+ const savedEl = document.getElementById('mcp-tools-saved');
3055
+ savedEl.style.opacity = '1';
3056
+ setTimeout(() => { savedEl.style.opacity = '0'; }, 2000);
3057
+ } catch(e) { /* ignore */ }
3058
+ }
3059
+
3060
+ async function loadGlobalMcpDefaults() {
3061
+ try {
3062
+ const res = await fetch('/api/global-mcp-defaults', {headers: AUTH_GET});
3063
+ const d = await res.json();
3064
+ const el = document.getElementById('settings-global-mcp-blocked');
3065
+ if (el) el.value = (d.blocked || []).join(', ');
3066
+ } catch(e) { /* ignore */ }
3067
+ }
3068
+
3069
+ async function saveGlobalMcpDefaults(applyAll) {
3070
+ const el = document.getElementById('settings-global-mcp-blocked');
3071
+ if (!el) return;
3072
+ const blocked = el.value.split(',').map(s => s.trim()).filter(Boolean);
3073
+ try {
3074
+ await fetch('/api/global-mcp-defaults', {method: 'POST', headers: AUTH_HEADERS, body: JSON.stringify({blocked, apply_all: !!applyAll})});
3075
+ const savedEl = document.getElementById('settings-global-mcp-saved');
3076
+ savedEl.textContent = applyAll ? 'Applied to all' : 'Saved';
3077
+ savedEl.style.opacity = '1';
3078
+ setTimeout(() => { savedEl.style.opacity = '0'; }, 2000);
3079
+ } catch(e) { /* ignore */ }
3080
+ }
3081
+
3082
+ function applyGlobalMcpToAll() {
3083
+ if (!confirm('This will overwrite all per-project MCP settings. Continue?')) return;
3084
+ saveGlobalMcpDefaults(true);
3085
+ }
3086
+
2865
3087
  function autoResize(textarea) {
2866
3088
  textarea.style.height = 'auto';
2867
3089
  textarea.style.height = textarea.scrollHeight + 'px';
@@ -2970,7 +3192,7 @@ async function loadRecentProjects() {
2970
3192
  if (!dirs.length) { dropdown.style.display = 'none'; return; }
2971
3193
  dropdown.style.display = 'block';
2972
3194
  dropdown.innerHTML = dirs.map(d =>
2973
- `<div style="padding:0.4rem 0.7rem;cursor:pointer;font-size:0.8rem;font-family:monospace;color:#8b949e;border-bottom:1px solid #21262d;transition:background 0.15s;" onmouseenter="this.style.background='#161b22';this.style.color='#e6edf3'" onmouseleave="this.style.background='';this.style.color='#8b949e'" onclick="document.getElementById('agent-workdir-input').value=this.textContent">${escapeHtml(d)}</div>`
3195
+ `<div style="padding:0.4rem 0.7rem;cursor:pointer;font-size:0.8rem;font-family:monospace;color:#8b949e;border-bottom:1px solid #21262d;transition:background 0.15s;" onmouseenter="this.style.background='#161b22';this.style.color='#e6edf3'" onmouseleave="this.style.background='';this.style.color='#8b949e'" onclick="document.getElementById('agent-workdir-input').value=this.textContent;fetchMcpServers(this.textContent)">${escapeHtml(d)}</div>`
2974
3196
  ).join('');
2975
3197
  } catch(e) { /* ignore */ }
2976
3198
  }
@@ -2992,6 +3214,7 @@ function closeDirBrowser() {
2992
3214
  function selectDirBrowser() {
2993
3215
  document.getElementById('agent-workdir-input').value = dirBrowserCurrentPath;
2994
3216
  closeDirBrowser();
3217
+ fetchMcpServers(dirBrowserCurrentPath);
2995
3218
  }
2996
3219
 
2997
3220
  function dirBrowserBack() {
@@ -3231,6 +3454,7 @@ function renderAgentUI(data) {
3231
3454
  const pulseStyle = a.status === 'busy' ? 'animation:pulse-busy 2s infinite;' : '';
3232
3455
  const roleIcon = role === 'manager' ? '&#128188;' : role === 'builder' ? '&#128736;' : '&#128269;';
3233
3456
  let statusDisplay;
3457
+ const hasStreamOutput = a.status === 'busy' && a.last_output && a.last_output.trim().length > 0;
3234
3458
  if (a.status === 'busy' && BUSY_PHRASES[role]) {
3235
3459
  const phrase = BUSY_PHRASES[role][busyPhraseIndices[role] % BUSY_PHRASES[role].length];
3236
3460
  const customName = (userProfile.agent_names && userProfile.agent_names[role]) || role.charAt(0).toUpperCase() + role.slice(1);
@@ -3276,6 +3500,8 @@ function renderAgentUI(data) {
3276
3500
  }
3277
3501
  }
3278
3502
  const errorBorder = (a.status === 'error' || a.status === 'timeout' || a.status === 'crashed') ? ' agent-card-error-border' : '';
3503
+ const outputMaxHeight = hasStreamOutput ? '6em' : '3em';
3504
+ const outputSnippet = escapeHtml((a.last_output || '').substring(0, hasStreamOutput ? 500 : 150));
3279
3505
  return `<div class="agent-card${errorBorder}" style="${pulseStyle}">
3280
3506
  <span class="agent-tooltip">${escapeHtml(ROLE_DESCRIPTIONS[role] || '')}</span>
3281
3507
  <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:0.6rem;">
@@ -3283,7 +3509,7 @@ function renderAgentUI(data) {
3283
3509
  <span style="background:${color}20;color:${color};padding:2px 8px;border-radius:12px;font-size:0.7rem;font-weight:600;text-transform:uppercase;">${statusDisplay}</span>
3284
3510
  </div>
3285
3511
  <div style="font-size:0.75rem;color:#8b949e;line-height:1.6;">
3286
- <div style="margin-top:0.3rem;color:#c9d1d9;font-size:0.72rem;max-height:3em;overflow:hidden;text-overflow:ellipsis;">${escapeHtml((a.last_output || '').substring(0, 150))}</div>
3512
+ <div style="margin-top:0.3rem;color:#c9d1d9;font-size:0.72rem;max-height:${outputMaxHeight};overflow:hidden;text-overflow:ellipsis;white-space:pre-wrap;word-break:break-word;">${outputSnippet}</div>
3287
3513
  </div>
3288
3514
  ${elapsedHtml}
3289
3515
  ${contextHtml}
@@ -3481,8 +3707,24 @@ async function loadSettings() {
3481
3707
  document.getElementById('settings-pro-dir').textContent = d.pro_skills_dir || '—';
3482
3708
  document.getElementById('settings-stats-db').textContent = d.stats_db || '—';
3483
3709
  document.getElementById('settings-sessions-dir').textContent = d.sessions_dir || '—';
3710
+
3711
+ const apiKeyEl = document.getElementById('settings-api-key-status');
3712
+ if (d.api_key_set) {
3713
+ apiKeyEl.innerHTML = '<span style="color:#3fb950;">&#10003;</span> API key detected';
3714
+ } else {
3715
+ apiKeyEl.innerHTML = '<span style="color:#f85149;">&#10007;</span> Not set &mdash; run <code style="background:#161b22;padding:0.1rem 0.4rem;border-radius:3px;color:#c9d1d9;">set ANTHROPIC_API_KEY=sk-ant-...</code> before starting the dashboard';
3716
+ }
3717
+
3718
+ const proxyEl = document.getElementById('settings-proxy-status');
3719
+ if (d.base_url) {
3720
+ proxyEl.innerHTML = '<span style="color:#3fb950;">&#10003;</span> Headroom proxy active at <code style="background:#161b22;padding:0.1rem 0.4rem;border-radius:3px;color:#c9d1d9;">' + d.base_url.replace(/</g,'&lt;') + '</code>';
3721
+ } else {
3722
+ proxyEl.innerHTML = 'Optional: Install <a href="https://github.com/chopratejas/headroom" target="_blank" style="color:#58a6ff;">Headroom</a> for ~34% token savings';
3723
+ }
3724
+
3484
3725
  loadModelPrefs();
3485
3726
  await loadUserProfile();
3727
+ loadGlobalMcpDefaults();
3486
3728
  settingsLoaded = true;
3487
3729
  } catch (e) {
3488
3730
  console.error('Settings load failed:', e);
@@ -458,6 +458,27 @@ class _Handler(BaseHTTPRequestHandler):
458
458
  pass
459
459
  body = json.dumps({"path": path}).encode()
460
460
  self._respond(200, "application/json", body)
461
+ elif self.path.startswith("/api/mcp-servers"):
462
+ parsed = urlparse(self.path)
463
+ qs = parse_qs(parsed.query)
464
+ workdir = qs.get("workdir", [""])[0]
465
+ servers = agent_runner.discover_mcp_servers(workdir) if workdir else []
466
+ cfg = agent_runner.load_builder_tools_config()
467
+ blocked: list[str] = []
468
+ has_project_config = False
469
+ if workdir:
470
+ if workdir in cfg:
471
+ blocked = cfg[workdir]
472
+ has_project_config = True
473
+ else:
474
+ blocked = cfg.get("_global_defaults", [])
475
+ global_blocked = cfg.get("_global_defaults", [])
476
+ body = json.dumps({"servers": servers, "blocked": blocked, "global_blocked": global_blocked, "has_project_config": has_project_config}).encode()
477
+ self._respond(200, "application/json", body)
478
+ elif self.path == "/api/global-mcp-defaults":
479
+ cfg = agent_runner.load_builder_tools_config()
480
+ body = json.dumps({"blocked": cfg.get("_global_defaults", [])}).encode()
481
+ self._respond(200, "application/json", body)
461
482
  elif self.path == "/api/headroom-stats":
462
483
  try:
463
484
  req = urllib.request.Request("http://127.0.0.1:8787/stats")
@@ -511,6 +532,8 @@ class _Handler(BaseHTTPRequestHandler):
511
532
  "pro_skills_dir": str(home / ".memstack" / "pro-skills"),
512
533
  "stats_db": str(DB_PATH),
513
534
  "sessions_dir": str(home / ".memstack" / "agent-runner" / "sessions"),
535
+ "api_key_set": bool(os.environ.get("ANTHROPIC_API_KEY")),
536
+ "base_url": os.environ.get("ANTHROPIC_BASE_URL", ""),
514
537
  }
515
538
  body = json.dumps(data).encode()
516
539
  self._respond(200, "application/json", body)
@@ -723,8 +746,10 @@ class _Handler(BaseHTTPRequestHandler):
723
746
  builder_model = data.get("builder_model", "")
724
747
  reviewer_model = data.get("reviewer_model", "")
725
748
  user_name = str(data.get("user_name", "")).strip()
749
+ blocked_mcp = data.get("blocked_mcp_servers", [])
726
750
  result = agent_runner.start_run(task=task, working_dir=working_dir, context=context, auto_commit=auto_commit, timeout_minutes=timeout_minutes,
727
- manager_model=manager_model, builder_model=builder_model, reviewer_model=reviewer_model, user_name=user_name)
751
+ manager_model=manager_model, builder_model=builder_model, reviewer_model=reviewer_model, user_name=user_name,
752
+ blocked_mcp_servers=blocked_mcp)
728
753
  body = json.dumps(result).encode()
729
754
  status_code = 200 if "session_id" in result else 400
730
755
  self._respond(status_code, "application/json", body)
@@ -766,6 +791,49 @@ class _Handler(BaseHTTPRequestHandler):
766
791
  except Exception as exc:
767
792
  body = json.dumps({"success": False, "error": str(exc)}).encode()
768
793
  self._respond(500, "application/json", body)
794
+ elif self.path == "/api/builder-tools-config":
795
+ try:
796
+ content_len = int(self.headers.get("Content-Length", 0))
797
+ raw = self.rfile.read(content_len).decode("utf-8") if content_len else ""
798
+ data = json.loads(raw)
799
+ workdir = data.get("workdir", "").strip()
800
+ blocked = data.get("blocked", [])
801
+ if not workdir:
802
+ body = json.dumps({"success": False, "error": "Missing 'workdir' field."}).encode()
803
+ self._respond(400, "application/json", body)
804
+ return
805
+ cfg = agent_runner.load_builder_tools_config()
806
+ cfg[workdir] = blocked
807
+ agent_runner.save_builder_tools_config(cfg)
808
+ body = json.dumps({"success": True}).encode()
809
+ self._respond(200, "application/json", body)
810
+ except (json.JSONDecodeError, ValueError):
811
+ body = json.dumps({"success": False, "error": "Invalid JSON body."}).encode()
812
+ self._respond(400, "application/json", body)
813
+ except Exception as exc:
814
+ body = json.dumps({"success": False, "error": str(exc)}).encode()
815
+ self._respond(500, "application/json", body)
816
+ elif self.path == "/api/global-mcp-defaults":
817
+ try:
818
+ content_len = int(self.headers.get("Content-Length", 0))
819
+ raw = self.rfile.read(content_len).decode("utf-8") if content_len else ""
820
+ data = json.loads(raw)
821
+ blocked = data.get("blocked", [])
822
+ cfg = agent_runner.load_builder_tools_config()
823
+ cfg["_global_defaults"] = blocked
824
+ if data.get("apply_all"):
825
+ for key in list(cfg.keys()):
826
+ if key != "_global_defaults":
827
+ cfg[key] = list(blocked)
828
+ agent_runner.save_builder_tools_config(cfg)
829
+ body = json.dumps({"success": True}).encode()
830
+ self._respond(200, "application/json", body)
831
+ except (json.JSONDecodeError, ValueError):
832
+ body = json.dumps({"success": False, "error": "Invalid JSON body."}).encode()
833
+ self._respond(400, "application/json", body)
834
+ except Exception as exc:
835
+ body = json.dumps({"success": False, "error": str(exc)}).encode()
836
+ self._respond(500, "application/json", body)
769
837
  elif self.path == "/api/burn-report/reset":
770
838
  try:
771
839
  from .stats import reset_burn_stats
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: memstack-skill-loader
3
- Version: 4.0.3
3
+ Version: 4.0.5
4
4
  Summary: MCP server that vector-indexes MemStack Pro skills for on-demand loading
5
5
  Requires-Python: >=3.10
6
6
  Requires-Dist: mcp>=1.0.0