codee-agent 0.7.5__tar.gz → 0.8.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 (100) hide show
  1. {codee_agent-0.7.5 → codee_agent-0.8.1}/PKG-INFO +1 -1
  2. {codee_agent-0.7.5 → codee_agent-0.8.1}/pyproject.toml +1 -1
  3. {codee_agent-0.7.5 → codee_agent-0.8.1}/src/codee/admin.py +336 -31
  4. {codee_agent-0.7.5 → codee_agent-0.8.1}/src/codee/admin_service.py +29 -8
  5. {codee_agent-0.7.5 → codee_agent-0.8.1}/src/codee/executor.py +60 -10
  6. {codee_agent-0.7.5 → codee_agent-0.8.1}/src/codee/lib/runs_db.py +86 -4
  7. {codee_agent-0.7.5 → codee_agent-0.8.1}/src/codee/lib/test_runs_db.py +47 -0
  8. {codee_agent-0.7.5 → codee_agent-0.8.1}/src/codee/lib/test_trigger_cron_skills.py +86 -13
  9. {codee_agent-0.7.5 → codee_agent-0.8.1}/src/codee/lib/trigger_aws_sqs_skills.py +14 -2
  10. {codee_agent-0.7.5 → codee_agent-0.8.1}/src/codee/lib/trigger_cron_skills.py +15 -5
  11. {codee_agent-0.7.5 → codee_agent-0.8.1}/src/codee/lib/trigger_email_skills.py +15 -2
  12. {codee_agent-0.7.5 → codee_agent-0.8.1}/src/codee/lib/trigger_issue_skills.py +3 -0
  13. codee_agent-0.8.1/src/codee/test_admin.py +248 -0
  14. {codee_agent-0.7.5 → codee_agent-0.8.1}/src/codee/test_admin_service.py +70 -4
  15. {codee_agent-0.7.5 → codee_agent-0.8.1}/src/codee/test_executor.py +69 -1
  16. codee_agent-0.8.1/src/codee_agent_abstract/acp.py +201 -0
  17. {codee_agent-0.7.5 → codee_agent-0.8.1}/src/codee_agent_abstract/provider.py +14 -2
  18. codee_agent-0.8.1/src/codee_agent_abstract/test.py +173 -0
  19. {codee_agent-0.7.5 → codee_agent-0.8.1}/src/codee_agent_claude_code/provider.py +29 -15
  20. {codee_agent-0.7.5 → codee_agent-0.8.1}/src/codee_agent_claude_code/test.py +28 -2
  21. {codee_agent-0.7.5 → codee_agent-0.8.1}/src/codee_agent_codex/provider.py +32 -7
  22. {codee_agent-0.7.5 → codee_agent-0.8.1}/src/codee_agent_codex/test.py +47 -2
  23. {codee_agent-0.7.5 → codee_agent-0.8.1}/src/codee_agent_github_copilot/provider.py +9 -112
  24. {codee_agent-0.7.5 → codee_agent-0.8.1}/src/codee_agent_github_copilot/test.py +31 -53
  25. {codee_agent-0.7.5 → codee_agent-0.8.1}/src/codee_agent_opencode/provider.py +35 -4
  26. {codee_agent-0.7.5 → codee_agent-0.8.1}/src/codee_agent_opencode/test.py +44 -4
  27. codee_agent-0.7.5/src/codee/test_admin.py +0 -41
  28. {codee_agent-0.7.5 → codee_agent-0.8.1}/LICENSE +0 -0
  29. {codee_agent-0.7.5 → codee_agent-0.8.1}/README.md +0 -0
  30. {codee_agent-0.7.5 → codee_agent-0.8.1}/src/codee/.gitignore +0 -0
  31. {codee_agent-0.7.5 → codee_agent-0.8.1}/src/codee/__init__.py +0 -0
  32. {codee_agent-0.7.5 → codee_agent-0.8.1}/src/codee/admin_api.py +0 -0
  33. {codee_agent-0.7.5 → codee_agent-0.8.1}/src/codee/admin_cli.py +0 -0
  34. {codee_agent-0.7.5 → codee_agent-0.8.1}/src/codee/agent_cli.py +0 -0
  35. {codee_agent-0.7.5 → codee_agent-0.8.1}/src/codee/coding_agents.py +0 -0
  36. {codee_agent-0.7.5 → codee_agent-0.8.1}/src/codee/init_cli.py +0 -0
  37. {codee_agent-0.7.5 → codee_agent-0.8.1}/src/codee/lib/__init__.py +0 -0
  38. {codee_agent-0.7.5 → codee_agent-0.8.1}/src/codee/lib/claude_key_rotation.py +0 -0
  39. {codee_agent-0.7.5 → codee_agent-0.8.1}/src/codee/lib/cron_describe.py +0 -0
  40. {codee_agent-0.7.5 → codee_agent-0.8.1}/src/codee/lib/mcp_config.py +0 -0
  41. {codee_agent-0.7.5 → codee_agent-0.8.1}/src/codee/lib/runtime_control.py +0 -0
  42. {codee_agent-0.7.5 → codee_agent-0.8.1}/src/codee/lib/test_claude_key_rotation.py +0 -0
  43. {codee_agent-0.7.5 → codee_agent-0.8.1}/src/codee/lib/test_mcp_config.py +0 -0
  44. {codee_agent-0.7.5 → codee_agent-0.8.1}/src/codee/lib/test_trigger_issue_skills.py +0 -0
  45. {codee_agent-0.7.5 → codee_agent-0.8.1}/src/codee/mail_server.py +0 -0
  46. {codee_agent-0.7.5 → codee_agent-0.8.1}/src/codee/setup_wizard.py +0 -0
  47. {codee_agent-0.7.5 → codee_agent-0.8.1}/src/codee/start_cli.py +0 -0
  48. {codee_agent-0.7.5 → codee_agent-0.8.1}/src/codee/tasks_providers.py +0 -0
  49. {codee_agent-0.7.5 → codee_agent-0.8.1}/src/codee/templates/AGENTS.md +0 -0
  50. {codee_agent-0.7.5 → codee_agent-0.8.1}/src/codee/templates/CLAUDE.md +0 -0
  51. {codee_agent-0.7.5 → codee_agent-0.8.1}/src/codee/templates/skills/aws-sqs-alarm-response/SKILL.md +0 -0
  52. {codee_agent-0.7.5 → codee_agent-0.8.1}/src/codee/templates/skills/cron-research-5xx-errors/SKILL.md +0 -0
  53. {codee_agent-0.7.5 → codee_agent-0.8.1}/src/codee/templates/skills/story-code-reviewer/SKILL.md +0 -0
  54. {codee_agent-0.7.5 → codee_agent-0.8.1}/src/codee/templates/skills/story-developer/SKILL.md +0 -0
  55. {codee_agent-0.7.5 → codee_agent-0.8.1}/src/codee/templates/skills/story-planner/SKILL.md +0 -0
  56. {codee_agent-0.7.5 → codee_agent-0.8.1}/src/codee/templates/skills/story-planner/assets/readme-template.md +0 -0
  57. {codee_agent-0.7.5 → codee_agent-0.8.1}/src/codee/templates/skills/story-qa/SKILL.md +0 -0
  58. {codee_agent-0.7.5 → codee_agent-0.8.1}/src/codee/templates/skills/story-security-reviewer/SKILL.md +0 -0
  59. {codee_agent-0.7.5 → codee_agent-0.8.1}/src/codee/templates/skills/task-code-reviewer/SKILL.md +0 -0
  60. {codee_agent-0.7.5 → codee_agent-0.8.1}/src/codee/templates/skills/task-developer/SKILL.md +0 -0
  61. {codee_agent-0.7.5 → codee_agent-0.8.1}/src/codee/templates/skills/task-qa/SKILL.md +0 -0
  62. {codee_agent-0.7.5 → codee_agent-0.8.1}/src/codee/templates/skills/task-security-reviewer/SKILL.md +0 -0
  63. {codee_agent-0.7.5 → codee_agent-0.8.1}/src/codee/test_admin_api.py +0 -0
  64. {codee_agent-0.7.5 → codee_agent-0.8.1}/src/codee/test_admin_cli.py +0 -0
  65. {codee_agent-0.7.5 → codee_agent-0.8.1}/src/codee/test_agent_cli.py +0 -0
  66. {codee_agent-0.7.5 → codee_agent-0.8.1}/src/codee/test_coding_agents.py +0 -0
  67. {codee_agent-0.7.5 → codee_agent-0.8.1}/src/codee/test_init_cli.py +0 -0
  68. {codee_agent-0.7.5 → codee_agent-0.8.1}/src/codee/test_memory_index.py +0 -0
  69. {codee_agent-0.7.5 → codee_agent-0.8.1}/src/codee/test_setup_wizard.py +0 -0
  70. {codee_agent-0.7.5 → codee_agent-0.8.1}/src/codee/test_start_cli.py +0 -0
  71. {codee_agent-0.7.5 → codee_agent-0.8.1}/src/codee/workflow_graph.py +0 -0
  72. {codee_agent-0.7.5 → codee_agent-0.8.1}/src/codee_admin/__init__.py +0 -0
  73. {codee_agent-0.7.5 → codee_agent-0.8.1}/src/codee_admin/codee_admin.py +0 -0
  74. {codee_agent-0.7.5 → codee_agent-0.8.1}/src/codee_agent_abstract/__init__.py +0 -0
  75. {codee_agent-0.7.5 → codee_agent-0.8.1}/src/codee_agent_claude_code/__init__.py +0 -0
  76. {codee_agent-0.7.5 → codee_agent-0.8.1}/src/codee_agent_claude_code/account.py +0 -0
  77. {codee_agent-0.7.5 → codee_agent-0.8.1}/src/codee_agent_claude_code/credentials.py +0 -0
  78. {codee_agent-0.7.5 → codee_agent-0.8.1}/src/codee_agent_claude_code/oauth.py +0 -0
  79. {codee_agent-0.7.5 → codee_agent-0.8.1}/src/codee_agent_claude_code/usage.py +0 -0
  80. {codee_agent-0.7.5 → codee_agent-0.8.1}/src/codee_agent_codex/__init__.py +0 -0
  81. {codee_agent-0.7.5 → codee_agent-0.8.1}/src/codee_agent_github_copilot/__init__.py +0 -0
  82. {codee_agent-0.7.5 → codee_agent-0.8.1}/src/codee_agent_opencode/__init__.py +0 -0
  83. {codee_agent-0.7.5 → codee_agent-0.8.1}/src/codee_database/__init__.py +0 -0
  84. {codee_agent-0.7.5 → codee_agent-0.8.1}/src/codee_database/claude_code_accounts.py +0 -0
  85. {codee_agent-0.7.5 → codee_agent-0.8.1}/src/codee_database/database.py +0 -0
  86. {codee_agent-0.7.5 → codee_agent-0.8.1}/src/codee_database/oauth_tokens.py +0 -0
  87. {codee_agent-0.7.5 → codee_agent-0.8.1}/src/codee_main_context/__init__.py +0 -0
  88. {codee_agent-0.7.5 → codee_agent-0.8.1}/src/codee_main_context/context.py +0 -0
  89. {codee_agent-0.7.5 → codee_agent-0.8.1}/src/codee_main_context/logging.py +0 -0
  90. {codee_agent-0.7.5 → codee_agent-0.8.1}/src/codee_main_context/test_context.py +0 -0
  91. {codee_agent-0.7.5 → codee_agent-0.8.1}/src/codee_main_context/test_logging.py +0 -0
  92. {codee_agent-0.7.5 → codee_agent-0.8.1}/src/codee_tasks_abstract/__init__.py +0 -0
  93. {codee_agent-0.7.5 → codee_agent-0.8.1}/src/codee_tasks_abstract/provider.py +0 -0
  94. {codee_agent-0.7.5 → codee_agent-0.8.1}/src/codee_tasks_azure_devops/__init__.py +0 -0
  95. {codee_agent-0.7.5 → codee_agent-0.8.1}/src/codee_tasks_azure_devops/oauth.py +0 -0
  96. {codee_agent-0.7.5 → codee_agent-0.8.1}/src/codee_tasks_azure_devops/provider.py +0 -0
  97. {codee_agent-0.7.5 → codee_agent-0.8.1}/src/codee_tasks_azure_devops/test.py +0 -0
  98. {codee_agent-0.7.5 → codee_agent-0.8.1}/src/codee_tasks_jira/__init__.py +0 -0
  99. {codee_agent-0.7.5 → codee_agent-0.8.1}/src/codee_tasks_jira/provider.py +0 -0
  100. {codee_agent-0.7.5 → codee_agent-0.8.1}/src/codee_tasks_jira/test.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codee-agent
