codee-agent 0.3.0__tar.gz → 0.4.1__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 (73) hide show
  1. {codee_agent-0.3.0 → codee_agent-0.4.1}/PKG-INFO +1 -1
  2. {codee_agent-0.3.0 → codee_agent-0.4.1}/pyproject.toml +7 -1
  3. {codee_agent-0.3.0 → codee_agent-0.4.1}/src/codee/admin.py +398 -26
  4. {codee_agent-0.3.0 → codee_agent-0.4.1}/src/codee/admin_cli.py +12 -2
  5. {codee_agent-0.3.0 → codee_agent-0.4.1}/src/codee/admin_service.py +387 -15
  6. {codee_agent-0.3.0 → codee_agent-0.4.1}/src/codee/executor.py +4 -20
  7. {codee_agent-0.3.0 → codee_agent-0.4.1}/src/codee/init_cli.py +5 -3
  8. codee_agent-0.4.1/src/codee/lib/mcp_config.py +112 -0
  9. codee_agent-0.4.1/src/codee/lib/test_mcp_config.py +134 -0
  10. codee_agent-0.4.1/src/codee/tasks_providers.py +25 -0
  11. {codee_agent-0.3.0 → codee_agent-0.4.1}/src/codee/test_admin_cli.py +32 -5
  12. {codee_agent-0.3.0 → codee_agent-0.4.1}/src/codee/test_admin_service.py +498 -1
  13. {codee_agent-0.3.0 → codee_agent-0.4.1}/src/codee/test_executor.py +3 -3
  14. {codee_agent-0.3.0 → codee_agent-0.4.1}/src/codee/test_init_cli.py +7 -5
  15. {codee_agent-0.3.0 → codee_agent-0.4.1}/src/codee/test_start_cli.py +2 -1
  16. codee_agent-0.4.1/src/codee_tasks_abstract/provider.py +151 -0
  17. {codee_agent-0.3.0 → codee_agent-0.4.1}/src/codee_tasks_azure_devops/oauth.py +17 -1
  18. {codee_agent-0.3.0 → codee_agent-0.4.1}/src/codee_tasks_azure_devops/provider.py +95 -5
  19. {codee_agent-0.3.0 → codee_agent-0.4.1}/src/codee_tasks_azure_devops/test.py +111 -0
  20. {codee_agent-0.3.0 → codee_agent-0.4.1}/src/codee_tasks_jira/provider.py +92 -13
  21. codee_agent-0.4.1/src/codee_tasks_jira/test.py +213 -0
  22. codee_agent-0.3.0/src/codee_tasks_abstract/provider.py +0 -58
  23. codee_agent-0.3.0/src/codee_tasks_jira/test.py +0 -75
  24. {codee_agent-0.3.0 → codee_agent-0.4.1}/LICENSE +0 -0
  25. {codee_agent-0.3.0 → codee_agent-0.4.1}/README.md +0 -0
  26. {codee_agent-0.3.0 → codee_agent-0.4.1}/src/codee/.gitignore +0 -0
  27. {codee_agent-0.3.0 → codee_agent-0.4.1}/src/codee/__init__.py +0 -0
  28. {codee_agent-0.3.0 → codee_agent-0.4.1}/src/codee/admin_api.py +0 -0
  29. {codee_agent-0.3.0 → codee_agent-0.4.1}/src/codee/lib/__init__.py +0 -0
  30. {codee_agent-0.3.0 → codee_agent-0.4.1}/src/codee/lib/cron_describe.py +0 -0
  31. {codee_agent-0.3.0 → codee_agent-0.4.1}/src/codee/lib/runs_db.py +0 -0
  32. {codee_agent-0.3.0 → codee_agent-0.4.1}/src/codee/lib/test_runs_db.py +0 -0
  33. {codee_agent-0.3.0 → codee_agent-0.4.1}/src/codee/lib/test_trigger_cron_skills.py +0 -0
  34. {codee_agent-0.3.0 → codee_agent-0.4.1}/src/codee/lib/test_trigger_issue_skills.py +0 -0
  35. {codee_agent-0.3.0 → codee_agent-0.4.1}/src/codee/lib/trigger_aws_sqs_skills.py +0 -0
  36. {codee_agent-0.3.0 → codee_agent-0.4.1}/src/codee/lib/trigger_cron_skills.py +0 -0
  37. {codee_agent-0.3.0 → codee_agent-0.4.1}/src/codee/lib/trigger_email_skills.py +0 -0
  38. {codee_agent-0.3.0 → codee_agent-0.4.1}/src/codee/lib/trigger_issue_skills.py +0 -0
  39. {codee_agent-0.3.0 → codee_agent-0.4.1}/src/codee/mail_server.py +0 -0
  40. {codee_agent-0.3.0 → codee_agent-0.4.1}/src/codee/start_cli.py +0 -0
  41. {codee_agent-0.3.0 → codee_agent-0.4.1}/src/codee/templates/AGENTS.md +0 -0
  42. {codee_agent-0.3.0 → codee_agent-0.4.1}/src/codee/templates/CLAUDE.md +0 -0
  43. {codee_agent-0.3.0 → codee_agent-0.4.1}/src/codee/templates/skills/aws-sqs-alarm-response/SKILL.md +0 -0
  44. {codee_agent-0.3.0 → codee_agent-0.4.1}/src/codee/templates/skills/cron-research-5xx-errors/SKILL.md +0 -0
  45. {codee_agent-0.3.0 → codee_agent-0.4.1}/src/codee/templates/skills/story-code-reviewer/SKILL.md +0 -0
  46. {codee_agent-0.3.0 → codee_agent-0.4.1}/src/codee/templates/skills/story-developer/SKILL.md +0 -0
  47. {codee_agent-0.3.0 → codee_agent-0.4.1}/src/codee/templates/skills/story-planner/SKILL.md +0 -0
  48. {codee_agent-0.3.0 → codee_agent-0.4.1}/src/codee/templates/skills/story-planner/assets/readme-template.md +0 -0
  49. {codee_agent-0.3.0 → codee_agent-0.4.1}/src/codee/templates/skills/story-qa/SKILL.md +0 -0
  50. {codee_agent-0.3.0 → codee_agent-0.4.1}/src/codee/templates/skills/task-developer/SKILL.md +0 -0
  51. {codee_agent-0.3.0 → codee_agent-0.4.1}/src/codee/templates/skills/task-qa/SKILL.md +0 -0
  52. {codee_agent-0.3.0 → codee_agent-0.4.1}/src/codee/test_admin_api.py +0 -0
  53. {codee_agent-0.3.0 → codee_agent-0.4.1}/src/codee/test_memory_index.py +0 -0
  54. {codee_agent-0.3.0 → codee_agent-0.4.1}/src/codee/workflow_graph.py +0 -0
  55. {codee_agent-0.3.0 → codee_agent-0.4.1}/src/codee_admin/__init__.py +0 -0
  56. {codee_agent-0.3.0 → codee_agent-0.4.1}/src/codee_admin/codee_admin.py +0 -0
  57. {codee_agent-0.3.0 → codee_agent-0.4.1}/src/codee_agent_abstract/__init__.py +0 -0
  58. {codee_agent-0.3.0 → codee_agent-0.4.1}/src/codee_agent_abstract/provider.py +0 -0
  59. {codee_agent-0.3.0 → codee_agent-0.4.1}/src/codee_agent_claude_code/__init__.py +0 -0
  60. {codee_agent-0.3.0 → codee_agent-0.4.1}/src/codee_agent_claude_code/provider.py +0 -0
  61. {codee_agent-0.3.0 → codee_agent-0.4.1}/src/codee_agent_github_copilot/__init__.py +0 -0
  62. {codee_agent-0.3.0 → codee_agent-0.4.1}/src/codee_agent_github_copilot/provider.py +0 -0
  63. {codee_agent-0.3.0 → codee_agent-0.4.1}/src/codee_agent_github_copilot/test.py +0 -0
  64. {codee_agent-0.3.0 → codee_agent-0.4.1}/src/codee_database/__init__.py +0 -0
  65. {codee_agent-0.3.0 → codee_agent-0.4.1}/src/codee_database/database.py +0 -0
  66. {codee_agent-0.3.0 → codee_agent-0.4.1}/src/codee_database/oauth_tokens.py +0 -0
  67. {codee_agent-0.3.0 → codee_agent-0.4.1}/src/codee_main_context/__init__.py +0 -0
  68. {codee_agent-0.3.0 → codee_agent-0.4.1}/src/codee_main_context/context.py +0 -0
  69. {codee_agent-0.3.0 → codee_agent-0.4.1}/src/codee_main_context/logging.py +0 -0
  70. {codee_agent-0.3.0 → codee_agent-0.4.1}/src/codee_main_context/test_logging.py +0 -0
  71. {codee_agent-0.3.0 → codee_agent-0.4.1}/src/codee_tasks_abstract/__init__.py +0 -0
  72. {codee_agent-0.3.0 → codee_agent-0.4.1}/src/codee_tasks_azure_devops/__init__.py +0 -0
  73. {codee_agent-0.3.0 → codee_agent-0.4.1}/src/codee_tasks_jira/__init__.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codee-agent
