codee-agent 0.7.4__tar.gz → 0.8.0__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 (99) hide show
  1. {codee_agent-0.7.4 → codee_agent-0.8.0}/PKG-INFO +1 -1
  2. {codee_agent-0.7.4 → codee_agent-0.8.0}/pyproject.toml +1 -1
  3. {codee_agent-0.7.4 → codee_agent-0.8.0}/src/codee/admin.py +335 -32
  4. {codee_agent-0.7.4 → codee_agent-0.8.0}/src/codee/admin_service.py +29 -8
  5. {codee_agent-0.7.4 → codee_agent-0.8.0}/src/codee/executor.py +60 -10
  6. {codee_agent-0.7.4 → codee_agent-0.8.0}/src/codee/lib/runs_db.py +86 -4
  7. {codee_agent-0.7.4 → codee_agent-0.8.0}/src/codee/lib/test_runs_db.py +47 -0
  8. {codee_agent-0.7.4 → codee_agent-0.8.0}/src/codee/lib/test_trigger_cron_skills.py +86 -13
  9. {codee_agent-0.7.4 → codee_agent-0.8.0}/src/codee/lib/trigger_aws_sqs_skills.py +14 -2
  10. {codee_agent-0.7.4 → codee_agent-0.8.0}/src/codee/lib/trigger_cron_skills.py +15 -5
  11. {codee_agent-0.7.4 → codee_agent-0.8.0}/src/codee/lib/trigger_email_skills.py +15 -2
  12. {codee_agent-0.7.4 → codee_agent-0.8.0}/src/codee/lib/trigger_issue_skills.py +3 -0
  13. codee_agent-0.8.0/src/codee/test_admin.py +248 -0
  14. {codee_agent-0.7.4 → codee_agent-0.8.0}/src/codee/test_admin_service.py +70 -4
  15. {codee_agent-0.7.4 → codee_agent-0.8.0}/src/codee/test_executor.py +69 -1
  16. codee_agent-0.8.0/src/codee_agent_abstract/acp.py +201 -0
  17. {codee_agent-0.7.4 → codee_agent-0.8.0}/src/codee_agent_abstract/provider.py +14 -2
  18. codee_agent-0.8.0/src/codee_agent_abstract/test.py +173 -0
  19. {codee_agent-0.7.4 → codee_agent-0.8.0}/src/codee_agent_claude_code/provider.py +29 -15
  20. {codee_agent-0.7.4 → codee_agent-0.8.0}/src/codee_agent_claude_code/test.py +28 -2
  21. {codee_agent-0.7.4 → codee_agent-0.8.0}/src/codee_agent_codex/provider.py +32 -7
  22. {codee_agent-0.7.4 → codee_agent-0.8.0}/src/codee_agent_codex/test.py +47 -2
  23. {codee_agent-0.7.4 → codee_agent-0.8.0}/src/codee_agent_github_copilot/provider.py +9 -112
  24. {codee_agent-0.7.4 → codee_agent-0.8.0}/src/codee_agent_github_copilot/test.py +31 -53
  25. {codee_agent-0.7.4 → codee_agent-0.8.0}/src/codee_agent_opencode/provider.py +35 -4
  26. {codee_agent-0.7.4 → codee_agent-0.8.0}/src/codee_agent_opencode/test.py +44 -4
  27. {codee_agent-0.7.4 → codee_agent-0.8.0}/LICENSE +0 -0
  28. {codee_agent-0.7.4 → codee_agent-0.8.0}/README.md +0 -0
  29. {codee_agent-0.7.4 → codee_agent-0.8.0}/src/codee/.gitignore +0 -0
  30. {codee_agent-0.7.4 → codee_agent-0.8.0}/src/codee/__init__.py +0 -0
  31. {codee_agent-0.7.4 → codee_agent-0.8.0}/src/codee/admin_api.py +0 -0
  32. {codee_agent-0.7.4 → codee_agent-0.8.0}/src/codee/admin_cli.py +0 -0
  33. {codee_agent-0.7.4 → codee_agent-0.8.0}/src/codee/agent_cli.py +0 -0
  34. {codee_agent-0.7.4 → codee_agent-0.8.0}/src/codee/coding_agents.py +0 -0
  35. {codee_agent-0.7.4 → codee_agent-0.8.0}/src/codee/init_cli.py +0 -0
  36. {codee_agent-0.7.4 → codee_agent-0.8.0}/src/codee/lib/__init__.py +0 -0
  37. {codee_agent-0.7.4 → codee_agent-0.8.0}/src/codee/lib/claude_key_rotation.py +0 -0
  38. {codee_agent-0.7.4 → codee_agent-0.8.0}/src/codee/lib/cron_describe.py +0 -0
  39. {codee_agent-0.7.4 → codee_agent-0.8.0}/src/codee/lib/mcp_config.py +0 -0
  40. {codee_agent-0.7.4 → codee_agent-0.8.0}/src/codee/lib/runtime_control.py +0 -0
  41. {codee_agent-0.7.4 → codee_agent-0.8.0}/src/codee/lib/test_claude_key_rotation.py +0 -0
  42. {codee_agent-0.7.4 → codee_agent-0.8.0}/src/codee/lib/test_mcp_config.py +0 -0
  43. {codee_agent-0.7.4 → codee_agent-0.8.0}/src/codee/lib/test_trigger_issue_skills.py +0 -0
  44. {codee_agent-0.7.4 → codee_agent-0.8.0}/src/codee/mail_server.py +0 -0
  45. {codee_agent-0.7.4 → codee_agent-0.8.0}/src/codee/setup_wizard.py +0 -0
  46. {codee_agent-0.7.4 → codee_agent-0.8.0}/src/codee/start_cli.py +0 -0
  47. {codee_agent-0.7.4 → codee_agent-0.8.0}/src/codee/tasks_providers.py +0 -0
  48. {codee_agent-0.7.4 → codee_agent-0.8.0}/src/codee/templates/AGENTS.md +0 -0
  49. {codee_agent-0.7.4 → codee_agent-0.8.0}/src/codee/templates/CLAUDE.md +0 -0
  50. {codee_agent-0.7.4 → codee_agent-0.8.0}/src/codee/templates/skills/aws-sqs-alarm-response/SKILL.md +0 -0
  51. {codee_agent-0.7.4 → codee_agent-0.8.0}/src/codee/templates/skills/cron-research-5xx-errors/SKILL.md +0 -0
  52. {codee_agent-0.7.4 → codee_agent-0.8.0}/src/codee/templates/skills/story-code-reviewer/SKILL.md +0 -0
  53. {codee_agent-0.7.4 → codee_agent-0.8.0}/src/codee/templates/skills/story-developer/SKILL.md +0 -0
  54. {codee_agent-0.7.4 → codee_agent-0.8.0}/src/codee/templates/skills/story-planner/SKILL.md +0 -0
  55. {codee_agent-0.7.4 → codee_agent-0.8.0}/src/codee/templates/skills/story-planner/assets/readme-template.md +0 -0
  56. {codee_agent-0.7.4 → codee_agent-0.8.0}/src/codee/templates/skills/story-qa/SKILL.md +0 -0
  57. {codee_agent-0.7.4 → codee_agent-0.8.0}/src/codee/templates/skills/story-security-reviewer/SKILL.md +0 -0
  58. {codee_agent-0.7.4 → codee_agent-0.8.0}/src/codee/templates/skills/task-code-reviewer/SKILL.md +0 -0
  59. {codee_agent-0.7.4 → codee_agent-0.8.0}/src/codee/templates/skills/task-developer/SKILL.md +0 -0
  60. {codee_agent-0.7.4 → codee_agent-0.8.0}/src/codee/templates/skills/task-qa/SKILL.md +0 -0
  61. {codee_agent-0.7.4 → codee_agent-0.8.0}/src/codee/templates/skills/task-security-reviewer/SKILL.md +0 -0
  62. {codee_agent-0.7.4 → codee_agent-0.8.0}/src/codee/test_admin_api.py +0 -0
  63. {codee_agent-0.7.4 → codee_agent-0.8.0}/src/codee/test_admin_cli.py +0 -0
  64. {codee_agent-0.7.4 → codee_agent-0.8.0}/src/codee/test_agent_cli.py +0 -0
  65. {codee_agent-0.7.4 → codee_agent-0.8.0}/src/codee/test_coding_agents.py +0 -0
  66. {codee_agent-0.7.4 → codee_agent-0.8.0}/src/codee/test_init_cli.py +0 -0
  67. {codee_agent-0.7.4 → codee_agent-0.8.0}/src/codee/test_memory_index.py +0 -0
  68. {codee_agent-0.7.4 → codee_agent-0.8.0}/src/codee/test_setup_wizard.py +0 -0
  69. {codee_agent-0.7.4 → codee_agent-0.8.0}/src/codee/test_start_cli.py +0 -0
  70. {codee_agent-0.7.4 → codee_agent-0.8.0}/src/codee/workflow_graph.py +0 -0
  71. {codee_agent-0.7.4 → codee_agent-0.8.0}/src/codee_admin/__init__.py +0 -0
  72. {codee_agent-0.7.4 → codee_agent-0.8.0}/src/codee_admin/codee_admin.py +0 -0
  73. {codee_agent-0.7.4 → codee_agent-0.8.0}/src/codee_agent_abstract/__init__.py +0 -0
  74. {codee_agent-0.7.4 → codee_agent-0.8.0}/src/codee_agent_claude_code/__init__.py +0 -0
  75. {codee_agent-0.7.4 → codee_agent-0.8.0}/src/codee_agent_claude_code/account.py +0 -0
  76. {codee_agent-0.7.4 → codee_agent-0.8.0}/src/codee_agent_claude_code/credentials.py +0 -0
  77. {codee_agent-0.7.4 → codee_agent-0.8.0}/src/codee_agent_claude_code/oauth.py +0 -0
  78. {codee_agent-0.7.4 → codee_agent-0.8.0}/src/codee_agent_claude_code/usage.py +0 -0
  79. {codee_agent-0.7.4 → codee_agent-0.8.0}/src/codee_agent_codex/__init__.py +0 -0
  80. {codee_agent-0.7.4 → codee_agent-0.8.0}/src/codee_agent_github_copilot/__init__.py +0 -0
  81. {codee_agent-0.7.4 → codee_agent-0.8.0}/src/codee_agent_opencode/__init__.py +0 -0
  82. {codee_agent-0.7.4 → codee_agent-0.8.0}/src/codee_database/__init__.py +0 -0
  83. {codee_agent-0.7.4 → codee_agent-0.8.0}/src/codee_database/claude_code_accounts.py +0 -0
  84. {codee_agent-0.7.4 → codee_agent-0.8.0}/src/codee_database/database.py +0 -0
  85. {codee_agent-0.7.4 → codee_agent-0.8.0}/src/codee_database/oauth_tokens.py +0 -0
  86. {codee_agent-0.7.4 → codee_agent-0.8.0}/src/codee_main_context/__init__.py +0 -0
  87. {codee_agent-0.7.4 → codee_agent-0.8.0}/src/codee_main_context/context.py +0 -0
  88. {codee_agent-0.7.4 → codee_agent-0.8.0}/src/codee_main_context/logging.py +0 -0
  89. {codee_agent-0.7.4 → codee_agent-0.8.0}/src/codee_main_context/test_context.py +0 -0
  90. {codee_agent-0.7.4 → codee_agent-0.8.0}/src/codee_main_context/test_logging.py +0 -0
  91. {codee_agent-0.7.4 → codee_agent-0.8.0}/src/codee_tasks_abstract/__init__.py +0 -0
  92. {codee_agent-0.7.4 → codee_agent-0.8.0}/src/codee_tasks_abstract/provider.py +0 -0
  93. {codee_agent-0.7.4 → codee_agent-0.8.0}/src/codee_tasks_azure_devops/__init__.py +0 -0
  94. {codee_agent-0.7.4 → codee_agent-0.8.0}/src/codee_tasks_azure_devops/oauth.py +0 -0
  95. {codee_agent-0.7.4 → codee_agent-0.8.0}/src/codee_tasks_azure_devops/provider.py +0 -0
  96. {codee_agent-0.7.4 → codee_agent-0.8.0}/src/codee_tasks_azure_devops/test.py +0 -0
  97. {codee_agent-0.7.4 → codee_agent-0.8.0}/src/codee_tasks_jira/__init__.py +0 -0
  98. {codee_agent-0.7.4 → codee_agent-0.8.0}/src/codee_tasks_jira/provider.py +0 -0
  99. {codee_agent-0.7.4 → codee_agent-0.8.0}/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.4