3
- Version: 0.7.5
3
+ Version: 0.8.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,codex,opencode,automation
6
6
  Author: Denis Kibalko
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "codee-agent"
3
- version = "0.7.5"
3
+ version = "0.8.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"
@@ -2,6 +2,7 @@
2
2
  from __future__ import annotations
3
3
 
4
4
  import asyncio
5
+ import re
5
6
  from typing import Any
6
7
 
7
8
  import reflex as rx
@@ -21,6 +22,8 @@ RUNS_PAGE_SIZE = 20
21
22
  # The agents a skill can be run by, as the editor's picker lists them. Built
22
23
  # once: which agents exist is decided by this build, not by the settings.
23
24
  DEFAULT_AGENT_OPTION = "Default agent"
25
+ # The effort picker's value for "no effort set": a select item can't be empty.
26
+ EFFORT_DEFAULT = "__default__"
24
27
  AGENT_NAMES = {agent["code"]: agent["name"] for agent in SERVICE.list_agents()}
25
28
  AGENT_CODES = {name: code for code, name in AGENT_NAMES.items()}
26
29
  AGENT_OPTIONS = [DEFAULT_AGENT_OPTION, *AGENT_NAMES.values()]
@@ -48,6 +51,15 @@ def _skill_summary(skill: dict[str, str]) -> SkillSummary:
48
51
  "agent": AGENT_NAMES.get(skill["agent"], DEFAULT_AGENT_OPTION)})
