codee-agent 0.1.0__py3-none-any.whl

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 (69) hide show
  1. codee/.gitignore +2 -0
  2. codee/__init__.py +0 -0
  3. codee/admin.py +1672 -0
  4. codee/admin_api.py +61 -0
  5. codee/admin_cli.py +86 -0
  6. codee/admin_service.py +1005 -0
  7. codee/executor.py +381 -0
  8. codee/init_cli.py +82 -0
  9. codee/lib/__init__.py +0 -0
  10. codee/lib/cron_describe.py +33 -0
  11. codee/lib/runs_db.py +195 -0
  12. codee/lib/test_runs_db.py +199 -0
  13. codee/lib/test_trigger_cron_skills.py +485 -0
  14. codee/lib/test_trigger_issue_skills.py +93 -0
  15. codee/lib/trigger_aws_sqs_skills.py +224 -0
  16. codee/lib/trigger_cron_skills.py +364 -0
  17. codee/lib/trigger_email_skills.py +225 -0
  18. codee/lib/trigger_issue_skills.py +107 -0
  19. codee/mail_server.py +45 -0
  20. codee/start_cli.py +98 -0
  21. codee/templates/AGENTS.md +42 -0
  22. codee/templates/CLAUDE.md +1 -0
  23. codee/templates/skills/aws-sqs-alarm-response/SKILL.md +23 -0
  24. codee/templates/skills/cron-research-5xx-errors/SKILL.md +17 -0
  25. codee/templates/skills/story-code-reviewer/SKILL.md +29 -0
  26. codee/templates/skills/story-developer/SKILL.md +26 -0
  27. codee/templates/skills/story-planner/SKILL.md +35 -0
  28. codee/templates/skills/story-planner/assets/readme-template.md +43 -0
  29. codee/templates/skills/story-qa/SKILL.md +28 -0
  30. codee/templates/skills/task-developer/SKILL.md +25 -0
  31. codee/templates/skills/task-qa/SKILL.md +26 -0
  32. codee/test_admin_api.py +64 -0
  33. codee/test_admin_cli.py +63 -0
  34. codee/test_admin_service.py +897 -0
  35. codee/test_executor.py +190 -0
  36. codee/test_init_cli.py +131 -0
  37. codee/test_memory_index.py +31 -0
  38. codee/test_start_cli.py +164 -0
  39. codee/workflow_graph.py +83 -0
  40. codee_admin/__init__.py +1 -0
  41. codee_admin/codee_admin.py +4 -0
  42. codee_agent-0.1.0.dist-info/METADATA +66 -0
  43. codee_agent-0.1.0.dist-info/RECORD +69 -0
  44. codee_agent-0.1.0.dist-info/WHEEL +4 -0
  45. codee_agent-0.1.0.dist-info/entry_points.txt +6 -0
  46. codee_agent-0.1.0.dist-info/licenses/LICENSE +21 -0
  47. codee_agent_abstract/__init__.py +0 -0
  48. codee_agent_abstract/provider.py +56 -0
  49. codee_agent_claude_code/__init__.py +0 -0
  50. codee_agent_claude_code/provider.py +90 -0
  51. codee_agent_github_copilot/__init__.py +0 -0
  52. codee_agent_github_copilot/provider.py +253 -0
  53. codee_agent_github_copilot/test.py +176 -0
  54. codee_database/__init__.py +0 -0
  55. codee_database/database.py +13 -0
  56. codee_database/oauth_tokens.py +148 -0
  57. codee_main_context/__init__.py +0 -0
  58. codee_main_context/context.py +127 -0
  59. codee_main_context/logging.py +111 -0
  60. codee_main_context/test_logging.py +90 -0
  61. codee_tasks_abstract/__init__.py +0 -0
  62. codee_tasks_abstract/provider.py +58 -0
  63. codee_tasks_azure_devops/__init__.py +0 -0
  64. codee_tasks_azure_devops/oauth.py +346 -0
  65. codee_tasks_azure_devops/provider.py +207 -0
  66. codee_tasks_azure_devops/test.py +462 -0
  67. codee_tasks_jira/__init__.py +0 -0
  68. codee_tasks_jira/provider.py +162 -0
  69. codee_tasks_jira/test.py +75 -0
