codee-agent 0.6.9__tar.gz → 0.7.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 (95) hide show
  1. {codee_agent-0.6.9 → codee_agent-0.7.1}/PKG-INFO +3 -3
  2. {codee_agent-0.6.9 → codee_agent-0.7.1}/README.md +1 -1
  3. {codee_agent-0.6.9 → codee_agent-0.7.1}/pyproject.toml +3 -2
  4. {codee_agent-0.6.9 → codee_agent-0.7.1}/src/codee/admin.py +54 -9
  5. {codee_agent-0.6.9 → codee_agent-0.7.1}/src/codee/admin_service.py +50 -8
  6. {codee_agent-0.6.9 → codee_agent-0.7.1}/src/codee/coding_agents.py +2 -0
  7. {codee_agent-0.6.9 → codee_agent-0.7.1}/src/codee/executor.py +2 -0
  8. {codee_agent-0.6.9 → codee_agent-0.7.1}/src/codee/lib/runs_db.py +31 -10
  9. {codee_agent-0.6.9 → codee_agent-0.7.1}/src/codee/lib/test_runs_db.py +29 -7
  10. {codee_agent-0.6.9 → codee_agent-0.7.1}/src/codee/test_admin_service.py +108 -44
  11. {codee_agent-0.6.9 → codee_agent-0.7.1}/src/codee/test_coding_agents.py +10 -0
  12. codee_agent-0.7.1/src/codee_agent_opencode/__init__.py +1 -0
  13. codee_agent-0.7.1/src/codee_agent_opencode/provider.py +225 -0
  14. codee_agent-0.7.1/src/codee_agent_opencode/test.py +153 -0
  15. {codee_agent-0.6.9 → codee_agent-0.7.1}/src/codee_main_context/context.py +1 -0
  16. {codee_agent-0.6.9 → codee_agent-0.7.1}/LICENSE +0 -0
  17. {codee_agent-0.6.9 → codee_agent-0.7.1}/src/codee/.gitignore +0 -0
  18. {codee_agent-0.6.9 → codee_agent-0.7.1}/src/codee/__init__.py +0 -0
  19. {codee_agent-0.6.9 → codee_agent-0.7.1}/src/codee/admin_api.py +0 -0
  20. {codee_agent-0.6.9 → codee_agent-0.7.1}/src/codee/admin_cli.py +0 -0
  21. {codee_agent-0.6.9 → codee_agent-0.7.1}/src/codee/agent_cli.py +0 -0
  22. {codee_agent-0.6.9 → codee_agent-0.7.1}/src/codee/init_cli.py +0 -0
  23. {codee_agent-0.6.9 → codee_agent-0.7.1}/src/codee/lib/__init__.py +0 -0
  24. {codee_agent-0.6.9 → codee_agent-0.7.1}/src/codee/lib/claude_key_rotation.py +0 -0
  25. {codee_agent-0.6.9 → codee_agent-0.7.1}/src/codee/lib/cron_describe.py +0 -0
  26. {codee_agent-0.6.9 → codee_agent-0.7.1}/src/codee/lib/mcp_config.py +0 -0
  27. {codee_agent-0.6.9 → codee_agent-0.7.1}/src/codee/lib/test_claude_key_rotation.py +0 -0
  28. {codee_agent-0.6.9 → codee_agent-0.7.1}/src/codee/lib/test_mcp_config.py +0 -0
  29. {codee_agent-0.6.9 → codee_agent-0.7.1}/src/codee/lib/test_trigger_cron_skills.py +0 -0
  30. {codee_agent-0.6.9 → codee_agent-0.7.1}/src/codee/lib/test_trigger_issue_skills.py +0 -0
  31. {codee_agent-0.6.9 → codee_agent-0.7.1}/src/codee/lib/trigger_aws_sqs_skills.py +0 -0
  32. {codee_agent-0.6.9 → codee_agent-0.7.1}/src/codee/lib/trigger_cron_skills.py +0 -0
  33. {codee_agent-0.6.9 → codee_agent-0.7.1}/src/codee/lib/trigger_email_skills.py +0 -0
  34. {codee_agent-0.6.9 → codee_agent-0.7.1}/src/codee/lib/trigger_issue_skills.py +0 -0
  35. {codee_agent-0.6.9 → codee_agent-0.7.1}/src/codee/mail_server.py +0 -0
  36. {codee_agent-0.6.9 → codee_agent-0.7.1}/src/codee/setup_wizard.py +0 -0
  37. {codee_agent-0.6.9 → codee_agent-0.7.1}/src/codee/start_cli.py +0 -0
  38. {codee_agent-0.6.9 → codee_agent-0.7.1}/src/codee/tasks_providers.py +0 -0
  39. {codee_agent-0.6.9 → codee_agent-0.7.1}/src/codee/templates/AGENTS.md +0 -0
  40. {codee_agent-0.6.9 → codee_agent-0.7.1}/src/codee/templates/CLAUDE.md +0 -0
  41. {codee_agent-0.6.9 → codee_agent-0.7.1}/src/codee/templates/skills/aws-sqs-alarm-response/SKILL.md +0 -0
  42. {codee_agent-0.6.9 → codee_agent-0.7.1}/src/codee/templates/skills/cron-research-5xx-errors/SKILL.md +0 -0
  43. {codee_agent-0.6.9 → codee_agent-0.7.1}/src/codee/templates/skills/story-code-reviewer/SKILL.md +0 -0
  44. {codee_agent-0.6.9 → codee_agent-0.7.1}/src/codee/templates/skills/story-developer/SKILL.md +0 -0
  45. {codee_agent-0.6.9 → codee_agent-0.7.1}/src/codee/templates/skills/story-planner/SKILL.md +0 -0
  46. {codee_agent-0.6.9 → codee_agent-0.7.1}/src/codee/templates/skills/story-planner/assets/readme-template.md +0 -0
  47. {codee_agent-0.6.9 → codee_agent-0.7.1}/src/codee/templates/skills/story-qa/SKILL.md +0 -0
  48. {codee_agent-0.6.9 → codee_agent-0.7.1}/src/codee/templates/skills/story-security-reviewer/SKILL.md +0 -0
  49. {codee_agent-0.6.9 → codee_agent-0.7.1}/src/codee/templates/skills/task-code-reviewer/SKILL.md +0 -0
  50. {codee_agent-0.6.9 → codee_agent-0.7.1}/src/codee/templates/skills/task-developer/SKILL.md +0 -0
  51. {codee_agent-0.6.9 → codee_agent-0.7.1}/src/codee/templates/skills/task-qa/SKILL.md +0 -0
  52. {codee_agent-0.6.9 → codee_agent-0.7.1}/src/codee/templates/skills/task-security-reviewer/SKILL.md +0 -0
  53. {codee_agent-0.6.9 → codee_agent-0.7.1}/src/codee/test_admin_api.py +0 -0
  54. {codee_agent-0.6.9 → codee_agent-0.7.1}/src/codee/test_admin_cli.py +0 -0
  55. {codee_agent-0.6.9 → codee_agent-0.7.1}/src/codee/test_agent_cli.py +0 -0
  56. {codee_agent-0.6.9 → codee_agent-0.7.1}/src/codee/test_executor.py +0 -0
  57. {codee_agent-0.6.9 → codee_agent-0.7.1}/src/codee/test_init_cli.py +0 -0
  58. {codee_agent-0.6.9 → codee_agent-0.7.1}/src/codee/test_memory_index.py +0 -0
  59. {codee_agent-0.6.9 → codee_agent-0.7.1}/src/codee/test_setup_wizard.py +0 -0
  60. {codee_agent-0.6.9 → codee_agent-0.7.1}/src/codee/test_start_cli.py +0 -0
  61. {codee_agent-0.6.9 → codee_agent-0.7.1}/src/codee/workflow_graph.py +0 -0
  62. {codee_agent-0.6.9 → codee_agent-0.7.1}/src/codee_admin/__init__.py +0 -0
  63. {codee_agent-0.6.9 → codee_agent-0.7.1}/src/codee_admin/codee_admin.py +0 -0
  64. {codee_agent-0.6.9 → codee_agent-0.7.1}/src/codee_agent_abstract/__init__.py +0 -0
  65. {codee_agent-0.6.9 → codee_agent-0.7.1}/src/codee_agent_abstract/provider.py +0 -0
  66. {codee_agent-0.6.9 → codee_agent-0.7.1}/src/codee_agent_claude_code/__init__.py +0 -0
  67. {codee_agent-0.6.9 → codee_agent-0.7.1}/src/codee_agent_claude_code/account.py +0 -0
  68. {codee_agent-0.6.9 → codee_agent-0.7.1}/src/codee_agent_claude_code/credentials.py +0 -0
  69. {codee_agent-0.6.9 → codee_agent-0.7.1}/src/codee_agent_claude_code/oauth.py +0 -0
  70. {codee_agent-0.6.9 → codee_agent-0.7.1}/src/codee_agent_claude_code/provider.py +0 -0
  71. {codee_agent-0.6.9 → codee_agent-0.7.1}/src/codee_agent_claude_code/test.py +0 -0
  72. {codee_agent-0.6.9 → codee_agent-0.7.1}/src/codee_agent_claude_code/usage.py +0 -0
  73. {codee_agent-0.6.9 → codee_agent-0.7.1}/src/codee_agent_codex/__init__.py +0 -0
  74. {codee_agent-0.6.9 → codee_agent-0.7.1}/src/codee_agent_codex/provider.py +0 -0
  75. {codee_agent-0.6.9 → codee_agent-0.7.1}/src/codee_agent_codex/test.py +0 -0
  76. {codee_agent-0.6.9 → codee_agent-0.7.1}/src/codee_agent_github_copilot/__init__.py +0 -0
  77. {codee_agent-0.6.9 → codee_agent-0.7.1}/src/codee_agent_github_copilot/provider.py +0 -0
  78. {codee_agent-0.6.9 → codee_agent-0.7.1}/src/codee_agent_github_copilot/test.py +0 -0
  79. {codee_agent-0.6.9 → codee_agent-0.7.1}/src/codee_database/__init__.py +0 -0
  80. {codee_agent-0.6.9 → codee_agent-0.7.1}/src/codee_database/claude_code_accounts.py +0 -0
  81. {codee_agent-0.6.9 → codee_agent-0.7.1}/src/codee_database/database.py +0 -0
  82. {codee_agent-0.6.9 → codee_agent-0.7.1}/src/codee_database/oauth_tokens.py +0 -0
  83. {codee_agent-0.6.9 → codee_agent-0.7.1}/src/codee_main_context/__init__.py +0 -0
  84. {codee_agent-0.6.9 → codee_agent-0.7.1}/src/codee_main_context/logging.py +0 -0
  85. {codee_agent-0.6.9 → codee_agent-0.7.1}/src/codee_main_context/test_context.py +0 -0
  86. {codee_agent-0.6.9 → codee_agent-0.7.1}/src/codee_main_context/test_logging.py +0 -0
  87. {codee_agent-0.6.9 → codee_agent-0.7.1}/src/codee_tasks_abstract/__init__.py +0 -0
  88. {codee_agent-0.6.9 → codee_agent-0.7.1}/src/codee_tasks_abstract/provider.py +0 -0
  89. {codee_agent-0.6.9 → codee_agent-0.7.1}/src/codee_tasks_azure_devops/__init__.py +0 -0
  90. {codee_agent-0.6.9 → codee_agent-0.7.1}/src/codee_tasks_azure_devops/oauth.py +0 -0
  91. {codee_agent-0.6.9 → codee_agent-0.7.1}/src/codee_tasks_azure_devops/provider.py +0 -0
  92. {codee_agent-0.6.9 → codee_agent-0.7.1}/src/codee_tasks_azure_devops/test.py +0 -0
  93. {codee_agent-0.6.9 → codee_agent-0.7.1}/src/codee_tasks_jira/__init__.py +0 -0
  94. {codee_agent-0.6.9 → codee_agent-0.7.1}/src/codee_tasks_jira/provider.py +0 -0
  95. {codee_agent-0.6.9 → codee_agent-0.7.1}/src/codee_tasks_jira/test.py +0 -0