3
- Version: 0.3.0
3
+ Version: 0.4.1
4
4
  Summary: A virtual co-worker that picks up Jira and Azure DevOps tasks and solves them with coding agents.
5
5
  Keywords: ai,agent,jira,azure-devops,claude-code,github-copilot,automation
6
6
  Author: Denis Kibalko
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "codee-agent"
3
- version = "0.3.0"
3
+ version = "0.4.1"
4
4
  description = "A virtual co-worker that picks up Jira and Azure DevOps tasks and solves them with coding agents."
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -47,6 +47,12 @@ codee-init = "codee.init_cli:main"
47
47
  codee-admin = "codee.admin_cli:main"
48
48
  codee-executor = "codee.executor:main"
49
49
 
50
+ [tool.pytest.ini_options]
51
+ # The provider and agent packages keep their suites in `test.py`, which pytest's
52
+ # default `test_*.py` pattern skips — without this, `pytest src` silently ran
53
+ # 61 fewer tests than the repo has.
54
+ python_files = ["test_*.py", "test.py"]
55
+
50
56
  [tool.uv.build-backend]
51
57
  module-name = [
52
58
  "codee",
@@ -8,7 +8,8 @@ import reflex as rx
8
8
  from pydantic import BaseModel
9
9
 
10
10
  from codee.admin_api import api_app
11
- from codee.admin_service import AGENTS_FILE, AdminService, ISSUE_TYPES, SKILL_TYPES
11
+ from codee.admin_service import (
12
+ AGENTS_FILE, TASKS_CHECKS, AdminService, ISSUE_TYPES, SKILL_TYPES)
12
13
  from codee.workflow_graph import workflow_graph
13
14
 
14
15
  SERVICE = AdminService()
@@ -48,12 +49,47 @@ class MemoryEntry(BaseModel):
48
49
  matched: bool
49
50
 
50
51
 
52
+ class RepositorySummary(BaseModel):
53
+ name: str
54
+ url: str
55
+ default_branch: str
56
+
57
+
51
58
  class ActiveJob(BaseModel):
52
59
  message: str
53
60
  elapsed_label: str
54
61
  viewer_url: str
55
62
 
56
63
 
64
+ class CheckResult(BaseModel):
65
+ """One line of the tasks-provider check list on the settings page.
66
+
67
+ ``status`` is where it is rather than how it went: a check has to be on
68
+ screen before it has an answer, so "waiting" and "running" are states of the
69
+ same row that later becomes ``ok`` or ``failed``.
70
+ """
71
+
72
+ name: str
73
+ status: str
74
+ message: str = ""
75
+
76
+
77
+ def pending_checks(done: list[CheckResult]) -> list[CheckResult]:
78
+ """The finished checks, then the running one, then the ones still to come."""
79
+ rows = list(done)
80
+ for position, name in enumerate(TASKS_CHECKS[len(done):], start=len(done)):
81
+ rows.append(CheckResult(
82
+ name=name,
83
+ status="running" if position == len(done) else "waiting"))
84
+ return rows
85
+
86
+
87
+ def check_result(check: dict[str, Any]) -> CheckResult:
88
+ return CheckResult(name=check["name"],
89
+ status="ok" if check["ok"] else "failed",
90
+ message=check["message"])
91
+
92
+
57
93
  class RunRecord(BaseModel):
58
94
  skill_name: str
59
95
  trigger_type: str
@@ -95,6 +131,10 @@ class AdminState(rx.State):
95
131
  selected_memory: str = ""
96
132
  memory_content: str = ""
97
133
 
134
+ repositories: list[RepositorySummary] = []
135
+ new_repository_url: str = ""
136
+ adding_repository: bool = False
137
+
98
138
  active_jobs: list[ActiveJob] = []
99
139
  total_runs: int = 0
100
140
  last_24h_runs: int = 0
@@ -133,6 +173,11 @@ class AdminState(rx.State):
133
173
  azure_account: str = ""
134
174
  azure_expires_label: str = ""
135
175
  azure_redirect_uri: str = ""
176
+ tasks_verifying: bool = False
177
+ tasks_checks: list[CheckResult] = []
178
+ mcp_setup_ok: bool = False
179
+ mcp_setup_message: str = ""
180
+ mcp_configured: bool = False
136
181
 
137
182
  @rx.var
138
183
  def filtered_skills(self) -> list[SkillSummary]:
@@ -325,6 +370,11 @@ class AdminState(rx.State):
325
370
  self.agents_content = SERVICE.load_agents()
326
371
  self.editing_agents = True
327
372
 
373
+ def open_agents_editor(self) -> Any:
374
+ """Open the AGENTS.md editor from a page that isn't Skills."""
375
+ self.edit_agents()
376
+ return rx.redirect("/skills")
377
+
328
378
  def set_agents_content(self, value: str) -> None:
329
379
  self.agents_content = value
330
380
 
@@ -335,7 +385,7 @@ class AdminState(rx.State):
335
385
  return _save_toast(*SERVICE.save_agents(self.agents_content))
336
386
 
337
387
  def load_memories(self) -> None:
338
- self.coding_agent = SERVICE.load_settings().coding_agent.value
388
+ self.load_coding_agent()
339
389
  if self.coding_agent == "github_copilot":
340
390
  self.memories = []
341
391
  self.selected_memory = ""
@@ -367,6 +417,33 @@ class AdminState(rx.State):
367
417
  self.load_memories()
368
418
  return _save_toast(deleted, pushed, message)
369
419
 
420
+ def load_repositories(self) -> None:
421
+ self.repositories = [RepositorySummary(**repository)
422
+ for repository in SERVICE.list_repositories()]
423
+
424
+ def set_new_repository_url(self, value: str) -> None:
425
+ self.new_repository_url = value
426
+
427
+ @rx.event(background=True)
428
+ async def add_repository(self) -> Any:
429
+ """Clone in the background: a first clone can take minutes."""
430
+ async with self:
431
+ if self.adding_repository:
432
+ return
433
+ url = self.new_repository_url.strip()
434
+ self.adding_repository = True
435
+ try:
436
+ added, message, _ = await asyncio.to_thread(
437
+ SERVICE.add_repository, url)
438
+ except Exception as error:
439
+ added, message = False, str(error)
440
+ async with self:
441
+ self.adding_repository = False
442
+ if added:
443
+ self.new_repository_url = ""
444
+ self.load_repositories()
445
+ yield rx.toast.success(message) if added else rx.toast.error(message)
446
+
370
447
  def _refresh_dashboard(self) -> None:
371
448
  dashboard = SERVICE.dashboard()
372
449
  self.active_jobs = [
@@ -476,6 +553,14 @@ class AdminState(rx.State):
476
553
  self.edit_skill(slug)
477
554
  return rx.redirect("/skills")
478
555
 
556
+ def load_coding_agent(self) -> None:
557
+ """Refresh the agent the sidebar keys off, on every page load.
558
+
559
+ Without this the default sticks until a page that reads settings is
560
+ opened, so the Sessions link shows for Github Copilot.
561
+ """
562
+ self.coding_agent = SERVICE.load_settings().coding_agent.value
563
+
479
564
  def load_settings(self) -> Any:
480
565
  settings = SERVICE.load_settings()
481
566
  self.tasks_provider = settings.tasks_provider.value
@@ -491,9 +576,15 @@ class AdminState(rx.State):
491
576
  self.azure_tenant_id = azure.get("tenant_id", "")
492
577
  self.azure_client_id = azure.get("client_id", "")
493
578
  self.azure_client_secret = azure.get("client_secret", "")
579
+ self._drop_provider_results()
580
+ self.load_mcp_status()
494
581
  self.load_azure_connection()
495
582
  return self._azure_callback_toast()
496
583
 
584
+ def load_mcp_status(self) -> None:
585
+ """Read back whether the selected provider's MCP server is already installed."""
586
+ self.mcp_configured = SERVICE.tasks_mcp_configured(self.tasks_provider)
587
+
497
588
  def load_azure_connection(self) -> None:
498
589
  connection = SERVICE.azure_connection()
499
590
  self.azure_connected = connection["connected"]
@@ -512,8 +603,17 @@ class AdminState(rx.State):
512
603
  return rx.toast.success(message or "Connected to Azure DevOps")
513
604
  return rx.toast.error(message or "Could not connect to Azure DevOps")
514
605
 
606
+ def _drop_provider_results(self) -> None:
607
+ """Forget the last checks and MCP setup: they spoke for credentials that just changed."""
608
+ self.tasks_verifying = False
609
+ self.tasks_checks = []
610
+ self.mcp_setup_ok = False
611
+ self.mcp_setup_message = ""
612
+
515
613
  def set_tasks_provider(self, value: str) -> None:
516
614
  self.tasks_provider = value
615
+ self._drop_provider_results()
616
+ self.load_mcp_status()
517
617
 
518
618
  def set_coding_agent(self, value: str) -> None:
519
619
  self.coding_agent = value
@@ -523,27 +623,35 @@ class AdminState(rx.State):
523
623
 
524
624
  def set_jira_base_url(self, value: str) -> None:
525
625
  self.jira_base_url = value
626
+ self._drop_provider_results()
526
627
 
527
628
  def set_jira_account_email(self, value: str) -> None:
528
629
  self.jira_account_email = value
630
+ self._drop_provider_results()
529
631
 
530
632
  def set_jira_api_token(self, value: str) -> None:
531
633
  self.jira_api_token = value
634
+ self._drop_provider_results()
532
635
 
533
636
  def set_jira_project(self, value: str) -> None:
534
637
  self.jira_project = value
638
+ self._drop_provider_results()
535
639
 
536
640
  def set_azure_organization_url(self, value: str) -> None:
537
641
  self.azure_organization_url = value
642
+ self._drop_provider_results()
538
643
 
539
644
  def set_azure_tenant_id(self, value: str) -> None:
540
645
  self.azure_tenant_id = value
646
+ self._drop_provider_results()
541
647
 
542
648
  def set_azure_client_id(self, value: str) -> None:
543
649
  self.azure_client_id = value
650
+ self._drop_provider_results()
544
651
 
545
652
  def set_azure_client_secret(self, value: str) -> None:
546
653
  self.azure_client_secret = value
654
+ self._drop_provider_results()
547
655
 
548
656
  @rx.var
549
657
  def azure_can_connect(self) -> bool:
@@ -552,6 +660,101 @@ class AdminState(rx.State):
552
660
  self.azure_organization_url,
553
661
  self.azure_client_id, self.azure_client_secret))