3
+ Version: 0.8.0
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.4"
3
+ version = "0.8.0"
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,
@@ -785,8 +910,37 @@ class AdminState(rx.State):
785
910
  self.last_24h_runs = dashboard["counts"]["last_24h"]
786
911
  self.hourly_runs = dashboard["hourly"]
787
912
 
788
- def toggle_pause(self) -> None:
913
+ def toggle_pause(self) -> Any:
789
914
  self.paused = SERVICE.set_paused(not self.paused)
915
+ if self.paused:
916
+ message = "New agents won't be spawned."
917
+ if self.active_jobs:
918
+ message += " Currently running agents will continue until completion."
919
+ return rx.toast.info(message)
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}")
790
944
 
791
945
  @rx.event(background=True)
792
946
  async def poll_dashboard(self) -> None:
@@ -860,7 +1014,10 @@ class AdminState(rx.State):
860
1014
  records = []
861
1015
  for run in rows[:limit]:
862
1016
  message = (run.get("message") or "").strip()
863
- 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)
864
1021
  records.append(RunRecord(
865
1022
  skill_name=run["skill_name"],
866
1023
  trigger_type=run["trigger_type"],
@@ -872,12 +1029,17 @@ class AdminState(rx.State):
872
1029
  relative_age_label=run["relative_age_label"],
873
1030
  session_id=run.get("session_id") or "",
874
1031
  message=message,
875
- user_message=(run.get("user_message") or message).strip(),
876
- response=(run.get("response") or "").strip(),
1032
+ user_message=user_message,
1033
+ response=response,
877
1034
  debug_logs=run.get("debug_logs") or "",
878
- preview=preview[:120] + ("..." if len(preview) > 120 else ""),
1035
+ preview=preview,
879
1036
  viewer_url=(SERVICE.session_viewer.format(session_id=run["session_id"])
880
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),
881
1043
  ))
