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.
- package/bin/tylor.js +0 -1
- package/package.json +5 -2
- package/pytest.ini +2 -2
- package/scripts/dev-sync.js +113 -0
- package/server/tools/agents.py +29 -12
- package/server/tools/executor.py +46 -12
- package/server/tools/harness.py +174 -72
- package/server/tools/security.py +141 -0
- package/skills/tylor-run/SKILL.md +61 -0
- package/server/server.log +0 -1
- package/server/storage/tests/__init__.py +0 -0
- package/server/storage/tests/test_dynamo.py +0 -452
- package/server/storage/tests/test_json_store.py +0 -226
- package/server/storage/tests/test_opensearch.py +0 -270
- package/server/storage/tests/test_s3.py +0 -125
- package/server/tests/__init__.py +0 -0
- package/server/tests/test_install.py +0 -620
- package/server/tests/test_isolation.py +0 -90
- package/server/tests/test_ui_server.py +0 -423
- package/server/tests/test_ui_shader_background.py +0 -49
- package/server/tests/test_ui_story_6_3.py +0 -98
- package/server/tools/tests/__init__.py +0 -0
- package/server/tools/tests/test_agents.py +0 -259
- package/server/tools/tests/test_code_index.py +0 -108
- package/server/tools/tests/test_ecc_tools.py +0 -51
- package/server/tools/tests/test_executor.py +0 -623
- package/server/tools/tests/test_help_agent101.py +0 -156
- package/server/tools/tests/test_hooks.py +0 -124
- package/server/tools/tests/test_kill_thread.py +0 -125
- package/server/tools/tests/test_new_thread_list_threads.py +0 -293
- package/server/tools/tests/test_personas.py +0 -52
- package/server/tools/tests/test_recall_memory.py +0 -55
- package/server/tools/tests/test_registry_client.py +0 -322
- package/server/tools/tests/test_router.py +0 -263
- package/server/tools/tests/test_skill_installer.py +0 -193
- package/server/tools/tests/test_spawn_agent_harness.py +0 -225
- package/server/tools/tests/test_switch_thread.py +0 -163
- package/server/tools/tests/test_thread_command_skills.py +0 -60
- package/server/tools/tests/test_thread_resolver.py +0 -165
- package/server/tools/tests/test_tier1_schema.py +0 -310
package/bin/tylor.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tylor-mcp",
|
|
3
|
-
"version": "1.1.
|
|
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.`);
|
package/server/tools/agents.py
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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":
|
|
361
|
+
"status": status,
|
|
345
362
|
"streaming": not wait_for_completion,
|
|
346
363
|
"skill_loads": persona_skill_loads,
|
|
347
364
|
"task_skill": task_skill,
|
package/server/tools/executor.py
CHANGED
|
@@ -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
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
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.
|
|
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
|
|
435
|
+
except subprocess.TimeoutExpired:
|
|
407
436
|
_terminate_process_group(process)
|
|
408
|
-
|
|
409
|
-
|
|
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):
|
package/server/tools/harness.py
CHANGED
|
@@ -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
|
-
|
|
13
|
-
from
|
|
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
|
-
|
|
238
|
-
f =
|
|
239
|
-
f.
|
|
240
|
-
lock
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
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
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
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
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
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"{
|
|
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(
|
|
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()
|