554
662
 
663
+ @rx.var
664
+ def tasks_can_verify(self) -> bool:
665
+ """Whether a real pull is worth attempting with what's on the form.
666
+
667
+ Every field the provider reads has to be filled in, and Azure DevOps
668
+ additionally needs the OAuth consent — without it the check could only
669
+ ever report "not connected", which the status line above already says.
670
+ """
671
+ if self.tasks_provider == "jira":
672
+ return all(value.strip() for value in (
673
+ self.jira_base_url, self.jira_account_email,
674
+ self.jira_api_token, self.jira_project))
675
+ return self.azure_can_connect and self.azure_connected
676
+
677
+ @rx.var
678
+ def mcp_can_setup(self) -> bool:
679
+ """Whether the selected provider can describe its MCP server yet.
680
+
681
+ Less than the pull needs in both cases. mcp-atlassian doesn't take a
682
+ project key — the agent searches and updates issues wherever they live
683
+ rather than polling one project — and the Azure DevOps server takes only
684
+ the organization, since it signs in through the Azure CLI rather than
685
+ through the app registration.
686
+ """
687
+ if self.tasks_provider == "jira":
688
+ return all(value.strip() for value in (
689
+ self.jira_base_url, self.jira_account_email,
690
+ self.jira_api_token))
691
+ return bool(self.azure_organization_url.strip())
692
+
693
+ @rx.var
694
+ def mcp_provider_label(self) -> str:
695
+ return ("Azure DevOps" if self.tasks_provider == "azure_devops"
696
+ else "Jira")
697
+
698
+ @rx.var
699
+ def mcp_button_label(self) -> str:
700
+ """Re-running is how stale credentials in the file get replaced."""
701
+ return (f"Setup {self.mcp_provider_label} MCP again"
702
+ if self.mcp_configured
703
+ else f"Setup {self.mcp_provider_label} MCP")
704
+
705
+ @rx.var
706
+ def mcp_missing_hint(self) -> str:
707
+ if self.tasks_provider == "jira":
708
+ return "Fill in base URL, account email and API token first."
709
+ return "Fill in the organization URL first."
710
+
711
+ def setup_tasks_mcp(self) -> None:
712
+ """Write the provider's MCP server into the project's .mcp.json.
713
+
714
+ Synchronous, unlike the connection check: this only touches a local file,
715
+ and the result should be on screen by the time the click lands.
716
+ """
717
+ self.mcp_setup_ok, self.mcp_setup_message = SERVICE.setup_tasks_mcp(
718
+ self.tasks_provider, self._credentials())
719
+ self.load_mcp_status()
720
+
721
+ @rx.event(background=True)
722
+ async def verify_tasks_connection(self) -> Any:
723
+ """Run the provider checks with the credentials on the form.
724
+
725
+ In the background because none of it is quick: the provider allows
726
+ itself 30s per request, and the MCP check runs a whole coding agent. The
727
+ whole list goes up at once and each row is filled in as its check
728
+ finishes, so the wait is spent looking at what is still running rather
729
+ than at nothing. Nothing is saved — verifying shouldn't commit anything.
730
+ """
731
+ async with self:
732
+ if self.tasks_verifying or not self.tasks_can_verify:
733
+ return
734
+ self.tasks_verifying = True
735
+ self.tasks_checks = pending_checks([])
736
+ provider, credentials = self.tasks_provider, self._credentials()
737
+
738
+ # A generator, so nothing has run yet: each next() is one check, and the
739
+ # thread keeps the page responsive while it does.
740
+ checks = SERVICE.verify_tasks_connection(provider, credentials)
741
+ done: list[CheckResult] = []
742
+ failure = ""
743
+ try:
744
+ while (result := await asyncio.to_thread(next, checks, None)) is not None:
745
+ done.append(check_result(result))
746
+ async with self:
747
+ self.tasks_checks = pending_checks(done)
748
+ except Exception as error:
749
+ failure = str(error)
750
+ async with self:
751
+ self.tasks_verifying = False
752
+ # Whatever the generator stopped short of is dropped rather than
753
+ # left spinning: a run that yielded one check has only one to show.
754
+ self.tasks_checks = done + ([CheckResult(
755
+ name="Verification failed", status="failed", message=failure)]
756
+ if failure else [])
757
+
555
758
  def connect_azure_devops(self) -> Any:
