tylor-mcp 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (101) hide show
  1. package/.aws-setup.sh +25 -0
  2. package/.claude-plugin/plugin.json +22 -0
  3. package/.mcp.json +12 -0
  4. package/AGENTS.md +93 -0
  5. package/CLAUDE.md +99 -0
  6. package/CLAUDE_PLATFORM_AWS_SETUP.md +105 -0
  7. package/LICENSE +21 -0
  8. package/README.md +146 -0
  9. package/assets/tylor_logo.png +0 -0
  10. package/assets/tylor_threads_concept.png +0 -0
  11. package/bin/tylor.js +23 -0
  12. package/hooks/kill-thread-trigger.sh +7 -0
  13. package/hooks/post-tool-use-code-index.sh +7 -0
  14. package/hooks/session-checkpoint.sh +7 -0
  15. package/hooks/session-start.sh +7 -0
  16. package/install.py +401 -0
  17. package/install.sh +260 -0
  18. package/package.json +24 -0
  19. package/pytest.ini +2 -0
  20. package/registry.json +26 -0
  21. package/server/.env.example +24 -0
  22. package/server/__init__.py +0 -0
  23. package/server/config.py +89 -0
  24. package/server/main.py +93 -0
  25. package/server/personas/analyst.md +15 -0
  26. package/server/personas/ceo.md +14 -0
  27. package/server/personas/code_agent.md +15 -0
  28. package/server/personas/cto.md +14 -0
  29. package/server/provision.py +260 -0
  30. package/server/provision_opensearch.py +154 -0
  31. package/server/requirements.txt +26 -0
  32. package/server/storage/__init__.py +0 -0
  33. package/server/storage/dynamo.py +399 -0
  34. package/server/storage/json_store.py +359 -0
  35. package/server/storage/opensearch.py +194 -0
  36. package/server/storage/s3.py +96 -0
  37. package/server/storage/tests/__init__.py +0 -0
  38. package/server/storage/tests/test_dynamo.py +452 -0
  39. package/server/storage/tests/test_json_store.py +226 -0
  40. package/server/storage/tests/test_opensearch.py +270 -0
  41. package/server/storage/tests/test_s3.py +125 -0
  42. package/server/tests/__init__.py +0 -0
  43. package/server/tests/test_install.py +606 -0
  44. package/server/tests/test_isolation.py +90 -0
  45. package/server/tests/test_ui_server.py +385 -0
  46. package/server/tests/test_ui_shader_background.py +52 -0
  47. package/server/tests/test_ui_story_6_3.py +105 -0
  48. package/server/tools/__init__.py +0 -0
  49. package/server/tools/_mcp.py +4 -0
  50. package/server/tools/agents.py +160 -0
  51. package/server/tools/ecc/__init__.py +1 -0
  52. package/server/tools/ecc/data.py +35 -0
  53. package/server/tools/ecc/diagrams.py +23 -0
  54. package/server/tools/ecc/pipeline.py +24 -0
  55. package/server/tools/ecc/presentation.py +24 -0
  56. package/server/tools/ecc/web.py +23 -0
  57. package/server/tools/executor.py +880 -0
  58. package/server/tools/harness.py +330 -0
  59. package/server/tools/help.py +162 -0
  60. package/server/tools/hooks.py +357 -0
  61. package/server/tools/personas.py +110 -0
  62. package/server/tools/registry.py +195 -0
  63. package/server/tools/router.py +117 -0
  64. package/server/tools/skill_installer.py +230 -0
  65. package/server/tools/summarizer.py +168 -0
  66. package/server/tools/tests/__init__.py +0 -0
  67. package/server/tools/tests/test_agents.py +246 -0
  68. package/server/tools/tests/test_code_index.py +108 -0
  69. package/server/tools/tests/test_ecc_tools.py +51 -0
  70. package/server/tools/tests/test_executor.py +584 -0
  71. package/server/tools/tests/test_help_agent101.py +149 -0
  72. package/server/tools/tests/test_hooks.py +124 -0
  73. package/server/tools/tests/test_kill_thread.py +125 -0
  74. package/server/tools/tests/test_new_thread_list_threads.py +293 -0
  75. package/server/tools/tests/test_personas.py +52 -0
  76. package/server/tools/tests/test_recall_memory.py +55 -0
  77. package/server/tools/tests/test_registry_client.py +308 -0
  78. package/server/tools/tests/test_router.py +263 -0
  79. package/server/tools/tests/test_skill_installer.py +174 -0
  80. package/server/tools/tests/test_switch_thread.py +163 -0
  81. package/server/tools/tests/test_thread_command_skills.py +54 -0
  82. package/server/tools/tests/test_thread_resolver.py +165 -0
  83. package/server/tools/tests/test_tier1_schema.py +296 -0
  84. package/server/tools/thread_resolver.py +75 -0
  85. package/server/tools/tylor.py +374 -0
  86. package/server/tools/ui.py +38 -0
  87. package/server/ui_server.py +292 -0
  88. package/server/validate.py +237 -0
  89. package/skills/add-skill/SKILL.md +37 -0
  90. package/skills/afk-status/SKILL.md +20 -0
  91. package/skills/bmad/SKILL.md +14 -0
  92. package/skills/help-agent101/SKILL.md +48 -0
  93. package/skills/kill-thread/SKILL.md +35 -0
  94. package/skills/list-threads/SKILL.md +35 -0
  95. package/skills/new-thread/SKILL.md +35 -0
  96. package/skills/recall/SKILL.md +39 -0
  97. package/skills/run/SKILL.md +33 -0
  98. package/skills/set-sandbox/SKILL.md +38 -0
  99. package/skills/switch-thread/SKILL.md +38 -0
  100. package/ui/claude-logo.png +0 -0
  101. package/ui/index.html +1314 -0