@@ -1,8 +1,8 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codee-agent
3
- Version: 0.6.9
3
+ Version: 0.7.1
4
4
  Summary: A virtual co-worker that picks up Jira and Azure DevOps tasks and solves them with coding agents.
5
- Keywords: ai,agent,jira,azure-devops,claude-code,github-copilot,codex,automation
5
+ Keywords: ai,agent,jira,azure-devops,claude-code,github-copilot,codex,opencode,automation
6
6
  Author: Denis Kibalko
7
7
  Author-email: Denis Kibalko <denis.kibalko@nimbusweb.me>
8
8
  License-Expression: MIT
@@ -39,7 +39,7 @@ The goal of this project is to provide an assistant that will integrate into you
39
39
 
40
40
  Codee works with Jira and Azure DevOps as tasks providers, new providers are quite easy to create, PRs are welcome.
41
41
 
42
- As agents it currently works with Claude Code, Github Copilot and Codex.
42
+ As agents it currently works with Claude Code, GitHub Copilot, Codex and OpenCode.
43
43
 
44
44
  ## Documentation
45
45
 
@@ -4,7 +4,7 @@ The goal of this project is to provide an assistant that will integrate into you
4
4
 
5
5
  Codee works with Jira and Azure DevOps as tasks providers, new providers are quite easy to create, PRs are welcome.