556
759
  """Save the app registration, then hand the browser to Entra ID for consent.
557
760
 
@@ -572,35 +775,37 @@ class AdminState(rx.State):
572
775
 
573
776
  def disconnect_azure_devops(self) -> Any:
574
777
  SERVICE.disconnect_azure()
778
+ self._drop_provider_results()
575
779
  self.load_azure_connection()
576
780
  return rx.toast.success("Disconnected from Azure DevOps")
577
781
 
782
+ def _credentials(self) -> dict[str, str]:
783
+ """The selected provider's credentials as they stand on the form."""
784
+ if self.tasks_provider == "jira":
785
+ return {
786
+ "base_url": self.jira_base_url,
787
+ "account_email": self.jira_account_email,
788
+ "api_token": self.jira_api_token,
789
+ "project": self.jira_project,
790
+ }
791
+ return {
792
+ "organization_url": self.azure_organization_url,
793
+ "tenant_id": self.azure_tenant_id,
794
+ "client_id": self.azure_client_id,
795
+ "client_secret": self.azure_client_secret,
796
+ }
797
+
578
798
  def _persist_settings(self) -> str:
579
799
  """Write the settings to disk. Returns an error message, or '' when saved."""
580
800
  try:
581
801
  parallel_agents = int(self.max_parallel_agents)
