tylor-mcp 1.1.0 → 1.1.2

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 (40) hide show
  1. package/bin/tylor.js +0 -1
  2. package/package.json +5 -2
  3. package/pytest.ini +2 -2
  4. package/scripts/dev-sync.js +113 -0
  5. package/server/tools/agents.py +29 -12
  6. package/server/tools/executor.py +46 -12
  7. package/server/tools/harness.py +174 -72
  8. package/server/tools/security.py +141 -0
  9. package/skills/tylor-run/SKILL.md +61 -0
  10. package/server/server.log +0 -1
  11. package/server/storage/tests/__init__.py +0 -0
  12. package/server/storage/tests/test_dynamo.py +0 -452
  13. package/server/storage/tests/test_json_store.py +0 -226
  14. package/server/storage/tests/test_opensearch.py +0 -270
  15. package/server/storage/tests/test_s3.py +0 -125
  16. package/server/tests/__init__.py +0 -0
  17. package/server/tests/test_install.py +0 -620
  18. package/server/tests/test_isolation.py +0 -90
  19. package/server/tests/test_ui_server.py +0 -423
  20. package/server/tests/test_ui_shader_background.py +0 -49
  21. package/server/tests/test_ui_story_6_3.py +0 -98
  22. package/server/tools/tests/__init__.py +0 -0
  23. package/server/tools/tests/test_agents.py +0 -259
  24. package/server/tools/tests/test_code_index.py +0 -108
  25. package/server/tools/tests/test_ecc_tools.py +0 -51
  26. package/server/tools/tests/test_executor.py +0 -623
  27. package/server/tools/tests/test_help_agent101.py +0 -156
  28. package/server/tools/tests/test_hooks.py +0 -124
  29. package/server/tools/tests/test_kill_thread.py +0 -125
  30. package/server/tools/tests/test_new_thread_list_threads.py +0 -293
  31. package/server/tools/tests/test_personas.py +0 -52
  32. package/server/tools/tests/test_recall_memory.py +0 -55
  33. package/server/tools/tests/test_registry_client.py +0 -322
  34. package/server/tools/tests/test_router.py +0 -263
  35. package/server/tools/tests/test_skill_installer.py +0 -193
  36. package/server/tools/tests/test_spawn_agent_harness.py +0 -225
  37. package/server/tools/tests/test_switch_thread.py +0 -163
  38. package/server/tools/tests/test_thread_command_skills.py +0 -60
  39. package/server/tools/tests/test_thread_resolver.py +0 -165
  40. package/server/tools/tests/test_tier1_schema.py +0 -310
package/bin/tylor.js CHANGED
@@ -55,4 +55,3 @@ if (result.error || result.status !== 0) {
55
55
  }
56
56
 
57
57
  process.exit(result.status);
58
-
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tylor-mcp",
3
- "version": "1.1.0",
3
+ "version": "1.1.2",
4
4
  "description": "Give Claude Code persistent memory, laser-focused context, and an autonomous team of specialists.",
5
5
  "main": "server/main.py",
6
6
  "bin": {
@@ -20,5 +20,8 @@
20
20
  "github-copilot"
21
21
  ],
22
22
  "author": "Gunjan Grunge",
23
- "license": "MIT"
23
+ "license": "MIT",
24
+ "scripts": {
25
+ "dev:sync": "node scripts/dev-sync.js"
26
+ }
24
27
  }