882
1044
  return records
883
1045
 
@@ -1941,6 +2103,12 @@ def active_job_row(job: ActiveJob) -> rx.Component:
1941
2103
  aria_label="View session", color=ACCENT, display="flex",
1942
2104
  align_items="center"),
1943
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
+ ),
1944
2112
  gap="0.85rem",
1945
2113
  align="center",
1946
2114
  padding="0.85rem 1rem",
@@ -1952,6 +2120,32 @@ def active_job_row(job: ActiveJob) -> rx.Component:
1952
2120
  )
1953
2121
 
1954
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
+
1955
2149
  def running_tile() -> rx.Component:
1956
2150
  """Stat tile that lights up while sessions are in flight."""
1957
2151
  running = AdminState.active_jobs.length()
@@ -1984,14 +2178,17 @@ def running_panel() -> rx.Component:
1984
2178
  animation="codee-breathe 2.4s ease-in-out infinite"),
1985
2179
  ),
1986
2180
  rx.spacer(),
1987
- rx.button(
1988
- rx.cond(AdminState.paused,
1989
- rx.icon("play", size=15),
1990
- rx.icon("pause", size=15)),
1991
- rx.cond(AdminState.paused, "Unpause", "Pause"),
1992
- variant="outline",
1993
- color_scheme=rx.cond(AdminState.paused, "green", "gray"),
1994
- 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
+ ),
1995
2192
  ),