582
802
  except ValueError:
583
803
  return "Max parallel tasks must be a number"
584
- credentials = (
585
- {
586
- "base_url": self.jira_base_url,
587
- "account_email": self.jira_account_email,
588
- "api_token": self.jira_api_token,
589
- "project": self.jira_project,
590
- }
591
- if self.tasks_provider == "jira"
592
- else {
593
- "organization_url": self.azure_organization_url,
594
- "tenant_id": self.azure_tenant_id,
595
- "client_id": self.azure_client_id,
596
- "client_secret": self.azure_client_secret,
597
- }
598
- )
599
804
  SERVICE.save_settings(
600
805
  self.tasks_provider,
601
806
  self.coding_agent,
602
807
  parallel_agents,
603
- credentials,
808
+ self._credentials(),
604
809
  )
605
810
  return ""
606
811
 
@@ -682,6 +887,8 @@ def shell(content: rx.Component) -> rx.Component:
682
887
  nav_link("Skills", "blocks", "/skills"),
683
888
  nav_link("Workflow", "git-branch", "/workflow"),
684
889
  nav_link("Memory", "notebook-text", "/memory"),
890
+ nav_link("Repositories", "folder-git-2",
891
+ "/repositories"),
685
892
  nav_link("Runs", "history", "/runs"),