package/pytest.ini CHANGED
@@ -1,2 +1,2 @@
1
- [pytest]
2
- asyncio_mode = auto
1
+ [pytest]
2
+ asyncio_mode = auto
@@ -0,0 +1,113 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * dev-sync.js — sync dev changes to the live installed server.
4
+ *
5
+ * Reads PYTHONPATH from ~/.claude/settings.json (written there by install.py),
6
+ * so it always targets the correct installed location regardless of npm version
7
+ * or _npx cache hash. No hardcoding.
8
+ *
9
+ * Usage:
10
+ * npm run dev:sync
11
+ */
12
+
13
+ const fs = require('fs');
14
+ const path = require('path');
15
+ const os = require('os');
16
+
17
+ // ── Find the installed server path from settings.json ────────────────────────
18
+
19
+ function findInstalledPath() {
20
+ const candidates = [
21
+ path.join(os.homedir(), '.claude', 'settings.json'),
22
+ path.join(os.homedir(), 'AppData', 'Roaming', 'Claude', 'settings.json'),
23
+ ];
24
+
25
+ for (const candidate of candidates) {
26
+ if (!fs.existsSync(candidate)) continue;
27
+ try {
28
+ const settings = JSON.parse(fs.readFileSync(candidate, 'utf8'));
29
+ const pythonpath =
30
+ settings?.mcpServers?.agent101?.env?.PYTHONPATH;
31
+ if (pythonpath && fs.existsSync(pythonpath)) {
32
+ return pythonpath;
33
+ }
34
+ } catch (_) { /* malformed — skip */ }
35
+ }
36
+ return null;
37
+ }
38
+
39
+ // ── Files and directories to sync ────────────────────────────────────────────
40
+
41
+ const DEV_ROOT = path.join(__dirname, '..');
42
+
43
+ const SYNC_FILES = [
44
+ 'server/tools/harness.py',
45
+ 'server/tools/security.py',
46
+ 'server/tools/executor.py',
47
+ 'server/tools/registry.py',
48
+ ];
49
+
50
+ const SYNC_DIRS = [
51
+ 'skills',
52
+ ];
53
+
54
+ // ── Helpers ───────────────────────────────────────────────────────────────────
55
+
56
+ function copyFile(src, dst) {
57
+ fs.mkdirSync(path.dirname(dst), { recursive: true });
58
+ fs.copyFileSync(src, dst);
59
+ }
60
+
61
+ function syncDir(srcDir, dstDir) {
62
+ if (!fs.existsSync(srcDir)) return 0;
63
+ let count = 0;
64
+ for (const entry of fs.readdirSync(srcDir, { withFileTypes: true })) {
65
+ const srcPath = path.join(srcDir, entry.name);
66
+ const dstPath = path.join(dstDir, entry.name);
67
+ if (entry.isDirectory()) {
68
+ count += syncDir(srcPath, dstPath);
69
+ } else {
70
+ copyFile(srcPath, dstPath);
71
+ count++;
72
+ }
73
+ }
74
+ return count;
75
+ }
76
+
77
+ // ── Main ──────────────────────────────────────────────────────────────────────
78
+
79
+ const installed = findInstalledPath();
80
+
81
+ if (!installed) {
82
+ console.error('❌ Could not find installed server path in ~/.claude/settings.json');
83
+ console.error(' Run: npx tylor-mcp to install first.');
84
+ process.exit(1);
85
+ }
86
+
87
+ console.log(`🎯 Target: ${installed}\n`);
88
+
89
+ let total = 0;
90
+
91
+ for (const rel of SYNC_FILES) {
92
+ const src = path.join(DEV_ROOT, rel);
93
+ const dst = path.join(installed, rel);
94
+ if (!fs.existsSync(src)) {
95
+ console.warn(`⚠️ skip (not found): ${rel}`);
96
+ continue;
97
+ }
98
+ copyFile(src, dst);
99
+ console.log(` ✓ ${rel}`);
100
+ total++;
101
+ }
102
+
103
+ for (const rel of SYNC_DIRS) {
104
+ const src = path.join(DEV_ROOT, rel);
105
+ const dst = path.join(installed, rel);
106
+ const n = syncDir(src, dst);
107
+ if (n > 0) {
108
+ console.log(` ✓ ${rel}/ (${n} files)`);
109
+ total += n;
110
+ }
111
+ }
112
+
113
+ console.log(`\n✅ ${total} file(s) synced. Restart Claude Code to pick up changes.`);
@@ -235,16 +235,29 @@ def _run_persona_agent(
235
235
  return {"output_sk": None, "output": "", "error": str(exc)}
236
236
 
237
237
  # Persist output to thread
238
- try:
239
- result = persist_agent_output(
240
- thread_id=thread_id,
241
- agent_id=agent_id,
242
- output=output or "(no output)",
243
- task=task,
244
- )
245
- output_sk = result.get("output_sk")
246
- except Exception:
247
- output_sk = None
238
+ try:
239
+ result = persist_agent_output(
240
+ thread_id=thread_id,
241
+ agent_id=agent_id,
242
+ output=output or "(no output)",
243
+ task=task,
244
+ )
245
+ output_sk = result.get("output_sk")
246
+ except Exception as exc:
247
+ message = f"{persona} output persistence failed: {exc}"
248
+ _record_agent_event(db, thread_id, agent_id, persona, "error", message)
249
+ _write_agent_state(
250
+ db,
251
+ thread_id,
252
+ agent_id,
253
+ {
254
+ "Status": "failed",
255
+ "Persona": persona,
256
+ "Task": task,
257
+ "Error": str(exc),
258
+ },
259
+ )
260
+ return {"output_sk": None, "output": output, "error": str(exc)}
248
261
 
249
262
  # Update agent state to completed
250
263
  _write_agent_state(
@@ -332,7 +345,11 @@ def spawn_agent(persona: str, thread_id: str, task: str, wait_for_completion: bo
332
345
  )
333
346
  worker.start()
334
347
 
335
- return {
348
+ status = "running"
349
+ if wait_for_completion:
350
+ status = "failed" if execution.get("error") else "completed"
351
+
352
+ return {
336
353
  "agent_id": agent_id,
337
354
  "persona": definition.name,
338
355
  "thread_id": thread_id,
@@ -341,7 +358,7 @@ def spawn_agent(persona: str, thread_id: str, task: str, wait_for_completion: bo
341
358
  "task": task,
342
359
  "state_sk": state_item["SK"],
343
360
  "output_sk": execution.get("output_sk"),
344
- "status": "completed" if wait_for_completion else "running",
361
+ "status": status,
345
362
  "streaming": not wait_for_completion,
346
363
  "skill_loads": persona_skill_loads,
347
364
  "task_skill": task_skill,
@@ -13,7 +13,7 @@ from pathlib import Path
13
13
  from mcp.server.fastmcp.exceptions import ToolError
14
14
 
15
15
  from ._mcp import mcp
16
- from .security import run_bumblebee_security_gate
16
+ from .security import run_bumblebee_security_gate, should_prompt_on_push
17
17
 
18
18
 
19
19
  NO_SANDBOX_MESSAGE = "No sandbox configured — run /set-sandbox <path> first"
@@ -272,17 +272,34 @@ def _validate_command_paths(
272
272
 
273
273
 
274
274
  def _terminate_process_group(process: subprocess.Popen) -> None:
275
- try:
276
- os.killpg(process.pid, signal.SIGTERM)
277
- except ProcessLookupError:
278
- return
279
- try:
280
- process.wait(timeout=2)
281
- except subprocess.TimeoutExpired:
275
+ """Terminate a process and its children — cross-platform."""
276
+ if os.name == "nt":
277
+ # Windows: no process groups — use taskkill to kill the tree
278
+ try:
279
+ subprocess.run(
280
+ ["taskkill", "/F", "/T", "/PID", str(process.pid)],
281
+ capture_output=True,
282
+ check=False,
283
+ )
284
+ except OSError:
285
+ pass
286
+ try:
287
+ process.kill()
288
+ except OSError:
289
+ pass
290
+ else:
291
+ # Unix: kill the entire process group (shell + children)
282
292
  try:
283
- os.killpg(process.pid, signal.SIGKILL)
293
+ os.killpg(process.pid, signal.SIGTERM)
284
294
  except ProcessLookupError:
285
295
  return
296
+ try:
297
+ process.wait(timeout=2)
298
+ except subprocess.TimeoutExpired:
299
+ try:
300
+ os.killpg(process.pid, signal.SIGKILL)
301
+ except ProcessLookupError:
302
+ return
286
303
 
287
304
 
288
305
  @mcp.tool()
@@ -362,6 +379,18 @@ def execute_in_sandbox(
362
379
  },
363
380
  )
364
381
 
382
+ if should_prompt_on_push(command):
383
+ return {
384
+ "status": "confirmation_required",
385
+ "command": command,
386
+ "message": (
387
+ "[bumblebee] 🛡️ git push detected. "
388
+ "Want a security scan before others pull? "
389
+ "Reply yes to scan first, or re-call execute_in_sandbox with "
390
+ "the original command to push without scanning."
391
+ ),
392
+ }
393
+
365
394
  start = time.monotonic()
366
395
  try:
367
396
  args = shlex.split(command)
@@ -403,10 +432,15 @@ def execute_in_sandbox(
403
432
  },
404
433
  )
405
434
  return result
406
- except subprocess.TimeoutExpired as exc:
435
+ except subprocess.TimeoutExpired:
407
436
  _terminate_process_group(process)
408
- stdout = exc.stdout or ""
409
- stderr = exc.stderr or ""
437
+ # Drain the pipe after kill to capture any partial output.
438
+ # This is critical on Windows where exc.stdout may be empty
439
+ # because the pipe buffer wasn't read before the process was killed.
440
+ try:
441
+ stdout, stderr = process.communicate(timeout=5)
442
+ except (subprocess.TimeoutExpired, OSError):
443
+ stdout, stderr = "", ""
410
444
  if isinstance(stdout, bytes):
411
445
  stdout = stdout.decode("utf-8", errors="replace")
412
446
  if isinstance(stderr, bytes):
@@ -7,13 +7,15 @@ Persistent session memory per thread. Interactive with human-in-the-loop.
7
7
  """
8
8
  from __future__ import annotations
9
9
 
10
- import asyncio
11
- import json
12
- from pathlib import Path
13
- from typing import AsyncIterator
10
+ import asyncio
11
+ import json
12
+ import os
13
+ from pathlib import Path
14
+ from typing import AsyncIterator
14
15
 
15
16
  from ._mcp import mcp
16
17
  from .registry import ECC_SKILLS, detect_registry_skill
18
+ from .security import is_dep_file, scan_packages_async, should_prompt_on_push
17
19
 
18
20
  # ── 5 roles (lenses, not knowledge bases) ────────────────────────────────────
19
21
 
@@ -233,64 +235,52 @@ def _load_session_id(thread_id: str) -> str | None:
233
235
  return None
234
236
 
235
237
 
236
- def _save_session_id(thread_id: str, session_id: str) -> None:
237
- import fcntl
238
- f = _sessions_file()
239
- f.parent.mkdir(parents=True, exist_ok=True)
240
- lock = f.with_suffix(".lock")
241
- with lock.open("a") as lf:
242
- fcntl.flock(lf, fcntl.LOCK_EX)
243
- try:
244
- data: dict = {}
245
- if f.exists():
246
- try:
247
- data = json.loads(f.read_text())
248
- except Exception:
249
- pass
250
- data[thread_id] = session_id
251
- tmp = f.with_suffix(".tmp")
252
- tmp.write_text(json.dumps(data, indent=2))
253
- tmp.replace(f)
254
- finally:
255
- fcntl.flock(lf, fcntl.LOCK_UN)
238
+ def _save_session_id(thread_id: str, session_id: str) -> None:
239
+ f = _sessions_file()
240
+ f.parent.mkdir(parents=True, exist_ok=True)
241
+ lock = f.with_suffix(".lock")
242
+ with lock.open("a+b") as lf:
243
+ if os.name == "nt":
244
+ import msvcrt
245
+
246
+ if lf.tell() == 0:
247
+ lf.write(b"\0")
248
+ lf.flush()
249
+ lf.seek(0)
250
+ msvcrt.locking(lf.fileno(), msvcrt.LK_LOCK, 1)
251
+ else:
252
+ import fcntl
253
+
254
+ fcntl.flock(lf, fcntl.LOCK_EX)
255
+ try:
256
+ data: dict = {}
257
+ if f.exists():
258
+ try:
259
+ data = json.loads(f.read_text(encoding="utf-8"))
260
+ except Exception:
261
+ pass
262
+ data[thread_id] = session_id
263
+ tmp = f.with_suffix(".tmp")
264
+ tmp.write_text(json.dumps(data, indent=2), encoding="utf-8")
265
+ tmp.replace(f)
266
+ finally:
267
+ if os.name == "nt":
268
+ lf.seek(0)
269
+ msvcrt.locking(lf.fileno(), msvcrt.LK_UNLCK, 1)
270
+ else:
271
+ fcntl.flock(lf, fcntl.LOCK_UN)
256
272
 
257
273
 
258
274
  # ── Core harness ──────────────────────────────────────────────────────────────
259
275
 
260
- def _block_to_verbose_text(block) -> str | None:
261
- text = getattr(block, "text", None)
262
- if isinstance(text, str) and text:
263
- return text
264
-
265
- block_type = getattr(block, "type", None) or block.__class__.__name__
266
- name = getattr(block, "name", None)
267
- tool_input = getattr(block, "input", None)
268
- content = getattr(block, "content", None)
269
-
270
- if name:
271
- suffix = ""
272
- if tool_input:
273
- try:
274
- suffix = " " + json.dumps(tool_input, ensure_ascii=False)[:500]
275
- except Exception:
276
- suffix = f" {tool_input!s}"[:500]
277
- return f"\n$ {name}{suffix}\n"
278
-
279
- if content:
280
- if isinstance(content, str):
281
- return f"\n[{block_type}] {content}\n"
282
- return f"\n[{block_type}] {content!s}\n"
283
-
284
- if block_type and block_type not in {"TextBlock", "text"}:
285
- return f"\n[{block_type}]\n"
286
- return None
287
-
288
276
  async def run_with_agents(
289
277
  message: str,
290
278
  thread_id: str,
291
279
  thread_name: str = "",
292
280
  cwd: str | None = None,
293
281
  system_prompt: str | None = None,
282
+ extra_tools: list[str] | None = None,
283
+ is_new_session: bool = False,
294
284
  ) -> AsyncIterator[str]:
295
285
  try:
296
286
  from claude_agent_sdk import query, ClaudeAgentOptions
@@ -305,43 +295,132 @@ async def run_with_agents(
305
295
 
306
296
  session_id = _load_session_id(thread_id)
307
297
 
298
+ base_tools = [
299
+ "Read", "Write", "Edit", "Bash", "Glob", "Grep",
300
+ "WebFetch", "WebSearch", "AskUserQuestion", "Agent",
301
+ ]
302
+ allowed_tools = base_tools + [t for t in (extra_tools or []) if t not in base_tools]
303
+
308
304
  options = ClaudeAgentOptions(
309
305
  system_prompt=system_prompt,
310
- allowed_tools=[
311
- "Read", "Write", "Edit", "Bash", "Glob", "Grep",
312
- "WebFetch", "WebSearch", "AskUserQuestion", "Agent",
313
- ],
306
+ allowed_tools=allowed_tools,
314
307
  agents=agent_registry,
315
308
  resume=session_id,
316
309
  cwd=cwd,
317
310
  max_turns=20,
318
311
  )
319
312
 
320
- new_session_id: str | None = None
321
- try:
322
- async for msg in query(prompt=message, options=options):
323
- # Capture session ID from ResultMessage (end of run)
313
+ # Kick off session-start scan in background so it runs alongside the agent
314
+ start_scan_task: asyncio.Task | None = None
315
+ if is_new_session and cwd:
316
+ yield "[bumblebee] starting session-start package scan...\n"
317
+ start_scan_task = asyncio.create_task(scan_packages_async(cwd))
318
+
319
+ agent_count = 0
320
+ dep_scan_tasks: list[tuple[str, asyncio.Task]] = []
321
+ bash_commands_seen: list[str] = []
322
+ new_session_id: str | None = None
323
+ query_error: Exception | None = None
324
+
325
+ try:
326
+ async for msg in query(prompt=message, options=options):
324
327
  sid = getattr(msg, "session_id", None)
325
328
  if sid:
326
329
  new_session_id = sid
327
330
 
328
- # Stream text and tool activity so the UI can mirror terminal-style
329
- # verbose agent progress instead of only showing final answers.
330
331
  content = getattr(msg, "content", None) or getattr(msg, "text", None)
331
332
  if isinstance(content, str) and content:
332
333
  yield content
333
334
  elif isinstance(content, list):
334
335
  for block in content:
335
- verbose = _block_to_verbose_text(block)
336
- if verbose:
337
- yield verbose
338
-
339
- except Exception as exc:
340
- yield f"\n⚠️ Error: {exc}"
336
+ name = getattr(block, "name", None)
337
+ input_ = getattr(block, "input", None) or {}
338
+
339
+ # ── Display ──────────────────────────────────────────────
340
+ text = getattr(block, "text", None)
341
+ if isinstance(text, str) and text:
342
+ yield text
343
+ elif name == "Agent":
344
+ agent_count += 1
345
+ role = (
346
+ input_.get("subagent_type")
347
+ or input_.get("description", "agent")
348
+ )
349
+ task_hint = (
350
+ input_.get("description") or input_.get("prompt", "")
351
+ )[:80].replace("\n", " ")
352
+ yield f"\n[agent: {role} #{agent_count}] starting — {task_hint}\n"
353
+ elif name:
354
+ yield f"\n$ {name}\n"
355
+
356
+ # ── Security side-effects ─────────────────────────────────
357
+ if name in ("Write", "Edit"):
358
+ fp = input_.get("file_path", "")
359
+ if fp and is_dep_file(fp):
360
+ fname = Path(fp).name
361
+ dep_scan_tasks.append((
362
+ fname,
363
+ asyncio.create_task(
364
+ scan_packages_async(cwd or str(Path(fp).parent))
365
+ ),
366
+ ))
367
+ yield f"\n[bumblebee] 📦 {fname} modified — scanning dependencies...\n"
368
+ elif name == "Bash":
369
+ cmd = input_.get("command", "")
370
+ if cmd:
371
+ bash_commands_seen.append(cmd)
372
+
373
+ except Exception as exc:
374
+ query_error = exc
375
+ yield f"\n⚠️ Error: {exc}"
341
376
 
342
377
  if new_session_id:
343
378
  _save_session_id(thread_id, new_session_id)
344
379
 
380
+ # ── Collect session-start scan result ─────────────────────────────────────
381
+ if start_scan_task:
382
+ try:
383
+ findings = await asyncio.wait_for(start_scan_task, timeout=60)
384
+ if findings:
385
+ yield "\n[bumblebee] ⚠️ session-start scan findings:\n"
386
+ for f in findings:
387
+ yield f" • {f}\n"
388
+ else:
389
+ yield "\n[bumblebee] ✅ session-start scan — no vulnerabilities found\n"
390
+ except asyncio.TimeoutError:
391
+ yield "\n[bumblebee] ⚠️ session-start scan timed out\n"
392
+ except Exception as exc:
393
+ yield f"\n[bumblebee] scan error: {exc}\n"
394
+
395
+ # ── Collect dep-file scan results ─────────────────────────────────────────
396
+ for fname, task in dep_scan_tasks:
397
+ try:
398
+ findings = await asyncio.wait_for(task, timeout=30)
399
+ if findings:
400
+ yield f"\n[bumblebee] ⚠️ {fname} scan findings:\n"
401
+ for f in findings:
402
+ yield f" • {f}\n"
403
+ else:
404
+ yield f"\n[bumblebee] ✅ {fname} scan — clean\n"
405
+ except asyncio.TimeoutError:
406
+ yield f"\n[bumblebee] ⚠️ {fname} scan timed out\n"
407
+ except Exception:
408
+ pass
409
+
410
+ # ── Push prompt ───────────────────────────────────────────────────────────
411
+ if any(should_prompt_on_push(cmd) for cmd in bash_commands_seen):
412
+ yield (
413
+ "\n[bumblebee] 🛡️ git push detected — want a security scan before others pull? "
414
+ "Ask me to run a security check or re-invoke with that request.\n"
415
+ )
416
+
417
+ # ── Completion summary ────────────────────────────────────────────────────
418
+ agent_label = f"{agent_count} agent{'s' if agent_count != 1 else ''}" if agent_count else "no sub-agents"
419
+ if query_error is not None:
420
+ yield f"\n[supervisor] failed — {agent_label} ran before error\n"
421
+ else:
422
+ yield f"\n[supervisor] complete — {agent_label} ran\n"
423
+
345
424
 
346
425
  # ── MCP tools ─────────────────────────────────────────────────────────────────
347
426
 
@@ -371,19 +450,42 @@ async def run_in_thread(thread_id: str, message: str, cwd: str | None = None) ->
371
450
  except Exception:
372
451
  pass
373
452
 
453
+ is_new_session = _load_session_id(thread_id) is None
454
+
374
455
  chunks: list[str] = []
456
+
457
+ # ── Intent classification log ─────────────────────────────────────────────
458
+ roles = _role_matches(message)
459
+ chunks.append(f"[agent101] intent classified: {', '.join(roles)}\n")
460
+
461
+ # ── Skill auto-detection and load ─────────────────────────────────────────
375
462
  auto_loaded = _auto_load_for_message(message)
463
+ extra_tools: list[str] = []
376
464
  if auto_loaded.get("matched"):
465
+ skill_name = auto_loaded.get("skill", "")
466
+ loaded = auto_loaded.get("loaded") or {}
467
+ extra_tools = list(loaded.get("tools", []))
377
468
  chunks.append(
378
- "[agent101] auto-loaded skill "
379
- f"{auto_loaded.get('skill')} ({auto_loaded.get('action')})\n"
469
+ f"[agent101] auto-loaded skill: {skill_name} "
470
+ f"({', '.join(extra_tools) if extra_tools else auto_loaded.get('action')})\n"
380
471
  )
472
+
473
+ ecc_matches = _matching_ecc_groups(message)
474
+ for match in ecc_matches:
475
+ group = match.get("tool_group", "")
476
+ if group and not any(t in extra_tools for t in (group,)):
477
+ chunks.append(f"[agent101] suggested skill: {group}\n")
478
+
381
479
  try:
382
- async for chunk in run_with_agents(message, thread_id, thread_name, cwd):
480
+ async for chunk in run_with_agents(
481
+ message, thread_id, thread_name, cwd,
482
+ extra_tools=extra_tools,
483
+ is_new_session=is_new_session,
484
+ ):
383
485
  chunks.append(chunk)
384
486
  except asyncio.TimeoutError:
385
487
  chunks.append("\n⚠️ Harness timed out (240s). The agent may still be running.")
386
- return "".join(chunks) or "(no output)"
488
+ return "Tylor:\n" + ("".join(chunks) or "(no output)")
387
489
 
388
490
 
389
491
  @mcp.tool()