1996
2193
  spacing="3",
1997
2194
  align="center",
@@ -2002,10 +2199,22 @@ def running_panel() -> rx.Component:
2002
2199
  is_running,
2003
2200
  rx.vstack(rx.foreach(AdminState.active_jobs, active_job_row),
2004
2201
  spacing="2", width="100%"),
2005
- rx.hstack(rx.icon("moon", size=16, color=SUBTLE_ICON),
2006
- rx.text("No sessions running right now.", color=MUTED),
2007
- 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
+ ),
2008
2216
  ),
2217
+ kill_job_dialog(),
2009
2218
  padding="1.25rem",
2010
2219
  background=SURFACE,
2011
2220
  border=rx.cond(is_running, f"1px solid {ACCENT}", BORDER),
@@ -2163,6 +2372,9 @@ def skill_card(skill: SkillSummary) -> rx.Component:
2163
2372
  rx.cond(skill.model != "",
2164
2373
  rx.code(skill.model, font_size="0.72rem",
2165
2374
  color_scheme="gray")),
2375
+ rx.cond(skill.effort != "",
2376
+ rx.code(skill.effort, font_size="0.72rem",
2377
+ color_scheme="gray")),
2166
2378
  spacing="2", align="center", width="100%",
2167
2379
  class_name="skill-card-meta"),
2168
2380
  rx.cond(
@@ -2345,6 +2557,39 @@ def model_picker() -> rx.Component:
2345
2557
  color=MUTED, font_size="0.82rem"))