686
893
  rx.cond(
687
894
  AdminState.coding_agent == "claude_code",
@@ -762,7 +969,8 @@ def live_dot(size: str = "0.6rem") -> rx.Component:
762
969
  return rx.box(
763
970
  rx.box(position="absolute", inset="0", border_radius="50%", background=ACCENT,
764
971
  animation="codee-ping 1.8s cubic-bezier(0, 0, 0.2, 1) infinite"),
765
- rx.box(position="absolute", inset="0", border_radius="50%", background=ACCENT),
972
+ rx.box(position="absolute", inset="0",
973
+ border_radius="50%", background=ACCENT),
766
974
  class_name="codee-live-dot",
767
975
  position="relative",
768
976
  width=size,
@@ -774,7 +982,8 @@ def live_dot(size: str = "0.6rem") -> rx.Component:
774
982
  def elapsed_pill(label: rx.Var | str) -> rx.Component:
775
983
  return rx.hstack(
776
984
  rx.icon("timer", size=14, color=ACCENT),
777
- rx.text(label, font_family=MONO, font_size="0.85rem", font_weight="500"),
985
+ rx.text(label, font_family=MONO,
986
+ font_size="0.85rem", font_weight="500"),
778
987
  spacing="2",
779
988
  align="center",
780
989
  flex_shrink="0",
@@ -1201,7 +1410,7 @@ def memory_page() -> rx.Component:
1201
1410
  rx.vstack(rx.foreach(AdminState.memories,
1202
1411
  memory_row), spacing="3", width="100%"),
1203
1412
  empty_state("notebook-text", "No memories yet."))
1204
- return shell(rx.vstack(page_header("Memory", "Review and maintain durable project context."),
1413
+ return shell(rx.vstack(page_header("Memory", "Manage agent provider's memory."),
1205
1414
  rx.cond(AdminState.coding_agent == "github_copilot",
1206
1415
  empty_state(
1207
1416
  "notebook-text",
@@ -1211,6 +1420,63 @@ def memory_page() -> rx.Component:
1211
1420
  align="start", width="100%"))
1212
1421
 
1213
1422
 
1423
+ def repository_row(repository: RepositorySummary) -> rx.Component:
1424
+ return rx.flex(
1425
+ rx.icon("folder-git-2", size=18, color=SUBTLE_ICON),
1426
+ rx.vstack(
1427
+ rx.hstack(rx.text(repository.name, font_weight="600"),
1428
+ rx.cond(repository.default_branch != "",
1429
+ rx.badge(rx.icon("house", size=12),
1430
+ repository.default_branch,
1431
+ color_scheme="blue", variant="soft",
1432
+ custom_attrs={"title": "Default branch"})),
1433
+ spacing="2", align="center"),
1434
+ rx.cond(repository.url != "",
1435
+ rx.text(repository.url, color=MUTED, font_family=MONO,
1436
+ font_size="0.8rem")),
1437
+ spacing="2", align="start", flex="1", min_width="0"),
1438
+ gap="0.85rem", align="start", padding="1rem", background=SURFACE,
1439
+ border=BORDER, width="100%")
1440
+
1441
+
1442
+ def repositories_page() -> rx.Component:
1443
+ listing = rx.cond(
1444
+ AdminState.repositories.length() > 0,
1445
+ rx.vstack(rx.foreach(AdminState.repositories, repository_row),
1446
+ spacing="3", width="100%"),
1447
+ empty_state("folder-git-2", "No repositories cloned yet."))
1448
+ return shell(rx.vstack(
1449
+ page_header("Repositories",
1450
+ "Repositories the coding agents work in."),
1451
+ rx.box(
1452
+ rx.flex(
1453
+ rx.input(placeholder="git@github.com:org/repo.git",
1454
+ value=AdminState.new_repository_url,
1455
+ on_change=AdminState.set_new_repository_url,
1456
+ flex="1"),
1457
+ rx.button(rx.icon("plus", size=16), "Add repository",
1458
+ loading=AdminState.adding_repository,
1459
+ on_click=AdminState.add_repository),
1460
+ gap="0.75rem", width="100%"),
1461
+ rx.text("Clones a bare repository into repositories/<name>/.bare "
1462
+ "and checks out its default branch as a worktree beside it. "
1463
+ "A first clone can take a few minutes.",
1464
+ color=MUTED, font_size="0.82rem", margin_top="0.75rem"),
1465
+ padding="1.25rem", background=SURFACE, border=BORDER, width="100%"),
1466
+ rx.callout(
1467
+ rx.fragment(
1468
+ "The agents work better when they know what each repository "
1469
+ "is for: describe them in ",
1470
+ rx.link(AGENTS_FILE, on_click=AdminState.open_agents_editor,
1471
+ color=ACCENT, cursor="pointer",
1472
+ text_decoration="underline"),
1473
+ ".",
1474
+ ),
1475
+ icon="info", size="1", color_scheme="gray", width="100%"),
1476
+ listing,
1477
+ spacing="5", align="start", width="100%"))
1478
+
1479
+
1214
1480
  def run_row(run: RunRecord) -> rx.Component:
1215
1481
  return rx.box(
1216
1482
  rx.flex(
@@ -1516,6 +1782,105 @@ def azure_fields() -> rx.Component:
1516
1782
  spacing="4", width="100%")
1517
1783
 
1518
1784
 
1785
+ def mcp_setup_button() -> rx.Component:
1786
+ """The setup button, built fresh per branch so each can sit in its own row."""
1787
+ return rx.button(
1788
+ rx.icon("file-cog", size=16), AdminState.mcp_button_label,
1789
+ type="button", variant="outline",
1790
+ disabled=~AdminState.mcp_can_setup,
1791
+ on_click=AdminState.setup_tasks_mcp)
1792
+
1793
+
1794
+ def tasks_mcp_setup() -> rx.Component:
1795
+ """Hand the provider's credentials to the coding agent as an MCP server."""
1796
+ return rx.vstack(
1797
+ rx.cond(
1798
+ AdminState.mcp_configured,
1799
+ # Already installed: the state is the sentence, and the button that
1800
+ # rewrites it with whatever the fields now say sits beside it.
1801
+ rx.hstack(
1802
+ rx.icon("circle-check", size=16, color="var(--green-9)"),
1803
+ rx.text("MCP config already set up.", font_size="0.85rem"),
1804
+ mcp_setup_button(),
1805
+ spacing="3", align="center", width="100%"),
1806
+ rx.hstack(
1807
+ mcp_setup_button(),
1808
+ rx.cond(
1809
+ AdminState.mcp_can_setup,
1810
+ rx.text("Writes .mcp.json so the coding agent can read and "
1811
+ "update tasks itself.",
1812
+ color=MUTED, font_size="0.8rem"),
1813
+ rx.text(AdminState.mcp_missing_hint,
1814
+ color=MUTED, font_size="0.8rem")),
1815
+ spacing="3", align="center", width="100%")),
1816
+ rx.cond(
1817
+ AdminState.mcp_setup_message != "",
1818
+ rx.cond(
1819
+ AdminState.mcp_setup_ok,
1820
+ rx.callout(AdminState.mcp_setup_message, icon="circle-check",
1821
+ size="1", color_scheme="green", width="100%"),
1822
+ rx.callout(AdminState.mcp_setup_message, icon="circle-alert",
1823
+ size="1", color_scheme="red", width="100%")),
1824
+ ),
1825
+ spacing="3", width="100%")
1826
+
1827
+
1828
+ def tasks_check_row(check: CheckResult) -> rx.Component:
1829
+ """One check: where it stands as an icon, its name, and what it found."""
1830
+ marker = {"flex_shrink": "0", "margin_top": "0.15rem"}
1831
+ return rx.hstack(
1832
+ rx.match(
1833
+ check.status,
1834
+ ("running", rx.spinner(size="2", **marker)),
1835
+ ("ok", rx.icon("circle-check", size=16,
1836
+ color="var(--green-9)", **marker)),
1837
+ ("failed", rx.icon("circle-alert", size=16,
1838
+ color="var(--red-9)", **marker)),
1839
+ # Still queued: named so the list is complete from the first frame,
1840
+ # but visibly not its turn yet.
1841
+ rx.icon("circle-dashed", size=16, color=MUTED, **marker)),
1842
+ rx.vstack(
1843
+ rx.text(check.name, font_size="0.85rem", weight="medium",
1844
+ color=rx.cond(check.status == "waiting", MUTED, TEXT)),
1845
+ rx.cond(
1846
+ check.message != "",
1847
+ rx.text(check.message, color=MUTED, font_size="0.8rem")),
1848
+ spacing="1", align="start", width="100%"),
1849
+ spacing="2", align="start", width="100%")
1850
+
1851
+
1852
+ def tasks_verification() -> rx.Component:
1853
+ """Run the provider checks and print what each one found."""
1854
+ return rx.vstack(
1855
+ rx.hstack(
1856
+ rx.button(
1857
+ rx.cond(AdminState.tasks_verifying,
1858
+ rx.spinner(size="2"), rx.icon("plug-zap", size=16)),
1859
+ rx.cond(AdminState.tasks_verifying,
1860
+ "Verifying…", "Verify connection"),
1861
+ type="button", variant="outline",
1862
+ disabled=AdminState.tasks_verifying | ~AdminState.tasks_can_verify,
1863
+ on_click=AdminState.verify_tasks_connection),
1864
+ rx.cond(
1865
+ AdminState.tasks_can_verify,
1866
+ # Worth saying up front: the second check spends a coding-agent
1867
+ # run and leaves a closed task behind in the real backend.
1868
+ rx.text("Pulls tasks, then has the coding agent create and "
1869
+ "close one task through MCP. Takes a minute.",
1870
+ color=MUTED, font_size="0.8rem"),
1871
+ rx.text("Fill in every field above to check the connection.",
1872
+ color=MUTED, font_size="0.8rem")),
1873
+ spacing="3", align="center", width="100%"),
1874
+ rx.cond(
1875
+ AdminState.tasks_checks,
1876
+ rx.vstack(rx.foreach(AdminState.tasks_checks, tasks_check_row),
1877
+ spacing="3", width="100%",
1878
+ padding="0.85rem", border=BORDER,
1879
+ background=PAGE_BACKGROUND),
1880
+ ),
1881
+ spacing="3", width="100%")
1882
+
1883
+
1519
1884
  def settings_page() -> rx.Component:
1520
1885
  jira_fields = rx.vstack(
1521
1886
  field("Base URL", rx.input(value=AdminState.jira_base_url,
@@ -1529,7 +1894,7 @@ def settings_page() -> rx.Component:
1529
1894
  spacing="4", width="100%")
1530
1895
  return shell(rx.vstack(
1531
1896
  page_header(
1532
- "Settings", "Choose providers and control executor concurrency."),
1897
+ "Settings", ""),
1533
1898
  rx.box(
1534
1899
  rx.heading("Coding agent", size="4", margin_bottom="1rem"),
1535
1900
  rx.grid(
@@ -1546,6 +1911,8 @@ def settings_page() -> rx.Component:
1546
1911
  on_change=AdminState.set_tasks_provider, width="100%")),
1547
1912
  rx.box(rx.cond(AdminState.tasks_provider == "jira",
1548
1913
  jira_fields, azure_fields()), margin_top="1rem"),
1914
+ rx.box(tasks_mcp_setup(), margin_top="1.25rem"),
1915
+ rx.box(tasks_verification(), margin_top="1.25rem"),
1549
1916
  padding="1.25rem", background=SURFACE, border=BORDER, width="100%"),
1550
1917
  rx.button(rx.icon("save", size=16), "Save settings",
1551
1918
  on_click=AdminState.save_settings),
@@ -1665,15 +2032,20 @@ app = rx.App(
1665
2032
  api_transformer=api_app,
1666
2033
  )
1667
2034
  app.add_page(dashboard_page, route="/", title="Dashboard | Codee",
1668
- on_load=AdminState.poll_dashboard)
2035
+ on_load=[AdminState.load_coding_agent, AdminState.poll_dashboard])
1669
2036
  app.add_page(skills_page, route="/skills", title="Skills | Codee",
1670
- on_load=[AdminState.load_skills, AdminState.load_agent_models])
2037
+ on_load=[AdminState.load_coding_agent, AdminState.load_skills,
2038
+ AdminState.load_agent_models])
1671
2039
  app.add_page(workflow_page, route="/workflow", title="Workflow | Codee",
1672
- on_load=AdminState.load_workflow)
2040
+ on_load=[AdminState.load_coding_agent, AdminState.load_workflow])
1673
2041
  app.add_page(memory_page, route="/memory", title="Memory | Codee",
1674
2042
  on_load=AdminState.load_memories)
2043
+ app.add_page(repositories_page, route="/repositories",
2044
+ title="Repositories | Codee",
2045
+ on_load=[AdminState.load_coding_agent,
2046
+ AdminState.load_repositories])
1675
2047
  app.add_page(runs_page, route="/runs", title="Runs | Codee",
1676
- on_load=AdminState.load_runs)
2048
+ on_load=[AdminState.load_coding_agent, AdminState.load_runs])
1677
2049
  app.add_page(sessions_page, route="/sessions",
1678
2050
  title="Sessions | Codee", on_load=AdminState.load_settings)
1679
2051
  app.add_page(settings_page, route="/settings",
@@ -4,7 +4,7 @@ import os
4
4
  import sys
5
5
  from pathlib import Path
6
6
 
7
- from codee.admin_service import DEFAULT_ADMIN_PORT
7
+ from codee.admin_service import DEFAULT_ADMIN_PORT, public_base_url
8
8
  from codee_main_context.context import data_dir, project_root
9
9
  from codee_main_context.logging import (
10
10
  configure_logging, enable_debug, get_logger, verbose_debug_enabled)
@@ -34,6 +34,16 @@ config = rx.Config(
34
34
  """
35
35
 
36
36
 
37
+ def _api_url(port: int) -> str:
38
+ """Origin the browser uses for the backend websocket and uploads.
39
+
40
+ Reflex bakes this into the compiled frontend, so behind a reverse proxy it
41
+ has to be the public origin rather than the loopback address the server
42
+ binds to -- otherwise the page loads and then fails to open its socket.
43
+ """
44
+ return public_base_url() or f"http://127.0.0.1:{port}"
45
+
46
+
37
47
  def _runtime_directory(root: Path) -> Path:
38
48
  runtime = data_dir(root) / "reflex"
39
49
  runtime.mkdir(parents=True, exist_ok=True)
@@ -59,7 +69,7 @@ def main() -> int:
59
69
  codee_data = data_dir(root).resolve()
60
70
  os.environ["CODEE_PROJECT_ROOT"] = str(root)
61
71
  os.environ["CODEE_DATA_DIR"] = str(codee_data)
62
- os.environ["REFLEX_API_URL"] = f"http://127.0.0.1:{arguments.port}"
72
+ os.environ["REFLEX_API_URL"] = _api_url(arguments.port)
63
73
  os.chdir(_runtime_directory(root))
64
74
 
65
75
  from reflex.reflex import cli