@@ -0,0 +1,69 @@
1
+ codee/.gitignore,sha256=Fltsn5J90L9HwYTVg7Xlaoek3U0riZWmDTWmf0Ttfr8,15
2
+ codee/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
3
+ codee/admin.py,sha256=nL_zv7XuO1gSND25AAVrBMRfDd042LLiM5MXgGp2-CY,71801
4
+ codee/admin_api.py,sha256=q2S2H6neSXkcjrlhI3s190jJbOgenL0sqle5vnYmM5E,2503
5
+ codee/admin_cli.py,sha256=V57mVyxDPPCW3IP715Fnejfp_o7H8XXiADTKLTAh-fY,2461
6
+ codee/admin_service.py,sha256=hs8hNLBeNg5wjkIndTzYRnw2MEpKrBpHeUu-uK-_lFs,42135
7
+ codee/executor.py,sha256=t30M0svaRfCP3CI1ES0FNEafu0H-Q70UR-_9tCgw71Q,14938
8
+ codee/init_cli.py,sha256=0i_epmF5S2PeKNwHgRIvrjY-yn3V2sGiTsI3O5ha0tQ,2858
9
+ codee/lib/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
10
+ codee/lib/cron_describe.py,sha256=OTq58sKafyEz23WsZJdlxO0mOf944X4sXtNqY0pkNq4,960
11
+ codee/lib/runs_db.py,sha256=YBL30GBucK6e5zHEDQYLdqCnuq0IX4A4ZJDpNdd_WwE,8350
12
+ codee/lib/test_runs_db.py,sha256=ILZ_Dv3GW6HNNE7reKbZ8kW--nBbwfw2MpaPO_K_VU8,8225
13
+ codee/lib/test_trigger_cron_skills.py,sha256=hLnJHETzO9vHP_dUws9i_abAUNOxv4eqOadaAQefJqk,18482
14
+ codee/lib/test_trigger_issue_skills.py,sha256=1tHA-aP28HEQEKxsYKaKXn-c5dn_TJirg7_Y-wevIFI,3675
15
+ codee/lib/trigger_aws_sqs_skills.py,sha256=n3r1zEgOiywPEsWtDiLpGZOpsQM49mfQREh6wJ7Myyo,7178
16
+ codee/lib/trigger_cron_skills.py,sha256=GnkG4ED9doONDRGyjaMF9BgRO9dC2dhGRZ7IhsdZuRw,12722
17
+ codee/lib/trigger_email_skills.py,sha256=1-eX__ZMCTzljzcTN34RCvU8-0Q4H67CFEW5_HlD_vM,8095
18
+ codee/lib/trigger_issue_skills.py,sha256=MXQMR23L1HN-c4a9dFvqFbY9BtVeyFdVX8f9kzc8R-w,3630
19
+ codee/mail_server.py,sha256=vqlObtIo59zjsTknWTdFhXJMOOG5bBbc5JMm4TDX1IU,1691
20
+ codee/start_cli.py,sha256=-YHCM3VHb1leZhH9lYOiJnMshGOJOlq7sTxfOrtzTB0,2940
21
+ codee/templates/AGENTS.md,sha256=NXJIXxo7_91jO47IdXxM-w5uSDjhPWeJryMy7_R6c1I,1815
22
+ codee/templates/CLAUDE.md,sha256=kYsckFWTVe948z_fNWLysCHvi9_YpchBXl3s1Ek03lU,10
23
+ codee/templates/skills/aws-sqs-alarm-response/SKILL.md,sha256=HGs1vAqIa2hxU94SS8lTmxaUnId3Wf9usAnqawtY8yE,904
24
+ codee/templates/skills/cron-research-5xx-errors/SKILL.md,sha256=h131nDVkndvg9HUfWaUC1sTyynDp56IVLYH2yti41Ho,704
25
+ codee/templates/skills/story-code-reviewer/SKILL.md,sha256=t8iADhFqrd5TvsZ849n1mPNSQT4ujwwtP7zF7uE9-oU,1311
26
+ codee/templates/skills/story-developer/SKILL.md,sha256=pndrmcfMhM5ubtkW0lCxgoqMtYHvro8oBbFv0DlKtyk,1398
27
+ codee/templates/skills/story-planner/SKILL.md,sha256=IKHvGuy9EUplYgkPuZts4edjTcmWwk6SptVA8C-n_5g,1871
28
+ codee/templates/skills/story-planner/assets/readme-template.md,sha256=kIzyB8yk9zA5SWIhQb2XhuvXDBbG6vxmzOMQAgCkukw,793
29
+ codee/templates/skills/story-qa/SKILL.md,sha256=-W7g8g4mePkCV5-mqt42_qwo6RPWrD_huQVHpadbcWs,1614
30
+ codee/templates/skills/task-developer/SKILL.md,sha256=CKMLv-YDKmXhwZV8c6Ufjpy0QFJ0nEM4WI8yBdv7t4E,1488
31
+ codee/templates/skills/task-qa/SKILL.md,sha256=ihdRKhSoZfupVXrclZIhILaAVMmGRIRaOuSmYwgg9sE,1395
32
+ codee/test_admin_api.py,sha256=eT-ZCWw8giVU5V1xdqdctytVjuXV3wm3FKv4Nnp3gaU,2710
33
+ codee/test_admin_cli.py,sha256=5AWSdeSgsY-RuFmP-ol5NDASvlelVhJXPZiQatSALrk,2350
34
+ codee/test_admin_service.py,sha256=ESz89Tva0U9VzjUSrGZEowssLDSLFEL6QLqDf1Jwj-g,42706
35
+ codee/test_executor.py,sha256=K5p_KWd9JC0uE0UXSge--rmSh7UjMGYtzBRfoQl-Tz4,7312
36
+ codee/test_init_cli.py,sha256=1POSbxbmOFf1mRLBEMgXDrr7eWQ6k8S02_ahqLjQJm8,5606
37
+ codee/test_memory_index.py,sha256=8Q2wq2l-L53QBDqzAeGaTQAaK4aCI4zo7WZND7byUZw,841
38
+ codee/test_start_cli.py,sha256=04Bcr-WH2gAfptii-6NgjEs7xxEFDqoNB0CJP0U58DM,5942
39
+ codee/workflow_graph.py,sha256=_R8TvXLQr0rtrELcPoNMMYTe4Wr9CRB_hRDw8imIl_4,2539
40
+ codee_admin/__init__.py,sha256=kdPfIMlHX4kajFVw6XmC2jqsw1XKkkfEGWWC_3bdDZE,51
41
+ codee_admin/codee_admin.py,sha256=MVzRzfRsMYWkcaD3zQG8bDmvs5FTrUSFz1Ij-_wU8e4,93
42
+ codee_agent_abstract/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
43
+ codee_agent_abstract/provider.py,sha256=lUYAuLTasdIwWc3Hw43ivYNTncTVRBq0xjMBVL19X2Y,1949
44
+ codee_agent_claude_code/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
45
+ codee_agent_claude_code/provider.py,sha256=ktD2idOc5cMb5h4yE-TQEc80L1iA7_bg3KPEzwefreU,3380
46
+ codee_agent_github_copilot/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
47
+ codee_agent_github_copilot/provider.py,sha256=jJm0vOyj6VfoCUUqup0tsTtYHgUmov1Hef7Eh_H54qQ,9392
48
+ codee_agent_github_copilot/test.py,sha256=VXMSmx-wl-pD2hh3bS3SyTjjxiUpuaYTOBkVt9uXJ_Y,7085
49
+ codee_database/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
50
+ codee_database/database.py,sha256=qoIemJhiOfZEaoN3AspFaLmfAXZkCm8bgctuUs9MKwE,358
51
+ codee_database/oauth_tokens.py,sha256=x3UOhfcc3qr-qnedhI9eGyvkUx1MsagoIy70JH7wuWM,5971
52
+ codee_main_context/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
53
+ codee_main_context/context.py,sha256=a4vl_upntRenMByuv8nr7VfQX_zLpeDFtSn2OGyRzhE,4583
54
+ codee_main_context/logging.py,sha256=dZk3RnYrKh12TXpG_3G-4v1ky4KXLPA7oj-_hmjU190,3742
55
+ codee_main_context/test_logging.py,sha256=5-GVjjmUNH320lc7_6YZBwIPav4--PfnSY1_QU_4c4Q,3543
56
+ codee_tasks_abstract/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
57
+ codee_tasks_abstract/provider.py,sha256=Jpv0FilAumNWDQkilJbrU5C88ddcD0LakYSvCWdxwk8,1923
58
+ codee_tasks_azure_devops/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
59
+ codee_tasks_azure_devops/oauth.py,sha256=gUaaFid32UxFYR9mULg3tDI4Pq1ij4pauLmxbF-6itE,13302
60
+ codee_tasks_azure_devops/provider.py,sha256=m83Qx79KvL_m9AxAmDS1_hZIREpJLmsiXJrgvF5WvMs,8922
61
+ codee_tasks_azure_devops/test.py,sha256=zvYi2dfILEENB6nMNfG1kMiskOACDaCCHFlDLlVXjkk,20355
62
+ codee_tasks_jira/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
63
+ codee_tasks_jira/provider.py,sha256=enIlKVsv50KDvAK37JFtQHiqh1dButKe4a9j0S837AU,6155
64
+ codee_tasks_jira/test.py,sha256=S-k51HdQD4qnp6qeTi73QSLFzvObmYZLPEMTOCq5jpw,2425
65
+ codee_agent-0.1.0.dist-info/licenses/LICENSE,sha256=kXz80_Kc5IXrxIiweSCMx9Dv_AUeOcfwnQqCk2IDTmY,1075
66
+ codee_agent-0.1.0.dist-info/WHEEL,sha256=a0ta4TK7UKMB0EbDesmi2mn0Djkly7aj_7Tu2R7SseE,81
67
+ codee_agent-0.1.0.dist-info/entry_points.txt,sha256=hTLMHLPAf2JRk3wwy-t3R5oqM4AlwQCJy2H7-MjA1xQ,159
68
+ codee_agent-0.1.0.dist-info/METADATA,sha256=5sXrClMO-uf8culqG6266h_cFUBNTw6ckQLDJ-33_-k,2304
69
+ codee_agent-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: uv 0.11.33
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,6 @@
1
+ [console_scripts]
2
+ codee-admin = codee.admin_cli:main
3
+ codee-executor = codee.executor:main
4
+ codee-init = codee.init_cli:main
5
+ codee-start = codee.start_cli:main
6
+
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Codee contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
File without changes
@@ -0,0 +1,56 @@
1
+ from abc import ABC, abstractmethod
2
+ from dataclasses import dataclass
3
+ from pathlib import Path
4
+
5
+ from codee_main_context.context import Settings
6
+
7
+
8
+ @dataclass(frozen=True)
9
+ class AgentModel:
10
+ """One model a coding agent can run, as the admin UI shows and stores it.
11
+
12
+ ``id`` is what goes into the skill's ``model:`` frontmatter and onto the CLI;
13
+ ``name`` is the human-readable label ("Claude Opus 5" for ``claude-opus-5``).
14
+ """
15
+
16
+ id: str
17
+ name: str
18
+
19
+
20
+ class AbstractCodingAgent(ABC):
21
+ """Base class every coding agent (e.g. Claude Code) inherits from.
22
+
23
+ A coding agent is constructed from the app ``Settings`` and the working
24
+ directory it should run in, and knows how to run one prompt under a session
25
+ id the caller hands it. The executor never has to know which agent it's
26
+ driving or what configuration that agent needs.
27
+ """
28
+
29
+ def __init__(self, settings: Settings, cwd: Path):
30
+ self._cwd = cwd
31
+
32
+ @abstractmethod
33
+ def run(self, user_message: str, session_id: str, model: str = "") -> str:
34
+ """Run the agent with the message in ``session_id`` and return its text.
35
+
36
+ ``model`` is the skill's ``model:`` frontmatter, or empty for the agent's
37
+ default. Agents that read the frontmatter themselves may ignore it.
38
+
39
+ Must raise on any failure so callers can retry (SQS keeps the message,
40
+ cron doesn't mark the slot done, email keeps the ``.eml``).
41
+ """
42
+ ...
43
+
44
+ @classmethod
45
+ def list_models(cls) -> list[AgentModel]:
46
+ """Models this agent offers, best-effort, for the admin UI's picker.
47
+
48
+ Returns an empty list when the agent can't be asked. Callers must still
49
+ accept a model id typed by hand, since the list is a convenience rather
50
+ than an allowlist.
51
+ """
52
+ return []
53
+
54
+ def describe(self) -> str:
55
+ """Human-readable one-liner about this agent, for logs."""
56
+ return type(self).__name__
File without changes
@@ -0,0 +1,90 @@
1
+ import json
2
+ import subprocess
3
+ from pathlib import Path
4
+
5
+ from codee_agent_abstract.provider import AbstractCodingAgent, AgentModel
6
+ from codee_main_context.context import Settings
7
+ from codee_main_context.logging import get_logger
8
+
9
+
10
+ log = get_logger(__name__)
11
+
12
+ # The `claude` CLI has no command that enumerates its models, so this catalog is
13
+ # maintained by hand. It only feeds the admin UI's picker — the editor also takes
14
+ # a model id typed by hand, so a model missing here is still usable.
15
+ MODELS = [
16
+ AgentModel("claude-opus-5", "Claude Opus 5"),
17
+ AgentModel("claude-sonnet-5", "Claude Sonnet 5"),
18
+ AgentModel("claude-fable-5", "Claude Fable 5"),
19
+ AgentModel("claude-opus-4-8", "Claude Opus 4.8"),
20
+ AgentModel("claude-opus-4-7", "Claude Opus 4.7"),
21
+ AgentModel("claude-opus-4-6", "Claude Opus 4.6"),
22
+ AgentModel("claude-sonnet-4-6", "Claude Sonnet 4.6"),
23
+ AgentModel("claude-haiku-4-5", "Claude Haiku 4.5"),
24
+ AgentModel("opus", "Latest Opus (alias)"),
25
+ AgentModel("sonnet", "Latest Sonnet (alias)"),
26
+ AgentModel("haiku", "Latest Haiku (alias)"),
27
+ ]
28
+
29
+
30
+ class ClaudeCodeAgent(AbstractCodingAgent):
31
+ """Runs the ``claude`` CLI in a fresh session, under the id the caller supplies."""
32
+
33
+ MAX_BUDGET_USD = "20.00"
34
+ TIMEOUT_SECONDS = 7200 # 2 hours
35
+
36
+ def __init__(self, settings: Settings, cwd: Path):
37
+ super().__init__(settings, cwd)
38
+
39
+ @classmethod
40
+ def list_models(cls) -> list[AgentModel]:
41
+ return list(MODELS)
42
+
43
+ def run(self, user_message: str, session_id: str, model: str = "") -> str:
44
+ # `model` is deliberately not forwarded as --model: Claude Code reads the
45
+ # skill's `model:` frontmatter itself.
46
+ cmd = [
47
+ "claude",
48
+ "-p", user_message,
49
+ "--session-id", session_id,
50
+ "--max-budget-usd", self.MAX_BUDGET_USD,
51
+ "--output-format", "json",
52
+ "--permission-mode", "bypassPermissions",
53
+ ]
54
+
55
+ log.info("Running claude with message: %s", user_message)
56
+ log.debug("cwd=%s cmd=%s", self._cwd, " ".join(cmd))
57
+
58
+ try:
59
+ result = subprocess.run(
60
+ cmd,
61
+ capture_output=True,
62
+ text=True,
63
+ timeout=self.TIMEOUT_SECONDS,
64
+ cwd=self._cwd,
65
+ )
66
+ except subprocess.TimeoutExpired:
67
+ raise RuntimeError("Claude CLI timed out after 2 hours")
68
+
69
+ log.debug("claude exited %d (%d bytes stdout, %d bytes stderr)",
70
+ result.returncode, len(result.stdout), len(result.stderr))
71
+
72
+ # Raise on any non-success so callers retry. Over-limit exits non-zero;
73
+ # a completed-but-errored run sets is_error in the JSON.
74
+ if result.returncode != 0:
75
+ raise RuntimeError(
76
+ f"Claude CLI exited {result.returncode}: {result.stderr.strip()[:500]}"
77
+ )
78
+
79
+ try:
80
+ response = json.loads(result.stdout)
81
+ except json.JSONDecodeError:
82
+ return result.stdout
83
+ if isinstance(response, dict):
84
+ if response.get("is_error"):
85
+ raise RuntimeError(
86
+ f"Claude run errored ({response.get('subtype', 'unknown')}): "
87
+ f"{str(response.get('result', ''))[:500]}"
88
+ )
89
+ return response.get("result", result.stdout)
90
+ return result.stdout
File without changes
@@ -0,0 +1,253 @@
1
+ import json
2
+ import queue
3
+ import subprocess
4
+ import threading
5
+ import time
6
+ from pathlib import Path
7
+
8
+ from codee_agent_abstract.provider import AbstractCodingAgent, AgentModel
9
+ from codee_main_context.context import Settings
10
+ from codee_main_context.logging import get_logger
11
+
12
+
13
+ log = get_logger(__name__)
14
+
15
+ # Events the CLI streams that carry the pieces we care about. Everything else in
16
+ # the JSONL stream (tool calls, MCP chatter, usage checkpoints) is skipped.
17
+ ASSISTANT_MESSAGE = "assistant.message"
18
+ SESSION_ERROR = "session.error"
19
+ RESULT = "result"
20
+
21
+ # `copilot` has no "list models" command, but its Agent Client Protocol mode
22
+ # answers session/new with the account's live catalog — ids and display names
23
+ # both. That's the only way to ask the CLI what it can run.
24
+ ACP_TIMEOUT_SECONDS = 60
25
+ _INITIALIZE_ID = 1
26
+ _SESSION_NEW_ID = 2
27
+
28
+
29
+ class GitHubCopilotAgent(AbstractCodingAgent):
30
+ """Runs the ``copilot`` CLI in a fresh session, under the id the caller supplies."""
31
+
32
+ # Ceiling on what one run may spend. AI credits bill at $0.04 each, so this
33
+ # is the same $20 cap the Claude Code agent puts on a run. Minimum is 30.
34
+ MAX_AI_CREDITS = "500"
35
+ TIMEOUT_SECONDS = 7200 # 2 hours
36
+
37
+ def __init__(self, settings: Settings, cwd: Path):
38
+ super().__init__(settings, cwd)
39
+
40
+ @classmethod
41
+ def list_models(cls) -> list[AgentModel]:
42
+ try:
43
+ return _fetch_acp_models()
44
+ except Exception as exc:
45
+ # The picker falls back to free text, so a CLI that isn't installed
46
+ # or isn't logged in must not break the admin UI.
47
+ log.warning("Could not read the copilot model catalog: %s", exc)
48
+ return []
49
+
50
+ def run(self, user_message: str, session_id: str, model: str = "") -> str:
51
+ cmd = [
52
+ "copilot",
53
+ "-p", user_message,
54
+ "--session-id", session_id,
55
+ "--max-ai-credits", self.MAX_AI_CREDITS,
56
+ "--output-format", "json",
57
+ # Tools, paths and URLs: the equivalent of claude's bypassPermissions.
58
+ "--allow-all",
59
+ # Nothing is around to answer questions in a headless run.
60
+ "--no-ask-user",
61
+ "--no-color",
62
+ ]
63
+ # Copilot never sees the skill's frontmatter — the triggers hand it the
64
+ # body alone — so a skill's model only takes effect via this flag.
65
+ if model:
66
+ cmd += ["--model", model]
67
+
68
+ log.info("Running copilot with message: %s", user_message)
69
+ log.debug("cwd=%s cmd=%s", self._cwd, " ".join(cmd))
70
+
71
+ try:
72
+ result = subprocess.run(
73
+ cmd,
74
+ capture_output=True,
75
+ text=True,
76
+ timeout=self.TIMEOUT_SECONDS,
77
+ cwd=self._cwd,
78
+ )
79
+ except subprocess.TimeoutExpired:
80
+ raise RuntimeError("Copilot CLI timed out after 2 hours")
81
+
82
+ log.debug("copilot exited %d (%d bytes stdout, %d bytes stderr)",
83
+ result.returncode, len(result.stdout), len(result.stderr))
84
+
85
+ reply, outcome, errors = _parse_events(result.stdout)
86
+
87
+ # Raise on any non-success so callers retry. A run that fails before the
88
+ # session starts (bad model, no auth) exits non-zero with nothing on
89
+ # stdout; one that fails mid-run reports it in the trailing result event.
90
+ if result.returncode != 0:
91
+ raise RuntimeError(
92
+ f"Copilot CLI exited {result.returncode}: "
93
+ f"{_detail(errors, result.stderr)}"
94
+ )
95
+ if outcome is None:
96
+ # Not the JSONL we know how to read — hand back whatever it printed
97
+ # rather than failing a run that the CLI itself called successful.
98
+ log.warning("copilot produced no result event; returning raw output")
99
+ return result.stdout
100
+ if outcome.get("exitCode"):
101
+ raise RuntimeError(
102
+ f"Copilot run errored (exit code {outcome['exitCode']}): "
103
+ f"{_detail(errors, result.stderr)}"
104
+ )
105
+ if not reply:
106
+ raise RuntimeError(
107
+ f"Copilot run produced no response: {_detail(errors, result.stderr)}"
108
+ )
109
+ return reply
110
+
111
+
112
+ def _fetch_acp_models() -> list[AgentModel]:
113
+ """Ask ``copilot --acp`` for the account's model catalog.
114
+
115
+ Speaks just enough of the Agent Client Protocol to get a session: initialize,
116
+ then session/new, whose result carries ``models.availableModels``. The session
117
+ it opens is a throwaway that no prompt is ever sent to.
118
+ """
119
+ process = subprocess.Popen(
120
+ ["copilot", "--acp"],
121
+ stdin=subprocess.PIPE,
122
+ stdout=subprocess.PIPE,
123
+ stderr=subprocess.PIPE,
124
+ text=True,
125
+ bufsize=1,
126
+ cwd=Path.cwd(),
127
+ )
128
+ try:
129
+ lines: queue.Queue[str] = queue.Queue()
130
+ reader = threading.Thread(
131
+ target=_drain, args=(process.stdout, lines), daemon=True)
132
+ reader.start()
133
+
134
+ _send(process, _INITIALIZE_ID, "initialize", {
135
+ "protocolVersion": 1,
136
+ "clientCapabilities": {
137
+ "fs": {"readTextFile": False, "writeTextFile": False},
138
+ },
139
+ })
140
+ _send(process, _SESSION_NEW_ID, "session/new", {
141
+ "cwd": str(Path.cwd()),
142
+ "mcpServers": [],
143
+ })
144
+
145
+ result = _await_result(process, lines, _SESSION_NEW_ID)
146
+ finally:
147
+ process.kill()
148
+
149
+ available = (result.get("models") or {}).get("availableModels") or []
150
+ models = []
151
+ for entry in available:
152
+ if not isinstance(entry, dict):
153
+ continue
154
+ model_id = str(entry.get("modelId", "")).strip()
155
+ if not model_id:
156
+ continue
157
+ name = str(entry.get("name", "")).strip() or model_id
158
+ models.append(AgentModel(model_id, name))
159
+ log.debug("copilot reported %d model(s)", len(models))
160
+ return models
161
+
162
+
163
+ def _send(process: subprocess.Popen, request_id: int, method: str, params: dict) -> None:
164
+ request = {"jsonrpc": "2.0", "id": request_id,
165
+ "method": method, "params": params}
166
+ process.stdin.write(json.dumps(request) + "\n")
167
+ process.stdin.flush()
168
+
169
+
170
+ def _drain(stream, lines: "queue.Queue[str]") -> None:
171
+ """Pump the CLI's stdout into a queue so the read can be given a deadline."""
172
+ for line in stream:
173
+ lines.put(line)
174
+
175
+
176
+ def _await_result(
177
+ process: subprocess.Popen, lines: "queue.Queue[str]", request_id: int
178
+ ) -> dict:
179
+ """Read until the response to ``request_id`` arrives, or the deadline passes.
180
+
181
+ Everything else on the wire — the initialize reply, the session/update
182
+ notifications the CLI starts emitting straight away — is skipped.
183
+ """
184
+ deadline = time.monotonic() + ACP_TIMEOUT_SECONDS
185
+ while True:
186
+ if time.monotonic() >= deadline:
187
+ raise RuntimeError(
188
+ f"copilot --acp did not answer within {ACP_TIMEOUT_SECONDS}s")
189
+ try:
190
+ line = lines.get(timeout=0.5)
191
+ except queue.Empty:
192
+ # An immediate exit means the CLI is missing, unauthenticated, or
193
+ # too old for --acp; no point waiting out the whole deadline.
194
+ if process.poll() is not None:
195
+ raise RuntimeError(
196
+ f"copilot --acp exited {process.returncode}: "
197
+ f"{(process.stderr.read() or '').strip()[:300] or 'no output'}"
198
+ )
199
+ continue
200
+
201
+ try:
202
+ message = json.loads(line)
203
+ except json.JSONDecodeError:
204
+ continue
205
+ if not isinstance(message, dict) or message.get("id") != request_id:
206
+ continue
207
+ if "error" in message:
208
+ raise RuntimeError(f"copilot --acp errored: {message['error']}")
209
+ result = message.get("result")
210
+ return result if isinstance(result, dict) else {}
211
+
212
+
213
+ def _parse_events(stdout: str) -> tuple[str, dict | None, list[str]]:
214
+ """Pull the reply, the trailing result event and any errors out of the JSONL.
215
+
216
+ Returns ``("", None, [])`` for output that isn't the JSONL stream at all, so
217
+ the caller can tell "no result event" from "the run failed".
218
+ """
219
+ reply = ""
220
+ outcome: dict | None = None
221
+ errors: list[str] = []
222
+
223
+ for line in stdout.splitlines():
224
+ line = line.strip()
225
+ if not line:
226
+ continue
227
+ try:
228
+ event = json.loads(line)
229
+ except json.JSONDecodeError:
230
+ continue
231
+ if not isinstance(event, dict):
232
+ continue
233
+
234
+ kind = event.get("type")
235
+ if kind == ASSISTANT_MESSAGE:
236
+ # Keep the last message that actually said something: the ones in
237
+ # between carry only tool requests, and reasoning is a separate field.
238
+ content = (event.get("data") or {}).get("content") or ""
239
+ if content.strip():
240
+ reply = content.strip()
241
+ elif kind == SESSION_ERROR:
242
+ data = event.get("data") or {}
243
+ message = data.get("message") or data.get("errorType") or "unknown error"
244
+ errors.append(str(message))
245
+ elif kind == RESULT:
246
+ outcome = event
247
+
248
+ return reply, outcome, errors
249
+
250
+
251
+ def _detail(errors: list[str], stderr: str) -> str:
252
+ """Best available explanation of a failure, trimmed for the log line."""
253
+ return ("; ".join(errors) or stderr.strip() or "no error detail")[:500]