49
52
 
50
53
 
54
+ def _model_option(models: list["ModelOption"],
55
+ model_id: str) -> "ModelOption | None":
56
+ """The catalog entry for ``model_id``, or None when the catalog lacks it."""
57
+ for model in models:
58
+ if model.id == model_id:
59
+ return model
60
+ return None
61
+
62
+
51
63
  def _usage_row(account: Any) -> "ClaudeAccount":
52
64
  """One account's dashboard row, with its percentages rounded to whole numbers.
53
65
 
@@ -91,15 +103,23 @@ class SkillSummary(BaseModel):
91
103
  # none is run by the default one, which is worth saying on the card.
92
104
  agent: str = ""
93
105
  model: str = ""
106
+ effort: str = ""
94
107
  issue_status: str = ""
95
108
  issue_type: str = ""
96
109
 
97
110
 
98
111
  class ModelOption(BaseModel):
99
- """One entry in the skill editor's model picker: code plus friendly name."""
112
+ """One entry in the skill editor's model picker: code plus friendly name.
113
+
114
+ ``efforts`` are the reasoning effort levels the model takes, empty when it
115
+ has none, and ``default_effort`` the one it runs at when the skill sets
116
+ none, empty when the agent doesn't say.
117
+ """
100
118
 
101
119
  id: str
102
120
  name: str
121
+ efforts: list[str] = []
122
+ default_effort: str = ""
103
123
 
104
124
 
105
125
  class ConversationMessage(BaseModel):
@@ -154,6 +174,7 @@ class RepositorySummary(BaseModel):
154
174
 
155
175
 
156
176
  class ActiveJob(BaseModel):
177
+ id: int = 0
157
178
  message: str
158
179
  elapsed_label: str
159
180
  viewer_url: str
@@ -186,6 +207,7 @@ def _active_job(job: dict[str, Any]) -> ActiveJob:
186
207
  url = job.get("task_url") or ""