2346
2558
 
2347
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
+ 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
+
2592
+
2348
2593
  def delete_skill_dialog() -> rx.Component:
2349
2594
  return rx.alert_dialog.root(
2350
2595
  rx.alert_dialog.trigger(
@@ -2384,7 +2629,7 @@ def skill_editor() -> rx.Component:
2384
2629
  field("Description", rx.text_area(value=AdminState.skill_description,
2385
2630
  on_change=AdminState.set_skill_description,
2386
2631
  width="100%", min_height="5rem")),
2387
- rx.grid(agent_picker(), model_picker(),
2632
+ rx.grid(agent_picker(), model_picker(), effort_picker(),
2388
2633
  columns=rx.breakpoints(initial="1", md="2"), gap="1rem",
2389
2634
  align="start", width="100%"),
2390
2635
  rx.cond(AdminState.skill_type == "cron trigger",
@@ -2441,11 +2686,21 @@ def skills_page() -> rx.Component:
2441
2686
  rx.button(rx.icon("plus", size=16), "Create",
2442
2687
  on_click=AdminState.create_skill),
2443
2688
  gap="0.75rem", width="100%"),
2444
- rx.grid(rx.input(placeholder="Search skills", value=AdminState.skill_query,
2445
- on_change=AdminState.set_skill_query, width="100%"),
2446
- rx.select(["All", *SKILL_TYPES], value=AdminState.skill_filter,
2447
- on_change=AdminState.set_skill_filter, width="100%"),
2448
- columns=rx.breakpoints(initial="1", md="3fr 1fr"), gap="0.75rem", width="100%"),
2689
+ rx.grid(rx.box(
2690
+ rx.input(placeholder="Search skills", value=AdminState.skill_query,
2691
+ on_change=AdminState.set_skill_query,
2692
+ padding_right="2.5rem", width="100%"),
2693
+ rx.cond(AdminState.skill_query != "",
2694
+ rx.icon_button(rx.icon("x", size=16), variant="ghost", size="1",
2695
+ aria_label="Clear skills search",
2696
+ on_click=AdminState.set_skill_query(
2697
+ ""),
2698
+ position="absolute", right="0.5rem", top="50%",
2699
+ transform="translateY(-50%)")),
2700
+ position="relative", width="100%"),
2701
+ rx.select(["All", *SKILL_TYPES], value=AdminState.skill_filter,
2702
+ on_change=AdminState.set_skill_filter, width="100%"),
2703
+ columns=rx.breakpoints(initial="1", md="3fr 1fr"), gap="0.75rem", width="100%"),
2449
2704
  rx.cond(AdminState.agents_card_visible | (AdminState.filtered_skills.length() > 0),
2450
2705
  rx.grid(rx.cond(AdminState.agents_card_visible, agents_card()),
2451
2706
  rx.foreach(AdminState.filtered_skills, skill_card),
@@ -2582,11 +2837,43 @@ def repositories_page() -> rx.Component:
2582
2837
  spacing="5", align="start", width="100%"))
2583
2838
 
2584
2839
 
2840
+ def highlighted_run_text(text: rx.Var, parts: rx.Var, **props: Any) -> rx.Component:
2841
+ """Render match segments as text nodes, never as injected HTML."""
2842
+ return rx.cond(
2843
+ parts.length() > 0,
2844
+ rx.text(rx.foreach(parts, lambda part: rx.cond(
2845
+ part.matched,
2846
+ rx.el.mark(part.text, background=rx.color_mode_cond("#ffe082", "#705419"),
2847
+ color=rx.color_mode_cond("#593d00", "#fff4b8"),
2848
+ border_radius="2px"),
2849
+ rx.el.span(part.text))), **props),
2850
+ rx.text(text, **props),
2851
+ )
2852
+
2853
+
2854
+ def run_markdown(text: rx.Var, parts: rx.Var) -> rx.Component:
2855
+ """A prompt or response rendered as markdown, or as highlighted text while searching.
2856
+
2857
+ Prompts carry issue and email bodies, so raw HTML stays off: a tag in them
2858
+ shows as text instead of reaching the page. Math is off too, so dollar
2859
+ amounts read as written. A search hit falls back to plain text, because
2860
+ the marks can only be drawn over text the page has not parsed.
2861
+ """
2862
+ return rx.cond(
2863
+ parts.length() > 0,
2864
+ highlighted_run_text(text, parts, white_space="pre-wrap"),
2865
+ rx.box(rx.markdown(text, use_raw=False, use_math=False, use_katex=False),
2866
+ width="100%", overflow_x="auto"),
2867
+ )
2868
+
2869
+
2585
2870
  def run_row(run: RunRecord) -> rx.Component:
2586
2871
  return rx.box(
2587
2872
  rx.flex(
2588
2873
  rx.vstack(rx.hstack(rx.text(run.skill_name, font_weight="600"),
2589
- rx.badge(run.status, color_scheme=rx.cond(run.status == "succeeded", "green", "red"))),
2874
+ rx.badge(run.status, color_scheme=rx.cond(
2875
+ run.status == "succeeded", "green",
2876
+ rx.cond(run.status == "killed", "gray", "red")))),
2590
2877
  rx.hstack(
2591
2878
  rx.text(local_datetime(run.ended_at)),
2592
2879
  rx.text("(", run.relative_age_label, ")"),
@@ -2599,7 +2886,8 @@ def run_row(run: RunRecord) -> rx.Component:
2599
2886
  rx.text("Thread ID: ", run.session_id, color=MUTED,
2600
2887
  font_size="0.8rem",
2601
2888
  font_family="IBM Plex Mono, monospace"),
2602
- rx.text(run.preview, color=MUTED),
2889
+ highlighted_run_text(
2890
+ run.preview, run.preview_parts, color=MUTED),
2603
2891
  rx.cond(run.error != "", rx.text(
2604
2892
  run.error, color="#b42318", font_size="0.85rem")),
2605
2893
  spacing="2", align="start", flex="1"),
@@ -2610,11 +2898,16 @@ def run_row(run: RunRecord) -> rx.Component:
2610
2898
  rx.cond((run.user_message != "") | (run.response != "") | (run.debug_logs != ""), rx.accordion.root(rx.accordion.item(
2611
2899
  header="Run info", content=rx.vstack(
2612
2900
  rx.text("User message", font_weight="600"),
2613
- rx.text(run.user_message, white_space="pre-wrap"),
2901
+ run_markdown(run.user_message, run.user_message_parts),
2902
+ rx.cond((run.message != "") & (run.message != run.user_message),
2903
+ rx.fragment(
2904
+ rx.text("Original message", font_weight="600",
2905
+ margin_top="0.75rem"),
2906
+ run_markdown(run.message, run.message_parts))),
2614
2907
  rx.cond(run.response != "", rx.fragment(
2615
2908
  rx.text("LLM response", font_weight="600",
2616
2909
  margin_top="0.75rem"),
2617
- rx.text(run.response, white_space="pre-wrap"))),
2910
+ run_markdown(run.response, run.response_parts))),
2618
2911
  rx.cond(run.debug_logs != "", rx.fragment(
2619
2912
  rx.text("Debug logs", font_weight="600",
2620
2913
  margin_top="0.75rem"),
@@ -2637,10 +2930,20 @@ def runs_page() -> rx.Component:
2637
2930
  on_click=AdminState.load_more_runs)),
2638
2931
  spacing="3", width="100%")
2639
2932
  return shell(rx.vstack(page_header("Runs", "Recent trigger executions and outcomes."),
2640
- rx.input(placeholder="Search prompts and LLM responses",
2641
- type="search", value=AdminState.runs_query,
2642
- on_change=AdminState.search_runs,
2643
- debounce_timeout=300, width="100%"),
2933
+ rx.box(
2934
+ rx.input(placeholder="Search prompts and LLM responses",
2935
+ value=AdminState.runs_query,
2936
+ on_change=AdminState.search_runs,
2937
+ debounce_timeout=300, padding_right="2.5rem",
2938
+ width="100%"),
2939
+ rx.cond(AdminState.runs_query != "",
2940
+ rx.icon_button(rx.icon("x", size=16), variant="ghost",
2941
+ size="1", aria_label="Clear runs search",
2942
+ on_click=AdminState.search_runs(
2943
+ ""),
2944
+ position="absolute", right="0.5rem",
2945
+ top="50%", transform="translateY(-50%)")),
2946
+ position="relative", width="100%"),
2644
2947
  rx.cond(AdminState.runs.length() > 0, listing,
2645
2948
  rx.cond(AdminState.runs_query != "",
2646
2949
  empty_state(