@@ -0,0 +1,880 @@
1
+ """server/tools/executor.py — AFK sandbox declaration and execution guard."""
2
+ from __future__ import annotations
3
+ import os
4
+ import re
5
+ import shlex
6
+ import signal
7
+ import subprocess
8
+ import threading
9
+ import time
10
+ import uuid
11
+ from pathlib import Path
12
+
13
+ from mcp.server.fastmcp.exceptions import ToolError
14
+
15
+ from ._mcp import mcp
16
+
17
+
18
+ NO_SANDBOX_MESSAGE = "No sandbox configured — run /set-sandbox <path> first"
19
+ DEFAULT_TIMEOUT_SECONDS = 120
20
+ TRANSIENT_BACKOFF_SECONDS = (5, 15, 45)
21
+ RECOVERY_CAP = 5
22
+ SAFE_MODULE_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]*$")
23
+
24
+
25
+ class SandboxViolation(ToolError):
26
+ """Raised when a command references a path outside configured sandbox roots."""
27
+
28
+
29
+ def classify_execution_failure(stderr: str) -> str:
30
+ """Classify command stderr for AFK recovery policy."""
31
+ text = (stderr or "").lower()
32
+ transient_markers = (
33
+ "connection reset",
34
+ "connection refused",
35
+ "network",
36
+ "timeout",
37
+ "timed out",
38
+ "temporary",
39
+ "temporarily unavailable",
40
+ "resource busy",
41
+ "file lock",
42
+ "locked",
43
+ )
44
+ if any(marker in text for marker in transient_markers):
45
+ return "transient"
46
+ return "logic"
47
+
48
+
49
+ def _missing_module(stderr: str) -> str | None:
50
+ match = re.search(r"No module named ['\"]([^'\"]+)['\"]", stderr or "")
51
+ module = match.group(1) if match else None
52
+ if not module or not SAFE_MODULE_RE.fullmatch(module):
53
+ return None
54
+ return module
55
+
56
+
57
+ def _get_db():
58
+ from server.tools.tylor import _get_db as get_thread_db
59
+
60
+ return get_thread_db()
61
+
62
+
63
+ def _resolve_existing_absolute_path(path: str) -> str:
64
+ expanded = Path(path).expanduser()
65
+ if not expanded.is_absolute() or not expanded.exists():
66
+ raise ToolError("Sandbox path must be absolute and exist")
67
+ return os.path.realpath(expanded)
68
+
69
+
70
+ def _thread_meta(db, thread_id: str) -> dict:
71
+ meta = db.get_thread_meta(thread_id)
72
+ if not meta:
73
+ raise ToolError(f"Thread not found: {thread_id}")
74
+ return meta
75
+
76
+
77
+ def _now_iso() -> str:
78
+ from server.tools.tylor import _now_iso as thread_now
79
+
80
+ return thread_now()
81
+
82
+
83
+ def _log_thread_event(db, thread_id: str, event_type: str, attributes: dict) -> None:
84
+ sk = f"THREAD#{thread_id}#MSG#{_now_iso()}#{event_type.upper()}#{uuid.uuid4().hex}"
85
+ db.put_item(sk, {"Role": "system", "Type": event_type, **attributes})
86
+
87
+
88
+ def _write_thread_meta(db, thread_id: str, meta: dict) -> dict:
89
+ updated = dict(meta)
90
+ return db.put_item(f"THREAD#{thread_id}#META", updated)
91
+
92
+
93
+ def _stdout_summary(result: dict, limit: int = 1000) -> str:
94
+ text = (result.get("stdout") or result.get("stderr") or "").strip()
95
+ if len(text) <= limit:
96
+ return text
97
+ return f"{text[:limit].rstrip()}..."
98
+
99
+
100
+ def _default_afk_steps(task: str) -> list[str]:
101
+ return [
102
+ f"printf '%s\\n' {shlex.quote('AFK task accepted: ' + task)}",
103
+ "pytest -q",
104
+ ]
105
+
106
+
107
+ def _pause_message(current_step: str) -> str:
108
+ return (
109
+ f"AFK paused — here's where I am: {current_step}. "
110
+ "Type 'resume' to continue or give new instructions"
111
+ )
112
+
113
+
114
+ def _afk_session(meta: dict) -> dict | None:
115
+ session = meta.get("afk_session")
116
+ return dict(session) if isinstance(session, dict) else None
117
+
118
+
119
+ def _is_pause_requested(meta: dict) -> bool:
120
+ session = _afk_session(meta)
121
+ return bool(
122
+ session
123
+ and (
124
+ session.get("pause_requested") is True
125
+ or session.get("status") == "pause_requested"
126
+ )
127
+ )
128
+
129
+
130
+ def _files_modified_summary(cwd: str | None) -> list[dict]:
131
+ if not cwd:
132
+ return []
133
+ try:
134
+ status = subprocess.run(
135
+ ["git", "status", "--short"],
136
+ cwd=cwd,
137
+ check=False,
138
+ capture_output=True,
139
+ text=True,
140
+ timeout=5,
141
+ )
142
+ except (OSError, subprocess.TimeoutExpired):
143
+ return []
144
+ if status.returncode != 0:
145
+ return []
146
+ changed = []
147
+ for line in status.stdout.splitlines():
148
+ if not line.strip():
149
+ continue
150
+ path = line[3:].strip()
151
+ path_for_diff = path.split(" -> ")[-1]
152
+ file_diff = subprocess.run(
153
+ ["git", "diff", "--stat", "--", path_for_diff],
154
+ cwd=cwd,
155
+ check=False,
156
+ capture_output=True,
157
+ text=True,
158
+ timeout=5,
159
+ )
160
+ changed.append({
161
+ "path": path,
162
+ "status": line[:2].strip(),
163
+ "diff_summary": file_diff.stdout.strip(),
164
+ })
165
+ return changed
166
+
167
+
168
+ def _test_state(outcomes: list[dict]) -> str:
169
+ testish = [
170
+ outcome
171
+ for outcome in outcomes
172
+ if "test" in outcome.get("command", "").lower()
173
+ or "pytest" in outcome.get("command", "").lower()
174
+ ]
175
+ if not testish:
176
+ return "not_run"
177
+ return "passing" if all(outcome.get("exit_code") == 0 for outcome in testish) else "failing"
178
+
179
+
180
+ def _recovery_log(
181
+ db,
182
+ thread_id: str,
183
+ command: str,
184
+ result: dict,
185
+ classification: str,
186
+ attempts: list[dict],
187
+ content: str,
188
+ recommended_next_step: str | None = None,
189
+ ) -> None:
190
+ attributes = {
191
+ "OriginalCommand": command,
192
+ "ExitCode": result.get("exit_code"),
193
+ "Classification": classification,
194
+ "Attempts": attempts,
195
+ "CommandsRun": [attempt.get("command") for attempt in attempts if attempt.get("command")],
196
+ "FilesModified": [],
197
+ "Content": content,
198
+ }
199
+ if recommended_next_step:
200
+ attributes["RecommendedNextStep"] = recommended_next_step
201
+ _log_thread_event(db, thread_id, "recovery_decision", attributes)
202
+
203
+
204
+ def _real_roots(raw_roots: list[str]) -> list[str]:
205
+ return [os.path.realpath(Path(root).expanduser()) for root in raw_roots]
206
+
207
+
208
+ def _is_inside_roots(path: str, roots: list[str]) -> bool:
209
+ real_path = os.path.realpath(path)
210
+ for root in roots:
211
+ try:
212
+ if os.path.commonpath([real_path, root]) == root:
213
+ return True
214
+ except ValueError:
215
+ continue
216
+ return False
217
+
218
+
219
+ def _candidate_paths(command: str, cwd: str) -> list[tuple[str, str]]:
220
+ candidates = [(cwd, cwd)]
221
+ for token in shlex.split(command):
222
+ # Extract value from --flag=value or -f/value forms
223
+ raw = token
224
+ if "=" in token and token.startswith("-"):
225
+ raw = token.split("=", 1)[1]
226
+ elif token.startswith("-") and not token.startswith("--") and len(token) > 2 and "/" in token:
227
+ raw = token[2:] # -o/path/to/file
228
+ elif token.startswith("-") and "=" not in token and "/" not in token:
229
+ continue # pure flag like -v, --verbose
230
+ path = Path(raw).expanduser()
231
+ if path.is_absolute():
232
+ candidates.append((raw, str(path)))
233
+ continue
234
+ relative = Path(cwd) / path
235
+ # Include bare filenames resolved relative to cwd (even if they don't exist yet)
236
+ candidates.append((raw, str(relative)))
237
+ return candidates
238
+
239
+
240
+ def _raise_violation(db, thread_id: str, command: str, display_path: str, resolved_path: str) -> None:
241
+ message = f"Path {display_path} is outside sandbox — operation rejected"
242
+ _log_thread_event(
243
+ db,
244
+ thread_id,
245
+ "sandbox_violation",
246
+ {
247
+ "Command": command,
248
+ "Path": display_path,
249
+ "ResolvedPath": resolved_path,
250
+ "Content": message,
251
+ },
252
+ )
253
+ raise SandboxViolation(message)
254
+
255
+
256
+ def _validate_command_paths(
257
+ db,
258
+ thread_id: str,
259
+ command: str,
260
+ cwd: str,
261
+ roots: list[str],
262
+ ) -> None:
263
+ for display_path, path in _candidate_paths(command, cwd):
264
+ resolved = os.path.realpath(path)
265
+ if not _is_inside_roots(resolved, roots):
266
+ _raise_violation(db, thread_id, command, display_path, resolved)
267
+
268
+
269
+ def _terminate_process_group(process: subprocess.Popen) -> None:
270
+ try:
271
+ os.killpg(process.pid, signal.SIGTERM)
272
+ except ProcessLookupError:
273
+ return
274
+ try:
275
+ process.wait(timeout=2)
276
+ except subprocess.TimeoutExpired:
277
+ try:
278
+ os.killpg(process.pid, signal.SIGKILL)
279
+ except ProcessLookupError:
280
+ return
281
+
282
+
283
+ @mcp.tool()
284
+ def set_sandbox(path: str, thread_id: str | None = None) -> dict:
285
+ """
286
+ Add or clear sandbox roots for a thread.
287
+
288
+ Args:
289
+ path: Absolute existing path to allow, or "clear" to remove all roots.
290
+ thread_id: Optional thread id. Defaults to the active current thread.
291
+ """
292
+ db = _get_db()
293
+ resolved_thread_id = db.resolve_thread_id(thread_id)
294
+
295
+ if path.strip().lower() == "clear":
296
+ item = db.set_sandbox_roots(resolved_thread_id, [])
297
+ return {
298
+ "status": "cleared",
299
+ "thread_id": resolved_thread_id,
300
+ "sandbox_roots": list(item.get("sandbox_roots", [])),
301
+ "message": (
302
+ "Sandbox cleared — execution tools will refuse all path operations "
303
+ "until a new sandbox is set"
304
+ ),
305
+ }
306
+
307
+ root = _resolve_existing_absolute_path(path)
308
+ meta = _thread_meta(db, resolved_thread_id)
309
+ roots = list(meta.get("sandbox_roots", []))
310
+ if root not in roots:
311
+ roots.append(root)
312
+
313
+ item = db.set_sandbox_roots(resolved_thread_id, roots)
314
+ return {
315
+ "status": "set",
316
+ "thread_id": resolved_thread_id,
317
+ "sandbox_roots": list(item.get("sandbox_roots", [])),
318
+ "message": f"Sandbox set to {path} — executor will reject any path outside this root",
319
+ }
320
+
321
+
322
+ @mcp.tool()
323
+ def execute_in_sandbox(
324
+ command: str,
325
+ thread_id: str | None = None,
326
+ cwd: str | None = None,
327
+ timeout_seconds: int = DEFAULT_TIMEOUT_SECONDS,
328
+ ) -> dict:
329
+ """
330
+ Execute a command inside declared sandbox roots.
331
+ Story 5.1 only implements the no-sandbox guard; Story 5.2 adds execution.
332
+ """
333
+ db = _get_db()
334
+ resolved_thread_id = db.resolve_thread_id(thread_id)
335
+ meta = _thread_meta(db, resolved_thread_id)
336
+ sandbox_roots = list(meta.get("sandbox_roots", []))
337
+ if not sandbox_roots:
338
+ raise ToolError(NO_SANDBOX_MESSAGE)
339
+
340
+ roots = _real_roots(sandbox_roots)
341
+ workdir = os.path.realpath(Path(cwd).expanduser()) if cwd else roots[0]
342
+ if not _is_inside_roots(workdir, roots):
343
+ _raise_violation(db, resolved_thread_id, command, cwd or workdir, workdir)
344
+ _validate_command_paths(db, resolved_thread_id, command, workdir, roots)
345
+
346
+ start = time.monotonic()
347
+ try:
348
+ args = shlex.split(command)
349
+ except ValueError as exc:
350
+ raise ToolError(f"Invalid command syntax: {exc}") from exc
351
+ if not args:
352
+ raise ToolError("Command must not be empty")
353
+
354
+ process = subprocess.Popen(
355
+ args,
356
+ cwd=workdir,
357
+ shell=False,
358
+ stdout=subprocess.PIPE,
359
+ stderr=subprocess.PIPE,
360
+ text=True,
361
+ start_new_session=True,
362
+ )
363
+ try:
364
+ stdout, stderr = process.communicate(timeout=timeout_seconds)
365
+ duration_ms = int((time.monotonic() - start) * 1000)
366
+ result = {
367
+ "status": "completed",
368
+ "exit_code": process.returncode,
369
+ "stdout": stdout,
370
+ "stderr": stderr,
371
+ "duration_ms": duration_ms,
372
+ }
373
+ _log_thread_event(
374
+ db,
375
+ resolved_thread_id,
376
+ "sandbox_execution",
377
+ {
378
+ "Command": command,
379
+ "Cwd": workdir,
380
+ "ExitCode": process.returncode,
381
+ "Outcome": "success" if process.returncode == 0 else "failed",
382
+ "DurationMs": duration_ms,
383
+ "Content": f"Command `{command}` exited {process.returncode}",
384
+ },
385
+ )
386
+ return result
387
+ except subprocess.TimeoutExpired as exc:
388
+ _terminate_process_group(process)
389
+ stdout = exc.stdout or ""
390
+ stderr = exc.stderr or ""
391
+ if isinstance(stdout, bytes):
392
+ stdout = stdout.decode("utf-8", errors="replace")
393
+ if isinstance(stderr, bytes):
394
+ stderr = stderr.decode("utf-8", errors="replace")
395
+ duration_ms = int((time.monotonic() - start) * 1000)
396
+ message = f"Command timed out after {timeout_seconds}s — partial stdout captured"
397
+ _log_thread_event(
398
+ db,
399
+ resolved_thread_id,
400
+ "sandbox_execution",
401
+ {
402
+ "Command": command,
403
+ "Cwd": workdir,
404
+ "ExitCode": None,
405
+ "Outcome": "timeout",
406
+ "DurationMs": duration_ms,
407
+ "Content": message,
408
+ },
409
+ )
410
+ return {
411
+ "status": "timeout",
412
+ "exit_code": None,
413
+ "stdout": stdout,
414
+ "stderr": stderr,
415
+ "duration_ms": duration_ms,
416
+ "message": message,
417
+ }
418
+
419
+
420
+ @mcp.tool()
421
+ def execute_with_recovery(
422
+ command: str,
423
+ thread_id: str | None = None,
424
+ cwd: str | None = None,
425
+ timeout_seconds: int = DEFAULT_TIMEOUT_SECONDS,
426
+ recovery_attempts_used: int = 0,
427
+ ) -> dict:
428
+ """
429
+ Execute a sandboxed command and apply bounded AFK failure recovery.
430
+
431
+ Retries transient failures with 5s, 15s, 45s backoff. For fixable import
432
+ failures, installs the missing module once and re-runs the original command.
433
+ """
434
+ db = _get_db()
435
+ resolved_thread_id = db.resolve_thread_id(thread_id)
436
+ if recovery_attempts_used >= RECOVERY_CAP:
437
+ message = "Recovery cap reached — pausing autonomous execution"
438
+ _log_thread_event(
439
+ db,
440
+ resolved_thread_id,
441
+ "recovery_decision",
442
+ {
443
+ "OriginalCommand": command,
444
+ "ExitCode": None,
445
+ "Classification": "cap_reached",
446
+ "Attempts": [],
447
+ "CommandsRun": [],
448
+ "FilesModified": [],
449
+ "RecommendedNextStep": "Developer input required before continuing AFK execution.",
450
+ "Content": message,
451
+ },
452
+ )
453
+ return {"status": "paused", "message": message}
454
+
455
+ attempts: list[dict] = []
456
+ result = execute_in_sandbox(
457
+ command=command,
458
+ thread_id=resolved_thread_id,
459
+ cwd=cwd,
460
+ timeout_seconds=timeout_seconds,
461
+ )
462
+ attempts.append({"command": command, "outcome": result.get("status"), "exit_code": result.get("exit_code")})
463
+ if result.get("exit_code") == 0:
464
+ return {"status": "success", "result": result, "attempts": attempts}
465
+
466
+ classification = classify_execution_failure(result.get("stderr", ""))
467
+ used = recovery_attempts_used
468
+
469
+ if classification == "transient":
470
+ current = result
471
+ for backoff in TRANSIENT_BACKOFF_SECONDS:
472
+ if used >= RECOVERY_CAP:
473
+ break
474
+ used += 1
475
+ time.sleep(backoff)
476
+ current = execute_in_sandbox(
477
+ command=command,
478
+ thread_id=resolved_thread_id,
479
+ cwd=cwd,
480
+ timeout_seconds=timeout_seconds,
481
+ )
482
+ attempts.append({
483
+ "command": command,
484
+ "strategy": f"transient_retry_after_{backoff}s",
485
+ "outcome": current.get("status"),
486
+ "exit_code": current.get("exit_code"),
487
+ })
488
+ if current.get("exit_code") == 0:
489
+ _recovery_log(
490
+ db,
491
+ resolved_thread_id,
492
+ command,
493
+ result,
494
+ classification,
495
+ attempts,
496
+ "Transient failure recovered after retry.",
497
+ )
498
+ return {
499
+ "status": "recovered",
500
+ "classification": classification,
501
+ "result": current,
502
+ "attempts": attempts,
503
+ }
504
+ result = current
505
+
506
+ module = _missing_module(result.get("stderr", ""))
507
+ if classification == "logic" and module and used < RECOVERY_CAP:
508
+ fix_command = f"python3 -m pip install {module}"
509
+ used += 1
510
+ fix = execute_in_sandbox(
511
+ command=fix_command,
512
+ thread_id=resolved_thread_id,
513
+ cwd=cwd,
514
+ timeout_seconds=timeout_seconds,
515
+ )
516
+ attempts.append({
517
+ "command": fix_command,
518
+ "strategy": "install_missing_module",
519
+ "outcome": fix.get("status"),
520
+ "exit_code": fix.get("exit_code"),
521
+ })
522
+ if fix.get("exit_code") == 0 and used < RECOVERY_CAP:
523
+ used += 1
524
+ rerun = execute_in_sandbox(
525
+ command=command,
526
+ thread_id=resolved_thread_id,
527
+ cwd=cwd,
528
+ timeout_seconds=timeout_seconds,
529
+ )
530
+ attempts.append({
531
+ "command": command,
532
+ "strategy": "rerun_after_fix",
533
+ "outcome": rerun.get("status"),
534
+ "exit_code": rerun.get("exit_code"),
535
+ })
536
+ if rerun.get("exit_code") == 0:
537
+ content = (
538
+ f"Failure: ModuleNotFoundError -> Auto-fix: pip install {module} "
539
+ "-> Re-run: success"
540
+ )
541
+ _recovery_log(db, resolved_thread_id, command, result, classification, attempts, content)
542
+ return {
543
+ "status": "recovered",
544
+ "classification": classification,
545
+ "result": rerun,
546
+ "attempts": attempts,
547
+ }
548
+ result = rerun
549
+
550
+ message = (
551
+ "Recovery cap reached — pausing autonomous execution"
552
+ if used >= RECOVERY_CAP
553
+ else "Recovery exhausted — pausing autonomous execution"
554
+ )
555
+ _recovery_log(
556
+ db,
557
+ resolved_thread_id,
558
+ command,
559
+ result,
560
+ classification,
561
+ attempts,
562
+ message,
563
+ recommended_next_step="Developer input required before continuing AFK execution.",
564
+ )
565
+ return {
566
+ "status": "paused",
567
+ "classification": classification,
568
+ "message": message,
569
+ "attempts": attempts,
570
+ }
571
+
572
+
573
+ @mcp.tool()
574
+ def start_afk(
575
+ task: str,
576
+ steps: list[str] | None = None,
577
+ thread_id: str | None = None,
578
+ cwd: str | None = None,
579
+ timeout_seconds: int = DEFAULT_TIMEOUT_SECONDS,
580
+ background: bool = False,
581
+ ) -> dict:
582
+ """
583
+ Start an AFK task: persist a plan, execute each command in the sandbox, and
584
+ write step and completion events to the active thread.
585
+ """
586
+ db = _get_db()
587
+ resolved_thread_id = db.resolve_thread_id(thread_id)
588
+ meta = _thread_meta(db, resolved_thread_id)
589
+ sandbox_roots = list(meta.get("sandbox_roots", []))
590
+ if not sandbox_roots:
591
+ raise ToolError(NO_SANDBOX_MESSAGE)
592
+
593
+ planned_steps = list(steps) if steps else _default_afk_steps(task)
594
+ if not planned_steps:
595
+ raise ToolError("AFK task requires at least one execution step")
596
+ effective_cwd = cwd or sandbox_roots[0]
597
+
598
+ session = {
599
+ "id": uuid.uuid4().hex,
600
+ "status": "active",
601
+ "task": task,
602
+ "steps": planned_steps,
603
+ "steps_completed": 0,
604
+ "steps_total": len(planned_steps),
605
+ "current_step": planned_steps[0],
606
+ "last_command_output": "",
607
+ "started_at": _now_iso(),
608
+ "started_at_monotonic": _now_iso(),
609
+ "pause_requested": False,
610
+ }
611
+ meta["afk_session"] = session
612
+ _write_thread_meta(db, resolved_thread_id, meta)
613
+ _log_thread_event(
614
+ db,
615
+ resolved_thread_id,
616
+ "afk_plan",
617
+ {
618
+ "TaskDescription": task,
619
+ "Steps": planned_steps,
620
+ "Content": "AFK execution plan:\n"
621
+ + "\n".join(f"{index}. {step}" for index, step in enumerate(planned_steps, start=1)),
622
+ },
623
+ )
624
+
625
+ if background:
626
+ worker = threading.Thread(
627
+ target=_run_afk_background,
628
+ args=(resolved_thread_id, task, planned_steps, effective_cwd, timeout_seconds),
629
+ daemon=True,
630
+ )
631
+ worker.start()
632
+ return {
633
+ "status": "started",
634
+ "thread_id": resolved_thread_id,
635
+ "steps_total": len(planned_steps),
636
+ "current_step": planned_steps[0],
637
+ "message": f"AFK started — see thread {resolved_thread_id} for progress",
638
+ }
639
+
640
+ return _run_afk_steps(
641
+ db=db,
642
+ resolved_thread_id=resolved_thread_id,
643
+ meta=meta,
644
+ task=task,
645
+ planned_steps=planned_steps,
646
+ effective_cwd=effective_cwd,
647
+ timeout_seconds=timeout_seconds,
648
+ session=session,
649
+ )
650
+
651
+
652
+ def _run_afk_background(
653
+ resolved_thread_id: str,
654
+ task: str,
655
+ planned_steps: list[str],
656
+ effective_cwd: str,
657
+ timeout_seconds: int,
658
+ ) -> None:
659
+ db = _get_db()
660
+ meta = _thread_meta(db, resolved_thread_id)
661
+ session = _afk_session(meta) or {
662
+ "id": uuid.uuid4().hex,
663
+ "status": "active",
664
+ "task": task,
665
+ "steps": planned_steps,
666
+ "steps_completed": 0,
667
+ "steps_total": len(planned_steps),
668
+ "current_step": planned_steps[0],
669
+ "last_command_output": "",
670
+ "started_at": _now_iso(),
671
+ "started_at_monotonic": _now_iso(),
672
+ "pause_requested": False,
673
+ }
674
+ _run_afk_steps(
675
+ db=db,
676
+ resolved_thread_id=resolved_thread_id,
677
+ meta=meta,
678
+ task=task,
679
+ planned_steps=planned_steps,
680
+ effective_cwd=effective_cwd,
681
+ timeout_seconds=timeout_seconds,
682
+ session=session,
683
+ )
684
+
685
+
686
+ def _run_afk_steps(
687
+ db,
688
+ resolved_thread_id: str,
689
+ meta: dict,
690
+ task: str,
691
+ planned_steps: list[str],
692
+ effective_cwd: str,
693
+ timeout_seconds: int,
694
+ session: dict,
695
+ ) -> dict:
696
+ outcomes: list[dict] = []
697
+ for index, command in enumerate(planned_steps, start=1):
698
+ checkpoint_meta = _thread_meta(db, resolved_thread_id)
699
+ if _is_pause_requested(checkpoint_meta):
700
+ session.update({
701
+ "status": "paused",
702
+ "current_step": command,
703
+ "pause_requested": False,
704
+ })
705
+ checkpoint_meta["afk_session"] = session
706
+ _write_thread_meta(db, resolved_thread_id, checkpoint_meta)
707
+ return {
708
+ "status": "paused",
709
+ "thread_id": resolved_thread_id,
710
+ "current_step": command,
711
+ "steps_completed": session["steps_completed"],
712
+ "steps_total": session["steps_total"],
713
+ "message": _pause_message(command),
714
+ }
715
+
716
+ session["current_step"] = command
717
+ meta["afk_session"] = session
718
+ _write_thread_meta(db, resolved_thread_id, meta)
719
+ result = execute_in_sandbox(
720
+ command=command,
721
+ thread_id=resolved_thread_id,
722
+ cwd=effective_cwd,
723
+ timeout_seconds=timeout_seconds,
724
+ )
725
+ outcome = {
726
+ "step": index,
727
+ "command": command,
728
+ "status": result.get("status"),
729
+ "exit_code": result.get("exit_code"),
730
+ "stdout_summary": _stdout_summary(result),
731
+ }
732
+ outcomes.append(outcome)
733
+ session.update({
734
+ "steps_completed": index,
735
+ "last_command_output": outcome["stdout_summary"],
736
+ })
737
+ meta["afk_session"] = session
738
+ _write_thread_meta(db, resolved_thread_id, meta)
739
+ _log_thread_event(
740
+ db,
741
+ resolved_thread_id,
742
+ "afk_step",
743
+ {
744
+ "Step": index,
745
+ "Command": command,
746
+ "Outcome": "success" if result.get("exit_code") == 0 else "failed",
747
+ "ExitCode": result.get("exit_code"),
748
+ "StdoutSummary": outcome["stdout_summary"],
749
+ "Content": f"AFK step {index}/{len(planned_steps)} `{command}` exited {result.get('exit_code')}",
750
+ },
751
+ )
752
+
753
+ checkpoint_meta = _thread_meta(db, resolved_thread_id)
754
+ if _is_pause_requested(checkpoint_meta) and index < len(planned_steps):
755
+ next_step = planned_steps[index]
756
+ session.update({
757
+ "status": "paused",
758
+ "current_step": next_step,
759
+ "pause_requested": False,
760
+ })
761
+ checkpoint_meta["afk_session"] = session
762
+ _write_thread_meta(db, resolved_thread_id, checkpoint_meta)
763
+ return {
764
+ "status": "paused",
765
+ "thread_id": resolved_thread_id,
766
+ "current_step": next_step,
767
+ "steps_completed": session["steps_completed"],
768
+ "steps_total": session["steps_total"],
769
+ "message": _pause_message(next_step),
770
+ }
771
+
772
+ if result.get("exit_code") != 0:
773
+ session["status"] = "failed"
774
+ meta["afk_session"] = session
775
+ _write_thread_meta(db, resolved_thread_id, meta)
776
+ _log_thread_event(
777
+ db,
778
+ resolved_thread_id,
779
+ "afk_completion",
780
+ {
781
+ "TaskDescription": task,
782
+ "StepsExecuted": outcomes,
783
+ "FilesModified": _files_modified_summary(effective_cwd),
784
+ "Tests": _test_state(outcomes),
785
+ "Content": f"AFK task failed at step {index}: {command}",
786
+ },
787
+ )
788
+ return {
789
+ "status": "failed",
790
+ "thread_id": resolved_thread_id,
791
+ "failed_step": command,
792
+ "steps_executed": outcomes,
793
+ }
794
+
795
+ files_modified = _files_modified_summary(effective_cwd)
796
+ tests = _test_state(outcomes)
797
+ session.update({
798
+ "status": "completed",
799
+ "current_step": planned_steps[-1],
800
+ "files_modified": files_modified,
801
+ "tests": tests,
802
+ })
803
+ meta["afk_session"] = session
804
+ _log_thread_event(
805
+ db,
806
+ resolved_thread_id,
807
+ "afk_completion",
808
+ {
809
+ "TaskDescription": task,
810
+ "StepsExecuted": outcomes,
811
+ "FilesModified": files_modified,
812
+ "Tests": tests,
813
+ "Content": f"AFK task complete: {task}",
814
+ },
815
+ )
816
+ _write_thread_meta(db, resolved_thread_id, meta)
817
+ return {
818
+ "status": "completed",
819
+ "thread_id": resolved_thread_id,
820
+ "steps_executed": outcomes,
821
+ "files_modified": files_modified,
822
+ "tests": tests,
823
+ "message": f"Task complete — see thread {resolved_thread_id} for full execution log",
824
+ }
825
+
826
+
827
+ @mcp.tool()
828
+ def pause_afk(thread_id: str | None = None) -> dict:
829
+ """
830
+ Request an active AFK session to pause after the current command finishes.
831
+ """
832
+ db = _get_db()
833
+ resolved_thread_id = db.resolve_thread_id(thread_id)
834
+ meta = _thread_meta(db, resolved_thread_id)
835
+ session = _afk_session(meta)
836
+ if not session or session.get("status") not in {"active", "pause_requested"}:
837
+ return {"status": "idle", "message": "No AFK session running"}
838
+
839
+ session["status"] = "pause_requested"
840
+ session["pause_requested"] = True
841
+ meta["afk_session"] = session
842
+ _write_thread_meta(db, resolved_thread_id, meta)
843
+ current_step = session.get("current_step") or "next checkpoint"
844
+ return {
845
+ "status": "pause_requested",
846
+ "thread_id": resolved_thread_id,
847
+ "current_step": current_step,
848
+ "message": _pause_message(current_step),
849
+ }
850
+
851
+
852
+ @mcp.tool()
853
+ def afk_status(thread_id: str | None = None) -> dict:
854
+ """
855
+ Return current AFK progress for `/afk-status`.
856
+ """
857
+ db = _get_db()
858
+ resolved_thread_id = db.resolve_thread_id(thread_id)
859
+ meta = _thread_meta(db, resolved_thread_id)
860
+ session = _afk_session(meta)
861
+ if not session or session.get("status") not in {"active", "pause_requested", "paused"}:
862
+ return {"status": "idle", "message": "No AFK session running"}
863
+
864
+ started_iso = session.get("started_at_monotonic") or session.get("started_at")
865
+ try:
866
+ from datetime import datetime, timezone
867
+ started_dt = datetime.fromisoformat(started_iso.replace("Z", "+00:00"))
868
+ elapsed = int((datetime.now(timezone.utc) - started_dt).total_seconds())
869
+ except Exception:
870
+ elapsed = None
871
+ return {
872
+ "status": session.get("status"),
873
+ "thread_id": resolved_thread_id,
874
+ "task": session.get("task"),
875
+ "current_step": session.get("current_step"),
876
+ "steps_completed": session.get("steps_completed", 0),
877
+ "steps_total": session.get("steps_total", 0),
878
+ "elapsed_seconds": elapsed,
879
+ "last_command_output": session.get("last_command_output", ""),
880
+ }