187
208
  linked = bool(url) and message.rstrip().endswith(key)
188
209
  return ActiveJob(
210
+ id=job["id"],
189
211
  message=message,
190
212
  prompt_prefix=message.rstrip()[:-len(key)] if linked else message,
191
213
  task_key=key if linked else "",
@@ -264,6 +286,48 @@ class WorkflowSection(BaseModel):
264
286
  warnings: list[str] = []
265
287
 
266
288
 
289
+ class RunTextPart(BaseModel):
290
+ text: str
291
+ matched: bool = False
292
+
293
+
294
+ def _highlight_parts(text: str, query: str) -> list[RunTextPart]:
295
+ """Split plain text into safely rendered, case-insensitive search matches."""
296
+ query = query.strip()
297
+ if not text or not query:
298
+ return []
299
+ parts = []
300
+ position = 0
301
+ for match in re.finditer(re.escape(query), text, re.IGNORECASE):
302
+ if match.start() > position:
303
+ parts.append(RunTextPart(text=text[position:match.start()]))
304
+ parts.append(RunTextPart(text=match.group(), matched=True))
305
+ position = match.end()
306
+ if not parts:
307
+ return []
308
+ if position < len(text):
309
+ parts.append(RunTextPart(text=text[position:]))
310
+ return parts
311
+
312
+
313
+ def _run_preview(message: str, user_message: str, response: str,
314
+ query: str) -> str:
315
+ """Show the first hit in the row, including hits buried in responses."""
316
+ query = query.strip()
317
+ if query:
318
+ for label, text in (("", message), ("Prompt: ", user_message),
319
+ ("LLM response: ", response)):
320
+ match = re.search(re.escape(query), text, re.IGNORECASE)
321
+ if match:
322
+ start = max(0, match.start() - 30)
323
+ end = max(start + 120 - len(label), match.end())
324
+ excerpt = text[start:end].replace("\n", " ")
325
+ return (label + ("..." if start else "") + excerpt
326
+ + ("..." if end < len(text) else ""))
327
+ first_line = message.splitlines()[0] if message else "No message"
328
+ return first_line[:120] + ("..." if len(first_line) > 120 else "")
329
+
330
+
267
331
  class RunRecord(BaseModel):
268
332
  skill_name: str
269
333
  trigger_type: str
@@ -280,6 +344,10 @@ class RunRecord(BaseModel):
280
344
  debug_logs: str
281
345
  preview: str
282
346
  viewer_url: str
347
+ preview_parts: list[RunTextPart] = []
348
+ message_parts: list[RunTextPart] = []
349
+ user_message_parts: list[RunTextPart] = []
350
+ response_parts: list[RunTextPart] = []
283
351
 
284
352
 
285
353
  class AdminState(rx.State):
@@ -291,6 +359,8 @@ class AdminState(rx.State):
291
359
  skill_name: str = ""
292
360
  skill_description: str = ""
293
361
  skill_model: str = ""
362
+ # The skill's `effort:`, empty for the model's own default.
363
+ skill_effort: str = ""
294
364
  # The agent code the skill declares, empty for the default agent.
295
365
  skill_agent: str = ""
296
366
  skill_type: str = "knowledge"
@@ -319,6 +389,11 @@ class AdminState(rx.State):
319
389
  adding_repository: bool = False
320
390
 
321
391
  active_jobs: list[ActiveJob] = []
392
+ # The session the kill confirmation is asking about. Held here rather than
393
+ # read off the row that opened it: the list redraws every second, and a
394
+ # row index can belong to another session by the time "Kill" is pressed.
395
+ kill_job_id: int = 0
396
+ kill_job_label: str = ""
322
397
  paused: bool = False
323
398
  total_runs: int = 0
324
399
  last_24h_runs: int = 0
@@ -480,6 +555,42 @@ class AdminState(rx.State):
480
555
  return model.name
481
556
  return self.skill_model
482
557
 
558
+ @rx.var
559
+ def skill_effort_options(self) -> list[str]:
560
+ """The effort levels the picked model takes, for the effort picker.
561
+
562
+ Empty — and the picker hidden — for a model with no effort control, and
563
+ for one the catalog doesn't know (the agent's default, a hand-typed id,
564
+ or a catalog that hasn't arrived), since nothing says what it takes. An
565
+ effort the skill already carries is always listed, so a value written by
566
+ hand stays visible and can be cleared rather than being dropped unseen.
567
+ """
568
+ model = _model_option(self.agent_models, self.skill_model)
569
+ options = list(model.efforts) if model else []
570
+ if self.skill_effort and self.skill_effort not in options:
571
+ options.append(self.skill_effort)
572
+ return options
573
+
574
+ @rx.var
575
+ def skill_effort_value(self) -> str:
576
+ """The effort picker's value; the select can't hold an empty one."""
577
+ return self.skill_effort or EFFORT_DEFAULT
578
+
579
+ @rx.var
580
+ def skill_effort_default_label(self) -> str:
581
+ """The "no effort set" choice, naming the model's default when known."""
582
+ model = _model_option(self.agent_models, self.skill_model)
583
+ if model and model.default_effort:
584
+ return f"Model default ({model.default_effort})"
585
+ return "Model default"
586
+
587
+ @rx.var
588
+ def skill_effort_unlisted(self) -> bool:
589
+ """Whether the skill's effort is one the picked model doesn't list."""
590
+ model = _model_option(self.agent_models, self.skill_model)
591
+ return bool(self.skill_effort) and (
592
+ model is None or self.skill_effort not in model.efforts)
593
+
483
594
  @rx.var
484
595
  def custom_model_query(self) -> str:
485
596
  """The search text when it names no known model, so it can be used as-is."""
@@ -547,6 +658,7 @@ class AdminState(rx.State):
547
658
  return None
548
659
  self.skill_agent = agent
549
660
  self.skill_model = ""
661
+ self.skill_effort = ""
550
662
  self.model_query = ""
551
663
  return AdminState.load_agent_models
552
664
 
@@ -554,9 +666,20 @@ class AdminState(rx.State):
554
666
  self.model_query = value
555
667
 
556
668
  def choose_model(self, model_id: str) -> None:
557
- """Pick a model from the list, or use whatever the user typed."""
669
+ """Pick a model from the list, or use whatever the user typed.
670
+
671
+ An effort the newly picked model is known not to take is cleared, since
672
+ it would only make that model's run fail. One the catalog can't vouch
673
+ either way for stays, and the picker keeps showing it.
674
+ """
558
675
  self.skill_model = model_id.strip()
559
676
  self.model_query = ""
677
+ model = _model_option(self.agent_models, self.skill_model)
678
+ if model is not None and self.skill_effort not in model.efforts:
679
+ self.skill_effort = ""
680
+
681
+ def set_skill_effort(self, value: str) -> None:
682
+ self.skill_effort = "" if value == EFFORT_DEFAULT else value
560
683
 
561
684
  @rx.event(background=True)
562
685
  async def load_agent_models(self) -> None:
@@ -633,6 +756,7 @@ class AdminState(rx.State):
633
756
  self.skill_name = skill["name"]
634
757
  self.skill_description = skill["description"]
635
758
  self.skill_model = skill["model"]
759
+ self.skill_effort = skill["effort"]
636
760
  self.skill_agent = skill["agent"]
637
761
  self.model_query = ""
638
762
  self.skill_type = skill["type"]
@@ -657,6 +781,7 @@ class AdminState(rx.State):
657
781
  "name": self.skill_name,
658
782
  "description": self.skill_description,
659
783
  "model": self.skill_model,
784
+ "effort": self.skill_effort,
660
785
  "agent": self.skill_agent,
661
786
  "type": self.skill_type,
662
787
  "cron": self.skill_cron,
@@ -793,6 +918,30 @@ class AdminState(rx.State):
793
918
  message += " Currently running agents will continue until completion."
794
919
  return rx.toast.info(message)
795
920
 
921
+ def confirm_kill_job(self, job_id: int, label: str) -> None:
922
+ self.kill_job_id = job_id
923
+ self.kill_job_label = label
924
+
925
+ def set_kill_dialog_open(self, is_open: bool) -> None:
926
+ if not is_open:
927
+ self.kill_job_id = 0
928
+ self.kill_job_label = ""
929
+
930
+ def kill_job(self) -> Any:
931
+ job_id, label = self.kill_job_id, self.kill_job_label
932
+ self.kill_job_id = 0
933
+ self.kill_job_label = ""
934
+ if not job_id:
935
+ return None
936
+ try:
937
+ killed = SERVICE.kill_job(job_id)
938
+ except Exception as error:
939
+ return rx.toast.error(f"Could not kill the session: {error}")
940
+ if not killed:
941
+ return rx.toast.info("That session had already finished.")
942
+ self._refresh_dashboard()
943
+ return rx.toast.success(f"Killed {label}")
944
+
796
945
  @rx.event(background=True)
797
946
  async def poll_dashboard(self) -> None:
798
947
  """Keep the dashboard live for as long as it is the page on screen.
@@ -865,7 +1014,10 @@ class AdminState(rx.State):
865
1014
  records = []
866
1015
  for run in rows[:limit]:
867
1016
  message = (run.get("message") or "").strip()
868
- preview = message.splitlines()[0] if message else "No message"
1017
+ user_message = (run.get("user_message") or message).strip()
1018
+ response = (run.get("response") or "").strip()
1019
+ preview = _run_preview(message, user_message,
1020
+ response, self.runs_query)
869
1021
  records.append(RunRecord(
870
1022
  skill_name=run["skill_name"],
871
1023
  trigger_type=run["trigger_type"],
@@ -877,12 +1029,17 @@ class AdminState(rx.State):
877
1029
  relative_age_label=run["relative_age_label"],
878
1030
  session_id=run.get("session_id") or "",
879
1031
  message=message,
880
- user_message=(run.get("user_message") or message).strip(),
881
- response=(run.get("response") or "").strip(),
1032
+ user_message=user_message,
1033
+ response=response,
882
1034
  debug_logs=run.get("debug_logs") or "",
883
- preview=preview[:120] + ("..." if len(preview) > 120 else ""),
1035
+ preview=preview,
884
1036
  viewer_url=(SERVICE.session_viewer.format(session_id=run["session_id"])
885
1037
  if SERVICE.session_viewer and run.get("session_id") else ""),
1038
+ preview_parts=_highlight_parts(preview, self.runs_query),
1039
+ message_parts=_highlight_parts(message, self.runs_query),
1040
+ user_message_parts=_highlight_parts(
1041
+ user_message, self.runs_query),
1042
+ response_parts=_highlight_parts(response, self.runs_query),
886
1043
  ))
887
1044
  return records
888
1045
 
@@ -1946,6 +2103,12 @@ def active_job_row(job: ActiveJob) -> rx.Component:
1946
2103
  aria_label="View session", color=ACCENT, display="flex",
1947
2104
  align_items="center"),
1948
2105
  ),
2106
+ rx.icon_button(
2107
+ rx.icon("square", size=14),
2108
+ size="1", variant="ghost", color_scheme="red",
2109
+ aria_label="Kill session", title="Kill session",
2110
+ on_click=AdminState.confirm_kill_job(job.id, job.message),
2111
+ ),
1949
2112
  gap="0.85rem",
1950
2113
  align="center",
1951
2114
  padding="0.85rem 1rem",
@@ -1957,6 +2120,32 @@ def active_job_row(job: ActiveJob) -> rx.Component:
1957
2120
  )
1958
2121
 
1959
2122
 
2123
+ def kill_job_dialog() -> rx.Component:
2124
+ """One confirmation for every row, bound to the session it was opened for."""
2125
+ return rx.alert_dialog.root(
2126
+ rx.alert_dialog.content(
2127
+ rx.alert_dialog.title("Kill session"),
2128
+ rx.alert_dialog.description(
2129
+ "This stops ", rx.text.strong(AdminState.kill_job_label),
2130
+ " right away. Whatever the agent was in the middle of is left "
2131
+ "as it is, and the run is logged as killed."),
2132
+ rx.text(
2133
+ "Cron, email and SQS runs are not retried. An issue that is "
2134
+ "still in its trigger status is picked up again on the next "
2135
+ "poll, so pause first if you don't want that.",
2136
+ color=MUTED, font_size="0.85rem", margin_top="0.75rem"),
2137
+ rx.hstack(
2138
+ rx.alert_dialog.cancel(rx.button("Cancel", variant="soft",
2139
+ color_scheme="gray")),
2140
+ rx.alert_dialog.action(rx.button("Kill session", color_scheme="red",
2141
+ on_click=AdminState.kill_job)),
2142
+ spacing="3", justify="end", margin_top="1.25rem", width="100%"),
2143
+ max_width="27rem"),
2144
+ open=AdminState.kill_job_id != 0,
2145
+ on_open_change=AdminState.set_kill_dialog_open,
2146
+ )
2147
+
2148
+
1960
2149
  def running_tile() -> rx.Component:
1961
2150
  """Stat tile that lights up while sessions are in flight."""
1962
2151
  running = AdminState.active_jobs.length()
@@ -1989,14 +2178,17 @@ def running_panel() -> rx.Component:
1989
2178
  animation="codee-breathe 2.4s ease-in-out infinite"),
1990
2179
  ),
1991
2180
  rx.spacer(),
1992
- rx.button(
1993
- rx.cond(AdminState.paused,
1994
- rx.icon("play", size=15),
1995
- rx.icon("pause", size=15)),
1996
- rx.cond(AdminState.paused, "Unpause", "Pause"),
1997
- variant="outline",
1998
- color_scheme=rx.cond(AdminState.paused, "green", "gray"),
1999
- on_click=AdminState.toggle_pause,
2181
+ rx.cond(
2182
+ is_running | ~AdminState.paused,
2183
+ rx.button(
2184
+ rx.cond(AdminState.paused,
2185
+ rx.icon("play", size=15),
2186
+ rx.icon("pause", size=15)),
2187
+ rx.cond(AdminState.paused, "Unpause", "Pause"),
2188
+ variant="outline",
2189
+ color_scheme=rx.cond(AdminState.paused, "green", "gray"),
2190
+ on_click=AdminState.toggle_pause,
2191
+ ),
2000
2192
  ),
2001
2193
  spacing="3",
2002
2194
  align="center",
@@ -2007,10 +2199,22 @@ def running_panel() -> rx.Component:
2007
2199
  is_running,
2008
2200
  rx.vstack(rx.foreach(AdminState.active_jobs, active_job_row),
2009
2201
  spacing="2", width="100%"),
2010
- rx.hstack(rx.icon("moon", size=16, color=SUBTLE_ICON),
2011
- rx.text("No sessions running right now.", color=MUTED),
2012
- spacing="2", align="center"),
2202
+ rx.cond(
2203
+ AdminState.paused,
2204
+ rx.hstack(
2205
+ rx.icon("pause", size=16, color=SUBTLE_ICON),
2206
+ rx.text("All work is on pause right now.", color=MUTED),
2207
+ rx.button(rx.icon("play", size=15), "Unpause",
2208
+ variant="outline", color_scheme="green",
2209
+ on_click=AdminState.toggle_pause),
2210
+ spacing="3", align="center", flex_wrap="wrap",
2211
+ ),
2212
+ rx.hstack(rx.icon("moon", size=16, color=SUBTLE_ICON),
2213
+ rx.text("No sessions running right now.", color=MUTED),
2214
+ spacing="2", align="center"),
2215
+ ),
2013
2216
  ),
2217
+ kill_job_dialog(),
2014
2218
  padding="1.25rem",
2015
2219
  background=SURFACE,
2016
2220
  border=rx.cond(is_running, f"1px solid {ACCENT}", BORDER),
@@ -2168,6 +2372,9 @@ def skill_card(skill: SkillSummary) -> rx.Component:
2168
2372
  rx.cond(skill.model != "",
2169
2373
  rx.code(skill.model, font_size="0.72rem",
2170
2374
  color_scheme="gray")),
2375
+ rx.cond(skill.effort != "",
2376
+ rx.code(skill.effort, font_size="0.72rem",
2377
+ color_scheme="gray")),
2171
2378
  spacing="2", align="center", width="100%",
2172
2379
  class_name="skill-card-meta"),
2173
2380
  rx.cond(
@@ -2350,6 +2557,40 @@ def model_picker() -> rx.Component:
2350
2557
  color=MUTED, font_size="0.82rem"))
2351
2558
 
2352
2559
 
2560
+ def effort_picker() -> rx.Component:
2561
+ """Reasoning effort for the picked model, shown only when it takes one."""
2562
+ return rx.cond(
2563
+ AdminState.skill_effort_options.length() > 0,
2564
+ rx.box(field(
2565
+ "Reasoning effort",
2566
+ rx.select.root(
2567
+ rx.select.trigger(width="100%"),
2568
+ rx.select.content(
2569
+ rx.select.item(AdminState.skill_effort_default_label,
2570
+ value=EFFORT_DEFAULT),
2571
+ rx.foreach(AdminState.skill_effort_options,
2572
+ lambda effort: rx.select.item(effort,
2573
+ value=effort))),
2574
+ value=AdminState.skill_effort_value,
2575
+ on_change=AdminState.set_skill_effort),
2576
+ rx.text(
2577
+ rx.cond(
2578
+ AdminState.skill_effort == "",
2579
+ "Runs at the model's own effort.",
2580
+ rx.cond(
2581
+ AdminState.skill_effort_unlisted,
2582
+ rx.fragment(
2583
+ rx.code(AdminState.skill_effort),
2584
+ " isn't listed for this model, so the agent may "
2585
+ "reject it."),
2586
+ rx.fragment("Saved as ",
2587
+ rx.code("effort: ",
2588
+ AdminState.skill_effort),
2589
+ " in the skill frontmatter."))),
2590
+ color=MUTED, font_size="0.82rem")),
2591
+ flex="1", min_width="0"))
2592
+
2593
+
2353
2594
  def delete_skill_dialog() -> rx.Component:
2354
2595
  return rx.alert_dialog.root(
2355
2596
  rx.alert_dialog.trigger(
@@ -2389,7 +2630,13 @@ def skill_editor() -> rx.Component:
2389
2630
  field("Description", rx.text_area(value=AdminState.skill_description,
2390
2631
  on_change=AdminState.set_skill_description,
2391
2632
  width="100%", min_height="5rem")),
2392
- rx.grid(agent_picker(), model_picker(),
2633
+ rx.grid(agent_picker(),
2634
+ # Effort shares the model's column; the model takes all of it
2635
+ # when the picked model has no effort levels.
2636
+ rx.flex(rx.box(model_picker(), flex="1", min_width="0"),
2637
+ effort_picker(),
2638
+ direction=rx.breakpoints(initial="column", sm="row"),
2639
+ gap="1rem", align="start", width="100%"),
2393
2640
  columns=rx.breakpoints(initial="1", md="2"), gap="1rem",
2394
2641
  align="start", width="100%"),
2395
2642
  rx.cond(AdminState.skill_type == "cron trigger",
@@ -2446,11 +2693,21 @@ def skills_page() -> rx.Component:
2446
2693
  rx.button(rx.icon("plus", size=16), "Create",
2447
2694
  on_click=AdminState.create_skill),
2448
2695
  gap="0.75rem", width="100%"),
2449
- rx.grid(rx.input(placeholder="Search skills", value=AdminState.skill_query,
2450
- on_change=AdminState.set_skill_query, width="100%"),
2451
- rx.select(["All", *SKILL_TYPES], value=AdminState.skill_filter,
2452
- on_change=AdminState.set_skill_filter, width="100%"),
2453
- columns=rx.breakpoints(initial="1", md="3fr 1fr"), gap="0.75rem", width="100%"),
2696
+ rx.grid(rx.box(
2697
+ rx.input(placeholder="Search skills", value=AdminState.skill_query,
2698
+ on_change=AdminState.set_skill_query,
2699
+ padding_right="2.5rem", width="100%"),
2700
+ rx.cond(AdminState.skill_query != "",
2701
+ rx.icon_button(rx.icon("x", size=16), variant="ghost", size="1",
2702
+ aria_label="Clear skills search",
2703
+ on_click=AdminState.set_skill_query(
2704
+ ""),
2705
+ position="absolute", right="0.5rem", top="50%",
2706
+ transform="translateY(-50%)")),
2707
+ position="relative", width="100%"),
2708
+ rx.select(["All", *SKILL_TYPES], value=AdminState.skill_filter,
2709
+ on_change=AdminState.set_skill_filter, width="100%"),
2710
+ columns=rx.breakpoints(initial="1", md="3fr 1fr"), gap="0.75rem", width="100%"),
2454
2711
  rx.cond(AdminState.agents_card_visible | (AdminState.filtered_skills.length() > 0),
2455
2712
  rx.grid(rx.cond(AdminState.agents_card_visible, agents_card()),
2456
2713
  rx.foreach(AdminState.filtered_skills, skill_card),
@@ -2587,11 +2844,43 @@ def repositories_page() -> rx.Component:
2587
2844
  spacing="5", align="start", width="100%"))
2588
2845
 
2589
2846
 
2847
+ def highlighted_run_text(text: rx.Var, parts: rx.Var, **props: Any) -> rx.Component:
2848
+ """Render match segments as text nodes, never as injected HTML."""
2849
+ return rx.cond(
2850
+ parts.length() > 0,
2851
+ rx.text(rx.foreach(parts, lambda part: rx.cond(
2852
+ part.matched,
2853
+ rx.el.mark(part.text, background=rx.color_mode_cond("#ffe082", "#705419"),
2854
+ color=rx.color_mode_cond("#593d00", "#fff4b8"),
2855
+ border_radius="2px"),
2856
+ rx.el.span(part.text))), **props),
2857
+ rx.text(text, **props),
2858
+ )
2859
+
2860
+
2861
+ def run_markdown(text: rx.Var, parts: rx.Var) -> rx.Component:
2862
+ """A prompt or response rendered as markdown, or as highlighted text while searching.
2863
+
2864
+ Prompts carry issue and email bodies, so raw HTML stays off: a tag in them
2865
+ shows as text instead of reaching the page. Math is off too, so dollar
2866
+ amounts read as written. A search hit falls back to plain text, because
2867
+ the marks can only be drawn over text the page has not parsed.
2868
+ """
2869
+ return rx.cond(
2870
+ parts.length() > 0,
2871
+ highlighted_run_text(text, parts, white_space="pre-wrap"),
2872
+ rx.box(rx.markdown(text, use_raw=False, use_math=False, use_katex=False),
2873
+ width="100%", overflow_x="auto"),
2874
+ )
2875
+
2876
+
2590
2877
  def run_row(run: RunRecord) -> rx.Component:
2591
2878
  return rx.box(
2592
2879
  rx.flex(
2593
2880
  rx.vstack(rx.hstack(rx.text(run.skill_name, font_weight="600"),
2594
- rx.badge(run.status, color_scheme=rx.cond(run.status == "succeeded", "green", "red"))),
2881
+ rx.badge(run.status, color_scheme=rx.cond(
2882
+ run.status == "succeeded", "green",
2883
+ rx.cond(run.status == "killed", "gray", "red")))),
2595
2884
  rx.hstack(
2596
2885
  rx.text(local_datetime(run.ended_at)),
2597
2886
  rx.text("(", run.relative_age_label, ")"),
@@ -2604,7 +2893,8 @@ def run_row(run: RunRecord) -> rx.Component:
2604
2893
  rx.text("Thread ID: ", run.session_id, color=MUTED,
2605
2894
  font_size="0.8rem",
2606
2895
  font_family="IBM Plex Mono, monospace"),
2607
- rx.text(run.preview, color=MUTED),
2896
+ highlighted_run_text(
2897
+ run.preview, run.preview_parts, color=MUTED),
2608
2898
  rx.cond(run.error != "", rx.text(
2609
2899
  run.error, color="#b42318", font_size="0.85rem")),
2610
2900
  spacing="2", align="start", flex="1"),
@@ -2615,11 +2905,16 @@ def run_row(run: RunRecord) -> rx.Component:
2615
2905
  rx.cond((run.user_message != "") | (run.response != "") | (run.debug_logs != ""), rx.accordion.root(rx.accordion.item(
2616
2906
  header="Run info", content=rx.vstack(
2617
2907
  rx.text("User message", font_weight="600"),
2618
- rx.text(run.user_message, white_space="pre-wrap"),
2908
+ run_markdown(run.user_message, run.user_message_parts),
2909
+ rx.cond((run.message != "") & (run.message != run.user_message),
2910
+ rx.fragment(
2911
+ rx.text("Original message", font_weight="600",
2912
+ margin_top="0.75rem"),
2913
+ run_markdown(run.message, run.message_parts))),
2619
2914
  rx.cond(run.response != "", rx.fragment(
2620
2915
  rx.text("LLM response", font_weight="600",
2621
2916
  margin_top="0.75rem"),
2622
- rx.text(run.response, white_space="pre-wrap"))),
2917
+ run_markdown(run.response, run.response_parts))),
2623
2918
  rx.cond(run.debug_logs != "", rx.fragment(
2624
2919
  rx.text("Debug logs", font_weight="600",
2625
2920
  margin_top="0.75rem"),
@@ -2642,10 +2937,20 @@ def runs_page() -> rx.Component:
2642
2937
  on_click=AdminState.load_more_runs)),
2643
2938
  spacing="3", width="100%")
2644
2939
  return shell(rx.vstack(page_header("Runs", "Recent trigger executions and outcomes."),
2645
- rx.input(placeholder="Search prompts and LLM responses",
2646
- type="search", value=AdminState.runs_query,
2647
- on_change=AdminState.search_runs,
2648
- debounce_timeout=300, width="100%"),
2940
+ rx.box(
2941
+ rx.input(placeholder="Search prompts and LLM responses",
2942
+ value=AdminState.runs_query,
2943
+ on_change=AdminState.search_runs,
2944
+ debounce_timeout=300, padding_right="2.5rem",
2945
+ width="100%"),
2946
+ rx.cond(AdminState.runs_query != "",
2947
+ rx.icon_button(rx.icon("x", size=16), variant="ghost",
2948
+ size="1", aria_label="Clear runs search",
2949
+ on_click=AdminState.search_runs(
2950
+ ""),
2951
+ position="absolute", right="0.5rem",
2952
+ top="50%", transform="translateY(-50%)")),
2953
+ position="relative", width="100%"),
2649
2954
  rx.cond(AdminState.runs.length() > 0, listing,
2650
2955
  rx.cond(AdminState.runs_query != "",
2651
2956
  empty_state(