6
6
 
7
- As agents it currently works with Claude Code, Github Copilot and Codex.
7
+ As agents it currently works with Claude Code, GitHub Copilot, Codex and OpenCode.
8
8
 
9
9
  ## Documentation
10
10
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "codee-agent"
3
- version = "0.6.9"
3
+ version = "0.7.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"
@@ -9,7 +9,7 @@ requires-python = ">=3.12"
9
9
  authors = [
10
10
  { name = "Denis Kibalko", email = "denis.kibalko@nimbusweb.me" },
11
11
  ]
12
- keywords = ["ai", "agent", "jira", "azure-devops", "claude-code", "github-copilot", "codex", "automation"]
12
+ keywords = ["ai", "agent", "jira", "azure-devops", "claude-code", "github-copilot", "codex", "opencode", "automation"]
13
13
  classifiers = [
14
14
  "Development Status :: 3 - Alpha",
15
15
  "Environment :: Console",
@@ -77,6 +77,7 @@ module-name = [
77
77
  "codee_agent_claude_code",
78
78
  "codee_agent_github_copilot",
79
79
  "codee_agent_codex",
80
+ "codee_agent_opencode",
80
81
  "codee_tasks_abstract",
81
82
  "codee_tasks_jira",
82
83
  "codee_tasks_azure_devops",
@@ -270,6 +270,9 @@ class RunRecord(BaseModel):
270
270
  status: str
271
271
  error: str
272
272
  started_at: str
273
+ ended_at: str
274
+ duration_label: str
275
+ relative_age_label: str
273
276
  session_id: str
274
277
  message: str
275
278
  user_message: str
@@ -334,6 +337,8 @@ class AdminState(rx.State):
334
337
  runs: list[RunRecord] = []
335
338
  runs_has_more: bool = False
336
339
  runs_loading: bool = False
340
+ runs_query: str = ""
341
+ runs_watch: int = 0
337
342
  session_viewer: str = SERVICE.session_viewer
338
343
 
339
344
  workflow_sections: list[WorkflowSection] = []
@@ -841,12 +846,14 @@ class AdminState(rx.State):
841
846
  for account in measured]
842
847
  await asyncio.sleep(USAGE_POLL_INTERVAL)
843
848
 
844
- def _fetch_runs_page(self, offset: int) -> list[RunRecord]:
849
+ def _fetch_runs_page(self, offset: int,
850
+ limit: int = RUNS_PAGE_SIZE) -> list[RunRecord]:
845
851
  """One page of runs. Reads one row past the page to learn whether more exist."""
846
- rows = SERVICE.recent_runs(RUNS_PAGE_SIZE + 1, offset)
847
- self.runs_has_more = len(rows) > RUNS_PAGE_SIZE
852
+ rows = SERVICE.recent_runs(
853
+ limit + 1, offset, self.runs_query)
854
+ self.runs_has_more = len(rows) > limit
848
855
  records = []
849
- for run in rows[:RUNS_PAGE_SIZE]:
856
+ for run in rows[:limit]:
850
857
  message = (run.get("message") or "").strip()
851
858
  preview = message.splitlines()[0] if message else "No message"
852
859
  records.append(RunRecord(
@@ -855,6 +862,9 @@ class AdminState(rx.State):
855
862
  status=run["status"],
856
863
  error=run.get("error") or "",
857
864
  started_at=run["started_at"],
865
+ ended_at=run["ended_at"],
866
+ duration_label=run["duration_label"],
867
+ relative_age_label=run["relative_age_label"],
858
868
  session_id=run.get("session_id") or "",
859
869
  message=message,
860
870
  user_message=(run.get("user_message") or message).strip(),
@@ -871,6 +881,11 @@ class AdminState(rx.State):
871
881
  self.runs_loading = False
872
882
  self.runs = self._fetch_runs_page(0)
873
883
 
884
+ def search_runs(self, value: str) -> None:
885
+ self.runs_query = value
886
+ self.runs_loading = False
887
+ self.runs = self._fetch_runs_page(0)
888
+
874
889
  def load_more_runs(self) -> None:
875
890
  if self.runs_loading or not self.runs_has_more:
876
891
  return
@@ -880,6 +895,23 @@ class AdminState(rx.State):
880
895
  finally:
881
896
  self.runs_loading = False
882
897
 
898
+ @rx.event(background=True)
899
+ async def poll_runs(self) -> None:
900
+ """Keep recent runs current while the Runs page is on screen."""
901
+ async with self:
902
+ self.runs_watch += 1
903
+ watch = self.runs_watch
904
+ while True:
905
+ async with self:
906
+ if self.runs_watch != watch or self.active_route != "/runs":
907
+ return
908
+ try:
909
+ visible_count = max(RUNS_PAGE_SIZE, len(self.runs))
910
+ self.runs = self._fetch_runs_page(0, visible_count)
911
+ except Exception as error:
912
+ print(f"[admin] Failed to refresh runs: {error}")
913
+ await asyncio.sleep(1)
914
+
883
915
  @rx.event(background=True)
884
916
  async def load_workflow(self, force: bool = False) -> None:
885
917
  """Show the workflow, attaching to a generation already under way.
@@ -2542,7 +2574,13 @@ def run_row(run: RunRecord) -> rx.Component:
2542
2574
  rx.flex(
2543
2575
  rx.vstack(rx.hstack(rx.text(run.skill_name, font_weight="600"),
2544
2576
  rx.badge(run.status, color_scheme=rx.cond(run.status == "succeeded", "green", "red"))),
2545
- rx.text(local_datetime(run.started_at), color=MUTED,
2577
+ rx.hstack(
2578
+ rx.text(local_datetime(run.ended_at)),
2579
+ rx.text("(", run.relative_age_label, ")"),
2580
+ color=MUTED, font_size="0.8rem",
2581
+ font_family="IBM Plex Mono, monospace",
2582
+ spacing="2"),
2583
+ rx.text("Duration: ", run.duration_label, color=MUTED,
2546
2584
  font_size="0.8rem",
2547
2585
  font_family="IBM Plex Mono, monospace"),
2548
2586
  rx.text("Thread ID: ", run.session_id, color=MUTED,
@@ -2570,7 +2608,7 @@ def run_row(run: RunRecord) -> rx.Component:
2570
2608
  rx.text(run.debug_logs, white_space="pre-wrap",
2571
2609
  font_family="IBM Plex Mono, monospace",
2572
2610
  font_size="0.8rem"))),
2573
- spacing="2", align="start", width="100%"), value=run.started_at),
2611
+ spacing="2", align="start", width="100%"), value=run.ended_at),
2574
2612
  collapsible=True, width="100%")),
2575
2613
  padding="1rem", background=SURFACE, border=BORDER, width="100%")
2576
2614
 
@@ -2586,8 +2624,15 @@ def runs_page() -> rx.Component:
2586
2624
  on_click=AdminState.load_more_runs)),
2587
2625
  spacing="3", width="100%")
2588
2626
  return shell(rx.vstack(page_header("Runs", "Recent trigger executions and outcomes."),
2627
+ rx.input(placeholder="Search prompts and LLM responses",
2628
+ type="search", value=AdminState.runs_query,
2629
+ on_change=AdminState.search_runs,
2630
+ debounce_timeout=300, width="100%"),
2589
2631
  rx.cond(AdminState.runs.length() > 0, listing,
2590
- empty_state("history", "No runs recorded yet.")),
2632
+ rx.cond(AdminState.runs_query != "",
2633
+ empty_state(
2634
+ "search-x", "No runs match your search."),
2635
+ empty_state("history", "No runs recorded yet."))),
2591
2636
  align="start", width="100%"))
2592
2637
 
2593
2638
 
@@ -3545,7 +3590,7 @@ def settings_page() -> rx.Component:
3545
3590
  field("Default agent",
3546
3591
  rx.grid(
3547
3592
  rx.select(
3548
- ["claude_code", "github_copilot", "codex"],
3593
+ list(AGENT_NAMES),
3549
3594
  value=AdminState.coding_agent,
3550
3595
  on_change=AdminState.set_coding_agent,
3551
3596
  width="100%"),
@@ -3800,7 +3845,7 @@ app.add_page(repositories_page, route="/repositories",
3800
3845
  title="Repositories | Codee",
3801
3846
  on_load=AdminState.load_repositories)
3802
3847
  app.add_page(runs_page, route="/runs", title="Runs | Codee",
3803
- on_load=AdminState.load_runs)
3848
+ on_load=AdminState.poll_runs)
3804
3849
  app.add_page(sessions_page, route="/sessions",
3805
3850
  title="Sessions | Codee", on_load=AdminState.load_settings)
3806
3851
  app.add_page(settings_page, route="/settings",
@@ -59,6 +59,26 @@ from codee_main_context.context import (
59
59
  work_item_mappings,
60
60
  )
61
61
 
62
+
63
+ def relative_age_label(value: str, now: datetime | None = None) -> str:
64
+ """Format an ISO timestamp as a compact minute, hour, or day age."""
65
+ try:
66
+ timestamp = datetime.fromisoformat(value)
67
+ except (TypeError, ValueError):
68
+ return ""
69
+ if timestamp.tzinfo is None:
70
+ timestamp = timestamp.replace(tzinfo=timezone.utc)
71
+ current = now or datetime.now(timezone.utc)
72
+ elapsed = max(int((current - timestamp).total_seconds()), 0)
73
+ if elapsed < 3600:
74
+ amount, unit = max(elapsed // 60, 1), "minute"
75
+ elif elapsed < 86400:
76
+ amount, unit = elapsed // 3600, "hour"
77
+ else:
78
+ amount, unit = elapsed // 86400, "day"
79
+ return f"{amount} {unit}{'' if amount == 1 else 's'} ago"
80
+
81
+
62
82
  load_dotenv()
63
83
 
64
84
  MANAGED = {
@@ -1300,16 +1320,25 @@ class AdminService:
1300
1320
  f"Generating workflow for work item {issue_type.capitalize()} "
1301
1321
  f"from {_count(len(skills), 'issue-trigger skill')}..."
1302
1322
  )
1323
+ session_id = str(uuid.uuid4())
1324
+
1325
+ def opened(agent_session_id: str) -> None:
1326
+ nonlocal session_id
1327
+ session_id = agent_session_id
1328
+
1303
1329
  validation_error = ""
1304
1330
  for attempt in range(2):
1305
- request = prompt
1306
1331
  if validation_error:
1307
- request += (
1308
- "\n\nYour previous response was invalid: "
1309
- f"{validation_error}. Return corrected JSON only."
1332
+ response = agent.continue_conversation(
1333
+ "Your previous response was invalid: "
1334
+ f"{validation_error}. Return corrected JSON only.",
1335
+ session_id,
1336
+ agent.best_model(),
1337
+ opened,
1310
1338
  )
1311
- response = agent.run(
1312
- request, str(uuid.uuid4()), agent.best_model())
1339
+ else:
1340
+ response = agent.run(
1341
+ prompt, session_id, agent.best_model(), opened)
1313
1342
  payload_text = _strip_code_fence(response)
1314
1343
  try:
1315
1344
  payload = json.loads(payload_text)
@@ -2046,8 +2075,21 @@ class AdminService:
2046
2075
 
2047
2076
  return url
2048
2077
 
2049
- def recent_runs(self, limit: int = 100, offset: int = 0) -> list[dict[str, Any]]:
2050
- return runs_db.recent_runs(limit, offset, main_context=self.context)
2078
+ def recent_runs(self, limit: int = 100, offset: int = 0,
2079
+ search: str = "") -> list[dict[str, Any]]:
2080
+ rows = runs_db.recent_runs(
2081
+ limit, offset, search=search, main_context=self.context)
2082
+ now = datetime.now(timezone.utc)
2083
+ for row in rows:
2084
+ try:
2085
+ elapsed = (datetime.fromisoformat(row["ended_at"])
2086
+ - datetime.fromisoformat(row["started_at"])).total_seconds()
2087
+ except (TypeError, ValueError):
2088
+ elapsed = 0
2089
+ row["duration_label"] = runs_db.fmt_elapsed(max(int(elapsed), 0))
2090
+ row["relative_age_label"] = relative_age_label(
2091
+ row["ended_at"], now)
2092
+ return rows
2051
2093
 
2052
2094
  def load_settings(self) -> Settings:
2053
2095
  self.context.settings = load_settings(self.data_dir)
@@ -11,6 +11,7 @@ from codee_agent_abstract.provider import AbstractCodingAgent
11
11
  from codee_agent_claude_code.provider import ClaudeCodeAgent
12
12
  from codee_agent_codex.provider import CodexAgent
13
13
  from codee_agent_github_copilot.provider import GitHubCopilotAgent
14
+ from codee_agent_opencode.provider import OpenCodeAgent
14
15
  from codee_main_context.context import CodingAgent, Settings
15
16
 
16
17
  # Concrete coding agents, keyed by the agent selected in settings.
@@ -18,6 +19,7 @@ CODING_AGENTS: dict[CodingAgent, type[AbstractCodingAgent]] = {
18
19
  CodingAgent.CLAUDE_CODE: ClaudeCodeAgent,
19
20
  CodingAgent.GITHUB_COPILOT: GitHubCopilotAgent,
20
21
  CodingAgent.CODEX: CodexAgent,
22
+ CodingAgent.OPENCODE: OpenCodeAgent,
21
23
  }
22
24
 
23
25
 
@@ -12,6 +12,7 @@ from codee_agent_claude_code.provider import ClaudeCodeAgent
12
12
  from codee_agent_codex.provider import CodexAgent
13
13
  from codee_agent_github_copilot.provider import (
14
14
  COPILOT_DEBUG_ENV_VAR, GitHubCopilotAgent)
15
+ from codee_agent_opencode.provider import OpenCodeAgent
15
16
  from codee_main_context.context import (
16
17
  CodeeMainContext, CodingAgent, Settings, codee_issue_types, data_dir,
17
18
  load_settings, project_root)
@@ -39,6 +40,7 @@ _CODING_AGENTS: dict[CodingAgent, type[AbstractCodingAgent]] = {
39
40
  CodingAgent.CLAUDE_CODE: ClaudeCodeAgent,
40
41
  CodingAgent.GITHUB_COPILOT: GitHubCopilotAgent,
41
42
  CodingAgent.CODEX: CodexAgent,
43
+ CodingAgent.OPENCODE: OpenCodeAgent,
42
44
  }
43
45
 
44
46
  POLL_INTERVAL = 60 # 1 minute
@@ -11,7 +11,8 @@ from codee_main_context.context import CodeeMainContext
11
11
  from codee_database.database import get_db_connection
12
12
 
13
13
  _COLUMNS = ("id", "skill_name", "trigger_type", "session_id", "status", "error",
14
- "started_at", "message", "user_message", "response", "debug_logs")
14
+ "started_at", "ended_at", "message", "user_message", "response",
15
+ "debug_logs")
15
16
 
16
17
  # Codee names a session before the agent runs, but not every agent runs under
17
18
  # the name it was given: Codex mints its own thread id and reports it back mid
@@ -50,6 +51,7 @@ def init(main_context: CodeeMainContext) -> None:
50
51
  status TEXT NOT NULL,
51
52
  error TEXT,
52
53
  started_at TEXT NOT NULL,
54
+ ended_at TEXT NOT NULL,
53
55
  message TEXT,
54
56
  user_message TEXT,
55
57
  response TEXT,
@@ -68,6 +70,12 @@ def init(main_context: CodeeMainContext) -> None:
68
70
  conn.execute("ALTER TABLE runs ADD COLUMN user_message TEXT")
69
71
  if "debug_logs" not in cols:
70
72
  conn.execute("ALTER TABLE runs ADD COLUMN debug_logs TEXT")
73
+ if "ended_at" not in cols:
74
+ conn.execute("ALTER TABLE runs ADD COLUMN ended_at TEXT")
75
+ conn.execute(
76
+ "UPDATE runs SET ended_at = started_at WHERE ended_at IS NULL")
77
+ conn.execute(
78
+ "CREATE INDEX IF NOT EXISTS idx_runs_ended_at ON runs(ended_at DESC)")
71
79
  # In-flight claude runs; a row lives only while its subprocess is running.
72
80
  conn.execute(
73
81
  """CREATE TABLE IF NOT EXISTS active_jobs (
@@ -90,7 +98,7 @@ def init(main_context: CodeeMainContext) -> None:
90
98
 
91
99
  def record_run(skill_name, trigger_type, session_id, status, error=None, started_at=None,
92
100
  message=None, user_message=None, response=None, debug_logs=None,
93
- *, main_context: CodeeMainContext) -> None:
101
+ ended_at=None, *, main_context: CodeeMainContext) -> None:
94
102
  """Insert one run row. Never raises to the caller (FR-009)."""
95
103
  try:
96
104
  init(main_context)
@@ -99,30 +107,43 @@ def record_run(skill_name, trigger_type, session_id, status, error=None, started
99
107
  debug_logs = getattr(response, "debug_logs", None)
100
108
  if started_at is None:
101
109
  started_at = datetime.now(timezone.utc).isoformat()
110
+ if ended_at is None:
111
+ ended_at = datetime.now(timezone.utc).isoformat()
102
112
  with get_db_connection(main_context) as conn:
103
113
  conn.execute(
104
114
  "INSERT INTO runs (skill_name, trigger_type, session_id, status, error,"
105
- " started_at, message, user_message, response, debug_logs)"
106
- " VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
115
+ " started_at, ended_at, message, user_message, response, debug_logs)"
116
+ " VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
107
117
  (skill_name, trigger_type, session_id,
108
- status, error, started_at, message, user_message, response,
118
+ status, error, started_at, ended_at, message, user_message, response,
109
119
  debug_logs),
110
120
  )
111
121
  except Exception as exc: # ponytail: a logging miss must never abort the skill run
112
122
  print(f"[runs_db] Failed to record run for {skill_name}: {exc}")
113
123
 
114
124
 
115
- def recent_runs(limit: int = 100, offset: int = 0, *,
125
+ def recent_runs(limit: int = 100, offset: int = 0, *, search: str = "",
116
126
  main_context: CodeeMainContext) -> list[dict]:
117
- """Most recent runs newest-first as dicts, skipping `offset` rows; [] on empty/missing DB (FR-006)."""
127
+ """Return matching runs newest-first; [] on an empty or missing DB."""
118
128
  try:
119
129
  init(main_context)
130
+ query = search.strip()
131
+ where = ""
132
+ params: list = []
133
+ if query:
134
+ where = (
135
+ " WHERE COALESCE(message, '') LIKE ?"
136
+ " OR COALESCE(user_message, '') LIKE ?"
137
+ " OR COALESCE(response, '') LIKE ?"
138
+ )
139
+ params.extend([f"%{query}%"] * 3)
140
+ params.extend((limit, max(offset, 0)))
120
141
  with get_db_connection(main_context) as conn:
121
142
  rows = conn.execute(
122
143
  "SELECT id, skill_name, trigger_type, session_id, status, error,"
123
- " started_at, message, user_message, response, debug_logs"
124
- " FROM runs ORDER BY started_at DESC, id DESC LIMIT ? OFFSET ?",
125
- (limit, max(offset, 0)),
144
+ " started_at, ended_at, message, user_message, response, debug_logs"
145
+ f" FROM runs{where} ORDER BY ended_at DESC, id DESC LIMIT ? OFFSET ?",
146
+ params,
126
147
  ).fetchall()
127
148
  return [dict(zip(_COLUMNS, row)) for row in rows]
128
149
  except Exception as exc:
@@ -25,17 +25,20 @@ def _db_file(data_dir: Path) -> Path:
25
25
  def test_round_trip_newest_first(tmp_path):
26
26
  ctx = _ctx(tmp_path)
27
27
  runs_db.record_run("skill-a", "cron", "sid-1", "succeeded",
28
- started_at="2026-06-27T10:00:00+00:00", main_context=ctx)
28
+ started_at="2026-06-27T10:00:00+00:00",
29
+ ended_at="2026-06-27T12:00:00+00:00", main_context=ctx)
29
30
  runs_db.record_run("skill-b", "email", "sid-2", "failed", error="boom",
30
- started_at="2026-06-27T11:00:00+00:00", main_context=ctx)
31
+ started_at="2026-06-27T11:00:00+00:00",
32
+ ended_at="2026-06-27T11:30:00+00:00", main_context=ctx)
31
33
 
32
34
  rows = runs_db.recent_runs(main_context=ctx)
33
35
  assert [r["skill_name"] for r in rows] == [
34
- "skill-b", "skill-a"] # newest first
35
- assert rows[0]["trigger_type"] == "email"
36
- assert rows[0]["status"] == "failed"
37
- assert rows[0]["error"] == "boom"
38
- assert rows[0]["session_id"] == "sid-2"
36
+ "skill-a", "skill-b"] # latest completion first
37
+ assert rows[0]["ended_at"] == "2026-06-27T12:00:00+00:00"
38
+ assert rows[1]["trigger_type"] == "email"
39
+ assert rows[1]["status"] == "failed"
40
+ assert rows[1]["error"] == "boom"
41
+ assert rows[1]["session_id"] == "sid-2"
39
42
 
40
43
 
41
44
  def test_empty_db_returns_empty_list(tmp_path):
@@ -58,6 +61,24 @@ def test_limit_and_offset_page_through_runs(tmp_path):
58
61
  assert runs_db.recent_runs(2, 5, main_context=ctx) == []
59
62
 
60
63
 
64
+ def test_search_matches_prompt_and_response_before_pagination(tmp_path):
65
+ ctx = _ctx(tmp_path)
66
+ runs_db.record_run("a", "cron", "sid-a", "succeeded",
67
+ message="Summarize invoices", response="Nothing relevant",
68
+ ended_at="2026-06-27T10:00:00+00:00", main_context=ctx)
69
+ runs_db.record_run("b", "cron", "sid-b", "succeeded",
70
+ user_message="Unrelated prompt", response="Invoice summary ready",
71
+ ended_at="2026-06-27T11:00:00+00:00", main_context=ctx)
72
+ runs_db.record_run("c", "cron", "sid-c", "succeeded",
73
+ message="Unrelated", response="No match",
74
+ ended_at="2026-06-27T12:00:00+00:00", main_context=ctx)
75
+
76
+ rows = runs_db.recent_runs(1, search="invoice", main_context=ctx)
77
+ assert [row["session_id"] for row in rows] == ["sid-b"]
78
+ rows = runs_db.recent_runs(1, 1, search="invoice", main_context=ctx)
79
+ assert [row["session_id"] for row in rows] == ["sid-a"]
80
+
81
+
61
82
  def test_record_run_never_raises_on_bad_path(tmp_path):
62
83
  # An unopenable db path must be swallowed (FR-009).
63
84
  runs_db.record_run("skill", "cron", "sid", "succeeded",
@@ -272,6 +293,7 @@ def test_migration_adds_run_detail_columns_without_data_loss(tmp_path):
272
293
  assert rows[0]["message"] is None # pre-feature row reads as NULL
273
294
  assert rows[0]["user_message"] is None
274
295
  assert rows[0]["response"] is None
296
+ assert rows[0]["ended_at"] == rows[0]["started_at"]
275
297
 
276
298
  # New writes carry both details; the upgraded DB round-trips them.
277
299
  runs_db.record_run("new", "cron", "sid-new", "succeeded", message="m",